chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:00 +08:00
commit 3de48288cb
2986 changed files with 1131193 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# omnigent-client
Python client SDK for the [omnigent](https://github.com/omnigent-ai/omnigent)
server API.
`omnigent-client` is a typed client for driving omnigent sessions over the
server's HTTP + SSE API — creating sessions, sending turns, and streaming
responses. It shares the `StreamEvent` / `SessionStreamEventType` types that the
server emits, so streamed envelopes are validated against a single source of
truth.
It is released in lockstep with the core `omnigent` package at a matching
version:
```bash
pip install omnigent-client
```
See the [omnigent repository](https://github.com/omnigent-ai/omnigent) for full
documentation.
@@ -0,0 +1,129 @@
"""omnigent client SDK — Python client for the omnigent server API.
Headless HTTP/SSE client for invoking agents, tracking conversation
state, and consuming the response stream as either raw events or
semantic blocks. No UI or terminal dependencies — frontends layer
on top of this.
Usage::
from omnigent_client import OmnigentClient
async with OmnigentClient(base_url="http://localhost:8080") as client:
session = client.session(model="archer")
async for event in session.send("hello"):
...
Or consume semantic blocks via :class:`BlockStream`::
from omnigent_client import BlockStream, pipe, skip_intermediate_ends
stream = BlockStream()
async for block in pipe(
stream.stream(session, "hello"),
skip_intermediate_ends(),
):
...
"""
from ._blocks import (
AnyBlock,
BlockContext,
CompactionBlock,
ErrorBlock,
FileBlock,
NativeToolBlock,
ReasoningBlock,
ReasoningChunk,
ReasoningStartBlock,
ResponseEndBlock,
ResponseStartBlock,
RetryBlock,
StreamBlock,
TextChunk,
TextDone,
ToolExecution,
ToolGroup,
ToolResultBlock,
)
from ._child_status import (
TERMINAL_TASK_STATUSES,
child_session_busy,
child_summary_busy,
)
from ._client import OmnigentClient
from ._errors import OmnigentError, ToolCallDenied
from ._events import MCP_ELICITATION_METHOD, ElicitationRequest
from ._query import QueryResult, QueryStream
from ._server import LocalServer
from ._session import Session
from ._sessions import SessionsNamespace
from ._sessions_chat import SessionsChat, SessionToolCallInfo, ToolCallable
from ._stream import BlockStream, format_tool_args_brief
from ._tool_handler import (
ElicitationRequestCtx,
StreamHooks,
ToolCallInfo,
ToolHandler,
)
from ._transforms import (
merge_text_across_iterations,
only_agent,
pipe,
skip_blocks,
skip_intermediate_ends,
)
from ._types import File
from .tools import ToolMetadata, ToolState, tool
__all__ = [
"MCP_ELICITATION_METHOD",
"TERMINAL_TASK_STATUSES",
"AnyBlock",
"BlockContext",
"BlockStream",
"CompactionBlock",
"ElicitationRequest",
"ElicitationRequestCtx",
"ErrorBlock",
"File",
"FileBlock",
"LocalServer",
"NativeToolBlock",
"OmnigentClient",
"OmnigentError",
"QueryResult",
"QueryStream",
"ReasoningBlock",
"ReasoningChunk",
"ReasoningStartBlock",
"ResponseEndBlock",
"ResponseStartBlock",
"RetryBlock",
"Session",
"SessionToolCallInfo",
"SessionsChat",
"SessionsNamespace",
"StreamBlock",
"StreamHooks",
"TextChunk",
"TextDone",
"ToolCallDenied",
"ToolCallInfo",
"ToolCallable",
"ToolExecution",
"ToolGroup",
"ToolHandler",
"ToolMetadata",
"ToolResultBlock",
"ToolState",
"child_session_busy",
"child_summary_busy",
"format_tool_args_brief",
"merge_text_across_iterations",
"only_agent",
"pipe",
"skip_blocks",
"skip_intermediate_ends",
"tool",
]
@@ -0,0 +1,282 @@
"""Stream block types with context.
Every block carries a ``BlockContext`` describing which agent produced
it, at what depth, and in which turn. The simple case ignores context.
Multi-agent frontends route by ``block.ctx.agent``.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from ._types import Response
@dataclass
class BlockContext:
"""Metadata attached to every stream block.
:param agent: Name of the agent that produced this block, e.g.
``"coder.researcher"``. ``None`` for the root agent.
:param depth: Nesting depth of the agent in the sub-agent tree.
``0`` for the root agent.
:param turn: Turn number within the current response.
:param timestamp: Monotonic timestamp when the block was created.
"""
agent: str | None = None
depth: int = 0
turn: int = 0
timestamp: float = field(default_factory=time.monotonic)
@dataclass(kw_only=True)
class StreamBlock:
"""Base for all stream blocks."""
ctx: BlockContext = field(default_factory=BlockContext)
# ── Response lifecycle ───────────────────────────────────
@dataclass(kw_only=True)
class ResponseStartBlock(StreamBlock):
"""The response has started.
:param model: Agent model name, e.g. ``"coder"``.
:param response_id: Server-assigned response ID.
"""
model: str
response_id: str
# ── Tool calls ───────────────────────────────────────────
@dataclass(kw_only=True)
class ToolExecution:
"""A single tool call paired with its result.
:param name: Tool name, e.g. ``"Read"``.
:param arguments: Parsed arguments dict.
:param args_summary: One-line summary of the arguments, e.g. ``"test.py"``.
:param call_id: Server-assigned call ID.
:param agent_name: Name of the agent that invoked the tool.
:param executed_by: ``"server"`` or ``"client"``.
:param output: Tool output text, or ``None`` if not yet available.
"""
name: str
arguments: dict[str, object] = field(default_factory=dict)
args_summary: str
call_id: str
agent_name: str
executed_by: str = "server"
output: str | None = None
@dataclass(kw_only=True)
class ToolGroup(StreamBlock):
"""A batch of tool calls from one iteration.
:param executions: The tool calls in this group.
:param iteration: The iteration number within the response.
"""
executions: list[ToolExecution] = field(default_factory=list)
iteration: int = 0
@dataclass(kw_only=True)
class ToolResultBlock(StreamBlock):
"""A tool result, emitted after the tool executes.
:param name: Tool name, e.g. ``"Read"``.
:param call_id: Server-assigned call ID.
:param agent_name: Name of the agent that invoked the tool.
:param output: Tool output text.
:param arguments: Parsed arguments from the matching tool call,
retained so result-only renderers can use call metadata.
:param args_summary: One-line summary of the matching tool call
arguments.
"""
name: str
call_id: str
agent_name: str
output: str
arguments: dict[str, object] = field(default_factory=dict)
args_summary: str = ""
@dataclass(kw_only=True)
class NativeToolBlock(StreamBlock):
"""A provider-native tool output (web_search, mcp, etc.).
:param tool_type: Provider tool type, e.g. ``"web_search_call"``.
:param label: Human-readable label for display, e.g. ``"search"``.
:param data: Raw provider data dict.
"""
tool_type: str
label: str
data: dict[str, object] = field(default_factory=dict)
# ── Text ─────────────────────────────────────────────────
@dataclass(kw_only=True)
class TextChunk(StreamBlock):
"""A flushed chunk of streamed text.
:param text: The text content of this chunk.
"""
text: str
@dataclass(kw_only=True)
class TextDone(StreamBlock):
"""Complete text from a text-streaming section.
:param full_text: The complete accumulated text.
:param has_code_blocks: Whether the text contains fenced code blocks.
"""
full_text: str
has_code_blocks: bool = False
# ── Reasoning ────────────────────────────────────────────
@dataclass(kw_only=True)
class ReasoningStartBlock(StreamBlock):
"""Reasoning has started — show a thinking indicator."""
@dataclass(kw_only=True)
class ReasoningChunk(StreamBlock):
"""An incremental reasoning chunk — analog of :class:`TextChunk`.
Emitted while reasoning is still in progress so renderers can
show live progress (e.g. Codex's command/reasoning stream during
the long tool-call window). The eventual :class:`ReasoningBlock`
is suppressed when any chunks were emitted, to avoid the
formatter re-rendering the same text as a summary panel.
:param text: The incremental reasoning text, e.g. one flushed
line of summary tokens.
"""
text: str
@dataclass(kw_only=True)
class ReasoningBlock(StreamBlock):
"""A completed reasoning/thinking block.
Emitted only when no :class:`ReasoningChunk` was streamed for
this reasoning section. Carries the full accumulated reasoning
so non-streaming renderers (logs, web UIs that prefer cards)
still get a single summary block.
:param reasoning_text: The raw reasoning text.
:param summary_text: A summary of the reasoning.
"""
reasoning_text: str
summary_text: str
# ── Status ───────────────────────────────────────────────
@dataclass(kw_only=True)
class ErrorBlock(StreamBlock):
"""An error during the response.
:param message: The free-form error message from the server's
:class:`ErrorInfo`. May be empty when the server emitted
``response.error`` without populating it — renderers should
fall back to :attr:`code` in that case so the user sees at
least the error classification instead of a blank panel.
:param source: Where the error originated, e.g. ``"llm"``.
:param code: The machine-readable error code from the server's
:class:`ErrorInfo` payload (e.g. ``"llm_auth_failed"``,
``"executor_error"``). Empty when the server omitted it.
Carried separately from ``message`` so renderers can show
a useful label even when the free-form message is blank.
"""
message: str
source: str
code: str = ""
@dataclass(kw_only=True)
class RetryBlock(StreamBlock):
"""The server is retrying.
:param source: What is being retried, e.g. ``"tool"``.
:param attempt: Current attempt number.
:param max_attempts: Maximum retry attempts.
:param delay_seconds: Delay before the next attempt.
"""
source: str
attempt: int
max_attempts: int
delay_seconds: float
@dataclass(kw_only=True)
class CompactionBlock(StreamBlock):
"""Conversation is being compacted."""
@dataclass(kw_only=True)
class FileBlock(StreamBlock):
"""A file artifact produced by the agent.
:param file_id: Server-assigned file ID.
:param filename: Original filename, or ``None`` if unknown.
"""
file_id: str
filename: str | None = None
@dataclass(kw_only=True)
class ResponseEndBlock(StreamBlock):
"""The response reached a terminal state.
:param status: Terminal status, e.g. ``"completed"`` or ``"failed"``.
:param response: The full response object, or ``None``.
"""
status: str
response: Response | None = None
# Union of all block types (for type hints).
AnyBlock = (
ResponseStartBlock
| ToolGroup
| ToolResultBlock
| NativeToolBlock
| TextChunk
| TextDone
| ReasoningStartBlock
| ReasoningChunk
| ReasoningBlock
| ErrorBlock
| RetryBlock
| CompactionBlock
| FileBlock
| ResponseEndBlock
)
@@ -0,0 +1,77 @@
"""Canonical sub-agent "busy" predicate — the single source of truth shared
by the CLI REPL and SDK drivers.
A parent session's own ``status`` is per-session: a delegating agent fans out
sub-agents and returns to its own prompt, so the parent reads ``idle`` while
its children are still working. To answer *"is anything in this subtree still
working?"* both the CLI (the ``state: N agents running`` badge / ``↓`` menu in
:mod:`omnigent_ui_sdk.terminal`) and SDK rollups
(:meth:`SessionsNamespace.subtree_busy` / :meth:`SessionsChat.tree_busy`) need
one agreed definition of a *single* child being busy. That definition lives
here so the two can't drift.
The predicate mirrors the web ``SubagentsPanel`` ``childStatus`` semantics
(``web/src/shell/SubagentsPanel.tsx``): awaiting input outranks everything
(a sub-agent parked on an elicitation is still mid-turn), then ``launching``,
then the live ``busy`` flag, then any non-terminal ``current_task_status``.
This is deliberately stateless — no linger/debounce. The terminal host wraps it
with a UI-only linger window to stop the badge flickering between a child's
turns; an SDK eval driver wants the un-debounced ground truth instead.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
# Task statuses that mean the child's latest run has settled. Shared with the
# terminal host so the CLI's terminal set and this predicate stay in lockstep.
TERMINAL_TASK_STATUSES: tuple[str, ...] = ("completed", "failed", "cancelled")
def child_session_busy(
*,
busy: bool,
current_task_status: str | None,
pending_elicitations_count: int = 0,
) -> bool:
"""Return whether a single sub-agent counts as still working.
Mirrors the web ``SubagentsPanel`` / CLI ``N agents running`` semantics:
* ``pending_elicitations_count > 0`` — parked on an elicitation it must
answer first; still mid-turn, so it counts as busy (outranks ``busy``).
* ``current_task_status == "launching"`` — spawned, not yet running.
* ``busy`` — the live "session loop is running" flag from the server.
* any non-terminal ``current_task_status`` (e.g. ``in_progress`` /
``queued``) — a busy-flag gap between turns still counts.
A warm-idle child (loop idle, no in-progress task, no pending input) and a
terminal one (``completed`` / ``failed`` / ``cancelled``) return ``False``.
:param busy: ``ChildSessionSummary.busy`` — server status in
``("running", "waiting")``.
:param current_task_status: ``ChildSessionSummary.current_task_status`` —
``launching`` / ``in_progress`` / ``completed`` / ``failed`` /
``cancelled`` / ``None``.
:param pending_elicitations_count: number of approvals/prompts blocking the
child.
:returns: ``True`` while the child is still working.
"""
if pending_elicitations_count > 0 or busy or current_task_status == "launching":
return True
return current_task_status is not None and current_task_status not in TERMINAL_TASK_STATUSES
def child_summary_busy(summary: Mapping[str, Any]) -> bool:
""":func:`child_session_busy` applied to a raw ``ChildSessionSummary`` dict
as returned by :meth:`SessionsNamespace.child_sessions`.
Tolerates missing / ``None`` fields (e.g. a poll row with no task yet).
"""
return child_session_busy(
busy=bool(summary.get("busy")),
current_task_status=summary.get("current_task_status"),
pending_elicitations_count=int(summary.get("pending_elicitations_count") or 0),
)
@@ -0,0 +1,348 @@
"""OmnigentClient — the top-level client tying all namespaces together."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Literal, overload
import httpx
from omnigent.runner.identity import OMNIGENT_INTERNAL_WS_ORIGIN
from ._files import FilesNamespace
from ._query import QueryResult, QueryStream
from ._responses import ResponsesNamespace
from ._session import Session
from ._sessions import SessionsNamespace
from ._sessions_chat import SessionsChat, ToolCallable
from ._tool_handler import StreamHooks, ToolHandler
class OmnigentClient:
"""Typed Python client for the omnigent server API.
One-shot::
async with OmnigentClient(base_url="http://localhost:8080") as client:
result = await client.query(model="archer", input="hello")
print(result.text) # the assistant's reply
print(result.files) # any files the agent produced
Streaming::
stream = await client.query(model="archer", input="hi", stream=True)
async for chunk in stream:
print(chunk, end="", flush=True)
print(stream.files) # populated after the stream ends
Multi-turn conversation::
session = client.session(model="archer")
await session.query("hello")
await session.query("what did I just say?")
For access to raw events or semantic blocks (tool-call display,
reasoning, lifecycle), drop to :attr:`responses` or
:class:`BlockStream`.
:param base_url: Server base URL, e.g. ``"http://localhost:8080"``.
:param headers: Extra headers sent on every request (e.g. auth).
:param auth: Optional ``httpx.Auth`` for per-request
authentication. When set, the auth handler runs on every
request, allowing transparent token refresh for OAuth
flows. ``None`` (default) relies on static ``headers``.
:param timeout: Default timeout for HTTP requests in seconds.
"""
def __init__(
self,
base_url: str,
*,
headers: dict[str, str] | None = None,
auth: httpx.Auth | None = None,
# Public SDK API surface. Removing would be a breaking
# change for downstream consumers; leaving it
# accepted-but-ignored preserves compatibility while the
# SSE client internally uses a fixed 600s read timeout
# (tool calls can legitimately hold the stream open for
# minutes).
timeout: float = 30.0,
) -> None:
self._base_url = base_url.rstrip("/")
# Long read timeout for SSE streams (tool execution can
# pause the stream for minutes).
sse_timeout = httpx.Timeout(
connect=30.0,
read=600.0,
write=30.0,
pool=30.0,
)
# Announce this as a first-party non-browser client via the sentinel
# Origin. The server's require_trusted_origin CSRF guard on the
# multipart routes (POST /v1/sessions bundle create, file upload)
# requires a trusted Origin; the SDK sends none of its own, so the
# sentinel is what lets it through. Caller-supplied headers win on
# conflict (so an explicit Origin override is still honored).
default_headers = {"Origin": OMNIGENT_INTERNAL_WS_ORIGIN}
if headers:
default_headers.update(headers)
self._http = httpx.AsyncClient(
headers=default_headers,
auth=auth,
timeout=sse_timeout,
)
self.sessions = SessionsNamespace(self._http, self._base_url)
self.files = FilesNamespace(self._http, self._base_url)
self.responses = ResponsesNamespace(self._http, self._base_url)
def session(
self,
model: str,
*,
tool_handler: ToolHandler | None = None,
hooks: StreamHooks | None = None,
) -> Session:
"""Create a conversation session.
A session tracks ``previous_response_id`` automatically.
``send()`` auto-steers if a response is in progress, or
starts a new turn if the response is terminal.
:param model: Agent name.
:param tool_handler: Optional client-side tool execution config.
:param hooks: Optional lifecycle hooks.
:returns: A new :class:`Session`.
"""
return Session(
client=self,
model=model,
tool_handler=tool_handler,
hooks=hooks,
)
@overload
async def query(
self,
*,
model: str,
input: str | list[dict[str, object]],
tools: list[Callable[..., Any]] | None = ...,
tool_handler: ToolHandler | None = ...,
files: list[str] | None = ...,
reasoning: dict[str, str] | None = ...,
model_override: str | None = ...,
stream: Literal[False] = ...,
) -> QueryResult: ...
@overload
async def query(
self,
*,
model: str,
input: str | list[dict[str, object]],
tools: list[Callable[..., Any]] | None = ...,
tool_handler: ToolHandler | None = ...,
files: list[str] | None = ...,
reasoning: dict[str, str] | None = ...,
model_override: str | None = ...,
stream: Literal[True],
) -> QueryStream: ...
async def query(
self,
*,
model: str,
input: str | list[dict[str, object]],
tools: list[Callable[..., Any]] | None = None,
tool_handler: ToolHandler | None = None,
files: list[str] | None = None,
reasoning: dict[str, str] | None = None,
model_override: str | None = None,
stream: bool = False,
) -> QueryResult | QueryStream:
"""One-shot invocation: send a prompt, get text (plus any files) back.
Non-streaming (default) returns a :class:`QueryResult`::
result = await client.query(model="archer", input="hi")
print(result.text)
for f in result.files:
await client.files.for_session("<session-id>").download(
f.id, f"./out/{f.filename}"
)
Streaming returns a :class:`QueryStream`::
stream = await client.query(model="archer", input="hi", stream=True)
async for chunk in stream:
print(chunk, end="", flush=True)
# After iteration, stream.files holds the produced files.
With client-side tools, pass ``@tool``-decorated functions::
from omnigent_client import tool
@tool
def get_time() -> str:
'''Return the current time.'''
return datetime.now().isoformat()
result = await client.query(
model="archer", input="what time?", tools=[get_time],
)
Creates a single-turn session internally. For multi-turn
conversations, call :meth:`session` and use its ``query()``.
:param model: Agent name, e.g. ``"archer"``.
:param input: User text or a list of content-block dicts.
:param tools: List of ``@tool``-decorated Python functions
the agent may call. Mutually exclusive with ``tool_handler``.
:param tool_handler: Low-level escape hatch — a pre-built
:class:`ToolHandler` with custom schemas/dispatch. Most
callers should use ``tools=`` instead.
:param files: Optional list of local file paths to attach.
:param reasoning: Optional Responses API reasoning config, e.g. {"effort": "high"}.
:param model_override: Optional per-request LLM model override
(e.g. ``"openai/gpt-5.4-mini"``). Shadows the spec model
for this one-shot call; mirrors
:meth:`Session.set_model_override`.
:param stream: If True, return a :class:`QueryStream`. If
False (default), return a :class:`QueryResult`.
:returns: :class:`QueryResult` (``stream=False``) or
:class:`QueryStream` (``stream=True``).
:raises ValueError: If both ``tools`` and ``tool_handler``
are provided.
"""
handler = _resolve_tool_handler(tools=tools, tool_handler=tool_handler)
session = self.session(model=model, tool_handler=handler)
effort = reasoning.get("effort") if reasoning is not None else None
if effort is not None:
session.set_reasoning_effort(effort)
if model_override is not None:
session.set_model_override(model_override)
if stream:
return await session.query(input, files=files, stream=True)
return await session.query(input, files=files)
async def sessions_chat(
self,
bundle: bytes,
*,
filename: str = "agent.tar.gz",
tool_callables: dict[str, ToolCallable] | None = None,
hooks: StreamHooks | None = None,
) -> SessionsChat:
"""Create a sessions-API-native chat helper bound to a new session.
Counterpart to :meth:`session` but built on ``/v1/sessions``
rather than ``/v1/responses``. Use this for new code; the
legacy :meth:`session` is preserved for in-flight migrations.
:param bundle: Gzipped agent tarball bytes uploaded through
multipart ``POST /v1/sessions``.
:param filename: Filename for the multipart upload, e.g.
``"agent.tar.gz"``.
:param tool_callables: Optional mapping from tool name to
an executable callable (sync or async) for client-side
tool execution. Validated against the agent's
spec-declared tools at stream-start time (the first
``send()`` / ``query()`` / ``stream()`` call), not at
construction. See :class:`SessionsChat` for the
validation rules.
:param hooks: Optional lifecycle hooks fired from sessions
stream events.
:returns: A :class:`SessionsChat` ready for use.
:raises OmnigentError: If session creation fails.
"""
return await SessionsChat.create(
namespace=self.sessions,
bundle=bundle,
filename=filename,
files_namespace=self.files,
tool_callables=tool_callables,
agent_tools_getter=self._fetch_agent_tools,
hooks=hooks,
)
async def _fetch_agent_tools(
self, agent_id: str, session_id: str | None = None
) -> list[dict[str, Any]]:
"""
Fetch the spec-declared tool entries for an agent.
Used as the ``agent_tools_getter`` injection for
:class:`SessionsChat`. Reads the tool list off the
:class:`Agent` returned by ``GET /api/agents/{agent_id}``.
The server's :class:`AgentObject`
carries a ``tools`` list where each entry has a ``name``
and a ``runtime`` discriminator
(``"server"`` or ``"client"``). When that field is not yet
present, the SDK's :class:`Agent` dataclass simply lacks
the field and this returns ``[]`` — which means
validation succeeds for any caller that doesn't pass
``tool_callables``, and fails loud (with a clear "extra
callable" message) for any caller that does. That is the
correct degraded behavior: in F1's absence we cannot
verify the spec, but we will never silently accept a
broken setup.
:param agent_id: The agent's durable identifier, e.g.
``"ag_abc123"``.
:returns: List of tool-entry dicts with at least ``name``
and (post-F1) ``runtime`` keys. Empty if the agent
declares no tools or the server response shape
predates F1.
:raises OmnigentError: If the agents endpoint returns
a non-2xx (e.g. 404).
"""
if session_id is None:
return [] # No session context — cannot resolve agent tools
path = f"{self._base_url}/v1/sessions/{session_id}/agent"
resp = await self._http.get(path)
if resp.status_code != 200:
return []
agent_data = resp.json()
tools = agent_data.get("tools")
if isinstance(tools, list):
return [t for t in tools if isinstance(t, dict)]
return []
async def close(self) -> None:
"""Close the underlying HTTP client."""
await self._http.aclose()
async def __aenter__(self) -> OmnigentClient:
return self
async def __aexit__(self, *exc: object) -> None:
await self.close()
def _resolve_tool_handler(
*,
tools: list[Callable[..., Any]] | None,
tool_handler: ToolHandler | None,
) -> ToolHandler | None:
"""Pick one of ``tools=`` or ``tool_handler=``; reject both.
:param tools: High-level list of ``@tool``-decorated functions.
:param tool_handler: Low-level pre-built handler.
:returns: The handler to use, or ``None`` if neither was given.
:raises ValueError: If both were provided.
"""
if tools is not None and tool_handler is not None:
raise ValueError(
"Pass either `tools=[...]` or `tool_handler=...`, not both. "
"`tools=` is the high-level API (auto-builds a handler from "
"@tool-decorated functions); `tool_handler=` is the low-level "
"escape hatch."
)
if tools is not None:
# Local import keeps the dep inside the tools subpackage.
from .tools import build_tool_handler
return build_tool_handler(tools)
return tool_handler
@@ -0,0 +1,180 @@
"""Typed exceptions for the omnigent client."""
from __future__ import annotations
import httpx
_BODY_PREVIEW_CHARS = 200
class OmnigentError(Exception):
"""Base exception for all omnigent client errors."""
def __init__(self, message: str, status_code: int | None = None, code: str | None = None):
super().__init__(message)
self.status_code = status_code
self.code = code
class AgentNotFoundError(OmnigentError):
"""Agent name or ID not found (HTTP 404)."""
class ResponseNotFoundError(OmnigentError):
"""Response ID not found (HTTP 404)."""
class FileNotFoundError(OmnigentError):
"""File ID not found (HTTP 404)."""
class ConversationNotFoundError(OmnigentError):
"""Conversation ID not found (HTTP 404)."""
class InvalidInputError(OmnigentError):
"""Bad request — invalid input, missing fields, etc. (HTTP 400)."""
class ConflictError(OmnigentError):
"""Resource conflict — duplicate name, stale state, etc. (HTTP 409)."""
class BundleInvalidError(OmnigentError):
"""Agent bundle is invalid — corrupt tarball, bad config, etc. (HTTP 400)."""
class ServerError(OmnigentError):
"""Internal server error (HTTP 5xx)."""
class ToolCallDenied(Exception):
"""Raised by ``on_tool_call_start`` hook to deny a client-side tool call.
The exception message is sent back to the agent as the tool's output,
so the agent knows the call was denied and can adapt.
"""
def raise_for_status(status_code: int, body: dict[str, object] | str) -> None:
"""Raise a typed exception based on HTTP status code and error body.
Uses the server's error ``code`` field for classification when
available, falling back to status code only. Never relies on
substring matching in error messages.
"""
if status_code < 400:
return
if isinstance(body, dict):
error = body.get("error", {})
if isinstance(error, dict):
code = str(error.get("code", ""))
message = str(error.get("message", str(body)))
else:
code = ""
message = str(body)
else:
code = ""
message = str(body)
# Use the server's error code for precise classification.
_CODE_MAP: dict[str, type[OmnigentError]] = {
"not_found": OmnigentError,
"invalid_input": InvalidInputError,
"conflict": ConflictError,
"server_error": ServerError,
}
if status_code == 404:
# 404 is always "not found" — the specific type (agent, response,
# file, conversation) is determined by the endpoint the caller
# hit, not the error message. Callers can catch the base
# OmnigentError or the specific subclass at the call site.
raise OmnigentError(message, status_code, code)
if status_code == 409:
raise ConflictError(message, status_code, code)
if status_code == 400:
raise InvalidInputError(message, status_code, code)
if status_code >= 500:
raise ServerError(message, status_code, code)
raise OmnigentError(message, status_code, code)
def response_body(resp: httpx.Response) -> dict[str, object] | str:
"""
Decode an HTTP response body for status handling.
Server errors and proxy/auth failures are not guaranteed to
return JSON. This helper preserves structured JSON error
envelopes when available and falls back to raw response text
otherwise.
:param resp: HTTP response returned by httpx.
:returns: Parsed JSON object, or raw text when the body is not
a JSON object.
"""
try:
data = resp.json()
except ValueError:
return resp.text
if isinstance(data, dict):
return data
return resp.text
def require_json_object(resp: httpx.Response, endpoint: str) -> dict[str, object]:
"""
Return a successful endpoint body as a JSON object.
Used after :func:`raise_for_status` when an endpoint's success
contract is a JSON object. A non-JSON response at this point is a
protocol/base-URL/auth problem, so raise :class:`OmnigentError`
with status, content type, and a short body preview instead of
leaking ``JSONDecodeError``.
:param resp: HTTP response returned by httpx.
:param endpoint: Human-readable endpoint label, e.g.
``"GET /v1/conversations"``.
:returns: Parsed JSON object body.
:raises OmnigentError: If the body is not a JSON object.
"""
try:
data = resp.json()
except ValueError as exc:
content_type = resp.headers.get("content-type")
content_type_detail = (
f"content-type={content_type}"
if content_type is not None
else "no content-type header"
)
preview = _response_text_preview(resp.text)
raise OmnigentError(
f"{endpoint} returned non-JSON response "
f"(status={resp.status_code}, {content_type_detail}): {preview}",
resp.status_code,
) from exc
if not isinstance(data, dict):
raise OmnigentError(
f"{endpoint} returned JSON {type(data).__name__}, expected object",
resp.status_code,
)
return data
def _response_text_preview(text: str) -> str:
"""
Return a compact, single-line response body preview.
:param text: Raw response text from HTTPX.
:returns: Whitespace-normalized body preview, capped at
``_BODY_PREVIEW_CHARS`` characters, or ``"<empty body>"``.
"""
preview = " ".join(text.split())[:_BODY_PREVIEW_CHARS]
if preview:
return preview
return "<empty body>"
@@ -0,0 +1,341 @@
"""Typed event dataclasses for SSE stream events.
The client parses raw SSE frames into these types. Consumers
iterate over them via ``async for event in stream``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from ._types import ErrorInfo, Response
# ── Native tool type constants ───────────────────────────
NATIVE_TOOL_TYPES: frozenset[str] = frozenset(
{
"web_search_call",
"file_search_call",
"code_interpreter_call",
"computer_call",
"image_generation_call",
"mcp_call",
"mcp_list_tools",
}
)
# JSON-RPC method name MCP uses for elicitation requests. The
# server's ``response.elicitation_request`` SSE event carries this
# verbatim under ``method`` so MCP-aware consumers can route on
# the same name they already recognize.
MCP_ELICITATION_METHOD = "elicitation/create"
# ── Response lifecycle events ────────────────────────────
@dataclass
class ResponseCreated:
"""``response.created`` — always first (sequence 0)."""
response: Response
@dataclass
class ResponseQueued:
"""``response.queued`` — only when ``background=True``."""
response: Response
@dataclass
class ResponseInProgress:
"""``response.in_progress`` — execution started."""
response: Response
@dataclass
class ResponseCompleted:
"""``response.completed`` — agent finished successfully."""
response: Response
@dataclass
class ResponseFailed:
"""``response.failed`` — unrecoverable error."""
response: Response
@dataclass
class ResponseIncomplete:
"""``response.incomplete`` — stopped early."""
response: Response
reason: str # "max_iterations", "execution_timeout", etc.
@dataclass
class ResponseCancelled:
"""``response.cancelled`` — cancelled via POST /cancel."""
response: Response
# ── Text streaming ───────────────────────────────────────
@dataclass
class TextDelta:
"""``response.output_text.delta`` — incremental text token."""
delta: str
# ── Reasoning ────────────────────────────────────────────
@dataclass
class ReasoningStarted:
"""``response.reasoning.started`` — reasoning block opened."""
@dataclass
class ReasoningDelta:
"""``response.reasoning_text.delta`` — reasoning token."""
delta: str
@dataclass
class ReasoningSummaryDelta:
"""``response.reasoning_summary_text.delta`` — summary token."""
delta: str
# ── Parsed output items ─────────────────────────────────
@dataclass
class ToolCall:
"""A tool call from ``output_item.done`` (type ``function_call``)."""
name: str
arguments: dict[str, object]
call_id: str
status: str # "completed", "action_required", "incomplete"
agent_name: str # "coder" or "coder.researcher"
@dataclass
class ToolResult:
"""A tool result from ``output_item.done`` (type ``function_call_output``).
``arguments`` is optional because the OpenAI-compatible
``function_call_output`` item only requires ``call_id`` and ``output``.
Omnigent producers may include it as a convenience copy of the
originating ``function_call.arguments`` so result-only renderers can use
call metadata (for example, rendering ``sys_os_edit`` as a diff).
"""
call_id: str
output: str
arguments: dict[str, object] = field(default_factory=dict)
@dataclass
class ElicitationRequest:
"""
A server-initiated elicitation, MCP shape.
Parsed out of the ``response.elicitation_request`` SSE event;
the event's ``params`` block matches MCP's
``ElicitRequestFormParams`` field-for-field, plus extras
(``phase``, ``policy_name``, ``content_preview``) under MCP's
``extra="allow"`` config that today's only producer (the
policy ASK flow) populates for renderer use.
Consumers respond via
``POST /v1/sessions/{session_id}/events`` with
``type == "approval"`` and MCP-shape ``ElicitResult`` fields
(``action`` + optional ``content``) in ``data``. The client
handles the POST automatically when an
``on_elicitation_request`` hook is registered on
:class:`StreamHooks`.
:param elicitation_id: Server-assigned id. Used in the
approval event payload, e.g. ``"elicit_abc123"``.
:param message: Human-readable prompt the consumer should
render to the user. For the policy ASK producer this is
the combined reason string from deciding ASKing policies.
:param requested_schema: A restricted subset of JSON Schema
defining the structure of the expected response. Empty
``{}`` for binary approve/reject elicitations (the verdict
is in the consumer's ``action``).
:param mode: MCP elicitation mode — ``"form"`` (inline) or
``"url"`` (standalone approval page). e.g. ``"url"``.
:param phase: Producer-supplied extra (policy ASK only):
which enforcement point produced the ASK — one of
``"input"``, ``"tool_call"``, ``"tool_result"``,
``"output"``. Empty string when the elicitation came from
a producer that doesn't populate this extra.
:param policy_name: Producer-supplied extra (policy ASK
only): name of the deciding (first-in-YAML-order) ASKing
policy, e.g. ``"approve_web_search"``. Empty string when
not applicable.
:param content_preview: Producer-supplied extra (policy ASK
only): truncated snapshot of the gated content. Safe to
display verbatim. Empty string when not applicable.
:param target_session_id: Session whose resolve endpoint owns
this elicitation, e.g. ``"conv_child123"``. Set when a
sub-agent's prompt was mirrored into an ancestor stream so the
consumer can route the verdict back to the child that parked on
it. ``None`` means resolve against the session the event arrived
on.
"""
elicitation_id: str
message: str
requested_schema: dict[str, object]
mode: str
phase: str
policy_name: str
content_preview: str
url: str | None = None
target_session_id: str | None = None
@dataclass
class NativeToolCall:
"""A provider-native tool output (web_search, mcp, etc.)."""
tool_type: str # e.g. "web_search_call"
data: dict[str, object]
@dataclass
class MessageDone:
"""The final assistant message from ``output_item.done`` (type ``message``)."""
content: list[dict[str, object]] = field(default_factory=list)
# ── File output ──────────────────────────────────────────
@dataclass
class OutputFileDone:
"""``response.output_file.done`` — file artifact produced."""
file_id: str
filename: str | None = None
content_type: str | None = None
# ── Error and retry ──────────────────────────────────────
@dataclass
class RetryEvent:
"""``response.retry`` — a retryable failure, will retry."""
source: str # "llm" or "tool"
tool_name: str | None
attempt: int
max_attempts: int
delay_seconds: float
error: ErrorInfo
@dataclass
class ErrorEvent:
"""``response.error`` — an error during execution."""
source: str # "llm" or "tool"
tool_name: str | None
error: ErrorInfo
# ── Compaction ───────────────────────────────────────────
@dataclass
class CompactionInProgress:
"""``response.compaction.in_progress`` — server started compacting."""
@dataclass
class CompactionCompleted:
"""``response.compaction.completed`` — compaction finished successfully."""
@dataclass
class CompactionFailed:
"""``response.compaction.failed`` — compaction failed; history unchanged."""
# ── Async client-tool cancel (Phase 5) ───────────────────
@dataclass
class ClientTaskCancel:
"""
``response.client_task.cancel`` — server-to-client cancel
notification for async client-tool dispatches.
Emitted when an async client tool task
(``kind="client_tool"``) was cancelled mid-flight, either
via direct ``sys_cancel_task`` or via parent-cancel
propagation. The SDK should cancel the matching local
``asyncio.Task`` running the tool body. The body's
eventual PATCH back is a no-op (G3 first-write-wins), so
the cancel is best-effort from the SDK's perspective —
the server has already decided the task is cancelled.
:param task_id: The server-issued client-tool task id from
the original ``function_call_output`` handle, e.g.
``"resp_async_xyz"``.
:param call_id: The synthesized ``function_call.call_id``
the SDK saw on the action_required event for this
dispatch, e.g. ``"call_async_b2c4..."``. Populated by
the server from ``pending_tool_calls`` so the SDK can
look up the local ``asyncio.Task`` it spawned. ``None``
on legacy emissions where the lookup wasn't done; the
SDK falls back to no-op when both fields can't be
matched to a tracked task.
"""
task_id: str
call_id: str | None = None
# ── Union type for all events ────────────────────────────
StreamEvent = (
ResponseCreated
| ResponseQueued
| ResponseInProgress
| ResponseCompleted
| ResponseFailed
| ResponseIncomplete
| ResponseCancelled
| TextDelta
| ReasoningStarted
| ReasoningDelta
| ReasoningSummaryDelta
| ToolCall
| ToolResult
| NativeToolCall
| MessageDone
| OutputFileDone
| RetryEvent
| ErrorEvent
| CompactionInProgress
| CompactionCompleted
| CompactionFailed
| ClientTaskCancel
| ElicitationRequest
)
@@ -0,0 +1,240 @@
"""Session-scoped files namespace — upload, list, get, delete."""
from __future__ import annotations
import mimetypes
import pathlib
from urllib.parse import quote
import httpx
from ._errors import raise_for_status, require_json_object, response_body
from ._types import File, PaginatedList
class FilesNamespace:
"""
Factory for session-scoped file namespaces.
:param http: Shared async HTTP client.
:param base_url: Server base URL, e.g. ``"http://localhost:8080"``.
"""
def __init__(self, http: httpx.AsyncClient, base_url: str) -> None:
"""
Initialize the unbound files namespace.
:param http: Shared async HTTP client.
:param base_url: Server base URL, e.g. ``"http://localhost:8080"``.
:returns: None.
"""
self._http = http
self._base = base_url
def for_session(self, session_id: str) -> SessionFilesNamespace:
"""
Return a file namespace bound to one session.
``/v1/files`` has been removed; callers must scope uploads and
downloads to the session/conversation that owns the file.
:param session_id: Session/conversation id, e.g. ``"conv_abc123"``.
:returns: A :class:`SessionFilesNamespace` bound to that session.
"""
return SessionFilesNamespace(self._http, self._base, session_id)
async def upload(self, path: str) -> File:
"""Legacy global upload removed; use ``for_session(id).upload``."""
raise RuntimeError(
"/v1/files was removed; use client.files.for_session(session_id).upload(path)"
)
async def list(self, *args: object, **kwargs: object) -> list[File]:
"""Legacy global list removed; use ``for_session(id).list``."""
raise RuntimeError(
"/v1/files was removed; use client.files.for_session(session_id).list()"
)
async def get(self, file_id: str) -> File:
"""Legacy global get removed; use ``for_session(id).get``."""
raise RuntimeError(
"/v1/files was removed; use client.files.for_session(session_id).get(file_id)"
)
async def get_content(self, file_id: str) -> bytes:
"""Legacy global content download removed; use ``for_session(id).get_content``."""
raise RuntimeError(
"/v1/files was removed; use client.files.for_session(session_id).get_content(file_id)"
)
async def download(self, file_id: str, to_path: str | pathlib.Path) -> pathlib.Path:
"""Legacy global download removed; use ``for_session(id).download``."""
raise RuntimeError(
"/v1/files was removed; use "
"client.files.for_session(session_id).download(file_id, path)"
)
async def delete(self, file_id: str) -> None:
"""Legacy global delete removed; use ``for_session(id).delete``."""
raise RuntimeError(
"/v1/files was removed; use client.files.for_session(session_id).delete(file_id)"
)
class SessionFilesNamespace:
"""
File operations scoped to a single session.
:param http: Shared async HTTP client.
:param base_url: Server base URL, e.g. ``"http://localhost:8080"``.
:param session_id: Session/conversation id, e.g. ``"conv_abc123"``.
"""
def __init__(self, http: httpx.AsyncClient, base_url: str, session_id: str) -> None:
"""
Initialize the session-scoped files namespace.
:param http: Shared async HTTP client.
:param base_url: Server base URL, e.g. ``"http://localhost:8080"``.
:param session_id: Session/conversation id, e.g. ``"conv_abc123"``.
:returns: None.
"""
self._http = http
self._base = base_url
self._session_id = session_id
@property
def session_id(self) -> str:
"""
The session/conversation id this namespace is bound to.
:returns: Session id, e.g. ``"conv_abc123"``.
"""
return self._session_id
@property
def _path(self) -> str:
"""
Session-scoped files collection URL.
:returns: Fully qualified endpoint URL.
"""
return f"{self._base}/v1/sessions/{quote(self._session_id, safe='')}/resources/files"
@staticmethod
def _resource_to_file(data: dict[str, object]) -> File:
"""
Convert a session file resource response to SDK ``File``.
:param data: JSON object returned by a session file endpoint.
:returns: The corresponding :class:`File`.
"""
metadata = data.get("metadata")
metadata_dict = metadata if isinstance(metadata, dict) else {}
return File.from_dict(
{
"id": data.get("id", ""),
"filename": metadata_dict.get("filename", data.get("name", "")),
"bytes": metadata_dict.get("bytes", 0),
"created_at": metadata_dict.get("created_at", 0),
}
)
async def upload(self, path: str) -> File:
"""
Upload a local file into this session's file namespace.
:param path: Path to the local file, e.g. ``"./report.pdf"``.
:returns: The uploaded file metadata.
"""
p = pathlib.Path(path)
content_type = mimetypes.guess_type(str(p))[0]
with open(p, "rb") as f:
resp = await self._http.post(
self._path,
files={"file": (p.name, f, content_type)},
timeout=30.0,
)
raise_for_status(resp.status_code, response_body(resp))
return self._resource_to_file(
require_json_object(resp, "POST /v1/sessions/{session_id}/resources/files")
)
async def list(
self,
*,
limit: int = 20,
after: str | None = None,
order: str = "desc",
) -> list[File]:
"""
List files owned by this session.
:param limit: Maximum number of files to return.
:param after: Cursor for forward pagination,
e.g. ``"file_abc123"``.
:param order: Sort order, e.g. ``"desc"``.
:returns: File metadata entries.
"""
params: dict[str, object] = {"limit": limit, "order": order}
if after is not None:
params["after"] = after
resp = await self._http.get(self._path, params=params)
raise_for_status(resp.status_code, response_body(resp))
page = PaginatedList.from_dict(
require_json_object(resp, "GET /v1/sessions/{session_id}/resources/files")
)
return [self._resource_to_file(d) for d in page.data]
async def get(self, file_id: str) -> File:
"""
Get session file metadata by ID.
:param file_id: Server-issued file id, e.g. ``"file_abc123"``.
:returns: File metadata.
"""
resp = await self._http.get(f"{self._path}/{quote(file_id, safe='')}")
raise_for_status(resp.status_code, response_body(resp))
return self._resource_to_file(
require_json_object(resp, "GET /v1/sessions/{session_id}/resources/files/{file_id}")
)
async def get_content(self, file_id: str) -> bytes:
"""
Download session file content.
:param file_id: Server-issued file id, e.g. ``"file_abc123"``.
:returns: Raw file bytes.
"""
resp = await self._http.get(
f"{self._path}/{quote(file_id, safe='')}/content",
timeout=30.0,
)
if resp.status_code >= 400:
raise_for_status(resp.status_code, response_body(resp))
return resp.content
async def download(self, file_id: str, to_path: str | pathlib.Path) -> pathlib.Path:
"""
Download file content and write it to disk.
:param file_id: Server-issued file id, e.g. ``"file_abc123"``.
:param to_path: Local output path, e.g. ``"./out/report.pdf"``.
:returns: The path that was written.
"""
content = await self.get_content(file_id)
path = pathlib.Path(to_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
return path
async def delete(self, file_id: str) -> None:
"""
Delete a session-scoped file.
:param file_id: Server-issued file id, e.g. ``"file_abc123"``.
:returns: None.
"""
resp = await self._http.delete(f"{self._path}/{quote(file_id, safe='')}")
if resp.status_code >= 400:
raise_for_status(resp.status_code, response_body(resp))
@@ -0,0 +1,92 @@
"""Return types for the ``query()`` convenience API.
:class:`QueryResult` is returned from ``query(stream=False)`` (the
default). It carries the final assistant text plus any file artifacts
the agent produced on this turn.
:class:`QueryStream` is returned from ``query(stream=True)``. It is
an async-iterable of text chunks. After the iteration completes, its
``files`` attribute holds the same list of produced files.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from ._types import File
@dataclass(frozen=True)
class QueryResult:
"""Non-streaming result of a ``query()`` call.
:param text: The assistant's final text, joined across tool-loop
iterations. Empty string if the agent produced no text.
:param files: Files the agent produced on this turn (via the
``upload_file`` builtin or equivalent). Empty list if none.
Each entry is a :class:`File` with ``id``, ``filename``,
``bytes``, and ``created_at``; use
:meth:`FilesNamespace.download` or
:meth:`FilesNamespace.get_content` to retrieve the bytes.
"""
text: str
files: list[File] = field(default_factory=list)
class QueryStream:
"""Async-iterable of text chunks from ``query(stream=True)``.
Iterating yields ``str`` chunks in order as the agent emits them.
After the iterator is exhausted, :attr:`files` holds the file
artifacts produced this turn. Files discovered during streaming
are appended as they arrive, so an in-progress iteration may
expose a partial list.
Single-use: iterating a second time raises ``RuntimeError``.
"""
def __init__(
self,
chunks: AsyncIterator[str],
files: list[File],
) -> None:
"""Wrap a text-chunk iterator with a reference to a shared file list.
:param chunks: Async iterator that yields assistant text
chunks. Typically produced by the ``Session`` internals.
:param files: List that will be populated with produced
:class:`File` entries as the iterator is consumed.
Passed by reference so the caller and the backing
generator share the same list.
"""
self._chunks = chunks
self._files = files
self._consumed = False
def __aiter__(self) -> AsyncIterator[str]:
"""Return the underlying async iterator.
:raises RuntimeError: If the stream has already been iterated.
:returns: The wrapped async iterator of text chunks.
"""
if self._consumed:
raise RuntimeError(
"QueryStream has already been iterated. Each stream is "
"single-use — call client.query(..., stream=True) again "
"to get a fresh one."
)
self._consumed = True
return self._chunks
@property
def files(self) -> list[File]:
"""Files the agent has produced so far this turn.
Empty until the first file arrives. Fully populated once the
iterator is exhausted.
:returns: A shallow copy of the internal list.
"""
return list(self._files)
@@ -0,0 +1,977 @@
"""Responses namespace — streaming, tool loop, polling, cancel, steer.
.. deprecated::
``ResponsesNamespace`` (``client.responses.*``) targets the
deprecated ``/v1/responses`` surface. New code should use
``SessionsNamespace`` (``client.sessions.*``) instead.
"""
from __future__ import annotations
import asyncio
import inspect
import json
import logging
import warnings
from collections.abc import AsyncIterator
from typing import Any
import httpx
from ._errors import ToolCallDenied, raise_for_status, require_json_object, response_body
from ._events import (
ClientTaskCancel,
CompactionInProgress,
ElicitationRequest,
ErrorEvent,
MessageDone,
NativeToolCall,
OutputFileDone,
ReasoningDelta,
ReasoningStarted,
ReasoningSummaryDelta,
ResponseCancelled,
ResponseCompleted,
ResponseCreated,
ResponseFailed,
ResponseIncomplete,
RetryEvent,
StreamEvent,
TextDelta,
ToolCall,
ToolResult,
)
from ._sse import parse_sse_stream
from ._tool_handler import (
CompactionStartCtx,
ElicitationRequestCtx,
FileOutputCtx,
MessageEndCtx,
MessageStartCtx,
NativeToolCallCtx,
ReasoningEndCtx,
ResponseEndCtx,
ResponseStartCtx,
RetryCtx,
ServerErrorCtx,
StreamHooks,
ToolCallEndCtx,
ToolCallInfo,
ToolCallStartCtx,
ToolHandler,
ToolResultInfo,
ToolResultsReadyCtx,
)
from ._tool_handler import (
ReasoningStartCtx as ReasoningStartHookCtx,
)
from ._types import Response
_log = logging.getLogger("omnigent_client.responses")
# Terminal statuses — the response won't change further.
_TERMINAL_STATUSES = frozenset({"completed", "failed", "incomplete", "cancelled"})
# Async dispatch of client tools flows through the
# action_required handler (``_execute_and_patch``) — same path
# the sub-agent tunnel uses. The SDK tracks the spawned local
# tasks by call_id so :class:`ClientTaskCancel` events can find
# and cancel them; everything else is fire-and-forget and
# self-cleans on done.
async def _call_hook(hook: Any, ctx: Any) -> Any:
"""Call a hook (sync or async) and return its result."""
if hook is None:
return None
result = hook(ctx)
if inspect.isawaitable(result):
return await result
return result
class ResponsesNamespace:
"""Methods for ``/v1/responses`` endpoints.
.. deprecated::
This namespace targets the deprecated ``/v1/responses``
surface. Use :class:`SessionsNamespace`
(``client.sessions.*``) instead — see
``designs/SESSION_REARCHITECTURE.md``.
"""
def __init__(self, http: httpx.AsyncClient, base_url: str) -> None:
self._http = http
self._base = base_url
self._warned = False
def _deprecation_warn(self, method: str) -> None:
"""Emit a one-shot ``DeprecationWarning`` per instance.
:param method: Name of the deprecated method, e.g.
``"create"``.
"""
if not self._warned:
warnings.warn(
f"ResponsesNamespace.{method}() is deprecated; use client.sessions.* instead",
DeprecationWarning,
stacklevel=3,
)
self._warned = True
async def create(
self,
*,
model: str,
input: str | list[dict[str, object]],
background: bool = False,
instructions: str | None = None,
previous_response_id: str | None = None,
tools: list[dict[str, object]] | None = None,
reasoning: dict[str, str] | None = None,
model_override: str | None = None,
) -> Response:
"""Create a response (blocking, non-streaming).
:param model: Agent name.
:param input: User text or content block list.
:param background: If True, returns immediately (poll via get()).
:param instructions: Per-request system instructions.
:param previous_response_id: Prior response for multi-turn.
:param tools: Client-specified tool schemas.
:param reasoning: Reasoning config, e.g. ``{"effort": "high"}``.
:param model_override: Optional per-request LLM model override,
e.g. ``"openai/gpt-5.4-mini"``. Shadows the spec model.
:returns: The response object.
"""
self._deprecation_warn("create")
body = _build_body(
model=model,
input=input,
stream=False,
background=background,
instructions=instructions,
previous_response_id=previous_response_id,
tools=tools,
reasoning=reasoning,
model_override=model_override,
)
resp = await self._http.post(f"{self._base}/v1/responses", json=body)
raise_for_status(resp.status_code, response_body(resp))
return Response.from_dict(require_json_object(resp, "POST /v1/responses"))
async def stream(
self,
*,
model: str,
input: str | list[dict[str, object]],
background: bool = False,
instructions: str | None = None,
previous_response_id: str | None = None,
tool_handler: ToolHandler | None = None,
hooks: StreamHooks | None = None,
reasoning: dict[str, str] | None = None,
model_override: str | None = None,
) -> AsyncIterator[StreamEvent]:
"""Stream a response, optionally running the tool loop.
If ``tool_handler`` is provided, the client runs the full tool
execution loop: stream -> detect tool calls -> execute via handler
-> POST results -> stream again. The consumer sees one continuous
sequence of events.
If ``tool_handler`` is None, yields raw events for a single server
response.
:param model: Agent name.
:param input: User text or content block list.
:param tool_handler: Optional client-side tool execution config.
:param hooks: Optional lifecycle hooks.
"""
self._deprecation_warn("stream")
if hooks is None:
hooks = StreamHooks()
current_input: str | list[dict[str, object]] = input
current_prev_id = previous_response_id
iteration = 0
# Track local asyncio.Tasks spawned by the action_required
# handler so :class:`ClientTaskCancel` SSE events can find
# and cancel them. Keyed by ``call_id`` because that's the
# field the cancel event carries (looked up from the
# server-side pending_tool_calls row when the cancel fires).
# Persists across the outer ``while True`` so a task
# dispatched in one iteration can be cancelled by an event
# arriving in a later iteration's stream. Each entry
# self-cleans on done via ``add_done_callback``.
pending_local_tasks: dict[str, asyncio.Task[None]] = {}
while True:
tools = tool_handler.schemas if tool_handler is not None else None
pending_client_calls: list[ToolCall] = []
completed_call_ids: set[str] = set()
current_response_id: str | None = None
current_session_id: str | None = None
in_reasoning = False
reasoning_text = ""
summary_text = ""
message_started = False
body = _build_body(
model=model,
input=current_input,
stream=True,
background=background,
instructions=instructions,
previous_response_id=current_prev_id,
tools=tools,
reasoning=reasoning,
model_override=model_override,
)
async with self._http.stream("POST", f"{self._base}/v1/responses", json=body) as resp:
if resp.status_code >= 400:
await resp.aread()
raise_for_status(resp.status_code, response_body(resp))
async for event in parse_sse_stream(resp.aiter_bytes()):
# ── Fire hooks and collect state ──────────
if isinstance(event, ResponseCreated):
current_response_id = event.response.id
if event.response.conversation is not None:
current_session_id = event.response.conversation.id
await _call_hook(
hooks.on_response_start, ResponseStartCtx(response=event.response)
)
elif isinstance(event, ReasoningStarted):
in_reasoning = True
reasoning_text = ""
summary_text = ""
await _call_hook(hooks.on_reasoning_start, ReasoningStartHookCtx())
elif isinstance(event, ReasoningDelta):
reasoning_text += event.delta
elif isinstance(event, ReasoningSummaryDelta):
summary_text += event.delta
elif isinstance(event, TextDelta):
if in_reasoning:
in_reasoning = False
await _call_hook(
hooks.on_reasoning_end,
ReasoningEndCtx(
reasoning_text=reasoning_text, summary_text=summary_text
),
)
if not message_started:
message_started = True
await _call_hook(
hooks.on_message_start,
MessageStartCtx(response_id=current_response_id or ""),
)
elif isinstance(event, CompactionInProgress):
await _call_hook(hooks.on_compaction_start, CompactionStartCtx())
elif isinstance(event, ToolCall):
is_client_side = (
tool_handler is not None and event.call_id not in completed_call_ids
)
executed_by = "client" if is_client_side else "server"
await _call_hook(
hooks.on_tool_call_start,
ToolCallStartCtx(
name=event.name,
arguments=event.arguments,
call_id=event.call_id,
agent_name=event.agent_name,
executed_by=executed_by,
),
)
# Action_required tool calls (sub-agent
# tunnel + sys_call_async-dispatched
# client tools) execute locally and PATCH
# back while the stream continues.
# Tracked by call_id so a follow-up
# ``ClientTaskCancel`` event can cancel
# the local task.
if event.status == "action_required" and tool_handler is not None:
completed_call_ids.add(event.call_id)
local_task = asyncio.create_task(
_execute_and_patch(
self._http,
self._base,
tool_handler,
hooks,
event,
current_response_id or "",
iteration,
)
)
pending_local_tasks[event.call_id] = local_task
# Auto-clean once the task settles —
# PATCH back, exception, or cancel all
# land here. Captured in a closure so
# the dict reference stays live.
_local_task_call_id = event.call_id
def _drop_done(
_t: asyncio.Task[None],
_key: str = _local_task_call_id,
) -> None:
pending_local_tasks.pop(_key, None)
local_task.add_done_callback(_drop_done)
elif is_client_side:
# Synchronous client-side tool calls —
# the LLM called the tool directly.
# The SDK doesn't run them inline; it
# accumulates them and the outer
# ``stream()`` loop runs them after
# the response terminates and PATCHes
# ``tool_results`` to feed the next
# iteration. Authors who want
# background dispatch use
# ``sys_call_async`` instead, which
# produces an action_required event
# handled by the branch above.
pending_client_calls.append(event)
elif isinstance(event, ElicitationRequest):
# MCP-shape elicitation — the server is
# waiting on a verdict. Route to the
# elicitation hook (not the tool_handler —
# this is not a tool call). If no hook is
# registered, fail-closed decline so the
# parked workflow doesn't stall forever.
# Fire-and-forget elicitation response —
# see the peer tunneled-call site above for
# why we intentionally drop the task
# reference.
asyncio.ensure_future( # noqa: RUF006 — see comment above
_handle_elicitation_request(
self._http,
self._base,
hooks,
event,
current_response_id or "",
current_session_id or "",
)
)
elif isinstance(event, ToolResult):
completed_call_ids.add(event.call_id)
await _call_hook(
hooks.on_tool_call_end,
ToolCallEndCtx(
name="",
call_id=event.call_id,
agent_name="",
output=event.output,
),
)
elif isinstance(event, ClientTaskCancel):
# Server is telling us to stop the local
# body. Look up the local task by
# call_id (the server includes the
# synthetic call_id from the
# pending_tool_call row in the SSE
# payload) and cancel — the body's
# ``except CancelledError`` runs to
# cleanup. ``ensure_future``-style fires
# without a tracked task remain
# un-cancellable; that case is benign
# because the server has already decided
# the task is cancelled and the late
# PATCH back will be a G3 no-op.
if event.call_id is not None:
tracked_task = pending_local_tasks.get(event.call_id)
if tracked_task is not None and not tracked_task.done():
tracked_task.cancel()
elif isinstance(event, NativeToolCall):
await _call_hook(
hooks.on_native_tool_call,
NativeToolCallCtx(tool_type=event.tool_type, data=event.data),
)
elif isinstance(event, MessageDone):
if in_reasoning:
in_reasoning = False
await _call_hook(
hooks.on_reasoning_end,
ReasoningEndCtx(
reasoning_text=reasoning_text, summary_text=summary_text
),
)
await _call_hook(
hooks.on_message_end,
MessageEndCtx(content=event.content),
)
message_started = False
elif isinstance(event, OutputFileDone):
await _call_hook(
hooks.on_file_output,
FileOutputCtx(
file_id=event.file_id,
filename=event.filename,
content_type=event.content_type,
),
)
elif isinstance(event, RetryEvent):
await _call_hook(
hooks.on_retry,
RetryCtx(
source=event.source,
tool_name=event.tool_name,
attempt=event.attempt,
max_attempts=event.max_attempts,
delay_seconds=event.delay_seconds,
error=event.error,
),
)
elif isinstance(event, ErrorEvent):
await _call_hook(
hooks.on_server_error,
ServerErrorCtx(
source=event.source,
tool_name=event.tool_name,
error=event.error,
),
)
elif isinstance(
event,
ResponseCompleted
| ResponseFailed
| ResponseIncomplete
| ResponseCancelled,
):
if in_reasoning:
in_reasoning = False
await _call_hook(
hooks.on_reasoning_end,
ReasoningEndCtx(
reasoning_text=reasoning_text, summary_text=summary_text
),
)
status = event.response.status
await _call_hook(
hooks.on_response_end,
ResponseEndCtx(response=event.response, status=status),
)
# ── Yield event to consumer ──────────────
yield event
# ── Post-stream: tool loop ───────────────────
if current_response_id is not None:
current_prev_id = current_response_id
# Filter out calls that already have server-side results.
pending_client_calls = [
tc for tc in pending_client_calls if tc.call_id not in completed_call_ids
]
if not pending_client_calls or tool_handler is None:
break
# Execute client-side tools and build results.
results: list[dict[str, object]] = []
result_infos: list[ToolResultInfo] = []
for tc in pending_client_calls:
call_info = ToolCallInfo(
name=tc.name,
arguments=tc.arguments,
call_id=tc.call_id,
agent_name=tc.agent_name,
response_id=current_response_id or "",
iteration=iteration,
)
try:
# Sync ``execute`` routes through
# ``asyncio.to_thread`` so a blocking body
# doesn't serialize the sync-tool loop
# (each call would otherwise freeze the
# event loop for its duration).
output = await _call_execute_off_loop(tool_handler, call_info)
except ToolCallDenied as exc:
output = str(exc)
await _call_hook(
hooks.on_tool_call_end,
ToolCallEndCtx(
name=tc.name,
call_id=tc.call_id,
agent_name=tc.agent_name,
output=output,
),
)
yield ToolResult(call_id=tc.call_id, output=output)
results.append(
{
"type": "function_call_output",
"call_id": tc.call_id,
"output": output,
}
)
result_infos.append(
ToolResultInfo(
call_id=tc.call_id,
name=tc.name,
output=output,
agent_name=tc.agent_name,
)
)
await _call_hook(
hooks.on_tool_results_ready,
ToolResultsReadyCtx(results=result_infos, iteration=iteration),
)
current_input = results
iteration += 1
async def get(self, response_id: str) -> Response:
"""Get a response by ID (poll for status).
:param response_id: The response/task ID.
:returns: Current response state.
"""
self._deprecation_warn("get")
resp = await self._http.get(f"{self._base}/v1/responses/{response_id}")
raise_for_status(resp.status_code, response_body(resp))
return Response.from_dict(require_json_object(resp, "GET /v1/responses/{response_id}"))
async def poll(
self,
response_id: str,
*,
interval: float = 0.5,
tool_handler: ToolHandler | None = None,
) -> Response:
"""Poll a background response until it reaches a terminal status.
If ``tool_handler`` is provided, tunneled tool calls
(``status: "action_required"``) are executed and PATCHed back.
:param response_id: The response/task ID.
:param interval: Seconds between polls.
:param tool_handler: Optional client-side tool handler.
:returns: The terminal response.
"""
while True:
response = await self.get(response_id)
if response.status in _TERMINAL_STATUSES:
return response
# Check for tunneled tool calls needing client execution.
if tool_handler is not None:
await self._handle_polling_tool_calls(response_id, response, tool_handler)
await asyncio.sleep(interval)
async def _handle_polling_tool_calls(
self,
response_id: str,
response: Response,
tool_handler: ToolHandler,
) -> None:
"""Execute action_required tool calls found during polling."""
action_required = [
item
for item in response.output
if isinstance(item, dict)
and item.get("type") == "function_call"
and item.get("status") == "action_required"
]
if not action_required:
return
tool_results = []
for fc in action_required:
name = str(fc.get("name", ""))
call_id = str(fc.get("call_id", ""))
args_str = str(fc.get("arguments", "{}"))
try:
arguments = json.loads(args_str)
except json.JSONDecodeError:
arguments = {}
call_info = ToolCallInfo(
name=name,
arguments=arguments,
call_id=call_id,
agent_name=str(fc.get("model", "")),
response_id=response_id,
iteration=0,
)
output = tool_handler.execute(call_info)
if inspect.isawaitable(output):
output = await output
tool_results.append({"call_id": call_id, "output": output})
if tool_results:
await self._patch_tool_results(response_id, tool_results)
async def _patch_tool_results(
self,
response_id: str,
tool_results: list[dict[str, str]],
) -> None:
"""PATCH tool results back to the server."""
resp = await self._http.patch(
f"{self._base}/v1/responses/{response_id}",
json={"tool_results": tool_results},
timeout=60.0,
)
if resp.status_code not in (200, 404, 409):
_log.warning(
"PATCH tool results failed (%d): %s",
resp.status_code,
resp.text[:200],
)
async def steer(
self,
response_id: str,
input: str,
*,
model: str,
reasoning: dict[str, str] | None = None,
model_override: str | None = None,
) -> Response:
"""Send a steering message to an in-progress response.
:param response_id: The in-progress response ID.
:param input: Steering text.
:param model: Agent name.
:param reasoning: Reasoning config if this steer races into a new response.
:param model_override: LLM model override if this steer races
into a new response (server ignores it otherwise).
:returns: The response (same ID if delivered, new ID if agent finished).
"""
self._deprecation_warn("steer")
body: dict[str, object] = {
"model": model,
"input": input,
"previous_response_id": response_id,
"stream": False,
"background": True,
}
if reasoning is not None:
body["reasoning"] = reasoning
if model_override is not None:
body["model_override"] = model_override
resp = await self._http.post(
f"{self._base}/v1/responses",
json=body,
timeout=120.0,
)
raise_for_status(resp.status_code, response_body(resp))
return Response.from_dict(require_json_object(resp, "POST /v1/responses"))
async def cancel(self, response_id: str) -> Response:
"""Cancel an in-progress response.
:param response_id: The response ID to cancel.
:returns: The cancelled response.
"""
self._deprecation_warn("cancel")
resp = await self._http.post(
f"{self._base}/v1/responses/{response_id}/cancel",
timeout=10.0,
)
raise_for_status(resp.status_code, response_body(resp))
return Response.from_dict(
require_json_object(resp, "POST /v1/responses/{response_id}/cancel")
)
async def delete(self, response_id: str) -> None:
"""Delete a response.
:param response_id: The response ID to delete.
"""
self._deprecation_warn("delete")
resp = await self._http.delete(
f"{self._base}/v1/responses/{response_id}",
)
if resp.status_code >= 400:
raise_for_status(resp.status_code, response_body(resp))
# ── Helpers ──────────────────────────────────────────────
def _build_body(
*,
model: str,
input: str | list[dict[str, object]],
stream: bool,
background: bool,
instructions: str | None,
previous_response_id: str | None,
tools: list[dict[str, object]] | None,
reasoning: dict[str, str] | None,
model_override: str | None = None,
) -> dict[str, object]:
"""Build the request body for POST /v1/responses."""
body: dict[str, object] = {
"model": model,
"input": input,
"stream": stream,
}
if background:
body["background"] = True
if instructions is not None:
body["instructions"] = instructions
if previous_response_id is not None:
body["previous_response_id"] = previous_response_id
if tools is not None:
body["tools"] = tools
if reasoning is not None:
body["reasoning"] = reasoning
if model_override is not None:
body["model_override"] = model_override
return body
async def _execute_and_patch(
http: httpx.AsyncClient,
base_url: str,
tool_handler: ToolHandler,
hooks: StreamHooks,
tool_call: ToolCall,
root_response_id: str,
iteration: int,
) -> None:
"""Execute a tunneled sub-agent tool call and PATCH the result back.
Runs in the background via ``asyncio.ensure_future`` while the
SSE stream continues.
"""
call_info = ToolCallInfo(
name=tool_call.name,
arguments=tool_call.arguments,
call_id=tool_call.call_id,
agent_name=tool_call.agent_name,
response_id=root_response_id,
iteration=iteration,
)
try:
# Sync ``execute`` goes through asyncio.to_thread so a
# blocking body doesn't stall the SSE stream or the
# TUI's render loop. See ``_call_execute_off_loop``.
output = await _call_execute_off_loop(tool_handler, call_info)
except ToolCallDenied as exc:
output = str(exc)
except Exception:
_log.exception("Error executing tunneled tool call %s", tool_call.name)
output = f"Error executing tool: {tool_call.name}"
await _call_hook(
hooks.on_tool_call_end,
ToolCallEndCtx(
name=tool_call.name,
call_id=tool_call.call_id,
agent_name=tool_call.agent_name,
output=output,
),
)
try:
resp = await http.patch(
f"{base_url}/v1/responses/{root_response_id}",
json={"tool_results": [{"call_id": tool_call.call_id, "output": output}]},
timeout=60.0,
)
if resp.status_code not in (200, 404, 409):
_log.warning(
"PATCH failed for call_id %s: %s",
tool_call.call_id,
resp.text[:200],
)
except Exception:
_log.exception("Error PATCHing tool result for call_id %s", tool_call.call_id)
async def _call_execute_off_loop(
tool_handler: ToolHandler,
call_info: ToolCallInfo,
) -> Any:
"""
Invoke ``tool_handler.execute(call_info)`` without blocking
the event loop.
``execute`` may be ``async def`` (returns a coroutine) or
sync ``def`` (returns a plain value). If async, we await
the coroutine on the loop; async bodies are already
loop-cooperative. If sync, we run it via
:func:`asyncio.to_thread` so blocking calls inside —
``time.sleep``, file I/O, subprocess, ``requests`` — don't
stall the loop.
Sync-vs-async is detected via
:func:`inspect.iscoroutinefunction` so we only invoke
``execute`` once. Without the thread bounce, a single
sync body with ``time.sleep(5)`` would serialize every
concurrent tool call AND freeze any render loop (like the
``omnigent chat`` TUI) sharing the event loop.
:param tool_handler: Handler whose ``execute`` will run.
:param call_info: Call context passed through to the handler.
:returns: Whatever ``execute`` returns (typically a string).
"""
if inspect.iscoroutinefunction(tool_handler.execute):
return await tool_handler.execute(call_info)
return await asyncio.to_thread(tool_handler.execute, call_info)
async def _invoke_elicitation_hook(
hooks: StreamHooks,
ctx: ElicitationRequestCtx,
) -> bool:
"""
Run the consumer's ``on_elicitation_request`` hook fail-closed.
Returns True only when the hook explicitly accepts. A missing
hook, an exception from the hook, or a falsy return all map to
False (decline) — the parked workflow must not stall on an
elicitation no consumer can answer.
:param hooks: Stream hooks — the ``on_elicitation_request``
callable to invoke.
:param ctx: Context carrying the parsed elicitation, passed to
the hook verbatim.
:returns: ``True`` iff the hook explicitly accepted; ``False``
on missing-hook, exception, or any falsy return.
"""
if hooks.on_elicitation_request is None:
_log.info(
"No on_elicitation_request hook registered; declining elicitation_id=%s policy=%s",
ctx.elicitation_id,
ctx.policy_name,
)
return False
try:
raw = hooks.on_elicitation_request(ctx)
if inspect.isawaitable(raw):
raw = await raw
return bool(raw)
except Exception:
_log.exception(
"on_elicitation_request hook raised for elicitation_id %s; declining fail-closed",
ctx.elicitation_id,
)
return False
async def _post_elicitation_result(
http: httpx.AsyncClient,
base_url: str,
session_id: str,
elicitation_id: str,
accepted: bool,
) -> None:
"""
POST a verdict to one elicitation's dedicated resolve URL.
URL-based elicitation: the verdict goes to
``POST /v1/sessions/{id}/elicitations/{eid}/resolve`` with the
MCP ``ElicitResult`` body (``{"action": "accept"}`` or
``{"action": "decline"}``) — the elicitation id rides in the URL
path. ``content`` is omitted for binary accept/decline (future
richer hooks would populate it). This matches the REPL and web
clients; the server routes all of them through the same
``_resolve_elicitation``.
Status codes the server returns (202/404/409) are all benign —
the verdict is either applied or moot. Anything else gets a
warning log; the call still returns normally because the
background task is fire-and-forget.
:param http: Shared HTTPX client.
:param base_url: Server base URL.
:param session_id: Session/conversation id that emitted the
elicitation. The resolve URL is scoped to this session so
the server can apply normal session authorization.
:param elicitation_id: Server-assigned elicitation id.
:param accepted: Hook verdict — ``True`` → ``"accept"``,
``False`` → ``"decline"``.
"""
if not session_id:
_log.warning(
"Cannot resolve elicitation_id %s: missing session id",
elicitation_id,
)
return
body = {"action": "accept" if accepted else "decline"}
try:
resp = await http.post(
f"{base_url}/v1/sessions/{session_id}/elicitations/{elicitation_id}/resolve",
json=body,
timeout=60.0,
)
# 202 = delivered, 404 = session/parked workflow already gone
# (cancel race), 409 = already completed. All three are
# benign: the verdict is either applied or moot.
if resp.status_code not in (202, 404, 409):
_log.warning(
"Resolve elicitation_id %s failed: %s",
elicitation_id,
resp.text[:200],
)
except Exception:
_log.exception(
"Error POSTing approval event for elicitation_id %s",
elicitation_id,
)
async def _handle_elicitation_request(
http: httpx.AsyncClient,
base_url: str,
hooks: StreamHooks,
event: ElicitationRequest,
response_id: str,
session_id: str,
) -> None:
"""
Route an elicitation through the hook and POST the verdict.
Runs on a background task so the SSE stream continues to drain
while the user is deciding. Two-step: invoke the hook
(:func:`_invoke_elicitation_hook`), POST the approval event
(:func:`_post_elicitation_result`).
:param http: Shared HTTPX client.
:param base_url: Server base URL.
:param hooks: Stream hooks — the ``on_elicitation_request``
hook gets the decision.
:param event: The parsed elicitation request.
:param response_id: Root response id (the parked workflow);
carried into the ctx for the hook's audit/logging only.
:param session_id: Session/conversation id that emitted the
elicitation. The verdict is posted back as a session
``approval`` event.
"""
ctx = ElicitationRequestCtx(
elicitation_id=event.elicitation_id,
message=event.message,
requested_schema=event.requested_schema,
mode=event.mode,
phase=event.phase,
policy_name=event.policy_name,
content_preview=event.content_preview,
response_id=response_id,
url=event.url,
)
accepted = await _invoke_elicitation_hook(hooks, ctx)
await _post_elicitation_result(http, base_url, session_id, event.elicitation_id, accepted)
@@ -0,0 +1,170 @@
"""LocalServer — start and manage a local omnigent server."""
from __future__ import annotations
import os
import pathlib
import signal
import subprocess
import sys
import tempfile
import time
from typing import TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from ._client import OmnigentClient
def _find_free_port() -> int:
"""Find a free TCP port."""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class LocalServer:
"""Context manager that starts a local omnigent server.
Usage::
async with LocalServer(agent_path="./my-agent/") as server:
client = server.client
async for event in client.responses.stream(...):
...
:param agent_path: Path to the agent directory or tarball.
The server pre-registers this agent at startup.
:param host: Bind address.
:param port: Port number (0 = auto-assign).
"""
def __init__(
self,
agent_path: str,
*,
host: str = "127.0.0.1",
port: int = 0,
) -> None:
self._agent_path = agent_path
self._host = host
self._port = port if port != 0 else _find_free_port()
self._proc: subprocess.Popen[bytes] | None = None
self._tmpdir: str | None = None
self._client: OmnigentClient | None = None
@property
def base_url(self) -> str:
"""The server's base URL."""
return f"http://{self._host}:{self._port}"
@property
def client(self) -> OmnigentClient:
"""A pre-configured client connected to this server."""
if self._client is None:
raise RuntimeError("Server not started — use 'async with LocalServer(...) as server:'")
return self._client
async def __aenter__(self) -> LocalServer:
self._start()
# Import here to avoid circular import at module level.
from ._client import OmnigentClient
self._client = OmnigentClient(base_url=self.base_url)
return self
async def __aexit__(self, *exc: object) -> None:
self._stop()
if self._client is not None:
await self._client.close()
self._client = None
def _start(self) -> None:
"""Launch the server subprocess."""
self._tmpdir = tempfile.mkdtemp(prefix="omnigent-client-")
db_uri = f"sqlite:///{self._tmpdir}/chat.db"
art_loc = f"{self._tmpdir}/artifacts"
# Resolve the omnigent project root so Alembic can find
# its migrations directory. Walk up from the agent path or
# from this file's location looking for omnigent/cli.py.
project_root = self._find_project_root()
self._proc = subprocess.Popen(
[
sys.executable,
"-m",
"omnigent.cli",
"server",
"--host",
self._host,
"--port",
str(self._port),
"--database-uri",
db_uri,
"--artifact-location",
art_loc,
"--agent",
os.path.abspath(self._agent_path),
],
env={**os.environ},
cwd=project_root,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
self._wait_for_ready()
def _find_project_root(self) -> str:
"""Find the omnigent project root directory.
Walks up from the agent path looking for a directory that
contains ``omnigent/cli.py``.
"""
# Try from agent path first.
candidate = pathlib.Path(self._agent_path).resolve()
for parent in [candidate, *list(candidate.parents)]:
if (parent / "omnigent" / "cli.py").exists():
return str(parent)
# Try from this file's location (inside frontends/clients/python/).
candidate = pathlib.Path(__file__).resolve()
for parent in candidate.parents:
if (parent / "omnigent" / "cli.py").exists():
return str(parent)
# Fallback to cwd.
return os.getcwd()
def _wait_for_ready(self, timeout: float = 15.0) -> None:
"""Poll until the server responds."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self._proc is not None and self._proc.poll() is not None:
out = (
self._proc.stdout.read().decode(errors="replace") if self._proc.stdout else ""
)
raise RuntimeError(
f"Server exited with code {self._proc.returncode}.\n{out[-3000:]}"
)
try:
resp = httpx.get(f"{self.base_url}/health", timeout=2.0)
if resp.status_code == 200:
return
except httpx.ConnectError:
pass
time.sleep(0.5)
raise RuntimeError(f"Server did not start within {timeout}s")
def _stop(self) -> None:
"""Stop the server subprocess."""
if self._proc is None:
return
self._proc.send_signal(signal.SIGINT)
try:
self._proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self._proc.kill()
self._proc = None
@@ -0,0 +1,473 @@
"""Session helper — tracks conversation state for interactive use."""
from __future__ import annotations
import contextlib
import mimetypes
import pathlib
from collections.abc import AsyncIterator, Callable, Iterator
# Import at the type level to avoid circular imports at runtime.
from typing import TYPE_CHECKING, Any, Literal, overload
from ._events import (
ResponseCancelled,
ResponseCompleted,
ResponseCreated,
ResponseFailed,
ResponseIncomplete,
StreamEvent,
)
from ._query import QueryResult, QueryStream
from ._tool_handler import StreamHooks, ToolHandler
from ._types import File, Response
if TYPE_CHECKING:
from ._client import OmnigentClient
_TERMINAL_STATUSES = frozenset({"completed", "failed", "incomplete", "cancelled"})
_VALID_REASONING_EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max"})
class Session:
"""Tracks conversation state for interactive use.
Holds the model name, last response ID, and whether the current
response is still running. ``send()`` automatically steers if
a response is in progress, or starts a new turn if the response
is terminal.
:param client: The underlying :class:`OmnigentClient`.
:param model: Agent name to use for requests.
:param tool_handler: Optional client-side tool execution config.
:param hooks: Optional lifecycle hooks.
"""
def __init__(
self,
client: OmnigentClient,
model: str,
tool_handler: ToolHandler | None = None,
hooks: StreamHooks | None = None,
) -> None:
self._client = client
self._model = model
self._tool_handler = tool_handler
self._hooks = hooks
self._previous_response_id: str | None = None
self._current_response_id: str | None = None
self._is_terminal: bool = True
self._reasoning_effort: str | None = None
# Session-local /model override. Forwarded as model_override
# on POST /v1/responses. Mirrors _reasoning_effort's shape.
self._model_override: str | None = None
# Lazily populated from the first /v1/responses event so
# produced-file lookups can use the session-scoped file endpoints.
self._conversation_id: str | None = None
@property
def model(self) -> str:
"""The agent name for this session."""
return self._model
@property
def current_response_id(self) -> str | None:
"""The most recent response ID, or None if no messages sent."""
return self._current_response_id
@property
def is_streaming(self) -> bool:
"""True if a response is currently in progress."""
return not self._is_terminal
@property
def reasoning_effort(self) -> str | None:
"""Current per-request reasoning-effort override, or None for default."""
return self._reasoning_effort
def set_reasoning_effort(self, effort: str | None) -> None:
"""Set or clear the per-request reasoning-effort override."""
if effort is not None:
normalized = effort.strip().lower()
if normalized not in _VALID_REASONING_EFFORTS:
valid = ", ".join(sorted(_VALID_REASONING_EFFORTS))
raise ValueError(f"Invalid reasoning effort {effort!r}; expected one of: {valid}")
effort = normalized
self._reasoning_effort = effort
def _reasoning_request(self) -> dict[str, str] | None:
"""Return the Responses API reasoning payload for this session."""
if self._reasoning_effort is None:
return None
return {"effort": self._reasoning_effort}
@property
def model_override(self) -> str | None:
"""Current per-request LLM model override, or ``None`` for agent default."""
return self._model_override
def set_model_override(self, model: str | None) -> None:
"""
Set or clear the per-request LLM model override.
:param model: A non-empty model identifier, e.g.
``"databricks-claude-sonnet-4-6"``. Whitespace is trimmed;
``None`` clears so subsequent requests fall back to the
agent spec's model.
:raises ValueError: When *model* is empty after trimming. No
other validation — server / runtime surfaces unknown-model
errors at request time.
"""
if model is not None:
normalized = model.strip()
if not normalized:
raise ValueError("model override must be a non-empty string")
model = normalized
self._model_override = model
async def send(
self,
input: str | list[dict[str, object]],
*,
files: list[str] | None = None,
instructions: str | None = None,
) -> AsyncIterator[StreamEvent]:
"""Send a message — auto-steers if a response is in progress.
Always returns an async iterator. The caller always does
``async for event in session.send(text): ...`` regardless
of whether it steered or started a new turn.
Three cases:
1. **Response in progress, steer delivered**: The server
accepted the message into the running agent's inbox.
Yields nothing — the existing stream (from the original
``send()`` call) will surface the agent's reaction.
2. **Response in progress, agent already finished**: The
server created a new response instead of steering.
Yields the full event stream for that new response.
3. **No response in progress**: Starts a new turn. Yields
the full event stream.
:param input: User text or content block list.
:param files: Optional file paths to upload and attach.
:param instructions: Per-request system instructions.
:yields: Stream events.
"""
if files:
input = await self._build_input_with_files(input, files)
# Auto-steer if response is in progress.
if not self._is_terminal and self._current_response_id is not None:
steer_resp = await self._client.responses.steer(
self._current_response_id,
input if isinstance(input, str) else str(input),
model=self._model,
reasoning=self._reasoning_request(),
model_override=self._model_override,
)
if steer_resp.id == self._current_response_id:
# Case 1: steering delivered. Nothing to yield.
return
yield
# Case 2: agent finished — server created a new response.
# Stream it like a normal turn. The input was already
# included in the steer POST, so the new response has it.
async for event in self._stream_and_track(steer_resp.id, instructions):
yield event
return
# Case 3: no response in progress — new turn.
async for event in self._stream_and_track(
None,
instructions,
input=input,
):
yield event
async def _stream_and_track(
self,
previous_response_id: str | None,
instructions: str | None,
*,
input: str | list[dict[str, object]] | None = None,
) -> AsyncIterator[StreamEvent]:
"""Stream a response and track session state.
If ``input`` is None, uses an empty string (for steer-fallback
where the input was already sent via the steer POST). If
``previous_response_id`` is None, uses the session's stored ID.
"""
self._is_terminal = False
prev_id = (
previous_response_id
if previous_response_id is not None
else self._previous_response_id
)
actual_input: str | list[dict[str, object]] = input if input is not None else ""
async for event in self._client.responses.stream(
model=self._model,
input=actual_input,
previous_response_id=prev_id,
tool_handler=self._tool_handler,
hooks=self._hooks,
instructions=instructions,
reasoning=self._reasoning_request(),
model_override=self._model_override,
):
if isinstance(event, ResponseCreated):
self._current_response_id = event.response.id
if event.response.conversation is not None:
self._conversation_id = event.response.conversation.id
if isinstance(
event, ResponseCompleted | ResponseFailed | ResponseIncomplete | ResponseCancelled
):
self._is_terminal = True
self._previous_response_id = event.response.id
self._current_response_id = event.response.id
yield event
@overload
async def query(
self,
input: str | list[dict[str, object]],
*,
files: list[str] | None = ...,
tools: list[Callable[..., Any]] | None = ...,
stream: Literal[False] = ...,
) -> QueryResult: ...
@overload
async def query(
self,
input: str | list[dict[str, object]],
*,
files: list[str] | None = ...,
tools: list[Callable[..., Any]] | None = ...,
stream: Literal[True],
) -> QueryStream: ...
async def query(
self,
input: str | list[dict[str, object]],
*,
files: list[str] | None = None,
tools: list[Callable[..., Any]] | None = None,
stream: bool = False,
) -> QueryResult | QueryStream:
"""Send a prompt and get text (plus any files) back.
Non-streaming (default) returns a :class:`QueryResult`::
result = await session.query("make me a chart")
print(result.text)
for f in result.files:
await client.files.for_session("<session-id>").download(
f.id, f"./out/{f.filename}"
)
Streaming returns a :class:`QueryStream`::
stream = await session.query("hello", stream=True)
async for chunk in stream:
print(chunk, end="", flush=True)
# After iteration, stream.files holds the produced files.
Client-side tools can be passed per-call via ``tools=``, or
configured session-wide via ``client.session(tool_handler=...)``.
If this turn's ``tools=`` is given, it OVERRIDES any session
handler for this call only.
:param input: User text or a list of content-block dicts,
e.g. ``"hello"`` or
``[{"type": "input_text", "text": "hi"}]``.
:param files: Optional list of local file paths to upload and
attach to the turn, e.g. ``["./data.csv"]``.
:param tools: Optional list of ``@tool``-decorated functions
the agent may call on this turn. Overrides the session's
configured ``tool_handler`` for this call only.
:param stream: If True, return a :class:`QueryStream`. If
False (default), return a :class:`QueryResult`.
:returns: :class:`QueryResult` (``stream=False``) or
:class:`QueryStream` (``stream=True``).
:raises OmnigentError: If the response ends in an error.
"""
if stream:
return self._stream_query(input, files=files, tools=tools)
return await self._collect_query(input, files=files, tools=tools)
async def _collect_query(
self,
input: str | list[dict[str, object]],
*,
files: list[str] | None,
tools: list[Callable[..., Any]] | None,
) -> QueryResult:
"""Run a turn; return final text + produced files as a QueryResult."""
# Local imports avoid a circular dep at module load — _stream
# imports _session under TYPE_CHECKING.
from ._blocks import FileBlock, TextDone
from ._stream import BlockStream
from ._transforms import merge_text_across_iterations, pipe, skip_intermediate_ends
with _per_call_tool_override(self, tools):
block_stream = BlockStream()
final_text = ""
produced: list[File] = []
async for block in pipe(
block_stream.stream(self, input, files=files),
# Merges per-iteration text into one TextDone per response,
# so we don't truncate to just the last iteration's text.
merge_text_across_iterations(),
skip_intermediate_ends(),
):
if isinstance(block, TextDone):
final_text = block.full_text
elif isinstance(block, FileBlock):
produced.append(await self._get_file(block.file_id))
return QueryResult(text=final_text, files=produced)
def _stream_query(
self,
input: str | list[dict[str, object]],
*,
files: list[str] | None,
tools: list[Callable[..., Any]] | None,
) -> QueryStream:
"""Build a QueryStream that yields text chunks and collects files."""
produced: list[File] = []
chunks = self._stream_chunks(input, files=files, tools=tools, produced=produced)
return QueryStream(chunks=chunks, files=produced)
async def _stream_chunks(
self,
input: str | list[dict[str, object]],
*,
files: list[str] | None,
tools: list[Callable[..., Any]] | None,
produced: list[File],
) -> AsyncIterator[str]:
"""Async generator backing QueryStream.
Yields text chunks as they arrive. Appends produced files to
the shared ``produced`` list as :class:`FileBlock` events
surface, so the owning :class:`QueryStream` sees them through
its reference to the same list.
"""
from ._blocks import FileBlock, TextChunk
from ._stream import BlockStream
from ._transforms import pipe, skip_intermediate_ends
with _per_call_tool_override(self, tools):
block_stream = BlockStream()
async for block in pipe(
block_stream.stream(self, input, files=files),
skip_intermediate_ends(),
):
if isinstance(block, TextChunk):
yield block.text
elif isinstance(block, FileBlock):
produced.append(await self._get_file(block.file_id))
async def _get_file(self, file_id: str) -> File:
"""Fetch produced-file metadata from this session's file namespace."""
if self._conversation_id is None:
raise RuntimeError("cannot fetch produced file before conversation is known")
return await self._client.files.for_session(self._conversation_id).get(file_id)
async def cancel(self) -> Response | None:
"""Cancel the current in-progress response.
:returns: The cancelled response, or None if no response is active.
"""
if self._current_response_id is None:
return None
response = await self._client.responses.cancel(self._current_response_id)
self._is_terminal = True
self._previous_response_id = response.id
return response
def reset(self) -> None:
"""Reset the session — start a new conversation."""
self._previous_response_id = None
self._current_response_id = None
self._is_terminal = True
def resume_from_response(self, response_id: str) -> None:
"""Resume conversation from a specific response ID."""
self._previous_response_id = response_id
self._current_response_id = response_id
self._is_terminal = True
async def _build_input_with_files(
self,
text: str | list[dict[str, object]],
file_paths: list[str],
) -> list[dict[str, object]]:
"""Upload files and build content blocks."""
blocks: list[dict[str, object]] = []
# Add text block.
if isinstance(text, str) and text:
blocks.append({"type": "input_text", "text": text})
elif isinstance(text, list):
blocks.extend(text)
# Upload and add file blocks.
for path in file_paths:
if self._conversation_id is None:
raise RuntimeError(
"file attachments require an established conversation; "
"use the sessions API for first-turn attachments"
)
uploaded = await self._client.files.for_session(self._conversation_id).upload(path)
content_type = mimetypes.guess_type(path)[0]
if content_type and content_type.startswith("image/"):
blocks.append({"type": "input_image", "file_id": uploaded.id})
else:
blocks.append(
{
"type": "input_file",
"file_id": uploaded.id,
"filename": pathlib.Path(path).name,
}
)
return blocks
@contextlib.contextmanager
def _per_call_tool_override(
session: Session,
tools: list[Callable[..., Any]] | None,
) -> Iterator[None]:
"""Temporarily override ``session._tool_handler`` for one call.
If ``tools`` is ``None``, the session's configured handler is
used unchanged. Otherwise a handler is built from the decorated
functions and swapped in for the duration of the ``with`` block;
the original is restored on exit, even on exception.
:param session: The session whose ``_tool_handler`` to override.
:param tools: List of ``@tool``-decorated functions, or ``None``
to leave the session's handler in place.
"""
if tools is None:
yield
return
from .tools import build_tool_handler
previous = session._tool_handler
session._tool_handler = build_tool_handler(tools)
try:
yield
finally:
session._tool_handler = previous
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+366
View File
@@ -0,0 +1,366 @@
"""SSE frame parser — converts raw byte chunks into typed events.
Handles the ``event:`` / ``data:`` / ``[DONE]`` framing from the
server's ``text/event-stream`` responses.
"""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from typing import Any
from omnigent.server import schemas as _srv_events
from ._events import (
NATIVE_TOOL_TYPES,
ClientTaskCancel,
CompactionInProgress,
ElicitationRequest,
ErrorEvent,
MessageDone,
NativeToolCall,
OutputFileDone,
ReasoningDelta,
ReasoningStarted,
ReasoningSummaryDelta,
ResponseCancelled,
ResponseCompleted,
ResponseCreated,
ResponseFailed,
ResponseIncomplete,
ResponseInProgress,
ResponseQueued,
RetryEvent,
StreamEvent,
TextDelta,
ToolCall,
ToolResult,
)
from ._types import ErrorInfo, Response
_log = logging.getLogger("omnigent_client.sse")
def _wire_type(cls: type) -> str:
"""
Extract the wire ``type`` literal from a server event class.
Each :mod:`omnigent.server.schemas` event class pins its
``type`` field as ``Literal["..."]``; this helper unwraps that
to a plain string so the SDK's ``str == str`` dispatch table
stays a ``str`` comparison rather than introducing a class-side
isinstance check.
:param cls: A subclass of the server's ``_SSEEventBase``,
e.g. :class:`omnigent.server.schemas.OutputTextDeltaEvent`.
:returns: The wire ``type`` literal, e.g.
``"response.output_text.delta"``.
"""
return cls.model_fields["type"].annotation.__args__[0] # type: ignore[union-attr]
# Wire type literals — pulled from the server's typed source of
# truth so a rename there is a one-edit change here as well.
_T_RESPONSE_CREATED = _wire_type(_srv_events.CreatedEvent)
_T_RESPONSE_QUEUED = _wire_type(_srv_events.QueuedEvent)
_T_RESPONSE_IN_PROGRESS = _wire_type(_srv_events.InProgressEvent)
_T_RESPONSE_COMPLETED = _wire_type(_srv_events.CompletedEvent)
_T_RESPONSE_FAILED = _wire_type(_srv_events.FailedEvent)
_T_RESPONSE_INCOMPLETE = _wire_type(_srv_events.IncompleteEvent)
_T_RESPONSE_CANCELLED = _wire_type(_srv_events.CancelledEvent)
_T_RESPONSE_OUTPUT_TEXT_DELTA = _wire_type(_srv_events.OutputTextDeltaEvent)
_T_RESPONSE_REASONING_STARTED = _wire_type(_srv_events.ReasoningStartedEvent)
_T_RESPONSE_REASONING_TEXT_DELTA = _wire_type(_srv_events.ReasoningTextDeltaEvent)
_T_RESPONSE_REASONING_SUMMARY_TEXT_DELTA = _wire_type(_srv_events.ReasoningSummaryTextDeltaEvent)
_T_RESPONSE_OUTPUT_ITEM_DONE = _wire_type(_srv_events.OutputItemDoneEvent)
_T_RESPONSE_OUTPUT_FILE_DONE = _wire_type(_srv_events.OutputFileDoneEvent)
_T_RESPONSE_RETRY = _wire_type(_srv_events.RetryEvent)
_T_RESPONSE_ERROR = _wire_type(_srv_events.ErrorEvent)
_T_RESPONSE_COMPACTION_IN_PROGRESS = _wire_type(_srv_events.CompactionInProgressEvent)
_T_RESPONSE_CLIENT_TASK_CANCEL = _wire_type(_srv_events.ClientTaskCancelEvent)
_T_RESPONSE_ELICITATION_REQUEST = _wire_type(_srv_events.ElicitationRequestEvent)
async def parse_sse_stream(
byte_stream: AsyncIterator[bytes],
) -> AsyncIterator[StreamEvent]:
"""Parse an SSE byte stream into typed events.
:param byte_stream: Raw bytes from ``httpx.Response.aiter_bytes()``.
:yields: Typed :class:`StreamEvent` instances.
"""
buf = ""
current_event: str | None = None
async for chunk in byte_stream:
buf += chunk.decode("utf-8", errors="replace")
while "\n" in buf:
line, buf = buf.split("\n", 1)
line = line.rstrip("\r")
if line.startswith("event: "):
current_event = line[7:]
elif line.startswith("data: "):
data_str = line[6:]
if data_str.strip() == "[DONE]":
# The server's terminal sentinel is a bare ``data: [DONE]``
# with no preceding ``event:`` line, so detect it regardless
# of ``current_event``.
return
# A ``data:`` line is only a parseable event when an ``event:``
# line preceded it; a lone data line (other than [DONE]) is
# ignored.
if current_event is not None:
try:
data = json.loads(data_str)
except json.JSONDecodeError:
_log.warning("Failed to parse SSE data: %s", data_str[:200])
current_event = None
continue
event = _parse_event(current_event, data)
if event is not None:
yield event
current_event = None
elif line == "":
current_event = None
def _normalize_event_type(event_type: str) -> str:
"""Normalize event type to handle server enum rendering.
The server builds terminal events as ``f"response.{task.status}"``
where ``task.status`` may be a Python enum (rendering as
``response.TaskStatus.COMPLETED``) instead of the expected
``response.completed``. Normalize by extracting and lowercasing
the enum value.
"""
if ".TaskStatus." in event_type:
# "response.TaskStatus.COMPLETED" → "response.completed"
parts = event_type.split(".")
status = parts[-1].lower()
return f"response.{status}"
return event_type
def _parse_event(event_type: str, data: dict[str, Any]) -> StreamEvent | None:
"""Convert a raw SSE event type + JSON data into a typed event.
:param event_type: Wire name of the SSE ``event:`` field, e.g.
``"response.output_text.delta"``. Compared against the
``_T_RESPONSE_*`` wire-type constants (sourced from
:mod:`omnigent.server.schemas`) to dispatch.
:param data: Decoded JSON payload from the SSE ``data:`` field.
:returns: A typed :class:`StreamEvent` for known event names, or
``None`` when the payload is missing required fields or the
event type is unrecognized (forward-compatible skip).
"""
event_type = _normalize_event_type(event_type)
# Response lifecycle
if event_type == _T_RESPONSE_CREATED:
return ResponseCreated(response=_parse_response(data))
if event_type == _T_RESPONSE_QUEUED:
return ResponseQueued(response=_parse_response(data))
if event_type == _T_RESPONSE_IN_PROGRESS:
return ResponseInProgress(response=_parse_response(data))
if event_type == _T_RESPONSE_COMPLETED:
return ResponseCompleted(response=_parse_response(data))
if event_type == _T_RESPONSE_FAILED:
return ResponseFailed(response=_parse_response(data))
if event_type == _T_RESPONSE_INCOMPLETE:
resp = _parse_response(data)
reason = ""
if resp.incomplete_details is not None:
reason = resp.incomplete_details.reason
return ResponseIncomplete(response=resp, reason=reason)
if event_type == _T_RESPONSE_CANCELLED:
return ResponseCancelled(response=_parse_response(data))
# Text streaming
if event_type == _T_RESPONSE_OUTPUT_TEXT_DELTA:
delta = data.get("delta")
if isinstance(delta, str):
return TextDelta(delta=delta)
return None
# Reasoning
if event_type == _T_RESPONSE_REASONING_STARTED:
return ReasoningStarted()
if event_type == _T_RESPONSE_REASONING_TEXT_DELTA:
delta = data.get("delta")
if isinstance(delta, str):
return ReasoningDelta(delta=delta)
return None
if event_type == _T_RESPONSE_REASONING_SUMMARY_TEXT_DELTA:
delta = data.get("delta")
if isinstance(delta, str):
return ReasoningSummaryDelta(delta=delta)
return None
# Output items
if event_type == _T_RESPONSE_OUTPUT_ITEM_DONE:
return _parse_output_item(data)
# File output
if event_type == _T_RESPONSE_OUTPUT_FILE_DONE:
return OutputFileDone(
file_id=str(data.get("file_id", "")),
filename=str(data["filename"]) if data.get("filename") is not None else None,
content_type=str(data["content_type"])
if data.get("content_type") is not None
else None,
)
# Retry
if event_type == _T_RESPONSE_RETRY:
return RetryEvent(
source=str(data.get("source", "")),
tool_name=str(data["tool_name"]) if data.get("tool_name") is not None else None,
attempt=int(data.get("attempt", 0)),
max_attempts=int(data.get("max_attempts", 0)),
delay_seconds=float(data.get("delay_seconds", 0.0)),
error=_parse_error_info(data.get("error", {})),
)
# Error
if event_type == _T_RESPONSE_ERROR:
return ErrorEvent(
source=str(data.get("source", "")),
tool_name=str(data["tool_name"]) if data.get("tool_name") is not None else None,
error=_parse_error_info(data.get("error", {})),
)
# Compaction
if event_type == _T_RESPONSE_COMPACTION_IN_PROGRESS:
return CompactionInProgress()
# Async client-tool cancel notification
if event_type == _T_RESPONSE_CLIENT_TASK_CANCEL:
task_id = data.get("task_id")
if isinstance(task_id, str) and task_id:
raw_call_id = data.get("call_id")
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id else None
return ClientTaskCancel(task_id=task_id, call_id=call_id)
_log.warning("response.client_task.cancel missing task_id: %r", data)
return None
# MCP-shape elicitation request (POLICIES.md §7 + the
# universal API additions in
# designs/SERVER_HARNESS_CONTRACT.md). The ``params`` block
# mirrors MCP's ``ElicitRequestFormParams`` field-for-field;
# we surface it as a typed event so the stream consumer can
# route it to the elicitation hook (not into a ToolHandler).
if event_type == "response.elicitation_request":
elicitation_id = data.get("elicitation_id")
if not isinstance(elicitation_id, str) or not elicitation_id:
_log.warning(
"response.elicitation_request missing elicitation_id: %r",
data,
)
return None
params = data.get("params")
if not isinstance(params, dict):
_log.warning(
"response.elicitation_request missing params dict: %r",
data,
)
return None
requested_schema = params.get("requestedSchema")
return ElicitationRequest(
elicitation_id=elicitation_id,
message=str(params.get("message") or ""),
# MCP spec restricts requestedSchema to a JSON-Schema
# dict — we accept anything dict-shaped and pass it
# through; non-dict inputs (defensive against
# malformed producers) become an empty schema.
requested_schema=requested_schema if isinstance(requested_schema, dict) else {},
mode=str(params.get("mode") or "form"),
phase=str(params.get("phase") or ""),
policy_name=str(params.get("policy_name") or ""),
content_preview=str(params.get("content_preview") or ""),
url=str(params["url"]) if isinstance(params.get("url"), str) else None,
)
# Unknown event — skip gracefully for forward-compatibility
_log.debug("Skipping unknown SSE event type: %s", event_type)
return None
def _parse_output_item(data: dict[str, Any]) -> StreamEvent | None:
"""Parse a ``response.output_item.done`` event into a typed event."""
item = data.get("item")
if not isinstance(item, dict):
return None
item_type = item.get("type", "")
if item_type == "function_call":
args_str = str(item.get("arguments", "{}"))
try:
arguments = json.loads(args_str)
except json.JSONDecodeError:
arguments = {}
name = str(item.get("name", ""))
call_id = str(item.get("call_id", ""))
return ToolCall(
name=name,
arguments=arguments,
call_id=call_id,
status=str(item.get("status", "")),
agent_name=str(item.get("model", "")),
)
if item_type == "function_call_output":
raw_arguments = item.get("arguments")
arguments: dict[str, object] = {}
if isinstance(raw_arguments, dict):
arguments = raw_arguments
elif isinstance(raw_arguments, str) and raw_arguments:
try:
decoded_arguments = json.loads(raw_arguments)
except json.JSONDecodeError:
decoded_arguments = {}
if isinstance(decoded_arguments, dict):
arguments = decoded_arguments
return ToolResult(
call_id=str(item.get("call_id", "")),
output=str(item.get("output", "")),
arguments=arguments,
)
if item_type == "message":
content = item.get("content", [])
return MessageDone(
content=content if isinstance(content, list) else [],
)
if item_type in NATIVE_TOOL_TYPES:
return NativeToolCall(
tool_type=item_type,
data=item,
)
# Compaction items, reasoning items, etc. — skip
_log.debug("Skipping output item type: %s", item_type)
return None
def _parse_response(data: dict[str, Any]) -> Response:
"""Extract the Response object from an SSE event payload."""
resp_data = data.get("response")
if isinstance(resp_data, dict):
return Response.from_dict(resp_data)
# Some events put fields at the top level
return Response.from_dict(data)
def _parse_error_info(raw: Any) -> ErrorInfo:
"""Parse an ErrorInfo from a nested dict."""
if isinstance(raw, dict):
return ErrorInfo(
code=str(raw.get("code", "")),
message=str(raw.get("message", "")),
)
return ErrorInfo(code="", message=str(raw))
@@ -0,0 +1,615 @@
"""BlockStream — event dispatch state machine that emits semantic blocks.
Consumes the raw event stream from ``session.send()`` and produces
a stream of typed blocks with context. Each block carries a
``BlockContext`` identifying which agent produced it.
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING
from ._blocks import (
AnyBlock,
BlockContext,
CompactionBlock,
ErrorBlock,
FileBlock,
NativeToolBlock,
ReasoningBlock,
ReasoningChunk,
ReasoningStartBlock,
ResponseEndBlock,
ResponseStartBlock,
RetryBlock,
TextChunk,
TextDone,
ToolExecution,
ToolGroup,
ToolResultBlock,
)
from ._events import (
CompactionInProgress,
ErrorEvent,
MessageDone,
NativeToolCall,
OutputFileDone,
ReasoningDelta,
ReasoningStarted,
ReasoningSummaryDelta,
ResponseCancelled,
ResponseCompleted,
ResponseCreated,
ResponseFailed,
ResponseIncomplete,
ResponseInProgress,
ResponseQueued,
RetryEvent,
TextDelta,
ToolCall,
ToolResult,
)
if TYPE_CHECKING:
from ._session import Session
def format_tool_args_brief(name: str, arguments: dict[str, object]) -> str:
"""
Format tool arguments for inline display next to the ``⏵ <name>``
line.
Public so frontends that re-render historical tool calls (e.g. the
REPL's conversation-resume preview) can produce the same
``args_summary`` string the live stream produced when the call
first ran. Without a single source of truth, "this is what you
saw originally" diverges from "this is what the renderer
chooses now" the moment either side changes.
:param name: Tool name, e.g. ``"Read"``.
:param arguments: Parsed arguments dict, e.g.
``{"file_path": "/x/y.py"}``.
:returns: One-line summary, e.g. ``"y.py"`` for ``Read`` or the
tool's primary user-meaningful field. Falls back to a JSON
encoding of the full dict (truncated to 80 chars) for
unrecognized tools.
"""
if not arguments:
return ""
_KEYS = {
"Read": "file_path",
"Write": "file_path",
"Edit": "file_path",
"Bash": "command",
"Glob": "pattern",
"Grep": "pattern",
"web_search": "query",
}
key = _KEYS.get(name)
if key and key in arguments:
s = str(arguments[key])
if key == "file_path" and "/" in s:
s = s.rsplit("/", 1)[-1]
return s[:80] + "" if len(s) > 80 else s
try:
s = json.dumps(arguments, ensure_ascii=False)
except (TypeError, ValueError):
s = str(arguments)
return s[:80] + "" if len(s) > 80 else s
def _format_native_label(tool_type: str, data: dict[str, object]) -> str:
"""Format a native tool label."""
if tool_type == "web_search_call":
action = data.get("action")
if isinstance(action, dict):
at = action.get("type", "")
if at == "search":
return f"web search: {str(action.get('query', ''))[:80]}"
if at == "open_page":
return f"web open: {str(action.get('url', ''))[:80]}"
return "web search"
if tool_type == "mcp_call":
n = data.get("name", "")
return f"mcp: {n}" if n else "mcp call"
return tool_type.replace("_", " ")
def _output_text_from_message_content(content: list[dict[str, object]]) -> str:
"""
Extract completed assistant text from a message item.
:param content: Message content blocks from ``MessageDone``.
:returns: Concatenated ``output_text`` content.
"""
parts: list[str] = []
for block in content:
if block.get("type") != "output_text":
continue
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts)
class BlockStream:
"""Consumes a session event stream and emits semantic stream blocks.
:param text_flush_threshold: Min chars to buffer before flushing
on a word boundary. Default 30.
"""
def __init__(self, text_flush_threshold: int = 30) -> None:
self._flush_threshold = text_flush_threshold
async def stream(
self,
session: Session,
input: str | list[dict[str, object]],
*,
files: list[str] | None = None,
) -> AsyncIterator[AnyBlock]:
"""Stream semantic blocks for one turn.
:param session: The :class:`Session` whose event stream to fold.
The session's ``send()`` method is invoked internally.
:param input: User text or a list of content-block dicts,
e.g. ``"hello"`` or ``[{"type": "input_text", "text": "hi"}]``.
:param files: Optional list of file paths to upload and attach
to the turn, e.g. ``["./data.csv"]``.
:returns: Async iterator of :class:`AnyBlock` values in order.
"""
in_reasoning = False
reasoning_text = ""
summary_text = ""
# Per-section accumulator for reasoning chunks awaiting a
# newline / threshold flush. Mirrors ``accumulated`` for text.
reasoning_accumulated = ""
# Set when this reasoning section streamed any
# ``ReasoningChunk``. The final ``ReasoningBlock`` is then
# suppressed so renderers don't show the same text twice
# (once live, once as a summary panel).
reasoning_chunks_emitted = False
in_text = False
accumulated = ""
full_text = ""
pending_tools: dict[str, ToolExecution] = {}
# Long-lived metadata for tool calls rendered in this stream.
# Unlike ``pending_tools``, this survives text deltas so a
# delayed ``ToolResult`` can still render with the original
# tool name/arguments instead of being dropped.
tool_executions_by_call_id: dict[str, ToolExecution] = {}
# Dedup set: every ``call_id`` we've yielded a
# ``ToolGroup`` for so far. Survives ``pending_tools``
# clears (which fire on each ``TextDelta`` to flush
# ``ToolResultBlock``s for completed tools), so the
# post-stream action_required event for an MCP tool
# whose inline observed event already rendered doesn't
# silently re-render once the intervening text deltas
# cleared ``pending_tools``. Call_ids are SDK-assigned
# ``toolu_*`` ids — globally unique within this turn,
# so the set never grows pathologically.
seen_call_ids: set[str] = set()
# Dedup set: every ``call_id`` we've yielded a
# ``ToolResultBlock`` for. Same survives-clear semantics
# as ``seen_call_ids`` for the call-line side, but on the
# result side. Necessary because every action_required
# tool's result fires TWICE on the SSE stream: once
# inline from ``_dispatch_action_required`` (the moment
# the dispatch returns) and once from the
# ``response.completed`` flush in
# ``_translate_omnigent_event``. Without this, the result
# panel renders twice.
seen_result_call_ids: set[str] = set()
agent: str | None = None
turn = 0
started = False
def _ctx() -> BlockContext:
depth = agent.count(".") if agent else 0
return BlockContext(agent=agent, depth=depth, turn=turn)
async for event in session.send(input, files=files):
# ── Response lifecycle ───────────────────
if isinstance(event, ResponseCreated):
# Tool calls were already yielded immediately. Emit
# result-only groups for tools that got output between
# ResponseCompleted and this ResponseCreated.
for ex in list(pending_tools.values()):
if ex.output is not None:
yield ToolResultBlock(
name=ex.name,
call_id=ex.call_id,
agent_name=ex.agent_name,
output=ex.output,
arguments=ex.arguments,
args_summary=ex.args_summary,
ctx=_ctx(),
)
pending_tools.clear()
tool_executions_by_call_id.clear()
agent = event.response.model
if not started:
started = True
yield ResponseStartBlock(
model=agent,
response_id=event.response.id,
ctx=_ctx(),
)
else:
turn += 1
elif isinstance(event, ResponseQueued | ResponseInProgress):
pass
# ── Reasoning ────────────────────────────
elif isinstance(event, ReasoningStarted):
# Already reasoning = a new summarized-thinking block in the
# same section. Flush this block's tail + a separator so
# consecutive thought items don't run together.
if in_reasoning:
yield ReasoningChunk(text=reasoning_accumulated + "\n\n", ctx=_ctx())
reasoning_accumulated = ""
reasoning_chunks_emitted = True
continue
# Entering reasoning closes open text (symmetric with
# TextDelta closing reasoning) — else interleaved
# think→speak→think orphans + concatenates text.
if in_text:
if accumulated:
yield TextChunk(text=accumulated, ctx=_ctx())
accumulated = ""
yield TextDone(
full_text=full_text,
has_code_blocks="```" in full_text,
ctx=_ctx(),
)
in_text = False
full_text = ""
in_reasoning = True
reasoning_text = ""
summary_text = ""
reasoning_accumulated = ""
reasoning_chunks_emitted = False
yield ReasoningStartBlock(ctx=_ctx())
elif isinstance(event, ReasoningDelta | ReasoningSummaryDelta):
# An out-of-order delta (no preceding ReasoningStarted)
# marks an implicit start — Codex's bridged events
# arrive this way. Emit the start block once so the
# formatter has its "thinking…" anchor.
if not in_reasoning:
# Close any open text first — same boundary as ReasoningStarted.
if in_text:
if accumulated:
yield TextChunk(text=accumulated, ctx=_ctx())
accumulated = ""
yield TextDone(
full_text=full_text,
has_code_blocks="```" in full_text,
ctx=_ctx(),
)
in_text = False
full_text = ""
in_reasoning = True
reasoning_text = ""
summary_text = ""
reasoning_accumulated = ""
reasoning_chunks_emitted = False
yield ReasoningStartBlock(ctx=_ctx())
if isinstance(event, ReasoningDelta):
reasoning_text += event.delta
else:
summary_text += event.delta
# Stream the delta as a ReasoningChunk so the TUI can
# render mid-flight. Mirrors the TextDelta line/
# threshold flush so chunks land on natural breaks.
reasoning_accumulated += event.delta
while "\n" in reasoning_accumulated:
line, reasoning_accumulated = reasoning_accumulated.split("\n", 1)
yield ReasoningChunk(text=line + "\n", ctx=_ctx())
reasoning_chunks_emitted = True
if len(reasoning_accumulated) >= self._flush_threshold:
last_space = reasoning_accumulated.rfind(" ")
if last_space > 0:
yield ReasoningChunk(
text=reasoning_accumulated[: last_space + 1],
ctx=_ctx(),
)
reasoning_accumulated = reasoning_accumulated[last_space + 1 :]
reasoning_chunks_emitted = True
# ── Text ─────────────────────────────────
elif isinstance(event, TextDelta):
if in_reasoning:
in_reasoning = False
if reasoning_accumulated:
yield ReasoningChunk(
text=reasoning_accumulated,
ctx=_ctx(),
)
reasoning_chunks_emitted = True
reasoning_accumulated = ""
if not reasoning_chunks_emitted:
yield ReasoningBlock(
reasoning_text=reasoning_text,
summary_text=summary_text,
ctx=_ctx(),
)
# Emit results for tools that completed.
for ex in list(pending_tools.values()):
if ex.output is not None:
yield ToolResultBlock(
name=ex.name,
call_id=ex.call_id,
agent_name=ex.agent_name,
output=ex.output,
arguments=ex.arguments,
args_summary=ex.args_summary,
ctx=_ctx(),
)
pending_tools.clear()
in_text = True
accumulated += event.delta
full_text += event.delta
while "\n" in accumulated:
line, accumulated = accumulated.split("\n", 1)
yield TextChunk(text=line + "\n", ctx=_ctx())
if len(accumulated) >= self._flush_threshold:
last_space = accumulated.rfind(" ")
if last_space > 0:
yield TextChunk(text=accumulated[: last_space + 1], ctx=_ctx())
accumulated = accumulated[last_space + 1 :]
# ── Tool calls ───────────────────────────
elif isinstance(event, ToolCall):
# Dedupe by call_id. Under the claude-sdk harness's
# MCP path, a tool call surfaces as TWO ToolCall
# events with correlated call_ids: an inline
# observed event (status="completed") emitted as
# the inner SDK parses the tool_use block, and a
# post-stream action_required event emitted when
# the SDK invokes the MCP-server handler. The
# adapter (omnigent/runtime/harnesses/
# _executor_adapter.py) threads the SDK's
# tool_use_id through both so they share a
# call_id; this block keeps the first occurrence
# (the inline render) and drops the second so the
# REPL doesn't render ``⏵ tool_name`` twice. See
# designs/RUN_OMNIGENT_REPL_PARITY.md.
#
# Non-MCP paths emit exactly one ToolCall per
# call_id, so the second-arrival branch never
# fires for them.
if event.call_id in seen_call_ids:
# Already rendered the ⏵ line for this call_id
# (e.g. via the inline observed event from the
# harness's content_block_stop, or via the
# ``ToolCallInProgress`` event the Omnigent server
# emits at action_required arrival). Re-register
# in pending_tools so the eventual ``ToolResult``
# can pair by call_id — the prior pending entry
# may have been cleared by the
# ``pending_tools.clear()`` that fires on every
# ``TextDelta`` between observed and
# action_required (or between action_required
# and the post-PATCH function_call_output).
execution = tool_executions_by_call_id.get(event.call_id)
if execution is None:
execution = ToolExecution(
name=event.name,
arguments=event.arguments,
args_summary=format_tool_args_brief(event.name, event.arguments),
call_id=event.call_id,
agent_name=event.agent_name,
executed_by="server",
)
tool_executions_by_call_id[event.call_id] = execution
pending_tools[event.call_id] = execution
continue
seen_call_ids.add(event.call_id)
if in_reasoning:
in_reasoning = False
if reasoning_accumulated:
yield ReasoningChunk(
text=reasoning_accumulated,
ctx=_ctx(),
)
reasoning_chunks_emitted = True
reasoning_accumulated = ""
if not reasoning_chunks_emitted:
yield ReasoningBlock(
reasoning_text=reasoning_text,
summary_text=summary_text,
ctx=_ctx(),
)
if in_text:
if accumulated:
yield TextChunk(text=accumulated, ctx=_ctx())
accumulated = ""
yield TextDone(
full_text=full_text,
has_code_blocks="```" in full_text,
ctx=_ctx(),
)
in_text = False
full_text = ""
execution = ToolExecution(
name=event.name,
arguments=event.arguments,
args_summary=format_tool_args_brief(event.name, event.arguments),
call_id=event.call_id,
agent_name=event.agent_name,
executed_by="server",
)
pending_tools[event.call_id] = execution
tool_executions_by_call_id[event.call_id] = execution
# Yield immediately so the user sees the tool call
# before execution. output=None means the formatter
# shows the call line but no result panel.
yield ToolGroup(executions=[execution], ctx=_ctx())
elif isinstance(event, ToolResult):
if event.call_id in seen_result_call_ids:
# Already rendered the result panel. The late
# ``response.completed`` flush emission is
# redundant; drop it so a subsequent
# ``ResponseCreated`` doesn't yield it again
# via the pending_tools sweep.
continue
ex = pending_tools.get(event.call_id) or tool_executions_by_call_id.get(
event.call_id
)
if ex is None:
# Result arrived after a TextDelta cleared
# ``pending_tools``. If the matching call line
# never rendered, we have no name/agent for the
# panel — drop the event rather than render
# with placeholders.
continue
ex.output = event.output
ex.executed_by = "client"
if event.arguments:
ex.arguments = event.arguments
# Yield the result panel IMMEDIATELY so multi-
# tool turns don't bunch result rendering at
# end-of-turn. Each dispatch's
# ``_live_publish`` of the function_call_output
# arrives here as the tool finishes.
seen_result_call_ids.add(event.call_id)
yield ToolResultBlock(
name=ex.name,
call_id=ex.call_id,
agent_name=ex.agent_name,
output=event.output,
arguments=ex.arguments,
args_summary=ex.args_summary,
ctx=_ctx(),
)
# Drop from ``pending_tools`` so the next
# ``TextDelta`` / ``ResponseCreated`` sweep
# doesn't re-yield this result.
pending_tools.pop(event.call_id, None)
# ── Native tools ─────────────────────────
elif isinstance(event, NativeToolCall):
yield NativeToolBlock(
tool_type=event.tool_type,
label=_format_native_label(event.tool_type, event.data),
data=event.data,
ctx=_ctx(),
)
# ── Message done ─────────────────────────
elif isinstance(event, MessageDone):
if in_reasoning:
in_reasoning = False
if reasoning_accumulated:
yield ReasoningChunk(
text=reasoning_accumulated,
ctx=_ctx(),
)
reasoning_chunks_emitted = True
reasoning_accumulated = ""
if not reasoning_chunks_emitted:
yield ReasoningBlock(
reasoning_text=reasoning_text,
summary_text=summary_text,
ctx=_ctx(),
)
if in_text:
if accumulated:
yield TextChunk(text=accumulated, ctx=_ctx())
accumulated = ""
yield TextDone(
full_text=full_text,
has_code_blocks="```" in full_text,
ctx=_ctx(),
)
in_text = False
full_text = ""
else:
text = _output_text_from_message_content(event.content)
if text:
# No prior deltas: synthesize a TextChunk so
# TextChunk-only consumers (Session._stream_chunks)
# still see the message.
yield TextChunk(text=text, ctx=_ctx())
yield TextDone(
full_text=text,
has_code_blocks="```" in text,
ctx=_ctx(),
)
# ── Status events ────────────────────────
elif isinstance(event, CompactionInProgress):
yield CompactionBlock(ctx=_ctx())
elif isinstance(event, RetryEvent):
yield RetryBlock(
source=event.source,
attempt=event.attempt,
max_attempts=event.max_attempts,
delay_seconds=event.delay_seconds,
ctx=_ctx(),
)
elif isinstance(event, ErrorEvent):
# Pass ``code`` through too — renderers need it as a
# fallback label when ``message`` is empty (otherwise
# the error panel shows just ``[llm]`` with no hint
# as to what went wrong).
yield ErrorBlock(
message=event.error.message,
source=event.source,
code=event.error.code,
ctx=_ctx(),
)
elif isinstance(event, OutputFileDone):
yield FileBlock(file_id=event.file_id, filename=event.filename, ctx=_ctx())
# ── Terminal events ──────────────────────
elif isinstance(
event,
ResponseCompleted | ResponseFailed | ResponseIncomplete | ResponseCancelled,
):
if in_reasoning:
in_reasoning = False
if reasoning_accumulated:
yield ReasoningChunk(
text=reasoning_accumulated,
ctx=_ctx(),
)
reasoning_chunks_emitted = True
reasoning_accumulated = ""
if not reasoning_chunks_emitted:
yield ReasoningBlock(
reasoning_text=reasoning_text,
summary_text=summary_text,
ctx=_ctx(),
)
if in_text:
if accumulated:
yield TextChunk(text=accumulated, ctx=_ctx())
accumulated = ""
yield TextDone(
full_text=full_text,
has_code_blocks="```" in full_text,
ctx=_ctx(),
)
in_text = False
full_text = ""
yield ResponseEndBlock(
status=event.response.status,
response=event.response,
ctx=_ctx(),
)
@@ -0,0 +1,323 @@
"""Tool handler, hooks, and context types for client-side tool execution."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from ._types import ErrorInfo, Response
# ── Tool handler ─────────────────────────────────────────
@dataclass
class ToolCallInfo:
"""Context passed to ``ToolHandler.execute``."""
name: str
arguments: dict[str, object]
call_id: str
agent_name: str # "coder" or "coder.researcher"
response_id: str
iteration: int # Tool loop iteration (0-based)
@dataclass
class ToolHandler:
"""Client-side tool execution configuration.
Passed to ``stream()`` to enable automatic tool execution.
The client runs the full loop: stream -> detect tool calls ->
call ``execute()`` -> send results -> continue streaming.
"""
schemas: list[dict[str, object]]
execute: Callable[[ToolCallInfo], Awaitable[str] | str]
# ── Hook context types ───────────────────────────────────
@dataclass
class ToolCallStartCtx:
"""Context for ``on_tool_call_start``."""
name: str
arguments: dict[str, object]
call_id: str
agent_name: str
executed_by: str # "client" or "server"
@dataclass
class ToolCallEndCtx:
"""Context for ``on_tool_call_end``."""
name: str
call_id: str
agent_name: str
output: str
@dataclass
class NativeToolCallCtx:
"""Context for ``on_native_tool_call``."""
tool_type: str
data: dict[str, object]
@dataclass
class ToolResultInfo:
"""A single tool result within ``ToolResultsReadyCtx``."""
call_id: str
name: str
output: str
agent_name: str
@dataclass
class ToolResultsReadyCtx:
"""Context for ``on_tool_results_ready``."""
results: list[ToolResultInfo]
iteration: int
@dataclass
class ReasoningStartCtx:
"""Context for ``on_reasoning_start``."""
@dataclass
class ReasoningEndCtx:
"""Context for ``on_reasoning_end``."""
reasoning_text: str
summary_text: str
@dataclass
class CompactionStartCtx:
"""Context for ``on_compaction_start``."""
@dataclass
class CompactionEndCtx:
"""Context for ``on_compaction_end``."""
item: dict[str, object]
@dataclass
class MessageStartCtx:
"""Context for ``on_message_start``."""
response_id: str
@dataclass
class MessageEndCtx:
"""Context for ``on_message_end``."""
content: list[dict[str, object]]
@dataclass
class FileOutputCtx:
"""Context for ``on_file_output``."""
file_id: str
filename: str | None
content_type: str | None
@dataclass
class RetryCtx:
"""Context for ``on_retry``."""
source: str
tool_name: str | None
attempt: int
max_attempts: int
delay_seconds: float
error: ErrorInfo
@dataclass
class ServerErrorCtx:
"""Context for ``on_server_error``."""
source: str
tool_name: str | None
error: ErrorInfo
@dataclass
class TransportErrorCtx:
"""Context for ``on_transport_error``."""
error: Exception
@dataclass
class SubAgentInfo:
"""Info about a single spawned sub-agent."""
response_id: str
agent_name: str
@dataclass
class SubAgentSpawnedCtx:
"""Context for ``on_sub_agent_spawned``."""
parent_response_id: str
sub_agents: list[SubAgentInfo] = field(default_factory=list)
@dataclass
class SubAgentCompletedCtx:
"""Context for ``on_sub_agent_completed``."""
response_id: str
agent_name: str
status: str
output_summary: str | None
@dataclass
class ElicitationRequestCtx:
"""
Context for ``on_elicitation_request``.
Surfaced when the server emits a ``response.elicitation_request``
SSE event (matches MCP's ``elicitation/create`` semantics —
see ``designs/SERVER_HARNESS_CONTRACT.md`` §"Universal API
additions" + POLICIES.md §7). The hook returns ``True`` to
accept or ``False`` to decline; the client submits the
verdict to the server automatically as an MCP-shape
``ElicitResult`` POST.
For richer elicitations (with form fields), the hook may
return an ``ElicitationResult``-shaped value instead of a
bool — bool is the convenience path for binary
approve/decline.
:param elicitation_id: Server-assigned id. Used in the URL
path of the reply endpoint, e.g. ``"elicit_abc123"``.
:param message: Human-readable prompt to render. For the
policy ASK producer this is the combined reason string
from deciding ASKing policies (``"; "``-joined).
:param requested_schema: A restricted subset of JSON Schema
defining the structure of an expected response. Empty
``{}`` for binary approve/reject elicitations.
:param mode: MCP elicitation mode — ``"form"`` (inline) or
``"url"`` (standalone approval page).
:param phase: Producer-supplied extra (policy ASK only) —
which enforcement point produced the ASK. Empty string
when not applicable.
:param policy_name: Producer-supplied extra (policy ASK
only) — name of the deciding ASKing policy, e.g.
``"approve_web_search"``. Empty string when not
applicable.
:param content_preview: Producer-supplied extra (policy ASK
only) — truncated (1024 chars) snapshot of the gated
content. Safe to display verbatim. Empty string when
not applicable.
:param response_id: The in-progress response id — for
logging / audit purposes only. The client handles the
elicitation reply POST automatically.
"""
elicitation_id: str
message: str
requested_schema: dict[str, object]
mode: str
phase: str
policy_name: str
content_preview: str
response_id: str
url: str | None = None
@dataclass
class ResponseStartCtx:
"""Context for ``on_response_start``."""
response: Response
@dataclass
class ResponseEndCtx:
"""Context for ``on_response_end``."""
response: Response
status: str # "completed", "failed", "incomplete", "cancelled"
# ── Stream hooks ─────────────────────────────────────────
# Hook type alias: sync or async callable, or None.
_Hook = Callable[..., Awaitable[None] | None] | None
@dataclass
class StreamHooks:
"""Lifecycle hooks for stream events.
Every class of event has a start/end hook pair where
applicable. Hooks can be sync or async. All are optional.
"""
# Tool calls (server-side and client-side)
on_tool_call_start: Callable[[ToolCallStartCtx], Awaitable[None] | None] | None = None
on_tool_call_end: Callable[[ToolCallEndCtx], Awaitable[None] | None] | None = None
# Native tool calls (provider-executed)
on_native_tool_call: Callable[[NativeToolCallCtx], Awaitable[None] | None] | None = None
# Tool loop iteration
on_tool_results_ready: Callable[[ToolResultsReadyCtx], Awaitable[None] | None] | None = None
# Reasoning
on_reasoning_start: Callable[[ReasoningStartCtx], Awaitable[None] | None] | None = None
on_reasoning_end: Callable[[ReasoningEndCtx], Awaitable[None] | None] | None = None
# Compaction
on_compaction_start: Callable[[CompactionStartCtx], Awaitable[None] | None] | None = None
on_compaction_end: Callable[[CompactionEndCtx], Awaitable[None] | None] | None = None
# Message (assistant response)
on_message_start: Callable[[MessageStartCtx], Awaitable[None] | None] | None = None
on_message_end: Callable[[MessageEndCtx], Awaitable[None] | None] | None = None
# File output
on_file_output: Callable[[FileOutputCtx], Awaitable[None] | None] | None = None
# Retry and error
on_retry: Callable[[RetryCtx], Awaitable[None] | None] | None = None
on_server_error: Callable[[ServerErrorCtx], Awaitable[None] | None] | None = None
on_transport_error: Callable[[TransportErrorCtx], Awaitable[bool] | bool] | None = None
# Sub-agent lifecycle
on_sub_agent_spawned: Callable[[SubAgentSpawnedCtx], Awaitable[None] | None] | None = None
on_sub_agent_completed: Callable[[SubAgentCompletedCtx], Awaitable[None] | None] | None = None
# Response lifecycle
on_response_start: Callable[[ResponseStartCtx], Awaitable[None] | None] | None = None
on_response_end: Callable[[ResponseEndCtx], Awaitable[None] | None] | None = None
# MCP-shape elicitations. Called when the server emits a
# ``response.elicitation_request`` SSE event (POLICIES.md §7
# surfaces policy ASKs as one producer; future producers
# include credential prompts, parameter clarifications, and
# any other MCP elicitation use case). The callback returns
# ``True`` to accept or ``False`` to decline; the client POSTs
# a session ``approval`` event carrying the MCP-shape
# ``ElicitResult``. When no callback is registered, the client
# fail-closed declines — an unhandled
# elicitation becomes DENY, preserving fail-loud-rather-than-
# block semantics.
on_elicitation_request: Callable[[ElicitationRequestCtx], Awaitable[bool] | bool] | None = None
@@ -0,0 +1,138 @@
"""Composable stream transforms for stream blocks.
Each transform is an async generator function that wraps a block
stream. Compose with ``pipe()``:
stream = pipe(
block_stream.stream(session, text),
skip_blocks(ReasoningBlock),
skip_intermediate_ends(),
)
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from ._blocks import (
AnyBlock,
ResponseEndBlock,
TextDone,
)
def pipe(stream: AsyncIterator[AnyBlock], *transforms: object) -> AsyncIterator[AnyBlock]:
"""Compose transforms left-to-right.
``pipe(stream, t1, t2)`` is equivalent to ``t2(t1(stream))``.
"""
for t in transforms:
stream = t(stream)
return stream
def skip_blocks(*types: type) -> object:
"""Drop blocks of the given types.
Usage::
stream = skip_blocks(ReasoningBlock)(renderer.stream(...))
"""
def _transform(stream: AsyncIterator[AnyBlock]) -> AsyncIterator[AnyBlock]:
async def _inner() -> AsyncIterator[AnyBlock]:
async for block in stream:
if not isinstance(block, tuple(types)):
yield block
return _inner()
return _transform
def skip_intermediate_ends() -> object:
"""Suppress ``ResponseEndBlock`` events from tool loop iterations.
Only yields the final ``ResponseEndBlock`` — the one not followed
by another block from the same turn.
"""
def _transform(stream: AsyncIterator[AnyBlock]) -> AsyncIterator[AnyBlock]:
async def _inner() -> AsyncIterator[AnyBlock]:
pending_end: ResponseEndBlock | None = None
async for block in stream:
if isinstance(block, ResponseEndBlock):
# Buffer it — might not be the last one.
pending_end = block
else:
# A non-end block arrived, so the buffered end
# was intermediate. Drop it.
pending_end = None
yield block
# Stream ended. The buffered end IS the final one.
if pending_end is not None:
yield pending_end
return _inner()
return _transform
def merge_text_across_iterations() -> object:
"""Merge ``TextDone`` blocks across tool loop iterations.
When the tool loop runs N iterations, each may produce a
``TextDone``. This merges them into a single ``TextDone`` at
the end. ``TextChunk`` blocks pass through unchanged.
"""
def _transform(stream: AsyncIterator[AnyBlock]) -> AsyncIterator[AnyBlock]:
async def _inner() -> AsyncIterator[AnyBlock]:
accumulated = ""
async for block in stream:
if isinstance(block, TextDone):
accumulated += block.full_text
elif isinstance(block, ResponseEndBlock):
if accumulated:
yield TextDone(
full_text=accumulated,
has_code_blocks="```" in accumulated,
ctx=block.ctx,
)
accumulated = ""
yield block
else:
yield block
# Edge case: stream ends without ResponseEndBlock.
if accumulated:
yield TextDone(
full_text=accumulated,
has_code_blocks="```" in accumulated,
)
return _inner()
return _transform
def only_agent(agent_name: str | None) -> object:
"""Filter to blocks from a specific agent.
Pass ``None`` to include all agents (no filtering).
Usage::
stream = only_agent("coder.researcher")(renderer.stream(...))
:param agent_name: Agent name to filter by, or ``None`` for all.
"""
def _transform(stream: AsyncIterator[AnyBlock]) -> AsyncIterator[AnyBlock]:
async def _inner() -> AsyncIterator[AnyBlock]:
async for block in stream:
if agent_name is None or block.ctx.agent == agent_name:
yield block
return _inner()
return _transform
@@ -0,0 +1,235 @@
"""Typed dataclasses for API response objects."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class ConversationRef:
"""Reference to a conversation, as returned on response objects."""
id: str
@dataclass
class Usage:
"""
Token usage statistics for a completed response.
:param input_tokens: Number of input (prompt) tokens consumed
(sum across all LLM sub-calls for multi-call turns).
:param output_tokens: Number of output (completion) tokens
generated (sum across all LLM sub-calls).
:param total_tokens: Billing total — ``input_tokens + output_tokens``
summed across all sub-calls.
:param context_tokens: Context-fill estimate for the next turn.
Present only for executors that make multiple LLM sub-calls per
turn (e.g. ``openai-agents``); ``None`` for single-call
executors where ``total_tokens`` serves the same purpose.
"""
input_tokens: int
output_tokens: int
total_tokens: int
context_tokens: int | None = None
@dataclass
class ErrorInfo:
"""Structured error information from the server."""
code: str
message: str
@dataclass
class IncompleteDetails:
"""Details about why a response stopped early."""
reason: str # "max_iterations", "execution_timeout", "context_overflow", etc.
@dataclass
class Response:
"""A response object from the server.
Mirrors the JSON shape from ``POST /v1/responses`` and
``GET /v1/responses/{id}``.
"""
id: str
status: str # "queued", "in_progress", "completed", "failed", "incomplete", "cancelled"
model: str
output: list[dict[str, object]] = field(default_factory=list)
created_at: int = 0
completed_at: int | None = None
previous_response_id: str | None = None
conversation: ConversationRef | None = None
usage: Usage | None = None
error: ErrorInfo | None = None
incomplete_details: IncompleteDetails | None = None
background: bool = False
instructions: str | None = None
@classmethod
def from_dict(cls, data: dict[str, object]) -> Response:
"""Parse a response object from a JSON dict."""
conv_raw = data.get("conversation")
conversation = (
ConversationRef(id=str(conv_raw["id"])) if isinstance(conv_raw, dict) else None
)
usage_raw = data.get("usage")
usage = (
Usage(
input_tokens=int(usage_raw.get("input_tokens", 0)),
output_tokens=int(usage_raw.get("output_tokens", 0)),
total_tokens=int(usage_raw.get("total_tokens", 0)),
context_tokens=(
int(usage_raw["context_tokens"])
if usage_raw.get("context_tokens") is not None
else None
),
)
if isinstance(usage_raw, dict)
else None
)
error_raw = data.get("error")
error = (
ErrorInfo(
code=str(error_raw.get("code", "")),
message=str(error_raw.get("message", "")),
)
if isinstance(error_raw, dict)
else None
)
inc_raw = data.get("incomplete_details")
incomplete = (
IncompleteDetails(reason=str(inc_raw.get("reason", "")))
if isinstance(inc_raw, dict)
else None
)
return cls(
id=str(data.get("id", "")),
status=str(data.get("status", "")),
model=str(data.get("model", "")),
output=data.get("output", []) if isinstance(data.get("output"), list) else [],
created_at=int(data.get("created_at", 0)),
completed_at=int(data["completed_at"])
if data.get("completed_at") is not None
else None,
previous_response_id=(
str(data["previous_response_id"])
if data.get("previous_response_id") is not None
else None
),
conversation=conversation,
usage=usage,
error=error,
incomplete_details=incomplete,
background=bool(data.get("background", False)),
instructions=(
str(data["instructions"]) if data.get("instructions") is not None else None
),
)
@dataclass
class Agent:
"""An agent registered on the server."""
id: str
name: str
description: str | None
created_at: int
@classmethod
def from_dict(cls, data: dict[str, object]) -> Agent:
"""Parse an agent from a JSON dict."""
return cls(
id=str(data.get("id", "")),
name=str(data.get("name", "")),
description=str(data["description"]) if data.get("description") is not None else None,
created_at=int(data.get("created_at", 0)),
)
@dataclass
class File:
"""A file uploaded to the server."""
id: str
filename: str
bytes: int
created_at: int
@classmethod
def from_dict(cls, data: dict[str, object]) -> File:
"""Parse a file from a JSON dict."""
return cls(
id=str(data.get("id", "")),
filename=str(data.get("filename", "")),
bytes=int(data.get("bytes", 0)),
created_at=int(data.get("created_at", 0)),
)
@dataclass
class Conversation:
"""
A conversation on the server.
:param id: Server-assigned conversation id, e.g. ``"conv_abc123"``.
:param title: Optional user-assigned title.
:param created_at: Unix epoch timestamp of creation.
:param labels: Guardrails labels on this conversation (keys are
label names, values are their current string values). Empty
dict when the server's PolicyEngine hasn't written any
labels yet. Surfaced so callers like the REPL's Ctrl+O debug
overlay can render them at parity with the legacy Ctrl+G
overview. Older servers that don't include ``labels`` in
their ``ConversationObject`` response parse to ``{}``.
"""
id: str
title: str | None
created_at: int
labels: dict[str, str] = field(default_factory=dict)
@classmethod
def from_dict(cls, data: dict[str, object]) -> Conversation:
"""Parse a conversation from a JSON dict."""
raw_labels = data.get("labels")
labels: dict[str, str] = {}
if isinstance(raw_labels, dict):
# Narrow to ``dict[str, str]`` — the server's schema declares
# that shape but the JSON boundary only gives us
# ``dict[str, object]``. Drop non-string entries rather than
# crash the overlay render on a malformed response.
labels = {str(k): str(v) for k, v in raw_labels.items() if isinstance(v, str)}
return cls(
id=str(data.get("id", "")),
title=str(data["title"]) if data.get("title") is not None else None,
created_at=int(data.get("created_at", 0)),
labels=labels,
)
@dataclass
class PaginatedList:
"""A paginated list response from the server."""
data: list[dict[str, object]]
first_id: str | None = None
last_id: str | None = None
has_more: bool = False
@classmethod
def from_dict(cls, data: dict[str, object]) -> PaginatedList:
"""Parse a paginated list from a JSON dict."""
return cls(
data=data.get("data", []) if isinstance(data.get("data"), list) else [],
first_id=str(data["first_id"]) if data.get("first_id") is not None else None,
last_id=str(data["last_id"]) if data.get("last_id") is not None else None,
has_more=bool(data.get("has_more", False)),
)
@@ -0,0 +1,34 @@
"""Tool-authoring primitives for omnigent.
Use the :func:`tool` decorator to mark a module-level Python function
as a tool the agent can call. The decorator derives the LLM-facing
JSON schema from the function's signature and Google-style docstring;
the caller just writes Python::
from omnigent_client import tool
@tool
def get_current_time() -> dict[str, str]:
\"\"\"Return the current UTC time as ISO-8601.\"\"\"
return {"now": datetime.now(timezone.utc).isoformat()}
Pass decorated functions as the ``tools=`` argument to
:meth:`OmnigentClient.query` or :meth:`Session.query`.
Server-side runtime (``omnigent.tools.local``) also consumes this
decorator to load ``@tool``-decorated functions bundled inside agent
images, so the same decorator powers both authoring and runtime.
"""
from ._decorator import TOOL_MARKER_ATTR, ToolMetadata, get_tool_metadata, tool
from ._handler import build_tool_handler
from ._state import ToolState
__all__ = [
"TOOL_MARKER_ATTR",
"ToolMetadata",
"ToolState",
"build_tool_handler",
"get_tool_metadata",
"tool",
]
@@ -0,0 +1,238 @@
"""
The ``@tool`` decorator and its metadata.
A ``@tool``-decorated module-level function is the authoring
contract for custom Python tools in omnigent. The decorator:
1. Validates that the target is a module-level ``def`` or
``async def`` (rejects class methods, lambdas, and nested
functions; see :func:`_validate_decorator_target`).
2. Derives the function-calling JSON schema from the signature
and Google-style docstring (see :mod:`._schema`).
3. Attaches metadata to the function via the
:data:`TOOL_MARKER_ATTR` attribute so the framework can
discover decorated functions by scanning a module's namespace.
The decorator is intentionally pure metadata: it returns the
original function unwrapped. The framework's executor handles
sync vs async execution (wrapping plain ``def`` bodies in
``asyncio.to_thread`` so they don't block the event loop).
**Decorator stacking**: ``@tool`` should be the outermost
decorator. Inner decorators (``@retry``, ``@cache``, etc.) must
use ``functools.wraps`` so the signature and docstring survive
to the schema-derivation pass; otherwise the schema will reflect
the wrapper, not the underlying function.
"""
from __future__ import annotations
import inspect
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, ParamSpec, TypeVar, overload
from ._schema import build_function_schema
# Marker attribute name. The framework's loader scans
# ``module.__dict__`` for objects carrying this attribute to
# enumerate the tools a Python file exports.
TOOL_MARKER_ATTR = "_omnigent_tool_metadata"
@dataclass(frozen=True)
class ToolMetadata:
"""
Metadata attached to a ``@tool``-decorated function.
Read by the framework loader and by the subprocess runner.
Read-only by convention — re-decoration or hand-editing is
not supported.
:param name: The tool name as the LLM sees it. Derived from
the function's ``__name__``, e.g. ``"word_count"``.
:param description: Human-readable description, derived from
the function's docstring's leading paragraph.
:param json_schema: The function-calling JSON schema for the
tool's parameters, in the OpenAI function-calling shape
(an ``object`` schema with ``properties`` / ``required``).
Already strict-mode-normalized if ``strict=True`` was
passed to the decorator.
:param strict: Whether the schema was normalized to strict
mode. Stored so the executor side can stay consistent with
the LLM's expectations.
:param return_annotation: The function's declared return type,
used by the executor to deserialize the return value via
``pydantic.TypeAdapter``. ``None`` if the function has no
return annotation (executor falls back to
``json.dumps(value, default=str)``).
"""
name: str
description: str
json_schema: dict[str, Any]
strict: bool
return_annotation: type[Any] | None
P = ParamSpec("P")
R = TypeVar("R")
@overload
def tool(fn: Callable[P, R]) -> Callable[P, R]: ...
@overload
def tool(
*,
strict: bool = ...,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
def tool(
fn: Callable[P, R] | None = None,
*,
strict: bool = True,
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
"""
Mark a module-level function as an omnigent tool.
The decorator infers the LLM-facing schema from the function's
type hints and Google-style docstring, then attaches the
derived metadata via the :data:`TOOL_MARKER_ATTR` attribute.
The framework's loader scans modules for this marker to
register tools at agent-image load time.
Every ``@tool``-decorated function is sync from the framework's
perspective. Authors who want a tool dispatched as background
work do not annotate the tool — the LLM picks per call site
via ``sys_call_async(tool=..., args=...)`` (see
``omnigent/tools/builtins/async_inbox.py``). The author-time
``@tool(synchronous=False)`` decoration was removed once
``sys_call_async`` shipped — keeping both surfaces would double
the dispatch paths without adding capability.
Usage::
@tool
async def word_count(text: str) -> dict[str, int]:
\"\"\"Count words, characters, and lines.\"\"\"
...
Restrictions:
- Target must be a module-level ``def`` or ``async def``.
Class methods, lambdas, and nested functions are rejected
with ``TypeError``.
- ``@tool`` should be the outermost decorator; inner
decorators must use ``functools.wraps`` for schema derivation
to see the underlying signature and docstring.
:param fn: When used as bare ``@tool`` (no parens), the
decorated function. ``None`` when used as
``@tool(strict=False)``.
:param strict: If ``True`` (default), the derived schema is
normalized to strict mode (``additionalProperties: false``
on objects, all properties required). Authors who hit a
schema strict mode breaks can opt out with
``@tool(strict=False)``.
:returns: The original function with the tool metadata
attached (when called with ``fn``), or a decorator
function (when called with keyword args).
"""
def wrap(target: Callable[P, R]) -> Callable[P, R]:
_validate_decorator_target(target)
schema_result = build_function_schema(target, strict=strict)
metadata = ToolMetadata(
name=target.__name__,
description=schema_result.description,
json_schema=schema_result.parameters_json_schema,
strict=strict,
return_annotation=schema_result.return_annotation,
)
# Attach metadata via setattr (the dynamic attribute name
# is intentional — it's the framework's discovery contract,
# see TOOL_MARKER_ATTR).
setattr(target, TOOL_MARKER_ATTR, metadata)
return target
if fn is None:
return wrap
return wrap(fn)
def _validate_decorator_target(target: Any) -> None:
"""
Reject decorator application to anything other than a
module-level ``def`` or ``async def``.
Class methods would include ``self`` / ``cls`` in the schema,
which the LLM has no way to fill. Lambdas have no name and
no docstring. Nested functions can close over enclosing scope
that doesn't survive subprocess invocation.
:param target: The object the decorator was applied to.
:raises TypeError: If ``target`` is not a module-level
function. The message names the offending construct so
agent authors get an actionable error.
"""
if not callable(target):
raise TypeError(f"@tool can only be applied to functions, got {type(target).__name__}.")
if isinstance(target, (staticmethod, classmethod)):
raise TypeError(
"@tool cannot be applied to staticmethod or classmethod. "
"Define the tool as a module-level function instead — "
"the framework has no way to bind 'self' or 'cls' from "
"an LLM-supplied argument set."
)
if not inspect.isfunction(target):
# Covers callables, methods of class instances, etc.
raise TypeError(
f"@tool requires a plain Python function, got "
f"{type(target).__name__}. Define the tool as a "
f"module-level def or async def."
)
if target.__name__ == "<lambda>":
raise TypeError(
"@tool cannot be applied to a lambda. Lambdas have no "
"name or docstring; the LLM has nothing to call. Define "
"the tool with `def` or `async def` instead."
)
# Nested-function detection: __qualname__ contains a dot path
# (e.g. "outer.<locals>.inner") for any function defined inside
# another function or class body.
qualname = target.__qualname__
if qualname != target.__name__:
# Allow class-level methods if someone re-binds them at module
# scope (rare); the staticmethod/classmethod check above is
# the primary defense. Otherwise reject as nested.
raise TypeError(
f"@tool cannot be applied to nested functions or methods "
f"({qualname!r}). Define the tool at module scope so the "
f"framework's subprocess runner can re-import it cleanly. "
f"State that needs to persist across invocations belongs "
f"in module-level globals, not closure variables."
)
def get_tool_metadata(obj: Any) -> ToolMetadata | None:
"""
Return the :class:`ToolMetadata` for an object if it is a
``@tool``-decorated function, else ``None``.
Used by the framework loader to filter a module's namespace
down to the decorated functions it should register.
:param obj: Any value pulled from a module's namespace.
:returns: The attached metadata, or ``None`` if the object
was not produced by ``@tool``.
"""
metadata = getattr(obj, TOOL_MARKER_ATTR, None)
if isinstance(metadata, ToolMetadata):
return metadata
return None
@@ -0,0 +1,189 @@
"""
Google-style docstring parsing for ``@tool``-decorated functions.
Extracts the function description (everything before the first
section header) and per-parameter descriptions (from the
``Args:`` / ``Arguments:`` / ``Parameters:`` section).
Used by the schema-derivation logic to populate the function-calling
JSON schema's ``description`` and ``properties[name].description``
fields.
We don't depend on a third-party docstring library because the
Google-style format is simple and our needs are narrow. NumPy and
Sphinx styles are intentionally not supported — authors should
use Google style or the explicit ``Annotated[T, Field(description=...)]``
form for per-param descriptions.
"""
from __future__ import annotations
import inspect
from dataclasses import dataclass
# Recognized section headers that terminate the description and
# the args section. Case-sensitive (Google convention).
_SECTION_HEADERS = (
"Args:",
"Arguments:",
"Parameters:",
"Returns:",
"Return:",
"Yields:",
"Yield:",
"Raises:",
"Raise:",
"Note:",
"Notes:",
"Example:",
"Examples:",
"See Also:",
"Warning:",
"Warnings:",
"Attributes:",
)
_ARGS_HEADERS = ("Args:", "Arguments:", "Parameters:")
@dataclass(frozen=True)
class ParsedDocstring:
"""
Result of parsing a Google-style docstring.
:param description: The function-level description, taken from the
text preceding the first section header. Whitespace-trimmed.
:param param_descriptions: Mapping from parameter name to its
description, extracted from the ``Args:`` / ``Arguments:`` /
``Parameters:`` section. Empty if no such section exists.
"""
description: str
param_descriptions: dict[str, str]
def parse_google_docstring(doc: str) -> ParsedDocstring:
"""
Parse a Google-style docstring into description and per-param docs.
Recognizes ``Args:`` / ``Arguments:`` / ``Parameters:`` as the
parameter-list section header. Within that section, lines like
`` name: description`` (or `` name (type): description``)
are parsed as parameter entries; subsequent more-indented lines
are treated as continuations of the current parameter's
description. The args section ends at the next recognized
section header.
:param doc: The raw docstring text (typically from
``fn.__doc__``). May be ``None``-equivalent (empty string).
:returns: A :class:`ParsedDocstring` with the extracted description
and parameter descriptions. Returns empty values rather than
raising for malformed input.
"""
if not doc:
return ParsedDocstring(description="", param_descriptions={})
cleaned = inspect.cleandoc(doc)
if not cleaned:
return ParsedDocstring(description="", param_descriptions={})
lines = cleaned.split("\n")
# Locate the first recognized section header. Everything before
# it is the description; the args section (if any) is what
# contains parameter docs.
description_lines: list[str] = []
args_section_lines: list[str] = []
in_args_section = False
in_other_section = False
for line in lines:
stripped = line.strip()
if stripped in _SECTION_HEADERS:
in_args_section = stripped in _ARGS_HEADERS
in_other_section = not in_args_section
continue
if in_args_section:
args_section_lines.append(line)
elif in_other_section:
# Skip non-args sections (Returns:, Raises:, etc.)
continue
else:
description_lines.append(line)
description = "\n".join(description_lines).strip()
param_descriptions = _parse_args_lines(args_section_lines)
return ParsedDocstring(
description=description,
param_descriptions=param_descriptions,
)
def _parse_args_lines(lines: list[str]) -> dict[str, str]:
"""
Parse the body of an ``Args:`` section into per-param descriptions.
Param lines have the form `` name: description`` or
`` name (type): description`` at the section's base indent.
Lines indented further are treated as continuations of the
current parameter's description.
:param lines: The lines following the ``Args:`` header (not
including the header itself), up to the next section.
:returns: Mapping from parameter name to its (whitespace-collapsed)
description.
"""
# Determine the base indent — the indent of the first non-empty line.
base_indent: int | None = None
for line in lines:
if line.strip():
base_indent = len(line) - len(line.lstrip())
break
if base_indent is None:
return {}
param_descriptions: dict[str, str] = {}
current_name: str | None = None
current_parts: list[str] = []
for line in lines:
if not line.strip():
# Blank lines within args section are continuation separators;
# they don't terminate a param.
continue
line_indent = len(line) - len(line.lstrip())
if line_indent == base_indent:
# Start of a new param entry.
if current_name is not None:
param_descriptions[current_name] = " ".join(current_parts).strip()
current_name = None
current_parts = []
stripped = line.strip()
if ":" not in stripped:
# Malformed entry; skip it.
continue
name_part, _, desc = stripped.partition(":")
# Handle "name (type)" form by trimming the parenthetical.
if "(" in name_part:
name_only = name_part.split("(", 1)[0].strip()
else:
name_only = name_part.strip()
if name_only.isidentifier():
current_name = name_only
current_parts = [desc.strip()]
# Else: not a valid param entry; ignore.
else:
# Continuation line for the current param.
if current_name is not None:
current_parts.append(line.strip())
if current_name is not None:
param_descriptions[current_name] = " ".join(current_parts).strip()
return param_descriptions
@@ -0,0 +1,114 @@
"""Adapter that turns ``@tool``-decorated functions into a ToolHandler.
The stream-layer ``ToolHandler`` takes a list of OpenAI-shape JSON
schemas and a single ``execute`` callable. Users who have written
tools with the ``@tool`` decorator (Python functions with type hints
and Google-style docstrings) shouldn't have to hand-roll that shape:
:func:`build_tool_handler` reads each function's tool metadata and
builds the handler for them.
Dispatch is by tool name. Calling an unknown tool raises — the SDK
surfaces the error back to the agent as a tool error.
"""
from __future__ import annotations
import asyncio
import inspect
import json
from collections.abc import Callable
from typing import Any
from .._tool_handler import ToolCallInfo, ToolHandler
from ._decorator import TOOL_MARKER_ATTR, ToolMetadata
def build_tool_handler(functions: list[Callable[..., Any]]) -> ToolHandler:
"""Build a :class:`ToolHandler` from ``@tool``-decorated functions.
Each function must carry tool metadata attached by the
:func:`~omnigent_client.tool` decorator (checked via
:data:`TOOL_MARKER_ATTR`). The returned handler exposes the
OpenAI-shape schemas the SDK sends to the server, and an
``execute`` callable that dispatches incoming tool calls by
name.
:param functions: List of ``@tool``-decorated Python functions,
e.g. ``[get_current_time, search_docs]``. Each must be a
module-level ``def`` or ``async def`` decorated with
``@tool``.
:returns: A :class:`ToolHandler` ready to pass as
``session.tool_handler`` or via the ``tools=`` keyword on
``OmnigentClient.query`` / ``Session.query``.
:raises TypeError: If any function is missing the ``@tool``
marker (i.e. wasn't decorated).
:raises ValueError: If two functions share the same tool name
— tool names must be unique per handler.
"""
if not functions:
raise ValueError("build_tool_handler() requires at least one function")
schemas: list[dict[str, object]] = []
funcs_by_name: dict[str, Callable[..., Any]] = {}
for fn in functions:
meta: ToolMetadata | None = getattr(fn, TOOL_MARKER_ATTR, None)
if meta is None:
raise TypeError(
f"{fn.__module__}.{fn.__qualname__} is not decorated with "
f"@tool. Decorate it with `from omnigent_client import tool` "
f"and apply @tool above the function definition."
)
if meta.name in funcs_by_name:
raise ValueError(
f"Duplicate tool name {meta.name!r}: "
f"{funcs_by_name[meta.name].__qualname__} and "
f"{fn.__qualname__} both export the same name."
)
funcs_by_name[meta.name] = fn
schema: dict[str, object] = {
"type": "function",
"function": {
"name": meta.name,
"description": meta.description,
"parameters": meta.json_schema,
},
}
schemas.append(schema)
async def execute(call: ToolCallInfo) -> str:
"""Dispatch ``call`` to the matching ``@tool`` function.
Async functions (``async def``) are awaited on the
event loop. Sync functions (``def``) are dispatched to
a worker thread via ``asyncio.to_thread`` so blocking
calls inside — ``time.sleep``, file I/O, subprocess,
``requests`` — don't stall the event loop. Without the
thread bounce, several concurrent ``@tool`` invocations
(e.g. a parallel fan-out of async client tools) would
serialize: each body would block every sibling AND any
caller render loop sharing the loop.
The return value is JSON-serialized unless the function
already returned a string (which is passed through).
"""
fn = funcs_by_name.get(call.name)
if fn is None:
# The SDK will surface this back to the agent as a tool
# error — this typically means the LLM invented a tool
# name that wasn't in the schemas we sent.
raise KeyError(f"Unknown tool {call.name!r}. Registered: {sorted(funcs_by_name)}")
if inspect.iscoroutinefunction(fn):
result = await fn(**call.arguments)
else:
# Sync body — route to a worker thread so it
# doesn't block the event loop (see the fan-out
# serialization case above).
result = await asyncio.to_thread(lambda: fn(**call.arguments))
if isinstance(result, str):
return result
# Pydantic models and dataclasses commonly aren't JSON-ready
# out of the box — ``default=str`` handles datetime/UUID/etc.
return json.dumps(result, default=str)
return ToolHandler(schemas=schemas, execute=execute)
@@ -0,0 +1,267 @@
"""
Schema derivation for ``@tool``-decorated functions.
Given a typed Python function, produce the function-calling JSON
schema the LLM sees. The pipeline:
1. Inspect the signature for parameters, annotations, and defaults.
2. Parse the Google-style docstring for description and per-param
descriptions (see :mod:`omnigent.tools._docstring`).
3. Build a Pydantic model from the parameters via ``create_model``;
Pydantic does the heavy lifting for type → schema (primitives,
Pydantic models, ``Optional``, ``Literal``, ``Annotated[..., Field]``,
etc.).
4. Apply strict-mode normalization (see
:mod:`omnigent.tools._strict`) when ``strict=True``.
Permissive types (``Any``, ``object``, missing annotations) are
allowed but produce an INFO-level warning so authors can find them.
"""
from __future__ import annotations
import inspect
import logging
import typing
from collections.abc import Callable
from dataclasses import dataclass
from typing import Annotated, Any, get_args, get_origin
from pydantic import Field, create_model
from pydantic.fields import FieldInfo
from ._docstring import parse_google_docstring
from ._state import ToolState
from ._strict import ensure_strict_schema
_logger = logging.getLogger(__name__)
# Reserved parameter name for framework-injected per-conversation
# per-agent tool state. A ``@tool`` function that declares a
# parameter with this name receives a live :class:`ToolState` at
# call time; the parameter is stripped from the LLM-facing JSON
# schema. Convention over configuration — every stateful tool uses
# the same identifier.
STATE_PARAM_NAME = "tool_state"
@dataclass(frozen=True)
class FunctionSchemaResult:
"""
Output of :func:`build_function_schema`.
:param description: Function-level description, derived from
the docstring's leading paragraph(s).
:param parameters_json_schema: JSON schema for the function's
parameters, in the OpenAI function-calling shape (an
``object`` schema with ``properties`` and ``required``).
Already normalized to strict mode if requested.
:param return_annotation: The function's return type annotation,
or ``None`` if no return annotation was provided. Used by
the executor to deserialize the tool's return value via
``pydantic.TypeAdapter``.
"""
description: str
parameters_json_schema: dict[str, Any]
return_annotation: type[Any] | None
def build_function_schema(
fn: Callable[..., Any],
*,
strict: bool = True,
) -> FunctionSchemaResult:
"""
Build the function-calling schema for a Python function.
:param fn: The Python function to derive a schema for. Must be
a module-level ``def`` or ``async def`` (the ``@tool``
decorator enforces this elsewhere; we do not re-validate
here).
:param strict: If ``True``, apply strict-mode normalization to
the resulting schema (see :mod:`._strict`).
:returns: A :class:`FunctionSchemaResult` with description,
JSON schema, and return-type annotation.
"""
sig = inspect.signature(fn)
type_hints = typing.get_type_hints(fn, include_extras=True)
parsed_doc = parse_google_docstring(fn.__doc__ or "")
fields: dict[str, tuple[Any, FieldInfo]] = {}
for name, param in sig.parameters.items():
ann = type_hints.get(name, Any)
# Framework-injected parameter — reserved by convention:
# any parameter named exactly ``tool_state`` is filled by
# the runtime with a :class:`ToolState`. Skipped from the
# LLM-facing schema; the LLM has no way to supply it.
# Enforce the convention: if someone types a param as
# ToolState but names it something else, fail loud so they
# know the right contract.
if name == STATE_PARAM_NAME:
if ann is not ToolState and ann is not Any:
raise TypeError(
f"@tool function {fn.__name__!r} declares parameter "
f"'{STATE_PARAM_NAME}' with unexpected type "
f"{ann!r}. It must be typed as ToolState "
f"(or left unannotated); any other type is a bug."
)
continue
if ann is ToolState:
raise TypeError(
f"@tool function {fn.__name__!r} types parameter "
f"{name!r} as ToolState but the parameter must be named "
f"{STATE_PARAM_NAME!r}. Rename it and the framework "
f"will inject a live ToolState at call time."
)
_warn_if_permissive(fn.__name__, name, ann)
doc_desc = parsed_doc.param_descriptions.get(name)
default = param.default if param.default is not inspect.Parameter.empty else ...
field_info = _build_field_info(ann, default, doc_desc)
fields[name] = (ann, field_info)
if fields:
# Pydantic uses the model name when generating $defs refs;
# capitalize so it looks reasonable in the schema output.
model_name = f"{_pascal_case(fn.__name__)}Args"
# mypy can't statically narrow create_model's overload for our
# dynamic field dict, but pydantic accepts (Type, FieldInfo)
# tuples here — they're the documented field-definition shape.
Model = create_model(model_name, **fields) # type: ignore[call-overload]
params_schema: dict[str, Any] = Model.model_json_schema()
else:
# Zero-arg tool: the schema is an empty object.
params_schema = {
"type": "object",
"properties": {},
"required": [],
}
if strict:
params_schema = ensure_strict_schema(params_schema)
return_annotation = type_hints.get("return")
return FunctionSchemaResult(
description=parsed_doc.description,
parameters_json_schema=params_schema,
return_annotation=return_annotation,
)
def _build_field_info(
annotation: Any,
default: Any,
doc_description: str | None,
) -> FieldInfo:
"""
Construct a Pydantic ``FieldInfo`` for one parameter.
Handles three description sources, with this priority:
1. An explicit ``Field(description=...)`` in
``Annotated[T, Field(description=...)]``.
2. A bare string in ``Annotated[T, "desc"]`` (a common shorthand
supported by some agent SDKs).
3. The docstring entry for this parameter (Google-style ``Args:``).
:param annotation: The parameter's type annotation, possibly
wrapped in ``Annotated[...]``.
:param default: The parameter's default value, or ``...`` if
the parameter is required.
:param doc_description: Description from the docstring's
``Args:`` section, or ``None`` if absent.
:returns: A ``FieldInfo`` ready to pass to
``pydantic.create_model``. The default (if any) and
description are baked in at construction time so
``model_json_schema`` picks them up correctly.
"""
# Pull metadata out of Annotated[T, ...] for description discovery.
annotated_str_desc: str | None = None
annotated_field: FieldInfo | None = None
if get_origin(annotation) is Annotated:
for extra in get_args(annotation)[1:]:
if isinstance(extra, FieldInfo) and annotated_field is None:
annotated_field = extra
elif isinstance(extra, str) and annotated_str_desc is None:
annotated_str_desc = extra
# Determine the effective description with the priority above.
description: str | None = None
if annotated_field is not None and annotated_field.description is not None:
description = annotated_field.description
elif annotated_str_desc is not None:
description = annotated_str_desc
elif doc_description:
description = doc_description
# Field(default=PydanticUndefined) is the marker for "required";
# we map our `...` sentinel to it via PydanticUndefined import.
# Easier: build the constructor kwargs and let Pydantic translate
# default=... directly (it accepts ``...`` as "required" too).
field_kwargs: dict[str, Any] = {}
if description is not None:
field_kwargs["description"] = description
if default is not ...:
field_kwargs["default"] = default
if annotated_field is not None:
# Merge: preserve other metadata (gt/lt/min_length/etc.) from
# the author's Field, but override description and default.
# merge_field_infos's stub return is too loose for mypy; cast.
merged: FieldInfo = FieldInfo.merge_field_infos(annotated_field, FieldInfo(**field_kwargs))
return merged
# pydantic.Field stub returns Any (it's polymorphic by default).
# We're constructing a fresh FieldInfo; cast to the documented type.
field_obj: FieldInfo = Field(**field_kwargs)
return field_obj
def _warn_if_permissive(fn_name: str, param_name: str, annotation: Any) -> None:
"""
Log a warning if a parameter's type provides no validation constraint.
``Any``, ``object``, and missing annotations all produce a
permissive schema (no ``type`` field) that the LLM can fill
with arbitrary structure. Useful but easy to write by accident.
:param fn_name: The decorated function's ``__name__``, for the
log message, e.g. ``"process_payload"``.
:param param_name: The offending parameter's name, e.g.
``"data"``.
:param annotation: The annotation as resolved by
``typing.get_type_hints``.
"""
# Strip Annotated[...] so we inspect the underlying type.
underlying = annotation
if get_origin(underlying) is Annotated:
underlying = get_args(underlying)[0]
if underlying is Any or underlying is object:
type_name = (
"Any" if underlying is Any else getattr(underlying, "__name__", str(underlying))
)
_logger.info(
"Tool '%s' parameter '%s' has no concrete type annotation "
"(resolved to %s); LLM will get a permissive schema with "
"no validation.",
fn_name,
param_name,
type_name,
)
def _pascal_case(snake: str) -> str:
"""
Convert a snake_case identifier to PascalCase.
Used to give the dynamically-created Pydantic model a readable
name in schema ``$defs`` references.
:param snake: A snake_case identifier, e.g. ``"word_count"``.
:returns: PascalCase form, e.g. ``"WordCount"``.
"""
return "".join(part.capitalize() for part in snake.split("_") if part)
@@ -0,0 +1,300 @@
"""Per-agent ToolState for stateful ``@tool`` functions.
See ``designs/TOOL_STATE.md`` for the full design. The primitive is
a simple key-value store, JSON-serialized, scoped to one
(conversation, agent) pair via the storage directory provided by
the framework. The ``@tool`` decorator hides ``ToolState``-typed
parameters from the LLM-facing schema; the subprocess runner
reconstructs a ``ToolState`` from the directory path and injects it
when the tool function is called.
Tool authors see::
from omnigent_client import tool, ToolState
@tool
def add_task(desc: str, state: ToolState) -> str:
with state.transaction("queue") as q:
q = q or []
q.append({"desc": desc})
return f"#{len(q) - 1}"
and nothing else.
"""
from __future__ import annotations
import json
import os
import threading
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from pathlib import Path
from typing import Any
try:
import fcntl as _fcntl
except ImportError: # pragma: no cover - exercised on native Windows
_fcntl = None # type: ignore[assignment]
if os.name == "nt":
import msvcrt as _msvcrt
else: # pragma: no cover - exercised on POSIX
_msvcrt = None # type: ignore[assignment]
# Subdirectory segments reserved by the framework — JSON key files
# live at ``{root}/{key}.json``. Keys must not contain path
# separators; we sanitize eagerly rather than allowing the bug to
# surface as directory traversal.
_KEY_SUFFIX = ".json"
_THREAD_LOCKS_GUARD = threading.Lock()
_THREAD_LOCKS: dict[Path, threading.Lock] = {}
class ToolState:
"""Per-agent, per-conversation key-value state for ``@tool`` functions.
Values are JSON-serialized. The keyspace is shared across every
tool invoked for the same registered agent within the same
conversation. Use :meth:`transaction` for atomic read-modify-write;
plain :meth:`get` and :meth:`set` do not serialize concurrent
writers on the same key.
Instances are constructed by the framework. Tool authors receive
a ``ToolState`` by declaring a parameter of this type on their
``@tool``-decorated function; the decorator strips the parameter
from the LLM-facing schema and the subprocess runner injects the
live ``ToolState`` at call time.
:param root: The directory this namespace lives in, e.g.
``{workspace}/.tool_state/{agent_id}``. The directory does
not need to exist yet; it is created lazily on first write.
"""
def __init__(self, root: Path) -> None:
self._root = root
# ── Primary API ──────────────────────────────────────────
def get(self, key: str, *, default: Any = None) -> Any:
"""Return the stored value at ``key``, or ``default`` if absent.
:param key: The state key, e.g. ``"queue"``.
:param default: Value to return when the key has never been
written. ``None`` by default.
:returns: The deserialized JSON value, or ``default``.
"""
path = self._path_for(key)
if not path.exists():
return default
with path.open("r") as f:
# Shared lock: allow parallel reads, block concurrent writers
# briefly so we see a complete JSON payload.
_lock_file(f, exclusive=False)
try:
return json.loads(f.read() or "null")
finally:
_unlock_file(f)
def set(self, key: str, value: Any) -> None:
"""Replace (or create) the value at ``key``. JSON-serialized.
Non-atomic relative to concurrent writers on the same key —
use :meth:`transaction` for read-modify-write sequences.
:param key: The state key.
:param value: Any JSON-serializable value.
"""
path = self._path_for(key)
path.parent.mkdir(parents=True, exist_ok=True)
# Write through a temp file + rename so a reader never sees
# a half-written JSON payload even without a lock.
tmp = path.with_suffix(_KEY_SUFFIX + ".tmp")
with tmp.open("w") as f:
json.dump(value, f)
tmp.replace(path)
def delete(self, key: str) -> None:
"""Remove ``key``. No-op if absent.
:param key: The state key to remove.
"""
path = self._path_for(key)
# Idempotent delete — tools commonly don't know whether the
# key was ever set.
with suppress(FileNotFoundError):
path.unlink()
def keys(self) -> list[str]:
"""List all keys currently stored in this namespace.
:returns: Sorted list of keys, e.g. ``["counter", "queue"]``.
Empty list if nothing has been written yet.
"""
if not self._root.exists():
return []
return sorted(p.stem for p in self._root.iterdir() if p.suffix == _KEY_SUFFIX)
def __contains__(self, key: object) -> bool:
"""Return whether ``key`` currently exists in this namespace.
:param key: Candidate key, e.g. ``"queue"``.
:returns: ``True`` when ``key`` is a valid stored key, else ``False``.
"""
if not isinstance(key, str):
return False
return self._path_for(key).exists()
@contextmanager
def transaction(self, key: str, *, default: Any = None) -> Iterator[Any]:
"""Atomic read-modify-write for one key.
Typical usage — supply a ``default`` so first-time callers
get a usable container without a ``None`` check::
with state.transaction("queue", default=[]) as queue:
queue.append(item)
# queue is written back on normal exit.
The yielded value is the current contents, or a fresh
``default`` if the key was never set. Mutating the yielded
object in place is the expected pattern — the same object
is serialized back on exit. Rebinding the local name inside
the ``with`` block does NOT propagate (Python closures), so
for "replace the value" semantics use :meth:`set` explicitly.
On a normal exit the yielded object is JSON-serialized and
written back. On exception no write happens — the prior
value is preserved.
:param key: The state key to lock + read + write.
:param default: Value yielded when the key has never been
written. Defaults to ``None``. Pass ``[]`` or ``{}``
(or any JSON-serializable value) to skip the absent-key
branch in caller code.
:yields: The current value at ``key``, or ``default`` if
the key has no stored value yet. Mutate in place.
"""
path = self._path_for(key)
path.parent.mkdir(parents=True, exist_ok=True)
thread_lock = _get_thread_lock(path)
# ``a+`` creates the file if missing and positions at end;
# we seek to 0 before read. Opening with ``r+`` would fail
# when the file doesn't exist yet, which is a common first-
# call case for a tool.
with thread_lock, path.open("a+") as f:
_lock_file(f, exclusive=True)
try:
value = _read_transaction_value(f, default)
yield value
_write_transaction_value(f, value)
finally:
_unlock_file(f)
# ── Internals ────────────────────────────────────────────
def _path_for(self, key: str) -> Path:
"""Resolve ``key`` to the on-disk path, rejecting traversal.
:param key: Caller-supplied key.
:returns: The ``{root}/{key}.json`` path.
:raises ValueError: If ``key`` is empty, contains a path
separator, or starts with a dot (no hidden or
escaped paths).
"""
if not key:
raise ValueError("ToolState key must be a non-empty string")
if "/" in key or "\\" in key or key.startswith("."):
# Rejects traversal and hidden-file sigils. Authors who
# really need slashes can encode them (e.g. "a__b") —
# we'd rather break loudly than accept quiet bugs.
raise ValueError(
f"ToolState key {key!r} contains an illegal character. "
f"Keys must be plain names (no '/', '\\', or leading '.')."
)
return self._root / f"{key}{_KEY_SUFFIX}"
def _read_transaction_value(f: Any, default: Any) -> Any:
"""Seek to 0 and decode the JSON value under the open file handle.
Returns ``default`` when the file is empty (first-time use of
the key). Factored out of :meth:`ToolState.transaction` so the
context manager stays short.
:param f: Open file handle positioned anywhere; will be seek(0)ed.
:param default: Value to return on empty/whitespace content.
:returns: Decoded JSON value or ``default``.
"""
f.seek(0)
raw = f.read()
if raw.strip():
return json.loads(raw)
return default
def _write_transaction_value(f: Any, value: Any) -> None:
"""Truncate and re-serialize ``value`` as JSON under the file handle.
Caller must hold the exclusive flock before calling. Factored
out of :meth:`ToolState.transaction` so the context manager
stays short.
:param f: Open file handle (must support ``r+``-style truncate).
:param value: Any JSON-serializable value to persist.
"""
f.seek(0)
f.truncate()
json.dump(value, f)
# Flush before releasing the flock held by the caller. Python file objects
# buffer writes; if we unlock before flushing, another process can acquire
# the lock and read stale on-disk contents, losing the prior update.
f.flush()
os.fsync(f.fileno())
def _lock_file(f: Any, *, exclusive: bool) -> None:
"""Acquire an advisory file lock for ``f``.
POSIX uses ``fcntl.flock``. Native Windows has no ``fcntl`` module, so it
locks one byte with ``msvcrt.locking``; that API is exclusive-only, which
is conservative for reads but preserves cross-process serialization.
"""
if _fcntl is not None:
_fcntl.flock(f, _fcntl.LOCK_EX if exclusive else _fcntl.LOCK_SH)
return
if _msvcrt is None: # pragma: no cover - defensive for unusual platforms
return
f.seek(0)
_msvcrt.locking(f.fileno(), _msvcrt.LK_LOCK, 1)
def _unlock_file(f: Any) -> None:
"""Release a lock acquired by :func:`_lock_file`."""
if _fcntl is not None:
_fcntl.flock(f, _fcntl.LOCK_UN)
return
if _msvcrt is None: # pragma: no cover - defensive for unusual platforms
return
f.seek(0)
_msvcrt.locking(f.fileno(), _msvcrt.LK_UNLCK, 1)
def _get_thread_lock(path: Path) -> threading.Lock:
"""Return the per-key in-process mutex for ``path``.
``flock`` serializes across processes, but threads in the same
process can still interleave on separate file descriptors. This
helper layers a per-path ``threading.Lock`` on top so
``transaction()`` is atomic under both thread and process
contention.
:param path: The key file path, e.g. ``Path("/tmp/state/queue.json")``.
:returns: The shared mutex guarding that path within this process.
"""
with _THREAD_LOCKS_GUARD:
lock = _THREAD_LOCKS.get(path)
if lock is None:
lock = threading.Lock()
_THREAD_LOCKS[path] = lock
return lock
@@ -0,0 +1,64 @@
"""
Strict JSON-schema normalization for ``@tool``-derived schemas.
Strict-mode schemas have two extra constraints beyond ordinary
JSON Schema:
- Every object schema sets ``additionalProperties: false``.
- Every property in an object is listed in ``required`` (Python
defaults are still applied on the executor side after the LLM
emits a value).
This is the form most major LLM providers accept for
function-calling tool schemas without further coercion. Authors
who hit a real schema that strict mode breaks can opt out via
``@tool(strict=False)``.
"""
from __future__ import annotations
from typing import Any
def ensure_strict_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""
Recursively normalize a JSON schema to strict-mode rules.
Returns a new dict (does not mutate the input). Recurses into
``properties``, ``items``, ``$defs``, and union variants
(``anyOf`` / ``oneOf`` / ``allOf``).
:param schema: A JSON schema dict (typically produced by
:func:`pydantic.BaseModel.model_json_schema`).
:returns: A new dict with strict-mode constraints applied.
"""
if not isinstance(schema, dict):
return schema
out: dict[str, Any] = dict(schema)
# Recurse into nested $defs FIRST so the recursion sees the
# normalized definitions when it walks references.
if "$defs" in out:
out["$defs"] = {k: ensure_strict_schema(v) for k, v in out["$defs"].items()}
# Recurse into union variants.
for union_key in ("anyOf", "oneOf", "allOf"):
if union_key in out and isinstance(out[union_key], list):
out[union_key] = [ensure_strict_schema(variant) for variant in out[union_key]]
# Recurse into array items.
if "items" in out:
out["items"] = ensure_strict_schema(out["items"])
if out.get("type") == "object":
properties = out.get("properties", {}) or {}
out["additionalProperties"] = False
out["properties"] = {k: ensure_strict_schema(v) for k, v in properties.items()}
# Strict mode requires every property to be listed as required.
# The default value (if any) still applies on the executor side
# via Pydantic — strict mode only governs what the LLM emits.
if properties:
out["required"] = list(properties.keys())
return out
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "omnigent-client"
version = "0.6.0.dev0"
description = "Python client SDK for the omnigent server API"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
# Sibling package — provides the SessionStreamEventType /
# StreamEvent source of truth that the SDK validates SSE
# envelopes against (see _sessions.py, _sse.py). Resolved via
# the relative path below so the SDK can be installed
# editable alongside the root ``omnigent`` package. Version-locked
# (==) so a published SDK always pairs with the server release it
# shipped with (release-omnigent.yml verifies the pin).
"omnigent==0.6.0.dev0",
"httpx>=0.27",
"pydantic>=2.0,<3",
]
[tool.uv.sources]
omnigent = { path = "../..", editable = true }