haystack-algenta
Haystack tool integration for Algenta: create_algenta_tools, a
governed-execution-aware haystack.tools.toolset.Toolset wrapping your own self-hosted Algenta
Engine's MCP tool surface via Haystack's own real
MCPToolset (a separate PyPI package,
mcp-haystack -- see Why mcp-haystack, not just haystack-ai),
plus build_algenta_governance_hooks, an Agent after_tool hook that raises a typed
AlgentaToolDenied for execute_decision's real, synchronous denial, via Haystack's own real
after_tool hook seam.
- Tool-profile filtering -- expose only
observe(read-only, the default),govern,execute, or the opt-infullregistry, percontracts/integration-tool-contract.json. This is Haystack's own nativeMCPToolset(tool_names=...)mechanism -- no wrapper needed for this half at all. - Two-layer never-model-facing scrubbing --
force/override_safetyare stripped from every returned tool's advertised schema and from the arguments dict actually forwarded to the real call. - Typed
execute_decisionoutcomes -- parseable viaextract_execution_outcome_from_tool_resultinto either a realExecutionReceipt(success) or anExecutionBlocked(one of the three real named-gate denials). - An honest denial mapping -- see Denial mapping below for the full
accounting of what Haystack's real primitives do and don't guarantee, verified live against
every
haystack-ai/mcp-haystackversion this package'spyproject.tomlpermits, not assumed from documentation.
Prerequisites
Before you install anything, you need:
- A running self-hosted Algenta Engine, reachable over MCP. There is no Algenta-hosted cloud service to point at instead -- see Self-hosted-first below. No engine running yet? Skip to Try it locally below, which needs nothing but this package's own test dependencies.
- Python 3.10 or newer.
- An API key for whatever chat-completion model you drive your agent with (e.g.
OPENAI_API_KEYfor theOpenAIChatGeneratorused in Quick start below). This is a key for your model provider, not for Algenta -- this package and the engine it talks to never require an API key of their own.
Install
pip install haystack-algenta
This package depends on haystack-ai and mcp-haystack (both real, non-optional runtime
dependencies) and deliberately not on algenta-sdk -- see
Why no algenta-sdk dependency.
Self-hosted-first
create_algenta_tools talks to your own self-hosted Algenta Engine over its MCP endpoint --
never a hosted-by-Algenta cloud service. The endpoint resolves, in order, from:
base_url=passed to the function,- the
ALGENTA_BASE_URLenvironment variable, http://localhost:8000/mcp(the default for a local self-hosted engine).
Quick start
Requires OPENAI_API_KEY in your environment (see Prerequisites) and a running
self-hosted Algenta Engine at the base_url below:
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack_algenta import create_algenta_tools
toolset = create_algenta_tools(base_url="http://localhost:8000/mcp", profile="observe")
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=toolset)
result = agent.run(messages=[ChatMessage.from_user("What's the expected value of scenario X?")])
print(result["messages"][-1].text)
toolset.close() # tears down the MCP connection this call built
If no engine is listening at base_url, this raises MCPConnectionError with a message naming
the URL it tried and the three things to check (is the URL right, is the server running, is the
auth token correct) -- there's nothing else to configure to get past it.
profile="observe" is also the default if you omit it -- the agent can call get_contract /
query_data / simulate / recommend, and nothing that plans, logs, or executes a decision.
Unlike this repository's other four Python siblings, everything here is synchronous.
MCPToolset's real, public API (warm_up(), Tool.invoke(), Agent.run()) is itself
synchronous -- it bridges the real async MCP client through its own internal AsyncExecutor --
so create_algenta_tools is a plain function, not an async def, and there is no pytest-asyncio
anywhere in this package's own test suite. A genuine framework-level difference from
pydantic-ai-algenta/langchain-algenta/litellm-algenta/maf-algenta, not an oversight.
Try it locally (no live engine required)
Want to see create_algenta_tools and build_algenta_governance_hooks run end to end before
you've stood up an engine or an LLM API key? This package's own test suite carries two small,
real, non-mocked pieces you can drive yourself:
tests/stub_server.py-- a realmcp.server.fastmcp.FastMCPserver, on a real local HTTP socket, implementing the same MCP tool surface a real Algenta Engine exposes.tests/fake_chat_generator.py-- a real HaystackChatGeneratorcomponent whose replies come from a scripted queue instead of a network call, so no model API key is needed either.
From a checkout of this repository:
cd python
uv sync --all-packages --all-extras
Then run this script (it imports the two test modules above, so it has to run from inside
python/haystack-algenta, the same way pytest does):
# python/haystack-algenta/try_it_locally.py
from haystack.components.agents import Agent
from haystack.dataclasses import ChatMessage
from haystack_algenta import create_algenta_tools
from tests.fake_chat_generator import ScriptedChatGenerator, text_reply, tool_call_reply
from tests.stub_server import StubServerFixture
with StubServerFixture() as stub:
toolset = create_algenta_tools(base_url=stub.base_url, profile="observe")
chat_generator = ScriptedChatGenerator(
scripted_replies=[
tool_call_reply("query_data", {"dataset": "orders"}),
text_reply("Found 2 rows in the orders dataset."),
]
)
agent = Agent(chat_generator=chat_generator, tools=toolset)
result = agent.run(messages=[ChatMessage.from_user("Look up the orders dataset.")])
toolset.close()
print(result["messages"][-1].text)
uv run python haystack-algenta/try_it_locally.py
This really does open a local HTTP socket, run a real MCPToolset client against it, and drive a
real Agent step loop -- the only things scripted are the two ends a live demo can't have without
an account: the model's replies and the engine itself. Prints:
Found 2 rows in the orders dataset.
Swap the scripted tool_call_reply/text_reply calls for your own scenario, or point
create_algenta_tools at stub.base_url from your own script, to explore the rest of this
package (including profile="execute" and the denial gates below) without touching a real engine.
Tool profiles
| Profile | Adds | Notes |
|---|---|---|
observe (default) |
get_contract, query_data, simulate, recommend |
Read-only. |
govern |
+ plan_decision, log_decision |
Propose/record decisions; never executes. |
execute |
+ execute_decision |
Real-world execution; see Denial mapping. |
full |
everything the connected engine advertises | Opt-in only; admin/ops tooling. |
toolset = create_algenta_tools(base_url="...", profile="execute")
An observe-profile call genuinely does not return execute_decision (or anything
govern/execute-tier) -- filtered at MCPToolset construction time via tool_names=..., so
this package never even sees the excluded tools in the first place. "full" deliberately omits
tool_names (matching the contract's own tools: "*" sentinel), which puts it well past the
20-30-tool threshold deepset's own MCPToolset docs warn can overwhelm an LLM's tool-resolution
logic (the real contract's full profile is ~117 tools) -- haystack-ai's own SearchableToolset
(semantic tool search instead of dumping every schema into context) is the documented mitigation
for that case, worth pairing with profile="full" in your own agent if you use it; this package
doesn't force that choice on you.
The real execute_decision contract
execute_decision(decision_id, webhook_url, timeout_seconds=None, force=None, override_safety=None, metadata=None) either:
- succeeds (200) with a real
ExecutionReceipt:{decision_id, webhook_url, execution_status: "delivered"|"failed", response_code, executed_at, policy_snapshot_id, schema_snapshot_id, manifest_version, payload_summary, safety_overridden}-- noteexecution_statusdescribes whether the webhook delivery itself succeeded, not a policy verdict; a"failed"delivery is still a completed, non-gated call, or - is blocked, in that same call (409), by exactly one of three real, named policy gates:
"idempotency"(thisdecision_idwas already delivered; bypassable only viaforce=True, for one re-execution),"confidence"(belowpolicy.min_confidence), or"risk_floor"(risk_p5below-policy.risk_floor) -- both of the latter bypassable only viaoverride_safety=True. The body is{"error": {"code": "execution_blocked_<gate>", "gate": "<gate>", "message": "...", "override_hint": "..."}}.
There is no third outcome. decision_id comes from a prior real log_decision call, never from a
model-facing plan_hash -- there is no plan_hash field anywhere on this tool, and no
asynchronous "pending approval" state to poll or resume. A genuinely separate, real
plan/nonce-based human-approval system does exist in the engine (decision-cases/analysis-runs/
decision-plans HTTP routes), but the engine's own source is explicit that it "is intentionally not
exposed as an MCP/LLM tool" -- it is out of reach for this package, or any MCP-based integration,
and this package makes no claim otherwise.
Denial mapping
Verified live against both ends of the version range this package's pyproject.toml permits --
haystack-ai 3.0.0 and 3.1.1, mcp-haystack 1.4.1 and 1.5.1 (a real mcp.server.fastmcp.FastMCP
stub server, real HTTP wire, real Agent run loop -- see tests/test_toolset_scenarios.py), not
assumed from documentation.
GovernedReceiptHook -- this package's own after_tool hook. Parses the real outcome out of
the tool-result message(s) Agent._run_step just wrote into state.data["messages"]
(haystack_algenta.receipts.extract_execution_outcome_from_tool_result), and raises
AlgentaToolDenied -- naming the real gate (error.gate, one of "idempotency", "confidence",
"risk_floor") plus the engine's own message/override_hint -- for the real 409 denial shape. A
successful ExecutionReceipt and any non-execute_decision tool's result are both left alone
(detected by shape, not by tool name: nothing else this package can expose validates as either
ExecutionReceipt or ExecutionBlocked).
Proven live that this exception propagates out of agent.run() completely unmodified --
because Agent._run_step's _run_hooks(self.hooks, AFTER_TOOL, state) call has no surrounding
try/except anywhere in agent.py (grep-verified against the installed package). This is
genuinely the only seam in Haystack's Agent loop that behaves this way.
Why not map inside the wrapped Tool.function the way every other sibling package does?
Verified live, and it is the strictest exception behavior of all five sibling frameworks:
Tool.invoke()/invoke_async() has a bare except Exception that unconditionally catches
and rewraps anything into haystack.tools.errors.ToolInvocationError (losing the original
exception's type, though not its message -- recoverable via .__cause__), with no escape hatch at
that layer at all (unlike pydantic-ai's dedicated ApprovalRequired/ToolDenied/ToolFailed, or
MAF's MiddlewareFailure, which is explicitly excluded from wrapping). And it gets swallowed, not
just wrapped: a real Agent's documented default is raise_on_tool_invocation_failure=False, and
with that default the wrapped exception is never even re-raised -- it's converted into an ordinary
error-flagged ChatMessage fed back into the conversation, and agent.run() returns normally.
Raising AlgentaToolDenied from inside haystack_algenta.toolset's wrapped calls would therefore
be exactly the wrong seam for the common, real-Agent case -- proven, not assumed
(tests/test_toolset_scenarios.py::test_a_blows_up_tool_error_is_wrapped_and_swallowed_by_default
reproduces this with a real server-side exception, unrelated to the gate mapping, to isolate the
claim).
There is no pre-call gate here, and none is needed. An earlier version of this package also
wired Haystack's real ConfirmationHook/BlockingConfirmationStrategy as a before_tool gate,
bridging to an AlgentaApprovalStillPending abort if the engine's own (fictional) out-of-band plan
approval hadn't landed by the time the model's call went through. execute_decision has no
asynchronous approval state to bridge to -- a call is a same-response success or a same-response
named-gate denial, never "pending, come back later" -- so that whole pre-call/post-call bridge is
gone, not merely dormant. Haystack's ConfirmationHook itself is unaffected and still a
perfectly real, general-purpose primitive -- a caller who wants a human to confirm the model's
request to call execute_decision at all, for their own reasons unrelated to this contract, can
still wire it directly:
from haystack.hooks.human_in_the_loop import AlwaysAskPolicy, BlockingConfirmationStrategy, ConfirmationHook
gate = ConfirmationHook(
confirmation_strategies={
"execute_decision": BlockingConfirmationStrategy(confirmation_policy=AlwaysAskPolicy(), confirmation_ui=your_ui)
}
)
agent = Agent(chat_generator=..., tools=toolset, hooks={"before_tool": [gate], **build_algenta_governance_hooks()})
tests/test_toolset_scenarios.py::test_a_confirmation_hook_can_still_be_wired_directly_for_a_pre_call_gate
proves this composition live -- with a UI that rejects, the real MCP server is genuinely never
contacted at all. This package just doesn't build or opinionate over that wiring itself anymore,
because doing so would imply a bridge to an approval state that doesn't exist on the real tool.
Why this package's mapping lives where it does
haystack-algenta surfaces the denial from a dedicated after_tool hook because that is the one
seam in Haystack's Agent loop proven to let a custom exception type survive agent.run() intact
-- Tool.invoke()'s own exception handling is too aggressive (see above) for the mapping to live
any closer to the real MCP call itself. Each of this repository's other framework packages maps
execute_decision's real outcome onto whatever that framework's own native tool-failure idiom
is, at whichever seam that framework actually supports -- this section only speaks for this
package's own, verified-live reasoning, not for any sibling's current implementation.
A Pipeline, or any direct Tool.invoke() caller, gets no hook at all
Hooks are an Agent-loop concept. build_algenta_governance_hooks only helps agents built with
haystack.components.agents.Agent. For a Pipeline, or any caller invoking Tool.invoke()/
invoke_async() directly, haystack_algenta.extract_execution_outcome_from_tool_result is the
same parsing GovernedReceiptHook uses, exposed directly so you can call it yourself on whatever
invoke() returned and decide what to do:
from haystack_algenta.receipts import ExecutionBlocked, extract_execution_outcome_from_tool_result
raw_result = execute_decision_tool.invoke(decision_id="...", webhook_url="...")
outcome = extract_execution_outcome_from_tool_result(raw_result)
if isinstance(outcome, ExecutionBlocked):
... # your own handling -- outcome.gate is one of "idempotency"/"confidence"/"risk_floor"
This is an honest, undisguised capability, not a fabricated approval-pause mechanism -- the same
stance litellm_algenta documents for its own no-pause gateway path.
Why the double-JSON unwrap
Verified live: Tool.invoke() for an MCPToolset-built tool returns the raw MCP
CallToolResult, JSON-serialized, with the actual tool payload nested as a JSON string inside
a text content block inside that JSON:
{"meta": null, "content": [{"type": "text", "text": "{\n \"decision_id\": \"decision-1\", ... \"execution_status\": \"delivered\", ...}"}], "structuredContent": null, "isError": false}
Neither Tool nor MCPToolset ever unwraps this -- there is zero receipt concept anywhere in
Haystack's own tool-calling layer, so a real execute_decision outcome and get_contract's plain
discovery blob are handled completely identically (both are just opaque strings) until something
-- this package's unwrap_mcp_tool_result -- peels both string layers back into a real Python
value.
Why mcp-haystack, not just haystack-ai
MCPToolset is not part of haystack-ai -- confirmed by grepping an installed haystack-ai
tree (checked on both 3.0.0 and 3.1.1) for any mcp module: none exists there. It ships in a
separate PyPI package, mcp-haystack, under the haystack_integrations.tools.mcp import
namespace. haystack-ai core only has the generic Tool/Toolset/ComponentTool/Agent/hooks
primitives this package also uses. Any consumer of this package gets both transitively (they're
both real, non-optional dependencies here), but it's worth knowing they're two separate packages
if you're pinning versions yourself.
Why no algenta-sdk dependency
Every package in this repository may depend on at most one Algenta-owned thing, the published
algenta-sdk client -- but only if it's genuinely used. This package never imports it:
create_algenta_tools talks to the caller's self-hosted Algenta MCP endpoint directly via
haystack_integrations.tools.mcp.MCPToolset, the same reason maf-algenta (D5) and
typescript/algenta-tools (D2) declare no algenta-sdk dependency either. Declaring it anyway,
without importing it anywhere in this package's own source, would repeat exactly the
leftover-placeholder-dependency pattern an adversarial review is on record catching elsewhere in
this repository's D2/D4 history.
Not a WrapperToolset/interceptor
Every real, in-process sibling in this repository (pydantic-ai-algenta, langchain-algenta,
maf-algenta) builds one wrapper object that filters, scrubs, and maps denials in a single
seam. This package deliberately does not: haystack_algenta.toolset only does profile filtering
(native, via MCPToolset(tool_names=...)) and the two-layer scrub; haystack_algenta.hooks is a
separate, Agent-level hook. Haystack genuinely has no single interception point on
MCPToolset/Tool to build a combined wrapper on top of -- Toolset's entire public surface is
add/from_dict/get_selectable_tools/spawn/to_dict/warm_up, and MCPToolset builds each
Tool's call as a closure with no tool_interceptors=-style hook anywhere (grep-verified against
the installed package). Rebuilding each Tool via dataclasses.replace (what this package does
for the scrub) is the same escape-hatch pattern langchain_algenta._wrap_plain_tool and
maf_algenta._wrap_mcp_function already use for their own non-native-seam cases; the denial
mapping just has to live one layer higher here than it does for them.
Testing this package's own test suite (not your agent)
cd python
uv sync --all-packages --all-extras
uv run pytest haystack-algenta -v
This repository's CI runs exactly that isolated command per package (never pytest . across
multiple packages at once) -- two packages sharing a tests/__init__.py module name would
otherwise collide in one shared invocation. tests/stub_server.py runs a real
mcp.server.fastmcp.FastMCP server over a real local HTTP socket, on its own background thread
(not an asyncio task on the same loop the synchronous MCPToolset/Agent calls block on -- see
that file's module docstring), so a wire-shape regression would actually be caught, not hidden
behind a mock.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file haystack_algenta-0.1.3.tar.gz.
File metadata
- Download URL: haystack_algenta-0.1.3.tar.gz
- Upload date:
- Size: 44.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fbe0739313a9ace573d648715cb39a6cf3087272a9c0001ccd1b2694f4849e48
|
|
| MD5 |
82f6d5cdc2e08e7c690e33b3c9637bc2
|
|
| BLAKE2b-256 |
8d99e4c7cd3ac5e6e2b5c50fc59326fcf11d75bbf52d571c6c168b6b4768f1bd
|
File details
Details for the file haystack_algenta-0.1.3-py3-none-any.whl.
File metadata
- Download URL: haystack_algenta-0.1.3-py3-none-any.whl
- Upload date:
- Size: 33.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cdf733eacba6d3db6d107d37cb396355b1021b1b6a58f29597cf4e2d6cafc61f
|
|
| MD5 |
1f175182672b9cad268c7fe284967b36
|
|
| BLAKE2b-256 |
132244d6825e76dd167adececc0e4fc874bdc7eb61c643c85a7018acc95dcbbe
|