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

1762 lines
74 KiB
Python

"""
:class:`HarnessApp` scaffold — base class every per-harness wrap
inherits.
The scaffold owns the boilerplate every harness needs:
- A FastAPI app exposing the harness API subset
(POST /v1/sessions/{conversation_id}/events for the discriminated
downward-event surface, GET /health). See
``designs/SERVER_HARNESS_CONTRACT.md`` §The Harness API Subset
and ``designs/session_rearchitecture.md`` §3.
- Per-turn in-memory state: dicts of Futures the event handlers
resolve when a ``tool_result`` event (tool output) or
``approval`` event (elicitation reply) arrives. Layer 2
(per-turn) and Layer 3 (per-tool-dispatch) bookkeeping per
§Harness in-memory state.
- Heartbeat emitter at :data:`_HEARTBEAT_INTERVAL_S` cadence.
- Cancellation event set by an ``interrupt`` event — the
per-harness ``run_turn`` should poll it before each LLM
iteration / native tool call.
- In-band routing for steering: a ``message`` event with
``previous_response_id`` matching the in-flight turn becomes
an injection rather than starting a new turn (also covers
async function-call completions).
- Graceful SIGTERM / SIGINT handling: cancel the in-flight turn,
drain pending tool results for a short grace window, then shut
down the FastAPI app.
A subclass implements just two things: an ``async def run_turn(
request, ctx) -> None`` method that runs the LLM-tool loop using
``ctx`` to interact with upstream, and a ``create_app()``-style
free function that instantiates the subclass and returns its
FastAPI app for the runner to serve.
"""
from __future__ import annotations
import asyncio
import contextlib
import hmac
import logging
import os
import time
import uuid
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass
from typing import Annotated, Any, Literal
from fastapi import APIRouter, FastAPI, Request, Response, status
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from omnigent import _native_forwarder_health as native_forwarder_health
from omnigent.errors import ErrorCode, OmnigentError
from omnigent.policies.types import FAIL_CLOSED_PHASES
from omnigent.runtime.tool_output import cap_tool_output
from omnigent.server.schemas import (
CompletedEvent,
CreatedEvent,
CreateResponseRequest,
ElicitationRequestEvent,
ElicitationRequestParams,
ElicitationResult,
FailedEvent,
HarnessStreamEvent,
HeartbeatEvent,
InProgressEvent,
OutputItemDoneEvent,
PolicyEvaluationRequestEvent,
ResponseObject,
ServerStreamEvent,
Usage,
)
_logger = logging.getLogger(__name__)
# Cadence for ``response.heartbeat`` SSE events while a turn is
# streaming. Matches the existing Omnigent cadence at
# ``omnigent/runtime/workflow.py:3732`` — 15 seconds is short
# enough that ~3 missed intervals (45s) is a reasonable
# dead-detection threshold for AP, long enough that the
# per-process emit overhead is negligible.
_HEARTBEAT_INTERVAL_S = 15.0
# Grace period between SIGTERM / SIGINT arrival and the FastAPI
# app exiting. Long enough to let a well-behaved harness flush
# pending responses + close the SDK session cleanly; short enough
# that a wedged subprocess doesn't block process-manager release
# (which has its own SIGKILL escalation at
# ``_RELEASE_GRACE_S = 5.0`` in process_manager.py).
_SHUTDOWN_GRACE_S = 4.5
# Timeout for the policy evaluation round-trip (harness → runner →
# Omnigent server → runner → harness). Held at one day (86400s) — matching
# the deciding policy's default ``ask_timeout``: a TOOL_CALL/REQUEST ASK parks
# server-side until a human answers, and this gate must block until the
# verdict arrives rather than auto-resolve on a short cut (the cost-policy
# bug). The server caps the real wait via the policy's ``ask_timeout``. On the
# (now rare) expiry the fallback below is phase-aware — TOOL_CALL fails CLOSED
# (DENY), advisory LLM/TOOL_RESULT phases fail OPEN (ALLOW).
_POLICY_EVAL_TIMEOUT_S = 86400.0
# Per-turn IDLE watchdog: max gap WITHOUT progress before a wedged
# ``run_turn`` becomes ``response.failed`` (vs heartbeating forever).
# Every non-heartbeat ``ctx.emit`` resets the deadline (see
# ``_guarded_run_turn``), so a long-but-active turn is never killed.
# Env var name kept for the ops knob; ``<= 0`` disables.
_TURN_IDLE_TIMEOUT_S = float(os.environ.get("HARNESS_TURN_TIMEOUT_S", "240"))
# Absolute per-turn ceiling: a hard cap on TOTAL turn duration, backstop
# to the idle watchdog above. The idle watchdog never trips a turn that
# keeps emitting, so a runaway-but-active loop (e.g. an infinite tool
# loop emitting steadily) needs this. Generous so it never clips a real
# long turn. ``<= 0`` disables. Whichever of (idle, absolute) trips first.
_TURN_ABSOLUTE_TIMEOUT_S = float(os.environ.get("HARNESS_TURN_ABSOLUTE_TIMEOUT_S", "3600"))
@dataclass(frozen=True)
class PolicyVerdictPayload:
"""
Result of a policy evaluation round-trip.
Returned by :meth:`TurnContext.evaluate_policy` after the runner
evaluates the policy on the Omnigent server and sends the verdict back
as a ``policy_verdict`` inbound event.
:param action: Proto-style verdict string, e.g.
``"POLICY_ACTION_ALLOW"`` or ``"POLICY_ACTION_DENY"``.
:param reason: Human-readable reason from the policy engine.
``None`` on ALLOW.
:param data: Optional data dict from content-rewriting
policies. ``None`` when the policy does not rewrite.
"""
action: str
reason: str | None = None
data: dict[str, Any] | None = None
# ── Inbound event schemas (POST /v1/sessions/{id}/events) ─────────
#
# The session-keyed surface collapses the four turn-keyed routes
# (start turn / cancel / PATCH tool_results / elicitation reply)
# into a single ``POST /v1/sessions/{conversation_id}/events``
# endpoint discriminated by the body's ``type`` field. Wire shapes
# match ``designs/session_rearchitecture.md`` §3 "Event types and
# direction" — the downward (client → server → runner → harness)
# block. Carrying these as a Pydantic discriminated union gives
# fail-loud rejection of unknown types (422) and per-variant field
# validation in one place.
class MessageEvent(BaseModel):
"""
Downward ``message`` event — start a new turn or steer an in-flight one.
A ``message`` arriving while no turn is in flight starts a fresh
turn (the scaffold allocates a ``response_id`` and runs
``run_turn``). A ``message`` arriving while a turn is in flight
is enqueued onto that turn's injection queue (the harness
delivers it to ``run_turn`` via :meth:`TurnContext.next_injection`).
The harness has at most one in-flight turn per conversation, so
no explicit turn id is needed on the wire.
Extra fields are accepted (``model_config = extra="allow"``) so
the runner can pass through optional knobs that
:class:`CreateResponseRequest` understands (``instructions``,
``conversation``, ``reasoning``, ``context_management``,
``tools``, ignored-but-tolerated controls like ``temperature``)
without forcing this schema to mirror every field individually.
:param type: Event discriminator; always ``"message"``.
:param role: Always ``"user"`` for downward messages — assistant
messages flow upward via the runner→server persistence
channel, not through this endpoint. Constrained at the
type level so a stray ``role: "assistant"`` POST fails 422
rather than silently starting a turn with bad provenance.
:param content: The message body. Either a plain string
shorthand (converted to a single ``input_text`` block on
the way to ``run_turn``) or a list of content blocks,
e.g. ``[{"type": "input_text", "text": "Hello"}]``.
:param model: Agent name to invoke, e.g. ``"research-agent"``.
Threaded into the synthesized :class:`CreateResponseRequest`
so the initial / terminal :class:`ResponseObject` carries
a real model identifier (no empty-string sentinel per the
project's anti-pattern checklist).
:param previous_response_id: Optional id of the prior response
in the conversation thread. The scaffold today uses a
match against the in-flight ``response_id`` as the
injection signal; passing it here keeps that path open
during the migration window. Spec long-term: any
``message`` while a turn is in flight is steering, no
explicit correlation id needed — once all callers stop
sending it, this field can be dropped.
"""
type: Literal["message"]
role: Literal["user"]
content: str | list[dict[str, Any]]
model: str
previous_response_id: str | None = None
# Allow runner-side passthrough of CreateResponseRequest fields
# (instructions, conversation, reasoning, context_management,
# tools, plus the ignored-but-tolerated controls). Forwarded
# verbatim into the synthesized CreateResponseRequest by
# :meth:`MessageEvent.to_create_request`.
model_config = ConfigDict(extra="allow")
def to_create_request(self) -> CreateResponseRequest:
"""
Synthesize a :class:`CreateResponseRequest` from this event.
Maps ``content`` → ``input`` and forwards every extra field
verbatim. Used by the scaffold's ``/events`` handler to
adapt the wire shape onto :meth:`HarnessApp._start_or_inject_turn`.
:returns: A populated :class:`CreateResponseRequest` ready
to hand off to :meth:`HarnessApp._start_or_inject_turn`.
"""
# ``model_dump`` includes extras; rename ``content`` →
# ``input`` and drop the discriminator + role fields the
# legacy schema doesn't carry.
payload = self.model_dump()
payload.pop("type", None)
payload.pop("role", None)
payload["input"] = payload.pop("content")
return CreateResponseRequest(**payload)
class InterruptEvent(BaseModel):
"""
Downward ``interrupt`` event — cancel the in-flight turn.
The harness has at most one in-flight turn per conversation
so no turn id is needed on the wire — the scaffold cancels
every entry in :attr:`HarnessApp._in_flight`. If no turn is
in flight, the route 404s — fail loud rather than silently
accept a no-op cancel so a stray interrupt after a turn ended
surfaces as an obvious operator error.
:param type: Event discriminator; always ``"interrupt"``.
"""
type: Literal["interrupt"]
class ToolResultEvent(BaseModel):
"""
Downward ``tool_result`` event — deliver a server-dispatched
tool's output back to the parked turn.
Resolves the parked Future on the in-flight turn whose
``call_id`` matches. Stale ids silently no-op — submit
results, apply what you can — so benign races (turn
cancelled mid-event, Future already resolved on a different
path) don't surface as hard failures upstream.
:param type: Event discriminator; always ``"tool_result"``.
:param call_id: The tool call id that this result corresponds
to, e.g. ``"call_abc123"``. Must match an outstanding
:meth:`TurnContext.dispatch_tool` Future.
:param output: The tool's stringified output, e.g.
``'["paper1.pdf"]'``.
"""
type: Literal["tool_result"]
call_id: str
output: str
class ApprovalEvent(BaseModel):
"""
Downward ``approval`` event — reply to an outstanding
elicitation.
Resolves the parked Future on whichever in-flight turn
registered the elicitation. Single-shot — an unknown
``elicitation_id`` is a hard 404 because the URL path's
correlation token must match an outstanding elicitation.
Wire shape mirrors MCP's ``elicitation/create`` response per
Principle 8 — same ``action`` + ``content`` fields as
:class:`ElicitationResult`.
:param type: Event discriminator; always ``"approval"``.
:param elicitation_id: Correlation id from the prior
``approval_required`` upward event,
e.g. ``"elicit_abc123"``.
:param action: User action per MCP semantics.
``"accept"`` = approved, ``"decline"`` = explicit refusal,
``"cancel"`` = dismissed.
:param content: Optional form data when ``action == "accept"``
and the prompt requested fields. Values restricted to JSON
scalars and string lists, mirroring MCP.
"""
type: Literal["approval"]
elicitation_id: str
action: Literal["accept", "decline", "cancel"]
content: dict[str, str | int | float | bool | list[str] | None] | None = None
def to_elicitation_result(self) -> ElicitationResult:
"""
Adapt this event onto the legacy :class:`ElicitationResult`.
:returns: An :class:`ElicitationResult` with the same
``action`` / ``content`` so
:meth:`HarnessApp._resolve_elicitation` can resolve
the parked Future.
"""
return ElicitationResult(action=self.action, content=self.content)
class PolicyVerdictEvent(BaseModel):
"""
Downward ``policy_verdict`` event — deliver a policy evaluation
result back to the parked turn.
Resolves the parked Future on whichever in-flight turn has a
matching ``evaluation_id``. Stale ids silently no-op — same
semantics as :class:`ToolResultEvent`.
:param type: Event discriminator; always ``"policy_verdict"``.
:param evaluation_id: Correlation id from the prior
``policy_evaluation.requested`` upward event,
e.g. ``"poleval_abc123"``.
:param action: Proto-style verdict string, e.g.
``"POLICY_ACTION_ALLOW"`` or ``"POLICY_ACTION_DENY"``.
:param reason: Human-readable reason from the policy engine,
e.g. ``"Denied by cost-limit policy"``. ``None`` on ALLOW.
:param data: Optional data dict from content-rewriting
policies. ``None`` when the policy does not rewrite.
"""
type: Literal["policy_verdict"]
evaluation_id: str
action: str
reason: str | None = None
data: dict[str, Any] | None = None
# Discriminated union of every downward event the harness accepts on
# ``POST /v1/sessions/{conversation_id}/events``. FastAPI / Pydantic
# v2 dispatches by the ``type`` field at request-validation time;
# unknown values raise 422 (fail-loud per
# ``designs/DESIGN_PRINCIPLES.md``).
InboundEventRequest = Annotated[
MessageEvent | InterruptEvent | ToolResultEvent | ApprovalEvent | PolicyVerdictEvent,
Field(discriminator="type"),
]
class TurnContext:
"""
Per-turn interaction surface the scaffold hands to ``run_turn``.
The subclass uses ``ctx`` to:
- Push SSE events upstream (``ctx.emit(event)``).
- Park on a tool dispatch (``await ctx.dispatch_tool(...)``).
- Park on an elicitation (``await ctx.elicit(...)``).
- Poll for cancellation (``ctx.cancelled.is_set()``).
- Receive in-band steering / async-completion injections
(``await ctx.next_injection(timeout=...)``).
All state is per-turn — a fresh ``TurnContext`` is constructed
for each fresh ``message`` event that starts a turn. Cross-turn
state belongs on the harness subclass instance (Layer 1 in the
in-memory state hierarchy).
:param response_id: Server-allocated id for this turn,
e.g. ``"resp_abc123"``. Surfaced on the SSE
``response.created`` envelope so Omnigent can correlate
replays / heartbeat-event-seq tracking.
:param event_queue: The :class:`asyncio.Queue` the SSE
streaming response reads from. ``ctx.emit`` puts
events onto this queue; the streaming response
formats and yields them.
:param cancelled: Set by the cancel route handler. The
subclass should poll this between LLM iterations / native
tool calls so a cancel arriving mid-LLM-call interrupts
promptly.
"""
def __init__(
self,
response_id: str,
event_queue: asyncio.Queue[HarnessStreamEvent | None],
cancelled: asyncio.Event,
) -> None:
self.response_id = response_id
self._event_queue = event_queue
self.cancelled = cancelled
# Layer 3 per-tool-dispatch state: ``call_id`` →
# Future[ToolResult-output-string]. Populated by
# ``dispatch_tool``; resolved by the ``tool_result``
# /events handler.
self._pending_tool_calls: dict[str, asyncio.Future[str]] = {}
# Per-turn elicitation state: ``elicitation_id`` →
# Future[ElicitationResult]. Populated by ``elicit``;
# resolved by the ``approval`` /events handler.
self._pending_elicitations: dict[str, asyncio.Future[ElicitationResult]] = {}
# Layer 3 per-policy-evaluation state:
# ``evaluation_id`` → Future[PolicyVerdictPayload].
# Populated by ``evaluate_policy``; resolved by the
# ``policy_verdict`` /events handler.
self._pending_policy_evaluations: dict[str, asyncio.Future[PolicyVerdictPayload]] = {}
# In-band injections (steering, async completions) the
# scaffold's ``message`` /events handler pushes here when
# ``previous_response_id`` matches this turn. Subclass
# consumes via ``next_injection``.
self._injection_queue: asyncio.Queue[CreateResponseRequest] = asyncio.Queue()
# Provider-reported token usage for this turn, set by the
# subclass (e.g. ExecutorAdapter) when it observes a
# TurnComplete with usage data. Read by _build_terminal_event
# to populate the usage field on the response.completed SSE.
# ``None`` when the inner executor does not report usage.
self.provider_usage: dict[str, Any] | None = None
# Idle-watchdog reset hook. ``_guarded_run_turn`` sets this to
# push the per-turn idle deadline forward on each real progress
# event so a long-but-active turn isn't killed mid-turn.
# ``None`` disables it (watchdog off, or outside a guarded run).
self._reset_idle_watchdog: Callable[[], None] | None = None
def emit(self, event: HarnessStreamEvent) -> None:
"""
Push an SSE event upstream.
Non-blocking. The event lands on the per-turn queue the
streaming response is consuming. Producers leave
``sequence_number`` unset; the streaming wrapper
assigns it monotonically.
:param event: A typed event from
:data:`omnigent.server.schemas.ServerStreamEvent`,
e.g.
``OutputTextDeltaEvent(type="response.output_text.delta",
delta="hi")``.
"""
# Treat any non-heartbeat event as progress and push the idle
# watchdog deadline forward. Heartbeats are keep-alive, NOT
# progress — letting them reset the deadline would defeat the
# watchdog (a wedged turn's 15s heartbeats would keep it alive
# forever).
if self._reset_idle_watchdog is not None and not isinstance(event, HeartbeatEvent):
self._reset_idle_watchdog()
self._event_queue.put_nowait(event)
async def dispatch_tool(self, call_id: str, name: str, arguments: str, agent: str) -> str:
"""
Emit a server-dispatched tool call and park until the result.
Surfaces a ``response.output_item.done`` carrying a
``function_call`` item with ``status: "action_required"``;
the scaffold's ``tool_result`` /events handler resolves
the parked Future when Omnigent delivers an event carrying the
matching ``call_id``. See §How Omnigent resolves action_required
tool calls in the design doc.
:param call_id: Unique identifier for this tool call,
e.g. ``"call_abc123"``. Omnigent echoes it back in the
``tool_result`` event so the scaffold can route the
result.
:param name: Tool name the LLM called, e.g.
``"search.web"``.
:param arguments: JSON-encoded argument string, e.g.
``'{"q": "foo"}'``.
:param agent: Agent name that invoked the tool — required
on the function_call item per
:class:`omnigent.entities.conversation.FunctionCallData`.
:returns: The tool's output string from
:class:`omnigent.server.schemas.ToolResult`.
:raises asyncio.CancelledError: If the turn is cancelled
(an ``interrupt`` event cancels every pending Future
on the in-flight :class:`TurnContext`).
"""
future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
self._pending_tool_calls[call_id] = future
# Build the function_call item shape that AP's resolver
# expects (per omnigent/entities/conversation.py:FunctionCallData
# plus the API-shape common fields ``id``, ``type``,
# ``status``).
item: dict[str, Any] = {
"id": f"fc_{uuid.uuid4().hex[:12]}",
"type": "function_call",
"status": "action_required",
"name": name,
"arguments": arguments,
"call_id": call_id,
"agent": agent,
}
self.emit(OutputItemDoneEvent(type="response.output_item.done", item=item))
try:
result = await future
item["status"] = "completed"
self.emit(OutputItemDoneEvent(type="response.output_item.done", item=item))
self.emit(
OutputItemDoneEvent(
type="response.output_item.done",
item={
"id": f"fco_{uuid.uuid4().hex[:12]}",
"type": "function_call_output",
"call_id": call_id,
"output": cap_tool_output(result),
"arguments": arguments,
"status": "completed",
},
)
)
return result
finally:
# Always remove — whether the Future resolved cleanly
# or was cancelled, holding it in the dict would
# leak memory across turns.
self._pending_tool_calls.pop(call_id, None)
async def elicit(
self, elicitation_id: str, params: ElicitationRequestParams
) -> ElicitationResult:
"""
Emit an elicitation request and park until the reply.
Wire shape adopts MCP's ``elicitation/create`` per
Principle 8. The scaffold's ``approval`` /events handler
resolves the parked Future when upstream sends the reply.
See §Elicitation in the design doc.
:param elicitation_id: Unique correlation id for this
elicitation, e.g. ``"elicit_abc123"``. Must appear
in the URL of the upstream reply.
:param params: MCP-shaped params block (mode, message,
requestedSchema / url, plus AP-specific extensions).
:returns: The MCP-shaped
:class:`ElicitationResult` reply.
:raises asyncio.CancelledError: If the turn is cancelled.
"""
future: asyncio.Future[ElicitationResult] = asyncio.get_running_loop().create_future()
self._pending_elicitations[elicitation_id] = future
self.emit(
ElicitationRequestEvent(
type="response.elicitation_request",
elicitation_id=elicitation_id,
params=params,
)
)
try:
return await future
finally:
self._pending_elicitations.pop(elicitation_id, None)
async def next_injection(self, timeout: float | None = None) -> CreateResponseRequest | None:
"""
Wait for an in-band steering / async-completion injection.
Blocks until either a ``message`` event with
``previous_response_id == self.response_id`` arrives (the
scaffold's /events handler enqueues it here) or the timeout
elapses. Subclasses that want to react to mid-turn
injections (e.g., a sub-agent OOB return delivered as a
``function_call_output`` item) call this in a poll loop.
:param timeout: Max seconds to wait, or ``None`` to wait
indefinitely. Subclasses commonly use a short timeout
(~1s) inside the LLM-tool loop so injections get
picked up between iterations without blocking.
:returns: The injected request body (whose ``input`` field
carries the new items to fold into the running turn),
or ``None`` on timeout.
"""
try:
if timeout is None:
return await self._injection_queue.get()
return await asyncio.wait_for(self._injection_queue.get(), timeout=timeout)
except asyncio.TimeoutError:
return None
def _complete_tool(self, call_id: str, output: str) -> bool:
"""
Internal: resolve a pending tool-call Future from a
``tool_result`` event.
:param call_id: The call id from the event body.
:param output: The tool's result output string.
:returns: ``True`` if a pending call was resolved, ``False``
if the id didn't match anything outstanding. The
``tool_result`` handler ignores the False case (stale
id = silent no-op).
"""
future = self._pending_tool_calls.get(call_id)
if future is None or future.done():
return False
future.set_result(output)
return True
def _complete_elicitation(self, elicitation_id: str, result: ElicitationResult) -> bool:
"""
Internal: resolve a pending elicitation Future.
:param elicitation_id: The id from the URL path.
:param result: The MCP-shaped reply body.
:returns: ``True`` if a pending elicitation was resolved,
``False`` if the id didn't match anything outstanding
(the route returns 404 in that case).
"""
future = self._pending_elicitations.get(elicitation_id)
if future is None or future.done():
return False
future.set_result(result)
return True
async def evaluate_policy(
self, evaluation_id: str, phase: str, data: dict[str, Any]
) -> PolicyVerdictPayload:
"""
Emit a policy evaluation request and park until the verdict.
Surfaces a ``policy_evaluation.requested`` SSE event; the
runner intercepts it, evaluates the policy via the AP
server's ``POST /sessions/{id}/policies/evaluate``, and
posts the verdict back as a ``policy_verdict`` inbound
event. The scaffold's event handler resolves the parked
Future when the verdict arrives.
:param evaluation_id: Unique correlation id, e.g.
``"poleval_abc123"``. The runner echoes it back in the
verdict event.
:param phase: Proto-style phase string, e.g.
``"PHASE_LLM_REQUEST"`` or ``"PHASE_LLM_RESPONSE"``.
:param data: Event data dict for the policy engine, e.g.
``{"model": "gpt-4o", "messages_count": 42}``.
:returns: The verdict payload from the Omnigent server.
:raises asyncio.CancelledError: If the turn is cancelled.
"""
future: asyncio.Future[PolicyVerdictPayload] = asyncio.get_running_loop().create_future()
self._pending_policy_evaluations[evaluation_id] = future
self.emit(
PolicyEvaluationRequestEvent(
type="policy_evaluation.requested",
evaluation_id=evaluation_id,
phase=phase,
data=data,
)
)
try:
return await asyncio.wait_for(future, timeout=_POLICY_EVAL_TIMEOUT_S)
except asyncio.TimeoutError:
# Phase-aware default: advisory LLM phases and TOOL_RESULT (the
# tool already ran) fail OPEN so a missing verdict never hangs the
# turn, but TOOL_CALL is the authoritative gate for
# connector-native tools and fails CLOSED.
_fail_closed = phase in FAIL_CLOSED_PHASES
_action = "POLICY_ACTION_DENY" if _fail_closed else "POLICY_ACTION_ALLOW"
_logger.warning(
"Policy evaluation %s timed out after %ds; defaulting to %s",
evaluation_id,
_POLICY_EVAL_TIMEOUT_S,
_action,
)
return PolicyVerdictPayload(
action=_action,
reason=(
f"Policy evaluation timed out; failing closed for {phase}."
if _fail_closed
else None
),
)
finally:
self._pending_policy_evaluations.pop(evaluation_id, None)
def _complete_policy_evaluation(
self, evaluation_id: str, verdict: PolicyVerdictPayload
) -> bool:
"""
Internal: resolve a pending policy-evaluation Future.
:param evaluation_id: Correlation id from the verdict event.
:param verdict: The policy verdict payload.
:returns: ``True`` if a pending evaluation was resolved,
``False`` if the id didn't match (stale = silent no-op).
"""
future = self._pending_policy_evaluations.get(evaluation_id)
if future is None or future.done():
return False
future.set_result(verdict)
return True
def _push_injection(self, request: CreateResponseRequest) -> None:
"""
Internal: push an injection request onto the queue.
:param request: The decoded ``message`` /events body whose
``previous_response_id`` matched this turn.
"""
self._injection_queue.put_nowait(request)
def _cancel_pending(self) -> None:
"""
Internal: cancel every pending tool / elicitation Future.
Called by the ``interrupt`` handler so subclass coroutines
parked on ``dispatch_tool`` / ``elicit`` unblock with
:class:`asyncio.CancelledError`. The subclass's
``run_turn`` then unwinds to its termination path.
"""
for tool_future in self._pending_tool_calls.values():
if not tool_future.done():
tool_future.cancel()
for elicitation_future in self._pending_elicitations.values():
if not elicitation_future.done():
elicitation_future.cancel()
for policy_future in self._pending_policy_evaluations.values():
if not policy_future.done():
policy_future.cancel()
class HarnessApp:
"""
Base class for harness wraps. Subclasses implement
:meth:`run_turn`.
Use the class as a context manager around a harness wrap's
``create_app()`` factory:
.. code-block:: python
class MyHarnessApp(HarnessApp):
async def run_turn(
self, request: CreateResponseRequest, ctx: TurnContext
) -> None:
ctx.emit(OutputTextDeltaEvent(
type="response.output_text.delta", delta="hi"
))
def create_app() -> FastAPI:
return MyHarnessApp().build()
The scaffold owns:
- The FastAPI app (built by :meth:`build`).
- Per-turn :class:`TurnContext` allocation + lifecycle.
- SSE streaming of events the subclass emits.
- Heartbeat emission at :data:`_HEARTBEAT_INTERVAL_S` cadence.
- Discriminated /events handler that resolves the right
pending Futures on the right :class:`TurnContext` for
``tool_result``, ``approval``, and ``interrupt`` variants.
- In-band injection routing: a ``message`` event with
``previous_response_id`` matching the in-flight turn
enqueues onto that turn's injection queue rather than
starting a new turn.
- Graceful SIGTERM / SIGINT shutdown.
The subclass owns:
- :meth:`run_turn` — the LLM-tool loop, using ``ctx`` to
interact upstream.
- Layer 1 in-memory state (SDK / CLI process handle, last-known
history for reconciliation) on the subclass instance.
"""
def __init__(self) -> None:
# In-flight turns keyed by ``response_id``. Single-entry in
# practice (AP enforces one in-flight turn per conversation
# at its REST boundary), but stored as a dict so the route
# handlers can look up by id without assuming uniqueness.
self._in_flight: dict[str, TurnContext] = {}
# The currently active turn context, or ``None`` when idle.
# Set under ``_lock`` when a turn starts, cleared
# synchronously when the streaming generator finishes
# (before async teardown). Used by sessions-native
# injection: a message arriving while this is set is
# steering, not a new turn.
self._active_turn_ctx: TurnContext | None = None
# Lock for ``_in_flight`` / ``_active_turn_ctx`` mutations —
# route handlers run concurrently in FastAPI's event loop.
self._lock = asyncio.Lock()
# Set by graceful-shutdown signal handlers; checked by the
# ``message`` /events handler to refuse new turns once
# shutdown started.
self._shutting_down = asyncio.Event()
async def on_shutdown(self) -> None:
"""
Subclass hook invoked during lifespan teardown.
Called from ``_lifespan``'s ``finally`` block after
in-flight turns have been cancelled but before the
drain grace period. Subclasses (e.g.
:class:`ExecutorAdapter`) override this to close inner
executor resources, SDK sessions, and child processes
that would otherwise leak on process exit.
The base implementation is a no-op.
"""
async def run_turn(self, request: CreateResponseRequest, ctx: TurnContext) -> None:
"""
Per-harness turn execution.
Subclasses MUST override. The scaffold has already wired
up the SSE response, allocated ``response_id``, and
constructed ``ctx``. The subclass's job is to run the
LLM-tool loop using ``ctx.emit`` for events,
``ctx.dispatch_tool`` / ``ctx.elicit`` for round-trips,
``ctx.cancelled`` for cancellation polling, and
``ctx.next_injection`` for steering / async completions.
On normal completion, the scaffold emits a
:class:`CompletedEvent` automatically once the subclass
returns. Subclasses do NOT need to emit the terminal
event themselves.
:param request: The decoded
:class:`CreateResponseRequest` body.
:param ctx: Per-turn :class:`TurnContext` providing the
interaction surface.
"""
raise NotImplementedError
def _build_error_detail(self, exception: BaseException) -> Any:
"""
Translate a ``run_turn`` exception into an :class:`ErrorDetail`.
Default implementation: uses the exception class name as
the error code (e.g. ``"RuntimeError"``, ``"ValueError"``)
and ``str(exception)`` as the message. AP's retryable-error
allowlist at
:data:`omnigent.runtime.harnesses._client_executor._RETRYABLE_HARNESS_ERROR_CODES`
uses semantic names (``"rate_limit_exceeded"``,
``"timeout"``, etc.), so the default mapping makes every
failure non-retryable — safe but coarse.
Per-harness subclasses (e.g.
:class:`omnigent.runtime.harnesses._executor_adapter.ExecutorAdapter`)
override this to translate SDK-specific exception types
onto the semantic allowlist so the retry-classification
promise from §Error envelopes / step 5j actually fires
for known retryable failures.
:param exception: The exception :meth:`run_turn` raised.
:returns: An :class:`ErrorDetail` whose ``code`` field
ideally matches one of the contract-recognized codes
so AP-side retry decisions can act on it.
"""
from omnigent.server.schemas import ErrorDetail
return ErrorDetail(code=type(exception).__name__, message=str(exception))
def build(self) -> FastAPI:
"""
Build the FastAPI app exposing the harness API subset.
Mounts:
- ``GET /health`` (liveness probe — at root, NOT /v1).
- ``POST /v1/sessions/{conversation_id}/events`` —
discriminated downward-event endpoint per
``designs/session_rearchitecture.md`` §3. The body's
``type`` field selects the variant (``message`` /
``interrupt`` / ``tool_result`` / ``approval``); FastAPI
/ Pydantic rejects unknown values with 422.
Harness-specific shutdown runs from the FastAPI lifespan
teardown path after uvicorn receives SIGTERM/SIGINT, so
in-flight turns are cancelled gracefully without competing
with uvicorn's process-level signal handlers.
:returns: A configured :class:`FastAPI` instance ready to
be served by ``omnigent.runtime.harnesses._runner``.
"""
app = FastAPI(title="omnigent-harness", lifespan=self._lifespan)
# Instrument the harness ASGI app so HTTP calls from the runner
# (over the per-conversation Unix socket) continue the caller's
# trace rather than rooting a fresh one.
from omnigent.runtime import telemetry
telemetry.instrument_fastapi_app(app)
# FastAPI / Starlette types add_exception_handler narrowly
# (Callable[[Request, Exception], ...]) — the OmnigentError
# subclass annotation is what we actually want at runtime, but
# the static type doesn't accept it. Cast at the boundary.
app.add_exception_handler(OmnigentError, _handle_omnigent_error) # type: ignore[arg-type]
# Health probe lives at root (matches AP's app.py:125).
app.add_api_route("/health", _health, methods=["GET"])
app.include_router(self._build_v1_router(), prefix="/v1")
return app
def _check_auth(self, request: Request) -> Response | None:
"""
Authenticate a ``/v1`` request with the per-spawn bearer token (S1).
The harness control channel is a uid-isolated Unix socket on POSIX but a
loopback-TCP listener on Windows, where any local process can connect.
The ``/v1`` event endpoint starts turns, runs tools, and satisfies
approval / policy verdicts, so it must not be reachable by an
unauthenticated peer that merely learns the (non-secret)
``conversation_id``. The runner stashes the expected token on
``app.state.harness_auth_token``; the parent (process_manager) presents
it as ``Authorization: Bearer <token>``. Comparison is constant-time.
Checked in the event handler rather than via middleware so the SSE
``StreamingResponse`` and lifespan teardown are untouched
(``BaseHTTPMiddleware`` interferes with both). ``/health`` is never
gated. When no token is configured — the app was built outside the
runner, e.g. a unit test calling ``create_app()`` directly — the gate is
inert, preserving those callers' direct access.
:param request: The inbound request, for ``Authorization`` + app.state.
:returns: A 401 :class:`Response` to short-circuit on failure, or
``None`` to proceed.
"""
expected = getattr(request.app.state, "harness_auth_token", None)
if not expected:
return None
header = request.headers.get("Authorization", "")
scheme, _, token = header.partition(" ")
if scheme.lower() != "bearer" or not hmac.compare_digest(token, expected):
return JSONResponse({"detail": "unauthorized"}, status_code=401)
return None
@contextlib.asynccontextmanager
async def _lifespan(self, _app: FastAPI) -> AsyncIterator[None]:
"""
FastAPI lifespan: cancel, close, and drain on exit.
Uvicorn owns process-level SIGTERM/SIGINT handling. The
scaffold intentionally does not install competing signal
handlers here: doing so can prevent uvicorn from setting its
own shutdown flag, leaving the harness runner alive after a
plain ``SIGTERM``. Instead, uvicorn enters lifespan teardown
and this ``finally`` block performs harness-specific cleanup:
cancel in-flight Futures, invoke subclass shutdown hooks, and
drain briefly for graceful completion.
:param _app: The FastAPI app (unused — required by the
lifespan protocol).
"""
try:
yield
finally:
self._on_shutdown_signal()
await self.on_shutdown()
await self._drain_for_shutdown()
def _on_shutdown_signal(self) -> None:
"""
Signal handler: kick off graceful shutdown.
Marks the scaffold as shutting down (so new ``message``
events get refused) and cancels every
in-flight turn's pending Futures. The actual app exit
happens after :meth:`_drain_for_shutdown` runs.
"""
if self._shutting_down.is_set():
# Second signal — escalate immediately. uvicorn's
# default SIGTERM handler will take over.
return
self._shutting_down.set()
for ctx in self._in_flight.values():
ctx.cancelled.set()
ctx._cancel_pending()
async def _drain_for_shutdown(self) -> None:
"""
Wait briefly for in-flight turns to finalize on shutdown.
Bounded by :data:`_SHUTDOWN_GRACE_S`; if turns haven't
cleared by then the process exits and AP's workflow
handles the disconnect via the standard
``executor_died`` retry path.
"""
deadline = time.monotonic() + _SHUTDOWN_GRACE_S
while self._in_flight and time.monotonic() < deadline:
await asyncio.sleep(0.05)
def _build_v1_router(self) -> APIRouter:
"""
Build the ``/v1`` router with the route handlers.
The router is bound to a bound method on ``self`` so the
handler closes over the scaffold instance — no FastAPI
dependency-injection plumbing needed.
Mounts the session-keyed surface per
``designs/session_rearchitecture.md`` §3 "Endpoints":
- ``POST /v1/sessions/{conversation_id}/events`` —
downward events (``message``, ``interrupt``,
``tool_result``, ``approval``). The body's ``type``
field discriminates the variant; FastAPI / Pydantic
rejects unknown values with 422.
The handler validates the path's ``conversation_id``
against :attr:`fastapi.applications.FastAPI.state.conversation_id`
(stashed by the runner per ``_runner.py``) and 404s on
mismatch.
:returns: A configured :class:`APIRouter`.
"""
router = APIRouter()
router.add_api_route(
"/sessions/{conversation_id}/events",
self._post_session_event,
methods=["POST"],
# MessageEvent variants stream a turn back as SSE
# (StreamingResponse) while the other variants return
# 204; declaring response_model=None disables OpenAPI
# introspection that would otherwise reject the
# heterogeneous return type.
response_model=None,
)
return router
def _check_conversation_id(self, request: Request, conversation_id: str) -> None:
"""
Validate that a session-keyed URL targets this scaffold's
conversation.
The harness scaffold is per-conversation: a single
subprocess serves exactly one ``conversation_id`` (set on
``app.state.conversation_id`` by the runner — see
``omnigent/runtime/harnesses/_runner.py``). Any
session-keyed request whose path does not match is
addressed at the wrong subprocess; failing with 404 is the
same shape the Omnigent side uses for unknown conversations
(per ``omnigent/server/routes/sessions.py``).
Boot ordering: ``app.state.conversation_id`` is set in
``_runner._load_harness_app`` AFTER the FastAPI app is
constructed but BEFORE uvicorn starts serving requests, so
any handler that runs has guaranteed access to it. A
missing attribute means the scaffold is being driven by a
custom embedder that skipped that step — fail loud with
500 rather than silently accept any conversation_id.
:param request: The FastAPI request, used to read
``app.state.conversation_id``.
:param conversation_id: The id from the URL path.
:raises OmnigentError: 500 if ``app.state.conversation_id``
is unset (misconfiguration); 404 if the path id does
not match the scaffold's bound id.
"""
bound = getattr(request.app.state, "conversation_id", None)
if bound is None:
raise OmnigentError(
"harness scaffold has no conversation_id bound on app.state — "
"the runner must set app.state.conversation_id before serving",
code=ErrorCode.INTERNAL_ERROR,
)
if bound != conversation_id:
raise OmnigentError(
f"conversation {conversation_id!r} not served by this harness "
f"(bound to {bound!r})",
code=ErrorCode.NOT_FOUND,
)
async def _post_session_event(
self,
conversation_id: str,
body: InboundEventRequest,
request: Request,
) -> StreamingResponse | Response:
"""
Handle ``POST /v1/sessions/{conversation_id}/events``.
Single discriminated entry point for every downward event
(``message``, ``interrupt``, ``tool_result``, ``approval``)
per ``designs/session_rearchitecture.md`` §3. After
validating the path's ``conversation_id`` matches the
scaffold's bound conversation, dispatches by ``body.type``
onto the matching per-action implementation.
Dispatch table:
- :class:`MessageEvent` → :meth:`_start_or_inject_turn`
after adapting to :class:`CreateResponseRequest`
(streaming SSE for new turns, 204 for in-band
injections).
- :class:`InterruptEvent` → cancel every in-flight turn
(single-entry in practice; 404 if none in flight).
- :class:`ToolResultEvent` → resolve the parked Future
on whichever in-flight turn has the matching
``call_id``; stale ids silently no-op.
- :class:`ApprovalEvent` → :meth:`_resolve_elicitation`
after adapting to :class:`ElicitationResult` (404 on
unknown elicitation_id — single-shot correlation).
Unknown ``type`` values fail at request validation with
422 (Pydantic discriminator), per ``designs/DESIGN_PRINCIPLES.md``
fail-loud.
:param conversation_id: AP-allocated conversation id from
the URL, e.g. ``"conv_abc123"``. Must match
``app.state.conversation_id``.
:param body: Decoded discriminated-union event body.
:param request: The FastAPI request, used to validate
``conversation_id``.
:returns: A :class:`StreamingResponse` for a fresh
``message`` event that starts a turn, or a 204
:class:`Response` for every other case (injection,
interrupt, tool_result, approval).
:raises OmnigentError: 404 on conversation_id mismatch,
unknown elicitation_id, or interrupt with no
in-flight turn; 503 on shutdown for fresh turns.
"""
denied = self._check_auth(request)
if denied is not None:
return denied
self._check_conversation_id(request, conversation_id)
if isinstance(body, MessageEvent):
return await self._start_or_inject_turn(body.to_create_request())
if isinstance(body, InterruptEvent):
return await self._handle_interrupt_event()
if isinstance(body, ToolResultEvent):
return await self._handle_tool_result_event(body)
if isinstance(body, ApprovalEvent):
return await self._resolve_elicitation(
body.elicitation_id, body.to_elicitation_result()
)
if isinstance(body, PolicyVerdictEvent):
return await self._handle_policy_verdict_event(body)
# Pydantic's discriminated-union validator should reject
# unknown variants before we reach this branch; if it ever
# falls through, fail loud rather than silently no-op.
raise OmnigentError(
f"unsupported inbound event type {type(body).__name__!r}",
code=ErrorCode.INVALID_INPUT,
)
async def _handle_interrupt_event(self) -> Response:
"""
Apply an :class:`InterruptEvent` to the in-flight turn.
The harness has at most one in-flight turn per conversation
in practice, but the registry is a dict so this iterates
defensively. If no turn is in flight, 404s — fail loud
rather than silently no-op so a stray interrupt arriving
after a turn ended surfaces as an obvious operator error.
:returns: 204 on success.
:raises OmnigentError: 404 if no turn is in flight.
"""
if not self._in_flight:
raise OmnigentError(
"no in-flight turn to interrupt",
code=ErrorCode.NOT_FOUND,
)
for ctx in self._in_flight.values():
ctx.cancelled.set()
ctx._cancel_pending()
# Drop the cancelled turn as the inject target so the next message
# starts a fresh turn (rebuilds with the marker), not into the dying one
# — otherwise it resumes the abandoned turn and the agent runs one behind.
async with self._lock:
self._active_turn_ctx = None
return Response(status_code=status.HTTP_204_NO_CONTENT)
async def _handle_tool_result_event(self, body: ToolResultEvent) -> Response:
"""
Apply a :class:`ToolResultEvent` to whichever in-flight
turn has a matching parked tool-call Future.
Loose-by-default semantics — stale ``call_id`` entries
silently no-op. Benign races (turn cancelled mid-event,
Future already resolved on a different path) MUST NOT
surface as hard failures upstream; the streaming response
is the source of truth for whether the result actually
landed.
:param body: The decoded :class:`ToolResultEvent`.
:returns: 204 No Content.
"""
for ctx in self._in_flight.values():
if ctx._complete_tool(body.call_id, body.output):
break
return Response(status_code=status.HTTP_204_NO_CONTENT)
async def _handle_policy_verdict_event(self, body: PolicyVerdictEvent) -> Response:
"""
Apply a :class:`PolicyVerdictEvent` to whichever in-flight
turn has a matching parked policy-evaluation Future.
Loose semantics — stale ``evaluation_id`` entries silently
no-op, same as :meth:`_handle_tool_result_event`.
:param body: The decoded :class:`PolicyVerdictEvent`.
:returns: 204 No Content.
"""
verdict = PolicyVerdictPayload(
action=body.action,
reason=body.reason,
data=body.data,
)
for ctx in self._in_flight.values():
if ctx._complete_policy_evaluation(body.evaluation_id, verdict):
break
return Response(status_code=status.HTTP_204_NO_CONTENT)
async def _start_or_inject_turn(
self, request: CreateResponseRequest
) -> StreamingResponse | Response:
"""
Start a new turn or inject into the in-flight one.
Invoked by :meth:`_post_session_event` for ``message``
events. Three cases:
1. ``previous_response_id`` matches an in-flight turn →
in-band injection: enqueue the request body on that
turn's injection queue, return 204.
2. Scaffold is shutting down → refuse with 503.
3. Otherwise → start a new turn: allocate ``response_id``,
build a :class:`TurnContext`, register it in
``_in_flight``, and return a streaming SSE response
that runs ``run_turn`` to completion.
:param request: The decoded request body.
:returns: Either a :class:`StreamingResponse` for the new
turn or a 204 :class:`Response` for an in-band
injection.
:raises OmnigentError: 503 on shutdown.
"""
if (
request.previous_response_id is not None
and request.previous_response_id in self._in_flight
and not self._in_flight[request.previous_response_id].cancelled.is_set()
):
ctx = self._in_flight[request.previous_response_id]
ctx._push_injection(request)
return Response(status_code=status.HTTP_204_NO_CONTENT)
async with self._lock:
# Sessions-native steering: no previous_response_id on
# the wire, but a turn is actively streaming. Inject.
# Serialized under _lock so two concurrent requests
# can't both pass the guard before either sets
# _active_turn_ctx.
if (
request.previous_response_id is None
and self._active_turn_ctx is not None
and not self._active_turn_ctx.cancelled.is_set()
):
self._active_turn_ctx._push_injection(request)
return Response(status_code=status.HTTP_204_NO_CONTENT)
if self._shutting_down.is_set():
raise OmnigentError(
"harness is shutting down; refusing new turn",
code=ErrorCode.CONFLICT,
)
response_id = f"resp_{uuid.uuid4().hex[:24]}"
event_queue: asyncio.Queue[HarnessStreamEvent | None] = asyncio.Queue()
cancelled = asyncio.Event()
ctx = TurnContext(
response_id=response_id,
event_queue=event_queue,
cancelled=cancelled,
)
self._in_flight[response_id] = ctx
self._active_turn_ctx = ctx
return StreamingResponse(
self._stream_turn(request, ctx),
media_type="text/event-stream",
)
async def _stream_turn(
self, request: CreateResponseRequest, ctx: TurnContext
) -> AsyncIterator[bytes]:
"""
Drive ``run_turn`` and yield SSE-formatted events.
Three phases:
1. Emit the initial response.created + response.in_progress
frames carrying the freshly-allocated response_id.
2. Spawn ``run_turn`` + heartbeat as concurrent tasks;
consume the event queue, assigning monotonic sequence
numbers, and yield each event as an SSE frame.
3. On run_turn termination (sentinel from
``_guarded_run_turn``), emit the terminal event
(completed / failed / cancelled) and tear down both
tasks + the in-flight registry entry.
:param request: The request body to pass to ``run_turn``.
:param ctx: The per-turn :class:`TurnContext`.
:yields: SSE-formatted ``bytes`` ready to ship over the
HTTP response.
"""
sequence = 0
for initial_event in self._initial_envelope_events(
ctx, model=request.model, start_seq=sequence
):
yield _format_sse_event(initial_event)
sequence += 1
run_task = asyncio.create_task(
self._guarded_run_turn(request, ctx),
name=f"harness-run-turn:{ctx.response_id}",
)
heartbeat_task = asyncio.create_task(
self._heartbeat_loop(ctx),
name=f"harness-heartbeat:{ctx.response_id}",
)
# Track the sequence_number of the last non-heartbeat
# event emitted on this stream so we can stamp the
# heartbeat's ``last_event_seq`` field at yield time.
# Per the contract (§Heartbeats), heartbeats carry the
# *previous* user-visible event's sequence number so
# consumers can detect dropped events. ``None`` until
# the first non-heartbeat event lands; the initial
# response.created / response.in_progress envelope
# events count, so this populates immediately.
last_event_seq: int | None = sequence - 1 if sequence > 0 else None
try:
while True:
event = await ctx._event_queue.get()
if event is None:
# Sentinel pushed by ``_guarded_run_turn`` when
# ``run_turn`` returns; emit terminal event
# below.
break
event.sequence_number = sequence
if isinstance(event, HeartbeatEvent):
# Stamp timing metadata at emit time, not
# construction time, so ``server_time`` reflects
# when the event actually leaves the queue and
# ``last_event_seq`` reflects the actual stream
# state at that moment.
event.server_time = _utc_now_iso()
event.last_event_seq = last_event_seq
else:
last_event_seq = sequence
sequence += 1
yield _format_sse_event(event)
terminal = await self._build_terminal_event(
ctx, model=request.model, run_task=run_task, sequence=sequence
)
# Clear before yielding the terminal event so the next
# request (continuation turn) sees _active_turn_ctx as
# None and starts a new turn instead of injecting into
# this completed one.
async with self._lock:
if self._active_turn_ctx is ctx:
self._active_turn_ctx = None
yield _format_sse_event(terminal)
finally:
await self._teardown_turn(ctx, run_task, heartbeat_task)
def _initial_envelope_events(
self, ctx: TurnContext, model: str, start_seq: int
) -> list[ServerStreamEvent]:
"""
Build the response.created + response.in_progress events
emitted at the start of every turn.
Matches AP's existing streaming contract — both events
carry the freshly-allocated :class:`ResponseObject` so
consumers can correlate subsequent PATCH / cancel /
elicitation calls back to this turn via the response_id.
:param ctx: The per-turn context (provides ``response_id``).
:param model: The agent name from the incoming
:class:`CreateResponseRequest.model`, e.g.
``"research-agent"``. Required because
:class:`ResponseObject` requires a real ``model`` value
(no empty-string sentinels per the project's
anti-pattern checklist).
:param start_seq: Sequence number to assign to the first
event; the second is ``start_seq + 1``.
:returns: A two-event list:
``[CreatedEvent, InProgressEvent]``.
"""
initial_response = ResponseObject(
id=ctx.response_id,
status="queued",
model=model,
created_at=int(time.time()),
)
in_progress_response = initial_response.model_copy(update={"status": "in_progress"})
return [
CreatedEvent(
type="response.created",
response=initial_response,
sequence_number=start_seq,
),
InProgressEvent(
type="response.in_progress",
response=in_progress_response,
sequence_number=start_seq + 1,
),
]
async def _teardown_turn(
self,
ctx: TurnContext,
run_task: asyncio.Task[None],
heartbeat_task: asyncio.Task[None],
) -> None:
"""
Cancel the heartbeat + (defensively) the run task, then
unregister the turn from ``_in_flight``.
Always called from ``_stream_turn``'s finally block —
guarantees no asyncio task leaks per turn even on
cancellation / exception paths.
:param ctx: The per-turn context (used for the response_id
registry key).
:param run_task: The completed (or to-be-cancelled)
run_turn task.
:param heartbeat_task: The heartbeat emitter task; always
still running at this point (it loops forever) and
needs explicit cancellation.
"""
heartbeat_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await heartbeat_task
if not run_task.done():
# Defensive: run_turn might still be parked on a
# Future the cleanup didn't release. Cancel here as a
# last resort so the task doesn't leak.
# ``CancelledError`` is the expected exit; don't
# blanket-suppress unrelated exceptions because a
# crashed run_turn at this stage indicates a real bug
# worth surfacing in the logs.
run_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await run_task
async with self._lock:
self._in_flight.pop(ctx.response_id, None)
if self._active_turn_ctx is ctx:
self._active_turn_ctx = None
async def _guarded_run_turn(self, request: CreateResponseRequest, ctx: TurnContext) -> None:
"""
Wrap ``run_turn`` so its termination always pushes the
sentinel onto the event queue.
Without this, an exception or early return in ``run_turn``
would leave the streaming loop blocked on
``event_queue.get()`` forever.
Enforces two per-turn watchdogs, whichever trips first:
- IDLE (:data:`_TURN_IDLE_TIMEOUT_S`): each non-heartbeat
``ctx.emit`` reschedules its deadline via the
``_reset_idle_watchdog`` hook, so a turn that keeps making
progress (an orchestrator running tests + build + many tool
calls) is never killed — only one that emits nothing for the
whole window. This replaces the prior fixed *cumulative* cap,
which guillotined long-but-healthy turns mid-stream.
- ABSOLUTE (:data:`_TURN_ABSOLUTE_TIMEOUT_S`): a hard ceiling on
total duration, never rescheduled. Backstops the idle watchdog
against a runaway-but-active loop the idle one never sees as
stuck.
Either expiry surfaces a wedged/runaway ``run_turn`` as
``response.failed``.
:param request: Forwarded to ``run_turn``.
:param ctx: Forwarded to ``run_turn``.
"""
idle_timeout = _TURN_IDLE_TIMEOUT_S
absolute_timeout = _TURN_ABSOLUTE_TIMEOUT_S
# ``asyncio.timeout(None)`` is a no-op, so ``<= 0`` disables each.
idle_wd = asyncio.timeout(idle_timeout if idle_timeout > 0 else None)
absolute_wd = asyncio.timeout(absolute_timeout if absolute_timeout > 0 else None)
if idle_timeout > 0:
loop = asyncio.get_running_loop()
def _reset() -> None:
# Push ONLY the idle deadline ``idle_timeout`` s past now
# (the absolute ceiling is never rescheduled). Called from
# ``ctx.emit`` during ``run_turn`` (inside the active
# context), so the reschedule is always valid.
idle_wd.reschedule(loop.time() + idle_timeout)
ctx._reset_idle_watchdog = _reset
try:
# Absolute outer, idle inner: ``.expired()`` on each tells which
# ceiling tripped so the error message is accurate.
async with absolute_wd, idle_wd:
await self.run_turn(request, ctx)
except TimeoutError as exc:
if idle_wd.expired():
_logger.warning(
"run_turn for %s made no progress for %.0fs (idle turn watchdog); "
"marking the turn failed",
ctx.response_id,
idle_timeout,
)
# A native forwarder that can't reach the server (e.g.
# ``No route to host``) starves the turn of progress events, so
# the idle watchdog — not the connectivity error — is what fails
# the turn. If such a POST failure was recorded recently, attach
# it so the user sees the real cause rather than a generic
# "wedged LLM" reason. The window is twice the
# idle timeout: the failure that began the stall is already
# ~idle_timeout old when the watchdog fires, so a window equal
# to the stall would race past it; 2x captures it while still
# ignoring a long-resolved earlier blip.
forwarder_failure = native_forwarder_health.recent_post_failure(idle_timeout * 2)
cause = (
f"likely a wedged LLM or tool call; "
f"recent forwarder POST failure ({forwarder_failure})"
if forwarder_failure is not None
else "likely a wedged LLM or tool call"
)
raise RuntimeError(
f"turn exceeded the {idle_timeout:.0f}s harness idle watchdog "
f"(run_turn emitted no events for {idle_timeout:.0f}s; {cause})"
) from exc
if absolute_wd.expired():
_logger.warning(
"run_turn for %s exceeded the %.0fs absolute turn ceiling; "
"marking the turn failed",
ctx.response_id,
absolute_timeout,
)
raise RuntimeError(
f"turn exceeded the {absolute_timeout:.0f}s harness absolute watchdog "
f"(total turn duration cap; the turn kept emitting but never finished)"
) from exc
# Neither ceiling tripped — an inner ``run_turn`` TimeoutError;
# pass it through unchanged.
raise
except asyncio.CancelledError:
# Re-raised so the streaming-side ``run_task.exception()``
# check sees it; the terminal event handling
# downgrades a cancelled run_turn to a
# ``response.cancelled`` event.
raise
finally:
# Detach the reset hook before the timeout context unwinds so
# a stray late ``emit`` can't reschedule a finished timeout.
ctx._reset_idle_watchdog = None
# Sentinel that tells ``_stream_turn`` to stop reading
# the queue and emit the terminal event.
ctx._event_queue.put_nowait(None)
async def _heartbeat_loop(self, ctx: TurnContext) -> None:
"""
Emit ``response.heartbeat`` on the queue every
:data:`_HEARTBEAT_INTERVAL_S`.
Cancellation is the normal exit path — the streaming
wrapper cancels this task when the turn finalizes.
:param ctx: The per-turn context whose queue receives
heartbeats.
"""
while True:
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
ctx.emit(HeartbeatEvent(type="response.heartbeat"))
async def _build_terminal_event(
self,
ctx: TurnContext,
model: str,
run_task: asyncio.Task[None],
sequence: int,
) -> ServerStreamEvent:
"""
Construct the terminal SSE event after ``run_turn`` returns.
:param ctx: The per-turn context (used to read
``response_id``).
:param model: The agent name from the incoming
:class:`CreateResponseRequest.model`. Required so the
terminal :class:`ResponseObject` carries a real model
value (no empty-string sentinels per the project's
anti-pattern checklist).
:param run_task: The completed (or failed) ``run_turn``
task whose exception state determines the event type.
:param sequence: The sequence number to assign to the
terminal event.
:returns: A :class:`CompletedEvent`, :class:`FailedEvent`,
or cancelled-event variant carrying a synthesized
:class:`ResponseObject`. The synthesized object is
minimal — AP's persistence path constructs the
authoritative ResponseObject.
"""
# The sentinel that gets us here is queued from ``run_turn``'s
# ``finally`` block, so the streaming side can observe it before the
# task is fully terminal. Wait for that last scheduling tick before
# inspecting task state; otherwise a cancel/error race can make the
# terminal-event builder raise while trying to classify the result.
exception: BaseException | None = None
if not run_task.done():
try:
await asyncio.shield(run_task)
except asyncio.CancelledError:
if not run_task.done():
raise
except Exception as exc:
exception = exc
if exception is None and run_task.done() and not run_task.cancelled():
try:
# ``run_task.exception()`` is ``None`` on clean return, an
# exception instance otherwise. It can still raise
# CancelledError for cancellation races; classify that as a
# cancelled terminal instead of letting terminal synthesis fail.
exception = run_task.exception()
except asyncio.CancelledError:
exception = None
cancelled = run_task.cancelled() or ctx.cancelled.is_set()
if cancelled:
status_value = "cancelled"
elif exception is not None:
status_value = "failed"
else:
status_value = "completed"
# Convert the inner executor's usage dict to a ResponseObject
# Usage instance so the harness client can read it from the
# serialized response.completed SSE payload.
usage: Usage | None = None
if ctx.provider_usage is not None:
u = ctx.provider_usage
usage = Usage(
input_tokens=u.get("input_tokens") or 0,
output_tokens=u.get("output_tokens") or 0,
total_tokens=u.get("total_tokens") or 0,
context_tokens=u.get("context_tokens"),
# Carry the cache breakdown through to response.completed
# so the server-side cost path can price cache reads /
# writes at their own rates. Anthropic-style executors
# (e.g. claude-sdk) report these; others omit them (0).
cache_read_input_tokens=u.get("cache_read_input_tokens") or 0,
cache_creation_input_tokens=u.get("cache_creation_input_tokens") or 0,
# Harness-reported model for cost pricing (ResponseObject.model is the agent name).
model=u.get("model"),
# Authoritative per-turn cost reported by the harness (e.g. Copilot
# AI credits); preferred over the catalog estimate. None if absent.
cost_usd=u.get("cost_usd"),
)
response = ResponseObject(
id=ctx.response_id,
status=status_value,
model=model,
created_at=int(time.time()),
error=None if exception is None else self._build_error_detail(exception),
usage=usage,
)
terminal: ServerStreamEvent
if status_value == "completed":
terminal = CompletedEvent(
type="response.completed", response=response, sequence_number=sequence
)
elif status_value == "failed":
terminal = FailedEvent(
type="response.failed", response=response, sequence_number=sequence
)
else:
from omnigent.server.schemas import CancelledEvent
terminal = CancelledEvent(
type="response.cancelled",
response=response,
sequence_number=sequence,
)
return terminal
async def _resolve_elicitation(
self,
elicitation_id: str,
body: ElicitationResult,
) -> Response:
"""
Resolve a parked elicitation Future from an
:class:`ApprovalEvent`.
Invoked by :meth:`_post_session_event` for ``approval``
events. The id MUST belong to an outstanding elicitation
on one of the in-flight turns; otherwise raises 404.
:param elicitation_id: Correlation id from the event body.
:param body: MCP-shape :class:`ElicitationResult` adapted
from the :class:`ApprovalEvent`.
:returns: 204 on success.
:raises OmnigentError: 404 if no pending elicitation
matches the id (across all in-flight turns).
"""
for ctx in self._in_flight.values():
if ctx._complete_elicitation(elicitation_id, body):
return Response(status_code=status.HTTP_204_NO_CONTENT)
raise OmnigentError(
f"no outstanding elicitation {elicitation_id!r}",
code=ErrorCode.NOT_FOUND,
)
def _format_sse_event(event: HarnessStreamEvent) -> bytes:
"""
Serialize a typed event to an SSE wire frame.
Wire shape: ``event: <name>\\ndata: <json>\\n\\n``.
:param event: Any variant from
:data:`omnigent.server.schemas.ServerStreamEvent`.
:returns: UTF-8-encoded bytes ready to ship over the HTTP
response.
"""
payload = event.model_dump_json(exclude_none=True)
return f"event: {event.type}\ndata: {payload}\n\n".encode()
def _utc_now_iso() -> str:
"""
Return the current UTC wall-clock as an ISO 8601 string.
Used for ``HeartbeatEvent.server_time`` so consumers can
detect clock drift between producer and consumer. Format
matches the contract example: ``"2026-04-27T15:30:00Z"``
(trailing ``Z`` for UTC, microseconds elided to keep the
wire compact).
:returns: An ISO 8601 timestamp like
``"2026-04-27T15:30:00Z"``.
"""
import datetime
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# Note: ``HarnessApp._build_error_detail`` is the canonical
# implementation. Per-harness wraps that need to map SDK-specific
# exceptions (e.g. ``anthropic.RateLimitError``) onto the
# AP-side retryable allowlist override the method on their
# subclass — see
# :meth:`omnigent.runtime.harnesses._executor_adapter.ExecutorAdapter._build_error_detail`.
async def _health() -> dict[str, str]:
"""
Liveness probe — matches AP's ``/health`` shape.
:returns: ``{"status": "ok"}``.
"""
return {"status": "ok"}
async def _handle_omnigent_error(_request: Request, exc: OmnigentError) -> Response:
"""
Convert :class:`OmnigentError` into a JSON response with the
correct HTTP status.
Mirrors AP's exception handler at
``omnigent/server/app.py``.
:param _request: Unused (FastAPI signature requirement).
:param exc: The application error to render.
:returns: A JSON response with the error code + message.
"""
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=exc.http_status,
content={"error": {"code": exc.code, "message": exc.message}},
)