6404 lines
254 KiB
Python
6404 lines
254 KiB
Python
"""Runner-local tool dispatch for intercepted action_required events.
|
|
|
|
Per designs/RUNNER_TOOL_DISPATCH.md, the runner dispatches most tools
|
|
locally and relays action_required events upstream UNCHANGED for
|
|
visibility (the executor emits ToolCallInProgress/ToolCallObserved for
|
|
the REPL but doesn't dispatch itself — it checks should_dispatch_locally
|
|
and skips).
|
|
|
|
Tool categories:
|
|
- _OS_ENV_TOOLS: execute through a runner-local OSEnvironment (sys_os_*)
|
|
- _REST_TOOLS: call server REST APIs (sys_call_async, sys_cancel_async)
|
|
- _FILE_TOOLS: call server file APIs (sys_upload/download/list_files)
|
|
- _TERMINAL_TOOLS: runner-local TerminalRegistry
|
|
- MCP tools: spec-defined; dispatched via RunnerMcpManager passed
|
|
in by proxy_stream (designs/RUNNER_MCP.md). Not in the static
|
|
allow-list because names vary per spec.
|
|
- Client-side tools: tunneled via REPL (deferred)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import json
|
|
import logging
|
|
import mimetypes
|
|
import os
|
|
import tempfile
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from omnigent.runtime.filesystem_registry import FilesystemRegistry
|
|
|
|
import httpx
|
|
|
|
from omnigent._wrapper_labels import (
|
|
CLAUDE_NATIVE_WRAPPER_VALUE,
|
|
CODEX_NATIVE_WRAPPER_VALUE,
|
|
)
|
|
from omnigent.harness_aliases import canonicalize_harness
|
|
from omnigent.model_override import (
|
|
harness_supports_model_override,
|
|
model_family_mismatch,
|
|
normalize_model_for_provider,
|
|
validate_model_override,
|
|
)
|
|
from omnigent.native_coding_agents import public_agent_name
|
|
from omnigent.runtime import pending_elicitations
|
|
from omnigent.session_lifecycle import (
|
|
CLOSED_LABEL_KEY,
|
|
CLOSED_LABEL_VALUE,
|
|
is_session_closed,
|
|
title_without_closed_marker,
|
|
)
|
|
from omnigent.tools import ToolManager
|
|
from omnigent.tools.base import ToolContext
|
|
from omnigent.tools.builtins.async_inbox import (
|
|
SysCallAsyncTool,
|
|
SysCancelAsyncTool,
|
|
SysCancelTaskTool,
|
|
SysReadInboxTool,
|
|
)
|
|
from omnigent.tools.builtins.download_file import DownloadFileTool
|
|
from omnigent.tools.builtins.list_comments import ListCommentsTool
|
|
from omnigent.tools.builtins.os_env import (
|
|
SysOsEditTool,
|
|
SysOsReadTool,
|
|
SysOsShellTool,
|
|
SysOsWriteTool,
|
|
)
|
|
from omnigent.tools.builtins.spawn import (
|
|
# Shared contract values with the in-process sys_session_* tools. Imported
|
|
# (not duplicated) so the runner's REST-backed peek clamps to the same
|
|
# bounds the LLM-facing tool schema advertises and tombstones with the
|
|
# same marker the in-process close writes.
|
|
_ACTIVITY_MAX_CHARS,
|
|
_CLOSED_TITLE_INFIX,
|
|
_HISTORY_DEFAULT_TAIL,
|
|
_clamp_tail_items,
|
|
)
|
|
from omnigent.tools.builtins.sys_terminal import (
|
|
SysTerminalCloseTool,
|
|
SysTerminalLaunchTool,
|
|
SysTerminalListTool,
|
|
SysTerminalReadTool,
|
|
SysTerminalSendTool,
|
|
)
|
|
from omnigent.tools.builtins.timer import (
|
|
# Shared with the in-process sys_timer_set tool so the runner's firing
|
|
# loop validates the same argument shape and delay ceiling the LLM-facing
|
|
# schema advertises.
|
|
validate_timer_set_args,
|
|
)
|
|
from omnigent.tools.builtins.update_comment import UpdateCommentTool
|
|
from omnigent.tools.builtins.upload_file import UploadFileTool, safe_resolve
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_INBOX_OUTPUT_MAX_CHARS = 12000
|
|
_OS_ENV_SHELL_DEFAULT_TIMEOUT_S = 120.0
|
|
_RUNNER_EXECUTION_TIMEOUT_S = 7200.0
|
|
_SUBAGENT_POLICY_STATUSES = frozenset({"completed", "failed"})
|
|
_SUBAGENT_INBOX_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"})
|
|
_SUBAGENT_POLICY_FAILURE_OUTPUT = "[Result suppressed by policy: policy evaluation failed]"
|
|
_SESSION_WRAPPER_LABEL_KEY = "omnigent.wrapper"
|
|
# Read budget for runner→server message-send POSTs that are gated at the
|
|
# recipient's REQUEST phase, which can PARK behind a human-approval ASK gate
|
|
# (e.g. session_cost_budget) for the deciding policy's ``ask_timeout``. Held at
|
|
# one day (86400s) — matching that default — so the send WAITS for the verdict
|
|
# instead of severing the parked gate at a short read timeout (a 30s cut
|
|
# previously fail-closed to DENY). Fast connect (30s) so an unreachable server
|
|
# still fails out promptly. Guarded by tests/test_ask_timeout_infinite.py.
|
|
_ASK_GATE_DELIVERY_READ_TIMEOUT_S: float = 86400.0
|
|
_ASK_GATE_DELIVERY_TIMEOUT = httpx.Timeout(_ASK_GATE_DELIVERY_READ_TIMEOUT_S, connect=30.0)
|
|
|
|
# Read timeouts for the two MCP-proxy hops that carry a tool call back to the
|
|
# runner (runner → Omnigent server → runner). ``sys_os_shell`` accepts caller-provided
|
|
# timeouts, so these must sit above the runner's execution timeout rather than
|
|
# only above the 120-second shell default. Keep the outer hop larger so the
|
|
# AP→runner leg fails first with the more specific error when the proxy wedges.
|
|
MCP_PROXY_FORWARD_TIMEOUT_S = _RUNNER_EXECUTION_TIMEOUT_S + 30.0
|
|
MCP_PROXY_CALL_TIMEOUT_S = _RUNNER_EXECUTION_TIMEOUT_S + 60.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _CancelAsyncToolResult:
|
|
"""
|
|
Internal result for local async-task cancellation.
|
|
|
|
:param output: Tool output string to return to the LLM.
|
|
:param try_subagent_cancel: Whether no local async task matched,
|
|
so ``sys_cancel_task`` should try the sub-agent work registry
|
|
next.
|
|
"""
|
|
|
|
output: str
|
|
try_subagent_cancel: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _SubagentInboxEvaluation:
|
|
"""
|
|
Result of delayed sub-agent output policy evaluation.
|
|
|
|
:param payload: Payload safe to format for ``sys_read_inbox``.
|
|
On fail-closed paths this contains a policy-failure sentinel
|
|
instead of the raw child output.
|
|
:param retry_original: Whether policy evaluation failed before
|
|
producing a terminal verdict, so the original payload should
|
|
be requeued for a future drain attempt.
|
|
"""
|
|
|
|
payload: dict[str, Any]
|
|
retry_original: bool = False
|
|
|
|
|
|
# ── Tool sets (Phase 0 reorganization) ─────────────────────
|
|
# Use class .name() methods where available for single-source-of-truth.
|
|
|
|
# Priority 5a: OS env tools — runner-local OSEnvironment-backed execution.
|
|
_OS_ENV_TOOLS = frozenset(
|
|
{
|
|
SysOsReadTool.name(),
|
|
SysOsWriteTool.name(),
|
|
SysOsEditTool.name(),
|
|
SysOsShellTool.name(),
|
|
}
|
|
)
|
|
|
|
# Priority 5b: REST-backed tools — runner calls server REST APIs.
|
|
# (sys_call_async / sys_cancel_async moved to _ASYNC_INBOX_TOOLS)
|
|
_REST_TOOLS: frozenset[str] = frozenset()
|
|
|
|
# Priority 5c: File tools — runner calls server file APIs.
|
|
_FILE_TOOLS = frozenset(
|
|
{
|
|
UploadFileTool.name(),
|
|
DownloadFileTool.name(),
|
|
"list_files", # from builtins registry; no standalone class
|
|
}
|
|
)
|
|
|
|
# Priority 5d: Terminal tools — runner-local TerminalRegistry.
|
|
_TERMINAL_TOOLS = frozenset(
|
|
{
|
|
SysTerminalLaunchTool.name(),
|
|
SysTerminalSendTool.name(),
|
|
SysTerminalReadTool.name(),
|
|
SysTerminalListTool.name(),
|
|
SysTerminalCloseTool.name(),
|
|
}
|
|
)
|
|
|
|
# Priority 5e: Async inbox tools — runner-local, backed by
|
|
# per-session asyncio queues (SESSION_REARCHITECTURE Step 7).
|
|
_ASYNC_INBOX_TOOLS = frozenset(
|
|
{
|
|
SysCallAsyncTool.name(),
|
|
SysReadInboxTool.name(),
|
|
SysCancelAsyncTool.name(),
|
|
}
|
|
)
|
|
|
|
# Priority 5f: Sub-agent tools. ``sys_session_send`` creates or
|
|
# continues child sessions. The read-only observability helpers
|
|
# (peek/list/close) dispatch via ``_SESSION_QUERY_TOOLS`` below.
|
|
_SUBAGENT_TOOLS = frozenset({"sys_session_send"})
|
|
|
|
# Priority 5f.0a: Session-create write. ``sys_session_create`` spawns a
|
|
# child session (parent forced to the caller) from an existing agent_id
|
|
# via the JSON POST /v1/sessions create — same server-permission posture
|
|
# as _execute_subagent_tool.
|
|
_SESSION_CREATE_TOOLS = frozenset({"sys_session_create"})
|
|
|
|
# Priority 5f.0: Session query tools — peek/list/close/get_info/share. The
|
|
# runner has no in-process ConversationStore, so these read/mutate session
|
|
# state via the Omnigent server's existing REST endpoints (GET /items, GET
|
|
# /child_sessions, GET /sessions/{id}, PATCH /sessions/{id}, PUT
|
|
# /sessions/{id}/permissions) over server_client — same channel and security
|
|
# posture as _execute_subagent_tool / _execute_comment_tool.
|
|
_SESSION_QUERY_TOOLS = frozenset(
|
|
{
|
|
"sys_session_get_history",
|
|
"sys_session_list",
|
|
"sys_session_close",
|
|
"sys_session_get_info",
|
|
"sys_session_share",
|
|
}
|
|
)
|
|
|
|
# Grantee sentinel for an anonymous, public read-only share. Mirrors the
|
|
# server's RESERVED_USER_PUBLIC; only specs with
|
|
# ``agent_session_sharing: public`` may grant it (enforced in
|
|
# _session_share_via_rest — the server can't see the agent's sharing
|
|
# policy, so the runner is the gate).
|
|
_PUBLIC_USER_SENTINEL = "__public__"
|
|
|
|
# Spec ``agent_session_sharing:`` policy values
|
|
# (omnigent.spec.types.SharePolicy) that enable the sys_session_share
|
|
# tool. Compared as plain strings since SharePolicy is a str-enum;
|
|
# anything else (incl. "none"/absent) is off.
|
|
_SHARE_ENABLED_POLICIES = frozenset({"non-public", "public"})
|
|
_SHARE_PUBLIC_POLICY = "public"
|
|
|
|
# Priority 5f.1: web_fetch — translates the LLM-facing query/url
|
|
# arguments into a sys_session_send call against the built-in
|
|
# ``__web_researcher`` sub-agent, then reuses
|
|
# ``_execute_subagent_tool``.
|
|
_WEB_FETCH_TOOLS = frozenset({"web_fetch"})
|
|
|
|
# Priority 5f.1b: web_search — the first-party search builtin. Runner-local
|
|
# so a non-OpenAI model's web_search function call resolves to the spec's
|
|
# configured backend (google / perplexity / nimble) via WebSearchTool.invoke.
|
|
# (OpenAI models use the native web_search_preview passthrough and never reach
|
|
# this path.) Without this entry the call fell through to the spec-callable
|
|
# branch and errored "tool unavailable" — the gap behind the non-OpenAI
|
|
# web_search known-failure.
|
|
_WEB_SEARCH_TOOLS = frozenset({"web_search"})
|
|
|
|
# Hindsight long-term memory builtins. Runner-local (like web_search) so that a
|
|
# wrapped harness's (claude-sdk / codex / cursor / pi) tool call resolves to the
|
|
# spec-configured Hindsight tool via its ``invoke``. Without this entry the call
|
|
# falls through to the harness, which has no such tool, and silently no-ops.
|
|
_HINDSIGHT_TOOLS = frozenset({"hindsight_retain", "hindsight_recall", "hindsight_reflect"})
|
|
|
|
# Priority 5f.2: sys_list_models — runner-local because provider resolution
|
|
# reads the runner host's config/credentials, same as the spawn paths.
|
|
_LIST_MODELS_TOOLS = frozenset({"sys_list_models"})
|
|
|
|
# Priority 5g: Timer tools — runner-local asyncio.sleep tasks
|
|
# (RUNNER_TIMER_DISPATCH.md).
|
|
_TIMER_TOOLS = frozenset({"sys_timer_set", "sys_timer_cancel"})
|
|
|
|
# Priority 5f.3: sys_advise_models — server-side via MCP intercept;
|
|
# included in the tool surface only when smart routing is enabled.
|
|
_ADVISE_MODELS_TOOLS = frozenset({"sys_advise_models"})
|
|
|
|
# Priority 5h: Task lifecycle tools — runner-local sys_cancel_task.
|
|
# The only cancellable task ids visible to the LLM are async dispatches
|
|
# and sub-agent handles; observation happens through sys_read_inbox.
|
|
_TASK_LIFECYCLE_TOOLS = frozenset(
|
|
{
|
|
SysCancelTaskTool.name(),
|
|
}
|
|
)
|
|
|
|
# Priority 5i: Skill tools — load_skill and read_skill_file.
|
|
# Dispatched locally in the runner so harness subprocesses can
|
|
# call them via the action_required → dispatch_tool_locally path.
|
|
_SKILL_TOOLS = frozenset({"load_skill", "read_skill_file"})
|
|
|
|
# Priority 5j: Comment tools — list_comments and update_comment.
|
|
# Auto-registered by ToolManager. The runner has no in-process
|
|
# CommentStore, so _execute_comment_tool uses server_client REST
|
|
# calls (GET/PATCH /v1/sessions/{id}/comments) instead.
|
|
_COMMENT_TOOLS = frozenset(
|
|
{
|
|
ListCommentsTool.name(),
|
|
UpdateCommentTool.name(),
|
|
}
|
|
)
|
|
|
|
# Priority 5k: Agent-management reads — sys_agent_get / sys_agent_download /
|
|
# sys_agent_list. The runner has no in-process AgentStore/ArtifactStore, so
|
|
# these proxy the Omnigent server's REST endpoints (GET /v1/sessions/{id}/agent,
|
|
# .../agent/contents, GET /v1/agents, GET /v1/sessions) over server_client.
|
|
# sys_agent_download writes the bundle bytes into the agent's local os_env
|
|
# cwd so sys_os_* can read it; sys_agent_list also scans that cwd for
|
|
# locally-authored configs.
|
|
_AGENT_TOOLS = frozenset({"sys_agent_get", "sys_agent_download", "sys_agent_list"})
|
|
|
|
# Priority 5l: Policy management — sys_add_policy.
|
|
# The runner proxies the Omnigent server's session policy REST endpoint.
|
|
_POLICY_TOOLS = frozenset({"sys_add_policy", "sys_policy_registry"})
|
|
|
|
# Priority 5m: Embedded-browser tools.
|
|
# Runner dispatch POSTs a blocking action request to the server, which parks a
|
|
# Future + publishes ``browser.action_request`` on the session stream; the
|
|
# Omnigent desktop renderer claims and executes the action, then POSTs the
|
|
# result back. Execution lives HERE (not in Tool.invoke) because the browser
|
|
# protocol needs the runner's ``server_client`` and ``ToolContext`` carries
|
|
# none. See omnigent/tools/builtins/browser.py for the schema-only classes.
|
|
_BROWSER_TOOLS = frozenset(
|
|
{
|
|
"browser_navigate",
|
|
"browser_snapshot",
|
|
"browser_click",
|
|
"browser_type",
|
|
"browser_screenshot",
|
|
}
|
|
)
|
|
|
|
# Runner-side outer HTTP read timeout for a browser action POST. The read
|
|
# budget (60s) MUST exceed the server-side browser-action await (30s) so the
|
|
# runner never severs the still-open POST before the server returns either the
|
|
# action result JSON or the clean timeout-error JSON. Fast connect (30s) so an
|
|
# unreachable server still fails promptly.
|
|
_BROWSER_ACTION_TIMEOUT = httpx.Timeout(60.0, connect=30.0)
|
|
|
|
# Returned as the tool output (HTTP 200 body, not an exception) when the server
|
|
# browser-action await elapses with no renderer result — a clear
|
|
# "is the session open?" message so the LLM gets a clean, actionable error.
|
|
_BROWSER_TIMEOUT_ERROR = (
|
|
'{"error": "browser action timed out — is the session open in the Omnigent desktop app?"}'
|
|
)
|
|
|
|
# Builtin tools the claude-native / codex-native relay advertises to the
|
|
# real CLI, beyond the always-relayed ``sys_os_*`` family. Native harnesses
|
|
# ignore the harness ``tools`` list, so the relay is their ONLY tool
|
|
# surface; this set is the runner-/server-proxied builtin surface that
|
|
# rides through the Omnigent ``/mcp`` endpoint (comment, session read/write,
|
|
# async inbox, task lifecycle, agent-discovery, and terminal families —
|
|
# the same dispatch posture non-native harnesses get via
|
|
# ``request.tools``). ``sys_terminal_*`` inherits the spec gate for
|
|
# free: the relay only advertises names that ``ToolManager(spec)``
|
|
# actually registered, and terminal tools register only when the spec
|
|
# declares a non-empty ``terminals:`` block.
|
|
# ``sys_os_*`` is intentionally excluded: the
|
|
# bridge exposes static ``sys_os_*`` tools and the relay overrides them
|
|
# unconditionally for policy enforcement (independent of the spec's
|
|
# ``os_env`` gate), so the native relay assembles them separately.
|
|
_NATIVE_RELAY_BUILTIN_TOOLS = (
|
|
_COMMENT_TOOLS
|
|
| _SESSION_QUERY_TOOLS
|
|
| _ASYNC_INBOX_TOOLS
|
|
| _SUBAGENT_TOOLS
|
|
| _LIST_MODELS_TOOLS
|
|
| _ADVISE_MODELS_TOOLS
|
|
| _SESSION_CREATE_TOOLS
|
|
| _TASK_LIFECYCLE_TOOLS
|
|
| _AGENT_TOOLS
|
|
| _POLICY_TOOLS
|
|
| _TERMINAL_TOOLS
|
|
# ``browser_*`` must ride the native relay: the Omnigent desktop app
|
|
# runs native (claude/codex/pi) sessions, which ignore ``request.tools``
|
|
# and see ONLY this relay surface — without this union member the
|
|
# feature is dead for its real target. The relay still filters
|
|
# ``ToolManager(spec).get_tool_schemas()``, so browser schemas appear
|
|
# only when the spec declares the builtins (see builtins/__init__.py).
|
|
| _BROWSER_TOOLS
|
|
# Memory builtins are relayed to native harnesses too — unlike web_search,
|
|
# native harnesses have no built-in long-term memory of their own.
|
|
| _HINDSIGHT_TOOLS
|
|
)
|
|
|
|
|
|
def build_native_relay_tool_schemas(spec: Any | None) -> list[dict[str, Any]]:
|
|
"""Build the flat Omnigent tool surface for native harness bridges.
|
|
|
|
Returns the same tool set the claude-native / codex-native relay advertises
|
|
and that pi-native registers via ``pi.registerTool``: the spec-gated builtin
|
|
surface (``_NATIVE_RELAY_BUILTIN_TOOLS`` — comment, session read/write,
|
|
agent-discovery, policy, and terminal families) plus the ``sys_os_*`` tools,
|
|
relayed unconditionally so they override any harness-static versions and get
|
|
centralized policy enforcement on the Omnigent server.
|
|
|
|
Each entry is a flat ``{"name", "description", "parameters"}`` dict (the
|
|
``"function"`` sub-dict of an OpenAI tool schema), which is exactly what
|
|
``pi.registerTool`` and the claude-native relay both consume.
|
|
|
|
:param spec: The session's resolved agent spec. ``None`` falls back to the
|
|
always-on read/discovery surface (never the opt-in spawn writes, whose
|
|
gate can't be evaluated without the spec), mirroring the relay.
|
|
:returns: Flat tool schemas for native bridges.
|
|
"""
|
|
from omnigent.tools.builtins.agents import (
|
|
SysAgentDownloadTool,
|
|
SysAgentGetTool,
|
|
SysAgentListTool,
|
|
)
|
|
from omnigent.tools.builtins.list_comments import ListCommentsTool
|
|
from omnigent.tools.builtins.os_env import (
|
|
SysOsEditTool,
|
|
SysOsReadTool,
|
|
SysOsShellTool,
|
|
SysOsWriteTool,
|
|
)
|
|
from omnigent.tools.builtins.spawn import (
|
|
SysSessionGetHistoryTool,
|
|
SysSessionGetInfoTool,
|
|
SysSessionListTool,
|
|
)
|
|
from omnigent.tools.builtins.update_comment import UpdateCommentTool
|
|
|
|
schemas: list[dict[str, Any]] = []
|
|
|
|
def _append(function_dict: dict[str, Any]) -> None:
|
|
schemas.append(
|
|
{
|
|
"name": function_dict["name"],
|
|
"description": function_dict.get("description", ""),
|
|
"parameters": function_dict.get(
|
|
"parameters", {"type": "object", "properties": {}}
|
|
),
|
|
}
|
|
)
|
|
|
|
if spec is not None:
|
|
from omnigent.tools.manager import ToolManager
|
|
|
|
for _schema in ToolManager(spec).get_tool_schemas():
|
|
_fn = _schema["function"]
|
|
if _fn["name"] in _NATIVE_RELAY_BUILTIN_TOOLS:
|
|
_append(_fn)
|
|
else:
|
|
from omnigent.tools.builtins.policy import SysAddPolicyTool, SysPolicyRegistryTool
|
|
|
|
for _cls in (
|
|
ListCommentsTool,
|
|
UpdateCommentTool,
|
|
SysSessionListTool,
|
|
SysSessionGetHistoryTool,
|
|
SysSessionGetInfoTool,
|
|
SysAgentGetTool,
|
|
SysAgentListTool,
|
|
SysAgentDownloadTool,
|
|
SysAddPolicyTool,
|
|
SysPolicyRegistryTool,
|
|
):
|
|
_append(_cls().get_schema()["function"])
|
|
|
|
# OS tools (sys_os_*), relayed unconditionally to override any harness-static
|
|
# versions and centralize policy enforcement. Create a minimal OSEnvironment
|
|
# purely for schema extraction.
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
from omnigent.inner.os_env import create_os_environment
|
|
|
|
_os_spec = OSEnvSpec(
|
|
type="caller_process",
|
|
cwd=str(Path.cwd()),
|
|
sandbox=OSEnvSandboxSpec(type="none"),
|
|
fork=False,
|
|
)
|
|
try:
|
|
_os_env = create_os_environment(_os_spec)
|
|
for _tool in (
|
|
SysOsReadTool(_os_env),
|
|
SysOsWriteTool(_os_env),
|
|
SysOsEditTool(_os_env),
|
|
SysOsShellTool(_os_env),
|
|
):
|
|
_append(_tool.get_schema()["function"])
|
|
_os_env.close()
|
|
except Exception: # noqa: BLE001 — OS env setup is best-effort for schema only
|
|
_logger.debug("Could not create OSEnvironment for native relay OS tool schemas")
|
|
|
|
return schemas
|
|
|
|
|
|
# sys_agent_list: locally-authored agent config YAMLs live under this
|
|
# subdirectory of the agent's os_env cwd, so the list tool can find them
|
|
# and the agent can read/edit them via sys_os_* (configs are authored with
|
|
# sys_os_write, e.g. following the ``build-omnigent`` skill).
|
|
_AGENT_CONFIG_SUBDIR = ".omnigent/agent-configs"
|
|
|
|
# Broad page size for the sys_agent_list fan-out reads. Orchestrators want
|
|
# the full launchable surface in one call, not a 20-row default page.
|
|
_AGENT_LIST_PAGE_LIMIT = 1000
|
|
|
|
# Union of all locally-dispatched tools.
|
|
_ALL_LOCAL_TOOLS = (
|
|
_OS_ENV_TOOLS
|
|
| _REST_TOOLS
|
|
| _FILE_TOOLS
|
|
| _TERMINAL_TOOLS
|
|
| _ASYNC_INBOX_TOOLS
|
|
| _SUBAGENT_TOOLS
|
|
| _LIST_MODELS_TOOLS
|
|
| _ADVISE_MODELS_TOOLS
|
|
| _SESSION_CREATE_TOOLS
|
|
| _SESSION_QUERY_TOOLS
|
|
| _WEB_FETCH_TOOLS
|
|
| _WEB_SEARCH_TOOLS
|
|
| _HINDSIGHT_TOOLS
|
|
| _TIMER_TOOLS
|
|
| _TASK_LIFECYCLE_TOOLS
|
|
| _SKILL_TOOLS
|
|
| _COMMENT_TOOLS
|
|
| _AGENT_TOOLS
|
|
| _POLICY_TOOLS
|
|
)
|
|
_PLACEHOLDER_CWDS = (None, "", ".", "./")
|
|
|
|
|
|
def is_action_required(event: dict[str, Any]) -> bool:
|
|
"""Check if an SSE event is an action_required tool call."""
|
|
if event.get("type") != "response.output_item.done":
|
|
return False
|
|
item = event.get("item") or {}
|
|
return item.get("type") == "function_call" and item.get("status") == "action_required"
|
|
|
|
|
|
def get_tool_name(event: dict[str, Any]) -> str:
|
|
"""Extract the tool name from an action_required event."""
|
|
return (event.get("item") or {}).get("name", "")
|
|
|
|
|
|
def get_call_id(event: dict[str, Any]) -> str:
|
|
"""Extract the call_id from an action_required event."""
|
|
return (event.get("item") or {}).get("call_id", "")
|
|
|
|
|
|
def get_arguments(event: dict[str, Any]) -> str:
|
|
"""Extract the arguments JSON string from an action_required event."""
|
|
return (event.get("item") or {}).get("arguments", "{}")
|
|
|
|
|
|
def should_dispatch_locally(tool_name: str) -> bool:
|
|
"""Return True if this tool should be dispatched by the runner locally.
|
|
|
|
Used by BOTH the runner's proxy_stream (to decide whether to
|
|
dispatch) AND the server-side executor (to skip its own dispatch
|
|
for tools the runner already handled). The executor imports this
|
|
function directly — Phase 5 of RUNNER_TOOL_DISPATCH.md.
|
|
"""
|
|
return tool_name in _ALL_LOCAL_TOOLS
|
|
|
|
|
|
def _is_spec_local_python_tool(tool_name: str, agent_spec: Any | None) -> bool:
|
|
local_tools = getattr(agent_spec, "local_tools", None) or []
|
|
return any(
|
|
getattr(info, "name", None) == tool_name
|
|
and getattr(info, "language", None) == "python"
|
|
and getattr(info, "path", None)
|
|
for info in local_tools
|
|
)
|
|
|
|
|
|
async def _execute_local_python_tool(
|
|
tool_name: str,
|
|
args: str,
|
|
*,
|
|
agent_spec: Any | None,
|
|
conversation_id: str | None,
|
|
task_id: str | None,
|
|
agent_id: str | None,
|
|
runner_workspace: Path | None,
|
|
) -> str:
|
|
if agent_spec is None:
|
|
return f"Error: {tool_name} not in local dispatch table (no agent spec)"
|
|
manager = ToolManager(agent_spec, workdir=runner_workspace)
|
|
try:
|
|
workspace = None
|
|
if runner_workspace is not None and conversation_id is not None:
|
|
workspace = runner_workspace / conversation_id
|
|
workspace.mkdir(parents=True, exist_ok=True)
|
|
ctx = ToolContext(
|
|
task_id=task_id or conversation_id or "runner-local-tool",
|
|
agent_id=agent_id or getattr(agent_spec, "name", "runner-agent") or "runner-agent",
|
|
workspace=workspace,
|
|
conversation_id=conversation_id,
|
|
)
|
|
return await asyncio.to_thread(manager.call_tool, tool_name, args, ctx)
|
|
except Exception as exc:
|
|
_logger.exception("runner local Python tool dispatch failed for %s", tool_name)
|
|
return f"Error: {type(exc).__name__}: {exc}"
|
|
finally:
|
|
manager.shutdown()
|
|
|
|
|
|
# Cache of resolved callables keyed by dotted path. Avoids
|
|
# re-importing on every invocation of the same tool.
|
|
_callable_cache: dict[str, Callable[..., Any]] = {}
|
|
|
|
|
|
def _resolve_spec_callable(
|
|
tool_name: str,
|
|
agent_spec: Any | None,
|
|
) -> Callable[..., Any] | str:
|
|
"""
|
|
Look up a custom callable tool in the agent spec and resolve it.
|
|
|
|
Returns the callable on success, or an error string on failure.
|
|
Caches resolved callables in :data:`_callable_cache` so
|
|
repeated invocations of the same tool skip the import.
|
|
|
|
:param tool_name: Tool name from the LLM, e.g. ``"echo"``.
|
|
:param agent_spec: The session's :class:`AgentSpec`. ``None``
|
|
when no spec is available.
|
|
:returns: The resolved callable, or an error string if the
|
|
tool is not found or the import fails.
|
|
"""
|
|
import importlib
|
|
|
|
if agent_spec is None:
|
|
return f"Error: {tool_name} not in local dispatch table (no agent spec)"
|
|
local_tools = getattr(agent_spec, "local_tools", None) or []
|
|
tool_info = next((lt for lt in local_tools if lt.name == tool_name), None)
|
|
if tool_info is None or not tool_info.path:
|
|
return f"Error: {tool_name} not in local dispatch table"
|
|
dotted_path = tool_info.path
|
|
cached = _callable_cache.get(dotted_path)
|
|
if cached is not None:
|
|
return cached
|
|
module_name, _, attr_name = dotted_path.rpartition(".")
|
|
if not module_name or not attr_name:
|
|
return f"Error: {tool_name} has invalid callable path {dotted_path!r}"
|
|
mod = importlib.import_module(module_name)
|
|
fn = getattr(mod, attr_name, None)
|
|
if fn is None:
|
|
return f"Error: {tool_name}: module {module_name!r} has no attribute {attr_name!r}"
|
|
_callable_cache[dotted_path] = fn
|
|
return fn
|
|
|
|
|
|
async def _execute_spec_callable_tool(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
*,
|
|
agent_spec: Any | None = None,
|
|
) -> str:
|
|
"""
|
|
Execute a custom callable tool defined in the agent spec YAML.
|
|
|
|
Resolves the dotted Python path via :func:`_resolve_spec_callable`,
|
|
then calls the function with the LLM's arguments as kwargs.
|
|
Sync callables run in a worker thread via ``asyncio.to_thread``
|
|
to avoid blocking the event loop.
|
|
|
|
:param tool_name: Tool name from the LLM, e.g. ``"echo"``.
|
|
:param args: Parsed argument dict from the LLM.
|
|
:param agent_spec: The session's :class:`AgentSpec`. ``None``
|
|
when no spec is available (returns an error string).
|
|
:returns: Tool output as a string, or an error message.
|
|
"""
|
|
resolved = _resolve_spec_callable(tool_name, agent_spec)
|
|
if isinstance(resolved, str):
|
|
return resolved
|
|
if asyncio.iscoroutinefunction(resolved):
|
|
result = await resolved(**args)
|
|
else:
|
|
result = await asyncio.to_thread(resolved, **args)
|
|
return str(result) if result is not None else ""
|
|
|
|
|
|
# ── Unity Catalog function dispatch ───────────────────────────
|
|
#
|
|
# UC function tools are declared with ``catalog_path:`` in the YAML
|
|
# and executed via the Databricks SQL Statement Execution API.
|
|
|
|
|
|
def _is_uc_function_tool(
|
|
tool_name: str,
|
|
agent_spec: Any | None,
|
|
) -> bool:
|
|
"""
|
|
Check whether *tool_name* is a UC function tool in the spec.
|
|
|
|
:param tool_name: Tool name from the LLM, e.g.
|
|
``"classify_text"``.
|
|
:param agent_spec: The session's :class:`AgentSpec`. ``None``
|
|
when no spec is available.
|
|
:returns: ``True`` if the tool is a
|
|
:attr:`ToolRuntime.UC_FUNCTION` tool.
|
|
"""
|
|
if agent_spec is None:
|
|
return False
|
|
local_tools = getattr(agent_spec, "local_tools", None) or []
|
|
from omnigent.spec.types import ToolRuntime
|
|
|
|
return any(
|
|
lt.name == tool_name and lt.runtime == ToolRuntime.UC_FUNCTION for lt in local_tools
|
|
)
|
|
|
|
|
|
def _resolve_uc_profile(agent_spec: Any) -> str | None:
|
|
"""
|
|
Extract the Databricks profile from the agent spec's executor
|
|
auth configuration.
|
|
|
|
Checks ``executor.auth`` (preferred) then falls back to
|
|
``executor.profile`` (deprecated) and finally
|
|
``executor.config["profile"]`` (compat bridge).
|
|
|
|
:param agent_spec: The session's :class:`AgentSpec`.
|
|
:returns: The profile name, e.g. ``"oss"``, or ``None`` for
|
|
SDK default resolution.
|
|
"""
|
|
executor = getattr(agent_spec, "executor", None)
|
|
if executor is None:
|
|
return None
|
|
# Preferred: executor.auth.profile (DatabricksAuth).
|
|
auth = getattr(executor, "auth", None)
|
|
if auth is not None and hasattr(auth, "profile"):
|
|
return auth.profile
|
|
# Deprecated: executor.profile.
|
|
profile = getattr(executor, "profile", None)
|
|
if profile:
|
|
return profile
|
|
# Compat bridge: executor.config["profile"].
|
|
config = getattr(executor, "config", None) or {}
|
|
return config.get("profile")
|
|
|
|
|
|
async def _execute_uc_function_tool(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
*,
|
|
agent_spec: Any | None = None,
|
|
) -> str:
|
|
"""
|
|
Execute a Unity Catalog function tool and return the output
|
|
string.
|
|
|
|
Resolves the ``catalog_path`` from the spec's ``local_tools``,
|
|
extracts the Databricks profile and warehouse ID from the
|
|
executor config, then delegates to
|
|
:func:`omnigent.runner.uc_function.execute_uc_function`.
|
|
|
|
:param tool_name: Tool name from the LLM, e.g.
|
|
``"classify_text"``.
|
|
:param args: Parsed argument dict from the LLM.
|
|
:param agent_spec: The session's :class:`AgentSpec`. Must not
|
|
be ``None`` (caller checks via :func:`_is_uc_function_tool`
|
|
first).
|
|
:returns: Tool output as a string, or an error message.
|
|
"""
|
|
from omnigent.runner.uc_function import execute_uc_function
|
|
|
|
local_tools = getattr(agent_spec, "local_tools", None) or []
|
|
tool_info = next((lt for lt in local_tools if lt.name == tool_name), None)
|
|
if tool_info is None or tool_info.catalog_path is None:
|
|
return f"Error: {tool_name} is not a UC function tool"
|
|
|
|
profile = _resolve_uc_profile(agent_spec)
|
|
warehouse_id = getattr(tool_info, "warehouse_id", None)
|
|
|
|
return await execute_uc_function(
|
|
catalog_path=tool_info.catalog_path,
|
|
args=args,
|
|
profile=profile,
|
|
warehouse_id=warehouse_id,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _SubagentLabel:
|
|
"""
|
|
Human-facing identity fields for a child session.
|
|
|
|
:param agent: Sub-agent tool name, e.g. ``"claude"``. ``None`` means the
|
|
server row did not include a valid tool name.
|
|
:param title: Child session title, e.g. ``"issue-1756"``. ``None`` means
|
|
the server row did not include a valid session title.
|
|
"""
|
|
|
|
agent: str | None
|
|
title: str | None
|
|
|
|
|
|
def _subagent_label(child: dict[str, Any]) -> _SubagentLabel:
|
|
"""
|
|
Extract child identity fields from a child-session summary.
|
|
|
|
:param child: One object from
|
|
``GET /v1/sessions/{parent}/child_sessions``, e.g.
|
|
``{"tool": "claude", "session_name": "issue-1"}``.
|
|
:returns: Named child identity fields.
|
|
"""
|
|
agent = child.get("tool")
|
|
title = child.get("session_name")
|
|
return _SubagentLabel(
|
|
agent=agent if isinstance(agent, str) and agent else None,
|
|
title=title if isinstance(title, str) and title else None,
|
|
)
|
|
|
|
|
|
def _session_wrapper_label(session_payload: dict[str, Any]) -> str | None:
|
|
"""
|
|
Extract the native terminal wrapper label from a session payload.
|
|
|
|
:param session_payload: Session or child-session payload, e.g.
|
|
``{"labels": {"omnigent.wrapper": "codex-native-ui"}}``.
|
|
:returns: Wrapper label value, or ``None`` when absent.
|
|
"""
|
|
labels = session_payload.get("labels")
|
|
if not isinstance(labels, dict):
|
|
return None
|
|
wrapper = labels.get(_SESSION_WRAPPER_LABEL_KEY)
|
|
return wrapper if isinstance(wrapper, str) and wrapper else None
|
|
|
|
|
|
def _publish_child_launching_update(
|
|
*,
|
|
parent_session_id: str,
|
|
child_session_id: str,
|
|
title: str,
|
|
tool: str,
|
|
session_name: str,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None,
|
|
) -> None:
|
|
"""
|
|
Publish the honest pre-start child state to the parent stream.
|
|
|
|
The child session exists at this point, but no child runtime has emitted
|
|
a busy edge yet. Surfacing ``launching`` prevents the UI/orchestrator from
|
|
mistaking session bookkeeping for a running worker.
|
|
"""
|
|
event = {
|
|
"type": "session.child_session.updated",
|
|
"conversation_id": parent_session_id,
|
|
"child_session_id": child_session_id,
|
|
"child": {
|
|
"id": child_session_id,
|
|
"title": title,
|
|
"tool": tool,
|
|
"session_name": session_name,
|
|
"busy": False,
|
|
"current_task_status": "launching",
|
|
},
|
|
}
|
|
if publish_event is not None:
|
|
publish_event(parent_session_id, event)
|
|
return
|
|
from omnigent.runtime import session_stream
|
|
|
|
session_stream.publish(parent_session_id, event)
|
|
|
|
|
|
async def _list_child_sessions(
|
|
*,
|
|
server_client: httpx.AsyncClient,
|
|
conversation_id: str,
|
|
limit: int = 100,
|
|
tool: str | None = None,
|
|
session_name: str | None = None,
|
|
) -> list[dict[str, Any]] | str:
|
|
"""
|
|
Fetch child-session summaries for a parent session.
|
|
|
|
:param server_client: Omnigent server client.
|
|
:param conversation_id: Parent session id, e.g. ``"conv_parent123"``.
|
|
:param limit: Maximum child rows to request, e.g. ``100``.
|
|
:param tool: When set alongside ``session_name``, filter to
|
|
children whose title is ``"{tool}:{session_name}"``
|
|
server-side.
|
|
:param session_name: See ``tool``.
|
|
:returns: List of child summary dicts, or an error string.
|
|
"""
|
|
params: dict[str, Any] = {"limit": limit, "order": "desc"}
|
|
if tool and session_name:
|
|
params["tool"] = tool
|
|
params["session_name"] = session_name
|
|
resp = await server_client.get(
|
|
f"/v1/sessions/{conversation_id}/child_sessions",
|
|
params=params,
|
|
timeout=30.0,
|
|
)
|
|
if resp.status_code >= 400:
|
|
return f"Error: failed to list child sessions: {resp.status_code} {resp.text[:200]}"
|
|
payload = resp.json()
|
|
data = payload.get("data")
|
|
if not isinstance(data, list):
|
|
return "Error: server child_sessions response missing data list"
|
|
return [item for item in data if isinstance(item, dict)]
|
|
|
|
|
|
async def _find_existing_child_session(
|
|
*,
|
|
server_client: httpx.AsyncClient,
|
|
conversation_id: str,
|
|
agent: str,
|
|
title: str,
|
|
) -> dict[str, Any] | str | None:
|
|
"""
|
|
Find an existing child session by ``(agent, title)``.
|
|
|
|
``sys_session_send`` promises that repeated sends to the same
|
|
pair continue the existing child. The runner must therefore look
|
|
up the row before trying to create a new one; otherwise the
|
|
server's unique child-title constraint turns a continuation into
|
|
a duplicate-create failure.
|
|
|
|
:param server_client: Omnigent server client.
|
|
:param conversation_id: Parent session id, e.g. ``"conv_parent123"``.
|
|
:param agent: Sub-agent name, e.g. ``"claude"``.
|
|
:param title: Caller-chosen child title, e.g. ``"issue-1756"``.
|
|
:returns: Matching child summary, ``None`` when absent, or an error
|
|
string when the server lookup failed.
|
|
"""
|
|
children = await _list_child_sessions(
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
limit=1,
|
|
tool=agent,
|
|
session_name=title,
|
|
)
|
|
if isinstance(children, str):
|
|
return children
|
|
for child in children:
|
|
if is_session_closed(child.get("labels"), child.get("title")):
|
|
continue
|
|
return child
|
|
return None
|
|
|
|
|
|
def _subagent_message_from_args(args: dict[str, Any]) -> str | None:
|
|
"""
|
|
Extract the user message from ``sys_session_send`` arguments.
|
|
|
|
The public ``SysSessionSendTool`` contract accepts ``args`` as a plain
|
|
string. polly also sends an object with ``input`` plus metadata such as
|
|
``purpose`` so its guardrail can classify headless helper usage.
|
|
|
|
:param args: Parsed ``sys_session_send`` arguments, e.g.
|
|
``{"args": "review this"}`` or
|
|
``{"args": {"input": "review this", "purpose": "review"}}``.
|
|
:returns: Message text, or ``None`` when the payload is malformed.
|
|
"""
|
|
raw_message = args.get("args")
|
|
if isinstance(raw_message, dict):
|
|
raw_input = raw_message.get("input")
|
|
return raw_input if isinstance(raw_input, str) else None
|
|
if isinstance(raw_message, str):
|
|
return raw_message
|
|
return None
|
|
|
|
|
|
def _subagent_model_from_args(args: dict[str, Any]) -> str | None:
|
|
"""
|
|
Extract and validate the per-dispatch model from ``sys_session_send`` args.
|
|
|
|
The optional ``model`` field lives inside the object form of
|
|
``args`` (``{"input": ..., "model": ...}``). Malformed values fail
|
|
loud instead of being silently dropped — the value later crosses
|
|
the harness spawn boundary as a ``--model`` argv element.
|
|
|
|
:param args: Parsed ``sys_session_send`` arguments, e.g.
|
|
``{"args": {"input": "fix the bug", "model": "claude-sonnet-4-6"}}``.
|
|
:returns: The validated model id, or ``None`` when absent.
|
|
:raises ValueError: If ``model`` is present but not a string, or
|
|
fails :func:`validate_model_override`.
|
|
"""
|
|
raw_message = args.get("args")
|
|
if not isinstance(raw_message, dict):
|
|
return None
|
|
raw_model = raw_message.get("model")
|
|
if raw_model is None:
|
|
return None
|
|
if not isinstance(raw_model, str):
|
|
raise ValueError("'model' must be a string when provided")
|
|
return validate_model_override(raw_model)
|
|
|
|
|
|
def _subagent_file_ids_from_args(args: dict[str, Any]) -> list[str]:
|
|
"""
|
|
Extract the optional ``file_ids`` from ``sys_session_send`` args.
|
|
|
|
``file_ids`` lives only in the object form of ``args``
|
|
(``{"input": ..., "file_ids": [...]}``); the plain-string form
|
|
carries no files. A present-but-malformed value fails loud rather
|
|
than being silently dropped — the ids later drive a parent→child
|
|
file copy whose failure must surface to the caller.
|
|
|
|
:param args: Parsed ``sys_session_send`` arguments, e.g.
|
|
``{"args": {"input": "review", "file_ids": ["file_abc"]}}``.
|
|
:returns: The requested file ids in order, or ``[]`` when absent.
|
|
:raises ValueError: If ``file_ids`` is present but is not a non-empty
|
|
list of unique non-empty strings.
|
|
"""
|
|
raw_message = args.get("args")
|
|
if not isinstance(raw_message, dict):
|
|
return []
|
|
raw_ids = raw_message.get("file_ids")
|
|
if raw_ids is None:
|
|
return []
|
|
if not isinstance(raw_ids, list) or not all(isinstance(fid, str) and fid for fid in raw_ids):
|
|
raise ValueError("'file_ids' must be a list of non-empty strings when provided")
|
|
if not raw_ids:
|
|
raise ValueError("'file_ids' must contain at least one file id when provided")
|
|
if len(set(raw_ids)) != len(raw_ids):
|
|
raise ValueError("'file_ids' must not contain duplicate file ids")
|
|
return list(raw_ids)
|
|
|
|
|
|
async def _teardown_failed_child(
|
|
server_client: httpx.AsyncClient,
|
|
child_session_id: str,
|
|
*,
|
|
created_child: bool,
|
|
) -> str | None:
|
|
"""Undo a failed named-send spawn so it leaves no phantom behind.
|
|
|
|
Unregisters the runner-local child/work mappings and, when this send
|
|
just created the server child session, deletes it. Deleting the child
|
|
also reclaims any files copied into it before the failure — leaving an
|
|
empty child behind would poison a retry with the same ``(agent, title)``
|
|
(the next send would attach to the phantom instead of spawning clean)
|
|
and orphan the copied file rows. Used on both the copy/content failure
|
|
and the message-post failure paths so they tear down identically.
|
|
|
|
:returns: ``None`` when no server cleanup was needed or cleanup
|
|
succeeded, otherwise a parent-visible warning string.
|
|
"""
|
|
from omnigent.runner import app as _runner_app
|
|
|
|
_runner_app.unregister_child_session(child_session_id)
|
|
_runner_app.unregister_subagent_work(child_session_id)
|
|
if not created_child:
|
|
return None
|
|
|
|
last_error = ""
|
|
for attempt in range(2):
|
|
try:
|
|
resp = await server_client.delete(
|
|
f"/v1/sessions/{child_session_id}",
|
|
timeout=30.0,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
last_error = f"{type(exc).__name__}: {exc}"
|
|
else:
|
|
if resp.status_code < 400:
|
|
return None
|
|
last_error = f"{resp.status_code} {resp.text[:200]}"
|
|
if resp.status_code < 500:
|
|
break
|
|
if attempt == 0:
|
|
await asyncio.sleep(0.1)
|
|
|
|
_logger.warning(
|
|
"Failed to delete child session after failed spawn: session=%s error=%s",
|
|
child_session_id,
|
|
last_error,
|
|
)
|
|
return (
|
|
"Warning: failed to delete newly-created child session "
|
|
f"{child_session_id!r}; retrying the same named send may attach "
|
|
f"to that orphaned session. Delete error: {last_error}"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CopyResult:
|
|
"""
|
|
Outcome of building a subagent's first-turn content.
|
|
|
|
Exactly one field is set: ``content`` on success, ``error`` on failure.
|
|
Replaces the earlier ``(value, error)`` tuple union — the dispatch path
|
|
branches on ``error is not None`` to tear down the child and surface the
|
|
message to the parent agent.
|
|
|
|
:param content: The first-turn content blocks, or ``None`` on failure.
|
|
:param error: A human-readable error string, or ``None`` on success.
|
|
"""
|
|
|
|
content: list[dict[str, Any]] | None = None
|
|
error: str | None = None
|
|
|
|
|
|
async def _build_subagent_message_content(
|
|
message: str,
|
|
file_ids: list[str],
|
|
*,
|
|
child_session_id: str,
|
|
parent_session_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> CopyResult:
|
|
"""
|
|
Build the child's first-turn content, copying parent files first.
|
|
|
|
With no ``file_ids`` this returns the single ``input_text`` block the
|
|
text-only path has always sent (byte-for-byte unchanged). With
|
|
``file_ids`` it copies those files from the parent into the child via
|
|
the lineage-scoped copy endpoint, then appends one file block per
|
|
original id (in order) referencing the MAPPED child-scoped id.
|
|
|
|
The block type mirrors ``_resolve_forwarded_message_content``: an
|
|
``image/*`` content type yields ``input_image``; everything else
|
|
yields ``input_file``. The content type comes straight from the copy
|
|
response (preserved from the source row), so no per-file metadata
|
|
fetch is needed; when the source had no recorded type, the filename is
|
|
the fallback signal.
|
|
|
|
:param message: The user message text.
|
|
:param file_ids: Parent-owned source file ids to forward, in order.
|
|
:param child_session_id: Destination (child) session id.
|
|
:param parent_session_id: Source session id (the dispatching runner's
|
|
own session), passed as the copy ``source_session_id``.
|
|
:param server_client: Authenticated Omnigent server client.
|
|
:returns: A :class:`CopyResult` — ``content`` set on success, ``error``
|
|
set when the copy fails (surfaced to the parent agent).
|
|
"""
|
|
content: list[dict[str, Any]] = [{"type": "input_text", "text": str(message)}]
|
|
if not file_ids:
|
|
return CopyResult(content=content)
|
|
|
|
try:
|
|
copy_resp = await server_client.post(
|
|
f"/v1/sessions/{child_session_id}/resources/files:copy",
|
|
json={"source_session_id": parent_session_id, "file_ids": file_ids},
|
|
timeout=30.0,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
return CopyResult(
|
|
error=f"Error: failed to copy files to child: {type(exc).__name__}: {exc}"
|
|
)
|
|
if copy_resp.status_code >= 400:
|
|
return CopyResult(
|
|
error=(
|
|
f"Error: failed to copy files to child: "
|
|
f"{copy_resp.status_code} {copy_resp.text[:200]}"
|
|
)
|
|
)
|
|
|
|
mapping = copy_resp.json().get("mapping")
|
|
if not isinstance(mapping, dict):
|
|
return CopyResult(error="Error: file copy response missing 'mapping'")
|
|
|
|
for old_id in file_ids:
|
|
entry = mapping.get(old_id)
|
|
if not isinstance(entry, dict):
|
|
return CopyResult(error=f"Error: file copy mapping missing entry for {old_id!r}")
|
|
new_id = entry.get("new_id")
|
|
if not isinstance(new_id, str) or not new_id:
|
|
return CopyResult(error=f"Error: file copy mapping missing new id for {old_id!r}")
|
|
# The copy response preserves the source's content_type, so the
|
|
# image-vs-file split uses the true type — no per-file metadata GET.
|
|
# Fall back to a filename guess only when the source had none.
|
|
content_type = entry.get("content_type")
|
|
if not content_type:
|
|
filename = entry.get("filename")
|
|
guessed, _ = (
|
|
mimetypes.guess_type(filename) if isinstance(filename, str) else (None, None)
|
|
)
|
|
content_type = guessed or ""
|
|
block_type = "input_image" if content_type.startswith("image/") else "input_file"
|
|
content.append({"type": block_type, "file_id": new_id})
|
|
|
|
return CopyResult(content=content)
|
|
|
|
|
|
def _find_subagent_spec(sub_agent_name: str, agent_spec: Any | None) -> Any | None:
|
|
"""
|
|
Look up a named sub-agent's spec in the parent's ``sub_agents`` list.
|
|
|
|
:param sub_agent_name: Name of the sub-agent, e.g. ``"claude_code"``.
|
|
:param agent_spec: Parent agent's spec. ``None`` when no spec is
|
|
available.
|
|
:returns: The sub-agent's spec (an :class:`AgentSpec` or structural
|
|
equivalent), or ``None`` when absent.
|
|
"""
|
|
if agent_spec is None:
|
|
return None
|
|
for sa in getattr(agent_spec, "sub_agents", None) or []:
|
|
if getattr(sa, "name", None) == sub_agent_name:
|
|
return sa
|
|
return None
|
|
|
|
|
|
def _subagent_harness(sub_agent_name: str, agent_spec: Any | None) -> str | None:
|
|
"""
|
|
Resolve the declared harness for a named sub-agent.
|
|
|
|
Mirrors the harness derivation in the runner's
|
|
``_resolve_harness_config`` (``executor.config["harness"]`` falling
|
|
back to ``executor.type``) for the AP-style ``sub_agents`` spec
|
|
shape. Returns ``None`` when the sub-spec or its executor cannot be
|
|
resolved — callers treat that as "unknown harness" and fail loud.
|
|
|
|
:param sub_agent_name: Name of the sub-agent, e.g. ``"claude_code"``.
|
|
:param agent_spec: Parent agent's spec. ``None`` when no spec is
|
|
available.
|
|
:returns: Harness id, e.g. ``"codex-native"``, or ``None``.
|
|
"""
|
|
from omnigent.model_catalog import spec_harness
|
|
|
|
sub_spec = _find_subagent_spec(sub_agent_name, agent_spec)
|
|
return spec_harness(sub_spec) if sub_spec is not None else None
|
|
|
|
|
|
def _subagent_harness_override_from_args(args: dict[str, Any]) -> str | None:
|
|
"""
|
|
Extract a per-dispatch harness override from ``sys_session_send`` args.
|
|
|
|
The optional ``harness`` field lives in the object form of ``args``
|
|
(``{"input": ..., "harness": "opencode-native"}``). Returned raw (not
|
|
yet canonicalized) so the caller can validate it against the sub-agent
|
|
allowlist and quote the original spelling in errors.
|
|
|
|
:param args: Parsed ``sys_session_send`` arguments.
|
|
:returns: The raw harness override, or ``None`` when absent.
|
|
:raises ValueError: If ``harness`` is present but not a string.
|
|
"""
|
|
raw_message = args.get("args")
|
|
if not isinstance(raw_message, dict):
|
|
return None
|
|
raw_harness = raw_message.get("harness")
|
|
if raw_harness is None:
|
|
return None
|
|
if not isinstance(raw_harness, str) or not raw_harness:
|
|
raise ValueError("'harness' must be a non-empty string when provided")
|
|
return raw_harness
|
|
|
|
|
|
def _subagent_cost_budget_from_args(
|
|
args: dict[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
"""
|
|
Extract and validate the per-dispatch cost budget from ``sys_session_send`` args.
|
|
|
|
The optional ``cost_budget`` field is an object with max_cost_usd
|
|
(hard limit) and/or ask_thresholds_usd (soft checkpoints). At least
|
|
one must be present.
|
|
|
|
:param args: Parsed ``sys_session_send`` arguments.
|
|
:returns: A dict with max_cost_usd and/or ask_thresholds_usd, or
|
|
``None`` when absent.
|
|
:raises ValueError: If cost_budget is malformed or values are invalid.
|
|
"""
|
|
raw_args = args.get("args")
|
|
if isinstance(raw_args, dict):
|
|
budget = raw_args.get("cost_budget")
|
|
if budget is None:
|
|
return None
|
|
|
|
if not isinstance(budget, dict):
|
|
raise ValueError("cost_budget must be an object")
|
|
|
|
result: dict[str, Any] = {}
|
|
|
|
# Extract and validate max_cost_usd if present.
|
|
if "max_cost_usd" in budget:
|
|
max_cost = budget["max_cost_usd"]
|
|
if max_cost is not None:
|
|
max_cost = float(max_cost)
|
|
if max_cost <= 0:
|
|
raise ValueError("cost_budget.max_cost_usd must be > 0")
|
|
result["max_cost_usd"] = max_cost
|
|
|
|
# Extract and validate ask_thresholds_usd if present.
|
|
if "ask_thresholds_usd" in budget:
|
|
thresholds = budget["ask_thresholds_usd"]
|
|
if thresholds is not None:
|
|
if not isinstance(thresholds, list):
|
|
raise ValueError("cost_budget.ask_thresholds_usd must be an array")
|
|
thresholds = [float(t) for t in thresholds]
|
|
if not all(t > 0 for t in thresholds):
|
|
raise ValueError("cost_budget.ask_thresholds_usd values must be > 0")
|
|
# Check that thresholds are less than max if both are set.
|
|
if "max_cost_usd" in result and result["max_cost_usd"] is not None:
|
|
if any(t >= result["max_cost_usd"] for t in thresholds):
|
|
raise ValueError("ask_thresholds_usd values must be < max_cost_usd")
|
|
result["ask_thresholds_usd"] = thresholds
|
|
|
|
# At least one must be present.
|
|
if not result:
|
|
raise ValueError("cost_budget must include max_cost_usd and/or ask_thresholds_usd")
|
|
return result
|
|
|
|
return None
|
|
|
|
|
|
def _subagent_allowed_harnesses(sub_agent_name: str, agent_spec: Any | None) -> frozenset[str]:
|
|
"""
|
|
Resolve the canonical harness allowlist a sub-agent opts into.
|
|
|
|
Reads ``executor.config.allowed_harnesses`` from the named sub-agent's
|
|
spec — the explicit opt-in that gates ``args.harness``. Each entry is
|
|
canonicalized so a user-facing alias still matches.
|
|
|
|
:param sub_agent_name: Name of the sub-agent, e.g. ``"opencode"``.
|
|
:param agent_spec: Parent agent's spec.
|
|
:returns: Canonical allowlisted harness ids (empty when none declared).
|
|
"""
|
|
sub_spec = _find_subagent_spec(sub_agent_name, agent_spec)
|
|
if sub_spec is None:
|
|
return frozenset()
|
|
executor = getattr(sub_spec, "executor", None)
|
|
config = getattr(executor, "config", None)
|
|
raw_allowed: Any = None
|
|
if isinstance(config, dict):
|
|
raw_allowed = config.get("allowed_harnesses")
|
|
elif config is not None:
|
|
raw_allowed = getattr(config, "allowed_harnesses", None)
|
|
if not isinstance(raw_allowed, (list, tuple, set, frozenset)):
|
|
return frozenset()
|
|
return frozenset(
|
|
canonicalize_harness(str(entry)) or str(entry)
|
|
for entry in raw_allowed
|
|
if isinstance(entry, str) and entry
|
|
)
|
|
|
|
|
|
def _normalize_subagent_model(
|
|
model: str,
|
|
*,
|
|
sub_agent_name: str,
|
|
agent_spec: Any | None,
|
|
harness: str | None,
|
|
) -> str:
|
|
"""
|
|
Localize a per-dispatch model id for the child's resolved provider.
|
|
|
|
Runs after the family guard (see
|
|
:func:`omnigent.model_override.normalize_model_for_provider` for
|
|
the ordering rationale): a canonical vendor id is prefixed with
|
|
``databricks-`` when the child routes through the Databricks
|
|
gateway, and the prefix is stripped for a vendor-direct child. When
|
|
the child's provider cannot be determined, the id passes through
|
|
unchanged — the existing fail-loud harness error stays the net.
|
|
|
|
:param model: The validated requested model id, e.g.
|
|
``"claude-sonnet-4-6"``.
|
|
:param sub_agent_name: Name of the sub-agent being dispatched.
|
|
:param agent_spec: Parent agent's spec. ``None`` skips normalization.
|
|
:param harness: The child's declared harness, e.g. ``"claude-native"``.
|
|
:returns: The id to persist as ``model_override``.
|
|
"""
|
|
from omnigent.model_catalog import resolve_model_provider
|
|
|
|
sub_spec = _find_subagent_spec(sub_agent_name, agent_spec)
|
|
if sub_spec is None or harness is None:
|
|
return model
|
|
# resolve_model_provider is total — undeterminable providers come
|
|
# back as kind "none", which normalize passes through.
|
|
provider = resolve_model_provider(sub_spec, harness)
|
|
normalized = normalize_model_for_provider(model, provider.kind)
|
|
if normalized != model:
|
|
_logger.info(
|
|
"sys_session_send: localized model %r -> %r for sub-agent %r "
|
|
"(harness %s, provider kind %s)",
|
|
model,
|
|
normalized,
|
|
sub_agent_name,
|
|
harness,
|
|
provider.kind,
|
|
)
|
|
return normalized
|
|
|
|
|
|
async def _execute_list_models_tool(*, agent_spec: Any | None) -> str:
|
|
"""
|
|
Dispatch ``sys_list_models``: per-worker model availability.
|
|
|
|
Runs the enumeration off the event loop — provider resolution reads
|
|
config files and the listing fetches hit provider HTTP APIs (TTL-
|
|
cached in :mod:`omnigent.model_catalog`).
|
|
|
|
:param agent_spec: The calling session's agent spec; its
|
|
``sub_agents`` define the worker rows.
|
|
:returns: JSON mapping of worker name (plus ``"self"``) to its
|
|
``{source, verified, models, note}`` row, or an error string.
|
|
"""
|
|
if agent_spec is None:
|
|
return "Error: sys_list_models requires an agent spec"
|
|
from omnigent.model_catalog import catalog_for_spec
|
|
|
|
catalog = await asyncio.to_thread(catalog_for_spec, agent_spec)
|
|
return json.dumps(catalog)
|
|
|
|
|
|
async def _execute_subagent_tool(
|
|
args: dict[str, Any],
|
|
*,
|
|
server_client: httpx.AsyncClient | None = None,
|
|
conversation_id: str | None = None,
|
|
agent_spec: Any | None = None,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None = None,
|
|
session_inbox: asyncio.Queue[dict[str, Any]] | None = None,
|
|
) -> str:
|
|
"""
|
|
Dispatch a sub-agent tool call (``sys_session_send``).
|
|
|
|
Creates or reuses a child session on the server, registers a
|
|
runner-local launch entry, posts the child message, and returns a
|
|
launching handle immediately. The child work becomes ``running`` only
|
|
after the child runtime emits a real busy status. When it completes,
|
|
runner turn-end bookkeeping pushes a completion payload into the
|
|
parent's ``sys_read_inbox`` queue.
|
|
|
|
:param args: Parsed arguments from the LLM. Expected keys:
|
|
``agent`` (sub-agent name, e.g. ``"researcher"``),
|
|
``args`` (user message text, or an object with ``input`` plus
|
|
optional ``purpose`` / ``model`` dispatch metadata),
|
|
``title`` (instance label).
|
|
:param server_client: httpx client pointed at the Omnigent server.
|
|
:param conversation_id: Parent session/conversation ID,
|
|
e.g. ``"conv_abc123"``.
|
|
:param agent_spec: Parent agent's :class:`AgentSpec`. Used
|
|
to resolve sub-agent name to ``agent_id``.
|
|
:param publish_event: Optional callback for publishing child-session
|
|
discovery events to the parent stream.
|
|
:param session_inbox: Parent session's inbox queue for async
|
|
completion delivery.
|
|
:returns: JSON child-session handle, or an error string.
|
|
"""
|
|
# Lazy import to avoid circular dependency at module load.
|
|
from omnigent.runner import app as _runner_app
|
|
|
|
message = _subagent_message_from_args(args)
|
|
if message is None or not message.strip():
|
|
return "Error: sys_session_send requires non-empty args string or args.input string"
|
|
if server_client is None:
|
|
return "Error: sys_session_send requires server_client"
|
|
if conversation_id is None:
|
|
return "Error: sys_session_send requires conversation_id"
|
|
if session_inbox is not None:
|
|
_runner_app._session_inboxes_ref.setdefault(conversation_id, session_inbox)
|
|
elif conversation_id not in _runner_app._session_inboxes_ref:
|
|
return "Error: sys_session_send requires parent session inbox"
|
|
|
|
try:
|
|
model = _subagent_model_from_args(args)
|
|
except ValueError as exc:
|
|
return f"Error: sys_session_send invalid 'model': {exc}"
|
|
|
|
try:
|
|
file_ids = _subagent_file_ids_from_args(args)
|
|
except ValueError as exc:
|
|
return f"Error: sys_session_send invalid 'file_ids': {exc}"
|
|
|
|
try:
|
|
harness_override = _subagent_harness_override_from_args(args)
|
|
except ValueError as exc:
|
|
return f"Error: sys_session_send invalid 'harness': {exc}"
|
|
|
|
try:
|
|
cost_budget = _subagent_cost_budget_from_args(args)
|
|
except (ValueError, TypeError) as exc:
|
|
return f"Error: sys_session_send invalid 'cost_budget': {exc}"
|
|
|
|
# By-session-id mode: post to an existing direct child instead of
|
|
# spawning/continuing a named (agent, title) sub-agent.
|
|
target_session_id = args.get("session_id")
|
|
if isinstance(target_session_id, str) and target_session_id:
|
|
# Fail loud on a double-addressed send. The two modes can point at
|
|
# different children, so silently letting session_id win would
|
|
# misroute the message with no signal to the caller.
|
|
if args.get("agent") or args.get("title"):
|
|
return (
|
|
"Error: sys_session_send received both 'session_id' and "
|
|
"'agent'/'title' — supply exactly one addressing mode"
|
|
)
|
|
if model is not None:
|
|
return (
|
|
"Error: sys_session_send 'model' applies only when a "
|
|
"sub-agent session is first created; it cannot change an "
|
|
"existing session. Re-send without 'model' to continue "
|
|
f"session {target_session_id!r}."
|
|
)
|
|
if file_ids:
|
|
return (
|
|
"Error: sys_session_send 'file_ids' is supported only when "
|
|
"addressing a sub-agent by 'agent'/'title'; it cannot be "
|
|
f"forwarded to an existing session by id ({target_session_id!r})."
|
|
)
|
|
if harness_override is not None:
|
|
return (
|
|
"Error: sys_session_send 'harness' applies only when a "
|
|
"sub-agent session is first created; it cannot change an "
|
|
"existing session. Re-send without 'harness' to continue "
|
|
f"session {target_session_id!r}."
|
|
)
|
|
if cost_budget is not None:
|
|
return (
|
|
"Error: sys_session_send 'cost_budget' applies only when a "
|
|
"sub-agent session is first created; it cannot change an "
|
|
"existing session. Re-send without 'cost_budget' to continue "
|
|
f"session {target_session_id!r}."
|
|
)
|
|
return await _send_to_existing_session(
|
|
target_session_id,
|
|
message,
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
publish_event=publish_event,
|
|
)
|
|
|
|
# Named mode: (agent, title) spawn-or-continue.
|
|
sub_agent_name = args.get("agent")
|
|
session_name = args.get("title")
|
|
if not sub_agent_name:
|
|
return "Error: sys_session_send requires 'agent' (or 'session_id')"
|
|
if not session_name or not isinstance(session_name, str):
|
|
return "Error: sys_session_send requires non-empty 'title' string"
|
|
|
|
# Verify the sub-agent exists in the parent spec.
|
|
if not _has_subagent(sub_agent_name, agent_spec):
|
|
return f"Error: sub-agent {sub_agent_name!r} not found in agent spec"
|
|
|
|
# Use the PARENT's agent_id — inline sub-agents are part of
|
|
# the same bundle, not separately registered. The runner
|
|
# resolves the sub-agent spec from the parent's sub_agents
|
|
# list when it starts the child turn.
|
|
# Try runner-local cache first, then fall back to server query.
|
|
parent_agent_id = _runner_app.get_session_agent_id(conversation_id)
|
|
if parent_agent_id is None:
|
|
try:
|
|
sess_resp = await server_client.get(
|
|
f"/v1/sessions/{conversation_id}",
|
|
timeout=10.0,
|
|
)
|
|
if sess_resp.status_code == 200:
|
|
parent_agent_id = sess_resp.json().get("agent_id")
|
|
except (httpx.HTTPError, RuntimeError):
|
|
pass
|
|
if parent_agent_id is None:
|
|
return "Error: cannot resolve parent agent_id for sub-agent dispatch"
|
|
|
|
existing = await _find_existing_child_session(
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
agent=str(sub_agent_name),
|
|
title=session_name,
|
|
)
|
|
if isinstance(existing, str):
|
|
return existing
|
|
created_child = False
|
|
child_wrapper_label: str | None = None
|
|
if existing is not None:
|
|
child_session_id = existing.get("id")
|
|
if not isinstance(child_session_id, str) or not child_session_id:
|
|
return "Error: existing child session is missing id"
|
|
if model is not None:
|
|
# A native child bakes --model in at terminal launch, so a
|
|
# mid-conversation override would be silently ignored there.
|
|
return (
|
|
f"Error: sys_session_send 'model' applies only when a "
|
|
f"sub-agent session is first created; {sub_agent_name!r} "
|
|
f"title {session_name!r} already exists as "
|
|
f"{child_session_id}. Re-send without 'model' to continue "
|
|
"it, or sys_session_close it first to spawn a fresh "
|
|
"session on the requested model."
|
|
)
|
|
if file_ids:
|
|
return (
|
|
f"Error: sys_session_send 'file_ids' applies only when a "
|
|
f"sub-agent session is first created; {sub_agent_name!r} "
|
|
f"title {session_name!r} already exists as "
|
|
f"{child_session_id}. Re-send without 'file_ids' to "
|
|
"continue it, or sys_session_close it first to spawn a "
|
|
"fresh session with the requested files."
|
|
)
|
|
if cost_budget is not None:
|
|
return (
|
|
f"Error: sys_session_send 'cost_budget' applies only when a "
|
|
f"sub-agent session is first created; {sub_agent_name!r} "
|
|
f"title {session_name!r} already exists as "
|
|
f"{child_session_id}. Re-send without 'cost_budget' to "
|
|
"continue it, or sys_session_close it first to spawn a "
|
|
"fresh session with the requested budget."
|
|
)
|
|
child_wrapper_label = _session_wrapper_label(existing)
|
|
existing_work = _runner_app.get_subagent_work(child_session_id)
|
|
if existing_work is not None and existing_work.status in (
|
|
"launching",
|
|
"running",
|
|
"waiting",
|
|
):
|
|
return (
|
|
f"Error: sub-agent {sub_agent_name!r} title {session_name!r} "
|
|
"already has a launching or running turn; wait for completion before sending again"
|
|
)
|
|
if existing.get("busy") is True:
|
|
return (
|
|
f"Error: sub-agent {sub_agent_name!r} title {session_name!r} "
|
|
"is already running; wait for completion before sending again"
|
|
)
|
|
else:
|
|
child_harness = _subagent_harness(str(sub_agent_name), agent_spec)
|
|
# Apply an allowlisted per-dispatch harness override. The sub-agent
|
|
# spec must explicitly opt in via executor.config.allowed_harnesses,
|
|
# and the requested harness must canonicalize into OMNIGENT_HARNESSES.
|
|
# NOTE: the server create route (``_validated_harness_override`` in
|
|
# server/routes/sessions.py) independently re-validates a session-create
|
|
# override against the GLOBAL ``OMNIGENT_HARNESSES`` (plus the omnigent
|
|
# executor-type rule), but it does NOT re-check the per-spec
|
|
# ``allowed_harnesses`` allowlist. So this orchestrator-dispatch check is
|
|
# the sole enforcement of that per-spec allowlist; a direct
|
|
# ``POST /v1/sessions`` harness_override is bounded only by the global
|
|
# allowlist.
|
|
harness_override_canonical: str | None = None
|
|
if harness_override is not None:
|
|
from omnigent.spec._omnigent_compat import OMNIGENT_HARNESSES
|
|
|
|
canonical = canonicalize_harness(harness_override) or harness_override
|
|
allowed = _subagent_allowed_harnesses(str(sub_agent_name), agent_spec)
|
|
if not allowed:
|
|
return (
|
|
f"Error: sys_session_send 'harness' override is not "
|
|
f"permitted for sub-agent {sub_agent_name!r}: its spec "
|
|
"declares no executor.config.allowed_harnesses allowlist."
|
|
)
|
|
if canonical not in allowed:
|
|
return (
|
|
f"Error: sys_session_send 'harness' {harness_override!r} is "
|
|
f"not allowlisted for sub-agent {sub_agent_name!r}: allowed "
|
|
f"harnesses are {sorted(allowed)}."
|
|
)
|
|
if canonical not in OMNIGENT_HARNESSES:
|
|
return (
|
|
f"Error: sys_session_send 'harness' {harness_override!r} is "
|
|
f"not a known harness; must be one of {sorted(OMNIGENT_HARNESSES)}."
|
|
)
|
|
harness_override_canonical = canonical
|
|
child_harness = canonical
|
|
# Fail loud at dispatch when the child's harness needs a CLI binary
|
|
# that isn't on PATH. Otherwise a missing CLI surfaces only as a lazy
|
|
# first-turn failure (e.g. the pi harness raises ImportError, which the
|
|
# parent sees as a generic "turn failed" inbox item that hides the
|
|
# cause), and the orchestrator may re-dispatch into the same wall. The
|
|
# which-probe here reads the same PATH the harness boot uses, so the
|
|
# verdict can't disagree with the real launch.
|
|
from omnigent.onboarding.harness_install import missing_harness_cli
|
|
|
|
if child_harness is not None:
|
|
missing_cli = missing_harness_cli(child_harness)
|
|
if missing_cli is not None:
|
|
# Non-npm CLIs (e.g. cursor-agent) carry an ``install_hint``
|
|
# instead of a ``package``; using the hint avoids an
|
|
# ``npm install -g None`` instruction.
|
|
install = (
|
|
f"npm install -g {missing_cli.package}"
|
|
if missing_cli.package
|
|
else (missing_cli.install_hint or "see the harness's install docs")
|
|
)
|
|
return (
|
|
f"Error: sub-agent {sub_agent_name!r} can't start on this "
|
|
f"machine: harness {child_harness!r} needs the "
|
|
f"{missing_cli.binary!r} CLI on PATH, which was not found. "
|
|
f"Install it with: {install} "
|
|
f"(or don't dispatch to {sub_agent_name!r} here)."
|
|
)
|
|
# Create child session on the server (no initial items —
|
|
# those go via a separate POST so the server forwards them
|
|
# to the runner and triggers a turn).
|
|
create_body: dict[str, Any] = {
|
|
"agent_id": parent_agent_id,
|
|
"parent_session_id": conversation_id,
|
|
"title": f"{sub_agent_name}:{session_name}",
|
|
"sub_agent_name": sub_agent_name,
|
|
}
|
|
if harness_override_canonical is not None:
|
|
create_body["harness_override"] = harness_override_canonical
|
|
if model is not None:
|
|
# Reject up front when the child harness would silently
|
|
# ignore the persisted override — no silent drops.
|
|
if not harness_supports_model_override(child_harness):
|
|
return (
|
|
f"Error: sys_session_send 'model' is not supported for "
|
|
f"sub-agent {sub_agent_name!r}: harness "
|
|
f"{child_harness or 'unknown'!r} has no model-override "
|
|
"plumbing. Omit 'model' to use the harness default."
|
|
)
|
|
mismatch = model_family_mismatch(child_harness, model) if child_harness else None
|
|
if mismatch is not None:
|
|
return (
|
|
f"Error: sys_session_send 'model' rejected for sub-agent "
|
|
f"{sub_agent_name!r}: {mismatch}"
|
|
)
|
|
# Family guard first (on the requested id, so the error
|
|
# quotes what the caller sent), then mechanical
|
|
# canonical<->gateway-local normalization. The normalized
|
|
# id is what the server persists as model_override.
|
|
create_body["model_override"] = _normalize_subagent_model(
|
|
model,
|
|
sub_agent_name=str(sub_agent_name),
|
|
agent_spec=agent_spec,
|
|
harness=child_harness,
|
|
)
|
|
resp = await server_client.post("/v1/sessions", json=create_body, timeout=30.0)
|
|
if resp.status_code >= 400:
|
|
return f"Error: failed to create child session: {resp.status_code} {resp.text[:200]}"
|
|
child_data = resp.json()
|
|
child_session_id = child_data.get("session_id") or child_data.get("id")
|
|
if not child_session_id:
|
|
return "Error: server did not return child session_id"
|
|
child_wrapper_label = _session_wrapper_label(child_data)
|
|
created_child = True
|
|
|
|
# Attach a subagent_cost_budget policy to the child when requested.
|
|
# Non-fatal: the child session is still usable without the budget.
|
|
if cost_budget is not None:
|
|
policy_body = {
|
|
"name": "__subagent_cost_budget",
|
|
"type": "python",
|
|
"handler": "omnigent.policies.builtins.cost.subagent_cost_budget",
|
|
"factory_params": cost_budget, # Dict with max_cost_usd and/or ask_thresholds_usd
|
|
"enabled": True,
|
|
}
|
|
try:
|
|
pol_resp = await server_client.post(
|
|
f"/v1/sessions/{child_session_id}/policies",
|
|
json=policy_body,
|
|
timeout=10.0,
|
|
)
|
|
if pol_resp.status_code >= 400:
|
|
_logger.warning(
|
|
"failed to set subagent_cost_budget policy on child %s: %s %s",
|
|
child_session_id,
|
|
pol_resp.status_code,
|
|
pol_resp.text[:200],
|
|
)
|
|
except httpx.HTTPError:
|
|
_logger.warning(
|
|
"failed to set subagent_cost_budget policy on child %s",
|
|
child_session_id,
|
|
exc_info=True,
|
|
)
|
|
|
|
# Publish session.created on the parent's SSE stream so the
|
|
# REPL debug panel and any client subscribers discover the
|
|
# child session. SSE-only (transient); durability comes from
|
|
# the conversation_store row written by the server above.
|
|
if not parent_agent_id:
|
|
return f"Error: missing parent agent_id for child session {child_session_id}"
|
|
from omnigent.server.schemas import SessionCreatedEvent
|
|
|
|
if created_child:
|
|
_evt = SessionCreatedEvent(
|
|
type="session.created",
|
|
conversation_id=conversation_id,
|
|
child_session_id=child_session_id,
|
|
agent_id=parent_agent_id,
|
|
parent_session_id=conversation_id,
|
|
)
|
|
# Route through the runner's per-session queue, NOT session_stream
|
|
# directly: in the out-of-process (--server) runner, session_stream
|
|
# has no subscribers (they live in the Omnigent server), so a direct
|
|
# publish here is silently dropped. ``publish_event`` enqueues onto
|
|
# the parent's queue, which the Omnigent server's relay republishes onto
|
|
# session_stream — the same channel terminals use. Falls back
|
|
# to a direct publish only for in-process callers without a queue.
|
|
if publish_event is not None:
|
|
publish_event(conversation_id, _evt.model_dump())
|
|
else:
|
|
from omnigent.runtime import session_stream
|
|
|
|
session_stream.publish(conversation_id, _evt.model_dump())
|
|
|
|
# Register the child→parent mapping so the runner can fan out the
|
|
# child's status/preview deltas onto the PARENT's stream (the child's
|
|
# own relay isn't running when only the parent is being viewed). The
|
|
# title/tool/session_name are known here (we set the title above), so
|
|
# even a cold status update carries a display name. Cleaned up when
|
|
# the child session ends.
|
|
_runner_app.register_child_session(
|
|
child_session_id,
|
|
parent_session_id=conversation_id,
|
|
title=f"{sub_agent_name}:{session_name}",
|
|
tool=sub_agent_name,
|
|
session_name=session_name,
|
|
)
|
|
_runner_app.register_subagent_work(
|
|
parent_session_id=conversation_id,
|
|
child_session_id=child_session_id,
|
|
agent=str(sub_agent_name),
|
|
title=session_name,
|
|
wrapper_label=child_wrapper_label,
|
|
)
|
|
_publish_child_launching_update(
|
|
parent_session_id=conversation_id,
|
|
child_session_id=child_session_id,
|
|
title=f"{sub_agent_name}:{session_name}",
|
|
tool=str(sub_agent_name),
|
|
session_name=session_name,
|
|
publish_event=publish_event,
|
|
)
|
|
|
|
# Copy any forwarded parent files into the child and build the
|
|
# first-turn content (input_text plus a file block per copied id).
|
|
# On copy failure we surface the error to the parent and post no
|
|
# event — but first undo the registrations made above so a failed
|
|
# spawn doesn't leak a phantom child.
|
|
copy_result = await _build_subagent_message_content(
|
|
message,
|
|
file_ids,
|
|
child_session_id=child_session_id,
|
|
parent_session_id=conversation_id,
|
|
server_client=server_client,
|
|
)
|
|
if copy_result.error is not None:
|
|
teardown_warning = await _teardown_failed_child(
|
|
server_client,
|
|
child_session_id,
|
|
created_child=created_child,
|
|
)
|
|
if teardown_warning is not None:
|
|
return f"{copy_result.error}\n{teardown_warning}"
|
|
return copy_result.error
|
|
message_content = copy_result.content
|
|
|
|
# Send the user message as a separate event so the server's
|
|
# post_event forwards it to the runner and starts the child
|
|
# turn.
|
|
try:
|
|
msg_resp = await server_client.post(
|
|
f"/v1/sessions/{child_session_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"data": {
|
|
"role": "user",
|
|
"content": message_content,
|
|
},
|
|
},
|
|
# This message is gated at the recipient's REQUEST phase, which can
|
|
# PARK on a human ASK (e.g. session_cost_budget) up to the policy's
|
|
# ``ask_timeout``. A 30s read budget severed that park → fail-closed
|
|
# /retry → duplicate cards. Wait for the real verdict (one-day read
|
|
# budget, fast connect); a non-parking eval still returns immediately.
|
|
timeout=_ASK_GATE_DELIVERY_TIMEOUT,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
teardown_warning = await _teardown_failed_child(
|
|
server_client,
|
|
child_session_id,
|
|
created_child=created_child,
|
|
)
|
|
error = f"Error: failed to send message to child: {type(exc).__name__}: {exc}"
|
|
if teardown_warning is not None:
|
|
return f"{error}\n{teardown_warning}"
|
|
return error
|
|
if msg_resp.status_code >= 400:
|
|
teardown_warning = await _teardown_failed_child(
|
|
server_client,
|
|
child_session_id,
|
|
created_child=created_child,
|
|
)
|
|
error = (
|
|
f"Error: failed to send message to child: {msg_resp.status_code} {msg_resp.text[:200]}"
|
|
)
|
|
if teardown_warning is not None:
|
|
return f"{error}\n{teardown_warning}"
|
|
return error
|
|
|
|
# Return the structured handle mirrored from ``spawn.py``. The debug panel
|
|
# parses this to discover child sessions in the sidebar.
|
|
return json.dumps(
|
|
{
|
|
"task_id": child_session_id,
|
|
"handle_id": child_session_id,
|
|
"conversation_id": child_session_id,
|
|
"kind": "sub_agent",
|
|
"agent": sub_agent_name,
|
|
"title": session_name,
|
|
"status": "launching",
|
|
"message": (
|
|
f"[System: sub-agent {sub_agent_name} title {session_name!r} "
|
|
f"launching as task {child_session_id}. Result will appear in "
|
|
"your inbox; call sys_read_inbox to check or sys_cancel_task "
|
|
"to interrupt it.]"
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
async def _send_to_existing_session(
|
|
target_session_id: str,
|
|
message: str,
|
|
*,
|
|
server_client: httpx.AsyncClient,
|
|
conversation_id: str,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None = None,
|
|
) -> str:
|
|
"""
|
|
Post a message to an existing direct-child session, return a handle.
|
|
|
|
The by-session-id mode of ``sys_session_send``. **Child-only**: the
|
|
target must be a direct child of the caller (its
|
|
``parent_session_id`` equals ``conversation_id``), so a caller can
|
|
only drive sessions inside its own subtree — never a sibling or an
|
|
unrelated session it merely has access to. Looks the target up to
|
|
verify parentage (404 → ``session_not_found``; wrong parent or
|
|
denied read → ``session_out_of_tree``), registers the child→parent
|
|
fan-out and work mappings, posts the message, and returns a
|
|
``running`` handle immediately — the completion lands in the parent's
|
|
``sys_read_inbox`` queue, matching named-mode send.
|
|
|
|
:param target_session_id: The existing child session id, e.g.
|
|
``"conv_abc123"``.
|
|
:param message: The user message text to post.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:param conversation_id: The caller's own session id — the required
|
|
parent of the target.
|
|
:returns: JSON handle on success; a JSON/text error otherwise.
|
|
"""
|
|
from omnigent.runner import app as _runner_app
|
|
|
|
try:
|
|
snap = await server_client.get(f"/v1/sessions/{target_session_id}", timeout=30.0)
|
|
except Exception as exc: # noqa: BLE001
|
|
return f"Error: sys_session_send failed to look up session: {exc}"
|
|
if snap.status_code == 404:
|
|
return json.dumps({"error": "session_not_found", "conversation_id": target_session_id})
|
|
if snap.status_code in (401, 403):
|
|
return json.dumps({"error": "session_out_of_tree", "conversation_id": target_session_id})
|
|
if snap.status_code != 200:
|
|
return f"Error: sys_session_send lookup returned {snap.status_code}"
|
|
snap_data = snap.json()
|
|
if snap_data.get("parent_session_id") != conversation_id:
|
|
return json.dumps(
|
|
{
|
|
"error": "session_out_of_tree",
|
|
"conversation_id": target_session_id,
|
|
"message": (
|
|
"target is not a direct child of the calling session; "
|
|
"sys_session_send by session_id is child-only."
|
|
),
|
|
}
|
|
)
|
|
if is_session_closed(snap_data.get("labels"), snap_data.get("title")):
|
|
return json.dumps(
|
|
{
|
|
"error": "session_closed",
|
|
"conversation_id": target_session_id,
|
|
"message": "target sub-agent session is closed; create a new session to continue.",
|
|
}
|
|
)
|
|
parsed = _parse_session_title(snap_data.get("title"))
|
|
agent_label = parsed.agent or "agent"
|
|
existing_work = _runner_app.get_subagent_work(target_session_id)
|
|
if existing_work is not None and existing_work.status in ("launching", "running", "waiting"):
|
|
return (
|
|
f"Error: session {target_session_id!r} already has a launching or running turn; "
|
|
"wait for completion before sending again"
|
|
)
|
|
if snap_data.get("busy") is True:
|
|
return (
|
|
f"Error: session {target_session_id!r} is already running; "
|
|
"wait for completion before sending again"
|
|
)
|
|
_runner_app.register_child_session(
|
|
target_session_id,
|
|
parent_session_id=conversation_id,
|
|
title=snap_data.get("title") or "",
|
|
tool=agent_label,
|
|
session_name=parsed.title or "",
|
|
)
|
|
_runner_app.register_subagent_work(
|
|
parent_session_id=conversation_id,
|
|
child_session_id=target_session_id,
|
|
agent=agent_label,
|
|
title=parsed.title or "",
|
|
wrapper_label=_session_wrapper_label(snap_data),
|
|
)
|
|
_publish_child_launching_update(
|
|
parent_session_id=conversation_id,
|
|
child_session_id=target_session_id,
|
|
title=snap_data.get("title") or "",
|
|
tool=agent_label,
|
|
session_name=parsed.title or "",
|
|
publish_event=publish_event,
|
|
)
|
|
|
|
try:
|
|
msg_resp = await server_client.post(
|
|
f"/v1/sessions/{target_session_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"data": {
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": message}],
|
|
},
|
|
},
|
|
# Same as the other message-send: gated at the recipient's REQUEST
|
|
# phase, which can PARK on a human ASK up to the policy's
|
|
# ``ask_timeout``. Wait for the real verdict (one-day read budget,
|
|
# fast connect) instead of severing at 30s and retrying into duplicates.
|
|
timeout=_ASK_GATE_DELIVERY_TIMEOUT,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
_runner_app.unregister_child_session(target_session_id)
|
|
_runner_app.unregister_subagent_work(target_session_id)
|
|
return f"Error: failed to send message to child: {type(exc).__name__}: {exc}"
|
|
if msg_resp.status_code >= 400:
|
|
_runner_app.unregister_child_session(target_session_id)
|
|
_runner_app.unregister_subagent_work(target_session_id)
|
|
return (
|
|
f"Error: failed to send message to child: {msg_resp.status_code} {msg_resp.text[:200]}"
|
|
)
|
|
|
|
return json.dumps(
|
|
{
|
|
"task_id": target_session_id,
|
|
"handle_id": target_session_id,
|
|
"conversation_id": target_session_id,
|
|
"kind": "sub_agent",
|
|
"agent": agent_label,
|
|
"title": parsed.title,
|
|
"status": "launching",
|
|
"message": (
|
|
f"[System: sub-agent {agent_label} title {parsed.title!r} "
|
|
f"launching as task {target_session_id}. Result will appear in "
|
|
"your inbox; call sys_read_inbox to check or sys_cancel_task "
|
|
"to interrupt it.]"
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
def _build_session_create_body(
|
|
agent_id: str,
|
|
conversation_id: str,
|
|
title: Any,
|
|
message: Any,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Build the JSON ``POST /v1/sessions`` body for ``sys_session_create``.
|
|
|
|
``parent_session_id`` is hard-forced to ``conversation_id`` — this is
|
|
what makes the write child-only (an orchestrator cannot create a
|
|
top-level or sibling session). A non-empty ``title`` and ``message``
|
|
are included when provided; the message becomes the child's first
|
|
queued user turn via ``initial_items``.
|
|
|
|
:param agent_id: The existing agent to launch, e.g. ``"ag_abc123"``.
|
|
:param conversation_id: The caller's session id — the forced parent.
|
|
:param title: Optional session label; included only when a non-empty
|
|
string.
|
|
:param message: Optional first user message; included only when a
|
|
non-empty string.
|
|
:returns: The JSON request body.
|
|
"""
|
|
body: dict[str, Any] = {
|
|
"agent_id": agent_id,
|
|
"parent_session_id": conversation_id,
|
|
}
|
|
if isinstance(title, str) and title:
|
|
body["title"] = title
|
|
if isinstance(message, str) and message:
|
|
body["initial_items"] = [
|
|
{
|
|
"type": "message",
|
|
"data": {"role": "user", "content": [{"type": "input_text", "text": message}]},
|
|
}
|
|
]
|
|
return body
|
|
|
|
|
|
def _finalize_created_session(
|
|
data: dict[str, Any],
|
|
*,
|
|
conversation_id: str,
|
|
agent_id: str,
|
|
title: Any,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None,
|
|
) -> str:
|
|
"""
|
|
Register fan-out, emit ``session.created``, and build the handle.
|
|
|
|
Records the child→parent mapping so the child's status/preview
|
|
deltas fan out onto the caller's stream, publishes a transient
|
|
``session.created`` event (durability comes from the server's
|
|
conversation row), and returns the handle the orchestrator uses to
|
|
drive / monitor the child.
|
|
|
|
:param data: The :class:`SessionResponse` JSON from the create call.
|
|
:param conversation_id: The caller (parent) session id.
|
|
:param agent_id: The launched agent id, e.g. ``"ag_abc123"``.
|
|
:param title: The caller-supplied title (or non-str when absent).
|
|
:param publish_event: Callback that enqueues an SSE event on the
|
|
caller's outbound queue; ``None`` for in-process callers.
|
|
:returns: JSON handle ``{conversation_id, kind, agent_id,
|
|
agent_name, title, status}``.
|
|
"""
|
|
from omnigent.runner import app as _runner_app
|
|
from omnigent.server.schemas import SessionCreatedEvent
|
|
|
|
child_id = data["id"]
|
|
label = title if isinstance(title, str) else ""
|
|
_runner_app.register_child_session(
|
|
child_id,
|
|
parent_session_id=conversation_id,
|
|
title=label,
|
|
tool=data.get("agent_name") or "agent",
|
|
session_name=label,
|
|
)
|
|
evt = SessionCreatedEvent(
|
|
type="session.created",
|
|
conversation_id=conversation_id,
|
|
child_session_id=child_id,
|
|
agent_id=agent_id,
|
|
parent_session_id=conversation_id,
|
|
)
|
|
if publish_event is not None:
|
|
publish_event(conversation_id, evt.model_dump())
|
|
return json.dumps(
|
|
{
|
|
"conversation_id": child_id,
|
|
"kind": "sub_agent",
|
|
"agent_id": agent_id,
|
|
"agent_name": data.get("agent_name"),
|
|
"title": title if isinstance(title, str) else None,
|
|
"status": data.get("status") or "created",
|
|
}
|
|
)
|
|
|
|
|
|
async def _execute_session_create(
|
|
args: dict[str, Any],
|
|
*,
|
|
server_client: httpx.AsyncClient | None,
|
|
conversation_id: str | None,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None,
|
|
agent_spec: Any | None = None,
|
|
runner_workspace: Path | None = None,
|
|
) -> str:
|
|
"""
|
|
Create a child session (``sys_session_create``).
|
|
|
|
Two modes, split on the provided argument (exactly one required):
|
|
|
|
- ``agent_id`` — spawn from an existing agent via the JSON
|
|
``POST /v1/sessions`` create.
|
|
- ``config_path`` — upload a NEW agent from local disk (an agent
|
|
config YAML, agent directory, or pre-built ``.tar.gz`` bundle
|
|
inside the caller's working directory) via the multipart
|
|
``POST /v1/sessions`` create.
|
|
|
|
Both modes force ``parent_session_id`` to the caller (child-only).
|
|
The child inherits the caller's runner (server-side affinity), so a
|
|
queued initial message starts a turn immediately. Returns a handle
|
|
the orchestrator can monitor (``sys_session_get_history`` /
|
|
``sys_session_get_info``) or drive (``sys_session_send`` by
|
|
``conversation_id``) — unlike named-mode send, it does NOT block on
|
|
the child turn.
|
|
|
|
Maps a 404 to ``agent_not_found`` and 401/403 to ``access_denied``.
|
|
|
|
:param args: Parsed arguments; exactly one of ``agent_id`` /
|
|
``config_path`` required, ``title`` / ``message`` optional.
|
|
:param server_client: HTTP client pointed at the Omnigent server; ``None``
|
|
returns an error string.
|
|
:param conversation_id: The caller's session id — the forced parent;
|
|
``None`` returns an error string.
|
|
:param publish_event: SSE publish callback for ``session.created``.
|
|
:param agent_spec: The calling agent's spec, used (with
|
|
``conversation_id`` / ``runner_workspace``) to resolve the
|
|
os_env cwd that ``config_path`` is read from.
|
|
:param runner_workspace: The runner's workspace dir, authoritative
|
|
for the os_env cwd when present.
|
|
:returns: JSON handle on success; a JSON error object otherwise.
|
|
"""
|
|
if server_client is None:
|
|
return json.dumps({"error": "sys_session_create requires server access"})
|
|
if conversation_id is None:
|
|
return json.dumps({"error": "sys_session_create requires a session id"})
|
|
agent_id = args.get("agent_id")
|
|
config_path = args.get("config_path")
|
|
has_agent_id = isinstance(agent_id, str) and bool(agent_id)
|
|
has_config_path = isinstance(config_path, str) and bool(config_path)
|
|
if has_agent_id == has_config_path:
|
|
# Fail loud on both-or-neither: the two modes create different
|
|
# agents, so silently preferring one would mislaunch.
|
|
return json.dumps(
|
|
{
|
|
"error": (
|
|
"sys_session_create requires exactly one of 'agent_id' "
|
|
"(existing agent) or 'config_path' (new agent from a "
|
|
"local config)"
|
|
)
|
|
}
|
|
)
|
|
if has_config_path:
|
|
return await _session_create_from_config_path(
|
|
str(config_path),
|
|
args,
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
publish_event=publish_event,
|
|
agent_spec=agent_spec,
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
body = _build_session_create_body(
|
|
str(agent_id), conversation_id, args.get("title"), args.get("message")
|
|
)
|
|
try:
|
|
resp = await server_client.post("/v1/sessions", json=body, timeout=30.0)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_session_create failed: {exc}"})
|
|
if resp.status_code == 404:
|
|
return json.dumps({"error": "agent_not_found", "agent_id": agent_id})
|
|
if resp.status_code in (401, 403):
|
|
return json.dumps({"error": "access_denied", "agent_id": agent_id})
|
|
if resp.status_code >= 400:
|
|
return json.dumps(
|
|
{"error": f"sys_session_create returned {resp.status_code}", "detail": resp.text[:200]}
|
|
)
|
|
data = resp.json()
|
|
if not isinstance(data.get("id"), str) or not data["id"]:
|
|
return json.dumps({"error": "server did not return a child session id"})
|
|
return _finalize_created_session(
|
|
data,
|
|
conversation_id=conversation_id,
|
|
agent_id=str(agent_id),
|
|
title=args.get("title"),
|
|
publish_event=publish_event,
|
|
)
|
|
|
|
|
|
def _bundle_local_agent_source(source: Path) -> bytes:
|
|
"""
|
|
Build gzipped agent-bundle bytes from a local source path.
|
|
|
|
Handles the same source shapes as the CLI bundler: a standalone
|
|
agent YAML file or an agent directory is materialized into a
|
|
uniform bundle directory and tarred; any other file (e.g. a
|
|
pre-built ``.tar.gz``) passes through as raw bytes for the
|
|
server's bundle validation to accept or reject.
|
|
|
|
Unlike the CLI bundler, no ``${VAR}`` env expansion is performed:
|
|
expanding from the runner process environment would leak runner
|
|
secrets into the uploaded bundle. Configs with unresolved env
|
|
references fail loud in the server's spec validation instead.
|
|
|
|
:param source: Local agent config YAML, agent directory, or
|
|
bundle file, e.g.
|
|
``Path("/work/.omnigent/agent-configs/helper.yaml")``.
|
|
:returns: Gzipped tarball bytes for the multipart ``bundle`` part.
|
|
:raises FileNotFoundError: If ``source`` does not exist.
|
|
"""
|
|
import io
|
|
import tarfile
|
|
|
|
from omnigent.spec import materialize_bundle
|
|
|
|
if source.is_file() and source.suffix.lower() not in {".yaml", ".yml"}:
|
|
return source.read_bytes()
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
bundle_dir = materialize_bundle(source, Path(tmpdir) / "bundle")
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
|
for file_path in sorted(bundle_dir.rglob("*")):
|
|
if file_path.is_file():
|
|
tf.add(
|
|
str(file_path),
|
|
arcname=str(file_path.relative_to(bundle_dir)),
|
|
)
|
|
return buf.getvalue()
|
|
|
|
|
|
async def _post_child_first_message(
|
|
child_session_id: str,
|
|
message: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> str | None:
|
|
"""
|
|
Queue a bundle-created child's first user message.
|
|
|
|
Posted as a separate event so the server's post_event forwards it
|
|
to the runner and starts the child turn (same pattern as
|
|
named-mode ``sys_session_send``).
|
|
|
|
:param child_session_id: The new child session id,
|
|
e.g. ``"conv_abc123"``.
|
|
:param message: The first user message text.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: ``None`` on success; a JSON error string (carrying the
|
|
created ``conversation_id`` so the orchestrator can retry via
|
|
``sys_session_send``) on failure.
|
|
"""
|
|
try:
|
|
msg_resp = await server_client.post(
|
|
f"/v1/sessions/{child_session_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"data": {
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": message}],
|
|
},
|
|
},
|
|
timeout=30.0,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
return json.dumps(
|
|
{
|
|
"error": f"child session created but message failed: {exc}",
|
|
"conversation_id": child_session_id,
|
|
}
|
|
)
|
|
if msg_resp.status_code >= 400:
|
|
return json.dumps(
|
|
{
|
|
"error": (
|
|
"child session created but message failed: "
|
|
f"{msg_resp.status_code} {msg_resp.text[:200]}"
|
|
),
|
|
"conversation_id": child_session_id,
|
|
}
|
|
)
|
|
return None
|
|
|
|
|
|
async def _upload_config_bundle(
|
|
config_path: str,
|
|
args: dict[str, Any],
|
|
*,
|
|
server_client: httpx.AsyncClient,
|
|
conversation_id: str,
|
|
agent_spec: Any | None,
|
|
runner_workspace: Path | None,
|
|
) -> dict[str, Any] | str:
|
|
"""
|
|
Resolve, bundle, and upload a local agent config as a child session.
|
|
|
|
Reads ``config_path`` from the caller's os_env working directory
|
|
(containment-checked, mirroring the ``sys_agent_download`` write
|
|
guard), bundles it, and proxies the multipart
|
|
``POST /v1/sessions`` create with ``parent_session_id`` forced to
|
|
the caller.
|
|
|
|
:param config_path: Caller-supplied path to the agent config YAML,
|
|
agent directory, or ``.tar.gz`` bundle, relative to the os_env
|
|
cwd, e.g. ``".omnigent/agent-configs/helper.yaml"``.
|
|
:param args: Parsed tool arguments; optional ``title``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:param conversation_id: The caller's session id — the forced parent.
|
|
:param agent_spec: The calling agent's spec, for os_env resolution.
|
|
:param runner_workspace: The runner workspace, authoritative cwd.
|
|
:returns: The parsed ``CreatedSessionResponse`` dict on success; a
|
|
JSON error string otherwise.
|
|
"""
|
|
os_spec = _effective_runner_os_env_spec(agent_spec, conversation_id, runner_workspace)
|
|
resolved_cwd = Path(os_spec.cwd).resolve()
|
|
source = (resolved_cwd / config_path).resolve()
|
|
if not source.is_relative_to(resolved_cwd):
|
|
return json.dumps(
|
|
{"error": "sys_session_create config_path escapes the working directory"}
|
|
)
|
|
if not source.exists():
|
|
return json.dumps({"error": "config_not_found", "config_path": config_path})
|
|
try:
|
|
bundle_bytes = await asyncio.to_thread(_bundle_local_agent_source, source)
|
|
except Exception as exc: # noqa: BLE001 — disk/tar errors become a typed tool error.
|
|
return json.dumps({"error": f"sys_session_create failed to bundle config: {exc}"})
|
|
|
|
metadata: dict[str, Any] = {"parent_session_id": conversation_id}
|
|
title = args.get("title")
|
|
if isinstance(title, str) and title:
|
|
metadata["title"] = title
|
|
try:
|
|
resp = await server_client.post(
|
|
"/v1/sessions",
|
|
data={"metadata": json.dumps(metadata)},
|
|
files={"bundle": (f"{source.name}.tar.gz", bundle_bytes, "application/gzip")},
|
|
timeout=60.0,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_session_create failed: {exc}"})
|
|
if resp.status_code in (401, 403):
|
|
return json.dumps({"error": "access_denied", "config_path": config_path})
|
|
if resp.status_code >= 400:
|
|
return json.dumps(
|
|
{"error": f"sys_session_create returned {resp.status_code}", "detail": resp.text[:200]}
|
|
)
|
|
data: dict[str, Any] = resp.json()
|
|
return data
|
|
|
|
|
|
async def _session_create_from_config_path(
|
|
config_path: str,
|
|
args: dict[str, Any],
|
|
*,
|
|
server_client: httpx.AsyncClient,
|
|
conversation_id: str,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None,
|
|
agent_spec: Any | None,
|
|
runner_workspace: Path | None,
|
|
) -> str:
|
|
"""
|
|
Bundle-mode ``sys_session_create``: upload a new agent and launch it.
|
|
|
|
Delegates the resolve/bundle/upload pipeline to
|
|
:func:`_upload_config_bundle`, validates the server's
|
|
``CreatedSessionResponse``, queues the optional first ``message``
|
|
via :func:`_post_child_first_message`, and returns the
|
|
orchestrator handle.
|
|
|
|
:param config_path: Caller-supplied path to the agent config YAML,
|
|
agent directory, or ``.tar.gz`` bundle, relative to the os_env
|
|
cwd, e.g. ``".omnigent/agent-configs/helper.yaml"``.
|
|
:param args: Parsed tool arguments; optional ``title`` /
|
|
``message``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:param conversation_id: The caller's session id — the forced parent.
|
|
:param publish_event: SSE publish callback for ``session.created``.
|
|
:param agent_spec: The calling agent's spec, for os_env resolution.
|
|
:param runner_workspace: The runner workspace, authoritative cwd.
|
|
:returns: JSON handle on success; a JSON error object otherwise.
|
|
"""
|
|
data = await _upload_config_bundle(
|
|
config_path,
|
|
args,
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
agent_spec=agent_spec,
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
if isinstance(data, str):
|
|
return data
|
|
child_session_id = data.get("session_id")
|
|
if not isinstance(child_session_id, str) or not child_session_id:
|
|
return json.dumps({"error": "server did not return a child session id"})
|
|
created_agent_id = data.get("agent_id")
|
|
if not isinstance(created_agent_id, str) or not created_agent_id:
|
|
# CreatedSessionResponse.agent_id is a required field — a
|
|
# missing value is a server contract violation, not a
|
|
# recoverable state.
|
|
return json.dumps(
|
|
{
|
|
"error": "server did not return the created agent id",
|
|
"conversation_id": child_session_id,
|
|
}
|
|
)
|
|
|
|
message = args.get("message")
|
|
if isinstance(message, str) and message:
|
|
message_error = await _post_child_first_message(child_session_id, message, server_client)
|
|
if message_error is not None:
|
|
return message_error
|
|
|
|
return _finalize_created_session(
|
|
# Adapt the multipart CreatedSessionResponse shape to the
|
|
# session-snapshot keys _finalize_created_session reads.
|
|
{
|
|
"id": child_session_id,
|
|
"agent_name": data.get("agent_name"),
|
|
"status": "created",
|
|
},
|
|
conversation_id=conversation_id,
|
|
agent_id=created_agent_id,
|
|
title=args.get("title"),
|
|
publish_event=publish_event,
|
|
)
|
|
|
|
|
|
async def _execute_web_fetch_tool(
|
|
args: dict[str, Any],
|
|
*,
|
|
server_client: httpx.AsyncClient | None,
|
|
conversation_id: str | None,
|
|
agent_spec: Any | None,
|
|
task_id: str | None,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None = None,
|
|
session_inbox: asyncio.Queue[dict[str, Any]] | None = None,
|
|
) -> str:
|
|
"""
|
|
Dispatch a ``web_fetch`` tool call.
|
|
|
|
Translates the user-facing ``query`` / ``url`` arguments into
|
|
a ``sys_session_send`` invocation against the built-in
|
|
``__web_researcher`` sub-agent, then delegates to
|
|
:func:`_execute_subagent_tool`. The session name embeds
|
|
``task_id`` so concurrent ``web_fetch`` calls from the same
|
|
parent don't collide on the
|
|
``(parent_conversation_id, title)`` unique index that
|
|
``_execute_subagent_tool`` ultimately exercises via
|
|
``POST /v1/sessions``.
|
|
|
|
:param args: Parsed LLM arguments — ``query`` (required) and
|
|
optional ``url``.
|
|
:param server_client: httpx client pointed at the Omnigent server.
|
|
:param conversation_id: Parent session id,
|
|
e.g. ``"conv_abc123"``.
|
|
:param agent_spec: Parent agent's spec — used by the inner
|
|
``_execute_subagent_tool`` to resolve the sub-agent.
|
|
:param task_id: Calling task id; used to discriminate parallel
|
|
``web_fetch`` invocations from the same parent.
|
|
:param session_inbox: Parent inbox queue for delayed sub-agent
|
|
completion delivery.
|
|
:returns: The researcher's findings, or an error string.
|
|
"""
|
|
from omnigent.tools.builtins.web_fetch import (
|
|
RESEARCHER_NAME,
|
|
build_web_fetch_prompt,
|
|
)
|
|
|
|
query = args.get("query")
|
|
if not query:
|
|
return "Error: 'query' parameter is required."
|
|
url = args.get("url")
|
|
prompt = build_web_fetch_prompt(str(query), str(url) if url else None)
|
|
|
|
# Embed task_id so each web_fetch from the same parent gets a
|
|
# distinct child conversation (the server enforces a partial
|
|
# unique index on (parent_conversation_id, title) where
|
|
# title="<tool>:<session>").
|
|
session_name = f"web_fetch_{task_id or 'anon'}"
|
|
|
|
return await _execute_subagent_tool(
|
|
{
|
|
"agent": RESEARCHER_NAME,
|
|
"args": prompt,
|
|
"title": session_name,
|
|
},
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
agent_spec=agent_spec,
|
|
publish_event=publish_event,
|
|
session_inbox=session_inbox,
|
|
)
|
|
|
|
|
|
def _web_search_config_from_spec(agent_spec: Any | None) -> dict[str, str]:
|
|
"""
|
|
Return the ``web_search`` builtin's config dict from the parent spec.
|
|
|
|
Mirrors ``ToolManager._register_builtin_tools``: scans
|
|
``spec.tools.builtins`` for the entry named ``"web_search"`` and returns
|
|
its ``config`` (``search_provider`` + credentials). Empty dict when the
|
|
builtin is declared as a bare string or absent.
|
|
|
|
:param agent_spec: Parent agent's spec, or ``None``.
|
|
:returns: The web_search config dict, e.g.
|
|
``{"search_provider": "nimble", "api_key": "..."}``.
|
|
"""
|
|
if agent_spec is None:
|
|
return {}
|
|
tools = getattr(agent_spec, "tools", None)
|
|
builtins = getattr(tools, "builtins", None) or []
|
|
for entry in builtins:
|
|
if getattr(entry, "name", None) == "web_search":
|
|
return getattr(entry, "config", None) or {}
|
|
return {}
|
|
|
|
|
|
async def _execute_web_search_tool(
|
|
args: dict[str, Any],
|
|
*,
|
|
agent_spec: Any | None,
|
|
conversation_id: str | None = None,
|
|
task_id: str | None = None,
|
|
agent_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Dispatch a ``web_search`` tool call to the spec's configured backend.
|
|
|
|
Builds ``WebSearchTool`` from the spec's ``web_search`` builtin config and
|
|
runs its synchronous ``invoke`` off the event loop (the backend makes a
|
|
blocking HTTP call).
|
|
|
|
``llm_provider`` is inferred exactly as ``ToolManager._create_web_search``
|
|
does, so the dispatch path preserves the same invariants as session setup:
|
|
|
|
- **OpenAI models** keep the native ``web_search_preview`` passthrough; if a
|
|
``web_search`` function call ever reached this path, ``invoke()`` raises
|
|
(its built-in fence) and the third-party backend is never run. In normal
|
|
operation OpenAI models never emit a ``web_search`` function call, so this
|
|
is defensive — but it keeps the promise rather than silently weakening it.
|
|
- **``databricks-*`` models** skip provider inference (they don't support
|
|
``web_search_preview``) and run in function-tool mode.
|
|
|
|
:param args: Parsed LLM arguments — ``query`` (required).
|
|
:param agent_spec: Parent agent's spec; carries the web_search config + model.
|
|
:param conversation_id: Parent session id, threaded into the context.
|
|
:param task_id: Calling task id, threaded into the context.
|
|
:param agent_id: Calling agent id, threaded into the context.
|
|
:returns: The formatted search results, or an error string.
|
|
"""
|
|
from omnigent.tools.base import ToolContext
|
|
from omnigent.tools.builtins.web_search import WebSearchTool
|
|
|
|
config = _web_search_config_from_spec(agent_spec)
|
|
# Mirror ToolManager._create_web_search's provider inference (same skip for
|
|
# databricks-*, same OpenAI passthrough fence) so dispatch honors session-setup invariants.
|
|
llm_provider: str | None = None
|
|
model = getattr(getattr(agent_spec, "executor", None), "model", None)
|
|
if model and not model.startswith("databricks-"):
|
|
from omnigent.llms.routing import parse_model_string
|
|
|
|
llm_provider = parse_model_string(model).provider
|
|
tool = WebSearchTool(config=config, llm_provider=llm_provider)
|
|
ctx = ToolContext(
|
|
task_id=task_id or "web_search",
|
|
agent_id=agent_id or "web_search",
|
|
conversation_id=conversation_id,
|
|
)
|
|
return await asyncio.to_thread(tool.invoke, json.dumps(args), ctx)
|
|
|
|
|
|
def _hindsight_config_from_spec(agent_spec: Any | None, tool_name: str) -> dict[str, str]:
|
|
"""
|
|
Return a Hindsight builtin's config dict from the parent spec.
|
|
|
|
Mirrors ``ToolManager._register_builtin_tools``: scans ``spec.tools.builtins``
|
|
for the entry named *tool_name* (e.g. ``"hindsight_recall"``) and returns its
|
|
``config`` (api_key, bank_id, etc.). Empty dict when declared bare or absent.
|
|
|
|
:param agent_spec: Parent agent's spec, or ``None``.
|
|
:param tool_name: The Hindsight tool name to look up.
|
|
:returns: The builtin's config dict.
|
|
"""
|
|
if agent_spec is None:
|
|
return {}
|
|
tools = getattr(agent_spec, "tools", None)
|
|
builtins = getattr(tools, "builtins", None) or []
|
|
for entry in builtins:
|
|
if getattr(entry, "name", None) == tool_name:
|
|
return getattr(entry, "config", None) or {}
|
|
return {}
|
|
|
|
|
|
async def _execute_hindsight_tool(
|
|
args: dict[str, Any],
|
|
*,
|
|
tool_name: str,
|
|
agent_spec: Any | None,
|
|
conversation_id: str | None = None,
|
|
task_id: str | None = None,
|
|
agent_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Dispatch a Hindsight memory tool call (retain / recall / reflect).
|
|
|
|
Builds the tool from the spec's builtin config and runs its synchronous
|
|
``invoke`` off the event loop (it makes a blocking HTTP call to Hindsight).
|
|
The bank is resolved inside the tool from ``config.bank_id`` → ``ctx.agent_id``
|
|
→ ``ctx.conversation_id``, so the real ``agent_id`` is threaded through here.
|
|
|
|
:param args: Parsed LLM arguments (``content`` for retain, ``query`` otherwise).
|
|
:param tool_name: The Hindsight tool name being dispatched.
|
|
:param agent_spec: Parent agent's spec; carries the Hindsight builtin config.
|
|
:param conversation_id: Parent session id, threaded into the context.
|
|
:param task_id: Calling task id, threaded into the context.
|
|
:param agent_id: Calling agent id — the default memory bank.
|
|
:returns: The tool's string result, or an error string.
|
|
"""
|
|
from omnigent.tools.base import ToolContext
|
|
from omnigent.tools.builtins import get_builtin_tool
|
|
|
|
config = _hindsight_config_from_spec(agent_spec, tool_name)
|
|
tool = get_builtin_tool(tool_name, config)
|
|
if tool is None:
|
|
return f"Hindsight tool {tool_name!r} is not available."
|
|
ctx = ToolContext(
|
|
task_id=task_id or tool_name,
|
|
agent_id=agent_id or tool_name,
|
|
conversation_id=conversation_id,
|
|
)
|
|
return await asyncio.to_thread(tool.invoke, json.dumps(args), ctx)
|
|
|
|
|
|
def _has_subagent(
|
|
sub_agent_name: str,
|
|
agent_spec: Any | None,
|
|
) -> bool:
|
|
"""
|
|
Check whether a sub-agent name exists in the parent spec.
|
|
|
|
Searches both ``sub_agents`` (AP-style spec) and ``tools``
|
|
dict (omnigent inner loader) for a matching name.
|
|
|
|
:param sub_agent_name: Name of the sub-agent, e.g.
|
|
``"researcher"``.
|
|
:param agent_spec: Parent agent's spec. ``None`` when no
|
|
spec is available.
|
|
:returns: ``True`` if the sub-agent is declared.
|
|
"""
|
|
if agent_spec is None:
|
|
return False
|
|
# AP-style spec: sub_agents list
|
|
sub_agents = getattr(agent_spec, "sub_agents", None) or []
|
|
for sa in sub_agents:
|
|
if getattr(sa, "name", None) == sub_agent_name:
|
|
return True
|
|
# Omnigent inner loader: tools dict with AgentTool entries
|
|
tools = getattr(agent_spec, "tools", None)
|
|
if isinstance(tools, dict) and sub_agent_name in tools:
|
|
return True
|
|
return False
|
|
|
|
|
|
# ── Timer dispatch (RUNNER_TIMER_DISPATCH.md) ─────────────────
|
|
# Argument validation and the delay ceiling live in the timer builtin
|
|
# (``validate_timer_set_args``) so this firing path and the LLM-facing
|
|
# schema stay in lockstep.
|
|
|
|
|
|
async def _execute_timer_set(
|
|
args: dict[str, Any],
|
|
*,
|
|
server_client: httpx.AsyncClient | None = None,
|
|
conversation_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Schedule a timer that fires after a delay.
|
|
|
|
:param args: Parsed arguments. Keys: ``seconds`` (number),
|
|
``repeat`` (bool, default False), ``note`` (optional str).
|
|
:param server_client: httpx client for persisting firings.
|
|
:param conversation_id: Session the timer belongs to, e.g.
|
|
``"conv_abc123"``.
|
|
:returns: JSON string with ``timer_id`` and ``status``.
|
|
"""
|
|
from omnigent.runner import app as _app
|
|
|
|
validated = validate_timer_set_args(args)
|
|
if isinstance(validated, str):
|
|
return json.dumps({"error": validated})
|
|
seconds, repeat, note = validated
|
|
if server_client is None or conversation_id is None:
|
|
return json.dumps({"error": "timer requires server_client and conversation_id"})
|
|
|
|
timer_id = f"timer_{uuid.uuid4().hex}"
|
|
task = asyncio.create_task(
|
|
_timer_loop(
|
|
timer_id=timer_id,
|
|
conversation_id=conversation_id,
|
|
seconds=seconds,
|
|
repeat=repeat,
|
|
note=note,
|
|
server_client=server_client,
|
|
),
|
|
name=f"timer-{timer_id}",
|
|
)
|
|
_app.register_timer(conversation_id, timer_id, task)
|
|
return json.dumps(
|
|
{
|
|
"timer_id": timer_id,
|
|
"status": "scheduled",
|
|
"seconds": seconds,
|
|
"repeat": repeat,
|
|
"note": note,
|
|
}
|
|
)
|
|
|
|
|
|
async def _timer_loop(
|
|
*,
|
|
timer_id: str,
|
|
conversation_id: str,
|
|
seconds: float,
|
|
repeat: bool,
|
|
note: str | None,
|
|
server_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""
|
|
Background loop: sleep then fire timer notifications.
|
|
|
|
:param timer_id: Unique timer id, e.g. ``"timer_a1b2..."``.
|
|
:param conversation_id: Session to fire into.
|
|
:param seconds: Delay between firings.
|
|
:param repeat: Loop indefinitely when True.
|
|
:param note: Optional note echoed in firing text.
|
|
:param server_client: httpx client for persistence.
|
|
"""
|
|
from omnigent.runner import app as _app
|
|
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(seconds)
|
|
text = f"[System: timer {timer_id} fired]"
|
|
if note:
|
|
text += f"\nnote: {note!r}"
|
|
try:
|
|
await server_client.post(
|
|
f"/v1/sessions/{conversation_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"data": {
|
|
"role": "user",
|
|
"is_meta": True,
|
|
"content": [{"type": "input_text", "text": text}],
|
|
},
|
|
},
|
|
timeout=30.0,
|
|
)
|
|
except (httpx.HTTPError, asyncio.TimeoutError):
|
|
_logger.warning(
|
|
"Timer %s firing persist failed for %s",
|
|
timer_id,
|
|
conversation_id,
|
|
exc_info=True,
|
|
)
|
|
if not repeat:
|
|
break
|
|
except asyncio.CancelledError:
|
|
return
|
|
finally:
|
|
_app.unregister_timer(conversation_id, timer_id)
|
|
|
|
|
|
async def _execute_timer_cancel(
|
|
args: dict[str, Any],
|
|
*,
|
|
conversation_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Cancel a previously scheduled timer by ``timer_id``.
|
|
|
|
:param args: Parsed arguments with ``timer_id`` (string).
|
|
:param conversation_id: Session the timer belongs to.
|
|
:returns: JSON with ``status`` ``"cancelled"`` or ``"not_found"``.
|
|
"""
|
|
from omnigent.runner import app as _app
|
|
|
|
timer_id = args.get("timer_id")
|
|
if not isinstance(timer_id, str) or not timer_id:
|
|
return json.dumps({"error": "timer_id is required"})
|
|
if conversation_id is None:
|
|
return json.dumps({"error": "timer_cancel requires conversation_id"})
|
|
cancelled = _app.cancel_timer(conversation_id, timer_id)
|
|
return json.dumps({"timer_id": timer_id, "status": "cancelled" if cancelled else "not_found"})
|
|
|
|
|
|
async def _execute_comment_tool(
|
|
tool_name: str,
|
|
arguments: str,
|
|
*,
|
|
conversation_id: str | None,
|
|
server_client: httpx.AsyncClient | None,
|
|
) -> str:
|
|
"""
|
|
Runner-local handler for ``list_comments`` and ``update_comment``.
|
|
|
|
The runner is a separate subprocess from the Omnigent server and has no
|
|
in-process ``CommentStore``. This handler uses ``server_client`` to
|
|
call the Omnigent server's REST API (``GET/PATCH
|
|
/v1/sessions/{id}/comments``), following the same pattern as the
|
|
file tools.
|
|
|
|
:param tool_name: ``"list_comments"`` or ``"update_comment"``.
|
|
:param arguments: JSON-encoded arguments string from the LLM.
|
|
:param conversation_id: Current session id, e.g.
|
|
``"conv_abc123"``. Required for per-session comment scoping.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
``None`` if unavailable (returns an error string).
|
|
:returns: Tool output JSON string.
|
|
"""
|
|
if server_client is None:
|
|
return json.dumps({"error": f"{tool_name} requires server access"})
|
|
if conversation_id is None:
|
|
return json.dumps({"error": f"{tool_name} requires a session id"})
|
|
|
|
try:
|
|
args: dict[str, Any] = json.loads(arguments) if arguments.strip() else {}
|
|
except json.JSONDecodeError:
|
|
return json.dumps({"error": f"{tool_name}: malformed JSON arguments"})
|
|
base = f"/v1/sessions/{conversation_id}/comments"
|
|
|
|
if tool_name == ListCommentsTool.name():
|
|
params: dict[str, str] = {}
|
|
if args.get("path"):
|
|
params["path"] = args["path"]
|
|
try:
|
|
resp = await server_client.get(base, params=params, timeout=30.0)
|
|
if resp.status_code != 200:
|
|
return json.dumps({"error": f"list_comments returned {resp.status_code}"})
|
|
all_comments: list[dict[str, Any]] = resp.json()
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"list_comments failed: {exc}"})
|
|
# The server's GET endpoint only supports ?path= filtering;
|
|
# apply status filter client-side.
|
|
status_filter: str | None = args.get("status")
|
|
if status_filter is not None:
|
|
all_comments = [c for c in all_comments if c.get("status") == status_filter]
|
|
return json.dumps({"comments": all_comments})
|
|
|
|
# update_comment
|
|
comment_id: str | None = args.get("comment_id")
|
|
status: str | None = args.get("status")
|
|
if not comment_id:
|
|
return json.dumps({"error": "missing required argument: comment_id"})
|
|
if not status:
|
|
return json.dumps({"error": "missing required argument: status"})
|
|
_valid_statuses = {"draft", "addressed"}
|
|
if status not in _valid_statuses:
|
|
return json.dumps(
|
|
{"error": f"invalid status {status!r}; must be one of {sorted(_valid_statuses)}"}
|
|
)
|
|
try:
|
|
resp = await server_client.patch(
|
|
f"{base}/{comment_id}",
|
|
json={"status": status},
|
|
timeout=30.0,
|
|
)
|
|
if resp.status_code == 404:
|
|
return json.dumps({"error": f"comment not found: {comment_id}"})
|
|
if resp.status_code != 200:
|
|
return json.dumps({"error": f"update_comment returned {resp.status_code}"})
|
|
return json.dumps({"comment": resp.json()})
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"update_comment failed: {exc}"})
|
|
|
|
|
|
async def _execute_browser_tool(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
*,
|
|
server_client: httpx.AsyncClient | None,
|
|
conversation_id: str | None,
|
|
) -> str:
|
|
"""
|
|
Runner-local handler for the ``browser_*`` embedded-browser tools.
|
|
|
|
Does the blocking round-trip that drives the Omnigent desktop app's
|
|
embedded browser: POST ``/v1/sessions/{conversation_id}/browser/
|
|
action_request`` with ``{action, args}`` (where ``action`` is the
|
|
tool name minus the ``browser_`` prefix) and return the server's JSON
|
|
response verbatim as the tool output. The server parks a Future,
|
|
publishes ``browser.action_request`` on the session stream, and
|
|
resolves the Future when the winning renderer POSTs the action
|
|
result — so this POST stays open until the action completes or the
|
|
server's 30s browser-action await elapses.
|
|
|
|
Mirrors the ask-gate ``server_client.post`` pattern in
|
|
``_execute_subagent_tool`` (with a much shorter read budget — see
|
|
``_BROWSER_ACTION_TIMEOUT``). On the runner-side read timeout
|
|
(should not fire before the server returns its own clean timeout JSON,
|
|
since read(60) > server await(30)), returns the same timeout-error JSON
|
|
so the LLM always sees a clean tool error rather than an exception.
|
|
|
|
:param tool_name: The browser tool name, e.g. ``"browser_navigate"``.
|
|
:param args: Parsed tool arguments from the LLM, e.g.
|
|
``{"url": "https://example.com"}``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:param conversation_id: Current session id, e.g. ``"conv_abc123"``.
|
|
:returns: The server action-result JSON string, or a timeout/error JSON.
|
|
"""
|
|
if server_client is None:
|
|
return json.dumps({"error": f"{tool_name} requires server access"})
|
|
if conversation_id is None:
|
|
return json.dumps({"error": f"{tool_name} requires a session id"})
|
|
|
|
# Strip the ``browser_`` prefix so the wire ``action`` matches the
|
|
# frozen contract (navigate / snapshot / click / type / screenshot).
|
|
action = tool_name[len("browser_") :]
|
|
try:
|
|
resp = await server_client.post(
|
|
f"/v1/sessions/{conversation_id}/browser/action_request",
|
|
json={"action": action, "args": args},
|
|
timeout=_BROWSER_ACTION_TIMEOUT,
|
|
)
|
|
except httpx.ReadTimeout:
|
|
# The server should return its own clean timeout JSON well before this
|
|
# fires (read(60) > server await(30)); this is the belt-and-suspenders
|
|
# path if the server itself stalls.
|
|
return _BROWSER_TIMEOUT_ERROR
|
|
except httpx.HTTPError as exc:
|
|
return json.dumps({"error": f"{tool_name} failed: {type(exc).__name__}: {exc}"})
|
|
if resp.status_code >= 400:
|
|
return json.dumps({"error": f"{tool_name} returned {resp.status_code}: {resp.text[:200]}"})
|
|
return resp.text
|
|
|
|
|
|
async def _execute_policy_tool(
|
|
tool_name: str,
|
|
arguments: str,
|
|
*,
|
|
conversation_id: str | None,
|
|
server_client: httpx.AsyncClient | None,
|
|
) -> str:
|
|
"""
|
|
Runner-local handler for ``sys_add_policy`` and ``sys_policy_registry``.
|
|
|
|
``sys_policy_registry`` proxies ``GET /v1/policy-registry`` so the
|
|
agent can browse available builtin policies before picking one.
|
|
|
|
``sys_add_policy`` proxies ``POST /v1/sessions/{id}/policies``.
|
|
Two modes: (1) CEL expression — ``expression`` + ``reason`` are
|
|
translated into the ``cel_policy`` builtin factory; (2) builtin —
|
|
``handler`` + ``factory_params`` are forwarded as-is.
|
|
|
|
:param tool_name: ``"sys_add_policy"`` or ``"sys_policy_registry"``.
|
|
:param arguments: JSON-encoded arguments string from the LLM.
|
|
:param conversation_id: Current session id, e.g.
|
|
``"conv_abc123"``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: Tool output JSON string.
|
|
"""
|
|
if server_client is None:
|
|
return json.dumps({"error": f"{tool_name} requires server access"})
|
|
|
|
if tool_name == "sys_policy_registry":
|
|
return await _execute_list_policies(server_client)
|
|
|
|
if conversation_id is None:
|
|
return json.dumps({"error": f"{tool_name} requires a session id"})
|
|
|
|
try:
|
|
args: dict[str, Any] = json.loads(arguments) if arguments.strip() else {}
|
|
except json.JSONDecodeError:
|
|
return json.dumps({"error": f"{tool_name}: malformed JSON arguments"})
|
|
|
|
return await _execute_add_policy(args, conversation_id, server_client)
|
|
|
|
|
|
async def _execute_list_policies(
|
|
server_client: httpx.AsyncClient,
|
|
) -> str:
|
|
"""
|
|
Proxy ``GET /v1/policy-registry`` and return the list.
|
|
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: JSON string with the policy registry entries.
|
|
"""
|
|
try:
|
|
resp = await server_client.get("/v1/policy-registry", timeout=30.0)
|
|
if resp.status_code != 200:
|
|
return json.dumps({"error": f"server returned {resp.status_code}"})
|
|
data = resp.json().get("data", [])
|
|
return json.dumps({"policies": data})
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_policy_registry failed: {exc}"})
|
|
|
|
|
|
async def _execute_add_policy(
|
|
args: dict[str, Any],
|
|
conversation_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> str:
|
|
"""
|
|
Proxy ``POST /v1/sessions/{id}/policies`` to create a policy.
|
|
|
|
Forwards ``handler`` and ``factory_params`` from the tool
|
|
arguments directly to the session policy API as
|
|
``type="python"``.
|
|
|
|
:param args: Parsed tool arguments from the LLM.
|
|
:param conversation_id: Current session id.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: JSON string — created policy or error.
|
|
"""
|
|
handler = args.get("handler")
|
|
if not handler:
|
|
return json.dumps(
|
|
{"error": "sys_add_policy requires 'handler' (dotted path from sys_policy_registry)"}
|
|
)
|
|
payload: dict[str, Any] = {
|
|
"name": args.get("name", ""),
|
|
"type": "python",
|
|
"handler": handler,
|
|
}
|
|
fp = args.get("factory_params")
|
|
if fp is not None:
|
|
payload["factory_params"] = fp
|
|
|
|
try:
|
|
resp = await server_client.post(
|
|
f"/v1/sessions/{conversation_id}/policies",
|
|
json=payload,
|
|
timeout=30.0,
|
|
)
|
|
if resp.status_code not in (200, 201):
|
|
body = resp.text[:500]
|
|
return json.dumps(
|
|
{
|
|
"error": f"server returned {resp.status_code}",
|
|
"details": body,
|
|
}
|
|
)
|
|
result = resp.json()
|
|
return json.dumps(
|
|
{
|
|
"policy_id": result.get("id"),
|
|
"name": result.get("name"),
|
|
"type": result.get("type"),
|
|
"handler": result.get("handler"),
|
|
"enabled": result.get("enabled"),
|
|
"message": f"Policy '{result.get('name')}' created successfully.",
|
|
}
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_add_policy failed: {exc}"})
|
|
|
|
|
|
@dataclass
|
|
class _ParsedTitle:
|
|
"""
|
|
A child-session title split into its agent + instance components.
|
|
|
|
:param agent: The agent/tool segment, e.g. ``"researcher"`` or
|
|
``"claude-native-ui"``; ``None`` when the title has no colon
|
|
(a top-level/legacy row that isn't a sub-agent).
|
|
:param title: The instance label segment, e.g. ``"auth"`` or
|
|
``"1"``; ``None`` in the same no-colon case.
|
|
"""
|
|
|
|
agent: str | None
|
|
title: str | None
|
|
|
|
|
|
def _parse_session_title(raw_title: str | None) -> _ParsedTitle:
|
|
"""
|
|
Split a child-session title into agent + instance label.
|
|
|
|
Mirrors the server's ``_child_session_summary_from_conversation``
|
|
parse: the canonical ``"<agent>:<title>"`` form written by
|
|
``sys_session_send``, plus the 3-segment ``"ui:<agent>:<label>"``
|
|
form written by the Web UI "Add agent" flow. Legacy closed suffixes
|
|
are stripped before parsing so display/tool output stays human
|
|
readable. Returns both fields ``None`` when the title has no colon
|
|
(a top-level conversation that is not a sub-agent).
|
|
|
|
:param raw_title: The conversation ``title``, e.g.
|
|
``"researcher:auth"`` or ``"ui:claude-native-ui:1"``; may be
|
|
``None``.
|
|
:returns: The parsed agent/title pair.
|
|
"""
|
|
display_title = title_without_closed_marker(raw_title)
|
|
if not display_title or ":" not in display_title:
|
|
return _ParsedTitle(agent=None, title=None)
|
|
head, _, tail = display_title.partition(":")
|
|
if head == "ui" and ":" in tail:
|
|
agent, _, label = tail.partition(":")
|
|
return _ParsedTitle(agent=agent, title=label)
|
|
return _ParsedTitle(agent=head, title=tail)
|
|
|
|
|
|
def _truncate_activity(text: str | None) -> str | None:
|
|
"""
|
|
Truncate text to ``_ACTIVITY_MAX_CHARS`` to bound peek prompt size.
|
|
|
|
:param text: The text to truncate, or ``None``.
|
|
:returns: The (possibly truncated) text, or ``None`` when the input
|
|
is ``None``.
|
|
"""
|
|
if text is None:
|
|
return None
|
|
if len(text) <= _ACTIVITY_MAX_CHARS:
|
|
return text
|
|
return text[:_ACTIVITY_MAX_CHARS] + " [truncated]"
|
|
|
|
|
|
def _text_from_api_content(content: Any) -> str:
|
|
"""
|
|
Join the text blocks of an API message ``content`` array.
|
|
|
|
:param content: The ``content`` field of an API message item — a
|
|
list of blocks like ``{"type": "output_text", "text": "..."}``.
|
|
:returns: The concatenated text, or ``""`` when there is none.
|
|
"""
|
|
if not isinstance(content, list):
|
|
return ""
|
|
parts = [
|
|
block["text"]
|
|
for block in content
|
|
if isinstance(block, dict) and isinstance(block.get("text"), str)
|
|
]
|
|
return " ".join(parts)
|
|
|
|
|
|
def _project_api_item(item: dict[str, Any]) -> dict[str, str | None]:
|
|
"""
|
|
Project a REST API conversation item into the compact peek shape.
|
|
|
|
Mirrors :func:`omnigent.tools.builtins.spawn._project_activity_item`
|
|
but reads the API item JSON returned by
|
|
``GET /v1/sessions/{id}/items`` (``ConversationItem.to_api_dict()``)
|
|
rather than the in-process entity, so the harness peek result reads
|
|
the same as the in-process tool's.
|
|
|
|
:param item: One API item dict from the items endpoint.
|
|
:returns: A compact dict — ``{type, tool, args}`` for tool calls,
|
|
``{type, output}`` for tool results, ``{type, role, text}`` for
|
|
messages.
|
|
"""
|
|
itype = item.get("type")
|
|
if itype == "function_call":
|
|
return {
|
|
"type": "function_call",
|
|
"tool": item.get("name"),
|
|
"args": _truncate_activity(item.get("arguments")),
|
|
}
|
|
if itype == "function_call_output":
|
|
output = item.get("output")
|
|
rendered = output if isinstance(output, str) else json.dumps(output)
|
|
return {"type": "function_call_output", "output": _truncate_activity(rendered)}
|
|
if itype == "message":
|
|
return {
|
|
"type": "message",
|
|
"role": item.get("role"),
|
|
"text": _truncate_activity(_text_from_api_content(item.get("content"))),
|
|
}
|
|
return {"type": itype}
|
|
|
|
|
|
async def _execute_session_query_tool(
|
|
tool_name: str,
|
|
arguments: str,
|
|
*,
|
|
conversation_id: str | None,
|
|
server_client: httpx.AsyncClient | None,
|
|
agent_spec: Any | None = None,
|
|
) -> str:
|
|
"""
|
|
Runner-local handler for ``sys_session_get_history`` / ``sys_session_list`` /
|
|
``sys_session_close``.
|
|
|
|
The runner is a separate subprocess from the Omnigent server and has no
|
|
in-process ``ConversationStore`` (same constraint as
|
|
:func:`_execute_comment_tool`). These tools therefore dispatch to the
|
|
Omnigent server's existing REST endpoints over ``server_client``:
|
|
|
|
- ``sys_session_list`` → ``GET /v1/sessions/{caller}/child_sessions``
|
|
- ``sys_session_get_history`` → ``GET /v1/sessions/{target}/items``
|
|
- ``sys_session_get_info`` → ``GET /v1/sessions/{target}`` (plus a
|
|
best-effort ``GET /v1/runners/{id}/status`` for connectivity)
|
|
- ``sys_session_close`` → ``GET`` the target snapshot then ``PATCH
|
|
/v1/sessions/{target}`` with a tombstoned title
|
|
- ``sys_session_share`` → ``PUT /v1/sessions/{target}/permissions``
|
|
with the grantee + numeric level
|
|
|
|
Output shapes mirror the in-process tools in
|
|
:mod:`omnigent.tools.builtins.spawn` so the LLM sees identical
|
|
results regardless of executor. No new identity handling is
|
|
introduced: access control is whatever the server already enforces
|
|
on those endpoints for ``server_client`` — the same posture as
|
|
:func:`_execute_subagent_tool`.
|
|
|
|
:param tool_name: ``"sys_session_get_history"``, ``"sys_session_list"``,
|
|
``"sys_session_close"``, ``"sys_session_get_info"``, or
|
|
``"sys_session_share"``.
|
|
:param arguments: JSON-encoded arguments string from the LLM, e.g.
|
|
``'{"conversation_id": "conv_abc123", "tail_items": 5}'``.
|
|
:param conversation_id: The calling session id, e.g. ``"conv_root1"``;
|
|
used as the parent for ``sys_session_list``.
|
|
:param server_client: HTTP client pointed at the Omnigent server; ``None``
|
|
if unavailable (returns an error string).
|
|
:param agent_spec: The session's :class:`AgentSpec`. Used only by
|
|
``sys_session_share`` to read the spec's
|
|
``agent_session_sharing:`` policy (the server can't see it, so
|
|
the runner is the gate). ``None`` when no spec is available —
|
|
sharing then fails closed.
|
|
:returns: Tool output JSON string matching the in-process tool shape.
|
|
"""
|
|
if server_client is None:
|
|
return json.dumps({"error": f"{tool_name} requires server access"})
|
|
if conversation_id is None:
|
|
return json.dumps({"error": f"{tool_name} requires a session id"})
|
|
try:
|
|
args: dict[str, Any] = json.loads(arguments) if arguments.strip() else {}
|
|
except json.JSONDecodeError:
|
|
return json.dumps({"error": f"{tool_name}: malformed JSON arguments"})
|
|
|
|
if tool_name == "sys_session_list":
|
|
return await _session_list_via_rest(conversation_id, server_client, args.get("agent_name"))
|
|
if tool_name == "sys_session_get_history":
|
|
return await _session_get_history_via_rest(args, server_client)
|
|
if tool_name == "sys_session_get_info":
|
|
return await _session_get_info_via_rest(args, conversation_id, server_client)
|
|
if tool_name == "sys_session_share":
|
|
return await _session_share_via_rest(args, conversation_id, server_client, agent_spec)
|
|
return await _session_close_via_rest(args, conversation_id, server_client)
|
|
|
|
|
|
async def _runner_online_or_none(
|
|
runner_id: str | None,
|
|
server_client: httpx.AsyncClient,
|
|
) -> bool | None:
|
|
"""
|
|
Resolve a runner's live connectivity via ``GET /v1/runners/{id}/status``.
|
|
|
|
Best-effort: returns ``None`` when no runner is bound or the status
|
|
lookup fails, so ``sys_session_get_info`` degrades to "connectivity
|
|
unknown" rather than erroring on a transient runner-status hiccup.
|
|
|
|
:param runner_id: The session's bound runner id, or ``None``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: ``True``/``False`` from the status endpoint, or ``None``
|
|
when unbound or the lookup is inconclusive.
|
|
"""
|
|
if not runner_id:
|
|
return None
|
|
try:
|
|
resp = await server_client.get(f"/v1/runners/{runner_id}/status", timeout=30.0)
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
if resp.status_code != 200:
|
|
return None
|
|
online = resp.json().get("online")
|
|
return online if isinstance(online, bool) else None
|
|
|
|
|
|
async def _session_get_info_via_rest(
|
|
args: dict[str, Any],
|
|
conversation_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> str:
|
|
"""
|
|
Return a session's metadata snapshot via ``GET /v1/sessions/{id}``.
|
|
|
|
Resolves the target from ``args["session_id"]`` (falling back to the
|
|
caller's own ``conversation_id`` when omitted), fetches the session
|
|
snapshot, and projects the metadata fields — status, title, agent
|
|
binding, runner binding, host, reasoning effort, effective model,
|
|
parent linkage, workspace / git branch, and the outstanding approval
|
|
prompts (the prompts themselves plus a count). Runner connectivity
|
|
is resolved best-effort via
|
|
``GET /v1/runners/{id}/status`` (``runner_online`` is ``None`` when
|
|
the lookup fails or no runner is bound). The full transcript is
|
|
intentionally omitted — that is what ``sys_session_get_history`` returns.
|
|
|
|
Maps a 404 to ``session_not_found`` and 401/403 to ``access_denied``
|
|
(the server denied the read, so from the caller's vantage the target
|
|
is one it may not see).
|
|
|
|
:param args: Parsed tool arguments; optional ``session_id``.
|
|
:param conversation_id: The caller's own session id, used as the
|
|
default target when ``session_id`` is omitted.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: JSON metadata object, or a JSON error object.
|
|
"""
|
|
raw_target = args.get("session_id") or conversation_id
|
|
if not isinstance(raw_target, str) or not raw_target:
|
|
return json.dumps(
|
|
{"error": "sys_session_get_info requires a non-empty 'session_id' string"}
|
|
)
|
|
try:
|
|
resp = await server_client.get(f"/v1/sessions/{raw_target}", timeout=30.0)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_session_get_info failed: {exc}"})
|
|
if resp.status_code == 404:
|
|
return json.dumps({"error": "session_not_found", "session_id": raw_target})
|
|
if resp.status_code in (401, 403):
|
|
return json.dumps({"error": "access_denied", "session_id": raw_target})
|
|
if resp.status_code != 200:
|
|
return json.dumps({"error": f"sys_session_get_info returned {resp.status_code}"})
|
|
snap: dict[str, Any] = resp.json()
|
|
pending = snap.get("pending_elicitations") or []
|
|
return json.dumps(
|
|
{
|
|
"session_id": snap.get("id"),
|
|
"status": snap.get("status"),
|
|
"title": snap.get("title"),
|
|
"agent_id": snap.get("agent_id"),
|
|
# Present the public agent name: a native-UI wrapper session
|
|
# (e.g. ``pi-native-ui``) reports its clean display name (``Pi``)
|
|
# so the internal ``-native-ui`` wrapper name never leaks to the
|
|
# model answering "what agent are you?". Non-wrapper names are
|
|
# unchanged.
|
|
"agent_name": public_agent_name(snap.get("agent_name")),
|
|
"runner_id": snap.get("runner_id"),
|
|
"runner_online": await _runner_online_or_none(snap.get("runner_id"), server_client),
|
|
"host_id": snap.get("host_id"),
|
|
"parent_session_id": snap.get("parent_session_id"),
|
|
"sub_agent_name": snap.get("sub_agent_name"),
|
|
"reasoning_effort": snap.get("reasoning_effort"),
|
|
# Effective model: a per-session override wins over the
|
|
# agent spec's default; both may be None when unset.
|
|
"model": snap.get("model_override") or snap.get("llm_model"),
|
|
"workspace": snap.get("workspace"),
|
|
"git_branch": snap.get("git_branch"),
|
|
# The outstanding approval prompts themselves (original
|
|
# elicitation-request event dicts), plus a count for quick
|
|
# status checks. Surfacing the prompts — not just a tally —
|
|
# lets the orchestrator see what each blocked session is
|
|
# waiting on.
|
|
"pending_elicitations": pending,
|
|
"pending_elicitation_count": len(pending),
|
|
}
|
|
)
|
|
|
|
|
|
def _omnigent_error_message(resp: httpx.Response) -> str | None:
|
|
"""
|
|
Extract the human-readable message from an Omnigent error response.
|
|
|
|
The server renders :class:`omnigent.errors.OmnigentError` as
|
|
``{"error": {"code": ..., "message": ...}}`` (see the exception
|
|
handler in ``omnigent/server/app.py``). Return that ``message`` so a
|
|
tool can surface the server's own explanation rather than a bare
|
|
status code; return ``None`` when the body is not that envelope (a
|
|
non-JSON body, or a differently-shaped payload) so the caller can
|
|
fall back to a generic message.
|
|
|
|
:param resp: The HTTP response whose body to parse, e.g. a 400 from
|
|
``PUT /v1/sessions/{id}/permissions``.
|
|
:returns: The ``error.message`` string, or ``None`` if absent.
|
|
"""
|
|
try:
|
|
body = resp.json()
|
|
except ValueError:
|
|
# Non-JSON error body (e.g. an HTML proxy page) — no detail to surface.
|
|
return None
|
|
if isinstance(body, dict):
|
|
err = body.get("error")
|
|
if isinstance(err, dict):
|
|
message = err.get("message")
|
|
if isinstance(message, str) and message:
|
|
return message
|
|
return None
|
|
|
|
|
|
async def _session_share_via_rest(
|
|
args: dict[str, Any],
|
|
conversation_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
agent_spec: Any | None,
|
|
) -> str:
|
|
"""
|
|
Grant a user access to a session via ``PUT /v1/sessions/{id}/permissions``.
|
|
|
|
Resolves the target from ``args["session_id"]`` (falling back to the
|
|
caller's own ``conversation_id`` when omitted), maps the friendly
|
|
``level`` name to the server's numeric level, and PUTs the grant.
|
|
Same channel and security posture as the other session REST tools:
|
|
the server enforces that ``server_client``'s identity holds
|
|
manage-level access on the target (the session owner does), and caps
|
|
public (``__public__``) grants at read.
|
|
|
|
The spec's ``agent_session_sharing:`` policy is enforced HERE, in the
|
|
runner, because the server cannot see it: an ``agent_session_sharing:
|
|
none`` (or unknown) spec refuses every grant, and an
|
|
``agent_session_sharing: non-public`` spec refuses ``__public__``.
|
|
This is the real gate — tool *advertisement* is gated in the
|
|
ToolManager, but an unadvertised-yet-named call must still be denied
|
|
so a prompt-injected agent can't escalate by emitting the tool name.
|
|
|
|
Maps 404 to ``session_not_found`` and 401/403 to ``access_denied``;
|
|
other 4xx/5xx surface the server's own error message when present
|
|
(e.g. the "public is read-only" rejection of a ``__public__`` grant
|
|
above read level) instead of a bare status code.
|
|
|
|
:param args: Parsed tool arguments. Requires ``user_id`` (grantee
|
|
email or ``"__public__"``); optional ``level`` (``"read"``
|
|
default / ``"edit"`` / ``"manage"``) and ``session_id``.
|
|
:param conversation_id: The caller's own session id, used as the
|
|
default target when ``session_id`` is omitted.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:param agent_spec: The session's :class:`AgentSpec`; its
|
|
``agent_session_sharing`` policy gates this call. ``None`` (or
|
|
``agent_session_sharing: none``) fails closed — no grant is
|
|
attempted.
|
|
:returns: JSON ``{"shared": true, ...}`` on success, or a JSON
|
|
error object.
|
|
"""
|
|
target = args.get("session_id") or conversation_id
|
|
if not isinstance(target, str) or not target:
|
|
return json.dumps({"error": "sys_session_share requires a non-empty 'session_id' string"})
|
|
user_id = args.get("user_id")
|
|
if not isinstance(user_id, str) or not user_id:
|
|
return json.dumps({"error": "sys_session_share requires a non-empty 'user_id'"})
|
|
# Enforce the spec's ``agent_session_sharing:`` policy (SharePolicy is
|
|
# a str-enum, so compare its value directly). ``none``/absent disables
|
|
# the feature; ``__public__`` requires the ``public`` tier specifically.
|
|
share_policy = getattr(agent_spec, "agent_session_sharing", None)
|
|
if share_policy not in _SHARE_ENABLED_POLICIES:
|
|
return json.dumps(
|
|
{
|
|
"error": (
|
|
"sys_session_share: session sharing is not enabled for this "
|
|
"agent (set agent_session_sharing: non-public or "
|
|
"agent_session_sharing: public in the spec)"
|
|
),
|
|
"session_id": target,
|
|
}
|
|
)
|
|
if user_id == _PUBLIC_USER_SENTINEL and share_policy != _SHARE_PUBLIC_POLICY:
|
|
return json.dumps(
|
|
{
|
|
"error": (
|
|
"sys_session_share: public ('__public__') sharing is not "
|
|
"enabled for this agent (requires agent_session_sharing: "
|
|
"public); grant a specific user instead"
|
|
),
|
|
"session_id": target,
|
|
}
|
|
)
|
|
# Friendly level name -> the server's numeric permission level
|
|
# (GrantPermissionRequest accepts 1=read, 2=edit, 3=manage).
|
|
level_by_name = {"read": 1, "edit": 2, "manage": 3}
|
|
level_name = args.get("level", "read")
|
|
if level_name not in level_by_name:
|
|
return json.dumps(
|
|
{"error": f"sys_session_share: level must be one of {sorted(level_by_name)}"}
|
|
)
|
|
try:
|
|
resp = await server_client.put(
|
|
f"/v1/sessions/{target}/permissions",
|
|
json={"user_id": user_id, "level": level_by_name[level_name]},
|
|
timeout=30.0,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_session_share failed: {exc}"})
|
|
if resp.status_code == 404:
|
|
return json.dumps({"error": "session_not_found", "session_id": target})
|
|
if resp.status_code in (401, 403):
|
|
return json.dumps({"error": "access_denied", "session_id": target})
|
|
if resp.status_code >= 400:
|
|
# Surface the server's own message when present — e.g. the 400
|
|
# rejecting a __public__ grant above read level carries "Public
|
|
# access is limited to read-only (level 1)", which is far more
|
|
# actionable for the agent than a bare status code.
|
|
detail = _omnigent_error_message(resp)
|
|
if detail is not None:
|
|
return json.dumps(
|
|
{"error": detail, "status_code": resp.status_code, "session_id": target}
|
|
)
|
|
return json.dumps({"error": f"sys_session_share returned {resp.status_code}"})
|
|
return json.dumps(
|
|
{"shared": True, "session_id": target, "user_id": user_id, "level": level_name}
|
|
)
|
|
|
|
|
|
async def _execute_agent_tool(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
*,
|
|
server_client: httpx.AsyncClient | None,
|
|
agent_spec: Any | None,
|
|
conversation_id: str | None,
|
|
runner_workspace: Path | None,
|
|
) -> str:
|
|
"""
|
|
Runner-local handler for ``sys_agent_get`` / ``sys_agent_download``.
|
|
|
|
The runner has no in-process ``AgentStore`` / ``ArtifactStore``, so
|
|
these proxy the Omnigent server's REST endpoints over ``server_client``:
|
|
|
|
- ``sys_agent_get`` → ``GET /v1/sessions/{id}/agent`` (project the
|
|
:class:`~omnigent.server.schemas.AgentObject`)
|
|
- ``sys_agent_download`` → ``GET /v1/sessions/{id}/agent/contents``,
|
|
write the ``.tar.gz`` into the agent's local os_env cwd, return the
|
|
path
|
|
- ``sys_agent_list`` → ``GET /v1/agents`` + ``GET /v1/sessions`` +
|
|
local-config scan (no ``session_id``)
|
|
|
|
:param tool_name: ``"sys_agent_get"``, ``"sys_agent_download"``, or
|
|
``"sys_agent_list"``.
|
|
:param args: Parsed tool arguments; ``session_id`` required for
|
|
get/download, ignored for list.
|
|
:param server_client: HTTP client pointed at the Omnigent server; ``None``
|
|
returns an error string.
|
|
:param agent_spec: The running agent's spec — used (with
|
|
``conversation_id`` / ``runner_workspace``) to resolve the
|
|
os_env cwd that ``sys_agent_download`` writes into and
|
|
``sys_agent_list`` scans for local configs.
|
|
:param conversation_id: The caller's session id, for os_env cwd
|
|
resolution, e.g. ``"conv_abc123"``.
|
|
:param runner_workspace: The runner's workspace dir, authoritative
|
|
for the os_env cwd when present.
|
|
:returns: Tool output JSON string.
|
|
"""
|
|
if server_client is None:
|
|
return json.dumps({"error": f"{tool_name} requires server access"})
|
|
if tool_name == "sys_agent_list":
|
|
return await _agent_list_via_rest(
|
|
server_client,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
session_id = args.get("session_id")
|
|
if not isinstance(session_id, str) or not session_id:
|
|
return json.dumps({"error": f"{tool_name} requires a non-empty 'session_id' string"})
|
|
if tool_name == "sys_agent_get":
|
|
return await _agent_get_via_rest(session_id, server_client)
|
|
return await _agent_download_via_rest(
|
|
session_id,
|
|
args,
|
|
server_client,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
|
|
|
|
async def _agent_get_via_rest(
|
|
session_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> str:
|
|
"""
|
|
Return a session's bound-agent metadata via ``GET .../agent``.
|
|
|
|
Projects the :class:`~omnigent.server.schemas.AgentObject` fields
|
|
the orchestrator cares about: agent id, name, version, description,
|
|
harness, MCP server summaries, and guardrail policy summaries. Maps a
|
|
404 to ``agent_not_found`` and 401/403 to ``access_denied``.
|
|
|
|
:param session_id: The session whose bound agent to inspect, e.g.
|
|
``"conv_abc123"``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: JSON agent-metadata object, or a JSON error object.
|
|
"""
|
|
try:
|
|
resp = await server_client.get(f"/v1/sessions/{session_id}/agent", timeout=30.0)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_agent_get failed: {exc}"})
|
|
if resp.status_code == 404:
|
|
return json.dumps({"error": "agent_not_found", "session_id": session_id})
|
|
if resp.status_code in (401, 403):
|
|
return json.dumps({"error": "access_denied", "session_id": session_id})
|
|
if resp.status_code != 200:
|
|
return json.dumps({"error": f"sys_agent_get returned {resp.status_code}"})
|
|
agent: dict[str, Any] = resp.json()
|
|
return json.dumps(
|
|
{
|
|
"session_id": session_id,
|
|
"agent_id": agent.get("id"),
|
|
"name": agent.get("name"),
|
|
"version": agent.get("version"),
|
|
"description": agent.get("description"),
|
|
"harness": agent.get("harness"),
|
|
"mcp_servers": agent.get("mcp_servers") or [],
|
|
"policies": agent.get("policies") or [],
|
|
}
|
|
)
|
|
|
|
|
|
def _agent_bundle_filename(
|
|
dest_filename: Any,
|
|
agent_name: str,
|
|
agent_version: str,
|
|
) -> str | None:
|
|
"""
|
|
Resolve the local filename for a downloaded agent bundle.
|
|
|
|
Uses the caller's ``dest_filename`` when given, else defaults to
|
|
``"<agent_name>-v<version>.tar.gz"``. The result must be a bare
|
|
filename — any path separator (a traversal attempt) is rejected by
|
|
returning ``None`` so the caller surfaces an error rather than
|
|
writing outside the working directory.
|
|
|
|
:param dest_filename: Caller-supplied filename, or ``None`` to use
|
|
the default. Anything non-str is treated as absent.
|
|
:param agent_name: Agent name from the ``X-Agent-Name`` header, e.g.
|
|
``"researcher"``.
|
|
:param agent_version: Agent version from the ``X-Agent-Version``
|
|
header, e.g. ``"3"``.
|
|
:returns: A safe bare filename, or ``None`` when ``dest_filename``
|
|
contains a path separator or is ``"."`` / ``".."``.
|
|
"""
|
|
if isinstance(dest_filename, str) and dest_filename:
|
|
if "/" in dest_filename or "\\" in dest_filename or dest_filename in (".", ".."):
|
|
return None
|
|
return dest_filename
|
|
safe_name = (
|
|
"".join(c if c.isalnum() or c in {"-", "_"} else "_" for c in agent_name) or "agent"
|
|
)
|
|
return f"{safe_name}-v{agent_version}.tar.gz"
|
|
|
|
|
|
async def _agent_download_via_rest(
|
|
session_id: str,
|
|
args: dict[str, Any],
|
|
server_client: httpx.AsyncClient,
|
|
*,
|
|
agent_spec: Any | None,
|
|
conversation_id: str | None,
|
|
runner_workspace: Path | None,
|
|
) -> str:
|
|
"""
|
|
Download a session's agent bundle and write it to the agent's disk.
|
|
|
|
Fetches the ``.tar.gz`` from ``GET /v1/sessions/{id}/agent/contents``
|
|
and writes the bytes into the agent's os_env working directory — the
|
|
same cwd the agent's ``sys_os_*`` tools operate on (resolved via
|
|
:func:`_effective_runner_os_env_spec`, so a ``caller_process``
|
|
os_env's cwd is the ``runner_workspace`` or the per-conversation
|
|
tmpdir). The default filename is ``"<agent_name>-v<version>.tar.gz"``
|
|
(from the ``X-Agent-*`` response headers); a caller-supplied
|
|
``dest_filename`` overrides it. Returns the written path so the
|
|
orchestrator can extract (``sys_os_shell``) and read
|
|
(``sys_os_read``) the bundle.
|
|
|
|
NOTE: writing through the resolved cwd is correct for the default
|
|
``caller_process`` os_env (a real local directory). A non-local
|
|
sandbox whose filesystem differs from the runner's would not see the
|
|
file; such os_env types are out of scope for v1 agent download.
|
|
|
|
Maps a 404 to ``agent_not_found`` and 401/403 to ``access_denied``.
|
|
|
|
:param session_id: The session whose agent bundle to download.
|
|
:param args: Parsed tool arguments; optional ``dest_filename``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:param agent_spec: The running agent's spec, for os_env resolution.
|
|
:param conversation_id: The caller's session id, for os_env cwd.
|
|
:param runner_workspace: The runner workspace, authoritative cwd.
|
|
:returns: JSON ``{path, agent_name, agent_version, bytes_written}``,
|
|
or a JSON error object.
|
|
"""
|
|
try:
|
|
resp = await server_client.get(f"/v1/sessions/{session_id}/agent/contents", timeout=60.0)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_agent_download failed: {exc}"})
|
|
if resp.status_code == 404:
|
|
return json.dumps({"error": "agent_not_found", "session_id": session_id})
|
|
if resp.status_code in (401, 403):
|
|
return json.dumps({"error": "access_denied", "session_id": session_id})
|
|
if resp.status_code != 200:
|
|
return json.dumps({"error": f"sys_agent_download returned {resp.status_code}"})
|
|
agent_name = resp.headers.get("X-Agent-Name", "agent")
|
|
agent_version = resp.headers.get("X-Agent-Version", "0")
|
|
filename = _agent_bundle_filename(args.get("dest_filename"), agent_name, agent_version)
|
|
if filename is None:
|
|
return json.dumps(
|
|
{"error": "sys_agent_download dest_filename must be a bare filename, not a path"}
|
|
)
|
|
spec = _effective_runner_os_env_spec(agent_spec, conversation_id, runner_workspace)
|
|
cwd = Path(spec.cwd)
|
|
await asyncio.to_thread(cwd.mkdir, parents=True, exist_ok=True)
|
|
# Resolve symlinks on the realized cwd and confirm the destination
|
|
# stays within it before writing. ``filename`` is already a bare name
|
|
# (``_agent_bundle_filename`` rejects separators), but a symlinked cwd
|
|
# could still redirect the write outside the sandbox — realpath the
|
|
# parent and check containment, matching the sys_os_write pattern.
|
|
resolved_cwd = cwd.resolve()
|
|
dest = (resolved_cwd / filename).resolve()
|
|
if not dest.is_relative_to(resolved_cwd):
|
|
return json.dumps(
|
|
{"error": "sys_agent_download resolved destination escapes the working directory"}
|
|
)
|
|
await asyncio.to_thread(dest.write_bytes, resp.content)
|
|
return json.dumps(
|
|
{
|
|
"path": str(dest),
|
|
"agent_name": agent_name,
|
|
"agent_version": agent_version,
|
|
"bytes_written": len(resp.content),
|
|
}
|
|
)
|
|
|
|
|
|
async def _agent_list_fetch(
|
|
path: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Fetch one page of a paginated list endpoint, returning its ``data``.
|
|
|
|
Best-effort: returns ``[]`` on transport error or non-200 so a single
|
|
failing source degrades ``sys_agent_list`` to "that section is empty"
|
|
rather than failing the whole call.
|
|
|
|
:param path: The list endpoint path, e.g. ``"/v1/agents"`` or
|
|
``"/v1/sessions"``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: The ``data`` list from the paginated response (possibly
|
|
empty).
|
|
"""
|
|
try:
|
|
resp = await server_client.get(
|
|
path,
|
|
params={"limit": _AGENT_LIST_PAGE_LIMIT, "order": "desc"},
|
|
timeout=30.0,
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
return []
|
|
if resp.status_code != 200:
|
|
return []
|
|
data = resp.json().get("data", [])
|
|
return data if isinstance(data, list) else []
|
|
|
|
|
|
def _scan_local_agent_configs(configs_dir: Path) -> list[dict[str, str | None]]:
|
|
"""
|
|
Scan a directory for locally-authored agent config YAMLs.
|
|
|
|
Reads each ``*.yaml`` under ``configs_dir`` (the agent-config subdir
|
|
of the os_env cwd), extracting ``name`` and ``description`` for the
|
|
listing. Files that don't parse to a mapping are skipped (defensive —
|
|
a stray non-config YAML shouldn't break the scan). Returns ``[]``
|
|
when the directory doesn't exist yet (no configs authored).
|
|
|
|
:param configs_dir: The agent-config directory to scan, e.g.
|
|
``<cwd>/.omnigent/agent-configs``.
|
|
:returns: ``[{"name", "path", "description"}, ...]``, sorted by path.
|
|
"""
|
|
import yaml
|
|
|
|
if not configs_dir.is_dir():
|
|
return []
|
|
entries: list[dict[str, str | None]] = []
|
|
for path in sorted(configs_dir.glob("*.yaml")):
|
|
try:
|
|
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except (OSError, yaml.YAMLError):
|
|
continue
|
|
if not isinstance(loaded, dict):
|
|
continue
|
|
entries.append(
|
|
{
|
|
"name": loaded.get("name"),
|
|
"path": str(path),
|
|
"description": loaded.get("description"),
|
|
}
|
|
)
|
|
return entries
|
|
|
|
|
|
async def _agent_list_via_rest(
|
|
server_client: httpx.AsyncClient,
|
|
*,
|
|
agent_spec: Any | None,
|
|
conversation_id: str | None,
|
|
runner_workspace: Path | None,
|
|
) -> str:
|
|
"""
|
|
List launchable agents across built-ins, session-bound, and local.
|
|
|
|
Fans out three independent reads — each degrades to an empty section
|
|
on failure rather than failing the whole call:
|
|
|
|
- ``builtins``: ``GET /v1/agents`` (template agents), projected to
|
|
``{agent_id, name, description, harness}``.
|
|
- ``session_agents``: ``GET /v1/sessions``, projected to
|
|
``{session_id, agent_id, agent_name, status}`` so the caller can
|
|
launch the agent directly (``sys_session_create`` by
|
|
``agent_id``) or ``sys_agent_get`` / ``sys_agent_download`` a
|
|
chosen session.
|
|
- ``local_configs``: a scan of the os_env cwd's agent-config subdir
|
|
(YAMLs authored with ``sys_os_write`` per the agent-authoring
|
|
skill), projected to ``{name, path, description}``.
|
|
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:param agent_spec: The running agent's spec, for os_env cwd
|
|
resolution of the local-config scan.
|
|
:param conversation_id: The caller's session id, for os_env cwd.
|
|
:param runner_workspace: The runner workspace, authoritative cwd.
|
|
:returns: JSON ``{builtins, session_agents, local_configs}``.
|
|
"""
|
|
builtins_raw = await _agent_list_fetch("/v1/agents", server_client)
|
|
sessions_raw = await _agent_list_fetch("/v1/sessions", server_client)
|
|
spec = _effective_runner_os_env_spec(agent_spec, conversation_id, runner_workspace)
|
|
configs_dir = Path(spec.cwd) / _AGENT_CONFIG_SUBDIR
|
|
local_configs = await asyncio.to_thread(_scan_local_agent_configs, configs_dir)
|
|
return json.dumps(_project_agent_list(builtins_raw, sessions_raw, local_configs))
|
|
|
|
|
|
def _project_agent_list(
|
|
builtins_raw: list[dict[str, Any]],
|
|
sessions_raw: list[dict[str, Any]],
|
|
local_configs: list[dict[str, str | None]],
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
"""
|
|
Project the three raw ``sys_agent_list`` sources into the tool result.
|
|
|
|
Built-in :class:`AgentObject` rows are projected to
|
|
``{agent_id, name, description, harness}`` (note ``id`` → ``agent_id``
|
|
for naming consistency with the rest of the surface); session rows to
|
|
``{session_id, agent_id, agent_name, status}``; local configs pass
|
|
through unchanged.
|
|
|
|
:param builtins_raw: ``data`` rows from ``GET /v1/agents``.
|
|
:param sessions_raw: ``data`` rows from ``GET /v1/sessions``.
|
|
:param local_configs: Entries from :func:`_scan_local_agent_configs`.
|
|
:returns: ``{builtins, session_agents, local_configs}``.
|
|
"""
|
|
builtins = [
|
|
{
|
|
"agent_id": a.get("id"),
|
|
"name": a.get("name"),
|
|
"description": a.get("description"),
|
|
"harness": a.get("harness"),
|
|
}
|
|
for a in builtins_raw
|
|
]
|
|
session_agents = [
|
|
{
|
|
"session_id": s.get("id"),
|
|
"agent_id": s.get("agent_id"),
|
|
"agent_name": s.get("agent_name"),
|
|
"status": s.get("status"),
|
|
}
|
|
for s in sessions_raw
|
|
]
|
|
return {
|
|
"builtins": builtins,
|
|
"session_agents": session_agents,
|
|
"local_configs": local_configs,
|
|
}
|
|
|
|
|
|
async def _session_list_via_rest(
|
|
conversation_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
agent_name: Any = None,
|
|
) -> str:
|
|
"""
|
|
Return the two-view session list: ``sub_agents`` + global ``sessions``.
|
|
|
|
``sub_agents`` is the caller's named-sub-agent view (children, plus
|
|
parent/siblings when the caller is itself a child) — see
|
|
:func:`_collect_sub_agents`. ``sessions`` is the **global**,
|
|
permission-bounded list of every session the caller can access, each
|
|
annotated with status + runner connectivity, optionally filtered by
|
|
``agent_name`` — see :func:`_collect_global_sessions`. Both are
|
|
best-effort: a failure in either view yields an empty list for it
|
|
rather than failing the whole call.
|
|
|
|
:param conversation_id: The caller session id, e.g. ``"conv_root1"``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:param agent_name: Optional agent-name filter for the global
|
|
``sessions`` view; ignored for ``sub_agents``.
|
|
:returns: JSON ``{"sub_agents": [...], "sessions": [...]}``.
|
|
"""
|
|
sub_agents = await _collect_sub_agents(conversation_id, server_client)
|
|
sessions = await _collect_global_sessions(server_client, agent_name)
|
|
return json.dumps({"sub_agents": sub_agents, "sessions": sessions})
|
|
|
|
|
|
async def _collect_sub_agents(
|
|
conversation_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> list[dict[str, str | None]]:
|
|
"""
|
|
Collect the caller's named-sub-agent view via ``GET .../child_sessions``.
|
|
|
|
Returns ``[{"agent", "title", "conversation_id"}, ...]``, skipping
|
|
closed and titleless/colonless rows so they never re-surface to the
|
|
LLM. Includes the caller's own children and, when the caller is
|
|
itself a child (e.g. a user-added agent), its parent (surfaced as
|
|
``agent="main"``) and its siblings — so an added agent can still
|
|
discover ``main`` and its session-mates. Best-effort: a failed
|
|
lookup yields ``[]`` (or own-children-only) rather than raising.
|
|
|
|
:param conversation_id: The caller session id.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: The sub-agent entries.
|
|
"""
|
|
try:
|
|
resp = await server_client.get(
|
|
f"/v1/sessions/{conversation_id}/child_sessions",
|
|
params={"limit": 100},
|
|
timeout=30.0,
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
return []
|
|
if resp.status_code != 200:
|
|
return []
|
|
result = _child_rows_to_entries(resp.json().get("data", []))
|
|
|
|
# If the caller is itself a child, surface main + siblings too.
|
|
parent_id = await _session_parent_id(conversation_id, server_client)
|
|
if parent_id is not None:
|
|
result.append({"agent": "main", "title": None, "conversation_id": parent_id})
|
|
try:
|
|
sib_resp = await server_client.get(
|
|
f"/v1/sessions/{parent_id}/child_sessions",
|
|
params={"limit": 100},
|
|
timeout=30.0,
|
|
)
|
|
if sib_resp.status_code == 200:
|
|
for entry in _child_rows_to_entries(sib_resp.json().get("data", [])):
|
|
# Exclude the caller itself from its own sibling list.
|
|
if entry["conversation_id"] != conversation_id:
|
|
result.append(entry)
|
|
except Exception: # noqa: BLE001
|
|
_logger.debug(
|
|
"sys_session_list sibling enrichment failed for parent %s",
|
|
parent_id,
|
|
exc_info=True,
|
|
)
|
|
return result
|
|
|
|
|
|
async def _resolve_runner_online_map(
|
|
rows: list[dict[str, Any]],
|
|
server_client: httpx.AsyncClient,
|
|
) -> dict[str, bool | None]:
|
|
"""
|
|
Resolve live connectivity for the unique runners bound across rows.
|
|
|
|
Checks each distinct ``runner_id`` once (sessions frequently share a
|
|
runner) so the status round-trips scale with the number of runners,
|
|
not the number of sessions. Best-effort per runner via
|
|
:func:`_runner_online_or_none`.
|
|
|
|
:param rows: Session rows from ``GET /v1/sessions``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: Map of ``runner_id`` → online bool (or ``None`` if the
|
|
lookup was inconclusive).
|
|
"""
|
|
unique_ids: list[str] = []
|
|
seen: set[str] = set()
|
|
for r in rows:
|
|
rid = r.get("runner_id")
|
|
if isinstance(rid, str) and rid and rid not in seen:
|
|
seen.add(rid)
|
|
unique_ids.append(rid)
|
|
results = await asyncio.gather(
|
|
*(_runner_online_or_none(rid, server_client) for rid in unique_ids)
|
|
)
|
|
# strict=True: results is gathered in unique_ids order, so lengths
|
|
# match by construction — assert it rather than silently truncating.
|
|
return dict(zip(unique_ids, results, strict=True))
|
|
|
|
|
|
async def _collect_global_sessions(
|
|
server_client: httpx.AsyncClient,
|
|
agent_name: Any,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Fetch the global session list via ``GET /v1/sessions``, with connectivity.
|
|
|
|
Projects each accessible session to ``{session_id, agent_name, title,
|
|
status, runner_id, runner_online, parent_session_id}``.
|
|
``runner_online`` is resolved once per unique bound runner (see
|
|
:func:`_resolve_runner_online_map`). An optional ``agent_name``
|
|
filters the list server-side. Permission-bounded by the server (the
|
|
runner's request carries the owning user's identity). Best-effort:
|
|
returns ``[]`` on a fetch failure.
|
|
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:param agent_name: Optional agent-name filter; applied only when a
|
|
non-empty string.
|
|
:returns: The projected global session entries.
|
|
"""
|
|
params: dict[str, Any] = {"limit": _AGENT_LIST_PAGE_LIMIT, "order": "desc"}
|
|
if isinstance(agent_name, str) and agent_name:
|
|
params["agent_name"] = agent_name
|
|
try:
|
|
resp = await server_client.get("/v1/sessions", params=params, timeout=30.0)
|
|
except Exception: # noqa: BLE001
|
|
return []
|
|
if resp.status_code != 200:
|
|
return []
|
|
rows = resp.json().get("data", [])
|
|
if not isinstance(rows, list):
|
|
return []
|
|
online = await _resolve_runner_online_map(rows, server_client)
|
|
return [
|
|
{
|
|
"session_id": r.get("id"),
|
|
# Hide the internal ``-native-ui`` wrapper name (e.g.
|
|
# ``pi-native-ui`` -> ``Pi``) in the global listing too, matching
|
|
# ``sys_session_get_info``. The server-side ``agent_name`` filter
|
|
# above still receives the caller's raw argument unchanged.
|
|
"agent_name": public_agent_name(r.get("agent_name")),
|
|
"title": r.get("title"),
|
|
"status": r.get("status"),
|
|
"runner_id": r.get("runner_id"),
|
|
"runner_online": online.get(r.get("runner_id")),
|
|
"parent_session_id": r.get("parent_session_id"),
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def _child_rows_to_entries(
|
|
rows: list[dict[str, Any]],
|
|
) -> list[dict[str, str | None]]:
|
|
"""
|
|
Map ``child_sessions`` rows to ``sys_session_list`` entries.
|
|
|
|
Skips closed and titleless/colonless rows. The server already
|
|
parses ``tool``/``session_name`` from the title (including the
|
|
``"ui:<agent>:<label>"`` form), so those are reused.
|
|
|
|
:param rows: ``data`` rows from ``GET .../child_sessions``.
|
|
:returns: ``[{"agent", "title", "conversation_id"}, ...]``.
|
|
"""
|
|
entries: list[dict[str, str | None]] = []
|
|
for row in rows:
|
|
title = row.get("title")
|
|
if not title or ":" not in title or is_session_closed(row.get("labels"), title):
|
|
continue
|
|
entries.append(
|
|
{
|
|
"agent": row.get("tool"),
|
|
"title": row.get("session_name"),
|
|
"conversation_id": row.get("id"),
|
|
}
|
|
)
|
|
return entries
|
|
|
|
|
|
async def _session_parent_id(
|
|
conversation_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> str | None:
|
|
"""
|
|
Return a session's ``parent_session_id`` (None if top-level/unknown).
|
|
|
|
Used to decide whether the caller is itself a child — i.e. a
|
|
user-added agent that should also see ``main`` + siblings. Best-
|
|
effort: returns ``None`` on any read failure rather than raising.
|
|
|
|
:param conversation_id: The session to inspect.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: The parent session id, or ``None``.
|
|
"""
|
|
try:
|
|
snap = await server_client.get(f"/v1/sessions/{conversation_id}", timeout=30.0)
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
if snap.status_code != 200:
|
|
return None
|
|
parent = snap.json().get("parent_session_id")
|
|
return parent if isinstance(parent, str) and parent else None
|
|
|
|
|
|
async def _session_get_history_via_rest(
|
|
args: dict[str, Any],
|
|
server_client: httpx.AsyncClient,
|
|
) -> str:
|
|
"""
|
|
Read a target session's recent items via ``GET .../items``.
|
|
|
|
Mirrors :class:`SysSessionGetHistoryTool`: returns
|
|
``{"conversation_id", "agent", "title", "items"}`` with items in
|
|
chronological order. The target's ``agent``/``title`` come from its
|
|
session snapshot. Maps a 404 to ``session_not_found`` and a
|
|
403/401 to ``session_out_of_tree`` (the server denied read access,
|
|
so from the caller's vantage the target is outside the sessions it
|
|
may read).
|
|
|
|
:param args: Parsed tool arguments; requires ``conversation_id``,
|
|
optional ``tail_items``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: JSON peek result, or a JSON error object.
|
|
"""
|
|
target_id = args.get("conversation_id")
|
|
if not isinstance(target_id, str) or not target_id:
|
|
return json.dumps(
|
|
{"error": "sys_session_get_history requires a non-empty 'conversation_id' string"}
|
|
)
|
|
tail_items = _clamp_tail_items(args.get("tail_items", _HISTORY_DEFAULT_TAIL))
|
|
if isinstance(tail_items, str):
|
|
return tail_items
|
|
try:
|
|
resp = await server_client.get(
|
|
f"/v1/sessions/{target_id}/items",
|
|
params={"limit": tail_items, "order": "desc"},
|
|
timeout=30.0,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_session_get_history failed: {exc}"})
|
|
if resp.status_code == 404:
|
|
return json.dumps({"error": "session_not_found", "conversation_id": target_id})
|
|
if resp.status_code in (401, 403):
|
|
return json.dumps({"error": "session_out_of_tree", "conversation_id": target_id})
|
|
if resp.status_code != 200:
|
|
return json.dumps({"error": f"sys_session_get_history returned {resp.status_code}"})
|
|
data: list[dict[str, Any]] = resp.json().get("data", [])
|
|
# ``order="desc"`` returns newest-first; reverse to chronological so
|
|
# the LLM reads top-to-bottom (matches the in-process peek).
|
|
items: list[dict[str, Any]] = [_project_api_item(it) for it in reversed(data)]
|
|
meta = await _fetch_peek_meta(target_id, server_client)
|
|
# A parked elicitation never lands in the conversation store (it
|
|
# lives only in the Omnigent server's pending-elicitations index, replayed
|
|
# on the snapshot), so append the snapshot's outstanding prompts
|
|
# after the stored tail — they are the sub-agent's most recent act.
|
|
items.extend(
|
|
pending_elicitations.project_for_peek(event) for event in meta.pending_elicitations
|
|
)
|
|
return json.dumps(
|
|
{
|
|
"conversation_id": target_id,
|
|
"agent": meta.agent,
|
|
"title": meta.title,
|
|
"items": items,
|
|
}
|
|
)
|
|
|
|
|
|
async def _fetch_close_target(
|
|
target_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> dict[str, Any] | str:
|
|
"""
|
|
Fetch + status-classify the close target's session snapshot.
|
|
|
|
:param target_id: The conversation id to close, e.g. ``"conv_abc123"``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: The parsed snapshot dict on HTTP 200; otherwise a JSON
|
|
error string (``session_not_found`` for 404,
|
|
``session_out_of_tree`` for 401/403, a generic status error
|
|
otherwise) suitable for returning verbatim to the LLM.
|
|
"""
|
|
try:
|
|
snap = await server_client.get(f"/v1/sessions/{target_id}", timeout=30.0)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_session_close failed: {exc}"})
|
|
if snap.status_code == 404:
|
|
return json.dumps({"error": "session_not_found", "conversation_id": target_id})
|
|
if snap.status_code in (401, 403):
|
|
return json.dumps({"error": "session_out_of_tree", "conversation_id": target_id})
|
|
if snap.status_code != 200:
|
|
return json.dumps({"error": f"sys_session_close returned {snap.status_code}"})
|
|
return snap.json()
|
|
|
|
|
|
async def _close_tree_scope_error(
|
|
target_snap: dict[str, Any],
|
|
caller_conversation_id: str,
|
|
target_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> str | None:
|
|
"""
|
|
Enforce the close tool's spawn-tree gate over REST.
|
|
|
|
Mirrors the in-process :func:`_resolve_session_call` check: the
|
|
target must share the caller's ``root_conversation_id`` and must be
|
|
a sub-agent (have a parent). The caller's own root is resolved via
|
|
its session snapshot — a session can always read itself, so this is
|
|
a 200 on the happy path; a non-200 is surfaced as an error rather
|
|
than failing open. A ``None`` root on either side is treated as
|
|
out-of-tree (never a match).
|
|
|
|
:param target_snap: The close target's session snapshot dict (from
|
|
:func:`_fetch_close_target`), carrying ``root_conversation_id``
|
|
and ``parent_session_id``.
|
|
:param caller_conversation_id: The calling session's own id, e.g.
|
|
``"conv_caller"``.
|
|
:param target_id: The target conversation id, echoed into errors,
|
|
e.g. ``"conv_abc123"``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: ``None`` when the target is in-tree and a sub-agent;
|
|
otherwise a JSON error string (``session_out_of_tree`` or
|
|
``session_not_a_sub_agent``).
|
|
"""
|
|
try:
|
|
caller_snap = await server_client.get(
|
|
f"/v1/sessions/{caller_conversation_id}", timeout=30.0
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_session_close failed: {exc}"})
|
|
if caller_snap.status_code != 200:
|
|
return json.dumps(
|
|
{
|
|
"error": "sys_session_close could not resolve caller session "
|
|
f"{caller_conversation_id!r}"
|
|
}
|
|
)
|
|
caller_root = caller_snap.json().get("root_conversation_id")
|
|
target_root = target_snap.get("root_conversation_id")
|
|
if caller_root is None or target_root != caller_root:
|
|
return json.dumps({"error": "session_out_of_tree", "conversation_id": target_id})
|
|
if target_snap.get("parent_session_id") is None:
|
|
return json.dumps({"error": "session_not_a_sub_agent", "conversation_id": target_id})
|
|
return None
|
|
|
|
|
|
async def _session_close_via_rest(
|
|
args: dict[str, Any],
|
|
conversation_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> str:
|
|
"""
|
|
Close a target sub-agent via ``GET`` snapshot + ``PATCH`` metadata.
|
|
|
|
Mirrors :class:`SysSessionCloseTool` — including its tree-scoping:
|
|
close is a write, so the target MUST share the caller's spawn tree
|
|
(same ``root_conversation_id``) and MUST itself be a sub-agent (have
|
|
a parent). Without this the REST path would let an agent tombstone
|
|
any session it merely has edit access to — e.g. a sub-agent in one
|
|
of the caller's *other*, unrelated spawn trees — which the in-process
|
|
path forbids. The gate lives in the close tool (via
|
|
:func:`_close_tree_scope_error`), not the PATCH route, because the
|
|
route is a general title/metadata mutator; only the close tool
|
|
carries the spawn-tree contract.
|
|
|
|
On success marks the child with ``omnigent.closed=true`` and
|
|
rewrites its internal title to ``"<agent>:<title>:closed:<id>"`` so
|
|
future ``sys_session_send`` calls with the same ``(agent, title)``
|
|
create a fresh child.
|
|
|
|
:param args: Parsed tool arguments; requires ``conversation_id``.
|
|
:param conversation_id: The calling session's own id, e.g.
|
|
``"conv_caller"``. Used to resolve the caller's spawn-tree root
|
|
for the tree-scope check.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: JSON ``{"closed": true, ...}`` on success; a JSON error
|
|
object otherwise: ``session_not_found`` (404),
|
|
``session_out_of_tree`` (403/401, or the target's root differs
|
|
from the caller's), or ``session_not_a_sub_agent`` (the target
|
|
is a top-level session, not a sub-agent).
|
|
"""
|
|
target_id = args.get("conversation_id")
|
|
if not isinstance(target_id, str) or not target_id:
|
|
return json.dumps(
|
|
{"error": "sys_session_close requires a non-empty 'conversation_id' string"}
|
|
)
|
|
target_snap = await _fetch_close_target(target_id, server_client)
|
|
if isinstance(target_snap, str):
|
|
return target_snap
|
|
scope_error = await _close_tree_scope_error(
|
|
target_snap, conversation_id, target_id, server_client
|
|
)
|
|
if scope_error is not None:
|
|
return scope_error
|
|
parsed = _parse_session_title(target_snap.get("title"))
|
|
if parsed.agent is None or parsed.title is None:
|
|
return json.dumps({"error": "session_not_a_sub_agent", "conversation_id": target_id})
|
|
new_title = f"{parsed.agent}:{parsed.title}{_CLOSED_TITLE_INFIX}{target_id}"
|
|
try:
|
|
patch = await server_client.patch(
|
|
f"/v1/sessions/{target_id}",
|
|
json={
|
|
"title": new_title,
|
|
"labels": {CLOSED_LABEL_KEY: CLOSED_LABEL_VALUE},
|
|
},
|
|
timeout=30.0,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
return json.dumps({"error": f"sys_session_close failed: {exc}"})
|
|
if patch.status_code != 200:
|
|
return json.dumps({"error": f"sys_session_close returned {patch.status_code}"})
|
|
return json.dumps(
|
|
{
|
|
"closed": True,
|
|
"conversation_id": target_id,
|
|
"agent": parsed.agent,
|
|
"title": parsed.title,
|
|
}
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _PeekMeta:
|
|
"""
|
|
Session metadata peek reads off the target's ``GET /v1/sessions/{id}``.
|
|
|
|
:param agent: Parsed agent/tool segment of the title, e.g.
|
|
``"researcher"``; ``None`` when the title isn't sub-agent-shaped.
|
|
:param title: Parsed instance label segment, e.g. ``"auth"``;
|
|
``None`` in the same case.
|
|
:param pending_elicitations: Outstanding
|
|
``response.elicitation_request`` event payloads the target is
|
|
parked on, replayed on the snapshot from the Omnigent server's
|
|
:mod:`omnigent.runtime.pending_elicitations` index. Empty list
|
|
when the target has none (or the snapshot couldn't be read).
|
|
"""
|
|
|
|
agent: str | None
|
|
title: str | None
|
|
pending_elicitations: list[dict[str, Any]]
|
|
|
|
|
|
async def _fetch_peek_meta(
|
|
target_id: str,
|
|
server_client: httpx.AsyncClient,
|
|
) -> _PeekMeta:
|
|
"""
|
|
Fetch a session's title + pending elicitations for peek output.
|
|
|
|
One snapshot read serves both peek's ``agent``/``title`` labels and
|
|
the parked-elicitation items it appends. Best-effort: returns empty
|
|
fields when the snapshot can't be read, so a miss degrades
|
|
gracefully (peek still returns the stored item tail) rather than
|
|
failing the whole call.
|
|
|
|
:param target_id: The session whose snapshot to read.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: The parsed title plus any outstanding elicitation
|
|
payloads (all empty/``None`` on any miss).
|
|
"""
|
|
try:
|
|
snap = await server_client.get(f"/v1/sessions/{target_id}", timeout=30.0)
|
|
except Exception: # noqa: BLE001
|
|
return _PeekMeta(agent=None, title=None, pending_elicitations=[])
|
|
if snap.status_code != 200:
|
|
return _PeekMeta(agent=None, title=None, pending_elicitations=[])
|
|
body = snap.json()
|
|
parsed = _parse_session_title(body.get("title"))
|
|
raw_pending = body.get("pending_elicitations")
|
|
pending = (
|
|
[e for e in raw_pending if isinstance(e, dict)] if isinstance(raw_pending, list) else []
|
|
)
|
|
return _PeekMeta(agent=parsed.agent, title=parsed.title, pending_elicitations=pending)
|
|
|
|
|
|
async def execute_tool(
|
|
*,
|
|
tool_name: str,
|
|
arguments: str,
|
|
server_client: httpx.AsyncClient | None = None,
|
|
terminal_registry: Any | None = None,
|
|
resource_registry: Any | None = None,
|
|
agent_spec: Any | None = None,
|
|
conversation_id: str | None = None,
|
|
task_id: str | None = None,
|
|
agent_id: str | None = None,
|
|
agent_name: str | None = None,
|
|
runner_workspace: Path | None = None,
|
|
mcp_manager: Any | None = None,
|
|
session_inbox: asyncio.Queue[dict[str, Any]] | None = None,
|
|
session_async_tasks: dict[str, tuple[asyncio.Task[str], asyncio.Event]] | None = None,
|
|
harness_client: httpx.AsyncClient | None = None,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None = None,
|
|
filesystem_registry: FilesystemRegistry | None = None,
|
|
) -> str:
|
|
"""
|
|
Execute a tool and return the output string.
|
|
|
|
Pure execution — does NOT post the result to the harness.
|
|
Used by ``dispatch_tool_locally`` (which adds the harness
|
|
POST) and by ``_spawn_async_tool`` background tasks (which
|
|
push to the inbox queue instead).
|
|
|
|
:param tool_name: Tool to execute, e.g. ``"sys_os_shell"``.
|
|
:param arguments: JSON-encoded arguments string.
|
|
:param publish_event: Callback that puts an SSE event on the
|
|
runner's per-session outbound queue. ``None`` from
|
|
dispatch sites that don't need event emission (e.g.
|
|
async background tools).
|
|
:param resource_registry: Optional session-resource registry used to
|
|
observe tool-launched terminals through the same lifecycle path as
|
|
runner-launched terminals.
|
|
:param filesystem_registry: Optional registry for tracking agent
|
|
file modifications. Forwarded to ``_execute_os_env_tool``
|
|
so that ``sys_os_write`` and ``sys_os_edit`` calls record changed
|
|
paths for the ``GET …/changes`` endpoint. ``sys_os_shell`` is
|
|
not tracked — shell side-effects cannot be attributed to a session.
|
|
:returns: Tool output string.
|
|
"""
|
|
try:
|
|
args = json.loads(arguments)
|
|
except json.JSONDecodeError:
|
|
args = {}
|
|
|
|
try:
|
|
if mcp_manager is not None:
|
|
# All MCP tool calls are routed through the AP server's
|
|
# /mcp endpoint, which enforces TOOL_CALL and TOOL_RESULT
|
|
# policies centrally before forwarding to the runner's
|
|
# /mcp/execute. No runner-side policy gate needed.
|
|
output = await mcp_manager.call_tool(agent_spec, tool_name, args)
|
|
elif tool_name in _OS_ENV_TOOLS:
|
|
output = await _execute_os_env_tool(
|
|
tool_name,
|
|
args,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
runner_workspace=runner_workspace,
|
|
filesystem_registry=filesystem_registry,
|
|
)
|
|
elif tool_name in _REST_TOOLS:
|
|
output = await _execute_rest_tool(
|
|
tool_name,
|
|
args,
|
|
server_client,
|
|
agent_id=agent_id,
|
|
conversation_id=conversation_id,
|
|
)
|
|
elif tool_name in _FILE_TOOLS:
|
|
output = await _execute_file_tool(
|
|
tool_name,
|
|
args,
|
|
server_client,
|
|
conversation_id=conversation_id,
|
|
agent_spec=agent_spec,
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
elif tool_name in _TERMINAL_TOOLS:
|
|
output = await _execute_terminal_tool(
|
|
tool_name,
|
|
args,
|
|
terminal_registry=terminal_registry,
|
|
resource_registry=resource_registry,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
task_id=task_id,
|
|
agent_id=agent_id,
|
|
runner_workspace=runner_workspace,
|
|
session_inbox=session_inbox,
|
|
publish_event=publish_event,
|
|
)
|
|
elif tool_name in _ASYNC_INBOX_TOOLS:
|
|
output = await _execute_async_inbox_tool(
|
|
tool_name,
|
|
args,
|
|
session_inbox=session_inbox,
|
|
session_async_tasks=session_async_tasks,
|
|
harness_client=harness_client or httpx.AsyncClient(),
|
|
server_client=server_client,
|
|
terminal_registry=terminal_registry,
|
|
resource_registry=resource_registry,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
task_id=task_id,
|
|
agent_id=agent_id,
|
|
agent_name=agent_name,
|
|
runner_workspace=runner_workspace,
|
|
mcp_manager=mcp_manager,
|
|
filesystem_registry=filesystem_registry,
|
|
)
|
|
elif tool_name in _SUBAGENT_TOOLS:
|
|
output = await _execute_subagent_tool(
|
|
args,
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
agent_spec=agent_spec,
|
|
publish_event=publish_event,
|
|
session_inbox=session_inbox,
|
|
)
|
|
elif tool_name in _LIST_MODELS_TOOLS:
|
|
output = await _execute_list_models_tool(agent_spec=agent_spec)
|
|
elif tool_name in _SESSION_CREATE_TOOLS:
|
|
output = await _execute_session_create(
|
|
args,
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
publish_event=publish_event,
|
|
agent_spec=agent_spec,
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
elif tool_name in _SESSION_QUERY_TOOLS:
|
|
output = await _execute_session_query_tool(
|
|
tool_name,
|
|
arguments,
|
|
conversation_id=conversation_id,
|
|
server_client=server_client,
|
|
agent_spec=agent_spec,
|
|
)
|
|
elif tool_name in _WEB_FETCH_TOOLS:
|
|
output = await _execute_web_fetch_tool(
|
|
args,
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
agent_spec=agent_spec,
|
|
task_id=task_id,
|
|
publish_event=publish_event,
|
|
session_inbox=session_inbox,
|
|
)
|
|
elif tool_name in _WEB_SEARCH_TOOLS:
|
|
output = await _execute_web_search_tool(
|
|
args,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
task_id=task_id,
|
|
agent_id=agent_id,
|
|
)
|
|
elif tool_name in _HINDSIGHT_TOOLS:
|
|
output = await _execute_hindsight_tool(
|
|
args,
|
|
tool_name=tool_name,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
task_id=task_id,
|
|
agent_id=agent_id,
|
|
)
|
|
elif tool_name in _TIMER_TOOLS:
|
|
if tool_name == "sys_timer_set":
|
|
output = await _execute_timer_set(
|
|
args,
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
)
|
|
else:
|
|
output = await _execute_timer_cancel(
|
|
args,
|
|
conversation_id=conversation_id,
|
|
)
|
|
elif tool_name in _TASK_LIFECYCLE_TOOLS:
|
|
output = await _execute_task_lifecycle_tool(
|
|
args,
|
|
session_async_tasks=session_async_tasks,
|
|
conversation_id=conversation_id,
|
|
server_client=server_client,
|
|
)
|
|
elif tool_name in _SKILL_TOOLS:
|
|
output = _execute_skill_tool(
|
|
tool_name,
|
|
args,
|
|
agent_spec=agent_spec,
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
elif tool_name in _COMMENT_TOOLS:
|
|
output = await _execute_comment_tool(
|
|
tool_name,
|
|
arguments,
|
|
conversation_id=conversation_id,
|
|
server_client=server_client,
|
|
)
|
|
elif tool_name in _AGENT_TOOLS:
|
|
output = await _execute_agent_tool(
|
|
tool_name,
|
|
args,
|
|
server_client=server_client,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
elif tool_name in _POLICY_TOOLS:
|
|
output = await _execute_policy_tool(
|
|
tool_name,
|
|
arguments,
|
|
conversation_id=conversation_id,
|
|
server_client=server_client,
|
|
)
|
|
elif tool_name in _BROWSER_TOOLS:
|
|
output = await _execute_browser_tool(
|
|
tool_name,
|
|
args,
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
)
|
|
elif _is_spec_local_python_tool(tool_name, agent_spec):
|
|
output = await _execute_local_python_tool(
|
|
tool_name,
|
|
arguments,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
task_id=task_id,
|
|
agent_id=agent_id,
|
|
runner_workspace=runner_workspace,
|
|
)
|
|
elif _is_uc_function_tool(tool_name, agent_spec):
|
|
output = await _execute_uc_function_tool(tool_name, args, agent_spec=agent_spec)
|
|
else:
|
|
output = await _execute_spec_callable_tool(tool_name, args, agent_spec=agent_spec)
|
|
except Exception as exc: # noqa: BLE001
|
|
output = f"Error: {type(exc).__name__}: {exc}"
|
|
|
|
return output
|
|
|
|
|
|
# Per-session leading-edge throttle for changed-files invalidation
|
|
# signals. A file-mutating tool publishes at most one
|
|
# ``session.changed_files.invalidated`` per this window; the web's
|
|
# react-query invalidation coalesces bursts and the end-of-turn trailing
|
|
# refetch backstops the final state. Leading (not trailing) so there is
|
|
# no timer to manage on the dispatch path.
|
|
_CHANGED_FILES_SIGNAL_THROTTLE_S = 0.75
|
|
# Bound the throttle map so a long-lived runner with churny session ids
|
|
# can't grow it without limit. Clearing past the cap only risks one extra
|
|
# (harmless) signal for sessions whose timestamp is dropped.
|
|
_CHANGED_FILES_SIGNAL_MAX_TRACKED = 4096
|
|
_changed_files_last_signal: dict[str, float] = {}
|
|
# Tools that can mutate the workspace filesystem. ``sys_os_shell`` is
|
|
# included because git-mode change detection derives from `git status`
|
|
# and shell edits are otherwise untracked.
|
|
_CHANGED_FILES_TOOLS = frozenset(
|
|
{SysOsWriteTool.name(), SysOsEditTool.name(), SysOsShellTool.name()}
|
|
)
|
|
|
|
|
|
def _maybe_signal_changed_files(
|
|
conversation_id: str | None,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None,
|
|
*,
|
|
now: float,
|
|
) -> None:
|
|
"""Publish a throttled ``session.changed_files.invalidated`` event.
|
|
|
|
Tells the web to refetch the changed-files list (a coarse "something
|
|
changed" signal — per-file events aren't available for git-mode
|
|
workspaces). Leading-edge throttle keyed by session collapses a
|
|
multi-file turn to roughly one refetch trigger.
|
|
|
|
:param conversation_id: Session id, or ``None`` (no-op).
|
|
:param publish_event: Per-session SSE emitter, or ``None`` (no-op).
|
|
:param now: Monotonic timestamp, e.g. ``loop.time()``.
|
|
"""
|
|
if conversation_id is None or publish_event is None:
|
|
return
|
|
last = _changed_files_last_signal.get(conversation_id, 0.0)
|
|
if now - last < _CHANGED_FILES_SIGNAL_THROTTLE_S:
|
|
return
|
|
if len(_changed_files_last_signal) > _CHANGED_FILES_SIGNAL_MAX_TRACKED:
|
|
_changed_files_last_signal.clear()
|
|
_changed_files_last_signal[conversation_id] = now
|
|
publish_event(
|
|
conversation_id,
|
|
{
|
|
"type": "session.changed_files.invalidated",
|
|
"session_id": conversation_id,
|
|
"environment_id": "default",
|
|
},
|
|
)
|
|
|
|
|
|
async def dispatch_tool_locally(
|
|
*,
|
|
tool_name: str,
|
|
call_id: str,
|
|
arguments: str,
|
|
response_id: str,
|
|
harness_client: httpx.AsyncClient,
|
|
server_client: httpx.AsyncClient | None = None,
|
|
terminal_registry: Any | None = None,
|
|
resource_registry: Any | None = None,
|
|
agent_spec: Any | None = None,
|
|
conversation_id: str | None = None,
|
|
task_id: str | None = None,
|
|
agent_id: str | None = None,
|
|
agent_name: str | None = None,
|
|
runner_workspace: Path | None = None,
|
|
mcp_manager: Any | None = None,
|
|
session_inbox: asyncio.Queue[dict[str, Any]] | None = None,
|
|
session_async_tasks: dict[str, tuple[asyncio.Task[str], asyncio.Event]] | None = None,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None = None,
|
|
filesystem_registry: FilesystemRegistry | None = None,
|
|
) -> str:
|
|
"""Execute a tool locally and PATCH the result to the harness.
|
|
|
|
:param runner_workspace: Optional CLI launch workspace used to
|
|
resolve placeholder cwd values for runner-owned filesystem
|
|
tools.
|
|
:param mcp_manager: When set, dispatch via
|
|
:meth:`RunnerMcpManager.call_tool`. Caller (proxy_stream)
|
|
passes this only for MCP-owned tools.
|
|
:param session_inbox: Per-session asyncio queue for async tool
|
|
completions. ``sys_call_async`` pushes results here;
|
|
``sys_read_inbox`` drains it.
|
|
:param session_async_tasks: Per-session dict of handle_id →
|
|
``(Task, cancel_event)`` tuple. Used by ``sys_cancel_async``
|
|
to signal cancellation via the event.
|
|
:param filesystem_registry: Optional registry for tracking agent
|
|
file modifications. Forwarded to ``execute_tool`` so that
|
|
``sys_os_write`` and ``sys_os_edit`` calls record changed paths
|
|
for the ``GET …/changes`` endpoint.
|
|
:param resource_registry: Optional session-resource registry used to
|
|
observe tool-launched terminals.
|
|
:returns: The tool output string.
|
|
"""
|
|
output = await execute_tool(
|
|
tool_name=tool_name,
|
|
arguments=arguments,
|
|
server_client=server_client,
|
|
terminal_registry=terminal_registry,
|
|
resource_registry=resource_registry,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
task_id=task_id,
|
|
agent_id=agent_id,
|
|
agent_name=agent_name,
|
|
runner_workspace=runner_workspace,
|
|
mcp_manager=mcp_manager,
|
|
session_inbox=session_inbox,
|
|
session_async_tasks=session_async_tasks,
|
|
harness_client=harness_client,
|
|
filesystem_registry=filesystem_registry,
|
|
publish_event=publish_event,
|
|
)
|
|
|
|
# A file-mutating tool just ran — nudge the web to refetch the
|
|
# changed-files list (throttled, coalesced client-side).
|
|
if tool_name in _CHANGED_FILES_TOOLS:
|
|
_maybe_signal_changed_files(
|
|
conversation_id,
|
|
publish_event,
|
|
now=asyncio.get_running_loop().time(),
|
|
)
|
|
|
|
# POST the result back to the harness as a ``tool_result``
|
|
# event on the session-keyed events endpoint. ``conversation_id``
|
|
# is required: the harness validates the URL segment against
|
|
# its own runner-stamped value and fails 404 on mismatch —
|
|
# without an id we'd be unable to form a valid URL. Fail loud
|
|
# per ``designs/DESIGN_PRINCIPLES.md`` rather than substituting
|
|
# a synthetic default. ``response_id`` is unused at the URL /
|
|
# body level (the harness has at most one in-flight turn so the
|
|
# ``call_id`` alone keys the parked Future) — kept on the
|
|
# function signature for symmetry with callers that track it.
|
|
del response_id # see comment above — intentionally unused
|
|
if not conversation_id:
|
|
raise ValueError(
|
|
"dispatch_tool_locally requires conversation_id to POST the "
|
|
"harness session-keyed URL; got None/empty"
|
|
)
|
|
try:
|
|
resp = await harness_client.post(
|
|
f"/v1/sessions/{conversation_id}/events",
|
|
json={"type": "tool_result", "call_id": call_id, "output": output},
|
|
timeout=30.0,
|
|
)
|
|
resp.raise_for_status()
|
|
except Exception as exc: # noqa: BLE001
|
|
_logger.warning(
|
|
"Runner local dispatch tool_result event failed for %s (call_id=%s): %s",
|
|
tool_name,
|
|
call_id,
|
|
exc,
|
|
)
|
|
|
|
return output
|
|
|
|
|
|
# ── OS env tools (OSEnvironment-backed) ──────────────────
|
|
|
|
|
|
def _clone_os_env_spec(spec: Any) -> Any:
|
|
"""Return a defensive copy of an OSEnvSpec-like object.
|
|
|
|
Uses :func:`dataclasses.replace` so any field added to
|
|
:class:`OSEnvSandboxSpec` or :class:`OSEnvSpec` in the future is
|
|
carried over automatically. Mutable list fields are copied
|
|
explicitly so the clone and the original don't alias the same
|
|
list (which would let one caller's later mutation leak into the
|
|
other's view — a real hazard when the same parent spec is reused
|
|
across many runner-local sys_os_* dispatches).
|
|
|
|
Symmetric with :func:`omnigent.inner.terminal._clone_sandbox_spec`;
|
|
both fixes close the same class of bug where hand-enumerated
|
|
field copies silently drop newly-added security-critical fields
|
|
such as ``egress_rules`` and ``egress_allow_private_destinations``.
|
|
"""
|
|
sandbox = getattr(spec, "sandbox", None)
|
|
sandbox_copy = None
|
|
if sandbox is not None:
|
|
sandbox_copy = dataclasses.replace(
|
|
sandbox,
|
|
read_paths=list(sandbox.read_paths) if sandbox.read_paths is not None else None,
|
|
write_paths=list(sandbox.write_paths) if sandbox.write_paths is not None else None,
|
|
write_files=list(sandbox.write_files) if sandbox.write_files is not None else None,
|
|
cwd_allow_hidden=(
|
|
list(sandbox.cwd_allow_hidden) if sandbox.cwd_allow_hidden is not None else None
|
|
),
|
|
env_passthrough=(
|
|
list(sandbox.env_passthrough) if sandbox.env_passthrough is not None else None
|
|
),
|
|
egress_rules=list(sandbox.egress_rules) if sandbox.egress_rules is not None else None,
|
|
)
|
|
return dataclasses.replace(spec, sandbox=sandbox_copy)
|
|
|
|
|
|
def _runner_default_os_env_cwd(conversation_id: str | None) -> str:
|
|
"""Return the cwd for a default runner-owned primary OSEnv."""
|
|
safe_conv = "default"
|
|
if conversation_id:
|
|
safe_conv = "".join(
|
|
ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in conversation_id
|
|
)
|
|
root = Path(
|
|
os.environ.get(
|
|
"OMNIGENT_RUNNER_OS_ENV_ROOT",
|
|
str(Path(tempfile.gettempdir()) / "omnigent-runner-os-envs"),
|
|
)
|
|
)
|
|
cwd = root / safe_conv / "workspace"
|
|
cwd.mkdir(parents=True, exist_ok=True)
|
|
return str(cwd)
|
|
|
|
|
|
def _effective_runner_os_env_spec(
|
|
agent_spec: Any | None,
|
|
conversation_id: str | None,
|
|
runner_workspace: Path | None = None,
|
|
) -> Any:
|
|
"""
|
|
Build the OSEnvSpec used by runner-local sys_os_* dispatch.
|
|
|
|
Precedence (per
|
|
designs/SESSION_WORKSPACE_SELECTION.md "How this maps onto runtime"):
|
|
|
|
- When ``runner_workspace`` is set, it ALWAYS wins — whether
|
|
the spec's cwd is relative, absolute, or unset. The runner
|
|
workspace is the authoritative starting cwd for both
|
|
CLI-launched sessions (CLI captures ``os.getcwd()`` and
|
|
passes it via ``OMNIGENT_RUNNER_WORKSPACE``) and
|
|
host-launched sessions (host applies the validated picked
|
|
directory). The agent's spec ``cwd`` is treated as a
|
|
boundary at session-create time, not a runtime override.
|
|
- When ``runner_workspace`` is unset (pure local runs without
|
|
the env var), the spec's cwd applies, with placeholder
|
|
values (``.``, ``./``, ``""``, ``None``) substituted by
|
|
a per-conversation tmpdir as before.
|
|
|
|
:param agent_spec: Agent spec resolved for the current turn, or
|
|
``None`` when dispatch only has request-body hints.
|
|
:param conversation_id: Conversation id used to derive the
|
|
per-conversation fallback workspace, e.g. ``"conv_123"``.
|
|
:param runner_workspace: Authoritative runtime cwd for the
|
|
runner, sourced from ``OMNIGENT_RUNNER_WORKSPACE``.
|
|
Overrides the spec's cwd when set.
|
|
:returns: An ``OSEnvSpec`` with a concrete cwd.
|
|
"""
|
|
from omnigent.inner.datamodel import OSEnvSpec
|
|
|
|
configured = getattr(agent_spec, "os_env", None) if agent_spec is not None else None
|
|
if configured is not None:
|
|
spec = _clone_os_env_spec(configured)
|
|
if runner_workspace is not None:
|
|
# Runner workspace is authoritative — overrides whatever
|
|
# the spec declared (relative or absolute).
|
|
spec.cwd = str(runner_workspace)
|
|
elif spec.cwd in _PLACEHOLDER_CWDS:
|
|
# No runner workspace; spec is relative — fall back to
|
|
# the per-conversation tmpdir so multiple sessions
|
|
# don't collide on a shared default cwd.
|
|
spec.cwd = _runner_default_os_env_cwd(conversation_id)
|
|
return spec
|
|
cwd = (
|
|
str(runner_workspace)
|
|
if runner_workspace is not None
|
|
else _runner_default_os_env_cwd(conversation_id)
|
|
)
|
|
return OSEnvSpec(type="caller_process", cwd=cwd)
|
|
|
|
|
|
async def _seed_os_env_snapshot(
|
|
os_env: Any,
|
|
path: str,
|
|
filesystem_registry: FilesystemRegistry,
|
|
conversation_id: str,
|
|
) -> None:
|
|
"""Seed the diff snapshot with *path*'s current content before a write or edit.
|
|
|
|
Reads the file via *os_env* and passes the content to
|
|
:meth:`~omnigent.runtime.filesystem_registry.FilesystemRegistry.seed_snapshot`
|
|
so the before/after diff endpoint can show the original content.
|
|
Silently skips when the file does not yet exist (new-file creates have no
|
|
baseline) or when any other read error occurs.
|
|
|
|
:param os_env: The :class:`~omnigent.inner.os_env.OSEnvironment` used for
|
|
the current tool dispatch — reused to avoid opening a second connection.
|
|
:param path: Path argument forwarded from the tool call, e.g. ``"src/foo.py"``.
|
|
:param filesystem_registry: Registry that stores the snapshot.
|
|
:param conversation_id: Session scope for the snapshot, e.g. ``"conv_abc123"``.
|
|
"""
|
|
try:
|
|
existing = await os_env.read(path=path, offset=1, limit=None)
|
|
if isinstance(existing, dict) and "content" in existing:
|
|
filesystem_registry.seed_snapshot(
|
|
path, existing["content"], session_id=conversation_id
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
pass # file does not exist yet or unreadable — no baseline to capture
|
|
|
|
|
|
async def _execute_os_env_tool(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
*,
|
|
agent_spec: Any | None = None,
|
|
conversation_id: str | None = None,
|
|
runner_workspace: Path | None = None,
|
|
filesystem_registry: FilesystemRegistry | None = None,
|
|
) -> str:
|
|
"""
|
|
Execute sys_os_* through a runner-local OSEnvironment.
|
|
|
|
:param tool_name: Built-in OS tool name, e.g. ``"sys_os_read"``.
|
|
:param args: Parsed tool-call arguments.
|
|
:param agent_spec: Agent spec resolved for the current turn, or
|
|
``None`` when unavailable.
|
|
:param conversation_id: Conversation id used for the fallback
|
|
workspace, e.g. ``"conv_123"``.
|
|
:param runner_workspace: Optional CLI launch workspace used for
|
|
placeholder cwd values in remote app sessions.
|
|
:param filesystem_registry: Optional registry for tracking agent
|
|
file modifications. When provided, ``sys_os_write`` and
|
|
``sys_os_edit`` calls record changed paths so the
|
|
``GET …/changes`` endpoint can surface them. ``sys_os_shell``
|
|
is not tracked — shell side-effects cannot be attributed to a
|
|
session.
|
|
:returns: Serialized tool result string.
|
|
"""
|
|
from omnigent.inner.os_env import _DEFAULT_READ_LIMIT, create_os_environment
|
|
|
|
os_env = None
|
|
try:
|
|
os_env = create_os_environment(
|
|
_effective_runner_os_env_spec(agent_spec, conversation_id, runner_workspace)
|
|
)
|
|
if os_env is None:
|
|
return "Error: unable to create OSEnvironment"
|
|
|
|
if tool_name == SysOsReadTool.name():
|
|
result = await os_env.read(
|
|
path=args.get("path", ""),
|
|
offset=args.get("offset", 1),
|
|
# Unspecified limit → agent-tool default (2 000 lines).
|
|
# None is now "unlimited" in _read_impl, so we must be explicit.
|
|
# Use is-None check (not `or`) so that invalid values like 0 are
|
|
# forwarded to os_env.read for validation rather than silently
|
|
# replaced with the default.
|
|
limit=(lv if (lv := args.get("limit")) is not None else _DEFAULT_READ_LIMIT),
|
|
)
|
|
elif tool_name == SysOsWriteTool.name():
|
|
_path = args.get("path", "")
|
|
if filesystem_registry is not None and conversation_id is not None:
|
|
await _seed_os_env_snapshot(os_env, _path, filesystem_registry, conversation_id)
|
|
result = await os_env.write(path=_path, content=args.get("content", ""))
|
|
if filesystem_registry is not None and conversation_id is not None:
|
|
# _write_impl returns {"created": True} when the file did not
|
|
# previously exist, {"created": False} for an overwrite.
|
|
was_created = isinstance(result, dict) and result.get("created") is True
|
|
status = "created" if was_created else "modified"
|
|
filesystem_registry.record_change(_path, status, conversation_id)
|
|
elif tool_name == SysOsEditTool.name():
|
|
_path = args.get("path", "")
|
|
if filesystem_registry is not None and conversation_id is not None:
|
|
await _seed_os_env_snapshot(os_env, _path, filesystem_registry, conversation_id)
|
|
result = await os_env.edit(
|
|
path=_path,
|
|
old_text=args.get("oldText") or args.get("old_string"),
|
|
new_text=args.get("newText") or args.get("new_string"),
|
|
edits=args.get("edits"),
|
|
)
|
|
if filesystem_registry is not None and conversation_id is not None:
|
|
filesystem_registry.record_change(_path, "modified", conversation_id)
|
|
elif tool_name == SysOsShellTool.name():
|
|
result = await os_env.shell(
|
|
command=args.get("command", ""),
|
|
timeout=args.get("timeout"),
|
|
)
|
|
else:
|
|
return f"Error: {tool_name} not implemented"
|
|
except Exception as exc:
|
|
_logger.exception("runner OSEnvironment dispatch failed for %s", tool_name)
|
|
return json.dumps({"error": str(exc)})
|
|
finally:
|
|
if os_env is not None:
|
|
os_env.close()
|
|
|
|
return json.dumps(result)
|
|
|
|
|
|
# ── REST-backed tools (Phase 1) ──────────────────────────
|
|
|
|
|
|
async def _execute_rest_tool(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
server_client: httpx.AsyncClient | None,
|
|
agent_id: str | None = None,
|
|
conversation_id: str | None = None,
|
|
) -> str:
|
|
"""Execute a REST-backed tool by calling server APIs.
|
|
|
|
Uses the ``/v1/sessions`` API: creates a child session,
|
|
posts a message event to kick off the turn, and returns the
|
|
session_id as the handle. Cancellation sends an interrupt
|
|
event to the child session.
|
|
|
|
:param tool_name: The tool to execute, e.g.
|
|
``"sys_call_async"``.
|
|
:param args: Tool arguments from the LLM.
|
|
:param server_client: httpx client pointed at the Omnigent server.
|
|
:param agent_id: Durable agent id, e.g. ``"ag_abc123"``.
|
|
Required from the session context.
|
|
:param conversation_id: Parent conversation id, e.g.
|
|
``"conv_abc123"``. Used to look up the runner binding
|
|
on the parent session so the child session can be bound
|
|
to the same runner.
|
|
:returns: JSON result string for the LLM.
|
|
"""
|
|
if server_client is None:
|
|
return f"Error: {tool_name} requires server access"
|
|
|
|
if tool_name == SysCallAsyncTool.name():
|
|
# agent_id must be provided by the session context.
|
|
resolved_agent_id = agent_id
|
|
if resolved_agent_id is None:
|
|
return "Error: sys_call_async requires agent_id from the session context"
|
|
|
|
input_items = args.get("input") or [{"role": "user", "content": args.get("prompt", "")}]
|
|
try:
|
|
# Create a child session bound to the same agent.
|
|
create_resp = await server_client.post(
|
|
"/v1/sessions",
|
|
json={"agent_id": resolved_agent_id},
|
|
timeout=30.0,
|
|
)
|
|
if create_resp.status_code not in (200, 201):
|
|
return (
|
|
f"Error: sys_call_async session create returned "
|
|
f"{create_resp.status_code}: {create_resp.text[:200]}"
|
|
)
|
|
session_id = create_resp.json()["id"]
|
|
|
|
# Bind to the parent's runner so event forwarding works.
|
|
if conversation_id is not None:
|
|
try:
|
|
parent_resp = await server_client.get(
|
|
f"/v1/sessions/{conversation_id}",
|
|
timeout=10.0,
|
|
)
|
|
if parent_resp.status_code == 200:
|
|
parent_runner = parent_resp.json().get("runner_id")
|
|
if parent_runner:
|
|
await server_client.patch(
|
|
f"/v1/sessions/{session_id}",
|
|
json={"runner_id": parent_runner},
|
|
timeout=10.0,
|
|
)
|
|
except httpx.HTTPError:
|
|
_logger.debug(
|
|
"sys_call_async: failed to bind runner for child session %s",
|
|
session_id,
|
|
exc_info=True,
|
|
)
|
|
|
|
# Post the message event to start the turn.
|
|
content = input_items
|
|
if isinstance(content, str):
|
|
content = [{"type": "input_text", "text": content}]
|
|
event_body: dict[str, Any] = {
|
|
"type": "message",
|
|
"data": {
|
|
"role": "user",
|
|
"content": content,
|
|
},
|
|
}
|
|
event_resp = await server_client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json=event_body,
|
|
timeout=30.0,
|
|
)
|
|
if event_resp.status_code >= 400:
|
|
return (
|
|
f"Error: sys_call_async event post returned "
|
|
f"{event_resp.status_code}: {event_resp.text[:200]}"
|
|
)
|
|
# Return session_id as the handle (replaces task_id).
|
|
return json.dumps({"task_id": session_id, "status": "running"})
|
|
except Exception as exc: # noqa: BLE001
|
|
return f"Error: sys_call_async failed: {exc}"
|
|
|
|
if tool_name == SysCancelAsyncTool.name():
|
|
# task_id from sys_call_async is now a session_id.
|
|
task_id = args.get("task_id", "")
|
|
try:
|
|
resp = await server_client.post(
|
|
f"/v1/sessions/{task_id}/events",
|
|
json={"type": "interrupt", "data": {}},
|
|
timeout=30.0,
|
|
)
|
|
if resp.status_code in (200, 201, 202):
|
|
return f"Cancelled task {task_id}"
|
|
return f"Error: sys_cancel_async returned {resp.status_code}"
|
|
except Exception as exc: # noqa: BLE001
|
|
return f"Error: sys_cancel_async failed: {exc}"
|
|
|
|
return f"Error: {tool_name} not implemented in REST dispatch"
|
|
|
|
|
|
# ── File tools (Phase 1) ──────────────────────────────────
|
|
|
|
|
|
async def _execute_file_tool(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
server_client: httpx.AsyncClient | None,
|
|
*,
|
|
conversation_id: str | None,
|
|
agent_spec: Any | None = None,
|
|
runner_workspace: Path | None = None,
|
|
) -> str:
|
|
"""
|
|
Execute a file tool by calling session-scoped server file APIs.
|
|
|
|
:param tool_name: File tool name, e.g. ``"upload_file"``.
|
|
:param args: Parsed tool arguments.
|
|
:param server_client: HTTP client for the Omnigent server.
|
|
:param conversation_id: Owning session/conversation id,
|
|
e.g. ``"conv_abc123"``.
|
|
:param agent_spec: Agent spec resolved for the current turn, used
|
|
(with ``runner_workspace``) to derive the workspace root that
|
|
an ``upload_file`` path is resolved against. ``None`` falls back
|
|
to the per-conversation default workspace.
|
|
:param runner_workspace: Authoritative runtime cwd for the runner,
|
|
sourced from ``OMNIGENT_RUNNER_WORKSPACE``. Combined with
|
|
``agent_spec`` to compute the workspace containment boundary
|
|
for ``upload_file``.
|
|
:returns: Tool result string.
|
|
"""
|
|
if server_client is None:
|
|
return f"Error: {tool_name} requires server access"
|
|
if conversation_id is None:
|
|
return f"Error: {tool_name} requires a session id"
|
|
files_path = f"/v1/sessions/{conversation_id}/resources/files"
|
|
|
|
if tool_name == UploadFileTool.name():
|
|
path = args.get("path")
|
|
if not path:
|
|
return "Error: sys_upload_file failed: empty path"
|
|
# Resolve the agent-supplied path against the session workspace
|
|
# (the same cwd the sys_os_* tools operate in) and reject any
|
|
# path that escapes it. The read happens in the un-sandboxed
|
|
# runner process, so without this containment an agent could
|
|
# exfiltrate arbitrary host files. Mirrors the
|
|
# builtin UploadFileTool's safe_resolve / sys_agent_download
|
|
# containment checks.
|
|
workspace = Path(
|
|
_effective_runner_os_env_spec(agent_spec, conversation_id, runner_workspace).cwd
|
|
)
|
|
try:
|
|
resolved = safe_resolve(path, workspace)
|
|
except ValueError as exc:
|
|
return f"Error: sys_upload_file failed: {exc}"
|
|
filename = resolved.name
|
|
try:
|
|
with open(resolved, "rb") as f:
|
|
content = f.read()
|
|
resp = await server_client.post(
|
|
files_path,
|
|
files={"file": (filename, content)},
|
|
timeout=60.0,
|
|
)
|
|
if resp.status_code in (200, 201):
|
|
data = resp.json()
|
|
return json.dumps({"file_id": data.get("id"), "filename": filename})
|
|
return f"Error: upload returned {resp.status_code}"
|
|
except Exception as exc: # noqa: BLE001
|
|
return f"Error: sys_upload_file failed: {exc}"
|
|
|
|
if tool_name == DownloadFileTool.name():
|
|
file_id = args.get("file_id", "")
|
|
try:
|
|
resp = await server_client.get(
|
|
f"{files_path}/{file_id}/content",
|
|
timeout=30.0,
|
|
)
|
|
if resp.status_code == 200:
|
|
return resp.text
|
|
return f"Error: download returned {resp.status_code}"
|
|
except Exception as exc: # noqa: BLE001
|
|
return f"Error: {DownloadFileTool.name()} failed: {exc}"
|
|
|
|
if tool_name == "list_files":
|
|
try:
|
|
resp = await server_client.get(files_path, timeout=30.0)
|
|
if resp.status_code == 200:
|
|
return json.dumps(resp.json())
|
|
return f"Error: list_files returned {resp.status_code}"
|
|
except Exception as exc: # noqa: BLE001
|
|
return f"Error: list_files failed: {exc}"
|
|
|
|
return f"Error: {tool_name} not implemented in file dispatch"
|
|
|
|
|
|
# ── Terminal tools (Phase 2) ──────────────────────────────
|
|
|
|
|
|
async def _execute_terminal_tool(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
*,
|
|
terminal_registry: Any | None,
|
|
resource_registry: Any | None = None,
|
|
agent_spec: Any | None,
|
|
conversation_id: str | None,
|
|
task_id: str | None,
|
|
agent_id: str | None,
|
|
runner_workspace: Path | None = None,
|
|
session_inbox: asyncio.Queue[dict[str, Any]] | None = None,
|
|
publish_event: Callable[[str, dict[str, Any]], None] | None = None,
|
|
) -> str:
|
|
"""Execute a terminal tool using the runner's TerminalRegistry.
|
|
|
|
:param runner_workspace: Optional CLI launch workspace passed
|
|
into ``ToolContext.workspace`` for terminal cwd resolution.
|
|
:param session_inbox: Per-session queue drained by
|
|
``sys_read_inbox``. Accepted at the dispatcher boundary but
|
|
no longer threaded into the launch tool — kept for callers
|
|
that still pass it.
|
|
:param publish_event: Per-session SSE emitter (the runner's
|
|
``_publish_event``). When set, a fresh ``sys_terminal_launch``
|
|
emits ``session.resource.created`` and a successful
|
|
``sys_terminal_close`` emits ``session.resource.deleted`` so
|
|
the web rail updates mid-turn instead of waiting for the
|
|
response-end terminals-cache invalidation. ``None`` for
|
|
in-process callers / tests that don't relay.
|
|
:param resource_registry: Optional session-resource registry used to
|
|
observe fresh launches as auxiliary terminal resources.
|
|
"""
|
|
import asyncio
|
|
|
|
if terminal_registry is None:
|
|
return "Error: terminal_registry not available in runner"
|
|
if agent_spec is None:
|
|
return "Error: agent_spec not available for terminal dispatch"
|
|
if conversation_id is None:
|
|
return "Error: conversation_id required for terminal tools"
|
|
|
|
from omnigent.tools.base import ToolContext
|
|
|
|
ctx = ToolContext(
|
|
task_id=task_id or "unknown",
|
|
agent_id=agent_id or "unknown",
|
|
workspace=runner_workspace,
|
|
conversation_id=conversation_id,
|
|
)
|
|
|
|
del session_inbox
|
|
if tool_name == SysTerminalLaunchTool.name():
|
|
tool_instance: Any = SysTerminalLaunchTool(
|
|
spec=agent_spec,
|
|
registry=terminal_registry,
|
|
)
|
|
elif tool_name == SysTerminalSendTool.name():
|
|
tool_instance = SysTerminalSendTool(registry=terminal_registry)
|
|
elif tool_name == SysTerminalReadTool.name():
|
|
tool_instance = SysTerminalReadTool(registry=terminal_registry)
|
|
elif tool_name == SysTerminalListTool.name():
|
|
tool_instance = SysTerminalListTool(registry=terminal_registry)
|
|
elif tool_name == SysTerminalCloseTool.name():
|
|
tool_instance = SysTerminalCloseTool(registry=terminal_registry)
|
|
else:
|
|
return f"Error: unknown terminal tool {tool_name}"
|
|
|
|
arguments_str = json.dumps(args)
|
|
|
|
# Terminal tools use blocking tmux APIs; bridge via to_thread.
|
|
try:
|
|
output = await asyncio.to_thread(tool_instance.invoke, arguments_str, ctx)
|
|
except Exception as exc: # noqa: BLE001
|
|
return f"Error: {tool_name} failed: {type(exc).__name__}: {exc}"
|
|
|
|
# Surface the resource lifecycle on the live SSE stream. The
|
|
# tool ran in the runner process, where ``session_stream`` (the
|
|
# AP-server pub-sub the web UI subscribes to) has no subscribers;
|
|
# ``publish_event`` is the runner's own per-session queue, which
|
|
# the Omnigent server's relay republishes onto ``session_stream``.
|
|
if publish_event is not None and tool_name in (
|
|
SysTerminalLaunchTool.name(),
|
|
SysTerminalCloseTool.name(),
|
|
):
|
|
await _emit_terminal_resource_event(
|
|
tool_name=tool_name,
|
|
output=output,
|
|
args=args,
|
|
conversation_id=conversation_id,
|
|
terminal_registry=terminal_registry,
|
|
resource_registry=resource_registry,
|
|
publish_event=publish_event,
|
|
)
|
|
return output
|
|
|
|
|
|
async def _emit_terminal_resource_event(
|
|
*,
|
|
tool_name: str,
|
|
output: str,
|
|
args: dict[str, Any],
|
|
conversation_id: str,
|
|
terminal_registry: Any,
|
|
resource_registry: Any | None,
|
|
publish_event: Callable[[str, dict[str, Any]], None],
|
|
) -> None:
|
|
"""Emit a ``session.resource.{created,deleted}`` event for a terminal tool.
|
|
|
|
Parses the terminal tool's JSON envelope and pushes a matching
|
|
SSE event onto ``publish_event`` so live subscribers (the web
|
|
rail) see tool-launched / tool-closed terminals immediately. The
|
|
event shapes match the REST resource path
|
|
(:func:`omnigent.server.routes.sessions._publish_and_persist_resource_event`)
|
|
so the AP-server relay and the web UI handle both surfaces
|
|
identically.
|
|
|
|
Best-effort: a malformed / error envelope, an unexpected status,
|
|
or a registry miss is a silent no-op — the snapshot endpoint
|
|
(``GET /resources/terminals``) plus the response-end cache
|
|
invalidation remain the source of truth for reconnecting clients.
|
|
|
|
:param tool_name: The terminal tool name, e.g.
|
|
``"sys_terminal_launch"`` or ``"sys_terminal_close"``.
|
|
:param output: The tool's JSON-encoded result envelope, e.g.
|
|
``{"terminal": "bash", "session": "s1", "status": "launched"}``.
|
|
:param args: Parsed launch / close arguments — fallback source
|
|
for ``terminal`` / ``session`` if the envelope omits them.
|
|
:param conversation_id: Owning conversation id, e.g.
|
|
``"conv_abc123"``.
|
|
:param terminal_registry: The runner's ``TerminalRegistry``,
|
|
used to look up the live instance for a fresh launch.
|
|
:param resource_registry: Optional session-resource registry used to
|
|
observe fresh launches as auxiliary terminal resources.
|
|
:param publish_event: The runner's per-session SSE emitter.
|
|
"""
|
|
try:
|
|
envelope = json.loads(output)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return
|
|
if not isinstance(envelope, dict):
|
|
return
|
|
terminal_name = envelope.get("terminal") or args.get("terminal")
|
|
session_key = envelope.get("session") or args.get("session")
|
|
if not isinstance(terminal_name, str) or not isinstance(session_key, str):
|
|
return
|
|
|
|
status = envelope.get("status")
|
|
if tool_name == SysTerminalLaunchTool.name() and status == "launched":
|
|
await _publish_terminal_created_event(
|
|
conversation_id=conversation_id,
|
|
terminal_name=terminal_name,
|
|
session_key=session_key,
|
|
terminal_registry=terminal_registry,
|
|
resource_registry=resource_registry,
|
|
publish_event=publish_event,
|
|
)
|
|
elif tool_name == SysTerminalCloseTool.name() and status == "closed":
|
|
_publish_terminal_deleted_event(
|
|
conversation_id=conversation_id,
|
|
terminal_name=terminal_name,
|
|
session_key=session_key,
|
|
publish_event=publish_event,
|
|
)
|
|
|
|
|
|
async def _publish_terminal_created_event(
|
|
*,
|
|
conversation_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
terminal_registry: Any,
|
|
resource_registry: Any | None,
|
|
publish_event: Callable[[str, dict[str, Any]], None],
|
|
) -> None:
|
|
"""Build and publish ``session.resource.created`` for a fresh launch.
|
|
|
|
Looks up the live :class:`TerminalInstance` from the registry and
|
|
projects it through :func:`terminal_resource_view` so the wire
|
|
shape exactly matches the REST resource path. A registry miss
|
|
(the instance vanished between launch and lookup) is a silent
|
|
no-op.
|
|
|
|
:param conversation_id: Owning conversation id, e.g.
|
|
``"conv_abc123"``.
|
|
:param terminal_name: Terminal spec name, e.g. ``"bash"``.
|
|
:param session_key: Per-launch session key, e.g. ``"s1"``.
|
|
:param terminal_registry: The runner's ``TerminalRegistry``.
|
|
:param resource_registry: Optional session-resource registry used to
|
|
observe the launched terminal as auxiliary.
|
|
:param publish_event: The runner's per-session SSE emitter.
|
|
"""
|
|
from omnigent.entities.session_resources import session_resource_view_to_dict
|
|
|
|
instance = terminal_registry.get(conversation_id, terminal_name, session_key)
|
|
if instance is None:
|
|
return
|
|
if resource_registry is not None:
|
|
try:
|
|
view = await resource_registry.observe_auxiliary_terminal(
|
|
conversation_id,
|
|
terminal_name,
|
|
session_key,
|
|
instance,
|
|
)
|
|
except Exception:
|
|
_logger.exception(
|
|
"Failed to observe tool-launched terminal: session=%s terminal=%s:%s",
|
|
conversation_id,
|
|
terminal_name,
|
|
session_key,
|
|
)
|
|
return
|
|
resource = session_resource_view_to_dict(view)
|
|
else:
|
|
from omnigent.entities.session_resources import terminal_resource_view
|
|
from omnigent.terminals.registry import TerminalListEntry
|
|
|
|
entry = TerminalListEntry(
|
|
terminal_name=terminal_name,
|
|
session_key=session_key,
|
|
instance=instance,
|
|
)
|
|
resource = session_resource_view_to_dict(terminal_resource_view(conversation_id, entry))
|
|
publish_event(
|
|
conversation_id,
|
|
{"type": "session.resource.created", "resource": resource},
|
|
)
|
|
|
|
# Legacy fallback for callers that do not have a SessionResourceRegistry:
|
|
# start the runner-side pane-activity watcher here so the web "active"
|
|
# badge still works. Normal runner dispatch uses observe_auxiliary_terminal
|
|
# above, which owns the watcher and terminal-exit lifecycle semantics.
|
|
if resource_registry is not None:
|
|
return
|
|
resource_id = resource["id"]
|
|
if isinstance(resource_id, str) and resource_id:
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def _on_activity() -> None:
|
|
loop.call_soon_threadsafe(
|
|
publish_event,
|
|
conversation_id,
|
|
{
|
|
"type": "session.terminal.activity",
|
|
"session_id": conversation_id,
|
|
"terminal_id": resource_id,
|
|
},
|
|
)
|
|
|
|
instance.start_idle_watcher_thread(on_activity=_on_activity)
|
|
|
|
|
|
def _publish_terminal_deleted_event(
|
|
*,
|
|
conversation_id: str,
|
|
terminal_name: str,
|
|
session_key: str,
|
|
publish_event: Callable[[str, dict[str, Any]], None],
|
|
) -> None:
|
|
"""Build and publish ``session.resource.deleted`` for a closed terminal.
|
|
|
|
The delete event carries only the deterministic resource id (no
|
|
instance lookup needed), matching the shape the REST resource
|
|
path emits via ``_publish_and_persist_resource_event``.
|
|
|
|
:param conversation_id: Owning conversation id, e.g.
|
|
``"conv_abc123"``.
|
|
:param terminal_name: Terminal spec name, e.g. ``"bash"``.
|
|
:param session_key: Per-launch session key, e.g. ``"s1"``.
|
|
:param publish_event: The runner's per-session SSE emitter.
|
|
"""
|
|
from omnigent.entities.session_resources import terminal_resource_id
|
|
|
|
publish_event(
|
|
conversation_id,
|
|
{
|
|
"type": "session.resource.deleted",
|
|
"resource_id": terminal_resource_id(terminal_name, session_key),
|
|
"resource_type": "terminal",
|
|
"session_id": conversation_id,
|
|
},
|
|
)
|
|
|
|
|
|
# ── Async inbox tools (Step 7) ───────────────────────────
|
|
|
|
|
|
async def _execute_async_inbox_tool(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
*,
|
|
session_inbox: asyncio.Queue[dict[str, Any]] | None,
|
|
session_async_tasks: dict[str, tuple[asyncio.Task[str], asyncio.Event]] | None,
|
|
server_client: httpx.AsyncClient | None,
|
|
terminal_registry: Any | None,
|
|
resource_registry: Any | None,
|
|
agent_spec: Any | None,
|
|
conversation_id: str | None,
|
|
task_id: str | None,
|
|
agent_id: str | None,
|
|
agent_name: str | None,
|
|
runner_workspace: Path | None,
|
|
mcp_manager: Any | None,
|
|
filesystem_registry: FilesystemRegistry | None = None,
|
|
harness_client: httpx.AsyncClient | None = None,
|
|
) -> str:
|
|
"""
|
|
Runner-local dispatch for async inbox tools.
|
|
|
|
Backed by per-session ``asyncio.Queue`` (SESSION_REARCHITECTURE
|
|
Step 7).
|
|
|
|
:param tool_name: Tool name, e.g. ``"sys_read_inbox"``.
|
|
:param args: Parsed JSON arguments from the LLM.
|
|
:param session_inbox: Per-session completion queue.
|
|
:param session_async_tasks: Per-session handle_id →
|
|
``(Task, cancel_event)`` tuple map.
|
|
:param filesystem_registry: Optional registry for tracking file
|
|
changes made by tools spawned via ``sys_call_async``.
|
|
Forwarded to ``_spawn_async_tool`` so that async OS-env tool
|
|
calls record paths for the ``GET …/changes`` endpoint.
|
|
:param resource_registry: Optional session-resource registry used by
|
|
async terminal-tool launches.
|
|
:param harness_client: Unused; kept for caller compatibility.
|
|
:returns: Tool output string.
|
|
"""
|
|
del harness_client
|
|
if tool_name == SysReadInboxTool.name():
|
|
return await _drain_inbox(
|
|
session_inbox,
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
)
|
|
|
|
if tool_name == SysCallAsyncTool.name():
|
|
return _spawn_async_tool(
|
|
args,
|
|
session_inbox=session_inbox,
|
|
session_async_tasks=session_async_tasks,
|
|
server_client=server_client,
|
|
terminal_registry=terminal_registry,
|
|
resource_registry=resource_registry,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
task_id=task_id,
|
|
agent_id=agent_id,
|
|
agent_name=agent_name,
|
|
runner_workspace=runner_workspace,
|
|
mcp_manager=mcp_manager,
|
|
filesystem_registry=filesystem_registry,
|
|
)
|
|
|
|
if tool_name == SysCancelAsyncTool.name():
|
|
return _cancel_async_tool(
|
|
args,
|
|
session_async_tasks=session_async_tasks,
|
|
)
|
|
|
|
return f"Error: {tool_name} not implemented in async inbox dispatch"
|
|
|
|
|
|
def _format_terminal_idle_item(
|
|
payload: dict[str, Any],
|
|
) -> str:
|
|
"""
|
|
Render a terminal-idle inbox item for ``sys_read_inbox``.
|
|
|
|
:param payload: Canonical terminal-idle inbox payload.
|
|
:returns: Human-readable inbox line.
|
|
:raises ValueError: If the payload is missing required fields or
|
|
top-level and content identities disagree.
|
|
"""
|
|
payload_type = payload.get("type")
|
|
source = payload.get("source")
|
|
session = payload.get("session")
|
|
content = payload.get("content")
|
|
if payload_type != "terminal_idle":
|
|
raise ValueError("terminal-idle inbox payload must have type 'terminal_idle'")
|
|
if not isinstance(source, str) or not source:
|
|
raise ValueError("terminal-idle inbox payload requires non-empty string source")
|
|
if not isinstance(session, str) or not session:
|
|
raise ValueError("terminal-idle inbox payload requires non-empty string session")
|
|
if not isinstance(content, dict):
|
|
raise ValueError("terminal-idle inbox payload requires object content")
|
|
if content.get("status") != "idle":
|
|
raise ValueError("terminal-idle inbox payload content.status must be 'idle'")
|
|
if content.get("terminal") != source or content.get("session") != session:
|
|
raise ValueError(
|
|
"terminal-idle inbox payload content terminal/session must match source/session"
|
|
)
|
|
return f"[System: inbox item terminal_idle — terminal {source}:{session} is idle]"
|
|
|
|
|
|
def _truncate_inbox_output(output: object) -> str:
|
|
"""
|
|
Convert an inbox payload output to bounded text.
|
|
|
|
:param output: Raw payload output, e.g. ``"done"`` or an error
|
|
object converted by the caller.
|
|
:returns: Text capped for LLM delivery.
|
|
"""
|
|
text = output if isinstance(output, str) else str(output)
|
|
if len(text) <= _INBOX_OUTPUT_MAX_CHARS:
|
|
return text
|
|
return (
|
|
text[:_INBOX_OUTPUT_MAX_CHARS].rstrip()
|
|
+ f"\n...[truncated {len(text) - _INBOX_OUTPUT_MAX_CHARS} chars]"
|
|
)
|
|
|
|
|
|
def _format_async_task_item(payload: dict[str, Any]) -> str:
|
|
"""
|
|
Render a completed/failed/cancelled async-task inbox payload.
|
|
|
|
:param payload: Async-task payload with ``handle_id``,
|
|
``tool_name``, ``status``, ``output`` keys.
|
|
:returns: Human-readable inbox line.
|
|
"""
|
|
handle_id = payload.get("handle_id", "unknown")
|
|
tool = payload.get("tool_name", "unknown")
|
|
status = payload.get("status", "unknown")
|
|
output = _truncate_inbox_output(payload.get("output", ""))
|
|
# An empty completion (e.g. a native child that idled with no assistant
|
|
# text — the runner delivers "" rather than fabricating from stale
|
|
# history) must read as "produced no output", not a dangling
|
|
# "…returned: " that the parent LLM mistakes for a truncated handoff.
|
|
has_output = bool(output and output.strip())
|
|
if payload.get("type") == "sub_agent":
|
|
agent = payload.get("agent") or payload.get("tool_name", "sub_agent")
|
|
title = payload.get("title", "")
|
|
target = f"{agent}:{title}" if title else str(agent)
|
|
if status == "completed":
|
|
if not has_output:
|
|
return (
|
|
f"[System: sub-agent task {handle_id} completed — {target} produced no output]"
|
|
)
|
|
return f"[System: sub-agent task {handle_id} completed — {target} returned: {output}]"
|
|
if status == "failed":
|
|
return f"[System: sub-agent task {handle_id} failed — {target} error: {output}]"
|
|
if status == "cancelled":
|
|
return f"[System: sub-agent task {handle_id} cancelled — {target}]"
|
|
return f"[System: sub-agent task {handle_id} {status} — {target}: {output}]"
|
|
if status == "completed":
|
|
if not has_output:
|
|
return f"[System: task {handle_id} completed — {tool} produced no output]"
|
|
return f"[System: task {handle_id} completed — {tool} returned: {output}]"
|
|
if status == "failed":
|
|
return f"[System: task {handle_id} failed — {tool} error: {output}]"
|
|
if status == "cancelled":
|
|
return f"[System: task {handle_id} cancelled]"
|
|
return f"[System: task {handle_id} {status} — {tool}: {output}]"
|
|
|
|
|
|
def _subagent_child_id(payload: dict[str, Any]) -> str | None:
|
|
"""
|
|
Extract the child session id from a sub-agent inbox payload.
|
|
|
|
:param payload: Inbox payload, e.g. a ``type="sub_agent"`` item.
|
|
:returns: Child session id, or ``None`` when absent.
|
|
"""
|
|
for key in ("conversation_id", "task_id", "handle_id"):
|
|
value = payload.get(key)
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _subagent_policy_failure_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Return a fail-closed copy of a sub-agent inbox payload.
|
|
|
|
:param payload: Original inbox payload.
|
|
:returns: Payload with output replaced by a policy-failure
|
|
sentinel.
|
|
"""
|
|
return {**payload, "output": _SUBAGENT_POLICY_FAILURE_OUTPUT}
|
|
|
|
|
|
def _subagent_tool_result_policy_request(
|
|
payload: dict[str, Any],
|
|
output: str,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Build the Omnigent policy-evaluation request for delayed child output.
|
|
|
|
:param payload: Completed sub-agent inbox payload.
|
|
:param output: Raw child output text.
|
|
:returns: JSON body for ``POST /policies/evaluate``.
|
|
"""
|
|
return {
|
|
"event": {
|
|
"type": "PHASE_TOOL_RESULT",
|
|
"data": {"result": output},
|
|
"request_data": {
|
|
"name": "sys_session_send",
|
|
"tool": "sys_session_send",
|
|
"args": {
|
|
"agent": payload.get("agent") or payload.get("tool_name"),
|
|
"title": payload.get("title"),
|
|
"conversation_id": _subagent_child_id(payload),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
|
|
async def _post_subagent_policy_verdict(
|
|
*,
|
|
server_client: httpx.AsyncClient,
|
|
conversation_id: str,
|
|
payload: dict[str, Any],
|
|
output: str,
|
|
) -> dict[str, Any] | None:
|
|
"""
|
|
POST delayed sub-agent output to Omnigent policy evaluation.
|
|
|
|
:param server_client: HTTP client pointed at Omnigent server.
|
|
:param conversation_id: Parent session id, e.g.
|
|
``"conv_parent123"``.
|
|
:param payload: Completed sub-agent inbox payload.
|
|
:param output: Raw child output text.
|
|
:returns: Parsed policy verdict, or ``None`` on failure.
|
|
"""
|
|
try:
|
|
resp = await server_client.post(
|
|
f"/v1/sessions/{conversation_id}/policies/evaluate",
|
|
json=_subagent_tool_result_policy_request(payload, output),
|
|
timeout=30.0,
|
|
)
|
|
except httpx.HTTPError:
|
|
_logger.exception(
|
|
"Sub-agent inbox TOOL_RESULT policy evaluation failed for parent=%s child=%s",
|
|
conversation_id,
|
|
_subagent_child_id(payload),
|
|
)
|
|
return None
|
|
if resp.status_code >= 400:
|
|
_logger.warning(
|
|
"Sub-agent inbox TOOL_RESULT policy evaluation rejected for "
|
|
"parent=%s status=%s body=%s",
|
|
conversation_id,
|
|
resp.status_code,
|
|
resp.text,
|
|
)
|
|
return None
|
|
try:
|
|
return resp.json()
|
|
except (json.JSONDecodeError, ValueError):
|
|
_logger.warning(
|
|
"Sub-agent inbox TOOL_RESULT policy evaluation returned non-JSON for parent=%s",
|
|
conversation_id,
|
|
)
|
|
return None
|
|
|
|
|
|
def _apply_subagent_policy_verdict(
|
|
payload: dict[str, Any],
|
|
verdict: dict[str, Any],
|
|
) -> _SubagentInboxEvaluation:
|
|
"""
|
|
Apply an Omnigent policy verdict to a sub-agent inbox payload.
|
|
|
|
:param payload: Original completed sub-agent payload.
|
|
:param verdict: Parsed Omnigent policy response, e.g.
|
|
``{"result": "POLICY_ACTION_ALLOW"}``.
|
|
:returns: Evaluation result for ``sys_read_inbox`` formatting.
|
|
"""
|
|
result = verdict.get("result")
|
|
if result in {"POLICY_ACTION_DENY", "POLICY_ACTION_ASK"}:
|
|
reason = verdict.get("reason") or "no reason given"
|
|
return _SubagentInboxEvaluation(
|
|
{**payload, "output": f"[Result suppressed by policy: {reason}]"}
|
|
)
|
|
if result in {"POLICY_ACTION_ALLOW", "POLICY_ACTION_UNSPECIFIED"}:
|
|
transformed = verdict.get("data")
|
|
if transformed is None:
|
|
return _SubagentInboxEvaluation(payload)
|
|
if not isinstance(transformed, str):
|
|
_logger.warning(
|
|
"Sub-agent inbox TOOL_RESULT policy data must be str; got %s",
|
|
type(transformed).__name__,
|
|
)
|
|
return _SubagentInboxEvaluation(
|
|
{
|
|
**payload,
|
|
"output": transformed if isinstance(transformed, str) else str(transformed),
|
|
}
|
|
)
|
|
_logger.warning(
|
|
"Sub-agent inbox TOOL_RESULT policy evaluation returned unknown result=%r",
|
|
result,
|
|
)
|
|
return _SubagentInboxEvaluation(
|
|
_subagent_policy_failure_payload(payload),
|
|
retry_original=True,
|
|
)
|
|
|
|
|
|
async def _evaluate_subagent_inbox_output(
|
|
payload: dict[str, Any],
|
|
*,
|
|
server_client: httpx.AsyncClient | None,
|
|
conversation_id: str | None,
|
|
) -> _SubagentInboxEvaluation:
|
|
"""
|
|
Apply parent TOOL_RESULT policy to a delayed sub-agent payload.
|
|
|
|
:param payload: Inbox payload for a completed sub-agent task.
|
|
:param server_client: HTTP client pointed at Omnigent server.
|
|
:param conversation_id: Parent session id, e.g.
|
|
``"conv_parent123"``.
|
|
:returns: Evaluation result carrying the safe payload plus retry
|
|
metadata for transient evaluation failures.
|
|
"""
|
|
if (
|
|
payload.get("type") != "sub_agent"
|
|
or payload.get("status") not in _SUBAGENT_POLICY_STATUSES
|
|
):
|
|
return _SubagentInboxEvaluation(payload)
|
|
output = payload.get("output")
|
|
if not isinstance(output, str) or server_client is None or conversation_id is None:
|
|
return _SubagentInboxEvaluation(
|
|
_subagent_policy_failure_payload(payload),
|
|
retry_original=True,
|
|
)
|
|
verdict = await _post_subagent_policy_verdict(
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
payload=payload,
|
|
output=output,
|
|
)
|
|
if verdict is None:
|
|
return _SubagentInboxEvaluation(
|
|
_subagent_policy_failure_payload(payload),
|
|
retry_original=True,
|
|
)
|
|
return _apply_subagent_policy_verdict(payload, verdict)
|
|
|
|
|
|
def _cleanup_drained_subagent_work(payload: dict[str, Any]) -> None:
|
|
"""
|
|
Remove terminal sub-agent work after its inbox item is drained.
|
|
|
|
:param payload: Drained inbox payload.
|
|
:returns: None.
|
|
"""
|
|
if payload.get("type") != "sub_agent":
|
|
return
|
|
if payload.get("status") not in _SUBAGENT_INBOX_TERMINAL_STATUSES:
|
|
return
|
|
child_id = _subagent_child_id(payload)
|
|
if child_id is None:
|
|
return
|
|
work_id = payload.get("work_id")
|
|
if not isinstance(work_id, str) or not work_id:
|
|
return
|
|
from omnigent.runner import app as _runner_app
|
|
|
|
_runner_app.unregister_subagent_work(
|
|
child_id,
|
|
work_id=work_id,
|
|
remember_drained_delivery=True,
|
|
)
|
|
|
|
|
|
async def _drain_inbox(
|
|
inbox: asyncio.Queue[dict[str, Any]] | None,
|
|
*,
|
|
server_client: httpx.AsyncClient | None = None,
|
|
conversation_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Non-blocking drain of the per-session inbox queue.
|
|
|
|
Returns formatted completion payloads or "Inbox is empty."
|
|
|
|
:param inbox: The session's asyncio.Queue, or ``None`` if
|
|
no queue has been created yet.
|
|
:param server_client: HTTP client pointed at Omnigent server.
|
|
:param conversation_id: Parent session id, e.g.
|
|
``"conv_parent123"``.
|
|
:returns: Formatted string of completed tasks.
|
|
"""
|
|
if inbox is None or inbox.empty():
|
|
return "Inbox is empty — no completed tasks."
|
|
items: list[str] = []
|
|
retry_payloads: list[dict[str, Any]] = []
|
|
while not inbox.empty():
|
|
try:
|
|
payload = inbox.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
if payload.get("type") == "terminal_idle":
|
|
try:
|
|
items.append(_format_terminal_idle_item(payload))
|
|
except ValueError as exc:
|
|
_logger.warning(
|
|
"malformed terminal-idle inbox item ignored: %s",
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
items.append(f"[System: malformed terminal_idle inbox item ignored — {exc}]")
|
|
continue
|
|
evaluation = await _evaluate_subagent_inbox_output(
|
|
payload,
|
|
server_client=server_client,
|
|
conversation_id=conversation_id,
|
|
)
|
|
items.append(_format_async_task_item(evaluation.payload))
|
|
if evaluation.retry_original:
|
|
retry_payloads.append(payload)
|
|
else:
|
|
_cleanup_drained_subagent_work(evaluation.payload)
|
|
for payload in retry_payloads:
|
|
inbox.put_nowait(payload)
|
|
return "\n\n".join(items) if items else "Inbox is empty — no completed tasks."
|
|
|
|
|
|
def _spawn_async_tool(
|
|
args: dict[str, Any],
|
|
*,
|
|
session_inbox: asyncio.Queue[dict[str, Any]] | None,
|
|
session_async_tasks: dict[str, tuple[asyncio.Task[str], asyncio.Event]] | None,
|
|
server_client: httpx.AsyncClient | None,
|
|
terminal_registry: Any | None,
|
|
resource_registry: Any | None,
|
|
agent_spec: Any | None,
|
|
conversation_id: str | None,
|
|
task_id: str | None,
|
|
agent_id: str | None,
|
|
agent_name: str | None,
|
|
runner_workspace: Path | None,
|
|
mcp_manager: Any | None,
|
|
filesystem_registry: FilesystemRegistry | None = None,
|
|
) -> str:
|
|
"""
|
|
Spawn a tool as a background asyncio.Task.
|
|
|
|
Returns a handle immediately. On completion, the result is
|
|
pushed to the session's inbox queue for ``sys_read_inbox``
|
|
to drain.
|
|
|
|
:param args: Must contain ``"tool"`` (target tool name) and
|
|
``"args"`` (JSON string of target tool arguments).
|
|
:param filesystem_registry: Optional registry forwarded to
|
|
``execute_tool`` so that OS-env tools invoked via
|
|
``sys_call_async`` record file changes for the
|
|
``GET …/changes`` endpoint.
|
|
:param resource_registry: Optional session-resource registry used by
|
|
async terminal-tool launches.
|
|
:returns: JSON handle string with ``handle_id``, ``tool_name``,
|
|
``status``.
|
|
"""
|
|
target_tool = args.get("tool")
|
|
target_args = args.get("args", "{}")
|
|
if not target_tool:
|
|
return 'Error: sys_call_async requires "tool" argument'
|
|
if target_tool == SysCallAsyncTool.name():
|
|
return "Error: sys_call_async cannot dispatch itself"
|
|
if session_inbox is None or session_async_tasks is None:
|
|
return "Error: async inbox not initialized for this session"
|
|
|
|
handle_id = f"handle_{uuid.uuid4().hex[:12]}"
|
|
cancel_event = asyncio.Event()
|
|
|
|
async def _bg() -> str:
|
|
"""
|
|
Background task: dispatch the tool and push result to inbox.
|
|
|
|
Uses a cancel_event to bail out immediately when
|
|
sys_cancel_async is called — asyncio.Task.cancel() alone
|
|
can't interrupt asyncio.to_thread (the thread keeps running
|
|
until the subprocess finishes).
|
|
|
|
:returns: The tool output string.
|
|
"""
|
|
try:
|
|
# Race the tool execution against the cancel event.
|
|
exec_coro = execute_tool(
|
|
tool_name=target_tool,
|
|
arguments=target_args,
|
|
server_client=server_client,
|
|
terminal_registry=terminal_registry,
|
|
resource_registry=resource_registry,
|
|
agent_spec=agent_spec,
|
|
conversation_id=conversation_id,
|
|
task_id=task_id,
|
|
agent_id=agent_id,
|
|
agent_name=agent_name,
|
|
runner_workspace=runner_workspace,
|
|
mcp_manager=mcp_manager,
|
|
session_inbox=session_inbox if target_tool in _TERMINAL_TOOLS else None,
|
|
filesystem_registry=filesystem_registry,
|
|
)
|
|
done, pending = await asyncio.wait(
|
|
[
|
|
asyncio.ensure_future(exec_coro),
|
|
asyncio.ensure_future(cancel_event.wait()),
|
|
],
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
if cancel_event.is_set():
|
|
# Drop the losing future (the tool coro). This cancels the
|
|
# task/coroutine but cannot interrupt an underlying
|
|
# asyncio.to_thread, so that thread may run to completion.
|
|
for fut in pending:
|
|
fut.cancel()
|
|
session_inbox.put_nowait(
|
|
{
|
|
"handle_id": handle_id,
|
|
"tool_name": target_tool,
|
|
"status": "cancelled",
|
|
"output": "",
|
|
}
|
|
)
|
|
return ""
|
|
# Drop the losing future (cancel_event.wait()) so it doesn't
|
|
# linger as a pending task for the life of the session.
|
|
for fut in pending:
|
|
fut.cancel()
|
|
result = next(iter(done)).result()
|
|
session_inbox.put_nowait(
|
|
{
|
|
"handle_id": handle_id,
|
|
"tool_name": target_tool,
|
|
"status": "completed",
|
|
"output": result,
|
|
}
|
|
)
|
|
return result
|
|
except asyncio.CancelledError:
|
|
session_inbox.put_nowait(
|
|
{
|
|
"handle_id": handle_id,
|
|
"tool_name": target_tool,
|
|
"status": "cancelled",
|
|
"output": "",
|
|
}
|
|
)
|
|
raise
|
|
except Exception as exc: # noqa: BLE001
|
|
session_inbox.put_nowait(
|
|
{
|
|
"handle_id": handle_id,
|
|
"tool_name": target_tool,
|
|
"status": "failed",
|
|
"output": str(exc),
|
|
}
|
|
)
|
|
return f"Error: {exc}"
|
|
finally:
|
|
session_async_tasks.pop(handle_id, None)
|
|
|
|
bg_task = asyncio.create_task(_bg(), name=f"async-{handle_id}")
|
|
session_async_tasks[handle_id] = (bg_task, cancel_event)
|
|
|
|
return json.dumps(
|
|
{
|
|
"handle_id": handle_id,
|
|
"tool_name": target_tool,
|
|
"status": "in_progress",
|
|
"message": (
|
|
f"[System: {target_tool} dispatched as background "
|
|
f"task {handle_id}. Result will appear in your "
|
|
f"inbox — call sys_read_inbox to check.]"
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
def _cancel_async_tool_result(
|
|
args: dict[str, Any],
|
|
*,
|
|
session_async_tasks: dict[str, tuple[asyncio.Task[str], asyncio.Event]] | None,
|
|
) -> _CancelAsyncToolResult:
|
|
"""
|
|
Cancel an in-flight local async tool by handle id.
|
|
|
|
Signals the cancel_event so the background task's
|
|
``asyncio.wait`` returns immediately — the underlying
|
|
thread may keep running but the task won't block on it.
|
|
|
|
:param args: Must contain ``"handle_id"`` (``"task_id"`` is
|
|
accepted as a legacy alias).
|
|
:returns: Structured local-cancel result. ``try_subagent_cancel``
|
|
is true only when no local async task matched.
|
|
"""
|
|
handle_id = args.get("handle_id") or args.get("task_id")
|
|
if not handle_id:
|
|
return _CancelAsyncToolResult('Error: sys_cancel_async requires "handle_id"')
|
|
if session_async_tasks is None:
|
|
return _CancelAsyncToolResult("Error: async inbox not initialized for this session")
|
|
entry = session_async_tasks.get(handle_id)
|
|
if entry is None:
|
|
return _CancelAsyncToolResult(
|
|
f"Error: no in-flight task with handle_id {handle_id}",
|
|
try_subagent_cancel=True,
|
|
)
|
|
_task, cancel_event = entry
|
|
# Signal the event — _bg's asyncio.wait returns immediately.
|
|
# Don't call task.cancel(): the CancelledError races with
|
|
# the event check and can prevent the inbox push.
|
|
cancel_event.set()
|
|
return _CancelAsyncToolResult(json.dumps({"cancelled": True, "handle_id": handle_id}))
|
|
|
|
|
|
def _cancel_async_tool(
|
|
args: dict[str, Any],
|
|
*,
|
|
session_async_tasks: dict[str, tuple[asyncio.Task[str], asyncio.Event]] | None,
|
|
) -> str:
|
|
"""
|
|
Cancel an in-flight async tool by handle_id.
|
|
|
|
:param args: Must contain ``"handle_id"`` (``"task_id"`` is
|
|
accepted as a legacy alias).
|
|
:param session_async_tasks: Per-session async task map, or
|
|
``None`` when async inbox state is unavailable.
|
|
:returns: Confirmation or error string.
|
|
"""
|
|
return _cancel_async_tool_result(
|
|
args,
|
|
session_async_tasks=session_async_tasks,
|
|
).output
|
|
|
|
|
|
async def _execute_task_lifecycle_tool(
|
|
args: dict[str, Any],
|
|
*,
|
|
session_async_tasks: dict[str, tuple[asyncio.Task[str], asyncio.Event]] | None,
|
|
conversation_id: str | None,
|
|
server_client: httpx.AsyncClient | None,
|
|
) -> str:
|
|
"""
|
|
Runner-local handler for ``sys_cancel_task``.
|
|
|
|
The generic cancel path first tries the in-memory async dispatches
|
|
tracked in ``session_async_tasks``. If no async tool handle matches,
|
|
it falls through to the sub-agent work registry so handles returned
|
|
by ``sys_session_send`` can be cancelled by task id.
|
|
|
|
:param args: Parsed JSON arguments from the LLM.
|
|
:param session_async_tasks: Per-session async task map
|
|
from ``create_runner_app``.
|
|
:param conversation_id: Parent session id, e.g.
|
|
``"conv_parent123"``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: JSON-encoded result string.
|
|
"""
|
|
async_result = _cancel_async_tool_result(
|
|
args,
|
|
session_async_tasks=session_async_tasks,
|
|
)
|
|
if not async_result.try_subagent_cancel:
|
|
return async_result.output
|
|
return await _cancel_subagent_task(
|
|
args,
|
|
conversation_id=conversation_id,
|
|
server_client=server_client,
|
|
)
|
|
|
|
|
|
async def _cancel_subagent_task(
|
|
args: dict[str, Any],
|
|
*,
|
|
conversation_id: str | None,
|
|
server_client: httpx.AsyncClient | None,
|
|
) -> str:
|
|
"""
|
|
Cancel a running sub-agent worker, routing by the child's harness.
|
|
|
|
Only ``claude-native`` has a runner-side hard-stop, so the cancel
|
|
event is chosen per harness — the child runner's ``stop_session``
|
|
handler 204 no-ops for every other harness, so posting it there
|
|
would silently do nothing:
|
|
|
|
* ``claude-native`` — POST ``stop_session``. The child runner
|
|
hard-kills the worker's tmux pane via ``_handle_claude_native_stop``
|
|
and marks the work entry cancelled, delivering a terminal payload to
|
|
the parent inbox and auto-waking it. A bare interrupt (Escape) only
|
|
cancelled the current turn and left the worker process alive; a stop
|
|
frees it.
|
|
* everything else (in-process harnesses, ``codex-native``) — POST
|
|
``interrupt``, the path those harnesses actually honor. For an
|
|
in-process child the runner marks the turn cancelled (via
|
|
``_interrupted_sessions`` → ``_on_proxy_stream_end``) and wakes the
|
|
parent. ``codex-native`` has no runner-side stop yet, so its cancel
|
|
stays best-effort (see message).
|
|
|
|
:param args: Tool arguments containing ``task_id`` or
|
|
``handle_id``, e.g. ``{"task_id": "conv_child456"}``.
|
|
:param conversation_id: Parent session id, e.g.
|
|
``"conv_parent123"``.
|
|
:param server_client: HTTP client pointed at the Omnigent server.
|
|
:returns: JSON cancellation result.
|
|
"""
|
|
from omnigent.runner import app as _runner_app
|
|
|
|
task_id = args.get("task_id") or args.get("handle_id")
|
|
if not task_id:
|
|
return 'Error: sys_cancel_task requires "task_id"'
|
|
if conversation_id is None:
|
|
return "Error: sys_cancel_task requires conversation_id"
|
|
entry = _runner_app.get_subagent_work(str(task_id))
|
|
if entry is None or entry.parent_session_id != conversation_id:
|
|
return f"Error: no in-flight task with task_id {task_id}"
|
|
# A dispatched child sits in ``launching`` until its runtime emits a real
|
|
# busy edge (see ``mark_subagent_work_started``). Cancellation must still
|
|
# route to the child during that window — otherwise cancelling a slow-to-
|
|
# start sub-agent would silently no-op and leave it running. Only terminal
|
|
# states (``completed`` / ``failed`` / ``cancelled``) short-circuit here.
|
|
if entry.status not in ("launching", "running", "waiting"):
|
|
return json.dumps(
|
|
{
|
|
"cancelled": entry.status == "cancelled",
|
|
"task_id": task_id,
|
|
"status": entry.status,
|
|
}
|
|
)
|
|
if server_client is None:
|
|
return "Error: sys_cancel_task requires server access for sub-agent tasks"
|
|
|
|
# claude-native is the only harness with a runner-side hard-stop; every
|
|
# other harness 204 no-ops on stop_session, so route them to interrupt.
|
|
event_type = (
|
|
"stop_session" if entry.wrapper_label == CLAUDE_NATIVE_WRAPPER_VALUE else "interrupt"
|
|
)
|
|
|
|
try:
|
|
resp = await server_client.post(
|
|
f"/v1/sessions/{task_id}/events",
|
|
# Bare control events 422 on servers that require body.data.
|
|
json={"type": event_type, "data": {}},
|
|
timeout=30.0,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
return f"Error: sys_cancel_task {event_type} failed: {type(exc).__name__}: {exc}"
|
|
if resp.status_code >= 400:
|
|
return (
|
|
f"Error: sys_cancel_task {event_type} returned {resp.status_code}: {resp.text[:200]}"
|
|
)
|
|
|
|
updated = _runner_app.get_subagent_work(str(task_id)) or entry
|
|
if updated.status == "cancelled":
|
|
return json.dumps({"cancelled": True, "task_id": task_id, "status": "cancelled"})
|
|
if updated.wrapper_label == CODEX_NATIVE_WRAPPER_VALUE:
|
|
return json.dumps(
|
|
{
|
|
"cancel_requested": True,
|
|
"cancel_confirmed": False,
|
|
"best_effort": True,
|
|
"task_id": task_id,
|
|
"status": updated.status,
|
|
"message": (
|
|
"Interrupt forwarded, but a runner-side hard-stop is not wired "
|
|
"for codex-native workers yet; the child may keep running and no "
|
|
"terminal inbox status is guaranteed."
|
|
),
|
|
}
|
|
)
|
|
return json.dumps(
|
|
{
|
|
"cancel_requested": True,
|
|
"cancel_confirmed": False,
|
|
"task_id": task_id,
|
|
"status": updated.status,
|
|
"message": (
|
|
"Cancel requested; cancellation has not been confirmed yet. "
|
|
"Use sys_read_inbox to observe terminal status."
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
def _inject_orchestrator_skills(
|
|
skills: list[Any],
|
|
agent_spec: Any | None,
|
|
) -> list[Any]:
|
|
"""
|
|
Auto-inject built-in platform skills for every omnigent agent.
|
|
|
|
The ``build-omnigent`` skill teaches the LLM how to author valid
|
|
agent configs. Every agent on the platform should have access to it
|
|
— whether it declares ``tools.agents`` or not — so that any
|
|
``omnigent claude`` user can author and launch new agents. The
|
|
skill is injected from the canonical source at
|
|
``omnigent/onboarding/agent/skills/build-omnigent/`` when not
|
|
already present in the bundled set.
|
|
|
|
:param skills: The agent's current skill list (bundled +
|
|
potentially others); mutated in-place and returned.
|
|
:param agent_spec: The session's AgentSpec (unused after the gate
|
|
removal; retained for call-site compatibility).
|
|
:returns: The (possibly augmented) skill list.
|
|
"""
|
|
del agent_spec # no longer gated; inject unconditionally
|
|
existing_names = {getattr(s, "name", None) for s in skills}
|
|
if "build-omnigent" in existing_names:
|
|
return skills
|
|
from omnigent.spec.parser import _discover_skills
|
|
|
|
onboarding_skills_dir = (
|
|
Path(__file__).resolve().parent.parent / "onboarding" / "agent" / "skills"
|
|
)
|
|
if not onboarding_skills_dir.is_dir():
|
|
return skills
|
|
for spec in _discover_skills(onboarding_skills_dir, skipped=[]):
|
|
if spec.name == "build-omnigent":
|
|
skills.append(spec)
|
|
break
|
|
return skills
|
|
|
|
|
|
def _execute_skill_tool(
|
|
tool_name: str,
|
|
args: dict[str, Any],
|
|
*,
|
|
agent_spec: Any | None,
|
|
runner_workspace: Path | None,
|
|
) -> str:
|
|
"""
|
|
Runner-local handler for ``load_skill`` and ``read_skill_file``.
|
|
|
|
Instantiates the tool with the agent spec's bundled skills
|
|
plus host-scope discovery from the runner workspace, then
|
|
invokes it.
|
|
|
|
:param tool_name: ``"load_skill"`` or ``"read_skill_file"``.
|
|
:param args: Parsed JSON arguments from the LLM.
|
|
:param agent_spec: The session's AgentSpec.
|
|
:param runner_workspace: The runner's workspace path for
|
|
host-scope skill discovery.
|
|
:returns: Tool output string.
|
|
"""
|
|
from omnigent.tools.builtins.load_skill import LoadSkillTool
|
|
from omnigent.tools.builtins.read_skill_file import ReadSkillFileTool
|
|
|
|
bundled_skills = list(getattr(agent_spec, "skills", None) or [])
|
|
skills_filter = getattr(agent_spec, "skills_filter", "all")
|
|
# Auto-inject the build-omnigent skill for agents that opt into the
|
|
# orchestration surface (tools.agents). This teaches the LLM how to
|
|
# author valid agent configs via sys_os_write without requiring the
|
|
# agent's own bundle to ship a skills/ directory.
|
|
bundled_skills = _inject_orchestrator_skills(bundled_skills, agent_spec)
|
|
|
|
if tool_name == "load_skill":
|
|
tool = LoadSkillTool(
|
|
bundled_skills,
|
|
agent_root=runner_workspace,
|
|
skills_filter=skills_filter,
|
|
)
|
|
else:
|
|
tool = ReadSkillFileTool(bundled_skills)
|
|
|
|
arguments_json = json.dumps(args)
|
|
from omnigent.tools.base import ToolContext
|
|
|
|
ctx = ToolContext(task_id="", conversation_id="", agent_id="")
|
|
return tool.invoke(arguments_json, ctx)
|