Files
2026-07-13 13:12:00 +08:00

1823 lines
81 KiB
Python

"""
Shared adapter that wraps any inner :class:`Executor` instance as
a :class:`HarnessApp` subclass.
The four per-harness wraps (claude-sdk, codex, pi,
openai-agents-sdk) all use this adapter — they only differ in the
inner ``Executor`` they construct. The adapter handles:
- Per-conversation lazy executor construction (Layer 1 state on
the adapter instance).
- Per-turn translation of Omnigent :class:`CreateResponseRequest` →
inner :class:`Message` list + :class:`ExecutorConfig`.
- Per-turn translation of inner :class:`ExecutorEvent`s →
typed Omnigent SSE events emitted via :meth:`TurnContext.emit`.
- Forwarding ``request.tools`` and ``request.instructions`` to
the inner Executor; wiring a ``_tool_executor`` callback so
the inner SDK round-trips spec-declared tools through
:meth:`TurnContext.dispatch_tool` (the scaffold's
action_required path).
- Cancellation propagation (POST /cancel → ctx.cancelled →
inner ``Executor.interrupt_session``).
- Per-conversation cleanup on shutdown.
V1 limitations (documented in §Autonomous decisions):
- **No per-conversation executor configuration via spec.** The
per-harness wrap's executor factory determines configuration
(cwd, model, sandbox, credentials) at construction time. A
follow-up should thread spec config through the request body
or via subprocess env vars.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import secrets
import uuid
from collections import deque
from collections.abc import Callable
from typing import Any
from fastapi import Response
from omnigent.errors import ElicitationDeclinedError
from omnigent.inner.executor import (
CompactionComplete,
Executor,
ExecutorConfig,
ExecutorError,
ExecutorEvent,
Message,
ReasoningChunk,
TextChunk,
ToolCallComplete,
ToolCallRequest,
TurnCancelled,
TurnComplete,
)
from omnigent.inner.tracing import TracingContext, is_tracing_enabled
from omnigent.policies.types import FAIL_CLOSED_PHASES
from omnigent.runtime.harnesses._scaffold import HarnessApp, PolicyVerdictPayload, TurnContext
from omnigent.runtime.tool_output import cap_tool_output
from omnigent.server.schemas import (
CreateResponseRequest,
ElicitationRequestParams,
InjectionConsumedEvent,
OutputItemDoneEvent,
OutputTextDeltaEvent,
ReasoningStartedEvent,
ReasoningSummaryTextDeltaEvent,
ReasoningTextDeltaEvent,
)
_logger = logging.getLogger(__name__)
# Status string the per-harness wraps emit on observed
# function_call items (i.e., tools the inner SDK already executed
# natively). Distinct from ``"action_required"`` which is what
# server-dispatched tool calls use — see §Sub-agent representation
# in designs/SERVER_HARNESS_CONTRACT.md.
_OBSERVED_TOOL_CALL_STATUS = "in_progress"
# Prefix the Claude SDK applies to MCP-registered tool names
# (e.g. ``mcp__omnigent__sys_terminal_launch``). Tools whose
# name starts with this prefix round-trip through the inner
# executor's ``_tool_executor`` callback -> :func:`_bridge_one_dispatch`
# -> ``ctx.dispatch_tool``, which emits an action_required
# function_call event POST-STREAM (the SDK's MCP-server handler
# fires after the assistant message finishes streaming).
#
# Adapter strategy for MCP tools:
#
# 1. Emit the observed function_call event INLINE (when the
# inner SDK yields ToolCallRequest). That's what gives the
# REPL the ``⏵ tool_name`` line interleaved with text rather
# than bunched at the end of the turn.
# 2. Queue the SDK's ``tool_use_id`` so the post-stream
# dispatch uses the SAME ``call_id`` (see
# ``self._pending_mcp_call_ids``). Correlated ids let the
# SDK client's ``BlockStream`` dedupe the action_required
# event against the inline observed event — single render,
# no duplicates.
# 3. Skip the observed function_call_output emission on
# ToolCallComplete — the dispatch's PATCH handler emits the
# paired output. Keeps the dedup story symmetric.
_MCP_TOOL_NAME_PREFIX = "mcp__"
def _strip_mcp_tool_prefix(name: str) -> str:
"""
Strip the Claude SDK MCP tool prefix from a tool name.
The Claude SDK names MCP tools as ``mcp__{server}__{tool}``
(e.g. ``mcp__omnigent__sys_terminal_launch``). The bare
name (``sys_terminal_launch``) is what the Omnigent wire shape
and persisted conversation items carry — kept in sync with
:func:`omnigent.runtime.workflow._observed_tool_call_sse_dicts`
and :func:`_build_observed_tool_items` so the SSE name, the
store-item name, and the adapter's emitted name all agree.
Mirrors :func:`omnigent.runtime.workflow._strip_mcp_tool_prefix`
— kept local here to avoid a cross-module import that
would tighten the dependency between the harness adapter
and the workflow package; both modules need this helper for
different consumers.
:param name: Tool name, possibly MCP-prefixed.
:returns: The bare tool name. Only ``mcp__<server>__<tool>``
shapes are stripped; bare names with ``__`` pass through.
"""
if name.startswith("mcp__"):
parts = name.split("__", 2)
if len(parts) == 3:
return parts[2]
return name
class ExecutorAdapter(HarnessApp):
"""
:class:`HarnessApp` subclass that drives any inner
:class:`Executor` instance.
The per-harness wrap supplies an ``executor_factory`` —
a zero-arg callable that constructs the inner executor. The
adapter calls it lazily on the first turn (so heavyweight
constructors like :class:`ClaudeSDKExecutor`'s eager
Databricks credential resolution don't fire at FastAPI
boot, only when a real conversation starts).
The factory's return value is cached as Layer 1 state on the
adapter instance — reused across turns for the conversation's
lifetime. ``shutdown()`` calls ``executor.close()`` on
teardown.
:param executor_factory: Zero-arg callable returning a fresh
:class:`Executor`. Tests pass a ``lambda:
MockExecutor()``; production wraps pass a lambda that
constructs the real per-harness Executor with config
appropriate to the spec, e.g. ``lambda:
ClaudeSDKExecutor(databricks=True, databricks_profile="<your-profile>")``.
:param session_key: Stable identifier the inner executor uses
to scope per-session state (clients, subprocesses). The
adapter passes this to ``executor.close_session()`` and
``executor.interrupt_session()``. Defaults to a uuid hex
— production wraps may want to set it from the
``conversation_id`` once the runner exposes it on
``app.state``.
"""
def __init__(
self,
executor_factory: Callable[[], Executor],
session_key: str | None = None,
harness_label: str = "Claude",
) -> None:
super().__init__()
self._executor_factory = executor_factory
self._session_key = session_key or uuid.uuid4().hex
# Human-readable harness name used in elicitation card messages,
# e.g. "Claude" → "Claude wants to call **Bash**" or
# "Cursor" → "Cursor wants to use **Bash**".
self._harness_label = harness_label
# Layer 1: lazily-constructed inner executor, reused
# across turns for the conversation's lifetime.
self._executor: Executor | None = None
# Per-turn pointer to the active :class:`TurnContext`,
# rebound at the top of each :meth:`run_turn` and cleared
# in its finally. The stable ``_tool_executor`` bridge
# (installed once on first use) reads from this slot so
# the MCP handlers cached inside ``ClaudeSDKClient`` —
# which closure-capture whatever ``_tool_executor`` was
# set on the FIRST turn — always dispatch into the
# CURRENT turn's ctx. Without this, turn N>1's tool calls
# park a Future inside turn 1's already-dead ctx and the
# SDK hangs forever waiting for a result that nobody can
# deliver.
self._current_ctx: TurnContext | None = None
self._current_agent: str | None = None
# FIFO queue of inner-SDK tool-use ids, one entry per
# ToolCallRequest the executor parses. Populated by
# :meth:`_translate_event` whenever ``event.metadata``
# carries a ``call_id`` (the inner Claude SDK / OpenAI
# Agents SDK / Codex / Pi executors all stamp it for
# bridged tools). Drained by :meth:`_stable_tool_executor`
# so each :func:`_bridge_one_dispatch` call reuses the
# SAME ``call_id`` the observed function_call event
# already carried — that's what lets the Omnigent REPL dedupe
# the inline observed render with the post-stream
# action_required render. Without this correlation, the
# two events have different uuids, the SDK client sees
# them as separate tool calls, and the REPL renders
# ``⏵ tool_name`` twice plus an empty result panel for
# the orphan call (the 2026-04-29 user-reported
# duplicate-render + empty-box regression for
# openai-agents — same shape as the 2026-04-28 claude-
# sdk regression resolved for MCP-prefixed
# tools). See commit 989bfde for the prior
# suppress-observed mitigation that introduced the
# end-of-turn ordering regression this queue resolves.
self._pending_mcp_call_ids: deque[str] = deque()
# Per-session tracing context. Created lazily on the first
# turn when tracing is enabled; reused across turns so the
# span parent chain stays rooted on the session's executor.
self._tracing_ctx: TracingContext | None = None
# Call ids actually round-tripped through :meth:`_stable_tool_executor`
# -> ``ctx.dispatch_tool`` this turn. ``dispatch_tool`` emits the paired
# function_call_output itself, so a ToolCallComplete carrying one of
# these ids must be SUPPRESSED in :meth:`_translate_event` to avoid a
# duplicate output card. Completions whose id is NOT here come from
# tools the inner SDK ran entirely internally (e.g. the antigravity
# executor, which has no ``_stable_tool_executor`` dispatch) — those are
# the ONLY output source and MUST be emitted. Reset per turn alongside
# ``_pending_mcp_call_ids``.
self._dispatched_call_ids: set[str] = set()
async def run_turn(self, request: CreateResponseRequest, ctx: TurnContext) -> None:
"""
Drive the inner executor and translate its events.
Lazily constructs the inner executor on the first call.
Subsequent calls reuse the cached instance. Wires a
per-turn ``_tool_executor`` callback so the inner SDK
can round-trip spec-declared tools through Omnigent via the
scaffold's ``dispatch_tool`` (action_required) path.
The hook is reset to ``None`` after the turn so cross-
turn references can't accidentally fire stale dispatch
contexts.
:param request: Decoded :class:`CreateResponseRequest`
synthesized by the harness scaffold for this turn.
:param ctx: Per-turn :class:`TurnContext` from the
scaffold.
"""
executor = self._ensure_executor()
messages = _translate_input_to_messages(request.input)
# Stamp every message with the adapter's session_key so
# the inner :class:`ClaudeSDKExecutor` keys its cached
# SDK client under THAT key. Without this stamp, the
# executor's :meth:`_session_key` falls back to
# ``"default"`` (no ``session_id`` on any message), and
# subsequent :meth:`Executor.enqueue_session_message`
# calls — which use ``self._session_key`` from this
# adapter — return ``False`` because the cached client
# is under a DIFFERENT key. Symptom (the user reported
# this): mid-turn steering arrives in the harness, the
# adapter forwards to enqueue_session_message, but the
# SDK never sees the new prompt and Claude keeps going.
for message in messages:
message["session_id"] = self._session_key
extra: dict[str, Any] = {}
if request.reasoning is not None:
effort = request.reasoning.get("effort")
if effort:
extra["reasoning_effort"] = effort
if request.max_output_tokens is not None:
extra["max_tokens"] = int(request.max_output_tokens)
# request.model is the agent name; request.model_override carries
# the per-request /model override. Threaded into cfg.model so the
# inner executor's per-turn precedence picks it up over the spec
# default (HARNESS_*_MODEL).
config = ExecutorConfig(model=request.model_override, extra=extra)
tools = _normalize_tool_schemas(request.tools or [])
system_prompt = request.instructions or ""
# Install the stable bridge ONCE on the executor. The SDK
# caches its client on first turn and the MCP handlers
# closure-capture ``self._tool_executor`` then. A fresh
# bridge per turn would leave those handlers pointing at
# a turn-1 closure forever — every later tool call would
# dispatch into a dead ctx and the SDK would hang.
# Rebind ``_current_ctx`` / ``_current_agent`` per turn
# instead; the stable bridge reads from those slots at
# call time. ``_tool_executor`` is harness-specific (not
# declared on the inner :class:`Executor` ABC), so the
# attribute set is best-effort — executors that don't
# read it ignore the value, executors that DO read it
# (Claude SDK) honor the round-trip protocol.
if getattr(executor, "_tool_executor", None) is None: # type: ignore[attr-defined]
executor._tool_executor = self._stable_tool_executor # type: ignore[attr-defined]
# Install the elicitation handler once on first use, same
# stable-reference pattern as ``_tool_executor``. The SDK's
# ``can_use_tool`` callback is constructed per-turn inside
# ClaudeSDKExecutor.run_turn() from this attribute, so the
# closure always reads ``_current_ctx`` at call time.
if getattr(executor, "_elicitation_handler", None) is None: # type: ignore[attr-defined]
executor._elicitation_handler = self._stable_elicitation_handler # type: ignore[attr-defined]
# Install the policy evaluator bridge once on first use, same
# stable-reference pattern as ``_tool_executor``. The inner
# executor calls this before/after each LLM call to evaluate
# LLM_REQUEST / LLM_RESPONSE policies via a round-trip to the
# Omnigent server (routed through the runner).
if getattr(executor, "_policy_evaluator", None) is None: # type: ignore[attr-defined]
executor._policy_evaluator = self._stable_policy_evaluator # type: ignore[attr-defined]
self._current_ctx = ctx
self._current_agent = request.model
# Reset the MCP call-id queue at turn start. A prior turn
# that errored mid-stream (e.g. cancelled while a tool_use
# block had been parsed but its MCP-handler hadn't fired
# yet) could leave entries behind; carrying them into the
# next turn would mis-correlate the new turn's first
# MCP dispatches with stale observed events from the
# previous turn. Clearing makes each turn's correlation
# window self-contained.
self._pending_mcp_call_ids.clear()
self._dispatched_call_ids.clear()
# --- Tracing setup ------------------------------------------------
# Create a TracingContext per turn when tracing is enabled.
# The trace_context_for_response wrapper derives the W3C
# trace ID from the response_id so operators can look up
# traces by response ID without a mapping table.
tracing = is_tracing_enabled()
from omnigent.runtime.telemetry import current_session_id, session_scope
# Prefer the conversation id the request hook already bound
# (authoritative, from the /sessions/<conv>/events path); fall back to
# the adapter session key, which can be a random uuid for harnesses
# built without one.
turn_session_id = current_session_id() or self._session_key
if tracing and self._tracing_ctx is None:
self._tracing_ctx = TracingContext(session_id=turn_session_id)
tctx = self._tracing_ctx if tracing else None
agent_span = None
# Active tool span for correlating ToolCallRequest → ToolCallComplete.
_active_tool_span = None
_active_tool_parent = None
user_message = _extract_last_user_message(request.input)
# --- End tracing setup --------------------------------------------
# Watcher for mid-turn steering injections. The scaffold
# routes incoming steering events with
# ``previous_response_id == ctx.response_id`` onto
# ``ctx.next_injection`` (see _push_injection in the
# scaffold). The watcher converts each injection into an
# :meth:`Executor.enqueue_session_message` call so the
# inner SDK delivers the new user message into its
# in-flight session — without this hook, AP-forwarded
# steering would queue forever and the LLM would never
# see it.
injection_watcher = asyncio.create_task(
self._watch_injections(ctx, executor),
name=f"executor-adapter-injection-watch:{ctx.response_id}",
)
try:
# Wrap the executor loop in the trace context so all
# MLflow spans share the response-derived trace ID.
# The context manager is built outside the `with` so we
# can fall back to nullcontext if the response_id format
# doesn't match (e.g. 24-char hex vs expected 32).
trace_cm: contextlib.AbstractContextManager[None] = contextlib.nullcontext()
if tctx:
try:
from omnigent.runtime.telemetry import trace_context_for_response
trace_cm = trace_context_for_response(response_id=ctx.response_id)
except Exception:
_logger.debug("trace_context_for_response unavailable", exc_info=True)
# Bind the session for the whole turn so every span the harness
# creates (agent/LLM/tool, the native tmux inject, DB/httpx child
# spans) is tagged with session.id by the span processor — no
# per-harness code. (session_scope imported above with current_session_id.)
with session_scope(turn_session_id), trace_cm:
if tctx is not None:
agent_span = tctx.start_agent_span(
agent_name=request.model or "unknown",
user_message=user_message,
model=request.model_override or request.model,
)
response_text: str | None = None
async for event in executor.run_turn(
messages=messages,
tools=tools,
system_prompt=system_prompt,
config=config,
):
if ctx.cancelled.is_set():
if tctx is not None and agent_span is not None:
from omnigent.runtime.telemetry import record_cancellation
record_cancellation(agent_span)
tctx.end_agent_span(agent_span, response=None)
agent_span = None
await executor.interrupt_session(self._session_key)
return
# --- Tracing: emit spans per event ---
if tctx is not None:
if isinstance(event, ToolCallRequest):
_active_tool_parent = tctx._current_span
_active_tool_span = tctx.start_tool_span(
_strip_mcp_tool_prefix(event.name),
event.args or {},
)
elif isinstance(event, ToolCallComplete):
if _active_tool_span is not None:
tctx.end_tool_span(
_active_tool_span,
result=event.result,
error=event.error,
duration_ms=event.duration_ms,
parent_span=_active_tool_parent,
)
_active_tool_span = None
_active_tool_parent = None
elif isinstance(event, TurnComplete):
response_text = event.response
if event.usage is not None:
from omnigent.runtime.telemetry import record_llm_usage
# Record usage on the agent span for
# aggregate visibility.
record_llm_usage(agent_span, event.usage)
# --- End tracing ---
self._translate_event(event, ctx)
if isinstance(event, TurnComplete):
if tctx is not None and agent_span is not None:
tctx.end_agent_span(agent_span, response=response_text)
agent_span = None
return
if isinstance(event, TurnCancelled):
ctx.cancelled.set()
if tctx is not None and agent_span is not None:
from omnigent.runtime.telemetry import record_cancellation
record_cancellation(agent_span)
tctx.end_agent_span(agent_span, response=None)
return
if isinstance(event, ExecutorError):
if tctx is not None and agent_span is not None:
tctx.end_agent_span(
agent_span,
response=None,
error=event.message,
)
agent_span = None
raise RuntimeError(f"inner executor error: {event.message}")
except ElicitationDeclinedError:
# Fallback for executors that propagate the exception directly
# (non-SDK / non-spawned-task paths). SDK-based executors use
# ctx.cancelled.set() from _stable_elicitation_handler instead,
# because the SDK wraps its control-request callbacks in their
# own tasks and would swallow a raised exception before it
# reached this handler. Either way the turn ends as cancelled.
_logger.info(
"elicitation explicitly declined for response %s — aborting turn",
ctx.response_id,
)
if tctx is not None and agent_span is not None:
from omnigent.runtime.telemetry import record_cancellation
record_cancellation(agent_span)
tctx.end_agent_span(agent_span, response=None)
ctx.cancelled.set()
# Interrupt the inner executor session so the in-flight
# generation stops immediately, same as the normal
# cancellation path.
if self._executor is not None:
await self._executor.interrupt_session(self._session_key)
except BaseException:
# End agent span on unhandled exceptions so it's not
# left open (which would leak on the OTel provider).
if tctx is not None and agent_span is not None:
tctx.end_agent_span(agent_span, response=None, error="unhandled exception")
agent_span = None
raise
finally:
# Stop the injection watcher and let it drain so a
# late ``next_injection`` doesn't fire after we've
# moved on. ``injection_watcher`` is a long-poll
# against an ``asyncio.Queue.get`` — cancelling is
# the only way to break it.
injection_watcher.cancel()
with contextlib.suppress(asyncio.CancelledError):
await injection_watcher
# Flush the OTel provider and finalize the trace status
# on the MLflow server. Without the flush, the
# BatchSpanProcessor may not have exported the final
# spans. Without the PATCH, the OTLP-ingested trace
# stays "In progress" because the server has no
# signal that all spans have arrived.
if tctx is not None:
try:
from opentelemetry import trace as otel_trace
provider = otel_trace.get_tracer_provider()
if hasattr(provider, "force_flush"):
provider.force_flush(timeout_millis=5000)
except Exception:
pass
# Clear the per-turn pointers so a stray late callback
# (e.g. one fired after the SDK's stream closed) sees
# ``None`` and returns an explicit error rather than
# silently dispatching into the just-finished ctx.
self._current_ctx = None
self._current_agent = None
async def _handle_interrupt_event(self) -> Response:
"""Cancel the turn AND drop the inner executor session.
The base handler sets ``ctx.cancelled`` (terminal event becomes
``response.cancelled``) and clears the inject target. But the live
client — dropping which is what actually stops the in-flight
generation and forces the next turn to rebuild fresh — was only
dropped by the run loop's between-events ``interrupt_session`` call,
which is skipped when the turn is blocked awaiting the first token or
torn down via HTTP disconnect. The next turn then reuses the client
and flushes the abandoned generation: the post-cancel stream dump +
the off-by-one. Drop it here, synchronously on the interrupt.
:returns: The base handler's 204 response.
:raises OmnigentError: 404 (from the base handler) when no turn is
in flight.
"""
response = await super()._handle_interrupt_event()
# ``self._executor`` is set once a turn has run; an interrupt only
# reaches here when one is in flight, so it is non-None in practice.
# interrupt_session is best-effort and idempotent (a no-op if the run
# loop already dropped the session), so call it bare like that path.
if self._executor is not None:
await self._executor.interrupt_session(self._session_key)
return response
async def _watch_injections(self, ctx: TurnContext, executor: Executor) -> None:
"""
Loop forwarding ``ctx.next_injection`` to the inner SDK.
Polls :meth:`TurnContext.next_injection` (a queue
backed by the scaffold's ``_push_injection``) and
translates each :class:`CreateResponseRequest` into an
:meth:`Executor.enqueue_session_message` call so the
inner SDK delivers the new user message into its
in-flight session. Loops until cancelled by the
:meth:`run_turn` finally block.
Best-effort throughout — a failed injection logs and
continues; a malformed request body (missing user
text) is skipped; the watcher never raises out of the
loop body. Errors that escape would teardown the watch
task and surface as a confusing test/test-env failure
in the parent run_turn.
:param ctx: The active turn's context.
:param executor: The inner executor to inject into.
Must implement ``enqueue_session_message`` for the
forwarding to actually deliver — executors that
don't (legacy harnesses) silently no-op.
"""
while True:
try:
injection = await ctx.next_injection(timeout=None)
except asyncio.CancelledError:
return
if injection is None:
# ``next_injection(None)`` blocks forever on the
# queue, so a None return here means the queue
# protocol changed or the scaffold pushed a
# sentinel. Defensive — bail rather than spin.
return
text = _extract_user_text(injection.input)
if not text:
# Empty / malformed input — skip rather than
# call the SDK with garbage.
_logger.warning(
"skipping in-band injection with no text payload: %r",
injection.input,
)
continue
if ctx.cancelled.is_set():
# Turn interrupted — don't deliver into the dying session.
return
try:
accepted = await executor.enqueue_session_message(self._session_key, text)
except Exception:
_logger.exception(
"inner executor.enqueue_session_message failed; in-band injection lost"
)
continue
if not accepted:
_logger.warning(
"inner executor refused in-band injection "
"(supports_live_message_queue=False?); LLM will "
"not see the steered message until the next turn"
)
continue
# The executor consumed this injection into the running turn.
# Echo the runner's correlation id back as an
# ``injection.consumed`` marker so the runner drops the
# buffered copy and does not re-deliver it as a continuation
# turn (RUNNER_MESSAGE_INGEST.md Part B). Only meaningful when
# the runner stamped an injection_id; fresh-turn injections and
# legacy callers leave it unset.
injection_id = getattr(injection, "injection_id", None)
if injection_id:
ctx.emit(
InjectionConsumedEvent(
type="injection.consumed",
injection_id=injection_id,
)
)
async def _stable_tool_executor(
self,
tool_name: str,
args: dict[str, Any],
) -> dict[str, Any]:
"""
Cached bridge the inner SDK keeps over the lifetime of
the executor instance.
Reads :attr:`_current_ctx` and :attr:`_current_agent` at
call time so the dispatch always lands in the active
turn's :class:`TurnContext`. If neither is set (a
stale callback fired after the turn ended, or the SDK
called the executor without a wrapping ``run_turn``),
returns a clear error payload — letting the call park
on a dead ctx would just hang the SDK indefinitely.
For MCP-prefixed tools, drains the next ``tool_use_id``
from :attr:`_pending_mcp_call_ids` and passes it to
:func:`_bridge_one_dispatch` so the dispatch's
action_required event shares a ``call_id`` with the
inline observed event already emitted by
:meth:`_translate_event`. The deque is FIFO and the
inner SDK invokes MCP-server handlers in the same
order it parsed the corresponding tool_use blocks, so
positional pop is correct.
:param tool_name: Tool name from the LLM's call. Carries
the MCP prefix for SDK-registered tools (e.g.
``"mcp__omnigent__sys_terminal_launch"``).
:param args: Decoded argument dict.
:returns: A dict suitable as the MCP tool result.
"""
ctx = self._current_ctx
agent = self._current_agent
if ctx is None or agent is None:
_logger.warning(
"tool callback fired with no active turn context (tool=%s); returning error",
tool_name,
)
return {"error": "no active turn context for tool dispatch"}
# Pop the matching ``tool_use_id`` for MCP tools so the
# dispatch reuses the observed event's call_id. Non-MCP
# tools and out-of-order edge cases (queue empty when an
# MCP tool fires) fall through to a freshly-allocated id
# in :func:`_bridge_one_dispatch` — that loses the dedup
# but doesn't break the dispatch protocol.
# ``_stable_tool_executor`` IS the SDK's MCP-server tool
# callback — only invoked for MCP-routed tools. The
# callback receives the BARE tool name (the MCP wrapper
# strips the ``mcp__omnigent__`` prefix before
# dispatching), so a ``startswith("mcp__")`` guard here
# would always be False and the queue would never pop.
# Pop whenever there's a queued id; non-MCP paths don't
# populate the queue, so this can't accidentally drain
# an unrelated id.
correlated_call_id: str | None = None
if self._pending_mcp_call_ids:
correlated_call_id = self._pending_mcp_call_ids.popleft()
# Allocate the dispatch id here (rather than letting
# _bridge_one_dispatch default it) so we can record EXACTLY which id was
# round-tripped. _translate_event suppresses the inner
# ToolCallComplete for these ids — dispatch_tool already emits their
# function_call_output — while letting internally-run SDK tool
# completions (not in this set) through as the sole output source.
dispatch_call_id = correlated_call_id or f"call_{uuid.uuid4().hex[:12]}"
self._dispatched_call_ids.add(dispatch_call_id)
return await _bridge_one_dispatch(
ctx,
agent,
tool_name,
args,
call_id=dispatch_call_id,
)
async def _stable_elicitation_handler(
self,
tool_name: str,
tool_input: dict[str, Any],
) -> bool:
"""
Cached bridge the inner SDK keeps over the executor's lifetime.
Called by :class:`omnigent.inner.claude_sdk_executor.ClaudeSDKExecutor`
via ``options.can_use_tool`` when the Claude CLI requests
permission before executing a tool and ``permission_mode`` is not
``"bypassPermissions"``. Reads :attr:`_current_ctx` at call time
so the elicitation always lands in the active turn's context —
same rebind-per-turn pattern as :meth:`_stable_tool_executor`.
If no turn context is active (callback fired after the turn ended
or before a turn starts), returns ``False`` to deny by default
rather than granting unreviewed permission.
:param tool_name: Tool name the agent wants to call, e.g. ``"Bash"``.
:param tool_input: Arguments dict for the tool call,
e.g. ``{"command": "ls -la"}``.
:returns: ``True`` when the user approves the tool call;
``False`` when they deny it.
"""
ctx = self._current_ctx
if ctx is None:
# Elicitation callback fired with no active turn — this is a
# code-level invariant violation (run_turn clears _current_ctx
# in its finally block; a callback should never arrive after
# that). Deny by default: fail safe rather than grant permission
# that was never reviewed.
_logger.error(
"elicitation callback fired with no active turn context "
"(tool=%s); denying by default",
tool_name,
)
return False
elicitation_id = f"elicit_{secrets.token_hex(16)}"
# Build a concise preview: truncate long args so the UI widget
# stays readable. 300 chars matches AP's policy-engine preview.
try:
preview = json.dumps(tool_input, ensure_ascii=False)
except (TypeError, ValueError):
preview = repr(tool_input)
preview = preview[:300]
label = self._harness_label
policy_name = f"{label.lower()}_sdk_permission"
params = ElicitationRequestParams(
mode="form",
message=f"{label} wants to use **{tool_name}**",
requestedSchema=None,
url=None,
phase="tool_call",
policy_name=policy_name,
content_preview=f"{tool_name}({preview})",
)
result = await ctx.elicit(elicitation_id, params)
if result.action == "decline":
# The SDK invokes this callback from a spawned control-request
# task whose try/except would swallow a raised exception before
# it could reach run_turn's except block. Signal the cancellation
# via ctx.cancelled instead — the run_turn event loop checks this
# flag between events and takes the existing interrupt path.
ctx.cancelled.set()
return result.action == "accept"
async def _stable_policy_evaluator(
self,
phase: str,
data: dict[str, Any],
) -> PolicyVerdictPayload:
"""
Cached bridge the inner executor keeps over its lifetime.
Called by the inner executor before (``PHASE_LLM_REQUEST``)
and after (``PHASE_LLM_RESPONSE``) each LLM call. Routes
the evaluation through the scaffold's
:meth:`TurnContext.evaluate_policy` round-trip, which emits
a ``policy_evaluation.requested`` SSE event and parks on a
Future until the runner delivers the verdict.
The round-trip has a timeout (see ``_POLICY_EVAL_TIMEOUT_S``
in ``_scaffold.py``) so a stalled verdict defaults to ALLOW
instead of hanging the executor.
:param phase: Proto-style phase string, e.g.
``"PHASE_LLM_REQUEST"`` or ``"PHASE_LLM_RESPONSE"``.
:param data: Event data dict for the policy engine.
:returns: The policy verdict from the Omnigent server.
"""
ctx = self._current_ctx
if ctx is None:
# Orphaned callback after a turn-context desync (#1026). Blanket
# ALLOW here silently bypasses guardrails: for a PHASE_TOOL_CALL this
# adapter is the only enforcement point (the call is never re-checked
# server-side), so an unevaluable verdict must fail closed. Mirror
# the runner's phase-aware default in _evaluate_policy_via_omnigent —
# tool calls DENY; advisory LLM phases and the post-execution result
# phase ALLOW so a transient desync never needlessly wedges them.
fail_closed = phase in FAIL_CLOSED_PHASES
action = "POLICY_ACTION_DENY" if fail_closed else "POLICY_ACTION_ALLOW"
_logger.warning(
"policy evaluator fired with no active turn context (phase=%s); "
"returning %s by default",
phase,
"DENY" if fail_closed else "ALLOW",
)
return PolicyVerdictPayload(
action=action,
reason=(
f"No active turn context; failing closed for {phase}." if fail_closed else None
),
)
evaluation_id = f"poleval_{secrets.token_hex(16)}"
return await ctx.evaluate_policy(evaluation_id, phase, data)
def _ensure_executor(self) -> Executor:
"""
Construct the inner executor on first use; return cached
instance thereafter.
:returns: The per-conversation inner :class:`Executor`.
"""
if self._executor is None:
self._executor = self._executor_factory()
return self._executor
def _translate_event(self, event: ExecutorEvent, ctx: TurnContext) -> None:
"""
Translate one inner :class:`ExecutorEvent` into Omnigent SSE
events emitted via ``ctx.emit``.
Per the v1 limitations in the module docstring, all
:class:`ToolCallRequest` / :class:`ToolCallComplete`
events are treated as observed-only (the SDK already
executed the tool natively) — they surface as paired
function_call + function_call_output items with
``status: "completed"`` per §Sub-agent representation.
:param event: The inner executor event to translate.
:param ctx: The per-turn context; events are pushed onto
its queue.
"""
if isinstance(event, TextChunk):
ctx.emit(
OutputTextDeltaEvent(
type="response.output_text.delta",
delta=event.text,
)
)
elif isinstance(event, ReasoningChunk):
# Translate inner reasoning to the Omnigent wire shape so the
# workflow sees the same SSE events whether the executor
# runs inline or behind the harness scaffold.
if event.event_type == "reasoning_started":
ctx.emit(
ReasoningStartedEvent(type="response.reasoning.started"),
)
elif event.event_type == "reasoning_summary":
ctx.emit(
ReasoningSummaryTextDeltaEvent(
type="response.reasoning_summary_text.delta",
delta=event.delta,
),
)
else:
# ``reasoning_text`` and any future flavor land here.
ctx.emit(
ReasoningTextDeltaEvent(
type="response.reasoning_text.delta",
delta=event.delta,
),
)
elif isinstance(event, ToolCallRequest):
# Observed function_call item, emitted INLINE as the
# inner SDK parses each tool_use block — that's what
# gives the REPL a ``⏵ tool_name`` line interleaved
# with assistant text rather than bunched at the end
# of the response.
#
# For MCP-prefixed tool names we ALSO queue the
# ``tool_use_id`` so the matching :func:`_bridge_one_dispatch`
# call (fired post-stream when the SDK invokes the
# MCP-server handler) uses the same call_id. With the
# ids correlated, the SDK client's ``BlockStream``
# dedupes the post-stream action_required event
# against this observed event — keeping the inline
# render and avoiding the 2026-04-28 duplicate-render
# bug. Without the queue, the two events would have
# different uuids and the REPL would render
# ``⏵ tool_name`` twice (which is what 989bfde
# mitigated by suppressing this branch entirely —
# but at the cost of inline rendering, the regression
# this fix reverses).
#
# The emitted ``name`` is the bare tool name. The
# inner SDK passes the MCP-prefixed form
# (``mcp__omnigent__sys_terminal_launch``) but the
# Omnigent wire shape and persisted conversation items
# carry the bare form — kept consistent with
# ``omnigent/runtime/workflow.py``'s
# ``_observed_tool_call_sse_dicts`` /
# ``_build_observed_tool_items`` pair so SSE name and
# store-item name don't drift.
tool_use_id = _call_id_from_metadata(event.metadata)
# Push the tool_use_id onto the correlation queue
# whenever the executor stamped one. The original
# gate restricted this to MCP-prefixed names (the
# claude-sdk path), but every wrapped harness whose
# tools round-trip through :meth:`_stable_tool_executor`
# needs the same correlation — openai-agents
# included. Without this, the dispatch path's
# action_required emit gets a fresh uuid, the AP
# client can't dedupe against the inline observed
# event, and the REPL renders the call twice plus an
# empty result panel for the orphan call.
if tool_use_id is not None:
self._pending_mcp_call_ids.append(tool_use_id)
call_id = tool_use_id or f"call_{uuid.uuid4().hex[:12]}"
bare_name = _strip_mcp_tool_prefix(event.name)
ctx.emit(
OutputItemDoneEvent(
type="response.output_item.done",
item={
"id": f"fc_{uuid.uuid4().hex[:12]}",
"type": "function_call",
"status": _OBSERVED_TOOL_CALL_STATUS,
"name": bare_name,
"arguments": _serialize_args(event.args),
"call_id": call_id,
"agent": ctx.response_id,
},
)
)
elif isinstance(event, ToolCallComplete):
# Paired function_call_output. Downstream consumers (web
# blockStream, runner persistence) pair results to requests
# STRICTLY by call_id and discard empty ones — there is NO
# positional correlation, so a ToolCallComplete that reaches
# here without a real call_id orphans and leaves its request a
# perpetual in_progress card. Inner executors must stamp a real
# id (antigravity allocates one positionally for the SDK's
# id-less error hook); an id-less completion is DROPPED below
# (it cannot pair) rather than emitted as a ghost card.
#
# A tool routed through _stable_tool_executor →
# ctx.dispatch_tool already has its function_call_output
# emitted by dispatch_tool when the Future resolves.
# Emitting a second one here would duplicate it on the
# SSE stream and produce ghost "Waiting for output"
# cards in the Web UI. Suppress ONLY for those
# round-tripped call ids (tracked in
# ``_dispatched_call_ids``) — the scaffold is their single
# output source.
#
# Tools the inner SDK ran ENTIRELY INTERNALLY — the
# antigravity executor has no _stable_tool_executor
# dispatch at all, and its ToolCallComplete is bridged
# from the SDK's PostToolCall/OnToolError hooks — never go
# through dispatch_tool, so their completion here is the
# ONLY output event. Suppressing those (the prior
# ``_current_ctx is not None`` blanket rule) left every
# antigravity tool call rendering as perpetual in_progress
# with no paired function_call_output. Emit them.
call_id = _call_id_from_metadata(getattr(event, "metadata", None)) or ""
# Suppress two cases — each would otherwise put a useless or duplicate
# function_call_output on the SSE stream:
# 1. a DISPATCHED id — _stable_tool_executor already emitted the
# authoritative output via ctx.dispatch_tool (its single source);
# a second one here duplicates it and ghosts a "Waiting for
# output" card.
# 2. an EMPTY call_id — downstream consumers pair STRICTLY by call_id
# and discard empty ones, so an id-less completion cannot pair; it
# only renders a perpetual ghost card. This is a shared adapter
# used by every adapter-backed harness, so an inner executor that
# bridges an id-less ToolCallComplete mid-turn (the old blanket
# ``_current_ctx is not None`` rule swallowed these) must not leak
# an empty-id output. Internal-tool executors (antigravity) stamp a
# real positional id, so their legitimate completions still emit.
if not call_id or call_id in self._dispatched_call_ids:
return
item: dict[str, Any] = {
"id": f"fco_{uuid.uuid4().hex[:12]}",
"type": "function_call_output",
"call_id": call_id,
# Cap the mirror; the inner SDK already consumed the full result.
"output": cap_tool_output(_serialize_tool_result(event)),
}
raw_args = getattr(event, "metadata", {}).get("arguments")
if isinstance(raw_args, dict):
item["arguments"] = raw_args
ctx.emit(OutputItemDoneEvent(type="response.output_item.done", item=item))
elif isinstance(event, TurnComplete):
# If the executor produced a final assistant message
# via TurnComplete.response (rather than through
# streaming TextChunks), emit it now. Streamed
# TextChunks have already gone out via the branch
# above; in that case TurnComplete.response is
# typically None or a duplicate that the inner
# executor sets for non-streaming consumers.
if event.response is not None:
# Avoid double-emitting if the response text
# matches the streamed deltas — but that
# accumulator lives on Session today, not on the
# adapter. For v1 we trust streaming-mode
# executors to leave response=None, and only emit
# on non-streaming paths.
pass
# Capture provider-reported usage so _build_terminal_event
# can include it in the response.completed SSE payload.
# The harness HTTP client reads response["usage"] from that
# payload to populate TurnComplete.usage on the Omnigent side.
if event.usage is not None:
ctx.provider_usage = event.usage
elif isinstance(event, CompactionComplete):
from omnigent.server.schemas import (
CompactionCompletedEvent,
CompactionInProgressEvent,
)
ctx.emit(
CompactionInProgressEvent(
type="response.compaction.in_progress",
)
)
ctx.emit(
CompactionCompletedEvent(
type="response.compaction.completed",
total_tokens=event.token_count,
summary=event.summary,
summary_model=event.model,
compacted_messages=event.compacted_messages,
)
)
# ExecutorError handled by the caller (re-raises so the
# scaffold can build a response.failed terminal event).
def _build_error_detail(self, exception: BaseException) -> Any:
"""
Map an inner-executor exception onto a contract-recognized
error code.
Default :meth:`HarnessApp._build_error_detail` uses
``type(exception).__name__`` as the code, which never
matches AP's retryable allowlist
(:data:`omnigent.runtime.harnesses._client_executor._RETRYABLE_HARNESS_ERROR_CODES`).
Result before this override: a known transient failure
like ``anthropic.RateLimitError`` would surface as
``code="RateLimitError"``, AP's allowlist wouldn't match,
retry-classification would call it permanent, and the
workflow would never retry. This method closes that gap
for the harnesses the adapter wraps (claude-sdk, plus the
codex / openai-agents-sdk / pi wraps once they land).
Translation precedence:
1. :class:`omnigent.errors.OmnigentError` (incl.
:class:`RetryableLLMError` / :class:`PermanentLLMError`)
— already carries a semantic ``code`` string per the
project's own classification, so use it verbatim.
2. OpenAI SDK exceptions — surfaced by the inner OpenAI
Agents SDK / Open Responses SDK executors used by the
codex / openai-agents wraps. Maps onto the AP
allowlist's semantic codes
(``"rate_limit_exceeded"``, ``"server_error"``,
``"timeout"``, ``"connection_error"``).
3. ``claude_agent_sdk`` exceptions — the Claude Code CLI
wrapper used by the claude-sdk wrap. Currently only
``CLIConnectionError`` maps to a retryable code; the
other CLI errors (NotFound, JSONDecode, ProcessError)
are non-retryable and fall through to the base
implementation so operators see the class name.
4. ``httpx`` exceptions for SDKs that surface raw
``httpx.TimeoutException`` / ``httpx.ConnectError``
rather than wrapping them.
5. Fallback: base class implementation
(``type(exception).__name__``).
:param exception: The exception :meth:`run_turn` raised.
:returns: An :class:`ErrorDetail` whose ``code`` matches
the Omnigent allowlist for known retryable failures, and is
still informative (provider exception class) for the
rest.
"""
from omnigent.errors import OmnigentError
from omnigent.server.schemas import ErrorDetail
if isinstance(exception, OmnigentError):
# Project-internal structured errors already carry a
# semantic code (e.g. ``RetryableLLMError(code="timeout")``).
return ErrorDetail(code=exception.code, message=str(exception))
code = classify_inner_exception(exception)
if code is not None:
return ErrorDetail(code=code, message=str(exception))
# Unknown exception type — preserve the class name so
# operators can still grep for it in logs, even though
# AP's retry allowlist won't match.
return super()._build_error_detail(exception)
async def on_shutdown(self) -> None:
"""
Release the inner executor's resources on subprocess
shutdown.
Overrides :meth:`HarnessApp.on_shutdown`; called from
the scaffold's lifespan teardown path. Closes the
executor's session and the executor itself so child
processes (e.g. ``claude --output-format``) are reaped
rather than orphaned.
"""
if self._executor is not None:
await self._executor.close_session(self._session_key)
await self._executor.close()
self._executor = None
def _classify_openai_exception(exception: BaseException) -> str | None:
"""
Map an OpenAI SDK exception onto the Omnigent semantic code allowlist.
The :mod:`openai` package is the runtime SDK for the
openai-agents-sdk + codex wraps (and the open-responses
inner executor). Its exception hierarchy mirrors HTTP
semantics. We only translate the variants that match AP's
retryable allowlist verbatim — adding more codes here without
extending the allowlist would just produce strings Omnigent ignores.
Lazy-imports :mod:`openai` because the package isn't a hard
dependency of every wrap (the claude-sdk wrap goes through the
Claude Code CLI, not the OpenAI API). ``ImportError`` returns
``None`` so the caller falls through to other classifiers —
never crashes the error path on a missing optional dep.
:param exception: The exception :meth:`ExecutorAdapter.run_turn`
caught.
:returns: A semantic code from the Omnigent allowlist (e.g.
``"rate_limit_exceeded"``), or ``None`` when *exception*
is not an OpenAI SDK exception we recognize.
"""
try:
import openai
except ImportError:
return None
if isinstance(exception, openai.RateLimitError):
return "rate_limit_exceeded"
if isinstance(exception, openai.APITimeoutError):
return "timeout"
if isinstance(exception, openai.APIConnectionError):
return "connection_error"
if isinstance(exception, openai.InternalServerError):
# 500-class server-side failures from the gateway. Worth
# retrying — usually transient capacity issues. Matches
# AP's existing ``"server_error"`` allowlist entry.
return "server_error"
# Context-window overflow may arrive as a direct BadRequestError
# or wrapped inside an openai-agents SDK exception. The generic
# classifier walks the cause chain for all providers.
from omnigent.llms.errors import is_context_length_exceeded
if is_context_length_exceeded(exception):
return "context_length_exceeded"
return None
def _classify_claude_sdk_exception(exception: BaseException) -> str | None:
"""
Map a :mod:`claude_agent_sdk` exception onto the Omnigent semantic
code allowlist.
The Claude Code CLI wrapper surfaces a small exception set
(``CLIConnectionError``, ``CLIJSONDecodeError``,
``CLINotFoundError``, ``ProcessError``,
``ClaudeSDKError``). Only ``CLIConnectionError`` matches a
retryable allowlist entry — ``CLINotFoundError`` (binary
missing) and ``CLIJSONDecodeError`` (corrupt CLI output)
are non-retryable so they fall through to the base
implementation where operators see the class name.
:param exception: The exception :meth:`ExecutorAdapter.run_turn`
caught.
:returns: A semantic code from the Omnigent allowlist, or ``None``
when *exception* isn't a recognized retryable
:mod:`claude_agent_sdk` exception.
"""
try:
import claude_agent_sdk
except ImportError:
return None
# ``CLINotFoundError`` is a subclass of ``CLIConnectionError``
# in the SDK's hierarchy — match it FIRST and return ``None``
# so the missing-binary case falls through to the base
# implementation (non-retryable: a missing CLI won't appear
# spontaneously). Without this guard, ``isinstance(not_found,
# CLIConnectionError)`` matches and we'd loop on a permanent
# configuration failure.
if isinstance(exception, claude_agent_sdk.CLINotFoundError):
return None
if isinstance(exception, claude_agent_sdk.CLIConnectionError):
return "connection_error"
return None
def _classify_httpx_exception(exception: BaseException) -> str | None:
"""
Map an :mod:`httpx` exception onto the Omnigent semantic code allowlist.
Some inner executors (notably ``litellm``-backed paths for
non-Anthropic providers) surface raw httpx exceptions instead
of wrapping them in a provider-specific class. Translating
these here keeps retry classification accurate regardless of
which transport layer raised.
Lazy-imports :mod:`httpx` for symmetry with the Anthropic
classifier — though httpx is currently a hard project
dependency, the lazy form keeps this module's import graph
minimal for harnesses that never raise httpx errors.
:param exception: The exception :meth:`ExecutorAdapter.run_turn`
caught.
:returns: A semantic code from the Omnigent allowlist, or ``None``
when *exception* is not an httpx exception we recognize.
"""
try:
import httpx
except ImportError:
return None
if isinstance(exception, httpx.TimeoutException):
return "timeout"
if isinstance(exception, httpx.ConnectError):
return "connection_error"
# ``httpx.ReadError`` / ``WriteError`` / ``CloseError`` are
# transport-level network failures (peer closed the
# connection mid-stream, broken pipe, etc.). They're
# siblings of ``ConnectError`` under ``NetworkError`` and
# behave the same way for retry purposes — the connection
# is gone, a fresh attempt may succeed. Without this branch,
# users see the bare class name (``[llm] ReadError``) with
# no semantic code, and the Omnigent retry classifier treats them
# as permanent.
if isinstance(exception, httpx.NetworkError):
return "connection_error"
# ``httpx.RemoteProtocolError`` ("peer closed connection without
# sending complete message body" — the canonical symptom of a
# subprocess being SIGKILL'd mid-stream). Sits under
# ``ProtocolError`` → ``TransportError``, NOT under
# ``NetworkError``, so the prior branch doesn't catch it.
# Without this branch, a SIGKILL during streaming surfaces as
# an unrecognized exception and the retry classifier treats it
# as permanent — the L2 retry layer never fires for the exact
# case it was designed to recover from.
if isinstance(exception, httpx.RemoteProtocolError):
return "connection_error"
return None
def _classify_anthropic_exception(exception: BaseException) -> str | None:
"""
Map an :mod:`anthropic` SDK exception onto the Omnigent semantic
code allowlist.
The Anthropic Python SDK is the underlying transport for the
Claude CLI subprocess used by the claude-sdk wrap; raw SDK
exceptions sometimes surface upward when the CLI fails to
intercept them (e.g. mid-stream gateway errors that bypass
the CLI's framing layer). Without a dedicated classifier
these would fall through to ``[llm] RateLimitError`` /
``[llm] APIConnectionError`` etc., which the Omnigent retry
allowlist doesn't match — silent demotion of retryable
failures to permanent.
Lazy-imports :mod:`anthropic` because the package isn't a
hard dependency of every wrap (only the claude-sdk path
pulls it in transitively via the CLI). ``ImportError``
returns ``None`` so the caller falls through to other
classifiers — matches the established pattern of the
sibling classifiers in this module.
Phase 3 of ``designs/RETRY_ACROSS_HARNESSES.md`` —
consolidates the per-SDK fan-out at
:func:`classify_inner_exception`.
:param exception: The exception :meth:`ExecutorAdapter.run_turn`
caught.
:returns: A semantic code from the Omnigent allowlist, or ``None``
when *exception* is not an Anthropic SDK exception we
recognize.
"""
try:
import anthropic
except ImportError:
return None
if isinstance(exception, anthropic.RateLimitError):
return "rate_limit_exceeded"
if isinstance(exception, anthropic.APITimeoutError):
return "timeout"
if isinstance(exception, anthropic.APIConnectionError):
return "connection_error"
if isinstance(exception, anthropic.InternalServerError):
# 500-class server-side failures from Anthropic — same
# semantic as ``_classify_openai_exception``: transient
# capacity issues that retry typically resolves.
return "server_error"
return None
def classify_inner_exception(exception: BaseException) -> str | None:
"""
Map any inner-SDK exception onto the Omnigent semantic code allowlist.
Single entry point that fans out across the per-SDK
classifiers. First match wins. Returns ``None`` when no
classifier recognizes the exception — caller is expected
to fall back to ``type(exception).__name__`` so operators
can still grep.
Phase 3 of ``designs/RETRY_ACROSS_HARNESSES.md``: this
function replaces the per-call fan-out at
:meth:`ExecutorAdapter._build_error_detail`'s old code,
which inlined three separate classifier calls. New
classifiers (e.g. for a Pi-specific exception type)
plug in here once and benefit every consumer.
Order matters when SDK exception hierarchies overlap. Today
none do — each lazy import only matches its own SDK's
classes — but if Anthropic ever subclasses an httpx
exception (or vice versa), a more-specific classifier
must come first. Documented as a sequence rather than a
dict so the order is explicit.
:param exception: The exception
:meth:`ExecutorAdapter.run_turn` caught.
:returns: A semantic code from the Omnigent allowlist (e.g.
``"rate_limit_exceeded"``), or ``None`` when no
classifier matched.
"""
for classifier in (
_classify_openai_exception,
_classify_anthropic_exception,
_classify_claude_sdk_exception,
_classify_httpx_exception,
):
code = classifier(exception)
if code is not None:
return code
# Generic fallback: context-window overflow from any SDK that
# stamps a recognized code on the exception or its cause chain
# but wasn't caught by the per-SDK classifiers above (e.g.
# because the SDK isn't installed so its classifier short-
# circuited on ImportError).
from omnigent.llms.errors import is_context_length_exceeded
if is_context_length_exceeded(exception):
return "context_length_exceeded"
return None
def _normalize_tool_schemas(
schemas: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
Flatten OpenAI Chat-Completions tool schemas to inner shape.
Omnigent emits :meth:`omnigent.tools.base.Tool.get_schema` in
OpenAI Chat Completions shape: ``{"type": "function",
"function": {"name", "description", "parameters"}}``. The
inner :class:`Executor` ABC and its
:func:`omnigent.inner.claude_sdk_executor._build_mcp_tools`
consumer read ``schema.get("name")`` / ``schema.get(...)``
directly — they expect a flat shape. Without this
translation the inner SDK registers MCP tools with empty
names, which the LLM cannot call.
Each schema is converted to:
``{"name": ..., "description": ..., "parameters": ...}``.
Schemas already in flat shape (``"name"`` at top level) pass
through unchanged so this function is idempotent.
:param schemas: AP-emitted tool schemas in either shape.
:returns: Schemas in flat shape ready for the inner
Executor.
"""
flat: list[dict[str, Any]] = []
for schema in schemas:
# Flat-form: validate ``name`` is non-empty even on the
# pass-through branch. The inner SDK's
# ``_build_mcp_tools`` reads ``schema.get("name")``
# directly; an entry like ``{"name": ""}`` would slip
# through and produce the "Tool name cannot be empty"
# warning + render a phantom MCP tool the LLM can't
# call. Drop those with the same warning the chat-
# completions branch uses.
if "name" in schema:
name = schema.get("name")
if not isinstance(name, str) or not name:
_logger.warning(
"skipping flat-form tool schema with empty name: %r",
schema,
)
continue
flat.append(schema)
continue
# Chat-completions form — pull from the ``function``
# sub-dict. Same skip-on-missing-name rule.
function = schema.get("function") or {}
name = function.get("name")
if not isinstance(name, str) or not name:
_logger.warning("skipping tool schema with no name: %r", schema)
continue
translated: dict[str, Any] = {"name": name}
description = function.get("description")
if isinstance(description, str):
translated["description"] = description
parameters = function.get("parameters")
if isinstance(parameters, dict):
translated["parameters"] = parameters
flat.append(translated)
return flat
async def _bridge_one_dispatch(
ctx: TurnContext,
agent: str,
tool_name: str,
args: dict[str, Any],
*,
call_id: str | None = None,
) -> dict[str, Any]:
"""
Round-trip one tool call through ``ctx.dispatch_tool``.
JSON-encodes *args*, awaits the result, and shapes the
return so the inner Claude SDK's MCP-tool wrapper hands a
sensible payload back to the LLM. Dispatch failures become
``{"error": <str>}`` so the SDK marks the MCP response as
an error rather than hanging.
:param ctx: The current turn's context.
:param agent: Agent name (required on the function_call item).
:param tool_name: Tool name from the LLM's call.
:param args: Decoded arguments dict from the LLM.
:param call_id: Optional explicit ``call_id`` to use on the
emitted action_required event. When provided (typically
the SDK's ``tool_use_id`` threaded through
:meth:`ExecutorAdapter._stable_tool_executor`'s queue),
lets the SDK client dedupe this event against the inline
observed event that :meth:`_translate_event` already
emitted with the same id. ``None`` falls back to a
freshly-allocated uuid — the dispatch still works but the
observed and action_required events render as separate
``⏵ tool_name`` lines.
:returns: A dict suitable as the MCP tool result.
"""
import json
if call_id is None:
call_id = f"call_{uuid.uuid4().hex[:12]}"
try:
output = await ctx.dispatch_tool(
call_id=call_id,
name=tool_name,
arguments=json.dumps(args),
agent=agent,
)
except Exception as exc:
_logger.exception("dispatch_tool failed for %s", tool_name)
return {"error": str(exc)}
# Try to parse the output as JSON — if Omnigent serialized the
# tool result via ``ToolResult.content`` (a JSON string),
# parsing here gives the SDK a structured dict it can show
# the LLM cleanly. Falls back to raw string under a
# ``result`` key for non-JSON outputs.
try:
parsed = json.loads(output)
except (json.JSONDecodeError, TypeError):
return {"result": output}
if isinstance(parsed, dict):
return parsed
return {"result": parsed}
def _extract_last_user_message(
input_value: str | list[dict[str, Any]],
) -> str:
"""Extract the last user message text from a request input.
Handles both conversation-history shape (list of message items
with ``role``/``content``) and single-turn shape (plain string
or content-block list). Used by tracing to populate the agent
span's ``user_message`` input.
:param input_value: The request's ``input`` field.
:returns: The text of the last user message, or empty string.
"""
if isinstance(input_value, str):
return input_value
# Conversation-history shape: find last user message
last_user_text = ""
for item in input_value:
role = item.get("role")
if role == "user":
content = item.get("content")
if isinstance(content, str):
last_user_text = content
elif isinstance(content, list):
parts = []
for block in content:
text = block.get("text")
if isinstance(text, str):
parts.append(text)
if parts:
last_user_text = "\n".join(parts)
# Single-turn content-block shape (no role key)
elif role is None:
text = item.get("text")
if isinstance(text, str):
last_user_text = text
return last_user_text
def _extract_user_text(
input_value: str | list[dict[str, Any]],
) -> str:
"""
Pull plain-text content from a steering injection's input.
Used by :meth:`ExecutorAdapter._watch_injections` to convert
a scaffold-side :class:`CreateResponseRequest` into a single
string the inner executor's
:meth:`Executor.enqueue_session_message` can deliver. Mirrors
the structure of :func:`_translate_input_to_messages` but
returns a plain string rather than a Message dict.
:param input_value: The injection's ``input`` field — either
a plain string (shorthand) or a list of content blocks.
:returns: Concatenated text content. Empty string when no
text blocks are present.
"""
if isinstance(input_value, str):
return input_value
parts: list[str] = []
for block in input_value:
text_value = block.get("text")
if isinstance(text_value, str):
parts.append(text_value)
return "\n".join(parts)
def _translate_input_to_messages(
input_value: str | list[dict[str, Any]],
) -> list[Message]:
"""
Convert :class:`CreateResponseRequest.input` into inner
:class:`Message` list.
Two shapes are accepted on the wire:
1. **Conversation-history shape** — a list whose entries are
``{"type": "message", "role": "...", "content": [...]}``
items, optionally interleaved with typed items
(``function_call``, ``function_call_output``,
``reasoning``, native tool calls). This is what
:func:`_translate_messages_to_input` produces for the
full Layer 2 history; one inner :class:`Message` is
emitted per role-keyed message item, and tool-related
items are dropped (the inner :class:`ClaudeSDKExecutor`'s
``_build_prompt`` only consumes role-keyed messages
when serializing the "Conversation so far:" prefix). The
contract here is "preserve user/assistant turns;
tool-call records are reconstructed from the SDK's own
Layer 1 state on resume," which is the right tradeoff
for the small inner-Message contract.
2. **Single-turn fallback shape** — a plain string or a
list of content-block dicts (``input_text``, etc.) with
no role wrappers. AP's older clients (and any direct
hand-off that hasn't been migrated yet) still send this.
Concatenated into a single user-role message — same
behavior the harness has always had for the
single-message case.
:param input_value: The request's ``input`` field.
:returns: One inner :class:`Message` per conversation turn
when history is present; a single user message
otherwise.
"""
if isinstance(input_value, str):
return [{"role": "user", "content": input_value}]
history_messages = _extract_role_keyed_messages(input_value)
if history_messages:
return history_messages
# Fallback: legacy single-turn path for inputs that don't
# carry role wrappers (e.g. a bare list of content blocks).
# Preserves multimodal blocks when present.
content = _normalize_message_content(input_value)
return [{"role": "user", "content": content}]
def _extract_role_keyed_messages(
input_value: list[dict[str, Any]],
) -> list[Message]:
"""
Pull role-keyed message items out of an Omnigent ``input`` list.
Looks for ``{"type": "message", "role": ..., "content": ...}``
entries (the shape :func:`_translate_messages_to_input`
produces from the workflow's history list). Each match
becomes one inner :class:`Message` whose content is either
a plain string (text-only messages) or the original list
of typed content blocks (when ``input_image`` /
``input_file`` blocks are present alongside text).
Tool-call items (``function_call``, ``function_call_output``,
``reasoning``, native tool items) are intentionally
skipped: the inner SDK reconstructs those from its own
Layer 1 state when resuming, so duplicating them in the
serialized prompt would just confuse the LLM.
:param input_value: The Omnigent ``input`` list.
:returns: One :class:`Message` per role-keyed entry, or
an empty list when *input_value* contains no
role-keyed items (caller falls back to the legacy
single-user-message path).
"""
messages: list[Message] = []
for item in input_value:
if not isinstance(item, dict):
continue
if item.get("type") != "message" or "role" not in item:
continue
role = item["role"]
content = _normalize_message_content(item.get("content"))
# Skip empty messages (e.g. assistant turns that only
# produced tool calls without text). Their absence
# doesn't break the prompt — the prior user turn still
# carries the question and the next user turn carries
# the follow-up.
if not content:
continue
messages.append({"role": role, "content": content})
return messages
_MULTIMODAL_BLOCK_TYPES: frozenset[str] = frozenset({"input_image", "input_file", "input_audio"})
def _normalize_message_content(
content: Any,
) -> str | list[dict[str, Any]]:
"""
Normalize Responses API message ``content`` for inner executors.
When the content list contains only text blocks
(``input_text`` / ``output_text``), collapses to a plain
string (the common text-only fast path). When multimodal
blocks (``input_image``, ``input_file``, ``input_audio``)
are present, returns the full block list so inner executors
can forward image/file content to the LLM.
:param content: The ``content`` field of a Responses API
message item, e.g. ``[{"type": "input_text",
"text": "..."}]``.
:returns: A plain string when all blocks are text-only, or
the original content block list when multimodal blocks
are present. Empty string for ``None`` or non-list
inputs.
"""
if content is None:
return ""
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
has_multimodal = any(
isinstance(block, dict) and block.get("type") in _MULTIMODAL_BLOCK_TYPES
for block in content
)
if has_multimodal:
return content
parts: list[str] = []
for block in content:
if not isinstance(block, dict):
continue
text_value = block.get("text")
if isinstance(text_value, str):
parts.append(text_value)
return "\n".join(parts)
def _serialize_args(args: dict[str, Any]) -> str:
"""
JSON-encode a tool-call arguments dict.
:param args: The arguments dict from
:class:`ToolCallRequest.args`.
:returns: A JSON string the AP-side function_call item
carries verbatim.
"""
import json
encoded: str = json.dumps(args)
return encoded
def _serialize_tool_result(event: ToolCallComplete) -> str:
"""
Stringify a :class:`ToolCallComplete` for the
function_call_output's ``output`` field.
The inner executor populates ``result`` with whatever shape
the wrapped SDK hands back. For the Claude SDK that's the
Anthropic ``ToolResultBlock.content`` value, which can be
one of:
- A plain ``str`` — pass through unchanged.
- A list of typed content blocks (``{"type": "text", "text":
...}``, image blocks, etc.) — join the ``text`` fields so
the LLM-rendered tool output reaches Omnigent intact. Without
this branch, ``str([{...}])`` would emit a Python repr
(literal ``[{'type': 'text', 'text': '...'}]``) and
function_call_output would carry garbage instead of the
actual command output.
- Anything else (dicts, ints, ``None``, …) — best-effort
JSON encode, falling back to ``repr`` so we never
silently drop the value.
Failure path:
- ``error`` is preferred when ``result`` is None and the
status carries one.
- Empty string is a last resort — the inner Executor
contract is that one of result/error is populated;
logged so a regression surfaces in the Omnigent logs.
:param event: The completion event.
:returns: A string suitable for the AP-side
function_call_output's ``output`` field.
"""
if event.result is not None:
return _stringify_tool_payload(event.result)
if event.error is not None:
return f"[error] {event.error}"
_logger.warning(
"ToolCallComplete for %s had neither result nor error",
event.name,
)
return ""
def _stringify_tool_payload(value: Any) -> str:
"""
Coerce a tool's result payload into a string.
See :func:`_serialize_tool_result` for the full rationale —
this helper is split out so the list-of-blocks branch is
independently testable without standing up a full
:class:`ToolCallComplete`.
:param value: Anything the inner Executor put in
``ToolCallComplete.result``.
:returns: A string suitable for serialization on the wire.
"""
import json
if isinstance(value, str):
return value
if isinstance(value, list):
# Anthropic-style content blocks: pull out every
# ``text`` field. Non-text blocks (image, tool_use, etc.)
# have no string projection here and are skipped — a
# follow-up could route image blocks into AP's file-
# store, but for the v1 stdout-of-Bash use case skipping
# them is correct.
text_parts: list[str] = []
for block in value:
if not isinstance(block, dict):
continue
block_text = block.get("text")
if isinstance(block_text, str):
text_parts.append(block_text)
if text_parts:
return "".join(text_parts)
# No usable text blocks — fall through to JSON encode so
# callers still get a structured representation rather
# than an empty string.
try:
return json.dumps(value)
except (TypeError, ValueError):
return repr(value)
def _call_id_from_metadata(metadata: dict[str, Any] | None) -> str | None:
"""
Extract a call_id from an executor's per-call metadata dict.
Different inner executors stash the call_id under different
keys (Claude SDK uses ``"call_id"``; Codex uses ``"call_id"``
on the protocol envelope; Pi uses an opaque id). For v1 we
look for the conventional ``"call_id"`` key first; harness
wraps that need other keys can subclass and override.
:param metadata: The metadata dict from
:class:`ToolCallRequest.metadata` (or ``None``).
:returns: The call_id string if present and stringy, else
``None``.
"""
if not metadata:
return None
value = metadata.get("call_id")
if isinstance(value, str):
return value
return None