Skip to content

Oi fork - #10

Open
spichen wants to merge 48 commits into
mainfrom
oi-fork
Open

Oi fork#10
spichen wants to merge 48 commits into
mainfrom
oi-fork

Conversation

@spichen

@spichen spichen commented Jun 19, 2026

Copy link
Copy Markdown
Owner

No description provided.

Salah Pichen and others added 10 commits June 19, 2026 21:06
…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.
_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.
Salah Pichen and others added 19 commits June 21, 2026 14:45
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.
Salah Pichen and others added 19 commits July 4, 2026 11:19
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants