handler-ai 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- handler_ai-0.1.0.dist-info/METADATA +60 -0
- handler_ai-0.1.0.dist-info/RECORD +234 -0
- handler_ai-0.1.0.dist-info/WHEEL +4 -0
- handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
- handler_cli/__init__.py +1 -0
- handler_cli/account_commands.py +314 -0
- handler_cli/addon_commands.py +128 -0
- handler_cli/agent_commands.py +139 -0
- handler_cli/app_commands.py +175 -0
- handler_cli/app_window.py +85 -0
- handler_cli/automation_commands.py +309 -0
- handler_cli/bridge.py +42 -0
- handler_cli/cli.py +326 -0
- handler_cli/client.py +81 -0
- handler_cli/daemon_commands.py +108 -0
- handler_cli/image_commands.py +102 -0
- handler_cli/inbox_commands.py +33 -0
- handler_cli/output.py +12 -0
- handler_cli/project_commands.py +63 -0
- handler_cli/provider_commands.py +113 -0
- handler_cli/secret_commands.py +64 -0
- handler_cli/session_bridge.py +93 -0
- handler_cli/session_commands.py +485 -0
- handler_cli/setup.py +120 -0
- handler_cli/terminal.py +280 -0
- handler_cli/terminal_client.py +37 -0
- handler_contracts/__init__.py +642 -0
- handler_contracts/account.py +36 -0
- handler_contracts/acp_endpoint.py +69 -0
- handler_contracts/addon.py +147 -0
- handler_contracts/agent_spec.py +75 -0
- handler_contracts/automation.py +285 -0
- handler_contracts/common.py +229 -0
- handler_contracts/control.py +1208 -0
- handler_contracts/harness.py +300 -0
- handler_contracts/miniapp.py +141 -0
- handler_contracts/provider.py +82 -0
- handler_contracts/remote.py +167 -0
- handler_contracts/sandbox.py +199 -0
- handler_contracts/schemas.py +313 -0
- handler_contracts/terminal.py +32 -0
- handler_control/__init__.py +7 -0
- handler_control/access.py +62 -0
- handler_control/accounts.py +279 -0
- handler_control/addon_install.py +268 -0
- handler_control/addons.py +118 -0
- handler_control/agent_state.py +46 -0
- handler_control/agents.py +257 -0
- handler_control/api/__init__.py +137 -0
- handler_control/api/accounts.py +231 -0
- handler_control/api/acp.py +116 -0
- handler_control/api/addons.py +95 -0
- handler_control/api/agents.py +150 -0
- handler_control/api/apps.py +104 -0
- handler_control/api/automations.py +128 -0
- handler_control/api/bindings.py +105 -0
- handler_control/api/builtin_agents.py +22 -0
- handler_control/api/caches.py +79 -0
- handler_control/api/catalog.py +197 -0
- handler_control/api/common.py +205 -0
- handler_control/api/contexts.py +88 -0
- handler_control/api/folders.py +51 -0
- handler_control/api/images.py +164 -0
- handler_control/api/inbox.py +21 -0
- handler_control/api/projects.py +170 -0
- handler_control/api/providers.py +107 -0
- handler_control/api/remote.py +45 -0
- handler_control/api/secrets.py +177 -0
- handler_control/api/sessions.py +192 -0
- handler_control/api/skills.py +270 -0
- handler_control/app_api.py +84 -0
- handler_control/app_http.py +214 -0
- handler_control/app_remote.py +206 -0
- handler_control/apps.py +867 -0
- handler_control/auth.py +48 -0
- handler_control/automation_config.py +255 -0
- handler_control/automations.py +1134 -0
- handler_control/bindings.py +149 -0
- handler_control/builds.py +341 -0
- handler_control/builtin_agents.py +82 -0
- handler_control/caches.py +113 -0
- handler_control/checks.py +287 -0
- handler_control/compose.py +317 -0
- handler_control/config_files.py +41 -0
- handler_control/contexts.py +75 -0
- handler_control/daemon.py +64 -0
- handler_control/data/automation/bin/handler-llm +85 -0
- handler_control/data/automations/env-issues/automation.toml +17 -0
- handler_control/data/automations/env-issues/extract.py +73 -0
- handler_control/data/automations/env-issues/findings.schema.json +28 -0
- handler_control/data/automations/env-issues/prompt.md +19 -0
- handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
- handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
- handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
- handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
- handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
- handler_control/data/builtin-agents/skill-review/interface.json +83 -0
- handler_control/data/interfaces/llm/v1/interface.toml +17 -0
- handler_control/data/interfaces/llm/v1/tools.json +138 -0
- handler_control/data/templates/README.md +10 -0
- handler_control/data/templates/providers/anthropic-api.toml +18 -0
- handler_control/data/templates/providers/llamacpp.toml +210 -0
- handler_control/data/templates/providers/mistral-api.toml +14 -0
- handler_control/data/templates/providers/ollama.toml +10 -0
- handler_control/data/templates/providers/openai-api.toml +13 -0
- handler_control/data/templates/providers/openrouter.toml +10 -0
- handler_control/desktop.py +68 -0
- handler_control/display.py +62 -0
- handler_control/files.py +23 -0
- handler_control/folders.py +68 -0
- handler_control/git_read.py +39 -0
- handler_control/image_check.py +322 -0
- handler_control/image_updates.py +174 -0
- handler_control/inbox.py +70 -0
- handler_control/interfaces.py +151 -0
- handler_control/knowledge.py +237 -0
- handler_control/launcher.py +356 -0
- handler_control/llm.py +194 -0
- handler_control/migrations/__init__.py +62 -0
- handler_control/policy.py +305 -0
- handler_control/projects.py +266 -0
- handler_control/providers.py +180 -0
- handler_control/relay.py +133 -0
- handler_control/remote.py +888 -0
- handler_control/remote_crypto.py +97 -0
- handler_control/runtime.py +140 -0
- handler_control/sandbox_process.py +224 -0
- handler_control/schedules.py +58 -0
- handler_control/secrets.py +242 -0
- handler_control/service_router.py +180 -0
- handler_control/services.py +92 -0
- handler_control/session_files.py +429 -0
- handler_control/sessions.py +1116 -0
- handler_control/settings.py +129 -0
- handler_control/shortcuts.py +80 -0
- handler_control/skill_scan.py +88 -0
- handler_control/skill_sources.py +61 -0
- handler_control/skills.py +378 -0
- handler_control/terminal.py +79 -0
- handler_control/terminals.py +108 -0
- handler_control/transcript.py +174 -0
- handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
- handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
- handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- handler_control/web-ui/favicon.svg +1 -0
- handler_control/web-ui/index.html +15 -0
- handler_control/webpush.py +90 -0
- handler_control/webui.py +105 -0
- handler_harness/__init__.py +13 -0
- handler_harness/addons.py +129 -0
- handler_harness/catalog.py +116 -0
- handler_harness/cli.py +109 -0
- handler_harness/data/addons/README.md +44 -0
- handler_harness/data/addons/miniapp/Containerfile +20 -0
- handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
- handler_harness/data/addons/miniapp/addon.toml +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
- handler_harness/data/catalog/README.md +12 -0
- handler_harness/data/catalog/playwright/Containerfile +28 -0
- handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
- handler_harness/data/catalog/playwright/addon.toml +25 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
- handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
- handler_harness/data/catalog/shopify/Containerfile +22 -0
- handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
- handler_harness/data/catalog/shopify/addon.toml +39 -0
- handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
- handler_harness/data/handler-sandbox-env +30 -0
- handler_harness/data/harnesses/README.md +47 -0
- handler_harness/data/harnesses/claude-code/Containerfile +27 -0
- handler_harness/data/harnesses/claude-code/harness.toml +95 -0
- handler_harness/data/harnesses/codex/Containerfile +26 -0
- handler_harness/data/harnesses/codex/harness.toml +103 -0
- handler_harness/data/harnesses/pi/Containerfile +27 -0
- handler_harness/data/harnesses/pi/harness.toml +65 -0
- handler_harness/data/harnesses/vibe/Containerfile +19 -0
- handler_harness/data/harnesses/vibe/harness.toml +96 -0
- handler_harness/data/toolbox/Containerfile +56 -0
- handler_harness/data/toolbox/README.md +24 -0
- handler_harness/data/toolbox/toolbox.toml +41 -0
- handler_harness/images.py +62 -0
- handler_harness/providers.py +300 -0
- handler_harness/render.py +472 -0
- handler_sandbox_podman/__init__.py +1 -0
- handler_sandbox_podman/caches.py +97 -0
- handler_sandbox_podman/check.py +121 -0
- handler_sandbox_podman/cli.py +239 -0
- handler_sandbox_podman/egress.py +118 -0
- handler_sandbox_podman/images.py +199 -0
- handler_sandbox_podman/launch.py +474 -0
- handler_sandbox_podman/podman.py +108 -0
- handler_sandbox_podman/proxy/Containerfile +6 -0
- handler_sandbox_podman/proxy/addon.py +255 -0
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
"""Sessions (contract F) — daemon-held chat sessions: one sandbox each, outliving its WebSocket.
|
|
2
|
+
|
|
3
|
+
A client attaches with plain ACP. The daemon answers `initialize` (cached) and `session/load`
|
|
4
|
+
(replay of the event log) itself, then relays through the policy (C.4). Several clients may be
|
|
5
|
+
attached (F.7): every one gets the agent's traffic, the first answer to a permission request
|
|
6
|
+
wins, and what one client changes (prompt, mode, option) is shown to the others. Detaching never
|
|
7
|
+
stops the agent or its turn.
|
|
8
|
+
|
|
9
|
+
SessionStore records on disk, live sessions, single-flight start, stop, delete
|
|
10
|
+
LiveSession one running agent: its own requests, the clients' (ids rewritten),
|
|
11
|
+
pending permission requests, the event log, the idle timer
|
|
12
|
+
Attachment one attached client (a WebSocket, or in-process: the remote connector),
|
|
13
|
+
with its own bounded queue so a slow client cannot hold up the agent
|
|
14
|
+
|
|
15
|
+
A session may be started with a request (sent by the daemon as its first turn). A run of an
|
|
16
|
+
agent with an interface (D.18) is started with an input instead, and is unattended: its
|
|
17
|
+
permission requests are answered by its interface's policy, its turn is cancelled after its
|
|
18
|
+
timeout, no client may prompt it, and its result is read from its final message (one more turn
|
|
19
|
+
when it is missing or invalid); then it stops, its answer (the agent's last message) kept.
|
|
20
|
+
|
|
21
|
+
Storage, one folder per session under <state>/sessions/<id>/ (F.2):
|
|
22
|
+
session.json the record
|
|
23
|
+
events.ndjson the event log (F.5), secret values redacted
|
|
24
|
+
agent/ bound to the harness's per-session state slot
|
|
25
|
+
work/ a folderless agent's (D.18): the folder the session works in
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import contextlib
|
|
30
|
+
import itertools
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import secrets as pysecrets
|
|
34
|
+
import shutil
|
|
35
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
36
|
+
from dataclasses import asdict, dataclass
|
|
37
|
+
from datetime import UTC, datetime
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Literal
|
|
40
|
+
|
|
41
|
+
from starlette.websockets import WebSocket
|
|
42
|
+
|
|
43
|
+
from handler_contracts import (
|
|
44
|
+
AcpCloseCode,
|
|
45
|
+
AgentInterface,
|
|
46
|
+
Session,
|
|
47
|
+
SessionErrorCode,
|
|
48
|
+
SessionInput,
|
|
49
|
+
SessionLifecycle,
|
|
50
|
+
SessionOrigin,
|
|
51
|
+
SessionOutput,
|
|
52
|
+
session_acp_path,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
from . import __version__, interfaces, launcher, session_files, skill_scan, skills
|
|
56
|
+
from .agents import AgentRecord, AgentRegistry, LiveConnection, RunsAs
|
|
57
|
+
from .policy import AcpPolicy, ToAgent, ToClient, instructed, is_request, is_response, parse
|
|
58
|
+
from .sandbox_process import read_stderr, stop
|
|
59
|
+
from .secrets import redactor
|
|
60
|
+
from .settings import Settings
|
|
61
|
+
|
|
62
|
+
log = logging.getLogger("handler.sessions")
|
|
63
|
+
|
|
64
|
+
Message = dict[str, Any]
|
|
65
|
+
START_TIMEOUT = 300.0 # initialize + session/new|load, image start included
|
|
66
|
+
TITLE_LENGTH = 80 # a title taken from the first request
|
|
67
|
+
CANCEL_GRACE = 30.0 # after a timeout's session/cancel, for the agent to end its turn
|
|
68
|
+
QUEUE_SIZE = 10_000 # messages waiting for one client; more: it is closed as too slow (F.7)
|
|
69
|
+
|
|
70
|
+
# how a session stopped (F.5), on its `lifecycle stopped` event: read by automations (I.2)
|
|
71
|
+
StopHow = Literal["explicit", "idle", "exited", "error", "run_ended", "daemon_restarted", "deleted"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def with_session_id(msg: Message, old: str | None, new: str | None) -> Message:
|
|
75
|
+
"""`msg` with `params.sessionId` set to `new` (only where it is `old`, unless None)."""
|
|
76
|
+
params = msg.get("params")
|
|
77
|
+
if not isinstance(params, dict) or "sessionId" not in params:
|
|
78
|
+
return msg
|
|
79
|
+
if old is not None and params["sessionId"] != old:
|
|
80
|
+
return msg
|
|
81
|
+
return msg | {"params": params | {"sessionId": new}}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SessionError(RuntimeError):
|
|
85
|
+
"""The session cannot be started or attached (the reason is shown to the client)."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def now() -> datetime:
|
|
89
|
+
return datetime.now(UTC)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def error(req_id: Any, code: int, message: str) -> Message:
|
|
93
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class SessionRecord:
|
|
98
|
+
id: str
|
|
99
|
+
agent_id: str
|
|
100
|
+
harness: str # which of its agent's models it was started with: the harness, on the
|
|
101
|
+
# account or provider below, with `model`
|
|
102
|
+
scope: str
|
|
103
|
+
created_at: datetime
|
|
104
|
+
last_activity_at: datetime
|
|
105
|
+
account: str | None = None
|
|
106
|
+
provider: str | None = None
|
|
107
|
+
model: str | None = None
|
|
108
|
+
acp_session_id: str | None = None
|
|
109
|
+
# the agent's id of its session, when not `acp_session_id`: restarted before its first turn,
|
|
110
|
+
# the agent's session is a new one; the clients keep the recorded id (F.6)
|
|
111
|
+
agent_session_id: str | None = None
|
|
112
|
+
title: str | None = None
|
|
113
|
+
state: SessionLifecycle = "starting"
|
|
114
|
+
can_resume: bool = False
|
|
115
|
+
error: str | None = None
|
|
116
|
+
harness_version: str | None = None
|
|
117
|
+
origin: SessionOrigin = "ui"
|
|
118
|
+
last_stop_reason: str | None = None
|
|
119
|
+
answer: str | None = None # a run's: the agent's last message, kept when it ended
|
|
120
|
+
interface: dict[str, Any] | None = None # a run (D.18): its agent's interface when it started
|
|
121
|
+
input: dict[str, Any] | None = None # a run: SessionInput, the subject's digest set
|
|
122
|
+
output: dict[str, Any] | None = None # a run: SessionOutput, once its turn ended
|
|
123
|
+
# its agent's instructions, until they go with the first prompt a client sends (F.3)
|
|
124
|
+
instructions: str | None = None
|
|
125
|
+
|
|
126
|
+
def to_json(self) -> dict[str, Any]:
|
|
127
|
+
return asdict(self) | {
|
|
128
|
+
"created_at": self.created_at.isoformat(),
|
|
129
|
+
"last_activity_at": self.last_activity_at.isoformat(),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_json(cls, data: dict[str, Any]) -> "SessionRecord":
|
|
134
|
+
fields: dict[str, Any] = data | {
|
|
135
|
+
"created_at": datetime.fromisoformat(data["created_at"]),
|
|
136
|
+
"last_activity_at": datetime.fromisoformat(data["last_activity_at"]),
|
|
137
|
+
}
|
|
138
|
+
return cls(**fields)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class EventLog:
|
|
142
|
+
"""events.ndjson (F.5): append-only, `seq` strictly increasing from 1."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, path: Path, on_stopped: Callable[[], None] | None = None) -> None:
|
|
145
|
+
self.path = path
|
|
146
|
+
self.on_stopped = on_stopped # told after a `lifecycle stopped` event is written
|
|
147
|
+
self.redact = redactor([])
|
|
148
|
+
self.seq = 0
|
|
149
|
+
self.open_turn = False # the last `turn` event is a start
|
|
150
|
+
self.had_turn = False # a conversation the agent may keep: something to load (F.6)
|
|
151
|
+
if path.exists():
|
|
152
|
+
for event in self._read():
|
|
153
|
+
self.seq = event["seq"]
|
|
154
|
+
if event["kind"] == "turn":
|
|
155
|
+
self.open_turn = event["phase"] == "start"
|
|
156
|
+
self.had_turn = True
|
|
157
|
+
|
|
158
|
+
def _read(self) -> list[dict[str, Any]]:
|
|
159
|
+
events = []
|
|
160
|
+
for line in self.path.read_text().splitlines():
|
|
161
|
+
with contextlib.suppress(ValueError, KeyError):
|
|
162
|
+
events.append(json.loads(line))
|
|
163
|
+
return events
|
|
164
|
+
|
|
165
|
+
def append(self, kind: str, **fields: Any) -> None:
|
|
166
|
+
self.seq += 1
|
|
167
|
+
if kind == "turn":
|
|
168
|
+
self.open_turn = fields["phase"] == "start"
|
|
169
|
+
self.had_turn = True
|
|
170
|
+
line = json.dumps({"seq": self.seq, "t": now().isoformat(), "kind": kind} | fields)
|
|
171
|
+
with self.path.open("a") as f:
|
|
172
|
+
f.write(self.redact(line) + "\n")
|
|
173
|
+
if kind == "lifecycle" and fields.get("event") == "stopped" and self.on_stopped:
|
|
174
|
+
self.on_stopped()
|
|
175
|
+
|
|
176
|
+
def updates(self, after: int = 0) -> list[tuple[int, Any]]:
|
|
177
|
+
if not self.path.exists():
|
|
178
|
+
return []
|
|
179
|
+
return [
|
|
180
|
+
(e["seq"], e["update"])
|
|
181
|
+
for e in self._read()
|
|
182
|
+
if e.get("kind") == "update" and e["seq"] > after
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
def answer(self) -> str | None:
|
|
186
|
+
"""The agent's message after the last prompt."""
|
|
187
|
+
text: list[str] = []
|
|
188
|
+
for _, update in self.updates():
|
|
189
|
+
kind = update.get("sessionUpdate")
|
|
190
|
+
if kind == "user_message_chunk":
|
|
191
|
+
text = []
|
|
192
|
+
elif kind == "agent_message_chunk":
|
|
193
|
+
content = update.get("content") or {}
|
|
194
|
+
if isinstance(content, dict) and isinstance(content.get("text"), str):
|
|
195
|
+
text.append(content["text"])
|
|
196
|
+
return "".join(text).strip() or None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Attachment:
|
|
200
|
+
"""One attached client (F.7). Messages are queued and written by the attachment's own task:
|
|
201
|
+
putting never blocks the agent, and a client that falls `QUEUE_SIZE` messages behind is
|
|
202
|
+
closed (4003) — it re-attaches and gets the log from where it was (`afterSeq`)."""
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
send: Callable[[str], Awaitable[None]],
|
|
207
|
+
close: Callable[[int, str], Awaitable[None]],
|
|
208
|
+
kind: str = "websocket",
|
|
209
|
+
) -> None:
|
|
210
|
+
self._send = send
|
|
211
|
+
self._close = close
|
|
212
|
+
self.kind = kind # "websocket" | "remote"
|
|
213
|
+
self.ready = False # the client has its replay: live messages may follow
|
|
214
|
+
self.closed = False
|
|
215
|
+
self.queue: asyncio.Queue[Message | tuple[int, str]] = asyncio.Queue(QUEUE_SIZE)
|
|
216
|
+
self.writer = asyncio.create_task(self._write())
|
|
217
|
+
|
|
218
|
+
@classmethod
|
|
219
|
+
def of(cls, ws: WebSocket) -> "Attachment":
|
|
220
|
+
async def close(code: int, reason: str) -> None:
|
|
221
|
+
await ws.close(code=code, reason=reason)
|
|
222
|
+
|
|
223
|
+
return cls(ws.send_text, close)
|
|
224
|
+
|
|
225
|
+
def put(self, msg: Message) -> None:
|
|
226
|
+
if self.closed:
|
|
227
|
+
return
|
|
228
|
+
try:
|
|
229
|
+
self.queue.put_nowait(msg)
|
|
230
|
+
except asyncio.QueueFull:
|
|
231
|
+
self.closed = True
|
|
232
|
+
self._close_now(AcpCloseCode.TOO_SLOW, "too slow")
|
|
233
|
+
|
|
234
|
+
def close(self, code: int, reason: str) -> None:
|
|
235
|
+
"""Close after what is already queued has been sent."""
|
|
236
|
+
if self.closed:
|
|
237
|
+
return
|
|
238
|
+
self.closed = True
|
|
239
|
+
try:
|
|
240
|
+
self.queue.put_nowait((code, reason[:120]))
|
|
241
|
+
except asyncio.QueueFull:
|
|
242
|
+
self._close_now(code, reason[:120])
|
|
243
|
+
|
|
244
|
+
def _close_now(self, code: int, reason: str) -> None:
|
|
245
|
+
"""Drop what is queued and close (the writer task becomes the closing one)."""
|
|
246
|
+
self.writer.cancel()
|
|
247
|
+
self.writer = asyncio.create_task(self._close_quietly(code, reason))
|
|
248
|
+
|
|
249
|
+
def discard(self) -> None:
|
|
250
|
+
"""The client is gone: stop writing to it (a close already queued is still sent)."""
|
|
251
|
+
if not self.closed:
|
|
252
|
+
self.closed = True
|
|
253
|
+
self.writer.cancel()
|
|
254
|
+
|
|
255
|
+
async def _write(self) -> None:
|
|
256
|
+
while True:
|
|
257
|
+
item = await self.queue.get()
|
|
258
|
+
if isinstance(item, tuple):
|
|
259
|
+
await self._close_quietly(*item)
|
|
260
|
+
return
|
|
261
|
+
with contextlib.suppress(Exception): # a closing client: it will re-attach
|
|
262
|
+
await self._send(json.dumps(item))
|
|
263
|
+
|
|
264
|
+
async def _close_quietly(self, code: int, reason: str) -> None:
|
|
265
|
+
with contextlib.suppress(Exception):
|
|
266
|
+
await self._close(code, reason)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class LiveSession:
|
|
270
|
+
"""One running agent. Every method runs on the event loop; `out` orders what the client gets."""
|
|
271
|
+
|
|
272
|
+
def __init__(
|
|
273
|
+
self, store: "SessionStore", record: SessionRecord, agent: AgentRecord, runs: RunsAs
|
|
274
|
+
) -> None:
|
|
275
|
+
self.store = store
|
|
276
|
+
self.record = record
|
|
277
|
+
self.agent = agent
|
|
278
|
+
self.runs = runs # the agent's model it runs with
|
|
279
|
+
self.log = store.event_log(record.id)
|
|
280
|
+
self.proc: asyncio.subprocess.Process | None = None
|
|
281
|
+
self.policy: AcpPolicy | None = None
|
|
282
|
+
self.init_result: Message = {}
|
|
283
|
+
self.modes: Any = None
|
|
284
|
+
self.config_options: Any = None
|
|
285
|
+
self.clients: list[Attachment] = [] # oldest first
|
|
286
|
+
self.remote = False # reachable through the remote connector (docs/dev/design/remote.md)
|
|
287
|
+
self.agent_lock = asyncio.Lock()
|
|
288
|
+
self.ids = itertools.count(1)
|
|
289
|
+
self.own: dict[str, asyncio.Future[Message]] = {} # the daemon's own requests
|
|
290
|
+
self.forwarded: dict[str, Forwarded] = {} # our id → the client's request
|
|
291
|
+
self.pending: dict[Any, Message] = {} # agent→client requests awaiting an answer
|
|
292
|
+
self.prompt_id: str | None = None # the turn in progress (our id of its session/prompt)
|
|
293
|
+
self.turn_started_at: datetime | None = None
|
|
294
|
+
self.stop_reason: str | None = None # why the daemon stopped it, if it did
|
|
295
|
+
self.stop_how: StopHow = "exited" # how, for the log (F.5)
|
|
296
|
+
self.replaying = False # updates are the agent's replay of history we have: not relayed
|
|
297
|
+
self.translate = False # the agent's session id is not the clients': translated (F.6)
|
|
298
|
+
self.idle: asyncio.TimerHandle | None = None
|
|
299
|
+
self.tasks: list[asyncio.Task[None]] = []
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def busy(self) -> bool:
|
|
303
|
+
return self.prompt_id is not None
|
|
304
|
+
|
|
305
|
+
# ── start ───────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
async def start(self) -> None:
|
|
308
|
+
rec, settings = self.record, self.store.settings
|
|
309
|
+
state_dir = self.store.folder(rec.id) / "agent"
|
|
310
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
self.log.redact = self.store.redactor(self.agent, self.runs)
|
|
312
|
+
subject = None
|
|
313
|
+
if rec.input is not None and (given := SessionInput.model_validate(rec.input)).subject:
|
|
314
|
+
try:
|
|
315
|
+
subject = interfaces.subject_mount(settings.skills_dir, given.subject)
|
|
316
|
+
except skills.SkillError as e:
|
|
317
|
+
raise SessionError(str(e)) from None
|
|
318
|
+
started = await launcher.start(
|
|
319
|
+
settings,
|
|
320
|
+
self.agent,
|
|
321
|
+
self.runs,
|
|
322
|
+
rec.id,
|
|
323
|
+
str(state_dir),
|
|
324
|
+
folder=rec.scope,
|
|
325
|
+
subject=subject,
|
|
326
|
+
)
|
|
327
|
+
session_files.record_mounts(self.store.mounts(rec.id), started.spec, now())
|
|
328
|
+
rec.harness_version = await launcher.harness_version(rec.harness)
|
|
329
|
+
self.proc = started.process # ty: ignore[invalid-assignment]
|
|
330
|
+
self.policy = AcpPolicy(
|
|
331
|
+
scope=rec.scope,
|
|
332
|
+
network=started.spec.network,
|
|
333
|
+
session_defaults=started.launch.session_defaults,
|
|
334
|
+
on_event=lambda text: log.info("[%s] policy: %s", rec.id, text),
|
|
335
|
+
)
|
|
336
|
+
self.agent.connections[rec.id] = LiveConnection(
|
|
337
|
+
id=rec.id,
|
|
338
|
+
sandbox=started.spec.name,
|
|
339
|
+
since=now(),
|
|
340
|
+
process=self.proc,
|
|
341
|
+
egress_base=started.egress_base,
|
|
342
|
+
)
|
|
343
|
+
assert self.proc is not None
|
|
344
|
+
registry = self.store.registry
|
|
345
|
+
self.tasks = [
|
|
346
|
+
asyncio.create_task(self.agent_pump()),
|
|
347
|
+
asyncio.create_task(
|
|
348
|
+
read_stderr(self.proc, rec.id, lambda e: registry.add_event(self.agent, rec.id, e))
|
|
349
|
+
),
|
|
350
|
+
]
|
|
351
|
+
caps = {"fs": {"readTextFile": False, "writeTextFile": False}, "terminal": False}
|
|
352
|
+
info = {"name": "handlerd", "version": __version__}
|
|
353
|
+
self.init_result = await self.request(
|
|
354
|
+
"initialize", {"protocolVersion": 1, "clientCapabilities": caps, "clientInfo": info}
|
|
355
|
+
)
|
|
356
|
+
rec.can_resume = bool((self.init_result.get("agentCapabilities") or {}).get("loadSession"))
|
|
357
|
+
params = {"cwd": rec.scope, "mcpServers": []}
|
|
358
|
+
if rec.acp_session_id and self.log.had_turn:
|
|
359
|
+
if not rec.can_resume:
|
|
360
|
+
raise SessionError("this agent cannot resume sessions")
|
|
361
|
+
# the agent replays its history: already in our log, unless we have none (F.5)
|
|
362
|
+
replay = not self.log.updates()
|
|
363
|
+
self.translate = rec.agent_session_id is not None
|
|
364
|
+
loaded = rec.agent_session_id or rec.acp_session_id
|
|
365
|
+
result = await self.request(
|
|
366
|
+
"session/load", params | {"sessionId": loaded}, replay=replay
|
|
367
|
+
)
|
|
368
|
+
else: # also a session stopped before its first turn: the agent kept nothing to load
|
|
369
|
+
self.translate = rec.acp_session_id is not None
|
|
370
|
+
result = await self.request("session/new", params)
|
|
371
|
+
if self.translate:
|
|
372
|
+
rec.agent_session_id = result["sessionId"]
|
|
373
|
+
else:
|
|
374
|
+
rec.acp_session_id = result["sessionId"]
|
|
375
|
+
self.modes, self.config_options = result.get("modes"), result.get("configOptions")
|
|
376
|
+
rec.state, rec.error = "running", None
|
|
377
|
+
self.log.append("lifecycle", event="started")
|
|
378
|
+
self.store.save(rec)
|
|
379
|
+
self.schedule_idle()
|
|
380
|
+
|
|
381
|
+
async def request(self, method: str, params: Message, *, replay: bool = True) -> Message:
|
|
382
|
+
"""A request of the daemon itself, through the policy (P1, P2, P7 apply)."""
|
|
383
|
+
rid = f"handler-d-{next(self.ids)}"
|
|
384
|
+
future: asyncio.Future[Message] = asyncio.get_running_loop().create_future()
|
|
385
|
+
self.own[rid] = future
|
|
386
|
+
self.replaying = not replay
|
|
387
|
+
try:
|
|
388
|
+
await self.execute({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
|
|
389
|
+
response = await asyncio.wait_for(future, START_TIMEOUT)
|
|
390
|
+
finally:
|
|
391
|
+
self.own.pop(rid, None)
|
|
392
|
+
self.replaying = False
|
|
393
|
+
if "error" in response:
|
|
394
|
+
raise SessionError(f"{method}: {response['error'].get('message', response['error'])}")
|
|
395
|
+
return response.get("result") or {}
|
|
396
|
+
|
|
397
|
+
# ── agent side ──────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
async def execute(self, msg: Message) -> None:
|
|
400
|
+
"""Client-side message (the daemon's or the client's) → policy → agent / back."""
|
|
401
|
+
assert self.policy is not None
|
|
402
|
+
for action in self.policy.from_client(msg):
|
|
403
|
+
await self.dispatch(action)
|
|
404
|
+
|
|
405
|
+
async def dispatch(self, action: ToAgent | ToClient) -> None:
|
|
406
|
+
if isinstance(action, ToAgent):
|
|
407
|
+
await self.to_agent(action.msg)
|
|
408
|
+
else:
|
|
409
|
+
await self.route(action.msg)
|
|
410
|
+
|
|
411
|
+
async def to_agent(self, msg: Message) -> None:
|
|
412
|
+
assert self.proc is not None and self.proc.stdin is not None
|
|
413
|
+
if self.translate and self.record.agent_session_id is not None:
|
|
414
|
+
msg = with_session_id(msg, self.record.acp_session_id, self.record.agent_session_id)
|
|
415
|
+
async with self.agent_lock:
|
|
416
|
+
with contextlib.suppress(ConnectionError):
|
|
417
|
+
self.proc.stdin.write(json.dumps(msg).encode() + b"\n")
|
|
418
|
+
await self.proc.stdin.drain()
|
|
419
|
+
|
|
420
|
+
async def agent_pump(self) -> None:
|
|
421
|
+
assert self.proc is not None and self.proc.stdout is not None and self.policy is not None
|
|
422
|
+
try:
|
|
423
|
+
while True:
|
|
424
|
+
try:
|
|
425
|
+
line = await self.proc.stdout.readline()
|
|
426
|
+
except (ValueError, asyncio.LimitOverrunError):
|
|
427
|
+
log.warning("[%s] agent message too big: stopping", self.record.id)
|
|
428
|
+
self.stop_reason, self.stop_how = "agent message too big", "error"
|
|
429
|
+
break
|
|
430
|
+
if not line:
|
|
431
|
+
break
|
|
432
|
+
msg = parse(line.decode(errors="replace"))
|
|
433
|
+
if msg is None:
|
|
434
|
+
log.warning("[%s] dropped a non-object agent message", self.record.id)
|
|
435
|
+
continue
|
|
436
|
+
if self.translate: # its one session, whatever id the agent gave it
|
|
437
|
+
msg = with_session_id(msg, None, self.record.acp_session_id)
|
|
438
|
+
for action in self.policy.from_agent(msg):
|
|
439
|
+
await self.dispatch(action)
|
|
440
|
+
finally:
|
|
441
|
+
await self.ended()
|
|
442
|
+
|
|
443
|
+
def responded(self, m: Message) -> None:
|
|
444
|
+
"""The agent's answer to a request: the daemon's own, or a client's (ids restored)."""
|
|
445
|
+
if (future := self.own.get(m["id"])) is not None:
|
|
446
|
+
if not future.done():
|
|
447
|
+
future.set_result(m)
|
|
448
|
+
return
|
|
449
|
+
entry = self.forwarded.pop(m["id"], None)
|
|
450
|
+
if entry is None:
|
|
451
|
+
return
|
|
452
|
+
if entry.client in self.clients: # else: detached, the turn goes on
|
|
453
|
+
entry.client.put(m | {"id": entry.id})
|
|
454
|
+
if "error" not in m:
|
|
455
|
+
self.mirror(entry, m.get("result"))
|
|
456
|
+
if entry.method == "session/prompt":
|
|
457
|
+
stop = (m.get("result") or {}).get("stopReason", "error")
|
|
458
|
+
self.end_turn(stop)
|
|
459
|
+
self.broadcast(notification("_handler/turn_ended", stopReason=stop), entry.client)
|
|
460
|
+
self.broadcast_state()
|
|
461
|
+
|
|
462
|
+
async def route(self, m: Message) -> None:
|
|
463
|
+
"""An agent message for the client side, after the policy."""
|
|
464
|
+
if is_response(m):
|
|
465
|
+
self.responded(m)
|
|
466
|
+
return
|
|
467
|
+
if m.get("method") == "session/update":
|
|
468
|
+
update = (m.get("params") or {}).get("update") or {}
|
|
469
|
+
if self.replaying:
|
|
470
|
+
return
|
|
471
|
+
self.observe_update(update)
|
|
472
|
+
# user messages are logged from the prompts themselves (the agent may echo them)
|
|
473
|
+
if not (self.busy and update.get("sessionUpdate") == "user_message_chunk"):
|
|
474
|
+
self.log.append("update", update=update)
|
|
475
|
+
self.broadcast(m)
|
|
476
|
+
elif is_request(m) and self.record.interface is not None: # a run: no one to ask
|
|
477
|
+
await self.to_agent(self.auto_answer(m))
|
|
478
|
+
elif is_request(m): # session/request_permission (P4 answered fs/*, terminal/*)
|
|
479
|
+
self.pending[m["id"]] = m
|
|
480
|
+
self.broadcast(m)
|
|
481
|
+
self.broadcast_state()
|
|
482
|
+
else:
|
|
483
|
+
self.broadcast(m)
|
|
484
|
+
|
|
485
|
+
def auto_answer(self, request: Message) -> Message:
|
|
486
|
+
"""A run is in mode `bypass` (D.18): a request its harness still sends gets its first
|
|
487
|
+
allow option; `cancelled` when there is none."""
|
|
488
|
+
params = request.get("params") or {}
|
|
489
|
+
options = params.get("options") or []
|
|
490
|
+
kinds = ("allow_once", "allow_always")
|
|
491
|
+
chosen = next((o for k in kinds for o in options if o.get("kind") == k), None)
|
|
492
|
+
outcome: Message = {"outcome": "cancelled"}
|
|
493
|
+
if chosen is not None:
|
|
494
|
+
outcome = {"outcome": "selected", "optionId": chosen["optionId"]}
|
|
495
|
+
title = (params.get("toolCall") or {}).get("title")
|
|
496
|
+
self.log.append("lifecycle", event="permission answered", detail=f"{title}: {outcome}")
|
|
497
|
+
return {"jsonrpc": "2.0", "id": request["id"], "result": {"outcome": outcome}}
|
|
498
|
+
|
|
499
|
+
def observe_update(self, update: Message) -> None:
|
|
500
|
+
kind = update.get("sessionUpdate")
|
|
501
|
+
if kind == "session_info_update" and "title" in update:
|
|
502
|
+
self.record.title = update["title"]
|
|
503
|
+
elif kind == "current_mode_update" and isinstance(self.modes, dict):
|
|
504
|
+
self.modes = self.modes | {"currentModeId": update.get("currentModeId")}
|
|
505
|
+
elif kind == "config_option_update":
|
|
506
|
+
self.config_options = update.get("configOptions")
|
|
507
|
+
|
|
508
|
+
def mirror(self, entry: "Forwarded", result: Any) -> None:
|
|
509
|
+
"""A client changed the mode or an option: keep it for later `session/load` answers
|
|
510
|
+
(F.4), and show it to the other clients (F.7)."""
|
|
511
|
+
update: Message | None = None
|
|
512
|
+
if entry.method == "session/set_mode" and isinstance(self.modes, dict):
|
|
513
|
+
mode = entry.params.get("modeId")
|
|
514
|
+
self.modes = self.modes | {"currentModeId": mode}
|
|
515
|
+
update = {"sessionUpdate": "current_mode_update", "currentModeId": mode}
|
|
516
|
+
elif entry.method == "session/set_config_option" and isinstance(result, dict):
|
|
517
|
+
if (options := result.get("configOptions")) is not None:
|
|
518
|
+
self.config_options = options
|
|
519
|
+
update = {"sessionUpdate": "config_option_update", "configOptions": options}
|
|
520
|
+
if update is not None:
|
|
521
|
+
params = {"sessionId": self.record.acp_session_id, "update": update}
|
|
522
|
+
self.broadcast(notification("session/update", **params), entry.client)
|
|
523
|
+
|
|
524
|
+
def end_turn(self, stop_reason: str) -> None:
|
|
525
|
+
self.prompt_id = self.turn_started_at = None
|
|
526
|
+
self.record.last_stop_reason = stop_reason
|
|
527
|
+
self.log.append("turn", phase="end", stopReason=stop_reason)
|
|
528
|
+
self.touch()
|
|
529
|
+
self.schedule_idle()
|
|
530
|
+
|
|
531
|
+
async def ended(self) -> None:
|
|
532
|
+
"""The agent is gone (exit, stop, idle timeout): the session becomes `stopped`."""
|
|
533
|
+
rec = self.record
|
|
534
|
+
code = await self.proc.wait() if self.proc else None
|
|
535
|
+
for task in self.tasks:
|
|
536
|
+
if task is not asyncio.current_task():
|
|
537
|
+
task.cancel()
|
|
538
|
+
self.cancel_idle()
|
|
539
|
+
for future in self.own.values():
|
|
540
|
+
if not future.done():
|
|
541
|
+
future.set_exception(SessionError(f"agent exited ({code})"))
|
|
542
|
+
reason = self.stop_reason or f"agent exited ({code})"
|
|
543
|
+
if self.busy:
|
|
544
|
+
self.log.append("turn", phase="end", stopReason="stopped")
|
|
545
|
+
self.prompt_id = self.turn_started_at = None
|
|
546
|
+
self.forwarded.clear()
|
|
547
|
+
self.pending.clear()
|
|
548
|
+
self.remote = False # reset: switched on again by hand after a restart
|
|
549
|
+
self.agent.connections.pop(rec.id, None)
|
|
550
|
+
self.store.live.pop(rec.id, None)
|
|
551
|
+
if rec.state == "running":
|
|
552
|
+
rec.state = "stopped"
|
|
553
|
+
rec.error = None if self.stop_reason or code == 0 else reason
|
|
554
|
+
self.log.append("lifecycle", event="stopped", detail=reason, how=self.stop_how)
|
|
555
|
+
self.store.save(rec)
|
|
556
|
+
clients, self.clients = self.clients, []
|
|
557
|
+
for client in clients:
|
|
558
|
+
client.close(AcpCloseCode.SESSION_STOPPED, reason)
|
|
559
|
+
self.store.changed()
|
|
560
|
+
|
|
561
|
+
async def stop(self, reason: str, how: StopHow) -> None:
|
|
562
|
+
self.stop_reason, self.stop_how = reason, how
|
|
563
|
+
if self.proc is not None:
|
|
564
|
+
await stop(self.proc)
|
|
565
|
+
|
|
566
|
+
# ── client side (F.4) ───────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
async def attach(self, ws: WebSocket) -> None:
|
|
569
|
+
"""Serve one attached WebSocket until it closes, or the daemon closes it."""
|
|
570
|
+
client = self.add(Attachment.of(ws))
|
|
571
|
+
try:
|
|
572
|
+
while not client.closed:
|
|
573
|
+
event = await ws.receive()
|
|
574
|
+
if event["type"] == "websocket.disconnect":
|
|
575
|
+
return
|
|
576
|
+
if event.get("bytes") is not None:
|
|
577
|
+
client.close(AcpCloseCode.UNSUPPORTED_DATA, "binary frame")
|
|
578
|
+
return
|
|
579
|
+
for line in (event.get("text") or "").splitlines():
|
|
580
|
+
if client.closed:
|
|
581
|
+
return
|
|
582
|
+
if line.strip() and (msg := parse(line)) is not None:
|
|
583
|
+
await self.from_client(client, msg)
|
|
584
|
+
finally:
|
|
585
|
+
self.remove(client)
|
|
586
|
+
|
|
587
|
+
def add(self, client: Attachment) -> Attachment:
|
|
588
|
+
"""A new attachment; beyond `HANDLER_SESSION_MAX_ATTACH`, the oldest is closed (4001)."""
|
|
589
|
+
while len(self.clients) >= self.store.settings.session_max_attach:
|
|
590
|
+
oldest = self.clients.pop(0)
|
|
591
|
+
oldest.close(AcpCloseCode.ATTACHED_ELSEWHERE, "attached elsewhere")
|
|
592
|
+
self.clients.append(client)
|
|
593
|
+
self.cancel_idle()
|
|
594
|
+
self.broadcast_state()
|
|
595
|
+
return client
|
|
596
|
+
|
|
597
|
+
def remove(self, client: Attachment) -> None:
|
|
598
|
+
"""The client has gone (or was closed): the agent and its turn go on."""
|
|
599
|
+
if client in self.clients:
|
|
600
|
+
self.clients.remove(client)
|
|
601
|
+
client.discard()
|
|
602
|
+
self.broadcast_state()
|
|
603
|
+
self.schedule_idle()
|
|
604
|
+
|
|
605
|
+
async def from_client(self, client: Attachment, m: Message) -> None:
|
|
606
|
+
method = m.get("method")
|
|
607
|
+
if is_response(m): # an answer to a permission request: the first one wins (F.7)
|
|
608
|
+
if (request := self.pending.pop(m["id"], None)) is not None:
|
|
609
|
+
await self.to_agent(m)
|
|
610
|
+
self.resolved(request, m, client)
|
|
611
|
+
return # an answer to a request already answered, or unknown: dropped
|
|
612
|
+
request = is_request(m)
|
|
613
|
+
raw = m.get("params")
|
|
614
|
+
params: Message = raw if isinstance(raw, dict) else {}
|
|
615
|
+
if method == "initialize" and request:
|
|
616
|
+
client.put({"jsonrpc": "2.0", "id": m["id"], "result": self.init_result})
|
|
617
|
+
return
|
|
618
|
+
if method == "session/load" and request:
|
|
619
|
+
self.load(client, m, params)
|
|
620
|
+
return
|
|
621
|
+
session_id = params.get("sessionId")
|
|
622
|
+
other = session_id is not None and session_id != self.record.acp_session_id
|
|
623
|
+
if request and (method in ("session/new", "session/resume") or other):
|
|
624
|
+
code = SessionErrorCode.ONE_SESSION_PER_ATTACHMENT
|
|
625
|
+
client.put(error(m["id"], code, "one session per attachment"))
|
|
626
|
+
return
|
|
627
|
+
if method == "session/prompt" and request:
|
|
628
|
+
if self.record.interface is not None:
|
|
629
|
+
code = SessionErrorCode.UNATTENDED
|
|
630
|
+
client.put(error(m["id"], code, "unattended: a run takes no prompt"))
|
|
631
|
+
return
|
|
632
|
+
if self.busy:
|
|
633
|
+
code = SessionErrorCode.TURN_IN_PROGRESS
|
|
634
|
+
client.put(error(m["id"], code, "turn in progress"))
|
|
635
|
+
return
|
|
636
|
+
if self.record.instructions:
|
|
637
|
+
params = params | {"prompt": instructed(self.record.instructions, params)}
|
|
638
|
+
m = m | {"params": params}
|
|
639
|
+
self.record.instructions = None
|
|
640
|
+
self.store.save(self.record)
|
|
641
|
+
self.begin_turn(params.get("prompt") or [], client)
|
|
642
|
+
if request:
|
|
643
|
+
ours = f"handler-c-{next(self.ids)}"
|
|
644
|
+
self.forwarded[ours] = Forwarded(client, m["id"], str(method), params)
|
|
645
|
+
if method == "session/prompt":
|
|
646
|
+
self.prompt_id = ours
|
|
647
|
+
m = m | {"id": ours}
|
|
648
|
+
await self.execute(m)
|
|
649
|
+
|
|
650
|
+
def resolved(self, request: Message, answer: Message, by: Attachment) -> None:
|
|
651
|
+
"""Tell the other clients a permission request was answered, so they drop it."""
|
|
652
|
+
tool_call = (request.get("params") or {}).get("toolCall") or {}
|
|
653
|
+
outcome = (answer.get("result") or {}).get("outcome")
|
|
654
|
+
self.broadcast(
|
|
655
|
+
notification(
|
|
656
|
+
"_handler/permission_resolved",
|
|
657
|
+
requestId=request["id"],
|
|
658
|
+
toolCallId=tool_call.get("toolCallId"),
|
|
659
|
+
outcome=outcome,
|
|
660
|
+
),
|
|
661
|
+
by,
|
|
662
|
+
)
|
|
663
|
+
self.broadcast_state()
|
|
664
|
+
self.schedule_idle()
|
|
665
|
+
|
|
666
|
+
async def first_prompt(self, blocks: list[Message], timeout: float | None = None) -> None:
|
|
667
|
+
"""The request a session was created with, sent by the daemon itself as its first turn:
|
|
668
|
+
the attached clients see it like another client's prompt (F.7). After `timeout` seconds
|
|
669
|
+
the turn is cancelled (and ends as `timed_out`)."""
|
|
670
|
+
rid = f"handler-d-{next(self.ids)}"
|
|
671
|
+
future: asyncio.Future[Message] = asyncio.get_running_loop().create_future()
|
|
672
|
+
self.own[rid] = future
|
|
673
|
+
self.begin_turn(blocks, None)
|
|
674
|
+
self.prompt_id = rid
|
|
675
|
+
params = {"sessionId": self.record.acp_session_id, "prompt": blocks}
|
|
676
|
+
timed_out = False
|
|
677
|
+
try:
|
|
678
|
+
await self.execute(
|
|
679
|
+
{"jsonrpc": "2.0", "id": rid, "method": "session/prompt", "params": params}
|
|
680
|
+
)
|
|
681
|
+
try:
|
|
682
|
+
response = await asyncio.wait_for(asyncio.shield(future), timeout)
|
|
683
|
+
except TimeoutError:
|
|
684
|
+
timed_out = True
|
|
685
|
+
await self.cancel_turn()
|
|
686
|
+
response = await asyncio.wait_for(future, CANCEL_GRACE)
|
|
687
|
+
except (SessionError, TimeoutError):
|
|
688
|
+
return # the agent is gone (`ended` closed the turn), or ignored the cancel
|
|
689
|
+
finally:
|
|
690
|
+
self.own.pop(rid, None)
|
|
691
|
+
if self.prompt_id != rid:
|
|
692
|
+
return
|
|
693
|
+
stop = (response.get("result") or {}).get("stopReason", "error")
|
|
694
|
+
stop = "timed_out" if timed_out else stop
|
|
695
|
+
self.end_turn(stop)
|
|
696
|
+
self.broadcast(notification("_handler/turn_ended", stopReason=stop))
|
|
697
|
+
self.broadcast_state()
|
|
698
|
+
|
|
699
|
+
async def cancel_turn(self) -> None:
|
|
700
|
+
"""session/cancel for the turn in progress, whoever started it."""
|
|
701
|
+
if self.busy:
|
|
702
|
+
params = {"sessionId": self.record.acp_session_id}
|
|
703
|
+
await self.execute({"jsonrpc": "2.0", "method": "session/cancel", "params": params})
|
|
704
|
+
|
|
705
|
+
def begin_turn(self, prompt: list[Any], by: Attachment | None) -> None:
|
|
706
|
+
self.cancel_idle()
|
|
707
|
+
for block in prompt:
|
|
708
|
+
update = {"sessionUpdate": "user_message_chunk", "content": block}
|
|
709
|
+
self.log.append("update", update=update)
|
|
710
|
+
params = {"sessionId": self.record.acp_session_id, "update": update}
|
|
711
|
+
self.broadcast(
|
|
712
|
+
notification("session/update", **params, _meta={"handler.dev/seq": self.log.seq}),
|
|
713
|
+
by,
|
|
714
|
+
)
|
|
715
|
+
self.log.append("turn", phase="start")
|
|
716
|
+
self.touch()
|
|
717
|
+
self.prompt_id = "starting" # busy from now on; set to the forwarded id right after
|
|
718
|
+
self.turn_started_at = now()
|
|
719
|
+
self.broadcast_state()
|
|
720
|
+
|
|
721
|
+
def load(self, client: Attachment, m: Message, params: Message) -> None:
|
|
722
|
+
"""The attach proper: replay the log, answer with the current state, re-send pending
|
|
723
|
+
permission requests; from then on the client gets live traffic."""
|
|
724
|
+
assert self.policy is not None
|
|
725
|
+
if params.get("sessionId") != self.record.acp_session_id:
|
|
726
|
+
code = SessionErrorCode.ONE_SESSION_PER_ATTACHMENT
|
|
727
|
+
client.put(error(m["id"], code, "one session per attachment"))
|
|
728
|
+
return
|
|
729
|
+
if refusal := self.policy.check_session_params(m): # P2 / P3
|
|
730
|
+
client.put(refusal)
|
|
731
|
+
return
|
|
732
|
+
meta = params.get("_meta")
|
|
733
|
+
after = meta.get("handler.dev/afterSeq", 0) if isinstance(meta, dict) else 0
|
|
734
|
+
result = {
|
|
735
|
+
k: v
|
|
736
|
+
for k, v in (("modes", self.modes), ("configOptions", self.config_options))
|
|
737
|
+
if v is not None
|
|
738
|
+
}
|
|
739
|
+
# no await from here on: nothing can be broadcast between the replay and `ready`
|
|
740
|
+
for seq, update in self.log.updates(after if isinstance(after, int) else 0):
|
|
741
|
+
params_ = {"sessionId": self.record.acp_session_id, "update": update}
|
|
742
|
+
client.put(notification("session/update", **params_, _meta={"handler.dev/seq": seq}))
|
|
743
|
+
client.put({"jsonrpc": "2.0", "id": m["id"], "result": result})
|
|
744
|
+
client.put(notification("_handler/session_state", **self.state()))
|
|
745
|
+
for pending in self.pending.values():
|
|
746
|
+
client.put(pending)
|
|
747
|
+
client.ready = True
|
|
748
|
+
|
|
749
|
+
def state(self) -> Message:
|
|
750
|
+
return {
|
|
751
|
+
"state": self.record.state,
|
|
752
|
+
"busy": self.busy,
|
|
753
|
+
"turnStartedAt": self.turn_started_at.isoformat() if self.turn_started_at else None,
|
|
754
|
+
"pendingPermissions": len(self.pending),
|
|
755
|
+
"attached": len(self.clients),
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
def broadcast(self, msg: Message, but: Attachment | None = None) -> None:
|
|
759
|
+
for client in self.clients:
|
|
760
|
+
if client.ready and client is not but:
|
|
761
|
+
client.put(msg)
|
|
762
|
+
|
|
763
|
+
def broadcast_state(self) -> None:
|
|
764
|
+
self.broadcast(notification("_handler/session_state", **self.state()))
|
|
765
|
+
|
|
766
|
+
# ── idle stop (F.6.3) ───────────────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
def touch(self) -> None:
|
|
769
|
+
self.record.last_activity_at = now()
|
|
770
|
+
self.store.save(self.record)
|
|
771
|
+
|
|
772
|
+
def schedule_idle(self) -> None:
|
|
773
|
+
self.cancel_idle()
|
|
774
|
+
if not self.clients and not self.busy and not self.pending:
|
|
775
|
+
loop = asyncio.get_running_loop()
|
|
776
|
+
self.idle = loop.call_later(
|
|
777
|
+
self.store.settings.session_idle_timeout,
|
|
778
|
+
lambda: self.store.spawn(self.stop("idle timeout", "idle")),
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
def cancel_idle(self) -> None:
|
|
782
|
+
if self.idle is not None:
|
|
783
|
+
self.idle.cancel()
|
|
784
|
+
self.idle = None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def title_of(prompt: str) -> str:
|
|
788
|
+
line = next((x.strip() for x in prompt.splitlines() if x.strip()), "")
|
|
789
|
+
return line if len(line) <= TITLE_LENGTH else line[: TITLE_LENGTH - 1].rstrip() + "…"
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def notification(method: str, **params: Any) -> Message:
|
|
793
|
+
return {"jsonrpc": "2.0", "method": method, "params": params}
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
@dataclass
|
|
797
|
+
class Forwarded:
|
|
798
|
+
"""A client's request, forwarded to the agent under our own id."""
|
|
799
|
+
|
|
800
|
+
client: Attachment
|
|
801
|
+
id: Any # the client's id, restored on the response
|
|
802
|
+
method: str
|
|
803
|
+
params: Message
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
class SessionStore:
|
|
807
|
+
def __init__(self, settings: Settings, registry: AgentRegistry) -> None:
|
|
808
|
+
self.settings = settings
|
|
809
|
+
self.registry = registry
|
|
810
|
+
self.dir = settings.state_dir / "sessions"
|
|
811
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
812
|
+
self.records: dict[str, SessionRecord] = {}
|
|
813
|
+
self.live: dict[str, LiveSession] = {}
|
|
814
|
+
self.starting: dict[str, asyncio.Task[LiveSession]] = {}
|
|
815
|
+
self.background: set[asyncio.Task[Any]] = set()
|
|
816
|
+
self.listeners: list[Callable[[], None]] = [] # told when sessions stop or are shared
|
|
817
|
+
# told with its id when a session's `lifecycle stopped` event is written (automations)
|
|
818
|
+
self.stop_listeners: list[Callable[[str], None]] = []
|
|
819
|
+
self._load()
|
|
820
|
+
|
|
821
|
+
# ── persistence ─────────────────────────────────────────────────────
|
|
822
|
+
|
|
823
|
+
def folder(self, session_id: str) -> Path:
|
|
824
|
+
return self.dir / session_id
|
|
825
|
+
|
|
826
|
+
def event_log(self, session_id: str) -> EventLog:
|
|
827
|
+
def stopped() -> None:
|
|
828
|
+
for listener in self.stop_listeners:
|
|
829
|
+
listener(session_id)
|
|
830
|
+
|
|
831
|
+
return EventLog(self.folder(session_id) / "events.ndjson", stopped)
|
|
832
|
+
|
|
833
|
+
def _load(self) -> None:
|
|
834
|
+
"""F.6.5: sessions that were live when the daemon stopped are `stopped` now."""
|
|
835
|
+
for path in self.dir.glob("*/session.json"):
|
|
836
|
+
try:
|
|
837
|
+
rec = SessionRecord.from_json(json.loads(path.read_text()))
|
|
838
|
+
except (ValueError, TypeError, KeyError) as e:
|
|
839
|
+
log.warning("ignoring %s: %s", path, e)
|
|
840
|
+
continue
|
|
841
|
+
self.records[rec.id] = rec
|
|
842
|
+
if rec.state in ("starting", "running"):
|
|
843
|
+
events = self.event_log(rec.id)
|
|
844
|
+
if events.open_turn:
|
|
845
|
+
events.append("turn", phase="end", stopReason="daemon_restarted")
|
|
846
|
+
events.append(
|
|
847
|
+
"lifecycle", event="stopped", detail="daemon restarted", how="daemon_restarted"
|
|
848
|
+
)
|
|
849
|
+
rec.state = "stopped" if rec.acp_session_id else "failed"
|
|
850
|
+
self.save(rec)
|
|
851
|
+
|
|
852
|
+
def save(self, rec: SessionRecord) -> None:
|
|
853
|
+
folder = self.folder(rec.id)
|
|
854
|
+
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
855
|
+
tmp = folder / "session.json.tmp"
|
|
856
|
+
tmp.write_text(json.dumps(rec.to_json(), indent=2))
|
|
857
|
+
tmp.replace(folder / "session.json")
|
|
858
|
+
|
|
859
|
+
def redactor(self, agent: AgentRecord, runs: RunsAs) -> Any:
|
|
860
|
+
"""Invariant 4: its account's or provider's and bindings' secret values never land in the
|
|
861
|
+
event log."""
|
|
862
|
+
return redactor(launcher.secret_values(self.settings, agent, runs))
|
|
863
|
+
|
|
864
|
+
def spawn(self, coro: Any) -> None:
|
|
865
|
+
task = asyncio.ensure_future(coro)
|
|
866
|
+
self.background.add(task)
|
|
867
|
+
task.add_done_callback(self.background.discard)
|
|
868
|
+
|
|
869
|
+
# ── API ─────────────────────────────────────────────────────────────
|
|
870
|
+
|
|
871
|
+
def to_contract(self, rec: SessionRecord) -> Session:
|
|
872
|
+
live = self.live.get(rec.id)
|
|
873
|
+
agent = self.registry.get(rec.agent_id)
|
|
874
|
+
return Session(
|
|
875
|
+
id=rec.id,
|
|
876
|
+
agent_id=rec.agent_id,
|
|
877
|
+
project_id=agent.project_id if agent else None,
|
|
878
|
+
harness=rec.harness,
|
|
879
|
+
account=rec.account,
|
|
880
|
+
provider=rec.provider,
|
|
881
|
+
model=rec.model,
|
|
882
|
+
scope=rec.scope,
|
|
883
|
+
acp_session_id=rec.acp_session_id,
|
|
884
|
+
title=rec.title,
|
|
885
|
+
state=rec.state,
|
|
886
|
+
busy=bool(live and live.busy),
|
|
887
|
+
pending_permissions=len(live.pending) if live else 0,
|
|
888
|
+
attached=len(live.clients) if live else 0,
|
|
889
|
+
remote=bool(live and live.remote),
|
|
890
|
+
can_resume=rec.can_resume,
|
|
891
|
+
error=rec.error,
|
|
892
|
+
harness_version=rec.harness_version,
|
|
893
|
+
origin=rec.origin,
|
|
894
|
+
last_stop_reason=rec.last_stop_reason,
|
|
895
|
+
answer=rec.answer,
|
|
896
|
+
input=SessionInput.model_validate(rec.input) if rec.input else None,
|
|
897
|
+
output=SessionOutput.model_validate(rec.output) if rec.output else None,
|
|
898
|
+
created_at=rec.created_at,
|
|
899
|
+
last_activity_at=rec.last_activity_at,
|
|
900
|
+
acp_url=self.settings.base_url.replace("http://", "ws://") + session_acp_path(rec.id),
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
def list(self, agent_id: str | None = None) -> list[SessionRecord]:
|
|
904
|
+
# a copy: sync routes read it in a worker thread, the event loop changes it
|
|
905
|
+
records = list(self.records.values())
|
|
906
|
+
records = [r for r in records if agent_id is None or r.agent_id == agent_id]
|
|
907
|
+
return sorted(records, key=lambda r: r.last_activity_at, reverse=True)
|
|
908
|
+
|
|
909
|
+
def mounts(self, session_id: str) -> Path:
|
|
910
|
+
"""What its sandbox saw of the host at its last start (F.8)."""
|
|
911
|
+
return self.folder(session_id) / "mounts.json"
|
|
912
|
+
|
|
913
|
+
def baseline(self, session_id: str) -> Path:
|
|
914
|
+
"""Its folder as it started (F.8)."""
|
|
915
|
+
return self.folder(session_id) / "baseline.json"
|
|
916
|
+
|
|
917
|
+
def work_folder(self, session_id: str) -> Path:
|
|
918
|
+
"""A folderless agent's session's own folder (D.18)."""
|
|
919
|
+
return self.folder(session_id) / "work"
|
|
920
|
+
|
|
921
|
+
def create(
|
|
922
|
+
self,
|
|
923
|
+
agent: AgentRecord,
|
|
924
|
+
runs: RunsAs,
|
|
925
|
+
acp_session_id: str | None = None,
|
|
926
|
+
prompt: str | None = None,
|
|
927
|
+
*,
|
|
928
|
+
origin: SessionOrigin = "ui",
|
|
929
|
+
run: SessionInput | None = None,
|
|
930
|
+
from_skill: str | None = None,
|
|
931
|
+
) -> SessionRecord:
|
|
932
|
+
"""A new session, started in the background (F.3). With a `prompt` (the request), the
|
|
933
|
+
daemon sends it once the agent runs, after the agent's instructions if it has any (a
|
|
934
|
+
blank line between them, in one text block); without, the instructions go the same way
|
|
935
|
+
with the first prompt a client sends. The
|
|
936
|
+
request's first line is the title until the agent gives one. `runs`: the agent's model it
|
|
937
|
+
runs with (one of its own).
|
|
938
|
+
|
|
939
|
+
`run`: an agent with an interface's checked input (D.18): an unattended run. A
|
|
940
|
+
folderless agent's session works in its own folder, a copy of `from_skill` if given."""
|
|
941
|
+
sid = "s_" + pysecrets.token_hex(8)
|
|
942
|
+
scope = agent.scope
|
|
943
|
+
if scope is None:
|
|
944
|
+
work = self.work_folder(sid)
|
|
945
|
+
work.mkdir(parents=True)
|
|
946
|
+
work.parent.chmod(0o700)
|
|
947
|
+
if from_skill is not None:
|
|
948
|
+
skills.copy(self.settings.skills_dir / from_skill, work)
|
|
949
|
+
scope = str(work)
|
|
950
|
+
interface = agent.interface if run is not None else None
|
|
951
|
+
title = title_of(prompt) if prompt else None
|
|
952
|
+
timeout = None
|
|
953
|
+
if interface is not None and run is not None:
|
|
954
|
+
timeout = interface.timeout_sec
|
|
955
|
+
subject = run.subject.skill if run.subject else None
|
|
956
|
+
title = f"{agent.name or runs.harness}: {subject}" if subject else agent.name
|
|
957
|
+
rec = SessionRecord(
|
|
958
|
+
id=sid,
|
|
959
|
+
agent_id=agent.id,
|
|
960
|
+
harness=runs.harness,
|
|
961
|
+
account=runs.account,
|
|
962
|
+
provider=runs.provider,
|
|
963
|
+
model=runs.model,
|
|
964
|
+
scope=scope,
|
|
965
|
+
created_at=now(),
|
|
966
|
+
last_activity_at=now(),
|
|
967
|
+
acp_session_id=acp_session_id,
|
|
968
|
+
title=title,
|
|
969
|
+
origin=origin,
|
|
970
|
+
interface=interface.model_dump(mode="json", by_alias=True) if interface else None,
|
|
971
|
+
input=run.model_dump(mode="json", by_alias=True) if run is not None else None,
|
|
972
|
+
# with no request to go with, they go with the first prompt (a resumed one has had
|
|
973
|
+
# its first turn)
|
|
974
|
+
instructions=None if prompt or run or acp_session_id else agent.instructions,
|
|
975
|
+
)
|
|
976
|
+
self.records[rec.id] = rec
|
|
977
|
+
self.save(rec)
|
|
978
|
+
text = None
|
|
979
|
+
if interface is not None and run is not None:
|
|
980
|
+
notes = self.subject_notes(run)
|
|
981
|
+
text = interfaces.request(agent.instructions, interface, run, notes)
|
|
982
|
+
elif prompt:
|
|
983
|
+
text = f"{agent.instructions}\n\n{prompt}" if agent.instructions else prompt
|
|
984
|
+
# one block: every adapter reads it (some would drop a second one)
|
|
985
|
+
blocks = [{"type": "text", "text": text}] if text else []
|
|
986
|
+
self.spawn(self._start_quietly(rec.id, blocks, timeout))
|
|
987
|
+
return rec
|
|
988
|
+
|
|
989
|
+
def subject_notes(self, run: SessionInput) -> str | None:
|
|
990
|
+
"""What handlerd tells a run about its subject besides where it is: a skill's scan
|
|
991
|
+
(D.17)."""
|
|
992
|
+
if run.subject is None:
|
|
993
|
+
return None
|
|
994
|
+
return skill_scan.notes(skill_scan.scan(self.settings.skills_dir / run.subject.skill))
|
|
995
|
+
|
|
996
|
+
async def _start_quietly(
|
|
997
|
+
self, session_id: str, prompt: Sequence[Message] = (), timeout: float | None = None
|
|
998
|
+
) -> None:
|
|
999
|
+
with contextlib.suppress(SessionError):
|
|
1000
|
+
live = await self.ensure_running(session_id)
|
|
1001
|
+
if prompt:
|
|
1002
|
+
await live.first_prompt(list(prompt), timeout)
|
|
1003
|
+
if live.record.interface is not None: # a run: its result, then no one to talk to
|
|
1004
|
+
await self.read_output(live, timeout)
|
|
1005
|
+
self.keep_answer(live.record)
|
|
1006
|
+
await self.stop(session_id, "run ended", "run_ended")
|
|
1007
|
+
|
|
1008
|
+
async def read_output(self, live: LiveSession, timeout: float | None) -> None:
|
|
1009
|
+
"""A run's result, from its final message (D.18); asked once more when it is missing or
|
|
1010
|
+
invalid. None when its turn did not end (timed out, cancelled, failed)."""
|
|
1011
|
+
rec = live.record
|
|
1012
|
+
assert rec.interface is not None
|
|
1013
|
+
schema = AgentInterface.model_validate(rec.interface).result
|
|
1014
|
+
output: SessionOutput | None = None
|
|
1015
|
+
for attempt in range(2):
|
|
1016
|
+
if rec.last_stop_reason != "end_turn":
|
|
1017
|
+
break
|
|
1018
|
+
output = interfaces.parse(self.event_log(rec.id).answer(), schema)
|
|
1019
|
+
if output.valid or attempt == 1 or live.record.state != "running":
|
|
1020
|
+
break
|
|
1021
|
+
await live.first_prompt([{"type": "text", "text": interfaces.repair(output)}], timeout)
|
|
1022
|
+
rec.output = output.model_dump(mode="json", by_alias=True) if output else None
|
|
1023
|
+
self.save(rec)
|
|
1024
|
+
|
|
1025
|
+
def keep_answer(self, rec: SessionRecord) -> None:
|
|
1026
|
+
rec.answer = self.event_log(rec.id).answer()
|
|
1027
|
+
self.save(rec)
|
|
1028
|
+
|
|
1029
|
+
async def cancel(self, session_id: str) -> None:
|
|
1030
|
+
if (live := self.live.get(session_id)) is not None:
|
|
1031
|
+
await live.cancel_turn()
|
|
1032
|
+
|
|
1033
|
+
async def ensure_running(self, session_id: str) -> LiveSession:
|
|
1034
|
+
"""The live session, started if needed — at most one start at a time (F.6.1)."""
|
|
1035
|
+
if (live := self.live.get(session_id)) is not None and live.record.state == "running":
|
|
1036
|
+
return live
|
|
1037
|
+
task = self.starting.get(session_id)
|
|
1038
|
+
if task is None:
|
|
1039
|
+
task = asyncio.create_task(self._start(self.records[session_id]))
|
|
1040
|
+
self.starting[session_id] = task
|
|
1041
|
+
task.add_done_callback(lambda _: self.starting.pop(session_id, None))
|
|
1042
|
+
return await asyncio.shield(task)
|
|
1043
|
+
|
|
1044
|
+
async def _start(self, rec: SessionRecord) -> LiveSession:
|
|
1045
|
+
if rec.state == "failed":
|
|
1046
|
+
raise SessionError(rec.error or "session failed")
|
|
1047
|
+
agent = self.registry.get(rec.agent_id)
|
|
1048
|
+
if agent is None:
|
|
1049
|
+
raise SessionError("its agent was deleted")
|
|
1050
|
+
# its history belongs to the harness, login and folder it was started with
|
|
1051
|
+
# (its project's context is checked when it starts, launcher.prepare)
|
|
1052
|
+
runs = agent.resumes_as(rec.harness, rec.account, rec.provider, rec.model)
|
|
1053
|
+
if (scope := agent.scope or str(self.work_folder(rec.id))) != rec.scope:
|
|
1054
|
+
raise SessionError(
|
|
1055
|
+
f"the agent now works in {scope}; this conversation was in {rec.scope}: "
|
|
1056
|
+
"start a new session"
|
|
1057
|
+
)
|
|
1058
|
+
self.registry.touch(agent)
|
|
1059
|
+
first = rec.state == "starting"
|
|
1060
|
+
rec.state = "starting"
|
|
1061
|
+
self.save(rec)
|
|
1062
|
+
if first: # what its folder holds before the agent runs (F.8)
|
|
1063
|
+
try:
|
|
1064
|
+
await asyncio.to_thread(
|
|
1065
|
+
session_files.record_baseline, Path(rec.scope), self.baseline(rec.id)
|
|
1066
|
+
)
|
|
1067
|
+
except OSError as e:
|
|
1068
|
+
log.warning("session %s: its folder not recorded: %s", rec.id, e)
|
|
1069
|
+
live = LiveSession(self, rec, agent, runs)
|
|
1070
|
+
self.live[rec.id] = live
|
|
1071
|
+
try:
|
|
1072
|
+
await live.start()
|
|
1073
|
+
except (SessionError, launcher.AgentStartError, OSError) as e:
|
|
1074
|
+
log.warning("session %s: start failed: %s", rec.id, e)
|
|
1075
|
+
rec.state, rec.error = ("failed" if first else "stopped"), str(e)
|
|
1076
|
+
live.stop_reason = str(e)
|
|
1077
|
+
self.save(rec)
|
|
1078
|
+
await live.stop(str(e), "error")
|
|
1079
|
+
self.live.pop(rec.id, None)
|
|
1080
|
+
raise SessionError(str(e)) from None
|
|
1081
|
+
return live
|
|
1082
|
+
|
|
1083
|
+
def changed(self) -> None:
|
|
1084
|
+
for listener in self.listeners:
|
|
1085
|
+
listener()
|
|
1086
|
+
|
|
1087
|
+
def set_remote(self, session_id: str, enabled: bool) -> None:
|
|
1088
|
+
"""Share a running session with paired devices, or stop sharing it
|
|
1089
|
+
(docs/dev/design/remote.md §5). Off closes the remote attachments at once (4004)."""
|
|
1090
|
+
live = self.live.get(session_id)
|
|
1091
|
+
if live is None or live.record.state != "running":
|
|
1092
|
+
raise SessionError("the session is not running")
|
|
1093
|
+
live.remote = enabled
|
|
1094
|
+
if not enabled:
|
|
1095
|
+
for client in list(live.clients):
|
|
1096
|
+
if client.kind == "remote":
|
|
1097
|
+
client.close(AcpCloseCode.REMOTE_DISABLED, "remote access disabled")
|
|
1098
|
+
self.changed()
|
|
1099
|
+
|
|
1100
|
+
async def stop(self, session_id: str, reason: str, how: StopHow) -> None:
|
|
1101
|
+
if (task := self.starting.get(session_id)) is not None:
|
|
1102
|
+
with contextlib.suppress(SessionError):
|
|
1103
|
+
await asyncio.shield(task)
|
|
1104
|
+
if (live := self.live.get(session_id)) is not None:
|
|
1105
|
+
await live.stop(reason, how)
|
|
1106
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1107
|
+
await asyncio.gather(*live.tasks, return_exceptions=True)
|
|
1108
|
+
|
|
1109
|
+
async def delete(self, session_id: str) -> None:
|
|
1110
|
+
await self.stop(session_id, "deleted", "deleted")
|
|
1111
|
+
self.records.pop(session_id, None)
|
|
1112
|
+
shutil.rmtree(self.folder(session_id), ignore_errors=True)
|
|
1113
|
+
|
|
1114
|
+
async def stop_agent(self, agent_id: str) -> None:
|
|
1115
|
+
for rec in self.list(agent_id):
|
|
1116
|
+
await self.stop(rec.id, "workspace deleted", "deleted")
|