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:
@@ -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
@@ -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 (1–1000, 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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user