Conversation
…tion Adds a ManagerWorkers branch to the LangGraph converter so a manager + workers spec compiles into a parent StateGraph whose nodes are the manager react-agent and each worker CompiledStateGraph. Delegation happens through synthesized delegate_to_<worker> tools that return Command(goto=..., graph=Command.PARENT) — same handoff pattern as langgraph_swarm. Workers run with an isolated messages context; the worker's reply comes back as a ToolMessage matched to the manager's pending tool_call_id, keeping the OpenAI tool contract intact across the manager's next turn. Recursive ManagerWorkers compose through self.convert(...). The "Available workers:" roster is rendered into the manager's system prompt by the converter. Stream/astream are wrapped to emit ManagerWorkersExecutionSpan + Start/End events.
Collapse the duplicated dict-or-attribute tool-call accessor into a single _tc_get helper, hoist the inline re imports to module scope (with a precompiled whitespace pattern), and move the worker wrapper's message imports out of the per-invocation path.
…ation The output schema validation in _ensure_checkpointer_and_valid_tool_config rejected tools that had requires_confirmation=True with typed outputs. This is unnecessary — the output schema is metadata for the LLM, not a runtime constraint. LangGraph does not enforce tool output schemas, and both the approved path (normal typed output) and rejected path (plain string) work fine as ToolMessage content. Resolves oracle#149 Signed-off-by: Salah Pichen <nh.salah@gmail.com>
Allow typed output schemas on confirmation-required tools. Only reject multi-output declarations inside a Flow ToolNode, since the rejection denial string has no mapping to multiple outputs. Reworked the test to use an object-typed single output (the old helper only wired the first edge) and added a test for the multi-output rejection.
…S CA trust boundary
_manager_workers_convert_to_langgraph did not accept or forward the middleware argument, so its call to _create_react_agent_with_given_info (a required keyword-only arg) raised TypeError. Thread middleware through the dispatcher, the method signature, recursive worker conversion, and the manager-graph creation, matching the swarm/agent/flow paths.
…m astream_events The manager routes by emitting a delegate_to_<worker> tool call that the worker answers with a matching ToolMessage. That pair is load-bearing for the manager react loop but is internal plumbing a consumer should never see as phantom tool calls. Add _DelegationEventFilter + _patch_hide_delegation_in_astream_events to scrub the delegate_to_* tool calls, their on_tool_* lifecycle events, and the worker's synthetic reply ToolMessage from the astream_events view. The filter operates on the emitted events only, never the graph state, so the react loop's tool-call/tool-result contract stays intact. Workers' real LLM/token events still propagate via callback propagation.
…e worker node Worker subgraphs were invoked with a fresh thread_id, which reset their checkpoint_ns so their token events streamed as a detached agent:<uuid> run with no worker prefix — indistinguishable from the manager's own events and so unattributable by stream consumers. Invoke the worker inheriting the ambient run config instead. The worker run then nests under this node's checkpoint_ns, so astream_events surfaces its events natively as <worker>:...|agent:..., letting consumers attribute the token stream to the sub-agent. Isolation is preserved: the worker is still fed only the delegated task, and LangGraph's distinct per-superstep checkpoint_ns keeps state isolated across repeated delegations without the fresh thread_id. Also harden the astream_events delegation filter to fail open: a scrubbing error now passes the event through unfiltered rather than tearing down the stream (which would swallow every later event, including the worker's). Add regression tests: worker token events are namespaced under the worker node, and the filter fails open on error.
…al message An Agent that declares a single string output now takes that output from the agent's final message instead of forcing structured generation: - _create_react_agent_with_given_info no longer attaches a response_format for a single string output (mirrors LlmNodeExecutor). - extract_outputs_from_invoke_result falls back to the final message content for a single string output when structured generation didn't populate it. This lets a string output work on models without structured-output support (previously the output came back empty). Multi-output and non-string outputs are unchanged. Adds offline tests using a fake (non-structured) chat model.
When a RemoteTool data field is a single whole placeholder (e.g. members:
'{{members}}'), the value was rendered via str(), turning structured tool
arguments into Python reprs ('[membersItem(...)]') and None into 'None'. A
downstream action doing input.members.map(...) then failed with
'input.members.map is not a function'.
Add render_nested_json_template (used only for the JSON request body): a value
that is exactly one placeholder is replaced with the raw input, JSON-normalized
(pydantic models -> dict via model_dump), preserving list/dict/number/bool/None.
Strings with surrounding text keep interpolation; dict keys stay strings.
render_template / render_nested_object_template are unchanged.
fix(adapters): preserve value types in RemoteTool JSON bodies
…turn When the manager emitted several delegate_to_<worker> tool calls in one turn (e.g. "spin up 5 sub-agents"), only the first delegation ran. The remaining tool_call_ids were left unanswered, producing an invalid tool-call / tool-result sequence under the OpenAI contract — so the manager hallucinated the missing replies. Two paths each assumed a single delegation per turn: - the delegate tool returned Command(goto=<worker>, graph=PARENT), and ToolNode collapses multiple parent commands down to one, so only one worker was scheduled; - the routing edge returned a single worker node name and the worker wrapper answered only the first matching tool_call_id. Fix: the delegate tool now returns Command(graph=PARENT) with no goto — it only breaks out of the manager's react loop. The routing edge becomes the single source of routing and fans out one Send per delegate call, each carrying its task and tool_call_id; the worker wrapper reads them from the Send payload (message-scan fallback kept for the direct-edge path). Multiple Sends to the same worker run as independent tasks, so every delegation is answered and the runs no longer collide on a shared "first pending call". Worker token events stay namespaced under the worker node, preserving sub-agent stream attribution. Adds a regression test covering multiple delegations to the same worker in one turn and updates the routing unit tests to assert the fan-out.
fix(adapters/langgraph): answer every ManagerWorkers delegation in a …
…s state snapshots The delegation-hiding filter dropped the worker reply ToolMessages from node/state payloads but left the synthetic delegate_to_<worker> tool calls on the manager's AIMessage. A consumer that builds its message snapshot from an on_chain_end state payload (e.g. the AG-UI MESSAGES_SNAPSHOT) reads tool_calls straight off the AIMessage, so it saw delegate tool calls whose results had been removed — rendered as a "tool call with no result". The multi-delegation fan-out made this obvious: every delegate call in the turn showed up orphaned. _scrub_payload_messages now also strips the delegate tool calls off AIMessages in payloads (dropping an AIMessage left empty — a pure delegation turn), reusing _scrubbed_ai_message. Messages are walked in order so a delegation AIMessage records its call ids before its reply ToolMessages are tested for removal. Real (non-delegation) tool calls and their results are preserved. The graph's message state is untouched; only the emitted event view is scrubbed. Adds a regression test asserting a full state snapshot surfaces neither the delegate calls nor their results while keeping a real tool call/result pair and the manager's final answer.
…n-snapshot fix(adapters/langgraph): strip delegate tool calls from ManagerWorker…
The langgraph converter built every remote MCP connection from `headers` only, dropping `sensitive_headers`. Sensitive headers are redacted from exported configs but must still travel on live requests (the two maps are validated disjoint), so credentials configured as sensitive headers (e.g. an Authorization token) never reached the MCP server. Merge both maps via a shared helper for all four remote transports (SSE, SSEmTLS, StreamableHTTP, StreamableHTTPmTLS).
fix(adapters/langgraph): send sensitive_headers on remote MCP requests
…_in_async_trace anyio.get_running_tasks() enumerates all live asyncio tasks (O(N)) just to detect async context. At high concurrency this was called per LangChain callback event — including on_llm_new_token — making it O(N²) per request stream and responsible for ~73% of CPU in profiles. anyio.get_current_task() is O(1) and has identical semantics: raises RuntimeError outside an async context, returns normally inside one.
fix(tracing): replace get_running_tasks() with get_current_task() in …
A flow step is wrapped in an AgentNode whose inputs/outputs are the wrapped
component's. A plain Agent exposes its prompt {{placeholders}} as inputs, but a
ManagerWorkers inferred none, so a data-flow edge into a manager step had no
port to resolve against; and AgentNodeExecutor raised a TypeError for any
non-Agent component.
- ManagerWorkers now infers its inputs/outputs from its group manager, so a
manager flow step exposes the group-manager prompt's {{placeholder}} inputs
(and the group manager's outputs) and data-flow edges resolve at load.
- AgentNodeExecutor runs a ManagerWorkers node: it renders the node inputs into
the group-manager prompt (cached per rendered prompt, like the Agent path),
compiles the manager graph, invokes it over MessagesState, and maps the result
back the same way as an Agent node.
feat(adapters/langgraph): run a ManagerWorkers as a flow step
The LangGraph adapter rejected any non-Agent Swarm member, so an Agent that carries sub-agents (serialized as a ManagerWorkers) could not take part in a Swarm: "Only Agents are supported as part of a Swarm ...". A Swarm handoff is a Command(goto=X, graph=Command.PARENT). Fired from inside a ManagerWorkers' manager it only reaches the ManagerWorkers graph (one level up) and is silently dropped, never reaching the Swarm. So a ManagerWorkers member needs the handoff re-emitted at its parent boundary. - _swarm_convert_to_langgraph: accept Agent and ManagerWorkers members (clear NotImplementedError for Flow / nested Swarm, which have no single LLM to attach handoff tools to). ManagerWorkers members are built with their swarm handoff destinations. - _manager_workers_convert_to_langgraph: new swarm_handoff_destinations param (default off -> unchanged behaviour for standalone / flow-step / nested ManagerWorkers). It synthesizes transfer_to_<sibling> placeholder tools on the manager (mirroring the delegate_to_<worker> pattern), adds a __handoff__ parent node that re-emits Command(goto=sibling, graph=PARENT, active_agent=sibling), and the router now prefers handoff over delegation. - The __handoff__ node forwards the manager's transfer AIMessage ahead of a ToolMessage answering every open tool call, so the Swarm transcript stays a valid tool-call/result sequence (the ManagerWorkers' internal messages do not merge into the Swarm on a PARENT-jump exit; an orphan ToolMessage would 400 the next member's LLM). Tests: 5 new offline tests in test_managerworkers.py (helpers, routing precedence, the handoff node, a full E2E Swarm-with-ManagerWorkers handoff, and the unsupported-member rejection). Agent-only Swarms are unchanged.
feat(adapters/langgraph): support ManagerWorkers as a Swarm member
Symmetric with the ManagerWorkers flow-step support. A Swarm is an AgenticComponent, not an Agent, so an AgentNode wrapping one previously raised TypeError in AgentNodeExecutor and exposed no input ports (a DataFlowEdge into the node could not resolve at load). - Swarm._get_inferred_inputs/_get_inferred_outputs expose the entry agent's (first_agent) ports, mirroring ManagerWorkers' group manager, so an AgentNode wrapping a Swarm declares inputs and data edges into it resolve at load. - AgentNodeExecutor now dispatches a Swarm flow step: it renders the node inputs into the entry agent's prompt (swapping the rendered agent into both first_agent and the relationship tuples), drops the satisfied input ports, compiles via _swarm_convert_to_langgraph and drives the graph over messages. Adds tests/adapters/langgraph/flows/test_swarm_node.py.
feat(adapters/langgraph): run a Swarm as a flow step
…hemas MCP servers commonly derive tool schemas from OpenAPI documents whose nested schemas carry human-readable titles (e.g. Notion's "Rich Text"). The on-the-fly MCPTool built for the tracing callback fed those raw schemas into Property, whose title validation rejects any title with spaces or special characters, failing the whole agent run before the model was ever invoked. Strip title annotations from the arg schema before building the tracing Property, visiting exactly the positions the validator traverses (items, anyOf, additionalProperties, properties values) so non-schema payloads like default values are left intact. The LLM-facing args_schema is untouched.
fix(adapters/langgraph): tolerate nested schema titles in MCP tool schemas
…erWorkers state snapshots" This reverts commit fd1eede.
…ocol from astream_events" This reverts commit 38b4f07.
…tion Revert/managerworkers hide delegation
…worker failure A ManagerWorkers worker runs as a subgraph node wrapped by _wrap_worker_for_subgraph, whose func/afunc built the answering ToolMessage only after worker_graph.(a)invoke returned. There was no try/except and no retry policy on the node, so a worker that raised propagated out of the whole parent run and left the manager's delegate_to_<worker> tool-call unanswered. An orphan tool-call breaks the OpenAI/Anthropic contract and 400s the manager's next turn — notably on a checkpoint resume. Wrap the worker invocation so a failure is turned into an error ToolMessage matched to the pending delegation (Send fan-out payload or the manager's last AIMessage), letting the manager see a well-formed 'worker failed' result and react — mirroring how a tool raising inside the react-agent ToolNode surfaces as an error ToolMessage rather than crashing the graph. _extract_pending stays outside the try: with no delegation to answer, the original error still surfaces.
…ssage fix(adapters/langgraph): answer delegation with error ToolMessage on worker failure
…stemMessage The manager's delegated task was forwarded to each worker subgraph as a HumanMessage. Because the worker inherits the parent node's astream_events callbacks, that input message streams out to consumers and renders in the chat UI as a spurious end-user turn — the sub-agent's prompt shown as if the user typed it. Forward the task as a SystemMessage instead: it is the manager's internal instruction to the worker, not an end-user message. The worker is still driven by the task, worker state stays isolated (the top-level snapshot is unchanged), and the OpenAI-compatible models the runtime uses accept a system-only message list. Add a regression test asserting the worker receives its task as a SystemMessage with no HumanMessage in its input.
fix(adapters/langgraph): forward ManagerWorkers delegation task as SystemMessage
…of sending it as a SystemMessage Reverts forwarding the delegated task as a SystemMessage. A system-only worker conversation gives the model no user turn to answer; strict OpenAI-compatible providers return an empty completion and langchain-core then raises "No generations found in stream", failing every delegation. The task is a HumanMessage again (a user turn the model can answer). To stop it rendering as a spurious end-user turn where the worker's inherited astream_events callbacks surface it, stamp additional_kwargs pyagentspec_kind=delegation_task so consumers can recognise it as internal delegation plumbing and drop/relabel it — non-destructively, without stripping anything from the stream (which breaks tool-call/result pairing and attribution, as the earlier reverts showed).
fix(adapters/langgraph): mark ManagerWorkers delegation task instead of SystemMessage
…hread When called from a running event loop (AsyncContext.ASYNC), run_async_in_sync runs the coroutine on a fresh worker thread with its own event loop. A new thread starts with an EMPTY contextvars context, so request-scoped state the caller set — tenant/user identity, the OTEL trace context — was silently lost inside async_function. In particular, an MCP client loaded synchronously from an async request handler read empty ContextVars and dropped the per-request headers derived from them (e.g. the tenant header the downstream Connectors proxy authenticates with), producing a 401. Copy the caller's context and run the worker-thread body inside it via ctx.run(...), so contextvars propagate across the thread boundary. Add a regression test that fails without the copy (empty value) and passes with it.
…read copy_context() (added so the case-3 worker thread inherits the caller's contextvars) also copies sniffio's current_async_library marker. That fresh thread has no running loop, so anyio.run() would then refuse with "Already running <lib> in this thread". Clear the marker in the worker before anyio.run() — anyio sets its own for the loop it starts — while keeping the app contextvars (tenant/user identity, OTEL trace context) intact. Add a regression test that sets the marker (the anyio-managed-caller case) and would otherwise raise.
fix(langgraph): propagate contextvars into run_async_in_sync worker thread
Node inputs are seeded into the agent's invoke state, so the invoke result
carries each input back under its port title. When an input port shares its
title with an output port — e.g. an {{output}} prompt placeholder wired from
an upstream agent's default 'output' port, the exact shape the data-edge
auto-weaving produces — extract_outputs_from_invoke_result preferred that
echoed input over the agent's final message, and the flow silently returned
the upstream agent's text while this agent's actual reply was discarded.
Drop result entries that still hold the exact seeded input value before
extraction, so the single-string fallback (or structured response) supplies
the node output; a value the graph genuinely rewrote is kept.
An AgentNode whose agent has sub-agents compiles as a ManagerWorkers (or Swarm)
graph. The group manager — and any swarm member with declared outputs — is a
react agent built with `response_format`, so it writes its answer to the
`structured_response` channel of its own subgraph state. Both parent graphs were
built over plain `MessagesState` / `SwarmState`, and LangGraph drops a subgraph's
updates to channels the parent does not declare, so the answer died one step
after being produced. The node's declared outputs then arrived unresolved and
`_cast_values_and_add_defaults` raised, at the producing node:
ValueError: Expected node `<node>` to have a value for property `<title>`
A single string output hid this behind the free-text fallback; two or more
outputs failed every run, whatever the model replied.
Both parent schemas now declare the channel, which is the whole fix — the value
arrives exactly as a plain agent's does and the existing `structured_response`
path in `extract_outputs_from_invoke_result` picks it up. Only the manager writes
it: `_wrap_worker_for_subgraph` returns `messages` alone, and in a swarm only
members with declared outputs are given a `response_format`.
Verified end to end for both graph types by the two tests added here, which fail
with the ValueError above without the change. Confirmed against a live model
whose provider satisfies `response_format` with native JSON rather than a tool
call, since `create_agent` normalizes both strategies into the same channel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tool parameter whose JSON schema is a bare {"type": "object"} (no
declared properties) was converted into an empty create_model() pydantic
model. Pydantic defaults to extra="ignore", so validating the LLM's
arguments against that model silently stripped every key — the tool
received {} (or [{}] for arrays of objects) no matter what the model
sent. Bare object schemas now map to Dict[str, Any], matching JSON
Schema semantics (an object with no property constraints accepts any
object). Schemas that declare properties or set
additionalProperties: false still build typed models as before.
A remote tool's response is always parsed as JSON, so a backend that answers with
an error page — or nothing — failed with only
Expecting value: line 1 column 1 (char 0)
which names neither the tool, the status, nor the body. The status was no help
either: it is checked only when a retry policy is configured, and oi-apps-core's
tool builder has no retry-policy field, so every HTTP tool created through the UI
takes the unchecked path. A 401 from a misconfigured backend and a 502 from a
gateway were indistinguishable from malformed JSON.
The decode is now wrapped to report the tool name, the status, the content type
and the start of the body. The status check is left exactly as it was: a non-2xx
carrying a JSON error body is still returned for the agent to read, which is what
the existing guard allows.
Reproduced against a URL serving HTML (200 text/html) and a 404 error page; both
now say so, and a JSON body — including a JSON error body — is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`black --check pyagentspec/` was failing on oi-fork for eight files that predate this branch, so every pull request against it starts red and the real gates are never reached (the matrix fail-fast cancels the rest). Mechanical only: `black --config pyagentspec/pyproject.toml pyagentspec/` with the version the CI pins (26.3.1). No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`flake8 --select C801 --copyright-check` was failing on `tests/test_bare_object_schema.py`, which arrived without the header the check requires. It was masked until now because the black step failed first and the matrix cancels the remaining jobs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AgentNodeExecutor invoked the inner agent's compiled graph with the conversion-time config, which carries none of the flow task's pregel context. The agent therefore ran as a standalone root graph: an interrupt() raised inside it (a ClientTool, a requires_confirmation tool) was absorbed into that root run's own state, invoke() returned with __interrupt__ in the result, the executor formatted the tool-call message as the node output, and the flow carried on as if the agent had answered. Invoke the inner agent with the current pregel task's config (langgraph.config.get_config()) so it runs as a true subgraph of the flow: the interrupt propagates to the flow's run and pauses it, and a Command(resume=...) replays back into the inner agent.
Signed-off-by: Mohamed Ali <mohamed.ali@openinnovation.ai>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.