项目文件夹

文件
2026-07-13 13:12:00 +08:00

21522 行
934 KiB
Python

"""Routes for the Sessions API (``/v1/sessions``).
These endpoints expose a thin, harness-agnostic surface over an
agent's conversation: create a session bound to an agent, post events
(messages, tool outputs, interrupts), read a snapshot, and live-tail
the SSE stream. The session is implemented on top of the existing
conversation-item + task + live-stream machinery — this module is a
boundary translation layer, not a new runtime.
Input dispatch (POST /events) persists the item to
``conversation_items`` and forwards to the bound runner over the WS
tunnel. The persist-before-forward order is invariant I1 in
``designs/SESSION_REARCHITECTURE.md`` — a snapshot read immediately
after POST observes the input in ``items``.
The reconnect contract is **snapshot + live tail**, not replay: a
client opens the live stream and ``GET``s the snapshot, then
deduplicates by item id any events that fire between the two reads.
See ``server/API.md`` for the full contract.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import mimetypes
import re
import secrets
import time
import urllib.parse
import weakref
from collections import deque
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from typing import Annotated, Any, Literal, cast
import cachetools
import httpx
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
File,
HTTPException,
Query,
Request,
UploadFile,
WebSocket,
WebSocketDisconnect,
WebSocketException,
status,
)
from fastapi.responses import Response, StreamingResponse
from pydantic import TypeAdapter, ValidationError
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from starlette.datastructures import UploadFile as StarletteUploadFile
from omnigent.codex_native_elicitation import codex_elicitation_id
from omnigent.cost_plan import (
COST_CONTROL_LABEL_NAMESPACE,
reserved_cost_control_keys,
)
from omnigent.db.db_models import LABEL_VALUE_MAX_LEN
from omnigent.db.utils import generate_agent_id, generate_task_id
from omnigent.entities import (
Agent,
CommentsFingerprint,
Conversation,
ConversationItem,
ErrorData,
MessageData,
NewConversationItem,
SlashCommandData,
StoredFile,
synthesize_conversation_title,
)
from omnigent.entities.conversation import (
ITEM_TYPE_TO_DATA_CLS,
FunctionCallData,
FunctionCallOutputData,
parse_item_data,
)
from omnigent.entities.permission import SessionPermission
from omnigent.entities.session_resources import session_resource_view_to_dict
from omnigent.errors import ElicitationDeclinedError, ErrorCode, OmnigentError
from omnigent.harness_plugins import (
CLAUDE_NATIVE_CODING_AGENT,
CODEX_NATIVE_CODING_AGENT,
CURSOR_NATIVE_CODING_AGENT,
KIRO_NATIVE_CODING_AGENT,
NativeCodingAgent,
)
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE as _HARNESS_NOT_CONFIGURED_ERROR_CODE,
)
from omnigent.model_override import validate_model_override
from omnigent.native_coding_agents import (
native_coding_agent_for_agent_name,
native_coding_agent_for_harness,
native_coding_agent_for_terminal_name,
native_coding_agent_for_wrapper_label,
)
from omnigent.policies.types import (
ElicitationRequest,
EvaluationContext,
PolicyAction,
PolicyResult,
)
from omnigent.reasoning_effort import (
EFFORT_CLEAR_VALUES,
EFFORT_VALUES,
validate_effort,
)
from omnigent.runner.identity import (
RUNNER_TUNNEL_TOKEN_HEADER,
token_bound_runner_id,
)
from omnigent.runner.routing import RunnerRouter
from omnigent.runner.transports.ws_tunnel.registry import TunnelRegistry
from omnigent.runtime import (
get_agent_cache,
get_caps,
get_policy_store,
inflight_text,
pending_elicitations,
pending_inputs,
session_stream,
user_session_stream,
)
from omnigent.runtime.agent_cache import AgentCache
from omnigent.runtime.policies.approval import (
_ELICITATION_MODE,
build_elicitation_request_event,
resolve_ask_timeout,
)
from omnigent.runtime.policies.builder import build_policy_engine, load_session_usage
from omnigent.runtime.policies.engine import PolicyEngine
from omnigent.runtime.tool_output import cap_tool_output
from omnigent.server import presence
from omnigent.server._elicitation_registry import (
_harness_elicitation_owners,
_harness_elicitation_registry,
_harness_parked_elicitations,
_harness_pre_resolved_elicitations,
_ParkedHarnessElicitation,
_PreResolvedHarnessElicitation,
)
from omnigent.server.auth import (
LEVEL_EDIT,
LEVEL_MANAGE,
LEVEL_OWNER,
LEVEL_READ,
RESERVED_USER_PUBLIC,
AuthProvider,
SharingMode,
local_single_user_enabled,
workspace_sharing_blocked,
)
from omnigent.server.bundles import bundle_location, validate_agent_bundle
from omnigent.server.host_registry import HostConnection, HostRegistry, RunnerExitReports
from omnigent.server.managed_hosts import (
ManagedHostLaunch,
ManagedLaunch,
ManagedLaunchTracker,
ManagedSandboxConfig,
RepoWorkspace,
host_resume_supported,
)
from omnigent.server.mcp_pool import ServerMcpPool
from omnigent.server.permissions import check_session_access
from omnigent.server.routes._auth_helpers import (
attribution_user as _attribution_user,
)
from omnigent.server.routes._auth_helpers import (
get_permission_level as _get_permission_level,
)
from omnigent.server.routes._auth_helpers import (
get_session_owner_id as _get_session_owner_id,
)
from omnigent.server.routes._auth_helpers import (
get_user_id as _get_user_id,
)
from omnigent.server.routes._auth_helpers import (
require_access as _require_access,
)
from omnigent.server.routes._auth_helpers import (
require_access_and_level as _require_access_and_level,
)
from omnigent.server.routes._auth_helpers import (
require_user as _require_user,
)
from omnigent.server.routes._codex_elicitation import parse_codex_elicitation_request
from omnigent.server.routes._content_type import (
require_json_content_type,
require_json_or_multipart_content_type,
)
from omnigent.server.routes._host_worktree import CreatedWorktree
from omnigent.server.routes._origin import require_trusted_origin
from omnigent.server.schemas import (
AgentObject,
BrowserActionRequestEvent,
ChildSessionList,
ChildSessionSummary,
CompletedEvent,
ConversationDeleted,
CopiedFile,
CopyFilesRequest,
CopyFilesResponse,
CreatedSessionResponse,
ElicitationRequestEvent,
ElicitationRequestParams,
ElicitationResult,
ErrorDetail,
ErrorEvent,
GrantPermissionRequest,
McpServerStartup,
MCPServerSummary,
ModelUsage,
OutputItemDoneEvent,
OutputTextDeltaEvent,
PaginatedList,
PermissionObject,
PolicyDeniedEvent,
PolicySummary,
ReadStatePutRequest,
ReasoningStartedEvent,
ReasoningTextDeltaEvent,
ResponseObject,
SandboxStatus,
ServerStreamEvent,
SessionAgentChangedEvent,
SessionCollaborationModeEvent,
SessionCreatedEvent,
SessionCreateMetadata,
SessionCreateRequest,
SessionEventInput,
SessionForkRequest,
SessionGitOptions,
SessionInputConsumedEvent,
SessionInputConsumedPayload,
SessionInterruptedEvent,
SessionInterruptedPayload,
SessionLabelsResponse,
SessionList,
SessionListItem,
SessionMcpStartupEvent,
SessionModelEvent,
SessionModelOptionsEvent,
SessionReasoningEffortEvent,
SessionResourceListPage,
SessionResourceObject,
SessionResourcePaginatedList,
SessionResponse,
SessionSandboxStatusEvent,
SessionSkillsEvent,
SessionStatusEvent,
SessionSupersededEvent,
SessionSwitchAgentRequest,
SessionTerminalPendingEvent,
SessionTodosEvent,
SessionUsageEvent,
SkillSummary,
UpdateSessionRequest,
)
from omnigent.session_lifecycle import (
is_session_closed,
labels_with_closed_status,
title_without_closed_marker,
)
from omnigent.spec.types import (
AgentSpec,
FunctionPolicySpec,
Phase,
PolicySpec,
StateUpdate,
)
from omnigent.stores import AgentStore, ConversationStore
from omnigent.stores.artifact_store import ArtifactStore
from omnigent.stores.comment_store import CommentStore
from omnigent.stores.conversation_store import (
PROJECT_LABEL_KEY,
ConversationNotFoundError,
NameAlreadyExistsError,
)
from omnigent.stores.file_store import FileStore
from omnigent.stores.host_store import Host, HostStore
from omnigent.stores.permission_store import PermissionStore
from omnigent.tools.client_specified import parse_client_side_tool_specs
_logger = logging.getLogger(__name__)
# ── Module-level constants (rule 34) ──────────────────────────────
# Wire literal for the interrupt input type. Lives here so the
# dispatcher in ``post_event`` matches a single named constant rather
# than an inline string buried in conditional logic.
_INTERRUPT_TYPE: str = "interrupt"
# Wire literal for the approval input type — resolves an outstanding
# elicitation in-band on the session-keyed surface, so a client only
# has to know one URL (``/v1/sessions/{id}/events``) for every
# downward signal.
_APPROVAL_TYPE: str = "approval"
_MCP_ELICITATION_TYPE: str = "mcp_elicitation"
# Wire literal for explicit user-requested context compaction. Unlike
# normal item events, this is a control event: it does not persist a
# user message or dispatch a normal agent turn. The route runs the
# runtime compaction helper directly and publishes the same
# ``response.compaction.in_progress`` event the automatic path emits.
_COMPACT_TYPE: str = "compact"
# Structured visible command item used by the REPL for skill invocations.
# The server handles skill slash commands specially: it persists this
# visible metadata item, then sends the runner a hidden ``message`` with
# the actual skill instructions.
_SLASH_COMMAND_TYPE: str = "slash_command"
# Web-UI-initiated request to terminate a live session without
# deleting its conversation (the transcript stays viewable). The AP
# server stays harness-agnostic and forwards this to the bound runner,
# whose ``/events`` handler kills the external process for harnesses
# that have one (claude-native hard-kills its tmux pane) and 204s for
# in-process harnesses. Owner-only — terminating a session for every
# participant is a lifecycle action on par with delete, not an edit.
_STOP_SESSION_TYPE: str = "stop_session"
# Internal input used by terminal-backed integrations that observe an
# assistant response outside the Omnigent task runtime and need to
# persist/broadcast it into the session transcript without starting a
# duplicate agent turn.
_EXTERNAL_ASSISTANT_MESSAGE_TYPE: str = "external_assistant_message"
# Internal input used by terminal-backed integrations to append a
# semantic item observed outside the Omnigent task runtime. Unlike a
# normal ``message`` POST, this does not create or steer an agent task.
_EXTERNAL_CONVERSATION_ITEM_TYPE: str = "external_conversation_item"
# Internal input used by terminal-backed integrations to publish a live
# assistant text delta observed outside the Omnigent task runtime. The
# payload is transient SSE only and is intentionally not persisted; the
# corresponding completed message still arrives later via
# ``external_conversation_item``.
_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE: str = "external_output_text_delta"
# Internal input used by terminal-backed integrations to publish a transient
# reasoning (chain-of-thought) delta observed before the completed message is
# available — the reasoning analogue of ``external_output_text_delta``. Nothing
# is persisted: it publishes ``response.reasoning_text.delta`` (preceded by a
# single ``response.reasoning.started`` when ``data.started`` is true) so the SPA
# paints a live reasoning block, matching the in-process executor's wire shape.
# Reasoning has no completed conversation item; the block is finalized when the
# assistant message arrives via ``external_conversation_item``. Payload:
# ``{"delta": "...", "started": true|false}``.
_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE: str = "external_output_reasoning_delta"
# Internal input used by terminal-backed integrations to publish an
# explicit ``session.interrupted`` edge observed outside the Omnigent
# task runtime. Payload is empty.
_EXTERNAL_SESSION_INTERRUPTED_TYPE: str = "external_session_interrupted"
# Internal input used by the claude-native forwarder when a Claude
# ``/clear`` rotates a session away: the old conversation keeps its
# history but the live terminal moves to a fresh conversation. Republished
# as a transient ``session.superseded`` SSE event so a client actively
# viewing the old conversation auto-redirects to the new one. Live-only
# (no replay) — the durable counterpart is the persisted notice message
# the forwarder also appends to the old conversation. Payload:
# ``{"target_conversation_id": "conv_new"}``.
_EXTERNAL_SESSION_SUPERSEDED_TYPE: str = "external_session_superseded"
# Internal input used by Codex-native forwarders to clear a harness
# elicitation that another Codex client already answered. Payload:
# ``{"elicitation_id": "elicit_codex_..."}``.
_EXTERNAL_ELICITATION_RESOLVED_TYPE: str = "external_elicitation_resolved"
# Internal input used by terminal-backed integrations to publish a
# session.status event observed outside the Omnigent task runtime
# (e.g. ``omnigent claude`` mirroring Claude Code's Stop hook into
# the session stream so the web UI's idle/running indicator updates).
# Payload shape: ``{"status": "idle" | "running" | "waiting" | "failed"}``.
# ``launching`` is runner-local sub-agent bookkeeping (it rides in a child's
# ``current_task_status``, never as an external session status) and is
# intentionally absent from ``_EXTERNAL_SESSION_STATUS_VALUES`` below.
_EXTERNAL_SESSION_STATUS_TYPE: str = "external_session_status"
_EXTERNAL_SESSION_STATUS_VALUES: frozenset[str] = frozenset(
{"idle", "running", "waiting", "failed"}
)
# Native transcript forwarders post completed assistant items immediately
# before ``external_session_status: idle``. Scanning the latest message
# window avoids a full transcript read while still tolerating tool/user
# records after the assistant item.
_EXTERNAL_STATUS_ASSISTANT_SCAN_LIMIT: int = 1000
# Compaction-progress edge observed inside the Claude Code terminal
# (claude-native forwarder, from the ``PreCompact`` and post-compaction
# ``SessionStart source=compact`` hooks). Publishes the same
# ``response.compaction.in_progress`` / ``response.compaction.completed``
# SSE events the AP-side compaction path emits, so the web UI shows its
# "Compacting conversation…" spinner while Claude runs the real
# compaction in its terminal. Payload: ``{"status": "in_progress" |
# "completed" | "failed"}``. ``completed`` carries no token count — the
# context ring is updated separately by ``external_session_usage``.
_EXTERNAL_COMPACTION_STATUS_TYPE: str = "external_compaction_status"
_EXTERNAL_COMPACTION_STATUS_VALUES: frozenset[str] = frozenset(
{"in_progress", "completed", "failed"}
)
# Per-MCP-server startup progress observed by a native forwarder while
# its harness boots MCP servers (codex-native today). Republished as a
# ``session.mcp_startup`` SSE event so the web UI shows which servers
# are still starting — instead of an apparently hung session — and
# which failed or were cancelled. Payload:
# ``{"servers": {"safe": {"status": "starting", "error": null}}}``.
_EXTERNAL_MCP_STARTUP_TYPE: str = "external_mcp_startup"
_EXTERNAL_MCP_STARTUP_STATUS_VALUES: frozenset[str] = frozenset(
{"starting", "ready", "failed", "cancelled"}
)
# Usage update from a terminal-backed runtime (claude-native
# forwarder). Persists ``context_tokens`` / ``context_window`` as
# conversation labels and publishes a ``session.usage`` SSE event.
_EXTERNAL_SESSION_USAGE_TYPE: str = "external_session_usage"
# Active-model switch observed inside the Claude Code terminal (a
# ``/model`` command or the in-TUI picker). Persists ``model_override``
# on the conversation and publishes a ``session.model`` SSE event so the
# web model picker reflects the switch. Payload: ``{"model": "opus"}``.
_EXTERNAL_MODEL_CHANGE_TYPE: str = "external_model_change"
# Active reasoning-effort switch observed inside a native terminal. Persists
# ``reasoning_effort`` on the conversation and publishes a
# ``session.reasoning_effort`` SSE event so the web effort picker reflects the
# switch. Payload: ``{"reasoning_effort": "medium"}``; JSON ``null`` clears.
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE: str = "external_reasoning_effort_change"
# Subagent-start signal from the claude-native forwarder. Claude Code
# spawns sub-agents internally (Task tool) and writes their transcripts
# to ``~/.claude/projects/.../subagents/agent-<id>.jsonl`` — there is
# no Claude Code hook fired when a sub-agent begins. The forwarder
# polls the on-disk directory and POSTs this event when a new
# ``.meta.json`` appears so the Omnigent server can mint a child Conversation
# row and surface it in the Subagents rail tab. Payload shape:
# ``{"subagent_id": "<claude-side id>", "agent_type": "Explore",
# "description": "...", "tool_use_id": "toolu_..."}``.
_EXTERNAL_SUBAGENT_START_TYPE: str = "external_subagent_start"
# Labels stamped on the new child Conversation. ``subagent_id`` is the
# stable Claude-side identifier used for idempotent retries — two POSTs
# carrying the same ``subagent_id`` resolve to the same child row.
# ``tool_use_id`` links back to the parent transcript's Task tool-use
# block so consumers can correlate sub-agent rows to the call that
# spawned them. The wrapper label distinguishes claude-native
# sub-agents from omnigent-spawned ones at the data layer.
_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE = "claude-code-native-ui-subagent"
_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY = "omnigent.claude_native.subagent_id"
_CLAUDE_NATIVE_TOOL_USE_ID_LABEL_KEY = "omnigent.claude_native.tool_use_id"
# Free-form human-readable description (the ``description`` field of
# the on-disk ``.meta.json``). Not used by the rail's display path
# today — Claude often passes the same string for many parallel
# sub-agents — but preserved as a label so debug surfaces / future
# UI work can read it without re-reading the meta file.
_CLAUDE_NATIVE_DESCRIPTION_LABEL_KEY = "omnigent.claude_native.description"
# Subagent-start signal from the codex-native forwarder. Codex AgentControl
# emits ``collabAgentToolCall`` items when it spawns child threads. The
# forwarder converts each receiver thread into this event so Omnigent can mint a
# child Conversation row and surface it in the Subagents rail.
_EXTERNAL_CODEX_SUBAGENT_START_TYPE: str = "external_codex_subagent_start"
# Wrapper label value distinguishing Codex-internal children from both
# omnigent-spawned and claude-native sub-agents.
_CODEX_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE = "codex-native-ui-subagent"
# Label keys stamped on the child Conversation for display and idempotency.
_CODEX_NATIVE_SUBAGENT_THREAD_ID_LABEL_KEY = "omnigent.codex_native.subagent_thread_id"
_CODEX_NATIVE_SUBAGENT_PARENT_THREAD_ID_LABEL_KEY = "omnigent.codex_native.parent_thread_id"
_CODEX_NATIVE_SUBAGENT_TOOL_CALL_ID_LABEL_KEY = "omnigent.codex_native.collab_tool_call_id"
_CODEX_NATIVE_SUBAGENT_PROMPT_LABEL_KEY = "omnigent.codex_native.prompt"
_CODEX_NATIVE_SUBAGENT_NICKNAME_LABEL_KEY = "omnigent.codex_native.agent_nickname"
_CODEX_NATIVE_SUBAGENT_ROLE_LABEL_KEY = "omnigent.codex_native.agent_role"
# Current Codex collaboration mode kind (``"plan"`` or ``"default"``)
# mirrored from app-server ``thread/settings/updated``.
_CODEX_NATIVE_COLLABORATION_MODE_LABEL_KEY = "omnigent.codex_native.collaboration_mode"
_EXTERNAL_CODEX_COLLABORATION_MODE_CHANGE_TYPE: str = "external_codex_collaboration_mode_change"
_CODEX_NATIVE_COLLABORATION_MODES: frozenset[str] = frozenset({"default", "plan"})
def _codex_plan_mode_enabled(mode: str) -> bool:
"""
Convert a validated Codex collaboration mode kind to the UI-facing flag.
:param mode: Codex collaboration mode kind, e.g. ``"plan"`` or
``"default"``.
:returns: ``True`` for Plan mode.
"""
return mode == "plan"
def _publish_collaboration_mode(session_id: str, mode: str) -> None:
"""
Publish the live collaboration-mode for a session.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param mode: The active collaboration mode string, e.g.
``"plan"`` or ``"default"``.
:returns: None.
"""
event = SessionCollaborationModeEvent(
type="session.collaboration_mode",
conversation_id=session_id,
mode=mode,
)
session_stream.publish(session_id, event.model_dump())
def _publish_policy_denied(session_id: str, reason: str, phase: str) -> None:
"""
Publish a native policy-DENY signal on the session stream.
A native harness's policy DENY is decided synchronously in the
``/policies/evaluate`` hook response, so nothing on the stream otherwise
reflects that an action was blocked. This surfaces the decision as a
positive event for observers (web UI, capability bench). Fire-and-forget.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
:param reason: Deny reason from the deciding policy.
:param phase: The policy phase the DENY landed on, e.g. ``"tool_call"``.
:returns: None.
"""
event = PolicyDeniedEvent(
type="response.policy_denied",
conversation_id=session_id,
reason=reason,
phase=phase,
)
session_stream.publish(session_id, event.model_dump())
# Display name fallback when neither nickname nor role is available.
_CODEX_NATIVE_SUBAGENT_DISPLAY_FALLBACK = "Codex"
# Labels read by ``_get_session_snapshot`` to seed the web ring on
# reload for sessions where no Omnigent task carries usage (claude-native).
_LAST_CONTEXT_TOKENS_LABEL_KEY: str = "omnigent.last_context_tokens"
_LAST_CONTEXT_WINDOW_LABEL_KEY: str = "omnigent.last_context_window"
# Labels read by ``_get_session_snapshot`` to surface the latest terminal /
# runner task failure after the live ``session.status: failed`` SSE has gone
# by. Empty string clears a stale value because labels are upsert-only.
_LAST_TASK_ERROR_CODE_LABEL_KEY: str = "omnigent.last_task_error_code"
_LAST_TASK_ERROR_MESSAGE_LABEL_KEY: str = "omnigent.last_task_error_message"
# Hard limit matching the ``conversation_labels.value`` column width. Sourced
# from the schema so the truncation and the column can never drift apart.
_LABEL_VALUE_MAX_LEN: int = LABEL_VALUE_MAX_LEN
# Todo-list update from the claude-native forwarder. Carries the raw
# todo items captured from PostToolUse/TodoWrite hook events. Payload
# shape: ``{"todos": [{"content": "...", "status": "...", "activeForm": ...}]}``.
_EXTERNAL_SESSION_TODOS_TYPE: str = "external_session_todos"
# Session labels stamped by ``omnigent claude``. A matching session
# is terminal-owned: Omnigent web-chat input must be forwarded to the local
# runner for tmux injection, and rendered transcript items must come
# back through ``external_conversation_item`` only.
_CLAUDE_NATIVE_WRAPPER_LABEL_KEY = "omnigent.wrapper"
_CLAUDE_NATIVE_WRAPPER_LABEL_VALUE = CLAUDE_NATIVE_CODING_AGENT.wrapper_label
# Marks a session as terminal-first in the Web UI (AppShell renders the
# Claude Code terminal pane via TerminalFirstContext). Stamped alongside
# the wrapper label so a claude-native session — created fresh by the
# new-session picker OR added to an existing session via "Add agent" —
# renders as a terminal without the client having to pass labels.
_CLAUDE_NATIVE_UI_LABEL_KEY = "omnigent.ui"
_CLAUDE_NATIVE_UI_LABEL_VALUE = "terminal"
_CLAUDE_NATIVE_HARNESS = CLAUDE_NATIVE_CODING_AGENT.harness
_CLAUDE_NATIVE_MODEL = CLAUDE_NATIVE_CODING_AGENT.agent_name
_CODEX_NATIVE_WRAPPER_LABEL_VALUE = CODEX_NATIVE_CODING_AGENT.wrapper_label
_CODEX_NATIVE_HARNESS = CODEX_NATIVE_CODING_AGENT.harness
_CODEX_NATIVE_MODEL = CODEX_NATIVE_CODING_AGENT.agent_name
_CURSOR_NATIVE_WRAPPER_LABEL_VALUE = CURSOR_NATIVE_CODING_AGENT.wrapper_label
_KIRO_NATIVE_WRAPPER_LABEL_VALUE = KIRO_NATIVE_CODING_AGENT.wrapper_label
_CLAUDE_NATIVE_MESSAGE_TIMEOUT_S = 30.0
_NATIVE_TERMINAL_START_FAILED_CODE = "native_terminal_start_failed"
_NATIVE_TERMINAL_ENSURE_FAILED_CODE = "native_terminal_ensure_failed"
# Banner code for the non-fatal notice shown when a native codex session
# starts but tool-call policy enforcement is NOT active (fail-open: codex
# too old, or the policy hook could not be trusted). The runner reports
# the reason once via ``policy_hook_disabled_reason`` in its
# terminal-ensure 200 response.
_NATIVE_POLICY_NOT_ENFORCED_CODE = "native_policy_not_enforced"
_HOST_BOUND_RUNNER_CONNECT_GRACE_S = 3.0
_HOST_RELAUNCH_RUNNER_CONNECT_TIMEOUT_S = 30.0
# How often the runner-connect wait re-checks the crash-report store while
# racing the event-driven connect signal. Small enough that conviction is
# detected within a fraction of a second of the daemon's report, without
# busy-spinning.
_RUNNER_CONVICTION_POLL_S = 0.25
# Wait budget for the host's ``host.launch_runner`` RESULT frame on the
# relaunch path. The daemon replies as soon as it has spawned (or refused
# to spawn) the runner — a local CLI-on-PATH + credential check then a
# subprocess fork — so this is short. A refusal here (harness not
# configured) is surfaced as a transcript error; a launch proceeds to the
# longer connect wait below. On timeout we assume "launched" and fall
# through to the connect wait, preserving the prior fire-and-forget
# behavior rather than blocking the turn.
_HOST_LAUNCH_RESULT_TIMEOUT_S = 10.0
# Server-side wait budget for Claude's ``PermissionRequest`` hook. Set
# to one day so a native permission prompt waits ~indefinitely for the
# user to answer in EITHER the web UI or the terminal, rather than
# auto-resolving after a few minutes. The terminal prompt stays usable
# the whole time: answering it closes the hook connection, which the
# disconnect poll catches and resolves the web card. Kept in lockstep
# with the hook subprocess httpx budget (``_PERMISSION_TIMEOUT_S`` in
# ``claude_native_hook``) and Claude Code's own command-hook
# ``timeout`` (set in ``build_hook_settings``) so no single layer caps
# the wait first. Empty 2xx body on timeout → Claude defers to its
# built-in prompt (fail-ask).
_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S = 86400.0
# ── Embedded-browser action bridge ──────────────────────────────────
# In-process registries (keyed by action_id) bridging a runner-side
# ``browser_*`` tool POST, parked on a Future, to the desktop renderer that
# drives the browser and POSTs the result back.
_browser_action_registry: dict[str, asyncio.Future[dict[str, Any]]] = {} # -> parked Future
_browser_action_owners: dict[str, str] = {} # -> issuing session_id (result POST must match)
# -> claim_token: single-winner lease so fan-out to multiple renderers can't
# double-execute; the result POST must present the matching token.
_browser_action_claims: dict[str, str] = {}
# Server-side wait budget for an interactive browser action. MUST stay below the
# runner's 60s read timeout (``_BROWSER_ACTION_TIMEOUT`` in tool_dispatch.py) so
# the server returns its own clean timeout JSON before the runner severs the POST.
_BROWSER_ACTION_AWAIT_S = 30.0
# Returned (HTTP 200) when the await elapses with no renderer result (desktop app
# not open / no subscriber); matches the runner-side timeout JSON.
_BROWSER_ACTION_TIMEOUT_RESULT: dict[str, Any] = {
"error": "browser action timed out — is the session open in the Omnigent desktop app?"
}
# Tools whose prompts get the "Accept & allow all edits" UI affordance —
# the exact set ``acceptEdits`` mode auto-approves.
_CLAUDE_NATIVE_EDIT_TOOLS: frozenset[str] = frozenset(
{"Edit", "Write", "MultiEdit", "NotebookEdit"}
)
def _allow_all_edits_eligible(tool_name: str, permission_mode: str | None) -> bool:
"""
Whether a claude-native PermissionRequest may offer / honor the
"Accept & allow all edits" affordance.
Eligible for file-editing tools under a mode that still prompts,
and for ``ExitPlanMode`` — accepting a plan with the flag is the
plan card's "Yes, and use auto mode" option (exit plan mode AND
switch the session into Claude's ``auto`` mode).
Already-permissive modes (``acceptEdits`` / ``bypassPermissions``)
wouldn't prompt at all, so the switch would be inert. Used at BOTH
the stamp site (drives the UI button) and the verdict site (gates
the ``setMode`` decision), so the server never honors a
client-supplied ``allow_all_edits`` flag on a tool/mode the
affordance was never offered for.
:param tool_name: The gated tool from Claude's PermissionRequest
payload, e.g. ``"Edit"`` or ``"Bash"``.
:param permission_mode: Claude's current permission mode from the
payload, e.g. ``"default"`` / ``"plan"`` / ``"acceptEdits"`` /
``None`` when absent.
:returns: ``True`` iff the affordance applies.
"""
return (
tool_name in _CLAUDE_NATIVE_EDIT_TOOLS or tool_name == "ExitPlanMode"
) and permission_mode not in (
"acceptEdits",
"bypassPermissions",
)
# Tools that own a dedicated approval affordance and therefore must NOT
# get the generic "don't ask again" (persistent allow-rule) button:
# ``ExitPlanMode`` (plan-review card with its own auto-mode action) and
# ``AskUserQuestion`` (interactive answer form, not a yes/no gate). Edit
# tools are excluded separately via ``_CLAUDE_NATIVE_EDIT_TOOLS`` — they
# take the ``setMode``/``acceptEdits`` path instead of an allow rule.
_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS: frozenset[str] = frozenset(
{"ExitPlanMode", "AskUserQuestion"}
)
def _allow_remember_eligible(tool_name: str, permission_mode: str | None) -> bool:
"""
Whether a claude-native PermissionRequest may offer / honor the
persistent "don't ask again" affordance — a session-scoped allow
rule for the gated tool (WebFetch domain, or tool-wide otherwise).
This restores native Claude Code parity for NON-edit tools: the
native TUI lets the user approve a tool/domain once and adds an
allow rule so same-scope calls stop prompting. The web UI used to
collapse every prompt into binary Approve/Reject and never wrote a
rule, so e.g. each WebFetch — even same-domain github.com URLs —
re-prompted forever.
Eligible for any tool that ISN'T an edit tool (those take the
``acceptEdits`` ``setMode`` path) and isn't one of the tools with a
bespoke card (see ``_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS``),
under any mode that still prompts. ``bypassPermissions`` never
prompts (the hook doesn't even fire), so a rule there would be
inert. Used at BOTH the stamp site (drives the UI button) and the
verdict site (gates the ``addRules`` decision), so the server never
honors a client-supplied ``remember`` flag on a tool/mode the
affordance was never offered for.
:param tool_name: The gated tool from Claude's PermissionRequest
payload, e.g. ``"WebFetch"`` or ``"Bash"``.
:param permission_mode: Claude's current permission mode from the
payload, e.g. ``"default"`` / ``"plan"`` / ``"acceptEdits"`` /
``None`` when absent.
:returns: ``True`` iff the affordance applies.
"""
return (
tool_name not in _CLAUDE_NATIVE_EDIT_TOOLS
and tool_name not in _CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS
and permission_mode != "bypassPermissions"
)
def _claude_native_remember_host(tool_name: str, tool_input: Any) -> str | None:
"""
Derive the domain host that a WebFetch "don't ask again" rule should
scope to, from the gated tool's input.
For ``WebFetch`` the persistent rule is scoped to the request's
host (``WebFetch(domain:<host>)`` in Claude rule syntax), so
approving ``https://github.com/a/b`` stops prompting for
``https://github.com/c/d`` too — but not for other domains. Any
other tool (or a WebFetch with a missing/unparseable URL) returns
``None``, which the callers treat as a tool-wide scope.
Only ``http`` / ``https`` URLs yield a domain scope: WebFetch
domain permissions are semantically HTTP(S)-oriented, so a
non-HTTP scheme (``ftp://``, ``file://``, …) falls back to a
tool-wide rule rather than persisting a ``domain:<host>`` that
would never match a real fetch.
:param tool_name: The gated tool from Claude's PermissionRequest
payload.
:param tool_input: The tool's input dict (``None``/non-dict tolerated).
:returns: The lowercased host (no port), bracketed when it is an IPv6
literal (``[2001:db8::1]``), or ``None`` when no domain scope
applies.
"""
if tool_name != "WebFetch" or not isinstance(tool_input, dict):
return None
url = tool_input.get("url")
if not isinstance(url, str) or not url:
return None
try:
parsed = urllib.parse.urlparse(url)
except ValueError:
return None
if parsed.scheme.lower() not in ("http", "https"):
return None
host = parsed.hostname
if not host:
return None
# urlparse already lowercases ``hostname`` and strips the port and
# any userinfo; lower() again makes the documented invariant explicit.
host = host.lower()
# urlparse strips the brackets off an IPv6 literal authority
# (``[2001:db8::1]`` → ``2001:db8::1``), but Claude's
# ``domain:<host>`` rule grammar is colon-delimited, so a bare
# colon-laden IPv6 atom persists a broken/inert rule (the user
# clicks "don't ask again" and keeps getting prompted). A registered
# domain name can never contain a colon, so a ``:`` here is an
# unambiguous IPv6 literal — re-bracket it so the emitted rule is
# ``domain:[2001:db8::1]``.
if ":" in host:
return f"[{host}]"
return host
# Server-side wait budget for Codex app-server requests forwarded by
# ``omnigent codex``. Held at one day like the Claude permission hook:
# a terminal-side answer ends the wait early via the app-server's
# explicit ``serverRequest/resolved`` notification, so the long park
# never blocks the TUI path — while the old 300s cap silently abandoned
# any prompt a headless sub-agent left unanswered for >5 minutes.
_CODEX_NATIVE_ELICITATION_HOOK_TIMEOUT_S = 86400.0
# Antigravity (agy) elicitation hook wait budget. Same 24-hour cap as
# Codex: a terminal-side verdict (or agy's own WAITING timeout) ends the
# wait early, so the long park never blocks native-TUI paths.
_ANTIGRAVITY_NATIVE_ELICITATION_HOOK_TIMEOUT_S = 86400.0
# Same one-day park budget for cursor-native tool-approval prompts mirrored
# from the TUI: a terminal-side answer ends the wait early via
# ``external_elicitation_resolved`` (posted by the runner-side approval mirror),
# so the long park never blocks the cursor pane.
_CURSOR_NATIVE_PERMISSION_HOOK_TIMEOUT_S = 86400.0
# Same one-day park budget for the generic native-permission hook used by the
# hermes- and goose-native approval mirrors (TUI prompt → web card). A
# terminal-side answer ends the wait early via ``external_elicitation_resolved``.
_NATIVE_PERMISSION_HOOK_TIMEOUT_S = 86400.0
# ``external_elicitation_resolved`` can arrive just before the matching
# Codex hook registers, and a web verdict can land between a severed
# long-poll and its retry. Pinned, NOT the hook wait budget: Codex ids
# are deterministic per (session, method, rpc id) and rpc ids reset on
# app-server restart, so a long-lived tombstone could replay a stale
# verdict onto an unrelated future prompt. 300s covers both gap kinds;
# the entry cap keeps bogus ids from growing the process forever.
_HARNESS_PRE_RESOLVED_ELICITATION_TTL_S = 300.0
_HARNESS_PRE_RESOLVED_ELICITATION_MAX_ENTRIES = 1024
# Grace between a verdict-less hook wait ending and the card-clearing
# resolved publish — lets the hook's retry re-park the same id instead
# of wiping a still-blocked prompt; a dead hook still clears after it.
_HARNESS_ELICITATION_REPARK_GRACE_S = 10.0
# Client-supplied re-attach ids, namespaced so they cannot collide
# with Codex deterministic ids or server-minted ids. The shared
# PermissionRequest endpoint is used by every native-CLI wrapper that mints its
# own id (claude-native ``elicit_claude_…``, kimi-native ``elicit_kimi_…``), so
# the namespace is the harness token, not a fixed ``claude``.
_HOOK_ELICITATION_ID_RE = re.compile(r"^elicit_[a-z]+_[0-9a-f]{32}$")
# Stable re-attach id for ``POST /policies/evaluate`` retries. Allows the
# server to re-park the existing ASK elicitation rather than minting a new
# approval card when a transient 5xx or connect-drop triggered a retry.
_EVALUATE_HOOK_ELICITATION_ID_RE = re.compile(r"^elicit_evaluate_[0-9a-f]{32}$")
# Cap on reaping a cancelled disconnect/terminal-resolved race task in
# the harness-elicitation gate's cleanup. Reaping normally completes in
# one loop tick; the cap exists because a cancellation that lands while
# the target is inside an anyio cancel scope can be coalesced with the
# scope's own cancellation and swallowed, and an unbounded
# ``await race_task`` then wedges the gate for its full timeout (24h on
# the claude hook path).
_RACE_TASK_REAP_TIMEOUT_S = 5.0
# Cadence for ``session.heartbeat`` keepalive events on
# ``GET /v1/sessions/{id}/stream`` (see :func:`_stream_live_events`).
# A stream that sits idle between turns has nothing crossing the wire,
# which lets a half-open socket go undetected for the client's full
# SSE read-timeout (10 minutes in the SDK). 15s mirrors the per-turn
# ``response.heartbeat`` cadence and is short enough to recover from
# a laptop-sleep-induced half-open socket within one user typing-step.
_SESSION_STREAM_HEARTBEAT_INTERVAL_S = 15.0
# Cap each runner-touching snapshot-on-connect gather so a slow/unavailable
# runner can never delay the live tail (the conversation snapshot endpoint
# remains the primary reconcile path).
_SNAPSHOT_RUNNER_TIMEOUT_S = 2.0
# Maximum time Omnigent waits for its runner->AP SSE relay to observe the
# runner stream's ready heartbeat before forwarding a no-replay input
# event. A timeout fails loud instead of accepting a prompt whose fast
# output could be dropped before the relay is subscribed.
_RUNNER_RELAY_READY_TIMEOUT_S = 5.0
# Set of event ``type`` values the route accepts on POST /events.
# Two are special-cased and bypass the normal item-persist path:
# ``interrupt`` → cancel active task + publish ``session.interrupted``
# ``approval`` → resolve the outstanding elicitation Future
# ``external_assistant_message`` → append/broadcast terminal-observed output
# ``external_conversation_item`` → append/broadcast a terminal-observed item
# ``external_session_interrupted`` → publish terminal-observed interruption
# Everything else must be a known item type from the conversation
# entity's discriminator map (``message``, ``function_call_output``,
# etc.) so the agent loop can rehydrate it via ``parse_item_data``.
# Anything not in this set is a client mistake — fail loud with 400
# at the route boundary rather than persist an item the consumer can
# only crash on later.
_ALLOWED_EVENT_TYPES: frozenset[str] = frozenset(ITEM_TYPE_TO_DATA_CLS.keys()) | {
_INTERRUPT_TYPE,
_APPROVAL_TYPE,
_MCP_ELICITATION_TYPE,
_COMPACT_TYPE,
_STOP_SESSION_TYPE,
_EXTERNAL_ASSISTANT_MESSAGE_TYPE,
_EXTERNAL_CONVERSATION_ITEM_TYPE,
_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE,
_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE,
_EXTERNAL_SESSION_INTERRUPTED_TYPE,
_EXTERNAL_SESSION_SUPERSEDED_TYPE,
_EXTERNAL_ELICITATION_RESOLVED_TYPE,
_EXTERNAL_SESSION_STATUS_TYPE,
_EXTERNAL_SESSION_USAGE_TYPE,
_EXTERNAL_COMPACTION_STATUS_TYPE,
_EXTERNAL_MCP_STARTUP_TYPE,
_EXTERNAL_MODEL_CHANGE_TYPE,
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
_EXTERNAL_SESSION_TODOS_TYPE,
_EXTERNAL_SUBAGENT_START_TYPE,
_EXTERNAL_CODEX_SUBAGENT_START_TYPE,
_EXTERNAL_CODEX_COLLABORATION_MODE_CHANGE_TYPE,
}
# Validates every dict that crosses the AP→client SSE boundary on
# the session stream. Built once at module load.
_SERVER_STREAM_EVENT_ADAPTER: TypeAdapter[ServerStreamEvent] = TypeAdapter(ServerStreamEvent)
# Strong-references for per-session task watchers spawned via
# ``asyncio.create_task``. Without this, asyncio's task registry only
# holds a weak reference and the GC can collect a running task before
# it finishes (the well-known RUF006 / Python ``asyncio`` footgun).
# Entries are evicted by a done-callback on the task itself.
_WATCHER_TASKS: set[asyncio.Task[None]] = set()
# Per-session status cache updated by the runner SSE relay.
# Used by _get_session_snapshot.
_session_status_cache: dict[str, str] = {}
# Per-session in-flight response id, tracked alongside _session_status_cache.
# Set when a running/waiting status edge carries a response_id (native Claude's
# turn-start edge does); popped on idle/failed. Projected onto the session
# snapshot as ``active_response_id`` so a client reconnecting mid-turn can
# reopen the streaming ``activeResponse`` and keep forwarded tool cards
# rendering LIVE — the SSE stream is "snapshot + live tail, no replay", so the
# turn-start ``running`` event is never re-sent on reconnect.
_session_active_response_cache: dict[str, str] = {}
# Per-session background-shell tally (claude-native), kept in lockstep with
# ``_session_status_cache`` so a snapshot/reload re-shows "N background tasks
# still running" after the live SSE edge is gone. The authoritative source is
# the ``Stop`` hook's ``background_tasks`` count: a positive count sets the
# tally, an explicit ``0`` clears it (so a finished shell drops the indicator
# at the next turn end), and a new turn (``running``) or a failure also clears
# it. The trailing PTY-activity ``idle`` carries no count and must NOT clear it.
#
# KNOWN LIMITATION — the tally only refreshes at a turn boundary. Claude Code
# emits no background-shell-completion hook, so a ``0`` is only ever posted by
# the next ``Stop``. If a shell exits while the session is already idle and the
# user never sends another message, no ``Stop`` fires and the indicator (chat,
# sidebar, and reloads via ``_get_session_snapshot``) can read "N background
# tasks still running" until the next turn. In practice the agent usually
# narrates the shell's completion — which IS a turn, so its ``Stop`` clears the
# tally — bounding the stale window to the next interaction. This mirrors the
# TUI's own turn-boundary update of its "N shells still running" banner.
# In-memory only — repopulates from live edges, exactly like the status cache.
_session_background_task_count_cache: dict[str, int] = {}
# Per-user read tracking, keyed by the user's discovery key (user id, or
# the shared key in single-user mode) then by session id. Mirrors the two
# values the web client used to keep in localStorage: a "last seen"
# wall-clock baseline and an explicit "marked unread" override set.
# In-memory only — like _session_status_cache it does NOT survive a server
# restart. Unlike status (rederivable from the runner), read state has no
# durable source, so a restart resets it; this is an accepted tradeoff for
# keeping it server-side (shared across a user's devices while up) without
# a DB. Entries are never pruned on session delete (bounded by churn,
# wiped on restart).
_read_last_seen: dict[str, dict[str, int]] = {}
_read_explicit_unread: dict[str, set[str]] = {}
def _read_state_entry(user_id: str | None, session_id: str) -> tuple[int | None, bool]:
"""
Read the caller's read-state for one session, for embedding in the
per-user ``GET /v1/sessions`` list items.
:param user_id: Authenticated user id, or ``None`` in single-user mode.
:param session_id: Session/conversation identifier.
:returns: ``(last_seen, unread)`` — the wall-clock baseline (or ``None``
when the user has never seen the session) and the explicit-unread flag.
"""
key = _discovery_key(user_id)
last_seen = _read_last_seen.get(key, {}).get(session_id)
unread = session_id in _read_explicit_unread.get(key, set())
return last_seen, unread
def _set_read_state(user_id: str | None, session_id: str, last_seen: int, unread: bool) -> None:
"""
Set the caller's read-state for one session.
:param user_id: Authenticated user id, or ``None`` in single-user mode.
:param session_id: Session/conversation identifier.
:param last_seen: Wall-clock baseline in seconds.
:param unread: Whether the session is explicitly flagged unread.
"""
key = _discovery_key(user_id)
_read_last_seen.setdefault(key, {})[session_id] = last_seen
if unread:
_read_explicit_unread.setdefault(key, set()).add(session_id)
else:
unread_set = _read_explicit_unread.get(key)
if unread_set is not None:
unread_set.discard(session_id)
def _prune_session_read_state(session_id: str) -> None:
"""
Drop a session's read-state from every user's caches.
Called when a session leaves the default view for good — on delete, and
on archive (archived sessions are hidden and never show the unread dot).
This bounds the otherwise-monotonic ``_read_last_seen`` growth to live,
non-archived sessions. Read-state is a session-level removal (the session
is gone/archived for everyone), so it clears across all users. Unarchiving
does NOT restore the prior state — the session reads as seen, which is the
intended "done with it" semantics of archiving.
:param session_id: Session/conversation identifier.
"""
for seen in _read_last_seen.values():
seen.pop(session_id, None)
for unread in _read_explicit_unread.values():
unread.discard(session_id)
# Sessions whose current turn was Stopped: the relay drops the turn's trailing
# response.* output (no forward, no persist). The fence lifts on the next
# turn's "running" status or on any terminal response.* event.
_interrupt_fenced_sessions: set[str] = set()
# Turn-terminal response lifecycle events: the relay flushes buffered
# assistant text on each of these and resets its turn-scoped state.
_TERMINAL_RESPONSE_EVENT_TYPES: frozenset[str] = frozenset(
{
"response.completed",
"response.failed",
"response.cancelled",
"response.incomplete",
}
)
# response.* events that pass the interrupt fence: elicitation lifecycle is
# pending-approvals bookkeeping, not turn output — swallowing a resolved event
# would leak a ghost approval card into every later session snapshot.
_FENCE_EXEMPT_EVENT_TYPES: frozenset[str] = frozenset(
{
"response.elicitation_request",
"response.elicitation_resolved",
}
)
# ── WS /v1/sessions/updates tuning ──────────────────────────────────
# How often the session-updates stream re-reads each connection's
# watched ids and diffs them against the last frame sent. This is the
# "poll" cadence, but server-side and per-connection: a frame is emitted
# only when something actually changed, so an idle list produces no
# traffic. Replaces the client's former 4 s HTTP poll of GET /v1/sessions.
_SESSION_UPDATES_RESCAN_INTERVAL_S: float = 4.0
# When a rescan produces no changes, emit a lightweight heartbeat frame
# at most this often so intermediaries (e.g. Databricks Apps ingress)
# don't reap the idle WebSocket and the client can detect a dead link.
_SESSION_UPDATES_HEARTBEAT_INTERVAL_S: float = 30.0
# Hard cap on the watch-set size a single connection may register, so a
# misbehaving or malicious client can't make the server fan out an
# unbounded per-interval batch of store reads.
_SESSION_UPDATES_MAX_WATCHED: int = 500
# Discovery key used for unauthenticated / single-user deployments (no
# permission store): every connection subscribes here and every create
# publishes here, so new sessions still push. Safe because in that mode all
# sessions are accessible to everyone, so there is no cross-user isolation to
# preserve. In multi-user mode the key is the authenticated user id instead,
# and a create publishes only to its owner's key.
_SHARED_DISCOVERY_KEY = "__all__"
def _discovery_key(user_id: str | None) -> str:
"""
Map an (optional) user id to the :mod:`user_session_stream` channel key.
:param user_id: Authenticated user id, e.g. ``"alice@example.com"``, or
``None`` in single-user / no-auth mode.
:returns: ``user_id`` when set, else :data:`_SHARED_DISCOVERY_KEY`.
"""
return user_id if user_id is not None else _SHARED_DISCOVERY_KEY
def _announce_session_added(user_id: str | None, session_id: str) -> None:
"""
Push a ``session_added`` discovery event to a user's updates streams.
Called after a session becomes accessible to ``user_id`` (created, forked,
or shared) so that user's open tabs surface it without a list poll. A no-op
when the user has no stream connected.
:param user_id: The user the session is now accessible to (the owner on
create/fork, the grantee on share), or ``None`` in single-user mode.
:param session_id: The newly-accessible session id, e.g. ``"conv_abc123"``.
"""
user_session_stream.publish(
_discovery_key(user_id), {"type": "session_added", "session_id": session_id}
)
# Per-session todo cache updated by external_session_todos events from the
# claude-native forwarder. Used by _build_session_response to populate the
# ``todos`` snapshot field so the panel survives page refresh.
_session_todos_cache: dict[str, list[dict[str, Any]]] = {}
# Per-session terminal-spin-up flag updated by the runner SSE relay from
# ``session.terminal_pending`` events (and self-healed when a real terminal
# resource is created). Used by _build_session_response to populate the
# ``terminal_pending`` snapshot field so a client connecting mid-spin-up
# still sees the Terminal-pill spinner. Only ``True`` entries are stored —
# the key is deleted on clear so the dict never accumulates stale ``False``
# entries for every session that ever spun up a terminal.
_session_terminal_pending_cache: dict[str, bool] = {}
# Managed-sandbox launch progress keyed by session id. Written by
# _publish_sandbox_status as the background launch pipeline advances;
# read by _build_session_response to populate the ``sandbox_status``
# snapshot field so a client opening the session mid-launch sees the
# current stage. Successful launches are evicted on "ready" (absent ==
# no launch in flight); failures are retained — mirroring
# ManagedLaunchTracker — so a reload after a dead launch still shows
# why the sandbox never came up.
_session_sandbox_status_cache: dict[str, SandboxStatus] = {}
# Per-MCP-server startup state keyed by session id. Written by
# _publish_mcp_startup as the native forwarder reports harness MCP
# startup progress; read by _build_session_response to populate the
# ``mcp_startup`` snapshot field so a client opening (or reloading) the
# session mid-startup still sees the startup band. Evicted when the
# forwarder posts an empty/settled map — absent == no startup state.
_session_mcp_startup_cache: dict[str, dict[str, McpServerStartup]] = {}
# Per-session runner-skills cache + in-flight fetch. The snapshot fetches
# these off its critical path (see _fetch_runner_skills) so the continuous
# poll can't pin the runner's event loop and wedge a turn.
_runner_skills_cache: dict[str, list[SkillSummary]] = {}
_runner_skills_inflight: dict[str, asyncio.Task[None]] = {}
# Per-session codex-native model catalog cache + in-flight fetch.
# The snapshot warms this from the bound runner's live Codex app-server
# (``model/list``) off the hot path, same shape as runner skills.
_model_options_cache: dict[str, list[dict[str, Any]]] = {}
_model_options_inflight: dict[str, asyncio.Task[None]] = {}
_CODEX_MODEL_OPTIONS_RETRY_DELAYS_S = (0.25, 0.5, 1.0, 2.0, 2.0)
@dataclass
class _MirroredToolCall:
"""
Tool identity of a forwarder-mirrored ``function_call``.
Cached by ``call_id`` so a later ``function_call_output`` (which
carries only ``call_id`` + ``output``) can recover the tool it
belongs to and correlate it to a parked permission prompt. See
:data:`_recent_mirrored_tool_calls`.
:param tool_name: Tool name, e.g. ``"Bash"``.
:param tool_input: Parsed tool arguments, e.g.
``{"command": "ls"}``; ``{}`` when the arguments were absent or
not a JSON object.
"""
tool_name: str
tool_input: dict[str, Any]
# call_id -> tool identity for recently mirrored ``function_call``
# items. The forwarder always posts a tool's ``function_call`` before
# its ``function_call_output``, so the entry is present when the output
# arrives. Bounded + LRU-evicting because tool calls are unbounded over
# a session's life and we only need each entry to bridge the gap to its
# own output (seconds). Used by _persist_external_conversation_item to
# drive the terminal-resolved elicitation fast path.
_recent_mirrored_tool_calls: cachetools.LRUCache[str, _MirroredToolCall] = cachetools.LRUCache(
maxsize=2048
)
@dataclass(frozen=True)
class _PendingPolicyAskWrites:
"""Policy writes deferred until a relay-path tool-call ASK is approved.
The relay / non-native tool-call gate (:func:`_evaluate_tool_call_policy`)
parks an ASK as a runner-owned elicitation and returns ``pending`` — it
cannot apply the deciding policy's ``state_updates`` / ``set_labels``
inline because the approval happens later, off that request. They are
stashed here keyed by elicitation id and applied when the matching
``approval`` event resolves with ``accept`` (POLICIES.md §7.2: a denied
ASK leaves no trace). Without this, e.g. a cost-budget soft checkpoint is
never recorded server-side, so it re-prompts on every subsequent tool
call. The native-harness path (:func:`_hold_native_ask_gate`) parks
server-side and applies these inline, so it does not need this.
:param state_updates: Deferred :class:`StateUpdate` ops to apply on
approve, or ``None``.
:param set_labels: Deferred label writes to apply on approve, or ``None``.
:param from_mcp: ``True`` when created by the ``/mcp`` endpoint's
first-call ASK path. The MCP retry path applies writes
itself, so the events handler skips write application for
these entries to avoid double-applying non-idempotent ops
(e.g. ``INCREMENT`` state updates for cost-budget counters).
"""
state_updates: list[StateUpdate] | None
set_labels: dict[str, str] | None
from_mcp: bool = False
# elicitation_id -> writes to apply when that relay tool-call ASK is approved.
# Bounded + LRU-evicting: an ASK that is declined via the other entry point or
# times out (its ``approval`` event never arrives) would otherwise leak an
# entry, so the oldest evict. Populated by _evaluate_tool_call_policy, drained
# by _apply_pending_policy_ask_writes on the approval verdict.
_pending_policy_ask_writes: cachetools.LRUCache[str, _PendingPolicyAskWrites] = (
cachetools.LRUCache(maxsize=512)
)
# (conversation_id, deciding_policy) -> lock serializing native ASK gates.
# When an agent fires several tool calls in parallel, each spawns its own
# PreToolUse hook that lands in the policy-evaluate endpoint concurrently.
# Without serialization every one of them would publish its own approval
# elicitation for the same crossed checkpoint (e.g. a cost-budget warning),
# prompting the human N times for one decision. Holding this lock across the
# human wait lets the first ASK resolve and record its approval, so the
# siblings re-evaluate to ALLOW (against the freshly persisted state) and never
# prompt again. Keyed on the deciding policy so unrelated policies' asks don't
# serialize against each other; keyed on the conversation so different sessions
# stay independent (claude/codex-native sub-agent tool calls share the parent
# conversation id, so this also covers them). A WeakValueDictionary drops a
# lock once no in-flight coroutine references it, bounding the registry without
# an eviction policy that could hand two waiters different lock objects.
_native_ask_gate_locks: weakref.WeakValueDictionary[tuple[str, str], asyncio.Lock] = (
weakref.WeakValueDictionary()
)
def _native_ask_gate_lock(conversation_id: str, deciding_policy: str) -> asyncio.Lock:
"""
Return the lock serializing native ASK gates for one (session, policy).
Concurrent native tool calls that all trip the same ASKing policy must
prompt the human once, not once each. Callers hold the returned lock
across the entire human-approval wait and re-evaluate the policy under it;
the first approval records a checkpoint that collapses the siblings to
ALLOW. Get-or-create is race-free because there is no ``await`` between the
lookup and the insert (single event loop).
:param conversation_id: Omnigent conversation id whose ASK gate is being
serialized, e.g. ``"conv_abc123"``. Sub-agent native tool calls
evaluate against the parent conversation id, so they share its lock.
:param deciding_policy: Name of the policy that produced the ASK verdict,
e.g. ``"session_cost_guard"``. Distinct policies get distinct locks so
their approval prompts can surface concurrently.
:returns: A process-wide :class:`asyncio.Lock` shared by every concurrent
caller for the same ``(conversation_id, deciding_policy)`` pair.
"""
key = (conversation_id, deciding_policy)
lock = _native_ask_gate_locks.get(key)
if lock is None:
lock = asyncio.Lock()
_native_ask_gate_locks[key] = lock
return lock
@dataclass
class _RelayHandle:
"""
Active SSE relay task plus the runner it streams from.
:param runner_id: Runner id the task is bound to, e.g.
``"runner_abc123"``. Used to detect rebinds to a
different runner so the stale task can be replaced.
:param task: The relay coroutine task.
:param ready: Event set after the relay observes the runner
stream's ready heartbeat, proving the runner-side
no-replay subscription is registered.
"""
runner_id: str
task: asyncio.Task[None]
ready: asyncio.Event
# Background SSE relays keyed by session_id (one per session).
_runner_relay_tasks: dict[str, _RelayHandle] = {}
async def _poll_request_disconnect(request: Request) -> None:
"""
Resolve once Starlette reports the client closed the connection.
Long-poll routes that park on a verdict (e.g. the Claude-native
``PermissionRequest`` hook) use this to detect that the upstream
client has hung up — Claude closes its HTTP request when its
TUI prompt receives an answer first, and without this wait the
handler would sit out the full timeout to notice.
Blocks on ``request.receive()`` rather than polling
``request.is_disconnected()``. The poll variant runs each check
inside a pre-cancelled anyio ``CancelScope`` (Starlette's
non-blocking receive idiom); an external ``Task.cancel()`` that
lands while that scope is unwinding coalesces with the scope's own
cancellation and is swallowed with it, so the poller survives its
cancel and the caller's race cleanup blocks on it forever.
A blocking receive has no cancel scope in its await chain, so
cancellation always propagates; it is also cheaper than waking
twice a second.
:param request: The active FastAPI :class:`Request`. By the time
the handler parks, the route has consumed the body, so the
next receive yields only ``http.disconnect``.
:returns: None when the disconnect is observed. Cancellation
propagates: callers that race this against a verdict Future
cancel the wait once the verdict arrives.
"""
while True:
message = await request.receive()
if message["type"] == "http.disconnect":
return
def _attachment_disposition(filename: str) -> str:
"""Build a safe ``Content-Disposition: attachment`` header value.
The filename is user-controlled, so it cannot be interpolated
into the header verbatim — a quote or newline would let the
uploader inject header content or break parsing. We emit an
ASCII-only ``filename`` fallback (with quotes/backslashes/control
characters stripped) plus an RFC 5987 ``filename*`` parameter that
percent-encodes the full UTF-8 name for modern browsers.
:param filename: The stored, user-supplied filename.
:returns: A ``Content-Disposition`` header value forcing download.
"""
# ASCII fallback: drop anything outside printable ASCII and the
# characters that are structurally significant in the header.
ascii_name = "".join(ch for ch in filename if 0x20 <= ord(ch) < 0x7F and ch not in '"\\')
if not ascii_name:
ascii_name = "download"
encoded = urllib.parse.quote(filename, safe="")
return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{encoded}"
def _stored_file_to_resource(
session_id: str,
stored: StoredFile,
) -> dict[str, Any]:
"""Convert a :class:`StoredFile` to a session file resource dict.
Matches the ``session.resource`` shape with ``type: "file"``
used by the unified inventory and the session-scoped file
endpoints.
:param session_id: Owning session/conversation id.
:param stored: The stored file entity.
:returns: JSON-serializable resource dict.
"""
return {
"id": stored.id,
"object": "session.resource",
"type": "file",
"session_id": session_id,
"name": stored.filename,
"metadata": {
"filename": stored.filename,
"bytes": stored.bytes,
"created_at": stored.created_at,
},
}
def _publish_and_persist_resource_event(
session_id: str,
event_type: str,
resource_id: str,
resource_type: str,
conversation_store: ConversationStore,
resource: dict[str, Any] | None = None,
) -> None:
"""Publish an SSE event and persist it as a conversation item.
Emits the event on the live session stream so connected
clients see it immediately, and appends a ``resource_event``
conversation item so reconnecting clients discover it in the
snapshot.
:param session_id: Session/conversation identifier.
:param event_type: SSE event type, e.g.
``"session.resource.created"``.
:param resource_id: Opaque id of the affected resource.
:param resource_type: Kind of resource, e.g. ``"terminal"``.
:param conversation_store: Store for persisting the item.
:param resource: Full resource dict for created events.
"""
from omnigent.entities.conversation import ResourceEventData
sse_payload: dict[str, Any] = {"type": event_type}
if event_type == "session.resource.created":
sse_payload["resource"] = resource or {}
else:
sse_payload["resource_id"] = resource_id
sse_payload["resource_type"] = resource_type
sse_payload["session_id"] = session_id
session_stream.publish(session_id, sse_payload)
item = NewConversationItem(
type="resource_event",
response_id=session_id,
data=ResourceEventData(
event_type=event_type,
resource_id=resource_id,
resource_type=resource_type,
resource=resource,
),
)
try:
conversation_store.append(session_id, [item])
except (AttributeError, TypeError, ValueError, RuntimeError):
_logger.debug(
"Failed to persist resource event for session=%s",
session_id,
exc_info=True,
)
def _structured_ask_user_question(
tool_input: Any,
) -> dict[str, Any] | None:
"""
Build a structured AskUserQuestion payload for the elicitation
params extras.
Claude's PermissionRequest payload includes the full tool_input
when the gated tool is AskUserQuestion. Rather than relying on
the (truncated) ``content_preview`` JSON-string, we extract the
questions + options here and ship them as a typed structure the
UI consumes directly.
The returned shape is the same one the UI's
:file:`@/lib/askUserQuestion.ts` produces from its preview
parser — so the front-end can treat both sources uniformly.
:param tool_input: The ``tool_input`` field from the
PermissionRequest payload.
:returns: ``{"questions": [...]}`` on success, or ``None`` when
the input doesn't carry a usable AskUserQuestion shape (no
questions, malformed options, etc.) — caller falls back to
the binary preview-only render.
"""
if not isinstance(tool_input, dict):
return None
questions_raw = tool_input.get("questions")
if not isinstance(questions_raw, list) or not questions_raw:
return None
questions: list[dict[str, Any]] = []
for entry in questions_raw:
if not isinstance(entry, dict):
continue
question_text = entry.get("question")
if not isinstance(question_text, str) or not question_text:
continue
options_raw = entry.get("options")
if not isinstance(options_raw, list):
continue
options: list[dict[str, Any]] = []
for opt in options_raw:
if isinstance(opt, dict):
label = opt.get("label")
if not isinstance(label, str) or not label:
continue
option: dict[str, Any] = {"label": label}
description = opt.get("description")
if isinstance(description, str) and description:
option["description"] = description
# ``preview`` is an optional richer snippet some
# Claude builds attach to an option (rendered as a
# <pre> below the option list when selected). Ride
# it through verbatim so the UI can surface it.
preview = opt.get("preview")
if isinstance(preview, str) and preview:
option["preview"] = preview
options.append(option)
elif isinstance(opt, str) and opt:
options.append({"label": opt})
if not options:
continue
question: dict[str, Any] = {
"question": question_text,
"options": options,
"multiSelect": entry.get("multiSelect") is True,
}
header = entry.get("header")
if isinstance(header, str) and header:
question["header"] = header
questions.append(question)
if not questions:
return None
return {"questions": questions}
async def _publish_and_wait_for_harness_elicitation(
request: Request,
*,
session_id: str,
params: ElicitationRequestParams,
timeout_s: float,
conversation_store: ConversationStore | None = None,
elicitation_id: str | None = None,
tool_name: str | None = None,
tool_input: dict[str, Any] | None = None,
) -> ElicitationResult | None:
"""
Publish one harness-originated elicitation and wait for web verdict.
Mirrors the ``omnigent claude`` permission hook contract: the
hook parks a server-side Future, publishes the standard
``response.elicitation_request`` event, waits until the session
``approval`` event resolves the Future, and always publishes
``response.elicitation_resolved`` when the upstream wait ends.
The wait ends on the first of three signals: (1) the web verdict
Future (session ``approval`` event); (2) the terminal-resolved
Event, set when a mirrored tool result for this gated tool proves
the prompt was answered in the native TUI (see
:func:`_signal_terminal_resolved_harness_elicitation`); or (3)
upstream disconnect / ``timeout_s``. Only (1) yields a verdict;
(2) and (3) return ``None`` (fail-ask). (1) and (2) publish
``response.elicitation_resolved`` immediately; (3) defers it by
``_HARNESS_ELICITATION_REPARK_GRACE_S`` and skips it when the
caller re-parks the same ``elicitation_id`` (hook retries after a
severed long-poll reuse their id), so a still-blocked prompt's
card survives the gap. A caller-supplied id likewise re-attaches
to a verdict that landed during a gap via the pre-resolved
tombstone, returned at registration time without re-publishing.
:param request: FastAPI request object so upstream disconnect can
be detected.
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
:param params: Elicitation params to publish.
:param timeout_s: Maximum wait in seconds, e.g. ``300.0``.
:param conversation_store: Optional store used to mirror
child-session prompts into ancestor streams. ``None`` keeps
the prompt scoped to ``session_id`` only.
:param elicitation_id: Optional precomputed correlation id, e.g.
``"elicit_codex_abc123"``. ``None`` mints a random id.
:param tool_name: Gated tool name, e.g. ``"Bash"``, used to
correlate a mirrored tool result back to this prompt for the
terminal-resolved fast path. ``None`` (e.g. Codex) disables
that correlation; the prompt still resolves via web verdict,
disconnect, or timeout.
:param tool_input: Gated tool input, e.g. ``{"command": "ls"}``,
used with ``tool_name`` to disambiguate the result when several
same-named prompts are parked at once.
:returns: Web verdict, or ``None`` on terminal-side resolution,
timeout, or disconnect.
"""
if elicitation_id is None:
elicitation_id = f"elicit_{secrets.token_hex(16)}"
future: asyncio.Future[ElicitationResult] = asyncio.get_running_loop().create_future()
# ``resolved_elsewhere`` is set when a native-side signal proves the
# prompt was answered outside the web UI: either a mirrored tool
# result for this gated tool, or Codex app-server's exact
# ``serverRequest/resolved`` notification. Raced below so the wait
# ends promptly without relying on the web verdict or on disconnect
# detection (unreliable behind the Databricks Apps proxy).
parked = _ParkedHarnessElicitation(
session_id=session_id,
tool_name=tool_name,
tool_input=tool_input,
resolved_elsewhere=asyncio.Event(),
)
_harness_elicitation_registry[elicitation_id] = future
_harness_elicitation_owners[elicitation_id] = session_id
_harness_parked_elicitations[elicitation_id] = parked
# settled = verdict / terminal-resolved (clear the card now); a
# severed wait instead defers the clear so a hook retry can re-park.
published_request = False
settled = False
try:
tombstone = _consume_pre_resolved_harness_elicitation(session_id, elicitation_id)
if tombstone is not None:
# Verdict from the un-parked gap; None = terminal answered (fail-ask).
return tombstone.result
event = ElicitationRequestEvent(
type="response.elicitation_request",
elicitation_id=elicitation_id,
params=params,
)
event_payload = event.model_dump()
session_stream.publish(session_id, event_payload)
published_request = True
if conversation_store is not None:
await asyncio.to_thread(
_publish_elicitation_request_to_ancestors,
conversation_store,
session_id,
event_payload,
)
disconnect_task = asyncio.create_task(
_poll_request_disconnect(request),
)
resolved_elsewhere_task = asyncio.create_task(parked.resolved_elsewhere.wait())
race_tasks = (disconnect_task, resolved_elsewhere_task)
try:
done, _pending = await asyncio.wait(
{future, *race_tasks},
timeout=timeout_s,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
for race_task in race_tasks:
if not race_task.done():
race_task.cancel()
# Bounded: a cancellation swallowed inside the race
# task (e.g. coalesced into an anyio cancel-scope
# unwind) must not convert this cleanup into
# an unbounded wait — that wedged the whole request
# for the gate's timeout. ``asyncio.wait`` absorbs
# the CancelledError outcome; an unreaped task is
# logged and abandoned to die with the request.
_reaped, still_pending = await asyncio.wait(
{race_task},
timeout=_RACE_TASK_REAP_TIMEOUT_S,
)
if still_pending:
_logger.warning(
"Race task %r for elicitation %s survived its "
"cancellation (swallowed cancel); abandoning it.",
race_task.get_coro(),
elicitation_id,
)
# Only an actual web verdict yields a result; a terminal-side
# resolution, disconnect, or timeout returns None (fail-ask).
# Checking ``future in done`` (not ``future.done()``) avoids
# honoring a verdict that lands in the same tick as a disconnect.
if future in done and future.exception() is None:
settled = True
return future.result()
settled = parked.resolved_elsewhere.is_set()
return None
finally:
# Pop only our own entries — a hook retry may have re-parked
# this id with a new future while this wait was unwinding.
if _harness_elicitation_registry.get(elicitation_id) is future:
_harness_elicitation_registry.pop(elicitation_id, None)
_harness_elicitation_owners.pop(elicitation_id, None)
if _harness_parked_elicitations.get(elicitation_id) is parked:
_harness_parked_elicitations.pop(elicitation_id, None)
if published_request and not settled:
# Severed without an answer — defer the clear (scheduled
# before any await so handler cancellation can't skip it).
_schedule_deferred_elicitation_clear(
session_id,
elicitation_id,
conversation_store,
)
elif published_request:
_publish_elicitation_resolved(session_id, elicitation_id)
if conversation_store is not None:
await asyncio.to_thread(
_publish_elicitation_resolved_to_ancestors,
conversation_store,
session_id,
elicitation_id,
)
def _canonical_tool_input(tool_input: dict[str, Any] | None) -> dict[str, Any]:
"""
Canonicalize a tool input for terminal-resolved correlation.
The park side records an absent / non-dict input as ``None`` (a
permission prompt whose hook payload carries no ``tool_input`` — see
the ``_publish_and_wait_for_harness_elicitation`` call sites), while
the mirror side normalizes the parsed transcript arguments to ``{}``
(see :func:`_drive_terminal_resolved_elicitation`). Both mean "no
input", so collapse them to ``{}`` before comparing — otherwise a
no-input prompt would never match its own mirrored result (``None ==
{}`` is ``False``) and, with no count-based fallback, would orphan
until the hook timeout.
:param tool_input: Parked or mirrored tool input, e.g.
``{"command": "ls"}``, ``{}``, or ``None``.
:returns: The dict unchanged, or ``{}`` when it is ``None``.
"""
return tool_input if isinstance(tool_input, dict) else {}
def _signal_terminal_resolved_harness_elicitation(
session_id: str,
tool_name: str,
tool_input: dict[str, Any] | None,
) -> None:
"""
Resolve the parked prompt a mirrored tool result belongs to,
ending its long-poll promptly.
Called when the transcript forwarder mirrors a tool result
(``function_call_output``) for a native session. A tool result is
only written AFTER the user answered that tool's permission prompt
in the native terminal — on accept the tool ran and produced output,
on reject the harness records a rejection result — so its arrival is
a reliable "the terminal already resolved this" signal.
Correlation is by exact tool identity, never positional: a result
resolves a parked prompt only when it has the SAME ``tool_name`` AND
the SAME ``tool_input`` in the same session. Claude Code's
``PermissionRequest`` payload carries no ``tool_use_id`` (the id is
minted only when the tool call is emitted, after the permission
check), so ``(tool_name, tool_input)`` is the only correlation signal
available — and both sides are unmodified JSON round-trips of the
same input, so exact equality holds whenever they describe the same
call (absent input and empty input both canonicalize to ``{}`` via
:func:`_canonical_tool_input`, since the park and mirror sides spell
"no input" differently — ``None`` vs ``{}``). A non-matching or
ambiguous result resolves nothing; the web verdict or timeout still
applies. Exact-only matching is what stops
one prompt's result from clearing a different prompt: approving
``Bash{ls}`` in the web UI un-parks it, and mirroring its own output
must not then clear a still-pending ``Bash{pwd}`` sibling (an
unrelated auto-allowed same-named tool's output is harmless for the
same reason).
Best-effort and idempotent: a no-op when no parked prompt matches
(e.g. the web UI already resolved it, the tool needed no permission,
or it is an unrelated tool). Harness-agnostic by construction —
keyed on the parked prompt's tool identity, not on a claude-native
check — so a Codex hook that records ``tool_name`` benefits too.
:param session_id: Omnigent conversation id whose forwarder mirrored the
result, e.g. ``"conv_abc123"``.
:param tool_name: Tool name the result is for, e.g. ``"Bash"``.
:param tool_input: Tool input the result is for, e.g.
``{"command": "ls"}``, or ``None`` if unavailable.
"""
candidates = [
parked
for parked in _harness_parked_elicitations.values()
if parked.session_id == session_id
and parked.tool_name == tool_name
and not parked.resolved_elsewhere.is_set()
]
if not candidates:
return
mirrored_input = _canonical_tool_input(tool_input)
for parked in candidates:
if _canonical_tool_input(parked.tool_input) == mirrored_input:
parked.resolved_elsewhere.set()
return
# No exact input match. Correlation is exact-only: resolving a
# same-named-but-different-input prompt here would clear the wrong
# card, so leave every candidate to its own result / web verdict /
# timeout. This branch is reached routinely and benignly — e.g. after
# a sibling prompt was web-approved and un-parked, its mirrored output
# finds only the still-pending different-input prompt — so it logs at
# debug, not warning. (A genuine match failing to compare equal would
# also land here, but is indistinguishable from the benign case inside
# this call; both inputs are unmodified JSON round-trips, so such drift
# is not expected.)
_logger.debug(
"Mirrored %s result in %s matched no parked prompt by input "
"(%d same-named prompt(s) pending); leaving them to web verdict/timeout.",
tool_name,
session_id,
len(candidates),
)
# Strong refs so deferred card-clear tasks aren't GC'd mid-sleep.
_deferred_elicitation_clear_tasks: set[asyncio.Task[None]] = set()
def _schedule_deferred_elicitation_clear(
session_id: str,
elicitation_id: str,
conversation_store: ConversationStore | None,
) -> None:
"""
Clear one elicitation's approval card after the re-park grace, unless
a hook retry re-parks the id first.
A wait severed without an answer (proxy cut, timeout) may still be
blocked in the native terminal; clearing immediately wiped the only
surface a headless sub-agent's user can answer from. A hook that
died for real never re-parks, so the clear still fires after the
grace and badges don't stick.
:param session_id: Session that owns the elicitation, e.g.
``"conv_abc123"``.
:param elicitation_id: Correlation id whose card may need clearing,
e.g. ``"elicit_claude_0f3a..."``.
:param conversation_store: Store used to mirror the clear into
ancestor streams, or ``None`` to keep it session-local.
"""
async def _clear_after_grace() -> None:
"""
Sleep out the grace, then publish the clear unless re-parked.
:returns: None.
"""
await asyncio.sleep(_HARNESS_ELICITATION_REPARK_GRACE_S)
if elicitation_id in _harness_elicitation_registry:
# Re-parked — the new wait owns the eventual clear.
return
_publish_elicitation_resolved(session_id, elicitation_id)
if conversation_store is not None:
await asyncio.to_thread(
_publish_elicitation_resolved_to_ancestors,
conversation_store,
session_id,
elicitation_id,
)
task = asyncio.create_task(_clear_after_grace())
_deferred_elicitation_clear_tasks.add(task)
task.add_done_callback(_deferred_elicitation_clear_tasks.discard)
def _client_supplied_hook_elicitation_id(
payload: dict[str, Any],
session_id: str,
) -> str | None:
"""
Validate the hook client's optional re-attach elicitation id.
The hook mints one stable id per prompt and re-sends it on every
retry POST, so a severed wait re-parks as the SAME elicitation.
Client-controlled, so it is constrained to the claude-hook
namespace and may not collide with another session's parked id.
:param payload: Parsed PermissionRequest hook body. Reads the
optional ``_omnigent_elicitation_id`` key.
:param session_id: Session the hook call is for, e.g.
``"conv_abc123"``.
:returns: The validated id, or ``None`` when the client supplied
none (the wait mints a random id as before).
:raises OmnigentError: 400 when the id is malformed or is
currently parked by a different session.
"""
raw = payload.get("_omnigent_elicitation_id")
if raw is None:
return None
if not isinstance(raw, str) or not _HOOK_ELICITATION_ID_RE.fullmatch(raw):
raise OmnigentError(
"PermissionRequest hook '_omnigent_elicitation_id' must match "
"'elicit_<harness>_' + 32 hex chars.",
code=ErrorCode.INVALID_INPUT,
)
owner = _harness_elicitation_owners.get(raw)
if owner is not None and owner != session_id:
raise OmnigentError(
"Elicitation id belongs to a different session.",
code=ErrorCode.INVALID_INPUT,
)
return raw
def _consume_pre_resolved_harness_elicitation(
session_id: str,
elicitation_id: str,
) -> _PreResolvedHarnessElicitation | None:
"""
Consume a resolution that arrived before the hook wait registered.
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
:param elicitation_id: Harness elicitation id, e.g.
``"elicit_codex_abc123"``.
:returns: The consumed tombstone when one matched this session
(its ``result`` carries the web verdict to honor, or ``None``
for a terminal-side resolution), or ``None`` when nothing was
pre-resolved.
"""
_prune_pre_resolved_harness_elicitations()
tombstone = _harness_pre_resolved_elicitations.pop(elicitation_id, None)
if tombstone is None:
return None
if tombstone.session_id == session_id:
return tombstone
_harness_pre_resolved_elicitations[elicitation_id] = tombstone
return None
def _prune_pre_resolved_harness_elicitations(now: float | None = None) -> None:
"""
Prune stale or excess pre-resolved harness elicitation tombstones.
:param now: Optional wall-clock timestamp from ``time.time()``,
e.g. ``1710000000.0``. ``None`` reads the current time.
:returns: None.
"""
if not _harness_pre_resolved_elicitations:
return
now = time.time() if now is None else now
expired = [
elicitation_id
for elicitation_id, tombstone in _harness_pre_resolved_elicitations.items()
if now - tombstone.created_at > _HARNESS_PRE_RESOLVED_ELICITATION_TTL_S
]
for elicitation_id in expired:
_harness_pre_resolved_elicitations.pop(elicitation_id, None)
overflow = (
len(_harness_pre_resolved_elicitations) - _HARNESS_PRE_RESOLVED_ELICITATION_MAX_ENTRIES
)
if overflow <= 0:
return
oldest = sorted(
_harness_pre_resolved_elicitations.items(),
key=lambda item: item[1].created_at,
)[:overflow]
for elicitation_id, _tombstone in oldest:
_harness_pre_resolved_elicitations.pop(elicitation_id, None)
def _signal_harness_elicitation_resolved_by_id(
session_id: str,
elicitation_id: str,
) -> None:
"""
Resolve or pre-resolve one parked harness elicitation by id.
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
:param elicitation_id: Harness elicitation id, e.g.
``"elicit_codex_abc123"``.
:returns: None.
:raises OmnigentError: If the id is malformed or belongs to a
different session.
"""
if not elicitation_id:
raise OmnigentError(
"external_elicitation_resolved requires data.elicitation_id.",
code=ErrorCode.INVALID_INPUT,
)
owner = _harness_elicitation_owners.get(elicitation_id)
if owner is not None and owner != session_id:
raise OmnigentError(
"Elicitation does not belong to this session.",
code=ErrorCode.INVALID_INPUT,
)
_prune_pre_resolved_harness_elicitations()
parked = _harness_parked_elicitations.get(elicitation_id)
if parked is None:
_harness_pre_resolved_elicitations[elicitation_id] = _PreResolvedHarnessElicitation(
session_id=session_id,
created_at=time.time(),
)
_prune_pre_resolved_harness_elicitations()
return
parked.resolved_elsewhere.set()
def _format_sse(event_type: str, data: dict[str, Any]) -> str:
"""
Format an SSE event string for the wire.
:param event_type: SSE event name, e.g.
``"response.output_text.delta"``.
:param data: The event payload dict.
:returns: A formatted SSE message string ending in two newlines.
"""
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
def _permission_level_from_grants(
user_id: str | None,
grants: list[SessionPermission],
is_admin: bool,
) -> int | None:
"""
Derive a user's permission level from a pre-fetched list of grants.
Mirrors :func:`omnigent.server.routes._auth_helpers._get_permission_level_sync`
but operates on grants already held in memory so callers can batch the
permission-store query across many sessions at once.
:param user_id: The authenticated user, or ``None`` for unauthenticated
requests, e.g. ``"alice@example.com"``.
:param grants: All grants for the session, as returned by
``permission_store.list_for_sessions()[conv_id]``.
:param is_admin: Whether the user holds the admin flag. Pass the result
of a single ``permission_store.is_admin(user_id)`` call made once
for the whole page rather than repeating it per session.
:returns: Numeric level (1–4), or ``None`` when permissions are disabled
or the user is unauthenticated.
"""
if user_id is None:
return None
if is_admin:
return LEVEL_OWNER
user_grant = next((g for g in grants if g.user_id == user_id), None)
if user_grant is not None:
return user_grant.level
public_grant = next((g for g in grants if g.user_id == RESERVED_USER_PUBLIC), None)
if public_grant is not None:
return public_grant.level
return None
def _owner_from_grants(grants: list[SessionPermission]) -> str | None:
"""
Find the session owner from a pre-fetched list of grants.
Mirrors :func:`omnigent.server.routes._auth_helpers.get_session_owner_id`
but operates on grants already held in memory so callers can batch the
permission-store query across many sessions at once.
:param grants: All grants for the session, as returned by
``permission_store.list_for_sessions()[conv_id]``.
:returns: The ``user_id`` of the first grant whose level is at least
:data:`LEVEL_OWNER`, or ``None`` if no such grant exists.
"""
return next((g.user_id for g in grants if g.level >= LEVEL_OWNER), None)
def _session_status_from_cache(conversation_id: str) -> Literal["idle", "running", "failed"]:
"""
Map the relay-fed status cache value to a list-item status.
The cache stores the fine-grained relay status (``"running"``,
``"waiting"``, ``"failed"``, ``"idle"``); the list-item shape
collapses ``"running"``/``"waiting"`` to ``"running"``. A cache
miss means no relay has reported on this session, which presents
as ``"idle"``.
:param conversation_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:returns: One of ``"idle"``, ``"running"``, ``"failed"``.
"""
cached = _session_status_cache.get(conversation_id)
if cached in ("running", "waiting"):
return "running"
if cached == "failed":
return "failed"
return "idle"
def _session_status_with_child_rollup(
conversation_id: str,
child_session_ids: list[str],
) -> Literal["idle", "running", "failed"]:
"""
Map a session's cached status plus direct child activity to list status.
A parent session should read as ``"running"`` in the sidebar while any
direct sub-agent child is still ``"running"`` or ``"waiting"``, even if
the parent runner has already gone idle. This keeps every sidebar row
honest without mounting a child-session query for each row.
:param conversation_id: Parent session/conversation identifier,
e.g. ``"conv_parent123"``.
:param child_session_ids: Direct sub-agent child conversation ids,
e.g. ``["conv_child1", "conv_child2"]``.
:returns: One of ``"idle"``, ``"running"``, ``"failed"`` for the
session-list row.
"""
own_status = _session_status_from_cache(conversation_id)
if own_status == "running":
return "running"
# A claude-native session can settle to ``idle`` while background shells
# keep running; the sticky tally keeps the sidebar spinner lit, matching
# the in-chat "N background tasks still running" indicator. (``failed``
# clears the tally, so this never masks a failure.)
if own_status != "failed" and _session_background_task_count_cache.get(conversation_id, 0) > 0:
return "running"
if any(
_session_status_cache.get(child_id) in ("running", "waiting")
for child_id in child_session_ids
):
return "running"
return own_status
async def _best_effort_stop(
session_id: str,
conversation_store: ConversationStore,
runner_router: Any,
) -> None:
"""Stop a running session before a destructive lifecycle action.
Mirrors the client-side stop-then-archive/delete pattern: if the
session (or any direct sub-agent child) is still running, attempt to
stop it via the runner. Failures are swallowed so the caller can
always proceed — the session must remain deletable/archivable even
when the runner is offline or unreachable.
:param session_id: Session/conversation identifier.
:param conversation_store: Store for child-id lookup.
:param runner_router: The ``RunnerRouter`` for runner-client
resolution, or ``None`` in tests / in-process setups.
"""
try:
child_ids_map = await asyncio.to_thread(
conversation_store.list_child_conversation_ids_by_parent,
[session_id],
)
child_ids = child_ids_map.get(session_id, [])
status = _session_status_with_child_rollup(session_id, child_ids)
if status != "running":
return
await _stop_session_via_runner(session_id, runner_router)
except Exception: # noqa: BLE001 — best-effort; must not block archive/delete
_logger.debug(
"Best-effort stop failed for %s; proceeding anyway",
session_id,
exc_info=True,
)
@dataclass(frozen=True)
class SessionLiveness:
"""
The two honest liveness signals for a single session.
Returned (keyed by session id) by the server's
``_bulk_session_liveness`` / ``_session_liveness`` lookups and
consumed by the list-item builder, the ``WS /v1/sessions/updates``
stream, the single-session ``SessionResponse`` snapshot, and
``GET /health``. Splitting the old single conflated boolean into
two fields lets the open-session view distinguish "runner stopped
but host can relaunch — just send a message" from "host offline —
reconnect / fork".
:param runner_online: Strict runner reachability — ``True`` iff a
runner tunnel is currently registered for this session. This
is the sole reachability signal: it does **not** fold in
host-relaunch optimism (a dead runner on a live host reads
``False`` here, not ``True``). A session with no runner
binding (in-process executor / not yet dispatched) reads
``True``.
:param host_online: Whether the session's host tunnel is live
(status online and fresh within ``HOST_LIVENESS_TTL_S``).
``True`` when the session's ``host_id`` is in the online-hosts
set, ``False`` when a ``host_id`` is set but not online, and
``None`` when the session has no ``host_id`` (CLI / local).
Used only to choose what the open view shows when
``runner_online`` is ``False``; never participates in the
reachability decision.
:param host_version: Version string from the bound host's
``host.hello`` frame, e.g. ``"0.1.0"`` — surfaced in the
session info popover. ``None`` when the session has no host
binding, the host is offline, or its version isn't resolvable
on this replica (the version lives in the in-memory host
registry, not the hosts table, so a host connected to another
replica reads ``None`` here).
"""
runner_online: bool
host_online: bool | None
host_version: str | None = None
def _build_session_list_item(
conv: Conversation,
*,
agent_names_by_id: dict[str, str | None],
grants: list[SessionPermission],
user_id: str | None,
user_is_admin: bool,
permissions_enabled: bool,
pending_count: int,
child_session_ids: list[str],
comments_fingerprint: CommentsFingerprint | None,
) -> SessionListItem:
"""
Assemble one :class:`SessionListItem` from a conversation row and
pre-fetched batch data.
Single source of truth for the list-item shape, shared by the
``GET /v1/sessions`` page builder and the ``WS /v1/sessions/updates``
push stream so the two never drift. The caller is responsible for
batching the permission grants, agent names, and pending-elicitation
counts across the whole set and passing the per-conversation slice
here.
:param conv: The persisted conversation entity. Must have a
non-``None`` ``agent_id`` (i.e. be a session, not a plain
conversation) — the caller filters these out beforehand.
:param agent_names_by_id: Map from agent id to display name, as
returned by ``agent_store.get_names()``,
e.g. ``{"ag_abc": "research-agent"}``.
:param grants: All permission grants for this conversation, as
returned by ``permission_store.list_for_sessions()[conv.id]``.
Empty list when permissions are disabled.
:param user_id: The authenticated requesting user, or ``None`` when
unauthenticated / permissions disabled,
e.g. ``"alice@example.com"``.
:param user_is_admin: Whether ``user_id`` holds the admin flag, from
a single ``permission_store.is_admin()`` call made once for the
whole batch.
:param permissions_enabled: ``True`` when a permission store is
wired; gates owner/level population to mirror ``list_sessions``.
:param pending_count: Number of outstanding elicitations for this
conversation, from ``pending_elicitations.counts_for()``.
:param child_session_ids: Direct sub-agent children for this
conversation, as returned by
``conversation_store.list_child_conversation_ids_by_parent()``.
:param comments_fingerprint: Change-detection summary of this
conversation's review comments, from
``comment_store.get_comments_fingerprints()[conv.id]``. ``None``
when the conversation has no comments or no comment store is
wired — emitted as ``comments_count=0`` /
``comments_updated_at=None`` so the two states look identical
on the wire.
:returns: The assembled :class:`SessionListItem`.
"""
# ``conv.agent_id`` is guaranteed non-None by the caller (sessions
# only); assert for the type checker without a runtime branch.
assert conv.agent_id is not None
level = _permission_level_from_grants(user_id, grants, user_is_admin)
owner = _owner_from_grants(grants) if permissions_enabled else None
# Per-viewer read tracking, embedded so the client hydrates the unread
# dots straight from the list (no separate fetch). Built per-user here —
# `user_id` is the requesting caller, never broadcast to other viewers.
viewer_last_seen, viewer_unread = _read_state_entry(user_id, conv.id)
return SessionListItem(
id=conv.id,
agent_id=conv.agent_id,
agent_name=agent_names_by_id.get(conv.agent_id),
status=_session_status_with_child_rollup(conv.id, child_session_ids),
created_at=conv.created_at,
updated_at=conv.updated_at,
title=title_without_closed_marker(conv.title),
labels=labels_with_closed_status(conv.labels, conv.title),
runner_id=conv.runner_id,
host_id=conv.host_id,
reasoning_effort=conv.reasoning_effort,
permission_level=level,
owner=owner,
external_session_id=conv.external_session_id,
pending_elicitations_count=pending_count,
workspace=conv.workspace,
git_branch=conv.git_branch,
archived=conv.archived,
comments_count=comments_fingerprint.count if comments_fingerprint else 0,
comments_updated_at=(
comments_fingerprint.last_updated_at if comments_fingerprint else None
),
viewer_last_seen=viewer_last_seen,
viewer_unread=viewer_unread,
# Transient; set by the store only on a content search. The WS
# push-stream path leaves it None (no query in flight there).
search_snippet=conv.search_snippet,
)
async def _apply_liveness_to_items(
items: list[SessionListItem],
liveness_lookup: Callable[[list[str]], dict[str, SessionLiveness]] | None,
) -> None:
"""
Attach runner + host liveness to session-list items when a lookup is
wired.
Both ``GET /v1/sessions`` and ``WS /v1/sessions/updates`` use this so
HTTP reconciliation preserves the same ``runner_online`` /
``host_online`` fields that push frames patch into the web cache.
:param items: Session-list rows to annotate.
:param liveness_lookup: Bulk liveness lookup from session id to a
:class:`SessionLiveness` pair, e.g.
``{"conv_abc123": SessionLiveness(runner_online=True,
host_online=None)}``. ``None`` means this server cannot compute
liveness for list rows, in which case both fields are left
``None``.
:returns: ``None``. Mutates ``items`` in place.
"""
if liveness_lookup is None or not items:
return
liveness = await asyncio.to_thread(liveness_lookup, [item.id for item in items])
for item in items:
result = liveness[item.id]
item.runner_online = result.runner_online
item.host_online = result.host_online
def _targeted_elicitation_event(
event: dict[str, Any],
*,
target_session_id: str,
) -> dict[str, Any]:
"""
Return an elicitation event annotated with its resolution target.
Child-session elicitations can be mirrored into an ancestor's
chat stream. The mirrored card is rendered in the ancestor
conversation, but the harness Future still belongs to the child.
``target_session_id`` tells clients which session's resolve URL
should receive the verdict.
:param event: Original ``response.elicitation_request`` event,
e.g. ``{"type": "response.elicitation_request",
"elicitation_id": "elicit_abc", "params": {...}}``.
:param target_session_id: Session that owns the parked
elicitation, e.g. ``"conv_child123"``.
:returns: A shallow event copy with a copied ``params`` dict
carrying ``target_session_id``.
"""
mirrored = dict(event)
params = event.get("params")
if isinstance(params, dict):
mirrored["params"] = {**params, "target_session_id": target_session_id}
else:
mirrored["params"] = {"target_session_id": target_session_id}
return mirrored
def _ancestor_session_ids(
conv_store: ConversationStore,
session_id: str,
) -> list[str]:
"""
Return ancestor session ids for a session, nearest parent first.
:param conv_store: Store used to read conversation parent links.
:param session_id: Session to walk upward from, e.g.
``"conv_child123"``.
:returns: Ancestor ids in parent-to-root order. Empty when the
session is top-level or missing.
"""
ancestors: list[str] = []
seen = {session_id}
current = conv_store.get_conversation(session_id)
while current is not None and current.parent_conversation_id is not None:
parent_id = current.parent_conversation_id
if parent_id in seen:
break
ancestors.append(parent_id)
seen.add(parent_id)
current = conv_store.get_conversation(parent_id)
return ancestors
def _publish_elicitation_request_to_ancestors(
conv_store: ConversationStore,
session_id: str,
event: dict[str, Any],
) -> None:
"""
Mirror a child elicitation request into each ancestor stream.
:param conv_store: Store used to discover ancestor sessions.
:param session_id: Child session that owns the elicitation,
e.g. ``"conv_child123"``.
:param event: Original ``response.elicitation_request`` event.
"""
mirrored = _targeted_elicitation_event(event, target_session_id=session_id)
for ancestor_id in _ancestor_session_ids(conv_store, session_id):
session_stream.publish(ancestor_id, mirrored)
def _publish_elicitation_resolved_to_ancestors(
conv_store: ConversationStore,
session_id: str,
elicitation_id: str,
) -> None:
"""
Mirror an elicitation-resolved event into each ancestor stream.
:param conv_store: Store used to discover ancestor sessions.
:param session_id: Child session that owns the elicitation,
e.g. ``"conv_child123"``.
:param elicitation_id: Elicitation correlation id, e.g.
``"elicit_abc123"``.
"""
for ancestor_id in _ancestor_session_ids(conv_store, session_id):
_publish_elicitation_resolved(ancestor_id, elicitation_id)
def _publish_subtree_cost_to_ancestors(
conv_store: ConversationStore,
session_id: str,
) -> None:
"""
Re-publish each ancestor's subtree-summed cost after a child usage update.
A sub-agent's spend is persisted on its own child conversation, so an
ancestor's stored ``session_usage`` doesn't move when the child spends —
yet the ancestor's displayed "Session cost" reads its own number, so a
parent's badge would never reflect a running sub-agent. (The policy gate
already reads the subtree sum via :func:`load_session_usage`; this is the
display side.) For each ancestor of *session_id*, recompute its subtree
priced cost and publish a ``session.usage`` event carrying it.
Sync (does store reads + SSE fan-out); call via
:func:`asyncio.to_thread`, mirroring the elicitation ancestor-publish
helpers. ``session_stream.publish`` is safe to call from a worker thread.
:param conv_store: Store used to discover ancestors and sum each
ancestor's subtree usage.
:param session_id: The child session whose usage just changed, e.g.
``"conv_child123"``.
:returns: None.
"""
for ancestor_id in _ancestor_session_ids(conv_store, session_id):
ancestor_usage = load_session_usage(ancestor_id, conv_store)
subtree_cost = _priced_cost_for_display(ancestor_usage)
usage_by_model = _usage_by_model_for_display(ancestor_usage)
if subtree_cost is None and usage_by_model is None:
# Ancestor's subtree has no priced cost or token usage yet —
# leave its badge showing "—"/its snapshot value rather than
# emit $0.00.
continue
payload: dict[str, Any] = {
"type": "session.usage",
"conversation_id": ancestor_id,
}
if subtree_cost is not None:
payload["total_cost_usd"] = subtree_cost
if usage_by_model is not None:
payload["usage_by_model"] = usage_by_model
event = SessionUsageEvent(**payload)
session_stream.publish(ancestor_id, event.model_dump(exclude_none=True))
def _descendant_sessions(
conv_store: ConversationStore,
session_id: str,
) -> list[Conversation]:
"""
Return descendant sub-agent conversations for a session.
:param conv_store: Store used to list conversations.
:param session_id: Ancestor session id, e.g. ``"conv_root123"``.
:returns: Sub-agent conversations below ``session_id``. Empty
for sessions with no descendants.
"""
descendants: list[Conversation] = []
queue: deque[str] = deque([session_id])
seen = {session_id}
while queue:
parent_id = queue.popleft()
after: str | None = None
while True:
page = conv_store.list_conversations(
kind="sub_agent",
parent_conversation_id=parent_id,
limit=100,
after=after,
)
for child in page.data:
if child.id in seen:
continue
seen.add(child.id)
descendants.append(child)
queue.append(child.id)
if not page.has_more or page.last_id is None:
break
after = page.last_id
return descendants
def _pending_elicitation_snapshot_for_session(
conv_store: ConversationStore,
conv: Conversation,
) -> list[dict[str, Any]]:
"""
Return pending elicitation events visible from a session snapshot.
The current session's own outstanding prompts are returned first.
Pending prompts from descendant sub-agents are appended with
``params.target_session_id`` so a cold-loaded ancestor chat can
render and resolve child approvals.
Duplicate ids are skipped because live mirroring also records the
ancestor copy in the in-memory index.
The descendant walk costs one ``list_conversations`` query per
session in the tree, so it is skipped entirely unless some session
other than ``conv`` has an outstanding prompt in the in-memory
index (the common case is none anywhere).
:param conv_store: Store used to list descendant sub-agents.
:param conv: Session conversation being snapshotted.
:returns: Pending elicitation event dicts suitable for
:class:`SessionResponse.pending_elicitations`.
"""
events = pending_elicitations.snapshot_for(conv.id)
if not (set(pending_elicitations.pending_session_ids()) - {conv.id}):
return events
seen = {
event.get("elicitation_id")
for event in events
if isinstance(event.get("elicitation_id"), str)
}
for child in _descendant_sessions(conv_store, conv.id):
for event in pending_elicitations.snapshot_for(child.id):
elicitation_id = event.get("elicitation_id")
if isinstance(elicitation_id, str) and elicitation_id in seen:
continue
if isinstance(elicitation_id, str):
seen.add(elicitation_id)
events.append(_targeted_elicitation_event(event, target_session_id=child.id))
return events
def _build_session_response(
conv: Conversation,
items: list[ConversationItem],
status: Literal["idle", "running", "waiting", "failed"],
permission_level: int | None = None,
background_task_count: int | None = None,
llm_model: str | None = None,
context_window: int | None = None,
last_total_tokens: int | None = None,
last_task_error: dict[str, str] | None = None,
agent_name: str | None = None,
skills: list[SkillSummary] | None = None,
runner_online: bool | None = None,
host_online: bool | None = None,
host_resumable: bool = False,
pending_elicitation_events: list[dict[str, Any]] | None = None,
subtree_usage: dict[str, Any] | None = None,
model_options: list[dict[str, Any]] | None = None,
) -> SessionResponse:
"""
Build a :class:`SessionResponse` from store-side entities.
``status`` is derived from the conversation's tasks by the
caller via :func:`_derive_session_lifecycle` — the conversation
row itself owns no lifecycle column.
:param conv: The persisted conversation entity.
:param items: Committed conversation items in chronological
order, each a :class:`ConversationItem`.
:param status: Derived session lifecycle status,
e.g. ``"running"``.
:param background_task_count: Background shells still running as of the
last status edge (claude-native), so a reload re-shows "N shells
still running" even after the session settles to ``"idle"``. ``None``
when none are tracked.
:param permission_level: The requesting user's numeric level
on this session (1=read, 2=edit, 3=manage), or ``None``
when permissions are disabled.
:param runner_online: Session-scoped liveness for the bound
runner/host, e.g. ``False`` for a dead tunneled runner.
``None`` when no lookup is wired.
:param llm_model: The LLM model identifier from the bound
agent's spec, e.g. ``"anthropic/claude-sonnet-4-6"``.
``None`` when not available.
:param context_window: Context window size in tokens looked up
from litellm server-side, e.g. ``200_000``. ``None`` when
the model is not in litellm's registry.
:param last_total_tokens: Total token count (input + output) from
the most recently completed task's usage, e.g. ``45231``.
``None`` when no task has completed yet. Lets clients seed
their context-ring on conversation resume without waiting for
the next ``response.completed`` SSE event.
:param last_task_error: Error dict from the most recently failed
task, e.g. ``{"code": "executor_error", "message": "..."}``.
``None`` when ``status`` is not ``"failed"`` or the task has
no stored error.
:param agent_name: Human-readable agent name, e.g.
``"research-agent"``. ``None`` when the agent row is not
available at snapshot-build time.
:param skills: Merged skill summaries (bundled + host) for
the bound agent. ``None`` is treated as the empty list,
e.g. when the agent spec cannot be loaded.
:param runner_online: Strict runner reachability — ``True`` iff a
runner tunnel is currently registered for this session (see
:class:`SessionLiveness`). ``None`` when the caller has no
liveness lookup wired (e.g. focused tests), in which case the
field is omitted from the API projection.
:param host_online: Whether the session's host tunnel is live, or
``None`` when the session has no ``host_id`` or no lookup is
wired (see :class:`SessionLiveness`). Used only to decide what
the open view shows when ``runner_online`` is ``False``.
:param pending_elicitation_events: Optional precomputed
outstanding elicitation events. ``None`` reads only the
current session's entries from the pending-elicitations index.
:param subtree_usage: Precomputed subtree usage dict (this session
plus its sub-agent descendants, from
:func:`load_session_usage`), used to display a cost that
includes sub-agents, e.g. ``{"total_cost_usd": 11.19}``.
``None`` falls back to this conversation's own ``session_usage``
(correct for childless sessions). Passed by the snapshot path;
other callers omit it.
:param model_options: Raw Codex app-server ``model/list``
options for this session, e.g. ``[{"id": "gpt-5.5"}]``.
``None`` is treated as ``[]``.
:returns: The :class:`SessionResponse` for the API.
:raises OmnigentError: If ``conv.agent_id`` is ``None``.
"""
if conv.agent_id is None:
raise OmnigentError(
"Session has no agent binding",
code=ErrorCode.INTERNAL_ERROR,
)
# Usage to display for this node: the SUBTREE total (this session + its
# sub-agents) when the caller computed it, else this conversation's own
# usage. Shared by the cost indicator and the per-model breakdown so
# both read the same numbers.
display_usage = subtree_usage if subtree_usage is not None else (conv.session_usage or {})
# Native-terminal-wrapper sessions (claude-native-ui / codex-native-ui) are
# always terminal-first: the web UI's Chat/Terminal pill is gated on the
# ``omnigent.ui = "terminal"`` label. That flag is fully determined by the
# agent identity, so derive it here from ``agent_name`` rather than relying
# solely on the stored label — the pill then stays correct even if the
# stored value is missing or stale. Idempotent: a no-op when already present.
labels = labels_with_closed_status(conv.labels, conv.title)
if agent_name in (_CLAUDE_NATIVE_MODEL, _CODEX_NATIVE_MODEL):
labels = {**labels, _CLAUDE_NATIVE_UI_LABEL_KEY: _CLAUDE_NATIVE_UI_LABEL_VALUE}
return SessionResponse(
id=conv.id,
agent_id=conv.agent_id,
agent_name=agent_name,
status=status,
background_task_count=background_task_count,
created_at=conv.created_at,
title=title_without_closed_marker(conv.title),
labels=labels,
runner_id=conv.runner_id,
host_id=conv.host_id,
runner_online=runner_online,
host_online=host_online,
host_resumable=host_resumable,
reasoning_effort=conv.reasoning_effort,
items=items,
permission_level=permission_level,
sub_agent_name=conv.sub_agent_name,
parent_session_id=conv.parent_conversation_id,
root_conversation_id=conv.root_conversation_id,
llm_model=llm_model,
harness=_resolve_harness(conv),
model_override=conv.model_override,
cost_control_mode_override=conv.cost_control_mode_override,
context_window=context_window,
last_total_tokens=last_total_tokens,
# Seed the client's cost indicator on resume. Uses the SUBTREE
# total (this session + its sub-agents) when the caller computed
# it, so a parent's badge reflects its sub-agents' spend; falls
# back to this conversation's own usage otherwise. A priced
# cumulative total, or None (rendered "—") when never priced.
total_cost_usd=_priced_cost_for_display(display_usage),
# Per-model breakdown over the same subtree usage. None (omitted)
# when no per-model usage was recorded.
usage_by_model=_usage_by_model_for_display(display_usage),
last_task_error=last_task_error,
external_session_id=conv.external_session_id,
terminal_launch_args=conv.terminal_launch_args,
# Replay outstanding approval prompts into the snapshot.
# The live SSE stream has no buffer, so a prompt emitted
# before the user opened this chat would otherwise never
# render — the UI rebuilds blocks from the snapshot on
# cold load, then live-tails. Empty list when nothing is
# outstanding (the common case).
pending_elicitations=(
pending_elicitation_events
if pending_elicitation_events is not None
else pending_elicitations.snapshot_for(conv.id)
),
# Replay un-consumed web messages on native-terminal sessions
# so a client that posted then navigated away / rebound re-
# hydrates the optimistic bubble. Empty for non-native sessions
# (their message is already persisted into ``items``).
pending_inputs=pending_inputs.snapshot_for(conv.id),
workspace=conv.workspace,
git_branch=conv.git_branch,
archived=conv.archived,
# Replay the latest todo list for claude-native sessions.
# Populated by _handle_external_session_todos; empty list for
# non-claude-native sessions or before the first poll tick.
todos=_session_todos_cache.get(conv.id, []),
skills=skills or [],
model_options=model_options or [],
# Replay terminal spin-up state so a client connecting while the
# runner is still creating a terminal-first session's terminal
# sees the Terminal-pill spinner. Populated by the runner SSE
# relay; absent (False) for non-terminal-first sessions or once
# the terminal lands / auto-create fails.
terminal_pending=_session_terminal_pending_cache.get(conv.id, False),
# Replay managed-sandbox launch progress so a client opening the
# session mid-launch (the Web UI navigates here immediately
# after the non-blocking managed create) sees the provisioning
# indicator. None for sessions without a managed launch and
# once the launch succeeds; a failed launch is retained with
# its reason. Populated by _publish_sandbox_status.
sandbox_status=_session_sandbox_status_cache.get(conv.id),
# Replay harness MCP-server startup state (codex-native) so a
# client opening the session mid-startup sees the startup band.
mcp_startup=_session_mcp_startup_cache.get(conv.id),
# In-flight turn id so a mid-turn reconnect can reopen a streaming
# ``activeResponse`` (the turn-start ``running`` edge that carried it
# is not replayed on the SSE stream). Populated for native-terminal
# sessions whose forwarder stamps a turn id; ``None`` otherwise.
active_response_id=_session_active_response_cache.get(conv.id),
)
def _publish_input_consumed(
session_id: str,
item: ConversationItem,
cleared_pending_id: str | None = None,
) -> None:
"""
Publish a ``session.input.consumed`` event for a just-persisted
conversation item.
Mirrors the wire shape consumers depend on for rendering the
input (user message bubble, tool-result block, etc.) at the
moment of acceptance.
:param session_id: The session/conversation identifier whose
stream should receive the event.
:param item: The persisted :class:`ConversationItem` carrying
the canonical ``id`` / ``type`` / ``data`` fields.
:param cleared_pending_id: When this message drained a
:mod:`omnigent.runtime.pending_inputs` entry (native-terminal
web message mirrored back from the transcript), that entry's
id, e.g. ``"pending_a1b2c3"`` — so clients drop the optimistic
bubble by id. ``None`` when nothing was drained.
"""
if item.type == "message" and isinstance(item.data, MessageData) and item.data.is_meta:
return
event = SessionInputConsumedEvent(
type="session.input.consumed",
data=SessionInputConsumedPayload(
item_id=item.id,
type=item.type,
data=item.data.model_dump() if item.data is not None else {},
created_by=item.created_by,
cleared_pending_id=cleared_pending_id,
),
)
session_stream.publish(session_id, event.model_dump())
def _publish_compaction_in_progress(session_id: str) -> None:
"""
Publish the standard compaction progress event to a session stream.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
"""
session_stream.publish(
session_id,
{"type": "response.compaction.in_progress"},
)
def _publish_compaction_completed(session_id: str, total_tokens: int | None) -> None:
"""
Publish the compaction-finished event to a session stream.
Emitted after :func:`compact_conversation_now` returns
successfully. Clients that rendered a spinner on the
``response.compaction.in_progress`` event should upgrade it to
the permanent "Conversation compacted" marker on this event.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param total_tokens: Tiktoken estimate of the post-compaction
context size, e.g. ``8421``. ``None`` when unavailable.
"""
payload: dict[str, object] = {"type": "response.compaction.completed"}
if total_tokens is not None:
payload["total_tokens"] = total_tokens
session_stream.publish(session_id, payload)
def _publish_compaction_failed(session_id: str) -> None:
"""
Publish the compaction-failed event to a session stream.
Emitted when :func:`compact_conversation_now` raises. Clients
that rendered a spinner on the
``response.compaction.in_progress`` event should dismiss it
without leaving a permanent marker — the conversation history
was not modified.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
"""
session_stream.publish(session_id, {"type": "response.compaction.failed"})
def _publish_external_assistant_message(
session_id: str,
item: ConversationItem,
*,
response_id: str,
agent_name: str,
) -> None:
"""
Broadcast an assistant message appended outside the task runtime.
Terminal-backed integrations such as native Claude produce output
in a live terminal first, then mirror the semantic text into AP.
There is no ``agent_task`` to watch, so this helper publishes the
completed output item directly. The browser reducer renders the
persisted message content from ``response.output_item.done``;
emitting synthetic text deltas here would duplicate the same
transcript item when the snapshot path also sees it.
:param session_id: Session/conversation identifier.
:param item: Persisted assistant message item.
:param response_id: Legacy endpoint response id. The persisted
item already carries this value, so the publisher does not
need it separately.
:param agent_name: Legacy endpoint agent/model name. The
persisted item already carries this value.
:returns: None.
"""
del response_id, agent_name
api_item = item.to_api_dict()
event = OutputItemDoneEvent(type="response.output_item.done", item=api_item)
session_stream.publish(session_id, event.model_dump())
def _resolve_llm_model(conv: Conversation | None) -> str | None:
"""
Resolve the LLM model identifier from a conversation's agent spec.
Uses the global agent cache to load the parsed spec and read
``spec.llm.model``. Returns ``None`` when the conversation has
no agent binding or the spec cannot be loaded.
:param conv: The conversation entity, or ``None``.
:returns: Model string (e.g. ``"databricks-gpt-5-5"``), or
``None`` when unavailable.
"""
if conv is None or conv.agent_id is None:
return None
try:
from omnigent.runtime import get_agent_cache
agent_cache = get_agent_cache()
# The agent store is injected at app startup; access it
# through the runtime globals.
from omnigent.runtime._globals import _agent_store
if _agent_store is None:
return None
agent = _agent_store.get(conv.agent_id)
if agent is None:
return None
loaded = agent_cache.load(
agent.id, agent.bundle_location, expand_env=agent.session_id is None
)
return loaded.spec.llm.model if loaded.spec.llm else None
except (KeyError, AttributeError, ValueError, ImportError, OSError, RuntimeError):
# ``RuntimeError`` covers ``get_agent_cache()`` before the runtime is
# initialized: this is a best-effort display resolver (now also called
# on native cost-only broadcasts), so an uninitialized runtime must
# degrade to "model unknown" — the cost still records, just unattributed.
return None
def _resolve_harness(conv: Conversation | None) -> str | None:
"""
Resolve the canonical harness for a conversation's bound agent.
Mirrors :func:`_resolve_llm_model`: loads the parsed spec via the agent
cache and returns the executor's harness
(``executor.config["harness"]``, else ``executor.type``), canonicalized.
Surfacing this on :class:`SessionResponse` lets the REPL render the
active credential for the correct provider *family* — anthropic for
claude-sdk, openai for codex / openai-agents — instead of guessing the
family from the model string (which is wrong when the agent declares no
model, e.g. a generic-provider launcher).
:param conv: The conversation entity, or ``None``.
:returns: The canonical harness (e.g. ``"openai-agents"`` or
``"claude-sdk"``), or ``None`` when unavailable.
"""
if conv is None:
return None
# A persisted per-session override (validated + canonicalized at
# create) wins over the spec's declared harness, so the snapshot
# reports what the runner actually spawns.
if conv.harness_override:
return conv.harness_override
if conv.agent_id is None:
return None
try:
from omnigent.harness_aliases import canonicalize_harness
from omnigent.runtime import get_agent_cache
from omnigent.runtime._globals import _agent_store
if _agent_store is None:
return None
agent = _agent_store.get(conv.agent_id)
if agent is None:
return None
loaded = get_agent_cache().load(
agent.id, agent.bundle_location, expand_env=agent.session_id is None
)
executor = loaded.spec.executor
# For a bundled-agent head sub-agent, report the HEAD's own harness,
# not the bundle brain's — `harness` is this session's provider family
# (a gpt head runs codex, not the claude-sdk brain). Falls back to the
# brain harness when the head declares none or can't be matched.
if conv.sub_agent_name:
sub = next(
(s for s in loaded.spec.sub_agents if s.name == conv.sub_agent_name),
None,
)
if sub is not None:
executor = sub.executor
harness = (
executor.config.get("harness")
or loaded.spec.executor.config.get("harness")
or executor.type
)
return canonicalize_harness(harness) or harness
except (KeyError, AttributeError, ValueError, ImportError, OSError):
return None
def _validated_harness_override(value: str | None, agent: Agent) -> str | None:
"""
Validate + canonicalize a session-create ``harness_override``.
Mirrors the CLI's ``--harness`` rules (``_apply_harness_override_to_executor``
in ``omnigent/chat.py``): the canonical name must be a known bundle
harness, and the bound agent must be an ``executor.type: omnigent``
spec — other executor types have no ``config.harness``, so an
override there would be a silent no-op.
:param value: The raw override from the request body, e.g. ``"pi"``
or the ``"openai-agents-sdk"`` alias. ``None`` means no override.
:param agent: The bound agent row (already fetched by the caller).
:returns: The canonical harness id, or ``None`` when *value* is.
:raises OmnigentError: ``invalid_input`` for an unknown harness, a
non-omnigent executor type, or an unloadable agent bundle.
"""
if value is None:
return None
from omnigent.harness_aliases import canonicalize_harness
from omnigent.runtime import get_agent_cache
from omnigent.spec._omnigent_compat import (
OMNIGENT_EXECUTOR_TYPE,
OMNIGENT_HARNESSES,
)
canonical = canonicalize_harness(value) or value
if canonical not in OMNIGENT_HARNESSES:
raise OmnigentError(
f"invalid harness_override: must be one of "
f"{sorted(OMNIGENT_HARNESSES)}, got {value!r}",
code=ErrorCode.INVALID_INPUT,
)
try:
loaded = get_agent_cache().load(
agent.id, agent.bundle_location, expand_env=agent.session_id is None
)
except (KeyError, AttributeError, ValueError, ImportError, OSError) as exc:
raise OmnigentError(
f"harness_override requires a loadable agent spec; "
f"agent {agent.name!r} failed to load: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
executor_type = loaded.spec.executor.type
if executor_type != OMNIGENT_EXECUTOR_TYPE:
raise OmnigentError(
f"harness_override only applies to executor.type "
f"{OMNIGENT_EXECUTOR_TYPE!r} agents; agent {agent.name!r} "
f"declares executor.type {executor_type!r}",
code=ErrorCode.INVALID_INPUT,
)
return canonical
def _utc_day(epoch_seconds: int) -> str:
"""
Convert a Unix epoch timestamp to its UTC calendar day.
:param epoch_seconds: Unix epoch seconds, e.g. ``1749081600``.
:returns: The UTC date as ``"YYYY-MM-DD"``, e.g. ``"2026-06-05"``.
"""
from datetime import datetime, timezone
return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).date().isoformat()
def _record_daily_cost(
conv: Conversation | None,
delta_usd: float,
conversation_store: ConversationStore,
) -> None:
"""
Add a turn's LLM cost to the session owner's daily rollup.
A no-op when *delta_usd* is not positive or the session has no
resolvable owner. Attributes the cost to the session creator
(:meth:`ConversationStore.get_session_owner`) and buckets it by the
current UTC day, so a session spanning midnight splits its spend
across both days. Recorded for every priced turn regardless of
whether the session runs under a policy — the daily rollup is the
backing store for the per-user daily cost-budget policy, and is now
populated universally. (This relies on the conversation store
implementing the daily-cost methods on every deployment that runs
this code; the earlier policy gate that kept the managed deployment
from touching an absent ``user_daily_cost`` table is no longer needed
now that the managed store backs it.)
Sub-agent conversations are created without a permission grant (the
internal runner POST carries no user context), so
``get_session_owner(conv.id)`` returns ``None`` for them. When
that happens, fall back to the spawn-tree root's owner: every
conversation carries ``root_conversation_id`` pointing to the
top-level session that *was* created with user context and therefore
always has an owner grant. This ensures relay / SDK sub-agent spend
is attributed to the same user as the parent rather than silently
dropped from the daily rollup.
:param conv: The conversation row for the session, or ``None``
(a no-op — no owner to attribute to).
:param delta_usd: The turn's cost in USD; ``<= 0`` is a no-op.
:param conversation_store: Store for the owner lookup and the
daily-cost UPSERT.
"""
if conv is None or delta_usd <= 0:
return
owner = conversation_store.get_session_owner(conv.id)
if owner is None and conv.root_conversation_id != conv.id:
# Sub-agent: no direct owner grant — fall back to the root session's
# owner so sub-agent spend is attributed rather than silently dropped.
owner = conversation_store.get_session_owner(conv.root_conversation_id)
if owner is None:
return
from omnigent.db.utils import now_epoch
conversation_store.add_daily_cost(owner, _utc_day(now_epoch()), delta_usd)
def _priced_cost_for_display(usage: dict[str, Any]) -> float | None:
"""
Extract ``total_cost_usd`` for client display, or ``None`` when unpriced.
The key is present only when a turn was priced, so its absence ("—" in
the UI) is distinct from a priced ``$0.00``. The cost-budget policy is
unaffected — it reads the value with a ``0.0`` default.
:param usage: A conversation's ``session_usage`` dict, e.g.
``{"input_tokens": 1200, "total_cost_usd": 0.42}`` (priced) or
``{"input_tokens": 1200}`` (unpriced — no cost key).
:returns: The cumulative cost in USD when priced, else ``None``.
"""
if "total_cost_usd" not in usage:
return None
try:
return float(usage["total_cost_usd"])
except (TypeError, ValueError):
# Defensive: a malformed persisted value must not break the
# snapshot / SSE emit. Treat it as unpriced.
return None
def _model_usage_bucket(usage: dict[str, Any], model: str) -> dict[str, float]:
"""
Get-or-create the per-model usage sub-bucket inside ``usage["by_model"]``.
The nested ``by_model`` map attributes token/cost usage to the specific
LLM that produced it, keyed on the raw harness-reported model id (faithful
and simplest — alias normalization is intentionally deferred). This mutates
``usage`` in place, creating ``by_model`` and the per-model dict on first
use, and returns the model's bucket for the caller to increment / set.
:param usage: The conversation's mutable ``session_usage`` dict.
:param model: The raw harness model id, e.g. ``"claude-sonnet-4-6"`` or
``"databricks-gpt-5-5"``.
:returns: The mutable per-model bucket, e.g. ``{"input_tokens": 1200}``.
"""
by_model = usage.setdefault("by_model", {})
return by_model.setdefault(model, {})
# Per-model token bucket keys (the five counters stored inside each
# ``by_model[<model>]`` sub-dict). Used by :func:`_usage_by_model_for_display`
# to coerce persisted values to ``int`` and by the native write path to copy
# flat session counters into the per-model bucket.
_MODEL_TOKEN_KEYS = (
"input_tokens",
"output_tokens",
"total_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
)
def _add_model_usage_delta(
bucket: dict[str, float],
token_deltas: dict[str, int],
cost_delta: float | None,
) -> None:
"""
Add one turn's per-model token/cost deltas into a model bucket (ADD).
Mirrors the flat-counter increments in :func:`_accumulate_session_usage`
so the per-model totals stay consistent with the flat totals: every flat
increment is matched by an increment to exactly one model bucket, so the
sum of per-model buckets equals the flat total. ``cost_delta`` is added
only when the turn was priced (``None`` otherwise), preserving the
"priced ⟺ ``total_cost_usd`` key present" contract at the per-model level.
:param bucket: The model's mutable bucket from :func:`_model_usage_bucket`.
:param token_deltas: This turn's per-bucket token counts to add, keyed by
the same names as :data:`_TOKEN_BREAKDOWN_KEYS`, e.g.
``{"input_tokens": 1200, "output_tokens": 340, ...}``.
:param cost_delta: This turn's priced cost in USD to add, or ``None`` when
the turn was unpriced (the model's cost key stays absent).
"""
for key, delta in token_deltas.items():
bucket[key] = bucket.get(key, 0) + delta
if cost_delta is not None:
bucket["total_cost_usd"] = bucket.get("total_cost_usd", 0.0) + cost_delta
def _usage_by_model_for_display(usage: dict[str, Any]) -> dict[str, ModelUsage] | None:
"""
Project the nested ``by_model`` usage map into typed :class:`ModelUsage`.
Companion to :func:`_token_breakdown_for_display` for the per-model view:
reads ``usage["by_model"]`` (the subtree-summed map from
:func:`load_session_usage`) and builds a ``{model_id: ModelUsage}`` dict
for the API. Token buckets are coerced to ``int`` and ``total_cost_usd``
to ``float``; an absent bucket stays ``None`` on the model (so a model
that was never priced has no cost), and malformed values are skipped.
:param usage: A subtree-summed usage dict, e.g.
``{"input_tokens": 1500, "by_model": {"claude-sonnet-4-6":
{"input_tokens": 1500, "total_cost_usd": 0.42}}}``.
:returns: The per-model map, or ``None`` when no per-model usage is
present (so ``exclude_none`` omits the field entirely).
"""
by_model = usage.get("by_model")
if not isinstance(by_model, dict) or not by_model:
return None
result: dict[str, ModelUsage] = {}
for model, bucket in by_model.items():
if not isinstance(bucket, dict):
continue
fields: dict[str, Any] = {}
for key in _MODEL_TOKEN_KEYS:
value = bucket.get(key)
if value is None:
continue
try:
fields[key] = int(value)
except (TypeError, ValueError):
continue
cost = _priced_cost_for_display(bucket)
if cost is not None:
fields["total_cost_usd"] = cost
result[model] = ModelUsage(**fields)
return result or None
def _accumulate_session_usage(
resp_obj: dict[str, Any],
session_id: str,
conversation_store: ConversationStore,
) -> float | None:
"""
Increment the session's cumulative token counters from a
``response.completed`` event's usage data.
Called synchronously from the relay loop. Builds a usage delta from
the response's ``usage`` field and atomically applies it to the
persisted ``session_usage`` via a single database transaction
(``SELECT FOR UPDATE`` on PostgreSQL, SQLite's single-writer lock
otherwise). This prevents the read-modify-write race that caused
concurrent relay completions to silently drop each other's cost /
token deltas (#9). No-op when the response carries no usage data.
Cost is computed when the model's per-token pricing is
available from the MLflow catalog (looked up once per call
from the response's ``model`` field). When the harness instead
reports an authoritative per-turn ``cost_usd`` (e.g. Copilot's
AI-credit total), that value is used directly in preference to
the catalog estimate. The ``total_cost_usd`` key is written
**only when the turn is priced** (catalog pricing available or a
harness-reported cost) — an unpriced session leaves it absent
(its presence is what distinguishes a priced ``$0.00`` from
"unpriced"; see :func:`_priced_cost_for_display`).
:param resp_obj: The ``response`` dict from the
``response.completed`` SSE event.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param conversation_store: Store for reading and writing
the ``session_usage`` column.
:returns: The session's cumulative priced cost in USD after this
update (for the caller to broadcast on a ``session.usage``
event), or ``None`` when the session is unpriced or carries no
usage to accumulate.
"""
usage_obj = resp_obj.get("usage")
if not isinstance(usage_obj, dict):
return None
input_tokens = usage_obj.get("input_tokens", 0)
output_tokens = usage_obj.get("output_tokens", 0)
total_tokens = usage_obj.get("total_tokens", 0)
if not any((input_tokens, output_tokens, total_tokens)):
return None
cache_read_input_tokens = usage_obj.get("cache_read_input_tokens", 0)
cache_creation_input_tokens = usage_obj.get("cache_creation_input_tokens", 0)
# Load conversation metadata for pricing only (NOT for reading session_usage —
# the atomic increment_session_usage call below handles that separately to
# avoid the read-modify-write race).
conv = conversation_store.get_conversation(session_id)
# Compute cost delta if pricing is available for the model. Resolve
# the model to price with, most-specific first:
# 1. ``usage.model`` — the model the harness actually used this turn.
# Relay executors report it; it's the only signal when the spec
# pins no ``llm.model`` (a supervisor that delegates / uses the
# harness default), so it's what makes those sessions priceable.
# 2. the session's ``model_override`` (a ``/model`` switch).
# 3. the agent spec's ``llm.model`` (the static default).
# The response's top-level ``model`` is the AGENT NAME, not the LLM
# model, so it is never used here. The ``total_cost_usd`` key is
# created only on this priced branch, so an unpriced session never
# gains a (misleading $0.00) cost key.
cost_delta = 0.0
priced = False
# Prefer an authoritative harness-reported cost over the catalog estimate.
provider_cost = usage_obj.get("cost_usd")
has_provider_cost = isinstance(provider_cost, (int, float))
usage_model = usage_obj.get("model")
llm_model = (
usage_model
if isinstance(usage_model, str) and usage_model
else (conv.model_override if conv and conv.model_override else _resolve_llm_model(conv))
)
if llm_model:
if has_provider_cost:
cost_delta = float(provider_cost)
priced = True
else:
from omnigent.llms.context_window import compute_llm_cost, fetch_model_pricing
pricing = fetch_model_pricing(llm_model)
priced = pricing is not None
if pricing is not None:
# Cache-aware: usage_obj carries cache_read/cache_creation
# token counts when the harness reports them; compute_llm_cost
# prices them at their own (cheaper read / pricier write) rates.
cost_delta = compute_llm_cost(usage_obj, pricing)
# Build the delta dict and atomically apply it to the persisted
# session_usage in a single DB transaction (SELECT FOR UPDATE on
# PostgreSQL; SQLite's exclusive write lock on SQLite). This is the fix
# for the read-modify-write race that caused concurrent completions to
# overwrite each other's deltas (#9).
delta: dict[str, Any] = {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"cache_read_input_tokens": cache_read_input_tokens,
"cache_creation_input_tokens": cache_creation_input_tokens,
}
if priced:
delta["total_cost_usd"] = cost_delta
if llm_model:
# Per-model attribution. Tokens are attributed whenever the model is
# known — including unpriced turns — so the per-model token view is
# complete; cost is attributed only when this model's turn was priced
# (keeping the model's cost key absent otherwise, matching the flat
# "priced ⟺ key present" contract).
model_delta: dict[str, Any] = {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"cache_read_input_tokens": cache_read_input_tokens,
"cache_creation_input_tokens": cache_creation_input_tokens,
}
if priced:
model_delta["total_cost_usd"] = cost_delta
delta["by_model"] = {llm_model: model_delta}
new_current = conversation_store.increment_session_usage(session_id, delta)
# Per-user daily rollup (policy-gated; this is the per-turn delta).
_record_daily_cost(conv, cost_delta, conversation_store)
return _priced_cost_for_display(new_current)
def _persist_native_cumulative_usage(
session_id: str,
data: dict[str, Any],
conversation_store: ConversationStore,
) -> float | None:
"""
Persist cumulative cost / token usage reported by a native harness.
Unlike the Omnigent relay path (:func:`_accumulate_session_usage`), which adds
per-response *deltas*, native harnesses (claude-native / codex-native)
report *cumulative* session usage — so this writes with SET semantics, not
add. The two paths never run for the same session, so they don't conflict.
Reads explicit cumulative fields from the ``external_session_usage`` event's
``data`` (all optional; a no-op when none are present):
- ``cumulative_cost_usd`` — total session cost for DISPLAY, e.g.
claude-native forwards Claude Code's own ``cost.total_cost_usd``
(exact billing; used directly). Stored in ``total_cost_usd``, which
drives the badge and the per-user daily rollup, so the badge matches
``/cost`` in the Claude TUI.
- ``policy_cost_usd`` — total session cost for ENFORCEMENT (the
cost-budget gate). claude-native forwards ``max(S, real-time
transcript estimate)`` here so the gate reflects in-flight sub-agent
spend while the displayed ``S`` is frozen for the sub-agent's run.
Stored verbatim in ``policy_cost_usd`` (the policy engine seeds from
it, falling back to ``total_cost_usd`` when absent). Not fed into the
daily rollup — that uses the authoritative ``total_cost_usd``.
- ``cumulative_input_tokens`` / ``cumulative_output_tokens`` — total session
tokens, e.g. codex-native's ``tokenUsage.total``. When
``cumulative_cost_usd`` is absent, cost is computed from these via
:func:`fetch_model_pricing`.
- ``cumulative_cache_read_input_tokens`` — the cached portion *included
in* ``cumulative_input_tokens`` (e.g. codex-native's
``tokenUsage.total.cachedInputTokens``). Split out of the input total
so :func:`compute_llm_cost` prices it at the cache-read rate rather
than the full input rate. Absent for harnesses that don't report it.
- ``model`` — LLM model id to price with (e.g. ``"databricks-gpt-5-5"``);
falls back to the agent spec's model when absent.
The ``total_cost_usd`` key is written only on the priced branches
below (exact billing, or token-priced when the model is in the
catalog), so an unpriced native session leaves it absent — the same
"priced ⟺ key present" contract the relay path uses. ``policy_cost_usd``
is written only when the event carries it (claude-native with the
display/policy split); codex-native and the relay omit it and the
policy engine falls back to ``total_cost_usd``.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc"``.
:param data: The ``external_session_usage`` event ``data`` dict.
:param conversation_store: Store for reading and writing ``session_usage``.
:returns: The session's cumulative priced cost in USD after this
update (for the caller to broadcast on a ``session.usage``
event), or ``None`` when the session is unpriced or no
cumulative field was present.
:raises OmnigentError: When a cumulative field is the wrong type.
"""
cost = _coerce_cumulative_field(data, "cumulative_cost_usd", numeric=True)
policy_cost = _coerce_cumulative_field(data, "policy_cost_usd", numeric=True)
cin = _coerce_cumulative_field(data, "cumulative_input_tokens", numeric=False)
cout = _coerce_cumulative_field(data, "cumulative_output_tokens", numeric=False)
ccache = _coerce_cumulative_field(data, "cumulative_cache_read_input_tokens", numeric=False)
if cost is None and policy_cost is None and cin is None and cout is None:
return None
conv = conversation_store.get_conversation(session_id)
current = dict(conv.session_usage) if conv and conv.session_usage else {}
# Native usage is cumulative (SET semantics), so the per-turn delta
# for the daily rollup is new_total - old_total. Capture the old
# cumulative + enforcement costs before the fields below overwrite them.
# Both are clamped MONOTONIC below (a write may only raise them): the
# ``external_session_usage`` event is posted with the session owner's own
# bearer token (the forwarder uses no privileged identity), so a client
# could otherwise replay it with a falsified low cost to reset the gate's
# cost to ~0 (disabling the budget DENY/ASK) and drive the daily rollup
# delta negative (clawing back already-spent budget). Monotonicity makes a
# downward report a no-op, so the worst a forged post can do is leave the
# figure unchanged. (See also the runner-token guard on cost_control.*
# label writes — usage was the missing half.)
old_cost = float(current.get("total_cost_usd", 0.0) or 0.0)
old_policy_cost = float(current.get("policy_cost_usd", 0.0) or 0.0)
if cin is not None:
# The reported input total is INCLUSIVE of cached tokens (codex's
# ``inputTokens`` counts cache reads). Split the cached portion into
# its own bucket so compute_llm_cost prices it at the cache-read rate;
# ``input_tokens`` keeps only the non-cached remainder (its contract).
# Clamp cached to the total so a malformed report never makes
# ``input_tokens`` negative.
cached = min(int(ccache), int(cin)) if ccache is not None else 0
current["cache_read_input_tokens"] = cached
current["input_tokens"] = int(cin) - cached
if cout is not None:
current["output_tokens"] = cout
if cin is not None or cout is not None:
# ``total_tokens`` reflects the full input (non-cached + cached) plus
# output, so the split above doesn't shrink the displayed total.
current["total_tokens"] = (
int(current.get("input_tokens", 0))
+ int(current.get("cache_read_input_tokens", 0))
+ int(current.get("output_tokens", 0))
)
# Resolve the model for per-model attribution on any broadcast that carries
# tokens OR a priced cost — both the token-pricing branch and the per-model
# attribution below need it. A cost-only broadcast must resolve it too:
# claude-native forwards Claude Code's statusLine total (S) with NO token
# counts, so gating model resolution on tokens alone dropped that cost from
# ``by_model`` entirely — the per-model TOKEN USAGE view undercounted the
# session total by every native (sub-)agent's spend, while the flat
# ``total_cost_usd`` (and the Session-cost badge) still included it.
# Priority mirrors the relay path's ``_accumulate_session_usage``: the
# event's ``model`` (the statusLine's active model, forwarded alongside the
# cost) wins, then the session's ``model_override`` (the forwarder mirrors
# in-pane /model switches there), then the agent spec's static model.
# Computed once out of the pricing-only branch so attribution works even on
# an unpriced turn. (The agent-cache lookup in ``_resolve_llm_model`` is
# memoized, so resolving on cost-only polls is cheap.)
has_tokens = cin is not None or cout is not None
needs_model = has_tokens or cost is not None
model_name = (
(
data.get("model")
or (conv.model_override if conv and conv.model_override else None)
or _resolve_llm_model(conv)
)
if needs_model
else None
)
if cost is not None:
# Monotonic: a reported total below the persisted one is ignored.
current["total_cost_usd"] = max(old_cost, float(cost))
elif has_tokens:
if isinstance(model_name, str) and model_name:
from omnigent.llms.context_window import compute_llm_cost, fetch_model_pricing
pricing = fetch_model_pricing(model_name)
if pricing is not None:
# SET (cumulative) — price the running token totals.
# ``current`` carries the cache-read split when the harness
# reports it (codex-native does), so compute_llm_cost prices
# cache reads at their own rate; it falls back to the input
# rate for cache tokens when the catalog omits a cache price
# (e.g. ``databricks-*`` entries today).
# Monotonic, like the explicit-cost branch: token totals are
# also client-SET, so a lowered token report can't drop the
# priced cost below the persisted figure.
current["total_cost_usd"] = max(old_cost, compute_llm_cost(current, pricing))
# Per-model attribution (SET). Native harnesses report cumulative SESSION
# totals, not per-model splits, so attribute the running cumulative buckets
# to the current model. For the usual single-model native session this
# makes the per-model view equal the flat totals; on a mid-session model
# switch the current model absorbs the cumulative (splitting deferred —
# keyed on the raw harness model id). Cost mirrors the flat
# ``total_cost_usd`` so the per-model cost key is present iff priced.
# ``model_name`` is set on token-bearing AND cost-bearing broadcasts, so a
# claude-native cost-only broadcast attributes its cumulative cost here too
# (token buckets stay absent — claude-native reports no token counts).
if isinstance(model_name, str) and model_name:
bucket = _model_usage_bucket(current, model_name)
for key in _MODEL_TOKEN_KEYS:
if key in current:
bucket[key] = current[key]
if "total_cost_usd" in current:
bucket["total_cost_usd"] = current["total_cost_usd"]
# Enforcement value (claude-native display/policy split). Stored
# separately from the displayed ``total_cost_usd`` so the gate can read
# the real-time figure (incl. in-flight sub-agent spend) while the badge
# shows the frozen statusLine total. Monotonic, like total_cost_usd: this
# is the value the cost-budget gate actually reads, so a forged low report
# must never lower it. When an in-flight estimate later resolves below a
# prior peak the clamp keeps the peak — conservative (the gate errs toward
# MORE enforcement, never less), which is the safe direction for a budget.
if policy_cost is not None:
current["policy_cost_usd"] = max(old_policy_cost, float(policy_cost))
conversation_store.set_session_usage(session_id, current)
# Per-user daily rollup. Native reports cumulative totals, so the turn's
# delta is the increase in cumulative cost. Uses the authoritative
# ``total_cost_usd`` (= statusLine S), NOT ``policy_cost_usd`` — the
# daily report must reflect real spend, not the real-time gate estimate.
new_cost = float(current.get("total_cost_usd", 0.0) or 0.0)
# Non-negative by the monotonic clamp above; ``max(0.0, ...)`` keeps the
# daily rollup from ever being clawed back even if that invariant changes.
_record_daily_cost(conv, max(0.0, new_cost - old_cost), conversation_store)
return _priced_cost_for_display(current)
def _coerce_cumulative_field(
data: dict[str, Any],
key: str,
*,
numeric: bool,
) -> float | int | None:
"""
Read and validate an optional cumulative usage field from event data.
:param data: The ``external_session_usage`` event ``data`` dict.
:param key: Field name, e.g. ``"cumulative_input_tokens"``.
:param numeric: When ``True`` accept any non-negative number (cost);
when ``False`` require a non-negative int (token counts).
:returns: The validated value, or ``None`` when the key is absent.
:raises OmnigentError: When present but the wrong type / negative.
"""
value = data.get(key)
if value is None:
return None
ok = (
isinstance(value, (int, float)) if numeric else isinstance(value, int)
) and not isinstance(value, bool)
if not ok or value < 0:
raise OmnigentError(
f"external_session_usage data.{key} must be a non-negative "
f"{'number' if numeric else 'int'}",
code=ErrorCode.INVALID_INPUT,
)
return value
async def _persist_external_session_usage(
session_id: str,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> int | None:
"""
Persist and broadcast a token-usage update from a terminal-backed runtime.
At least one of ``data.context_tokens`` (non-negative int),
``data.context_window`` (positive int), or a cumulative usage field
(:func:`_persist_native_cumulative_usage`) must be present.
:param session_id: Session/conversation identifier.
:param body: External session-usage event body.
:param conversation_store: Store used to upsert the labels.
:returns: The persisted ``context_tokens`` when present, else ``None``.
:raises OmnigentError: On missing / malformed fields.
"""
raw_tokens = body.data.get("context_tokens")
if raw_tokens is not None and (not isinstance(raw_tokens, int) or raw_tokens < 0):
raise OmnigentError(
"external_session_usage data.context_tokens must be a non-negative int",
code=ErrorCode.INVALID_INPUT,
)
raw_window = body.data.get("context_window")
if raw_window is not None and (not isinstance(raw_window, int) or raw_window <= 0):
raise OmnigentError(
"external_session_usage data.context_window must be a positive int",
code=ErrorCode.INVALID_INPUT,
)
_CUMULATIVE_USAGE_KEYS = (
"cumulative_cost_usd",
# ``policy_cost_usd`` alone is a valid post: mid-turn the displayed
# statusLine total (``cumulative_cost_usd``) is frozen, so the
# forwarder posts only the advancing real-time enforcement cost.
"policy_cost_usd",
"cumulative_input_tokens",
"cumulative_output_tokens",
)
has_cumulative = any(body.data.get(k) is not None for k in _CUMULATIVE_USAGE_KEYS)
if raw_tokens is None and raw_window is None and not has_cumulative:
raise OmnigentError(
"external_session_usage requires at least one of "
"data.context_tokens, data.context_window, or a cumulative usage field",
code=ErrorCode.INVALID_INPUT,
)
# Native harnesses report cumulative cost / tokens (SET semantics) — distinct
# from the Omnigent relay's per-response accumulation. Persist this session's
# own cumulative usage (its priced own-cost return is unused — the badge shows
# the subtree total computed below, not own cost).
await asyncio.to_thread(
_persist_native_cumulative_usage,
session_id,
body.data,
conversation_store,
)
label_updates: dict[str, str] = {}
if raw_tokens is not None:
label_updates[_LAST_CONTEXT_TOKENS_LABEL_KEY] = str(raw_tokens)
if raw_window is not None:
label_updates[_LAST_CONTEXT_WINDOW_LABEL_KEY] = str(raw_window)
await asyncio.to_thread(
conversation_store.set_labels,
session_id,
label_updates,
)
# The displayed cost is this session's SUBTREE total (itself + its
# sub-agents), matching the GET snapshot. A sub-agent persists its spend on
# its own child conversation, so broadcasting only this session's own cost
# would drop a parent's badge back to own-cost on every parent flush and
# hide in-flight sub-agent spend until the next child flush (the badge would
# oscillate own ⇄ subtree). For a childless session the subtree is just
# itself, so this equals own cost — one indexed tree query per flush.
subtree_usage = await asyncio.to_thread(load_session_usage, session_id, conversation_store)
subtree_cost = _priced_cost_for_display(subtree_usage)
usage_by_model = _usage_by_model_for_display(subtree_usage)
# Only include fields that were sent; the client treats absent
# fields as "no change" so a window-only update doesn't zero tokens.
# ``total_cost_usd`` is included only when the subtree is priced
# (``exclude_none`` strips it otherwise) — an unpriced session keeps
# showing "—" from the snapshot rather than a misleading $0.00.
event_payload: dict[str, Any] = {
"type": "session.usage",
"conversation_id": session_id,
}
if raw_tokens is not None:
event_payload["context_tokens"] = raw_tokens
if raw_window is not None:
event_payload["context_window"] = raw_window
if subtree_cost is not None:
event_payload["total_cost_usd"] = subtree_cost
if usage_by_model is not None:
event_payload["usage_by_model"] = usage_by_model
event = SessionUsageEvent(**event_payload)
session_stream.publish(session_id, event.model_dump(exclude_none=True))
# This session's usage also moves its ANCESTORS' subtree cost (its spend
# rolls up into every ancestor), so re-publish each ancestor's subtree cost
# too — otherwise a grandparent's badge wouldn't reflect a deep descendant.
# No-op for a top-level session (no ancestors). Threaded: it pages the
# conversation tree per ancestor.
await asyncio.to_thread(
_publish_subtree_cost_to_ancestors,
conversation_store,
session_id,
)
return raw_tokens
async def _persist_external_model_change(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> None:
"""
Persist and broadcast a model switch made inside the terminal.
Mirrors a ``/model`` change typed into a claude-native session's
Claude Code pane (or picked via its in-TUI model picker) onto the
Omnigent session: writes ``model_override`` so the value survives reload
and publishes a ``session.model`` SSE event so the web picker
updates live. Unlike the PATCH path
(:func:`update_session`), this deliberately does NOT forward a
``model_change`` back to the runner — the terminal is already on
the model, so re-injecting ``/model`` would loop.
No-ops (no write, no event) when the observed model already equals
the persisted ``model_override`` — the common case on the web→TUI
round-trip where the web PATCH set the override moments earlier.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row for ``session_id`` (read at the route
boundary); ``conv.model_override`` is the dedupe baseline.
:param body: External model-change event body. ``data.model`` must
be a non-empty string tier alias, e.g. ``"opus"``.
:param conversation_store: Store used to upsert ``model_override``.
:raises OmnigentError: If ``data.model`` is missing or not a
non-empty string.
"""
raw_model = body.data.get("model")
if not isinstance(raw_model, str) or not raw_model.strip():
raise OmnigentError(
"external_model_change requires data.model to be a non-empty string",
code=ErrorCode.INVALID_INPUT,
)
model = raw_model.strip()
if conv.model_override == model:
return
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
model_override=model,
)
event = SessionModelEvent(
type="session.model",
conversation_id=session_id,
model=model,
)
session_stream.publish(session_id, event.model_dump())
def _validate_external_reasoning_effort(body: SessionEventInput) -> str | None:
"""
Validate a terminal-observed reasoning-effort payload.
:param body: External effort-change event body. ``data.reasoning_effort``
must be present and either ``None`` or a supported effort string, e.g.
``"medium"``.
:returns: Normalized effort string, or ``None`` when the terminal cleared
to its default effort.
:raises OmnigentError: If the payload is missing or unsupported.
"""
if "reasoning_effort" not in body.data:
raise OmnigentError(
"external_reasoning_effort_change requires data.reasoning_effort",
code=ErrorCode.INVALID_INPUT,
)
raw_effort = body.data["reasoning_effort"]
if raw_effort is None:
return None
if not isinstance(raw_effort, str) or not raw_effort.strip():
raise OmnigentError(
"external_reasoning_effort_change requires data.reasoning_effort "
"to be a non-empty string or null",
code=ErrorCode.INVALID_INPUT,
)
effort = raw_effort.strip()
try:
return validate_effort(effort, "session metadata", EFFORT_VALUES)
except ValueError as exc:
raise OmnigentError(
f"invalid reasoning_effort: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
async def _persist_external_reasoning_effort_change(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> None:
"""
Persist and broadcast a reasoning-effort switch made inside the terminal.
Mirrors a native-terminal thinking-level change onto the Omnigent session.
Unlike the public PATCH path, this deliberately does NOT forward an
``effort_change`` back to the runner: the terminal is already on that
effort, so re-injecting it would loop.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row for ``session_id`` at the route boundary.
:param body: External effort-change event body.
:param conversation_store: Store used to update ``reasoning_effort``.
:returns: None.
"""
effort = _validate_external_reasoning_effort(body)
if conv.reasoning_effort == effort:
return
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
reasoning_effort=effort,
_unset_reasoning_effort=effort is None,
)
event = SessionReasoningEffortEvent(
type="session.reasoning_effort",
conversation_id=session_id,
reasoning_effort=effort,
)
session_stream.publish(session_id, event.model_dump())
async def _persist_external_codex_collaboration_mode_change(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> None:
"""
Persist Codex's collaboration mode kind as an internal session label.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row for ``session_id`` at the route boundary.
:param body: External Codex mode-change event body. ``data.mode`` must be
``"default"`` or ``"plan"``.
:param conversation_store: Store used to upsert the mode label.
:returns: None.
:raises OmnigentError: If ``data.mode`` is missing or unsupported.
"""
raw_mode = body.data.get("mode")
if not isinstance(raw_mode, str) or not raw_mode.strip():
raise OmnigentError(
"external_codex_collaboration_mode_change requires data.mode to be a non-empty string",
code=ErrorCode.INVALID_INPUT,
)
mode = raw_mode.strip()
if mode not in _CODEX_NATIVE_COLLABORATION_MODES:
raise OmnigentError(
"external_codex_collaboration_mode_change requires data.mode in "
f"{sorted(_CODEX_NATIVE_COLLABORATION_MODES)}; got {mode!r}",
code=ErrorCode.INVALID_INPUT,
)
if conv.labels.get(_CODEX_NATIVE_COLLABORATION_MODE_LABEL_KEY) == mode:
return
await asyncio.to_thread(
conversation_store.set_labels,
session_id,
{_CODEX_NATIVE_COLLABORATION_MODE_LABEL_KEY: mode},
)
_publish_collaboration_mode(session_id, mode)
async def _persist_model_change_note(
session_id: str,
model_override: str | None,
conversation_store: ConversationStore,
) -> None:
"""
Append a ``[System: ...]`` transcript note recording a model switch.
Records a web/REPL ``/model`` change as a user-role system marker
(the web UI renders ``[System: ...]`` user messages centered + muted
via ``SystemMessageView``) so the user gets a durable record in the
conversation that the switch happened — not just a transient composer
hint. Persisted through the store as append-only history (does NOT
start an agent turn, unlike the message-post path) and published over
SSE so connected clients render it live.
The caller gates this to **non-native** sessions (those WITHOUT an
``omnigent.wrapper`` native label, via ``_is_native_terminal_session``)
and to real ``/model`` commands: claude-native / codex-native manage
their model through the in-TUI picker / launch flag and must not receive
an injected AP-side item, and ``silent`` bind-time auto-applies are
skipped (see the ``live_forward`` guard in ``update_session``). The gate
keys on ``omnigent.wrapper`` rather than ``omnigent.ui == "terminal"``
because the latter is also set on chat-first SDK sessions that expose a
REPL terminal view (e.g. polly / debby), which DO want the note. The note
is a user-role message, so the agent sees it in history on the next turn —
consistent with other ``[System: ...]`` markers (timer fired, sub-agent
done).
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param model_override: The new model id, e.g.
``"databricks-gpt-5-4"``, or ``None`` when the override was
cleared back to the agent default.
:param conversation_store: Store used to append the note item.
:returns: None.
"""
text = (
f"[System: model changed to {model_override}]"
if model_override is not None
else "[System: model reset to the agent default]"
)
item = NewConversationItem(
type="message",
response_id=generate_task_id(),
data=MessageData(
role="user",
content=[{"type": "input_text", "text": text}],
),
)
persisted_items = await asyncio.to_thread(conversation_store.append, session_id, [item])
_publish_external_conversation_item(session_id, persisted_items[0])
def _handle_external_session_todos(
session_id: str,
body: SessionEventInput,
) -> None:
"""
Cache and broadcast a todo-list update from the claude-native forwarder.
Updates the in-memory ``_session_todos_cache`` so subsequent
``GET /v1/sessions/{id}`` snapshot calls can populate the ``todos``
field without a file read. Then publishes a ``session.todos`` SSE event
so connected web clients update their todo panel immediately.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param body: The ``external_session_todos`` event body. Must have
``data.todos`` as a list of todo dicts, e.g.
``[{"content": "Fix bug", "status": "in_progress", "activeForm": "Fixing the bug"}]``.
:raises OmnigentError: When ``data.todos`` is missing or not a list.
"""
todos = body.data.get("todos")
if not isinstance(todos, list):
raise OmnigentError(
"external_session_todos requires data.todos to be a list",
code=ErrorCode.INVALID_INPUT,
)
# Filter to well-formed items before caching so that malformed entries
# from a buggy forwarder version don't persist in the snapshot. The
# same filter is applied by sse.ts on the live-event path; keeping the
# two in sync means the snapshot and live panel always show the same set.
valid_statuses = {"pending", "in_progress", "completed"}
validated: list[dict[str, Any]] = [
t
for t in todos
if isinstance(t, dict)
and isinstance(t.get("content"), str)
and t.get("status") in valid_statuses
and isinstance(t.get("activeForm"), str)
]
_session_todos_cache[session_id] = validated
event = SessionTodosEvent(
type="session.todos",
conversation_id=session_id,
todos=validated,
)
session_stream.publish(session_id, event.model_dump())
def _publish_external_conversation_item(
session_id: str,
item: ConversationItem,
cleared_pending_id: str | None = None,
) -> None:
"""
Broadcast a terminal-observed conversation item.
User messages use ``session.input.consumed`` so the web UI renders
them exactly like local/composer messages. Assistant/tool-side
items use ``response.output_item.done`` because they are already
completed records from Claude's transcript, not token deltas from
an active Omnigent task.
:param session_id: Session/conversation identifier.
:param item: Persisted conversation item.
:param cleared_pending_id: For a native user message, the id of the
optimistic pending-input entry the caller drained for it (so
clients drop that bubble by id), or ``None``. The drain happens
at the persist site — see :func:`_persist_external_conversation_item`
— because it also folds the entry's file blocks into the durable
item before append.
:returns: None.
"""
if item.type == "message" and isinstance(item.data, MessageData) and item.data.is_meta:
return
if item.type == "message" and isinstance(item.data, MessageData) and item.data.role == "user":
_publish_input_consumed(session_id, item, cleared_pending_id=cleared_pending_id)
return
event = OutputItemDoneEvent(type="response.output_item.done", item=item.to_api_dict())
session_stream.publish(session_id, event.model_dump())
def _publish_external_output_text_delta(session_id: str, body: SessionEventInput) -> None:
"""
Broadcast a terminal-observed assistant text delta.
Terminal-backed integrations can observe streaming output before
their completed transcript item is available. This publishes the
standard Responses-style text-delta SSE event without persisting
anything; the final assistant message is persisted separately when
the integration posts ``external_conversation_item``.
The optional ``message_id`` / ``index`` / ``final`` fields are
carried through when present (claude-native live streaming) and
omitted otherwise — ``exclude_none`` keeps the wire shape identical
to in-process task streaming for callers that don't set them.
:param session_id: Session/conversation identifier.
:param body: ``POST /events`` body whose type is
:data:`_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE`.
:returns: None.
:raises OmnigentError: If ``data.delta`` is not a string, or any
provided ``message_id`` / ``index`` / ``final`` has the wrong
type.
"""
delta = body.data.get("delta")
if not isinstance(delta, str):
raise OmnigentError(
"external_output_text_delta requires string data.delta",
code=ErrorCode.INVALID_INPUT,
)
message_id = body.data.get("message_id")
if message_id is not None and not isinstance(message_id, str):
raise OmnigentError(
"external_output_text_delta data.message_id must be a string",
code=ErrorCode.INVALID_INPUT,
)
index = body.data.get("index")
# ``bool`` is an ``int`` subclass; reject it explicitly so a stray
# boolean index is a loud error rather than a silent 0/1.
if index is not None and (not isinstance(index, int) or isinstance(index, bool)):
raise OmnigentError(
"external_output_text_delta data.index must be an integer",
code=ErrorCode.INVALID_INPUT,
)
final = body.data.get("final")
if final is not None and not isinstance(final, bool):
raise OmnigentError(
"external_output_text_delta data.final must be a boolean",
code=ErrorCode.INVALID_INPUT,
)
event = OutputTextDeltaEvent(
type="response.output_text.delta",
delta=delta,
message_id=message_id,
index=index,
final=final,
)
session_stream.publish(session_id, event.model_dump(exclude_none=True))
def _publish_external_output_reasoning_delta(session_id: str, body: SessionEventInput) -> None:
"""
Broadcast a terminal-observed reasoning (chain-of-thought) delta.
The reasoning analogue of :func:`_publish_external_output_text_delta`:
terminal-backed integrations (the antigravity-native reader) observe a
streaming ``thinking`` block before the completed assistant item exists. This
publishes the standard reasoning SSE events the SPA already renders —
``response.reasoning.started`` once (when ``data.started`` is true, marking a
new reasoning block) followed by ``response.reasoning_text.delta`` — without
persisting anything. Reasoning has no completed conversation item; the block
is finalized when the assistant message is persisted via
``external_conversation_item``.
:param session_id: Session/conversation identifier.
:param body: ``POST /events`` body whose type is
:data:`_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE`.
:returns: None.
:raises OmnigentError: If ``data.delta`` is not a string, or ``data.started``
is provided with a non-boolean type.
"""
delta = body.data.get("delta")
if not isinstance(delta, str):
raise OmnigentError(
"external_output_reasoning_delta requires string data.delta",
code=ErrorCode.INVALID_INPUT,
)
started = body.data.get("started")
if started is not None and not isinstance(started, bool):
raise OmnigentError(
"external_output_reasoning_delta data.started must be a boolean",
code=ErrorCode.INVALID_INPUT,
)
if started:
session_stream.publish(
session_id,
ReasoningStartedEvent(type="response.reasoning.started").model_dump(exclude_none=True),
)
event = ReasoningTextDeltaEvent(type="response.reasoning_text.delta", delta=delta)
session_stream.publish(session_id, event.model_dump(exclude_none=True))
def _publish_elicitation_resolved(session_id: str, elicitation_id: str) -> None:
"""
Universal "approval done" signal — single publish drives both
sidebar (via :func:`pending_elicitations.record_publish` decrement)
and the chat-side ``ApprovalCard`` flip on every live subscriber.
Idempotent on duplicate emissions for the same id.
:param session_id: Session id, e.g. ``"conv_abc123"``.
:param elicitation_id: Correlation id, e.g. ``"elicit_abc123"``.
"""
session_stream.publish(
session_id,
{
"type": "response.elicitation_resolved",
"elicitation_id": elicitation_id,
},
)
async def _forward_approval_to_runner(
session_id: str,
data: dict[str, Any],
runner_router: RunnerRouter | None,
) -> None:
"""
Forward an approval verdict to the session's bound runner.
Runner-side elicitations (policy approvals parked in the runner's
``_pending_approvals`` dict, scaffold dispatch) resolve when the
canonical ``approval`` event reaches the runner's ``/events``. The
server↔runner contract stays the ``approval`` event regardless of
how the verdict arrived at the server (resolve URL or approval
event). No-op when no runner is bound (in-process setups). HTTP
errors are logged, not raised — a dead runner must not fail the
caller's resolution (the server-side Future was already set).
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param data: The approval payload to forward verbatim as the
event ``data``, e.g. ``{"elicitation_id": "elicit_abc",
"action": "accept"}``.
:param runner_router: Router used to resolve the bound runner, or
``None`` in in-process setups (forward skipped).
"""
runner_client = await _get_runner_client(session_id, runner_router)
if runner_client is None:
return
try:
await runner_client.post(
f"/v1/sessions/{session_id}/events",
json={"type": _APPROVAL_TYPE, "data": data},
timeout=10.0,
)
except (httpx.HTTPError, ConnectionError):
_logger.exception(
"Approval forward failed for %r",
session_id,
)
async def _resolve_elicitation(
session_id: str,
data: dict[str, Any],
runner_router: RunnerRouter | None,
conversation_store: ConversationStore | None = None,
) -> None:
"""
Resolve one outstanding elicitation from an approval payload.
Shared by the two entry points that deliver a verdict for a
parked elicitation: the ``type == "approval"`` branch of
``POST /v1/sessions/{id}/events`` and the dedicated
``POST /v1/sessions/{id}/elicitations/{eid}/resolve`` URL
endpoint (URL-based elicitation). Both converge here so
resolution semantics — server-side harness Future, sidebar
badge clear, and runner forward — stay identical regardless of
how the verdict arrived.
Three effects, in order:
1. **Server-side harness Future.** Claude-native permission
hooks (and any other server-parked elicitation) register a
Future in ``_harness_elicitation_registry``. If one exists
for this id, is unresolved, and is owned by *this* session
(cross-user guard), set its result. An
ownership mismatch silently skips resolution — the runner
forward below still fires so a runner-side elicitation with
the same id can reject it on its own terms.
2. **Sidebar badge clear.** Publish
``response.elicitation_resolved`` so every subscribed client
(other tabs, the REPL TUI) flips its ``ApprovalCard`` and the
pending-elicitation badge decrements. Idempotent.
3. **Runner forward.** Runner-side elicitations (policy
approvals parked in the runner's ``_pending_approvals`` dict)
resolve when the approval event reaches the runner's
``/events``. Forwarded as a canonical ``approval`` event.
:param session_id: Session/conversation identifier that owns
the elicitation, e.g. ``"conv_abc123"``.
:param data: Approval payload carrying the ``elicitation_id``
correlation key plus the MCP ``ElicitationResult`` fields
(``action``, optional ``content``), e.g.
``{"elicitation_id": "elicit_abc", "action": "accept"}``.
:param runner_router: Router used to resolve the session's bound
runner for the forward, or ``None`` in in-process setups
(the forward is skipped when no runner is bound).
:param conversation_store: Optional store used to mirror the
resolved signal into ancestor streams when ``session_id`` is
a child session. ``None`` keeps the signal scoped locally.
"""
# Empty-string default is intentional, NOT a fail-loud miss: the
# resolve-URL caller always supplies the id (it comes from the URL
# path), but the public ``approval`` event caller may post a
# malformed body. A missing id degrades gracefully below (no Future
# matches, no resolved event published) rather than 500-ing the
# client — the runner forward still fires so the runner can reject.
elicitation_id = data.get("elicitation_id", "")
harness_future = _harness_elicitation_registry.get(elicitation_id)
if harness_future is not None and not harness_future.done():
# Only the session that owns this elicitation
# may resolve its server-side Future. A mismatch skips
# resolution (the runner forward still fires below).
if _harness_elicitation_owners.get(elicitation_id) == session_id:
result_payload = {k: v for k, v in data.items() if k != "elicitation_id"}
try:
harness_future.set_result(
ElicitationResult.model_validate(result_payload),
)
except ValidationError:
_logger.warning(
"Invalid approval payload for %r",
elicitation_id,
exc_info=True,
)
elif harness_future is None and isinstance(elicitation_id, str) and elicitation_id:
# Nothing parked (severed long-poll mid-retry, or a runner-side
# id that just ages out) — tombstone the verdict so a re-park
# returns it; consume is session-checked, so no cross-session use.
result_payload = {k: v for k, v in data.items() if k != "elicitation_id"}
try:
pre_resolved = ElicitationResult.model_validate(result_payload)
except ValidationError:
pre_resolved = None
if pre_resolved is not None:
_prune_pre_resolved_harness_elicitations()
_harness_pre_resolved_elicitations[elicitation_id] = _PreResolvedHarnessElicitation(
session_id=session_id,
created_at=time.time(),
result=pre_resolved,
)
_prune_pre_resolved_harness_elicitations()
# Wake a currently-parked long-poll via resolved_elsewhere, not only its
# Future: setting the Future alone races the sever/re-park cycle and the
# ASK-gated call hangs. Set the event directly; the signal helper's
# parked-is-None branch would clobber the verdict-carrying tombstone.
if isinstance(elicitation_id, str) and elicitation_id:
_parked = _harness_parked_elicitations.get(elicitation_id)
if _parked is not None and _harness_elicitation_owners.get(elicitation_id) == session_id:
_parked.resolved_elsewhere.set()
# Fan-out for every other subscribed client (other tabs, REPL
# TUI). Idempotent vs. the runner's own ``wait_for_user_approval``
# finally / harness hook finally — those also publish for the id.
if isinstance(elicitation_id, str) and elicitation_id:
_publish_elicitation_resolved(session_id, elicitation_id)
if conversation_store is not None:
await asyncio.to_thread(
_publish_elicitation_resolved_to_ancestors,
conversation_store,
session_id,
elicitation_id,
)
# Runner-side elicitations (policy approvals, scaffold dispatch)
# resolve when the canonical approval event reaches the runner.
await _forward_approval_to_runner(session_id, data, runner_router)
# Fire-and-forget tasks that ask the bound runner to pop a native-terminal
# approval modal for a parked tool-policy ASK. Kept referenced so they aren't
# garbage-collected before the POST completes.
_native_popup_forward_tasks: set[asyncio.Task[None]] = set()
def _spawn_native_approval_popup_forward(
session_id: str, elicitation_id: str, message: str, policy_name: str | None = None
) -> None:
"""
Ask the bound runner to pop a native-terminal modal for a parked ASK.
Fire-and-forget. Forwards the same ``cost_approval_popup`` control event
the cost gate uses — the runner dispatch + popup launcher are
policy-agnostic — so a user working in the native terminal can answer a
parked tool-policy ASK there, not only in the web ApprovalCard. (Native
tool-policy ASKs were moved server-side, which took them out of the
TUI; this puts them back.) The popup resolves the SAME elicitation via
the same resolve endpoint the web card uses, so whichever surface
answers first releases the gate. Non-native harnesses 204 no-op on the
runner.
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
:param elicitation_id: The parked elicitation's id, e.g. ``"elicit_x"``.
:param message: The approval reason shown in the popup.
:param policy_name: Name of the deciding policy, rendered as the
popup header so it reflects the actual policy rather than a
hardcoded cost-budget label. ``None`` falls back to a generic
header on the runner.
:returns: None. Fire-and-forget: forwarding failures (runner offline,
no runner bound) are swallowed by ``_forward_session_change_to_runner``
and never block the gate — the web ApprovalCard remains the surface.
"""
async def _forward() -> None:
await _forward_session_change_to_runner(
session_id,
_server_runner_router,
{
"type": "cost_approval_popup",
"elicitation_id": elicitation_id,
"message": message,
"policy_name": policy_name,
},
)
task = asyncio.create_task(_forward())
_native_popup_forward_tasks.add(task)
task.add_done_callback(_native_popup_forward_tasks.discard)
def _spawn_native_blocked_notice_forward(
session_id: str, message: str, policy_name: str | None = None
) -> None:
"""
Ask the bound runner to pop an INFORMATIONAL hard-block notice on the pane.
The request-phase HARD-DENY counterpart of
:func:`_spawn_native_approval_popup_forward`: no approve/decline (the prompt
is blocked). opencode can only hard-block a prompt by its policy plugin
throwing, which opencode renders as a generic "Unexpected server error";
this forwards the policy reason so the runner can surface it as a dismissable
tmux popup on the opencode pane. Fire-and-forget; the runner dispatch is
harness-gated (only ``opencode-native`` pops — claude/codex already show a
clean ``UserPromptSubmit`` block, so they no-op).
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
:param message: The block reason shown in the popup.
:param policy_name: Deciding policy, rendered as the popup header. ``None``
falls back to a generic header on the runner.
:returns: None. Forwarding failures (runner offline / none bound) are
swallowed and never affect the verdict.
"""
async def _forward() -> None:
await _forward_session_change_to_runner(
session_id,
_server_runner_router,
{
"type": "policy_blocked_notice",
"message": message,
"policy_name": policy_name,
},
)
task = asyncio.create_task(_forward())
_native_popup_forward_tasks.add(task)
task.add_done_callback(_native_popup_forward_tasks.discard)
async def _hold_native_ask_gate(
request: Request,
*,
session_id: str,
phase: Phase,
data: dict[str, Any],
engine: PolicyEngine,
result: PolicyResult,
conversation_store: ConversationStore,
elicitation_id: str | None = None,
) -> bool:
"""
Hold a server-side ASK gate until a human resolves it.
Publishes a ``response.elicitation_request`` (the web UI / REPL
render the approve card) and parks a server-side Future via
:func:`_publish_and_wait_for_harness_elicitation`, exactly as the
``PermissionRequest`` hook does. The human approves through the
elicitation's resolve URL; this collapses the verdict to a single
boolean the caller maps to ALLOW / DENY.
Used for any phase whose ASK must be resolved on the server rather
than by a runner-side ``wait_for_user_approval`` park:
:attr:`Phase.TOOL_CALL` (the native ``PreToolUse`` hook gate) and
:attr:`Phase.REQUEST` (the user-message input gate, which has no
runner in the loop yet — see :func:`_evaluate_input_policy`).
Unlike the old ASK→``defer`` path, the gate lives on the server,
so a permissive native ``permission_mode`` (``acceptEdits`` /
``bypassPermissions``) cannot skip it — the action stays blocked
until a real human verdict. Timeout / disconnect fail closed
(return ``False`` → DENY).
On approve, the ASK-accumulated ``set_labels`` / ``state_updates``
are applied (POLICIES.md §7.2: side effects land only on approve);
a denied / timed-out ASK leaves no trace.
:param request: FastAPI request, for upstream-disconnect detection
inside the parking helper.
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
:param phase: Enforcement phase being gated, e.g.
:attr:`Phase.TOOL_CALL` or :attr:`Phase.REQUEST`.
:param data: The proto event ``data`` — for a tool call,
``{"name": "Bash", "arguments": {"command": "ls"}}``; for a
request, the user ``message`` body
(``{"role": "user", "content": [...]}``).
:param engine: The policy engine, used to resolve the per-policy
``ask_timeout`` and to apply approved side effects.
:param result: The composed ASK :class:`PolicyResult` — carries
the reason, deciding_policy, and withheld set_labels.
:param conversation_store: Store used to mirror child-session
prompts into ancestor streams.
:param elicitation_id: Optional stable re-attach id from the
calling hook, e.g. ``"elicit_evaluate_abc123"``. When supplied,
``_publish_and_wait_for_harness_elicitation`` re-attaches to the
existing parked elicitation rather than publishing a new card —
used by ``POST /policies/evaluate`` retries so a hook retry after
a transient 5xx / connect-drop does not prompt the human twice.
``None`` mints a fresh id (the default for non-retry callers).
:returns: ``True`` iff a human accepted; ``False`` on cancel /
timeout / disconnect (fail closed).
:raises ElicitationDeclinedError: when the human explicitly
declines (``action == "decline"``). Callers should abort the
turn rather than continuing with a DENY.
"""
tool_name = data.get("name")
tool_input = data.get("arguments")
params = ElicitationRequestParams(
mode="form",
message=result.reason or "Approval required",
requestedSchema={},
phase=phase.value,
policy_name=result.deciding_policy or "unknown",
content_preview=json.dumps(data)[:1024],
)
# Per-policy ``ask_timeout`` override wins over the spec-level default.
timeout_s = float(resolve_ask_timeout(engine, result))
# Use the caller-supplied id when present (hook retries re-attach to
# the same elicitation); otherwise mint a fresh one so we can surface
# this ASK in the native terminal before parking on the web verdict.
if elicitation_id is None:
elicitation_id = f"elicit_{secrets.token_hex(16)}"
_spawn_native_approval_popup_forward(
session_id, elicitation_id, params.message, result.deciding_policy
)
verdict = await _publish_and_wait_for_harness_elicitation(
request,
session_id=session_id,
params=params,
timeout_s=timeout_s,
elicitation_id=elicitation_id,
conversation_store=conversation_store,
tool_name=tool_name if isinstance(tool_name, str) else None,
tool_input=tool_input if isinstance(tool_input, dict) else None,
)
# Explicit user decline → raise so callers can abort the turn rather
# than feeding a DENY message to the LLM and letting it continue.
if verdict is not None and verdict.action == "decline":
raise ElicitationDeclinedError(
result.reason or "",
policy_name=result.deciding_policy,
)
approved = verdict is not None and verdict.action == "accept"
if approved:
# POLICIES.md §7.2: writes accumulated by the ASKing policy
# land only on approve.
if result.set_labels:
engine.apply_label_writes(result.set_labels)
if result.state_updates:
engine.apply_state_updates(result.state_updates)
return approved
def _parse_external_assistant_message(
body: SessionEventInput,
) -> tuple[str, str, str]:
"""
Validate and unpack an external assistant-message event.
:param body: ``POST /events`` body whose type is
:data:`_EXTERNAL_ASSISTANT_MESSAGE_TYPE`.
:returns: ``(agent_name, text, response_id)``.
:raises OmnigentError: If required fields are missing or
malformed.
"""
agent_name = body.data.get("agent")
if not isinstance(agent_name, str) or not agent_name.strip():
raise OmnigentError(
"external_assistant_message requires data.agent",
code=ErrorCode.INVALID_INPUT,
)
text = body.data.get("text")
if not isinstance(text, str) or not text:
raise OmnigentError(
"external_assistant_message requires non-empty data.text",
code=ErrorCode.INVALID_INPUT,
)
response_id = body.data.get("response_id")
if response_id is None:
response_id = generate_task_id()
if not isinstance(response_id, str) or not response_id.strip():
raise OmnigentError(
"external_assistant_message data.response_id must be a non-empty string",
code=ErrorCode.INVALID_INPUT,
)
return agent_name.strip(), text, response_id.strip()
async def _persist_external_assistant_message(
session_id: str,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> str:
"""
Persist and broadcast assistant text produced outside Omnigent tasks.
The event is append-only conversation history. It intentionally
bypasses the legacy persist path so mirroring a
Claude terminal response does not create or steer an Omnigent
agent task.
:param session_id: Session/conversation identifier.
:param body: External assistant-message event body.
:param conversation_store: Store used to append the message.
:returns: Store-assigned conversation item id.
"""
agent_name, text, response_id = _parse_external_assistant_message(body)
item = NewConversationItem(
type="message",
response_id=response_id,
data=MessageData(
role="assistant",
agent=agent_name,
content=[{"type": "output_text", "text": text}],
),
)
persisted_items = await asyncio.to_thread(conversation_store.append, session_id, [item])
persisted = persisted_items[0]
_publish_external_assistant_message(
session_id,
persisted,
response_id=response_id,
agent_name=agent_name,
)
return persisted.id
def _parse_external_conversation_item(
body: SessionEventInput,
) -> NewConversationItem:
"""
Validate and unpack an external conversation-item event.
:param body: ``POST /events`` body whose type is
:data:`_EXTERNAL_CONVERSATION_ITEM_TYPE`.
:returns: A parsed :class:`NewConversationItem` ready to append.
:raises OmnigentError: If required fields are missing or
malformed.
"""
item_type = body.data.get("item_type")
if not isinstance(item_type, str) or item_type not in ITEM_TYPE_TO_DATA_CLS:
raise OmnigentError(
"external_conversation_item requires known data.item_type",
code=ErrorCode.INVALID_INPUT,
)
item_data = body.data.get("item_data")
if not isinstance(item_data, dict):
raise OmnigentError(
"external_conversation_item requires object data.item_data",
code=ErrorCode.INVALID_INPUT,
)
response_id = body.data.get("response_id")
if response_id is None:
response_id = generate_task_id()
if not isinstance(response_id, str) or not response_id.strip():
raise OmnigentError(
"external_conversation_item data.response_id must be a non-empty string",
code=ErrorCode.INVALID_INPUT,
)
# NOTE: external conversation items are persisted with a random
# primary key like any other item — there is no server-side dedup.
# Producers (the claude-native / codex-native forwarders) are
# responsible for not re-posting records they have already sent;
# they no longer emit a ``source_id`` dedup key to the server.
# Cap a native tool result so a multi-MB output isn't persisted + broadcast as one frame.
if item_type == "function_call_output" and isinstance(item_data.get("output"), str):
item_data = {**item_data, "output": cap_tool_output(item_data["output"])}
try:
data = parse_item_data(item_type, {"type": item_type, **item_data})
except (ValueError, TypeError) as exc:
raise OmnigentError(
f"Invalid data payload for external item type {item_type!r}: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
return NewConversationItem(
type=item_type,
response_id=response_id.strip(),
data=data,
)
def _find_claude_native_subagent_child(
conversation_store: ConversationStore,
parent_id: str,
subagent_id: str,
) -> Conversation | None:
"""
Look up an existing claude-native sub-agent child by its Claude-
side ``subagent_id``.
Used to make :func:`_persist_external_subagent_start` idempotent:
the forwarder retries on transient HTTP errors, so two POSTs may
carry the same ``subagent_id`` for the same physical sub-agent —
we want both to resolve to the same child Conversation row.
:param conversation_store: Store to query.
:param parent_id: Parent (claude-native) conversation id,
e.g. ``"conv_parent987"``.
:param subagent_id: Stable Claude-side identifier read from
``agent-<id>.meta.json``'s directory name, e.g.
``"a5c7effac5a9a35ab"``.
:returns: The matching child :class:`Conversation`, or ``None``
when no row has been minted for this sub-agent yet.
"""
# Page through all children so the lookup isn't capped by result
# ordering. A parent with > 100 sub-agents would otherwise miss the
# existing row for an older ``subagent_id`` and fall through to
# ``create_conversation``, which then trips the
# ``(parent, title)`` unique constraint instead of returning the
# existing child id.
after: str | None = None
while True:
page = conversation_store.list_conversations(
kind="sub_agent",
parent_conversation_id=parent_id,
limit=100,
after=after,
)
for child in page.data:
if child.labels.get(_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY) == subagent_id:
return child
if not page.has_more or page.last_id is None:
return None
after = page.last_id
def _find_subagent_child_by_title(
conversation_store: ConversationStore,
parent_id: str,
title: str,
) -> Conversation | None:
"""
Look up an existing sub-agent child by its exact title.
Recovery path for duplicate-title races: when ``create_conversation``
trips the ``(parent_conversation_id, title)`` unique index but the
label-based idempotency lookup missed — the original POST crashed
after creating the row and before ``set_labels`` ran — the row can
only be found by the title itself. Native sub-agent titles embed the
stable harness-side id (e.g. ``"Explore:a5c7effac5a9a35ab"``,
``"codex-native-ui-subagent:<thread_id>"``), so an exact title match
under the same parent identifies the same physical sub-agent.
:param conversation_store: Store to query.
:param parent_id: Parent conversation id, e.g. ``"conv_parent987"``.
:param title: Exact child title, e.g. ``"Explore:a5c7effac5a9a35ab"``.
:returns: Matching child :class:`Conversation`, or ``None`` when no
row under *parent_id* carries that title.
"""
after: str | None = None
while True:
page = conversation_store.list_conversations(
kind="sub_agent",
parent_conversation_id=parent_id,
limit=100,
after=after,
)
for child in page.data:
if child.title == title:
return child
if not page.has_more or page.last_id is None:
return None
after = page.last_id
def _publish_session_created(
parent_id: str,
child_session_id: str,
agent_id: str | None,
) -> None:
"""
Emit ``session.created`` on the parent's stream for a child session.
Clients watching the parent (e.g. the web Subagents rail tab)
invalidate their ``child_sessions`` cache and re-fetch on this
event.
:param parent_id: Parent conversation id, e.g. ``"conv_parent987"``.
:param child_session_id: The minted (or adopted) child id, e.g.
``"conv_child456"``.
:param agent_id: Agent id stamped on the child (the parent's
agent), e.g. ``"ag_abc123"``. ``None`` only for legacy parents
without one.
"""
event = SessionCreatedEvent(
type="session.created",
conversation_id=parent_id,
child_session_id=child_session_id,
agent_id=agent_id,
parent_session_id=parent_id,
)
session_stream.publish(parent_id, event.model_dump())
async def _persist_external_subagent_start(
parent_id: str,
parent_conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> str:
"""
Mint a child :class:`Conversation` row for a claude-native
sub-agent and emit the parent's ``session.created`` SSE event.
Claude Code spawns sub-agents internally via its Task tool and
never POSTs to Omnigent to register them. The forwarder watches the
parent's on-disk ``subagents/`` directory and calls this handler
when a new ``.meta.json`` appears. We reuse the parent's
``agent_id`` (claude-native sub-agents don't have their own
omnigent agent), stamp identifying labels, and publish the
same ``session.created`` event omnigent-spawned children fire
so the rail's ``child_sessions`` cache invalidates.
Idempotent: a second POST with the same ``subagent_id`` returns
the existing child's id without creating a duplicate — via the
label lookup when the row is fully stamped, or via title-collision
recovery when an earlier POST died between ``create_conversation``
and ``set_labels`` (the recovery also re-stamps the labels so the
row is healed for subsequent deliveries).
:param parent_id: Parent (claude-native) conversation id,
e.g. ``"conv_parent987"``.
:param parent_conv: Pre-fetched parent row — its ``agent_id`` is
copied onto the child and its labels disambiguate
claude-native parents from other harnesses.
:param body: The POST event body. Required ``data`` keys:
``subagent_id`` (Claude-side id, e.g. ``"a5c7eff..."``),
``agent_type`` (e.g. ``"Explore"``), ``description``
(free-form, used in the title), ``tool_use_id``
(e.g. ``"toolu_..."``).
:param conversation_store: Store used to read existing children
(for idempotency) and create the new row.
:returns: The child conversation id, e.g. ``"conv_child456"``.
:raises OmnigentError: 400 if the payload is missing any of
the required keys; 400 if the parent has no ``agent_id``
(claude-native parents always carry one, so this would be
a corrupted row).
"""
subagent_id = body.data.get("subagent_id")
agent_type = body.data.get("agent_type")
description = body.data.get("description")
tool_use_id = body.data.get("tool_use_id")
if not isinstance(subagent_id, str) or not subagent_id:
raise OmnigentError(
"external_subagent_start requires non-empty data.subagent_id",
code=ErrorCode.INVALID_INPUT,
)
if not isinstance(agent_type, str) or not agent_type:
raise OmnigentError(
"external_subagent_start requires non-empty data.agent_type",
code=ErrorCode.INVALID_INPUT,
)
if not isinstance(description, str):
raise OmnigentError(
"external_subagent_start requires data.description (string)",
code=ErrorCode.INVALID_INPUT,
)
if not isinstance(tool_use_id, str) or not tool_use_id:
raise OmnigentError(
"external_subagent_start requires non-empty data.tool_use_id",
code=ErrorCode.INVALID_INPUT,
)
if parent_conv.agent_id is None:
# claude-native parents are always created with an agent_id
# by ``omnigent claude`` (the synthetic Claude bundle).
# A null agent_id here means we're being called against a
# legacy / corrupt row — fail loud rather than silently
# mint a child without a parent agent.
raise OmnigentError(
f"parent session {parent_id!r} has no agent_id; cannot "
"create a claude-native sub-agent child",
code=ErrorCode.INVALID_INPUT,
)
# Idempotency: a forwarder retry with the same subagent_id must
# resolve to the same child row, not mint a duplicate. The
# forwarder also persists its own cursor file so this should be
# rare, but the network is unreliable and the cursor write
# happens after the POST.
existing = await asyncio.to_thread(
_find_claude_native_subagent_child,
conversation_store,
parent_id,
subagent_id,
)
if existing is not None:
return existing.id
# Title format mirrors omnigent-spawned children
# (``"{tool}:{session_name}"``) so the rail's split-on-colon
# parser surfaces the same ``tool`` shape. The ``session_name``
# half must be unique per parent because the conversation store
# has a ``(parent_conversation_id, title)`` unique index — using
# the description here would collide whenever Claude's LLM
# passes the same agentType + description for parallel
# sub-agents (which the Task tool does routinely). The
# ``subagent_id`` is the only stable per-sub-agent identifier
# in the meta file, so it goes here. The human-readable
# description is stored as a label below for downstream surfaces
# that want it; the rail's ``SubagentsPanel`` already hides the
# ``session_name`` half so the user only sees ``agent_type``.
title = f"{agent_type}:{subagent_id}"
labels = {
_CLAUDE_NATIVE_WRAPPER_LABEL_KEY: _CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE,
_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY: subagent_id,
_CLAUDE_NATIVE_TOOL_USE_ID_LABEL_KEY: tool_use_id,
_CLAUDE_NATIVE_DESCRIPTION_LABEL_KEY: description,
}
try:
child = await asyncio.to_thread(
conversation_store.create_conversation,
kind="sub_agent",
title=title,
parent_conversation_id=parent_id,
agent_id=parent_conv.agent_id,
runner_id=parent_conv.runner_id,
sub_agent_name=agent_type,
)
except NameAlreadyExistsError:
# The (parent, title) unique index fired: the row already exists
# but the label-based idempotency lookup above missed it — either
# a concurrent POST won the insert race, or an earlier POST died
# after create_conversation and before set_labels, leaving an
# unlabeled row. Without this recovery every forwarder redelivery
# 500s on the same collision until the forwarder gives up and
# parks the sub-agent (it then never appears in the rail). Adopt
# the existing row and re-stamp its labels (idempotent upsert) so
# the next delivery takes the fast label-lookup path.
adopted = await asyncio.to_thread(
_find_subagent_child_by_title,
conversation_store,
parent_id,
title,
)
if adopted is None:
raise
await asyncio.to_thread(conversation_store.set_labels, adopted.id, labels)
# The POST that created this orphan died before reaching the
# ``session.created`` publish below, so live clients (the web
# Subagents rail) have never heard about the child — emit it now.
# In the concurrent-race case the winner also published; a
# duplicate event is a harmless extra cache invalidation.
_publish_session_created(parent_id, adopted.id, parent_conv.agent_id)
return adopted.id
await asyncio.to_thread(conversation_store.set_labels, child.id, labels)
_publish_session_created(parent_id, child.id, parent_conv.agent_id)
return child.id
def _find_codex_native_subagent_child(
conversation_store: ConversationStore,
parent_id: str,
thread_id: str,
) -> Conversation | None:
"""
Look up an existing Codex-native sub-agent child by its Codex thread id.
Makes ``_persist_external_codex_subagent_start`` idempotent: when the
forwarder re-posts because it observed both ``item/started`` and
``item/completed`` for the same collab item, the second POST returns
the existing child row rather than creating a duplicate.
:param conversation_store: Store to query.
:param parent_id: Parent codex-native conversation id, e.g.
``"conv_parent987"``.
:param thread_id: Codex child thread id, e.g.
``"019e8720-98d7-7b23-ac0a-bfb0eb02e0c9"``.
:returns: Matching child :class:`Conversation`, or ``None`` when no
row exists for this thread id.
"""
after: str | None = None
while True:
page = conversation_store.list_conversations(
kind="sub_agent",
parent_conversation_id=parent_id,
limit=100,
after=after,
)
for child in page.data:
if child.labels.get(_CODEX_NATIVE_SUBAGENT_THREAD_ID_LABEL_KEY) == thread_id:
return child
if not page.has_more or page.last_id is None:
return None
after = page.last_id
def _codex_subagent_display_tool(labels: dict[str, str]) -> str:
"""
Return the UI-facing label for a Codex child session.
Uses the Codex-assigned nickname when available, then the agent
role, then ``"Codex"`` as a generic fallback.
:param labels: Conversation labels from a Codex child row.
:returns: Display label, e.g. ``"auth-auditor"``.
"""
nickname = labels.get(_CODEX_NATIVE_SUBAGENT_NICKNAME_LABEL_KEY)
if nickname:
return nickname
role = labels.get(_CODEX_NATIVE_SUBAGENT_ROLE_LABEL_KEY)
if role:
return role
return _CODEX_NATIVE_SUBAGENT_DISPLAY_FALLBACK
def _is_codex_native_subagent(conv: Conversation) -> bool:
"""
Return whether a child conversation tracks a Codex internal sub-agent.
:param conv: Conversation row to inspect.
:returns: ``True`` when the row carries the codex-native sub-agent
wrapper label.
"""
return (
conv.kind == "sub_agent"
and conv.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
== _CODEX_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE
)
def _subagent_delivery_status(
status: str,
background_task_count: int | None,
conv: Conversation,
) -> str:
"""Collapse a sub-agent's background-task ``waiting`` back to ``idle``.
A claude-native session running as an Omnigent sub-agent relabels its
``Stop`` turn-end ``idle`` to ``waiting`` (in the forwarder) when
background shells linger, purely so its own UI shows a spinner. But the
sub-agent terminal-delivery branch in ``post_event`` keys off
``idle``/``failed``: a ``waiting`` edge would never deliver the child's
result to the parent, hanging the orchestrator with no follow-up ``Stop``
to recover. The ``background_task_count`` alone already drives the child's
spinner at ``idle`` (the in-chat indicator and the sidebar rollup both
treat a positive tally as working), so for a sub-agent the turn genuinely
ended — deliver ``idle``. Top-level sessions are returned unchanged so the
web UI keeps its ``waiting`` shimmer.
:param status: The incoming external status, e.g. ``"waiting"``.
:param background_task_count: Parsed background-shell tally, or ``None``.
:param conv: The conversation the status is for.
:returns: ``"idle"`` for a non-codex sub-agent's background-task
``waiting``; otherwise ``status`` unchanged.
"""
if (
status == "waiting"
and background_task_count is not None
and background_task_count > 0
and conv.kind == "sub_agent"
and not _is_codex_native_subagent(conv)
):
return "idle"
return status
def _codex_subagent_labels_from_body(
thread_id: str,
body: SessionEventInput,
) -> dict[str, str]:
"""
Build the label dict for a Codex-native sub-agent child row.
:param thread_id: Codex child thread id, e.g. ``"thread_child"``.
:param body: Validated ``external_codex_subagent_start`` event body.
:returns: Labels to upsert on the child conversation row.
"""
labels: dict[str, str] = {
_CLAUDE_NATIVE_WRAPPER_LABEL_KEY: _CODEX_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE,
_CODEX_NATIVE_SUBAGENT_THREAD_ID_LABEL_KEY: thread_id,
}
for data_key, label_key in (
("parent_thread_id", _CODEX_NATIVE_SUBAGENT_PARENT_THREAD_ID_LABEL_KEY),
("tool_call_id", _CODEX_NATIVE_SUBAGENT_TOOL_CALL_ID_LABEL_KEY),
("prompt", _CODEX_NATIVE_SUBAGENT_PROMPT_LABEL_KEY),
("agent_nickname", _CODEX_NATIVE_SUBAGENT_NICKNAME_LABEL_KEY),
("agent_role", _CODEX_NATIVE_SUBAGENT_ROLE_LABEL_KEY),
):
value = body.data.get(data_key)
if isinstance(value, str) and value:
labels[label_key] = value
return labels
async def _create_and_publish_codex_child(
parent_id: str,
parent_conv: Conversation,
thread_id: str,
labels: dict[str, str],
conversation_store: ConversationStore,
) -> str:
"""
Create a new Codex child Conversation row and publish ``session.created``.
:param parent_id: Parent codex-native conversation id, e.g.
``"conv_parent987"``.
:param parent_conv: Parent row whose ``agent_id`` and ``runner_id``
are inherited by the child.
:param thread_id: Codex child thread id, e.g. ``"thread_child"``.
:param labels: Labels to stamp on the new child row.
:param conversation_store: Store used to create the child row.
:returns: New child conversation id, e.g. ``"conv_child456"``.
"""
# Stable title so the (parent, title) unique index prevents race-condition
# duplicate rows when the forwarder retries a failed registration.
title = f"codex-native-ui-subagent:{thread_id}"
try:
child = await asyncio.to_thread(
conversation_store.create_conversation,
kind="sub_agent",
title=title,
parent_conversation_id=parent_id,
agent_id=parent_conv.agent_id,
runner_id=parent_conv.runner_id,
sub_agent_name=_CODEX_NATIVE_SUBAGENT_DISPLAY_FALLBACK,
)
except NameAlreadyExistsError:
# A concurrent POST (or a retry that arrived before set_labels ran)
# already created the row — find it and upsert labels instead.
existing = await asyncio.to_thread(
_find_codex_native_subagent_child, conversation_store, parent_id, thread_id
)
if existing is None:
# The thread-id label never landed (the original POST died
# between create_conversation and set_labels), so the label
# lookup can't see the row. The title embeds the same thread
# id and must exist for the unique index to have fired — fall
# back to it so redelivery heals the unlabeled row instead of
# permanently 500ing.
existing = await asyncio.to_thread(
_find_subagent_child_by_title,
conversation_store,
parent_id,
title,
)
if existing is not None:
await asyncio.to_thread(conversation_store.set_labels, existing.id, labels)
# An orphaned row's creator died before publishing
# ``session.created``, so live clients have never heard about
# this child — emit it now. In the concurrent-race case the
# winner also published; the duplicate is a harmless extra
# cache invalidation.
_publish_session_created(parent_id, existing.id, parent_conv.agent_id)
return existing.id
raise
await asyncio.to_thread(conversation_store.set_labels, child.id, labels)
_publish_session_created(parent_id, child.id, parent_conv.agent_id)
return child.id
async def _persist_external_codex_subagent_start(
parent_id: str,
parent_conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> str:
"""
Mint or update a child Conversation for a Codex AgentControl sub-agent.
Idempotent: repeated POSTs for the same ``thread_id`` return the
existing child id and upsert any new labels.
:param parent_id: Parent codex-native conversation id, e.g.
``"conv_parent987"``.
:param parent_conv: Pre-fetched parent row.
:param body: POST event body with ``data.thread_id`` required.
:param conversation_store: Store for reading/creating child rows.
:returns: Child conversation id, e.g. ``"conv_child456"``.
:raises OmnigentError: If ``thread_id`` is missing or parent has
no bound agent.
"""
thread_id = body.data.get("thread_id")
if not isinstance(thread_id, str) or not thread_id:
raise OmnigentError(
"external_codex_subagent_start requires non-empty data.thread_id",
code=ErrorCode.INVALID_INPUT,
)
if parent_conv.agent_id is None:
raise OmnigentError(
f"parent session {parent_id!r} has no agent_id; cannot "
"create a codex-native sub-agent child",
code=ErrorCode.INVALID_INPUT,
)
existing = await asyncio.to_thread(
_find_codex_native_subagent_child, conversation_store, parent_id, thread_id
)
labels = _codex_subagent_labels_from_body(thread_id, body)
if existing is not None:
await asyncio.to_thread(conversation_store.set_labels, existing.id, labels)
return existing.id
return await _create_and_publish_codex_child(
parent_id, parent_conv, thread_id, labels, conversation_store
)
async def _persist_external_conversation_item(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
created_by: str | None = None,
) -> str:
"""
Persist and broadcast a conversation item produced outside AP.
This is the transcript bridge path for native Claude. It appends
user messages, assistant messages, tool calls, and tool results
without starting or steering the placeholder Omnigent agent.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param conv: Conversation row for title seeding.
:param body: External item event body.
:param conversation_store: Store used to append the item.
:param created_by: Authenticated identity of the actor whose
request triggered the forwarder POST, e.g.
``"alice@example.com"``. Used to attribute user messages typed
directly in the native terminal (no pending-input entry exists
for those). ``None`` in single-user / unauthenticated mode —
no label is stamped in that case.
:returns: Store-assigned conversation item id.
"""
item = _parse_external_conversation_item(body)
# A native user message round-tripping back from the transcript:
# drain its optimistic pending-input entry (FIFO) and fold the
# entry's file blocks (image / file) into the item BEFORE persisting.
# The transcript is text-only, so without this the image is dropped
# from durable history and disappears on every reload / navigation.
cleared_pending_id: str | None = None
skipped_kiro_pending: list[pending_inputs.DrainedInput] = []
if (
item.type == "message"
and isinstance(item.data, MessageData)
and item.data.role == "user"
and not item.data.is_meta
):
if _is_kiro_native_session(conv):
text = _message_text(item.data.content) or ""
matched = pending_inputs.resolve_matching_text(session_id, text)
drained = matched.matched
skipped_kiro_pending = matched.skipped
else:
drained = pending_inputs.resolve_oldest(session_id)
if drained is not None:
cleared_pending_id = drained.pending_id
item = _merge_pending_file_blocks(item, drained.content)
# Apply the original sender's identity recorded at POST time.
# The transcript forwarder is the single writer here and has no
# auth context, so the persisted item would otherwise have
# created_by=None, causing session.input.consumed to broadcast
# without an author — the label would flash in from the optimistic
# bubble then disappear once the committed item arrived.
if drained.created_by is not None and item.created_by is None:
item = item.model_copy(update={"created_by": drained.created_by})
elif item.created_by is None and created_by is not None:
# No pending entry — direct terminal input. Fall back to the
# identity authenticated on the forwarder's own request.
item = item.model_copy(update={"created_by": created_by})
for skipped in skipped_kiro_pending:
await _persist_skipped_kiro_pending_input(
session_id,
skipped,
conversation_store,
)
persisted_items = await asyncio.to_thread(conversation_store.append, session_id, [item])
await _seed_missing_title_from_user_message(conv, item, conversation_store)
persisted = persisted_items[0]
_publish_external_conversation_item(
session_id, persisted, cleared_pending_id=cleared_pending_id
)
_drive_terminal_resolved_elicitation(session_id, persisted)
return persisted.id
def _is_kiro_native_session(conv: Conversation) -> bool:
"""Return whether a conversation is backed by the native Kiro terminal."""
return conv.labels.get("omnigent.wrapper") == "kiro-native-ui"
async def _persist_skipped_kiro_pending_input(
session_id: str,
skipped: pending_inputs.DrainedInput,
conversation_store: ConversationStore,
) -> None:
"""Persist a Kiro web input that never appeared in Kiro's JSONL transcript."""
turn_id = generate_task_id()
user_item = NewConversationItem(
type="message",
response_id=turn_id,
data=MessageData(role="user", content=skipped.content),
created_by=skipped.created_by,
)
error = ErrorData(
source="execution",
code="kiro_native_prompt_not_recorded",
message=(
"Kiro did not accept this web message into its structured session transcript. "
"The native terminal may have shown the underlying error."
),
)
persisted_items = await asyncio.to_thread(
conversation_store.append,
session_id,
[
user_item,
NewConversationItem(type="error", response_id=turn_id, data=error),
],
)
_publish_input_consumed(
session_id,
persisted_items[0],
cleared_pending_id=skipped.pending_id,
)
_publish_external_conversation_item(session_id, persisted_items[1])
def _merge_pending_file_blocks(
item: NewConversationItem,
pending_content: list[dict[str, Any]],
) -> NewConversationItem:
"""
Prepend a pending entry's file blocks onto a user-message item.
The claude-native transcript mirrors a user message back as
text-only — ``input_image`` / ``input_file`` blocks are dropped. The
optimistic pending-input entry still carries them (with real
``file_id``s, assigned at upload), so we fold them into the durable
item here. Without it the image renders only on the optimistic
bubble and vanishes from history on the next reload.
No-op when the pending entry has no file blocks, or when the item
already carries file blocks (defensive — a future transcript that
does include them must not be doubled).
:param item: The parsed user-message item about to be persisted.
Its ``data`` is a :class:`MessageData` whose ``content`` is a
list of block dicts, e.g. ``[{"type": "input_text",
"text": "hi"}]``.
:param pending_content: The drained pending entry's content blocks,
e.g. ``[{"type": "input_image", "file_id": "file_x",
"filename": "a.png"}, {"type": "input_text", "text": "hi"}]``.
:returns: A copy of *item* with the file blocks prepended, or *item*
unchanged when there is nothing to merge.
"""
if not isinstance(item.data, MessageData):
return item
file_blocks = [
block
for block in pending_content
if isinstance(block, dict) and block.get("type") in ("input_image", "input_file")
]
if not file_blocks:
return item
already_has_files = any(
isinstance(block, dict) and block.get("type") in ("input_image", "input_file")
for block in item.data.content
)
if already_has_files:
return item
merged_data = item.data.model_copy(update={"content": [*file_blocks, *item.data.content]})
return item.model_copy(update={"data": merged_data})
def _message_text(content: list[dict[str, Any]]) -> str | None:
"""
Extract joined text from message content blocks.
:param content: Message content blocks, e.g.
``[{"type": "output_text", "text": "Done"}]``.
:returns: Joined text from ``text`` / ``input_text`` fields,
or ``None`` when no text field exists.
"""
parts: list[str] = []
found_text = False
for block in content:
if not isinstance(block, dict):
continue
text = block.get("text")
if not isinstance(text, str):
text = block.get("input_text")
if isinstance(text, str):
found_text = True
parts.append(text)
return "\n".join(parts) if found_text else None
def _latest_assistant_text_from_store(
conversation_store: ConversationStore,
session_id: str,
) -> str | None:
"""
Return the latest persisted assistant message text for a session.
Native harnesses mirror completed transcript items to the AP
server, not necessarily to the runner's in-memory history. This
helper lets Omnigent forward the durable assistant output with the
terminal-observed idle edge.
:param conversation_store: Store used to read conversation items.
:param session_id: Session/conversation id, e.g.
``"conv_child123"``.
:returns: Latest assistant text, or ``None`` when none is
persisted yet.
"""
page = conversation_store.list_items(
session_id,
limit=_EXTERNAL_STATUS_ASSISTANT_SCAN_LIMIT,
order="desc",
type="message",
)
for item in page.data:
if not isinstance(item.data, MessageData):
continue
if item.data.role != "assistant" or item.data.is_meta:
continue
text = _message_text(item.data.content)
if text is not None:
return text
return None
async def _enrich_idle_status_with_subagent_output(
data: dict[str, Any],
status: str,
session_id: str,
conversation_store: ConversationStore,
) -> dict[str, Any]:
"""
Attach a native sub-agent's durable assistant text to an idle status edge.
Shared by both native sub-agent delivery paths (the codex
``external_session_status`` POST handler and the claude-native relay
forward) so the parent inbox result carries the child's output. Native
harnesses mirror transcript items to the store, not runner memory, so the
text is read here and forwarded with the idle edge.
:param data: The ``external_session_status`` ``data`` to enrich, e.g.
``{"status": "idle"}``.
:param status: Status edge; only ``"idle"`` is enriched.
:param session_id: Sub-agent session id, e.g. ``"conv_child123"``.
:param conversation_store: Store read for the child's assistant text.
:returns: ``data`` with ``"output"`` added when an idle edge has a
persisted assistant message; otherwise unchanged.
"""
if status != "idle":
return data
output = await asyncio.to_thread(
_latest_assistant_text_from_store,
conversation_store,
session_id,
)
if output is None:
return data
return {**data, "output": output}
@dataclass(frozen=True)
class _RunnerForwardResult:
"""
HTTP result from forwarding a session-control event to the runner.
:param status_code: Runner response status, e.g. ``204``.
:param body: Runner response body text. Empty string when the runner
returns no body.
"""
status_code: int
body: str
def _require_external_status_forward(
session_id: str,
status: str,
runner_result: _RunnerForwardResult | None,
) -> None:
"""
Fail loudly when required external status forwarding does not land.
Terminal native sub-agent completion is delivered to the parent
runner through this forward. Dropping it would leave the parent
waiting forever with no inbox result.
:param session_id: Sub-agent session id, e.g. ``"conv_child123"``.
:param status: External status value, e.g. ``"idle"``.
:param runner_result: HTTP result returned by the runner, or ``None``
when no runner could be reached.
:returns: None.
:raises OmnigentError: If the runner was unavailable or
rejected the forwarded status.
"""
if runner_result is None:
raise OmnigentError(
f"Could not reach runner to deliver external_session_status "
f"{status!r} for sub-agent session {session_id!r}",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
if runner_result.status_code >= 400:
detail = runner_result.body[:500]
suffix = f": {detail}" if detail else ""
raise OmnigentError(
f"Runner rejected external_session_status {status!r} for "
f"sub-agent session {session_id!r} with status "
f"{runner_result.status_code}{suffix}",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
# How long the terminal sub-agent-status forward waits for the parent's
# runner tunnel to (re)connect before giving up. A relaunch/redeploy
# reconnect gap is normally sub-second; a few seconds bridges it without
# holding the POST open long. The runner re-posts on a 503 regardless, so
# this is a best-effort fast path, not the only delivery chance.
_SUBAGENT_FORWARD_RECONNECT_WAIT_S = 5.0
async def _recover_subagent_status_forward_via_parent(
child_conv: Conversation,
runner_router: RunnerRouter | None,
tunnel_registry: TunnelRegistry | None,
conversation_store: ConversationStore,
forward_body: dict[str, Any],
) -> _RunnerForwardResult | None:
"""
Re-deliver a sub-agent terminal status through the parent's live runner.
A native sub-agent child copies its parent's ``runner_id`` once, at
creation (``create_conversation(..., runner_id=parent_conv.runner_id)`` —
see :func:`_persist_external_subagent_start`). It is never repointed when
the runner is later relaunched under a freshly minted ``runner_id`` (a host
relaunch after a tunnel drop / server redeploy / crash mints a new binding
token; only the *parent* conversation is rebound, via the PATCH path on its
next message). The child then points at a permanently offline ``runner_id``,
so its terminal ``idle``/``failed`` forward resolves no runner client and
503s forever (``_forward_session_change_to_runner`` → ``None`` →
:func:`_require_external_status_forward`). The parent never receives the
child's inbox result and hangs with no timeout.
A child always runs on its parent's runner, so the live binding is the
parent's. This re-resolves the forward through the parent/root
conversation's *current* ``runner_id``: it waits briefly for that runner's
tunnel to (re)connect (covering the reconnect gap right after a relaunch),
heals the child's stale ``runner_id`` so future forwards and
``_on_runner_connect`` resolve it correctly, and retries the forward.
:param child_conv: The sub-agent child conversation whose terminal-status
forward could not reach its pinned runner.
:param runner_router: Router used to resolve the bound runner client, or
``None`` in in-process setups.
:param tunnel_registry: Runner-tunnel registry used to await the parent
runner's (re)connect, or ``None`` in setups without runner tunnels.
:param conversation_store: Store used to look up the parent and persist the
child's healed ``runner_id``.
:param forward_body: The ``external_session_status`` event body to re-POST.
:returns: The retry's :class:`_RunnerForwardResult` when a live parent
runner was resolved, or ``None`` when none could be (the caller then
fails the forward as before).
"""
parent_id = child_conv.parent_conversation_id or child_conv.root_conversation_id
if not parent_id or parent_id == child_conv.id:
return None
parent = await asyncio.to_thread(conversation_store.get_conversation, parent_id)
if parent is None or parent.runner_id is None:
return None
parent_runner_id = parent.runner_id
# Wait for the parent's runner tunnel to be live before re-resolving. When
# no registry is wired (in-process / tests) skip the wait and retry
# best-effort against whatever the router resolves.
if tunnel_registry is not None:
client = await _wait_for_runner_client(
parent_id,
runner_router,
tunnel_registry,
runner_id=parent_runner_id,
timeout_s=_SUBAGENT_FORWARD_RECONNECT_WAIT_S,
)
if client is None:
return None
if parent_runner_id != child_conv.runner_id:
# Heal the divergence so this child's id matches the live runner: the
# next forward resolves directly and a future ``_on_runner_connect``
# (which rebinds by matching runner_id) can recover it.
try:
await asyncio.to_thread(
conversation_store.replace_runner_id, child_conv.id, parent_runner_id
)
except ConversationNotFoundError:
# The child was deleted between ``post_event`` reading it and this
# heal (e.g. the session was removed mid-teardown). Recovery is
# strictly best-effort — degrade to ``None`` so the caller falls
# through to the existing 503/no-op rather than surfacing this
# benign race as an unhandled 500.
return None
return await _forward_session_change_to_runner(
child_conv.id,
runner_router,
forward_body,
)
def _require_collaboration_mode_forward(
session_id: str,
enabled: bool,
runner_result: _RunnerForwardResult | None,
) -> None:
"""
Fail when a live Codex Plan-mode switch was not applied by the runner.
Codex Plan mode is a loaded-thread collaboration mode inside Codex
app-server. Persisting the Omnigent label without a successful runner
update would make the web UI claim Plan mode while Codex still runs in
the previous mode, so explicit UI toggles require a confirmed 2xx forward.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param enabled: ``True`` when entering Plan mode; ``False`` when
returning to Default mode.
:param runner_result: HTTP result returned by the runner, or ``None``
when no runner could be reached.
:returns: None.
:raises OmnigentError: If no runner was reachable or the runner rejected
the live Plan-mode update.
"""
action = "enter Plan mode" if enabled else "exit Plan mode"
if runner_result is None:
raise OmnigentError(
f"Could not {action}: no live Codex runner is available for "
f"session {session_id!r}. Reconnect the session and try again.",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
if not 200 <= runner_result.status_code < 300:
raise OmnigentError(
f"Could not {action}: runner returned status "
f"{runner_result.status_code} for session {session_id!r}. "
f"Reconnect the session and try again.",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
def _drive_terminal_resolved_elicitation(session_id: str, persisted: ConversationItem) -> None:
"""
Feed a mirrored tool item into the terminal-resolved fast path.
A ``function_call`` records its tool identity by ``call_id`` so the
matching ``function_call_output`` can be correlated back to a parked
permission prompt. A ``function_call_output`` means the gated tool
already ran (or was rejected) in the native terminal, so the prompt
the web UI may still be showing was resolved there — resolve the
matching parked prompt now instead of waiting for the hook timeout.
Other item types are ignored.
:param session_id: Omnigent conversation id the item was mirrored for,
e.g. ``"conv_abc123"``.
:param persisted: The stored conversation item the forwarder just
mirrored via ``external_conversation_item``.
"""
data = persisted.data
if persisted.type == "function_call" and isinstance(data, FunctionCallData):
try:
parsed = json.loads(data.arguments) if data.arguments else {}
except json.JSONDecodeError:
parsed = {}
_recent_mirrored_tool_calls[data.call_id] = _MirroredToolCall(
tool_name=data.name,
tool_input=parsed if isinstance(parsed, dict) else {},
)
elif persisted.type == "function_call_output" and isinstance(data, FunctionCallOutputData):
identity = _recent_mirrored_tool_calls.get(data.call_id)
if identity is not None:
_signal_terminal_resolved_harness_elicitation(
session_id, identity.tool_name, identity.tool_input
)
def _publish_status(
session_id: str,
status: str,
error: ErrorDetail | None = None,
response_id: str | None = None,
background_task_count: int | None = None,
) -> None:
"""
Publish a typed :class:`SessionStatusEvent` to the live stream and
update the cache the list endpoint reads.
``status`` must be one of the literals on
:class:`SessionStatusEvent` (``idle`` / ``running`` / ``waiting``
/ ``failed``); other values fail Pydantic validation rather than
silently shipping a non-conforming wire shape (rule 15).
Every publish site funnels through here so the in-memory
``_session_status_cache`` stays coherent with the SSE stream.
Without this, paths that publish but don't write the cache —
notably the ``external_session_status`` handler used by the
claude-native forwarder — leave the sidebar stuck on "idle"
while the chat itself shows "Working…".
:param session_id: Session/conversation identifier.
:param status: New session status value.
:param error: Failure detail to forward on a ``"failed"``
transition, e.g. ``ErrorDetail(code="runner_error",
message="turn setup failed: ...")``. ``None`` for every
non-failed transition. Carrying it lets clients render a
terminal error line for SETUP-phase failures that never emit
a ``response.failed`` event.
:param response_id: Optional response id for terminal-backed status
edges, e.g. ``"codex_turn_abc123"``.
"""
# ``failed`` is sticky against a trailing ``idle``. A turn error is
# terminal — it must not be silently downgraded to ``idle`` by a
# follow-on quiescence signal. This matters for claude-native: the
# turn-error edge comes from the ``StopFailure`` hook (→ ``failed``),
# but the pane then goes quiet, so the PTY-activity watcher emits a
# trailing ``idle`` ~1s later. Without this guard that ``idle`` would
# erase the error state before the user could see it. The next
# ``running`` edge (new activity) clears ``failed`` normally, so the
# error persists exactly until the session does real work again. No
# in-process flow performs a legitimate ``failed`` → ``idle``
# transition (compaction failure publishes ``running`` → ``idle``, not
# ``failed``), so this is a safe, harness-agnostic invariant.
if status == "idle" and _session_status_cache.get(session_id) == "failed":
# Session stays ``failed`` (terminal); the turn is over, so drop any
# tracked in-flight response id rather than leaving it for the
# snapshot to reopen a streaming bubble.
_session_active_response_cache.pop(session_id, None)
return
_session_status_cache[session_id] = status
# Track the in-flight response id for snapshot-based reconnect (see
# _session_active_response_cache). A running/waiting edge that names a
# turn opens it; any idle/failed edge closes it.
if status in ("running", "waiting"):
if response_id is not None:
_session_active_response_cache[session_id] = response_id
else:
_session_active_response_cache.pop(session_id, None)
# Keep the background-shell tally sticky alongside the status (see the
# cache's declaration). A ``Stop`` hook reports an authoritative count
# (``None`` is never sent by it): a positive count sets the tally, and
# an explicit ``0`` clears it so a finished background shell drops the
# indicator on the next turn end. ``None`` means "no information" (the
# trailing PTY-activity ``idle`` carries none) and must NOT wipe the
# count the Stop hook just published. A new turn or a failure clears it.
if background_task_count is not None:
if background_task_count > 0:
_session_background_task_count_cache[session_id] = background_task_count
else:
_session_background_task_count_cache.pop(session_id, None)
elif status in ("running", "failed"):
_session_background_task_count_cache.pop(session_id, None)
event = SessionStatusEvent(
type="session.status",
conversation_id=session_id,
status=status, # type: ignore[arg-type]
response_id=response_id,
error=error,
background_task_count=background_task_count,
)
payload = event.model_dump()
if response_id is None:
payload.pop("response_id", None)
if background_task_count is None:
payload.pop("background_task_count", None)
session_stream.publish(session_id, payload)
def _truncate_label(value: str) -> str:
"""Truncate a label value to fit the ``conversation_labels.value`` column.
Long failure messages (tracebacks, 5xx bodies) overflow the column and
cause a ``DataError`` that silently drops the error reason. Error messages
front-load their signal, so keeping the head and appending an ellipsis
preserves the useful part while flagging that more was dropped. The store
clamps again as a final guard, but truncating here keeps the marker and
makes the call site directly testable.
:param value: The raw string to truncate.
:returns: ``value`` unchanged if it already fits, else the head trimmed to
the column width with a trailing ``…`` to signal truncation.
"""
if len(value) <= _LABEL_VALUE_MAX_LEN:
return value
return value[: _LABEL_VALUE_MAX_LEN - 1] + "…"
async def _persist_session_status_error_labels(
session_id: str,
error: ErrorDetail | None,
conversation_store: ConversationStore,
) -> None:
"""
Persist or clear the reload-visible failure detail for a session status.
``session.status`` is an SSE edge, so its ``error`` object disappears on
reload. Terminal-native sessions can fail before any transcript item is
written, so store the latest failure detail as runner-owned labels and let
snapshots project it as ``last_task_error``. Empty string clears stale
values because the label store is upsert-only.
:param session_id: Session/conversation identifier.
:param error: Failure detail from a ``session.status: failed`` edge, or
``None`` to clear stale error labels on subsequent activity.
:param conversation_store: Store used to upsert labels.
"""
updates = (
{
_LAST_TASK_ERROR_CODE_LABEL_KEY: _truncate_label(error.code),
_LAST_TASK_ERROR_MESSAGE_LABEL_KEY: _truncate_label(error.message),
}
if error is not None
else {
_LAST_TASK_ERROR_CODE_LABEL_KEY: "",
_LAST_TASK_ERROR_MESSAGE_LABEL_KEY: "",
}
)
try:
await asyncio.to_thread(conversation_store.set_labels, session_id, updates)
except Exception:
_logger.exception(
"Failed to persist session status error labels for %s",
session_id,
)
def _last_task_error_from_labels(labels: Mapping[str, str]) -> dict[str, str] | None:
"""
Project runner-owned failure labels into the typed API error shape.
Terminal/native runtimes can fail before they write any transcript item,
so the session-status relay stores the latest failure as durable labels.
This helper is the single server-side boundary where those internal labels
become public ``last_task_error`` data for snapshots and child summaries.
:param labels: Conversation labels, usually after closed-status projection.
:returns: ``{"code": "...", "message": "..."}``, or ``None`` when either
value is absent/cleared.
"""
raw_error_code = labels.get(_LAST_TASK_ERROR_CODE_LABEL_KEY)
raw_error_message = labels.get(_LAST_TASK_ERROR_MESSAGE_LABEL_KEY)
if raw_error_code and raw_error_message:
return {
"code": raw_error_code,
"message": raw_error_message,
}
return None
async def _publish_runner_recovered_status(
session_id: str,
conversation_store: ConversationStore,
*,
require_disconnect_code: bool = False,
) -> None:
"""
Clear a stale failed session status after runner recovery.
Native terminal startup failures are sticky against trailing
``idle`` PTY-quiescence signals so users can see the error. A
later runner bind/session-init success is different: it proves AP
reached a live runner for this session again, so the old failure is
stale and should not keep the conversation marked failed until the
next user turn emits ``running``.
Recovery also clears the durable ``last_task_error`` labels the
disconnect relay persisted. Those labels survive reload so an
ongoing disconnect still projects a "Disconnected" pill, but once
the runner is reachable again the session is healthy and idle — the
pill must drop without waiting for the next ``running`` edge.
An explicit rebind/handshake (a PATCH ``/clear`` or ``/switch``, or
the message-forward session-init) is a user-driven proof the runner
is live, so it clears any stale ``failed`` state. A *passive* tunnel
reconnect is weaker: the process merely came back on its own, saying
nothing about a genuine task error. Callers on that path pass
``require_disconnect_code=True`` so only a ``runner_disconnected``
failure is cleared — a genuine task failure (``response.failed`` / a
setup error with any other ``last_task_error`` code) survives the
reconnect, keeping the red "Failed" pill instead of silently flipping
it back to idle and hiding the error.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conversation_store: Store used to read the persisted error
code and clear the labels on genuine recovery.
:param require_disconnect_code: When ``True`` (passive-reconnect
caller), only clear if the persisted ``last_task_error.code`` is
``runner_disconnected``; when ``False`` (default, explicit
rebind/handshake), clear any stale ``failed`` state. Labels are
cleared in both cases.
:returns: None.
"""
if _session_status_cache.get(session_id) != "failed":
return
# A passive reconnect must distinguish a benign runner disconnect
# from a real task failure: both land the cache on "failed", but only
# the disconnect persists a ``runner_disconnected`` label. The
# reconnect proves the runner is reachable again, which invalidates a
# disconnect failure but says nothing about a genuine task error —
# leave that one alone. Explicit rebinds skip this guard.
if require_disconnect_code:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
last_error = _last_task_error_from_labels(conv.labels) if conv is not None else None
if last_error is None or last_error.get("code") != "runner_disconnected":
return
_session_status_cache[session_id] = "idle"
event = SessionStatusEvent(
type="session.status",
conversation_id=session_id,
status="idle",
error=None,
)
session_stream.publish(session_id, event.model_dump())
await _persist_session_status_error_labels(session_id, None, conversation_store)
def _publish_terminal_pending(session_id: str, pending: bool) -> None:
"""
Publish a typed :class:`SessionTerminalPendingEvent` and update the
cache the snapshot reads.
Every relay site that changes the terminal-spin-up flag funnels
through here so the in-memory ``_session_terminal_pending_cache``
stays coherent with the SSE stream — a client connecting
mid-spin-up seeds the spinner from the snapshot's
``terminal_pending`` field, while already-connected clients update
live off this event.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param pending: ``True`` while the runner is auto-creating the
terminal; ``False`` once it lands or auto-create fails.
"""
# Store only ``True`` entries; delete on clear so the cache never
# accumulates stale ``False`` entries for every terminal-first session
# that has ever completed spin-up. The snapshot getter uses
# ``.get(id, False)`` so absent == False.
if pending:
_session_terminal_pending_cache[session_id] = True
else:
_session_terminal_pending_cache.pop(session_id, None)
event = SessionTerminalPendingEvent(
type="session.terminal_pending",
conversation_id=session_id,
pending=pending,
)
session_stream.publish(session_id, event.model_dump())
def _publish_sandbox_status(session_id: str, stage: str, error: str | None = None) -> None:
"""
Publish a typed :class:`SessionSandboxStatusEvent` and update the
cache the snapshot reads.
Every stage transition of a managed-sandbox launch funnels through
here so the in-memory ``_session_sandbox_status_cache`` stays
coherent with the SSE stream — a client opening the session
mid-launch seeds its progress indicator from the snapshot's
``sandbox_status`` field, while already-connected clients update
live off this event. Thread-safe (``session_stream.publish`` is a
thread-safe broadcast and the cache write is a single dict
assignment), so the launch pipeline may call this from the worker
thread its sandbox exec steps run on.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param stage: The launch stage just entered, e.g.
``"provisioning"`` — one of
:data:`omnigent.server.schemas.SandboxLaunchStage`.
:param error: Failure detail when *stage* is ``"failed"``, e.g.
``"managed sandbox launch failed: spend limit reached"``.
``None`` for non-terminal stages.
"""
# "ready" evicts: from then on the session looks like any
# host-bound session and the snapshot carries no launch state.
# Failures stay cached (mirroring ManagedLaunchTracker retention)
# so a reload after a dead launch still shows the reason.
if stage == "ready":
_session_sandbox_status_cache.pop(session_id, None)
else:
_session_sandbox_status_cache[session_id] = SandboxStatus(stage=stage, error=error)
event = SessionSandboxStatusEvent(
type="session.sandbox_status",
conversation_id=session_id,
stage=stage,
error=error,
)
session_stream.publish(session_id, event.model_dump())
def _publish_mcp_startup(session_id: str, servers: dict[str, McpServerStartup]) -> None:
"""
Publish a typed :class:`SessionMcpStartupEvent` to the live stream.
Fired when a native forwarder reports harness MCP-server startup
progress via ``external_mcp_startup``, so the web UI can show
per-server startup state while the harness boots instead of an
apparently hung session. Also updates the snapshot cache so a client
opening the session mid-startup seeds the band from the snapshot's
``mcp_startup`` field; a map with nothing left to show — empty, or
every server ``ready`` — evicts the cache entry, mirroring the web
store's all-ready clear so a reloading client never seeds a band
that renders nothing.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param servers: Latest per-server startup map, e.g.
``{"safe": McpServerStartup(status="starting", error=None)}``.
"""
if any(record.status != "ready" for record in servers.values()):
_session_mcp_startup_cache[session_id] = servers
else:
_session_mcp_startup_cache.pop(session_id, None)
event = SessionMcpStartupEvent(
type="session.mcp_startup",
conversation_id=session_id,
servers=servers,
)
session_stream.publish(session_id, event.model_dump())
def _publish_runner_skills(session_id: str) -> None:
"""
Publish a typed :class:`SessionSkillsEvent` to the live stream.
Fired the moment the background runner-skills fetch
(:func:`_load_runner_skills`) populates the per-session cache, so a
connected client can re-read the session snapshot and fill its
slash-command menu instead of waiting for the next bind. Carries no
payload beyond the conversation id — it is a "skills resolved,
re-read the snapshot" nudge; the snapshot's cache-backed ``skills``
field stays the source of truth.
No-op when no client is subscribed (``session_stream`` has no
buffer): a client binding later reads the now-warm snapshot directly.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
"""
event = SessionSkillsEvent(
type="session.skills",
conversation_id=session_id,
)
session_stream.publish(session_id, event.model_dump())
def _publish_model_options(session_id: str) -> None:
"""
Publish a typed :class:`SessionModelOptionsEvent` to the live stream.
Fired when the background Codex ``model/list`` fetch populates the
per-session model-options cache. Connected clients re-read the session
snapshot and apply its cache-backed ``model_options`` field.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
"""
event = SessionModelOptionsEvent(
type="session.model_options",
conversation_id=session_id,
)
session_stream.publish(session_id, event.model_dump())
def _invalidate_runner_backed_snapshot_state(
session_id: str,
*,
cancel_inflight: bool,
) -> None:
"""
Drop runner-derived session snapshot overlays for one session.
These fields are discovered from the bound runner (skills and the
codex-native ``model/list`` catalog), so browser reloads can ask the
next snapshot to refresh them from the live session instead of serving
stale AP-process memory. Runner teardown additionally cancels any
in-flight fetch so a dead runner cannot land a late stale value.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param cancel_inflight: Whether to cancel currently-running fetches.
Use ``True`` when a runner disconnects; use ``False`` for browser
refreshes so concurrent page-load callers do not cancel each other.
"""
_runner_skills_cache.pop(session_id, None)
if cancel_inflight:
inflight = _runner_skills_inflight.pop(session_id, None)
if inflight is not None:
inflight.cancel()
_model_options_cache.pop(session_id, None)
if cancel_inflight:
codex_inflight = _model_options_inflight.pop(session_id, None)
if codex_inflight is not None:
codex_inflight.cancel()
def _publish_changed_files_invalidated(session_id: str, environment_id: str = "default") -> None:
"""
Publish a coarse filesystem-change invalidation to the live stream.
The event tells web clients to refetch visible filesystem views
for the environment instead of polling the tree while a session is
active. It is intentionally coarse because git-mode workspaces can
only answer "the working tree changed" cheaply, not per-directory
deltas.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param environment_id: Environment resource id,
e.g. ``"default"``.
"""
session_stream.publish(
session_id,
{
"type": "session.changed_files.invalidated",
"session_id": session_id,
"environment_id": environment_id,
},
)
def _publish_interrupted(session_id: str, response_id: str | None = None) -> None:
"""
Publish a ``session.interrupted`` event to the live stream.
The event is co-emitted with ``response.incomplete`` (reason
``"user_interrupt"``) by the runtime cancel handler so off-the-
shelf Responses parsers still close cleanly. This helper is
responsible only for the session-level signal — not the
response-level one.
:param session_id: The session/conversation identifier whose
stream should receive the event, e.g. ``"conv_abc123"``.
:param response_id: Optional response id for terminal-backed
interrupted turns, e.g. ``"codex_turn_abc123"``.
"""
event = SessionInterruptedEvent(
type="session.interrupted",
data=SessionInterruptedPayload(
requested_at=int(time.time()),
response_id=response_id,
),
)
payload = event.model_dump()
if response_id is None:
data = payload.get("data")
if isinstance(data, dict):
data.pop("response_id", None)
session_stream.publish(session_id, payload)
def _publish_session_superseded(session_id: str, target_conversation_id: str) -> None:
"""
Publish a ``session.superseded`` event to the live stream.
Emitted when a Claude ``/clear`` rotates a session away (see
``_post_clear_supersession`` in
``omnigent/claude_native_forwarder.py``): a client actively viewing
``session_id`` follows to ``target_conversation_id``. Live-only —
there is no SSE replay, so a client connecting after the rotation
relies on the persisted notice message instead.
:param session_id: The superseded (old) conversation id whose stream
should receive the event, e.g. ``"conv_old"``.
:param target_conversation_id: The conversation to redirect to, e.g.
``"conv_new"``.
"""
event = SessionSupersededEvent(
type="session.superseded",
conversation_id=session_id,
target_conversation_id=target_conversation_id,
reason="clear",
)
session_stream.publish(session_id, event.model_dump())
# Discard any unconsumed pending inputs on the superseded session — notably
# the ``/clear`` the user typed in the web UI. ``/clear`` is never mirrored
# back as a committed item (the session rotated away), so its pending entry
# would otherwise linger forever as a stuck optimistic bubble, re-hydrating
# from the snapshot on every reload of the old chat. Live viewers already
# drop the bubble on the ``session.superseded`` event above; this stops it
# coming back. We deliberately do NOT emit ``session.input.consumed`` (that
# would commit ``/clear`` as a user message) — the persisted clear notice
# already explains the rotation, so the input is simply abandoned.
discarded = 0
while pending_inputs.resolve_oldest(session_id) is not None:
discarded += 1
if discarded:
_logger.info(
"Discarded %d unconsumed pending input(s) on superseded session %s",
discarded,
session_id,
)
async def _get_runner_client(
session_id: str,
runner_router: RunnerRouter | None,
) -> httpx.AsyncClient | None:
"""
Get an HTTP client for the runner bound to a session.
Uses the ``RunnerRouter`` to resolve the pinned runner. Falls
back to the in-process runner client for test setups.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param runner_router: The ``RunnerRouter`` instance, or
``None`` for in-process setups.
:returns: An ``httpx.AsyncClient`` pointed at the runner,
or ``None`` if no runner is available.
"""
from omnigent.runtime import get_runner_client
if runner_router is not None:
try:
routed = runner_router.client_for_session_resources(
session_id,
)
return routed.client
except (LookupError, httpx.HTTPError, OmnigentError):
_logger.debug(
"No runner bound for session=%s",
session_id,
)
return None
return cast("httpx.AsyncClient | None", get_runner_client())
async def _wait_for_runner_client(
session_id: str,
runner_router: RunnerRouter | None,
tunnel_registry: TunnelRegistry | None,
*,
runner_id: str | None,
timeout_s: float,
runner_exit_reports: RunnerExitReports | None = None,
) -> httpx.AsyncClient | None:
"""
Wait until a runner connects, then resolve the session's runner client.
The tunnel registry owns the event-driven "runner connected" signal.
After that signal fires, this helper intentionally resolves through
:func:`_get_runner_client` instead of constructing a client directly
from the registry session: the router re-checks the conversation's
current ``runner_id`` binding and preserves the existing ownership /
capability checks.
When ``runner_exit_reports`` is supplied, the wait also ends the
moment the daemon reports this runner died (``host.runner_exited``).
That report is the authoritative "this runner is busted" signal — a
crashed runner can never connect, so waiting out ``timeout_s`` would
only delay the caller's failure handling. Returning ``None`` on the
report (same as a timeout) lets the caller persist the failure the
instant we are convinced, neither speculatively early nor a full
timeout late.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param runner_router: The ``RunnerRouter`` instance, or ``None`` for
in-process test setups.
:param tunnel_registry: The server's ``TunnelRegistry`` instance, or
``None`` in test setups without runner tunnels.
:param runner_id: Runner id expected to connect, e.g.
``"runner_0123456789abcdef"``.
:param timeout_s: Maximum seconds to wait, e.g. ``3.0``.
:param runner_exit_reports: Crash-report store consulted to abort the
wait early when this runner is reported dead. ``None`` keeps the
plain wait-to-timeout behavior.
:returns: A runner HTTP client if one becomes available, otherwise
``None`` (timed out, or the runner was reported dead).
"""
if runner_id is None:
return None
if tunnel_registry is None:
return await _get_runner_client(session_id, runner_router)
if runner_exit_reports is None:
session = await tunnel_registry.wait_for_runner(runner_id, timeout_s=timeout_s)
return None if session is None else await _get_runner_client(session_id, runner_router)
# Race the event-driven connect signal against the crash-report poll;
# whichever resolves first wins. A report means the runner is busted —
# stop waiting and let the caller fail the turn now.
connect_task = asyncio.ensure_future(
tunnel_registry.wait_for_runner(runner_id, timeout_s=timeout_s)
)
try:
while not connect_task.done():
if runner_exit_reports.get(runner_id) is not None:
return None
await asyncio.wait({connect_task}, timeout=_RUNNER_CONVICTION_POLL_S)
finally:
if not connect_task.done():
connect_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await connect_task
session = connect_task.result()
return None if session is None else await _get_runner_client(session_id, runner_router)
async def _validate_session_workspace(
*,
user_id: str | None,
host_id: str,
workspace: str | None,
agent: Any,
agent_cache: AgentCache | None,
request: Request,
) -> str:
"""
Validate a session's workspace against the agent's os_env boundary.
Wraps the seven-step validation in
:mod:`omnigent.server.routes._workspace_validation` and
raises :class:`OmnigentError` on failure so the route layer
converts the error into a 400 response with a clear message.
See ``designs/SESSION_WORKSPACE_SELECTION.md`` for the full
semantic spec.
The caller's host ownership is checked BEFORE the ``host.stat``
round-trip the validation performs, so a non-owner never reaches
another user's host (raises 403/404 via ``resolve_host_owner``).
:param user_id: Authenticated caller, e.g.
``"alice@example.com"``, or ``None`` when auth is disabled.
:param host_id: Stable host id, e.g. ``"host_a1b2c3d4..."``.
:param workspace: Absolute path supplied by the caller, e.g.
``"/Users/corey/universe/src/foo"``. ``None`` is rejected
with the "workspace required when host_id is set" message.
:param agent: The agent the session binds to. Used to load the
bundle and read ``os_env.cwd`` for boundary computation.
:param agent_cache: Cache for loading parsed agent specs from
bundle storage. Required because session-create needs the
spec; ``None`` is treated as a server config error.
:param request: FastAPI request; ``request.app.state``
carries the host registry and host store.
:returns: The canonicalized workspace path that should be
stored on the session row, e.g.
``"/Users/corey/universe/src/foo"`` (realpath; symlinks
already resolved by the host).
:raises OmnigentError: With ``ErrorCode.INVALID_INPUT`` on
any validation failure (offline host, missing path,
outside boundary, missing subdir). With
``ErrorCode.INTERNAL_ERROR`` if ``agent_cache`` is unset.
"""
from omnigent.server.routes._workspace_validation import (
WorkspaceValidationError,
validate_workspace,
)
if workspace is None:
raise OmnigentError(
"workspace required when host_id is set",
code=ErrorCode.INVALID_INPUT,
)
if not workspace.startswith("/"):
raise OmnigentError(
"workspace must be an absolute path starting with /",
code=ErrorCode.INVALID_INPUT,
)
if agent_cache is None:
# Should never happen in production — the route factory
# always wires an agent cache. Fail loud rather than
# silently skipping validation, which would let bad
# workspaces through.
raise OmnigentError(
"workspace validation requires an agent cache",
code=ErrorCode.INTERNAL_ERROR,
)
host_registry = getattr(request.app.state, "host_registry", None)
if host_registry is None:
raise OmnigentError(
"host registry is not configured on this server",
code=ErrorCode.INTERNAL_ERROR,
)
# Authorize host ownership FIRST — before loading the agent spec or
# the host.stat round-trip below. A non-owner must be rejected
# (403/404 via the shared resolve_host_owner) before we touch the
# host or even read the agent bundle (cross-user host probe). The
# returned host also gives the display name for error messages.
from omnigent.server.routes._host_launch import resolve_host_owner
host_name: str | None = None
host_store_inst = getattr(request.app.state, "host_store", None)
if host_store_inst is not None:
host = await asyncio.to_thread(
resolve_host_owner,
user_id=user_id,
host_id=host_id,
host_store=host_store_inst,
)
host_name = host.name
# Read the agent's os_env.cwd — None when the spec has no
# os_env block (headless agents). Headless agents have no
# filesystem access at all but still get launched on hosts
# for sessions that don't need it; treat their cwd as
# relative-equivalent so the boundary is unrestricted.
spec_cwd: str | None = None
if agent.bundle_location is not None:
try:
loaded = await asyncio.to_thread(
agent_cache.load,
agent.id,
agent.bundle_location,
)
os_env = getattr(loaded.spec, "os_env", None)
spec_cwd = getattr(os_env, "cwd", None) if os_env is not None else None
except Exception as exc:
_logger.exception("Failed to load agent spec for workspace validation")
raise OmnigentError(
f"failed to load agent spec: {exc}",
code=ErrorCode.INTERNAL_ERROR,
) from exc
try:
return await validate_workspace(
host_registry=host_registry,
host_id=host_id,
workspace=workspace,
spec_cwd=spec_cwd,
host_name_for_errors=host_name,
)
except WorkspaceValidationError as exc:
raise OmnigentError(
exc.message,
code=ErrorCode.INVALID_INPUT,
) from exc
@dataclass
class _HostLaunchAttempt:
"""
Outcome of a relaunch ``host.launch_runner`` round-trip.
:param runner_id: The token-bound runner id minted for this attempt,
e.g. ``"runner_token_abc123..."``. Always set (the binding is
rotated before the frame is sent), even when the host refused.
:param error_code: Structured failure category from the host's result
frame, e.g. ``"harness_not_configured"``; ``None`` on a successful
launch, on a timeout waiting for the result, or when the host sent
no code.
:param error: Human-readable failure message from the host, e.g.
``"harness 'codex' is not configured on host 'laptop' — run
`omnigent setup` ..."``; ``None`` when there was no error.
"""
runner_id: str
error_code: str | None = None
error: str | None = None
async def _launch_runner_on_host(
conv: Conversation,
conversation_store: ConversationStore,
host_registry: HostRegistry,
host_conn: HostConnection,
) -> _HostLaunchAttempt:
"""
Ask a host to spawn a runner for a session and capture the result.
Generates a new binding token, writes the runner_id to the session
row, sends ``host.launch_runner`` (carrying the session's canonical
harness so the host can refuse an unconfigured one), and waits up to
:data:`_HOST_LAUNCH_RESULT_TIMEOUT_S` for the host's result frame.
Does NOT wait for the runner to *connect* — the caller polls for that
separately; this only captures the spawn/refuse verdict so a
structured refusal (harness not configured) can be surfaced instead
of silently timing out as ``RUNNER_UNAVAILABLE``.
:param conv: The conversation that needs a runner.
:param conversation_store: Store for updating ``runner_id``.
:param host_registry: In-memory ``HostRegistry``.
:param host_conn: The live ``HostConnection`` for the host.
:returns: The :class:`_HostLaunchAttempt` — the new runner id plus any
structured refusal from the host.
"""
from omnigent.host.frames import HostLaunchRunnerFrame, encode_host_frame
from omnigent.runner.identity import token_bound_runner_id
binding_token = secrets.token_urlsafe(32)
new_runner_id = token_bound_runner_id(binding_token)
await asyncio.to_thread(
conversation_store.replace_runner_id,
conv.id,
new_runner_id,
)
# Pull workspace from the session row — populated and validated
# at session create per designs/SESSION_WORKSPACE_SELECTION.md.
# The check constraint guarantees workspace is non-NULL when
# host_id is set, so this assertion is a tripwire for any path
# that bypassed the validation.
if conv.workspace is None: # pragma: no cover — constraint guards
_logger.error(
"session %s has host_id=%s but workspace is NULL — schema "
"constraint should have prevented this",
conv.id,
conv.host_id,
)
return _HostLaunchAttempt(runner_id=new_runner_id)
request_id = secrets.token_hex(8)
launch_future: asyncio.Future[dict[str, str | None]] = (
asyncio.get_running_loop().create_future()
)
host_conn.pending_launches[request_id] = launch_future
launch_frame = encode_host_frame(
HostLaunchRunnerFrame(
request_id=request_id,
binding_token=binding_token,
workspace=conv.workspace,
session_id=conv.id,
# Canonical harness (see _resolve_harness) so the host runs the
# same configuration check it does at create-time launch. None
# (agent not resolvable) skips the host-side check — fail open.
harness=_resolve_harness(conv),
)
)
try:
host_registry.send_text(host_conn, launch_frame)
except ConnectionError:
host_conn.pending_launches.pop(request_id, None)
_logger.warning(
"Host %s connection lost while launching runner for %s",
conv.host_id,
conv.id,
)
return _HostLaunchAttempt(runner_id=new_runner_id)
try:
result = await asyncio.wait_for(
launch_future,
timeout=_HOST_LAUNCH_RESULT_TIMEOUT_S,
)
except asyncio.TimeoutError:
# No result yet — fall through to the caller's connect wait, which
# preserves the prior fire-and-forget timing for a slow-but-fine host.
host_conn.pending_launches.pop(request_id, None)
return _HostLaunchAttempt(runner_id=new_runner_id)
if result.get("status") == "failed":
return _HostLaunchAttempt(
runner_id=new_runner_id,
error_code=result.get("error_code"),
error=result.get("error"),
)
return _HostLaunchAttempt(runner_id=new_runner_id)
# Strong references to in-flight background managed-launch tasks.
# asyncio.create_task results are weakly held by the loop; without a
# reference here a long provision could be garbage-collected mid-flight.
# Cancelled at server shutdown via cancel_managed_launch_tasks().
_managed_launch_tasks: set[asyncio.Task[None]] = set()
async def cancel_managed_launch_tasks() -> None:
"""
Cancel and await every in-flight background managed launch.
Lifespan-teardown hook: without it, a slow provision outlives the
ASGI shutdown and dies wherever the loop teardown happens to kill
it. Cancellation is deterministic teardown of the TASK only — an
already-provisioned sandbox is not terminated here (there is no
time budget for provider calls during shutdown); its armed launch
token expires with the provider lifetime cap that also reaps the
sandbox.
:returns: None once every task has settled (cancellations and any
in-flight failures are absorbed via ``return_exceptions``).
"""
tasks = list(_managed_launch_tasks)
if not tasks:
return
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def _run_managed_launch(
*,
session_id: str,
owner: str,
sandbox_config: ManagedSandboxConfig,
repo: RepoWorkspace | None,
tracker: ManagedLaunchTracker,
conversation_store: ConversationStore,
host_store: HostStore,
host_registry: HostRegistry | None,
tunnel_registry: TunnelRegistry | None,
relaunch_host: Host | None = None,
) -> None:
"""
Provision a managed sandbox for a session in the background.
The ``host_type="managed"`` create returns before the sandbox
exists; this task carries the rest of the pipeline: provision the
sandbox + start the host (:func:`launch_managed_host`), bind the
host + workspace to the session row, launch a runner on the host,
and wait for that runner's tunnel so a message POST rendezvousing
on *tracker* can forward immediately once the launch settles.
The same pipeline serves a sandbox RELAUNCH (*relaunch_host* set):
a message arriving for a session whose managed sandbox died kicks
this task with the existing host row, and
:func:`relaunch_managed_host` provisions a new sandbox generation
under the same host identity instead of minting a new one.
Every exit path settles the tracker entry — success via
``finish`` (the session then looks like any host-bound session),
failure via ``fail`` with the reason a waiting message POST
reports. A session deleted mid-provision is detected at the bind
step and the fresh sandbox is torn down.
Server shutdown cancels this task (the lifespan teardown calls
:func:`cancel_managed_launch_tasks`); an already-provisioned
sandbox then leaks until the provider's lifetime cap reaps it
(the armed launch token expires with the same cap).
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param owner: User the managed host acts for — the session
creator, e.g. ``"alice@example.com"`` (or the reserved local
user on auth-disabled servers).
:param sandbox_config: The deployment's sandbox config.
:param repo: Parsed repository-URL workspace to clone inside the
sandbox, or ``None`` for an empty workspace.
:param tracker: The app's :class:`ManagedLaunchTracker`; this
session's entry was registered by the caller.
:param conversation_store: Store holding the session row.
:param host_store: Persistent host registrations.
:param host_registry: Live host tunnels, used to send the
launch-runner frame. ``None`` in minimal test wirings.
:param tunnel_registry: Runner-tunnel registry used to await the
launched runner's connection. ``None`` in minimal test
wirings (the rendezvous then settles at frame-send).
:param relaunch_host: Existing managed host row to relaunch a new
sandbox generation for, or ``None`` for a first launch (a
fresh host identity is minted).
"""
managed = await _provision_managed_sandbox(
session_id=session_id,
owner=owner,
sandbox_config=sandbox_config,
repo=repo,
tracker=tracker,
host_store=host_store,
relaunch_host=relaunch_host,
)
if managed is None:
return
await _bind_and_launch_managed_runner(
session_id=session_id,
managed=managed,
sandbox_config=sandbox_config,
tracker=tracker,
conversation_store=conversation_store,
host_store=host_store,
host_registry=host_registry,
tunnel_registry=tunnel_registry,
)
async def _provision_managed_sandbox(
*,
session_id: str,
owner: str,
sandbox_config: ManagedSandboxConfig,
repo: RepoWorkspace | None,
tracker: ManagedLaunchTracker,
host_store: HostStore,
relaunch_host: Host | None,
) -> ManagedHostLaunch | None:
"""
Run the provision phase of a background managed launch.
Dispatches to :func:`relaunch_managed_host` (existing host row)
or :func:`launch_managed_host` (fresh identity) and converts any
failure into a settled tracker entry — the background task has no
caller to raise to.
:param session_id: Session/conversation identifier.
:param owner: User the managed host acts for.
:param sandbox_config: The deployment's sandbox config.
:param repo: Repository workspace to clone, or ``None``.
:param tracker: The app's launch tracker (failed here on error).
:param host_store: Persistent host registrations.
:param relaunch_host: Existing host row for a relaunch, or
``None`` for a first launch.
:returns: The launch result, or ``None`` when the launch failed
(the tracker entry is already settled with the reason).
"""
from omnigent.server.managed_hosts import launch_managed_host, relaunch_managed_host
def _on_stage(stage: str) -> None:
"""
Relay a launch-pipeline stage to the session's progress surface.
Passed into the launch helpers, which may invoke it from the
worker thread their sandbox exec steps run on —
:func:`_publish_sandbox_status` is thread-safe.
:param stage: The stage just entered, e.g. ``"cloning"``.
"""
_publish_sandbox_status(session_id, stage)
try:
if relaunch_host is not None:
return await relaunch_managed_host(
config=sandbox_config,
host=relaunch_host,
host_store=host_store,
repo=repo,
on_stage=_on_stage,
)
return await launch_managed_host(
config=sandbox_config,
owner=owner,
host_store=host_store,
repo=repo,
on_stage=_on_stage,
)
except HTTPException as exc:
_logger.warning(
"Managed sandbox launch failed for session %s: %s",
session_id,
exc.detail,
)
tracker.fail(session_id, str(exc.detail))
_publish_sandbox_status(session_id, "failed", str(exc.detail))
return None
except Exception:
# Broad on purpose: this is a fire-and-forget task — an
# unexpected error must settle the tracker (or a waiting
# message POST hangs until its timeout) and must not escape
# as an unhandled-task traceback.
_logger.exception(
"Managed sandbox launch crashed for session %s",
session_id,
)
tracker.fail(session_id, "internal error during managed sandbox launch")
_publish_sandbox_status(
session_id, "failed", "internal error during managed sandbox launch"
)
return None
async def _bind_and_launch_managed_runner(
*,
session_id: str,
managed: ManagedHostLaunch,
sandbox_config: ManagedSandboxConfig,
tracker: ManagedLaunchTracker,
conversation_store: ConversationStore,
host_store: HostStore,
host_registry: HostRegistry | None,
tunnel_registry: TunnelRegistry | None,
) -> None:
"""
Bind a provisioned managed host to its session and launch a runner.
The bind step doubles as the delete-race detector: a session
deleted while its sandbox provisioned surfaces here as
``ConversationNotFoundError``, and the fresh sandbox is torn down
(the delete route could not see the host binding yet). Settles
the tracker on every path.
:param session_id: Session/conversation identifier.
:param managed: The provision result (host id + workspace).
:param sandbox_config: The deployment's sandbox config.
:param tracker: The app's launch tracker.
:param conversation_store: Store holding the session row.
:param host_store: Persistent host registrations.
:param host_registry: Live host tunnels, used to send the
launch-runner frame. ``None`` in minimal test wirings.
:param tunnel_registry: Runner-tunnel registry used to await the
launched runner's connection. ``None`` in minimal test
wirings (the rendezvous then settles at frame-send).
"""
from omnigent.server.managed_hosts import terminate_managed_host
try:
conv = await asyncio.to_thread(
conversation_store.set_host_id,
session_id,
managed.host_id,
managed.workspace,
)
except ConversationNotFoundError:
# The session was deleted while its sandbox provisioned. The
# delete route couldn't see the host binding yet, so tear the
# fresh sandbox down here (deleting the host row also revokes
# its launch token).
_logger.info(
"Session %s was deleted during managed provisioning; "
"terminating fresh sandbox on host %s",
session_id,
managed.host_id,
)
host = await asyncio.to_thread(host_store.get_host, managed.host_id)
if host is not None:
await terminate_managed_host(host, host_store, sandbox_config)
tracker.fail(session_id, "session was deleted while its sandbox was provisioning")
_publish_sandbox_status(
session_id, "failed", "session was deleted while its sandbox was provisioning"
)
return
# Host bound; what remains is launching the runner and waiting
# for its tunnel.
_publish_sandbox_status(session_id, "connecting")
runner_id: str | None = None
if host_registry is not None:
host_conn = host_registry.get(managed.host_id)
if host_conn is not None:
launch_attempt = await _launch_runner_on_host(
conv,
conversation_store,
host_registry,
host_conn,
)
if launch_attempt.error_code == _HARNESS_NOT_CONFIGURED_ERROR_CODE:
# The sandbox image should bake in the harness, but if the
# host refuses, fail the launch loudly (mirroring the
# delete-during-provisioning path) rather than waiting out
# the connect timeout for a runner that will never appear.
reason = launch_attempt.error or "harness not configured on the sandbox host"
tracker.fail(session_id, reason)
_publish_sandbox_status(session_id, "failed", reason)
return
runner_id = launch_attempt.runner_id
if runner_id is not None and tunnel_registry is not None:
# Wait for the runner tunnel before settling so a rendezvoused
# message POST resolves its runner client on the first try. A
# timeout still settles successfully — the host is bound, and
# post_event's normal host-relaunch path owns dead runners.
await tunnel_registry.wait_for_runner(
runner_id,
timeout_s=_HOST_RELAUNCH_RUNNER_CONNECT_TIMEOUT_S,
)
tracker.finish(session_id)
_publish_sandbox_status(session_id, "ready")
async def _await_settled_managed_launch(launch: ManagedLaunch) -> None:
"""
Block until a managed launch settles, raising its failure.
The rendezvous a message POST takes when it races a background
managed launch (create-time provisioning or a dead-sandbox
relaunch): resolve as soon as the launch settles, surface the
recorded reason when it failed, and give up with a clear retry
hint when the launch outlives the rendezvous budget.
:param launch: The session's tracker entry.
:raises OmnigentError: 503 when the launch failed or is still
running at the timeout.
"""
from omnigent.server.managed_hosts import MANAGED_LAUNCH_RENDEZVOUS_TIMEOUT_S
try:
await asyncio.wait_for(
launch.settled.wait(),
timeout=MANAGED_LAUNCH_RENDEZVOUS_TIMEOUT_S,
)
except asyncio.TimeoutError:
raise OmnigentError(
"The session's managed sandbox is still provisioning; try again shortly",
code=ErrorCode.RUNNER_UNAVAILABLE,
) from None
if launch.error is not None:
raise OmnigentError(
f"The session's managed sandbox failed to launch: {launch.error}",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
async def _maybe_relaunch_managed_sandbox(
*,
session_id: str,
conv: Conversation,
app_state: Any,
conversation_store: ConversationStore,
) -> bool:
"""
Relaunch a dead managed sandbox for a session, if it has one.
Called from the message-dispatch relaunch path when the session's
host tunnel is gone. For an external (laptop) host that is the end
of the line, but a managed host's sandbox is RELAUNCHABLE: the
host row is durable, so a new sandbox generation can be provisioned
under the same host identity — "send a message to wake the
sandbox", mirroring how a message relaunches a dead runner on a
live host.
Single-flighted through the app's :class:`ManagedLaunchTracker`:
the first message kicks the background relaunch, concurrent and
later messages rendezvous on the same entry (the check-then-begin
below has no ``await`` between check and begin, so it is atomic on
the event loop). A previously FAILED attempt's retained entry is
replaced — every new message retries.
:param session_id: Session/conversation identifier.
:param conv: The session row (``host_id`` set; caller guards).
:param app_state: ``request.app.state`` — supplies the host store,
sandbox config, tracker, and registries.
:param conversation_store: Store holding the session row.
:returns: ``True`` when a relaunch engaged and settled
successfully (the session row is re-bound; re-resolve the
runner client). ``False`` when the host is not a managed
sandbox or managed hosts are not configured — the caller
falls through to the normal unavailable handling.
:raises OmnigentError: 503 when the relaunch failed or timed out.
"""
host_store = getattr(app_state, "host_store", None)
sandbox_config = getattr(app_state, "sandbox_config", None)
tracker = getattr(app_state, "managed_launches", None)
if host_store is None or sandbox_config is None or tracker is None:
return False
if conv.host_id is None:
return False
host = await asyncio.to_thread(host_store.get_host, conv.host_id)
if host is None or host.sandbox_provider is None:
return False
if await asyncio.to_thread(host_store.is_online, conv.host_id):
# The host row still reads live (status online with a fresh
# heartbeat) — the missing tunnel is likely a transient blip
# on THIS replica and the host will reconnect on its own
# backoff. Replacing the sandbox now would destroy a healthy
# workspace; let the message fail unavailable instead. A dead
# sandbox goes stale within the host liveness TTL, after which
# the next message lands here and relaunches.
return False
launch = tracker.get(session_id)
if launch is None or launch.settled.is_set():
# A resumable managed host whose sandbox merely idle-stopped is WOKEN
# in place (resume: same sandbox + workspace volume) rather than
# relaunched onto a fresh empty sandbox — same gate the wake itself
# uses (host_resume_supported). Both run in the background through this
# same tracker, so the message parks on the rendezvous either way; only
# the provision step differs.
if host_resume_supported(host, sandbox_config):
_kick_managed_wake(
session_id=session_id,
conv=conv,
sandbox_config=sandbox_config,
tracker=tracker,
conversation_store=conversation_store,
host_store=host_store,
app_state=app_state,
)
else:
_kick_managed_relaunch(
session_id=session_id,
conv=conv,
host=host,
sandbox_config=sandbox_config,
tracker=tracker,
conversation_store=conversation_store,
host_store=host_store,
app_state=app_state,
)
launch = tracker.get(session_id)
if launch is not None:
await _await_settled_managed_launch(launch)
return True
def _kick_managed_relaunch(
*,
session_id: str,
conv: Conversation,
host: Host,
sandbox_config: ManagedSandboxConfig,
tracker: ManagedLaunchTracker,
conversation_store: ConversationStore,
host_store: HostStore,
app_state: Any,
) -> None:
"""
Register and spawn the background relaunch for a dead sandbox.
Recovers the session's create-time repository workspace from its
label so the fresh generation re-clones it, registers the tracker
entry, and schedules :func:`_run_managed_launch` with the existing
host row.
:param session_id: Session/conversation identifier.
:param conv: The session row (supplies the repo label).
:param host: The dead managed host row to relaunch.
:param sandbox_config: The deployment's sandbox config.
:param tracker: The app's launch tracker.
:param conversation_store: Store holding the session row.
:param host_store: Persistent host registrations.
:param app_state: ``request.app.state`` — supplies the registries.
"""
from omnigent.server.managed_hosts import MANAGED_REPO_LABEL_KEY, parse_repo_workspace
# Re-clone the repository the session was created with so the
# fresh generation's workspace matches the create-time state.
# The label holds the raw create-time value, already validated
# by the create's parse — a parse failure here means the label
# was tampered with, and the relaunch proceeds with an empty
# workspace rather than dying.
repo = None
raw_repo = conv.labels.get(MANAGED_REPO_LABEL_KEY)
if raw_repo is not None:
try:
repo = parse_repo_workspace(raw_repo)
except ValueError:
_logger.warning(
"Session %s has an unparseable %s label (%r); relaunching with an empty workspace",
session_id,
MANAGED_REPO_LABEL_KEY,
raw_repo,
)
_logger.info(
"Managed sandbox for session %s (host %s) is gone; relaunching a new generation",
session_id,
conv.host_id,
)
tracker.begin(session_id)
# Seed the relaunch's progress indicator immediately — the user is
# typically watching the session page when "wake the sandbox" runs.
_publish_sandbox_status(session_id, "provisioning")
relaunch_task = asyncio.create_task(
_run_managed_launch(
session_id=session_id,
owner=host.owner,
sandbox_config=sandbox_config,
repo=repo,
tracker=tracker,
conversation_store=conversation_store,
host_store=host_store,
host_registry=getattr(app_state, "host_registry", None),
tunnel_registry=getattr(app_state, "tunnel_registry", None),
relaunch_host=host,
)
)
_managed_launch_tasks.add(relaunch_task)
relaunch_task.add_done_callback(_managed_launch_tasks.discard)
def _kick_managed_wake(
*,
session_id: str,
conv: Conversation,
sandbox_config: ManagedSandboxConfig,
tracker: ManagedLaunchTracker,
conversation_store: ConversationStore,
host_store: HostStore,
app_state: Any,
) -> None:
"""
Register and spawn the background WAKE for a dormant resumable host.
Unlike :func:`_kick_managed_relaunch` (which provisions a NEW sandbox and
re-clones the repo), this resumes the SAME stopped sandbox in place
(reattaching its persistent volume) — so it does NOT re-bind the session's
host/workspace. Reuses the launch tracker so a racing message POST parks on
the rendezvous instead of forwarding into a half-woken host or triggering a
workspace-destroying relaunch.
:param session_id: Session/conversation identifier.
:param conv: The session row bound to the dormant host.
:param sandbox_config: The deployment's sandbox config.
:param tracker: The app's launch tracker.
:param conversation_store: Store holding the session row.
:param host_store: Persistent host registrations.
:param app_state: ``request.app.state`` — supplies the registries.
"""
_logger.info(
"Managed host %s (session %s) is dormant but resumable; waking in background",
conv.host_id,
session_id,
)
tracker.begin(session_id)
# Seed the progress indicator immediately — the user is watching the
# session page when the wake fires (the composer let them send into a
# host_asleep session).
_publish_sandbox_status(session_id, "provisioning")
wake_task = asyncio.create_task(
_run_managed_wake(
session_id=session_id,
conv=conv,
sandbox_config=sandbox_config,
tracker=tracker,
conversation_store=conversation_store,
host_store=host_store,
host_registry=getattr(app_state, "host_registry", None),
tunnel_registry=getattr(app_state, "tunnel_registry", None),
)
)
_managed_launch_tasks.add(wake_task)
wake_task.add_done_callback(_managed_launch_tasks.discard)
async def _run_managed_wake(
*,
session_id: str,
conv: Conversation,
sandbox_config: ManagedSandboxConfig,
tracker: ManagedLaunchTracker,
conversation_store: ConversationStore,
host_store: HostStore,
host_registry: HostRegistry | None,
tunnel_registry: TunnelRegistry | None,
) -> None:
"""
Wake a dormant resumable managed host in the background, settling the
tracker so a parked message POST forwards once the host is back.
Resumes the stopped sandbox in place (:func:`resume_managed_host`: resume +
re-arm token + re-exec host, preserving the workspace volume — no re-bind),
then launches a runner on the woken host and waits for its tunnel so a
rendezvoused message resolves on the first try. The parked send runs the
session-init handshake (transcript forwarder attach) before forwarding, so
the first post-wake turn is mirrored + persisted.
Mirrors :func:`_bind_and_launch_managed_runner` (launch runner + wait
tunnel + settle) but with a resume instead of a fresh provision + bind.
Every exit settles the tracker — a failed wake does NOT tear the sandbox
down (the volume is the user's), it just surfaces the reason to the waiter.
:param session_id: Session/conversation identifier.
:param conv: The session row bound to the dormant host.
:param sandbox_config: The deployment's sandbox config.
:param tracker: The app's launch tracker (this session's entry was begun
by the caller).
:param conversation_store: Store holding the session row.
:param host_store: Persistent host registrations.
:param host_registry: Live host tunnels, used to send the launch-runner
frame. ``None`` in minimal test wirings.
:param tunnel_registry: Runner-tunnel registry used to await the launched
runner's connection. ``None`` in minimal test wirings.
"""
from omnigent.server.managed_hosts import resume_managed_host
try:
# Wake the same sandbox in place; resume_managed_host is single-flight
# per host and a no-op if it's already online.
await resume_managed_host(conv.host_id, host_store, sandbox_config)
_publish_sandbox_status(session_id, "connecting")
refreshed = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if refreshed is None:
tracker.fail(session_id, "session not found after wake")
return
runner_id: str | None = None
host_conn = host_registry.get(conv.host_id) if host_registry is not None else None
if host_registry is not None and host_conn is None:
# resume_managed_host waits on cross-replica host-store liveness, not
# this replica's in-memory tunnel registry — the woken host's tunnel
# can lag here (or land on another replica). Poll briefly so the runner
# launches once it reconnects, instead of settling "ready" with no
# runner; fail clearly if it never shows rather than losing the turn.
_host_reconnect_deadline = time.monotonic() + _HOST_RELAUNCH_RUNNER_CONNECT_TIMEOUT_S
while host_conn is None and time.monotonic() < _host_reconnect_deadline:
await asyncio.sleep(0.5)
host_conn = host_registry.get(conv.host_id)
if host_conn is None:
tracker.fail(session_id, "managed host did not reconnect after wake")
_publish_sandbox_status(
session_id, "failed", "managed host did not reconnect after wake"
)
return
if host_conn is not None:
launch_attempt = await _launch_runner_on_host(
refreshed,
conversation_store,
host_registry,
host_conn,
)
if launch_attempt.error_code == _HARNESS_NOT_CONFIGURED_ERROR_CODE:
reason = launch_attempt.error or "harness not configured on the sandbox host"
tracker.fail(session_id, reason)
_publish_sandbox_status(session_id, "failed", reason)
return
runner_id = launch_attempt.runner_id
if runner_id is not None and tunnel_registry is not None:
# Wait for the runner tunnel before settling so a rendezvoused
# message resolves its runner client on the first try (the
# post-settle session-init handshake then attaches the forwarder
# before the message is forwarded).
await tunnel_registry.wait_for_runner(
runner_id,
timeout_s=_HOST_RELAUNCH_RUNNER_CONNECT_TIMEOUT_S,
)
tracker.finish(session_id)
_publish_sandbox_status(session_id, "ready")
except HTTPException as exc:
tracker.fail(session_id, str(exc.detail))
_publish_sandbox_status(session_id, "failed", str(exc.detail))
except Exception:
# Fire-and-forget task — settle the tracker (else a waiting message
# POST hangs to its timeout) and never escape as an unhandled-task
# traceback. A failed wake leaves the sandbox intact for a retry.
_logger.exception("Managed host wake crashed for session %s", session_id)
tracker.fail(session_id, "internal error during managed host wake")
_publish_sandbox_status(session_id, "failed", "internal error during managed host wake")
# Matches the create / PATCH handshake timeout — POST /v1/sessions caches
# the spec and (for claude-native) launches the terminal pane + transcript
# forwarder synchronously, which stays well under 10s.
_RUNNER_SESSION_INIT_TIMEOUT_S = 10.0
async def _ensure_runner_session_initialized(
session_id: str,
conv: Conversation,
runner_client: httpx.AsyncClient,
conversation_store: ConversationStore,
) -> None:
"""
Drive — and wait for — the runner's session-init handshake.
Posts ``POST /v1/sessions`` to a freshly (re)launched runner and
awaits it, so the runner's ``create_session`` completes before the
caller forwards a message. For a claude-native session that means
the tmux terminal **and its transcript forwarder are watching**
before the web message is injected into the TUI — the round-trip
that promotes the optimistic bubble and streams the reply only
happens if the forwarder is in place first.
This closes the host-restart race: today the auto-relaunch /
resume paths wait only for the runner's *tunnel* to register
(``runner_client`` becomes non-None), not for the session
handshake, so the message can be injected before the forwarder
attaches and is lost. The new / runner-bound paths don't hit this
because they run the handshake as a distinct step before any
message (``create_session`` endpoint) or against a from-offset-0
forwarder.
The runner's ``create_session`` is idempotent (it skips terminal
auto-create under a per-session lock when one already exists), so
this is safe even though ``_on_runner_connect`` (server/app.py)
also posts ``/v1/sessions`` on the same connection — whichever
lands first creates the terminal; the other no-ops.
Best-effort and matching the create / PATCH handshakes: a transport
error is logged and swallowed (the relay + ``_on_runner_connect``
are the backstop), but the *await* — the actual fix — still
serializes the handshake ahead of the caller's message forward.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row for *session_id*; supplies
``agent_id`` and ``sub_agent_name`` for the handshake body.
:param runner_client: Runner client already resolved for
*session_id* (its tunnel is up).
:param conversation_store: Store used to clear persisted disconnect
error labels once the handshake proves the runner recovered.
:returns: None.
"""
try:
resp = await runner_client.post(
"/v1/sessions",
json={
"session_id": session_id,
"agent_id": conv.agent_id,
"sub_agent_name": conv.sub_agent_name,
},
timeout=_RUNNER_SESSION_INIT_TIMEOUT_S,
)
# httpx only raises on transport errors; a 4xx/5xx means create_session
# likely didn't run (terminal + forwarder not set up), so surface it
# via the same warning path rather than silently forwarding into a
# half-initialized runner.
resp.raise_for_status()
await _publish_runner_recovered_status(session_id, conversation_store)
except (httpx.HTTPError, ConnectionError):
_logger.warning(
"Session-init handshake to runner failed for session %s; "
"forwarding the message anyway",
session_id,
exc_info=True,
)
async def _get_runner_client_for_resource_access(
session_id: str,
) -> httpx.AsyncClient | None:
"""Return the authoritative runner client for session resources.
Requires the session to be bound to a runner via
``PATCH /v1/sessions/{id}``; raises ``conflict`` otherwise. If no
runner router is configured (unit-test/in-process setups), callers
may fall back to local registries.
"""
from omnigent.runtime import get_runner_client, get_runner_router
runner_router = get_runner_router()
if runner_router is not None:
routed_runner = runner_router.client_for_session_resources(session_id)
return routed_runner.client
return cast("httpx.AsyncClient | None", get_runner_client())
async def _proxy_get_session_resources_to_runner(
runner_client: httpx.AsyncClient,
session_id: str,
resource_type: str | None = None,
) -> SessionResourcePaginatedList:
"""Proxy ``GET /resources`` to the runner with strict validation.
:param runner_client: HTTP client bound to the session's runner.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param resource_type: Optional ``?type=`` filter forwarded to the
runner, e.g. ``"environment"``. ``None`` returns all types.
:returns: The runner's validated resource page.
:raises HTTPException: 502 on runner failure or malformed response.
"""
try:
resp = await runner_client.get(
f"/v1/sessions/{session_id}/resources",
# Runner-side list_session_resources applies the type filter.
params={"type": resource_type} if resource_type else None,
timeout=10.0,
)
if resp.status_code != 200:
_logger.warning(
"session resources: runner returned %d for session=%s",
resp.status_code,
session_id,
)
raise HTTPException(
status_code=502,
detail="runner session-resources endpoint failed",
)
try:
body = resp.json()
if not isinstance(body, dict):
raise TypeError("response body must be an object")
page = SessionResourceListPage.model_validate(body)
except (TypeError, ValueError, ValidationError) as exc:
_logger.warning(
"session resources: malformed runner response for session=%s: %s",
session_id,
exc,
)
raise HTTPException(
status_code=502,
detail="runner session-resources endpoint returned malformed response",
) from exc
return SessionResourcePaginatedList(
data=page.data,
first_id=page.first_id,
last_id=page.last_id,
has_more=page.has_more,
)
except HTTPException:
raise
except (httpx.HTTPError, ConnectionError) as exc:
_logger.warning(
"session resources: runner call failed for session=%s (%s)",
session_id,
exc,
)
raise HTTPException(
status_code=502,
detail="runner session-resources endpoint unavailable",
) from exc
async def _reset_runner_resources_after_switch(session_id: str) -> None:
"""Best-effort reset of the session's runner-side state after a switch.
Run as a fire-and-forget background task by the switch-agent route. Calls
the runner's dedicated ``POST /v1/sessions/{id}/reset-state`` endpoint,
which closes the cached primary OSEnv + terminals AND drops the
spec-derived session caches. Two reasons:
1. **Sandbox correctness.** The primary OSEnv (which backs the web-UI
filesystem / shell endpoints) is materialized once per session from the
*original* agent's spec and cached. Closing it AND invalidating the
spec/snapshot caches forces the next access to re-resolve and
re-materialize from the NEW agent's spec, so those endpoints run
under the switched-to agent's ``os_env``/sandbox — not the old one.
(Agent ``sys_os_*`` tool calls already re-derive os_env per call, and
native terminals re-evaluate the sandbox gate on respawn; this closes
the remaining stale path.)
2. **Terminal rebuild.** A lingering native terminal would otherwise shadow
the switch-back transcript rebuild (auto-create skips while one exists).
A dedicated endpoint (rather than ``DELETE /resources``) keeps the
session-deletion contract untouched — deletion never needs the
switch-specific cache reset.
A switch only runs while the session is idle, so closing the env + terminal
here is safe — unlike doing it inside the next turn's dispatch, which wedges
that turn. cwd is re-derived from the runner's bound workspace, so the
working directory / git worktree is preserved (only the sandbox changes;
a ``fork``/``start_in_scratch`` agent gets a fresh scratch copy). The
claude-native auto-create gate remains the switch-back safety net if this
call is lost (runner offline, races).
:param session_id: Session/conversation id just switched, e.g.
``"conv_abc123"``.
:returns: None.
"""
try:
runner_client = await _get_runner_client_for_resource_access(session_id)
if runner_client is None:
return
reset_resp = await runner_client.post(
f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/reset-state",
timeout=15.0,
)
# httpx only raises on transport errors — a 4xx/5xx reset response
# still returns. A non-2xx means the runner did NOT close the old
# env, so it must take the failure path below (suppressing the
# invalidation publish); HTTPStatusError is an httpx.HTTPError.
reset_resp.raise_for_status()
except (httpx.HTTPError, HTTPException, OmnigentError, RuntimeError):
# Best-effort: a runner hiccup must not break the (already-committed)
# switch. OmnigentError covers the session-not-runner-bound / runner-
# offline case raised by _get_runner_client_for_resource_access. The
# auto-create gate rebuilds on switch-back regardless. No
# changed-files event on this path either: the runner's env cache is
# still the OLD agent's, so a triggered refetch would re-serve it —
# and a lost runner rebuilds from the new spec on relaunch anyway.
_logger.warning(
"post-switch runner-resource reset failed for session=%s", session_id, exc_info=True
)
return
# The old agent's cached OSEnv is now closed, so a refetch triggered by
# this event re-materializes filesystem state from the NEW agent's spec.
# This is what flips the web Files tab when the switch crosses an
# os_env boundary (none→some shows it, some→none hides it) — the
# session.agent_changed event fires before the reset and so cannot
# carry a trustworthy availability signal.
_publish_changed_files_invalidated(session_id)
def _native_coding_agent_for_session(conv: Conversation) -> NativeCodingAgent | None:
"""
Resolve native terminal metadata for a session, by wrapper label OR harness.
Two independent signals identify a native session, because native message
handling must NOT be coupled to the terminal-first presentation labels:
* the ``omnigent.wrapper`` presentation label — set for the built-in
terminal-first wrapper sessions (``omnigent claude`` / ``omnigent
codex``); resolved directly and cheaply here (short-circuits the harness
load below); and
* the bound agent's RESOLVED harness — for a CUSTOM agent that declares a
native harness (e.g. a user ``polly`` orchestrator with
``executor.harness: codex-native``) but is intentionally CHAT-first, so
it carries no wrapper label. Its runner still runs a native transcript
forwarder (the single writer for the conversation), so its web messages
must take the same native single-writer path — else the inbound user
message is persisted AP-side AND mirrored by the forwarder, landing
twice. Resolved via :func:`_resolve_harness` (honors a per-session
``harness_override``), independent of the presentation labels; SDK
harnesses resolve to ``None``.
:param conv: Conversation row for the target session.
:returns: The :class:`NativeCodingAgent` for the session's harness, or
``None`` when it is not a native terminal harness.
"""
wrapper = conv.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
native_agent = native_coding_agent_for_wrapper_label(wrapper)
if native_agent is not None:
return native_agent
return native_coding_agent_for_harness(_resolve_harness(conv))
def _is_native_terminal_session(conv: Conversation) -> bool:
"""
Return whether a session's turns are driven by a native terminal harness.
True for both a built-in terminal-first wrapper (``omnigent.wrapper``
label) and a custom chat-first agent bound to a native harness — see
:func:`_native_coding_agent_for_session` for why routing keys on the
resolved harness, not the presentation labels.
:param conv: Conversation row for the target session.
:returns: ``True`` when the session's harness is a native terminal harness.
"""
return _native_coding_agent_for_session(conv) is not None
def _native_terminal_runtime(conv: Conversation) -> tuple[str, str, str]:
"""
Return native terminal runtime strings for a native-harness session.
Resolves by wrapper label OR resolved harness (see
:func:`_native_coding_agent_for_session`), so a custom chat-first agent on
a native harness (no wrapper label) resolves too — otherwise it would raise
``Unsupported native terminal session`` the moment its first web message
reached the native dispatch branch.
:param conv: Conversation row for the target session.
:returns: ``(display_name, model, harness)``.
:raises OmnigentError: If the session is not a native terminal harness.
"""
native_agent = _native_coding_agent_for_session(conv)
if native_agent is not None:
return native_agent.display_name, native_agent.agent_name, native_agent.harness
raise OmnigentError(
"Unsupported native terminal session",
code=ErrorCode.INVALID_INPUT,
)
def _native_terminal_name_for_harness(harness: str) -> str:
"""
Return the runner terminal resource name for a native harness.
:param harness: Native harness identifier, e.g. ``"codex-native"``.
:returns: Terminal resource name, e.g. ``"codex"``.
:raises OmnigentError: If *harness* is not a supported native
terminal harness.
"""
native_agent = native_coding_agent_for_harness(harness)
if native_agent is not None:
return native_agent.terminal_name
raise OmnigentError(
"Unsupported native terminal session",
code=ErrorCode.INVALID_INPUT,
)
def _native_terminal_failure_from_runner_response(
resp: httpx.Response,
*,
display_name: str,
) -> ErrorData:
"""
Convert a failed runner terminal-ensure response into durable error data.
The runner's terminal ensure endpoint must return structured
``{"error": {"code": ..., "message": ...}}`` for definitive startup
failures (for example a missing native CLI). Preserve that message
exactly so the transcript shows the real cause. If the runner returns
an opaque framework 500 body such as ``"Internal Server Error"``,
surface an explicit malformed-runner-response error instead of
inventing a native terminal cause.
:param resp: Non-2xx response from
``POST /v1/sessions/{id}/resources/terminals``.
:param display_name: Human-readable runtime name, e.g. ``"Codex"``.
:returns: Error data suitable for a persisted ``type="error"``
conversation item.
"""
try:
body = resp.json()
except ValueError:
body = None
if isinstance(body, dict):
raw_error = body.get("error")
if isinstance(raw_error, dict):
raw_code = raw_error.get("code")
raw_message = raw_error.get("message")
if (
isinstance(raw_code, str)
and raw_code.strip()
and isinstance(raw_message, str)
and raw_message.strip()
):
return ErrorData(
source="execution",
code=raw_code,
message=raw_message,
)
return ErrorData(
source="execution",
code=_NATIVE_TERMINAL_ENSURE_FAILED_CODE,
message=(
f"Native {display_name} terminal ensure failed with malformed "
f"runner response (HTTP {resp.status_code})."
),
)
def _native_terminal_ensure_transport_error(
exc: httpx.HTTPError | ConnectionError,
*,
display_name: str,
) -> ErrorData:
"""
Convert runner transport failure during native terminal ensure.
The message path has exactly one preflight path for native terminal
readiness. If that path cannot reach the runner, fail the user turn
explicitly instead of falling back to the old forward-and-wait path.
:param exc: Transport exception from the ensure request, e.g.
``httpx.ConnectError("connection refused")`` or the bare
``ConnectionError("tunnel closed before request completed")``
that ``WSTunnelTransport`` raises on tunnel close.
:param display_name: Human-readable runtime name, e.g. ``"Codex"``.
:returns: Error data suitable for a persisted ``type="error"``
conversation item.
"""
detail = str(exc).strip()
message = f"Native {display_name} terminal ensure request failed."
if detail:
message = f"{message} {detail}"
return ErrorData(
source="execution",
code=_NATIVE_TERMINAL_ENSURE_FAILED_CODE,
message=message,
)
@dataclass
class _NativeTerminalEnsureOutcome:
"""
Result of a native terminal readiness probe.
:param error: Error data when the runner definitively failed to
create the terminal (fails the turn with a durable banner), or
``None`` when the terminal is ready / the failure was not
definitive.
:param policy_notice: Human-readable reason that tool-call policy
enforcement is NOT active for this session (fail-open — codex too
old or the hook could not be trusted), or ``None`` when
enforcement is active. Non-fatal: surfaced once as a durable
banner, never blocks the turn.
"""
error: ErrorData | None
policy_notice: str | None
async def _ensure_native_terminal_ready(
runner_client: httpx.AsyncClient,
session_id: str,
conv: Conversation,
) -> _NativeTerminalEnsureOutcome:
"""
Ask the runner to create or return the native terminal for a message.
The runner's explicit ``ensure_native_terminal`` endpoint is the
authoritative readiness check for native user messages. Any non-2xx
response or transport failure fails this user turn quickly with a
durable error item; a 2xx response preserves the normal boot grace
because the runner has accepted responsibility for terminal startup.
A 2xx response may also carry ``policy_hook_disabled_reason`` — a
one-shot, non-fatal notice that policy enforcement is inactive — which
is returned as ``policy_notice`` for the caller to surface as a banner.
:param runner_client: HTTP client pointed at the session's runner.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row used to identify the native harness.
:returns: The probe outcome — a definitive ``error`` (terminal could
not start) and/or a non-fatal ``policy_notice``.
"""
display_name, _, harness = _native_terminal_runtime(conv)
terminal_name = _native_terminal_name_for_harness(harness)
try:
resp = await runner_client.post(
f"/v1/sessions/{session_id}/resources/terminals",
json={
"terminal": terminal_name,
"session_key": "main",
"ensure_native_terminal": True,
},
timeout=10.0,
)
except (httpx.HTTPError, ConnectionError) as exc:
# WSTunnelTransport raises bare ConnectionError on tunnel close
# ("tunnel closed before request completed"); without this clause
# a runner tunnel drop escaped to the catch-all handler and the
# web client showed an opaque 500 ``internal_error`` instead of
# the durable ensure-failure turn error below.
_logger.warning(
"%s terminal ensure transport failed for session=%s",
display_name,
session_id,
exc_info=True,
)
return _NativeTerminalEnsureOutcome(
error=_native_terminal_ensure_transport_error(exc, display_name=display_name),
policy_notice=None,
)
if resp.status_code < 400:
return _NativeTerminalEnsureOutcome(
error=None,
policy_notice=_policy_notice_from_ensure_response(resp),
)
_logger.warning(
"%s terminal ensure failed definitively for session=%s status=%s body=%s",
display_name,
session_id,
resp.status_code,
resp.text[:500],
)
return _NativeTerminalEnsureOutcome(
error=_native_terminal_failure_from_runner_response(resp, display_name=display_name),
policy_notice=None,
)
def _policy_notice_from_ensure_response(resp: httpx.Response) -> str | None:
"""
Extract a non-fatal policy-disabled notice from a 2xx ensure response.
The runner attaches ``policy_hook_disabled_reason`` (once) to its
terminal-ensure success body when the session degraded to no policy
enforcement. A malformed / non-JSON body is treated as "no notice"
rather than failing the (successful) readiness probe.
:param resp: The runner's 2xx ensure response.
:returns: The reason string, or ``None`` when absent / unparseable.
"""
try:
body = resp.json()
except ValueError:
return None
if not isinstance(body, dict):
return None
reason = body.get("policy_hook_disabled_reason")
return reason if isinstance(reason, str) and reason.strip() else None
def _publish_error_event(session_id: str, error: ErrorData) -> None:
"""
Publish a live ``response.error`` event for a persisted error item.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param error: Durable error payload to mirror into SSE.
:returns: None.
"""
event = ErrorEvent(
type="response.error",
source=error.source,
error={"code": error.code, "message": error.message},
)
session_stream.publish(session_id, event.model_dump())
async def _persist_native_terminal_failure(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
error: ErrorData,
runner_router: RunnerRouter | None,
*,
created_by: str | None,
) -> str:
"""
Persist a consumed user message and terminal-start error.
Used when a native terminal definitively cannot start. The AP
server becomes the writer for this failure turn only: it records
the user's message so the input is consumed, records a sibling
``type="error"`` item so refresh/reconnect can render the banner,
and publishes the same live error/status events clients already
understand.
When the failing session is a native sub-agent, the parent's runner
is also notified via an ``external_session_status: failed`` forward
(see :func:`_forward_native_subagent_terminal_failure`). The native
bypass returns HTTP 200 to the parent's runner ``spawn`` call, so
without this forward the parent's work entry would stay ``running``
forever — no harness boots, so no Stop hook ever fires the terminal
edge the normal completion path relies on.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row for the session.
:param body: Original user message event.
:param conversation_store: Store used for the durable append.
:param error: Error data derived from the runner's ensure response.
:param runner_router: Router used to resolve the (sub-agent's own)
runner for the parent-wake forward, or ``None`` in
in-process / test setups where the global client is used.
:param created_by: Authenticated posting actor, e.g.
``"alice@example.com"``; ``None`` in single-user mode.
:returns: Store-assigned id of the consumed user message item.
"""
turn_id = generate_task_id()
user_item = _build_new_item(body, turn_id, created_by=created_by)
persisted_items = await asyncio.to_thread(
conversation_store.append,
session_id,
[user_item],
)
await _seed_missing_title_from_user_message(
conv,
user_item,
conversation_store,
)
error_persist_result = await _relay_persist_error_once(
conversation_store,
session_id,
NewConversationItem(
type="error",
response_id=turn_id,
data=error,
),
)
consumed = persisted_items[0]
_publish_input_consumed(session_id, consumed)
if error_persist_result == "persisted":
_publish_error_event(session_id, error)
_publish_terminal_pending(session_id, False)
_publish_status(
session_id,
"failed",
ErrorDetail(code=error.code, message=error.message),
)
# A boot failure on a native sub-agent must wake the parent — mirror
# the normal terminal-status path (publish + forward), gated on
# ``kind == "sub_agent"`` so top-level native sessions are unaffected.
await _forward_native_subagent_terminal_failure(
session_id,
conv,
error,
runner_router,
)
return consumed.id
async def _persist_host_launch_failure_turn(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
host_error: str | None,
runner_router: RunnerRouter | None,
*,
created_by: str | None,
) -> str:
"""
Persist a consumed user message and a host-launch failure error.
Used when a message arrives for a host-bound session whose runner is
dead and the host *refuses* to relaunch because the agent's harness
isn't configured there (the daemon's structured
``harness_not_configured`` reply). The message is the real
runner-start attempt, so — exactly like a native terminal that can't
boot (:func:`_persist_native_terminal_failure`) — the server records
the user's message (so the input is consumed, not silently dropped)
and a sibling ``type="error"`` item carrying the host's message
(which names the fix, ``omnigent setup``), then publishes the same
live error/status events the web renders as an error banner. The host
binding is left intact so a later message relaunches once the user has
run setup.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row for the session.
:param body: Original user message event.
:param conversation_store: Store used for the durable append.
:param host_error: The host's human-readable refusal, e.g.
``"harness 'codex' is not configured on host 'laptop' — run
`omnigent setup` ..."``. ``None`` falls back to a generic
``omnigent setup`` pointer so the banner is never empty.
:param runner_router: Router used to resolve a sub-agent's runner for
the parent-wake forward, or ``None`` in in-process / test setups.
:param created_by: Authenticated posting actor, e.g.
``"alice@example.com"``; ``None`` in single-user mode.
:returns: Store-assigned id of the consumed user message item.
"""
error = ErrorData(
source="execution",
# Stable classifier mirroring the host's wire error code, so the
# web can special-case the banner if it ever wants to.
code="harness_not_configured",
message=(
host_error
if host_error
# Defensive fallback: the daemon always sends a message with
# the code, but the banner must stay actionable if a
# third-party host omits it.
else (
"the agent's harness is not configured on the selected host — run `omnigent setup`"
)
),
)
turn_id = generate_task_id()
user_item = _build_new_item(body, turn_id, created_by=created_by)
persisted_items = await asyncio.to_thread(
conversation_store.append,
session_id,
[user_item],
)
await _seed_missing_title_from_user_message(conv, user_item, conversation_store)
error_persist_result = await _relay_persist_error_once(
conversation_store,
session_id,
NewConversationItem(type="error", response_id=turn_id, data=error),
)
consumed = persisted_items[0]
_publish_input_consumed(session_id, consumed)
if error_persist_result == "persisted":
_publish_error_event(session_id, error)
_publish_terminal_pending(session_id, False)
_publish_status(session_id, "failed", ErrorDetail(code=error.code, message=error.message))
# A host-launched sub-agent that can't configure must wake its parent,
# the same way a boot failure does — no-ops for top-level sessions.
await _forward_native_subagent_terminal_failure(session_id, conv, error, runner_router)
return consumed.id
async def _forward_native_subagent_terminal_failure(
session_id: str,
conv: Conversation,
error: ErrorData,
runner_router: RunnerRouter | None,
) -> None:
"""
Wake the parent runner when a native sub-agent fails to boot its terminal.
Mirrors the terminal-status path's parent-wake (the ``idle`` /
``failed`` branch of ``external_session_status`` in
:func:`post_event`): forward an ``external_session_status: failed``
edge — carrying the boot error as ``output`` so it lands in the
parent's inbox — to the sub-agent's own runner, then require the
forward to land. The runner's ``external_session_status`` handler
maps ``failed`` to ``mark_subagent_work_terminal(status="failed")``,
which marks the parent's work entry terminal and wakes the parent.
No-ops for non-sub-agent sessions and for codex-internal sub-agents
(tracked inside the same app-server thread tree, with no runner
inbox entry to forward to — identical to the normal path's
``_is_codex_native_subagent`` exclusion).
:param session_id: Sub-agent session id, e.g. ``"conv_child123"``.
:param conv: Conversation row for the sub-agent session.
:param error: Boot error to relay to the parent as the turn result.
:param runner_router: Router used to resolve the sub-agent's runner,
or ``None`` (then the global client is used).
:returns: None.
:raises OmnigentError: If the parent's runner could not be reached
or rejected the forwarded failure status — dropping it would
strand the parent waiting forever.
"""
if conv.kind != "sub_agent" or _is_codex_native_subagent(conv):
return
forward_body: dict[str, Any] = {
"type": _EXTERNAL_SESSION_STATUS_TYPE,
# ``output`` is the parent-inbox result text on a failed edge
# (runner: ``output or "...turn failed"``); pass the real error.
"data": {"status": "failed", "output": error.message},
}
runner_result = await _forward_session_change_to_runner(
session_id,
runner_router,
forward_body,
)
_require_external_status_forward(session_id, "failed", runner_result)
async def _persist_native_policy_notice(
session_id: str,
conversation_store: ConversationStore,
reason: str,
) -> None:
"""
Persist + publish a non-fatal "policy not enforced" banner.
The runner reports (once, via the terminal-ensure success response)
that a native codex session started but tool-call policy enforcement
is inactive (fail-open: codex too old, or the policy hook could not be
trusted). This records a durable ``type="error"`` banner so the web UI
shows the degraded-security state across refresh/reconnect, and
mirrors it as a live ``response.error`` event. Unlike
:func:`_persist_native_terminal_failure` it does NOT consume the user
message or mark the turn failed — the terminal is up and the message
still forwards; this is an advisory notice only.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conversation_store: Store used for the durable append.
:param reason: Human-readable cause from the runner, e.g. ``"Codex CLI
0.128.0 is older than 0.129.0; upgrade codex to enforce tool-call
policies."``.
:returns: None.
"""
error = ErrorData(
source="execution",
code=_NATIVE_POLICY_NOT_ENFORCED_CODE,
message=f"Tool-call policy enforcement is not active for this session: {reason}",
)
persisted = await _relay_persist_error_once(
conversation_store,
session_id,
NewConversationItem(
type="error",
response_id=generate_task_id(),
data=error,
),
)
# Mirror to live clients only when newly persisted (the runner's
# one-shot flag already prevents re-surfacing; this dedups a same-turn
# retry against an already-recorded notice).
if persisted == "persisted":
_publish_error_event(session_id, error)
def _build_native_terminal_message_event(
conv: Conversation,
body: SessionEventInput,
) -> dict[str, Any]:
"""
Build the runner event that delivers a web message to a native TUI.
:param conv: Conversation row for the target session.
:param body: Validated Sessions API message event, e.g.
``{"type": "message", "data": {"role": "user",
"content": [{"type": "input_text", "text": "Hi"}]}}``.
:returns: Harness ``MessageEvent`` body for the runner-local
native terminal harness, including ``agent_id`` so the runner
can resolve the harness spec on the first message.
:raises OmnigentError: If the event is not a user message.
"""
display_name, model, harness = _native_terminal_runtime(conv)
data = parse_item_data(body.type, {"type": body.type, **body.data})
if not isinstance(data, MessageData) or data.role != "user":
raise OmnigentError(
f"{display_name} terminal sessions accept only user message events",
code=ErrorCode.INVALID_INPUT,
)
return {
"type": "message",
"role": "user",
"content": data.content,
"model": model,
"harness": harness,
# The runner resolves the harness from the agent spec keyed by
# agent_id; the forwarded ``harness`` hint is ignored on the turn
# path. Without agent_id, the first message of a freshly
# host-spawned runner (arriving before POST /v1/sessions caches
# the spec) falls back to the test-only "runner-test-default"
# harness and is dropped. Match the non-native forward path,
# which always includes it.
"agent_id": conv.agent_id,
}
async def _forward_native_terminal_message(
runner_client: httpx.AsyncClient,
session_id: str,
conv: Conversation,
body: SessionEventInput,
file_store: FileStore | None = None,
artifact_store: ArtifactStore | None = None,
) -> None:
"""
Forward one Omnigent web-chat message to the native terminal harness.
The message is intentionally not persisted here. Claude Code
and Codex record the accepted prompt in their terminal/app-server
state, and their forwarders later post that terminal-originated
item back through ``external_conversation_item``.
:param runner_client: Runner client selected for ``session_id``.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param conv: Conversation row for *session_id*.
:param body: Sessions API message event to inject.
:param file_store: Optional file metadata store for resolving
``file_id`` references in ``input_image`` / ``input_file``
content blocks.
:param artifact_store: Optional binary content store for
fetching file bytes during resolution.
:returns: None.
:raises HTTPException: 502 when the runner or harness rejects
the injection request.
"""
display_name, _, _ = _native_terminal_runtime(conv)
event = _build_native_terminal_message_event(conv, body)
_logger.info(
"%s terminal message forward starting: session=%s block_types=%s",
display_name,
session_id,
[block.get("type") for block in event.get("content", []) if isinstance(block, dict)]
if isinstance(event.get("content"), list)
else type(event.get("content")).__name__,
)
if (
file_store is not None
and artifact_store is not None
and isinstance(event.get("content"), list)
):
from omnigent.runtime.content_resolver import (
_resolve_message_content,
)
try:
event["content"] = _resolve_message_content(
event["content"],
file_store,
artifact_store,
session_id=session_id,
)
except (ValueError, KeyError):
_logger.warning(
"File reference resolution failed for native session=%s",
session_id,
exc_info=True,
)
try:
resp = await runner_client.post(
f"/v1/sessions/{session_id}/events",
json=event,
timeout=_CLAUDE_NATIVE_MESSAGE_TIMEOUT_S,
)
_logger.info(
"%s terminal message runner response: session=%s status=%s body=%s",
display_name,
session_id,
resp.status_code,
resp.text[:500],
)
except (httpx.HTTPError, ConnectionError) as exc:
# WSTunnelTransport raises bare ConnectionError on tunnel close;
# map it to the same 502 as an httpx transport failure so a
# runner tunnel drop mid-forward doesn't escape as an opaque 500.
_logger.warning(
"%s terminal message forward failed for session=%s",
display_name,
session_id,
exc_info=True,
)
raise HTTPException(
status_code=502,
detail=f"{display_name} terminal message delivery failed",
) from exc
if resp.status_code >= 400:
_logger.warning(
"%s terminal message forward rejected for session=%s status=%s body=%s",
display_name,
session_id,
resp.status_code,
resp.text,
)
raise HTTPException(
status_code=502,
detail=f"{display_name} terminal message delivery failed ({resp.status_code})",
)
failure = _extract_claude_native_runner_failure(resp)
if failure is not None:
_logger.warning(
"%s terminal message forward failed in runner SSE for session=%s: %s",
display_name,
session_id,
failure,
)
raise HTTPException(
status_code=502,
detail=f"{display_name} terminal message delivery failed: {failure}",
)
def _extract_claude_native_runner_failure(resp: httpx.Response) -> str | None:
"""
Return a harness failure message from a runner SSE response.
Runner ``POST /v1/sessions/{id}/events`` returns HTTP 200 for a
syntactically valid harness stream even when the harness emits
``response.failed``. Claude-native Omnigent forwarding must treat that
as failed injection, otherwise the web UI would believe a message
reached the terminal when ``tmux send-keys`` actually failed.
:param resp: Completed runner response.
:returns: Failure message, or ``None`` when no failure event is
present.
"""
content_type = resp.headers.get("content-type", "")
text = resp.text
if "text/event-stream" not in content_type and "response.failed" not in text:
return None
for frame in text.split("\n\n"):
data_lines = [
line.removeprefix("data:").strip()
for line in frame.splitlines()
if line.startswith("data:")
]
if not data_lines:
continue
try:
payload = json.loads("\n".join(data_lines))
except json.JSONDecodeError:
continue
if not isinstance(payload, dict) or payload.get("type") != "response.failed":
continue
error = payload.get("error")
if isinstance(error, dict):
message = error.get("message") or error.get("detail")
if isinstance(message, str) and message:
return message
return json.dumps(error, sort_keys=True)
if isinstance(error, str) and error:
return error
return "runner reported response.failed"
return None
async def _forward_session_change_to_runner(
session_id: str,
runner_router: Any,
event: dict[str, Any],
) -> _RunnerForwardResult | None:
"""
Best-effort POST a control event to the bound runner.
Used for control inputs the runner dispatches by harness in its
``/v1/sessions/{id}/events`` handler — claude-native injects the
corresponding slash command into the tmux pane; other harnesses
return 204 no-op. Two kinds of caller use this:
* PATCH-driven harness notifications (``effort_change``,
``model_change``) — claude-native injects the slash command,
other harnesses re-read the persisted value at the next turn
boundary, so they ignore the return value.
* Explicit ``compact`` — the caller inspects the returned status
to decide whether the runner handled the control (claude-native,
200) or the Omnigent server must run its own in-process compaction
(204 / no runner). See the ``compact`` branch in
:func:`post_event`.
Mirrors the interrupt-forward fallback chain: prefer the per-
session router binding, fall back to the global runner client
(in-process / test setups where the router hasn't bound the
session). When neither resolves to a client, the POST is silently
skipped — the persisted value on the Omnigent side is the authoritative
fallback, picked up by the next spawn.
Non-2xx runner responses (e.g. 503 when the tmux pane isn't
advertised yet) are logged as warnings so the failure surfaces
in the Omnigent log — otherwise the POST succeeds at the httpx layer
and the status would be silently dropped.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param runner_router: The session's ``RunnerRouter`` (may be
``None`` in tests / in-process setups).
:param event: The ``/events`` POST body, e.g.
``{"type": "effort_change", "effort": "high"}``,
``{"type": "model_change", "model": "claude-opus-4-7"}``, or
``{"type": "compact"}``.
:returns: The runner's HTTP status/body, or ``None`` when no
runner client could be resolved or the POST failed at the
transport layer (in both cases the AP-side persisted value /
operation is the authoritative fallback).
"""
from omnigent.runtime import get_runner_client
runner_client = await _get_runner_client(session_id, runner_router)
if runner_client is None:
runner_client = cast("httpx.AsyncClient | None", get_runner_client())
if runner_client is None:
return None
try:
resp = await runner_client.post(
f"/v1/sessions/{session_id}/events",
json=event,
timeout=5.0,
)
except (httpx.HTTPError, ConnectionError):
_logger.exception(
"Session-change forward failed for session=%r type=%r",
session_id,
event.get("type"),
)
return None
if resp.status_code >= 400:
_logger.warning(
"Session-change forward rejected for session=%s type=%r status=%s body=%s",
session_id,
event.get("type"),
resp.status_code,
resp.text,
)
return _RunnerForwardResult(status_code=resp.status_code, body=resp.text)
async def _stop_session_via_runner(
session_id: str,
runner_router: Any,
) -> bool:
"""
Forward a ``stop_session`` request to the bound runner, surfacing
failures to the caller instead of swallowing them.
Unlike :func:`_forward_session_change_to_runner` (used for
``effort_change`` / ``model_change``, where a dropped forward is
benign — the runner re-reads the persisted value at the next turn),
a failed ``stop_session`` means the session is *still alive*. The
web UI's "Stop session" action is destructive and treats a 2xx as
success (it closes the confirmation dialog), so a swallowed failure
would tell the user the session stopped when it did not. This
helper therefore raises on a transport error or non-2xx runner
response.
Runner-client resolution mirrors the best-effort helper's fallback
chain: prefer the per-session router binding, fall back to the
global runner client (in-process / test setups). When neither
resolves to a client there is no live runner bound — the session is
not running on any runner, so the stop is a no-op success and this
returns ``False`` without raising (the caller uses that to discard
the turn fence it installed, since no runner means nothing else
would ever lift it).
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param runner_router: The session's ``RunnerRouter`` (may be
``None`` in tests / in-process setups).
:returns: ``True`` if the stop was delivered to a runner (2xx),
``False`` if no runner client resolved (nothing forwarded).
:raises OmnigentError: ``RUNNER_UNAVAILABLE`` (HTTP 503) if the
runner could not be reached or reported a non-2xx — e.g. the
claude-native tmux pane is wedged and ``kill_session`` failed.
The web UI maps this to a visible "stop failed" state rather
than closing the dialog as if the session stopped.
"""
from omnigent.runtime import get_runner_client
runner_client = await _get_runner_client(session_id, runner_router)
if runner_client is None:
runner_client = cast("httpx.AsyncClient | None", get_runner_client())
if runner_client is None:
return False
try:
resp = await runner_client.post(
f"/v1/sessions/{session_id}/events",
json={"type": _STOP_SESSION_TYPE},
timeout=5.0,
)
except (httpx.HTTPError, ConnectionError) as exc:
# WSTunnelTransport raises bare ConnectionError on tunnel close.
raise OmnigentError(
f"Could not reach the runner to stop session {session_id!r}: {exc}",
code=ErrorCode.RUNNER_UNAVAILABLE,
) from exc
if resp.status_code >= 400:
raise OmnigentError(
f"Runner failed to stop session {session_id!r} "
f"(status {resp.status_code}): {resp.text}",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
return True
# How long to wait for the host to acknowledge a ``stop_runner`` before
# giving up. The claude pane is already dead by then (see
# :func:`_stop_session_host_runner`), so a slow/unreachable host only costs
# the "disconnected" UI transition, not session correctness — a short wait
# keeps the web UI's Stop action snappy.
_STOP_RUNNER_RESULT_TIMEOUT_S = 10.0
async def _stop_session_host_runner(
session_id: str,
host_id: str,
runner_id: str,
host_registry: Any,
) -> None:
"""
Terminate the host-launched runner backing a host-spawned session.
"Stop session" on a host-spawned session must end the dedicated runner
subprocess the host launched for it — there is exactly one runner per
host-launched session (see ``POST /v1/hosts/{host_id}/runners`` and the
host-launch branch of session create). Killing the ``claude`` tmux pane
via :func:`_stop_session_via_runner` is not enough on its own: the
runner stays connected, so ``GET /health`` keeps reporting
``runner_online: true`` for the session and the web UI never shows it as
disconnected — new messages are accepted and hang on "working" against a
dead pane.
Bringing the runner's tunnel down is what flips ``runner_online`` to
``false``; ``_on_runner_disconnect`` then marks the session and the web
UI renders the "Agent disconnected — click to show reconnect command"
banner, identical to the end state a CLI-launched session reaches when
its process exits.
Best-effort by design: the pane is already gone before this runs, so a
host that is offline, was replaced, or is slow to acknowledge is logged
and swallowed rather than failing the whole Stop. In the common case —
the host's ``omnigent host`` tunnel is open while the user drives
the web UI — the stop is delivered and the runner exits. The runner this
targets is read from the caller's own (owner-gated) session row, so it
can only ever stop the runner bound to that session.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param host_id: Owning host identifier from the session row, e.g.
``"host_a1b2c3d4..."``.
:param runner_id: Runner bound to the session, e.g.
``"runner_token_abc123..."``.
:param host_registry: The :class:`HostRegistry` tracking live host
tunnels on this replica, or ``None`` when host support is not wired
(in-process / test setups without a host tunnel).
:returns: None.
"""
if host_registry is None:
return
conn = host_registry.get(host_id)
if conn is None:
_logger.warning(
"Cannot stop runner %s for session %s: host %s is offline; "
"the runner may linger online and the session will not show as "
"disconnected",
runner_id,
session_id,
host_id,
)
return
from omnigent.host.frames import HostStopRunnerFrame, encode_host_frame
request_id = secrets.token_hex(8)
future: asyncio.Future[dict[str, str | None]] = asyncio.get_running_loop().create_future()
conn.pending_stops[request_id] = future
stop_frame = encode_host_frame(
HostStopRunnerFrame(request_id=request_id, runner_id=runner_id),
)
try:
host_registry.send_text(conn, stop_frame)
except ConnectionError:
conn.pending_stops.pop(request_id, None)
_logger.warning(
"Cannot stop runner %s for session %s: host %s connection was replaced",
runner_id,
session_id,
host_id,
)
return
try:
result = await asyncio.wait_for(
future,
timeout=_STOP_RUNNER_RESULT_TIMEOUT_S,
)
except asyncio.TimeoutError:
conn.pending_stops.pop(request_id, None)
_logger.warning(
"Host %s did not acknowledge stop of runner %s for session %s",
host_id,
runner_id,
session_id,
)
return
if result.get("status") == "failed":
_logger.warning(
"Host %s failed to stop runner %s for session %s: %s",
host_id,
runner_id,
session_id,
result.get("error"),
)
def _build_new_item(
body: SessionEventInput,
response_id: str,
created_by: str | None = None,
) -> NewConversationItem:
"""
Construct a :class:`NewConversationItem` from a POSTed event.
Validates the data payload via ``parse_item_data`` (the same
validator the route boundary already invoked) and wraps the
result with the response_id linkage required by the conversation
store.
:param body: Validated event input — guaranteed to be a known
item type (the route checked ``_ALLOWED_EVENT_TYPES``).
:param response_id: The task id the new item should be tagged
with — either the steered active task or a freshly-created
one.
:param created_by: Authenticated identity of the actor posting
the event, recorded for per-message attribution. ``None`` in
single-user mode.
:returns: A :class:`NewConversationItem` ready for delivery
or persistence.
"""
data = parse_item_data(body.type, {"type": body.type, **body.data})
return NewConversationItem(
type=body.type,
response_id=response_id,
data=data,
created_by=created_by,
)
def _parse_skill_slash_command(body: SessionEventInput) -> tuple[str, str]:
"""
Validate and unpack a structured skill slash-command event.
The REPL posts ``type="slash_command"`` for skill invocations.
Other command kinds are surfaced by terminal transcript bridges
through ``external_conversation_item`` and are not executable
session inputs on this route.
:param body: Validated event input with ``type="slash_command"``
and data such as ``{"kind": "skill", "name": "grill-me",
"arguments": "review this plan"}``.
:returns: ``(skill_name, arguments)`` with whitespace-trimmed
command name and raw argument text.
:raises OmnigentError: If the payload is not a skill command
or is missing a usable skill name / arguments string.
"""
kind = body.data.get("kind", "skill")
if kind != "skill":
raise OmnigentError(
"slash_command events only support kind='skill'; use the "
"dedicated control event for built-in commands",
code=ErrorCode.INVALID_INPUT,
)
name = body.data.get("name")
if not isinstance(name, str) or not name.strip():
raise OmnigentError(
"slash_command requires non-empty data.name",
code=ErrorCode.INVALID_INPUT,
)
arguments = body.data.get("arguments", "")
if not isinstance(arguments, str):
raise OmnigentError(
"slash_command data.arguments must be a string",
code=ErrorCode.INVALID_INPUT,
)
return name.strip(), arguments
def _build_skill_slash_command_policy_body(body: SessionEventInput) -> SessionEventInput:
"""
Build the user-message shape used for input policy evaluation.
Skill commands inject a hidden meta message containing the full
skill body, but input guardrails should evaluate the text the user
actually typed, not the skill instructions maintained by the
server. This preserves the legacy policy surface of
``/<skill> <arguments>`` without making bundled skill content
policy-sensitive.
:param body: Validated ``slash_command`` event body with data such
as ``{"name": "grill-me", "arguments": "review this plan"}``.
:returns: Synthetic user ``message`` event for policy evaluation.
:raises OmnigentError: If the slash-command payload is invalid.
"""
skill_name, arguments = _parse_skill_slash_command(body)
command_text = f"/{skill_name}" if not arguments else f"/{skill_name} {arguments}"
return SessionEventInput(
type="message",
data={
"role": "user",
"content": [{"type": "input_text", "text": command_text}],
},
)
async def _resolve_skill_meta_text_via_runner(
session_id: str,
skill_name: str,
arguments: str,
runner_client: httpx.AsyncClient,
) -> str:
"""
Resolve a skill's hidden ``<skill>`` meta text on the bound runner.
Skill content is runner-owned: the runner reads the ``SKILL.md``
body and resource files from the skill's directory on its own
filesystem, so the embedded ``<path>`` and resource listing are
valid where the harness executes. Wraps
``POST /v1/sessions/{id}/skills/resolve``.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param skill_name: Exact skill name to resolve, e.g.
``"code-review"``.
:param arguments: Raw argument string typed after the slash
command, e.g. ``"review this plan"``. Empty when none.
:param runner_client: HTTP client pointed at the bound runner.
:returns: The hidden ``<skill>`` meta text for a single
``input_text`` block.
:raises OmnigentError: If the skill is not exposed for the session
(the runner 404s with the available list), or the runner is
unreachable / errors while resolving.
"""
try:
resp = await runner_client.post(
f"/v1/sessions/{session_id}/skills/resolve",
json={"name": skill_name, "arguments": arguments},
timeout=10.0,
)
except (httpx.HTTPError, ConnectionError) as exc:
raise OmnigentError(
f"Runner unreachable while resolving skill {skill_name!r}: {exc}",
code=ErrorCode.INTERNAL_ERROR,
) from exc
if resp.status_code not in (200, 404):
raise OmnigentError(
f"Runner failed to resolve skill {skill_name!r}: HTTP {resp.status_code}",
code=ErrorCode.INTERNAL_ERROR,
)
# Parse the body once, guarded: a transport proxy / HTML error page /
# non-object body must surface as a controlled runner failure, not an
# uncaught 500.
try:
payload = resp.json()
if not isinstance(payload, dict):
raise ValueError("expected a JSON object")
except ValueError as exc:
raise OmnigentError(
f"Runner returned a malformed skill resolution for {skill_name!r}: {exc}",
code=ErrorCode.INTERNAL_ERROR,
) from exc
if resp.status_code == 404:
available = payload.get("available", [])
raise OmnigentError(
f"Skill {skill_name!r} not found. Available skills: {available}",
code=ErrorCode.INVALID_INPUT,
)
meta_text = payload.get("meta_text")
if not isinstance(meta_text, str):
raise OmnigentError(
f"Runner returned malformed skill resolution for {skill_name!r}: missing 'meta_text'",
code=ErrorCode.INTERNAL_ERROR,
)
return meta_text
async def _dispatch_skill_slash_command_to_runner(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
runner_client: httpx.AsyncClient,
*,
agent: Agent,
has_mcp_servers: bool,
created_by: str | None,
) -> str:
"""
Persist a skill slash command and forward hidden skill context.
Skill content is runner-owned: this asks the bound runner to
resolve the skill (``POST /v1/sessions/{id}/skills/resolve``) into
its ``<skill>`` meta text, reading the ``SKILL.md`` body and
resource files from the skill's directory *on the runner* — so the
embedded ``<path>`` and resource listing are valid where the harness
executes. The server then persists the result (runner-resolves,
server-persists). Appends two conversation items with the same
response id:
* a visible ``slash_command`` item for the UI transcript;
* a hidden ``message`` item with ``is_meta=True`` containing the
full skill instructions for runner history replay.
Only the hidden message is sent to the runner as input. The visible
command is published as ``response.output_item.done`` after the
runner accepts the event.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param conv: Conversation row for ``session_id``.
:param body: Structured ``slash_command`` event body.
:param conversation_store: Store used to append both durable
items.
:param runner_client: HTTP client pointed at the bound runner.
:param agent: Agent bound to the conversation.
:param has_mcp_servers: ``True`` when the agent spec declares MCP
servers; forwarded unchanged to the runner event.
:param created_by: Authenticated actor id, e.g.
``"alice@example.com"``, or ``None`` in single-user mode.
:returns: The persisted visible ``slash_command`` item id.
:raises OmnigentError: If the skill is not exposed for the
session, or the runner is unreachable while resolving it.
"""
import uuid
skill_name, arguments = _parse_skill_slash_command(body)
meta_text = await _resolve_skill_meta_text_via_runner(
session_id,
skill_name,
arguments,
runner_client,
)
response_id = f"turn_{uuid.uuid4().hex}"
meta_content = [{"type": "input_text", "text": meta_text}]
visible_item = NewConversationItem(
type=_SLASH_COMMAND_TYPE,
response_id=response_id,
data=SlashCommandData(
agent=agent.name,
kind="skill",
name=skill_name,
arguments=arguments,
),
created_by=created_by,
)
meta_item = NewConversationItem(
type="message",
response_id=response_id,
data=MessageData(
role="user",
content=meta_content,
is_meta=True,
),
created_by=created_by,
)
persisted_items = await asyncio.to_thread(
conversation_store.append,
session_id,
[visible_item, meta_item],
)
visible = persisted_items[0]
# Mirror the plain-message path's title seeding: a session whose FIRST
# message is a skill invocation (web landing composer, REPL) would
# otherwise keep a NULL title and the sidebar falls back to the
# conversation id. Titled from the typed command ("/debate kafka…"),
# NOT the hidden meta item — that's the full SKILL.md instruction blob.
command_text = f"/{skill_name} {arguments}" if arguments else f"/{skill_name}"
await _seed_missing_title(
conv,
[{"type": "input_text", "text": command_text}],
conversation_store,
)
runner_body: dict[str, Any] = {
"type": "message",
"role": "user",
"content": meta_content,
"agent_id": conv.agent_id,
"model": agent.name,
"has_mcp_servers": has_mcp_servers,
# The forwarded message carries ``meta_content`` — i.e. the
# META item (persisted_items[1]), not the user-visible item.
# Hand the runner that id so a cold-cache reload drops the
# right persisted copy (see _forward_event_to_runner).
"persisted_item_id": persisted_items[1].id,
}
effective_runner_override = (
body.model_override if body.model_override is not None else conv.model_override
)
if effective_runner_override is not None:
runner_body["model_override"] = effective_runner_override
# Per-session brain-harness override — create-time only, so no
# per-event value exists; the persisted column is the source.
if conv.harness_override is not None:
runner_body["harness_override"] = conv.harness_override
try:
await runner_client.post(
f"/v1/sessions/{session_id}/events",
json=runner_body,
timeout=10.0,
)
event = OutputItemDoneEvent(type="response.output_item.done", item=visible.to_api_dict())
session_stream.publish(session_id, event.model_dump())
except (httpx.HTTPError, ConnectionError):
_logger.exception(
"Forward of skill slash command failed for session=%s; "
"items persisted, runner picks up on reconnect.",
session_id,
)
_publish_status(session_id, "idle")
return visible.id
def _title_content_from_item(item: NewConversationItem) -> list[dict[str, Any]]:
"""
Extract title candidate content blocks from a session item.
Only user ``message`` items contribute. Tool results and
assistant-shaped messages return an empty list so callers leave
the conversation title unchanged.
:param item: The parsed item being persisted, e.g. a user
``"message"`` item with input text content.
:returns: Content blocks that may contribute to a synthesized
title, e.g. ``[{"type": "input_text", "text": "Hello"}]``.
"""
if item.type != "message":
return []
if not isinstance(item.data, MessageData):
return []
if item.data.role != "user":
return []
return item.data.content
async def _seed_missing_title(
conv: Conversation,
content: list[dict[str, Any]],
conversation_store: ConversationStore,
) -> None:
"""
Set an untitled conversation's title from message content blocks.
No-op when the conversation already has a title or the blocks
yield no usable text. Mutates ``conv.title`` in place on success
so callers holding the row see the persisted value.
:param conv: The conversation row for the session.
:param content: Title-candidate blocks, e.g.
``[{"type": "input_text", "text": "/debate kafka vs sqs"}]``.
:param conversation_store: Store used to persist the title.
:returns: None.
"""
if conv.title is not None:
return
title = synthesize_conversation_title(content)
if title is None:
return
updated = await asyncio.to_thread(
conversation_store.update_conversation,
conv.id,
title=title,
)
if updated is not None:
conv.title = updated.title
async def _seed_missing_title_from_user_message(
conv: Conversation,
item: NewConversationItem,
conversation_store: ConversationStore,
) -> None:
"""
Set an untitled session's title from a user message.
The app UI creates sessions with ``initial_items=[]`` and posts
the first user message through ``POST /v1/sessions/{id}/events``.
This helper also covers callers that pass initial items to
``POST /v1/sessions``. Non-user-message items are ignored, and
already-titled conversations are left unchanged.
:param conv: The conversation row for the session.
:param item: The parsed item being persisted.
:param conversation_store: Store used to persist the title.
:returns: None.
"""
await _seed_missing_title(conv, _title_content_from_item(item), conversation_store)
async def _persist_session_event(
session_id: str,
body: SessionEventInput,
conversation_store: ConversationStore,
) -> str:
"""
Persist a user event without forwarding to a runner.
Used when the runner isn't online yet but the session has a
``host_id`` — the message is stored so the runner's crash-
recovery block picks it up from history when it connects.
:param session_id: Session/conversation identifier.
:param body: The validated event input.
:param conversation_store: Store for item persistence.
:param agent_name: Agent name for title seeding.
:returns: The store-assigned item id.
"""
import uuid
turn_id = f"turn_{uuid.uuid4().hex}"
item = _build_new_item(body, turn_id)
persisted_items = await asyncio.to_thread(
conversation_store.append,
session_id,
[item],
)
conv = await asyncio.to_thread(
conversation_store.get_conversation,
session_id,
)
if conv is not None:
await _seed_missing_title_from_user_message(
conv,
item,
conversation_store,
)
item_id = persisted_items[0].id if persisted_items else turn_id
_publish_external_conversation_item(session_id, persisted_items[0])
return item_id
def _extract_user_text_for_routing(body: SessionEventInput) -> str:
"""Extract plain text from a user message event for the routing judge.
Concatenates all ``input_text`` blocks in ``body.data["content"]``,
returning the first 4 000 characters. Returns ``""`` for non-message
events or events with no text content.
"""
content = body.data.get("content")
if not isinstance(content, list):
return ""
parts: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "input_text":
text = block.get("text", "")
if isinstance(text, str):
parts.append(text)
return " ".join(parts)[:4000]
async def _emit_server_routing_decision(
session_id: str,
conversation_store: ConversationStore,
model: str,
verdict: dict[str, Any],
*,
agent: str | None = None,
) -> None:
"""Persist and publish a ``routing_decision`` transcript chip.
Called by the server-side routing path before the turn is forwarded
to the runner. The chip shows the judge's model pick at turn start
— the same UX the runner-side advisor produced, but driven entirely
by the server.
:param agent: Sub-agent name to include when mirroring a child
session's routing decision into the parent's transcript.
"""
import uuid
from omnigent.runtime import session_stream
rationale = verdict.get("rationale", "")
item_data: dict[str, Any] = {
"model": model,
"applied": True,
"rationale": rationale if isinstance(rationale, str) else "",
}
if agent is not None:
item_data["agent"] = agent
try:
parsed_data = parse_item_data("routing_decision", item_data)
except (ValueError, TypeError):
_logger.warning("Server routing: failed to parse routing_decision data")
return
routing_item = NewConversationItem(
type="routing_decision",
response_id=f"routing_{uuid.uuid4().hex}",
data=parsed_data,
)
try:
persisted = await asyncio.to_thread(conversation_store.append, session_id, [routing_item])
persisted_id: str | None = persisted[0].id if persisted else None
except Exception:
_logger.exception(
"Server routing: routing_decision persist failed for session=%s",
session_id,
)
persisted_id = None
# Publish live event so the web UI renders the chip immediately.
session_stream.publish(
session_id,
{
"type": "response.output_item.done",
"item": {
"id": persisted_id,
"type": "routing_decision",
**item_data,
},
},
)
async def _forward_event_to_runner(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
runner_client: httpx.AsyncClient,
agent_name: str | None = None,
file_store: FileStore | None = None,
artifact_store: ArtifactStore | None = None,
has_mcp_servers: bool = False,
created_by: str | None = None,
) -> str:
"""
Persist a user event and forward it to the runner.
The server persists the item to the conversation store
(invariant I1: persist-before-forward), publishes acknowledgment
events, then POSTs the event to the runner's
``POST /v1/sessions/{id}/events``.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param conv: The conversation row for ``session_id``.
:param body: The validated event input from the client.
:param conversation_store: Store for item persistence.
:param runner_client: HTTP client pointed at the runner.
:param agent_name: Human-readable agent name for the
``model`` field on the runner body, e.g. ``"research-agent"``.
:param file_store: Optional file metadata store for resolving
``file_id`` references before forwarding.
:param artifact_store: Optional binary content store for
resolving ``file_id`` references before forwarding.
:param has_mcp_servers: ``True`` when the agent spec declares at
least one MCP server. Forwarded to the runner as the
``has_mcp_servers`` hint so ``proxy_stream`` knows to load
the agent spec and initialise :class:`ProxyMcpManager` for
this turn. ``False`` by default (agents without MCP servers).
:param created_by: Authenticated identity of the posting actor,
recorded on the persisted item for attribution.
:returns: The store-assigned id of the persisted item.
"""
import uuid
turn_id = f"turn_{uuid.uuid4().hex}"
item = _build_new_item(body, turn_id, created_by=created_by)
persisted_items = await asyncio.to_thread(
conversation_store.append,
session_id,
[item],
)
await _seed_missing_title_from_user_message(
conv,
item,
conversation_store,
)
# Don't publish status="running" or input.consumed here —
# wait until after the forward to the runner succeeds.
# Publishing early causes the REPL to start its streaming
# timer before the turn actually starts, showing a
# premature "working" phase.
# Resolve file_id references (input_image, input_file) to
# inline base64 data: URIs before forwarding. The runner and
# harness don't have access to the server's file store — the
# LLM endpoint needs the actual content, not an internal ID.
forwarded_data = dict(body.data)
if (
file_store is not None
and artifact_store is not None
and "content" in forwarded_data
and isinstance(forwarded_data["content"], list)
):
from omnigent.runtime.content_resolver import (
_resolve_message_content,
)
_unresolved = [
b for b in forwarded_data["content"] if isinstance(b, dict) and "file_id" in b
]
if _unresolved:
try:
forwarded_data["content"] = _resolve_message_content(
forwarded_data["content"],
file_store,
artifact_store,
session_id=session_id,
)
_logger.debug(
"Resolved %d file_id block(s) for session=%s before forwarding",
len(_unresolved),
session_id,
)
except (ValueError, KeyError):
_logger.warning(
"File reference resolution failed for session=%s "
"(unresolved file_id blocks will reach the runner unresolved — "
"runner will attempt fallback resolution)",
session_id,
exc_info=True,
)
# Flatten SessionEventInput {type, data} into the runner's
# discriminated-union shape {type, ...data_fields}. The runner's
# POST handler expects the harness event shape, not the
# session-API wrapper. Include agent_id so the runner can
# resolve the harness type and spawn environment.
runner_body: dict[str, Any] = {
"type": body.type,
**forwarded_data,
"agent_id": conv.agent_id,
# model tags the ResponseObject for REPL rendering.
# Use the human-readable agent name when available.
"model": agent_name or conv.agent_id or "",
# Signal to proxy_stream that it should initialise
# ProxyMcpManager and fetch MCP tool schemas for this turn.
# Only included (and only True) when the agent has MCP
# servers — False/absent saves the runner from a no-op spec
# load on every turn for agents without MCP servers.
"has_mcp_servers": has_mcp_servers,
# Id of the item just persisted for this turn. On a cold runner
# cache the runner reloads history (which includes this item in
# PRE-resolution form) and drops it by id, appending its own
# resolved copy — id-based dedup, not a role/content guess.
"persisted_item_id": persisted_items[0].id,
}
# Forward request-supplied client-side tool schemas so non-native
# harnesses can emit (and tunnel) the caller's tools — the runner
# merges these into the harness tool list (_merge_request_client_tools).
# Without this the runner only ever sees the spec's builtin/MCP tools
# and the model can't invoke client-side Read/Write/Glob/etc.
if body.tools:
runner_body["tools"] = body.tools
# Per-event override wins; fall back to the persisted column so a
# UI / REPL PATCH applies even when the client doesn't repeat
# model_override on every event. ``is not None`` over ``or`` per
# the no-invented-defaults rule.
effective_runner_override = (
body.model_override if body.model_override is not None else conv.model_override
)
# ── Server-side intelligent routing ──────────────────────────────
# When the session toggle is ON and no model has been chosen yet,
# call the judge LLM on the FIRST message to pick the model for
# the entire session. The verdict is persisted as model_override
# on the conversation so subsequent turns reuse it without another
# judge call.
# Route if: toggle is on for this session (top-level), OR this is a
# sub-agent and its parent session has the toggle on.
_parent_routing_on = False
if conv.parent_conversation_id is not None:
_parent_conv = await asyncio.to_thread(
conversation_store.get_conversation, conv.parent_conversation_id
)
_parent_routing_on = (
_parent_conv is not None and _parent_conv.cost_control_mode_override == "on"
)
_routing_enabled = (
conv.cost_control_mode_override == "on" and conv.parent_conversation_id is None
) or _parent_routing_on
_routed_model: str | None = None
_verdict: dict[str, Any] | None = None
# For child sessions, route even when the orchestrator specified a model via
# sys_session_send (effective_runner_override is already set). Smart routing
# always wins over the LLM's own model choice when the parent toggle is on.
_should_route = (
_routing_enabled
and body.type == "message"
and (effective_runner_override is None or conv.parent_conversation_id is not None)
)
if _should_route:
from omnigent.server.smart_routing import route_turn
_harness = _resolve_harness(conv)
_user_text = _extract_user_text_for_routing(body)
if _user_text:
_routed_model, _verdict = await route_turn(
_harness,
_user_text,
session_id=session_id,
runner_client=runner_client,
)
if _routed_model is not None:
effective_runner_override = _routed_model
# Persist as the session's model_override so all
# subsequent turns use this model automatically.
try:
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
model_override=_routed_model,
)
except (OSError, ValueError):
_logger.warning(
"smart_routing: failed to persist model_override "
"for session=%s; turn still uses routed model",
session_id,
exc_info=True,
)
# ────────────────────────────────────────────────────────────────
if effective_runner_override is not None:
runner_body["model_override"] = effective_runner_override
# Per-session brain-harness override — create-time only, so no
# per-event value exists; the persisted column is the source.
if conv.harness_override is not None:
runner_body["harness_override"] = conv.harness_override
# The runner's sessions-native POST returns 202 immediately
# and starts the turn as a background task. No streaming
# response to drain — events flow through GET /stream.
try:
await runner_client.post(
f"/v1/sessions/{session_id}/events",
json=runner_body,
timeout=10.0,
)
# Publish input.consumed AFTER the forward succeeds —
# the runner has the message and will start the turn.
_publish_input_consumed(session_id, persisted_items[0])
# Emit the routing_decision chip AFTER input.consumed so the
# live SSE stream delivers the user bubble before the chip —
# matching the store order (user message was persisted first).
if _routed_model is not None and _verdict is not None:
await _emit_server_routing_decision(
session_id,
conversation_store,
_routed_model,
_verdict,
)
# Mirror the routing decision into the parent session so the
# orchestrator's transcript also shows which model was chosen
# for this sub-agent — the decision is otherwise only visible
# on the child session screen.
if _parent_routing_on and conv.parent_conversation_id is not None:
await _emit_server_routing_decision(
conv.parent_conversation_id,
conversation_store,
_routed_model,
_verdict,
agent=agent_name or "",
)
except (httpx.HTTPError, ConnectionError):
_logger.exception(
"Forward to runner failed for session=%s; "
"event persisted, runner picks up on reconnect.",
session_id,
)
_publish_status(session_id, "idle")
return persisted_items[0].id
@dataclass
class _SessionEventDispatchResult:
"""
Outcome of forwarding one item-event to the runner.
:param item_id: Store-assigned id of the AP-persisted item, e.g.
``"item_abc123"``. ``None`` for the claude-native message
bypass, which persists nothing AP-side.
:param pending_id: Id of the :mod:`omnigent.runtime.pending_inputs`
entry recorded for a native-terminal web message, e.g.
``"pending_a1b2c3"`` — surfaced to the sender so it can adopt
the id and dedupe against the snapshot. ``None`` for non-native
events (already persisted, so no separate pending entry).
"""
item_id: str | None
pending_id: str | None
async def _dispatch_session_event_to_runner(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
runner_client: httpx.AsyncClient,
*,
agent_name: str | None,
file_store: FileStore | None,
artifact_store: ArtifactStore | None,
has_mcp_servers: bool = False,
created_by: str | None = None,
runner_router: RunnerRouter | None = None,
) -> _SessionEventDispatchResult:
"""
Forward an item-event to the runner with harness-aware dispatch.
Callers stay harness-agnostic — the claude-native message bypass
is encapsulated here. Two dispatch outcomes:
* **transcript-forwarded native + ``type == "message"``**: web-chat user
messages on these sessions must NOT be persisted by the AP
server. The Omnigent would otherwise persist an AP-side copy AND
let the transcript forwarder mirror the same message back
(with its own store-assigned item id), so every web-typed
prompt would land as two items in the chat panel. We forward
to the bound runner so the native harness types the
message into tmux; the transcript forwarder becomes the
single writer for the conversation history. Returns a result
with ``item_id=None`` (no AP-side persisted item) and a
``pending_id`` for the optimistic-bubble index entry.
* **All other cases**: persist the item AP-side (invariant I1:
persist-before-forward) and forward via the harness's
``/events`` scaffold. Returns the persisted item id and
``pending_id=None``.
The single-writer invariant is the entire reason the bypass
exists; do NOT collapse the two branches into a single forward
that always persists. Doing so on a native session causes
duplicate items in the chat panel as soon as the transcript
forwarder mirrors the same prompt back.
The pending-input entry recorded on the native path bridges the
transcript round-trip: until the forwarder mirrors the message
back, it lives nowhere durable, so a client that navigates away /
rebinds would lose the optimistic bubble. The entry is replayed
into the snapshot and drained when the message persists (see
:mod:`omnigent.runtime.pending_inputs`). It is rolled back if the
forward fails, so a never-delivered message leaves no ghost.
:param session_id: Session/conversation identifier.
:param conv: Conversation row for *session_id*.
:param body: Validated event from the client.
:param conversation_store: Used by the non-native path to
persist the item.
:param runner_client: The session's runner client, already
resolved by the caller via :func:`_get_runner_client`.
:param agent_name: Human-readable agent name for the
``model`` field on non-native forwards.
:param file_store: Optional file metadata store for resolving
``file_id`` references before forwarding.
:param artifact_store: Optional binary store for the same.
:param has_mcp_servers: ``True`` when the agent spec declares at
least one MCP server. Forwarded to the runner as the
``has_mcp_servers`` hint. ``False`` by default.
:param created_by: Authenticated identity of the posting actor,
e.g. ``"alice@example.com"``. On the non-native path it is
recorded directly on the persisted item. On the claude-native
bypass the transcript forwarder is the single writer, so
``created_by`` is stored in the ``pending_inputs`` entry via
:func:`omnigent.runtime.pending_inputs.record` and applied
to the item when the forwarder mirrors it back (see
:func:`_persist_external_conversation_item`).
:param runner_router: Router used to resolve the runner for the
native-terminal parent-wake forward when a sub-agent fails to
boot (see :func:`_persist_native_terminal_failure`). ``None``
in in-process / test setups where the global client is used.
:returns: A :class:`_SessionEventDispatchResult` carrying the
persisted item id (non-native) or the pending-input id
(claude-native message bypass).
"""
if body.type == "message" and _is_native_terminal_session(conv):
# Validate before touching the runner. The ensure probe is only
# for syntactically valid user messages; assistant/system-shaped
# inputs should still fail locally without creating terminals.
_build_native_terminal_message_event(conv, body)
ensure_outcome = await _ensure_native_terminal_ready(
runner_client,
session_id,
conv,
)
if ensure_outcome.error is not None:
item_id = await _persist_native_terminal_failure(
session_id,
conv,
body,
conversation_store,
ensure_outcome.error,
runner_router,
created_by=created_by,
)
return _SessionEventDispatchResult(item_id=item_id, pending_id=None)
if ensure_outcome.policy_notice is not None:
# Terminal is up but policy enforcement is off (fail-open). Post
# a durable, non-fatal banner; the user message still forwards.
await _persist_native_policy_notice(
session_id,
conversation_store,
ensure_outcome.policy_notice,
)
# Record the optimistic bubble before forwarding so it's known
# server-side immediately (replayed into the snapshot). Roll it
# back on any failure/cancellation so a message the TUI never
# received doesn't replay as a ghost.
content = body.data.get("content")
pending_id: str | None = (
pending_inputs.record(session_id, content, created_by=created_by)
if isinstance(content, list) and content
else None
)
# ── Server-side routing for native terminal sessions ────────
# Same logic as the SDK path in _forward_event_to_runner: if
# the toggle is on and no model_override is set, call the
# judge and persist the chosen model on the conversation row.
# The native CLI reads model_override from the session.
_native_parent_routing_on = False
if conv.parent_conversation_id is not None:
_native_parent_conv = await asyncio.to_thread(
conversation_store.get_conversation, conv.parent_conversation_id
)
_native_parent_routing_on = (
_native_parent_conv is not None
and _native_parent_conv.cost_control_mode_override == "on"
)
_native_routing_enabled = (
conv.cost_control_mode_override == "on" and conv.parent_conversation_id is None
) or _native_parent_routing_on
_native_routed_model: str | None = None
_native_verdict: dict[str, Any] | None = None
if _native_routing_enabled and (
conv.model_override is None or conv.parent_conversation_id is not None
):
from omnigent.server.smart_routing import route_turn
_harness = _resolve_harness(conv)
_user_text = _extract_user_text_for_routing(body)
if _user_text:
_native_runner_client = await _get_runner_client(session_id, runner_router)
_native_routed_model, _native_verdict = await route_turn(
_harness,
_user_text,
session_id=session_id,
runner_client=_native_runner_client,
)
if _native_routed_model is not None:
try:
await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
model_override=_native_routed_model,
)
except (OSError, ValueError):
_logger.warning(
"smart_routing: persist failed for native session=%s",
session_id,
exc_info=True,
)
# For claude-native: inject /model into the running
# terminal so the change takes effect immediately
# (model_override alone is only applied at spawn).
try:
await runner_client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "model_change", "model": _native_routed_model},
timeout=5.0,
)
except httpx.HTTPError:
_logger.debug(
"smart_routing: model_change forward failed for session=%s "
"(runner may not support it yet)",
session_id,
)
# ────────────────────────────────────────────────────────────
forwarded = False
try:
await _forward_native_terminal_message(
runner_client,
session_id,
conv,
body,
file_store=file_store,
artifact_store=artifact_store,
)
forwarded = True
finally:
if not forwarded and pending_id is not None:
pending_inputs.resolve(session_id, pending_id)
# Emit the routing chip AFTER forwarding the message to the
# terminal so the live SSE stream delivers the user bubble
# (echoed back by the CLI) before the chip.
if _native_routed_model is not None and _native_verdict is not None:
await _emit_server_routing_decision(
session_id,
conversation_store,
_native_routed_model,
_native_verdict,
)
if _native_parent_routing_on and conv.parent_conversation_id is not None:
await _emit_server_routing_decision(
conv.parent_conversation_id,
conversation_store,
_native_routed_model,
_native_verdict,
agent=agent_name or "",
)
return _SessionEventDispatchResult(item_id=None, pending_id=pending_id)
item_id = await _forward_event_to_runner(
session_id,
conv,
body,
conversation_store,
runner_client,
agent_name=agent_name,
file_store=file_store,
artifact_store=artifact_store,
has_mcp_servers=has_mcp_servers,
created_by=created_by,
)
return _SessionEventDispatchResult(item_id=item_id, pending_id=None)
def _extract_persistent_item_from_sse(
event: dict[str, Any],
response_id: str | None = None,
) -> NewConversationItem | None:
"""
Extract a persistable conversation item from a runner SSE event.
Returns a ``NewConversationItem`` for:
- ``response.output_item.done`` events carrying an assistant
message, function_call, or function_call_output.
- ``compaction`` events carrying a conversation summary from
the runner's compaction system.
Returns ``None`` for all other events (transient deltas, turn
lifecycle, compaction progress indicators, etc.).
:param event: Parsed SSE event dict from the runner stream.
:param response_id: Turn-scoped id from the most recent
``response.in_progress`` event. All items persisted within
the same turn share this id so the web UI can group them
into a single bubble and pair function_calls with their
outputs. Falls back to a fresh uuid when unavailable.
:returns: A ``NewConversationItem`` ready for
``conv_store.append()``, or ``None``.
"""
import uuid
evt_type = event.get("type")
if evt_type == "compaction":
try:
data = parse_item_data("compaction", event)
except (ValueError, TypeError):
_logger.warning("Failed to parse compaction item from SSE")
return None
return NewConversationItem(
type="compaction",
response_id=f"compact_{uuid.uuid4().hex}",
data=data,
)
if evt_type != "response.output_item.done":
return None
item = event.get("item")
if not isinstance(item, dict):
return None
item_type = item.get("type")
if item_type not in ("message", "function_call", "function_call_output"):
return None
# Skip transient observed function_call events (status
# ``in_progress`` / ``action_required``). Only ``completed``
# function_calls are durable — the scaffold emits them after
# the dispatch Future resolves. Persisting interim statuses
# creates orphan conversation items whose spinners never
# resolve in the web UI.
if item_type == "function_call" and item.get("status") != "completed":
return None
try:
data = parse_item_data(item_type, item)
except (ValueError, TypeError):
_logger.warning(
"Failed to parse persistent item from SSE: %s",
item_type,
)
return None
return NewConversationItem(
type=item_type,
response_id=response_id or f"turn_{uuid.uuid4().hex}",
data=data,
)
def _resource_event_item_from_sse(
session_id: str,
event: dict[str, Any],
) -> NewConversationItem | None:
"""
Build a ``resource_event`` conversation item from a runner SSE event.
The runner emits ``session.resource.created`` /
``session.resource.deleted`` when an agent tool
(``sys_terminal_launch`` / ``sys_terminal_close``) materializes or
tears down a session resource mid-turn. The relay republishes the
raw event onto the live ``session_stream`` (so connected clients
update instantly); this helper produces the durable conversation
item so a client that reconnects mid-turn rediscovers the resource
in the snapshot — matching the REST resource path
(:func:`_publish_and_persist_resource_event`).
Returns ``None`` for every other event type, and for malformed
resource events (missing id / type) so a bad frame can't poison
the relay.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param event: Parsed SSE event dict from the runner stream.
:returns: A ``resource_event`` :class:`NewConversationItem`, or
``None``.
"""
from omnigent.entities.conversation import ResourceEventData
evt_type = event.get("type")
if evt_type == "session.resource.created":
resource = event.get("resource")
if not isinstance(resource, dict):
return None
resource_id = resource.get("id")
resource_type = resource.get("type")
elif evt_type == "session.resource.deleted":
resource = None
resource_id = event.get("resource_id")
resource_type = event.get("resource_type")
else:
return None
# Require non-empty id/type. ``isinstance(x, str)`` alone admits
# ``""``, which would persist a malformed resource_event item the
# snapshot can't resolve back to a real resource. Drop the frame
# instead — the snapshot endpoint stays the source of truth.
if not resource_id or not isinstance(resource_id, str):
return None
if not resource_type or not isinstance(resource_type, str):
return None
return NewConversationItem(
type="resource_event",
response_id=session_id,
data=ResourceEventData(
event_type=evt_type,
resource_id=resource_id,
resource_type=resource_type,
resource=resource,
),
)
def _routing_decision_item_from_sse(
event: dict[str, Any],
) -> NewConversationItem | None:
"""
Build a ``routing_decision`` conversation item from a runner SSE event.
The runner's cost advisor emits a ``response.output_item.done`` with a
``routing_decision`` item at the START of an advised turn (the
intelligent model router's pick). This produces the durable,
display-only transcript item so the pick survives reload at the right
position (BEFORE the turn's assistant output); the relay also
re-publishes a live event carrying the persisted item id so the live
chip and a turn-start snapshot refetch dedup by the same id (no
double render).
Returns ``None`` for every other event, and for a malformed routing
item (empty model) so a bad frame can't poison the relay.
:param event: Parsed SSE event dict from the runner stream.
:returns: A ``routing_decision`` :class:`NewConversationItem`, or
``None``.
"""
if event.get("type") != "response.output_item.done":
return None
item = event.get("item")
if not isinstance(item, dict) or item.get("type") != "routing_decision":
return None
try:
data = parse_item_data("routing_decision", item)
except (ValueError, TypeError):
_logger.warning("Failed to parse routing_decision item from SSE")
return None
# No turn response_id exists yet (emitted before response.in_progress),
# so stamp a fresh routing id — the chip renders as its own standalone
# line at turn start.
import uuid
return NewConversationItem(
type="routing_decision",
response_id=f"routing_{uuid.uuid4().hex}",
data=data,
)
def _error_item_from_sse(
event: dict[str, Any],
response_id: str | None = None,
) -> NewConversationItem | None:
"""
Build a durable ``error`` item from a runner error SSE event.
The web UI already renders live ``response.error`` and
``response.failed`` error payloads as real error banners. This
helper mirrors turn-scoped payloads into conversation history so the
banner survives refresh/reconnect.
A bare ``response.error`` emitted before ``response.in_progress`` is
a session/startup signal, not a transcript turn. Leaving it live-only
avoids creating an orphan banner at the top of the transcript; when
a user sends a message into the failed native terminal, the AP-side
fast-fail path records that user item and its sibling error in order.
:param event: Parsed runner SSE event.
:param response_id: Current response id, e.g. ``"resp_abc123"``.
``None`` means no turn is active.
:returns: A ``type="error"`` item, or ``None`` when the event has
no structured error payload or is not tied to a turn.
"""
evt_type = event.get("type")
raw_error: Any
source = event.get("source")
if evt_type == "response.error":
if response_id is None:
return None
raw_error = event.get("error")
elif evt_type == "response.failed":
raw_response = event.get("response")
raw_error = raw_response.get("error") if isinstance(raw_response, dict) else None
if raw_error is None:
raw_error = event.get("error")
source = "execution"
if response_id is None and isinstance(raw_response, dict):
raw_response_id = raw_response.get("id")
if isinstance(raw_response_id, str) and raw_response_id:
response_id = raw_response_id
else:
return None
if response_id is None:
return None
if not isinstance(raw_error, dict):
return None
raw_code = raw_error.get("code")
raw_message = raw_error.get("message")
if not isinstance(raw_code, str) or not raw_code.strip():
return None
if not isinstance(raw_message, str) or not raw_message.strip():
return None
if source not in ("llm", "execution", "tool"):
return None
return NewConversationItem(
type="error",
response_id=response_id,
data=ErrorData(
source=source,
code=raw_code,
message=raw_message,
),
)
async def _relay_persist_error_once(
conversation_store: ConversationStore | None,
session_id: str,
item: NewConversationItem,
) -> Literal["persisted", "duplicate", "skipped", "failed"]:
"""
Persist a runner error item unless the same error already exists.
Native terminal startup can fail again on every runner reconnect.
Dedupe by the visible payload ``(source, code, message)`` only
when no user message has appeared since the matching error. That
suppresses reconnect spam while still recording a new error for a
user-initiated retry against the same broken terminal.
:param conversation_store: Store instance, or ``None`` to skip.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param item: The candidate ``type="error"`` item.
:returns: ``"persisted"`` if this call appended the item,
``"duplicate"`` if a matching recent error already exists,
``"skipped"`` if no store or non-error item was provided, or
``"failed"`` if the store operation failed.
"""
if conversation_store is None:
return "skipped"
if not isinstance(item.data, ErrorData):
return "skipped"
try:
recent = await asyncio.to_thread(
conversation_store.list_items,
session_id,
limit=20,
order="desc",
)
for existing in recent.data:
if (
existing.type == "message"
and isinstance(existing.data, MessageData)
and existing.data.role == "user"
):
break
if existing.type != "error" or not isinstance(existing.data, ErrorData):
continue
if (
existing.data.source == item.data.source
and existing.data.code == item.data.code
and existing.data.message == item.data.message
):
return "duplicate"
await asyncio.to_thread(
conversation_store.append,
session_id,
[item],
)
return "persisted"
except Exception:
_logger.exception(
"Relay error persist failed for session=%s",
session_id,
)
return "failed"
async def _relay_persist(
conversation_store: ConversationStore | None,
session_id: str,
item: NewConversationItem,
) -> None:
"""
Persist a single conversation item from the relay.
:param conversation_store: Store instance, or ``None`` to skip.
:param session_id: Session/conversation identifier.
:param item: The item to persist.
"""
if conversation_store is None:
return
try:
await asyncio.to_thread(
conversation_store.append,
session_id,
[item],
)
except Exception:
_logger.exception(
"Relay persist failed for session=%s",
session_id,
)
async def _flush_relay_text(
conversation_store: ConversationStore | None,
session_id: str,
text_acc: list[str],
response_id: str | None,
model_id: str | None,
) -> None:
"""
Persist buffered assistant text as a message item and clear the buffer.
Scaffold harnesses (claude-sdk) stream text deltas with no per-message
``output_item.done``, so the relay buffers them. Flushing at each
text→function_call boundary (not only at ``response.completed``) keeps
the persisted transcript interleaved — ``[text, tool, text, tool]`` —
instead of collapsing a turn's narration into one block after its tool
calls (which renders tools-above-text + run-on text on reload).
After a confirmed persist the item is also published to the live
stream as ``response.output_item.done`` (mirroring the native path's
:func:`_publish_external_conversation_item`). Live clients already
rendered the text from the deltas; the publish delivers the
store-assigned item id so they can stamp it onto the streamed block.
Without it the rendered block stays id-less and every reconnect's
itemId-keyed reconciliation splices the persisted copy in as a
duplicate. Clients must dedupe this event by CONTENT, not by
open-section state: at a mid-turn tool-call boundary the streamed
text has already been closed/committed client-side (by the
function_call item or interleaved reasoning) before this publish
arrives. The web stamps the id onto the matching streamed
``text_done`` block in place (web ``chatStore.ts``
``pumpStreamEvents``); the TUI consumes a byte-equal committed
segment (``_repl.py`` ``_TurnProseTracker``).
The buffer and the in-flight replay are cleared ONLY after the append
is confirmed: clearing first would let a reconnect during the persist
``await`` see neither the (not-yet-committed) message nor the replay,
dropping the narration — and a swallowed append failure would lose it
permanently. On failure the buffers are left intact so the text still
replays and is retried at the next flush / ``response.completed``.
:param conversation_store: Store to append to, or ``None`` to skip
persistence (test parsing path).
:param session_id: Conversation/session id, e.g. ``"conv_abc123"``.
:param text_acc: Accumulated delta strings; cleared in place on success.
:param response_id: Turn id so the segment groups with its tool calls.
:param model_id: Assistant agent label for the message.
"""
if not text_acc:
return
text = "".join(text_acc)
if not text.strip():
# Whitespace-only: nothing worth persisting. Drop it so it neither
# accumulates into the next segment nor replays as an empty bubble.
text_acc.clear()
inflight_text.reset_text(session_id)
return
if conversation_store is None:
text_acc.clear()
return
import uuid
try:
item = NewConversationItem(
type="message",
response_id=response_id or f"turn_{uuid.uuid4().hex}",
data=parse_item_data(
"message",
{
"type": "message",
"role": "assistant",
"agent": model_id or "unknown",
"content": [{"type": "output_text", "text": text}],
},
),
)
persisted = await asyncio.to_thread(conversation_store.append, session_id, [item])
except Exception:
# Keep text_acc + the in-flight buffer so the narration isn't lost:
# it still replays on reconnect and is retried at the next flush.
_logger.exception(
"Relay: failed to persist assistant text segment for session=%s",
session_id,
)
return
# Confirmed persisted — now safe to clear. Synchronous (no await before
# the next yield), so no reconnect observes the committed message and a
# stale replay together.
text_acc.clear()
inflight_text.reset_text(session_id)
# Publish the persisted item so live clients learn its store-assigned
# id and stamp it onto the already-rendered streamed text (see the
# docstring). Ordered before the boundary item / terminal event the
# caller publishes next; clients match it back to the streamed text
# by byte-equal content, not by open-section state.
done_event = OutputItemDoneEvent(
type="response.output_item.done",
item=persisted[0].to_api_dict(),
)
session_stream.publish(session_id, done_event.model_dump())
async def _relay_runner_stream(
session_id: str,
runner_client: httpx.AsyncClient,
conversation_store: ConversationStore,
ready: asyncio.Event | None = None,
) -> None:
"""
Subscribe to the runner's SSE stream and relay events locally.
Long-lived background task that opens
``GET /v1/sessions/{id}/stream`` on the runner and publishes
each event to the local ``session_stream`` pub-sub. Also
updates ``_session_status_cache`` from turn lifecycle events
and persists conversation items (assistant messages, tool
calls) to the conversation store as they arrive.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param runner_client: HTTP client pointed at the runner.
:param conversation_store: Store for persisting conversation
items extracted from the runner's SSE stream.
:param ready: Optional event set once the runner stream emits its
ready heartbeat, proving AP's runner-side no-replay subscriber
slot is registered. ``None`` is accepted for direct unit tests
that exercise relay parsing/persistence without asserting on
startup readiness.
"""
from omnigent.runtime import session_stream
text_acc: list[str] = []
current_response_id: str | None = None
# Model/agent label from the turn header, stamped on text segments
# flushed at tool-call boundaries (the boundary event carries no model).
current_model: str | None = None
# Map tool call_id → response_id so a function_call_output that
# arrives after a new response.in_progress (different response_id)
# still pairs with its matching function_call. Without this, the
# web UI's block stream clears its pending-tool state on the
# response_id transition and the tool card spinner never resolves.
tool_call_response_ids: dict[str, str] = {}
_logger.info("Relay: connecting to runner GET /stream for session=%s", session_id)
# Read timeout: 3x the runner's session-stream heartbeat interval
# (15s). Between turns the runner emits ``session.heartbeat`` every
# 15s to keep proxies from dropping the idle connection. If 3
# consecutive heartbeats are missed (45s), the connection is likely
# dead — let the relay exit so ``_ensure_runner_relay`` can restart
# it on the next ``POST /events``. ``connect`` stays at httpx's
# default (5s); ``write``/``pool`` are not rate-limiting here.
_relay_timeout = httpx.Timeout(connect=5.0, read=45.0, write=None, pool=None)
try:
async with runner_client.stream(
"GET",
f"/v1/sessions/{session_id}/stream",
timeout=_relay_timeout,
) as resp:
_logger.info("Relay: connected to runner GET /stream for session=%s", session_id)
buffer = ""
async for chunk in resp.aiter_text():
buffer += chunk
while "\n\n" in buffer:
frame, _, buffer = buffer.partition("\n\n")
data_line = next(
(ln for ln in frame.splitlines() if ln.startswith("data:")),
None,
)
if data_line is None:
continue
payload = data_line[5:].strip()
if payload == "[DONE]":
return
try:
event = json.loads(payload)
except json.JSONDecodeError:
continue
evt_type = event.get("type", "")
# The runner emits session.status events
# directly.
# Re-publish via _publish_status so the event
# gets the conversation_id field required by
# SessionStatusEvent's schema. The cache write
# happens inside _publish_status itself.
# Runner-emitted keepalive — consumed to reset the
# read timeout; not forwarded to the session stream
# (the Omnigent subscriber generates its own heartbeats).
if evt_type == "session.heartbeat":
if ready is not None:
ready.set()
continue
# Stopped turn: drop its trailing response.* output (no
# forward, no persist) but keep text_acc — the pre-stop
# narration the user watched persists at the terminal flush.
if session_id in _interrupt_fenced_sessions:
if evt_type == "session.status" and event.get("status") == "running":
_interrupt_fenced_sessions.discard(session_id)
elif evt_type in _TERMINAL_RESPONSE_EVENT_TYPES:
# Terminal proves the stopped turn is over (completed =
# the stop lost the race); process it normally.
_interrupt_fenced_sessions.discard(session_id)
elif (
evt_type.startswith("response.")
and evt_type not in _FENCE_EXEMPT_EVENT_TYPES
):
continue
if evt_type == "session.status":
status = event.get("status", "")
if status:
# Forward the runner's failure detail on a
# ``failed`` transition so a SETUP-phase
# failure (which never emits response.failed)
# surfaces a real error message downstream
# instead of ending the turn silently.
raw_err = event.get("error")
status_error = (
ErrorDetail.model_validate(raw_err)
if isinstance(raw_err, dict)
else None
)
if status == "failed" and status_error is not None:
await _persist_session_status_error_labels(
session_id,
status_error,
conversation_store,
)
elif status == "running":
await _persist_session_status_error_labels(
session_id,
None,
conversation_store,
)
# PTY-activity status is a UI signal only. Terminal
# sub-agent delivery rides the Stop/StopFailure hook
# via external_session_status (the codex-shared path)
# — the PTY idle oscillates on mid-turn lulls and
# would deliver a premature, lock-out completion.
_publish_status(session_id, status, status_error)
if status == "running":
text_acc.clear()
continue
# Terminal spin-up status from the runner's auto-create
# path. Re-publish via _publish_terminal_pending so the
# event carries conversation_id and the cache write
# (read by the snapshot) stays coherent with the stream.
if evt_type == "session.terminal_pending":
# Use ``is True`` (not bool()) so a malformed frame
# with a string like ``"false"`` can't strand the
# spinner on — the runner always sends a real bool.
_publish_terminal_pending(
session_id,
event.get("pending") is True,
)
continue
# Track the turn's response_id from lifecycle
# events so persisted items share one id.
if evt_type == "response.in_progress":
resp_obj = event.get("response", {})
_rid = resp_obj.get("id")
if isinstance(_rid, str) and _rid:
current_response_id = _rid
_model = resp_obj.get("model")
if isinstance(_model, str) and _model:
current_model = _model
# Accumulate response-scoped (scaffold) text deltas for
# persistence. Native message-scoped deltas (with a
# message_id) persist via their own output_item.done(message),
# so buffering them here would double-persist. Guard on
# non-empty str (like inflight_text.record_publish) so a
# malformed delta can't break the later "".join(text_acc).
if evt_type == "response.output_text.delta" and not event.get("message_id"):
_delta = event.get("delta")
if isinstance(_delta, str) and _delta:
text_acc.append(_delta)
# Track tool call_id → response_id so a
# function_call_output that arrives under a later
# response still pairs with its call. Done
# before _extract_persistent_item_from_sse because
# the parse may fail (serialization alias mismatch)
# while the mapping is still needed for the live
# event patch below.
_raw_item = event.get("item")
_item = _raw_item if isinstance(_raw_item, dict) else {}
_item_type = _item.get("type")
_item_call_id = _item.get("call_id")
if (
_item_type == "function_call"
and _item.get("status") == "completed"
and isinstance(_item_call_id, str)
and current_response_id is not None
):
tool_call_response_ids[_item_call_id] = current_response_id
# For function_call_output, use the response_id
# of the matching function_call so the web UI
# pairs them in the same bubble even when a new
# response.in_progress has already overwritten
# current_response_id.
if (
_item_type == "function_call_output"
and isinstance(_item_call_id, str)
and _item_call_id in tool_call_response_ids
):
_persist_rid = tool_call_response_ids[_item_call_id]
else:
_persist_rid = current_response_id
# Flush buffered narration as its own message BEFORE the
# function_call it preceded, so the transcript interleaves
# [text, tool, text, tool] instead of pooling a turn's text
# after its tool calls (tools-above-text + run-on on reload).
if (
_item_type == "function_call"
and _item.get("status") == "completed"
and text_acc
):
await _flush_relay_text(
conversation_store,
session_id,
text_acc,
current_response_id,
current_model,
)
conv_item = _extract_persistent_item_from_sse(
event,
response_id=_persist_rid,
)
if conv_item is not None:
await _relay_persist(
conversation_store,
session_id,
conv_item,
)
# On ANY terminal event (not just completed), persist the
# final text segment: narration streamed before a failure /
# cancel must survive reload too, ordered BEFORE the error
# item below and before the publish pops the in-flight
# replay entry (flush → publish keeps reload == live).
# NB: fenced deltas never reached text_acc (the fence's
# continue precedes accumulation), so a post-Stop flush
# carries pre-stop narration only.
if evt_type in _TERMINAL_RESPONSE_EVENT_TYPES:
_resp_obj = event.get("response")
_resp_model = (
_resp_obj.get("model") if isinstance(_resp_obj, dict) else None
)
_final_model = (
_resp_model
if isinstance(_resp_model, str) and _resp_model
else current_model
)
await _flush_relay_text(
conversation_store,
session_id,
text_acc,
current_response_id,
_final_model,
)
error_item = _error_item_from_sse(
event,
response_id=current_response_id,
)
if error_item is not None:
await _relay_persist_error_once(
conversation_store,
session_id,
error_item,
)
# Persist resource lifecycle events
# (session.resource.created / .deleted) emitted by
# agent-tool terminal launches/closes so reconnecting
# clients rediscover the resource in the snapshot.
# The live publish below already updates connected
# clients.
resource_item = _resource_event_item_from_sse(session_id, event)
if resource_item is not None:
await _relay_persist(
conversation_store,
session_id,
resource_item,
)
# Self-heal the spin-up flag: a created terminal is
# authoritative proof the session is no longer
# "starting up", so clear it even if the runner's
# auto-create finally was skipped (e.g. hard kill
# between launch and clear). Only fire on a real
# state change to avoid redundant stream traffic.
if (
resource_item.data.event_type == "session.resource.created"
and resource_item.data.resource_type == "terminal"
and _session_terminal_pending_cache.get(session_id, False)
):
_publish_terminal_pending(session_id, False)
# Intelligent-model-router decision emitted by the runner's
# cost advisor at turn start. Persist as a display-only
# transcript item (arrival order = BEFORE the assistant
# output), then re-publish the live event carrying the
# store-assigned id so the live chip and a turn-start
# snapshot refetch dedup by the same id. Handled
# exclusively here (persist + publish + continue) so the
# raw, id-less runner event is not also forwarded below.
routing_item = _routing_decision_item_from_sse(event)
if routing_item is not None:
# Persist failure must NOT suppress the live chip
# (the owner's hard requirement: the pick shows the
# moment the turn starts). On a store error, log and
# still publish the live event — id-less, so a later
# snapshot can't dedup it, but a missing reload chip
# beats no chip at all.
try:
persisted = await asyncio.to_thread(
conversation_store.append, session_id, [routing_item]
)
_persisted_id: str | None = persisted[0].id if persisted else None
except Exception:
_logger.exception(
"Relay: routing_decision persist failed for session=%s; "
"publishing the live chip without a durable id",
session_id,
)
_persisted_id = None
session_stream.publish(
session_id,
{
**event,
"item": {**event["item"], "id": _persisted_id},
},
)
continue
# Accumulate LLM token usage from the harness
# response so policy callables can read
# event["context"]["usage"]["total_cost_usd"].
if evt_type == "response.completed":
# Persist the turn's usage (cost + token buckets) so
# policy callables can read
# event["context"]["usage"]["total_cost_usd"] and the
# subtree roll-up below sees the new totals.
_accumulate_session_usage(
event.get("response", {}),
session_id,
conversation_store,
)
# Push the server-computed cost AND token breakdown
# to the web client's session indicator, rolled up
# over the spawn subtree. The session's own event
# carries its SUBTREE total (this conversation + its
# sub-agents), and each ancestor gets its own subtree
# total on its own stream — so a supervisor's badge
# includes its sub-agents and a parent updates live
# when a relay sub-agent spends. Mirrors the native
# path (_persist_external_session_usage); the roll-up
# was wired for native only, but relay agents (e.g.
# claude-sdk) need it too. Cost is included only when
# priced; the token breakdown rides along whenever any
# bucket is recorded (so an unpriced session still
# surfaces tokens). context_tokens/window already ride
# on the response.completed event. Threaded: store
# reads + SSE fan-out.
_subtree_usage = await asyncio.to_thread(
load_session_usage,
session_id,
conversation_store,
)
_subtree_cost = _priced_cost_for_display(_subtree_usage)
_usage_by_model = _usage_by_model_for_display(_subtree_usage)
if _subtree_cost is not None or _usage_by_model is not None:
_usage_payload: dict[str, Any] = {
"type": "session.usage",
"conversation_id": session_id,
}
if _subtree_cost is not None:
_usage_payload["total_cost_usd"] = _subtree_cost
if _usage_by_model is not None:
_usage_payload["usage_by_model"] = _usage_by_model
session_stream.publish(
session_id,
SessionUsageEvent(**_usage_payload).model_dump(exclude_none=True),
)
await asyncio.to_thread(
_publish_subtree_cost_to_ancestors,
conversation_store,
session_id,
)
# Reset the turn-scoped response_id on any
# terminal event so it doesn't leak to the
# next turn.
if evt_type in _TERMINAL_RESPONSE_EVENT_TYPES:
current_response_id = None
# Patch the live event's response_id for
# function_call_output items whose call_id maps
# to a known function_call response_id. This
# ensures the web UI's block stream pairs the
# tool result with its call in the same bubble.
if (
evt_type == "response.output_item.done"
and isinstance(event.get("item"), dict)
and event["item"].get("type") == "function_call_output"
):
_live_cid = event["item"].get("call_id")
if isinstance(_live_cid, str) and _live_cid in tool_call_response_ids:
event = {
**event,
"item": {
**event["item"],
"response_id": tool_call_response_ids[_live_cid],
},
}
if evt_type == "response.elicitation_request":
session_stream.publish(session_id, event)
await asyncio.to_thread(
_publish_elicitation_request_to_ancestors,
conversation_store,
session_id,
event,
)
continue
if evt_type == "response.elicitation_resolved":
session_stream.publish(session_id, event)
elicitation_id = event.get("elicitation_id")
if isinstance(elicitation_id, str) and elicitation_id:
await asyncio.to_thread(
_publish_elicitation_resolved_to_ancestors,
conversation_store,
session_id,
elicitation_id,
)
continue
session_stream.publish(session_id, event)
except (httpx.HTTPError, ConnectionError):
# WSTunnelTransport raises bare ConnectionError on tunnel
# close; treat the same as HTTPError so the task exits
# gracefully instead of leaving an unretrieved exception.
_logger.warning(
"Relay: runner transport lost for session=%s",
session_id,
exc_info=True,
)
# Publish a failed status so the client's SSE stream sees a
# clean error event instead of silent truncation (#1114).
disconnect_error = ErrorDetail(
code="runner_disconnected",
message="Runner disconnected unexpectedly.",
)
_publish_status(session_id, "failed", disconnect_error)
# Persist the disconnect cause as durable labels so the
# distinction survives into snapshots and child-session
# summaries. Without this the relay-fed cache only carries a
# generic ``failed`` and ``last_task_error`` is dropped, leaving
# the UI unable to tell a benign runner disconnect from a real
# task failure (Option B: render a "Disconnected" pill, not the
# red "Failed" pill). Cleared on the next ``running`` edge by the
# session.status handler, exactly like other failure labels.
await _persist_session_status_error_labels(
session_id,
disconnect_error,
conversation_store,
)
except asyncio.CancelledError:
raise
finally:
_logger.info("Relay: task exiting for session=%s", session_id)
# Drop any in-flight assistant-text entry so a relay that exits
# WITHOUT a terminal turn event (runner death / tunnel drop
# mid-turn, or a rebind cancellation) can't strand it forever.
# Normal turn-ends already clear via record_publish.
inflight_text.discard(session_id)
# Relay ended (runner dropped/rebound): re-discover runner-backed
# snapshot overlays next time. Cancel in-flight fetches so they can't
# land stale values from the dead runner after this pop.
_invalidate_runner_backed_snapshot_state(session_id, cancel_inflight=True)
def _ensure_runner_relay(
session_id: str,
runner_id: str | None,
runner_client: httpx.AsyncClient | None,
conversation_store: ConversationStore | None = None,
) -> _RelayHandle | None:
"""
Start (or replace) the SSE relay for ``session_id``.
No-op when a healthy relay is already bound to ``runner_id``.
When the bound runner changes (last-write-wins PATCH-rebind),
the stale relay is cancelled and a fresh one is created
against the new runner.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param runner_id: Runner id the new relay subscribes to,
e.g. ``"runner_abc123"``. ``None`` skips relay
(in-process path with no runner binding).
:param runner_client: HTTP client pointed at ``runner_id``.
``None`` skips relay.
:param conversation_store: Store for persisting items from
the runner's SSE stream. ``None`` disables persistence.
:returns: The active relay handle, or ``None`` when no runner is
bound.
"""
if runner_client is None or runner_id is None:
_logger.info(
"Relay: skipping for session=%s (runner_client=%s, runner_id=%s)",
session_id,
runner_client is not None,
runner_id,
)
return None
existing = _runner_relay_tasks.get(session_id)
if existing is not None:
if existing.runner_id == runner_id and not existing.task.done():
_logger.info("Relay: reusing existing for session=%s runner=%s", session_id, runner_id)
return existing # same runner, healthy task
_logger.info(
"Relay: replacing stale for session=%s (old_runner=%s done=%s)",
session_id,
existing.runner_id,
existing.task.done(),
)
if not existing.task.done():
existing.task.cancel() # stale binding; replace
else:
_logger.info("Relay: creating new for session=%s runner=%s", session_id, runner_id)
ready = asyncio.Event()
task = asyncio.create_task(
_relay_runner_stream(
session_id,
runner_client,
conversation_store,
ready,
),
name=f"runner-relay-{session_id}",
)
handle = _RelayHandle(runner_id=runner_id, task=task, ready=ready)
_runner_relay_tasks[session_id] = handle
def _on_done(t: asyncio.Task[None]) -> None:
# Clear our slot only if it still holds this task — a
# later rebind may have replaced us.
current = _runner_relay_tasks.get(session_id)
if current is not None and current.task is t:
_runner_relay_tasks.pop(session_id, None)
task.add_done_callback(_on_done)
return handle
async def _ensure_runner_relay_ready(
session_id: str,
runner_id: str | None,
runner_client: httpx.AsyncClient | None,
conversation_store: ConversationStore | None = None,
) -> _RelayHandle | None:
"""
Start the runner SSE relay and wait for its subscription ack.
The runner stream has no replay buffer. For item events, Omnigent must
subscribe to runner output before it forwards the input event; a
fast harness can otherwise complete before Omnigent is listening, leaving
the user with an apparently successful empty response.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param runner_id: Runner id the relay should bind to, e.g.
``"runner_abc123"``. ``None`` skips relay setup.
:param runner_client: HTTP client pointed at ``runner_id``.
``None`` skips relay setup.
:param conversation_store: Store for persisting relayed items.
:returns: The active relay handle, or ``None`` when no runner is
bound.
:raises OmnigentError: If the relay cannot observe the
runner stream's ready heartbeat before the timeout.
"""
handle = _ensure_runner_relay(
session_id,
runner_id,
runner_client,
conversation_store,
)
if handle is None or handle.ready.is_set():
return handle
try:
await asyncio.wait_for(
handle.ready.wait(),
timeout=_RUNNER_RELAY_READY_TIMEOUT_S,
)
except asyncio.TimeoutError as exc:
if handle.task.done():
raise OmnigentError(
"Runner stream relay exited before becoming ready",
code=ErrorCode.RUNNER_UNAVAILABLE,
) from exc
raise OmnigentError(
"Timed out waiting for runner stream relay to subscribe",
code=ErrorCode.RUNNER_UNAVAILABLE,
) from exc
return handle
# Per-session compaction locks so concurrent ``/compact`` POSTs
# don't race.
_COMPACT_LOCKS: dict[str, asyncio.Lock] = {}
async def _run_compact_locked(
session_id: str,
conv: Conversation,
agent_store: AgentStore,
agent_cache: AgentCache | None,
) -> None:
"""
Run explicit compaction while holding the per-session compact lock.
:param session_id: Session/conversation identifier.
:param conv: Conversation row.
:param agent_store: Agent store for spec lookup.
:param agent_cache: Agent cache for bundle loading.
"""
if conv.agent_id is None:
raise OmnigentError("Session has no agent binding", code=ErrorCode.INTERNAL_ERROR)
if agent_cache is None:
raise OmnigentError(
"Compaction is unavailable: agent cache is not configured",
code=ErrorCode.INTERNAL_ERROR,
)
# Check live status via cache; tasks table has been removed.
if _session_status_cache.get(session_id) in ("running", "waiting"):
raise OmnigentError(
"Cannot compact while a turn is running; cancel or wait for it to finish first",
code=ErrorCode.CONFLICT,
)
agent = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent is None or agent.bundle_location is None:
raise OmnigentError(
f"Agent not found: {conv.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
loaded = agent_cache.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
spec = loaded.spec
if spec.llm is not None:
llm_config = spec.llm
elif spec.executor.model is not None:
from omnigent.spec.types import LLMConfig
llm_config = LLMConfig(model=spec.executor.model, connection=spec.executor.connection)
else:
raise OmnigentError(
"Compaction requires a configured LLM model",
code=ErrorCode.INVALID_INPUT,
)
task_id = f"compact_{int(time.time() * 1000)}"
_publish_status(session_id, "running")
# compact() publishes its own in_progress / completed SSE events
# when conversation_id is set — don't double-publish here.
from omnigent.runtime.workflow import compact_conversation_now
try:
await compact_conversation_now(
task_id=task_id,
conversation_id=session_id,
spec=spec,
llm_config=llm_config,
tool_schemas=[],
preserve_recent_window=1,
)
except Exception as exc:
_logger.exception("Explicit session compaction failed for %s", session_id)
detail = str(exc) or repr(exc)
_publish_compaction_failed(session_id)
_publish_status(session_id, "idle")
raise OmnigentError(
f"Compaction failed while generating a summary: {detail}",
code=ErrorCode.INTERNAL_ERROR,
) from exc
_publish_status(session_id, "idle")
def _agent_provider_family(agent: Agent) -> str | None:
"""Return the provider family of an agent's harness, or ``None``.
Loads the agent's spec to read its ``harness_kind`` and maps it to a
provider family (``"anthropic"`` / ``"openai"``). Returns ``None`` when
the bundle can't be loaded or the harness is unknown — callers treat
``None`` as "can't confirm same family".
:param agent: The agent whose harness family to resolve.
:returns: ``"anthropic"`` / ``"openai"``, else ``None``.
"""
from omnigent.onboarding.provider_config import provider_family_for_harness
try:
spec = (
get_agent_cache()
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → unknown family
return None
return provider_family_for_harness(spec.executor.harness_kind)
def _same_provider_family(a: Agent, b: Agent) -> bool:
"""Return whether two agents share a (known) provider family.
``False`` when either family is undeterminable, so a fork that can't
confirm both agents speak the same provider resets model settings and
skips resuming the source's native session (the runner rebuilds the
native transcript from Omnigent items instead).
:param a: First agent (e.g. the fork source's agent).
:param b: Second agent (e.g. the switch target).
:returns: ``True`` when both resolve to the same non-``None`` family.
"""
family_a = _agent_provider_family(a)
return family_a is not None and family_a == _agent_provider_family(b)
def _agent_is_native(agent: Agent) -> bool:
"""Return whether an agent runs a native CLI harness.
Loads the agent's spec to read its ``harness_kind``. Native targets run
a vendor TUI in a terminal (claude-native / codex-native / pi-native /
cursor-native). This is broader than "can replay fork history" — every
native harness except cursor-native carries the session-file-rebuild path;
use ``_agent_carries_native_fork_history`` for that narrower gate. Returns
``False`` when the bundle can't be loaded (treated as non-native).
:param agent: The agent whose harness to classify.
:returns: ``True`` for a native CLI harness, else ``False``.
"""
from omnigent.harness_aliases import is_native_harness
try:
spec = (
get_agent_cache()
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → treat as non-native
return False
return is_native_harness(spec.executor.harness_kind)
# Native harnesses that rebuild a resumable on-disk transcript from the copied
# Omnigent items and relaunch the CLI with --resume, so prior turns reappear as
# native chat history. Used by BOTH fork and switch-agent. claude/codex are
# listed in both spellings because canonicalize_harness passes their reversed
# native ids through unchanged; pi-native needs only the one canonical id
# ("native-pi" is aliased to "pi-native") — same reasoning as
# model_override._CLAUDE_FAMILY_HARNESSES. pi-native rebuilds the Pi CLI's JSONL
# session file from copied items (omnigent/pi_native_resume.py), the same
# file-based mechanism claude/codex use.
#
# cursor is intentionally absent here: its conversation is server-backed (a
# synthesized/cloned local store.db is NOT loaded by `cursor-agent --resume`),
# so it can't rebuild a transcript. Instead a FORK carries cursor history as a
# text preamble (see _CURSOR_FORK_HISTORY_HARNESSES below) — switch-agent keeps
# the current fresh-launch behavior.
_FORK_HISTORY_NATIVE_HARNESSES: frozenset[str] = frozenset(
{
"claude-native",
"native-claude",
"codex-native",
"native-codex",
"hermes-native",
"native-hermes",
"pi-native",
# qwen-native rebuilds qwen's on-disk chat recording (+ runtime/meta
# sidecars) from the copied items, so a fork carries history into the
# qwen TUI (see _build_qwen_fork_recording / write_qwen_session_recording).
# Only the canonical id is needed — "native-qwen" is aliased to it.
"qwen-native",
}
)
# Native harnesses that carry FORK history as a text preamble (text-prefix
# replay) instead of a rebuilt transcript. Fork-only — switch-agent does not
# use this set, so switching into one still launches fresh. The runner branches
# on the harness to choose preamble vs transcript rebuild (see
# _auto_create_cursor_terminal / cursor_native_executor and the opencode
# resume/fork rehydration in _auto_create_opencode_terminal). opencode-native
# joins cursor here: opencode has no history-import API, so a fork seeds prior
# context as a noReply preamble rather than a rebuilt session.
_CURSOR_FORK_HISTORY_HARNESSES: frozenset[str] = frozenset(
{"cursor-native", "native-cursor", "opencode-native", "native-opencode"}
)
def _agent_carries_native_fork_history(agent: Agent) -> bool:
"""Return whether *agent*'s native harness rebuilds a fork's transcript.
claude-native / codex-native / pi-native each record a resumable native
session file that the runner rebuilds from the copied Omnigent items on
fork/resume, so a fork bound to one of them carries prior history into the
native CLI. Used by both fork and switch-agent. cursor-native is a native
CLI but has no resumable session file to rebuild; it carries fork history a
different way (a text preamble, fork-only — see
:func:`_agent_carries_cursor_fork_history`), so stamping
``carry_history_into_native`` for it here would be a false promise. Returns
``False`` when the bundle can't be loaded (treated as non-carrying).
:param agent: The agent whose harness to classify.
:returns: ``True`` only for transcript-rebuild native harnesses.
"""
from omnigent.harness_aliases import canonicalize_harness
try:
spec = (
get_agent_cache()
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → treat as non-carrying
return False
return canonicalize_harness(spec.executor.harness_kind) in _FORK_HISTORY_NATIVE_HARNESSES
def _agent_carries_cursor_fork_history(agent: Agent) -> bool:
"""Return whether *agent*'s native harness carries FORK history via preamble.
Cursor's conversation is server-backed and opencode has no history-import
API, so neither can seed a local store for a rebuilt resume; instead the
runner replays prior turns as a text preamble on the fork (cursor: the
first message; opencode: a ``noReply`` context message). Fork-only —
switch-agent does not call this, so switching into one still launches fresh.
Returns ``False`` when the bundle can't be loaded.
:param agent: The agent whose harness to classify.
:returns: ``True`` for the cursor-native / opencode-native harnesses.
"""
from omnigent.harness_aliases import canonicalize_harness
try:
spec = (
get_agent_cache()
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → treat as non-carrying
return False
return canonicalize_harness(spec.executor.harness_kind) in _CURSOR_FORK_HISTORY_HARNESSES
def _native_coding_agent_for_agent(agent: Agent) -> NativeCodingAgent | None:
"""
Return native coding-agent metadata for an agent's harness.
:param agent: The agent whose bundle should be inspected.
:returns: Registry metadata for the native TUI harness, or ``None``.
"""
try:
spec = (
get_agent_cache()
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → non-native presentation
return None
return native_coding_agent_for_harness(spec.executor.harness_kind)
def _presentation_labels_for_agent(agent: Agent) -> dict[str, str]:
"""Return the Web UI presentation labels for an agent's harness.
A native-CLI agent runs **terminal-first** (the inline terminal is the
main view), gated on ``omnigent.ui == "terminal"`` plus the matching
``omnigent.wrapper`` value; an SDK agent runs as plain chat (no such
labels). Used by the fork route so a switched clone's UI mode matches
the TARGET harness instead of inheriting the source's — otherwise an SDK
clone of a claude-native session renders a stale interactive terminal.
:param agent: The agent the fork will bind.
:returns: ``{ui: terminal, wrapper: <value>}`` for a native agent, or
``{}`` for an SDK agent / undeterminable family (chat mode).
"""
native_agent = _native_coding_agent_for_agent(agent)
return native_agent.presentation_labels if native_agent is not None else {}
async def _register_policy_elicitation(
session_id: str,
result: PolicyResult,
arguments_preview: str,
conversation_store: ConversationStore,
) -> str:
"""
Publish an elicitation request event on the session stream.
Approval state lives on the runner (in-memory
``_pending_approvals`` dict). The server just publishes the
``response.elicitation_request`` SSE event so the client
sees the approval prompt, and returns the elicitation_id
so the runner can key its Future on it.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param result: The :class:`PolicyResult` with action=ASK,
carrying the reason and deciding_policy fields.
:param arguments_preview: Truncated argument string for
the elicitation UI preview (max ~1024 chars).
:param conversation_store: Store used to mirror child-session
prompts into ancestor streams.
:returns: The generated elicitation id,
e.g. ``"elicit_a1b2c3..."``.
"""
elicitation_id = f"elicit_{secrets.token_hex(16)}"
elicitation = ElicitationRequest(
message=result.reason or "Approval required",
requested_schema={},
phase=Phase.TOOL_CALL.value,
policy_names=result.deciding_policies or ["unknown"],
content_preview=arguments_preview[:1024],
)
# Approval state lives on the runner (in-memory
# _pending_approvals dict of elicitation_id → Future).
# The server just publishes the elicitation SSE event and
# returns the elicitation_id. The runner parks on the
# Future; the client's approval event is forwarded to the
# runner which resolves it. No server-side state needed.
_elicit_event = build_elicitation_request_event(
elicitation_id, elicitation, session_id=session_id
)
session_stream.publish(session_id, _elicit_event)
await asyncio.to_thread(
_publish_elicitation_request_to_ancestors,
conversation_store,
session_id,
_elicit_event,
)
return elicitation_id
def _load_agent_spec_for_session(
conv: Conversation,
agent_store: AgentStore,
) -> AgentSpec | None:
# Split from _build_policy_engine_from_spec so the caller can run the
# cheap guardrails/default-policy skip check between the two and avoid
# paying for engine construction when no policy could fire. Both halves
# are blocking DB/IO, so each is run under asyncio.to_thread.
if conv.agent_id is None:
return None
agent = agent_store.get(conv.agent_id)
if agent is None:
return None
return (
get_agent_cache()
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
def _build_policy_engine_from_spec(
spec: AgentSpec,
session_id: str,
conversation_store: ConversationStore,
) -> PolicyEngine:
caps = get_caps()
host_connection = (
caps.policy_llm_connection_factory() if caps.policy_llm_connection_factory else None
)
return build_policy_engine(
spec=spec,
conversation_id=session_id,
conversation_store=conversation_store,
default_policies=caps.default_policies,
policy_store=get_policy_store(),
server_llm=caps.llm,
host_connection=host_connection,
)
async def _apply_pending_policy_ask_writes(
session_id: str,
conv: Conversation,
conversation_store: ConversationStore,
agent_store: AgentStore,
data: dict[str, Any],
) -> None:
"""
Apply (or drop) policy writes stashed for a relay tool-call ASK.
Called when an ``approval`` verdict resolves a runner-owned policy
elicitation (both approval entry points — the ``approval`` event and the
resolve URL — route here via their callers). On ``accept`` the deciding
policy's stashed ``state_updates`` / ``set_labels`` are persisted by a
freshly built engine — exactly what the native ``_hold_native_ask_gate``
path does inline. On any other verdict (decline / cancel / missing) they
are dropped (POLICIES.md §7.2: a denied ASK leaves no trace). No-op when
the elicitation has no stashed writes (the common case — most ASKs and
all non-policy elicitations).
:param session_id: Session id that owns the elicitation, e.g.
``"conv_abc123"``.
:param conv: The session conversation, for the agent / spec lookup.
:param conversation_store: Store the engine persists session state to.
:param agent_store: Store for the agent spec lookup.
:param data: The approval payload, carrying ``elicitation_id`` and the
verdict ``action`` (e.g. ``{"elicitation_id": "elicit_x",
"action": "accept"}``).
:returns: None.
"""
elicitation_id = data.get("elicitation_id", "")
pending = _pending_policy_ask_writes.get(elicitation_id)
if pending is None:
return
if data.get("action") != "accept":
# Declined — remove the stashed writes (POLICIES.md §7.2:
# a denied ASK leaves no trace).
_pending_policy_ask_writes.pop(elicitation_id, None)
return
if pending.from_mcp:
# MCP entries: the retry path (POST /mcp with requestState)
# pops and applies the writes itself. Applying here too would
# double-apply non-idempotent ops (e.g. INCREMENT state
# updates for cost-budget counters). Leave the entry for the
# retry path; it owns cleanup.
return
# Non-MCP relay path: pop and apply writes here since no retry
# will arrive.
_pending_policy_ask_writes.pop(elicitation_id, None)
# Resolve the agent spec + build the engine off the event loop: the
# lookup, cold-cache bundle fetch, and engine construction are all
# blocking DB/IO.
spec = await asyncio.to_thread(_load_agent_spec_for_session, conv, agent_store)
if spec is None:
return
engine = await asyncio.to_thread(
_build_policy_engine_from_spec, spec, session_id, conversation_store
)
# The label/state writes hit the DB synchronously too — keep them
# off the loop.
if pending.set_labels:
await asyncio.to_thread(engine.apply_label_writes, pending.set_labels)
if pending.state_updates:
await asyncio.to_thread(engine.apply_state_updates, pending.state_updates)
def _build_actor(user_id: str | None) -> dict[str, str] | None:
"""
Build the ``actor`` dict for :class:`EvaluationContext`.
Returns ``{"run_as": user_id}`` when the authenticated user is
known, ``None`` otherwise (tests, legacy callers without auth).
:param user_id: Authenticated user email from the request,
e.g. ``"alice@example.com"``. ``None`` when auth is
disabled or the caller is unauthenticated.
:returns: Actor dict or ``None``.
"""
if user_id is None:
return None
return {"run_as": user_id}
def _build_evaluation_context(
phase: Phase,
data: dict[str, Any] | str,
event: dict[str, Any],
*,
actor: dict[str, str] | None = None,
) -> EvaluationContext:
"""
Build an :class:`EvaluationContext` from a proto-style event dict.
Maps the proto ``Event.data`` shape to the internal convention:
- ``TOOL_CALL``: ``content = {"name": name, "arguments": args}``,
``tool_name = name``.
- ``TOOL_RESULT``: ``content = {"result": result_str}``,
``tool_name`` from ``request_data.name``,
``request_data`` from the event's ``request_data`` field.
- ``REQUEST`` / ``RESPONSE``: ``content = str(data)``.
:param phase: Internal phase enum.
:param data: ``event.data`` dict from the proto request.
:param event: Full event dict (for ``request_data``, ``context``).
:param actor: Authenticated principal, e.g.
``{"run_as": "alice@example.com"}``. ``None`` when
identity is unknown.
:returns: Ready-to-evaluate context.
"""
# A native hook may stamp the session's live model into the event context
# (e.g. the codex hook reads it from ``config.toml`` at gate time — the
# source of truth for an in-TUI ``/model`` selection). When present, this
# wins over the engine's server-resolved model (see
# ``PolicyEngine._inject_model``); ``None`` falls back to that resolution.
raw_context = event.get("context") or {}
supplied_model = raw_context.get("model")
hook_model = supplied_model if isinstance(supplied_model, str) and supplied_model else None
# The harness, when a native hook stamped it (e.g. the codex hook), so
# policies can tailor messages to the session's model-switch surface
# (codex-native is terminal-only). Carried through unchanged — the engine
# neither resolves nor overrides it.
supplied_harness = raw_context.get("harness")
hook_harness = (
supplied_harness if isinstance(supplied_harness, str) and supplied_harness else None
)
if phase == Phase.TOOL_CALL:
tool_name = data.get("name") or ""
args = data.get("arguments") or {}
return EvaluationContext(
phase=phase,
content={"name": tool_name, "arguments": args},
tool_name=tool_name or None,
actor=actor,
model=hook_model,
harness=hook_harness,
)
if phase == Phase.TOOL_RESULT:
tool_result = data.get("result", "")
request_data = event.get("request_data")
tool_name = None
if isinstance(request_data, dict):
tool_name = request_data.get("name")
return EvaluationContext(
phase=phase,
content={
"result": tool_result if isinstance(tool_result, str) else json.dumps(tool_result),
},
tool_name=tool_name,
request_data=request_data,
actor=actor,
model=hook_model,
harness=hook_harness,
)
# LLM_REQUEST / LLM_RESPONSE — content is the full request/response dict.
if phase in (Phase.LLM_REQUEST, Phase.LLM_RESPONSE):
return EvaluationContext(
phase=phase,
content=data,
actor=actor,
model=hook_model,
harness=hook_harness,
)
# REQUEST / RESPONSE — content is the user/assistant text. The wire ``data``
# is a dict for the native command hooks (``{"text"|"content": ...}``), but
# may be a bare string — opencode's policy plugin sends the prompt text
# directly for ``PHASE_REQUEST``. Accept both, and NEVER raise here: a crash
# 500s the evaluate endpoint, which silently fails the request/result gate
# OPEN (the exact symptom that let cost-over-budget terminal prompts through).
if isinstance(data, str):
text = data
elif isinstance(data, dict):
text = data.get("text") or data.get("content") or str(data)
else:
text = str(data)
return EvaluationContext(
phase=phase,
content=text if isinstance(text, str) else json.dumps(text),
actor=actor,
model=hook_model,
harness=hook_harness,
)
async def _evaluate_tool_call_policy(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
agent_store: AgentStore,
_runner_router: RunnerRouter | None,
*,
actor: dict[str, str] | None = None,
) -> dict[str, Any] | None:
"""
Evaluate a tool call against TOOL_CALL phase policy rules.
Pure evaluation — does NOT persist the event. Returns
``None`` on ALLOW. Returns a verdict dict on DENY or ASK.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param conv: The session's :class:`Conversation` entity.
:param body: The validated ``function_call`` event with
``evaluate_policy: true``.
:param conversation_store: Store for label state.
:param agent_store: Store for agent spec lookups.
:param runner_router: Unused, kept for signature
consistency.
:param actor: Authenticated principal, e.g.
``{"run_as": "alice@example.com"}``. ``None`` when
identity is unknown.
:returns: ``None`` on ALLOW (fall through). Verdict dict
on DENY/ASK.
"""
tool_name = body.data.get("name")
if not tool_name or not isinstance(tool_name, str):
raise OmnigentError(
"function_call event with evaluate_policy requires a non-empty 'name' field in data",
code=ErrorCode.INVALID_INPUT,
)
arguments_str = body.data.get("arguments", "{}")
# Resolve agent spec + build engine off the event loop (blocking
# DB/IO). Tool-call policy always evaluates (no guardrails skip).
spec = await asyncio.to_thread(_load_agent_spec_for_session, conv, agent_store)
if spec is None:
return None
engine = await asyncio.to_thread(
_build_policy_engine_from_spec, spec, session_id, conversation_store
)
try:
args_payload = json.loads(arguments_str)
except (ValueError, TypeError):
args_payload = arguments_str
ctx = EvaluationContext(
phase=Phase.TOOL_CALL,
content={"name": tool_name, "arguments": args_payload},
tool_name=tool_name,
actor=actor,
)
result = await engine.evaluate(ctx)
if result.action == PolicyAction.ALLOW:
if result.set_labels:
await asyncio.to_thread(engine.apply_label_writes, result.set_labels)
return None
if result.action == PolicyAction.DENY:
if result.set_labels:
await asyncio.to_thread(engine.apply_label_writes, result.set_labels)
return {
"verdict": "deny",
"reason": result.reason or "Denied by policy",
}
# ASK — publish elicitation event. Approval state lives
# on the runner (_pending_approvals dict).
elicitation_id = await _register_policy_elicitation(
session_id=session_id,
result=result,
arguments_preview=arguments_str,
conversation_store=conversation_store,
)
# The deciding policy's writes (e.g. a cost-budget checkpoint via
# ``state_updates``) must land ONLY on approve. This relay path returns
# ``pending`` and the verdict arrives later off-request, so stash them to
# apply when the matching ``approval`` resolves with accept (see
# _apply_pending_policy_ask_writes). The native path applies these inline
# in _hold_native_ask_gate; without this, a relay/non-native session's
# checkpoint is never recorded and the ASK re-prompts every tool call.
# Always store an entry even when there are no deferred writes —
# the MCP retry path checks the pending map to verify the
# elicitation was genuinely issued by the server.
_pending_policy_ask_writes[elicitation_id] = _PendingPolicyAskWrites(
state_updates=result.state_updates,
set_labels=result.set_labels,
)
return {
"verdict": "pending",
"elicitation_id": elicitation_id,
# Spec-resolved approval window; the runner's park honors it.
"ask_timeout": resolve_ask_timeout(engine, result),
}
def _extract_user_text_from_event(body: SessionEventInput) -> str:
"""
Extract concatenated text from a user message event body.
Mirrors the logic in ``workflow._extract_user_text`` but
operates on the raw ``SessionEventInput.data`` dict rather
than a parsed ``MessageData`` object.
:param body: The validated ``message`` event with
``role: "user"``.
:returns: Joined text from ``input_text`` / ``text`` content
blocks. Empty string if no text blocks found.
"""
content = body.data.get("content") or []
parts: list[str] = []
for block in content:
if isinstance(block, dict):
text = block.get("text") or block.get("input_text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts)
def _publish_policy_deny(session_id: str, reason: str) -> None:
"""
Publish the ``[Denied by policy: ...]`` sentinel on the session stream.
The sentinel text is a load-bearing contract (the REPL renders it, e2e
tests assert it, and native harnesses relay it to the model), so it is
always carried in a ``response.output_text.delta``.
Input DENY callers also persist the same sentinel as an assistant
conversation item. This stream publish remains separate so live clients
still get immediate feedback before the handler returns. Stamping a unique
``message_id`` (matching how live streaming text is tagged) routes the
delta through the web's live-preview path, where it folds into a single
``live:<id>`` block rather than a response-scoped stray bubble.
Safe for the other consumers: the REPL converts any ``output_text.delta``
to a ``TextDelta`` regardless of ``message_id``; the ``/v1/responses`` API
surfaces the deny via input-deny synthesis (not session-stream deltas);
and the only ``message_id``-gated accumulator (``_relay_runner_stream``)
reads runner-relayed deltas, never this server-published one.
:param session_id: Session/conversation identifier.
:param reason: Human-readable deny reason from the policy verdict.
"""
session_stream.publish(
session_id,
{
"type": "response.output_text.delta",
"delta": f"[Denied by policy: {reason}]",
# Unique per deny so two separate denials don't fold into one
# block; a single delta carries the whole sentinel, so index 0.
"message_id": f"deny_{secrets.token_hex(8)}",
"index": 0,
},
)
def _publish_input_deny_terminal(session_id: str, conv: Conversation, reason: str) -> None:
"""
Publish a terminal ``response.completed`` for an INPUT-phase DENY.
The short-circuit never forwards to a runner, so no runner-relayed
terminal ``response.*`` event is emitted. SSE consumers that drive a
turn off the live-tail (the headless ``-p`` client,
:class:`omnigent_client.SessionsChat.send`) iterate until a
turn-terminal event arrives and would otherwise block forever. The
output carries the same sentinel text so the terminal-snapshot fallback
also surfaces the deny.
:param session_id: Session/conversation identifier.
:param conv: Conversation whose agent/model name tags the response.
:param reason: Human-readable deny reason from the policy verdict.
"""
sentinel = f"{_DENY_SENTINEL_PREFIX}{reason}]"
response = ResponseObject(
id=f"deny_{secrets.token_hex(8)}",
status="completed",
model=conv.agent_id or "policy",
created_at=int(time.time()),
completed_at=int(time.time()),
output=[
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": sentinel}],
}
],
)
session_stream.publish(
session_id,
CompletedEvent(type="response.completed", response=response).model_dump(exclude_none=True),
)
async def _persist_policy_deny_sentinel(
session_id: str,
conv: Conversation,
reason: str,
conversation_store: ConversationStore,
agent_store: AgentStore,
) -> None:
"""
Persist the ``[Denied by policy: ...]`` sentinel as assistant history.
INPUT policy DENY returns synchronously and never forwards the user turn
to a runner, so no downstream stream relay can append the assistant-side
deny marker. Persisting the same assistant message shape used by OUTPUT
policy DENY keeps follow-up turns and the items API consistent with the
streamed deny users already see.
:param session_id: Session/conversation identifier.
:param conv: Conversation whose agent/model name tags the message.
:param reason: Human-readable deny reason from the policy verdict.
:param conversation_store: Store for item persistence.
:param agent_store: Store used to resolve the agent's display name.
"""
import uuid
sentinel = f"{_DENY_SENTINEL_PREFIX}{reason}]"
agent = agent_store.get(conv.agent_id) if conv.agent_id else None
agent_name = agent.name if agent is not None else conv.agent_id or "policy"
item = NewConversationItem(
type="message",
response_id=f"deny_{uuid.uuid4().hex}",
data=parse_item_data(
"message",
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": sentinel}],
"agent": agent_name,
},
),
)
await asyncio.to_thread(conversation_store.append, session_id, [item])
async def _evaluate_input_policy(
request: Request,
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
agent_store: AgentStore,
_runner_router: RunnerRouter | None,
*,
actor: dict[str, str] | None = None,
) -> dict[str, Any] | None:
"""
Evaluate a user message against REQUEST (input) phase policy rules.
Does not persist the event. On ALLOW returns ``None`` (caller
forwards the message). On DENY returns a verdict dict (caller does
NOT forward). On ASK this function **parks for human approval**
before returning: unlike the ``tool_call`` phase — where the runner
parks via ``wait_for_user_approval`` — the REQUEST phase has no
runner in the loop yet (the message hasn't been forwarded), so the
approval gate must live here. It reuses :func:`_hold_native_ask_gate`
(the same server-side park the native ``tool_call`` gate uses):
accept collapses to ALLOW (``None``, forward the message), while
decline / timeout collapses to a DENY verdict (fail-closed).
:param request: The active FastAPI request, threaded to
:func:`_hold_native_ask_gate` for upstream-disconnect detection
while parked on an ASK.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param conv: The session's :class:`Conversation` entity.
:param body: The validated ``message`` event.
:param conversation_store: Store for label state.
:param agent_store: Store for agent spec lookups.
:param _runner_router: Unused, kept for signature
consistency.
:param actor: Authenticated principal, e.g.
``{"run_as": "alice@example.com"}``. ``None`` when
identity is unknown.
:returns: ``None`` on ALLOW or an approved ASK (fall through to the
forward path). A verdict dict ``{"verdict": "deny", "reason":
...}`` on DENY or a declined / timed-out ASK.
"""
user_text = _extract_user_text_from_event(body)
if not user_text:
return None
# Resolve the agent spec off the event loop (blocking DB + cold-cache
# bundle fetch). Spec only, so the cheap skip check below runs before
# the more expensive engine build.
spec = await asyncio.to_thread(_load_agent_spec_for_session, conv, agent_store)
if spec is None:
return None
# Skip only when there are no agent guardrails AND no server-wide
# default policies AND no session policies. Without this, default/
# session policies (e.g. deny_pii_in_llm_request added via the UI)
# are silently skipped for agents without a guardrails: YAML block.
if not spec.guardrails and not get_caps().default_policies and get_policy_store() is None:
return None
engine = await asyncio.to_thread(
_build_policy_engine_from_spec, spec, session_id, conversation_store
)
ctx = EvaluationContext(
phase=Phase.REQUEST,
content=user_text,
tool_name=None,
actor=actor,
)
result = await engine.evaluate(ctx)
if result.action == PolicyAction.ALLOW:
if result.set_labels:
await asyncio.to_thread(engine.apply_label_writes, result.set_labels)
return None
if result.action == PolicyAction.DENY:
if result.set_labels:
await asyncio.to_thread(engine.apply_label_writes, result.set_labels)
return {
"verdict": "deny",
"reason": result.reason or "Denied by policy",
}
# ASK — park server-side for human approval. The REQUEST phase has no
# runner-side approval round-trip (the message has not been forwarded to
# a runner yet, so nothing would park on a "pending" verdict — it would
# collapse to a silent deny). Hold the gate here exactly like the native
# tool_call path: _hold_native_ask_gate publishes the approval card,
# awaits the human verdict on a server-side Future, and applies the
# deciding policy's writes only on accept (POLICIES.md §7.2). Accept ->
# ALLOW (fall through to forward the message); decline / timeout ->
# DENY (fail-closed).
try:
approved = await _hold_native_ask_gate(
request,
session_id=session_id,
phase=Phase.REQUEST,
data=body.data,
engine=engine,
result=result,
conversation_store=conversation_store,
)
except ElicitationDeclinedError as exc:
return {
"verdict": "deny",
"reason": exc.args[0] or "Denied by policy",
}
if approved:
return None
return {
"verdict": "deny",
"reason": result.reason or "Denied by policy",
}
def _extract_assistant_text_from_event(body: SessionEventInput) -> str:
"""
Extract concatenated text from an assistant message event.
Mirrors :func:`_extract_user_text_from_event` but for
assistant messages. Content blocks use ``"text"`` (not
``"input_text"``).
:param body: The validated ``message`` event with
``role: "assistant"``.
:returns: Joined text from content blocks. Empty string if
no text blocks found.
"""
content = body.data.get("content") or []
parts: list[str] = []
for block in content:
if isinstance(block, dict):
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts)
_DENY_SENTINEL_PREFIX = "[Denied by policy: "
def _replace_text_in_message_body(
body: SessionEventInput,
replacement: str,
) -> SessionEventInput:
"""
Return a copy of the message body with all text content
blocks replaced by *replacement*.
Used by OUTPUT policy DENY to substitute the deny sentinel
into the persisted message while preserving non-text content
blocks (images, etc.) and all other body fields.
:param body: The original assistant message event.
:param replacement: The deny sentinel text,
e.g. ``"[Denied by policy: harmful content]"``.
:returns: A new body with text blocks replaced.
"""
content = body.data.get("content") or []
new_content: list[dict[str, Any]] = []
replaced = False
for block in content:
if isinstance(block, dict) and "text" in block:
if not replaced:
new_content.append({"type": "output_text", "text": replacement})
replaced = True
else:
new_content.append(block)
if not replaced:
new_content.append({"type": "output_text", "text": replacement})
new_data = {**body.data, "content": new_content}
return type(body)(type=body.type, data=new_data)
async def _evaluate_output_policy(
session_id: str,
conv: Conversation,
body: SessionEventInput,
conversation_store: ConversationStore,
agent_store: AgentStore,
_runner_router: RunnerRouter | None,
*,
actor: dict[str, str] | None = None,
) -> dict[str, Any] | None:
"""
Evaluate an assistant message against OUTPUT phase policies.
Pure evaluation — does NOT persist the event. Returns
``None`` on ALLOW. On DENY, returns a verdict dict with
``_denied_body`` — the caller should persist this modified
body (text replaced with deny sentinel) instead of the
original.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param conv: The session's :class:`Conversation` entity.
:param body: The validated ``message`` event.
:param conversation_store: Store for label state.
:param agent_store: Store for agent spec lookups.
:param runner_router: Unused, kept for signature
consistency.
:param actor: Authenticated principal, e.g.
``{"run_as": "alice@example.com"}``. ``None`` when
identity is unknown.
:returns: ``None`` on ALLOW (fall through). Verdict dict
with ``_denied_body`` on DENY.
"""
assistant_text = _extract_assistant_text_from_event(body)
if not assistant_text:
return None
# Resolve the agent spec off the event loop (blocking DB + cold-cache
# bundle fetch). Spec only, so the cheap skip check below runs before
# the more expensive engine build.
spec = await asyncio.to_thread(_load_agent_spec_for_session, conv, agent_store)
if spec is None:
return None
if not spec.guardrails and not get_caps().default_policies and get_policy_store() is None:
return None
engine = await asyncio.to_thread(
_build_policy_engine_from_spec, spec, session_id, conversation_store
)
ctx = EvaluationContext(
phase=Phase.RESPONSE,
content=assistant_text,
tool_name=None,
actor=actor,
)
result = await engine.evaluate(ctx)
if result.action == PolicyAction.ALLOW:
if result.set_labels:
await asyncio.to_thread(engine.apply_label_writes, result.set_labels)
return None
# DENY — build the denied body with sentinel text.
# The caller persists this modified body instead of the
# original (Option B).
if result.set_labels:
await asyncio.to_thread(engine.apply_label_writes, result.set_labels)
reason = result.reason or "Denied by policy"
sentinel = f"{_DENY_SENTINEL_PREFIX}{reason}]"
denied_body = _replace_text_in_message_body(body, sentinel)
return {
"verdict": "deny",
"reason": reason,
"_denied_body": denied_body,
}
# Runner router for the native-terminal approval popup, set once at app
# startup (see :func:`set_server_runner_router`). The tool-policy ASK gate
# forwards a ``cost_approval_popup`` control event to the bound runner from
# a parked-gate background task that carries no FastAPI request / route
# closure, so it reads the router from this module-level global.
_server_runner_router: RunnerRouter | None = None
def set_server_runner_router(runner_router: RunnerRouter | None) -> None:
"""
Stash the runner router for the native-terminal approval popup.
Called once from ``create_app`` so the tool-policy ASK gate
(:func:`_spawn_native_approval_popup_forward`) can reach the bound
runner from background contexts that do not carry the request / route
closure.
:param runner_router: The session runner router, or ``None`` in
in-process setups.
:returns: None.
"""
global _server_runner_router
_server_runner_router = runner_router
async def _wake_parent_for_blocked_child(
parent_id: str,
child: Conversation,
notice: str,
*,
conversation_store: ConversationStore,
runner_router: RunnerRouter | None,
) -> bool:
"""
Deliver a parent-wake notice when a sub-agent blocks on an approval.
Posts the ``[System: …]`` notice as a synthetic user message to the
parent's ``POST /v1/sessions/{id}/events`` — the same path the runner's
terminal-completion wake uses, so it starts a continuation turn (idle
parent) or coalesces with pending input (busy parent). Best-effort: a
missing parent, missing runner, or transport error is logged and swallowed
(a dropped wake is no worse than the pre-fix no-wake baseline), but the
*outcome* is reported back so the notifier can release its per-block
debounce and let a later publish retry rather than silencing the block.
:param parent_id: Parent session id, e.g. ``\"conv_parent123\"``.
:param child: The blocked child :class:`Conversation`; used only for its
label/id in the notice and logs.
:param notice: The ``[System: …]`` text to inject into the parent.
:param conversation_store: Used to load the parent :class:`Conversation`
and persist the synthetic user message item.
:param runner_router: Router used to resolve the parent's bound
runner. ``None`` in in-process setups (the runtime singleton is
consulted as a fallback).
:returns: ``True`` when the notice was dispatched to the parent's runner;
``False`` when delivery could not happen (parent gone, no runner bound,
or the forward raised a transport error).
"""
parent_conv = await asyncio.to_thread(conversation_store.get_conversation, parent_id)
if parent_conv is None:
# Parent vanished between publish and wake (cascading-delete race).
_logger.debug(
"subagent block notifier: parent %s missing; dropping wake for %s",
parent_id,
child.id,
)
return False
runner_client = await _get_runner_client(parent_id, runner_router)
if runner_client is None:
# WARNING (not DEBUG): an unbound parent is the transient-miss case the
# notifier retries — surface it rather than burying it as routine.
_logger.warning(
"subagent block notifier: no runner bound for parent %s; dropping wake for %s",
parent_id,
child.id,
)
return False
# Ensure the parent's SSE relay is live so the wake turn's output is
# persisted (parity with post_event).
_ensure_runner_relay(
parent_id,
parent_conv.runner_id,
runner_client,
conversation_store,
)
body = SessionEventInput(
type="message",
data={
"role": "user",
"content": [{"type": "input_text", "text": notice}],
},
)
try:
# None args: a system notice carries no agent/files/artifacts; the runner
# recomputes has_mcp_servers from the parent's cached spec.
await _dispatch_session_event_to_runner(
parent_id,
parent_conv,
body,
conversation_store,
runner_client,
agent_name=None,
file_store=None,
artifact_store=None,
runner_router=runner_router,
)
except (httpx.HTTPError, OmnigentError):
_logger.warning(
"subagent block wake POST failed for parent=%s child=%s",
parent_id,
child.id,
exc_info=True,
)
return False
return True
def configure_subagent_block_notifier(
conversation_store: ConversationStore,
runner_router: RunnerRouter | None,
) -> Callable[[], None]:
"""
Install the parent-wake notifier on the elicitation publish path.
Wires :class:`SubagentBlockNotifier` into
:mod:`omnigent.runtime.pending_elicitations` so a sub-agent that
blocks on an approval immediately wakes its immediate parent through
the same ``/events`` ingest path the runner-side terminal-completion
wake already uses (see
:func:`_wake_parent_for_blocked_child`). Top-level sessions (no
parent) are no-ops; multi-user safety is inherent because the wake
is delivered to the recorded ``parent_conversation_id`` only, never
fanned out to collaborators or unrelated sessions.
:param conversation_store: Store used to resolve a child's
``parent_conversation_id`` and to persist the wake message.
:param runner_router: Router used by the wake to reach the parent's
bound runner. ``None`` in in-process setups.
:returns: A callable that uninstalls the observer and cancels any
in-flight wake futures. Call from the lifespan teardown.
"""
from omnigent.runtime import pending_elicitations as _pending_elicitations
from omnigent.runtime.subagent_block_notifier import SubagentBlockNotifier
loop = asyncio.get_running_loop()
async def _wake_dispatch(parent_id: str, child: Conversation, notice: str) -> bool:
"""
Deliver one wake notice (the notifier's injected dispatch).
:param parent_id: Parent session id.
:param child: The blocked child :class:`Conversation`.
:param notice: Pre-formatted ``[System: …]`` text.
:returns: ``True`` when the notice reached the parent's runner,
``False`` when it could not be delivered (so the notifier
releases the debounce and a re-publish can retry).
"""
return await _wake_parent_for_blocked_child(
parent_id,
child,
notice,
conversation_store=conversation_store,
runner_router=runner_router,
)
notifier = SubagentBlockNotifier(
conversation_store=conversation_store,
wake_dispatch=_wake_dispatch,
loop=loop,
)
_pending_elicitations.set_elicitation_observer(notifier.observe)
def _uninstall() -> None:
"""Remove the observer and cancel any outstanding wake futures."""
_pending_elicitations.set_elicitation_observer(None)
notifier.close()
return _uninstall
async def _stream_live_events(
request: Request,
session_id: str,
on_subscribed: Callable[[], Awaitable[Iterable[dict[str, Any]]]] | None = None,
viewer_user_id: str | None = None,
viewer_idle: bool = False,
presence_root_id: str | None = None,
) -> AsyncIterator[str]:
"""
Yield SSE-formatted events from the conversation's live stream.
Events are delivered live from the moment :func:`session_stream.subscribe`
is invoked forward — there is no buffer and no replay. Events
published before this generator subscribed are lost; clients
reconcile pre-subscribe state via the snapshot endpoint
(``GET /v1/sessions/{id}``) and dedupe by item id.
On client disconnect the subscribe loop breaks; the
``finally`` block emits a ``[DONE]`` sentinel so well-behaved
SSE consumers see a clean stream termination. The pub-sub
layer auto-cleans this generator's subscriber slot in its own
``finally`` when iteration exits.
Each emitted dict is validated against
:data:`ServerStreamEvent` at the wire boundary so a runtime
that publishes an unmodelled ``type`` fails loud rather than
serializing an unknown event verbatim.
The subscribe call passes a ``ready_event`` heartbeat plus
``heartbeat_interval_s``. The ready heartbeat is yielded
immediately after the live-tail subscriber slot is registered,
before any snapshot hook runs, so clients can wait for a
concrete subscription acknowledgment before posting a fast
one-shot turn. The interval heartbeat keeps an idle stream
emitting ``session.heartbeat`` events on a fixed cadence (see
:data:`_SESSION_STREAM_HEARTBEAT_INTERVAL_S`). Without that,
a stream that sits between turns has nothing crossing the wire;
the client's SSE read-timeout and this route's
``request.is_disconnected()`` check (only polled on event
arrival) both lag for minutes after a half-open socket forms
(e.g. after a laptop sleep). The heartbeat gives both sides a
regular byte to fire against.
:param request: The FastAPI request, used to detect disconnect.
:param session_id: Session/conversation identifier whose stream
to subscribe to, e.g. ``"conv_abc123"``.
:param on_subscribed: Optional snapshot-on-connect hook forwarded to
:func:`session_stream.subscribe`; its events are yielded ahead of
the live tail so a fresh client sees current resource state
without polling. ``None`` (default) keeps the pure live-tail
shape used by callers that reconcile via the snapshot endpoint.
:param viewer_user_id: Authenticated identity to register in the
session's presence registry for this stream's lifetime, e.g.
``"alice@example.com"``. ``None`` (default, and the reserved
single-user sentinel mapped via ``attribution_user``) skips
presence tracking entirely.
:param viewer_idle: The viewer's connect-time idle flag (tab
backgrounded), from the route's ``idle`` query param. Ignored
when *viewer_user_id* is ``None``.
:param presence_root_id: Root conversation of the streamed
session's tree (its ``root_conversation_id``), e.g.
``"conv_root123"``. Presence is scoped to the tree's root so
viewers of different agents/sub-agents in one session see
each other. Required when *viewer_user_id* is set; ignored
otherwise.
:returns: An async iterator of SSE message strings.
:raises ValueError: If *viewer_user_id* is set without
*presence_root_id* — a per-conversation presence scope would
silently split a session's viewers per agent.
"""
# Presence registers before the subscribe loop: the join broadcast
# fans out to ALREADY-subscribed co-viewers, while this stream
# learns the full list (self included) from the snapshot-on-connect
# presence event — full-state events make that ordering race benign.
presence_token: str | None = None
if viewer_user_id is not None:
if presence_root_id is None:
raise ValueError("presence_root_id is required when viewer_user_id is set")
presence_token = presence.connect(
presence_root_id, session_id, viewer_user_id, viewer_idle
)
try:
async for event in session_stream.subscribe(
session_id,
heartbeat_interval_s=_SESSION_STREAM_HEARTBEAT_INTERVAL_S,
ready_event={"type": "session.heartbeat"},
# In-flight text replay must be captured synchronously at slot
# registration (before ``ready_event`` suspends), not in the
# async ``on_subscribed`` hook, or window deltas double-render.
# Resource state stays in ``on_subscribed`` — it needs
# awaits and is not dedup-sensitive.
pre_ready_snapshot=lambda: inflight_text.snapshot_for(session_id),
on_subscribed=on_subscribed,
):
if await request.is_disconnected():
break
event_type = event.get("type")
if not isinstance(event_type, str):
raise ValueError(
f"session stream event missing string ``type`` field: {event!r}",
)
validated = _SERVER_STREAM_EVENT_ADAPTER.validate_python(event)
yield _format_sse(event_type, validated.model_dump())
finally:
# The non-None checks besides presence_token's are type
# narrowing only: a minted token implies both were set above.
if (
presence_token is not None
and viewer_user_id is not None
and presence_root_id is not None
):
presence.disconnect(presence_root_id, viewer_user_id, presence_token)
yield "data: [DONE]\n\n"
# Bounds for per-session native-terminal pass-through args
# (conversations.terminal_launch_args). These are CLI flags for the
# user's own claude / codex binary, so a few hundred short strings is
# already far beyond any real invocation; the caps just keep one
# session row small and bound the work the runner does rebuilding the
# launch command.
_MAX_TERMINAL_LAUNCH_ARGS = 256
_MAX_TERMINAL_LAUNCH_ARG_LEN = 4096
def _validate_terminal_launch_args(value: list[str] | None) -> list[str] | None:
"""
Validate per-session native-terminal pass-through args.
Enforces a flat list of strings within bounded count / length.
The flat-list shape is the security boundary: there is no key for
a caller to smuggle internal launch wiring (bridge dir, Omnigent URL,
auth) through — those stay runner-owned (see
designs/NATIVE_RUNNER_SERVER_LAUNCH.md).
:param value: The candidate args, e.g.
``["--dangerously-skip-permissions"]``, or ``None`` to leave
unset / unchanged.
:returns: The validated list unchanged, or ``None`` when *value*
is ``None``.
:raises ValueError: If *value* is not a list of strings, exceeds
:data:`_MAX_TERMINAL_LAUNCH_ARGS` entries, or any entry
exceeds :data:`_MAX_TERMINAL_LAUNCH_ARG_LEN` characters.
"""
if value is None:
return None
if not isinstance(value, list) or not all(isinstance(arg, str) for arg in value):
raise ValueError("terminal_launch_args must be a list of strings")
if len(value) > _MAX_TERMINAL_LAUNCH_ARGS:
raise ValueError(f"terminal_launch_args exceeds {_MAX_TERMINAL_LAUNCH_ARGS} entries")
for arg in value:
if len(arg) > _MAX_TERMINAL_LAUNCH_ARG_LEN:
raise ValueError(
f"terminal_launch_args entry exceeds {_MAX_TERMINAL_LAUNCH_ARG_LEN} characters"
)
return value
# Accepted values for the per-session cost-control switch
# (conversations.cost_control_mode_override): "on" activates the
# spec's configured cost-control mode, "off" disables it. Unset
# (NULL) defers to the spec default.
COST_CONTROL_OVERRIDE_VALUES = frozenset({"on", "off"})
def _validated_cost_control_mode_override(value: str | None) -> str | None:
"""
Validate a caller-supplied per-session cost-control switch.
:param value: The candidate value, e.g. ``"on"``, or ``None``
when the caller did not set / wants to clear the override.
:returns: The value unchanged when valid, or ``None``.
:raises OmnigentError: 400 (``invalid_input``) when *value* is
anything other than ``"on"``, ``"off"``, or ``None``.
"""
if value is None or value in COST_CONTROL_OVERRIDE_VALUES:
return value
raise OmnigentError(
f"invalid cost_control_mode_override: {value!r} (expected 'on', 'off', or null to clear)",
code=ErrorCode.INVALID_INPUT,
)
def _parse_session_create_metadata(metadata: str) -> SessionCreateMetadata:
"""
Parse the JSON metadata part from bundled session creation.
:param metadata: Raw JSON string from the multipart form,
e.g. ``{"title": "debug auth flow"}``.
:returns: Validated :class:`SessionCreateMetadata`.
:raises OmnigentError: If the JSON fails the request schema.
"""
try:
parsed = SessionCreateMetadata.model_validate_json(metadata)
reasoning_effort = validate_effort(
parsed.reasoning_effort,
"session metadata",
EFFORT_VALUES,
)
# Bounds-check the native-terminal args; raises ValueError
# (wrapped below) on a malformed or oversized list.
_validate_terminal_launch_args(parsed.terminal_launch_args)
return parsed.model_copy(update={"reasoning_effort": reasoning_effort})
except (ValidationError, ValueError) as exc:
raise OmnigentError(
f"invalid session metadata: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
def _multipart_missing_detail(field: str) -> dict[str, Any]:
"""
Build a FastAPI-style missing multipart field error.
:param field: Missing form field name, e.g. ``"bundle"``.
:returns: A validation-detail dict for HTTP 422 responses.
"""
return {
"type": "missing",
"loc": ["body", field],
"msg": "Field required",
"input": None,
}
def _require_host_conn_for_worktree(host_id: str | None, request: Request) -> HostConnection:
"""
Resolve the live host connection for a worktree operation.
:param host_id: Target host id from the session request, e.g.
``"host_a1b2c3d4..."``. ``None`` is rejected — git worktree
creation requires a host (the server has no filesystem).
:param request: FastAPI request carrying ``app.state.host_registry``.
:returns: The live :class:`HostConnection` for ``host_id``.
:raises OmnigentError: ``invalid_input`` when ``host_id`` is
``None``; ``internal_error`` when no host registry is
configured; ``conflict`` when the host is offline.
"""
if host_id is None:
raise OmnigentError(
"git worktree creation requires host_id",
code=ErrorCode.INVALID_INPUT,
)
host_registry = getattr(request.app.state, "host_registry", None)
if host_registry is None:
# Server misconfiguration, not bad client input — mirror
# _validate_session_workspace, which also returns internal_error.
raise OmnigentError(
"host registry is not configured; cannot create a worktree",
code=ErrorCode.INTERNAL_ERROR,
)
host_conn = host_registry.get(host_id)
if host_conn is None:
raise OmnigentError(
f"host {host_id!r} is offline; reconnect the host and try again",
code=ErrorCode.CONFLICT,
)
return host_conn
async def _create_session_worktree(
*,
host_id: str | None,
source_repo: str | None,
git: SessionGitOptions,
request: Request,
) -> CreatedWorktree:
"""
Create a git worktree on the host for a new session branch.
Validates the branch name server-side (the host re-validates), then
proxies ``host.create_worktree``. The returned worktree path
becomes the session ``workspace``. See
designs/SESSION_GIT_WORKTREE.md.
:param host_id: Target host id, e.g. ``"host_a1b2c3d4..."``.
Required (worktree creation needs a host).
:param source_repo: Canonical path of the picked source repo (the
boundary-validated workspace), e.g. ``"/Users/alice/myrepo"``.
``None`` is a programming error and fails loud.
:param git: Validated git options (``branch_name``, optional
``base_branch``).
:param request: FastAPI request carrying the host registry.
:returns: The created worktree's ``worktree_path`` (to store as
``workspace``) and ``branch`` (to store as ``git_branch``).
:raises OmnigentError: ``invalid_input`` for a bad branch name,
missing source repo, or a host-reported git failure (duplicate
branch, bad base ref, not a repo); ``conflict`` when the host is
offline or unresponsive; ``internal_error`` when no host registry
is configured.
"""
from omnigent.host.git_worktree import WorktreeError, validate_branch_name
from omnigent.server.routes._host_worktree import (
WorktreeHostUnavailableError,
WorktreeProxyError,
create_worktree_on_host,
)
if source_repo is None: # pragma: no cover — host_id guarantees a workspace
raise OmnigentError(
"git worktree creation requires a source repository workspace",
code=ErrorCode.INVALID_INPUT,
)
try:
validate_branch_name(git.branch_name)
except WorktreeError as exc:
raise OmnigentError(exc.message, code=ErrorCode.INVALID_INPUT) from exc
host_conn = _require_host_conn_for_worktree(host_id, request)
host_registry = request.app.state.host_registry
try:
return await create_worktree_on_host(
host_registry=host_registry,
host_conn=host_conn,
repo_path=source_repo,
branch_name=git.branch_name,
base_branch=git.base_branch,
)
except WorktreeHostUnavailableError as exc:
# Host offline / unresponsive — infra, not user input.
raise OmnigentError(exc.message, code=ErrorCode.CONFLICT) from exc
except WorktreeProxyError as exc:
# Host-reported git failure (dup branch, bad base, not a repo) —
# user-correctable input.
raise OmnigentError(exc.message, code=ErrorCode.INVALID_INPUT) from exc
async def _remove_session_worktree_best_effort(
*,
host_id: str,
worktree_path: str,
branch: str,
delete_branch: bool,
request: Request,
reason: str,
) -> None:
"""
Best-effort removal of a session's git worktree.
Used for create-rollback (orphan cleanup) and opt-in session-delete
cleanup. Never raises — a failure is logged so the caller's primary
operation still completes.
:param host_id: Host that owns the worktree, e.g.
``"host_a1b2c3d4..."``.
:param worktree_path: Absolute worktree directory to remove on the
host, e.g. ``"/Users/alice/myrepo-worktrees/feature-login"``.
:param branch: Branch checked out in the worktree, e.g.
``"feature/login"``.
:param delete_branch: When ``True``, also run ``git branch -D``
after removing the worktree directory.
:param request: FastAPI request carrying the host registry.
:param reason: Short label for log lines, e.g.
``"create-rollback"`` or ``"session-delete"``.
"""
from omnigent.server.routes._host_worktree import (
WorktreeProxyError,
remove_worktree_on_host,
)
host_registry = getattr(request.app.state, "host_registry", None)
if host_registry is None:
return
host_conn = host_registry.get(host_id)
if host_conn is None:
_logger.warning(
"Skipping worktree removal (%s) for %s: host %s offline",
reason,
worktree_path,
host_id,
)
return
try:
await remove_worktree_on_host(
host_registry=host_registry,
host_conn=host_conn,
worktree_path=worktree_path,
branch=branch,
delete_branch=delete_branch,
)
except WorktreeProxyError:
_logger.warning(
"Best-effort worktree removal (%s) failed for %s",
reason,
worktree_path,
exc_info=True,
)
def _resolve_subagent_spec(
*,
agent: Agent,
sub_agent_name: str,
agent_cache: AgentCache | None,
) -> AgentSpec | None:
"""
Load the parent bundle and resolve a child sub-agent's trusted spec.
This is the single trusted source for any per-sub-agent launch wiring
the server derives at create time (terminal-first labels, YOLO
pass-through args). The spec comes from the server-loaded parent
bundle — never from caller-supplied request fields — so a caller
cannot smuggle in launch config a sub-agent's own bundle did not
declare.
:param agent: The parent agent row, e.g. the ``polly`` orchestrator,
whose bundle contains the sub-agent specs.
:param sub_agent_name: The dispatched sub-agent's name, e.g.
``"claude_code"``.
:param agent_cache: Cache for loading the parsed parent bundle. ``None``
disables resolution (returns ``None``).
:returns: The matching child :class:`AgentSpec`, or ``None`` when the
cache is absent, the bundle fails to load, or no sub-agent matches.
"""
if agent_cache is None:
return None
from omnigent.runtime.workflow import _find_spec_by_name
try:
parent_spec = agent_cache.load(
agent.id, agent.bundle_location, expand_env=agent.session_id is None
).spec
except Exception: # noqa: BLE001 -- create-time resolution is best-effort; never block create.
# A bundle that fails to load here must not break session
# creation; the session still works, just without the
# derived labels / launch args.
_logger.warning(
"Could not load bundle for agent %s to resolve sub-agent %r spec",
agent.id,
sub_agent_name,
exc_info=True,
)
return None
return _find_spec_by_name(parent_spec, sub_agent_name)
def _spec_harness(spec: AgentSpec) -> str:
"""
Return the canonical harness identifier for a resolved spec.
:param spec: A parsed agent / sub-agent spec.
:returns: The canonical harness id, e.g. ``"claude-native"`` or
``"codex-native"``; falls back to ``executor.type`` when no
``harness`` is declared.
"""
from omnigent.harness_aliases import canonicalize_harness
harness = spec.executor.config.get("harness") or spec.executor.type
return canonicalize_harness(harness) or harness
def _spec_config_flag_explicitly_disabled(spec: AgentSpec, key: str) -> bool:
"""
Return whether an ``executor.config`` flag is explicitly set false.
The spec parser stringifies every ``executor.config`` value (see
``omnigent/spec/parser.py`` — ``{str(k): str(v) ...}``), so a YAML
``yolo: false`` arrives here as the string ``"False"``. A naive
``not bool(value)`` is wrong: ``bool("False")`` is ``True`` (so a
naive truthiness test would read ``"False"`` as enabled). This
compares against the falsey spellings explicitly so only an
intentional ``false`` / ``False`` counts as disabled — an absent key
or any other value is NOT disabled.
Used for opt-OUT semantics: the relevant flag defaults to enabled and
an explicit ``false`` is the escape hatch (see the codex-native branch
of :func:`_derive_terminal_launch_args_from_spec`).
:param spec: A parsed sub-agent spec.
:param key: The ``executor.config`` key to read, e.g. ``"yolo"``.
:returns: ``True`` only when the value is the boolean ``False`` or the
string ``"false"`` (case-insensitive); ``False`` otherwise
(including when the key is absent).
"""
value = spec.executor.config.get(key)
if isinstance(value, bool):
return value is False
return isinstance(value, str) and value.strip().lower() == "false"
def _derive_terminal_launch_args_from_spec(sub_spec: AgentSpec) -> list[str] | None:
"""
Derive native-terminal YOLO pass-through args from a trusted sub-spec.
polly's native workers (claude-native / codex-native) launch in a
headless pane where no human can answer an ApprovalCard, so every
Edit/Write/Bash that prompts stalls the worker. This translates a
worker bundle's declared full-bypass intent into the per-session
``terminal_launch_args`` the runner already appends to the claude /
codex argv:
- claude-native + ``executor.config.permission_mode`` set ->
``["--permission-mode", "<value>"]``. The value is passed through
verbatim so non-YOLO modes (``acceptEdits``, ``plan``, ...) work too;
YOLO uses ``bypassPermissions``.
- codex-native -> ``["--dangerously-bypass-approvals-and-sandbox"]``
by DEFAULT. A headless codex worker has no human to answer codex's
approval prompts, and codex's own command sandbox often cannot even
start (e.g. inside a hardened container), so codex's default
``approval_policy=on-request`` + own-sandbox stance stalls the
worker on its first Edit/Write/Bash. Full bypass is the only
non-stalling stance for the headless seam (the container / worktree
is the real boundary, matching claude-native's ``bypassPermissions``
and the codex-sdk executor's ``approvalPolicy="never"``). An explicit
``executor.config.yolo: false`` opts back out for a read-only / must
-keep-prompting sub-agent. See issue #171.
Only the two native harnesses are translated; for any other harness
(e.g. ``claude-sdk``, whose bypass is set via the SDK ``permissionMode``
spawn env, not a terminal flag) this returns ``None`` so no terminal
args are set. ``None`` is also returned when the relevant field is
absent / falsey.
:param sub_spec: The trusted child sub-agent spec, resolved from the
server-loaded parent bundle via :func:`_resolve_subagent_spec`.
:returns: A flat CLI-arg list to store as the child session's
``terminal_launch_args``, or ``None`` when nothing should be set.
:raises ValueError: If a spec-derived argument violates the same
bounds enforced for request-supplied ``terminal_launch_args``.
"""
harness = _spec_harness(sub_spec)
if harness == _CLAUDE_NATIVE_HARNESS:
permission_mode = sub_spec.executor.config.get("permission_mode")
if permission_mode:
return _validate_terminal_launch_args(["--permission-mode", str(permission_mode)])
return None
if harness == _CODEX_NATIVE_HARNESS:
# Headless default: full bypass. The terminal_launch_args set the
# codex --remote TUI's launch flags, which is what creates the
# app-server thread and fixes its approval/sandbox stance for the
# session; the omnigent executor's later turn/start inherits that
# stance (codex_native_executor.run_turn carries no per-turn
# approval/sandbox). Without the flag the thread is created at
# codex's on-request + own-sandbox default and a headless worker
# stalls. An explicit ``yolo: false`` is the opt-out. See #171.
if _spec_config_flag_explicitly_disabled(sub_spec, "yolo"):
return None
return _validate_terminal_launch_args(["--dangerously-bypass-approvals-and-sandbox"])
return None
def _native_subagent_wrapper_labels_from_spec(sub_spec: AgentSpec) -> dict[str, str]:
"""
Resolve terminal-first wrapper labels from an already-loaded sub-spec.
:param sub_spec: Trusted child sub-agent spec resolved from the
parent bundle.
:returns: ``{wrapper_key: value, ui_key: "terminal"}`` for a native
sub-agent, or ``{}`` when the sub-agent is not native.
"""
harness = _spec_harness(sub_spec)
native_agent = native_coding_agent_for_harness(harness)
if native_agent is not None:
return {
_CLAUDE_NATIVE_WRAPPER_LABEL_KEY: native_agent.wrapper_label,
_CLAUDE_NATIVE_UI_LABEL_KEY: _CLAUDE_NATIVE_UI_LABEL_VALUE,
}
return {}
def _native_subagent_wrapper_labels(
*,
agent: Agent,
sub_agent_name: str,
agent_cache: AgentCache | None,
) -> dict[str, str]:
"""
Resolve the terminal-first wrapper labels for a native-harness sub-agent.
A sub-agent dispatched via ``sys_session_send`` whose own spec uses a
native terminal harness (``claude-native`` / ``codex-native``) must
render with the Chat/Terminal pill in the web UI, exactly like a
top-level ``claude-native-ui`` / ``codex-native-ui`` wrapper session.
The pill is gated on the conversation's ``omnigent.wrapper`` +
``omnigent.ui`` labels (see ``web`` ``TerminalFirstContext``), but
the sub-agent create path never stamps them. This resolves the child
sub-agent's spec from the parent bundle and returns the labels to stamp,
or an empty dict when the sub-agent is not native (e.g. ``claude-sdk``).
:param agent: The parent agent row, e.g. the ``polly`` orchestrator,
whose bundle contains the sub-agent specs.
:param sub_agent_name: The dispatched sub-agent's name, e.g.
``"claude_code"``.
:param agent_cache: Cache for loading the parsed parent bundle. ``None``
disables resolution (returns an empty dict).
:returns: ``{wrapper_key: value, ui_key: "terminal"}`` for a native
sub-agent, or ``{}`` when not native / not resolvable.
"""
sub_spec = _resolve_subagent_spec(
agent=agent,
sub_agent_name=sub_agent_name,
agent_cache=agent_cache,
)
if sub_spec is None:
return {}
return _native_subagent_wrapper_labels_from_spec(sub_spec)
def _reject_reserved_cost_control_label_seed(labels: dict[str, str]) -> None:
"""
Reject a session-create body that seeds policy-owned labels.
``cost_control.*`` is the cost advisor's telemetry namespace and its
only legitimate writer is the session's bound runner — which cannot
exist yet at create time, so a seed is always a forgery.
:param labels: The client-supplied initial labels, e.g.
``{"team": "ml"}``.
:raises OmnigentError: 400 when any ``cost_control.*`` key is
present.
"""
reserved = reserved_cost_control_keys(labels)
if reserved:
raise OmnigentError(
f"labels {', '.join(repr(key) for key in reserved)} "
f"are in the policy-owned {COST_CONTROL_LABEL_NAMESPACE}* "
"namespace and cannot be set at session creation",
code=ErrorCode.INVALID_INPUT,
)
def _require_cost_control_label_authority(
*,
reserved_keys: Sequence[str],
tunnel_token: str | None,
bound_runner_id: str | None,
allowed_tunnel_tokens: frozenset[str] | None,
multi_user: bool,
) -> None:
"""
Authorize a label write touching the policy-owned ``cost_control.*`` keys.
These are the cost advisor's telemetry labels, so ordinary session
editors must not set them via PATCH; the advisor's persist proves
itself with the runner tunnel binding token (allow-listed, or bound
to this session's runner id — the tunnel route's trust model).
Single-user servers skip the check: loopback runners may register
under stable ids unrelated to any token, and there is no second
identity to forge against.
:param reserved_keys: The ``cost_control.*`` keys the request tries
to write, e.g. ``("cost_control.plan",)``. Quoted in the error.
:param tunnel_token: Value of the ``X-Omnigent-Runner-Tunnel-Token``
request header, or ``None`` when absent.
:param bound_runner_id: The session's current ``runner_id``, or
``None`` when no runner is bound.
:param allowed_tunnel_tokens: The server's tunnel-token allow-list,
or ``None`` when not configured.
:param multi_user: ``True`` when the server enforces per-user
permissions (a permission store is configured).
:raises OmnigentError: 403 when the caller presents no acceptable
runner proof on a multi-user server.
"""
if not multi_user:
return
keys = ", ".join(repr(key) for key in reserved_keys)
token = (tunnel_token or "").strip()
if token:
if allowed_tunnel_tokens is not None and token in allowed_tunnel_tokens:
return
if bound_runner_id is not None and token_bound_runner_id(token) == bound_runner_id:
return
raise OmnigentError(
f"labels {keys} are in the policy-owned "
f"{COST_CONTROL_LABEL_NAMESPACE}* namespace; only the session's "
"bound runner may write them",
code=ErrorCode.FORBIDDEN,
)
async def _create_session_from_existing_agent(
conversation_store: ConversationStore,
agent_store: AgentStore,
runner_router: RunnerRouter | None,
body: SessionCreateRequest,
request: Request,
agent_cache: AgentCache | None = None,
user_id: str | None = None,
permission_store: PermissionStore | None = None,
liveness_lookup: Callable[[list[str]], dict[str, SessionLiveness]] | None = None,
file_store: FileStore | None = None,
artifact_store: ArtifactStore | None = None,
) -> SessionResponse:
"""
Create a session bound to an already-registered agent.
This preserves the existing JSON ``POST /v1/sessions`` contract:
clients that uploaded an agent separately still bind by durable
``agent_id`` and receive the full session snapshot.
:param conversation_store: Store for conversation persistence.
:param agent_store: Store for agent lookup by durable id.
:param runner_router: Runner router used to validate any initial
dispatch triggered by ``initial_items``.
:param body: Validated JSON create request.
:param agent_cache: Optional cache for loading parsed agent specs
from bundles, used to populate ``llm_model`` and
``context_window`` in the response.
:param user_id: Authenticated caller, e.g.
``"alice@example.com"``. Used to authorize parent-session
and agent ownership and enforce runner
ownership on parent-session inheritance.
:param permission_store: Permission store for session-access
checks. Required for authorization of
``parent_session_id`` and session-scoped ``agent_id``.
:param liveness_lookup: Optional session-scoped liveness lookup
to populate ``SessionResponse.runner_online``.
:param file_store: Optional file metadata store for resolving
``file_id`` references in ``initial_items`` before forwarding
to the runner.
:param artifact_store: Optional binary content store for the same.
:returns: The newly created session snapshot.
:raises OmnigentError: 404 if no agent matches ``body.agent_id``;
403/404 if ``parent_session_id`` or session-scoped ``agent_id``
fails authorization.
"""
_reject_reserved_cost_control_label_seed(body.labels)
agent = await asyncio.to_thread(agent_store.get, body.agent_id)
if agent is None:
raise OmnigentError(
f"Agent not found: {body.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
# Session-scoped agents belong to a specific session.
# The caller must have at least READ access to that owning
# session — otherwise they can execute another user's private
# agent by guessing the raw agent id.
if agent.session_id is not None:
await _require_access(
user_id,
agent.session_id,
LEVEL_READ,
permission_store,
conversation_store,
)
# Authorize parent_session_id before inheriting anything.
# The caller must own or have READ access to the parent session;
# otherwise a forged parent link lets them inherit runner
# bindings and establish a parent-child relationship with a
# session they don't control.
if body.parent_session_id is not None:
await _require_access(
user_id,
body.parent_session_id,
LEVEL_READ,
permission_store,
conversation_store,
)
# The persisted override reaches a native CLI as a ``--model`` argv
# element at terminal launch, so reject shell-/flag-shaped values
# before any row or worktree exists.
model_override: str | None = None
if body.model_override is not None:
try:
model_override = validate_model_override(body.model_override)
except ValueError as exc:
raise OmnigentError(
f"invalid model_override: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
# Persisted effort reaches a native CLI as a ``--effort`` argv element
# at terminal launch (and SDK harnesses via the spawn env). Validate
# against the shared vocabulary before any row exists; provider-specific
# support (e.g. ANTHROPIC_EFFORTS) is enforced downstream at launch,
# mirroring the multipart metadata create path.
reasoning_effort: str | None = None
if body.reasoning_effort is not None:
try:
reasoning_effort = validate_effort(
body.reasoning_effort,
"session metadata",
EFFORT_VALUES,
)
except ValueError as exc:
raise OmnigentError(
f"invalid reasoning_effort: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
# Validated before any row exists so a bad value never creates an
# orphan session; None (unset) defers to the spec default.
cost_control_mode_override = _validated_cost_control_mode_override(
body.cost_control_mode_override
)
# Validated against the loaded spec (known harness + omnigent
# executor type) before any row exists, mirroring the CLI's
# --harness fail-loud rules.
harness_override = await asyncio.to_thread(
_validated_harness_override, body.harness_override, agent
)
# Inherit runner affinity from the parent session so the child
# is assigned to the same runner (sub-agent co-location).
inherited_runner_id: str | None = None
if body.parent_session_id is not None:
parent_conv = conversation_store.get_conversation(body.parent_session_id)
if parent_conv is not None:
inherited_runner_id = parent_conv.runner_id
# Defense-in-depth: don't inherit a runner the
# caller doesn't own.
if (
inherited_runner_id is not None
and user_id is not None
and runner_router is not None
):
runner_owner = runner_router.runner_owner(inherited_runner_id)
if runner_owner is not None and runner_owner != user_id:
inherited_runner_id = None
# Workspace validation: if the caller is binding to a host,
# they must also pass a workspace, and the workspace must
# satisfy the agent's os_env.cwd boundary on that host (per
# designs/SESSION_WORKSPACE_SELECTION.md). Done before
# create_conversation so a bad workspace never produces a row.
# With git worktree creation, the validated path is the source
# repo; the worktree it produces becomes the stored workspace.
canonical_workspace: str | None = body.workspace
if body.host_id is not None:
canonical_workspace = await _validate_session_workspace(
user_id=user_id,
host_id=body.host_id,
workspace=body.workspace,
agent=agent,
agent_cache=agent_cache,
request=request,
)
# Git worktree options (optional). Two modes on body.git:
# - create (default): make a worktree; it becomes the stored
# workspace and its branch is recorded.
# - bind (existing_worktree): workspace already IS the worktree;
# record its branch only, create nothing.
git_branch: str | None = None
# Set to the created worktree path ONLY when Omnigent creates one.
# Gates create-rollback: an existing worktree bound via
# existing_worktree must never be force-removed on failure — it is
# the user's, not an Omnigent orphan.
created_worktree_path: str | None = None
if body.git is not None:
if body.git.existing_worktree:
# Starting in a pre-existing worktree: no worktree is created, but
# record its branch so the sidebar shows it and the opt-in delete
# flow can offer to remove it. Validate the name (the host never
# runs git for this path, so the server is the only gate).
from omnigent.host.git_worktree import WorktreeError, validate_branch_name
try:
validate_branch_name(body.git.branch_name)
except WorktreeError as exc:
raise OmnigentError(exc.message, code=ErrorCode.INVALID_INPUT) from exc
git_branch = body.git.branch_name
else:
created_worktree = await _create_session_worktree(
host_id=body.host_id,
source_repo=canonical_workspace,
git=body.git,
request=request,
)
canonical_workspace = created_worktree.worktree_path
git_branch = created_worktree.branch
created_worktree_path = created_worktree.worktree_path
# Native-terminal pass-through args.
#
# Named sub-agent creates (``body.sub_agent_name`` set) DERIVE these
# from the trusted, server-loaded sub-spec only — any caller-supplied
# ``body.terminal_launch_args`` is ignored. This is the YOLO seam:
# claude-native maps ``permission_mode`` to ``--permission-mode``,
# while codex-native defaults to full bypass
# (``--dangerously-bypass-approvals-and-sandbox``) so a headless
# codex worker can edit/run unattended without stalling on codex's
# on-request approval default (opt out with ``yolo: false``). A
# caller cannot inject launch wiring by smuggling args through the
# spawn body.
#
# Sessions that resolve their own agent (top-level sessions and the
# manual Add Agent child flow where ``sub_agent_name`` is null) keep
# the validated body args (e.g. ``["--permission-mode",
# "bypassPermissions"]`` from the web permission-mode selector). The
# flat-list shape plus this bounds check is the security boundary;
# mirrors the multipart create + PATCH paths.
sub_spec: AgentSpec | None = None
if body.sub_agent_name:
sub_spec = _resolve_subagent_spec(
agent=agent,
sub_agent_name=body.sub_agent_name,
agent_cache=agent_cache,
)
try:
validated_launch_args = (
_derive_terminal_launch_args_from_spec(sub_spec) if sub_spec is not None else None
)
except ValueError as exc:
raise OmnigentError(
f"invalid terminal_launch_args in sub-agent spec: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
else:
try:
validated_launch_args = _validate_terminal_launch_args(body.terminal_launch_args)
except ValueError as exc:
raise OmnigentError(
f"invalid terminal_launch_args: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
try:
conv = conversation_store.create_conversation(
agent_id=agent.id,
title=body.title,
parent_conversation_id=body.parent_session_id,
runner_id=inherited_runner_id,
kind="sub_agent" if body.parent_session_id else "default",
sub_agent_name=body.sub_agent_name,
host_id=body.host_id,
workspace=canonical_workspace,
git_branch=git_branch,
terminal_launch_args=validated_launch_args,
)
except Exception:
# Broad catch is intentional: ANY create_conversation failure
# (integrity error, name clash, ...) must trigger orphan-worktree
# cleanup before the error propagates. We re-raise unchanged
# below, so nothing is swallowed. Gate on created_worktree_path,
# NOT git_branch: only a worktree Omnigent created here may be
# force-removed. An existing worktree bound via workspace_branch
# also sets git_branch but is the user's — never destroy it.
if (
created_worktree_path is not None
and body.host_id is not None
and git_branch is not None
):
await _remove_session_worktree_best_effort(
host_id=body.host_id,
worktree_path=created_worktree_path,
branch=git_branch,
delete_branch=True,
request=request,
reason="create-rollback",
)
raise
# The create request has no conv id in its URL, so the path-based
# FastAPI hook can't tag it — stamp the minted id so the create span
# joins the session's session.id group.
from omnigent.runtime import telemetry
telemetry.set_session_id(conv.id)
if (
model_override is not None
or reasoning_effort is not None
or cost_control_mode_override is not None
or harness_override is not None
):
# ``create_conversation`` has no override params; reuse the
# PATCH path's store write before the runner reads the snapshot
# (the first turn / terminal launch happens only after this
# create returns and the caller posts a message event).
updated_conv = await asyncio.to_thread(
conversation_store.update_conversation,
conv.id,
model_override=model_override,
reasoning_effort=reasoning_effort,
cost_control_mode_override=cost_control_mode_override,
harness_override=harness_override,
)
if updated_conv is None:
raise OmnigentError(
f"Session {conv.id!r} disappeared while persisting session overrides",
code=ErrorCode.INTERNAL_ERROR,
)
conv = updated_conv
# Set wrapper labels at creation time if the agent is a native
# terminal wrapper, so all messages
# (including early ones sent before the runner connects) take
# the native path and avoid double-persistence with the
# transcript forwarder.
native_agent = native_coding_agent_for_agent_name(agent.name)
if native_agent is not None:
_native_labels = dict(body.labels) if body.labels else {}
_native_labels.update(native_agent.presentation_labels)
await asyncio.to_thread(conversation_store.set_labels, conv.id, _native_labels)
conv = await asyncio.to_thread(conversation_store.get_conversation, conv.id)
elif (
body.sub_agent_name
and sub_spec is not None
and (_sa_labels := _native_subagent_wrapper_labels_from_spec(sub_spec))
):
# A native-harness sub-agent (claude-native / codex-native) must
# render terminal-first with the Chat/Terminal pill, same as a
# top-level wrapper session. Merge over any caller-supplied labels.
_merged = dict(body.labels) if body.labels else {}
_merged.update(_sa_labels)
await asyncio.to_thread(conversation_store.set_labels, conv.id, _merged)
conv = await asyncio.to_thread(conversation_store.get_conversation, conv.id)
elif body.labels:
await asyncio.to_thread(conversation_store.set_labels, conv.id, body.labels)
if body.initial_items:
runner_client = await _get_runner_client(conv.id, runner_router)
if runner_client is None:
# No runner bound — persist initial items as history-only
# seed via the conversation store. No execution fires; the
# caller is responsible for binding a runner and posting a
# follow-up event if they want the agent to react.
# SessionEventInput carries no response_id; this is a
# pre-execution history seed, so tag all items with a
# synthetic ``"seed"`` response id. The runner overwrites
# this on first turn via a normal append path.
new_items = [
NewConversationItem(
type=item.type,
response_id="seed",
data=item.data,
created_by=_attribution_user(user_id),
)
for item in body.initial_items
]
await asyncio.to_thread(conversation_store.append, conv.id, new_items)
else:
await _ensure_runner_relay_ready(
conv.id,
conv.runner_id,
runner_client,
conversation_store,
)
for item in body.initial_items:
await _forward_event_to_runner(
conv.id,
conv,
item,
conversation_store,
runner_client,
agent_name=agent.name,
file_store=file_store,
artifact_store=artifact_store,
created_by=_attribution_user(user_id),
)
# Re-read rather than reusing the local ``conv``: the label-only branch
# above and ``_forward_event_to_runner`` can mutate the row after it was
# built, so a fresh read is what keeps the create response current.
return await _get_session_snapshot(
conversation_store,
conv.id,
agent_store=agent_store,
agent_cache=agent_cache,
liveness_lookup=liveness_lookup,
)
def _create_session_from_bundle(
conversation_store: ConversationStore,
artifact_store: ArtifactStore,
metadata: SessionCreateMetadata,
bundle_bytes: bytes,
runner_id: str | None = None,
) -> CreatedSessionResponse:
"""
Validate, store, and persist a bundled session request.
Each upload creates a session-scoped agent row, even when a
template agent with the same spec name already exists. Agent
names are user-authored labels, not global content identities:
reusing a template by name would make a fresh ``omnigent run
<yaml>`` session execute whatever bundle that template currently
points at, silently discarding the uploaded bundle and coupling
unrelated users who chose the same name.
:param conversation_store: Store that owns the atomic
conversation-plus-agent transaction.
:param artifact_store: Store for uploaded bundle bytes.
:param metadata: Validated session metadata. When
``metadata.parent_session_id`` is set (already authorized by
the caller), the session is created as a sub-agent
child of that conversation.
:param bundle_bytes: Raw uploaded ``.tar.gz`` agent bundle.
:param runner_id: Optional runner binding inherited from the
parent session (caller-resolved, ownership-checked),
e.g. ``"runner_abc123"``. ``None`` leaves the session
unbound.
:returns: Response with the new session id.
:raises OmnigentError: If bundle validation or agent insert
integrity checks fail, or the parent session vanished
between authorization and insert.
:raises SQLAlchemyError: If the database transaction fails for
any non-integrity reason.
"""
# Enforce the policy-handler allowlist only on a shared /
# multi-user server. On a trusted single-user/local server,
# ``omnigent run`` uploads the operator's own bundle through this same
# path, so custom handlers must keep working (the operator already has
# code execution — the restriction would add no security there).
spec = validate_agent_bundle(
bundle_bytes,
enforce_handler_allowlist=not local_single_user_enabled(),
)
assert spec.name is not None
agent_id = generate_agent_id()
agent_bundle_location = bundle_location(agent_id, bundle_bytes)
try:
artifact_store.put(agent_bundle_location, bundle_bytes)
except Exception:
_delete_stored_session_bundle_after_failure(
artifact_store,
agent_bundle_location,
)
raise
return _persist_stored_session_bundle(
conversation_store,
artifact_store,
metadata,
agent_id=agent_id,
agent_name=spec.name,
agent_bundle_location=agent_bundle_location,
agent_description=spec.description,
runner_id=runner_id,
)
def _persist_stored_session_bundle(
conversation_store: ConversationStore,
artifact_store: ArtifactStore,
metadata: SessionCreateMetadata,
*,
agent_id: str,
agent_name: str,
agent_bundle_location: str,
agent_description: str | None,
runner_id: str | None = None,
) -> CreatedSessionResponse:
"""
Persist database rows for a bundle already written to artifacts.
:param conversation_store: Store that owns the atomic
conversation-plus-agent transaction.
:param artifact_store: Store for deleting the bundle on failure.
:param metadata: Validated session metadata. A set
``parent_session_id`` creates the conversation as a
sub-agent child of that session.
:param agent_id: New agent id, e.g. ``"ag_abc123"``.
:param agent_name: Agent name loaded from the uploaded spec.
:param agent_bundle_location: Artifact key for the stored bundle.
:param agent_description: Optional description from the spec.
:param runner_id: Optional runner binding inherited from the
parent session, e.g. ``"runner_abc123"``.
:returns: Response with the new session id.
:raises OmnigentError: If the agent insert violates integrity
checks or the parent session no longer exists.
:raises SQLAlchemyError: If the database transaction fails for
any non-integrity reason.
"""
try:
created = conversation_store.create_session_with_agent(
agent_id=agent_id,
agent_name=agent_name,
agent_bundle_location=agent_bundle_location,
agent_description=agent_description,
title=metadata.title,
labels=metadata.labels,
reasoning_effort=metadata.reasoning_effort,
workspace=metadata.workspace,
terminal_launch_args=metadata.terminal_launch_args,
parent_conversation_id=metadata.parent_session_id,
runner_id=runner_id,
)
except ConversationNotFoundError as exc:
# Parent was authorized by the caller but vanished (deleted)
# before the insert transaction ran.
_delete_stored_session_bundle_after_failure(
artifact_store,
agent_bundle_location,
)
raise OmnigentError(
str(exc),
code=ErrorCode.NOT_FOUND,
) from exc
except IntegrityError as exc:
_delete_stored_session_bundle_after_failure(
artifact_store,
agent_bundle_location,
)
# Expected integrity failures here are uniqueness collisions:
# generated agent id, generated conversation id, or
# agents.session_id. The route maps those to 409.
raise OmnigentError(
f"session agent write failed integrity checks: {exc.orig}",
code=ErrorCode.ALREADY_EXISTS,
) from exc
except SQLAlchemyError:
_delete_stored_session_bundle_after_failure(
artifact_store,
agent_bundle_location,
)
raise
# The create request has no conv id in its URL; stamp the minted id so
# the create span joins the session's session.id group.
from omnigent.runtime import telemetry
telemetry.set_session_id(created.conversation.id)
return CreatedSessionResponse(
session_id=created.conversation.id,
agent_id=agent_id,
agent_name=agent_name,
)
def _delete_stored_session_bundle_after_failure(
artifact_store: ArtifactStore,
agent_bundle_location: str,
) -> None:
"""
Delete an uploaded bundle after database creation fails.
Cleanup failures are logged but suppressed so the original
exception remains the error seen by callers.
:param artifact_store: Store that contains the uploaded bundle.
:param agent_bundle_location: Artifact key to delete, e.g.
``"ag_abc123/a1b2c3d4"``.
:returns: None.
"""
try:
artifact_store.delete(agent_bundle_location)
except Exception: # noqa: BLE001 - cleanup must not mask the original failure.
_logger.warning(
"Failed to delete uploaded session bundle %s after rollback",
agent_bundle_location,
exc_info=True,
)
async def _authorize_bundled_parent_and_inherit_runner(
parent_session_id: str,
*,
user_id: str | None,
permission_store: PermissionStore | None,
conversation_store: ConversationStore,
runner_router: RunnerRouter | None,
) -> str | None:
"""
Authorize a bundled create's parent link and resolve runner affinity.
The caller must have READ access to the parent session
before inheriting anything, mirroring the JSON create path —
without this, a forged parent link lets the caller inherit runner
bindings and parent a session they don't control. On success the
parent's runner binding is inherited (sub-agent co-location),
subject to a defense-in-depth ownership check: a runner the
caller doesn't own is not inherited.
:param parent_session_id: The requested parent session id,
e.g. ``"conv_abc123"``.
:param user_id: Authenticated caller, e.g. ``"alice@example.com"``.
:param permission_store: Permission store for the access
check; ``None`` in single-user / no-auth mode.
:param conversation_store: Store for the parent-conversation read.
:param runner_router: Router for the runner-ownership check;
``None`` skips it.
:returns: The inherited runner id, or ``None`` when the parent has
no runner binding or ownership disallows inheritance.
:raises OmnigentError: 403/404 when the caller may not access the
parent session.
"""
await _require_access(
user_id,
parent_session_id,
LEVEL_READ,
permission_store,
conversation_store,
)
parent_conv = await asyncio.to_thread(
conversation_store.get_conversation,
parent_session_id,
)
if parent_conv is None:
return None
inherited_runner_id = parent_conv.runner_id
if inherited_runner_id is not None and user_id is not None and runner_router is not None:
runner_owner = runner_router.runner_owner(inherited_runner_id)
if runner_owner is not None and runner_owner != user_id:
return None
return inherited_runner_id
async def _notify_runner_of_bundled_child(
session_id: str,
agent_id: str,
runner_router: RunnerRouter | None,
) -> None:
"""
Notify the inherited runner that a bundled child session exists.
Lets the runner initialize per-session state (inbox queue,
agent-id cache) before the first forwarded event, mirroring the
JSON create path's post-create notify. Failures are logged and
swallowed — the notify is additive and must not fail the create.
:param session_id: The new child session id, e.g. ``"conv_abc123"``.
:param agent_id: The child's session-scoped agent id,
e.g. ``"ag_abc123"``.
:param runner_router: Router used to resolve the bound runner's
client; ``None`` falls back to the in-process runner.
:returns: None.
"""
runner_client = await _get_runner_client(session_id, runner_router)
if runner_client is None:
return
try:
await runner_client.post(
"/v1/sessions",
json={
"session_id": session_id,
"agent_id": agent_id,
"sub_agent_name": None,
},
timeout=10.0,
)
except (httpx.HTTPError, ConnectionError):
_logger.warning(
"Failed to notify runner about bundled session %s",
session_id,
exc_info=True,
)
def _registered_runner_id(
runner_router: RunnerRouter | None,
raw_runner_id: str,
*,
user_id: str | None = None,
) -> str:
"""
Validate a runner id from ``PATCH /v1/sessions/{id}``.
When ``user_id`` is provided the function also enforces runner
ownership: only the user who established the tunnel may
bind sessions to that runner.
:param runner_router: Router backed by the live tunnel registry.
``None`` means this server cannot bind runners.
:param raw_runner_id: Runner id from the request body, e.g.
``"runner_abc123"``.
:param user_id: Authenticated caller, e.g.
``"alice@example.com"``. ``None`` skips the ownership
check (single-user / no-auth mode).
:returns: Trimmed registered runner id.
:raises OmnigentError: If the id is empty, the router is
unavailable, the runner is not registered, or the caller
does not own the runner.
"""
runner_id = raw_runner_id.strip()
if not runner_id:
raise OmnigentError(
"runner_id must not be empty",
code=ErrorCode.INVALID_INPUT,
)
if runner_router is None:
raise OmnigentError(
"runner router is not configured",
code=ErrorCode.INTERNAL_ERROR,
)
if not runner_router.runner_is_online(runner_id):
raise OmnigentError(
f"runner {runner_id!r} is not registered",
code=ErrorCode.INVALID_INPUT,
)
# Enforce runner ownership. A caller must own the runner
# they are trying to bind to a session.
if user_id is not None:
runner_owner = runner_router.runner_owner(runner_id)
if runner_owner is not None and runner_owner != user_id:
raise OmnigentError(
f"runner {runner_id!r} is not owned by the requesting user",
code=ErrorCode.FORBIDDEN,
)
return runner_id
_CHILD_PREVIEW_LIMIT = 150
def _latest_message_preview(
items: list[ConversationItem],
limit_chars: int = _CHILD_PREVIEW_LIMIT,
) -> str | None:
"""
Return a single-line text preview from newest-first message items.
Powers the sub-agent rail row's status line so the user can see what
the child is saying without opening it. The caller supplies a
batched newest-first message list for one child; this function joins
``input_text`` / ``output_text`` blocks from the first non-meta
message with text, collapses whitespace, and truncates to
``limit_chars``. Hidden meta messages carry durable runner context
and must never be shown as user-facing previews.
:param items: Newest-first message items for one conversation.
:param limit_chars: Max preview length in characters,
e.g. ``150``.
:returns: Truncated single-line preview text, e.g.
``"I'll search the codebase for references…"``, or ``None``.
"""
for item in items:
if not isinstance(item.data, MessageData) or item.data.is_meta:
continue
parts: list[str] = []
for block in item.data.content:
block_type = block.get("type")
text = block.get("text")
if block_type in ("input_text", "output_text") and isinstance(text, str):
parts.append(text)
collapsed = " ".join(" ".join(parts).split())
if not collapsed:
continue
if len(collapsed) <= limit_chars:
return collapsed
# Trim to one char less than the limit so the trailing ellipsis
# keeps the field at ``limit_chars`` total.
return collapsed[: max(0, limit_chars - 1)].rstrip() + "…"
return None
# Title prefix marking a child session that a user added from the Web UI
# "Add agent" picker (vs. an LLM-spawned sub-agent). Such titles take the
# 3-segment form "ui:<agent_name>:<user_label>"; the leading sentinel keeps
# them from colliding with the 2-segment "<sub_agent_name>:<session_name>"
# titles that sys_session_send writes. The spec validator rejects sub-agent
# names equal to this sentinel (see _validate_agent_names) to preserve the
# disambiguation.
_UI_ADDED_AGENT_TITLE_PREFIX = "ui"
def _child_session_summary_from_conversation(
conv: Conversation,
parent_session_id: str,
last_message_preview: str | None,
) -> ChildSessionSummary:
"""
Build a :class:`ChildSessionSummary` from a child conversation.
Parses the canonical sub-agent title format
``"{agent_type}:{session_name}"`` written by
:func:`omnigent.tools.builtins.spawn._spawn_one`, plus the
3-segment ``"ui:{agent_name}:{user_label}"`` form written by the
Web UI "Add agent" flow (surfaced as ``tool={agent_name}`` and
``session_name={user_label}``). Tolerates malformed/legacy rows:
if the title is ``None`` or has no colon, ``tool`` falls back to
the raw title and ``session_name`` is ``None`` — the row is still
surfaced so debug views can investigate.
``busy`` is derived from the relay-fed ``_session_status_cache``
(the tasks table has been removed). ``agent_id`` and ``agent_name``
are read from the conversation row directly.
:param conv: A child :class:`Conversation` row
(``kind="sub_agent"``) from
:meth:`ConversationStore.list_conversations`.
:param parent_session_id: The parent session id from the
route, e.g. ``"conv_parent987"``. Passed in rather than
re-reading from ``conv.parent_conversation_id`` to keep
the helper indifferent to legacy rows where the FK might
be missing.
:param last_message_preview: Preview text derived from a batched
child-message lookup, or ``None`` when no visible message exists.
:returns: A populated :class:`ChildSessionSummary`.
"""
display_title = title_without_closed_marker(conv.title)
labels = labels_with_closed_status(conv.labels, conv.title)
tool: str | None
session_name: str | None
if _is_codex_native_subagent(conv):
# Codex-native child: surface the Codex-assigned nickname/role as
# ``tool`` and the raw thread id as ``session_name`` for correlation.
tool = _codex_subagent_display_tool(labels)
session_name = labels.get(_CODEX_NATIVE_SUBAGENT_THREAD_ID_LABEL_KEY)
elif display_title and ":" in display_title:
head, _, tail = display_title.partition(":")
if head == _UI_ADDED_AGENT_TITLE_PREFIX and ":" in tail:
# User-added agent: "ui:<agent_name>:<user_label>". Surface the
# bound agent as ``tool`` and the user's label as ``session_name``
# so the Agents rail renders it like any other child row.
agent_name, _, user_label = tail.partition(":")
tool = agent_name
session_name = user_label
else:
tool = head
session_name = tail
else:
tool = display_title or None
session_name = None
# Derive busy from the relay-fed cache; tasks table is gone.
cached_status = _session_status_cache.get(conv.id)
if cached_status in ("running", "waiting"):
busy = True
else:
busy = False
last_task_error = _last_task_error_from_labels(labels)
current_task_status = (
"failed" if cached_status == "failed" or last_task_error is not None else None
)
# For Codex children, fall back to the prompt label as preview when the
# real transcript has not arrived yet — avoids synthesizing a user message
# just so the rail has something to show.
if last_message_preview is None and _is_codex_native_subagent(conv):
raw_prompt = labels.get(_CODEX_NATIVE_SUBAGENT_PROMPT_LABEL_KEY)
if raw_prompt:
collapsed = " ".join(raw_prompt.split())
last_message_preview = collapsed[:_CHILD_PREVIEW_LIMIT] or None
return ChildSessionSummary(
id=conv.id,
parent_session_id=parent_session_id,
title=display_title,
tool=tool,
session_name=session_name,
created_at=conv.created_at,
updated_at=conv.updated_at,
# agent_id comes from the conversation row; agent_name and task_id
# are no longer available from the (removed) tasks table.
agent_id=conv.agent_id,
agent_name=None,
current_task_id=None,
current_task_status=current_task_status,
busy=busy,
labels=labels,
last_task_error=last_task_error,
last_message_preview=last_message_preview,
# Surface the sub-agent's parked-elicitation count from the same
# in-memory index that feeds the sidebar badge, so the Agents
# rail can flag a child that's awaiting user input.
pending_elicitations_count=pending_elicitations.count_for(conv.id),
)
async def _child_session_summaries_from_conversations(
children: list[Conversation],
parent_session_id: str,
conv_store: ConversationStore,
) -> list[ChildSessionSummary]:
"""
Build child summaries with one batched message-preview lookup.
``ChildSessionSummary.last_message_preview`` needs the latest visible
message per child. Loading those by calling ``list_items`` once per
child blocks the event loop and creates N+1 database traffic. This
helper reads newest message items for all child ids in a worker
thread, computes previews in memory, then builds summaries without
further store access.
:param children: Child conversation rows from
``list_conversations(kind="sub_agent")``.
:param parent_session_id: Parent session id, e.g. ``"conv_parent987"``.
:param conv_store: Conversation store used for the batched message read.
:returns: One :class:`ChildSessionSummary` per input child, preserving
input order.
"""
if not children:
return []
child_ids = [child.id for child in children]
message_items_by_child = await asyncio.to_thread(
conv_store.list_latest_message_items_for_conversations,
child_ids,
10,
)
previews = {
child_id: _latest_message_preview(message_items)
for child_id, message_items in message_items_by_child.items()
}
return [
_child_session_summary_from_conversation(
child,
parent_session_id,
previews.get(child.id),
)
for child in children
]
# ── MCP proxy helpers ───────────────────────────────────────────────────────
#
# These module-level functions implement the JSON-RPC 2.0 handlers for
# ``POST /v1/sessions/{session_id}/mcp``. They live outside the router
# factory so the factory closure stays compact.
def _mcp_tool_result(rpc_id: int | str | None, text: str) -> Response:
"""
Wrap a plain-text tool result in a JSON-RPC 2.0 MCP ``tools/call`` response.
:param rpc_id: The JSON-RPC request id (may be int, str, or ``None``
for notifications), e.g. ``1``.
:param text: The tool output text to embed in the ``content`` block.
:returns: A :class:`Response` with ``Content-Type: application/json``
carrying the JSON-RPC 2.0 envelope with a single ``text`` content block.
"""
body = json.dumps(
{"jsonrpc": "2.0", "id": rpc_id, "result": {"content": [{"type": "text", "text": text}]}}
)
return Response(content=body, media_type="application/json")
async def _handle_advise_models_mcp(
rpc_id: int | str | None,
conv: Any,
arguments: dict[str, Any],
agent_store: Any,
*,
session_id: str | None = None,
runner_router: Any = None,
) -> Response:
"""
Server-side handler for ``sys_advise_models`` MCP tool calls.
Intercepts the call before the runner forward because
``RuntimeCaps.routing_client`` lives in the server process.
:param rpc_id: The JSON-RPC request id.
:param conv: The :class:`Conversation` for this session.
:param arguments: Parsed tool arguments from the LLM.
:param agent_store: Store for agent lookup (used to resolve sub-agent harnesses).
:returns: A JSON-RPC 2.0 ``tools/call`` result response.
"""
tasks = arguments.get("tasks")
if not isinstance(tasks, list):
return _mcp_tool_result(
rpc_id, json.dumps({"error": "tasks must be a list", "router_on": False})
)
caps = get_caps()
routing_client = caps.routing_client
if routing_client is None:
return _mcp_tool_result(rpc_id, json.dumps({"router_on": False, "recommendations": []}))
from omnigent.model_catalog import spec_harness
from omnigent.server.smart_routing import fetch_runner_models, infer_models
# Fetch live model catalog from the runner once; used below to populate
# per-agent model lists when the caller omits explicit models.
# Keys are worker names ("self", "claude_code", etc.) as returned by
# catalog_for_spec. None when runner is unreachable — falls back to
# infer_models static table.
_runner_catalog: dict[str, list[str]] | None = None
if session_id is not None and runner_router is not None:
_runner_client = await _get_runner_client(session_id, runner_router)
if _runner_client is not None:
_runner_catalog = await fetch_runner_models(session_id, _runner_client)
# Resolve the parent agent spec to look up sub-agent harnesses.
spec: Any | None = None
if conv.agent_id is not None:
agent_obj = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent_obj is not None:
try:
spec = (
get_agent_cache()
.load(
agent_obj.id,
agent_obj.bundle_location,
expand_env=agent_obj.session_id is None,
)
.spec
)
except Exception: # noqa: BLE001
_logger.debug(
"_handle_advise_models_mcp: failed to load spec for agent=%s", conv.agent_id
)
_WORKER_HARNESS: dict[str, str] = {
"claude_code": "claude-sdk",
"codex": "codex",
"pi": "pi",
}
def _resolve_harness_for_worker(agent: str) -> str | None:
if spec is not None:
sub_agents = getattr(spec, "sub_agents", None) or []
for sub in sub_agents:
if getattr(sub, "name", None) == agent:
h = spec_harness(sub)
if h:
return h
break
return _WORKER_HARNESS.get(agent)
recommendations: list[dict[str, Any]] = []
for task in tasks:
if not isinstance(task, dict):
continue
title = task.get("title", "")
task_text = task.get("task", "")
agents_spec = task.get("agents")
if not isinstance(agents_spec, list) or not agents_spec:
continue
# Build harness→models map for the routing client, plus two reverse
# maps for resolving the chosen agent after the verdict:
# - harness_to_agent: preferred path when the judge picks a harness
# - model_to_agent: fallback when harness is absent or unrecognised
# Insertion order is preserved; first-agent-wins dedup applies when
# the same model appears in multiple harness lists.
model_to_agent: dict[str, str] = {}
harness_to_agent: dict[str, str] = {}
harness_models: dict[str, list[str]] = {}
for agent_entry in agents_spec:
if not isinstance(agent_entry, dict):
continue
agent = agent_entry.get("agent", "")
explicit_models: list[str] | None = agent_entry.get("models")
if explicit_models is not None and not isinstance(explicit_models, list):
explicit_models = None
if explicit_models:
harness_key = agent # use agent name as key when models are explicit
candidates = explicit_models
else:
harness_key = _resolve_harness_for_worker(agent) or agent
# Prefer live runner catalog (worker name or harness key);
# fall back to static infer_models table.
candidates = (
(_runner_catalog or {}).get(agent)
or (_runner_catalog or {}).get(harness_key)
or infer_models(harness_key)
or []
)
if candidates:
harness_models.setdefault(harness_key, [])
harness_to_agent.setdefault(harness_key, agent)
for m in candidates:
if m not in model_to_agent:
model_to_agent[m] = agent
harness_models[harness_key].append(m)
if not harness_models:
recommendations.append(
{"title": title, "agent": None, "model": None, "rationale": "no candidates"}
)
continue
try:
verdict = await routing_client.route(task_text, harness_models)
except Exception: # routing failures must not crash the advisor
_logger.exception("_handle_advise_models_mcp: route failed task=%r", title)
verdict = None
if verdict is None:
recommendations.append(
{
"title": title,
"agent": None,
"model": None,
"rationale": "router returned no verdict",
}
)
else:
# Prefer the judge's harness pick; fall back to model ownership.
chosen_agent = (
harness_to_agent.get(verdict.harness) if verdict.harness else None
) or model_to_agent.get(verdict.model)
recommendations.append(
{
"title": title,
"agent": chosen_agent,
"model": verdict.model,
"rationale": verdict.rationale,
}
)
return _mcp_tool_result(
rpc_id, json.dumps({"router_on": True, "recommendations": recommendations})
)
def _mcp_ok_response(rpc_id: int | str | None, result: dict[str, Any]) -> Response:
"""
Wrap *result* in a JSON-RPC 2.0 success response.
:param rpc_id: The JSON-RPC request id (may be int, str, or ``None``
for notifications), e.g. ``1``.
:param result: The JSON-serialisable result payload, e.g.
``{"tools": [...]}``.
:returns: A :class:`Response` with ``Content-Type: application/json``
carrying the JSON-RPC 2.0 envelope.
"""
body = json.dumps({"jsonrpc": "2.0", "id": rpc_id, "result": result})
return Response(content=body, media_type="application/json")
def _mcp_error_response(
rpc_id: int | str | None,
code: int,
message: str,
) -> Response:
"""
Wrap an error in a JSON-RPC 2.0 error response.
:param rpc_id: The JSON-RPC request id. Use ``None`` when the id
could not be parsed, e.g. ``None``.
:param code: JSON-RPC error code, e.g. ``-32601`` (method not found)
or ``-32000`` (application error).
:param message: Human-readable error description,
e.g. ``"Method not found: 'unsupported/method'"``.
:returns: A :class:`Response` with ``Content-Type: application/json``
carrying the JSON-RPC 2.0 error envelope.
"""
body = json.dumps(
{
"jsonrpc": "2.0",
"id": rpc_id,
"error": {"code": code, "message": message},
}
)
return Response(content=body, media_type="application/json")
def _mcp_input_required_response(
rpc_id: int | str | None,
elicitation_id: str,
message: str,
request_state: str,
session_id: str | None = None,
) -> Response:
"""
Return an MCP ``InputRequiredResult`` asking the runner to collect
user approval before retrying the tool call.
Follows the Multi Round-Trip Requests (MRTR) spec:
``https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr``.
The ``elicitation_id`` is used as the key in ``inputRequests`` so the
runner can identify the approval Future without inspecting the opaque
``requestState``. When URL-mode is active and ``session_id`` is
known, adds ``mode``/``url`` to params.
:param rpc_id: The JSON-RPC request id, e.g. ``1``.
:param elicitation_id: Server-minted elicitation id used both as the
``inputRequests`` key and inside the opaque ``requestState``,
e.g. ``"elicit_abc123"``.
:param message: Human-readable prompt shown to the user,
e.g. ``"Allow tool sys_os_shell?"``.
:param request_state: Opaque state blob the client echoes on retry.
Contains the ``elicitation_id`` and ``session_id`` so the server
can verify authenticity on retry without server-side storage.
:param session_id: Session/conversation id for constructing the
approval page URL, e.g. ``"conv_abc123"``. ``None`` omits the
URL (form mode).
:returns: A :class:`Response` carrying the JSON-RPC 2.0
``InputRequiredResult`` envelope.
"""
params: dict[str, Any] = {
"message": message,
"requestedSchema": {
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
},
}
if session_id is not None and _ELICITATION_MODE == "url":
params["mode"] = "url"
params["url"] = f"/approve/{session_id}/{elicitation_id}"
else:
params["mode"] = "form"
body = json.dumps(
{
"jsonrpc": "2.0",
"id": rpc_id,
"result": {
"resultType": "input_required",
"inputRequests": {
elicitation_id: {
"method": "elicitation/create",
"params": params,
}
},
"requestState": request_state,
},
}
)
return Response(content=body, media_type="application/json")
async def _handle_mcp_tools_list(
rpc_id: int | str | None,
session_id: str,
runner_router: RunnerRouter | None,
) -> Response:
"""
Handle a ``tools/list`` JSON-RPC request for the MCP proxy endpoint.
Delegates execution to the runner's ``POST
/v1/sessions/{id}/mcp/execute`` endpoint so that stdio MCP
subprocesses spawn on the runner's machine (correct ``cwd``,
env, and tooling). The Omnigent server's role here is routing only —
policy evaluation happens in ``tools/call``.
:param rpc_id: The JSON-RPC request id, e.g. ``1``.
:param session_id: The session id whose agent's tools to list,
e.g. ``"conv_abc123"``.
:param runner_router: Router used to get an httpx client pointed
at the session's runner. ``None`` returns an error.
:returns: A JSON-RPC 2.0 ``tools/list`` result response, or an
error response when the runner is unavailable.
"""
runner_client = await _get_runner_client(session_id, runner_router)
if runner_client is None:
# Fall back to the in-process runner client (local single-user mode).
from omnigent.runtime import get_runner_client
runner_client = cast("httpx.AsyncClient | None", get_runner_client())
if runner_client is None:
return _mcp_error_response(rpc_id, -32000, f"No runner bound for session {session_id!r}")
_logger.debug("MCP tools/list: delegating to runner execute for session=%r", session_id)
try:
resp = await runner_client.post(
f"/v1/sessions/{session_id}/mcp/execute",
json={"method": "tools/list", "params": {}},
timeout=30.0,
)
resp.raise_for_status()
data = resp.json()
except Exception as exc: # noqa: BLE001
_logger.warning("Runner MCP execute failed: %s", exc, exc_info=True)
return _mcp_error_response(rpc_id, -32000, "Runner MCP execute failed.")
if "error" in data:
err = data["error"]
return _mcp_error_response(
rpc_id, err.get("code", -32000), err.get("message", "unknown error")
)
result = data.get("result", {})
# schemas are already in OpenAI function-tool format from RunnerMcpManager;
# convert back to MCP inputSchema format for the tools/list response since
# ProxyMcpManager on the runner expects MCP-shaped tools/list output.
schemas: list[dict[str, Any]] = result.get("schemas", [])
tools = []
for schema in schemas:
# schema shape: {"type": "function", "name": "srv__tool",
# "description": "...", "parameters": {...}}
tools.append(
{
"name": schema.get("name", ""),
"description": schema.get("description", ""),
"inputSchema": schema.get("parameters") or {"type": "object", "properties": {}},
}
)
failures: dict[str, str] = result.get("failures", {})
for srv, msg in failures.items():
_logger.warning("runner MCP server %r unavailable: %s", srv, msg)
_logger.debug(
"MCP tools/list: session=%r returning %d tools, %d failures",
session_id,
len(tools),
len(failures),
)
return _mcp_ok_response(rpc_id, {"tools": tools})
async def _handle_mcp_tools_call(
rpc_id: int | str | None,
session_id: str,
params: dict[str, Any],
conversation_store: ConversationStore,
agent_store: AgentStore,
runner_router: RunnerRouter | None,
*,
actor: dict[str, str] | None = None,
request: Request | None = None,
) -> Response:
"""
Handle a ``tools/call`` JSON-RPC request for the MCP proxy endpoint.
Steps:
1. Validate the tool name (namespaced like ``github__search`` for MCP
tools, or bare like ``sys_os_read`` for runner-local tools).
2. Load session → agent → spec for policy evaluation.
3. On first call: evaluate TOOL_CALL policy. On DENY, return error.
On ASK, emit a ``response.elicitation_request`` SSE event and
return an MCP ``InputRequiredResult`` so the runner can park for
user approval and retry per the MRTR spec.
4. On retry (``requestState`` present in ``params``): verify the
state, check the user's ``inputResponses``, and proceed if
approved.
5. Delegate execution to the runner's ``POST
/v1/sessions/{id}/mcp/execute`` endpoint via the WS tunnel so
that stdio MCP subprocesses and runner-local tools execute on the
runner's machine (correct ``cwd``, environment, and tooling).
6. Evaluate the TOOL_RESULT policy phase on the returned output;
replace with a redaction notice on DENY.
7. Return the result in MCP ``content`` format.
:param rpc_id: The JSON-RPC request id, e.g. ``1``.
:param session_id: The session id, e.g. ``"conv_abc123"``.
:param params: The JSON-RPC ``params`` object. On first call,
contains ``"name"`` and ``"arguments"``. On retry, also
contains ``"requestState"`` (opaque blob from the server) and
``"inputResponses"`` (user's approval decision), e.g.
``{"name": "sys_os_shell", "arguments": {}, "requestState": "...",
"inputResponses": {"elicit_abc": {"action": "accept"}}}``.
:param conversation_store: Store for session and label state.
:param agent_store: Store for agent lookup.
:param runner_router: Router used to get a tunneled client pointed at
the session's runner. ``None`` returns an error response.
:param actor: Authenticated principal, e.g.
``{"run_as": "alice@example.com"}``. ``None`` when
identity is unknown.
:returns: A JSON-RPC 2.0 response carrying the tool result as MCP
``content`` blocks, an ``InputRequiredResult`` on ASK, or an
error response when the call is denied, the runner is
unavailable, or the underlying MCP call fails.
"""
namespaced_name = params.get("name", "")
arguments: dict[str, Any] = params.get("arguments") or {}
request_state_str: str | None = params.get("requestState")
input_responses: dict[str, Any] = params.get("inputResponses") or {}
is_retry = request_state_str is not None
_logger.debug(
"MCP tools/call: session=%r tool=%r is_retry=%r",
session_id,
namespaced_name,
is_retry,
)
if not namespaced_name:
return _mcp_error_response(rpc_id, -32000, "Missing tool name in tools/call params")
# Session → agent → spec (needed for policy evaluation on both paths).
# All three reads — conversation row, agent row, and the cold-cache
# bundle fetch + spec parse — are blocking IO. Run them off the event
# loop so an MCP tool call doesn't stall the single-worker server and
# serialize concurrent requests behind it.
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None or conv.agent_id is None:
return _mcp_error_response(
rpc_id, -32000, f"Session not found or has no agent: {session_id!r}"
)
spec = await asyncio.to_thread(_load_agent_spec_for_session, conv, agent_store)
if spec is None:
return _mcp_error_response(rpc_id, -32000, f"Agent not found: {conv.agent_id!r}")
# Build the policy engine once — used for both TOOL_CALL (first call
# only) and TOOL_RESULT (both paths). Engine construction reads
# session-policy specs and labels from the DB, so keep it off-loop too.
engine = await asyncio.to_thread(
_build_policy_engine_from_spec, spec, session_id, conversation_store
)
if is_retry:
# ── Retry path: user has responded to the elicitation ────────
# Verify the opaque requestState.
try:
state = json.loads(request_state_str) # type: ignore[arg-type]
except Exception: # noqa: BLE001
return _mcp_error_response(rpc_id, -32000, "Invalid requestState: not valid JSON")
if state.get("session_id") != session_id:
# Reject cross-session replay.
return _mcp_error_response(rpc_id, -32000, "requestState session mismatch")
# ── Fail-closed: re-evaluate TOOL_CALL policy on retry ──────
# The original retry path trusted the caller-supplied
# requestState + inputResponses as proof that "policy ran and
# the user approved." Because requestState is unsigned JSON
# and inputResponses is caller-controlled, a forged retry
# could bypass DENY/ASK gates entirely. Re-evaluating the
# policy on every retry closes this vector: a DENY'd tool
# stays denied regardless of what the request body claims.
retry_ctx = EvaluationContext(
phase=Phase.TOOL_CALL,
content={"name": namespaced_name, "arguments": arguments},
tool_name=namespaced_name,
actor=actor,
)
retry_result = await engine.evaluate(retry_ctx)
_logger.debug(
"MCP tools/call retry TOOL_CALL policy: session=%r tool=%r action=%r reason=%r",
session_id,
namespaced_name,
retry_result.action,
retry_result.reason,
)
if retry_result.action == PolicyAction.DENY:
return _mcp_error_response(
rpc_id,
-32000,
f"Denied by policy: {retry_result.reason or 'no reason given'}",
)
if retry_result.action == PolicyAction.ASK:
# Policy still requires approval — verify the elicitation
# was genuinely issued by the server (present in the
# server-side pending map) and that the user approved it.
elicitation_id_from_state: str = state.get("elicitation_id", "")
if elicitation_id_from_state not in _pending_policy_ask_writes:
# The elicitation_id is not in the server-side map.
# Either it was forged, already consumed, or expired.
# Check inputResponses: if the caller claims approval
# for an unrecognised elicitation, reject it.
approval: dict[str, Any] = input_responses.get(elicitation_id_from_state) or {}
if approval.get("action") == "accept":
# Claimed approval for an elicitation the server
# never issued or already consumed — reject.
return _mcp_error_response(
rpc_id,
-32000,
"Elicitation not found or already resolved",
)
return _mcp_error_response(rpc_id, -32000, "Tool call denied by user")
approval = input_responses.get(elicitation_id_from_state) or {}
if approval.get("action") != "accept":
return _mcp_error_response(rpc_id, -32000, "Tool call denied by user")
# Recover any policy-transformed args that were serialised into
# requestState on the initial ASK — the client re-sends the
# original arguments which we must not use when a transform was set.
if state.get("transformed_arguments") is not None:
arguments = state["transformed_arguments"]
# Apply the deciding policy's deferred writes now that the
# user approved (POLICIES.md §7.2: only on accept).
_pending = _pending_policy_ask_writes.pop(elicitation_id_from_state, None)
if _pending is not None:
if _pending.set_labels:
await asyncio.to_thread(engine.apply_label_writes, _pending.set_labels)
if _pending.state_updates:
await asyncio.to_thread(engine.apply_state_updates, _pending.state_updates)
else:
# ALLOW — policy no longer requires approval (e.g. label
# state changed between the original ASK and this retry).
# Recover transformed args if present, then fall through.
if state.get("transformed_arguments") is not None:
arguments = state["transformed_arguments"]
# Fall through to execution.
else:
# ── First call: evaluate TOOL_CALL policy ────────────────────
call_ctx = EvaluationContext(
phase=Phase.TOOL_CALL,
content={"name": namespaced_name, "arguments": arguments},
tool_name=namespaced_name,
actor=actor,
)
call_result = await engine.evaluate(call_ctx)
_logger.debug(
"MCP tools/call TOOL_CALL policy: session=%r tool=%r action=%r reason=%r",
session_id,
namespaced_name,
call_result.action,
call_result.reason,
)
if call_result.action == PolicyAction.DENY:
if call_result.set_labels:
await asyncio.to_thread(engine.apply_label_writes, call_result.set_labels)
return _mcp_error_response(
rpc_id,
-32000,
f"Denied by policy: {call_result.reason or 'no reason given'}",
)
if call_result.action == PolicyAction.ASK:
# Emit elicitation SSE event (for REPL approval UI) and return
# InputRequiredResult per the MCP MRTR spec so the runner can
# park on the approval Future and retry when the user decides.
elicitation_id = await _register_policy_elicitation(
session_id,
call_result,
json.dumps(arguments)[:1024],
conversation_store,
)
# Defer the deciding policy's writes (label mutations AND
# state_updates such as a cost-budget checkpoint) to the
# approved retry path — POLICIES.md §7.2 lands them only on
# accept. The approval handler at the top of this function
# already applies both via ``apply_label_writes`` and
# ``apply_state_updates``. Mirrors the relay path pattern.
# Always store an entry even when there are no deferred
# writes — the retry path checks the pending map to verify
# the elicitation was genuinely issued by the server. A
# missing entry causes "Elicitation not found or already
# resolved" on the retry.
_pending_policy_ask_writes[elicitation_id] = _PendingPolicyAskWrites(
state_updates=call_result.state_updates,
set_labels=call_result.set_labels,
from_mcp=True,
)
request_state_payload: dict[str, Any] = {
"elicitation_id": elicitation_id,
"session_id": session_id,
}
# If the policy returned transformed args alongside ASK (e.g.
# PII-redacted arguments), persist them so the retry path can
# apply them after the user approves — the client re-sends the
# original arguments, which would silently bypass the transform.
if call_result.data is not None:
request_state_payload["transformed_arguments"] = call_result.data
request_state = json.dumps(request_state_payload)
return _mcp_input_required_response(
rpc_id,
elicitation_id=elicitation_id,
message=call_result.reason or "Approval required to run this tool",
request_state=request_state,
session_id=session_id,
)
# ALLOW — apply labels now that we know the action is not ASK.
if call_result.set_labels:
await asyncio.to_thread(engine.apply_label_writes, call_result.set_labels)
# If the policy returned transformed arguments (e.g.
# PII-redacted args), use them instead of the originals.
if call_result.data is not None:
arguments = call_result.data
# ── Server-side sys_advise_models intercept ──────────────────────────
# After policy evaluation (DENY/ASK handled above); arguments may have
# been transformed. The advisor runs server-side where routing_client lives.
if namespaced_name in ("sys_advise_models", "mcp__omnigent__sys_advise_models"):
return await _handle_advise_models_mcp(
rpc_id,
conv,
arguments,
agent_store,
session_id=session_id,
runner_router=runner_router,
)
# ── Execute on the runner via WS tunnel ──────────────────────────
# The runner owns stdio subprocess spawning (correct machine, cwd,
# and env). We call its /mcp/execute endpoint through the same WS
# tunnel the runner already opened to the Omnigent server at startup.
runner_client = await _get_runner_client(session_id, runner_router)
if runner_client is None:
from omnigent.runtime import get_runner_client
runner_client = cast("httpx.AsyncClient | None", get_runner_client())
if runner_client is None:
return _mcp_error_response(rpc_id, -32000, f"No runner bound for session {session_id!r}")
try:
from omnigent.runner.tool_dispatch import MCP_PROXY_FORWARD_TIMEOUT_S
exec_resp = await runner_client.post(
f"/v1/sessions/{session_id}/mcp/execute",
json={
"method": "tools/call",
"params": {"name": namespaced_name, "arguments": arguments},
},
# ``sys_session_send`` returns a launch handle immediately; this
# timeout now protects ordinary runner proxy hangs.
timeout=MCP_PROXY_FORWARD_TIMEOUT_S,
)
exec_resp.raise_for_status()
exec_data = exec_resp.json()
except Exception as exc: # noqa: BLE001
_logger.warning("Runner MCP execute failed: %s", exc, exc_info=True)
return _mcp_error_response(rpc_id, -32000, "Runner MCP execute failed.")
if "error" in exec_data:
err = exec_data["error"]
return _mcp_error_response(
rpc_id, err.get("code", -32000), err.get("message", "unknown error")
)
# ── MRTR: external MCP server needs user input ───────────────
# The runner returns ``{"result": {"input_required": {...}}}``
# when the external MCP server sent an ``InputRequiredResult``.
# Surface each elicitation to the user via the existing SSE
# infrastructure, gather responses, then retry on the runner.
mcp_input_required = exec_data.get("result", {}).get("input_required")
if mcp_input_required is not None:
if request is None:
return _mcp_error_response(
rpc_id, -32000, "MCP server requires elicitation but no request context available"
)
input_requests: dict[str, Any] = mcp_input_required.get("inputRequests") or {}
mcp_request_state: str = mcp_input_required.get("requestState", "")
# Gather user responses for each inputRequest.
input_responses: dict[str, Any] = {}
for eid, req_entry in input_requests.items():
req_params = req_entry.get("params", {}) if isinstance(req_entry, dict) else {}
elicit_params = ElicitationRequestParams(
mode=req_params.get("mode", "form"),
message=req_params.get("message", "Approval required"),
requestedSchema=req_params.get("requestedSchema"),
)
elicit_result = await _publish_and_wait_for_harness_elicitation(
request,
session_id=session_id,
params=elicit_params,
timeout_s=300.0,
conversation_store=conversation_store,
)
if elicit_result is None:
input_responses[eid] = {"action": "decline"}
else:
resp_entry: dict[str, Any] = {"action": elicit_result.action}
if elicit_result.content is not None:
resp_entry["content"] = elicit_result.content
input_responses[eid] = resp_entry
# Retry on the runner with the user's inputResponses.
try:
retry_resp = await runner_client.post(
f"/v1/sessions/{session_id}/mcp/execute",
json={
"method": "tools/call",
"params": {
"name": namespaced_name,
"arguments": arguments,
"inputResponses": input_responses,
"requestState": mcp_request_state,
},
},
timeout=MCP_PROXY_FORWARD_TIMEOUT_S,
)
retry_resp.raise_for_status()
exec_data = retry_resp.json()
except Exception as exc: # noqa: BLE001
_logger.warning("Runner MCP retry failed: %s", exc, exc_info=True)
return _mcp_error_response(rpc_id, -32000, "Runner MCP retry failed.")
if "error" in exec_data:
err = exec_data["error"]
return _mcp_error_response(
rpc_id, err.get("code", -32000), err.get("message", "unknown error")
)
# Multi-round MRTR: the server returned yet another
# InputRequiredResult on the retry. Return an error rather
# than looping indefinitely — the user can retry the tool.
if exec_data.get("result", {}).get("input_required") is not None:
return _mcp_error_response(
rpc_id,
-32000,
"MCP server requires additional elicitation rounds (not yet supported)",
)
output: str = exec_data.get("result", {}).get("output", "")
_logger.debug(
"MCP tools/call execute: session=%r tool=%r output_len=%d",
session_id,
namespaced_name,
len(output),
)
# ── TOOL_RESULT policy ───────────────────────────────────────────
result_ctx = EvaluationContext(
phase=Phase.TOOL_RESULT,
content={"result": output},
tool_name=namespaced_name,
request_data={"name": namespaced_name, "arguments": arguments},
actor=actor,
)
result_policy = await engine.evaluate(result_ctx)
if result_policy.set_labels:
await asyncio.to_thread(engine.apply_label_writes, result_policy.set_labels)
_logger.debug(
"MCP tools/call TOOL_RESULT policy: session=%r tool=%r action=%r reason=%r",
session_id,
namespaced_name,
result_policy.action,
result_policy.reason,
)
if result_policy.action == PolicyAction.DENY:
output = f"[Result suppressed by policy: {result_policy.reason or 'no reason given'}]"
elif result_policy.data is not None:
# Policy returned transformed output (e.g. PII-redacted content).
# The TOOL_RESULT phase contract requires data to be a str; coerce
# and warn rather than dropping the result if a policy author returns
# the wrong type (common mistake: returning the full content dict).
if not isinstance(result_policy.data, str):
_logger.warning(
"TOOL_RESULT policy data must be str; got %s — coercing via str()",
type(result_policy.data).__name__,
)
output = (
result_policy.data if isinstance(result_policy.data, str) else str(result_policy.data)
)
return _mcp_ok_response(
rpc_id,
{"content": [{"type": "text", "text": output}]},
)
# Read uploads in 1 MiB chunks so an oversized body is aborted ~1 MiB past
# the cap instead of being buffered whole (the previous unconditional
# ``await file.read()`` was an OOM risk for very large uploads).
_UPLOAD_READ_CHUNK_BYTES: int = 1024 * 1024
async def _read_upload_capped(file: UploadFile, limit_bytes: int) -> bytes:
"""
Read an uploaded file into memory, aborting if it exceeds *limit_bytes*.
Reads in :data:`_UPLOAD_READ_CHUNK_BYTES` chunks and raises HTTP 413 as
soon as the cap is crossed, so an oversized upload never buffers more
than one chunk past the limit.
:param file: The multipart upload.
:param limit_bytes: Maximum allowed size in bytes.
:returns: The full file content.
:raises HTTPException: 413 when the upload exceeds *limit_bytes*.
"""
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(_UPLOAD_READ_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > limit_bytes:
raise HTTPException(
status_code=413,
detail=(
f"Attachment exceeds the {limit_bytes // (1024 * 1024)} MB "
"limit for this file type."
),
)
chunks.append(chunk)
return b"".join(chunks)
def create_sessions_router(
conversation_store: ConversationStore,
agent_store: AgentStore,
file_store: FileStore | None = None,
artifact_store: ArtifactStore | None = None,
runner_router: RunnerRouter | None = None,
auth_provider: AuthProvider | None = None,
permission_store: PermissionStore | None = None,
agent_cache: AgentCache | None = None,
mcp_pool: ServerMcpPool | None = None, # noqa: ARG001 — retained for API compat
liveness_lookup: Callable[[list[str]], dict[str, SessionLiveness]] | None = None,
comment_store: CommentStore | None = None,
runner_tunnel_tokens: frozenset[str] | None = None,
runner_exit_reports: RunnerExitReports | None = None,
) -> APIRouter:
"""
Factory that builds the sessions router.
Stores are closed over rather than dependency-injected, matching
the convention established by the other route modules
(conversations, agents, files).
:param conversation_store: Store for conversation and item
persistence.
:param agent_store: Store for agent lookups by ID.
:param file_store: Store for file metadata CRUD. Required for
session-scoped file endpoints (Phase 1c). ``None`` in
test setups that don't exercise file routes.
:param artifact_store: Store for binary file content and agent
bundles. Required for bundled session creation and session
file upload/download.
:param runner_router: Router used to validate registered
runners for ``PATCH /v1/sessions/{id}``. ``None`` only in
tests that do not exercise runner binding.
:param auth_provider: Auth provider for user identity
extraction. ``None`` disables permission checks.
:param permission_store: Permission store for session-level
access control. ``None`` disables permission checks.
:param agent_cache: Optional agent cache for loading parsed specs
from bundles. Used to populate ``llm_model`` and
``context_window`` in :class:`SessionResponse`. ``None`` in
test setups that don't exercise context-window lookup.
:param mcp_pool: Unused; retained for API compatibility. MCP
execution is now delegated to the runner via
``POST /v1/sessions/{id}/mcp/execute``. The
``POST /v1/sessions/{id}/mcp`` endpoint is enabled whenever
``runner_router`` is set.
:param liveness_lookup: Bulk session-liveness lookup
(the server's ``_bulk_session_liveness``): maps a list of
session ids to ``{id: SessionLiveness}``, each carrying
strict ``runner_online`` and ``host_online``. When provided,
the ``GET /sessions`` list and ``WS /sessions/updates`` stream
include both fields per item, and the stream pushes a delta
when liveness flips, so the web app can stop polling
``GET /health``. ``None`` (e.g. in focused tests) omits the
fields and the client falls back to its ``/health`` poll.
:param comment_store: Store for per-session review comments. When
provided, ``GET /sessions`` and ``WS /sessions/updates`` items
carry the per-session comments fingerprint
(``comments_count`` / ``comments_updated_at``) so the web app
can refresh its comment list when another user or the agent
mutates comments. ``None`` (e.g. in focused tests or servers
without comments wired) emits the no-comments shape.
:param runner_tunnel_tokens: The server's runner tunnel-token
allow-list (same value the tunnel router receives), used to
authorize runner writes to the policy-owned ``cost_control.*``
labels on ``PATCH /v1/sessions/{id}``. ``None`` when the
server has no allow-list (token-bound runner ids are then the
only accepted proof).
:returns: A configured :class:`APIRouter` exposing the
``/sessions`` endpoints.
"""
router = APIRouter()
# ── POST /sessions ───────────────────────────────────────────
@router.post(
"/sessions",
status_code=201,
response_model=None,
# CSRF hardening: this route dispatches on Content-Type (JSON vs
# multipart bundled-create), so reject text/plain and other simple
# types up front while still allowing both legitimate body shapes.
# The multipart shape is CORS-safelisted, so the content-type guard
# alone can't stop a cross-site bundle upload — require_trusted_origin
# closes that gap (allows absent Origin for non-browser SDK/runner
# clients; in local mode a present Origin must be loopback).
dependencies=[
Depends(require_json_or_multipart_content_type),
Depends(require_trusted_origin),
],
)
async def create_session(
request: Request,
) -> SessionResponse | CreatedSessionResponse:
"""
Create a session.
``application/json`` preserves the existing contract: bind to
an already-registered agent by ``agent_id`` and return the full
session snapshot. ``multipart/form-data`` is the Alpha
runner-state create path: the request carries a JSON
``metadata`` part and a ``bundle`` file part, then the server
stores the bundle and creates the conversation row plus
session-scoped agent row in one database transaction.
:param request: FastAPI request containing either JSON or
multipart form data.
:returns: :class:`SessionResponse` for JSON create, or
:class:`CreatedSessionResponse` for bundled create.
:raises OmnigentError: If metadata, bundle, or agent lookup
validation fails, artifact storage is unavailable, or
database creation fails.
"""
user_id = _require_user(request, auth_provider)
content_type = request.headers.get("content-type", "").split(";", 1)[0].lower()
if content_type == "multipart/form-data":
result = await _create_bundled_session_from_multipart(request, user_id)
if permission_store is not None and user_id is not None:
await asyncio.to_thread(permission_store.ensure_user, user_id)
await asyncio.to_thread(
permission_store.grant, user_id, result.session_id, LEVEL_OWNER
)
# Push the new session to this user's other open tabs so it
# enters the sidebar without a list poll (WS /sessions/updates).
_announce_session_added(user_id, result.session_id)
return result
try:
payload = await request.json()
body = SessionCreateRequest.model_validate(payload)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=422,
detail=[
{
"type": "json_invalid",
"loc": ["body"],
"msg": "Invalid JSON",
"input": None,
},
],
) from exc
except ValidationError as exc:
# include_context=False: pydantic v2 puts the RAW exception
# object in ctx for validator-raised ValueErrors, which
# JSONResponse cannot serialize — every model_validator 422
# on this route 500'd as internal_error. The human-readable
# message survives in each entry's `msg`.
raise HTTPException(status_code=422, detail=exc.errors(include_context=False)) from exc
resp = await _create_session_from_existing_agent(
conversation_store,
agent_store,
runner_router,
body,
request,
agent_cache=agent_cache,
user_id=user_id,
permission_store=permission_store,
liveness_lookup=liveness_lookup,
file_store=file_store,
artifact_store=artifact_store,
)
# Notify the runner about the new session so it can resolve
# the spec and cache sub_agent_name before the first turn.
# Without this, the runner doesn't know this session exists
# until the first forwarded event.
conv = conversation_store.get_conversation(resp.id)
# Mark the terminal spin-up flag at creation — the earliest
# possible point — for a host-launched terminal-first session
# (claude-native / codex-native). The runner's own pending emit
# arrives much later (after host launch, runner boot, spec
# resolve, and harness spawn — each a round-trip), so the spinner
# would otherwise only flash for the sub-second window before the
# already-spawned terminal resolves. Gated on host_id because the
# runner only auto-creates (and thus only clears) a terminal for
# host-launched sessions; a CLI-bound terminal-first session
# manages its own terminal and would strand the flag. Clears come
# from the runner's finally, the relay's resource.created
# self-heal, or the host-launch-failure path below.
_terminal_first_create = (
conv is not None
and body.host_id is not None
and conv.labels.get(_CLAUDE_NATIVE_UI_LABEL_KEY) == _CLAUDE_NATIVE_UI_LABEL_VALUE
)
if _terminal_first_create:
_publish_terminal_pending(resp.id, True)
_rc = await _get_runner_client(resp.id, runner_router)
if _rc is not None and conv is not None:
try:
await _rc.post(
"/v1/sessions",
json={
"session_id": resp.id,
"agent_id": conv.agent_id,
"sub_agent_name": conv.sub_agent_name,
},
timeout=10.0,
)
except (httpx.HTTPError, ConnectionError):
_logger.warning(
"Failed to notify runner about session %s",
resp.id,
exc_info=True,
)
# Grant the creator ownership BEFORE any host launch so the
# launch's session-ownership check (shared with
# POST /v1/hosts/{host_id}/runners via resolve_host_launch)
# sees the grant.
if permission_store is not None and user_id is not None:
await asyncio.to_thread(permission_store.ensure_user, user_id)
await asyncio.to_thread(permission_store.grant, user_id, resp.id, LEVEL_OWNER)
resp.permission_level = await _get_permission_level(user_id, resp.id, permission_store)
# Push the new session to this user's other open tabs (see the
# multipart path above for the rationale).
_announce_session_added(user_id, resp.id)
# Managed host: schedule a BACKGROUND sandbox provision bound
# to this session and return immediately — provisioning takes
# tens of seconds and must not block the create POST. The
# background task binds host + workspace to the session row
# and launches the runner once the sandbox host registers; a
# message POST racing the provision rendezvouses on the
# tracker entry registered here (see post_event). Config
# problems and malformed repo workspaces still fail the POST
# synchronously.
launch_host_id = body.host_id
if body.host_type == "managed" and resp.runner_id is None:
sandbox_config = getattr(request.app.state, "sandbox_config", None)
host_store_for_managed = getattr(request.app.state, "host_store", None)
managed_launches = getattr(request.app.state, "managed_launches", None)
if (
sandbox_config is None
or host_store_for_managed is None
or managed_launches is None
):
raise OmnigentError(
"managed hosts are not configured on this server — add a "
"'sandbox:' section to the server config",
code=ErrorCode.INVALID_INPUT,
)
from omnigent.server.auth import RESERVED_USER_LOCAL
from omnigent.server.managed_hosts import (
MANAGED_REPO_LABEL_KEY,
parse_repo_workspace,
)
# A managed workspace is a repository URL (schema-
# validated) the launch clones inside the sandbox; parse
# it now so a malformed URL is a synchronous 4xx, not a
# background failure.
repo = parse_repo_workspace(body.workspace) if body.workspace is not None else None
if body.workspace is not None:
# The session row's workspace is overwritten with the
# CLONED path at bind time; record the raw request
# value so a sandbox relaunch can re-clone the same
# repository into the new generation.
await asyncio.to_thread(
conversation_store.set_labels,
resp.id,
{MANAGED_REPO_LABEL_KEY: body.workspace},
)
managed_launches.begin(resp.id)
# Seed the launch-progress indicator before the background
# task starts, so the first GET snapshot (the Web UI
# navigates to the session page immediately after this
# 201) already carries the "provisioning" stage.
_publish_sandbox_status(resp.id, "provisioning")
launch_task = asyncio.create_task(
_run_managed_launch(
session_id=resp.id,
# On auth-disabled servers user_id is None; the
# sandbox host registers under the reserved local
# owner, same as a directly-connected host would.
owner=user_id if user_id is not None else RESERVED_USER_LOCAL,
sandbox_config=sandbox_config,
repo=repo,
tracker=managed_launches,
conversation_store=conversation_store,
host_store=host_store_for_managed,
host_registry=getattr(request.app.state, "host_registry", None),
tunnel_registry=getattr(request.app.state, "tunnel_registry", None),
)
)
_managed_launch_tasks.add(launch_task)
launch_task.add_done_callback(_managed_launch_tasks.discard)
# Host launch: if a host is targeted (caller-supplied or
# managed) and no runner is bound yet, authorize (caller must
# own the host AND the session), atomically bind, then launch.
# Same authorization path as POST /v1/hosts/{host_id}/runners.
if launch_host_id is not None and resp.runner_id is None:
host_registry = getattr(request.app.state, "host_registry", None)
host_store_inst = getattr(request.app.state, "host_store", None)
if host_registry is not None and host_store_inst is not None:
from omnigent.host.frames import (
HostLaunchRunnerFrame,
encode_host_frame,
)
from omnigent.runner.identity import token_bound_runner_id
from omnigent.server.routes._host_launch import resolve_host_launch
target = await asyncio.to_thread(
resolve_host_launch,
user_id=user_id,
host_id=launch_host_id,
session_id=resp.id,
host_store=host_store_inst,
host_registry=host_registry,
conversation_store=conversation_store,
permission_store=permission_store,
)
conn = target.conn
binding_token = secrets.token_urlsafe(32)
runner_id = token_bound_runner_id(binding_token)
# Atomic bind (WHERE runner_id IS NULL) closes the TOCTOU.
bound = await asyncio.to_thread(
conversation_store.set_runner_id,
resp.id,
runner_id,
)
if not bound:
raise OmnigentError(
f"Session {resp.id!r} already has a runner bound",
code=ErrorCode.CONFLICT,
)
# host_id and workspace were already written by
# _create_session_from_existing_agent; we only need
# to set runner_id atomically (above) and send the
# launch frame.
request_id = secrets.token_hex(8)
future: asyncio.Future[dict[str, str | None]] = (
asyncio.get_running_loop().create_future()
)
conn.pending_launches[request_id] = future
if resp.workspace is None: # pragma: no cover — schema guards
raise OmnigentError(
"session has host_id but no workspace; "
"schema constraint should have prevented this",
code=ErrorCode.INTERNAL_ERROR,
)
launch_frame = encode_host_frame(
HostLaunchRunnerFrame(
request_id=request_id,
binding_token=binding_token,
workspace=resp.workspace,
session_id=resp.id,
# Already canonical (see _resolve_harness); lets
# the host refuse an unconfigured harness before
# spawning. None (agent not resolvable) skips the
# host-side check.
harness=resp.harness,
)
)
host_registry.send_text(conn, launch_frame)
try:
result = await asyncio.wait_for(future, timeout=30.0)
except asyncio.TimeoutError:
conn.pending_launches.pop(request_id, None)
result = {"status": "failed", "error": "host launch timed out"}
if result.get("status") == "failed":
# Lenient on every create-time launch failure, including
# an unconfigured harness: the picker's readiness data
# can be stale (the user may have run `omnigent setup`
# since the host last connected), so we never block the
# create. The session opens with the binding intact; the
# first message drives the real runner start, and if the
# host still refuses there, that path consults the daemon
# and persists a transcript error (see post_event's
# relaunch branch). No create-time harness gating.
_logger.warning(
"Host %s failed to launch runner for session %s: %s",
launch_host_id,
resp.id,
result.get("error"),
)
# The runner never booted, so its pending=False clear
# will never fire. Clear the spin-up flag here so a
# failed launch doesn't strand the Terminal-pill
# spinner. No-op when we never set it.
if _terminal_first_create:
_publish_terminal_pending(resp.id, False)
resp.runner_id = runner_id
resp.host_id = launch_host_id
return resp
async def _create_bundled_session_from_multipart(
request: Request,
user_id: str | None,
) -> CreatedSessionResponse:
"""
Handle multipart ``POST /v1/sessions`` with inline agent upload.
:param request: FastAPI request containing ``metadata`` and
``bundle`` form parts.
:param user_id: Authenticated caller, e.g.
``"alice@example.com"``. Used to authorize
``metadata.parent_session_id`` and enforce
runner ownership on parent inheritance.
:returns: :class:`CreatedSessionResponse` with the new
session id.
:raises HTTPException: 422 when a required multipart part is
absent.
:raises OmnigentError: If metadata or bundle validation
fails, or ``parent_session_id`` fails authorization.
"""
if artifact_store is None:
raise OmnigentError(
"artifact store is not configured",
code=ErrorCode.INTERNAL_ERROR,
)
form = await request.form()
metadata = form.get("metadata")
bundle = form.get("bundle")
missing = [
_multipart_missing_detail(field)
for field, value in (("metadata", metadata), ("bundle", bundle))
if value is None
]
if missing:
raise HTTPException(status_code=422, detail=missing)
if not isinstance(metadata, str):
raise HTTPException(status_code=422, detail=[_multipart_missing_detail("metadata")])
if not isinstance(bundle, StarletteUploadFile):
raise HTTPException(status_code=422, detail=[_multipart_missing_detail("bundle")])
parsed_metadata = _parse_session_create_metadata(metadata)
_reject_reserved_cost_control_label_seed(parsed_metadata.labels)
inherited_runner_id: str | None = None
if parsed_metadata.parent_session_id is not None:
inherited_runner_id = await _authorize_bundled_parent_and_inherit_runner(
parsed_metadata.parent_session_id,
user_id=user_id,
permission_store=permission_store,
conversation_store=conversation_store,
runner_router=runner_router,
)
bundle_bytes = await bundle.read()
result = await asyncio.to_thread(
_create_session_from_bundle,
conversation_store,
artifact_store,
parsed_metadata,
bundle_bytes,
inherited_runner_id,
)
# Top-level creates (no inherited runner) skip the notify —
# their runner registers itself later.
if inherited_runner_id is not None:
await _notify_runner_of_bundled_child(
result.session_id,
result.agent_id,
runner_router,
)
return result
# ── GET /sessions/projects ────────────────────────────────────
#
# MUST be registered before ``GET /sessions/{session_id}``: FastAPI
# matches routes in registration order, so a literal ``/sessions/projects``
# would otherwise be captured by the ``{session_id}`` path param and 404
# as a missing conversation.
@router.get(
"/sessions/projects",
response_model=None,
)
async def list_session_projects(
request: Request,
) -> list[str]:
"""
Return all project names for the authenticated user, ordered
alphabetically.
Projects are implicit: they exist while at least one session
has a ``conversation_labels`` row with ``key="omni_project"``.
:returns: List of project names.
"""
user_id = _require_user(request, auth_provider)
# Filing into a project is owner-only, so the sidebar renders project
# folders only on "My sessions". Scope to owned sessions so a project
# owned by someone else (with a session shared to this user) doesn't
# surface as one of their own folders.
return await asyncio.to_thread(
conversation_store.list_projects,
owned_by=user_id,
)
# ── PUT /sessions/{session_id}/read-state ─────────────────────
#
# The per-user read-state *write* path. The *read* path is the
# per-viewer ``viewer_last_seen`` / ``viewer_unread`` fields embedded in
# the ``GET /v1/sessions`` list items — no separate read endpoint.
@router.put(
"/sessions/{session_id}/read-state",
status_code=204,
)
async def put_read_state(
request: Request,
session_id: str,
body: ReadStatePutRequest,
) -> Response:
"""
Set the calling user's read-state for one session.
Requires ``LEVEL_READ`` on the session in multi-user mode — you can
only track read-state for sessions you can see. Stores the values
verbatim (the client enforces the baseline's monotonicity and the
unread semantics); the server does not interpret them against
session status. Returns ``204`` — the client already has the
optimistic state and re-reads the authoritative value on the next
``GET /v1/sessions`` poll.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param body: The validated :class:`ReadStatePutRequest`.
:returns: An empty ``204 No Content`` response.
:raises OmnigentError: 403 if the caller lacks read access.
"""
user_id = _require_user(request, auth_provider)
await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
_set_read_state(user_id, session_id, body.last_seen, body.unread)
return Response(status_code=204)
# ── GET /sessions/{session_id} ───────────────────────────────
@router.get(
"/sessions/{session_id}",
# See create_session for the response_model=None rationale. We keep
# response_model=None (no response re-validation/serialization) but
# still advertise the body schema for docs/SDK tooling via responses=.
response_model=None,
responses={200: {"model": SessionResponse}},
)
async def get_session(
request: Request,
response: Response,
session_id: str,
include_items: bool = Query(default=True),
include_liveness: bool = Query(default=True),
refresh_state: bool = Query(default=False),
) -> SessionResponse:
"""
Return a session snapshot: identity, status, and committed
items.
:param request: The incoming FastAPI request (for auth).
:param response: The FastAPI response (for cache headers).
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param include_items: When ``False``, skip the committed-items
read and return ``items=[]``. The web chat surface passes
``False`` because it hydrates the transcript via the
paginated ``GET /sessions/{id}/items`` endpoint in parallel
and never reads the snapshot's copy; the items read is the
single most expensive step of the snapshot build.
:param include_liveness: When ``False``, skip the runner/host
liveness lookup and return ``runner_online``/``host_online``
as ``None``. The web chat surface passes ``False`` because
it sources liveness from the ``/health`` poll and the WS
stream, not the snapshot.
:param refresh_state: When ``True``, refresh runner-derived
snapshot overlays from the live session instead of serving
stale AP-process caches. Browser reload/bind requests use
this to recover from fixed bugs without restarting the AP
server.
:returns: The matching :class:`SessionResponse`.
:raises OmnigentError: 404 if no session exists.
"""
response.headers["Cache-Control"] = "no-store"
user_id = _get_user_id(request, auth_provider)
# Single permission pass: authorize + resolve the display level +
# fetch the conversation once, then reuse the conversation in the
# snapshot (the snapshot's read is skipped). Replaces the former
# require_access + get_permission_level + snapshot-get_conversation
# sequence, which made ~5-6 separate store round-trips.
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
return await _get_session_snapshot(
conversation_store,
session_id,
access.level,
agent_store,
agent_cache,
conversation=access.conversation,
liveness_lookup=liveness_lookup if include_liveness else None,
include_items=include_items,
runner_exit_reports=runner_exit_reports,
refresh_state=refresh_state,
host_store=getattr(request.app.state, "host_store", None),
sandbox_config=getattr(request.app.state, "sandbox_config", None),
)
@router.get(
"/sessions/{session_id}/labels",
response_model=SessionLabelsResponse,
)
async def get_session_labels(
request: Request,
response: Response,
session_id: str,
) -> SessionLabelsResponse:
"""
Return only the labels for a session.
Native runner bridge setup needs labels during harness spawn,
but the full session snapshot also loads history, skills,
runner status, and agent metadata. This endpoint keeps that
spawn-time dependency to one authorized conversation read.
:param request: The incoming FastAPI request (for auth).
:param response: The FastAPI response (for cache headers).
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:returns: The session id and labels.
:raises OmnigentError: 404 if no session exists.
"""
response.headers["Cache-Control"] = "no-store"
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
return SessionLabelsResponse(
id=conv.id,
labels=labels_with_closed_status(conv.labels, conv.title),
)
# ── GET /sessions ───────────────────────────────────────────
@router.get(
"/sessions",
response_model=None,
responses={200: {"model": SessionList}},
)
async def list_sessions(
request: Request,
limit: int = Query(default=20, ge=1, le=1000),
after: str | None = Query(default=None),
before: str | None = Query(default=None),
agent_id: str | None = Query(default=None),
agent_name: str | None = Query(default=None),
order: str = Query(default="desc", pattern="^(asc|desc)$"),
sort_by: str = Query(default="created_at", pattern="^(created_at|updated_at)$"),
search_query: str | None = Query(default=None),
include_archived: bool = Query(default=False),
kind: str = Query(default="default", pattern="^(default|sub_agent|any)$"),
project: str | None = Query(default=None),
) -> PaginatedList:
"""
List sessions with cursor-based pagination.
Sessions are conversations with a non-``None`` ``agent_id``
— i.e. those created via ``POST /v1/sessions``.
Conversations without an agent binding are excluded.
:param limit: Maximum number of sessions to return
(1-1000, default 20).
:param after: Cursor — return sessions after this
session ID in sort order, e.g. ``"conv_abc123"``.
:param before: Cursor — return sessions before this
session ID.
:param agent_id: When set, only return sessions bound
to this agent, e.g. ``"ag_abc123"``. ``None``
returns sessions across all agents.
:param agent_name: When set, only return sessions whose
bound agent row has this name. This intentionally
includes session-scoped agents that share a name but
have distinct bundles. ``None`` disables the filter.
:param order: Sort direction, ``"desc"`` (newest-first)
or ``"asc"`` (oldest-first).
:param sort_by: Column to sort on, ``"created_at"`` or
``"updated_at"``.
:param search_query: Case-insensitive substring filter on
the session title or conversation content. ``None``
or empty string disables the filter. A session
matches if its title contains the query or any of
its conversation items' text does. Powers the
sidebar's session search.
:param include_archived: When ``False`` (default), archived
sessions are omitted. When ``True``, archived sessions
are returned alongside active ones (the sidebar groups
them into an "Archived" section). Powers the sidebar's
"Show archived" toggle.
:param kind: Conversation kind to return. ``"default"``
(the default) returns only top-level user-initiated
sessions — the sidebar's view. ``"sub_agent"`` returns
only sub-agent child sessions. ``"any"`` returns both;
this lets the new-session agent picker discover agents
that are only bound to sub-agent sessions (e.g. ones
uploaded via ``sys_session_create``).
:returns: A :class:`PaginatedList` of
:class:`SessionListItem`.
"""
# Empty-string normalization — the UI sends
# ``?search_query=`` when the search box is cleared and
# that should behave identically to the param being
# absent. Keeping the store's contract crisp: ``None``
# means "no filter", anything else means "search".
#
# require_user, not get_user_id: ``accessible_by=None`` below
# means "no ACL filter", so an unauthenticated request slipping
# through as None would list EVERY user's sessions. Fail closed
# with 401 instead (user_id stays None only when auth is
# disabled entirely — no auth_provider).
user_id = _require_user(request, auth_provider)
normalized_query = search_query if search_query else None
# A specific project folder ("My sessions"-only) must show only the
# viewer's own sessions — a session shared with them but filed under a
# like-named project belongs on "Shared with me", not in this folder.
# The flat list (project=None) and Unfiled (project="") stay unscoped so
# shared sessions still surface for the "Shared with me" tab.
owned_by = user_id if project else None
page = await asyncio.to_thread(
conversation_store.list_conversations,
limit=limit,
after=after,
before=before,
agent_id=agent_id,
agent_name=agent_name,
accessible_by=user_id,
owned_by=owned_by,
has_agent_id=True,
# The store treats ``None`` as "no kind filter"; the API
# spells that ``kind=any`` to keep the param required-ish
# and pattern-validated.
kind=None if kind == "any" else kind,
order=order,
sort_by=sort_by,
search_query=normalized_query,
include_archived=include_archived,
project=project,
)
# list_conversations may return rows with agent_id=None for
# legacy conversations; skip them before building the batch IDs.
conv_ids = [conv.id for conv in page.data if conv.agent_id is not None]
if not conv_ids:
return PaginatedList(
data=[],
first_id=page.first_id,
last_id=page.last_id,
has_more=page.has_more,
)
# Batch-fetch permissions and agent names concurrently.
# The tasks table has been removed — status comes exclusively from
# the relay-fed ``_session_status_cache``.
unique_agent_ids = list({c.agent_id for c in page.data if c.agent_id is not None})
if permission_store is not None:
perms_by_conv, agent_names_by_id, child_ids_by_parent = await asyncio.gather(
asyncio.to_thread(permission_store.list_for_sessions, conv_ids),
asyncio.to_thread(agent_store.get_names, unique_agent_ids),
asyncio.to_thread(
conversation_store.list_child_conversation_ids_by_parent,
conv_ids,
),
)
user_is_admin = (
await asyncio.to_thread(permission_store.is_admin, user_id)
if user_id is not None
else False
)
else:
agent_names_by_id, child_ids_by_parent = await asyncio.gather(
asyncio.to_thread(agent_store.get_names, unique_agent_ids),
asyncio.to_thread(
conversation_store.list_child_conversation_ids_by_parent,
conv_ids,
),
)
perms_by_conv: dict[str, list[SessionPermission]] = {}
user_is_admin = False
# In-memory lookup — no I/O, so batching avoids re-acquiring
# the index's lock per row but otherwise has no DB cost.
pending_counts = pending_elicitations.counts_for(conv_ids)
comments_fingerprints = await _comments_fingerprints_for(conv_ids)
items: list[SessionListItem] = [
_build_session_list_item(
conv,
agent_names_by_id=agent_names_by_id,
grants=perms_by_conv.get(conv.id, []),
user_id=user_id,
user_is_admin=user_is_admin,
permissions_enabled=permission_store is not None,
pending_count=pending_counts.get(conv.id, 0),
child_session_ids=child_ids_by_parent[conv.id],
comments_fingerprint=comments_fingerprints.get(conv.id),
)
for conv in page.data
if conv.agent_id is not None
]
# The list deliberately does NOT compute per-item liveness
# (runner_online / host_online). No list consumer reads it: the
# sidebar no longer surfaces connection state, and the only live
# consumer — the open-session view — sources liveness from the
# single-session snapshot, the WS stream, and the /health poll, not
# from list rows. Skipping it here removes the session-connectivity
# and hosts-table queries from every GET /v1/sessions.
return PaginatedList(
data=[item.model_dump(exclude_none=True) for item in items],
first_id=page.first_id,
last_id=page.last_id,
has_more=page.has_more,
)
async def _comments_fingerprints_for(
conv_ids: list[str],
) -> dict[str, CommentsFingerprint]:
"""
Batch-fetch comment change fingerprints for the given sessions.
Shared by the ``GET /v1/sessions`` page builder and
``WS /v1/sessions/updates`` so both emit the same
``comments_count`` / ``comments_updated_at`` values and the
stream's diff fires when a comment is added, edited, addressed,
or deleted.
:param conv_ids: Session ids to summarize,
e.g. ``["conv_abc123"]``.
:returns: Map from session id to its
:class:`CommentsFingerprint`; empty when no comment store
is wired. Sessions without comments are absent.
"""
if comment_store is None or not conv_ids:
return {}
return await asyncio.to_thread(comment_store.get_comments_fingerprints, conv_ids)
# ── WS /sessions/updates ────────────────────────────────────
async def _fetch_watched_items(
watched: list[str],
user_id: str | None,
) -> list[dict[str, Any]]:
"""
Build current list-item payloads for the watched ids.
Reads exactly the same sources as ``GET /v1/sessions`` (the
relay-fed status cache plus the conversation store) and enforces
per-session read access: ids the user cannot access, that don't
exist, or that aren't sessions (no ``agent_id``) are silently
omitted. This is the pull the session-updates stream diffs each
interval — it is a drop-in for the client's former list poll, not
a new event source, so it carries no new cross-replica semantics.
When ``liveness_lookup`` is wired, each payload also carries
``runner_online`` and ``host_online`` (the same values
``GET /health`` and ``GET /v1/sessions`` return), so the client
can drop its per-session ``/health`` poll for watched sessions.
:param watched: Conversation ids the client is currently
displaying, e.g. ``["conv_abc", "conv_def"]``. Already
deduplicated and length-capped by the caller.
:param user_id: The authenticated requesting user, or ``None``
when permissions are disabled, e.g. ``"alice@example.com"``.
:returns: One JSON-ready dict per accessible, existing watched
session, in no particular order.
"""
if not watched:
return []
if permission_store is not None:
perms_by_conv = await asyncio.to_thread(permission_store.list_for_sessions, watched)
user_is_admin = (
await asyncio.to_thread(permission_store.is_admin, user_id)
if user_id is not None
else False
)
accessible = [
cid
for cid in watched
if _permission_level_from_grants(
user_id, perms_by_conv.get(cid, []), user_is_admin
)
is not None
]
else:
perms_by_conv = {}
user_is_admin = False
accessible = list(watched)
if not accessible:
return []
def _load_sessions(ids: list[str]) -> list[Conversation]:
"""Bulk-load the accessible conversations that are sessions
(non-null ``agent_id``) in one batched store call, preserving
the caller's id order for deterministic output."""
by_id = conversation_store.get_conversations(ids)
return [
conv
for cid in ids
if (conv := by_id.get(cid)) is not None and conv.agent_id is not None
]
convs = await asyncio.to_thread(_load_sessions, accessible)
if not convs:
return []
unique_agent_ids = list({c.agent_id for c in convs if c.agent_id is not None})
conv_ids = [c.id for c in convs]
agent_names_by_id, child_ids_by_parent, comments_fingerprints = await asyncio.gather(
asyncio.to_thread(agent_store.get_names, unique_agent_ids),
asyncio.to_thread(
conversation_store.list_child_conversation_ids_by_parent,
conv_ids,
),
_comments_fingerprints_for(conv_ids),
)
pending_counts = pending_elicitations.counts_for(conv_ids)
items = [
_build_session_list_item(
conv,
agent_names_by_id=agent_names_by_id,
grants=perms_by_conv.get(conv.id, []),
user_id=user_id,
user_is_admin=user_is_admin,
permissions_enabled=permission_store is not None,
pending_count=pending_counts.get(conv.id, 0),
child_session_ids=child_ids_by_parent[conv.id],
comments_fingerprint=comments_fingerprints.get(conv.id),
)
for conv in convs
]
await _apply_liveness_to_items(items, liveness_lookup)
# Full-row dumps (every field, nulls included) — NOT exclude_none. The
# stream is a diff source: the client overlays these onto its cached
# rows, so a field that cleared to null must arrive as an explicit null
# (an absent key would leave the stale value in the cache). The client
# converts null → undefined on apply, so a cleared field lands in the
# same shape GET /v1/sessions produces (absent), and the
# ``permission_level === null`` full-access sentinel in the web sidebar
# is never tripped by a streamed null. The GET list endpoint keeps
# exclude_none — it replaces whole pages, so it has nothing to clear.
#
# search_snippet is excluded: it is search-only (populated just by
# GET /v1/sessions?search_query=), so this no-query path always has it
# None. Dumping it as an explicit null would overwrite a snippet the
# search response put in the client cache, making the palette's match
# preview flicker away on the next stream tick. Omitting the key leaves
# the cached snippet untouched.
return [item.model_dump(exclude={"search_snippet"}) for item in items]
@router.websocket("/sessions/updates")
async def session_updates(websocket: WebSocket) -> None:
"""
Push session-list changes for a client-supplied watch-set.
Replaces the web app's 4 s HTTP poll of ``GET /v1/sessions``
with one persistent connection. Protocol (JSON text frames):
- **client → server**:
``{"type": "watch", "session_ids": [...]}`` — the ids the
client is currently displaying. Sent on connect and re-sent
whenever the visible set changes (scroll / filter /
pagination); it fully replaces the prior watch-set. Unknown
message shapes are ignored for forward compatibility.
- **server → client**:
``{"type": "snapshot", "items": [SessionListItem, ...]}`` once
per ``watch`` (full state for the new set), then
``{"type": "changed", "items": [...]}`` /
``{"type": "removed", "ids": [...]}`` deltas as watched
sessions change, and ``{"type": "heartbeat"}`` when idle.
Watched-row freshness is pull-based — each interval the server
re-reads the watched ids (the same read ``GET /v1/sessions`` does)
and emits only what changed. *Discovery* of sessions the client
isn't watching yet (created / forked / shared elsewhere) is instead
push-based: a ``session_added`` event on this user's
:mod:`user_session_stream` channel makes the server push the new
session as a ``changed`` frame, which the client reconciles into the
sidebar. Together these mean an idle list makes zero HTTP polls yet a
new session still appears within a tick of being created.
:param websocket: The incoming FastAPI :class:`WebSocket`.
"""
user_id = auth_provider.get_user_id(websocket) if auth_provider is not None else None
# When permissions are enabled, an unauthenticated socket can see
# nothing useful and must not be allowed to probe ids; reject the
# handshake (mirrors the terminal-attach authorization gate).
if permission_store is not None and user_id is None:
raise WebSocketException(
code=status.WS_1008_POLICY_VIOLATION,
reason="authentication required",
)
await websocket.accept()
watched: list[str] = []
# Last SessionListItem dump sent per id, used to diff. Keyed only
# by currently-watched ids; pruned when the watch-set narrows.
last_sent: dict[str, dict[str, Any]] = {}
last_send_monotonic = time.monotonic()
# Serializes the read-diff-send-update critical section between the
# reader (snapshot on watch) and the ticker (interval deltas) so
# they never interleave updates to ``last_sent``.
emit_lock = asyncio.Lock()
async def _send(frame: dict[str, Any]) -> None:
"""
Serialize and send one frame, stamping the last-send time so
the heartbeat timer measures idleness from the last real send.
:param frame: The outgoing frame, e.g.
``{"type": "changed", "items": [...]}``. Sent as JSON text.
"""
nonlocal last_send_monotonic
# Stamp the active trace context into the frame so a client
# with browser-side propagation can correlate sidebar updates
# to the trace that produced them. No-op when no span is
# active (idle heartbeats/snapshots), keeping the frame
# wire-identical in the common case.
from omnigent.runtime import telemetry
telemetry.record_message_payload(frame)
telemetry.inject_trace_context(frame)
await websocket.send_text(json.dumps(frame))
last_send_monotonic = time.monotonic()
async def _emit_snapshot() -> None:
"""Send a full snapshot for the current watch-set and reset the
diff baseline to it."""
items = await _fetch_watched_items(watched, user_id)
dumps = {item["id"]: item for item in items}
last_sent.clear()
last_sent.update(dumps)
await _send({"type": "snapshot", "items": list(dumps.values())})
async def _emit_deltas() -> None:
"""Diff the watched ids against the last frame and send only the
changes; emit a heartbeat when nothing changed but the link has
been idle."""
nonlocal last_send_monotonic
if watched:
items = await _fetch_watched_items(watched, user_id)
current = {item["id"]: item for item in items}
changed = [dump for cid, dump in current.items() if last_sent.get(cid) != dump]
# Removed = a still-watched id that no longer resolves (lost
# access or deleted). De-watched ids are pruned silently
# below, not reported as removed.
removed = [cid for cid in watched if cid not in current and cid in last_sent]
last_sent.clear()
last_sent.update(current)
if changed:
await _send({"type": "changed", "items": changed})
if removed:
await _send({"type": "removed", "ids": removed})
if time.monotonic() - last_send_monotonic >= _SESSION_UPDATES_HEARTBEAT_INTERVAL_S:
await _send({"type": "heartbeat"})
async def _reader() -> None:
"""Apply incoming watch-set updates and snapshot each one."""
nonlocal watched
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
if not isinstance(msg, dict) or msg.get("type") != "watch":
# Forward-compatible: ignore frames we don't understand.
continue
ids = msg.get("session_ids")
if not isinstance(ids, list):
continue
# Dedupe preserving order, keep only strings. Dedupe fully
# first, then cap — so the truncation count below is the real
# number of distinct ids dropped, not skewed by duplicates that
# happen to sit past the cap.
deduped: list[str] = []
unique: set[str] = set()
for cid in ids:
if isinstance(cid, str) and cid not in unique:
unique.add(cid)
deduped.append(cid)
if len(deduped) > _SESSION_UPDATES_MAX_WATCHED:
# Ids past the cap get no push updates and are never reported
# "removed" (they aren't watched). The client's low-rate list
# reconciliation still covers them, but log the silent drop so
# an oversized watch-set is diagnosable rather than invisible.
_logger.warning(
"session-updates watch-set truncated to %d of %d distinct ids "
"for user %r; ids beyond the cap rely on list-poll reconciliation",
_SESSION_UPDATES_MAX_WATCHED,
len(deduped),
user_id,
)
deduped = deduped[:_SESSION_UPDATES_MAX_WATCHED]
# The watched set after capping — used to prune baselines for ids
# the client no longer watches (including any just truncated).
watched_set = set(deduped)
# Handle the watch under a span parented on any trace
# context the browser stamped into the frame, so the
# snapshot read (and its DB spans) nest under the
# client-originated trace.
from omnigent.runtime import telemetry
with telemetry.consume_frame_span("session_updates.watch", msg):
async with emit_lock:
watched = deduped
# Drop baselines for ids no longer watched so they
# can't surface as spurious "removed" later.
for stale in [cid for cid in last_sent if cid not in watched_set]:
del last_sent[stale]
await _emit_snapshot()
async def _ticker() -> None:
"""Emit deltas / heartbeats on a fixed interval."""
while True:
await asyncio.sleep(_SESSION_UPDATES_RESCAN_INTERVAL_S)
async with emit_lock:
try:
await _emit_deltas()
except WebSocketDisconnect:
# The client went away mid-send — the normal terminal
# condition. Propagate so the stream tears down and the
# reader/ticker pair is cancelled.
raise
except Exception: # noqa: BLE001 — a transient tick failure must not tear down a live stream
# A transient store/DB read failure must not kill a live
# stream and force every watcher to reconnect +
# re-snapshot. Log it and try again next interval; the
# diff is recomputed from scratch each tick, so a skipped
# tick costs at most one delayed delta. (CancelledError
# is not an Exception subclass, so cancellation still
# propagates.)
_logger.warning(
"session-updates delta tick failed; retrying next interval",
exc_info=True,
)
async def _discovery() -> None:
"""Push sessions newly made accessible to this user — created,
forked, or shared from elsewhere — so they enter the sidebar
without a list poll.
Such ids are NOT in the client's watch-set (the client doesn't
know about them yet), so the per-interval diff can't surface them.
This reacts to the create/grant event instead: it fetches the one
announced id (access-checked, same as the watch path) and pushes
it. The client reconciles the unknown id into its cache, then
re-sends its watch-set including it, after which it is tracked
like any normal watched row. Idle users with no new sessions
receive nothing — so the zero-traffic property holds."""
async for evt in user_session_stream.subscribe(_discovery_key(user_id)):
if not isinstance(evt, dict) or evt.get("type") != "session_added":
continue
sid = evt.get("session_id")
if not isinstance(sid, str):
continue
async with emit_lock:
# Already watched ⇒ the normal diff already covers it.
if sid in watched:
continue
try:
items = await _fetch_watched_items([sid], user_id)
if items:
await _send({"type": "changed", "items": items})
except WebSocketDisconnect:
# Client gone mid-send — propagate to tear the stream down.
raise
except Exception: # noqa: BLE001 — a failed discovery push must not kill a live stream
# A transient read/send failure for one announcement
# must not drop the whole stream; the session is still
# discoverable on the client's next list reconcile.
_logger.warning(
"session-updates discovery push failed for %r; "
"falling back to list reconcile",
sid,
exc_info=True,
)
reader_task = asyncio.create_task(_reader(), name="session-updates-reader")
ticker_task = asyncio.create_task(_ticker(), name="session-updates-ticker")
discovery_task = asyncio.create_task(_discovery(), name="session-updates-discovery")
try:
done, pending = await asyncio.wait(
{reader_task, ticker_task, discovery_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
for task in done:
exc = task.exception()
# A client disconnect is the normal terminal condition; any
# other exception is a real bug worth surfacing in logs.
if exc is not None and not isinstance(exc, WebSocketDisconnect):
_logger.warning("session-updates stream task crashed: %r", exc)
finally:
with contextlib.suppress(RuntimeError):
await websocket.close()
# ── Codex-native goal controls ───────────────────────────────
from omnigent.server.routes.codex.sessions import register_codex_session_routes
register_codex_session_routes(
router,
conversation_store=conversation_store,
runner_router=runner_router,
auth_provider=auth_provider,
permission_store=permission_store,
runner_exit_reports=runner_exit_reports,
)
# ── PATCH /sessions/{session_id} ────────────────────────────
@router.patch(
"/sessions/{session_id}",
response_model=None,
responses={200: {"model": SessionResponse}},
)
async def update_session(
request: Request,
session_id: str,
body: UpdateSessionRequest,
) -> SessionResponse:
"""
Update a session's mutable fields. When ``runner_id`` is
provided, this is the mutable affinity primitive for the Alpha
runner-state pivot: create-bind, resume-bind, and recover-bind
all send the currently registered runner id, and the server
atomically replaces ``conversations.runner_id`` with that
value using last-write-wins semantics. Title, labels, and
reasoning-effort updates remain supported for existing
sessions clients.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param body: The validated :class:`UpdateSessionRequest`.
:returns: The updated :class:`SessionResponse` snapshot.
:raises OmnigentError: 400 if the runner is not
registered; 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
# Archiving/unarchiving is an owner-only lifecycle action: it pairs
# with a client-driven, owner-gated stop, so an editor must not be
# able to archive a session (hiding it, and via the client stopping
# it) when they couldn't issue that stop. Every other field on this
# endpoint needs only edit. Owner implies edit, so a single check at
# the level the request actually requires gates both — no redundant
# second permission-store read for archive/unarchive.
required_level = LEVEL_OWNER if body.archived is not None else LEVEL_EDIT
await _require_access(
user_id, session_id, required_level, permission_store, conversation_store
)
if body.archived is True:
await _best_effort_stop(session_id, conversation_store, runner_router)
if body.runner_id is not None and permission_store is not None:
if not check_session_access(
user_id, session_id, LEVEL_OWNER, permission_store, conversation_store
):
raise OmnigentError(
f"Only the session owner can attach a runner to session {session_id!r}. "
f"To fork this session instead, run: omnigent run --fork {session_id}",
code=ErrorCode.FORBIDDEN,
)
if body.labels:
# Advisor-owned cost_control.* labels are written only by the
# session's bound runner; gate them on runner proof BEFORE any
# store mutation so a rejected request leaves the session untouched.
_reserved_labels = reserved_cost_control_keys(body.labels)
if _reserved_labels:
_conv_for_reserved = await asyncio.to_thread(
conversation_store.get_conversation, session_id
)
_require_cost_control_label_authority(
reserved_keys=_reserved_labels,
tunnel_token=request.headers.get(RUNNER_TUNNEL_TOKEN_HEADER),
bound_runner_id=(
_conv_for_reserved.runner_id if _conv_for_reserved is not None else None
),
allowed_tunnel_tokens=runner_tunnel_tokens,
multi_user=permission_store is not None,
)
collaboration_mode_requested = "collaboration_mode" in body.model_fields_set
requested_codex_collaboration_mode: str | None = None
conv_for_collaboration_mode: Conversation | None = None
if collaboration_mode_requested:
if body.collaboration_mode is None:
raise OmnigentError(
"collaboration_mode must be a non-empty string",
code=ErrorCode.INVALID_INPUT,
)
if body.collaboration_mode not in _CODEX_NATIVE_COLLABORATION_MODES:
raise OmnigentError(
"collaboration_mode must be one of "
f"{sorted(_CODEX_NATIVE_COLLABORATION_MODES)}",
code=ErrorCode.INVALID_INPUT,
)
conv_for_collaboration_mode = await asyncio.to_thread(
conversation_store.get_conversation,
session_id,
)
if conv_for_collaboration_mode is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
if (
conv_for_collaboration_mode.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
!= _CODEX_NATIVE_WRAPPER_LABEL_VALUE
):
raise OmnigentError(
"collaboration_mode is only supported for codex-native sessions",
code=ErrorCode.INVALID_INPUT,
)
requested_codex_collaboration_mode = body.collaboration_mode
labels_to_set = dict(body.labels or {})
if requested_codex_collaboration_mode is not None:
labels_to_set[_CODEX_NATIVE_COLLABORATION_MODE_LABEL_KEY] = (
requested_codex_collaboration_mode
)
effort = body.reasoning_effort
clear_effort = effort in EFFORT_CLEAR_VALUES
if effort is not None and not clear_effort:
try:
effort = validate_effort(
effort,
"session metadata",
EFFORT_VALUES,
)
except ValueError as exc:
raise OmnigentError(
f"invalid reasoning_effort: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
# Empty / whitespace strings are rejected loud — the only
# clear path is the explicit ``default | off | reset`` alias.
model_override = body.model_override
clear_model = (
isinstance(model_override, str)
and model_override.strip().lower() in EFFORT_CLEAR_VALUES
)
if model_override is not None and not clear_model:
# Mirror the create path: the persisted value reaches a native
# CLI as a ``--model`` argv element and the Codex provider
# ``config.toml`` as a ``model="..."`` field, so it must pass the
# conservative model-id charset before it is stored. A bare
# strip()/non-empty check here let shell-/TOML-shaped values
# through, enabling host RCE via the Codex ``auth.command``.
if not isinstance(model_override, str):
raise OmnigentError(
"invalid model_override: must be a non-empty string",
code=ErrorCode.INVALID_INPUT,
)
try:
model_override = validate_model_override(model_override)
except ValueError as exc:
raise OmnigentError(
f"invalid model_override: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
# Cost-control switch: ``"off"`` is a real stored value here,
# so the clear signal is an explicit JSON null (field present,
# value None) rather than a clear alias; an omitted field
# leaves the stored value unchanged.
clear_cost_control = (
"cost_control_mode_override" in body.model_fields_set
and body.cost_control_mode_override is None
)
cost_control_mode_override = _validated_cost_control_mode_override(
body.cost_control_mode_override
)
# Native-terminal pass-through args: ``None`` leaves them
# unchanged; a provided list (including ``[]``) replaces the
# stored value wholesale (resume is last-write-wins, never an
# append). Bounds are validated here so a malformed list fails
# loud at the route rather than at the DB.
try:
terminal_launch_args = _validate_terminal_launch_args(body.terminal_launch_args)
except ValueError as exc:
raise OmnigentError(
f"invalid terminal_launch_args: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if body.runner_id is not None:
# Empty string is the clear sentinel (None = leave unchanged);
# used by /clear and /switch to move the runner between sessions.
if body.runner_id == "":
try:
await asyncio.to_thread(conversation_store.clear_runner_id, session_id)
except ConversationNotFoundError as exc:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
) from exc
else:
runner_id = _registered_runner_id(runner_router, body.runner_id, user_id=user_id)
try:
await asyncio.to_thread(
conversation_store.replace_runner_id, session_id, runner_id
)
except ConversationNotFoundError as exc:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
) from exc
_runner_client = await _get_runner_client(
session_id,
runner_router,
)
# Notify the runner about the session so it can
# resolve the spec and cache it before the first turn.
# This is the design doc's "Server POST /v1/sessions
# (to runner)" step from §7 Flow: session creation.
conv = conversation_store.get_conversation(
session_id,
)
if _runner_client is not None and conv is not None:
try:
runner_init_resp = await _runner_client.post(
"/v1/sessions",
json={
"session_id": session_id,
"agent_id": conv.agent_id,
"sub_agent_name": conv.sub_agent_name,
},
timeout=10.0,
)
if runner_init_resp.status_code < 400:
await _publish_runner_recovered_status(session_id, conversation_store)
except (httpx.HTTPError, ConnectionError):
# ConnectionError covers a tunnel close mid-POST
# (same source as the relay's except clause).
_logger.warning(
"Failed to notify runner about session %s",
session_id,
exc_info=True,
)
if _runner_client is None:
# Runner deregistered between validation and
# lookup; PATCH still returns 200 but no
# relay starts, so log the silent-skip case.
_logger.warning(
"PATCH rebind to %s on session %s: no runner "
"client resolved; relay not restarted.",
runner_id,
session_id,
)
# Restart the relay for the new runner; replaces
# any relay still pointing at the prior runner.
await _ensure_runner_relay_ready(
session_id,
runner_id,
_runner_client,
conversation_store,
)
else:
conv = conv_for_collaboration_mode
if conv is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
if conv.agent_id is None:
raise OmnigentError(
"Not a session (no agent binding)",
code=ErrorCode.NOT_FOUND,
)
updated = await asyncio.to_thread(
conversation_store.update_conversation,
session_id,
title=body.title,
reasoning_effort=None if clear_effort else effort,
_unset_reasoning_effort=clear_effort,
model_override=None if clear_model else model_override,
_unset_model_override=clear_model,
cost_control_mode_override=None if clear_cost_control else cost_control_mode_override,
_unset_cost_control_mode_override=clear_cost_control,
terminal_launch_args=terminal_launch_args,
archived=body.archived,
)
if updated is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
# Archiving hides the session from the default view (and its unread
# dot), so drop its per-user read-state to bound in-memory growth.
# Only on archive→true; unarchiving leaves it pruned (reads as seen).
if body.archived is True:
_prune_session_read_state(session_id)
# Notify the runner of effort / model changes so harnesses
# that can't re-read these from store at turn boundaries
# (today: claude-native, whose ``claude`` binary has
# ``--effort`` / ``--model`` baked in at spawn) get a chance
# to propagate them live. Best-effort — persisted values
# remain the authoritative fallback. Skip both when
# ``silent`` so bind-time auto-apply doesn't inject visible
# ``/model X`` items into a fresh pane.
# Effort and model both go through the unified ``/events``
# dispatch — Omnigent server stays harness-agnostic; the runner
# dispatches by harness (claude-native injects the slash
# command into tmux, other harnesses 204 no-op). See
# ``_forward_session_change_to_runner`` for the shared
# runner-client fallback + non-2xx logging.
live_forward = not body.silent
if live_forward and (effort is not None or clear_effort):
await _forward_session_change_to_runner(
session_id,
runner_router,
{"type": "effort_change", "effort": updated.reasoning_effort},
)
if live_forward and (model_override is not None or clear_model):
await _forward_session_change_to_runner(
session_id,
runner_router,
{"type": "model_change", "model": updated.model_override},
)
# Append a durable [System: model changed to X] note for sessions
# whose history Omnigent writes. Gate on the wrapper label (NOT
# omnigent.ui, which chat-first SDK terminal-view sessions like
# polly/debby also carry) — see _persist_model_change_note for the
# full rationale. live_forward (== not silent) already excludes
# bind-time auto-applies, so only an explicit /model lands a note.
if not _is_native_terminal_session(updated):
await _persist_model_change_note(
session_id,
updated.model_override,
conversation_store,
)
if requested_codex_collaboration_mode is not None and live_forward:
_codex_plan_enabled = _codex_plan_mode_enabled(requested_codex_collaboration_mode)
_runner_result = await _forward_session_change_to_runner(
session_id,
runner_router,
{
"type": "plan_mode_change",
"enabled": _codex_plan_enabled,
},
)
_require_collaboration_mode_forward(
session_id,
_codex_plan_enabled,
_runner_result,
)
# The project label is special: an empty-string value means "remove
# from project" (delete the label row) rather than upsert an empty value.
# Split it out before the bulk upsert so other labels are unaffected.
if labels_to_set and labels_to_set.get(PROJECT_LABEL_KEY) == "":
labels_to_set = {k: v for k, v in labels_to_set.items() if k != PROJECT_LABEL_KEY}
await asyncio.to_thread(conversation_store.delete_label, session_id, PROJECT_LABEL_KEY)
if labels_to_set:
await asyncio.to_thread(conversation_store.set_labels, session_id, labels_to_set)
if requested_codex_collaboration_mode is not None:
_publish_collaboration_mode(
session_id,
requested_codex_collaboration_mode,
)
if body.external_session_id is not None:
try:
await asyncio.to_thread(
conversation_store.set_external_session_id,
session_id,
body.external_session_id,
)
except ConversationNotFoundError as exc:
# Race: row vanished between the update above and this
# write. Reuse the NOT_FOUND code for consistency.
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
) from exc
except ValueError as exc:
# Store raises ValueError on attempted overwrite of an
# already-set external_session_id — surface as
# invalid_input so the caller (a wrapper bridge) sees a
# 400 with the conflict explained.
raise OmnigentError(
str(exc),
code=ErrorCode.INVALID_INPUT,
) from exc
level = await _get_permission_level(user_id, session_id, permission_store)
return await _get_session_snapshot(
conversation_store,
session_id,
level,
agent_store,
agent_cache,
liveness_lookup=liveness_lookup,
runner_exit_reports=runner_exit_reports,
)
# ── POST /sessions/{source_id}/fork ─────────────────────────
@router.post(
"/sessions/{source_id}/fork",
status_code=201,
# response_model=None keeps FastAPI from re-validating/serializing
# the handler's SessionResponse; responses= still advertises the
# body schema to docs/SDK tooling.
response_model=None,
responses={201: {"model": SessionResponse}},
)
async def fork_session(
request: Request,
source_id: str,
body: SessionForkRequest,
) -> SessionResponse:
"""
Fork an existing session into a new session.
Deep-copies the source session's conversation items and
clones the agent into a new session. When ``body.agent_id``
is set, the fork binds that built-in agent instead of the
source's — switching harness (e.g. Claude-SDK → Claude Code,
or Claude → Codex). The source's model settings carry over
only within the same provider family; a same-family native
target also carries conversation history (the runner rebuilds
its transcript). The REPL/CLI binds the fork to its runner via
``PATCH /v1/sessions/{id}`` after creation.
When ``body.up_to_response_id`` is set, only history up to and
including that response is copied into the fork (a "fork from
this response"); a native target then rebuilds its transcript
from the truncated items instead of resuming the source's full
native transcript.
:param request: The incoming FastAPI request (for auth).
:param source_id: Session/conversation identifier of the
source session to fork, e.g. ``"conv_abc123"``.
:param body: The validated :class:`SessionForkRequest`.
:returns: A :class:`SessionResponse` describing the newly
created fork (status ``"idle"``).
:raises OmnigentError: 404 if *source_id* does not exist
or ``body.agent_id`` is not a bindable built-in agent;
403 if the caller lacks read access; 400 if the source
is a sub-agent session, has no agent binding, or
``body.up_to_response_id`` names no response in the
source session.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, source_id, LEVEL_READ, permission_store, conversation_store
)
source = access.conversation
if source is None:
source = await asyncio.to_thread(conversation_store.get_conversation, source_id)
if source is None:
raise OmnigentError(
f"Session not found: {source_id!r}",
code=ErrorCode.NOT_FOUND,
)
if source.kind == "sub_agent":
raise OmnigentError(
"Cannot fork a sub-agent session — only top-level sessions can be forked.",
code=ErrorCode.INVALID_INPUT,
)
if source.agent_id is None:
raise OmnigentError(
"Source session has no agent binding — cannot fork.",
code=ErrorCode.INVALID_INPUT,
)
source_agent = await asyncio.to_thread(agent_store.get, source.agent_id)
if source_agent is None:
raise OmnigentError(
f"Source agent not found: {source.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
# By default the fork clones the source's agent (same harness). When
# ``body.agent_id`` names a different agent, the fork SWITCHES to it
# — e.g. fork a Claude-SDK session into Claude Code. Only built-in
# agents (``session_id IS NULL``) are bindable: a session-scoped
# agent belongs to one conversation (possibly another user's) and
# must never be cloned across sessions.
base_agent = source_agent
switching_agent = body.agent_id is not None and body.agent_id != source.agent_id
if switching_agent:
target_agent = await asyncio.to_thread(agent_store.get, body.agent_id)
if target_agent is None or target_agent.session_id is not None:
raise OmnigentError(
f"Agent not found or not bindable: {body.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
base_agent = target_agent
# Clone params for the fork's session-scoped agent. Created inside
# fork_conversation's transaction (not agent_store.create): a
# pre-created row would survive a fork failure as an orphaned
# session_id=NULL built-in polluting the picker. Session-scoped rows
# are exempt from the unique built-in-name index, so the clone reuses
# the source's name verbatim — no "(fork …)" suffix needed.
cloned_agent_id = generate_agent_id()
cloned_agent_name = base_agent.name
# A model id is provider-bound, so the source's model_override /
# reasoning_effort only carry over when the switch stays in the same
# provider family. A cross-family switch (or an undeterminable
# family) resets them; same-agent forks always copy.
copy_model_settings = True
if switching_agent:
copy_model_settings = await asyncio.to_thread(
_same_provider_family, source_agent, base_agent
)
# When the fork binds a NATIVE target, the native CLI won't replay
# the copied Omnigent transcript on its own — mark the fork so the
# runner carries history into the native harness. Same-family: clone
# the source's native transcript when present, else rebuild from the
# copied Omnigent items. Cross-family: the source's native transcript
# is the wrong format, so ALWAYS rebuild from the copied Omnigent
# items (the converters consume Omnigent's normalized item shape, so
# the source harness doesn't matter). SDK targets replay the
# transcript as context regardless, so the marker is inert for them.
# claude/codex/pi native rebuild the transcript (each rebuilds its
# resumable session file from the copied items, so all three sit in
# _FORK_HISTORY_NATIVE_HARNESSES); cursor native instead replays prior
# turns as a text preamble (its conversation is server-backed, so a
# local store can't be seeded — fork-only, see
# _agent_carries_cursor_fork_history). The single FORK_CARRY_HISTORY
# label drives both; the runner branches on harness.
target_is_cursor = await asyncio.to_thread(_agent_carries_cursor_fork_history, base_agent)
carry_history_into_native = target_is_cursor or await asyncio.to_thread(
_agent_carries_native_fork_history, base_agent
)
# The source's native session id is only resumable by a target in the
# SAME provider family — a Claude target can't clone a Codex rollout.
# Cross-family, the store must skip the fork-source directive so the
# runner takes the rebuild path instead of a doomed clone attempt
# (a failed clone launches fresh, losing history). cursor never clones a
# native session (server-backed; it carries history via the preamble),
# so it always skips the source directive too.
resume_source_native_session = (
not switching_agent or copy_model_settings
) and not target_is_cursor
# On an agent switch, recompute the Web UI presentation labels for
# the TARGET harness so the clone isn't left in the source's UI mode
# (e.g. a claude-native source's terminal-first labels would put an
# SDK clone in terminal mode with a stale interactive terminal).
# A same-agent fork leaves the copied labels untouched (None).
presentation_labels = (
await asyncio.to_thread(_presentation_labels_for_agent, base_agent)
if switching_agent
else None
)
try:
new_conv = await asyncio.to_thread(
conversation_store.fork_conversation,
source_id,
title=body.title,
agent_id=cloned_agent_id,
cloned_agent_name=cloned_agent_name,
cloned_agent_bundle_location=base_agent.bundle_location,
cloned_agent_description=base_agent.description,
copy_model_settings=copy_model_settings,
carry_history_into_native=carry_history_into_native,
resume_source_native_session=resume_source_native_session,
presentation_labels=presentation_labels,
up_to_response_id=body.up_to_response_id,
)
except LookupError as exc:
raise OmnigentError(
f"Session not found: {source_id!r}",
code=ErrorCode.NOT_FOUND,
) from exc
except ValueError as exc:
# Store raises ValueError when up_to_response_id names no
# response in the source conversation (stale client state).
raise OmnigentError(
str(exc),
code=ErrorCode.INVALID_INPUT,
) from exc
if permission_store is not None and user_id is not None:
await asyncio.to_thread(permission_store.ensure_user, user_id)
await asyncio.to_thread(permission_store.grant, user_id, new_conv.id, LEVEL_OWNER)
# Push the forked session to this user's other open tabs.
_announce_session_added(user_id, new_conv.id)
fork_items = await asyncio.to_thread(
conversation_store.list_items, new_conv.id, limit=10000
)
level = await _get_permission_level(user_id, new_conv.id, permission_store)
return _build_session_response(
new_conv,
fork_items.data,
"idle",
permission_level=level,
last_task_error=None,
agent_name=base_agent.name,
)
# ── POST /sessions/{session_id}/switch-agent ─────────────────
@router.post(
"/sessions/{session_id}/switch-agent",
# response_model=None keeps FastAPI from re-validating/serializing
# the handler's SessionResponse; responses= still advertises the
# body schema to docs/SDK tooling.
response_model=None,
responses={200: {"model": SessionResponse}},
)
async def switch_session_agent(
request: Request,
session_id: str,
body: SessionSwitchAgentRequest,
background_tasks: BackgroundTasks,
) -> SessionResponse:
"""
Switch an existing session in place to a different agent/harness.
Unlike fork, this keeps the SAME session — transcript, comments,
files, host, and workspace are untouched; only the agent/harness
changes. The current session-scoped agent is replaced by a clone
of the target built-in, model settings carry over only within the
same provider family (a model id is provider-bound), the native
runtime session id is cleared, and the harness-presentation labels
are recomputed for the target. The next turn cold-starts the new
harness (rebuilding the native transcript from this session's own
items for a same-family native target). Only built-in agents are
bindable, and only while the session is idle.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier to switch,
e.g. ``"conv_abc123"``.
:param body: The validated :class:`SessionSwitchAgentRequest`.
:returns: A :class:`SessionResponse` describing the session after
the switch (status ``"idle"``).
:raises OmnigentError: 404 if the session or target agent does
not exist or the target is not a bindable built-in; 403 if the
caller lacks edit access; 400 if the session is a sub-agent,
has no agent binding, or the target bundle can't be loaded;
409 if a turn is currently running.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
session = access.conversation
if session is None:
session = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if session is None:
raise OmnigentError(
f"Session not found: {session_id!r}",
code=ErrorCode.NOT_FOUND,
)
if session.kind == "sub_agent":
raise OmnigentError(
"Cannot switch the agent of a sub-agent session — only top-level "
"sessions can switch agent.",
code=ErrorCode.INVALID_INPUT,
)
if session.agent_id is None:
raise OmnigentError(
"Session has no agent binding — cannot switch agent.",
code=ErrorCode.INVALID_INPUT,
)
# Switching mid-turn would tear the running harness subprocess out
# from under an active stream. Reject; the caller retries when idle.
if _session_status_from_cache(session_id) == "running":
raise OmnigentError(
"Session is busy — wait for the current turn to finish before switching agent.",
code=ErrorCode.CONFLICT,
)
current_agent = await asyncio.to_thread(agent_store.get, session.agent_id)
if current_agent is None:
raise OmnigentError(
f"Current agent not found: {session.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
# Only built-in agents (``session_id IS NULL``) are bindable: a
# session-scoped agent belongs to one conversation (possibly another
# user's) and must never be cloned across sessions.
target_agent = await asyncio.to_thread(agent_store.get, body.agent_id)
if target_agent is None or target_agent.session_id is not None:
raise OmnigentError(
f"Agent not found or not bindable: {body.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
# Reject a no-op switch to the built-in the session is already running:
# its session-scoped clone shares the built-in's ``bundle_location``, so
# switching would delete + re-clone the same agent and tear the terminal
# down for nothing. The contract is that the target differs from the
# current agent; the picker already hides the current one, so this only
# guards a direct API call.
if target_agent.bundle_location == current_agent.bundle_location:
raise OmnigentError(
"Session is already running this agent — pick a different one.",
code=ErrorCode.INVALID_INPUT,
)
# Load the target bundle BEFORE committing so an unloadable spec fails
# the request with zero mutation — the irreversible part of the switch
# (deleting the old agent) must not run for a target that can't start.
try:
await asyncio.to_thread(
get_agent_cache().load, target_agent.id, target_agent.bundle_location
)
except Exception as exc:
# Surface any bundle-load failure as a 400 before mutating state.
raise OmnigentError(
f"Target agent bundle could not be loaded: {body.agent_id!r}",
code=ErrorCode.INVALID_INPUT,
) from exc
# A model id is provider-bound, so model_override / reasoning_effort
# carry over only within the same provider family. A native target
# carries history regardless of family: the switch clears
# external_session_id and drops the fork-source directive, so the
# runner rebuilds the native transcript from this session's own
# Omnigent items (a format-agnostic conversion). SDK targets replay
# the AP transcript as context regardless.
copy_model_settings = await asyncio.to_thread(
_same_provider_family, current_agent, target_agent
)
# claude/codex/pi native can replay fork history (each rebuilds its
# resumable session file from the copied items); cursor-native can't
# (no resumable session file), so don't stamp a carry-history promise
# it would silently break with a fresh launch.
carry_history_into_native = await asyncio.to_thread(
_agent_carries_native_fork_history, target_agent
)
presentation_labels = await asyncio.to_thread(_presentation_labels_for_agent, target_agent)
# Resolve the built-in the session is leaving so the UI can offer a
# one-click "Switch back". The current agent is a session-scoped clone
# whose bundle_location was copied verbatim from its source built-in,
# so match on that. Page through the full template-agent list (not a
# single bounded scan) so the match isn't missed when there are many
# built-ins. Best-effort: None when no built-in matches (e.g. its
# source built-in was removed) → no switch-back offered.
previous_builtin_id: str | None = None
_after: str | None = None
while True:
_page = await asyncio.to_thread(agent_store.list, 100, _after)
previous_builtin_id = next(
(a.id for a in _page.data if a.bundle_location == current_agent.bundle_location),
None,
)
if previous_builtin_id is not None or not _page.has_more or not _page.data:
break
_after = _page.last_id
cloned_agent_id = generate_agent_id()
cloned_agent_name = f"{target_agent.name} (switch {cloned_agent_id[:10]})"
try:
updated = await asyncio.to_thread(
conversation_store.switch_conversation_agent,
session_id,
new_agent_id=cloned_agent_id,
new_agent_name=cloned_agent_name,
new_agent_bundle_location=target_agent.bundle_location,
new_agent_description=target_agent.description,
copy_model_settings=copy_model_settings,
carry_history_into_native=carry_history_into_native,
presentation_labels=presentation_labels,
previous_builtin_id=previous_builtin_id,
)
except LookupError as exc:
raise OmnigentError(
f"Session not found: {session_id!r}",
code=ErrorCode.NOT_FOUND,
) from exc
# Tell every connected client the binding changed so they re-derive
# session state (presentation labels, bound agent) from a fresh
# snapshot. Without this, a client that bound before the switch keeps
# treating the session as the OLD harness — e.g. its status handler
# clears the optimistic first-message bubble that a native target
# only reconciles later via session.input.consumed.
switch_event = SessionAgentChangedEvent(
type="session.agent_changed",
conversation_id=session_id,
agent_id=cloned_agent_id,
# Clean target name, not the clone row's "<name> (switch ag_…)":
# the suffix only disambiguates agent rows; clients render
# agent_name verbatim (same choice as the session snapshot).
agent_name=target_agent.name,
)
session_stream.publish(session_id, switch_event.model_dump())
# Reset the OLD harness's runner-side resources (async, after the
# response): close the cached primary OSEnv so the new agent's
# os_env/sandbox governs the web filesystem/shell endpoints, and tear
# down the native terminal so it can't shadow the switch-back transcript
# rebuild. Safe because the switch only runs while the session is idle
# (doing it mid-turn would wedge the turn); the next access
# re-materializes from the new agent's spec, preserving the workspace /
# worktree (cwd comes from the runner workspace).
background_tasks.add_task(_reset_runner_resources_after_switch, session_id)
items = await asyncio.to_thread(conversation_store.list_items, session_id, limit=10000)
level = await _get_permission_level(user_id, session_id, permission_store)
return _build_session_response(
updated,
items.data,
"idle",
permission_level=level,
last_task_error=None,
agent_name=target_agent.name,
)
# ── POST /sessions/{session_id}/hooks/permission-request ─────
@router.post(
"/sessions/{session_id}/hooks/permission-request",
# Internal harness callback webhook — hidden from the public API reference.
include_in_schema=False,
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
dependencies=[Depends(require_json_content_type)],
)
async def claude_permission_request_hook(
request: Request,
session_id: str,
) -> Response:
"""
Claude Code ``PermissionRequest`` HTTP hook endpoint.
Receives Claude Code's PermissionRequest hook payload (tool
name + input the user would otherwise see a TUI prompt for),
publishes a ``response.elicitation_request`` SSE event on the
session stream so the web UI's :file:`ApprovalCard` renders
inline, and long-polls until the verdict arrives via the
session ``approval`` event path.
Response shape follows Claude Code's PermissionRequest hook
contract: ``hookSpecificOutput.decision.behavior`` is
``"allow"`` or ``"deny"``. On timeout the endpoint returns
``200`` with an empty body — Claude Code treats that as
"defer to the TUI prompt", which matches the wrapper's
fail-ask contract (UI unreachable / unattended → fall back
to terminal-side approval).
Auth: standard session ACL — the wrapper's outbound headers
(``ap_auth_headers`` in :func:`build_hook_settings`) carry
the same Bearer token used for every other Omnigent request. For
local-server mode (no auth provider), unauth'd calls are
allowed.
:param request: FastAPI request — body is Claude Code's
PermissionRequest payload as JSON.
:param session_id: Omnigent conversation id from the URL path.
:returns: Claude PermissionRequest hookSpecificOutput JSON,
or ``200`` with empty body on timeout (fail-ask).
:raises OmnigentError: 404 if the session doesn't exist,
400 if the body fails JSON parse or is missing
``tool_name``.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise OmnigentError(
f"Invalid JSON in PermissionRequest hook body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(payload, dict):
raise OmnigentError(
"PermissionRequest hook body must be a JSON object.",
code=ErrorCode.INVALID_INPUT,
)
tool_name = payload.get("tool_name")
if not isinstance(tool_name, str) or not tool_name:
raise OmnigentError(
"PermissionRequest hook body must include a non-empty 'tool_name' string.",
code=ErrorCode.INVALID_INPUT,
)
tool_input = payload.get("tool_input")
if tool_input is not None and not isinstance(tool_input, dict):
raise OmnigentError(
"PermissionRequest hook body 'tool_input' must be an object when present.",
code=ErrorCode.INVALID_INPUT,
)
# Claude Code's PermissionRequest payload carries no
# ``tool_use_id`` (verified against a real payload — the field
# is absent, not merely unstable; the id is only minted when the
# tool call is emitted, AFTER this permission check). And newer
# builds can write the transcript ``function_call`` (tool_use)
# before this hook returns — so neither can correlate/resolve the
# parked request. The parked wait ends on one of three signals: an
# explicit web verdict, hook disconnect, or the mirrored
# ``function_call_output`` (tool_result) for this gated tool,
# which — unlike the tool_use — is written only AFTER the
# prompt was answered in the TUI. We pass ``tool_name`` /
# ``tool_input`` below so that result can be correlated back to
# THIS prompt (see _signal_terminal_resolved_harness_elicitation).
cwd = payload.get("cwd")
if cwd is not None and not isinstance(cwd, str):
cwd = None
permission_mode = payload.get("permission_mode")
if permission_mode is not None and not isinstance(permission_mode, str):
permission_mode = None
elicitation_id = _client_supplied_hook_elicitation_id(payload, session_id)
try:
preview_str = json.dumps(tool_input or {}, ensure_ascii=False)
except (TypeError, ValueError):
preview_str = repr(tool_input)
preview_str = preview_str[:1024]
# ``extra="allow"`` on ElicitationRequestParams permits
# extra keyword arguments to ride alongside the MCP
# standard fields. Use it for Claude-native display and
# correlation hints rather than minting AP-specific fields
# on the model; strict MCP clients can ignore unknown fields
# while AP's UI consumes them.
# ``tool_name`` rides along so the UI can render the
# permission card with the gated tool name and distinguish
# simultaneous prompts from different tools.
extras: dict[str, Any] = {"tool_name": tool_name}
if cwd is not None:
extras["cwd"] = cwd
if permission_mode is not None:
extras["permission_mode"] = permission_mode
# The card offers ONE persistent-approval affordance, picked by
# the gated tool — the two hints below are mutually exclusive
# (disjoint eligibility), never two buttons competing on one card.
#
# Edit tools → "Accept & allow all edits" (switches the session to
# acceptEdits via setMode). Stamped only for edit-tool prompts
# under a still-prompting mode — see _allow_all_edits_eligible.
# The verdict site re-checks the same predicate before honoring it.
if _allow_all_edits_eligible(tool_name, permission_mode):
extras["allow_all_edits"] = True
# Non-edit eligible tools → "don't ask again" (installs a
# session-scoped allow rule via addRules). Stamped only when the
# affordance applies — see _allow_remember_eligible.
# ``remember_scope`` carries the gated tool and, for WebFetch, the
# request host so the UI can label the button ("… for github.com"
# vs "… for WebFetch"); the verdict site re-derives the same scope
# before honoring the flag, never trusting a client-supplied rule.
if _allow_remember_eligible(tool_name, permission_mode):
remember_scope: dict[str, Any] = {"tool": tool_name}
remember_host = _claude_native_remember_host(tool_name, tool_input)
if remember_host is not None:
remember_scope["host"] = remember_host
extras["remember_scope"] = remember_scope
# When Claude's built-in AskUserQuestion tool is the one
# needing permission, the PermissionRequest payload
# already carries the full questions + options structure
# in ``tool_input``. Surface it as a structured extra so
# the UI can render an interactive form WITHOUT having to
# parse the (truncated) ``content_preview`` JSON blob.
# ``content_preview`` keeps its 1024-char cap for the
# binary-card fallback; the structured field is the
# authoritative source the UI consumes when present.
if tool_name == "AskUserQuestion":
ask_payload = _structured_ask_user_question(tool_input)
if ask_payload is not None:
extras["ask_user_question"] = ask_payload
# When the gated tool is ExitPlanMode, ride the full
# ``tool_input`` through verbatim so the UI can render a
# dedicated plan-review card. ``content_preview`` is
# hard-capped at 1024 chars — real plans blow well past it —
# and the input's shape varies across Claude Code builds
# (``plan`` markdown, ``allowedPrompts``, ...), so no field
# filtering: every field the hook carried natively reaches
# the UI. An empty/absent input stamps nothing, leaving the
# binary-card fallback.
if tool_name == "ExitPlanMode" and isinstance(tool_input, dict) and tool_input:
extras["exit_plan_mode"] = tool_input
params = ElicitationRequestParams(
mode="form",
message=f"Claude wants to call **{tool_name}**",
requestedSchema=None,
url=None,
phase="pre_tool_use",
policy_name="claude_native_permission",
content_preview=f"{tool_name}({preview_str})",
**extras,
)
result = await _publish_and_wait_for_harness_elicitation(
request,
session_id=session_id,
params=params,
timeout_s=_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S,
conversation_store=conversation_store,
# Client-minted stable id so a retry re-parks the same elicitation.
elicitation_id=elicitation_id,
# Tool identity lets a mirrored tool result for this gated
# tool resolve the prompt promptly when the user answers in
# Claude's TUI instead of the web UI (terminal-resolved
# fast path). ``tool_input`` is the dict from the payload
# (or None when absent).
tool_name=tool_name,
tool_input=tool_input if isinstance(tool_input, dict) else None,
)
if result is None:
# Disconnect or timeout. Either way Claude is no
# longer waiting on this response; empty 2xx → Claude
# defers to its built-in TUI prompt (fail-ask).
return Response(status_code=status.HTTP_200_OK)
behavior = "allow" if result.action == "accept" else "deny"
decision: dict[str, Any] = {"behavior": behavior}
# A decline can carry feedback typed into the web card (the
# ExitPlanMode "Reject with feedback" flow). Claude's
# PermissionRequest decision contract surfaces it via
# ``decision.message`` — the model sees it as the denial
# reason, so for a rejected plan Claude stays in plan mode
# and revises toward the feedback instead of guessing why
# the plan was refused.
if behavior == "deny" and isinstance(result.content, dict):
feedback = result.content.get("feedback")
if isinstance(feedback, str) and feedback.strip():
decision["message"] = feedback
# When the gated tool is AskUserQuestion AND the user accepted
# with selections, propagate those selections back to Claude
# via ``decision.updatedInput``. Claude reads
# ``tool_input.answers`` and skips its TUI picker, returning
# the supplied selections as the tool result the LLM sees.
#
# ``result.content`` is MCP-shaped (a flat ``{[field]: value}``
# map) — exactly the shape ``tool_input.answers`` expects on
# AskUserQuestion. Single-select values are strings,
# multi-select are ``list[str]``; both ride through verbatim.
if (
behavior == "allow"
and tool_name == "AskUserQuestion"
and isinstance(tool_input, dict)
and isinstance(result.content, dict)
and result.content
):
decision["updatedInput"] = {**tool_input, "answers": result.content}
# "Accept & allow all edits" — the user approved this edit AND
# asked to auto-accept future edits. Echo a ``setMode`` permission
# update so Claude Code switches this session into ``acceptEdits``
# mode, exactly as the native shift+tab toggle does. The
# ``updatedPermissions`` shape matches the Agent SDK's
# ``PermissionUpdate`` union (``{type, mode, destination}`` for
# ``setMode``); ``destination: "session"`` scopes it to this
# session, so it resets on the next one.
#
# Re-check eligibility server-side rather than trusting the
# client's ``content.allow_all_edits`` flag alone: the flag is
# only meaningful for the edit-tool / prompting-mode prompts the
# affordance was offered for. Without this, a client could send
# the flag on e.g. a Bash prompt and flip the session into
# ``acceptEdits`` — a mode switch it was never offered.
if (
behavior == "allow"
and isinstance(result.content, dict)
and result.content.get("allow_all_edits") is True
and _allow_all_edits_eligible(tool_name, permission_mode)
):
decision["updatedPermissions"] = [
{
"type": "setMode",
# The plan card's "Yes, and use auto mode" switches the
# session into Claude's ``auto`` mode; the edit-tool
# "Accept & allow all edits" keeps the narrower
# ``acceptEdits`` (auto-approve edits only).
"mode": "auto" if tool_name == "ExitPlanMode" else "acceptEdits",
"destination": "session",
}
]
elif behavior == "allow" and tool_name == "ExitPlanMode":
# Plan approved WITHOUT auto mode — the card's "Yes,
# manually approve edits". Pin the session to the prompting
# ``default`` mode instead of trusting whatever mode
# Claude's plan-exit restores, so every subsequent edit
# prompts exactly as the button promised. De-escalation
# only (most restrictive prompting mode), so no eligibility
# gate is needed.
decision["updatedPermissions"] = [
{"type": "setMode", "mode": "default", "destination": "session"}
]
# "Approve & don't ask again" — the user approved this non-edit
# tool AND asked to stop prompting for the same scope. Echo an
# ``addRules`` permission update so Claude Code installs a
# session-scoped allow rule, exactly as the native TUI's "don't
# ask again" option does. The shape matches the Agent SDK's
# ``PermissionUpdate`` union (``addRules``): ``rules`` is a list
# of ``{toolName, ruleContent?}`` — ``ruleContent`` omitted means
# the whole tool; ``destination: "session"`` scopes it to this
# session so it resets on the next one. The claude-native hook
# forwards this decision verbatim to Claude Code.
#
# The host is re-derived server-side from the gated tool's input
# rather than trusting any client-supplied rule, and gated by the
# same ``_allow_remember_eligible`` predicate the button was
# offered under — so a forged ``remember`` flag on an ineligible
# tool (e.g. an edit tool, which takes the setMode path) can't
# smuggle in an allow rule. Mutually exclusive with the edit-tool
# ``allow_all_edits``/ExitPlanMode branches above (disjoint tool
# sets), so it never overwrites their ``updatedPermissions``.
if (
behavior == "allow"
and isinstance(result.content, dict)
and result.content.get("remember") is True
and _allow_remember_eligible(tool_name, permission_mode)
):
rule: dict[str, Any] = {"toolName": tool_name}
remember_host = _claude_native_remember_host(tool_name, tool_input)
if remember_host is not None:
rule["ruleContent"] = f"domain:{remember_host}"
decision["updatedPermissions"] = [
{
"type": "addRules",
"rules": [rule],
"behavior": "allow",
"destination": "session",
}
]
body = {
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": decision,
},
}
return Response(
content=json.dumps(body),
media_type="application/json",
)
# ── Proto event-type → internal Phase mapping ────────────────────
_PROTO_EVENT_TYPE_TO_PHASE: dict[str, Phase] = {
"PHASE_TOOL_CALL": Phase.TOOL_CALL,
"PHASE_TOOL_RESULT": Phase.TOOL_RESULT,
"PHASE_LLM_REQUEST": Phase.LLM_REQUEST,
"PHASE_LLM_RESPONSE": Phase.LLM_RESPONSE,
# A native session's UserPromptSubmit hook posts the request phase
# here (the server-level _evaluate_input_policy skips native message
# events). The prompt text rides in ``event.data.text``.
"PHASE_REQUEST": Phase.REQUEST,
}
_PHASE_TO_PROTO_ACTION: dict[PolicyAction, str] = {
PolicyAction.ALLOW: "POLICY_ACTION_ALLOW",
PolicyAction.DENY: "POLICY_ACTION_DENY",
PolicyAction.ASK: "POLICY_ACTION_ASK",
}
# ── POST /sessions/{session_id}/policies/evaluate ─────────────
@router.post(
"/sessions/{session_id}/policies/evaluate",
# Returns EvaluationResponse JSON; no Pydantic model since the
# proto-style schema is validated manually.
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
dependencies=[Depends(require_json_content_type)],
)
async def evaluate_policy(
request: Request,
session_id: str,
) -> Response:
"""
Generic policy evaluation endpoint (proto-compatible).
Accepts an ``EvaluationRequest`` JSON body whose ``event``
field carries the phase (``PHASE_TOOL_CALL``,
``PHASE_TOOL_RESULT``, ``PHASE_LLM_REQUEST``,
``PHASE_LLM_RESPONSE``), the event data, and optional
context. Returns an ``EvaluationResponse`` with the policy
verdict (``result``), an optional ``reason``, and optional
``data`` for content-rewriting policies.
Used by Claude Code's ``PreToolUse`` and ``PostToolUse``
command hooks (via ``omnigent.claude_native_hook``) to
evaluate admin policies on native tool calls. Also usable
by any client that speaks the proto-compatible JSON schema.
:param request: FastAPI request — body is the
``EvaluationRequest`` JSON envelope.
:param session_id: Omnigent conversation id from the URL path.
:returns: ``EvaluationResponse`` JSON with ``result``,
``reason``, and optional ``data``.
:raises OmnigentError: 404 if the session doesn't exist,
400 if the body is malformed.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
is_read_only = access.level is not None and access.level < LEVEL_EDIT
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise OmnigentError(
f"Invalid JSON in policy evaluate body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(payload, dict):
raise OmnigentError(
"Policy evaluate body must be a JSON object.",
code=ErrorCode.INVALID_INPUT,
)
event = payload.get("event")
if not isinstance(event, dict):
raise OmnigentError(
"Policy evaluate body must include an 'event' object.",
code=ErrorCode.INVALID_INPUT,
)
event_type = event.get("type")
phase = _PROTO_EVENT_TYPE_TO_PHASE.get(event_type or "")
if phase is None:
raise OmnigentError(
f"Unknown event type: {event_type!r}. "
f"Expected one of {list(_PROTO_EVENT_TYPE_TO_PHASE)}.",
code=ErrorCode.INVALID_INPUT,
)
# Optional stable re-attach id for hook retries. Validated but not
# required — absent on non-retrying callers (old hooks, direct API use).
raw_elicitation_id = payload.get("_omnigent_elicitation_id")
hook_elicitation_id: str | None = None
if raw_elicitation_id is not None:
if not isinstance(raw_elicitation_id, str) or not (
_EVALUATE_HOOK_ELICITATION_ID_RE.fullmatch(raw_elicitation_id)
):
raise OmnigentError(
"Policy evaluate '_omnigent_elicitation_id' must match "
"'elicit_evaluate_' + 32 hex chars.",
code=ErrorCode.INVALID_INPUT,
)
hook_elicitation_id = raw_elicitation_id
data = event.get("data") or {}
conv = conversation_store.get_conversation(session_id)
if conv is None:
raise OmnigentError(
f"Session {session_id!r} not found.",
code=ErrorCode.NOT_FOUND,
)
# Dedup the native request-phase gate. A native session's
# ``UserPromptSubmit`` hook posts ``PHASE_REQUEST`` here for *every*
# prompt, but a web-UI prompt was already gated server-side by
# ``_evaluate_input_policy`` at POST /events (before injection, so no
# TUI freeze). Re-gating it here would double-prompt the human. A
# web-UI prompt in flight has a ``pending_inputs`` entry (recorded at
# dispatch, drained when the forwarder mirrors it back); a prompt
# typed directly in the TUI has none and never hit POST /events, so it
# is gated here — the hook is its only request-phase gate. The signal
# is "is a web prompt in flight", not text correlation (the native
# transcript gives no reliable id channel — see ``pending_inputs``).
if phase == Phase.REQUEST and pending_inputs.snapshot_for(session_id):
return Response(
content=json.dumps({"result": "POLICY_ACTION_ALLOW"}),
media_type="application/json",
)
agent = agent_store.get(conv.agent_id) if conv.agent_id else None
if agent is None:
# No agent — no policies. Return unspecified (pass-through).
return Response(
content=json.dumps({"result": "POLICY_ACTION_UNSPECIFIED"}),
media_type="application/json",
)
loaded = get_agent_cache().load(
agent.id, agent.bundle_location, expand_env=agent.session_id is None
)
_caps = get_caps()
_host_conn = (
_caps.policy_llm_connection_factory() if _caps.policy_llm_connection_factory else None
)
def _build_engine() -> PolicyEngine:
"""
Build a policy engine for this session from the loaded spec.
Re-reads persisted ``session_state`` / usage from the store on
every call: the engine snapshots that state at construction and
does not re-query it during ``evaluate``, so a fresh build is the
only way to observe a concurrent sibling's just-recorded approval.
:returns: A :class:`PolicyEngine` seeded with the latest
persisted state for ``session_id``.
"""
return build_policy_engine(
spec=loaded.spec,
conversation_id=session_id,
conversation_store=conversation_store,
default_policies=_caps.default_policies,
policy_store=get_policy_store(),
server_llm=_caps.llm,
host_connection=_host_conn,
)
engine = _build_engine()
ctx = _build_evaluation_context(phase, data, event, actor=_build_actor(user_id))
result = await engine.evaluate(ctx, read_only=is_read_only)
# URL-based elicitation for blocking phases: on a TOOL_CALL or
# LLM_REQUEST ASK, hold the gate server-side rather than
# returning ASK. Returning ASK makes the native hook emit
# ``defer``, which a permissive ``permission_mode``
# (acceptEdits / bypassPermissions) auto-approves — bypassing
# the human. Instead we publish the approval elicitation, park
# until the human resolves it via the resolve URL, and collapse
# to a hard ALLOW / DENY so the caller never sees ASK.
# TOOL_CALL, LLM_REQUEST, and REQUEST are the phases that can block
# before the action proceeds (tool dispatch / LLM call / a native
# session's user prompt via the UserPromptSubmit hook — which has no
# ASK primitive of its own, so the server resolves ASK here).
if result.action == PolicyAction.ASK and phase in (
Phase.TOOL_CALL,
Phase.LLM_REQUEST,
Phase.REQUEST,
):
if is_read_only:
# Read-only callers must not enter the ASK gate — parking
# creates an elicitation (a server-side mutation). Return
# the ASK verdict directly so the caller sees the policy
# decision without mutating the session.
pass
else:
# Serialize concurrent native ASK gates for this (session, policy)
# so parallel tool calls that all trip the same checkpoint prompt
# the human once. The first ASK to win the lock parks; on approve
# it records a checkpoint. Siblings then rebuild the engine and
# re-evaluate UNDER the lock against that freshly persisted state —
# an ALLOW (or now-hard DENY) collapses the ASK and falls through
# without a second prompt. Held across the human wait by design;
# a declined ASK records nothing, so siblings legitimately re-ask.
async with _native_ask_gate_lock(session_id, result.deciding_policy):
engine = _build_engine()
result = await engine.evaluate(ctx, read_only=is_read_only)
if result.action == PolicyAction.ASK and phase in (
Phase.TOOL_CALL,
Phase.LLM_REQUEST,
Phase.REQUEST,
):
try:
approved = await _hold_native_ask_gate(
request,
session_id=session_id,
phase=phase,
data=data,
engine=engine,
result=result,
conversation_store=conversation_store,
elicitation_id=hook_elicitation_id,
)
except ElicitationDeclinedError as exc:
# Explicit user decline: interrupt the native
# harness BEFORE returning the hook deny so the
# Escape key reaches Claude Code's tmux pane first.
# By the time the DENY response reaches the hook
# subprocess, the abort signal is already queued.
# Best-effort: forwarding failures are swallowed.
await _forward_session_change_to_runner(
session_id,
_server_runner_router,
{"type": "interrupt"},
)
verdict_body = {
"result": "POLICY_ACTION_DENY",
"reason": exc.args[0] or "Approval was declined.",
}
return Response(
content=json.dumps(verdict_body),
media_type="application/json",
)
verdict_body: dict[str, Any] = (
{"result": "POLICY_ACTION_ALLOW"}
if approved
else {
"result": "POLICY_ACTION_DENY",
"reason": result.reason or "Approval was not granted.",
}
)
return Response(
content=json.dumps(verdict_body),
media_type="application/json",
)
# Re-evaluation collapsed the ASK (a sibling's approval recorded
# the checkpoint) — fall through to the generic ALLOW/DENY handling
# below with the rebuilt engine and updated result.
if result.set_labels and not is_read_only:
engine.apply_label_writes(result.set_labels)
resp_body: dict[str, Any] = {
"result": _PHASE_TO_PROTO_ACTION.get(result.action, "POLICY_ACTION_UNSPECIFIED"),
}
if result.reason:
resp_body["reason"] = result.reason
if result.data is not None:
resp_body["data"] = result.data
# A request-phase HARD DENY (no approve option) — surface the reason as a
# dismissable tmux popup on the native pane. opencode hard-blocks the
# prompt by its plugin throwing (rendered as a generic error), so this is
# the clean explanation; the runner dispatch only pops for opencode
# (claude/codex already show a clean UserPromptSubmit block). Best-effort.
if result.action == PolicyAction.DENY and phase == Phase.REQUEST and not is_read_only:
_spawn_native_blocked_notice_forward(
session_id, result.reason or "Blocked by policy.", result.deciding_policy
)
# A tool-call DENY is decided synchronously here, so nothing else on the
# stream reflects that the native tool was blocked. Publish a positive
# signal so observers (web UI, capability bench) see the decision rather
# than infer it from the blocked tool's absence. Observational, so it is
# not gated on write access.
if result.action == PolicyAction.DENY and phase == Phase.TOOL_CALL:
_publish_policy_denied(session_id, result.reason or "Blocked by policy.", phase.value)
return Response(
content=json.dumps(resp_body),
media_type="application/json",
)
# ── POST /sessions/{session_id}/hooks/codex-elicitation-request ─
@router.post(
"/sessions/{session_id}/hooks/codex-elicitation-request",
# Internal harness callback webhook — hidden from the public API reference.
include_in_schema=False,
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
dependencies=[Depends(require_json_content_type)],
)
async def codex_elicitation_request_hook(
request: Request,
session_id: str,
) -> Response:
"""
Codex app-server elicitation request endpoint.
Receives server-to-client JSON-RPC request envelopes forwarded
by ``omnigent codex`` (for example
``mcpServer/elicitation/request`` and
``item/tool/requestUserInput``), publishes the standard
``response.elicitation_request`` session event for the web UI,
then waits for the session-scoped ``approval`` reply. This uses
the same registry / publish / cleanup path as the Claude-native
``PermissionRequest`` hook so pending badges and disconnect
handling stay consistent across native harnesses.
:param request: FastAPI request carrying the Codex JSON-RPC
request envelope.
:param session_id: Omnigent conversation id from the URL path.
:returns: Codex JSON-RPC ``result`` payload for the forwarded
request, or ``200`` with empty body on timeout/disconnect.
:raises OmnigentError: 404 if the session does not exist,
400 if the request envelope is malformed or unsupported.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise OmnigentError(
f"Invalid JSON in Codex elicitation hook body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(payload, dict):
raise OmnigentError(
"Codex elicitation hook body must be a JSON object.",
code=ErrorCode.INVALID_INPUT,
)
codex_request = parse_codex_elicitation_request(payload)
result = await _publish_and_wait_for_harness_elicitation(
request,
session_id=session_id,
params=codex_request.params,
timeout_s=_CODEX_NATIVE_ELICITATION_HOOK_TIMEOUT_S,
conversation_store=conversation_store,
elicitation_id=codex_elicitation_id(
session_id,
codex_request.method,
codex_request.request_id,
),
)
if result is None:
return Response(status_code=status.HTTP_200_OK)
if result.action == "decline":
# Explicit user decline: interrupt Codex before returning the
# deny response, same as the Claude-native path. The await
# ensures the abort signal reaches Codex before it processes
# the decline result and lets the LLM continue.
await _forward_session_change_to_runner(
session_id,
_server_runner_router,
{"type": "interrupt"},
)
body = codex_request.build_response(result)
return Response(
content=json.dumps(body),
media_type="application/json",
)
# ── POST /sessions/{session_id}/hooks/antigravity-elicitation-request ──
@router.post(
"/sessions/{session_id}/hooks/antigravity-elicitation-request",
# Internal harness callback webhook — hidden from the public API reference.
include_in_schema=False,
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
dependencies=[Depends(require_json_content_type)],
)
async def antigravity_elicitation_request_hook(
request: Request,
session_id: str,
) -> Response:
"""
Antigravity (agy) elicitation request endpoint.
Receives ``{"elicitation_id": <str>, "params": <ElicitationRequestParams>}``
from the interaction bridge (Task 8), which POSTs here when it
surfaces an agy WAITING interaction for the web UI. Parks the call
on the shared harness elicitation registry, emits the standard
``response.elicitation_request`` SSE event, waits for the session
``approval`` verdict, then returns the raw
:class:`~omnigent.server.schemas.ElicitationResult` so the bridge
can forward it to agy via ``HandleCascadeUserInteraction``.
This is intentionally simpler than the Codex hook: the bridge
(not the endpoint) builds the agy interaction payload via
``to_interaction_payload``, so this endpoint only passes back
the verdict as-is. The body shape is minimal and symmetric:
``elicitation_id`` from the bridge's deterministic id function
(``agy_elicitation_id``), ``params`` as an
:class:`~omnigent.server.schemas.ElicitationRequestParams` dict.
:param request: FastAPI request carrying the agy elicitation body.
:param session_id: Omnigent conversation id from the URL path.
:returns: ``ElicitationResult`` JSON on user verdict; ``200`` with
empty body on timeout/disconnect (bridge interprets as ``None``).
:raises OmnigentError: 404 if the session does not exist, 400 if
the request body is malformed.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise OmnigentError(
f"Invalid JSON in antigravity elicitation hook body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(payload, dict):
raise OmnigentError(
"Antigravity elicitation hook body must be a JSON object.",
code=ErrorCode.INVALID_INPUT,
)
elicitation_id = payload.get("elicitation_id")
if not isinstance(elicitation_id, str) or not elicitation_id:
raise OmnigentError(
"Antigravity elicitation hook body must include a non-empty"
" 'elicitation_id' string.",
code=ErrorCode.INVALID_INPUT,
)
raw_params = payload.get("params")
if not isinstance(raw_params, dict):
raise OmnigentError(
"Antigravity elicitation hook body must include a 'params' object.",
code=ErrorCode.INVALID_INPUT,
)
try:
params = ElicitationRequestParams.model_validate(raw_params)
except Exception as exc:
raise OmnigentError(
f"Invalid 'params' in antigravity elicitation hook body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
result = await _publish_and_wait_for_harness_elicitation(
request,
session_id=session_id,
params=params,
timeout_s=_ANTIGRAVITY_NATIVE_ELICITATION_HOOK_TIMEOUT_S,
conversation_store=conversation_store,
elicitation_id=elicitation_id,
)
if result is None:
return Response(status_code=status.HTTP_200_OK)
if result.action == "decline":
# Explicit user decline: interrupt the native harness before
# returning the decline so the abort signal arrives first.
await _forward_session_change_to_runner(
session_id,
_server_runner_router,
{"type": "interrupt"},
)
return Response(
content=result.model_dump_json(),
media_type="application/json",
)
# ── POST /sessions/{session_id}/hooks/cursor-permission-request ─
@router.post(
"/sessions/{session_id}/hooks/cursor-permission-request",
# Internal harness callback webhook — hidden from the public API reference.
include_in_schema=False,
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
dependencies=[Depends(require_json_content_type)],
)
async def cursor_permission_request_hook(
request: Request,
session_id: str,
) -> Response:
"""
Cursor-native tool-approval hook (TUI → web elicitation).
Receives a tool-approval prompt detected on the ``cursor-agent`` TUI
pane by the runner-side mirror
(:mod:`omnigent.cursor_native_permissions`), publishes the standard
``response.elicitation_request`` event for the web UI, then parks for
the session ``approval`` verdict — the same registry / publish /
cleanup path as the Codex- and Claude-native hooks, so pending badges
and disconnect handling stay consistent across native harnesses. An
empty ``200`` (no web verdict — the prompt was answered in the TUI, or
the wait timed out) leaves cursor's native prompt authoritative.
:param request: FastAPI request carrying the detected prompt
(``elicitation_id`` plus the ``message`` / ``content_preview`` /
``operation_type`` to render).
:param session_id: Omnigent conversation id from the URL path.
:returns: An ``ElicitationResult`` (``{"action": …}``) on a web
verdict, or ``200`` with empty body on TUI-resolution / timeout /
disconnect.
:raises OmnigentError: 404 if the session does not exist, 400 if the
body is malformed.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise OmnigentError(
f"Invalid JSON in cursor permission hook body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(payload, dict):
raise OmnigentError(
"Cursor permission hook body must be a JSON object.",
code=ErrorCode.INVALID_INPUT,
)
elicitation_id = payload.get("elicitation_id")
if not isinstance(elicitation_id, str) or not elicitation_id:
raise OmnigentError(
"Cursor permission hook body must include 'elicitation_id'.",
code=ErrorCode.INVALID_INPUT,
)
message = payload.get("message")
if not isinstance(message, str) or not message:
message = "Cursor wants approval to run a tool"
content_preview = payload.get("content_preview")
if not isinstance(content_preview, str):
content_preview = None
operation_type = payload.get("operation_type")
if not isinstance(operation_type, str) or not operation_type:
operation_type = "tool"
# Structured AskQuestion payload (cursor's multiple-choice tool): when
# present, stamp it as the ``ask_user_question`` extra so the web UI
# renders the interactive form from it directly. ``content_preview`` is
# hard-capped at 1024 chars, which truncates a multi-question payload and
# breaks the preview-parse fallback — the structured field has no such
# cap and is the authoritative source the UI consumes when present.
extras: dict[str, Any] = {}
ask_user_question = payload.get("ask_user_question")
if isinstance(ask_user_question, dict) and isinstance(
ask_user_question.get("questions"), list
):
extras["ask_user_question"] = ask_user_question
params = ElicitationRequestParams(
mode="form",
message=message,
requestedSchema=None,
url=None,
phase="pre_tool_use",
policy_name="cursor_native_permission",
content_preview=content_preview,
**extras,
)
result = await _publish_and_wait_for_harness_elicitation(
request,
session_id=session_id,
params=params,
timeout_s=_CURSOR_NATIVE_PERMISSION_HOOK_TIMEOUT_S,
conversation_store=conversation_store,
elicitation_id=elicitation_id,
tool_name=f"Cursor({operation_type})",
)
if result is None:
return Response(status_code=status.HTTP_200_OK)
if result.action == "decline":
# Explicit user decline: interrupt the native harness before
# returning the decline so the abort signal arrives first.
await _forward_session_change_to_runner(
session_id,
_server_runner_router,
{"type": "interrupt"},
)
return Response(
content=json.dumps(result.model_dump(exclude_none=True)),
media_type="application/json",
)
# ── POST /sessions/{session_id}/hooks/native-permission-request ─
@router.post(
"/sessions/{session_id}/hooks/native-permission-request",
# Internal harness callback webhook — hidden from the public API reference.
include_in_schema=False,
response_model=None,
dependencies=[Depends(require_json_content_type)],
)
async def native_permission_request_hook(
request: Request,
session_id: str,
) -> Response:
"""
Generic native-TUI tool-approval hook (TUI → web elicitation).
The vendor-agnostic counterpart of
:func:`cursor_permission_request_hook`, used by the hermes- and
goose-native approval mirrors. The runner-side mirror detects the
vendor's in-terminal approval prompt, POSTs it here, and the server
publishes ``response.elicitation_request`` and parks for the web verdict
— the same registry/publish/cleanup path as the cursor/codex/claude
hooks. An empty ``200`` (TUI answered, or timeout) leaves the vendor's
native prompt authoritative.
Unlike the cursor hook, the card label / policy name come from the
payload (``agent`` / ``policy_name``) so a Hermes or Goose approval is
labelled as such, not "Cursor".
:param request: FastAPI request carrying the detected prompt
(``elicitation_id``, ``message``, ``content_preview``,
``operation_type``, optional ``agent`` / ``policy_name``).
:param session_id: Omnigent conversation id from the URL path.
:returns: An ``ElicitationResult`` (``{"action": …}``) on a web verdict,
or ``200`` with empty body on TUI-resolution / timeout / disconnect.
:raises OmnigentError: 404 if the session does not exist, 400 if the
body is malformed.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
try:
payload = await request.json()
except json.JSONDecodeError as exc:
raise OmnigentError(
f"Invalid JSON in native permission hook body: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(payload, dict):
raise OmnigentError(
"Native permission hook body must be a JSON object.",
code=ErrorCode.INVALID_INPUT,
)
elicitation_id = payload.get("elicitation_id")
if not isinstance(elicitation_id, str) or not elicitation_id:
raise OmnigentError(
"Native permission hook body must include 'elicitation_id'.",
code=ErrorCode.INVALID_INPUT,
)
agent = payload.get("agent")
if not isinstance(agent, str) or not agent:
agent = "Agent"
message = payload.get("message")
if not isinstance(message, str) or not message:
message = f"{agent} wants approval to run a tool"
content_preview = payload.get("content_preview")
if not isinstance(content_preview, str):
content_preview = None
operation_type = payload.get("operation_type")
if not isinstance(operation_type, str) or not operation_type:
operation_type = "tool"
policy_name = payload.get("policy_name")
if not isinstance(policy_name, str) or not policy_name:
policy_name = "native_permission"
params = ElicitationRequestParams(
mode="form",
message=message,
requestedSchema=None,
url=None,
phase="pre_tool_use",
policy_name=policy_name,
content_preview=content_preview,
)
result = await _publish_and_wait_for_harness_elicitation(
request,
session_id=session_id,
params=params,
timeout_s=_NATIVE_PERMISSION_HOOK_TIMEOUT_S,
conversation_store=conversation_store,
elicitation_id=elicitation_id,
tool_name=f"{agent}({operation_type})",
)
if result is None:
return Response(status_code=status.HTTP_200_OK)
if result.action == "decline":
# Explicit user decline: interrupt the native harness before
# returning the decline so the abort signal arrives first.
await _forward_session_change_to_runner(
session_id,
_server_runner_router,
{"type": "interrupt"},
)
return Response(
content=json.dumps(result.model_dump(exclude_none=True)),
media_type="application/json",
)
# ── GET /sessions/{session_id}/items ─────────────────────────
@router.get(
"/sessions/{session_id}/items",
response_model=None,
responses={200: {"model": PaginatedList}},
)
async def list_session_items(
request: Request,
session_id: str,
limit: int = Query(default=100, ge=1, le=1000),
after: str | None = Query(default=None),
before: str | None = Query(default=None),
order: str = Query(default="asc", pattern="^(asc|desc)$"),
) -> PaginatedList:
"""
List items in a session with cursor-based pagination.
Delegates to the conversation items store — session_id is
the conversation_id. Same pagination contract as
``GET /v1/conversations/{id}/items``.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param limit: Maximum number of items to return
(1-1000, default 100).
:param after: Cursor — return items after this item ID,
e.g. ``"msg_abc123"``.
:param before: Cursor — return items before this item ID.
:param order: Sort order, ``"asc"`` (chronological,
default) or ``"desc"``.
:returns: A :class:`PaginatedList` of conversation items.
:raises OmnigentError: 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
if access.conversation is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
page = await asyncio.to_thread(
conversation_store.list_items,
session_id,
limit=limit,
after=after,
before=before,
order=order,
)
data = [m.to_api_dict() for m in page.data]
return PaginatedList(
data=data,
first_id=page.first_id,
last_id=page.last_id,
has_more=page.has_more,
)
# ── GET /sessions/{session_id}/child_sessions ────────────────
@router.get(
"/sessions/{session_id}/child_sessions",
response_model=None,
responses={200: {"model": ChildSessionList}},
)
async def list_child_sessions(
request: Request,
session_id: str,
limit: int = Query(default=20, ge=1, le=1000),
after: str | None = Query(default=None),
before: str | None = Query(default=None),
order: str = Query(default="desc", pattern="^(asc|desc)$"),
tool: str | None = Query(default=None),
session_name: str | None = Query(default=None),
) -> PaginatedList:
"""
List sub-agent (child) sessions under a parent session.
Returns a page of :class:`ChildSessionSummary` objects
derived from child conversations (``kind="sub_agent"``,
``parent_conversation_id=session_id``) plus each child's
latest task. Powers the web / REPL debug surfaces' "child
sessions" panel without parsing parent
``function_call_output`` JSON handles. Pagination contract
matches :func:`list_session_items` so existing client code
can reuse the same cursor logic.
:param request: Inbound HTTP request; carries the caller
identity used to authorize READ on the parent session.
:param session_id: Parent session/conversation identifier,
e.g. ``"conv_abc123"``.
:param limit: Maximum number of children to return
(1-1000, default 20 — sub-agent fan-out is typically
sparse compared to conversation items).
:param after: Cursor — return children whose id appears
after this one in sort order,
e.g. ``"conv_child123"``.
:param before: Cursor — return children before this one.
:param order: Sort direction, ``"desc"`` (newest-first,
default) or ``"asc"``. Sort column is ``created_at``.
:param tool: When set, only return children whose title
starts with this agent type (the segment before the
``":"``). Combined with ``session_name`` to form the
exact title ``"{tool}:{session_name}"`` for server-side
filtering.
:param session_name: When set alongside ``tool``, only
return children whose title matches
``"{tool}:{session_name}"`` exactly.
:returns: A :class:`PaginatedList` of
:class:`ChildSessionSummary` objects.
:raises OmnigentError: 403 if the caller lacks READ on
``session_id``; 404 if no session exists there.
"""
user_id = _get_user_id(request, auth_provider)
# Require READ on the parent before listing its children (no cross-user enumeration).
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
parent = access.conversation
if parent is None:
parent = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if parent is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
title_filter: str | None = None
if tool and session_name:
title_filter = f"{tool}:{session_name}"
page = await asyncio.to_thread(
conversation_store.list_conversations,
limit=limit,
after=after,
before=before,
kind="sub_agent",
parent_conversation_id=session_id,
order=order,
sort_by="created_at",
title=title_filter,
)
data = await _child_session_summaries_from_conversations(
page.data,
session_id,
conversation_store,
)
return PaginatedList(
data=data,
first_id=page.first_id,
last_id=page.last_id,
has_more=page.has_more,
)
# ── GET /sessions/{session_id}/resources ─────────────────────
@router.get(
"/sessions/{session_id}/resources",
response_model=SessionResourcePaginatedList,
response_model_exclude_none=True,
)
async def list_session_resources(
request: Request,
session_id: str,
# Shadows the ``type`` builtin deliberately: FastAPI maps the
# parameter name to the wire query param, which is ``?type=``.
type: str | None = Query(default=None),
) -> SessionResourcePaginatedList:
"""
Return the runner-authoritative resource inventory for a session.
Requires the session to be bound to a runner via
``PATCH /v1/sessions/{id}``; raises ``conflict`` otherwise.
The server validates the session exists, then proxies to the
runner's ``GET /v1/sessions/{id}/resources`` endpoint. In
unit-test / in-process setups with no runner router/client, the
route falls back to adapting the local terminal registry.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param type: Optional resource-type filter, e.g.
``"environment"`` / ``"terminal"`` / ``"file"``. Forwarded
to the runner (its registry applies it) and honored by the
local-registry fallback and the file-store merge below.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
if access.conversation is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
runner_client = await _get_runner_client_for_resource_access(session_id)
if runner_client is not None:
page = await _proxy_get_session_resources_to_runner(
runner_client, session_id, resource_type=type
)
else:
from omnigent.entities.session_resources import (
list_session_resources_from_terminal_registry,
)
from omnigent.runtime import get_terminal_registry
try:
local_registry = get_terminal_registry()
except RuntimeError:
local_registry = None
resource_page = list_session_resources_from_terminal_registry(
session_id,
local_registry,
)
# Mirror the runner's ``?type=`` semantics on the fallback so
# both paths return the same shape for filtered queries.
local_data = [
SessionResourceObject.model_validate(
session_resource_view_to_dict(resource),
)
for resource in resource_page.data
if type is None or resource.type == type
]
page = SessionResourcePaginatedList(
data=local_data,
first_id=local_data[0].id if local_data else None,
last_id=local_data[-1].id if local_data else None,
has_more=resource_page.has_more,
)
# Files live in the server's file store, not on the runner, so a
# ``type`` filter for non-file resources must skip the merge.
if file_store is not None and type in (None, "file"):
file_page = await asyncio.to_thread(
file_store.list,
session_id=session_id,
limit=1000,
)
for stored in file_page.data:
resource_dict = _stored_file_to_resource(
session_id,
stored,
)
page.data.append(
SessionResourceObject.model_validate(resource_dict),
)
if page.data:
page.last_id = page.data[-1].id
if not page.first_id:
page.first_id = page.data[0].id
return page
# ── Phase 1b: typed resource collections & terminal lifecycle ──
async def _validate_session(
session_id: str,
request: Request | None = None,
required_level: int = LEVEL_READ,
) -> Conversation:
"""Validate session existence and enforce permission checks.
:param session_id: Session/conversation identifier.
:param request: The incoming FastAPI request (for auth).
When ``None``, permission checks are skipped (internal
calls only).
:param required_level: Minimum permission level needed.
:returns: The matching conversation.
:raises OmnigentError: 401/403/404 on auth or access failure.
"""
if request is not None:
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id,
session_id,
required_level,
permission_store,
conversation_store,
)
# _require_access_and_level already fetched the conversation for
# non-admin callers — reuse it to avoid a second DB round-trip.
if access.conversation is not None:
return access.conversation
# Fallback: no-auth path, admin caller, or permissions disabled.
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
return conv
async def _proxy_get_to_runner(
session_id: str,
path: str,
params: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Proxy a GET request to the runner and return parsed JSON.
:param session_id: Session/conversation identifier.
:param path: Runner-relative URL path.
:param params: Optional query params forwarded to the runner,
e.g. ``{"order": "asc"}``. ``None`` sends no query string.
:returns: Parsed JSON response body.
:raises HTTPException: 502 on runner failure.
"""
runner_client = await _get_runner_client_for_resource_access(
session_id,
)
if runner_client is None:
raise HTTPException(
status_code=502,
detail="no runner available for resource access",
)
try:
resp = await runner_client.get(path, params=params, timeout=10.0)
except (httpx.HTTPError, ConnectionError) as exc:
raise HTTPException(
status_code=502,
detail="runner resource endpoint unavailable",
) from exc
if resp.status_code == 404:
raise OmnigentError(
resp.json().get("error", {}).get("message", "Resource not found"),
code=ErrorCode.NOT_FOUND,
)
if resp.status_code != 200:
try:
body = resp.json()
error = body.get("error", {})
msg = error.get("message") or "runner resource endpoint failed"
except Exception: # noqa: BLE001
msg = "runner resource endpoint failed"
raise HTTPException(status_code=502, detail=msg)
return resp.json()
async def _proxy_post_to_runner(
session_id: str,
path: str,
body: dict[str, Any],
) -> tuple[int, dict[str, Any]]:
"""Proxy a POST request to the runner and return status + JSON.
:param session_id: Session/conversation identifier.
:param path: Runner-relative URL path.
:param body: JSON body to forward.
:returns: Tuple of (status_code, parsed_json_body).
:raises HTTPException: 502 on transport failure.
"""
runner_client = await _get_runner_client_for_resource_access(
session_id,
)
if runner_client is None:
raise HTTPException(
status_code=502,
detail="no runner available for resource access",
)
try:
resp = await runner_client.post(
path,
json=body,
timeout=10.0,
)
except (httpx.HTTPError, ConnectionError) as exc:
raise HTTPException(
status_code=502,
detail="runner resource endpoint unavailable",
) from exc
return resp.status_code, resp.json()
async def _proxy_delete_to_runner(
session_id: str,
path: str,
) -> tuple[int, dict[str, Any]]:
"""Proxy a DELETE request to the runner and return status + JSON.
:param session_id: Session/conversation identifier.
:param path: Runner-relative URL path.
:returns: Tuple of (status_code, parsed_json_body).
:raises HTTPException: 502 on transport failure.
"""
runner_client = await _get_runner_client_for_resource_access(
session_id,
)
if runner_client is None:
raise HTTPException(
status_code=502,
detail="no runner available for resource access",
)
try:
resp = await runner_client.delete(path, timeout=10.0)
except (httpx.HTTPError, ConnectionError) as exc:
raise HTTPException(
status_code=502,
detail="runner resource endpoint unavailable",
) from exc
return resp.status_code, resp.json()
async def _proxy_put_to_runner(
session_id: str,
path: str,
body: dict[str, Any],
) -> tuple[int, dict[str, Any]]:
"""Proxy a PUT request to the runner.
:param session_id: Session/conversation identifier.
:param path: Runner-relative URL path.
:param body: JSON body to forward.
:returns: Tuple of (status_code, parsed_json_body).
:raises HTTPException: 502 on transport failure.
"""
runner_client = await _get_runner_client_for_resource_access(
session_id,
)
if runner_client is None:
raise HTTPException(
status_code=502,
detail="no runner available for resource access",
)
try:
resp = await runner_client.put(
path,
json=body,
timeout=10.0,
)
except (httpx.HTTPError, ConnectionError) as exc:
raise HTTPException(
status_code=502,
detail="runner resource endpoint unavailable",
) from exc
return resp.status_code, resp.json()
async def _proxy_patch_to_runner(
session_id: str,
path: str,
body: dict[str, Any],
) -> tuple[int, dict[str, Any]]:
"""Proxy a PATCH request to the runner.
:param session_id: Session/conversation identifier.
:param path: Runner-relative URL path.
:param body: JSON body to forward.
:returns: Tuple of (status_code, parsed_json_body).
:raises HTTPException: 502 on transport failure.
"""
runner_client = await _get_runner_client_for_resource_access(
session_id,
)
if runner_client is None:
raise HTTPException(
status_code=502,
detail="no runner available for resource access",
)
try:
resp = await runner_client.patch(
path,
json=body,
timeout=10.0,
)
except (httpx.HTTPError, ConnectionError) as exc:
raise HTTPException(
status_code=502,
detail="runner resource endpoint unavailable",
) from exc
return resp.status_code, resp.json()
# Typed collection routes registered BEFORE /{resource_id} so
# "environments", "terminals", "files" are not captured as ids.
@router.get(
"/sessions/{session_id}/resources/environments",
response_model=None,
)
async def list_session_environments(
request: Request,
session_id: str,
) -> dict[str, Any]:
"""
Return only environment resources for a session.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:returns: ``PaginatedList`` of environment resources.
"""
await _validate_session(session_id, request, LEVEL_READ)
path = f"/v1/sessions/{session_id}/resources/environments"
return await _proxy_get_to_runner(session_id, path)
@router.get(
"/sessions/{session_id}/resources/environments/{environment_id}",
response_model=None,
)
async def get_session_environment(
request: Request,
session_id: str,
environment_id: str,
) -> dict[str, Any]:
"""
Return a single environment resource by id.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param environment_id: Opaque environment resource id,
e.g. ``"default"``.
:returns: The environment resource object.
"""
await _validate_session(session_id, request, LEVEL_READ)
path = f"/v1/sessions/{session_id}/resources/environments/{environment_id}"
return await _proxy_get_to_runner(session_id, path)
@router.get(
"/sessions/{session_id}/resources/terminals",
response_model=None,
)
async def list_session_terminals(
request: Request,
session_id: str,
) -> dict[str, Any]:
"""
Return only terminal resources for a session.
The runner endpoint's pagination params (``limit`` / ``after`` /
``before`` / ``order``) are forwarded from the incoming query
string — without this, a client-requested ``order=asc`` (the web
terminal tabs rely on creation order to keep the session's own
terminal first) would be silently dropped and the runner's
``desc`` default would apply.
:param request: The incoming FastAPI request (for auth and the
forwarded query params).
:param session_id: Session/conversation identifier.
:returns: ``PaginatedList`` of terminal resources.
"""
await _validate_session(session_id, request, LEVEL_READ)
path = f"/v1/sessions/{session_id}/resources/terminals"
forwarded = {
key: value
for key, value in request.query_params.items()
if key in ("limit", "after", "before", "order")
}
return await _proxy_get_to_runner(session_id, path, params=forwarded or None)
@router.post(
"/sessions/{session_id}/resources/terminals",
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
dependencies=[Depends(require_json_content_type)],
)
async def create_session_terminal(
session_id: str,
request: Request,
) -> Any:
"""
Launch or return an existing terminal resource.
Preserves ``sys_terminal_launch`` idempotency: an
already-running ``(terminal, session_key)`` returns the
existing resource.
User-initiated creates are gated on the agent's terminal
access: the requested ``terminal`` must be one of the names
declared in the agent spec's ``terminals:`` block. Native
harness bootstrap requests (marked ``ensure_native_terminal``
or ``bridge_inject_dir`` — the ``omnigent claude`` / ``codex``
wrappers launching the session's own CLI terminal) are exempt:
they launch undeclared names via the runner's
synthesize-from-body path and predate the gate. The markers
are client-controlled, so the exemption is narrowed to the
exact shape those wrappers send — a registered native terminal
name with ``session_key`` ``"main"`` — anything else carrying a
marker still goes through the declared-name gate (it would
otherwise be an arbitrary-terminal bypass).
:param session_id: Session/conversation identifier.
:param request: JSON body with ``terminal`` and
``session_key``.
:returns: The terminal resource object.
:raises OmnigentError: 400 when the requested terminal is not
declared by the agent spec (or the agent has no
``terminals:`` block at all).
"""
conv = await _validate_session(session_id, request, LEVEL_EDIT)
body = await request.json()
is_native_bootstrap = (
bool(body.get("ensure_native_terminal") or body.get("bridge_inject_dir"))
and native_coding_agent_for_terminal_name(body.get("terminal")) is not None
and body.get("session_key") == "main"
)
if not is_native_bootstrap:
spec = await asyncio.to_thread(_load_agent_spec_for_session, conv, agent_store)
declared = list(spec.terminals or {}) if spec is not None else []
if body.get("terminal") not in declared:
raise OmnigentError(
(
f"Terminal {body.get('terminal')!r} is not declared by this "
f"agent. Terminals can only be created for agents whose spec "
f"declares them; this agent declares: {declared or 'none'}."
),
code=ErrorCode.INVALID_INPUT,
)
path = f"/v1/sessions/{session_id}/resources/terminals"
status, payload = await _proxy_post_to_runner(
session_id,
path,
body,
)
if status >= 400:
error = payload.get("error", {})
# OmnigentError derives http_status from code; pass the runner's code, not a status.
raise OmnigentError(
error.get("message", f"Terminal launch failed (runner returned HTTP {status})"),
code=error.get("code", ErrorCode.INTERNAL_ERROR),
)
_publish_and_persist_resource_event(
session_id,
"session.resource.created",
resource_id=payload.get("id", ""),
resource_type="terminal",
conversation_store=conversation_store,
resource=payload,
)
return payload
@router.get(
"/sessions/{session_id}/resources/terminals/{terminal_id}",
response_model=None,
)
async def get_session_terminal(
request: Request,
session_id: str,
terminal_id: str,
) -> dict[str, Any]:
"""
Return a single terminal resource by id.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param terminal_id: Opaque terminal resource id.
:returns: The terminal resource object.
"""
await _validate_session(session_id, request, LEVEL_READ)
path = f"/v1/sessions/{session_id}/resources/terminals/{terminal_id}"
return await _proxy_get_to_runner(session_id, path)
@router.post(
"/sessions/{session_id}/resources/terminals/{terminal_id}/transfer",
# Internal terminal transfer — hidden from the public API reference.
include_in_schema=False,
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
dependencies=[Depends(require_json_content_type)],
)
async def transfer_session_terminal(
request: Request,
session_id: str,
terminal_id: str,
) -> Any:
"""
Move a terminal resource to another session without closing it.
Used by native Claude ``/clear`` rotation: ownership changes
from the previous conversation to the fresh one while the tmux
pane keeps running.
:param request: The incoming FastAPI request (for auth) with
JSON body ``{"target_session_id": "conv_new"}``.
:param session_id: Current owning session/conversation id,
e.g. ``"conv_old"``.
:param terminal_id: Opaque terminal resource id,
e.g. ``"terminal_claude_main"``.
:returns: The terminal resource object under the target session.
"""
await _validate_session(session_id, request, LEVEL_EDIT)
body = await request.json()
target_session_id = body.get("target_session_id") if isinstance(body, dict) else None
if not isinstance(target_session_id, str) or not target_session_id:
raise OmnigentError(
"'target_session_id' is required",
code=ErrorCode.INVALID_INPUT,
)
await _validate_session(target_session_id, request, LEVEL_EDIT)
path = f"/v1/sessions/{session_id}/resources/terminals/{terminal_id}/transfer"
status, payload = await _proxy_post_to_runner(
session_id,
path,
{"target_session_id": target_session_id},
)
if status == 404:
error = payload.get("error", {})
raise OmnigentError(
error.get("message", "Terminal not found"),
code=ErrorCode.NOT_FOUND,
)
if status == 409:
error = payload.get("error", {})
raise OmnigentError(
error.get("message", "Terminal transfer conflict"),
code=ErrorCode.INVALID_INPUT,
)
if status >= 400:
error = payload.get("error", {})
raise OmnigentError(
error.get("message", "Terminal transfer failed"),
code=error.get("code", "internal_error"),
http_status=status,
)
_publish_and_persist_resource_event(
session_id,
"session.resource.deleted",
resource_id=terminal_id,
resource_type="terminal",
conversation_store=conversation_store,
)
_publish_and_persist_resource_event(
target_session_id,
"session.resource.created",
resource_id=payload.get("id", ""),
resource_type="terminal",
conversation_store=conversation_store,
resource=payload,
)
return payload
@router.delete(
"/sessions/{session_id}/resources/terminals/{terminal_id}",
response_model=None,
)
async def delete_session_terminal(
request: Request,
session_id: str,
terminal_id: str,
) -> Any:
"""
Close a terminal resource.
Delegates to ``TerminalRegistry.close()`` on the runner.
Returns 404 for unknown terminals.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param terminal_id: Opaque terminal resource id.
:returns: Deletion confirmation object.
"""
await _validate_session(session_id, request, LEVEL_EDIT)
path = f"/v1/sessions/{session_id}/resources/terminals/{terminal_id}"
status, payload = await _proxy_delete_to_runner(
session_id,
path,
)
if status == 404:
error = payload.get("error", {})
raise OmnigentError(
error.get("message", "Terminal not found"),
code=ErrorCode.NOT_FOUND,
)
if status >= 400:
raise HTTPException(
status_code=502,
detail="runner terminal delete failed",
)
_publish_and_persist_resource_event(
session_id,
"session.resource.deleted",
resource_id=terminal_id,
resource_type="terminal",
conversation_store=conversation_store,
)
return payload
# ── Phase 1c: session-scoped file endpoints ────────────────────
@router.get(
"/sessions/{session_id}/resources/files",
response_model=None,
)
async def list_session_files(
request: Request,
session_id: str,
limit: int = Query(default=20, ge=1, le=1000),
after: str | None = Query(default=None),
before: str | None = Query(default=None),
order: str = Query(default="desc", pattern="^(asc|desc)$"),
) -> dict[str, Any]:
"""
List files owned by a session.
:param session_id: Session/conversation identifier.
:param limit: Maximum number of files to return.
:param after: Cursor file ID for forward pagination.
:param before: Cursor file ID for backward pagination.
:param order: Sort direction, ``"desc"`` or ``"asc"``.
:returns: ``PaginatedList`` of session file resources.
"""
await _validate_session(session_id, request, LEVEL_READ)
if file_store is None:
raise HTTPException(
status_code=501,
detail="file store not configured",
)
page = file_store.list(
session_id=session_id,
limit=limit,
after=after,
before=before,
order=order,
)
data = [_stored_file_to_resource(session_id, f) for f in page.data]
return {
"object": "list",
"data": data,
"first_id": page.first_id,
"last_id": page.last_id,
"has_more": page.has_more,
}
@router.post(
"/sessions/{session_id}/resources/files",
status_code=201,
response_model=None,
# CSRF hardening: this route only accepts multipart/form-data, which
# is CORS-safelisted, so a content-type guard can't stop a cross-site
# upload. require_trusted_origin closes the gap (allows absent Origin
# for the non-browser SDK/runner clients; in local mode a present
# Origin must be loopback).
dependencies=[Depends(require_trusted_origin)],
)
async def upload_session_file(
request: Request,
session_id: str,
file: Annotated[UploadFile, File(...)],
) -> dict[str, Any]:
"""
Upload a file into the session file namespace.
Accepts the multipart upload shape used by session file resources.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param file: The uploaded file (multipart form data).
:returns: The session file resource object.
"""
await _validate_session(session_id, request, LEVEL_EDIT)
if file_store is None or artifact_store is None:
raise HTTPException(
status_code=501,
detail="file store not configured",
)
if not file.filename:
raise OmnigentError(
"filename is required",
code=ErrorCode.INVALID_INPUT,
)
from omnigent.runtime.content_resolver import (
MAX_ATTACHMENT_UPLOAD_BYTES,
_resolve_content_type,
attachment_text_type_for_extension,
attachment_upload_limit,
)
# Resolve the type from the declared MIME + filename BEFORE reading
# the body, so an unsupported or oversized upload is rejected without
# buffering it. Attachments are inlined into the model context as
# base64 (see content_resolver.resolve_content_references); only
# images, PDF, and text/code files are usable — others (pptx, docx,
# zip, …) would be garbled or blow the request size, so reject them.
content_type = _resolve_content_type(
file.content_type,
file.filename,
)
type_limit = attachment_upload_limit(content_type)
if type_limit is None:
# The browser/OS can mislabel a text/code file as binary (e.g. a
# .csv reported as application/vnd.ms-excel on Windows). Fall back
# to the extension — matching the web client's allowlist — and
# normalize the type so the resolver inlines it as text.
ext_type = attachment_text_type_for_extension(file.filename)
if ext_type is not None:
content_type = ext_type
type_limit = attachment_upload_limit(content_type)
if type_limit is None:
raise HTTPException(
status_code=415,
detail=(
f"Unsupported attachment type '{content_type}'. Only images, "
"PDF, and text/code files can be attached."
),
)
content = await _read_upload_capped(
file,
min(type_limit, MAX_ATTACHMENT_UPLOAD_BYTES),
)
stored = file_store.create(
session_id=session_id,
filename=file.filename,
bytes=len(content),
content_type=content_type,
)
artifact_store.put(stored.id, content)
resource = _stored_file_to_resource(session_id, stored)
_publish_and_persist_resource_event(
session_id,
"session.resource.created",
resource_id=stored.id,
resource_type="file",
conversation_store=conversation_store,
resource=resource,
)
return resource
@router.get(
"/sessions/{session_id}/resources/files/{file_id}",
response_model=None,
)
async def get_session_file(
request: Request,
session_id: str,
file_id: str,
) -> dict[str, Any]:
"""
Retrieve metadata for a session file resource.
Verifies that ``file_id`` belongs to ``session_id``.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param file_id: Unique file identifier.
:returns: The session file resource object.
"""
await _validate_session(session_id, request, LEVEL_READ)
if file_store is None:
raise HTTPException(
status_code=501,
detail="file store not configured",
)
stored = file_store.get(file_id, session_id=session_id)
if stored is None:
raise OmnigentError(
"File not found",
code=ErrorCode.NOT_FOUND,
)
return _stored_file_to_resource(session_id, stored)
@router.get(
"/sessions/{session_id}/resources/files/{file_id}/content",
response_model=None,
)
async def get_session_file_content(
request: Request,
session_id: str,
file_id: str,
) -> Response:
"""
Download raw content of a session file resource.
:param session_id: Session/conversation identifier.
:param file_id: Unique file identifier.
:returns: Response with file bytes and Content-Type.
"""
await _validate_session(session_id, request, LEVEL_READ)
if file_store is None or artifact_store is None:
raise HTTPException(
status_code=501,
detail="file store not configured",
)
stored = file_store.get(file_id, session_id=session_id)
if stored is None:
raise OmnigentError(
"File not found",
code=ErrorCode.NOT_FOUND,
)
content = artifact_store.get(stored.id)
media_type = mimetypes.guess_type(stored.filename)[0] or "application/octet-stream"
# The filename and bytes are fully user-controlled. Serving the
# content inline lets a browser navigating directly to this URL
# render an uploaded ``evil.html`` as ``text/html`` and execute
# its script in the server's own origin (stored XSS — acute on
# the OSS/local server, which has no CSRF/apiproxy boundary).
# Force a download with ``Content-Disposition: attachment`` and
# disable MIME sniffing so the response cannot be reinterpreted
# as an active type.
return Response(
content=content,
media_type=media_type,
headers={
"Content-Disposition": _attachment_disposition(stored.filename),
"X-Content-Type-Options": "nosniff",
},
)
@router.delete(
"/sessions/{session_id}/resources/files/{file_id}",
response_model=None,
)
async def delete_session_file(
request: Request,
session_id: str,
file_id: str,
) -> dict[str, Any]:
"""
Delete a session file resource and its artifact bytes.
:param session_id: Session/conversation identifier.
:param file_id: Unique file identifier.
:returns: Deletion confirmation object.
"""
await _validate_session(session_id, request, LEVEL_EDIT)
if file_store is None or artifact_store is None:
raise HTTPException(
status_code=501,
detail="file store not configured",
)
if not file_store.delete(file_id, session_id=session_id):
raise OmnigentError(
"File not found",
code=ErrorCode.NOT_FOUND,
)
artifact_store.delete(file_id)
_publish_and_persist_resource_event(
session_id,
"session.resource.deleted",
resource_id=file_id,
resource_type="file",
conversation_store=conversation_store,
)
return {
"id": file_id,
"object": "session.resource.deleted",
"deleted": True,
}
@router.post(
"/sessions/{session_id}/resources/files:copy",
response_model=None,
)
async def copy_session_files(
request: Request,
session_id: str,
body: CopyFilesRequest,
) -> dict[str, Any]:
"""
Copy lineage-owned files into this (destination) session.
Authorizes by spawn lineage: ``body.source_session_id`` must be a
STRICT ancestor of this session up the ``parent_conversation_id``
chain — the session may not name itself as the source. Each source
file is read and re-stored as a new child-scoped row owned by
``session_id`` — this preserves the session-scoping invariant (the
child reads its OWN copy; no cross-session read grant is created).
Validation is all-or-nothing: an unauthorized source, a missing
file, or a request past the copy limits copies nothing.
The request is bounded before any blob is read: the file count and
the summed ``StoredFile.bytes`` are checked against the copy limits
during metadata validation, so an over-limit request is rejected
without buffering a single blob. Within the limits, files are copied
one at a time (read → create → put) so peak memory is a single blob,
not the whole batch.
:param request: The incoming FastAPI request (for auth).
:param session_id: Destination (child) session/conversation id.
:param body: Source session id plus the file ids to copy.
:returns: A ``session.files.copied`` object carrying the
``{source_file_id: new_file_id}`` mapping.
"""
from omnigent.server.server_config import (
copy_file_count_limit,
copy_total_bytes_limit,
)
await _validate_session(session_id, request, LEVEL_EDIT)
if file_store is None or artifact_store is None:
raise HTTPException(
status_code=501,
detail="file store not configured",
)
# Lineage authorization: the source must be a STRICT ancestor up
# the parent_conversation_id chain. A session may not name itself
# as the source — the contract is "copy files down from a parent",
# and a top-level session has no lineage to copy from.
if body.source_session_id not in set(
_ancestor_session_ids(conversation_store, session_id)
):
raise OmnigentError(
"Source session is not an ancestor of this session",
code=ErrorCode.FORBIDDEN,
)
# Validate every source file WITHOUT reading a blob, enforcing the copy
# limits before any blob is read. Summing StoredFile.bytes here means
# an over-count or over-size request is rejected without buffering a
# single blob — a rejected request never spikes memory. artifact_store
# .exists() is a cheap metadata probe (S3 HEAD / local stat / DB row),
# NOT a blob read, so checking it here preserves the original
# "missing blob surfaces before any child row is created" guarantee
# without reintroducing the batch prefetch. The blobs themselves are
# fetched one at a time in the write loop below.
max_files = copy_file_count_limit()
max_total_bytes = copy_total_bytes_limit()
if len(body.file_ids) > max_files:
raise OmnigentError(
f"Cannot copy {len(body.file_ids)} files: limit is {max_files}",
code=ErrorCode.INVALID_INPUT,
)
if len(set(body.file_ids)) != len(body.file_ids):
raise OmnigentError(
"file_ids must not contain duplicates",
code=ErrorCode.INVALID_INPUT,
)
sources: list[StoredFile] = []
total_bytes = 0
for file_id in body.file_ids:
stored = file_store.get(file_id, session_id=body.source_session_id)
if stored is None or not artifact_store.exists(stored.id):
raise OmnigentError(
f"File '{file_id}' not found in source session",
code=ErrorCode.NOT_FOUND,
)
total_bytes += stored.bytes
if total_bytes > max_total_bytes:
raise OmnigentError(
f"Cannot copy files: total size exceeds limit of {max_total_bytes} bytes",
code=ErrorCode.INVALID_INPUT,
)
sources.append(stored)
# Commit the copies one file at a time (read → create → put) so peak
# memory is a single blob, not the whole batch. If any step fails
# mid-batch, roll back the rows/blobs already created.
mapping: dict[str, CopiedFile] = {}
created: list[str] = []
copied: list[StoredFile] = []
try:
for stored in sources:
content = artifact_store.get(stored.id)
new = file_store.create(
session_id=session_id,
filename=stored.filename,
bytes=stored.bytes,
content_type=stored.content_type,
)
created.append(new.id)
artifact_store.put(new.id, content)
# Carry the preserved filename + content_type back so the
# caller can attach the copy without a follow-up metadata GET.
mapping[stored.id] = CopiedFile(
new_id=new.id,
filename=new.filename,
content_type=new.content_type,
)
copied.append(new)
except Exception as exc:
for new_id in created:
try:
file_store.delete(new_id, session_id=session_id)
except Exception: # noqa: BLE001 - rollback cleanup is best effort.
_logger.warning(
"Failed to delete copied file row during rollback: session=%s file_id=%s",
session_id,
new_id,
exc_info=True,
)
try:
artifact_store.delete(new_id)
except Exception: # noqa: BLE001 - rollback cleanup is best effort.
_logger.warning(
"Failed to delete copied file blob during rollback: session=%s file_id=%s",
session_id,
new_id,
exc_info=True,
)
raise OmnigentError(
"Failed to copy files into destination session",
code=ErrorCode.INTERNAL_ERROR,
) from exc
# Resource events fire only after every write lands. Publishing them
# inside the copy loop would emit (and persist as transcript items)
# ``session.resource.created`` for early files, then a later write
# failure would roll back the file rows/blobs without compensating
# those events — clients would see phantom files that no longer
# exist. Keep the create + event all-or-nothing together.
for new in copied:
_publish_and_persist_resource_event(
session_id,
"session.resource.created",
resource_id=new.id,
resource_type="file",
conversation_store=conversation_store,
resource=_stored_file_to_resource(session_id, new),
)
return CopyFilesResponse(
session_id=session_id,
mapping=mapping,
).model_dump()
# ── Phase 3: environment filesystem proxy endpoints ──────────
async def _proxy_fs_response(
session_id: str,
method: str,
path: str,
body: dict[str, Any] | None = None,
*,
request: Request | None = None,
required_level: int = LEVEL_EDIT,
environment_id: str = "default",
publish_invalidation: bool = True,
) -> Any:
"""Proxy a filesystem request to the runner.
Translates runner error status codes into appropriate
API-level exceptions.
:param session_id: Session/conversation identifier.
:param method: HTTP method.
:param path: Runner-relative URL path.
:param body: Optional JSON body.
:param request: The incoming FastAPI request (for auth).
:param required_level: Minimum permission level needed.
:param environment_id: Environment resource id,
e.g. ``"default"``. Used for the live invalidation event
after successful mutating filesystem operations.
:param publish_invalidation: Whether a successful proxied
mutation should publish ``session.changed_files.invalidated``.
False for generic shell commands because read-only commands
are common and cannot be distinguished cheaply here.
:returns: Parsed JSON response.
"""
await _validate_session(session_id, request, required_level)
if method == "GET":
return await _proxy_get_to_runner(session_id, path)
if method == "PUT":
status, payload = await _proxy_put_to_runner(
session_id,
path,
body or {},
)
elif method == "PATCH":
status, payload = await _proxy_patch_to_runner(
session_id,
path,
body or {},
)
elif method == "POST":
status, payload = await _proxy_post_to_runner(
session_id,
path,
body or {},
)
elif method == "DELETE":
status, payload = await _proxy_delete_to_runner(
session_id,
path,
)
else:
raise HTTPException(status_code=405)
if status >= 400:
error = payload.get("error", {})
message = error.get("message", "filesystem operation failed")
if status == 404:
raise OmnigentError(message, code=ErrorCode.NOT_FOUND)
raise HTTPException(status_code=status, detail=message)
if publish_invalidation:
_publish_changed_files_invalidated(session_id, environment_id)
return payload
@router.get(
"/sessions/{session_id}/resources/environments/{environment_id}/filesystem",
response_model=None,
)
async def list_environment_root(
request: Request,
session_id: str,
environment_id: str,
limit: int = Query(default=20, ge=1, le=1000),
after: str | None = Query(default=None),
before: str | None = Query(default=None),
order: str = Query(default="desc", pattern="^(asc|desc)$"),
) -> Any:
"""
List root directory of an environment.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param environment_id: Environment resource id.
:param limit: Maximum number of entries to return (1-1000, default 20).
:param after: Cursor entry id for forward pagination.
:param before: Cursor entry id for backward pagination.
:param order: Sort order, ``"asc"`` or ``"desc"``.
:returns: PaginatedList of filesystem entries.
"""
params: dict[str, str] = {"limit": str(limit), "order": order}
if after is not None:
params["after"] = after
if before is not None:
params["before"] = before
qs = urllib.parse.urlencode(params)
path = f"/v1/sessions/{session_id}/resources/environments/{environment_id}/filesystem?{qs}"
await _validate_session(session_id, request, LEVEL_READ)
return await _proxy_get_to_runner(session_id, path)
@router.get(
"/sessions/{session_id}/resources/environments/{environment_id}/search",
response_model=None,
)
async def search_environment_files(
request: Request,
session_id: str,
environment_id: str,
q: str = Query(min_length=1, pattern=r".*\S.*"),
include: str | None = Query(default=None),
exclude: str | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=500),
) -> Any:
"""
Search for files recursively by name/path substring and glob filters.
Proxies to the runner's search endpoint. Returns a flat list of
matching file entries (not directories) whose name or relative path
contains ``q`` (case-insensitive), optionally scoped by ``include`` /
``exclude`` globs. Requires at least one non-whitespace character in
``q`` to prevent accidental full-tree scans.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param environment_id: Environment resource id,
e.g. ``"default"``.
:param q: Case-insensitive search substring, e.g. ``"test.md"``.
Must contain at least one non-whitespace character.
:param include: Comma-separated glob patterns scoping which files are
returned, e.g. ``"*.ts,src/**"``.
:param exclude: Comma-separated glob patterns for files to drop,
e.g. ``"**/node_modules,*.test.ts"``.
:param limit: Maximum number of results (1-500, default 500).
:returns: JSON list response with matching filesystem entries.
"""
params: dict[str, str] = {"q": q, "limit": str(limit)}
if include is not None:
params["include"] = include
if exclude is not None:
params["exclude"] = exclude
qs = urllib.parse.urlencode(params)
path = f"/v1/sessions/{session_id}/resources/environments/{environment_id}/search?{qs}"
await _validate_session(session_id, request, LEVEL_READ)
return await _proxy_get_to_runner(session_id, path)
@router.get(
"/sessions/{session_id}/resources/environments/{environment_id}/changes",
response_model=None,
)
async def list_environment_filesystem_changes(
request: Request,
session_id: str,
environment_id: str,
) -> Any:
"""
List all files changed since session start (flat, registry-backed).
Returns the watchdog change set for the session — every file
created, modified, or deleted since the session began, regardless
of directory depth. Use for the flat "changed files" view.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param environment_id: Environment resource id.
:returns: Flat list of changed filesystem entries with ``status``.
"""
path = f"/v1/sessions/{session_id}/resources/environments/{environment_id}/changes"
await _validate_session(session_id, request, LEVEL_READ)
return await _proxy_get_to_runner(session_id, path)
@router.get(
"/sessions/{session_id}/resources/environments/{environment_id}/diff/{relative_path:path}",
# Internal (UI diff view) — hidden from the public API reference.
include_in_schema=False,
response_model=None,
)
async def read_environment_file_diff(
request: Request,
session_id: str,
environment_id: str,
relative_path: str,
) -> Any:
"""
Return before/after diff content for a changed file.
Proxies to the runner's diff endpoint and returns before/after
content strings so the UI can render a diff view. Returns 404 when
the file has not been modified this session.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param environment_id: Environment resource id.
:param relative_path: Path relative to environment root.
:returns: JSON with ``before`` and ``after`` content strings.
"""
path = (
f"/v1/sessions/{session_id}/resources/environments"
f"/{environment_id}/diff/{relative_path}"
)
await _validate_session(session_id, request, LEVEL_READ)
return await _proxy_get_to_runner(session_id, path)
@router.get(
"/sessions/{session_id}/resources/environments"
"/{environment_id}/filesystem/{relative_path:path}",
response_model=None,
)
async def read_or_list_environment_path(
request: Request,
session_id: str,
environment_id: str,
relative_path: str,
limit: int = Query(default=20, ge=1, le=1000),
after: str | None = Query(default=None),
before: str | None = Query(default=None),
order: str = Query(default="desc", pattern="^(asc|desc)$"),
) -> Any:
"""
Read a file or list a directory in an environment.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param environment_id: Environment resource id.
:param relative_path: Path relative to environment root.
:param limit: Maximum number of entries to return for directory
listings (1-1000, default 20). Ignored for file reads.
:param after: Cursor entry id for forward pagination.
:param before: Cursor entry id for backward pagination.
:param order: Sort order, ``"asc"`` or ``"desc"``.
:returns: File content or directory listing.
"""
params: dict[str, str] = {"limit": str(limit), "order": order}
if after is not None:
params["after"] = after
if before is not None:
params["before"] = before
qs = urllib.parse.urlencode(params)
path = (
f"/v1/sessions/{session_id}/resources/environments"
f"/{environment_id}/filesystem/{relative_path}?{qs}"
)
await _validate_session(session_id, request, LEVEL_READ)
return await _proxy_get_to_runner(session_id, path)
@router.put(
"/sessions/{session_id}/resources/environments"
"/{environment_id}/filesystem/{relative_path:path}",
response_model=None,
)
async def write_environment_file(
session_id: str,
environment_id: str,
relative_path: str,
request: Request,
) -> Any:
"""
Write/replace a file in an environment.
:param session_id: Session/conversation identifier.
:param environment_id: Environment resource id.
:param relative_path: Path relative to environment root.
:param request: JSON body with ``content``.
:returns: Write result.
"""
body = await request.json()
path = (
f"/v1/sessions/{session_id}/resources/environments"
f"/{environment_id}/filesystem/{relative_path}"
)
return await _proxy_fs_response(
session_id,
"PUT",
path,
body,
request=request,
environment_id=environment_id,
)
@router.patch(
"/sessions/{session_id}/resources/environments"
"/{environment_id}/filesystem/{relative_path:path}",
response_model=None,
)
async def edit_environment_file(
session_id: str,
environment_id: str,
relative_path: str,
request: Request,
) -> Any:
"""
Edit a file in an environment via text replacement.
:param session_id: Session/conversation identifier.
:param environment_id: Environment resource id.
:param relative_path: Path relative to environment root.
:param request: JSON body with ``old_text`` and ``new_text``.
:returns: Edit result.
"""
body = await request.json()
path = (
f"/v1/sessions/{session_id}/resources/environments"
f"/{environment_id}/filesystem/{relative_path}"
)
return await _proxy_fs_response(
session_id,
"PATCH",
path,
body,
request=request,
environment_id=environment_id,
)
@router.delete(
"/sessions/{session_id}/resources/environments"
"/{environment_id}/filesystem/{relative_path:path}",
response_model=None,
)
async def delete_environment_path(
request: Request,
session_id: str,
environment_id: str,
relative_path: str,
) -> Any:
"""
Delete a file or directory in an environment.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier.
:param environment_id: Environment resource id.
:param relative_path: Path relative to environment root.
:returns: Delete result.
"""
path = (
f"/v1/sessions/{session_id}/resources/environments"
f"/{environment_id}/filesystem/{relative_path}"
)
return await _proxy_fs_response(
session_id,
"DELETE",
path,
request=request,
environment_id=environment_id,
)
# ── Phase 5: environment shell proxy ─────────────────────────
@router.post(
"/sessions/{session_id}/resources/environments/{environment_id}/shell",
response_model=None,
# CSRF hardening: body is parsed via request.json(); require a JSON
# Content-Type so a cross-site text/plain request can't reach it.
dependencies=[Depends(require_json_content_type)],
)
async def run_environment_shell(
session_id: str,
environment_id: str,
request: Request,
) -> Any:
"""
Execute a shell command in an environment.
:param session_id: Session/conversation identifier.
:param environment_id: Environment resource id.
:param request: JSON body with ``command`` and optional
``timeout``.
:returns: Shell result.
"""
body = await request.json()
path = f"/v1/sessions/{session_id}/resources/environments/{environment_id}/shell"
return await _proxy_fs_response(
session_id,
"POST",
path,
body,
request=request,
environment_id=environment_id,
publish_invalidation=False,
)
# Generic single-resource lookup — registered AFTER typed
# collections so "environments", "terminals", "files" are not
# captured as resource_id.
@router.get(
"/sessions/{session_id}/resources/{resource_id}",
response_model=None,
)
async def get_session_resource(
request: Request,
session_id: str,
resource_id: str,
) -> dict[str, Any]:
"""
Return a single resource by id from the unified inventory.
:param session_id: Session/conversation identifier.
:param resource_id: Opaque resource id.
:returns: The resource object regardless of type.
"""
await _validate_session(session_id, request, LEVEL_READ)
path = f"/v1/sessions/{session_id}/resources/{resource_id}"
return await _proxy_get_to_runner(session_id, path)
# ── Embedded-browser action bridge ───────────────────────────
@router.post(
"/sessions/{session_id}/browser/action_request",
# Internal embedded-browser flow — hidden from the public API reference.
include_in_schema=False,
response_model=None,
)
async def browser_action_request(
request: Request,
session_id: str,
body: dict[str, Any],
) -> dict[str, Any]:
"""
Park one embedded-browser action and await the renderer result.
Mints an ``action_id``, parks a Future owned by ``session_id``, publishes
a ``browser.action_request`` event, and awaits up to
``_BROWSER_ACTION_AWAIT_S``; on timeout returns the timeout result (HTTP
200) so the runner gets a clean tool error. Called by the runner's
``browser_*`` dispatch, not the LLM.
:param request: The inbound request, used for identity extraction.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param body: ``{"action": <str>, "args": <dict>}`` where ``action``
is the ``browser_`` tool name minus the prefix.
:returns: The renderer's action-result JSON, or the timeout result.
:raises OmnigentError: 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
action = body.get("action")
args = body.get("args")
if not isinstance(action, str) or not action:
raise OmnigentError(
"browser action_request requires a non-empty 'action'",
code=ErrorCode.INVALID_INPUT,
)
if not isinstance(args, dict):
args = {}
action_id = f"baction_{secrets.token_hex(16)}"
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
_browser_action_registry[action_id] = future
_browser_action_owners[action_id] = session_id
try:
event = BrowserActionRequestEvent(
type="browser.action_request",
action_id=action_id,
action=action,
args=args,
)
session_stream.publish(session_id, event.model_dump())
done, _pending = await asyncio.wait(
{future},
timeout=_BROWSER_ACTION_AWAIT_S,
return_when=asyncio.FIRST_COMPLETED,
)
if future in done and not future.cancelled():
return future.result()
# Timed out/cancelled with no renderer result (no subscribed app).
return _BROWSER_ACTION_TIMEOUT_RESULT
finally:
# Drop registry entries so a resolved/timed-out action leaks nothing.
if _browser_action_registry.get(action_id) is future:
_browser_action_registry.pop(action_id, None)
_browser_action_owners.pop(action_id, None)
_browser_action_claims.pop(action_id, None)
@router.post(
"/sessions/{session_id}/browser/action_claim/{action_id}",
# Internal embedded-browser flow — hidden from the public API reference.
include_in_schema=False,
response_model=None,
)
async def browser_action_claim(
request: Request,
session_id: str,
action_id: str,
) -> dict[str, Any]:
"""
Atomically claim a parked browser action (one winner per action).
The request event fans out to every subscribed renderer; an atomic
``setdefault`` grants exactly one claim so they don't double-execute.
Winner gets ``{"claimed": true, "claim_token": <token>}``; everyone
else ``{"claimed": false}``.
:param request: The inbound request, used for identity extraction.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param action_id: The action to claim, e.g. ``"baction_abc123"``.
:returns: ``{"claimed": true, "claim_token": <str>}`` to the winner,
``{"claimed": false}`` to losers or for an unknown/expired action.
:raises OmnigentError: 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
# Unknown / already-resolved action: nothing to claim.
if _browser_action_owners.get(action_id) != session_id:
return {"claimed": False}
# Single-winner lease via atomic setdefault: a losing racer sees the
# winner's token, not its own, and bails.
claim_token = secrets.token_hex(16)
existing = _browser_action_claims.setdefault(action_id, claim_token)
if existing != claim_token:
return {"claimed": False}
return {"claimed": True, "claim_token": claim_token}
@router.post(
"/sessions/{session_id}/browser/action_result/{action_id}",
# Internal embedded-browser flow — hidden from the public API reference.
include_in_schema=False,
status_code=202,
response_model=None,
)
async def browser_action_result(
request: Request,
session_id: str,
action_id: str,
body: dict[str, Any],
) -> dict[str, bool]:
"""
Deliver a browser action result, resolving the parked Future.
Guarded by owner + claim-token: the caller must present the token this
action was leased under, so a renderer that lost the claim race can't
resolve the Future with stale work (tokenless/mismatched → 403).
:param request: The inbound request, used for identity extraction.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param action_id: The action being resolved, e.g. ``"baction_abc"``.
:param body: ``{"result": <dict>, "claim_token": <str>}``.
:returns: ``{"resolved": true}`` when the Future was set,
``{"resolved": false}`` when it was already done/gone.
:raises OmnigentError: 404 if no session exists; 403 on a missing or
mismatched claim token or an owner mismatch.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
claim_token = body.get("claim_token")
expected = _browser_action_claims.get(action_id)
if not isinstance(claim_token, str) or expected is None or claim_token != expected:
raise OmnigentError(
"browser action result requires a matching claim_token",
code=ErrorCode.FORBIDDEN,
)
# Only the session that issued the action may resolve it.
if _browser_action_owners.get(action_id) != session_id:
raise OmnigentError(
"browser action is not owned by this session",
code=ErrorCode.FORBIDDEN,
)
future = _browser_action_registry.get(action_id)
if future is None or future.done():
return {"resolved": False}
result = body.get("result")
future.set_result(result if isinstance(result, dict) else {"result": result})
return {"resolved": True}
# ── POST /sessions/{session_id}/events ───────────────────────
@router.post(
"/sessions/{session_id}/elicitations/{elicitation_id}/resolve",
# Internal elicitation flow — hidden from the public API reference.
include_in_schema=False,
status_code=202,
# response_model=None: the body is a small acknowledgement
# dict, not a domain model.
response_model=None,
)
async def resolve_elicitation(
request: Request,
session_id: str,
elicitation_id: str,
body: ElicitationResult,
) -> dict[str, bool]:
"""
Resolve an outstanding elicitation by its URL (URL-based
elicitation).
The dedicated, RESTful counterpart to delivering a verdict
via the ``type == "approval"`` event on
``POST /v1/sessions/{id}/events``. An elicitation request
published in ``mode == "url"`` carries this endpoint's path
as its ``params.url``; the client hits it directly with the
MCP :class:`ElicitationResult` body instead of POSTing a
generic approval event. The verdict routes through the
shared :func:`_resolve_elicitation`, so resolution semantics
are identical to the event path.
The ``elicitation_id`` is taken from the URL rather than the
body, so the unguessable id (``secrets.token_hex(16)``) is
the capability scoping the resolution — combined with the
session-owner ``LEVEL_EDIT`` gate below and the server-side
ownership check inside :func:`_resolve_elicitation`.
:param request: The inbound request, used for identity
extraction.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param elicitation_id: Correlation id of the elicitation to
resolve, e.g. ``"elicit_abc123"``. Taken from the URL
path, not the body.
:param body: The MCP-shaped verdict — ``action``
(``"accept"`` / ``"decline"`` / ``"cancel"``) plus
optional form ``content``.
:returns: ``{"queued": False}`` — resolution is synchronous
and persists no conversation item.
:raises OmnigentError: 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
_resolve_data = {"elicitation_id": elicitation_id, **body.model_dump(exclude_none=True)}
await _resolve_elicitation(session_id, _resolve_data, runner_router, conversation_store)
# Apply any policy writes deferred by the relay tool-call ASK gate
# (e.g. a cost-budget checkpoint) now that the verdict is in.
await _apply_pending_policy_ask_writes(
session_id, conv, conversation_store, agent_store, _resolve_data
)
return {"queued": False}
@router.get(
"/sessions/{session_id}/elicitations/{elicitation_id}",
# Internal elicitation flow — hidden from the public API reference.
include_in_schema=False,
response_model=None,
)
async def get_elicitation(
request: Request,
session_id: str,
elicitation_id: str,
) -> dict[str, Any]:
"""
Return the state of a pending elicitation as JSON.
Used by the frontend's standalone approval page
(``/approve/:sessionId/:elicitationId``) to fetch the
elicitation prompt and render approve/reject controls.
The payload is read from the in-memory
:mod:`omnigent.runtime.pending_elicitations` index — no
database persistence required.
:param request: The inbound request, used for identity
extraction.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param elicitation_id: Correlation id of the elicitation,
e.g. ``"elicit_abc123"``.
:returns: JSON with ``status`` (``"pending"`` or
``"resolved"``), and when pending: ``message``,
``phase``, ``policy_name``, ``content_preview``.
:raises OmnigentError: 404 if the session does not exist.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
if access.conversation is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
found = pending_elicitations.lookup(elicitation_id)
if found is None or found[0] != session_id:
return {"status": "resolved"}
_conv_id, event = found
params = event.get("params") if isinstance(event.get("params"), dict) else {}
return {
"status": "pending",
"message": params.get("message", "Approval required"),
"phase": params.get("phase", ""),
"policy_name": params.get("policy_name", ""),
"content_preview": params.get("content_preview", ""),
}
@router.post(
"/sessions/{session_id}/events",
# Internal event ingestion — hidden from the public API reference.
include_in_schema=False,
status_code=202,
# response_model=None: the body is a small acknowledgement
# dict, not a domain model.
response_model=None,
)
async def post_event(
request: Request,
session_id: str,
body: SessionEventInput,
) -> dict[str, bool | str]:
"""
Submit a session event (input message, tool output,
approval, or interrupt).
Dispatches on ``body.type``:
- ``"interrupt"`` cancels any active task and publishes a
``session.interrupted`` event. Bypasses item persistence.
- ``"approval"`` resolves an outstanding elicitation
in-band (see :func:`_dispatch_approval`).
- ``"external_assistant_message"`` appends and streams an
assistant message observed outside the Omnigent task runtime,
without starting or steering a task.
- ``"external_conversation_item"`` appends and streams a
completed item observed outside the Omnigent task runtime,
without starting or steering a task.
- ``"external_output_text_delta"`` publishes a transient
``response.output_text.delta`` event observed outside the
Omnigent task runtime, without persisting an item or starting /
steering a task.
- ``"external_output_reasoning_delta"`` publishes a transient
``response.reasoning_text.delta`` event (preceded by one
``response.reasoning.started`` when ``data.started`` is true)
observed outside the Omnigent task runtime, without persisting an
item or starting / steering a task.
- ``"external_session_interrupted"`` publishes a
``session.interrupted`` event observed outside the Omnigent task
runtime, without persisting an item or starting / steering a
task.
- ``"external_elicitation_resolved"`` marks a native
harness-originated elicitation as resolved elsewhere so
subscribed clients clear the pending approval card.
- ``"external_session_status"`` publishes a terminal-observed
``session.status`` edge without persisting an item or
starting/steering a task.
- ``"external_model_change"`` persists a terminal-observed
model switch to ``model_override`` and publishes a
``session.model`` SSE event so the web picker reflects it.
- ``"external_reasoning_effort_change"`` persists a terminal-observed
thinking-level switch to ``reasoning_effort`` and publishes a
``session.reasoning_effort`` SSE event so the web picker reflects it.
- ``"external_codex_collaboration_mode_change"`` persists the
Codex app-server collaboration mode kind as an internal session label
(``omnigent.codex_native.collaboration_mode``).
- ``"stop_session"`` terminates the live session without
deleting the conversation (owner-only). Forwarded
harness-agnostically to the runner, which hard-kills the
external process for harnesses that have one (claude-native
kills its tmux pane) and 204s otherwise. Stop is non-sticky:
it writes no persistent marker, so the next message
auto-relaunches the session on its (still-online) host via
the normal message-dispatch relaunch path.
- ``"message"`` on an ``omnigent claude`` terminal session
is forwarded to the bound runner for tmux injection only;
the accepted prompt is persisted later when Claude records
it in the terminal transcript.
- Any other (item-typed) event is persisted into
``conversation_items`` via the legacy create-or-steer path
(legacy persist path): if an active
task is present, the item is delivered into its inbox;
otherwise a new task is created and started. In both
cases ``session.input.consumed`` fires with the persisted
item's id.
:param session_id: Session/conversation identifier.
:param body: The validated :class:`SessionEventInput`.
:returns: ``{"queued": True, "item_id": "..."}`` for
item-typed events, where ``item_id`` is the persisted
conversation item id also emitted by
``session.input.consumed``; ``{"queued": False}`` for
control and internal transient events.
:raises OmnigentError: 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
# Validate event type at the route boundary. Anything not in
# ``_ALLOWED_EVENT_TYPES`` is a client mistake — failing here
# is far better than silently persisting an item the agent
# loop will only crash on later when ``parse_item_data`` runs
# against the payload (rule 15 — fail loud).
if body.type not in _ALLOWED_EVENT_TYPES:
raise OmnigentError(
f"Unknown event type: {body.type!r}. "
f"Allowed types: {sorted(_ALLOWED_EVENT_TYPES)}",
code=ErrorCode.INVALID_INPUT,
)
# For item types, validate the data payload shape against
# the item-type's discriminator class. The control types
# (interrupt, approval) bypass the item-persist path and have
# their own payload schemas — they skip this check (interrupt
# has no payload; approval's MCP-shape payload is validated
# inside ``_dispatch_approval``).
if body.type not in (
_INTERRUPT_TYPE,
_APPROVAL_TYPE,
_MCP_ELICITATION_TYPE,
_COMPACT_TYPE,
_SLASH_COMMAND_TYPE,
_STOP_SESSION_TYPE,
_EXTERNAL_ASSISTANT_MESSAGE_TYPE,
_EXTERNAL_CONVERSATION_ITEM_TYPE,
_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE,
_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE,
_EXTERNAL_SESSION_INTERRUPTED_TYPE,
_EXTERNAL_SESSION_SUPERSEDED_TYPE,
_EXTERNAL_ELICITATION_RESOLVED_TYPE,
_EXTERNAL_SESSION_STATUS_TYPE,
_EXTERNAL_SESSION_USAGE_TYPE,
_EXTERNAL_COMPACTION_STATUS_TYPE,
_EXTERNAL_MCP_STARTUP_TYPE,
_EXTERNAL_MODEL_CHANGE_TYPE,
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
_EXTERNAL_SESSION_TODOS_TYPE,
_EXTERNAL_SUBAGENT_START_TYPE,
_EXTERNAL_CODEX_SUBAGENT_START_TYPE,
_EXTERNAL_CODEX_COLLABORATION_MODE_CHANGE_TYPE,
):
try:
parse_item_data(body.type, {"type": body.type, **body.data})
except (ValueError, TypeError) as exc:
raise OmnigentError(
f"Invalid data payload for event type {body.type!r}: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
# Fail fast on malformed tools at the boundary. The raw dicts
# (not the parsed objects) are what the runner stores — the
# parse call is purely a validator.
if body.tools:
try:
parse_client_side_tool_specs(body.tools)
except ValueError as exc:
raise OmnigentError(str(exc), code=ErrorCode.INVALID_INPUT) from exc
# ── Policy evaluation (path-agnostic) ────────────────
# Evaluate policies BEFORE persistence/runner forwarding so
# enforcement fires on both paths. On DENY, persist the
# event (possibly with modified body) through whichever
# path is active, then return the deny verdict. On ALLOW,
# fall through to the normal persist/forward path.
_policy_body = body # may be replaced by OUTPUT deny
_actor = _build_actor(user_id)
# A closed sub-agent session (sys_session_close) rejects new user
# input — the orchestrator must spawn a fresh session to continue.
if (
body.type == "message"
and body.data.get("role") == "user"
and is_session_closed(conv.labels, conv.title)
):
raise OmnigentError(
"Session is closed. Start a new sub-agent session to continue.",
code=ErrorCode.CONFLICT,
)
if (
body.type == "message"
and body.data.get("role") == "user"
and conv.agent_id is not None
):
try:
_input_verdict = await _evaluate_input_policy(
request,
session_id,
conv,
body,
conversation_store,
agent_store,
runner_router,
actor=_actor,
)
except Exception as _policy_exc: # noqa: BLE001 — fail-safe for misconfigured policies
# Policy evaluation crashed (e.g. factory misconfigured).
# Log and treat as DENY so the session doesn't hang on
# "working" forever. The full cause is logged for admins;
# the denial reason returned to (and streamed at) the client
# stays generic so the raw exception text isn't exposed.
_logger.warning(
"Input policy evaluation failed for %s: %s",
session_id,
_policy_exc,
exc_info=True,
)
_input_verdict = {
"verdict": "deny",
"reason": "Denied by policy (policy evaluation error).",
}
if _input_verdict is not None:
# DENY or ASK — don't forward to runner. Publish a
# deny sentinel on the session stream so the
# client/REPL sees feedback.
reason = _input_verdict.get("reason", "Denied by policy")
_publish_status(session_id, "running")
_publish_policy_deny(session_id, reason)
await _persist_policy_deny_sentinel(
session_id,
conv,
reason,
conversation_store,
agent_store,
)
# Terminal response.completed before idle so live-tail
# consumers (the headless ``-p`` client) unblock.
_publish_input_deny_terminal(session_id, conv, reason)
_publish_status(session_id, "idle")
# Return the same shape the client expects from POST
# /events so postEvent doesn't throw on an unexpected
# response body. queued=False signals the event was
# handled synchronously (denied, not queued for a turn).
return {"queued": False, "denied": True, "reason": reason}
elif body.type == _SLASH_COMMAND_TYPE and conv.agent_id is not None:
_input_verdict = await _evaluate_input_policy(
request,
session_id,
conv,
_build_skill_slash_command_policy_body(body),
conversation_store,
agent_store,
runner_router,
)
if _input_verdict is not None:
reason = _input_verdict.get("reason", "Denied by policy")
_publish_status(session_id, "running")
_publish_policy_deny(session_id, reason)
await _persist_policy_deny_sentinel(
session_id,
conv,
reason,
conversation_store,
agent_store,
)
# Terminal response.completed before idle (see message branch).
_publish_input_deny_terminal(session_id, conv, reason)
_publish_status(session_id, "idle")
return {"queued": False, "denied": True, "reason": reason}
elif (
body.type == "message"
and body.data.get("role") == "assistant"
and conv.agent_id is not None
):
_output_verdict = await _evaluate_output_policy(
session_id,
conv,
body,
conversation_store,
agent_store,
runner_router,
actor=_actor,
)
if _output_verdict is not None:
if _output_verdict.get("_denied_body") is not None:
_policy_body = _output_verdict["_denied_body"]
body = _policy_body
# For OUTPUT DENY, fall through to persist the
# denied body (with sentinel text). The verdict
# is returned after persistence below.
if _output_verdict["verdict"] == "deny":
pass # fall through with modified body
else:
return _output_verdict
elif body.type == "function_call" and body.data.get("evaluate_policy"):
_tool_verdict = await _evaluate_tool_call_policy(
session_id,
conv,
body,
conversation_store,
agent_store,
runner_router,
actor=_actor,
)
if _tool_verdict is not None:
return _tool_verdict
# ALLOW — return explicit verdict so the request does
# not fall through to the persist-and-forward path.
# Policy evaluation requests are queries, not items to
# persist or relay to the harness (which rejects
# ``function_call`` as an unknown inbound event type).
return {"verdict": "allow"}
if body.type == _INTERRUPT_TYPE:
_publish_interrupted(session_id)
# Fence the cancelled turn (see _interrupt_fenced_sessions).
_interrupt_fenced_sessions.add(session_id)
runner_client = await _get_runner_client(
session_id,
runner_router,
)
interrupt_delivered = False
if runner_client is not None:
try:
interrupt_resp = await runner_client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "interrupt"},
timeout=5.0,
)
interrupt_delivered = interrupt_resp.status_code < 400
except (httpx.HTTPError, ConnectionError):
# WSTunnelTransport raises bare ConnectionError on tunnel close.
_logger.exception(
"Interrupt forward failed for %r",
session_id,
)
if not interrupt_delivered:
# The turn keeps running and nothing else lifts the fence —
# remove it so the turn's remaining output isn't dropped.
_interrupt_fenced_sessions.discard(session_id)
return {"queued": False}
if body.type == _STOP_SESSION_TYPE:
# Terminating the whole session (not just the current turn)
# is a lifecycle action; require owner access on top of the
# LEVEL_EDIT gate above so a shared editor can't kill the
# owner's session.
await _require_access(
user_id, session_id, LEVEL_OWNER, permission_store, conversation_store
)
# Fence the cancelled turn, same as interrupt.
_interrupt_fenced_sessions.add(session_id)
# Harness-agnostic forward: the runner kills the external
# process for harnesses that have one (claude-native
# hard-kills its tmux pane) and 204s otherwise. Unlike the
# best-effort effort/model_change relay, a failed stop means
# the session is still alive — so this helper RAISES on a
# non-2xx / unreachable runner (503) rather than swallowing
# it, letting the web UI show the stop didn't land instead
# of closing the dialog as if it succeeded.
try:
stop_delivered = await _stop_session_via_runner(session_id, runner_router)
except Exception:
# Stop didn't land: the turn keeps running, so lift the
# fence or its remaining output is dropped forever.
_interrupt_fenced_sessions.discard(session_id)
raise
if not stop_delivered:
# No runner resolved: nothing else lifts the fence (same as interrupt).
_interrupt_fenced_sessions.discard(session_id)
# Host-spawned sessions run on a dedicated runner the host
# launched for this one session. Killing the pane (above) leaves
# that runner connected, so GET /health keeps reporting
# runner_online: true and the web UI never shows the session as
# disconnected — new messages hang on "working" against a dead
# pane. Stop the runner too so its tunnel drops and the web UI
# shows the same "Agent disconnected — click to show reconnect
# command" banner a CLI-launched session reaches on exit. Read
# host_id / runner_id from the owner-gated session row so we can
# only ever stop the runner bound to this session.
stop_conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if stop_conv is not None and stop_conv.host_id and stop_conv.runner_id:
await _stop_session_host_runner(
session_id,
stop_conv.host_id,
stop_conv.runner_id,
getattr(request.app.state, "host_registry", None),
)
# Stop is non-sticky: no persistent marker is written. The
# runner tunnel dropping above flips ``runner_online`` to false
# honestly, and the next message auto-relaunches the session on
# its (still-online) host via the normal message-dispatch
# relaunch path below.
return {"queued": False}
if body.type == _APPROVAL_TYPE:
# Deliver the verdict through the shared resolver: it
# sets any server-side harness Future (owner-checked),
# clears the sidebar badge, and forwards
# to the runner for runner-side (policy) elicitations.
# The dedicated URL endpoint (``.../elicitations/{eid}/
# resolve``) routes through the same helper.
await _resolve_elicitation(session_id, body.data, runner_router, conversation_store)
# Apply any policy writes deferred by the relay tool-call ASK gate
# (e.g. a cost-budget checkpoint) now that the verdict is in.
await _apply_pending_policy_ask_writes(
session_id, conv, conversation_store, agent_store, body.data
)
return {"queued": False}
if body.type == _MCP_ELICITATION_TYPE:
# The runner's inline MCP elicitation callback fires when
# an external MCP server sends ``elicitation/create``
# during a ``tools/call``. Publish the elicitation as an
# SSE event (approval card in web UI, y/a/n prompt in
# REPL) and return the elicitation_id immediately so the
# runner can park on ``pending_approvals``. The user's
# verdict arrives later via ``type: "approval"`` →
# ``_resolve_elicitation`` → ``_forward_approval_to_runner``
# → runner's ``pending_approvals`` resolves.
elicit_data = body.data or {}
elicit_id = f"elicit_{secrets.token_hex(16)}"
elicit_params = ElicitationRequestParams(
mode="form",
message=elicit_data.get("message", ""),
requestedSchema=elicit_data.get("requestedSchema"),
)
event = ElicitationRequestEvent(
type="response.elicitation_request",
elicitation_id=elicit_id,
params=elicit_params,
)
_mcp_elicit_payload = event.model_dump()
session_stream.publish(session_id, _mcp_elicit_payload)
# Mirror the prompt into ancestor streams so a sub-agent MCP
# elicitation surfaces in the parent (polly) chat with a
# ``target_session_id`` pointing back at this child. The
# verdict still arrives via the generic ``approval`` event,
# which mirrors the resolved signal back up through
# ``_resolve_elicitation``.
await asyncio.to_thread(
_publish_elicitation_request_to_ancestors,
conversation_store,
session_id,
_mcp_elicit_payload,
)
return {"queued": False, "elicitation_id": elicit_id}
if body.type == _COMPACT_TYPE:
# Unified control dispatch (designs/CLAUDE_NATIVE.md
# "Control events dispatch on the runner"): forward /compact
# to the bound runner first, regardless of harness. The
# runner dispatches by harness — claude-native injects
# /compact into the tmux pane so Claude Code compacts its
# own context and returns 200; other harnesses 204 no-op.
# The Omnigent server stays harness-agnostic: it runs its own
# in-process compaction only when the runner did NOT handle
# the control (204 / no runner bound). A 4xx/5xx from the
# runner (e.g. 503 when the claude-native pane isn't
# attached) is surfaced as an error rather than silently
# falling through to AP-side compaction, which would be
# wrong for a terminal-owned session.
runner_result = await _forward_session_change_to_runner(
session_id,
runner_router,
{"type": _COMPACT_TYPE},
)
if runner_result is not None and runner_result.status_code == 200:
return {"queued": False}
if runner_result is not None and runner_result.status_code != 204:
raise OmnigentError(
f"Compaction failed: runner returned {runner_result.status_code}",
code=ErrorCode.INTERNAL_ERROR,
)
await _run_compact_locked(
session_id,
conv,
agent_store,
agent_cache,
)
return {"queued": False}
if body.type == "compaction":
import uuid as _uuid
item = NewConversationItem(
type="compaction",
response_id=f"compact_{_uuid.uuid4().hex}",
data=parse_item_data("compaction", body.data),
)
await asyncio.to_thread(
conversation_store.append,
session_id,
[item],
)
return {"queued": True}
if body.type == _EXTERNAL_ASSISTANT_MESSAGE_TYPE:
item_id = await _persist_external_assistant_message(
session_id,
body,
conversation_store,
)
return {"queued": False, "item_id": item_id}
if body.type == _EXTERNAL_CONVERSATION_ITEM_TYPE:
item_id = await _persist_external_conversation_item(
session_id,
conv,
body,
conversation_store,
created_by=_attribution_user(user_id),
)
return {"queued": False, "item_id": item_id}
if body.type == _EXTERNAL_OUTPUT_TEXT_DELTA_TYPE:
_publish_external_output_text_delta(session_id, body)
return {"queued": False}
if body.type == _EXTERNAL_OUTPUT_REASONING_DELTA_TYPE:
_publish_external_output_reasoning_delta(session_id, body)
return {"queued": False}
if body.type == _EXTERNAL_SESSION_INTERRUPTED_TYPE:
response_id = body.data.get("response_id")
if response_id is not None and not isinstance(response_id, str):
raise OmnigentError(
"external_session_interrupted data.response_id must be a string",
code=ErrorCode.INVALID_INPUT,
)
_publish_interrupted(session_id, response_id=response_id)
return {"queued": False}
if body.type == _EXTERNAL_SESSION_SUPERSEDED_TYPE:
target_conversation_id = body.data.get("target_conversation_id")
if not isinstance(target_conversation_id, str) or not target_conversation_id.strip():
raise OmnigentError(
"external_session_superseded requires a non-empty string "
"data.target_conversation_id",
code=ErrorCode.INVALID_INPUT,
)
_publish_session_superseded(session_id, target_conversation_id.strip())
return {"queued": False}
if body.type == _EXTERNAL_ELICITATION_RESOLVED_TYPE:
elicitation_id = body.data.get("elicitation_id")
if not isinstance(elicitation_id, str):
raise OmnigentError(
"external_elicitation_resolved requires string data.elicitation_id.",
code=ErrorCode.INVALID_INPUT,
)
_signal_harness_elicitation_resolved_by_id(session_id, elicitation_id)
return {"queued": False}
if body.type == _EXTERNAL_SESSION_STATUS_TYPE:
status = body.data.get("status")
if status not in _EXTERNAL_SESSION_STATUS_VALUES:
raise OmnigentError(
f"external_session_status requires data.status in "
f"{sorted(_EXTERNAL_SESSION_STATUS_VALUES)}; got {status!r}",
code=ErrorCode.INVALID_INPUT,
)
response_id = body.data.get("response_id")
if response_id is not None and not isinstance(response_id, str):
raise OmnigentError(
"external_session_status data.response_id must be a string",
code=ErrorCode.INVALID_INPUT,
)
# Surface the failure reason a native forwarder carries so a
# top-level session sees it on its own status edge and persisted
# last_task_error, not only the sub-agent parent-inbox path.
output = body.data.get("output")
status_error: ErrorDetail | None = None
if status == "failed" and isinstance(output, str) and output.strip():
status_error = ErrorDetail(
code=(
"codex_reauth_required"
if body.data.get("reauth_required") is True
else "codex_turn_error"
),
message=output.strip(),
)
if status_error is not None:
await _persist_session_status_error_labels(
session_id, status_error, conversation_store
)
elif status == "running":
await _persist_session_status_error_labels(session_id, None, conversation_store)
# ``None`` (field absent) = no information; leave the sticky
# tally untouched (the PTY-activity ``idle`` carries none). An
# explicit ``0`` from a ``Stop`` hook is authoritative and clears
# the tally, so a finished background shell drops the indicator.
raw_bg_count = body.data.get("background_task_count")
bg_count = (
raw_bg_count
if isinstance(raw_bg_count, int)
and not isinstance(raw_bg_count, bool)
and raw_bg_count >= 0
else None
)
# A sub-agent's background-task ``waiting`` must deliver as ``idle``
# so the parent's terminal-delivery branch below fires (otherwise
# the orchestrator hangs); the tally still drives the child spinner.
effective_status = _subagent_delivery_status(status, bg_count, conv)
if effective_status != status:
status = effective_status
body.data["status"] = status
_publish_status(
session_id,
status,
status_error,
response_id=response_id,
background_task_count=bg_count,
)
forward_body = body.model_dump()
forward_body["data"] = await _enrich_idle_status_with_subagent_output(
forward_body["data"], status, session_id, conversation_store
)
runner_result = await _forward_session_change_to_runner(
session_id,
runner_router,
forward_body,
)
if (
conv.kind == "sub_agent"
and status in {"idle", "failed"}
and not _is_codex_native_subagent(conv)
):
# Codex-internal children are tracked inside the same
# app-server thread tree; they have no runner inbox entry
# to forward terminal status to.
if runner_result is None:
# The child's pinned runner_id is stale — its runner was
# relaunched under a new id and only the parent was
# rebound, so the child points at a dead runner forever and
# this terminal status would 503 indefinitely while the
# parent hangs waiting for the child's inbox result. Heal
# the binding and re-deliver through the parent's live
# runner before failing.
recovered = await _recover_subagent_status_forward_via_parent(
conv,
runner_router,
getattr(request.app.state, "tunnel_registry", None),
conversation_store,
forward_body,
)
if recovered is not None:
runner_result = recovered
_require_external_status_forward(
session_id,
status,
runner_result,
)
return {"queued": False}
if body.type == _EXTERNAL_COMPACTION_STATUS_TYPE:
# Terminal-observed compaction edge (claude-native forwarder):
# republish as the standard compaction SSE so the web UI
# spinner brackets Claude's real terminal compaction. No token
# count is available here — the context ring is updated
# separately by external_session_usage — so completed carries
# total_tokens=None.
compaction_status = body.data.get("status")
if compaction_status not in _EXTERNAL_COMPACTION_STATUS_VALUES:
raise OmnigentError(
f"external_compaction_status requires data.status in "
f"{sorted(_EXTERNAL_COMPACTION_STATUS_VALUES)}; got {compaction_status!r}",
code=ErrorCode.INVALID_INPUT,
)
if compaction_status == "in_progress":
_publish_compaction_in_progress(session_id)
elif compaction_status == "completed":
_publish_compaction_completed(session_id, None)
else:
_publish_compaction_failed(session_id)
return {"queued": False}
if body.type == _EXTERNAL_MCP_STARTUP_TYPE:
# Harness MCP-server startup progress (codex-native forwarder):
# republish as a ``session.mcp_startup`` SSE so the web UI shows
# per-server startup state while the harness boots. Malformed
# entries are rejected at the boundary — a bogus map would only
# strand the UI's startup band.
raw_servers = body.data.get("servers")
if not isinstance(raw_servers, dict):
raise OmnigentError(
"external_mcp_startup requires data.servers to be an object "
f"mapping server names to startup records; got {raw_servers!r}",
code=ErrorCode.INVALID_INPUT,
)
mcp_servers: dict[str, McpServerStartup] = {}
for server_name, record in raw_servers.items():
record_status = record.get("status") if isinstance(record, dict) else None
if not (
isinstance(server_name, str)
and server_name
and record_status in _EXTERNAL_MCP_STARTUP_STATUS_VALUES
):
raise OmnigentError(
"external_mcp_startup server records require a status in "
f"{sorted(_EXTERNAL_MCP_STARTUP_STATUS_VALUES)}; got "
f"{server_name!r}: {record!r}",
code=ErrorCode.INVALID_INPUT,
)
record_error = record.get("error")
mcp_servers[server_name] = McpServerStartup(
status=record_status,
error=record_error if isinstance(record_error, str) and record_error else None,
)
_publish_mcp_startup(session_id, mcp_servers)
return {"queued": False}
if body.type == _EXTERNAL_SESSION_USAGE_TYPE:
# Persist the harness-reported cumulative usage so the
# tool-call cost gate can read the running
# ``total_cost_usd`` on the next tool call. (Cost budgets
# now enforce at ``tool_call`` via the PreToolUse hook, not
# post-hoc here — a logged output cannot be un-logged.)
await _persist_external_session_usage(
session_id,
body,
conversation_store,
)
return {"queued": False}
if body.type == _EXTERNAL_MODEL_CHANGE_TYPE:
await _persist_external_model_change(
session_id,
conv,
body,
conversation_store,
)
return {"queued": False}
if body.type == _EXTERNAL_REASONING_EFFORT_CHANGE_TYPE:
await _persist_external_reasoning_effort_change(
session_id,
conv,
body,
conversation_store,
)
return {"queued": False}
if body.type == _EXTERNAL_CODEX_COLLABORATION_MODE_CHANGE_TYPE:
await _persist_external_codex_collaboration_mode_change(
session_id,
conv,
body,
conversation_store,
)
return {"queued": False}
if body.type == _EXTERNAL_SESSION_TODOS_TYPE:
_handle_external_session_todos(session_id, body)
return {"queued": False}
if body.type == _EXTERNAL_SUBAGENT_START_TYPE:
child_id = await _persist_external_subagent_start(
session_id,
conv,
body,
conversation_store,
)
# Returned to the claude-native forwarder so it can address
# subsequent ``external_conversation_item`` /
# ``external_session_status`` events to the child id.
return {"queued": False, "child_session_id": child_id}
if body.type == _EXTERNAL_CODEX_SUBAGENT_START_TYPE:
child_id = await _persist_external_codex_subagent_start(
session_id,
conv,
body,
conversation_store,
)
return {"queued": False, "child_session_id": child_id}
if body.type == "function_call_output":
# A client-side tool's result tunneling back to a parked turn.
# The harness scaffold resolves the parked tool Future on a
# ``tool_result`` event (ToolResultEvent {call_id, output}), so
# translate the session-API ``function_call_output`` into that
# wire shape and forward to the bound runner, which relays it
# verbatim to the parked harness. Mirrors the runner's own
# dispatch_tool_locally tool_result post; the output here came
# from the caller (a client-side tool) instead of a local
# dispatch. ``parse_item_data`` above already validated the
# payload against ``FunctionCallOutputData`` (call_id: str,
# output: str), so both fields are present strings. Stale
# call_ids no-op at the scaffold; the harness re-emits the
# completed function_call + output on resume, so history is
# written through the normal stream path (no separate persist).
runner_client = await _get_runner_client(session_id, runner_router)
if runner_client is None:
raise OmnigentError(
"No runner bound to this session; cannot deliver the tool result.",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
try:
await runner_client.post(
f"/v1/sessions/{session_id}/events",
json={
"type": "tool_result",
"call_id": body.data["call_id"],
"output": body.data["output"],
},
timeout=10.0,
)
except (httpx.HTTPError, ConnectionError) as exc:
# Fail loud (503), not best-effort: unlike the advisory
# interrupt-forward, a dropped tool_result leaves the parked
# turn hanging until it times out. Surfacing the failure lets
# the caller retry the delivery (the scaffold no-ops if a
# retry double-delivers a now-stale call_id).
raise OmnigentError(
"Failed to deliver the tool result to the session runner.",
code=ErrorCode.RUNNER_UNAVAILABLE,
) from exc
return {"queued": True, "item_id": body.data["call_id"]}
# Item event (message, function_call_output, etc.).
runner_client = await _get_runner_client(session_id, runner_router)
# Managed-launch rendezvous: a ``host_type="managed"`` create
# returns before the sandbox exists, so the first message (the
# Web UI auto-sends the composer prompt right after navigate)
# can land while the background provision is still running.
# Instead of failing with "no runner bound", wait for the
# launch to settle: success leaves the session host-bound with
# its runner tunnel already up (the background task awaits
# it), failure surfaces the recorded reason.
if runner_client is None and conv.host_id is None:
_managed_tracker = getattr(request.app.state, "managed_launches", None)
_managed_launch = (
_managed_tracker.get(session_id) if _managed_tracker is not None else None
)
if _managed_launch is not None:
await _await_settled_managed_launch(_managed_launch)
# The launch bound host_id / workspace / runner_id to
# the row after this handler's fetch — re-read so the
# resolution below sees the bound runner.
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
runner_client = await _get_runner_client(session_id, runner_router)
# Whether the runner was initially unavailable but became routable
# below. In that case the session-init handshake may still be
# racing the first message, even if we reused the original binding
# instead of launching a replacement.
_runner_needs_session_init = False
if runner_client is None and conv.host_id is not None:
_tunnel_registry = getattr(request.app.state, "tunnel_registry", None)
# A just-created host session already has a runner_id before
# the runner's tunnel is registered. The Web UI can post the
# first message during that gap; wait briefly for the pinned
# runner before treating it as dead and replacing it.
if conv.runner_id is not None and _HOST_BOUND_RUNNER_CONNECT_GRACE_S > 0:
_logger.info(
"Waiting up to %.1fs for host-bound runner %s to register "
"for session %s before relaunch",
_HOST_BOUND_RUNNER_CONNECT_GRACE_S,
conv.runner_id,
session_id,
)
runner_client = await _wait_for_runner_client(
session_id,
runner_router,
_tunnel_registry,
runner_id=conv.runner_id,
timeout_s=_HOST_BOUND_RUNNER_CONNECT_GRACE_S,
runner_exit_reports=runner_exit_reports,
)
# Runner is dead or still not spawned for a host-bound
# session. Ask the host to launch one, then re-fetch the
# runner client and wait briefly for it to connect before
# forwarding the message. This is the relaunch path a
# non-sticky Stop relies on: after Stop drops the runner
# tunnel, the next message lands here and relaunches the
# session on its still-online host. Gated only on host
# presence — if the host is offline this falls through to
# the RUNNER_UNAVAILABLE raise below, the same as a
# disconnected CLI session.
_host_reg = getattr(request.app.state, "host_registry", None)
if runner_client is None and _host_reg is not None:
_host_conn = _host_reg.get(conv.host_id)
if _host_conn is not None:
launch_attempt = await _launch_runner_on_host(
conv,
conversation_store,
_host_reg,
_host_conn,
)
if launch_attempt.error_code == _HARNESS_NOT_CONFIGURED_ERROR_CODE:
# The host refused: the agent's harness isn't
# configured there. This message was the real
# runner-start attempt, so consume it and record a
# transcript error (the host's message names the
# fix, `omnigent setup`) the web renders as a
# banner — instead of timing out into a generic
# RUNNER_UNAVAILABLE. The binding stays so a later
# message relaunches once setup is done.
item_id = await _persist_host_launch_failure_turn(
session_id,
conv,
body,
conversation_store,
launch_attempt.error,
runner_router,
created_by=_attribution_user(user_id),
)
return {"queued": True, "item_id": item_id}
relaunched_runner_id = launch_attempt.runner_id
else:
relaunched_runner_id = None
# The host tunnel is gone entirely. A managed
# host's sandbox is relaunchable — provision a new
# generation under the same host identity and ride
# it; an external (laptop) host falls through to
# the unavailable raise below.
if await _maybe_relaunch_managed_sandbox(
session_id=session_id,
conv=conv,
app_state=request.app.state,
conversation_store=conversation_store,
):
conv_after_relaunch = await asyncio.to_thread(
conversation_store.get_conversation, session_id
)
if conv_after_relaunch is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
conv = conv_after_relaunch
runner_client = await _get_runner_client(session_id, runner_router)
else:
relaunched_runner_id = None
if runner_client is None:
_logger.info(
"Waiting up to %.0fs for host %s to spawn a runner for session %s",
_HOST_RELAUNCH_RUNNER_CONNECT_TIMEOUT_S,
conv.host_id,
session_id,
)
runner_client = await _wait_for_runner_client(
session_id,
runner_router,
_tunnel_registry,
runner_id=relaunched_runner_id,
timeout_s=_HOST_RELAUNCH_RUNNER_CONNECT_TIMEOUT_S,
runner_exit_reports=runner_exit_reports,
)
if runner_client is None:
_runner_needs_session_init = False
else:
_runner_needs_session_init = True
if runner_client is None:
# A native terminal-session message must NOT be silently
# dropped when no runner is reachable — the runner crashed
# before connecting (the daemon couldn't bring it up). Persist
# the user's message together with the runner-failure error so
# it survives reload and the banner explains why, becoming the
# AP-server-as-writer failed turn (same shape as a definitive
# ensure-probe failure). The cause, when known, is the daemon's
# exit report keyed by this session's runner_id; otherwise a
# generic unavailable message. This is safe precisely because
# the harness will never see it (no desync — there is no live
# harness). Other event types and non-native sessions still
# raise: their message would replay to a relaunched runner, so
# persisting now WOULD desync the store from harness state.
if body.type == "message" and _is_native_terminal_session(conv):
exit_cause = (
runner_exit_reports.get(conv.runner_id)
if runner_exit_reports is not None and conv.runner_id is not None
else None
)
offline_error = ErrorData(
source="execution",
code="runner_failed_to_start",
message=(
exit_cause
if exit_cause
else (
"The runner for this session is not available — "
"it may have failed to start. See the host logs."
)
),
)
item_id = await _persist_native_terminal_failure(
session_id,
conv,
body,
conversation_store,
offline_error,
runner_router,
created_by=_attribution_user(user_id),
)
return {"queued": True, "item_id": item_id}
# Raise so the Omnigent server doesn't persist an item the
# harness will never see. Other event paths (interrupt,
# approval) are best-effort and silently skip when no
# runner is bound — item events can't, because that
# would desync conversation store and harness state.
raise OmnigentError(
"No runner bound for session",
code=ErrorCode.RUNNER_UNAVAILABLE,
)
refreshed_conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if refreshed_conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
conv = refreshed_conv
if _runner_needs_session_init:
# The runner was unavailable when this request began, so its
# connect callback may still be racing us. Await the handshake
# so the terminal + transcript forwarder are watching before we
# inject the message — otherwise a native web message is
# forwarded into a TUI whose forwarder isn't attached, the
# round-trip never mirrors back, and the optimistic bubble
# sticks with no reply (host-restart bug).
await _ensure_runner_session_initialized(
session_id, conv, runner_client, conversation_store
)
await _ensure_runner_relay_ready(
session_id,
conv.runner_id,
runner_client,
conversation_store,
)
_agent = agent_store.get(conv.agent_id) if conv.agent_id else None
# Determine whether the agent has MCP servers so the runner's
# proxy_stream handler knows to initialise ProxyMcpManager.
# agent_cache.load() is O(1) on a warm in-memory cache; the
# asyncio.to_thread wrapper covers the rare cold-cache path
# where the bundle is extracted from disk for the first time.
_has_mcp_servers = False
if _agent is not None and agent_cache is not None and _agent.bundle_location:
try:
_loaded_agent = await asyncio.to_thread(
agent_cache.load,
_agent.id,
_agent.bundle_location,
)
_has_mcp_servers = bool(_loaded_agent.spec.mcp_servers)
except Exception: # noqa: BLE001 — spec load failure must not break event forwarding
_logger.warning(
"Failed to load agent spec for MCP hint for session=%s",
session_id,
exc_info=True,
)
if body.type == _SLASH_COMMAND_TYPE:
if _agent is None:
raise OmnigentError(
f"Session {session_id!r} has no agent; cannot run slash command",
code=ErrorCode.INVALID_INPUT,
)
item_id = await _dispatch_skill_slash_command_to_runner(
session_id,
conv,
body,
conversation_store,
runner_client,
agent=_agent,
has_mcp_servers=_has_mcp_servers,
created_by=_attribution_user(user_id),
)
return {"queued": True, "item_id": item_id}
dispatch = await _dispatch_session_event_to_runner(
session_id,
conv,
body,
conversation_store,
runner_client,
agent_name=_agent.name if _agent else None,
file_store=file_store,
artifact_store=artifact_store,
has_mcp_servers=_has_mcp_servers,
created_by=_attribution_user(user_id),
runner_router=runner_router,
)
response: dict[str, Any] = {"queued": True}
if dispatch.item_id is not None:
response["item_id"] = dispatch.item_id
# Native-terminal web message: hand back the pending-input id. It
# identifies the snapshot's replayed bubble on rebind and is the
# cleared_pending_id the consume event carries to drop it. Clients
# may adopt it onto their optimistic bubble for id-based dedupe;
# the first-party web client keeps its client temp id (React-key
# stability) and relies on stableKey + FIFO instead.
if dispatch.pending_id is not None:
response["pending_id"] = dispatch.pending_id
return response
# ── GET /sessions/{session_id}/stream ────────────────────────
# Live-tail only. Clients reconnect via GET /v1/sessions/{id}
# for snapshot, then open a new stream; events that fire
# between are deduped client-side by item id (see API.md).
@router.get(
"/sessions/{session_id}/stream",
# response_model=None: returns StreamingResponse, not a model.
response_model=None,
# responses=: surface the SSE union to OpenAPI. The
# ``text/event-stream`` content entry's schema points at the
# discriminated union so generated clients know what to
# expect on the wire. ``scripts/dump_openapi.py`` rewrites
# this in OpenAPI 3.2's ``itemSchema`` form (the OAS 3.2
# mechanism for typing each item in a sequential stream)
# before writing ``openapi.json`` to disk.
responses={
200: {
"description": ("SSE stream of :data:`ServerStreamEvent` frames for the session."),
"content": {
"text/event-stream": {
"schema": {"$ref": "#/components/schemas/ServerStreamEvent"},
},
},
},
},
)
async def stream_session(
request: Request,
session_id: str,
idle: bool = False,
) -> StreamingResponse:
"""
Subscribe to the session's live SSE event stream.
Does NOT replay history; clients reconcile via the snapshot
endpoint. The generator handles disconnects via a
``try/finally`` that emits the ``[DONE]`` sentinel in all
exit paths — see :func:`_stream_live_events`.
Holding this stream open registers the caller as a session
*viewer* (presence): co-viewers' streams receive
``session.presence`` events on join/leave/idle edges, and
this stream's snapshot-on-connect includes the current
viewer list. Presence is scoped to the session tree's root
conversation, so viewers of different agents/sub-agents in
one session see each other. See
``omnigent/server/presence.py``.
:param request: The FastAPI request, used to detect
disconnect.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param idle: Presence idle flag computed by the web client
at connect time (tab backgrounded ≥ its debounce). An
idle *flip* mid-view arrives as a reconnect carrying the
new value — there is no separate update endpoint.
:returns: An SSE :class:`StreamingResponse`.
:raises OmnigentError: 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
runner_client = await _get_runner_client(
session_id,
runner_router,
)
await _ensure_runner_relay_ready(
session_id,
conv.runner_id,
runner_client,
conversation_store,
)
async def _resource_snapshot() -> list[dict[str, Any]]:
"""Gather current resource state to emit as snapshot-on-connect.
Best-effort: every runner-touching gather is time-boxed and
guarded so a slow/unavailable runner never blocks the live
tail. Terminals arrive as ``session.resource.created`` (the
same shape the web's live handler already consumes); child
sessions as ``session.child_session.updated``; changed files
as a single invalidate that triggers a client refetch.
The in-flight assistant-text replay is NOT read here: it is
dedup-sensitive and must be captured synchronously at slot
registration via ``subscribe``'s ``pre_ready_snapshot`` hook,
before ``ready_event`` suspends. The resource
gathers below need awaits and are not dedup-sensitive, so they
stay in this async hook.
"""
events: list[dict[str, Any]] = []
try:
page = await asyncio.to_thread(
conversation_store.list_conversations,
limit=100,
kind="sub_agent",
parent_conversation_id=session_id,
order="desc",
sort_by="created_at",
)
summaries = await _child_session_summaries_from_conversations(
page.data,
session_id,
conversation_store,
)
for summary in summaries:
events.append(
{
"type": "session.child_session.updated",
"conversation_id": session_id,
"child_session_id": summary.id,
"child": summary.model_dump(mode="json"),
}
)
except Exception: # noqa: BLE001 -- best-effort snapshot; never block live tail
_logger.debug("snapshot: child sessions failed for %s", session_id, exc_info=True)
try:
resp = await asyncio.wait_for(
# order=asc: the web cache appends each replayed
# ``created`` event, so the replay must arrive in
# creation order or the session's own terminal (always
# created first) lands behind later agent-launched
# ones. limit=1000 (the runner endpoint max) keeps the
# oldest-first window from dropping the newest
# terminals past the default page of 20.
runner_client.get(
f"/v1/sessions/{session_id}/resources/terminals",
params={"order": "asc", "limit": "1000"},
),
timeout=_SNAPSHOT_RUNNER_TIMEOUT_S,
)
if resp.status_code == 200:
for item in resp.json().get("data", []):
events.append({"type": "session.resource.created", "resource": item})
except Exception: # noqa: BLE001 -- best-effort snapshot; never block live tail
_logger.debug("snapshot: terminals failed for %s", session_id, exc_info=True)
# Tell the client to (re)fetch the changed-files list rather
# than fetching it here (avoids a second runner round-trip).
events.append(
{
"type": "session.changed_files.invalidated",
"session_id": session_id,
"environment_id": "default",
}
)
# Current viewer list (full state, includes this stream's own
# registration) so a joiner never waits for the next presence
# edge to learn who's here. Scoped to the session tree's root
# so a sub-agent page sees viewers of every agent in the tree.
events.append(presence.snapshot(conv.root_conversation_id, session_id))
return events
return StreamingResponse(
_stream_live_events(
request,
session_id,
_resource_snapshot,
# Presence tracks distinct human actors only — the reserved
# single-user "local" sentinel maps to None (no tracking),
# same as message attribution.
viewer_user_id=_attribution_user(user_id),
viewer_idle=idle,
# Scope presence to the tree's root: sub-agent pages open
# the CHILD conversation's stream, and per-conversation
# scoping would hide co-viewers on other agents.
presence_root_id=conv.root_conversation_id,
),
media_type="text/event-stream",
headers={
# Keep intermediaries from buffering the SSE stream:
# ``X-Accel-Buffering: no`` disables nginx-style response
# buffering so heartbeats and deltas reach the client as
# they're written (a buffered proxy can delay the 15s
# heartbeat past a client/idle timeout), and ``no-cache``
# keeps the long-lived response out of any shared cache.
# NOTE: this does NOT defeat the Databricks Apps ingress'
# hard ~5-min HTTP/2 stream-duration cap — that drop is
# handled by the client's transparent reconnect.
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
# ── DELETE /sessions/{session_id} ──────────────────────────────
@router.delete(
"/sessions/{session_id}",
response_model=None,
responses={200: {"model": ConversationDeleted}},
)
async def delete_session(
request: Request,
session_id: str,
delete_branch: bool = False,
) -> ConversationDeleted:
"""Delete a session and all associated resources.
Requires owner-level access. Tears down tasks, runner-side
resources (environments, terminals), session files, and the
conversation row.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param delete_branch: Opt-in git cleanup, as a query param
(``?delete_branch=true``). When ``True`` and the session
has a server-created worktree (``git_branch`` set), the
host removes the worktree directory and deletes its branch
(``git worktree remove --force`` then ``git branch -D``).
Ignored for sessions with no worktree. Best-effort: a
cleanup failure does not block the delete. Defaults to
``False`` (worktree and branch left untouched). See
designs/SESSION_GIT_WORKTREE.md.
:returns: A :class:`ConversationDeleted` confirmation.
:raises OmnigentError: 404 if no session or no access,
403 if insufficient permissions.
"""
user_id = _require_user(request, auth_provider)
if permission_store is not None and user_id is not None:
is_admin = await asyncio.to_thread(permission_store.is_admin, user_id)
if not is_admin:
grant = await asyncio.to_thread(permission_store.get, user_id, session_id)
if grant is None or grant.level < LEVEL_OWNER:
if grant is not None:
raise OmnigentError(
"Only the session owner can delete this session",
code=ErrorCode.FORBIDDEN,
)
raise OmnigentError(
"Conversation not found",
code=ErrorCode.NOT_FOUND,
)
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
await _best_effort_stop(session_id, conversation_store, runner_router)
# Runner-side resource cleanup is best-effort: if the bound
# runner is offline or unbound, the session must still be
# deletable. Server-owned records (files and conversation row
# below) live independently of the runner, and runner-side
# resources are gone with the runner anyway.
runner_client: httpx.AsyncClient | None = None
try:
runner_client = await _get_runner_client_for_resource_access(session_id)
except OmnigentError as exc:
_logger.info(
"Skipping runner-side cleanup for %s; proceeding with server-side delete: %s",
session_id,
exc,
)
if runner_client is not None:
try:
await runner_client.delete(
f"/v1/sessions/{session_id}/resources",
timeout=10.0,
)
except (httpx.HTTPError, ConnectionError):
_logger.warning(
"Runner cleanup failed for %s, falling back",
session_id,
)
else:
import contextlib
from omnigent.runtime import get_terminal_registry
with contextlib.suppress(RuntimeError):
await get_terminal_registry().cleanup_conversation(session_id)
# Session file cleanup.
if file_store is not None and artifact_store is not None:
deleted_file_ids = await asyncio.to_thread(
file_store.delete_all_for_session, session_id
)
for fid in deleted_file_ids:
await asyncio.to_thread(artifact_store.delete, fid)
# Opt-in git worktree cleanup: only when delete_branch=true and
# the session has a server-created worktree. Runs after runner
# teardown; best-effort (designs/SESSION_GIT_WORKTREE.md).
if (
delete_branch
and conv.git_branch is not None
and conv.workspace is not None
and conv.host_id is not None
):
await _remove_session_worktree_best_effort(
host_id=conv.host_id,
worktree_path=conv.workspace,
branch=conv.git_branch,
delete_branch=True,
request=request,
reason="session-delete",
)
_interrupt_fenced_sessions.discard(session_id)
deleted = await conversation_store.delete_conversation(session_id)
if not deleted:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
# The session is gone, so is its launch-progress state. Failed
# launches are retained in the cache for reload visibility while
# the session exists; without this eviction every deleted
# failed-launch session would leak one entry for the process
# lifetime.
_session_sandbox_status_cache.pop(session_id, None)
# Same for MCP startup state: failed/cancelled maps are retained
# for reload visibility while the session exists, so a session
# whose MCP startup never settled clean would leak its entry.
_session_mcp_startup_cache.pop(session_id, None)
# Drop the deleted session's per-user read-state from every user's
# caches so they don't accumulate orphan entries for the process
# lifetime.
_prune_session_read_state(session_id)
# Same for the tracker's entry — a deleted session's launch can
# never be rendezvoused again (access checks 404 first), so a
# retained failure is dead weight. ``finish`` also settles a
# still-in-flight entry, releasing any parked message POST into
# its session re-read (which now correctly 404s); the background
# task's later ``fail`` on the popped entry is a no-op.
managed_launches_for_delete = getattr(request.app.state, "managed_launches", None)
if managed_launches_for_delete is not None:
managed_launches_for_delete.finish(session_id)
# Managed-host cleanup: when the session's host is backed by a
# server-provisioned sandbox (host_type="managed"), terminate
# the sandbox and delete the host row — which also revokes its
# launch token. Best-effort by design — the provider's lifetime
# cap reaps stragglers. External (laptop) hosts have no
# sandbox_id and are never touched.
host_store_for_managed = getattr(request.app.state, "host_store", None)
if conv.host_id is not None and host_store_for_managed is not None:
bound_host = await asyncio.to_thread(host_store_for_managed.get_host, conv.host_id)
if bound_host is not None and bound_host.sandbox_id is not None:
from omnigent.server.managed_hosts import terminate_managed_host
await terminate_managed_host(
bound_host,
host_store_for_managed,
# Supplies the launcher for the provider-side
# terminate; None (config removed since launch)
# still deletes the row and revokes the token.
getattr(request.app.state, "sandbox_config", None),
)
return ConversationDeleted(id=session_id)
# ── Permission management endpoints ──────────────────────────
@router.put(
"/sessions/{session_id}/permissions",
response_model=None,
responses={200: {"model": PermissionObject}},
)
async def grant_permission(
request: Request,
session_id: str,
body: GrantPermissionRequest,
) -> PermissionObject:
"""Grant or update a permission on a session.
Requires manage-level access. Upserts the grant — can
upgrade or downgrade an existing level. Auto-creates the
grantee user if they don't exist yet.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session to grant access to,
e.g. ``"conv_abc123"``.
:param body: The grant request with ``user_id`` and ``level``.
:returns: The resulting :class:`PermissionObject`.
:raises OmnigentError: 404 if no session or no access,
401 if unauthenticated.
"""
user_id = _require_user(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_MANAGE, permission_store, conversation_store
)
# Server-wide sharing policy gate (see SharingMode). Applied only
# to *new* grants — revoke/list and owner grants are unaffected.
# ``getattr`` default keeps a hand-built app (a router mounted without
# create_app, e.g. in a focused test) from AttributeError-ing; every
# production path sets these via create_app.
_sharing_mode = getattr(request.app.state, "sharing_mode", lambda: SharingMode.ON)()
if _sharing_mode == SharingMode.OFF:
raise OmnigentError(
"Sharing has been disabled for this Omnigent server.",
code=ErrorCode.FORBIDDEN,
)
# RESTRICTED_READ_ONLY blocks sharing entirely (even read) for a session
# whose cwd is a home dir or the filesystem root — that workspace is too
# broad to expose. Other sessions fall through to the read-only cap.
if _sharing_mode == SharingMode.RESTRICTED_READ_ONLY:
_conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if _conv is not None and workspace_sharing_blocked(_conv.workspace):
raise OmnigentError(
"This session's working directory (a home or root directory) "
"cannot be shared on this Omnigent server.",
code=ErrorCode.FORBIDDEN,
)
if (
_sharing_mode in (SharingMode.READ_ONLY, SharingMode.RESTRICTED_READ_ONLY)
and body.level > LEVEL_READ
):
raise OmnigentError(
"Sharing is limited to read-only access on this Omnigent server.",
code=ErrorCode.FORBIDDEN,
)
if permission_store is None:
raise OmnigentError(
"Permissions not enabled",
code=ErrorCode.INTERNAL_ERROR,
)
if body.user_id == user_id:
raise OmnigentError(
"Cannot modify your own permissions",
code=ErrorCode.FORBIDDEN,
)
if body.user_id == RESERVED_USER_PUBLIC:
# Public-access kill switch, independent of the sharing_mode gate
# above (see app.state.public_sharing). Blocks the anyone-with-the
# -link grant while leaving user-to-user sharing intact. ``getattr``
# default mirrors the sharing_mode read above (hand-built apps).
if not getattr(request.app.state, "public_sharing", lambda: True)():
raise OmnigentError(
"Public access has been disabled for this Omnigent server.",
code=ErrorCode.FORBIDDEN,
)
if body.level > LEVEL_READ:
raise OmnigentError(
"Public access is limited to read-only (level 1)",
code=ErrorCode.INVALID_INPUT,
)
existing = await asyncio.to_thread(permission_store.get, body.user_id, session_id)
if existing is not None and existing.level == LEVEL_OWNER:
raise OmnigentError(
"Cannot modify owner permissions",
code=ErrorCode.FORBIDDEN,
)
await asyncio.to_thread(permission_store.ensure_user, body.user_id)
perm = await asyncio.to_thread(
permission_store.grant, body.user_id, session_id, body.level
)
# Push the now-shared session to the GRANTEE's open tabs so it
# appears in their sidebar without a list poll.
_announce_session_added(body.user_id, session_id)
return PermissionObject(
user_id=perm.user_id,
conversation_id=perm.conversation_id,
level=perm.level,
)
@router.delete(
"/sessions/{session_id}/permissions/{target_user_id}",
status_code=204,
response_model=None,
)
async def revoke_permission(
request: Request,
session_id: str,
target_user_id: str,
) -> Response:
"""Revoke a user's permission on a session.
Requires manage-level access. Cannot revoke your own
manage grant (prevents orphaned sessions). Returns 204
whether or not the grant existed (idempotent).
:param request: The incoming FastAPI request (for auth).
:param session_id: Session to revoke access from,
e.g. ``"conv_abc123"``.
:param target_user_id: User whose grant to revoke,
e.g. ``"alice@example.com"``.
:returns: 204 No Content.
:raises OmnigentError: 404 if no session or no access,
403 if attempting to revoke own manage grant.
"""
user_id = _require_user(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_MANAGE, permission_store, conversation_store
)
if permission_store is None:
raise OmnigentError(
"Permissions not enabled",
code=ErrorCode.INTERNAL_ERROR,
)
if target_user_id == user_id:
raise OmnigentError(
"Cannot modify your own permissions",
code=ErrorCode.FORBIDDEN,
)
existing = await asyncio.to_thread(permission_store.get, target_user_id, session_id)
if existing is not None and existing.level == LEVEL_OWNER:
raise OmnigentError(
"Cannot revoke owner permissions",
code=ErrorCode.FORBIDDEN,
)
await asyncio.to_thread(permission_store.revoke, target_user_id, session_id)
return Response(status_code=204)
@router.get(
"/sessions/{session_id}/owner",
response_model=None,
)
async def get_session_owner(
request: Request,
session_id: str,
) -> dict[str, str | None]:
"""Return the owner of a session.
Requires read-level access.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session to look up,
e.g. ``"conv_abc123"``.
:returns: ``{"owner": "<user_id>"}`` or
``{"owner": null}``.
"""
user_id = _require_user(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
return {"owner": _get_session_owner_id(session_id, permission_store)}
@router.get(
"/sessions/{session_id}/permissions",
response_model=None,
responses={200: {"model": list[PermissionObject]}},
)
async def list_permissions(
request: Request,
session_id: str,
) -> list[PermissionObject]:
"""List all permission grants on a session.
Requires manage-level access.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session to list grants for,
e.g. ``"conv_abc123"``.
:returns: List of :class:`PermissionObject`.
:raises OmnigentError: 404 if no session or no access.
"""
user_id = _require_user(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_MANAGE, permission_store, conversation_store
)
if permission_store is None:
raise OmnigentError(
"Permissions not enabled",
code=ErrorCode.INTERNAL_ERROR,
)
grants = await asyncio.to_thread(permission_store.list_for_session, session_id)
return [
PermissionObject(
user_id=g.user_id,
conversation_id=g.conversation_id,
level=g.level,
)
for g in grants
]
# ── Agent sub-resource ────────────────────────────────────────
# These endpoints expose the session's bound agent metadata
# and bundle through the session namespace, removing the need
# for a standalone ``/api/agents`` router.
def _policy_type(spec: PolicySpec) -> str:
"""Return ``"function"`` for all policies."""
if isinstance(spec, FunctionPolicySpec):
return "function"
return "unknown"
def _policy_description(spec: PolicySpec) -> str | None:
"""Return a short description for a policy spec.
Looks up the policy registry for a human-readable
description; falls back to the callable path.
"""
if isinstance(spec, FunctionPolicySpec) and spec.function:
from omnigent.policies.registry import get_entry
entry = get_entry(spec.function.path)
return entry.description if entry else spec.function.path
return None
def _to_agent_object(agent: Agent, cache: AgentCache | None) -> AgentObject:
"""
Convert a runtime :class:`Agent` entity to an API-layer
:class:`AgentObject`.
Loads the agent spec from *cache* to populate ``mcp_servers``,
``policies``, ``skills``, and (when the stored row has none) the
``description``. If the cache is ``None``, the spec is not
cached, or the load fails, those fall back to empty lists / the
stored value rather than raising — the endpoint must not fail
because one spec can't be read.
:param agent: The runtime agent entity.
:param cache: Agent cache, or ``None`` in test setups.
:returns: An :class:`AgentObject` for the API response.
"""
mcp_servers: list[MCPServerSummary] = []
policies: list[PolicySummary] = []
skills: list[SkillSummary] = []
terminals: list[str] = []
# Harness/kind for the UI; None until the spec loads (mirrors the
# GET /v1/agents catalog so both endpoints report it consistently).
harness: str | None = None
# Prefer the stored entity's description; fall back to the spec's
# top-level description when the stored value is unset (single-file
# YAML agents don't persist it at registration today). Lets the
# new-session picker show a hover description without a migration.
description: str | None = agent.description
if cache is not None:
try:
loaded = cache.load(
agent.id, agent.bundle_location, expand_env=agent.session_id is None
)
harness = loaded.spec.executor.harness_kind
if description is None:
description = loaded.spec.description
# Declared terminal names, in spec order — the Web UI
# gates its "new terminal" affordance on this list.
terminals = list(loaded.spec.terminals or {})
# Bundled skills only (mirrors GET /v1/agents); the merged
# bundled + host-discovered set lives on the session snapshot.
skills = [
SkillSummary(name=s.name, description=s.description)
for s in loaded.spec.skills
]
mcp_servers = [
MCPServerSummary(
name=srv.name,
transport=srv.transport,
description=srv.description,
url=srv.url,
command=srv.command,
args=srv.args,
)
for srv in loaded.spec.mcp_servers
]
if loaded.spec.guardrails and loaded.spec.guardrails.policies:
policies = [
PolicySummary(
name=ps.name,
type=_policy_type(ps),
on=[
f"{sel.phase.value}:{sel.tool_name}"
if sel.tool_name
else sel.phase.value
for sel in (ps.on or [])
],
description=_policy_description(ps),
)
for ps in loaded.spec.guardrails.policies
]
except Exception: # noqa: BLE001 — spec load failure must not break agent fetch
_logger.debug(
"Failed to load spec for agent %s; mcp_servers/policies will be empty",
agent.id,
exc_info=True,
)
return AgentObject(
id=agent.id,
name=agent.name,
version=agent.version,
description=description,
created_at=agent.created_at,
updated_at=agent.updated_at,
harness=harness,
mcp_servers=mcp_servers,
mcp_servers_editable=(
agent.session_id is not None and not (harness or "").endswith("-native")
),
policies=policies,
skills=skills,
terminals=terminals,
)
@router.get("/sessions/{session_id}/agent")
async def get_session_agent(
request: Request,
session_id: str,
) -> AgentObject:
"""
Return the :class:`AgentObject` for the session's bound agent.
Replaces the standalone ``GET /api/agents/{id}`` endpoint by
resolving the agent through the session's ``agent_id`` foreign
key. The caller only needs to know the session id.
:param request: The incoming FastAPI request.
:param session_id: Session identifier, e.g.
``"conv_abc123"``.
:returns: The bound agent's :class:`AgentObject`.
:raises OmnigentError: If the session or agent is not found.
"""
user_id = _require_user(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = conversation_store.get_conversation(session_id)
if conv is None:
raise OmnigentError(
f"Session not found: {session_id!r}",
code=ErrorCode.NOT_FOUND,
)
if conv.agent_id is None:
raise OmnigentError(
"Session has no agent binding",
code=ErrorCode.INTERNAL_ERROR,
)
agent = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent is None:
raise OmnigentError(
f"Agent not found: {conv.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
return _to_agent_object(agent, agent_cache)
@router.get(
"/sessions/{session_id}/agent/contents",
response_class=Response,
responses={
200: {"content": {"application/gzip": {}}},
404: {"description": "Session or agent not found"},
},
)
async def get_session_agent_contents(
request: Request,
session_id: str,
) -> Response:
"""
Download the raw ``.tar.gz`` agent bundle for the session's
bound agent.
Replaces ``GET /api/agents/{id}/contents``. Runners call this
on cache miss to fetch the spec + bundled files.
:param request: The incoming FastAPI request.
:param session_id: Session identifier, e.g.
``"conv_abc123"``.
:returns: Raw bundle bytes as ``application/gzip``.
:raises OmnigentError: If the session, agent, or bundle is
not found.
"""
user_id = _require_user(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = conversation_store.get_conversation(session_id)
if conv is None:
raise OmnigentError(
f"Session not found: {session_id!r}",
code=ErrorCode.NOT_FOUND,
)
if conv.agent_id is None:
raise OmnigentError(
"Session has no agent binding",
code=ErrorCode.INTERNAL_ERROR,
)
agent = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent is None:
raise OmnigentError(
f"Agent not found: {conv.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
if artifact_store is None:
raise OmnigentError(
"Artifact store not configured",
code=ErrorCode.INTERNAL_ERROR,
)
bundle_bytes = artifact_store.get(agent.bundle_location)
if bundle_bytes is None:
raise OmnigentError(
"Agent bundle not found in artifact store",
code=ErrorCode.INTERNAL_ERROR,
)
return Response(
content=bundle_bytes,
media_type="application/gzip",
headers={
"X-Agent-Version": str(agent.version),
"X-Agent-Name": agent.name,
# Provenance for the runner's env-expansion decision:
# session-scoped agents are
# tenant-uploaded and must NOT have ${VAR} expanded
# against the runner process env; template agents
# (session_id is None) are operator-authored and may.
# The runner fails safe (treats a missing header as
# session-scoped → no expansion).
"X-Agent-Session-Scoped": "true" if agent.session_id is not None else "false",
},
)
@router.put(
"/sessions/{session_id}/agent",
)
async def update_session_agent(
request: Request,
session_id: str,
bundle: Annotated[UploadFile, File(...)],
) -> AgentObject:
"""
Replace the session's agent bundle with a new upload.
Validates the new bundle, checks that the spec name matches
the existing agent, stores the bundle under a
content-addressed key, updates the agent row, and warm-swaps
the cache. Idempotent when the bundle content is unchanged.
:param request: The incoming FastAPI request.
:param session_id: Session identifier, e.g.
``"conv_abc123"``.
:param bundle: Uploaded ``.tar.gz`` agent bundle file.
:returns: The updated :class:`AgentObject`.
:raises OmnigentError: If the session or agent is not found,
the bundle is invalid, or the spec name doesn't match.
"""
user_id = _require_user(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = conversation_store.get_conversation(session_id)
if conv is None:
raise OmnigentError(
f"Session not found: {session_id!r}",
code=ErrorCode.NOT_FOUND,
)
if conv.agent_id is None:
raise OmnigentError(
"Session has no agent binding",
code=ErrorCode.INTERNAL_ERROR,
)
agent = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent is None:
raise OmnigentError(
f"Agent not found: {conv.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
# Shared/template agents are read-only here;
# mirrors the guard in session_mcp_servers._editable_agent.
if agent.session_id is None:
raise OmnigentError(
"Built-in agents are read-only through this endpoint.",
code=ErrorCode.INVALID_INPUT,
)
bundle_bytes = await bundle.read()
# Run bundle validation (tar extraction + spec parse, both
# blocking) off the event loop -- mirrors the POST
# /sessions/bundled path. A malicious bundle that blocks here
# must not hang the entire server loop. The
# policy-handler allowlist is enforced only on a
# shared / multi-user server; a trusted single-user/local server
# keeps supporting custom handlers (see _create_session_from_bundle).
spec = await asyncio.to_thread(
validate_agent_bundle,
bundle_bytes,
enforce_handler_allowlist=not local_single_user_enabled(),
)
if spec.name is None:
raise OmnigentError("spec missing name", code=ErrorCode.INVALID_INPUT)
if spec.name != agent.name:
raise OmnigentError(
f"spec name '{spec.name}' does not match agent "
f"name '{agent.name}'; name is immutable",
code=ErrorCode.INVALID_INPUT,
)
new_loc = bundle_location(agent.id, bundle_bytes)
# Idempotency: same bundle content = no-op
if new_loc == agent.bundle_location:
return _to_agent_object(agent, agent_cache)
if artifact_store is None:
raise OmnigentError(
"Artifact store not configured",
code=ErrorCode.INTERNAL_ERROR,
)
artifact_store.put(new_loc, bundle_bytes)
updated = await asyncio.to_thread(agent_store.update, agent.id, new_loc)
if updated is None:
raise OmnigentError(
f"Agent not found: {agent.id!r}",
code=ErrorCode.NOT_FOUND,
)
if agent_cache is not None:
# Only operator-authored template agents
# (session_id is None) may expand ${VAR} against the server
# env; tenant session-scoped bundles must not.
agent_cache.replace(
agent.id, new_loc, bundle_bytes, expand_env=agent.session_id is None
)
return _to_agent_object(updated, agent_cache)
# ── POST /sessions/{session_id}/mcp ──────────────────────────────────
# MCP Streamable HTTP proxy endpoint. Only registered when a
# ``runner_router`` is injected; returns 503 otherwise so test
# setups that don't wire a runner skip the endpoint cleanly.
@router.post(
"/sessions/{session_id}/mcp",
# Internal MCP proxy — hidden from the public API reference.
include_in_schema=False,
response_model=None, # Returns a raw Response with application/json
# CSRF hardening: the MCP Streamable HTTP contract already mandates
# an application/json request body; enforce it so a cross-site
# text/plain request can't drive JSON-RPC against this proxy.
dependencies=[Depends(require_json_content_type)],
)
async def mcp_proxy(
session_id: str,
request: Request,
) -> Response:
"""
MCP Streamable HTTP proxy endpoint.
Implements the MCP JSON-RPC 2.0 protocol over HTTP. The AP
server owns policy enforcement (TOOL_CALL / TOOL_RESULT); the
runner owns execution via ``POST /v1/sessions/{id}/mcp/execute``
(reached through the WS tunnel the runner opened at startup).
This split ensures:
- Policy runs on the Omnigent server where the ConversationStore and
label state live.
- Stdio MCP subprocesses spawn on the runner's machine with the
correct ``cwd``, environment, and installed tooling.
Supported methods:
- ``initialize`` — capability negotiation.
- ``tools/list`` — list all tools; delegated to runner execute.
- ``tools/call`` — policy eval on AP, execution on runner.
:param session_id: Session whose agent's MCP servers to proxy,
e.g. ``"conv_abc123"``.
:param request: The incoming FastAPI request. Body must be a
JSON-RPC 2.0 object.
:returns: A ``application/json`` JSON-RPC 2.0 response.
:raises HTTPException: 503 when no ``runner_router`` is configured.
"""
if runner_router is None:
raise HTTPException(
status_code=503,
detail="MCP proxy requires a runner_router; none configured on this server",
)
user_id = _require_user(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
# Parse JSON-RPC body. Return a parse-error response (not HTTP
# 400) on failure — JSON-RPC errors travel in the body.
try:
body = await request.json()
except Exception: # noqa: BLE001 — catch all JSON parse failures
return _mcp_error_response(None, -32700, "Parse error: invalid JSON")
if not isinstance(body, dict):
return _mcp_error_response(None, -32600, "Invalid Request: expected JSON object")
rpc_id: int | str | None = body.get("id")
method: str = body.get("method") or ""
params: dict[str, Any] = body.get("params") or {}
_logger.debug(
"MCP proxy: session=%r method=%r rpc_id=%r",
session_id,
method,
rpc_id,
)
if method == "initialize":
# Minimal capability negotiation response. We declare
# ``tools`` capability so MCP clients know to call
# ``tools/list`` and ``tools/call``.
return _mcp_ok_response(
rpc_id,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "omnigent-mcp-proxy", "version": "1.0.0"},
},
)
if method == "tools/list":
return await _handle_mcp_tools_list(
rpc_id,
session_id,
runner_router,
)
if method == "tools/call":
return await _handle_mcp_tools_call(
rpc_id,
session_id,
params,
conversation_store,
agent_store,
runner_router,
actor=_build_actor(user_id),
request=request,
)
return _mcp_error_response(rpc_id, -32601, f"Method not found: {method!r}")
return router
async def _fetch_runner_skills(
runner_client: httpx.AsyncClient | None,
session_id: str,
) -> list[SkillSummary]:
"""
Fetch a session's merged skills from its bound runner.
Skills are runner-owned: the runner discovers them against its own
filesystem (the spec's bundled skills plus host skills under the
session's workspace and the runner's ``~/.claude/skills/``). The
server only overlays the result onto the session snapshot (the web
composer's slash-command menu).
Best-effort: a missing/unreachable runner, a non-200, or any
transport error yields an empty list rather than failing the
snapshot.
:param runner_client: HTTP client pointed at the bound runner, or
``None`` when no runner is bound.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:returns: Skill summaries (name + one-line description) for the
session, or ``[]`` when unavailable.
"""
if runner_client is None:
return []
cached = _runner_skills_cache.get(session_id)
if cached is not None:
return cached
# Don't await the runner here: this snapshot is polled continuously
# (incl. mid-turn), and a per-poll runner round-trip pins the runner's
# event loop and wedges the turn. Kick one background fetch (single-
# flight) and return ``[]``; a later poll serves the cached result.
if session_id not in _runner_skills_inflight:
task = asyncio.create_task(_load_runner_skills(runner_client, session_id))
_runner_skills_inflight[session_id] = task
task.add_done_callback(lambda _t, sid=session_id: _runner_skills_inflight.pop(sid, None))
return []
async def _load_runner_skills(
runner_client: httpx.AsyncClient,
session_id: str,
) -> None:
"""Background single-flight fetch of a session's runner-owned skills.
Populates :data:`_runner_skills_cache` on success so subsequent
snapshot polls serve skills without a per-poll runner round-trip. Runs
off the snapshot's critical path (see :func:`_fetch_runner_skills`).
Best-effort: transport errors / non-200 / malformed payloads leave the
cache unset so a later poll retries.
:param runner_client: HTTP client pointed at the bound runner.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc"``.
"""
try:
resp = await runner_client.get(
f"/v1/sessions/{session_id}/skills",
timeout=5.0,
)
except (httpx.HTTPError, ConnectionError):
_logger.debug("Runner skills query failed for %s", session_id)
return
if resp.status_code != 200:
return
try:
raw = resp.json().get("skills", [])
skills = [SkillSummary(name=s["name"], description=s["description"]) for s in raw]
except (ValueError, AttributeError, KeyError, TypeError):
_logger.debug("Runner skills payload malformed for %s", session_id)
return
_runner_skills_cache[session_id] = skills
# Nudge any subscribed client to re-read the (now-warm) snapshot so
# its slash-command menu fills without waiting for the next bind.
_publish_runner_skills(session_id)
def _model_options_from_wire(raw_models: Any) -> list[dict[str, Any]]:
"""
Validate runner-returned raw Codex ``model/list`` data.
:param raw_models: JSON value from the runner's
``{"models": [...]}`` response, e.g. a list of Codex model dicts.
:returns: Raw model options for the session snapshot.
:raises ValueError: If the payload is not the expected list/dict
shape.
"""
if not isinstance(raw_models, list):
raise ValueError("Codex model options payload must be a list")
options: list[dict[str, Any]] = []
for raw_model in raw_models:
if not isinstance(raw_model, dict):
raise ValueError("Codex model option must be an object")
options.append(raw_model)
return options
# Native harnesses whose model picker is populated from a *live*, runner-owned
# model-options endpoint, keyed by wrapper label -> the runner route segment.
# Codex queries its live app-server ``model/list`` (account/session-scoped, so
# it must come from the bound runner). Cursor is deliberately NOT here: its
# catalog is a curated *static* base list served directly (see
# ``_fetch_model_options``), which keeps it off the runner-backed cache that
# ``refresh_state`` invalidates — otherwise an effort/model change would blank
# the cursor picker mid-session.
_MODEL_OPTIONS_ENDPOINT_BY_WRAPPER: dict[str, str] = {
_CODEX_NATIVE_WRAPPER_LABEL_VALUE: "codex-model-options",
}
async def _fetch_model_options(
runner_client: httpx.AsyncClient | None,
session_id: str,
conv: Conversation,
) -> list[dict[str, Any]]:
"""
Resolve the Web UI model-picker options for a native session.
Two shapes:
* **cursor-native** — a curated *static* base catalog
(:func:`omnigent.cursor_native.cursor_base_model_options`), returned
directly on every snapshot. It deliberately bypasses the runner-backed
cache below: the catalog never changes per session, and routing it
through that cache would let a ``refresh_state`` snapshot (which pops the
cache) blank the picker on an effort/model change.
* **codex-native** — a *live*, account-scoped catalog only the bound runner
can read (its app-server ``model/list``). Like skills, this stays off the
snapshot hot path: the first snapshot kicks a background fetch and returns
``[]``; subsequent snapshots serve the cache.
:param runner_client: HTTP client pointed at the bound runner, or
``None`` when no runner is bound.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param conv: Conversation row whose labels identify the wrapper.
:returns: Model options, or ``[]`` when the session has no model picker or
the (codex) options are not yet available.
"""
wrapper = conv.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
if wrapper == _CURSOR_NATIVE_WRAPPER_LABEL_VALUE:
from omnigent.cursor_native import cursor_base_model_options
return cursor_base_model_options()
if wrapper == _KIRO_NATIVE_WRAPPER_LABEL_VALUE:
from omnigent.kiro_native import kiro_base_model_options
return kiro_base_model_options()
endpoint = _MODEL_OPTIONS_ENDPOINT_BY_WRAPPER.get(wrapper or "")
if endpoint is None:
return []
if runner_client is None:
return []
cached = _model_options_cache.get(session_id)
if cached is not None:
return cached
if session_id not in _model_options_inflight:
path = f"/v1/sessions/{session_id}/{endpoint}"
task = asyncio.create_task(_load_model_options(runner_client, session_id, path))
_model_options_inflight[session_id] = task
task.add_done_callback(lambda _t, sid=session_id: _model_options_inflight.pop(sid, None))
return []
async def _load_model_options(
runner_client: httpx.AsyncClient,
session_id: str,
path: str,
) -> None:
"""
Background single-flight fetch of a session's native model catalog.
:param runner_client: HTTP client pointed at the bound runner.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc"``.
:param path: Runner route to query, e.g.
``"/v1/sessions/conv_abc/cursor-model-options"``.
"""
for attempt in range(len(_CODEX_MODEL_OPTIONS_RETRY_DELAYS_S) + 1):
try:
resp = await runner_client.get(path, timeout=5.0)
except (httpx.HTTPError, ConnectionError):
_logger.debug("Runner model-options query failed for %s", session_id)
return
if resp.status_code != 200:
# 503 means the native backend (Codex app-server bridge / cursor
# login) is still booting. Keep the background single-flight alive
# so the web picker fills without a second manual refresh.
if resp.status_code == 503 and attempt < len(_CODEX_MODEL_OPTIONS_RETRY_DELAYS_S):
await asyncio.sleep(_CODEX_MODEL_OPTIONS_RETRY_DELAYS_S[attempt])
continue
return
try:
options = _model_options_from_wire(resp.json().get("models", []))
except (ValueError, KeyError, TypeError, ValidationError):
_logger.debug("Runner model-options payload malformed for %s", session_id)
return
if not options:
# Older runners returned 200 + [] for the same not-ready window.
# Do not cache that empty catalog; retry, then leave the cache
# cold so a later snapshot can try again.
if attempt < len(_CODEX_MODEL_OPTIONS_RETRY_DELAYS_S):
await asyncio.sleep(_CODEX_MODEL_OPTIONS_RETRY_DELAYS_S[attempt])
continue
return
_model_options_cache[session_id] = options
_publish_model_options(session_id)
return
async def _get_session_snapshot(
conv_store: ConversationStore,
session_id: str,
permission_level: int | None = None,
agent_store: AgentStore | None = None,
agent_cache: AgentCache | None = None,
conversation: Conversation | None = None,
liveness_lookup: Callable[[list[str]], dict[str, SessionLiveness]] | None = None,
include_items: bool = True,
runner_exit_reports: RunnerExitReports | None = None,
refresh_state: bool = False,
host_store: HostStore | None = None,
sandbox_config: ManagedSandboxConfig | None = None,
) -> SessionResponse:
"""
Read a full session snapshot from the store.
Centralizes the create/get response building so both endpoints
return identical projections. The lifecycle ``status`` is
derived from the relay-fed ``_session_status_cache`` (the tasks
table has been removed).
:param conv_store: The conversation store to read from.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param permission_level: The requesting user's numeric level
on this session, or ``None`` when permissions are disabled.
:param agent_store: Optional agent store used to look up the
bound agent's bundle location. ``None`` in legacy call sites
that don't yet pass it.
:param agent_cache: Optional agent cache used to load the parsed
spec from the bundle (provides ``llm_model`` and
``context_window``). ``None`` in legacy call sites.
:param conversation: The already-fetched conversation row to reuse,
skipping the ``get_conversation`` read. Pass it when the caller
just authorized the session (which fetched the same row) so the
snapshot doesn't re-read it. ``None`` reads it here as before.
:param liveness_lookup: Bulk session-liveness lookup (the server's
``_bulk_session_liveness``) used to populate ``runner_online``
and ``host_online`` on the snapshot. ``None`` (e.g. focused
tests) leaves both fields ``None`` so the client falls back to
its ``/health`` poll.
:param include_items: When ``False``, skip the committed-items read
and return ``items=[]``. Callers that hydrate the transcript
through ``GET /sessions/{id}/items`` (the web chat surface)
pass ``False`` — the items read is the most expensive step of
the snapshot build and its result would be discarded.
:param refresh_state: When ``True``, clear runner-backed snapshot
overlays for this session before building the response. Browser
reloads use this so a refresh re-reads current live-session
capabilities instead of serving stale AP-process caches.
:returns: The fully populated :class:`SessionResponse`.
:raises OmnigentError: 404 if no session exists, 500 if the
underlying conversation has no agent binding
(see :func:`_build_session_response`).
"""
conv = conversation
if conv is None:
conv = await asyncio.to_thread(conv_store.get_conversation, session_id)
if conv is None:
raise OmnigentError(
"Session not found",
code=ErrorCode.NOT_FOUND,
)
if refresh_state:
_invalidate_runner_backed_snapshot_state(session_id, cancel_inflight=False)
# Return the most recent committed items while preserving the
# SessionResponse contract that ``items`` is chronological. The
# store's default page is the oldest 100 (``order="asc"``), which
# makes long-session reconnects appear stale in clients that use the
# snapshot directly.
items: list[ConversationItem] = []
if include_items:
items_page = await asyncio.to_thread(
conv_store.list_items,
conversation_id=session_id,
limit=100,
order="desc",
)
items = list(reversed(items_page.data))
# Resolve the bound runner client once — used for live status (on a
# status-cache miss) and for runner-owned skill discovery below.
#
# Prefer the router (multi-runner deployments wire only
# ``set_runner_router``; the legacy ``get_runner_client`` singleton
# stays ``None`` there). Fall back to the legacy singleton for
# single-runner / in-process tests.
from omnigent.runtime import get_runner_client, get_runner_router
runner_client: httpx.AsyncClient | None = None
runner_router = get_runner_router()
if runner_router is not None:
try:
routed = runner_router.client_for_session_resources(session_id)
runner_client = routed.client
except (LookupError, httpx.HTTPError, OmnigentError):
_logger.debug(
"No runner bound for session=%s on snapshot build",
session_id,
)
if runner_client is None:
runner_client = get_runner_client()
status = _session_status_from_cache(session_id)
if status == "idle":
# Cache miss (or truly idle): either the server restarted, or the
# relay has not yet published the first ``"running"`` event for a
# freshly bound session (the relay's GET /stream is still in its
# tunnel handshake). Ask the runner for live status so we don't
# synthesize a stale ``"idle"`` while a turn is actually in flight.
# ``_session_status_from_cache`` already collapses the fine-grained
# relay values (``"waiting"`` → ``"running"``), so the raw cache value
# is only needed here when it is actually missing (None).
if _session_status_cache.get(session_id) is None and runner_client is not None:
try:
resp = await runner_client.get(
f"/v1/sessions/{session_id}",
timeout=5.0,
)
if resp.status_code == 200:
raw = resp.json().get("status", "idle")
_session_status_cache[session_id] = raw
status = _session_status_from_cache(session_id)
except (httpx.HTTPError, ConnectionError):
_logger.debug(
"Runner status query failed for %s",
session_id,
)
# last_total_tokens and last_task_error come from the context-tokens
# label written by the forwarder (tasks table has been removed).
last_total_tokens: int | None = None
last_task_error: dict[str, str] | None = None
raw_label = conv.labels.get(_LAST_CONTEXT_TOKENS_LABEL_KEY)
if isinstance(raw_label, str) and raw_label.isdigit():
last_total_tokens = int(raw_label)
last_task_error = _last_task_error_from_labels(conv.labels)
# Runner-crash durability: if the session's bound runner reported an
# unexpected exit (host.runner_exited → RunnerExitReports), surface the
# cause as last_task_error so a reload/late-open still renders the error
# banner — the live session.status:failed push is gone by then. status
# already reads "failed" from the cache (set by _on_runner_exited). The
# report is keyed by the CURRENT runner_id, so a successful relaunch
# (new token-bound runner_id) naturally stops matching. Access is gated
# by the session-snapshot's own authorization, so the unscoped get is
# correct here (the report is this session's own runner).
if runner_exit_reports is not None and conv.runner_id is not None:
exit_error = runner_exit_reports.get(conv.runner_id)
if exit_error is not None:
last_task_error = {"code": "runner_failed_to_start", "message": exit_error}
status = "failed"
llm_model: str | None = None
context_window: int | None = None
agent_name: str | None = None
if agent_store is not None and agent_cache is not None and conv.agent_id is not None:
try:
agent = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent is not None:
agent_name = agent.name
if agent.bundle_location is not None:
# Offload to a worker thread: on a cold cache this fetches
# the bundle from the artifact store and parses the spec —
# blocking IO that would otherwise stall the single-worker
# event loop on every page-load snapshot.
loaded = await asyncio.to_thread(
agent_cache.load, agent.id, agent.bundle_location
)
spec = loaded.spec
# Prefer the spec's name over the agent row's: a
# switch-created session-scoped clone is named
# "<builtin> (switch ag_…)" for row disambiguation,
# but clients display agent_name verbatim — the spec
# carries the clean identity (e.g. "claude-native-ui").
if spec.name:
agent_name = spec.name
llm_model = spec.executor.model
from omnigent.llms.context_window import (
resolve_effective_context_window,
)
# Size the context ring against whatever the next turn will
# actually run, using the SAME resolver the runner uses to
# budget compaction. That makes the UI ring and the runner's
# compaction trigger a single source of truth — computed by
# one function — so they can't drift even though they run in
# different processes at different times. (They previously
# each inlined this rule and silently fell out of step;
# sharing the function removes the manual
# sync.) spec.executor.context_window describes only the spec
# model, so an active override bypasses it — the resolver
# makes that decision from the spec model + override.
#
# Offload to a worker thread: an active override (or an
# undeclared window) can trigger a cache-cold provider
# catalog fetch (blocking HTTP / CPU-bound litellm) inside
# the resolver, which would otherwise stall the single-worker
# event loop and serialize every concurrent snapshot.
context_window = await asyncio.to_thread(
resolve_effective_context_window,
spec.executor.context_window,
llm_model,
model_override=conv.model_override,
)
except Exception: # noqa: BLE001 — best-effort; missing agent must not break session fetch
pass
# Skills are runner-owned: the bound runner discovers them against its
# own filesystem (bundled skills + host skills under the session's
# workspace and ``~/.claude/skills/``) — the host where the harness
# actually executes and may read a skill's local resource files. The
# server only overlays the result; best-effort, empty when no runner
# is bound or it can't be reached.
skills = await _fetch_runner_skills(runner_client, session_id)
# Codex model options are also runner-owned: they come from the
# session's live Codex app-server ``model/list`` response. Best-effort
# and cache-backed like skills so a snapshot poll cannot wedge the
# runner while a turn is active.
model_options = await _fetch_model_options(runner_client, session_id, conv)
# Dynamic override from the forwarder (real Claude Code window).
# Only present after the first statusLine tick; before that the
# spec default applies.
raw_window_label = conv.labels.get(_LAST_CONTEXT_WINDOW_LABEL_KEY)
if isinstance(raw_window_label, str) and raw_window_label.isdigit():
observed = int(raw_window_label)
if observed > 0:
context_window = observed
# Resolve strict runner + host liveness for the open-session view.
# The lookup hits the conversations + hosts tables, so offload it to
# a worker thread (mirroring _apply_liveness_to_items). Left None on
# both fields when no lookup is wired (focused tests).
runner_online: bool | None = None
host_online: bool | None = None
if liveness_lookup is not None:
liveness = await asyncio.to_thread(liveness_lookup, [session_id])
result = liveness.get(session_id)
if result is not None:
runner_online = result.runner_online
host_online = result.host_online
# Subtree usage (this session + its sub-agent descendants) so the
# displayed cost includes sub-agents — a codex/claude sub-agent's spend
# is persisted on its own child conversation, not the parent's, so the
# parent's own session_usage would under-report. Off the event loop
# because it pages the conversation tree from the store.
subtree_usage = await asyncio.to_thread(load_session_usage, conv.id, conv_store)
# Static signal telling the open view a host-bound, host-down session is a
# resumable managed host it can wake by sending a message, vs a terminal
# host_offline dead-end. Computed independently of liveness_lookup (the web
# chat passes include_liveness=False, so host_online is None here and
# liveness arrives via the poll/stream). One indexed host read, gated to
# host-bound sessions.
host_resumable = False
if host_store is not None and sandbox_config is not None and conv.host_id is not None:
host_for_resume = await asyncio.to_thread(host_store.get_host, conv.host_id)
if host_for_resume is not None:
host_resumable = host_resume_supported(host_for_resume, sandbox_config)
return _build_session_response(
conv,
items,
status,
permission_level,
background_task_count=_session_background_task_count_cache.get(session_id),
llm_model=llm_model,
context_window=context_window,
last_total_tokens=last_total_tokens,
last_task_error=last_task_error,
agent_name=agent_name,
skills=skills,
model_options=model_options,
runner_online=runner_online,
host_online=host_online,
host_resumable=host_resumable,
pending_elicitation_events=await asyncio.to_thread(
_pending_elicitation_snapshot_for_session,
conv_store,
conv,
),
subtree_usage=subtree_usage,
)