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.
Files changed (234) hide show
  1. handler_ai-0.1.0.dist-info/METADATA +60 -0
  2. handler_ai-0.1.0.dist-info/RECORD +234 -0
  3. handler_ai-0.1.0.dist-info/WHEEL +4 -0
  4. handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
  5. handler_cli/__init__.py +1 -0
  6. handler_cli/account_commands.py +314 -0
  7. handler_cli/addon_commands.py +128 -0
  8. handler_cli/agent_commands.py +139 -0
  9. handler_cli/app_commands.py +175 -0
  10. handler_cli/app_window.py +85 -0
  11. handler_cli/automation_commands.py +309 -0
  12. handler_cli/bridge.py +42 -0
  13. handler_cli/cli.py +326 -0
  14. handler_cli/client.py +81 -0
  15. handler_cli/daemon_commands.py +108 -0
  16. handler_cli/image_commands.py +102 -0
  17. handler_cli/inbox_commands.py +33 -0
  18. handler_cli/output.py +12 -0
  19. handler_cli/project_commands.py +63 -0
  20. handler_cli/provider_commands.py +113 -0
  21. handler_cli/secret_commands.py +64 -0
  22. handler_cli/session_bridge.py +93 -0
  23. handler_cli/session_commands.py +485 -0
  24. handler_cli/setup.py +120 -0
  25. handler_cli/terminal.py +280 -0
  26. handler_cli/terminal_client.py +37 -0
  27. handler_contracts/__init__.py +642 -0
  28. handler_contracts/account.py +36 -0
  29. handler_contracts/acp_endpoint.py +69 -0
  30. handler_contracts/addon.py +147 -0
  31. handler_contracts/agent_spec.py +75 -0
  32. handler_contracts/automation.py +285 -0
  33. handler_contracts/common.py +229 -0
  34. handler_contracts/control.py +1208 -0
  35. handler_contracts/harness.py +300 -0
  36. handler_contracts/miniapp.py +141 -0
  37. handler_contracts/provider.py +82 -0
  38. handler_contracts/remote.py +167 -0
  39. handler_contracts/sandbox.py +199 -0
  40. handler_contracts/schemas.py +313 -0
  41. handler_contracts/terminal.py +32 -0
  42. handler_control/__init__.py +7 -0
  43. handler_control/access.py +62 -0
  44. handler_control/accounts.py +279 -0
  45. handler_control/addon_install.py +268 -0
  46. handler_control/addons.py +118 -0
  47. handler_control/agent_state.py +46 -0
  48. handler_control/agents.py +257 -0
  49. handler_control/api/__init__.py +137 -0
  50. handler_control/api/accounts.py +231 -0
  51. handler_control/api/acp.py +116 -0
  52. handler_control/api/addons.py +95 -0
  53. handler_control/api/agents.py +150 -0
  54. handler_control/api/apps.py +104 -0
  55. handler_control/api/automations.py +128 -0
  56. handler_control/api/bindings.py +105 -0
  57. handler_control/api/builtin_agents.py +22 -0
  58. handler_control/api/caches.py +79 -0
  59. handler_control/api/catalog.py +197 -0
  60. handler_control/api/common.py +205 -0
  61. handler_control/api/contexts.py +88 -0
  62. handler_control/api/folders.py +51 -0
  63. handler_control/api/images.py +164 -0
  64. handler_control/api/inbox.py +21 -0
  65. handler_control/api/projects.py +170 -0
  66. handler_control/api/providers.py +107 -0
  67. handler_control/api/remote.py +45 -0
  68. handler_control/api/secrets.py +177 -0
  69. handler_control/api/sessions.py +192 -0
  70. handler_control/api/skills.py +270 -0
  71. handler_control/app_api.py +84 -0
  72. handler_control/app_http.py +214 -0
  73. handler_control/app_remote.py +206 -0
  74. handler_control/apps.py +867 -0
  75. handler_control/auth.py +48 -0
  76. handler_control/automation_config.py +255 -0
  77. handler_control/automations.py +1134 -0
  78. handler_control/bindings.py +149 -0
  79. handler_control/builds.py +341 -0
  80. handler_control/builtin_agents.py +82 -0
  81. handler_control/caches.py +113 -0
  82. handler_control/checks.py +287 -0
  83. handler_control/compose.py +317 -0
  84. handler_control/config_files.py +41 -0
  85. handler_control/contexts.py +75 -0
  86. handler_control/daemon.py +64 -0
  87. handler_control/data/automation/bin/handler-llm +85 -0
  88. handler_control/data/automations/env-issues/automation.toml +17 -0
  89. handler_control/data/automations/env-issues/extract.py +73 -0
  90. handler_control/data/automations/env-issues/findings.schema.json +28 -0
  91. handler_control/data/automations/env-issues/prompt.md +19 -0
  92. handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
  93. handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
  94. handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
  95. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
  96. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
  97. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
  98. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
  99. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
  100. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
  101. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  102. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  103. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
  104. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
  105. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  106. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
  107. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
  108. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
  109. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
  110. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
  111. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
  112. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
  113. handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
  114. handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
  115. handler_control/data/builtin-agents/skill-review/interface.json +83 -0
  116. handler_control/data/interfaces/llm/v1/interface.toml +17 -0
  117. handler_control/data/interfaces/llm/v1/tools.json +138 -0
  118. handler_control/data/templates/README.md +10 -0
  119. handler_control/data/templates/providers/anthropic-api.toml +18 -0
  120. handler_control/data/templates/providers/llamacpp.toml +210 -0
  121. handler_control/data/templates/providers/mistral-api.toml +14 -0
  122. handler_control/data/templates/providers/ollama.toml +10 -0
  123. handler_control/data/templates/providers/openai-api.toml +13 -0
  124. handler_control/data/templates/providers/openrouter.toml +10 -0
  125. handler_control/desktop.py +68 -0
  126. handler_control/display.py +62 -0
  127. handler_control/files.py +23 -0
  128. handler_control/folders.py +68 -0
  129. handler_control/git_read.py +39 -0
  130. handler_control/image_check.py +322 -0
  131. handler_control/image_updates.py +174 -0
  132. handler_control/inbox.py +70 -0
  133. handler_control/interfaces.py +151 -0
  134. handler_control/knowledge.py +237 -0
  135. handler_control/launcher.py +356 -0
  136. handler_control/llm.py +194 -0
  137. handler_control/migrations/__init__.py +62 -0
  138. handler_control/policy.py +305 -0
  139. handler_control/projects.py +266 -0
  140. handler_control/providers.py +180 -0
  141. handler_control/relay.py +133 -0
  142. handler_control/remote.py +888 -0
  143. handler_control/remote_crypto.py +97 -0
  144. handler_control/runtime.py +140 -0
  145. handler_control/sandbox_process.py +224 -0
  146. handler_control/schedules.py +58 -0
  147. handler_control/secrets.py +242 -0
  148. handler_control/service_router.py +180 -0
  149. handler_control/services.py +92 -0
  150. handler_control/session_files.py +429 -0
  151. handler_control/sessions.py +1116 -0
  152. handler_control/settings.py +129 -0
  153. handler_control/shortcuts.py +80 -0
  154. handler_control/skill_scan.py +88 -0
  155. handler_control/skill_sources.py +61 -0
  156. handler_control/skills.py +378 -0
  157. handler_control/terminal.py +79 -0
  158. handler_control/terminals.py +108 -0
  159. handler_control/transcript.py +174 -0
  160. handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
  161. handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
  162. handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  163. handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  164. handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  165. handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  166. handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  167. handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  168. handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  169. handler_control/web-ui/favicon.svg +1 -0
  170. handler_control/web-ui/index.html +15 -0
  171. handler_control/webpush.py +90 -0
  172. handler_control/webui.py +105 -0
  173. handler_harness/__init__.py +13 -0
  174. handler_harness/addons.py +129 -0
  175. handler_harness/catalog.py +116 -0
  176. handler_harness/cli.py +109 -0
  177. handler_harness/data/addons/README.md +44 -0
  178. handler_harness/data/addons/miniapp/Containerfile +20 -0
  179. handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
  180. handler_harness/data/addons/miniapp/addon.toml +16 -0
  181. handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
  182. handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
  183. handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
  184. handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
  185. handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
  186. handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
  187. handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
  188. handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
  189. handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
  190. handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
  191. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
  192. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
  193. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
  194. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
  195. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
  196. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
  197. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
  198. handler_harness/data/catalog/README.md +12 -0
  199. handler_harness/data/catalog/playwright/Containerfile +28 -0
  200. handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
  201. handler_harness/data/catalog/playwright/addon.toml +25 -0
  202. handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
  203. handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
  204. handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
  205. handler_harness/data/catalog/shopify/Containerfile +22 -0
  206. handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
  207. handler_harness/data/catalog/shopify/addon.toml +39 -0
  208. handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
  209. handler_harness/data/handler-sandbox-env +30 -0
  210. handler_harness/data/harnesses/README.md +47 -0
  211. handler_harness/data/harnesses/claude-code/Containerfile +27 -0
  212. handler_harness/data/harnesses/claude-code/harness.toml +95 -0
  213. handler_harness/data/harnesses/codex/Containerfile +26 -0
  214. handler_harness/data/harnesses/codex/harness.toml +103 -0
  215. handler_harness/data/harnesses/pi/Containerfile +27 -0
  216. handler_harness/data/harnesses/pi/harness.toml +65 -0
  217. handler_harness/data/harnesses/vibe/Containerfile +19 -0
  218. handler_harness/data/harnesses/vibe/harness.toml +96 -0
  219. handler_harness/data/toolbox/Containerfile +56 -0
  220. handler_harness/data/toolbox/README.md +24 -0
  221. handler_harness/data/toolbox/toolbox.toml +41 -0
  222. handler_harness/images.py +62 -0
  223. handler_harness/providers.py +300 -0
  224. handler_harness/render.py +472 -0
  225. handler_sandbox_podman/__init__.py +1 -0
  226. handler_sandbox_podman/caches.py +97 -0
  227. handler_sandbox_podman/check.py +121 -0
  228. handler_sandbox_podman/cli.py +239 -0
  229. handler_sandbox_podman/egress.py +118 -0
  230. handler_sandbox_podman/images.py +199 -0
  231. handler_sandbox_podman/launch.py +474 -0
  232. handler_sandbox_podman/podman.py +108 -0
  233. handler_sandbox_podman/proxy/Containerfile +6 -0
  234. handler_sandbox_podman/proxy/addon.py +255 -0
@@ -0,0 +1,1134 @@
1
+ """Automations (I): when a session stops or a schedule's slot comes, the enabled automations it
2
+ fires run, one at a time for the whole daemon. A run is the automation's script in a sandbox (on
3
+ the new part of the conversation, for a stop; once it succeeded, handlerd appends what it wrote to
4
+ the declared knowledge notes), a run of an agent with an interface (its result goes to the inbox),
5
+ or a call to a mini app's route.
6
+
7
+ pending_stop pure: the stop of a log that fires an automation past its cursor (I.2)
8
+ result_problems pure: why an interface's result cannot make an inbox message (I.3)
9
+ Automations the runner: stops and schedules checked, the queue, runs and their
10
+ records, on and off (I.7), tries (I.8), what the API shows (I.9)
11
+
12
+ No other log than the sessions' own: a stop is told by `SessionStore.stop_listeners` (only the
13
+ session's id: the check reads its log, off the event loop), and at start every session is checked
14
+ once (the catch-up scan: stops while handlerd was not running, runs cut by its stop). Schedules are
15
+ checked every SCHEDULE_TICK seconds. Nothing pending is stored: a cursor per (automation, session),
16
+ moved when a run ends, is what was read; a schedule's last slot, moved when its run starts.
17
+
18
+ Storage, per automation (automation_config.state_dir):
19
+ state.json on or off, since, the cursors, the schedule's last slot
20
+ runs/<runId>/ run.json, definition/, input/, output/, run.log, services.ndjson
21
+ """
22
+
23
+ import asyncio
24
+ import contextlib
25
+ import json
26
+ import logging
27
+ import os
28
+ import secrets as pysecrets
29
+ import shutil
30
+ import threading
31
+ from collections.abc import Callable, Sequence
32
+ from dataclasses import dataclass
33
+ from datetime import UTC, datetime, timedelta
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from handler_contracts import (
38
+ DEFAULT_TIMEOUT_SEC,
39
+ MAX_APPEND,
40
+ Automation,
41
+ AutomationAgentRun,
42
+ AutomationAppended,
43
+ AutomationCheck,
44
+ AutomationLlmUsage,
45
+ AutomationRun,
46
+ AutomationView,
47
+ InboxStatus,
48
+ SessionInput,
49
+ SessionOutput,
50
+ )
51
+ from handler_harness import catalog, images
52
+
53
+ from . import automation_config as config
54
+ from . import (
55
+ checks,
56
+ compose,
57
+ interfaces,
58
+ knowledge,
59
+ runtime,
60
+ sandbox_process,
61
+ schedules,
62
+ secrets,
63
+ service_router,
64
+ transcript,
65
+ )
66
+ from .agents import AgentRecord, AgentRegistry
67
+ from .apps import AppError, AppStore
68
+ from .inbox import Inbox
69
+ from .projects import ProjectRegistry
70
+ from .sessions import SessionRecord, SessionStore
71
+ from .settings import Settings
72
+
73
+ log = logging.getLogger("handler.automations")
74
+
75
+ BIN = Path(__file__).resolve().parent / "data" / "automation" / "bin" # handler-llm
76
+ DAILY_CAP = 100 # runs per automation per day (I.3)
77
+ KEEP_RUNS = 50
78
+ KEEP_DAYS = 30
79
+ MAX_LOG = 1024 * 1024 # run.log
80
+ PREVIEW = 4096 # of what a try would append
81
+ NOT_FIRING = ("deleted", "daemon_restarted")
82
+ LAST = 2**62 # a seq past any log's end
83
+ LINE_LIMIT = 1024 * 1024 # one line of a run's output
84
+ SCHEDULE_TICK = 20.0 # schedules are checked this often (seconds)
85
+ DELETE_WAIT = 30.0 # a deleted one's run in progress is given this long to stop (seconds)
86
+ SESSION_POLL = 1.0 # an agent run's session is looked at this often (seconds)
87
+ STATUSES = ("ok", "attention", "failed")
88
+
89
+
90
+ class AutomationError(ValueError):
91
+ """What was asked cannot be done (the message says why)."""
92
+
93
+
94
+ class AutomationNotFoundError(AutomationError):
95
+ pass
96
+
97
+
98
+ class AutomationConflictError(AutomationError):
99
+ """The state does not allow it: problems, a run that ended."""
100
+
101
+
102
+ class AutomationCappedError(AutomationError):
103
+ pass
104
+
105
+
106
+ def now() -> datetime:
107
+ return datetime.now(UTC)
108
+
109
+
110
+ def pending_stop(
111
+ events: Sequence[dict[str, Any]], cursor: int, since: datetime | None
112
+ ) -> dict[str, Any] | None:
113
+ """The last stop after `cursor` that fires (I.2): not `deleted` nor `daemon_restarted`, at or
114
+ after `since`, in a log with a turn after `cursor`. None: nothing to read."""
115
+ if not any(e.get("kind") == "turn" and e["seq"] > cursor for e in events):
116
+ return None
117
+ stops = [
118
+ e
119
+ for e in events
120
+ if e.get("kind") == "lifecycle"
121
+ and e.get("event") == "stopped"
122
+ and e["seq"] > cursor
123
+ and e.get("how") not in NOT_FIRING
124
+ and (since is None or datetime.fromisoformat(e["t"]) >= since)
125
+ ]
126
+ return stops[-1] if stops else None
127
+
128
+
129
+ def result_problems(schema: dict[str, Any]) -> list[str]:
130
+ """An agent run by an automation gives a result its inbox message is made of (I.3):
131
+ required `status` (some of ok, attention, failed) and `summary` (a string)."""
132
+ props = schema.get("properties") or {}
133
+ required = set(schema.get("required") or [])
134
+ found = []
135
+ status = props.get("status") or {}
136
+ if "status" not in required or not status.get("enum"):
137
+ found.append("its result has no required 'status' enum (ok, attention, failed)")
138
+ elif unknown := [v for v in status["enum"] if v not in STATUSES]:
139
+ found.append(f"its result's status has {unknown}: only ok, attention, failed")
140
+ if "summary" not in required or (props.get("summary") or {}).get("type") != "string":
141
+ found.append("its result has no required 'summary' string")
142
+ return found
143
+
144
+
145
+ @dataclass
146
+ class Item:
147
+ """A run waiting: for (automation, session), or (automation, its schedule), at most one
148
+ (coalescing); or a try."""
149
+
150
+ automation: str
151
+ session_id: str | None = None # a stop's
152
+ slot: datetime | None = None # a schedule's
153
+ missed: int = 0
154
+ trial: AutomationRun | None = None # a try's record, written when it was asked
155
+
156
+ @property
157
+ def key(self) -> tuple[str, str, str]:
158
+ return self.automation, self.session_id or "", self.trial.id if self.trial else ""
159
+
160
+
161
+ @dataclass
162
+ class Running:
163
+ record: AutomationRun
164
+ proc: Any = None # asyncio.subprocess.Process, once a script started
165
+ cancelled: bool = False
166
+
167
+
168
+ class Automations:
169
+ def __init__(
170
+ self,
171
+ settings: Settings,
172
+ sessions: SessionStore,
173
+ registry: AgentRegistry,
174
+ projects: ProjectRegistry,
175
+ *,
176
+ apps: AppStore,
177
+ inbox: Inbox,
178
+ ) -> None:
179
+ self.settings = settings
180
+ self.sessions = sessions
181
+ self.registry = registry
182
+ self.projects = projects
183
+ self.apps = apps
184
+ self.inbox = inbox
185
+ self.queue: dict[tuple[str, str, str], Item] = {} # in the order queued
186
+ self.stopped: set[str] = set() # sessions whose stop is to be checked
187
+ self.running: Running | None = None
188
+ self.started_today: dict[tuple[str, str], int] = {} # (automation, day) → runs
189
+ self.wake = asyncio.Event()
190
+ self.task: asyncio.Task[None] | None = None
191
+ self.lock = threading.Lock() # state.json: read, changed, written by one at a time
192
+ self.schedule_tick = SCHEDULE_TICK
193
+ self._load()
194
+ sessions.stop_listeners.append(self.on_stop)
195
+
196
+ # ── start, stop ─────────────────────────────────────────────────────
197
+
198
+ def _load(self) -> None:
199
+ """Runs the previous handlerd left queued or running are `interrupted` (a stop's part is
200
+ read again: the cursor did not move); today's runs count in the cap."""
201
+ today = now().date().isoformat()
202
+ for loaded in config.load_all(self.settings.automations_dir):
203
+ for record in self.runs(loaded.name):
204
+ if record.state in ("queued", "running"):
205
+ ended = record.model_copy(update={"state": "interrupted", "ended_at": now()})
206
+ self.write_record(ended)
207
+ if record.created_at.date().isoformat() == today:
208
+ key = (loaded.name, today)
209
+ self.started_today[key] = self.started_today.get(key, 0) + 1
210
+
211
+ def start(self) -> None:
212
+ self.stopped |= set(self.sessions.records) # the catch-up scan (I.2)
213
+ self.task = asyncio.create_task(self.work())
214
+
215
+ async def close(self) -> None:
216
+ if self.task is not None:
217
+ self.task.cancel()
218
+ with contextlib.suppress(asyncio.CancelledError):
219
+ await self.task
220
+
221
+ def on_stop(self, session_id: str) -> None:
222
+ """A session's `lifecycle stopped` was written: checked later, off this path."""
223
+ self.stopped.add(session_id)
224
+ self.wake.set()
225
+
226
+ # ── the queue ───────────────────────────────────────────────────────
227
+
228
+ async def work(self) -> None:
229
+ while True:
230
+ if self.stopped:
231
+ sessions, self.stopped = self.stopped, set()
232
+ found = await asyncio.to_thread(self.check, sessions)
233
+ for item in found:
234
+ self.queue.setdefault(item.key, item)
235
+ for item in await asyncio.to_thread(self.due):
236
+ self.queue[item.key] = item # a pending one gets the latest slot
237
+ item = self.next_item()
238
+ if item is None:
239
+ self.wake.clear()
240
+ with contextlib.suppress(TimeoutError): # a schedule, a capped one tomorrow
241
+ await asyncio.wait_for(self.wake.wait(), self.schedule_tick)
242
+ continue
243
+ try:
244
+ await self.run(item)
245
+ except asyncio.CancelledError:
246
+ raise
247
+ except Exception:
248
+ log.exception("automation %s", item.automation)
249
+
250
+ def next_item(self) -> Item | None:
251
+ for key, item in self.queue.items():
252
+ if not self.capped(item.automation):
253
+ del self.queue[key]
254
+ return item
255
+ return None
256
+
257
+ def capped(self, name: str) -> bool:
258
+ return self.started_today.get((name, now().date().isoformat()), 0) >= DAILY_CAP
259
+
260
+ def count_start(self, name: str) -> None:
261
+ key = (name, now().date().isoformat())
262
+ self.started_today[key] = self.started_today.get(key, 0) + 1
263
+
264
+ def check(self, session_ids: set[str]) -> list[Item]:
265
+ """The automations these sessions' stops fire (I.2)."""
266
+ found = []
267
+ logs: dict[str, list[dict[str, Any]]] = {} # each read once
268
+ for loaded in config.load_all(self.settings.automations_dir):
269
+ state = config.read_state(self.settings.state_dir, loaded.name)
270
+ definition = loaded.definition
271
+ if not state.enabled or definition is None or definition.when != "session.stopped":
272
+ continue
273
+ for sid in sorted(session_ids):
274
+ rec = self.sessions.records.get(sid)
275
+ if rec is None or rec.interface is not None or not self.matches(loaded, rec):
276
+ continue
277
+ path = self.log_path(sid)
278
+ with contextlib.suppress(OSError):
279
+ if state.since and path.stat().st_mtime < state.since.timestamp():
280
+ continue # written before it was turned on: nothing to read
281
+ if sid not in logs:
282
+ logs[sid] = transcript.read(path, 0, LAST)
283
+ if pending_stop(logs[sid], state.cursors.get(sid, 0), state.since):
284
+ found.append(Item(loaded.name, sid))
285
+ return found
286
+
287
+ def due(self) -> list[Item]:
288
+ """The scheduled automations whose slot came (I.2): one run each, for the latest slot."""
289
+ t, found = now(), []
290
+ for loaded in config.load_all(self.settings.automations_dir):
291
+ schedule = loaded.definition.schedule if loaded.definition else None
292
+ if schedule is None:
293
+ continue
294
+ state = config.read_state(self.settings.state_dir, loaded.name)
295
+ last = state.last_slot or state.since
296
+ if not state.enabled or last is None:
297
+ continue
298
+ slot = schedules.latest_slot(schedule, last, t)
299
+ if slot is None:
300
+ continue
301
+ found.append(
302
+ Item(loaded.name, slot=slot, missed=schedules.missed(schedule, last, slot))
303
+ )
304
+ return found
305
+
306
+ def matches(self, loaded: config.Loaded, rec: SessionRecord) -> bool:
307
+ only = loaded.definition.only if loaded.definition else None
308
+ if only is None:
309
+ return True
310
+ agent, project = self.names(rec)
311
+ return (only.project is None or project in only.project) and (
312
+ only.agent is None or agent in only.agent
313
+ )
314
+
315
+ def names(self, rec: SessionRecord) -> tuple[str | None, str | None]:
316
+ """The session's agent's name and its project's, now."""
317
+ agent = self.registry.get(rec.agent_id)
318
+ if agent is None:
319
+ return None, None
320
+ project = self.projects.get(agent.project_id) if agent.project_id else None
321
+ return agent.name, project.name if project else None
322
+
323
+ def log_path(self, session_id: str) -> Path:
324
+ return self.sessions.folder(session_id) / "events.ndjson"
325
+
326
+ # ── state, problems, what it may touch (I.7) ────────────────────────
327
+
328
+ def update_state(self, name: str, change: Callable[[config.State], None]) -> config.State:
329
+ with self.lock:
330
+ state = config.read_state(self.settings.state_dir, name)
331
+ change(state)
332
+ config.write_state(self.settings.state_dir, name, state)
333
+ return state
334
+
335
+ def find_agent(self, run: AutomationAgentRun) -> AgentRecord:
336
+ """The agent an agent run names: by its name, in its project (none: of no project)."""
337
+ project = None
338
+ if run.project is not None:
339
+ found = [p for p in self.projects.projects.values() if p.name == run.project]
340
+ if not found:
341
+ raise AutomationError(f"run.project: no project named {run.project}")
342
+ project = found[0].id
343
+ agents = [
344
+ a
345
+ for a in self.registry.agents.values()
346
+ if a.name == run.agent and a.project_id == project
347
+ ]
348
+ where = f"in project {run.project}" if run.project else "of no project"
349
+ if not agents:
350
+ raise AutomationError(f"run.agent: no agent named {run.agent} {where}")
351
+ if len(agents) > 1:
352
+ raise AutomationError(f"run.agent: {len(agents)} agents named {run.agent} {where}")
353
+ return agents[0]
354
+
355
+ def agent_problems(self, run: AutomationAgentRun) -> tuple[list[str], list[str]]:
356
+ """(problems, warnings) of an agent run: the agent, its interface and input, its model
357
+ and the terms of its account's subscription (I.7)."""
358
+ try:
359
+ agent = self.find_agent(run)
360
+ except AutomationError as e:
361
+ return [str(e)], []
362
+ if agent.interface is None:
363
+ return [f"agent {run.agent} has no interface: only those run unattended (D.18)"], []
364
+ found = result_problems(agent.interface.result)
365
+ try:
366
+ self.agent_input(agent, run)
367
+ except AutomationError as e:
368
+ found.append(str(e))
369
+ warnings: list[str] = []
370
+ try:
371
+ runs = checks.session_model(self.settings, agent, None)
372
+ except checks.CheckError as e:
373
+ return [*found, f"agent {run.agent}'s model: {e}"], warnings
374
+ if runs.account is not None:
375
+ login = catalog.get(runs.harness).descriptor.login
376
+ assert login is not None # an account's harness has one
377
+ read = f", read on {login.checked}" if login.checked else ", not read yet"
378
+ terms = f"{login.subscription} terms ({login.terms}{read})"
379
+ if login.unattended == "refused":
380
+ found.append(
381
+ f"agent {run.agent} runs on account {runs.account}: unattended use of a "
382
+ f"{login.subscription} subscription is refused by handler until it is clearly "
383
+ f"allowed by the {terms}; give the agent a provider (an API key) first"
384
+ )
385
+ elif login.unattended == "check-terms":
386
+ warnings.append(
387
+ f"Runs on your subscription (account {runs.account}). Check your "
388
+ f"{login.subscription} plan's terms: automated or unattended use may not be "
389
+ f"allowed ({login.terms}). An API key (a provider) is recommended."
390
+ )
391
+ return found, warnings
392
+
393
+ def agent_input(self, agent: AgentRecord, run: AutomationAgentRun) -> SessionInput:
394
+ assert agent.interface is not None
395
+ given = SessionInput.model_validate(run.input.model_dump(by_alias=True))
396
+ try:
397
+ return interfaces.check_input(agent.interface, given, self.settings.skills_dir)
398
+ except interfaces.InterfaceError as e:
399
+ raise AutomationError(f"run.input: {e}") from None
400
+
401
+ def checked(self, loaded: config.Loaded) -> tuple[list[str], list[str]]:
402
+ """(problems, warnings): what keeps it from running (I.1), what to know first (I.7)."""
403
+ root, _ = knowledge.root(self.settings)
404
+ found = config.problems(
405
+ loaded,
406
+ providers_dir=self.settings.providers_dir,
407
+ store=secrets.SecretStore(self.settings.data_dir),
408
+ knowledge_root=root,
409
+ projects={p.name for p in self.projects.projects.values()},
410
+ agents={a.name for a in self.registry.agents.values() if a.name},
411
+ )
412
+ definition, warnings = loaded.definition, []
413
+ if definition is not None and (run := definition.agent_run) is not None:
414
+ more, warnings = self.agent_problems(run)
415
+ found += more
416
+ call = definition.app_call if definition is not None else None
417
+ if call is not None and call.app not in self.apps.records:
418
+ found.append(f"run.app: no app named {call.app}")
419
+ return found, warnings
420
+
421
+ def problems(self, loaded: config.Loaded) -> list[str]:
422
+ return self.checked(loaded)[0]
423
+
424
+ def permissions(self, definition: Automation) -> list[str]:
425
+ """What it may touch and what starts it, in sentences (I.7)."""
426
+ if (schedule := definition.schedule) is not None:
427
+ days = f" on {', '.join(schedule.days)}" if schedule.days else ""
428
+ when = (
429
+ f"Starts every {schedule.every}"
430
+ if schedule.every
431
+ else f"Starts at {schedule.at} (local time){days}"
432
+ )
433
+ when += "; after the computer was off, only the latest missed slot runs."
434
+ else:
435
+ only = definition.only
436
+ whose = [
437
+ f"{kind} {', '.join(names)}"
438
+ for kind, names in (
439
+ ("of project", only.project if only else None),
440
+ ("of agent", only.agent if only else None),
441
+ )
442
+ if names
443
+ ]
444
+ when = "Starts when a session " + (" ".join(whose) + " " if whose else "") + "stops."
445
+ found = [when]
446
+ if (script := definition.script) is not None:
447
+ reads = (
448
+ "the conversation of the session that stopped"
449
+ if definition.schedule is None
450
+ else "nothing but the time it runs"
451
+ )
452
+ found.append(f"Runs `{script}` in a sandbox without network, reading {reads}.")
453
+ if definition.services and definition.services.llm:
454
+ found.append(f"May call the models {', '.join(definition.services.llm)}.")
455
+ if definition.append:
456
+ notes = ", ".join(a.to for a in definition.append)
457
+ found.append(f"Appends what it writes to the notes {notes}.")
458
+ found.append("Tells your inbox when a run fails.")
459
+ elif (run := definition.agent_run) is not None:
460
+ where = f"of project {run.project}" if run.project else "of no project"
461
+ found.append(f"Runs the agent {run.agent} {where} unattended, without asking you.")
462
+ with contextlib.suppress(AutomationError):
463
+ agent = self.find_agent(run)
464
+ folder = agent.scope or "a folder of its own for each run"
465
+ runs = agent.models[0] if agent.models else None
466
+ model = (
467
+ " · ".join(
468
+ x for x in (runs.harness, runs.account or runs.provider, runs.model) if x
469
+ )
470
+ if runs
471
+ else "none"
472
+ )
473
+ found.append(
474
+ f"It works in {folder} (read-write), network {agent.network}, "
475
+ f"confinement {agent.confinement}, on {model}."
476
+ )
477
+ found.append("Sends its result to your inbox.")
478
+ elif (call := definition.app_call) is not None:
479
+ found.append(f"Calls {call.call} of the app {call.app}, as you.")
480
+ found.append("Tells your inbox when a call fails.")
481
+ return found
482
+
483
+ def get(self, name: str) -> config.Loaded:
484
+ loaded = config.get(self.settings.automations_dir, name)
485
+ if loaded is None:
486
+ raise AutomationNotFoundError(f"no automation named {name}")
487
+ return loaded
488
+
489
+ def view(self, name: str) -> AutomationView:
490
+ loaded = self.get(name)
491
+ state = config.read_state(self.settings.state_dir, name)
492
+ definition = loaded.definition
493
+ problems, warnings = self.checked(loaded)
494
+ runs = self.runs(name)
495
+ next_at = None
496
+ if state.enabled and definition is not None and definition.schedule is not None:
497
+ last = state.last_slot or state.since
498
+ if last is not None:
499
+ next_at = schedules.next_slot(definition.schedule, last, now())
500
+ pending = sum(1 for i in self.queue.values() if i.automation == name and not i.trial)
501
+ busy = self.running.record.automation if self.running else None
502
+ return AutomationView(
503
+ name=name,
504
+ description=definition.description if definition else None,
505
+ definition=definition,
506
+ directory=str(loaded.path),
507
+ files=config.files(loaded.path),
508
+ problems=problems,
509
+ enabled=state.enabled,
510
+ permissions=self.permissions(definition) if definition else [],
511
+ warnings=warnings,
512
+ next_at=next_at,
513
+ pending=pending,
514
+ behind=busy if pending and busy != name else None,
515
+ capped=self.capped(name),
516
+ last_run=runs[0] if runs else None,
517
+ )
518
+
519
+ def views(self) -> list[AutomationView]:
520
+ return [self.view(a.name) for a in config.load_all(self.settings.automations_dir)]
521
+
522
+ def enable(self, name: str) -> AutomationView:
523
+ """Turned on: the owner's only approval (I.7). Stops and slots before are not read."""
524
+ loaded = self.get(name)
525
+ if loaded.definition is None or (found := self.problems(loaded)):
526
+ raise AutomationConflictError(f"{name} has problems: {'; '.join(found)}")
527
+
528
+ def on(state: config.State) -> None:
529
+ if not state.enabled:
530
+ state.since, state.last_slot = now(), None
531
+ state.enabled = True
532
+
533
+ self.update_state(name, on)
534
+ return self.view(name)
535
+
536
+ def disable(self, name: str) -> AutomationView:
537
+ self.get(name)
538
+
539
+ def off(state: config.State) -> None:
540
+ state.enabled = False
541
+
542
+ self.update_state(name, off)
543
+ for key in [k for k, i in self.queue.items() if i.automation == name and not i.trial]:
544
+ del self.queue[key]
545
+ return self.view(name)
546
+
547
+ def create(
548
+ self,
549
+ name: str,
550
+ *,
551
+ example: str | None = None,
552
+ definition: Automation | None = None,
553
+ enable: bool = False,
554
+ ) -> AutomationView:
555
+ """A new one, off: an example copied, or `definition` written (I.1); `enable`: then
556
+ turned on, when it has no problems (409 else, and it stays, off)."""
557
+ directory = self.settings.automations_dir
558
+ try:
559
+ if definition is not None:
560
+ config.write_new(directory, name, definition)
561
+ else:
562
+ assert example is not None
563
+ config.copy_example(directory, name, example)
564
+ except config.AutomationExistsError as e:
565
+ raise AutomationConflictError(str(e)) from None
566
+ except config.AutomationConfigError as e:
567
+ raise AutomationNotFoundError(str(e)) from None
568
+ return self.enable(name) if enable else self.view(name)
569
+
570
+ def update(self, name: str, definition: Automation) -> AutomationView:
571
+ """`definition` written over its automation.toml (the owner's edit, as in an editor: on,
572
+ it stays on and the edit applies at its next run, I.7)."""
573
+ config.write_definition(self.get(name).path, definition)
574
+ return self.view(name)
575
+
576
+ def preview(self, definition: Automation) -> AutomationCheck:
577
+ """What `definition` would do, its problems and warnings, before it is saved."""
578
+ problems, warnings = self.checked(config.Loaded("", Path(), definition))
579
+ at, schedule = now(), definition.schedule
580
+ return AutomationCheck(
581
+ problems=problems,
582
+ warnings=warnings,
583
+ permissions=self.permissions(definition),
584
+ next_at=schedules.next_slot(schedule, at, at) if schedule is not None else None,
585
+ )
586
+
587
+ async def delete(self, name: str) -> None:
588
+ """Gone: its waiting runs dropped, the one in progress stopped (and waited for, so that
589
+ nothing it writes brings its state back), its folder and its state (runs, logs) removed.
590
+ What it told the inbox stays."""
591
+ loaded = self.get(name)
592
+ for key in [k for k, i in self.queue.items() if i.automation == name]:
593
+ del self.queue[key]
594
+ running = self.running
595
+ if running is not None and running.record.automation == name:
596
+ await self.cancel(name, running.record.id)
597
+ loop = asyncio.get_running_loop()
598
+ deadline = loop.time() + DELETE_WAIT
599
+ while self.running is running and loop.time() < deadline:
600
+ await asyncio.sleep(0.1)
601
+ await asyncio.to_thread(shutil.rmtree, loaded.path)
602
+ state = config.state_dir(self.settings.state_dir, name)
603
+ await asyncio.to_thread(shutil.rmtree, state, ignore_errors=True)
604
+
605
+ # ── runs ────────────────────────────────────────────────────────────
606
+
607
+ def runs_dir(self, name: str) -> Path:
608
+ return config.state_dir(self.settings.state_dir, name) / "runs"
609
+
610
+ def runs(self, name: str) -> list[AutomationRun]:
611
+ """Most recent first."""
612
+ found = []
613
+ for path in self.runs_dir(name).glob("a_*/run.json"):
614
+ with contextlib.suppress(OSError, ValueError):
615
+ found.append(AutomationRun.model_validate_json(path.read_text()))
616
+ return sorted(found, key=lambda r: r.created_at, reverse=True)
617
+
618
+ def runs_of_session(self, session_id: str) -> list[AutomationRun]:
619
+ found = [
620
+ r
621
+ for loaded in config.load_all(self.settings.automations_dir)
622
+ for r in self.runs(loaded.name)
623
+ if session_id in (r.session_id, r.agent_session_id)
624
+ ]
625
+ return sorted(found, key=lambda r: r.created_at, reverse=True)
626
+
627
+ def get_run(self, name: str, run_id: str) -> AutomationRun:
628
+ self.get(name)
629
+ path = self.runs_dir(name) / run_id / "run.json"
630
+ if "/" in run_id or not run_id.startswith("a_") or not path.is_file():
631
+ raise AutomationNotFoundError(f"no run {run_id} of {name}")
632
+ if self.running and self.running.record.id == run_id:
633
+ return self.running.record
634
+ return AutomationRun.model_validate_json(path.read_text())
635
+
636
+ def run_log(self, name: str, run_id: str) -> str:
637
+ self.get_run(name, run_id)
638
+ path = self.runs_dir(name) / run_id / "run.log"
639
+ return path.read_text(errors="replace") if path.is_file() else ""
640
+
641
+ def write_record(self, record: AutomationRun) -> None:
642
+ folder = self.runs_dir(record.automation) / record.id
643
+ folder.mkdir(mode=0o700, parents=True, exist_ok=True)
644
+ tmp = folder / "run.json.tmp"
645
+ tmp.write_text(record.model_dump_json(by_alias=True, exclude_none=True, indent=2))
646
+ tmp.replace(folder / "run.json")
647
+
648
+ def try_run(self, name: str, session_id: str | None) -> AutomationRun:
649
+ """I.8: now, on or off; on a stop's whole log; appends nothing."""
650
+ loaded = self.get(name)
651
+ if loaded.definition is None or (found := self.problems(loaded)):
652
+ raise AutomationConflictError(f"{name} has problems: {'; '.join(found)}")
653
+ on_stop = loaded.definition.when == "session.stopped"
654
+ if on_stop and session_id is None:
655
+ raise AutomationConflictError(f"{name} runs on a session that stopped: give one")
656
+ if not on_stop and session_id is not None:
657
+ raise AutomationConflictError(f"{name} runs on a schedule: no session")
658
+ if session_id is not None and session_id not in self.sessions.records:
659
+ raise AutomationNotFoundError(f"no session {session_id}")
660
+ if self.capped(name):
661
+ raise AutomationCappedError(f"{name} ran {DAILY_CAP} times today")
662
+ record = AutomationRun(
663
+ id=new_run_id(), automation=name, trial=True, state="queued", created_at=now()
664
+ )
665
+ if session_id is not None:
666
+ events = transcript.read(self.log_path(session_id), 0, LAST)
667
+ seq_to = events[-1]["seq"] if events else 0
668
+ record = record.model_copy(
669
+ update={"session_id": session_id, "seq_from": 1, "seq_to": seq_to}
670
+ )
671
+ self.write_record(record)
672
+ item = Item(name, session_id, trial=record)
673
+ self.queue[item.key] = item
674
+ self.wake.set()
675
+ return record
676
+
677
+ async def cancel(self, name: str, run_id: str) -> AutomationRun:
678
+ record = self.get_run(name, run_id)
679
+ if (key := (name, record.session_id or "", run_id)) in self.queue:
680
+ del self.queue[key]
681
+ record = record.model_copy(update={"state": "cancelled", "ended_at": now()})
682
+ self.write_record(record)
683
+ return record
684
+ running = self.running
685
+ if running is None or running.record.id != run_id:
686
+ raise AutomationConflictError(f"run {run_id} has ended ({record.state})")
687
+ running.cancelled = True
688
+ if running.proc is not None:
689
+ await sandbox_process.stop(running.proc)
690
+ if (sid := running.record.agent_session_id) is not None:
691
+ await self.sessions.stop(sid, "its automation run was cancelled", "explicit")
692
+ return running.record
693
+
694
+ async def run(self, item: Item) -> None:
695
+ loaded = config.get(self.settings.automations_dir, item.automation)
696
+ rec = self.sessions.records.get(item.session_id) if item.session_id else None
697
+ if loaded is None or loaded.definition is None or (item.session_id and rec is None):
698
+ if item.trial:
699
+ self.end(item.trial, "failed", error="the automation or the session is gone")
700
+ return
701
+ events: list[dict[str, Any]] = []
702
+ if item.session_id is not None:
703
+ events = await asyncio.to_thread(
704
+ transcript.read, self.log_path(item.session_id), 0, LAST
705
+ )
706
+ record = self.new_record(loaded, item, events)
707
+ if record is None:
708
+ return
709
+ self.count_start(loaded.name)
710
+ self.running = Running(record)
711
+ self.write_record(record)
712
+ try:
713
+ await self.execute(loaded, rec, events, self.running)
714
+ except asyncio.CancelledError: # handlerd stops: a stop's part is read again at its start
715
+ if self.running.proc is not None:
716
+ await sandbox_process.stop(self.running.proc)
717
+ self.end(self.running.record, "interrupted", move=False)
718
+ raise
719
+ except Exception as e: # handlerd's side failed (a file, the sandbox's executable)
720
+ log.exception("automation %s: run %s", loaded.name, record.id)
721
+ self.end(self.running.record, "failed", error=f"handler could not run it: {e}")
722
+ finally:
723
+ self.running = None
724
+
725
+ def new_record(
726
+ self, loaded: config.Loaded, item: Item, events: list[dict[str, Any]]
727
+ ) -> AutomationRun | None:
728
+ """The record of the run starting now; None when it does not run after all: turned off or
729
+ with problems meanwhile (read once it is on again without problems), nothing new read.
730
+ A schedule's slot is taken now: a run cut by handlerd stopping is not run again."""
731
+ if item.trial is not None:
732
+ return item.trial.model_copy(update={"state": "running"})
733
+ state = config.read_state(self.settings.state_dir, loaded.name)
734
+ if not state.enabled or self.problems(loaded):
735
+ return None
736
+ record = AutomationRun(
737
+ id=new_run_id(), automation=loaded.name, state="running", created_at=now()
738
+ )
739
+ if item.session_id is not None:
740
+ cursor = state.cursors.get(item.session_id, 0)
741
+ if not pending_stop(events, cursor, state.since):
742
+ return None
743
+ seqs = {"seq_from": cursor + 1, "seq_to": events[-1]["seq"]}
744
+ return record.model_copy(update={"session_id": item.session_id} | seqs)
745
+ slot = item.slot
746
+
747
+ def ran(s: config.State) -> None:
748
+ s.last_slot = slot
749
+
750
+ self.update_state(loaded.name, ran)
751
+ return record.model_copy(update={"slot": slot, "missed": item.missed})
752
+
753
+ async def execute(
754
+ self,
755
+ loaded: config.Loaded,
756
+ rec: SessionRecord | None,
757
+ events: list[dict[str, Any]],
758
+ running: Running,
759
+ ) -> None:
760
+ definition = loaded.definition
761
+ assert definition is not None
762
+ if (run := definition.agent_run) is not None:
763
+ await self.execute_agent(loaded, run, running)
764
+ elif (call := definition.app_call) is not None:
765
+ loop = asyncio.get_running_loop()
766
+ started = loop.time()
767
+ try:
768
+ status = await self.apps.call(call.app, call.call, loaded.name)
769
+ except AppError as e:
770
+ self.end(running.record, "failed", error=f"app {call.app}: {e}")
771
+ return
772
+ running.record = running.record.model_copy(
773
+ update={"ms": int((loop.time() - started) * 1000)}
774
+ )
775
+ if status >= 400:
776
+ self.end(running.record, "failed", error=f"{call.call}: HTTP {status}")
777
+ else:
778
+ self.end(running.record, "succeeded")
779
+ else:
780
+ await self.execute_script(loaded, rec, events, running)
781
+
782
+ async def execute_agent(
783
+ self, loaded: config.Loaded, run: AutomationAgentRun, running: Running
784
+ ) -> None:
785
+ """A run of the agent (D.18), watched until its session stops; its result → the inbox."""
786
+ loop = asyncio.get_running_loop()
787
+ started = loop.time()
788
+ try:
789
+ agent = self.find_agent(run)
790
+ given = self.agent_input(agent, run)
791
+ runs = checks.session_model(self.settings, agent, None)
792
+ except (AutomationError, checks.CheckError) as e:
793
+ self.end(running.record, "failed", error=str(e))
794
+ return
795
+ if running.cancelled: # before its session: nothing to stop, nothing to start
796
+ self.end(running.record, "cancelled")
797
+ return
798
+ session = self.sessions.create(agent, runs, origin="automation", run=given)
799
+ running.record = running.record.model_copy(update={"agent_session_id": session.id})
800
+ self.write_record(running.record)
801
+ while True:
802
+ await asyncio.sleep(SESSION_POLL)
803
+ rec = self.sessions.records.get(session.id)
804
+ if rec is None:
805
+ self.end(running.record, "failed", error="its session was deleted")
806
+ return
807
+ busy = session.id in self.sessions.live or session.id in self.sessions.starting
808
+ if rec.state in ("stopped", "failed") and not busy:
809
+ break
810
+ running.record = running.record.model_copy(
811
+ update={"ms": int((loop.time() - started) * 1000)}
812
+ )
813
+ output = SessionOutput.model_validate(rec.output) if rec.output else None
814
+ if running.cancelled:
815
+ self.end(running.record, "cancelled")
816
+ elif output is not None and output.valid and isinstance(output.value, dict):
817
+ value: dict[str, Any] = output.value
818
+ self.end(running.record, "succeeded", result=value)
819
+ else:
820
+ why = (
821
+ (output.error if output else None)
822
+ or rec.error
823
+ or f"its turn ended: {rec.last_stop_reason or 'no result'}"
824
+ )
825
+ self.end(running.record, "failed", error=f"no valid result: {why}")
826
+
827
+ async def execute_script(
828
+ self,
829
+ loaded: config.Loaded,
830
+ rec: SessionRecord | None,
831
+ events: list[dict[str, Any]],
832
+ running: Running,
833
+ ) -> None:
834
+ definition = loaded.definition
835
+ assert definition is not None and definition.script is not None
836
+ record = running.record
837
+ folder = self.runs_dir(loaded.name) / record.id
838
+ redact = secrets.redactor(secrets.SecretStore(self.settings.data_dir).values())
839
+ if rec is not None and record.seq_from is not None and record.seq_to is not None:
840
+ slice_ = [e for e in events if record.seq_from <= e["seq"] <= record.seq_to]
841
+ await asyncio.to_thread(self.prepare, loaded, rec, slice_, record, redact)
842
+ else:
843
+ await asyncio.to_thread(self.prepare_scheduled, loaded, record)
844
+ if running.cancelled:
845
+ self.end(record, "cancelled")
846
+ return
847
+ toolbox = images.ref(images.TOOLBOX, images.LATEST)
848
+ if not await asyncio.to_thread(runtime.exists, toolbox):
849
+ self.end(record, "failed", error="toolbox not built: `handler image rebuild toolbox`")
850
+ return
851
+ code, timed_out = await self.sandboxed(definition, toolbox, folder, running, redact)
852
+ record = running.record
853
+ if running.cancelled:
854
+ self.end(record, "cancelled")
855
+ elif timed_out:
856
+ timeout = definition.timeout_sec or DEFAULT_TIMEOUT_SEC
857
+ self.end(record, "failed", error=f"timed out after {timeout} s")
858
+ elif code != 0:
859
+ self.end(record, "failed", error=f"the script exited with {code}")
860
+ else:
861
+ try:
862
+ appended = await asyncio.to_thread(self.append, loaded, record, folder / "output")
863
+ except AutomationError as e:
864
+ self.end(record, "failed", error=str(e))
865
+ else:
866
+ self.end(record, "succeeded", appended=appended)
867
+
868
+ async def sandboxed(
869
+ self,
870
+ definition: Automation,
871
+ toolbox: str,
872
+ folder: Path,
873
+ running: Running,
874
+ redact: Callable[[str], str],
875
+ ) -> tuple[int | None, bool]:
876
+ """The run's sandbox (I.3), with its services socket (I.5) when it calls models, until
877
+ it ends or its timeout: (its exit code, timed out). Its record gets what it used."""
878
+ assert definition.script is not None
879
+ record = running.record
880
+ grant, server, socket_dir = None, None, None
881
+ if models := definition.services.llm if definition.services else []:
882
+ socket_dir = self.settings.runtime_dir / "automations" / record.id
883
+ socket_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
884
+ grant = service_router.Grant(llm=frozenset(models), log=folder / "services.ndjson")
885
+ server = await service_router.serve(self.settings, socket_dir / "services.sock", grant)
886
+ spec = compose.automation_run_spec(
887
+ name=f"handler-run-{record.id.replace('_', '-')}",
888
+ run_id=record.id,
889
+ toolbox=toolbox,
890
+ run=definition.script,
891
+ definition=str(folder / "definition"),
892
+ input_dir=str(folder / "input"),
893
+ output=str(folder / "output"),
894
+ bin_dir=str(BIN),
895
+ services=str(socket_dir / "services.sock") if socket_dir else None,
896
+ trial=record.trial,
897
+ )
898
+ loop = asyncio.get_running_loop()
899
+ started, timed_out = loop.time(), False
900
+ try:
901
+ running.proc = proc = await sandbox_process.start_piped(spec, line_limit=LINE_LIMIT)
902
+ copying = asyncio.create_task(copy_log(proc, folder / "run.log", redact))
903
+ if running.cancelled: # while it was being set up: there was nothing to stop yet
904
+ await sandbox_process.stop(proc)
905
+ try:
906
+ await asyncio.wait_for(proc.wait(), definition.timeout_sec or DEFAULT_TIMEOUT_SEC)
907
+ except TimeoutError:
908
+ timed_out = True
909
+ await sandbox_process.stop(proc)
910
+ await copying
911
+ finally:
912
+ if server is not None:
913
+ server.close()
914
+ if socket_dir is not None:
915
+ shutil.rmtree(socket_dir, ignore_errors=True)
916
+ used: dict[str, Any] = {
917
+ "exit_code": proc.returncode,
918
+ "ms": int((loop.time() - started) * 1000),
919
+ }
920
+ if grant is not None:
921
+ used["llm"] = AutomationLlmUsage(
922
+ calls=grant.calls, in_tokens=grant.in_tokens, out_tokens=grant.out_tokens
923
+ )
924
+ running.record = record.model_copy(update=used)
925
+ return proc.returncode, timed_out
926
+
927
+ def copy_definition(self, loaded: config.Loaded, record: AutomationRun) -> Path:
928
+ """The run's folder (I.3): a copy of the automation's, an empty input and output."""
929
+ folder = self.runs_dir(loaded.name) / record.id
930
+ shutil.copytree(
931
+ loaded.path,
932
+ folder / "definition",
933
+ ignore=shutil.ignore_patterns(".*", "__pycache__"),
934
+ dirs_exist_ok=True,
935
+ )
936
+ (folder / "output").mkdir(exist_ok=True)
937
+ (folder / "input").mkdir(exist_ok=True)
938
+ return folder
939
+
940
+ def prepare_scheduled(self, loaded: config.Loaded, record: AutomationRun) -> None:
941
+ """A scheduled script's input (I.4): its slot only."""
942
+ folder = self.copy_definition(loaded, record)
943
+ event = {
944
+ "type": "schedule",
945
+ "slot": record.slot.isoformat() if record.slot else None,
946
+ "missed": record.missed,
947
+ "t": now().isoformat(),
948
+ }
949
+ (folder / "input" / "event.json").write_text(json.dumps(event, indent=2) + "\n")
950
+
951
+ def prepare(
952
+ self,
953
+ loaded: config.Loaded,
954
+ rec: SessionRecord,
955
+ events: list[dict[str, Any]],
956
+ record: AutomationRun,
957
+ redact: Callable[[str], str],
958
+ ) -> None:
959
+ """A stop's run folder (I.3, I.4): its input is the session's part read."""
960
+ given = self.copy_definition(loaded, record) / "input"
961
+ agent = self.registry.get(rec.agent_id)
962
+ agent_name, project_name = self.names(rec)
963
+ stops = [e for e in events if e.get("kind") == "lifecycle" and e.get("event") == "stopped"]
964
+ event = {
965
+ "type": "session.stopped",
966
+ "sessionId": rec.id,
967
+ "agentId": rec.agent_id,
968
+ "agent": agent_name,
969
+ "projectId": agent.project_id if agent else None,
970
+ "project": project_name,
971
+ "how": stops[-1].get("how") if stops else None,
972
+ "seqFrom": record.seq_from,
973
+ "seqTo": record.seq_to,
974
+ "t": stops[-1]["t"] if stops else now().isoformat(),
975
+ }
976
+ session = self.sessions.to_contract(rec).model_dump(mode="json", by_alias=True)
977
+ settings: dict[str, Any] = {"name": agent_name}
978
+ if agent is not None:
979
+ settings |= agent.settings.model_dump(
980
+ mode="json",
981
+ by_alias=True,
982
+ include={"network", "allow", "confinement", "permissions", "bindings", "knowledge"},
983
+ )
984
+ session["agent"] = settings
985
+ model = " · ".join(x for x in (rec.harness, rec.account or rec.provider, rec.model) if x)
986
+ header = [
987
+ ("Agent", agent_name or rec.harness),
988
+ ("Project", project_name or "none (an ad-hoc agent)"),
989
+ ("Model", model),
990
+ ("Folder", rec.scope),
991
+ ("Network", f"{settings.get('network')} {' '.join(settings.get('allow') or [])}"),
992
+ ("Confinement", str(settings.get("confinement"))),
993
+ ("Permissions", str(settings.get("permissions"))),
994
+ ("Addons", ", ".join(settings.get("bindings") or []) or "none"),
995
+ ("Knowledge", ", ".join(settings.get("knowledge") or []) or "none"),
996
+ ("Events", f"{record.seq_from} to {record.seq_to}"),
997
+ ]
998
+ files = {
999
+ "event.json": json.dumps(event, indent=2) + "\n",
1000
+ "session.json": json.dumps(session, indent=2) + "\n",
1001
+ "events.ndjson": "".join(json.dumps(e) + "\n" for e in events),
1002
+ "transcript.md": transcript.render(events, header),
1003
+ }
1004
+ for file, text in files.items():
1005
+ (given / file).write_text(redact(text))
1006
+
1007
+ def append(
1008
+ self, loaded: config.Loaded, record: AutomationRun, output: Path
1009
+ ) -> list[AutomationAppended]:
1010
+ """I.6: each `from` file the run wrote to its note, in order, once every one was
1011
+ checked (a try: what would be appended, nothing written)."""
1012
+ assert loaded.definition is not None
1013
+ root, _ = knowledge.root(self.settings)
1014
+ base = os.path.realpath(output)
1015
+ blocks = []
1016
+ for append in loaded.definition.append:
1017
+ path = os.path.realpath(os.path.join(base, append.from_))
1018
+ if not path.startswith(base + "/"):
1019
+ raise AutomationError(f"{append.from_}: outside the run's output folder")
1020
+ if not os.path.lexists(path):
1021
+ continue # nothing to add
1022
+ if not os.path.isfile(path):
1023
+ raise AutomationError(f"{append.from_}: not a file")
1024
+ if os.path.getsize(path) > MAX_APPEND:
1025
+ raise AutomationError(f"{append.from_}: larger than {MAX_APPEND // 1024} KiB")
1026
+ try:
1027
+ knowledge.check_note(root, append.to)
1028
+ except knowledge.KnowledgeError as e:
1029
+ raise AutomationError(str(e)) from None
1030
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
1031
+ blocks.append((append.to, text))
1032
+ appended = []
1033
+ for to, text in blocks:
1034
+ if record.trial:
1035
+ size = len(text.encode())
1036
+ appended.append(AutomationAppended(to=to, bytes=size, text=text[:PREVIEW]))
1037
+ continue
1038
+ where = f"run {record.id}" + (
1039
+ f" · session {record.session_id}" if record.session_id else ""
1040
+ )
1041
+ t = now().isoformat(timespec="seconds")
1042
+ head = f"<!-- handler automation {loaded.name} · {where} · {t} -->"
1043
+ block = f"\n{head}\n{text}" + ("" if text.endswith("\n") else "\n")
1044
+ knowledge.append_note(root, to, block)
1045
+ appended.append(AutomationAppended(to=to, bytes=len(block.encode())))
1046
+ return appended
1047
+
1048
+ def end(
1049
+ self,
1050
+ record: AutomationRun,
1051
+ state: str,
1052
+ *,
1053
+ error: str | None = None,
1054
+ appended: list[AutomationAppended] | None = None,
1055
+ result: dict[str, Any] | None = None,
1056
+ move: bool = True,
1057
+ ) -> None:
1058
+ """Its record, then (a run of a stop) its cursor: the part it read is read (I.2); then
1059
+ what the inbox is told (I.3): an agent run's result, a failure."""
1060
+ update: dict[str, Any] = {"state": state, "ended_at": now(), "error": error}
1061
+ if appended is not None:
1062
+ update["appended"] = appended
1063
+ record = record.model_copy(update=update)
1064
+ self.write_record(record)
1065
+ if move and not record.trial and record.session_id is not None:
1066
+ session_id, seq_to = record.session_id, record.seq_to or 0
1067
+
1068
+ def moved(s: config.State) -> None:
1069
+ s.cursors[session_id] = max(s.cursors.get(session_id, 0), seq_to)
1070
+
1071
+ self.update_state(record.automation, moved)
1072
+ self.tell(record, result)
1073
+ self.prune(record.automation)
1074
+
1075
+ def tell(self, record: AutomationRun, result: dict[str, Any] | None) -> None:
1076
+ """The inbox message of a run that ended (I.3): an agent run's result (a try's too), a
1077
+ failure (not a try's: its page shows it), nothing else."""
1078
+ loaded = config.get(self.settings.automations_dir, record.automation)
1079
+ run = loaded.definition.agent_run if loaded and loaded.definition else None
1080
+ title = record.automation + (" (try)" if record.trial else "")
1081
+ fields: dict[str, Any] = {
1082
+ "source": "automation",
1083
+ "name": record.automation,
1084
+ "agent": run.agent if run else None,
1085
+ "person": "owner",
1086
+ "title": title,
1087
+ "run_id": record.id,
1088
+ "session_id": record.agent_session_id,
1089
+ }
1090
+ if result is not None:
1091
+ status: InboxStatus = result["status"] if result.get("status") in STATUSES else "ok"
1092
+ summary = result.get("summary")
1093
+ summary = summary if isinstance(summary, str) else None
1094
+ self.inbox.add(**fields, status=status, summary=summary, data=result)
1095
+ elif record.state == "failed" and (run is not None or not record.trial):
1096
+ self.inbox.add(**fields, status="failed", summary=record.error)
1097
+
1098
+ def prune(self, name: str) -> None:
1099
+ """The last KEEP_RUNS runs, none older than KEEP_DAYS days."""
1100
+ oldest = now() - timedelta(days=KEEP_DAYS)
1101
+ busy = self.running.record.id if self.running else None
1102
+ for i, record in enumerate(self.runs(name)):
1103
+ if (
1104
+ record.id != busy
1105
+ and record.state != "queued"
1106
+ and (i >= KEEP_RUNS or record.created_at < oldest)
1107
+ ):
1108
+ shutil.rmtree(self.runs_dir(name) / record.id, ignore_errors=True)
1109
+
1110
+
1111
+ def new_run_id() -> str:
1112
+ return "a_" + pysecrets.token_hex(8)
1113
+
1114
+
1115
+ async def copy_log(proc: Any, path: Path, redact: Callable[[str], str]) -> None:
1116
+ """The run's output into run.log, redacted, at most MAX_LOG bytes (the rest is read, not
1117
+ kept)."""
1118
+ written = 0
1119
+ with path.open("w") as f:
1120
+ while True:
1121
+ try:
1122
+ raw = await proc.stdout.readline()
1123
+ except (ValueError, asyncio.LimitOverrunError):
1124
+ raw = b"(a line longer than 1 MiB, left out)\n"
1125
+ if not raw:
1126
+ break
1127
+ line = redact(raw.decode(errors="replace"))
1128
+ if written < MAX_LOG:
1129
+ kept = line[: MAX_LOG - written]
1130
+ f.write(kept)
1131
+ written += len(kept)
1132
+ if written >= MAX_LOG:
1133
+ f.write("\n… the log is cut at 1 MiB\n")
1134
+ f.flush()