refactor(server): split sessions.py into 8 domain sub-modules (#3194)

* refactor(server): split sessions.py into domain sub-modules

sessions.py (7799 lines) is split into 8 focused route files under
_sessions/:

  routes_core.py       — CRUD, list, WS updates, fork, switch-agent
  routes_hooks.py      — /hooks/* and /policies/evaluate
  routes_items.py      — /items and /child_sessions
  routes_resources.py  — /resources/* (terminals, files, environments)
  routes_browser.py    — /browser/*
  routes_elicitations.py — /elicitations/*
  routes_events.py     — /events, /stream, DELETE /sessions/{id}
  routes_permissions.py — /permissions/*, /owner
  routes_agent.py      — /agent, /agent/contents, /mcp

Each file exports a register_X_routes(router, ...) function.
create_sessions_router() becomes a thin delegator (~533 lines).

helpers.py gains proxy wrappers for _same_provider_family,
_agent_is_native, _agent_carries_native_fork_history,
_presentation_labels_for_agent, and _reset_runner_resources_after_switch
so existing test monkeypatches on sessions.<name> continue to work.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* refactor(server): move sessions/ route sub-modules out of _sessions/

Convert sessions.py to a sessions/ package and move the 8 routes_*.py
files from _sessions/ into sessions/, so the public package layout is:

  routes/sessions/__init__.py  (facade, formerly sessions.py)
  routes/sessions/routes_core.py
  routes/sessions/routes_hooks.py
  routes/sessions/routes_items.py
  routes/sessions/routes_resources.py
  routes/sessions/routes_browser.py
  routes/sessions/routes_elicitations.py
  routes/sessions/routes_events.py
  routes/sessions/routes_permissions.py
  routes/sessions/routes_agent.py

_sessions/ retains only the private internals (common, helpers,
orchestration) that do not need public names.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(server): use facade indirection for session_stream and get_agent_cache consistently

routes_browser, routes_events, and routes_hooks were still calling
session_stream.publish() and get_agent_cache() via the direct module
binding. Apply the same facade-indirection pattern already used in
routes_core so all call sites are consistent and test monkeypatches on
sessions.session_stream / sessions.get_agent_cache are honored.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(server): fix lint, _to_agent_object closure, and pyproject.toml exemptions

- Move _policy_type, _policy_description, _to_agent_object from inside
  register_permissions_routes closure to module-level in routes_permissions.py
  so routes_agent.py can import them directly. Fixes NameError crash on
  GET /sessions/{id}/agent in server-approvals tests and E2E tests.

- Add missing 'return router' at end of register_permissions_routes (was
  missing after the closure reorganization).

- Import the three helpers explicitly in routes_agent.py.

- Update pyproject.toml per-file-ignores to cover sessions/*.py and
  sessions/__init__.py with the same exemptions the original sessions.py
  had (ARG001, ARG002, BLE001, E501, F401, F403, F405) so pre-commit
  ruff passes.

- Run ruff format on all sessions/ sub-modules.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(server): fix all proxy/monkeypatch misses and restore noqa directives

Route sub-modules were importing _X_impl directly instead of using the
facade proxy, causing monkeypatch(sessions, '_X', ...) to have no effect.
Fix by removing all '_X_impl as _X' imports from routes_*.py — the star-
imports from _sessions.helpers and _sessions.orchestration already bring
in the correct facade-delegating proxies.

Additional fixes:
- Access _SESSION_UPDATES_MAX_WATCHED, _SESSION_UPDATES_RESCAN_INTERVAL_S,
  _SESSION_UPDATES_HEARTBEAT_INTERVAL_S through the facade in routes_core.py
  so monkeypatch(sessions_routes, '_SESSION_UPDATES_*', N) works.
- Use _load_agent_spec_for_session proxy (not impl) in routes_resources.py.
- Access get_caps() through facade in routes_hooks.py evaluate_policy so
  monkeypatch('omnigent.server.routes.sessions.get_caps', ...) fires.
- Restore noqa: BLE001 and F401 directives in _sessions/helpers.py and
  _sessions/orchestration.py that were stripped by the RUF100 auto-fix.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(server): delete old sessions.py, fix remaining facade proxy misses

- Delete omnigent/server/routes/sessions.py (the file was rm'd in a prior
  commit but never staged; CI was still linting it and seeing F403/F405).

- Route _HOST_BOUND_RUNNER_CONNECT_GRACE_S through the facade in
  routes_events.py (3 call sites) so monkeypatch(sessions_module,
  '_HOST_BOUND_RUNNER_CONNECT_GRACE_S', ...) is honored.

- Route _recover_subagent_status_forward_via_parent through facade
  in routes_events.py.

- Route _registered_runner_id through facade in routes_core.py.

- Route _BROWSER_ACTION_AWAIT_S through facade in routes_browser.py.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

* fix(server): route patchable names in routes_hooks.py through facade

All five hook handlers and evaluate_policy use module-level timeout
constants (_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S etc.) and auth
helpers (_get_user_id, get_caps, get_agent_cache) that tests monkeypatch
on the sessions facade module. Access them through _sf (the facade) at
call time so monkeypatch(sessions_route, '_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S', 0.1)
and monkeypatch('omnigent.server.routes.sessions.get_caps', ...) are honored.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
This commit is contained in:
Tomu Hirata
2026-07-24 17:24:25 +09:00
committed by GitHub
parent d8da36d081
commit 59e6b70ea1
14 changed files with 8790 additions and 7816 deletions
+52 -12
View File
@@ -4310,7 +4310,14 @@ async def _proxy_get_session_resources_to_runner(
) from exc
async def _reset_runner_resources_after_switch(session_id: str) -> None:
async def _reset_runner_resources_after_switch(*args: Any, **kwargs: Any) -> None:
"""Call-time proxy so a facade patch of this symbol is honored here."""
from omnigent.server.routes import sessions as _facade
return await _facade._reset_runner_resources_after_switch(*args, **kwargs)
async def _reset_runner_resources_after_switch_impl(session_id: str) -> None:
"""Best-effort reset of the session's runner-side state after a switch.
Run as a fire-and-forget background task by the switch-agent route. Calls
@@ -6018,12 +6025,19 @@ def _agent_provider_family(agent: Agent) -> str | None:
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → unknown family
except Exception: # noqa: BLE001
return None
return provider_family_for_harness(spec.executor.harness_kind)
def _same_provider_family(a: Agent, b: Agent) -> bool:
def _same_provider_family(*args: Any, **kwargs: Any) -> bool:
"""Call-time proxy so a facade patch of this symbol is honored here."""
from omnigent.server.routes import sessions as _facade
return _facade._same_provider_family(*args, **kwargs)
def _same_provider_family_impl(a: Agent, b: Agent) -> bool:
"""Return whether two agents share a (known) provider family.
``False`` when either family is undeterminable, so a fork that can't
@@ -6039,7 +6053,14 @@ def _same_provider_family(a: Agent, b: Agent) -> bool:
return family_a is not None and family_a == _agent_provider_family(b)
def _agent_is_native(agent: Agent) -> bool:
def _agent_is_native(*args: Any, **kwargs: Any) -> bool:
"""Call-time proxy so a facade patch of this symbol is honored here."""
from omnigent.server.routes import sessions as _facade
return _facade._agent_is_native(*args, **kwargs)
def _agent_is_native_impl(agent: Agent) -> bool:
"""Return whether an agent runs a native CLI harness.
Loads the agent's spec to read its ``harness_kind``. Native targets run
@@ -6060,12 +6081,19 @@ def _agent_is_native(agent: Agent) -> bool:
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → treat as non-native
except Exception: # noqa: BLE001
return False
return is_native_harness(spec.executor.harness_kind)
def _agent_carries_native_fork_history(agent: Agent) -> bool:
def _agent_carries_native_fork_history(*args: Any, **kwargs: Any) -> bool:
"""Call-time proxy so a facade patch of this symbol is honored here."""
from omnigent.server.routes import sessions as _facade
return _facade._agent_carries_native_fork_history(*args, **kwargs)
def _agent_carries_native_fork_history_impl(agent: Agent) -> bool:
"""Return whether *agent*'s native harness rebuilds a fork's transcript.
claude-native / codex-native / pi-native each record a resumable native
@@ -6089,7 +6117,7 @@ def _agent_carries_native_fork_history(agent: Agent) -> bool:
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → treat as non-carrying
except Exception: # noqa: BLE001
return False
return canonicalize_harness(spec.executor.harness_kind) in _FORK_HISTORY_NATIVE_HARNESSES
@@ -6115,7 +6143,7 @@ def _agent_carries_cursor_fork_history(agent: Agent) -> bool:
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → treat as non-carrying
except Exception: # noqa: BLE001
return False
return canonicalize_harness(spec.executor.harness_kind) in _CURSOR_FORK_HISTORY_HARNESSES
@@ -6133,12 +6161,19 @@ def _native_coding_agent_for_agent(agent: Agent) -> NativeCodingAgent | None:
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
.spec
)
except Exception: # noqa: BLE001 — unloadable bundle → non-native presentation
except Exception: # noqa: BLE001
return None
return native_coding_agent_for_harness(spec.executor.harness_kind)
def _presentation_labels_for_agent(agent: Agent) -> dict[str, str]:
def _presentation_labels_for_agent(*args: Any, **kwargs: Any) -> dict[str, str]:
"""Call-time proxy so a facade patch of this symbol is honored here."""
from omnigent.server.routes import sessions as _facade
return _facade._presentation_labels_for_agent(*args, **kwargs)
def _presentation_labels_for_agent_impl(agent: Agent) -> dict[str, str]:
"""Return the Web UI presentation labels for an agent's harness.
A native-CLI agent runs **terminal-first** (the inline terminal is the
@@ -7110,7 +7145,7 @@ def _resolve_subagent_spec(
parent_spec = agent_cache.load(
agent.id, agent.bundle_location, expand_env=agent.session_id is None
).spec
except Exception: # noqa: BLE001 -- create-time resolution is best-effort; never block create.
except Exception: # noqa: BLE001
# A bundle that fails to load here must not break session
# creation; the session still works, just without the
# derived labels / launch args.
@@ -7469,7 +7504,7 @@ def _delete_stored_session_bundle_after_failure(
"""
try:
artifact_store.delete(agent_bundle_location)
except Exception: # noqa: BLE001 - cleanup must not mask the original failure.
except Exception: # noqa: BLE001
_logger.warning(
"Failed to delete uploaded session bundle %s after rollback",
agent_bundle_location,
@@ -8312,7 +8347,9 @@ __all__ = [
"_add_model_usage_delta",
"_agent_carries_cursor_fork_history",
"_agent_carries_native_fork_history",
"_agent_carries_native_fork_history_impl",
"_agent_is_native",
"_agent_is_native_impl",
"_agent_provider_family",
"_allow_all_edits_eligible",
"_allow_remember_eligible",
@@ -8414,6 +8451,7 @@ __all__ = [
"_policy_notice_from_ensure_response",
"_poll_request_disconnect",
"_presentation_labels_for_agent",
"_presentation_labels_for_agent_impl",
"_priced_cost_for_display",
"_provision_managed_sandbox",
"_proxy_get_session_resources_to_runner",
@@ -8463,6 +8501,7 @@ __all__ = [
"_require_external_status_forward",
"_require_host_conn_for_worktree",
"_reset_runner_resources_after_switch",
"_reset_runner_resources_after_switch_impl",
"_resolve_harness",
"_resolve_llm_model",
"_resolve_skill_meta_text_via_runner",
@@ -8471,6 +8510,7 @@ __all__ = [
"_routing_decision_item_from_sse",
"_run_compact_locked",
"_same_provider_family",
"_same_provider_family_impl",
"_seed_missing_title",
"_seed_missing_title_from_user_message",
"_session_status_from_cache",
@@ -417,7 +417,7 @@ async def _best_effort_stop(
try:
descendant_ids = await _collect_descendant_conversation_ids(conversation_store, session_id)
status = _session_status_with_child_rollup(session_id, descendant_ids)
except Exception: # noqa: BLE001 (best-effort; must not block archive/delete)
except Exception: # noqa: BLE001
_logger.debug(
"Best-effort stop failed for %s; proceeding anyway",
session_id,
@@ -431,7 +431,7 @@ async def _best_effort_stop(
async def _stop(target_id: str) -> None:
try:
await _stop_session_via_runner(target_id, runner_router)
except Exception: # noqa: BLE001 (best-effort; must not block archive/delete)
except Exception: # noqa: BLE001
_logger.debug(
"Best-effort stop failed for %s; proceeding anyway",
target_id,
@@ -5469,7 +5469,7 @@ async def _create_session_from_existing_agent(
agent_name=_tel_agent_name,
)
)
except Exception: # noqa: BLE001 — telemetry must not disrupt session creation
except Exception: # noqa: BLE001
pass
if body.initial_items:
@@ -6383,7 +6383,7 @@ async def _get_session_snapshot(
llm_model,
model_override=conv.model_override,
)
except Exception: # noqa: BLE001 — best-effort; missing agent must not break session fetch
except Exception: # noqa: BLE001
pass
# Skills are runner-owned: the bound runner discovers them against its
# own filesystem (bundled skills + host skills under the session's
File diff suppressed because it is too large Load Diff
+533
View File
@@ -0,0 +1,533 @@
"""Routes for the Sessions API (``/v1/sessions``).
These endpoints expose a thin, harness-agnostic surface over an
agent's conversation: create a session bound to an agent, post events
(messages, tool outputs, interrupts), read a snapshot, and live-tail
the SSE stream. The session is implemented on top of the existing
conversation-item + task + live-stream machinery — this module is a
boundary translation layer, not a new runtime.
Input dispatch (POST /events) persists the item to
``conversation_items`` and forwards to the bound runner over the WS
tunnel. The persist-before-forward order is invariant I1 in
``designs/SESSION_REARCHITECTURE.md`` — a snapshot read immediately
after POST observes the input in ``items``.
The reconnect contract is **snapshot + live tail**, not replay: a
client opens the live stream and ``GET``s the snapshot, then
deduplicates by item id any events that fire between the two reads.
See ``server/API.md`` for the full contract.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import mimetypes
import secrets
import time
import urllib.parse
from collections.abc import Callable
from typing import Annotated, Any
import httpx
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
File,
HTTPException,
Query,
Request,
UploadFile,
WebSocket,
WebSocketDisconnect,
WebSocketException,
status,
)
from fastapi.responses import Response, StreamingResponse
from pydantic import ValidationError
from starlette.datastructures import UploadFile as StarletteUploadFile
from omnigent.codex_native_elicitation import codex_elicitation_id
from omnigent.cost_plan import (
reserved_cost_control_keys,
)
from omnigent.db.utils import generate_agent_id
from omnigent.entities import (
Agent,
CommentsFingerprint,
Conversation,
ErrorData,
NewConversationItem,
StoredFile,
synthesize_conversation_title,
)
from omnigent.entities.conversation import (
parse_item_data,
)
from omnigent.entities.permission import SessionPermission
from omnigent.entities.session_resources import session_resource_view_to_dict
from omnigent.errors import ElicitationDeclinedError, ErrorCode, OmnigentError
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE as _HARNESS_NOT_CONFIGURED_ERROR_CODE,
)
from omnigent.model_override import validate_model_override
from omnigent.native_coding_agents import (
native_coding_agent_for_terminal_name,
)
from omnigent.policies.types import (
PolicyAction,
)
from omnigent.reasoning_effort import (
EFFORT_CLEAR_VALUES,
EFFORT_VALUES,
validate_effort,
)
from omnigent.runner.identity import (
RUNNER_TUNNEL_TOKEN_HEADER,
)
from omnigent.runner.routing import RunnerRouter
from omnigent.runtime import (
get_agent_cache,
get_caps,
get_policy_store,
pending_elicitations,
pending_inputs,
session_stream,
user_session_stream,
)
from omnigent.runtime.agent_cache import AgentCache
from omnigent.runtime.policies.approval import _ELICITATION_MODE
from omnigent.runtime.policies.builder import (
any_policies_apply,
build_policy_engine,
)
from omnigent.runtime.policies.engine import PolicyEngine
from omnigent.server import presence
# Elicitation-registry state and dataclasses. Tests reach these through this
# facade module (``sessions._ParkedHarnessElicitation`` etc.); re-export them so
# the module namespace matches the pre-split file.
from omnigent.server._elicitation_registry import (
_harness_elicitation_owners,
_harness_elicitation_registry,
_harness_parked_elicitations,
_harness_pre_resolved_elicitations,
_ParkedHarnessElicitation,
_PreResolvedHarnessElicitation,
)
from omnigent.server.auth import (
LEVEL_EDIT,
LEVEL_MANAGE,
LEVEL_OWNER,
LEVEL_READ,
RESERVED_USER_PUBLIC,
AuthProvider,
SharingMode,
local_single_user_enabled,
workspace_sharing_blocked,
)
from omnigent.server.background_session_titles import (
BackgroundSessionTitleCoordinator,
prepare_background_session_title,
)
from omnigent.server.bundles import bundle_location, validate_agent_bundle
from omnigent.server.host_registry import HostRegistry, RunnerExitReports
from omnigent.server.mcp_pool import ServerMcpPool
from omnigent.server.permissions import check_session_access
from omnigent.server.routes._auth_helpers import (
attribution_user as _attribution_user,
)
from omnigent.server.routes._auth_helpers import (
get_permission_level as _get_permission_level,
)
from omnigent.server.routes._auth_helpers import (
get_session_owner_id as _get_session_owner_id,
)
from omnigent.server.routes._auth_helpers import (
get_user_id as _get_user_id,
)
from omnigent.server.routes._auth_helpers import (
require_access as _require_access,
)
from omnigent.server.routes._auth_helpers import (
require_access_and_level as _require_access_and_level,
)
from omnigent.server.routes._auth_helpers import (
require_user as _require_user,
)
from omnigent.server.routes._codex_elicitation import parse_codex_elicitation_request
from omnigent.server.routes._content_type import (
require_json_content_type,
require_json_or_multipart_content_type,
)
from omnigent.server.routes._errors import session_not_found as _session_not_found
from omnigent.server.routes._origin import require_trusted_origin
# Shared constants, state, and small dataclasses live in the _sessions.common
# leaf module; import them here so this module and its re-exporters see the same
# objects. The mutable caches are shared by reference across the package.
from omnigent.server.routes._sessions.common import *
from omnigent.server.routes._sessions.common import (
get_server_runner_router,
set_server_runner_router,
)
# Lower-layer helpers (SSE builders, publishers, persistence, runner-forward
# primitives) live in _sessions.helpers.
from omnigent.server.routes._sessions.helpers import *
# Runner-forward / ASK-gate helpers are patched by tests on this facade module
# (``monkeypatch(sessions.<X>)``). Their real bodies live in the package as
# ``<X>_impl`` and the siblings call a lazy proxy that resolves the attribute
# here at call time, so a facade patch is honored across module boundaries.
# Bind the real bodies here (overriding the star-imported proxies) so the facade
# attribute is the implementation tests replace.
from omnigent.server.routes._sessions.helpers import (
_agent_carries_native_fork_history_impl as _agent_carries_native_fork_history,
)
from omnigent.server.routes._sessions.helpers import (
_agent_is_native_impl as _agent_is_native,
)
from omnigent.server.routes._sessions.helpers import (
_build_policy_engine_from_spec_impl as _build_policy_engine_from_spec,
)
from omnigent.server.routes._sessions.helpers import (
_compact_lock_impl as _compact_lock,
)
from omnigent.server.routes._sessions.helpers import (
_forward_session_change_to_runner_impl as _forward_session_change_to_runner,
)
from omnigent.server.routes._sessions.helpers import (
_get_runner_client_for_resource_access_impl as _get_runner_client_for_resource_access,
)
from omnigent.server.routes._sessions.helpers import (
_get_runner_client_impl as _get_runner_client,
)
from omnigent.server.routes._sessions.helpers import (
_launch_runner_on_host_impl as _launch_runner_on_host,
)
from omnigent.server.routes._sessions.helpers import (
_load_agent_spec_for_session_impl as _load_agent_spec_for_session,
)
from omnigent.server.routes._sessions.helpers import (
_poll_request_disconnect_impl as _poll_request_disconnect,
)
from omnigent.server.routes._sessions.helpers import (
_presentation_labels_for_agent_impl as _presentation_labels_for_agent,
)
from omnigent.server.routes._sessions.helpers import (
_publish_sandbox_status_impl as _publish_sandbox_status,
)
from omnigent.server.routes._sessions.helpers import (
_reset_runner_resources_after_switch_impl as _reset_runner_resources_after_switch,
)
from omnigent.server.routes._sessions.helpers import (
_resolve_harness_impl as _resolve_harness,
)
from omnigent.server.routes._sessions.helpers import (
_same_provider_family_impl as _same_provider_family,
)
from omnigent.server.routes._sessions.helpers import (
_signal_terminal_resolved_harness_elicitation_impl as _signal_terminal_resolved_harness_elicitation,
)
from omnigent.server.routes._sessions.helpers import (
_stop_session_via_runner_impl as _stop_session_via_runner,
)
from omnigent.server.routes._sessions.helpers import (
_wait_for_runner_client_impl as _wait_for_runner_client,
)
# Higher-layer orchestration flows (runner relay, session-event dispatch,
# native-terminal launch, MCP tool calls) live in _sessions.orchestration.
from omnigent.server.routes._sessions.orchestration import *
from omnigent.server.routes._sessions.orchestration import (
_dispatch_session_event_to_runner_impl as _dispatch_session_event_to_runner,
)
from omnigent.server.routes._sessions.orchestration import (
_ensure_runner_relay_ready_impl as _ensure_runner_relay_ready,
)
from omnigent.server.routes._sessions.orchestration import (
_hold_native_ask_gate_impl as _hold_native_ask_gate,
)
from omnigent.server.routes._sessions.orchestration import (
_kick_managed_wake_impl as _kick_managed_wake,
)
from omnigent.server.routes._sessions.orchestration import (
_publish_runner_recovered_status_impl as _publish_runner_recovered_status,
)
from omnigent.server.schemas import (
AgentObject,
AutomaticSessionRenameRequest,
AutomaticSessionRenameResponse,
BrowserActionRequestEvent,
ChildSessionList,
ConversationDeleted,
CopiedFile,
CopyFilesRequest,
CopyFilesResponse,
CreatedSessionResponse,
ElicitationRequestEvent,
ElicitationRequestParams,
ElicitationResult,
ErrorDetail,
GrantPermissionRequest,
McpServerStartup,
MCPServerSummary,
PaginatedList,
PermissionObject,
PolicySummary,
ReadStatePutRequest,
SessionAgentChangedEvent,
SessionCreateRequest,
SessionEventInput,
SessionForkRequest,
SessionLabelsResponse,
SessionList,
SessionListItem,
SessionProjectSummary,
SessionResourceObject,
SessionResourcePaginatedList,
SessionResponse,
SessionSwitchAgentRequest,
SkillSummary,
UpdateSessionRequest,
)
from omnigent.session_lifecycle import (
is_session_closed,
labels_with_closed_status,
)
from omnigent.spec.types import (
FunctionPolicySpec,
Phase,
PolicySpec,
)
from omnigent.stores import AgentStore, ConversationStore
from omnigent.stores.artifact_store import ArtifactStore
from omnigent.stores.comment_store import CommentStore
from omnigent.stores.conversation_store import (
PROJECT_LABEL_KEY,
ConversationNotFoundError,
)
from omnigent.stores.file_store import FileStore
from omnigent.stores.permission_store import PermissionStore
from omnigent.stores.project_store import ProjectStore
from omnigent.telemetry import emit as _tel_emit
from omnigent.telemetry.events import SessionDeletedEvent as _TelSessionDeletedEvent
from omnigent.telemetry.events import SessionStoppedEvent as _TelSessionStoppedEvent
from omnigent.telemetry.installation_id import get_installation_id as _get_installation_id
from omnigent.tools.client_specified import parse_client_side_tool_specs
# ── Module-level constants (rule 34) ──────────────────────────────
# ── MCP proxy helpers ───────────────────────────────────────────────────────
#
# These module-level functions implement the JSON-RPC 2.0 handlers for
# ``POST /v1/sessions/{session_id}/mcp``. They live outside the router
# factory so the factory closure stays compact.
def create_sessions_router(
conversation_store: ConversationStore,
agent_store: AgentStore,
file_store: FileStore | None = None,
artifact_store: ArtifactStore | None = None,
runner_router: RunnerRouter | None = None,
auth_provider: AuthProvider | None = None,
permission_store: PermissionStore | None = None,
agent_cache: AgentCache | None = None,
mcp_pool: ServerMcpPool | None = None,
liveness_lookup: Callable[[list[str]], dict[str, SessionLiveness]] | None = None,
comment_store: CommentStore | None = None,
runner_tunnel_tokens: frozenset[str] | None = None,
runner_exit_reports: RunnerExitReports | None = None,
host_registry: HostRegistry | None = None,
project_store: ProjectStore | None = None,
background_title_coordinator: BackgroundSessionTitleCoordinator | None = None,
) -> APIRouter:
"""
Factory that builds the sessions router.
Stores are closed over rather than dependency-injected, matching
the convention established by the other route modules
(conversations, agents, files).
:param conversation_store: Store for conversation and item
persistence.
:param agent_store: Store for agent lookups by ID.
:param file_store: Store for file metadata CRUD. Required for
session-scoped file endpoints (Phase 1c). ``None`` in
test setups that don't exercise file routes.
:param artifact_store: Store for binary file content and agent
bundles. Required for bundled session creation and session
file upload/download.
:param runner_router: Router used to validate registered
runners for ``PATCH /v1/sessions/{id}``. ``None`` only in
tests that do not exercise runner binding.
:param auth_provider: Auth provider for user identity
extraction. ``None`` disables permission checks.
:param permission_store: Permission store for session-level
access control. ``None`` disables permission checks.
:param agent_cache: Optional agent cache for loading parsed specs
from bundles. Used to populate ``llm_model`` and
``context_window`` in :class:`SessionResponse`. ``None`` in
test setups that don't exercise context-window lookup.
:param mcp_pool: Unused; retained for API compatibility. MCP
execution is now delegated to the runner via
``POST /v1/sessions/{id}/mcp/execute``. The
``POST /v1/sessions/{id}/mcp`` endpoint is enabled whenever
``runner_router`` is set.
:param liveness_lookup: Bulk session-liveness lookup
(the server's ``_bulk_session_liveness``): maps a list of
session ids to ``{id: SessionLiveness}``, each carrying
strict ``runner_online`` and ``host_online``. When provided,
the ``GET /sessions`` list and ``WS /sessions/updates`` stream
include both fields per item, and the stream pushes a delta
when liveness flips, so the web app can stop polling
``GET /health``. ``None`` (e.g. in focused tests) omits the
fields and the client falls back to its ``/health`` poll.
:param comment_store: Store for per-session review comments. When
provided, ``GET /sessions`` and ``WS /sessions/updates`` items
carry the per-session comments fingerprint
(``comments_count`` / ``comments_updated_at``) so the web app
can refresh its comment list when another user or the agent
mutates comments. ``None`` (e.g. in focused tests or servers
without comments wired) emits the no-comments shape.
:param runner_tunnel_tokens: The server's runner tunnel-token
allow-list (same value the tunnel router receives), used to
authorize runner writes to the policy-owned ``cost_control.*``
labels on ``PATCH /v1/sessions/{id}``. ``None`` when the
server has no allow-list (token-bound runner ids are then the
only accepted proof).
:param host_registry: Live host tunnels. Lets the filesystem
endpoints read a session's workspace over its host tunnel when
the runner is offline, so the file panel stays live without
waking the agent. ``None`` disables the fallback (the endpoints
then 503 on an offline runner, as before).
:param project_store: Store for first-class projects. Required to
validate ownership when ``PATCH /v1/sessions/{id}`` files a
session into a project. ``None`` disables the move-into-project
action (a non-empty ``project_id`` is then rejected as unsupported).
:param background_title_coordinator: Optional app-owned coordinator for
semantic title generation after first-turn forwarding. ``None`` disables
background titles in focused router tests.
:returns: A configured :class:`APIRouter` exposing the
``/sessions`` endpoints.
"""
router = APIRouter()
from omnigent.server.routes.sessions.routes_agent import register_agent_routes
from omnigent.server.routes.sessions.routes_browser import register_browser_routes
from omnigent.server.routes.sessions.routes_core import register_core_routes
from omnigent.server.routes.sessions.routes_elicitations import register_elicitations_routes
from omnigent.server.routes.sessions.routes_events import register_events_routes
from omnigent.server.routes.sessions.routes_hooks import register_hooks_routes
from omnigent.server.routes.sessions.routes_items import register_items_routes
from omnigent.server.routes.sessions.routes_permissions import register_permissions_routes
from omnigent.server.routes.sessions.routes_resources import register_resources_routes
register_core_routes(
router,
conversation_store=conversation_store,
agent_store=agent_store,
file_store=file_store,
artifact_store=artifact_store,
runner_router=runner_router,
auth_provider=auth_provider,
permission_store=permission_store,
agent_cache=agent_cache,
liveness_lookup=liveness_lookup,
comment_store=comment_store,
runner_tunnel_tokens=runner_tunnel_tokens,
runner_exit_reports=runner_exit_reports,
host_registry=host_registry,
project_store=project_store,
background_title_coordinator=background_title_coordinator,
)
register_hooks_routes(
router,
conversation_store=conversation_store,
agent_store=agent_store,
runner_router=runner_router,
auth_provider=auth_provider,
permission_store=permission_store,
agent_cache=agent_cache,
)
register_items_routes(
router,
conversation_store=conversation_store,
agent_store=agent_store,
auth_provider=auth_provider,
permission_store=permission_store,
)
register_resources_routes(
router,
conversation_store=conversation_store,
agent_store=agent_store,
file_store=file_store,
artifact_store=artifact_store,
runner_router=runner_router,
auth_provider=auth_provider,
permission_store=permission_store,
host_registry=host_registry,
)
register_browser_routes(
router,
conversation_store=conversation_store,
auth_provider=auth_provider,
permission_store=permission_store,
)
register_elicitations_routes(
router,
conversation_store=conversation_store,
agent_store=agent_store,
runner_router=runner_router,
auth_provider=auth_provider,
permission_store=permission_store,
)
register_events_routes(
router,
conversation_store=conversation_store,
agent_store=agent_store,
file_store=file_store,
artifact_store=artifact_store,
runner_router=runner_router,
auth_provider=auth_provider,
permission_store=permission_store,
agent_cache=agent_cache,
liveness_lookup=liveness_lookup,
runner_exit_reports=runner_exit_reports,
host_registry=host_registry,
background_title_coordinator=background_title_coordinator,
)
register_permissions_routes(
router,
conversation_store=conversation_store,
agent_store=agent_store,
auth_provider=auth_provider,
permission_store=permission_store,
agent_cache=agent_cache,
)
register_agent_routes(
router,
conversation_store=conversation_store,
agent_store=agent_store,
artifact_store=artifact_store,
runner_router=runner_router,
auth_provider=auth_provider,
permission_store=permission_store,
agent_cache=agent_cache,
)
return router
@@ -0,0 +1,425 @@
"""Agent sub-resource routes: get/update session agent, MCP proxy."""
from __future__ import annotations
import asyncio
from typing import Annotated, Any
from fastapi import (
APIRouter,
Depends,
File,
HTTPException,
Request,
UploadFile,
)
from fastapi.responses import Response
from omnigent.errors import ErrorCode, OmnigentError
from omnigent.runner.routing import RunnerRouter
from omnigent.runtime.agent_cache import AgentCache
from omnigent.runtime.policies.approval import _ELICITATION_MODE
from omnigent.server._elicitation_registry import (
_harness_elicitation_owners,
_harness_elicitation_registry,
_harness_parked_elicitations,
_harness_pre_resolved_elicitations,
_ParkedHarnessElicitation,
_PreResolvedHarnessElicitation,
)
from omnigent.server.auth import (
LEVEL_EDIT,
LEVEL_READ,
AuthProvider,
local_single_user_enabled,
)
from omnigent.server.bundles import bundle_location, validate_agent_bundle
from omnigent.server.routes._auth_helpers import (
require_access as _require_access,
)
from omnigent.server.routes._auth_helpers import (
require_access_and_level as _require_access_and_level,
)
from omnigent.server.routes._auth_helpers import (
require_user as _require_user,
)
from omnigent.server.routes._content_type import (
require_json_content_type,
)
from omnigent.server.routes._sessions.common import *
from omnigent.server.routes._sessions.common import (
get_server_runner_router,
set_server_runner_router,
)
from omnigent.server.routes._sessions.helpers import *
from omnigent.server.routes._sessions.orchestration import *
from omnigent.server.routes.sessions.routes_permissions import (
_policy_description,
_policy_type,
_to_agent_object,
)
from omnigent.server.schemas import (
AgentObject,
MCPServerSummary,
PolicySummary,
SkillSummary,
)
from omnigent.stores import AgentStore, ConversationStore
from omnigent.stores.artifact_store import ArtifactStore
from omnigent.stores.permission_store import PermissionStore
def register_agent_routes(
router: APIRouter,
*,
conversation_store: ConversationStore,
agent_store: AgentStore,
artifact_store: ArtifactStore | None = None,
runner_router: RunnerRouter | None = None,
auth_provider: AuthProvider | None = None,
permission_store: PermissionStore | None = None,
agent_cache: AgentCache | None = None,
) -> None:
"""Register the agent sub-resource routes on router."""
@router.get("/sessions/{session_id}/agent")
async def get_session_agent(
request: Request,
session_id: str,
) -> AgentObject:
"""
Return the :class:`AgentObject` for the session's bound agent.
Replaces the standalone ``GET /api/agents/{id}`` endpoint by
resolving the agent through the session's ``agent_id`` foreign
key. The caller only needs to know the session id.
:param request: The incoming FastAPI request.
:param session_id: Session identifier, e.g.
``"conv_abc123"``.
:returns: The bound agent's :class:`AgentObject`.
:raises OmnigentError: If the session or agent is not found.
"""
user_id = _require_user(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = conversation_store.get_conversation(session_id)
if conv is None:
raise OmnigentError(
f"Session not found: {session_id!r}",
code=ErrorCode.NOT_FOUND,
)
if conv.agent_id is None:
raise OmnigentError(
"Session has no agent binding",
code=ErrorCode.INTERNAL_ERROR,
)
agent = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent is None:
raise OmnigentError(
f"Agent not found: {conv.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
return _to_agent_object(agent, agent_cache)
@router.get(
"/sessions/{session_id}/agent/contents",
response_class=Response,
responses={
200: {"content": {"application/gzip": {}}},
404: {"description": "Session or agent not found"},
},
)
async def get_session_agent_contents(
request: Request,
session_id: str,
) -> Response:
"""
Download the raw ``.tar.gz`` agent bundle for the session's
bound agent.
Replaces ``GET /api/agents/{id}/contents``. Runners call this
on cache miss to fetch the spec + bundled files.
:param request: The incoming FastAPI request.
:param session_id: Session identifier, e.g.
``"conv_abc123"``.
:returns: Raw bundle bytes as ``application/gzip``.
:raises OmnigentError: If the session, agent, or bundle is
not found.
"""
user_id = _require_user(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = conversation_store.get_conversation(session_id)
if conv is None:
raise OmnigentError(
f"Session not found: {session_id!r}",
code=ErrorCode.NOT_FOUND,
)
if conv.agent_id is None:
raise OmnigentError(
"Session has no agent binding",
code=ErrorCode.INTERNAL_ERROR,
)
agent = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent is None:
raise OmnigentError(
f"Agent not found: {conv.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
if artifact_store is None:
raise OmnigentError(
"Artifact store not configured",
code=ErrorCode.INTERNAL_ERROR,
)
bundle_bytes = artifact_store.get(agent.bundle_location)
if bundle_bytes is None:
raise OmnigentError(
"Agent bundle not found in artifact store",
code=ErrorCode.INTERNAL_ERROR,
)
return Response(
content=bundle_bytes,
media_type="application/gzip",
headers={
"X-Agent-Version": str(agent.version),
"X-Agent-Name": agent.name,
# Provenance for the runner's env-expansion decision:
# session-scoped agents are
# tenant-uploaded and must NOT have ${VAR} expanded
# against the runner process env; template agents
# (session_id is None) are operator-authored and may.
# The runner fails safe (treats a missing header as
# session-scoped → no expansion).
"X-Agent-Session-Scoped": "true" if agent.session_id is not None else "false",
},
)
@router.put(
"/sessions/{session_id}/agent",
)
async def update_session_agent(
request: Request,
session_id: str,
bundle: Annotated[UploadFile, File(...)],
) -> AgentObject:
"""
Replace the session's agent bundle with a new upload.
Validates the new bundle, checks that the spec name matches
the existing agent, stores the bundle under a
content-addressed key, updates the agent row, and warm-swaps
the cache. Idempotent when the bundle content is unchanged.
:param request: The incoming FastAPI request.
:param session_id: Session identifier, e.g.
``"conv_abc123"``.
:param bundle: Uploaded ``.tar.gz`` agent bundle file.
:returns: The updated :class:`AgentObject`.
:raises OmnigentError: If the session or agent is not found,
the bundle is invalid, or the spec name doesn't match.
"""
user_id = _require_user(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = conversation_store.get_conversation(session_id)
if conv is None:
raise OmnigentError(
f"Session not found: {session_id!r}",
code=ErrorCode.NOT_FOUND,
)
if conv.agent_id is None:
raise OmnigentError(
"Session has no agent binding",
code=ErrorCode.INTERNAL_ERROR,
)
agent = await asyncio.to_thread(agent_store.get, conv.agent_id)
if agent is None:
raise OmnigentError(
f"Agent not found: {conv.agent_id!r}",
code=ErrorCode.NOT_FOUND,
)
# Shared/template agents are read-only here;
# mirrors the guard in session_mcp_servers._editable_agent.
if agent.session_id is None:
raise OmnigentError(
"Built-in agents are read-only through this endpoint.",
code=ErrorCode.INVALID_INPUT,
)
bundle_bytes = await bundle.read()
# Run bundle validation (tar extraction + spec parse, both
# blocking) off the event loop -- mirrors the POST
# /sessions/bundled path. A malicious bundle that blocks here
# must not hang the entire server loop. The
# policy-handler allowlist is enforced only on a
# shared / multi-user server; a trusted single-user/local server
# keeps supporting custom handlers (see _create_session_from_bundle).
spec = await asyncio.to_thread(
validate_agent_bundle,
bundle_bytes,
enforce_handler_allowlist=not local_single_user_enabled(),
)
if spec.name is None:
raise OmnigentError("spec missing name", code=ErrorCode.INVALID_INPUT)
if spec.name != agent.name:
raise OmnigentError(
f"spec name '{spec.name}' does not match agent "
f"name '{agent.name}'; name is immutable",
code=ErrorCode.INVALID_INPUT,
)
new_loc = bundle_location(agent.id, bundle_bytes)
# Idempotency: same bundle content = no-op
if new_loc == agent.bundle_location:
return _to_agent_object(agent, agent_cache)
if artifact_store is None:
raise OmnigentError(
"Artifact store not configured",
code=ErrorCode.INTERNAL_ERROR,
)
artifact_store.put(new_loc, bundle_bytes)
updated = await asyncio.to_thread(agent_store.update, agent.id, new_loc)
if updated is None:
raise OmnigentError(
f"Agent not found: {agent.id!r}",
code=ErrorCode.NOT_FOUND,
)
if agent_cache is not None:
# Only operator-authored template agents
# (session_id is None) may expand ${VAR} against the server
# env; tenant session-scoped bundles must not.
agent_cache.replace(
agent.id, new_loc, bundle_bytes, expand_env=agent.session_id is None
)
return _to_agent_object(updated, agent_cache)
# ── POST /sessions/{session_id}/mcp ──────────────────────────────────
# MCP Streamable HTTP proxy endpoint. Only registered when a
# ``runner_router`` is injected; returns 503 otherwise so test
# setups that don't wire a runner skip the endpoint cleanly.
@router.post(
"/sessions/{session_id}/mcp",
# Internal MCP proxy — hidden from the public API reference.
include_in_schema=False,
response_model=None, # Returns a raw Response with application/json
# CSRF hardening: the MCP Streamable HTTP contract already mandates
# an application/json request body; enforce it so a cross-site
# text/plain request can't drive JSON-RPC against this proxy.
dependencies=[Depends(require_json_content_type)],
)
async def mcp_proxy(
session_id: str,
request: Request,
) -> Response:
"""
MCP Streamable HTTP proxy endpoint.
Implements the MCP JSON-RPC 2.0 protocol over HTTP. The AP
server owns policy enforcement (TOOL_CALL / TOOL_RESULT); the
runner owns execution via ``POST /v1/sessions/{id}/mcp/execute``
(reached through the WS tunnel the runner opened at startup).
This split ensures:
- Policy runs on the Omnigent server where the ConversationStore and
label state live.
- Stdio MCP subprocesses spawn on the runner's machine with the
correct ``cwd``, environment, and installed tooling.
Supported methods:
- ``initialize`` — capability negotiation.
- ``tools/list`` — list all tools; delegated to runner execute.
- ``tools/call`` — policy eval on AP, execution on runner.
:param session_id: Session whose agent's MCP servers to proxy,
e.g. ``"conv_abc123"``.
:param request: The incoming FastAPI request. Body must be a
JSON-RPC 2.0 object.
:returns: A ``application/json`` JSON-RPC 2.0 response.
:raises HTTPException: 503 when no ``runner_router`` is configured.
"""
if runner_router is None:
raise HTTPException(
status_code=503,
detail="MCP proxy requires a runner_router; none configured on this server",
)
user_id = _require_user(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
# Parse JSON-RPC body. Return a parse-error response (not HTTP
# 400) on failure — JSON-RPC errors travel in the body.
try:
body = await request.json()
except Exception:
return _mcp_error_response(None, -32700, "Parse error: invalid JSON")
if not isinstance(body, dict):
return _mcp_error_response(None, -32600, "Invalid Request: expected JSON object")
rpc_id: int | str | None = body.get("id")
method: str = body.get("method") or ""
params: dict[str, Any] = body.get("params") or {}
_logger.debug(
"MCP proxy: session=%r method=%r rpc_id=%r",
session_id,
method,
rpc_id,
)
if method == "initialize":
# Minimal capability negotiation response. We declare
# ``tools`` capability so MCP clients know to call
# ``tools/list`` and ``tools/call``.
return _mcp_ok_response(
rpc_id,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "omnigent-mcp-proxy", "version": "1.0.0"},
},
)
if method == "tools/list":
return await _handle_mcp_tools_list(
rpc_id,
session_id,
runner_router,
)
if method == "tools/call":
_mcp_conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
turn_actor = _mcp_conv.labels.get(_TURN_ACTOR_LABEL) if _mcp_conv is not None else None
return await _handle_mcp_tools_call(
rpc_id,
session_id,
params,
conversation_store,
agent_store,
runner_router,
actor=_build_actor(turn_actor or user_id),
request=request,
)
return _mcp_error_response(rpc_id, -32601, f"Method not found: {method!r}")
@@ -0,0 +1,226 @@
"""Browser action bridge routes."""
from __future__ import annotations
import asyncio
import secrets
from typing import Any
from fastapi import (
APIRouter,
Request,
)
from omnigent.errors import ErrorCode, OmnigentError
from omnigent.runtime import (
session_stream,
)
from omnigent.runtime.policies.approval import _ELICITATION_MODE
from omnigent.server._elicitation_registry import (
_harness_elicitation_owners,
_harness_elicitation_registry,
_harness_parked_elicitations,
_harness_pre_resolved_elicitations,
_ParkedHarnessElicitation,
_PreResolvedHarnessElicitation,
)
from omnigent.server.auth import (
LEVEL_EDIT,
AuthProvider,
)
from omnigent.server.routes._auth_helpers import (
get_user_id as _get_user_id,
)
from omnigent.server.routes._auth_helpers import (
require_access_and_level as _require_access_and_level,
)
from omnigent.server.routes._sessions.common import *
from omnigent.server.routes._sessions.common import (
get_server_runner_router,
set_server_runner_router,
)
from omnigent.server.routes._sessions.helpers import *
from omnigent.server.routes._sessions.orchestration import *
from omnigent.server.schemas import (
BrowserActionRequestEvent,
)
from omnigent.stores import ConversationStore
from omnigent.stores.permission_store import PermissionStore
def register_browser_routes(
router: APIRouter,
*,
conversation_store: ConversationStore,
auth_provider: AuthProvider | None = None,
permission_store: PermissionStore | None = None,
) -> None:
"""Register the browser routes on router."""
@router.post(
"/sessions/{session_id}/browser/action_request",
# Internal embedded-browser flow — hidden from the public API reference.
include_in_schema=False,
response_model=None,
)
async def browser_action_request(
request: Request,
session_id: str,
body: dict[str, Any],
) -> dict[str, Any]:
"""
Park one embedded-browser action and await the renderer result.
Mints an ``action_id``, parks a Future owned by ``session_id``, publishes
a ``browser.action_request`` event, and awaits up to
``_BROWSER_ACTION_AWAIT_S``; on timeout returns the timeout result (HTTP
200) so the runner gets a clean tool error. Called by the runner's
``browser_*`` dispatch, not the LLM.
:param request: The inbound request, used for identity extraction.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param body: ``{"action": <str>, "args": <dict>}`` where ``action``
is the ``browser_`` tool name minus the prefix.
:returns: The renderer's action-result JSON, or the timeout result.
:raises OmnigentError: 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
action = body.get("action")
args = body.get("args")
if not isinstance(action, str) or not action:
raise OmnigentError(
"browser action_request requires a non-empty 'action'",
code=ErrorCode.INVALID_INPUT,
)
if not isinstance(args, dict):
args = {}
action_id = f"baction_{secrets.token_hex(16)}"
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
_browser_action_registry[action_id] = future
_browser_action_owners[action_id] = session_id
try:
event = BrowserActionRequestEvent(
type="browser.action_request",
action_id=action_id,
action=action,
args=args,
)
from omnigent.server.routes import sessions as _sessions_facade
_sessions_facade.session_stream.publish(session_id, event.model_dump())
done, _pending = await asyncio.wait(
{future},
timeout=_sessions_facade._BROWSER_ACTION_AWAIT_S,
return_when=asyncio.FIRST_COMPLETED,
)
if future in done and not future.cancelled():
return future.result()
# Timed out/cancelled with no renderer result (no subscribed app).
return _BROWSER_ACTION_TIMEOUT_RESULT
finally:
# Drop registry entries so a resolved/timed-out action leaks nothing.
if _browser_action_registry.get(action_id) is future:
_browser_action_registry.pop(action_id, None)
_browser_action_owners.pop(action_id, None)
_browser_action_claims.pop(action_id, None)
@router.post(
"/sessions/{session_id}/browser/action_claim/{action_id}",
# Internal embedded-browser flow — hidden from the public API reference.
include_in_schema=False,
response_model=None,
)
async def browser_action_claim(
request: Request,
session_id: str,
action_id: str,
) -> dict[str, Any]:
"""
Atomically claim a parked browser action (one winner per action).
The request event fans out to every subscribed renderer; an atomic
``setdefault`` grants exactly one claim so they don't double-execute.
Winner gets ``{"claimed": true, "claim_token": <token>}``; everyone
else ``{"claimed": false}``.
:param request: The inbound request, used for identity extraction.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param action_id: The action to claim, e.g. ``"baction_abc123"``.
:returns: ``{"claimed": true, "claim_token": <str>}`` to the winner,
``{"claimed": false}`` to losers or for an unknown/expired action.
:raises OmnigentError: 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
# Unknown / already-resolved action: nothing to claim.
if _browser_action_owners.get(action_id) != session_id:
return {"claimed": False}
# Single-winner lease via atomic setdefault: a losing racer sees the
# winner's token, not its own, and bails.
claim_token = secrets.token_hex(16)
existing = _browser_action_claims.setdefault(action_id, claim_token)
if existing != claim_token:
return {"claimed": False}
return {"claimed": True, "claim_token": claim_token}
@router.post(
"/sessions/{session_id}/browser/action_result/{action_id}",
# Internal embedded-browser flow — hidden from the public API reference.
include_in_schema=False,
status_code=202,
response_model=None,
)
async def browser_action_result(
request: Request,
session_id: str,
action_id: str,
body: dict[str, Any],
) -> dict[str, bool]:
"""
Deliver a browser action result, resolving the parked Future.
Guarded by owner + claim-token: the caller must present the token this
action was leased under, so a renderer that lost the claim race can't
resolve the Future with stale work (tokenless/mismatched → 403).
:param request: The inbound request, used for identity extraction.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:param action_id: The action being resolved, e.g. ``"baction_abc"``.
:param body: ``{"result": <dict>, "claim_token": <str>}``.
:returns: ``{"resolved": true}`` when the Future was set,
``{"resolved": false}`` when it was already done/gone.
:raises OmnigentError: 404 if no session exists; 403 on a missing or
mismatched claim token or an owner mismatch.
"""
user_id = _get_user_id(request, auth_provider)
await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
claim_token = body.get("claim_token")
expected = _browser_action_claims.get(action_id)
if not isinstance(claim_token, str) or expected is None or claim_token != expected:
raise OmnigentError(
"browser action result requires a matching claim_token",
code=ErrorCode.FORBIDDEN,
)
# Only the session that issued the action may resolve it.
if _browser_action_owners.get(action_id) != session_id:
raise OmnigentError(
"browser action is not owned by this session",
code=ErrorCode.FORBIDDEN,
)
future = _browser_action_registry.get(action_id)
if future is None or future.done():
return {"resolved": False}
result = body.get("result")
future.set_result(result if isinstance(result, dict) else {"result": result})
return {"resolved": True}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
"""Elicitation routes: resolve and get elicitations."""
from __future__ import annotations
import asyncio
from typing import Any
from fastapi import (
APIRouter,
Request,
)
from omnigent.runner.routing import RunnerRouter
from omnigent.runtime import (
pending_elicitations,
)
from omnigent.runtime.policies.approval import _ELICITATION_MODE
from omnigent.server._elicitation_registry import (
_harness_elicitation_owners,
_harness_elicitation_registry,
_harness_parked_elicitations,
_harness_pre_resolved_elicitations,
_ParkedHarnessElicitation,
_PreResolvedHarnessElicitation,
)
from omnigent.server.auth import (
LEVEL_EDIT,
AuthProvider,
)
from omnigent.server.routes._auth_helpers import (
get_user_id as _get_user_id,
)
from omnigent.server.routes._auth_helpers import (
require_access_and_level as _require_access_and_level,
)
from omnigent.server.routes._errors import session_not_found as _session_not_found
from omnigent.server.routes._sessions.common import *
from omnigent.server.routes._sessions.common import (
get_server_runner_router,
set_server_runner_router,
)
from omnigent.server.routes._sessions.helpers import *
from omnigent.server.routes._sessions.orchestration import *
from omnigent.server.schemas import (
ElicitationResult,
)
from omnigent.stores import AgentStore, ConversationStore
from omnigent.stores.permission_store import PermissionStore
def register_elicitations_routes(
router: APIRouter,
*,
conversation_store: ConversationStore,
agent_store: AgentStore,
runner_router: RunnerRouter | None = None,
auth_provider: AuthProvider | None = None,
permission_store: PermissionStore | None = None,
) -> None:
"""Register the elicitations routes on router."""
@router.post(
"/sessions/{session_id}/elicitations/{elicitation_id}/resolve",
# Internal elicitation flow — hidden from the public API reference.
include_in_schema=False,
status_code=202,
# response_model=None: the body is a small acknowledgement
# dict, not a domain model.
response_model=None,
)
async def resolve_elicitation(
request: Request,
session_id: str,
elicitation_id: str,
body: ElicitationResult,
) -> dict[str, bool]:
"""
Resolve an outstanding elicitation by its URL (URL-based
elicitation).
The dedicated, RESTful counterpart to delivering a verdict
via the ``type == "approval"`` event on
``POST /v1/sessions/{id}/events``. An elicitation request
published in ``mode == "url"`` carries this endpoint's path
as its ``params.url``; the client hits it directly with the
MCP :class:`ElicitationResult` body instead of POSTing a
generic approval event. The verdict routes through the
shared :func:`_resolve_elicitation`, so resolution semantics
are identical to the event path.
The ``elicitation_id`` is taken from the URL rather than the
body, so the unguessable id (``secrets.token_hex(16)``) is
the capability scoping the resolution — combined with the
session-owner ``LEVEL_EDIT`` gate below and the server-side
ownership check inside :func:`_resolve_elicitation`.
:param request: The inbound request, used for identity
extraction.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param elicitation_id: Correlation id of the elicitation to
resolve, e.g. ``"elicit_abc123"``. Taken from the URL
path, not the body.
:param body: The MCP-shaped verdict — ``action``
(``"accept"`` / ``"decline"`` / ``"cancel"``) plus
optional form ``content``.
:returns: ``{"queued": False}`` — resolution is synchronous
and persists no conversation item.
:raises OmnigentError: 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
conv = access.conversation
if conv is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise _session_not_found()
_resolve_data = {"elicitation_id": elicitation_id, **body.model_dump(exclude_none=True)}
await _resolve_elicitation(session_id, _resolve_data, runner_router, conversation_store)
# Apply any policy writes deferred by the relay tool-call ASK gate
# (e.g. a cost-budget checkpoint) now that the verdict is in.
await _apply_pending_policy_ask_writes(
session_id, conv, conversation_store, agent_store, _resolve_data
)
return {"queued": False}
@router.get(
"/sessions/{session_id}/elicitations/{elicitation_id}",
# Internal elicitation flow — hidden from the public API reference.
include_in_schema=False,
response_model=None,
)
async def get_elicitation(
request: Request,
session_id: str,
elicitation_id: str,
) -> dict[str, Any]:
"""
Return the state of a pending elicitation as JSON.
Used by the frontend's standalone approval page
(``/approve/:sessionId/:elicitationId``) to fetch the
elicitation prompt and render approve/reject controls.
The payload is read from the in-memory
:mod:`omnigent.runtime.pending_elicitations` index — no
database persistence required.
:param request: The inbound request, used for identity
extraction.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param elicitation_id: Correlation id of the elicitation,
e.g. ``"elicit_abc123"``.
:returns: JSON with ``status`` (``"pending"`` or
``"resolved"``), and when pending: ``message``,
``phase``, ``policy_name``, ``content_preview``.
:raises OmnigentError: 404 if the session does not exist.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_EDIT, permission_store, conversation_store
)
if access.conversation is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise _session_not_found()
found = pending_elicitations.lookup(elicitation_id)
if found is None or found[0] != session_id:
return {"status": "resolved"}
_conv_id, event = found
params = event.get("params") if isinstance(event.get("params"), dict) else {}
return {
"status": "pending",
"message": params.get("message", "Approval required"),
"phase": params.get("phase", ""),
"policy_name": params.get("policy_name", ""),
"content_preview": params.get("content_preview", ""),
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
"""Items and child-session routes."""
from __future__ import annotations
import asyncio
from fastapi import (
APIRouter,
Query,
Request,
)
from omnigent.runtime.policies.approval import _ELICITATION_MODE
from omnigent.server._elicitation_registry import (
_harness_elicitation_owners,
_harness_elicitation_registry,
_harness_parked_elicitations,
_harness_pre_resolved_elicitations,
_ParkedHarnessElicitation,
_PreResolvedHarnessElicitation,
)
from omnigent.server.auth import (
LEVEL_READ,
AuthProvider,
)
from omnigent.server.routes._auth_helpers import (
get_user_id as _get_user_id,
)
from omnigent.server.routes._auth_helpers import (
require_access_and_level as _require_access_and_level,
)
from omnigent.server.routes._errors import session_not_found as _session_not_found
from omnigent.server.routes._sessions.common import *
from omnigent.server.routes._sessions.common import (
get_server_runner_router,
set_server_runner_router,
)
from omnigent.server.routes._sessions.helpers import *
from omnigent.server.routes._sessions.orchestration import *
from omnigent.server.schemas import (
ChildSessionList,
PaginatedList,
)
from omnigent.stores import AgentStore, ConversationStore
from omnigent.stores.permission_store import PermissionStore
def register_items_routes(
router: APIRouter,
*,
conversation_store: ConversationStore,
agent_store: AgentStore,
auth_provider: AuthProvider | None = None,
permission_store: PermissionStore | None = None,
) -> None:
"""Register the items routes on router."""
@router.get(
"/sessions/{session_id}/items",
response_model=None,
responses={200: {"model": PaginatedList}},
)
async def list_session_items(
request: Request,
session_id: str,
limit: int = Query(default=100, ge=1, le=1000),
after: str | None = Query(default=None),
before: str | None = Query(default=None),
order: str = Query(default="asc", pattern="^(asc|desc)$"),
) -> PaginatedList:
"""
List items in a session with cursor-based pagination.
Delegates to the conversation items store — session_id is
the conversation_id. Same pagination contract as
``GET /v1/conversations/{id}/items``.
:param session_id: Session/conversation identifier,
e.g. ``"conv_abc123"``.
:param limit: Maximum number of items to return
(1-1000, default 100).
:param after: Cursor — return items after this item ID,
e.g. ``"msg_abc123"``.
:param before: Cursor — return items before this item ID.
:param order: Sort order, ``"asc"`` (chronological,
default) or ``"desc"``.
:returns: A :class:`PaginatedList` of conversation items.
:raises OmnigentError: 404 if no session exists.
"""
user_id = _get_user_id(request, auth_provider)
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
if access.conversation is None:
conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if conv is None:
raise _session_not_found()
page = await asyncio.to_thread(
conversation_store.list_items,
session_id,
limit=limit,
after=after,
before=before,
order=order,
)
data = [m.to_api_dict() for m in page.data]
return PaginatedList(
data=data,
first_id=page.first_id,
last_id=page.last_id,
has_more=page.has_more,
)
# ── GET /sessions/{session_id}/child_sessions ────────────────
@router.get(
"/sessions/{session_id}/child_sessions",
response_model=None,
responses={200: {"model": ChildSessionList}},
)
async def list_child_sessions(
request: Request,
session_id: str,
limit: int = Query(default=20, ge=1, le=1000),
after: str | None = Query(default=None),
before: str | None = Query(default=None),
order: str = Query(default="desc", pattern="^(asc|desc)$"),
tool: str | None = Query(default=None),
session_name: str | None = Query(default=None),
) -> PaginatedList:
"""
List sub-agent (child) sessions under a parent session.
Returns a page of :class:`ChildSessionSummary` objects
derived from child conversations (``kind="sub_agent"``,
``parent_conversation_id=session_id``) plus each child's
latest task. Powers the web / REPL debug surfaces' "child
sessions" panel without parsing parent
``function_call_output`` JSON handles. Pagination contract
matches :func:`list_session_items` so existing client code
can reuse the same cursor logic.
:param request: Inbound HTTP request; carries the caller
identity used to authorize READ on the parent session.
:param session_id: Parent session/conversation identifier,
e.g. ``"conv_abc123"``.
:param limit: Maximum number of children to return
(1-1000, default 20 — sub-agent fan-out is typically
sparse compared to conversation items).
:param after: Cursor — return children whose id appears
after this one in sort order,
e.g. ``"conv_child123"``.
:param before: Cursor — return children before this one.
:param order: Sort direction, ``"desc"`` (newest-first,
default) or ``"asc"``. Sort column is ``created_at``.
:param tool: When set, only return children whose title
starts with this agent type (the segment before the
``":"``). Combined with ``session_name`` to form the
exact title ``"{tool}:{session_name}"`` for server-side
filtering.
:param session_name: When set alongside ``tool``, only
return children whose title matches
``"{tool}:{session_name}"`` exactly.
:returns: A :class:`PaginatedList` of
:class:`ChildSessionSummary` objects.
:raises OmnigentError: 403 if the caller lacks READ on
``session_id``; 404 if no session exists there.
"""
user_id = _get_user_id(request, auth_provider)
# Require READ on the parent before listing its children (no cross-user enumeration).
access = await _require_access_and_level(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
parent = access.conversation
if parent is None:
parent = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if parent is None:
raise _session_not_found()
title_filter: str | None = None
if tool and session_name:
title_filter = f"{tool}:{session_name}"
page = await asyncio.to_thread(
conversation_store.list_conversations,
limit=limit,
after=after,
before=before,
kind="sub_agent",
parent_conversation_id=session_id,
order=order,
sort_by="created_at",
title=title_filter,
)
data = await _child_session_summaries_from_conversations(
page.data,
session_id,
conversation_store,
)
return PaginatedList(
data=data,
first_id=page.first_id,
last_id=page.last_id,
has_more=page.has_more,
)
@@ -0,0 +1,416 @@
"""Permission management routes."""
from __future__ import annotations
import asyncio
from fastapi import (
APIRouter,
Query,
Request,
)
from fastapi.responses import Response
from omnigent.entities import (
Agent,
)
from omnigent.errors import ErrorCode, OmnigentError
from omnigent.runtime.agent_cache import AgentCache
from omnigent.runtime.policies.approval import _ELICITATION_MODE
from omnigent.server._elicitation_registry import (
_harness_elicitation_owners,
_harness_elicitation_registry,
_harness_parked_elicitations,
_harness_pre_resolved_elicitations,
_ParkedHarnessElicitation,
_PreResolvedHarnessElicitation,
)
from omnigent.server.auth import (
LEVEL_MANAGE,
LEVEL_OWNER,
LEVEL_READ,
RESERVED_USER_PUBLIC,
AuthProvider,
SharingMode,
workspace_sharing_blocked,
)
from omnigent.server.routes._auth_helpers import (
get_session_owner_id as _get_session_owner_id,
)
from omnigent.server.routes._auth_helpers import (
require_access as _require_access,
)
from omnigent.server.routes._auth_helpers import (
require_user as _require_user,
)
from omnigent.server.routes._sessions.common import *
from omnigent.server.routes._sessions.common import (
get_server_runner_router,
set_server_runner_router,
)
from omnigent.server.routes._sessions.helpers import *
from omnigent.server.routes._sessions.orchestration import *
from omnigent.server.schemas import (
AgentObject,
GrantPermissionRequest,
MCPServerSummary,
PermissionObject,
PolicySummary,
SkillSummary,
)
from omnigent.spec.types import (
FunctionPolicySpec,
PolicySpec,
)
from omnigent.stores import AgentStore, ConversationStore
from omnigent.stores.permission_store import PermissionStore
def register_permissions_routes(
router: APIRouter,
*,
conversation_store: ConversationStore,
agent_store: AgentStore,
auth_provider: AuthProvider | None = None,
permission_store: PermissionStore | None = None,
agent_cache: AgentCache | None = None,
) -> None:
"""Register the permissions routes on router."""
@router.put(
"/sessions/{session_id}/permissions",
response_model=None,
responses={200: {"model": PermissionObject}},
)
async def grant_permission(
request: Request,
session_id: str,
body: GrantPermissionRequest,
) -> PermissionObject:
"""Grant or update a permission on a session.
Requires manage-level access. Upserts the grant — can
upgrade or downgrade an existing level. Auto-creates the
grantee user if they don't exist yet.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session to grant access to,
e.g. ``"conv_abc123"``.
:param body: The grant request with ``user_id`` and ``level``.
:returns: The resulting :class:`PermissionObject`.
:raises OmnigentError: 404 if no session or no access,
401 if unauthenticated.
"""
user_id = _require_user(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_MANAGE, permission_store, conversation_store
)
# Server-wide sharing policy gate (see SharingMode). Applied only
# to *new* grants — revoke/list and owner grants are unaffected.
# ``getattr`` default keeps a hand-built app (a router mounted without
# create_app, e.g. in a focused test) from AttributeError-ing; every
# production path sets these via create_app.
_sharing_mode = getattr(request.app.state, "sharing_mode", lambda: SharingMode.ON)()
if _sharing_mode == SharingMode.OFF:
raise OmnigentError(
"Sharing has been disabled for this Omnigent server.",
code=ErrorCode.FORBIDDEN,
)
# RESTRICTED_READ_ONLY blocks sharing entirely (even read) for a session
# whose cwd is a home dir or the filesystem root — that workspace is too
# broad to expose. Other sessions fall through to the read-only cap.
if _sharing_mode == SharingMode.RESTRICTED_READ_ONLY:
_conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
if _conv is not None and workspace_sharing_blocked(_conv.workspace):
raise OmnigentError(
"This session's working directory (a home or root directory) "
"cannot be shared on this Omnigent server.",
code=ErrorCode.FORBIDDEN,
)
if (
_sharing_mode in (SharingMode.READ_ONLY, SharingMode.RESTRICTED_READ_ONLY)
and body.level > LEVEL_READ
):
raise OmnigentError(
"Sharing is limited to read-only access on this Omnigent server.",
code=ErrorCode.FORBIDDEN,
)
if permission_store is None:
raise OmnigentError(
"Permissions not enabled",
code=ErrorCode.INTERNAL_ERROR,
)
if body.user_id == user_id:
raise OmnigentError(
"Cannot modify your own permissions",
code=ErrorCode.FORBIDDEN,
)
if body.user_id == RESERVED_USER_PUBLIC:
# Public-access kill switch, independent of the sharing_mode gate
# above (see app.state.public_sharing). Blocks the anyone-with-the
# -link grant while leaving user-to-user sharing intact. ``getattr``
# default mirrors the sharing_mode read above (hand-built apps).
if not getattr(request.app.state, "public_sharing", lambda: True)():
raise OmnigentError(
"Public access has been disabled for this Omnigent server.",
code=ErrorCode.FORBIDDEN,
)
if body.level > LEVEL_READ:
raise OmnigentError(
"Public access is limited to read-only (level 1)",
code=ErrorCode.INVALID_INPUT,
)
existing = await asyncio.to_thread(permission_store.get, body.user_id, session_id)
if existing is not None and existing.level == LEVEL_OWNER:
raise OmnigentError(
"Cannot modify owner permissions",
code=ErrorCode.FORBIDDEN,
)
await asyncio.to_thread(permission_store.ensure_user, body.user_id)
perm = await asyncio.to_thread(
permission_store.grant, body.user_id, session_id, body.level
)
# Push the now-shared session to the GRANTEE's open tabs so it
# appears in their sidebar without a list poll.
_announce_session_added(body.user_id, session_id)
return PermissionObject(
user_id=perm.user_id,
conversation_id=perm.conversation_id,
level=perm.level,
)
@router.delete(
"/sessions/{session_id}/permissions/{target_user_id}",
status_code=204,
response_model=None,
)
async def revoke_permission(
request: Request,
session_id: str,
target_user_id: str,
) -> Response:
"""Revoke a user's permission on a session.
Requires manage-level access. Cannot revoke your own
manage grant (prevents orphaned sessions). Returns 204
whether or not the grant existed (idempotent).
:param request: The incoming FastAPI request (for auth).
:param session_id: Session to revoke access from,
e.g. ``"conv_abc123"``.
:param target_user_id: User whose grant to revoke,
e.g. ``"alice@example.com"``.
:returns: 204 No Content.
:raises OmnigentError: 404 if no session or no access,
403 if attempting to revoke own manage grant.
"""
user_id = _require_user(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_MANAGE, permission_store, conversation_store
)
if permission_store is None:
raise OmnigentError(
"Permissions not enabled",
code=ErrorCode.INTERNAL_ERROR,
)
if target_user_id == user_id:
raise OmnigentError(
"Cannot modify your own permissions",
code=ErrorCode.FORBIDDEN,
)
existing = await asyncio.to_thread(permission_store.get, target_user_id, session_id)
if existing is not None and existing.level == LEVEL_OWNER:
raise OmnigentError(
"Cannot revoke owner permissions",
code=ErrorCode.FORBIDDEN,
)
await asyncio.to_thread(permission_store.revoke, target_user_id, session_id)
return Response(status_code=204)
@router.get(
"/sessions/{session_id}/owner",
response_model=None,
)
async def get_session_owner(
request: Request,
session_id: str,
) -> dict[str, str | None]:
"""Return the owner of a session.
Requires read-level access.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session to look up,
e.g. ``"conv_abc123"``.
:returns: ``{"owner": "<user_id>"}`` or
``{"owner": null}``.
"""
user_id = _require_user(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_READ, permission_store, conversation_store
)
return {"owner": _get_session_owner_id(session_id, permission_store)}
@router.get(
"/sessions/{session_id}/permissions",
response_model=None,
)
async def list_permissions(
request: Request,
session_id: str,
limit: int = Query(default=100, ge=1, le=1000),
after: str | None = Query(default=None, description="Cursor: user_id to start after"),
) -> dict:
"""List permission grants on a session with cursor pagination.
Requires manage-level access.
:param request: The incoming FastAPI request (for auth).
:param session_id: Session to list grants for,
e.g. ``"conv_abc123"``.
:param limit: Max grants to return (11000, default 100).
:param after: Cursor — user_id to start after (exclusive).
:returns: ``{"permissions": [...], "next_cursor": str|null}``.
:raises OmnigentError: 404 if no session or no access.
"""
user_id = _require_user(request, auth_provider)
await _require_access(
user_id, session_id, LEVEL_MANAGE, permission_store, conversation_store
)
if permission_store is None:
raise OmnigentError(
"Permissions not enabled",
code=ErrorCode.INTERNAL_ERROR,
)
grants, next_cursor = await asyncio.to_thread(
permission_store.list_for_session, session_id, limit=limit, after_user_id=after
)
return {
"permissions": [
PermissionObject(
user_id=g.user_id,
conversation_id=g.conversation_id,
level=g.level,
)
for g in grants
],
"next_cursor": next_cursor,
}
return router
def _policy_type(spec: PolicySpec) -> str:
"""Return ``"function"`` for all policies."""
if isinstance(spec, FunctionPolicySpec):
return "function"
return "unknown"
def _policy_description(spec: PolicySpec) -> str | None:
"""Return a short description for a policy spec.
Looks up the policy registry for a human-readable
description; falls back to the callable path.
"""
if isinstance(spec, FunctionPolicySpec) and spec.function:
from omnigent.policies.registry import get_entry
entry = get_entry(spec.function.path)
return entry.description if entry else spec.function.path
return None
def _to_agent_object(agent: Agent, cache: AgentCache | None) -> AgentObject:
"""
Convert a runtime :class:`Agent` entity to an API-layer
:class:`AgentObject`.
Loads the agent spec from *cache* to populate ``mcp_servers``,
``policies``, ``skills``, and (when the stored row has none) the
``description``. If the cache is ``None``, the spec is not
cached, or the load fails, those fall back to empty lists / the
stored value rather than raising — the endpoint must not fail
because one spec can't be read.
:param agent: The runtime agent entity.
:param cache: Agent cache, or ``None`` in test setups.
:returns: An :class:`AgentObject` for the API response.
"""
mcp_servers: list[MCPServerSummary] = []
policies: list[PolicySummary] = []
skills: list[SkillSummary] = []
terminals: list[str] = []
# Harness/kind for the UI; None until the spec loads (mirrors the
# GET /v1/agents catalog so both endpoints report it consistently).
harness: str | None = None
# Prefer the stored entity's description; fall back to the spec's
# top-level description when the stored value is unset (single-file
# YAML agents don't persist it at registration today). Lets the
# new-session picker show a hover description without a migration.
description: str | None = agent.description
if cache is not None:
try:
loaded = cache.load(
agent.id, agent.bundle_location, expand_env=agent.session_id is None
)
harness = loaded.spec.executor.harness_kind
if description is None:
description = loaded.spec.description
# Declared terminal names, in spec order — the Web UI
# gates its "new terminal" affordance on this list.
terminals = list(loaded.spec.terminals or {})
# Bundled skills only (mirrors GET /v1/agents); the merged
# bundled + host-discovered set lives on the session snapshot.
skills = [
SkillSummary(name=s.name, description=s.description) for s in loaded.spec.skills
]
mcp_servers = [
MCPServerSummary(
name=srv.name,
transport=srv.transport,
description=srv.description,
url=srv.url,
headers=dict.fromkeys(srv.headers, "[REDACTED]") if srv.headers else {},
command=srv.command,
args=srv.args,
)
for srv in loaded.spec.mcp_servers
]
if loaded.spec.guardrails and loaded.spec.guardrails.policies:
policies = [
PolicySummary(
name=ps.name,
type=_policy_type(ps),
on=[
f"{sel.phase.value}:{sel.tool_name}"
if sel.tool_name
else sel.phase.value
for sel in (ps.on or [])
],
description=_policy_description(ps),
)
for ps in loaded.spec.guardrails.policies
]
except Exception:
_logger.debug(
"Failed to load spec for agent %s; mcp_servers/policies will be empty",
agent.id,
exc_info=True,
)
return AgentObject(
id=agent.id,
name=agent.name,
version=agent.version,
description=description,
created_at=agent.created_at,
updated_at=agent.updated_at,
harness=harness,
mcp_servers=mcp_servers,
mcp_servers_editable=(
agent.session_id is not None and not (harness or "").endswith("-native")
),
policies=policies,
skills=skills,
terminals=terminals,
)
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -564,7 +564,8 @@ known-first-party = ["omnigent"]
# orchestration). Star re-export is the point of the facade, so F403 (star
# import) and F405 (name may be from star import) are expected here and can't
# be resolved without enumerating hundreds of re-exports by hand.
"omnigent/server/routes/sessions.py" = ["F403", "F405"]
"omnigent/server/routes/sessions/__init__.py" = ["ARG001", "ARG002", "BLE001", "E501", "F401", "F403", "F405"]
"omnigent/server/routes/sessions/*.py" = ["ARG001", "ARG002", "BLE001", "E501", "F401", "F403", "F405"]
"omnigent/server/routes/_sessions/*.py" = ["F403", "F405"]
[tool.mypy]