omnigent-ai--omnigent
21522 行
934 KiB
Python
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,
|
|
)
|