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,69 @@
1
+ """Contract C — ACP endpoint and policy (`handler.acp-endpoint/v1`).
2
+
3
+ Mostly behavioural; these are the wire-level constants every implementation and client share.
4
+ """
5
+
6
+ from enum import IntEnum
7
+ from typing import Literal
8
+ from urllib.parse import quote
9
+
10
+ ACP_ENDPOINT_API_VERSION = "handler.acp-endpoint/v1"
11
+
12
+ ACP_SUBPROTOCOL = "acp.v1"
13
+ """WebSocket subprotocol the server must select (C.1)."""
14
+
15
+ BEARER_SUBPROTOCOL_PREFIX = "bearer."
16
+ """Prefix of the subprotocol carrying the token: `bearer.<token>` (C.1)."""
17
+
18
+ DEFAULT_MAX_MESSAGE_BYTES = 16 * 1024 * 1024
19
+ """Default maximum JSON-RPC message size (C.1)."""
20
+
21
+
22
+ class AcpCloseCode(IntEnum):
23
+ NORMAL = 1000
24
+ PROTOCOL_ERROR = 1002
25
+ UNSUPPORTED_DATA = 1003
26
+ MESSAGE_TOO_BIG = 1009
27
+ AGENT_EXITED = 1011
28
+ ATTACHED_ELSEWHERE = 4001 # F.7: more attachments than allowed, the oldest is closed
29
+ SESSION_STOPPED = 4002 # F.4: stop, idle timeout or agent exit
30
+ TOO_SLOW = 4003 # F.7: the client did not read its traffic fast enough
31
+ # remote access to the session was switched off (docs/dev/design/remote.md)
32
+ REMOTE_DISABLED = 4004
33
+
34
+
35
+ class SessionErrorCode(IntEnum):
36
+ """JSON-RPC errors of a Session's ACP endpoint (F.4)."""
37
+
38
+ ONE_SESSION_PER_ATTACHMENT = -32010
39
+ TURN_IN_PROGRESS = -32011
40
+ UNATTENDED = -32012 # a run of an agent with an interface takes no prompt (D.18)
41
+
42
+
43
+ class PolicyErrorCode(IntEnum):
44
+ """JSON-RPC error codes emitted by the policy pipeline (C.4); `data.policy` names the rule."""
45
+
46
+ CWD_OUTSIDE_SCOPE = -32001 # P2
47
+ MCP_NOT_ALLOWED = -32002 # P3
48
+ NOT_PERMITTED = -32003 # P4
49
+ SESSION_DEFAULTS_FAILED = -32004 # P7
50
+
51
+
52
+ PolicyRule = Literal["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8"]
53
+
54
+ BLOCKED_AGENT_METHOD_PREFIXES = ("fs/", "terminal/")
55
+ """Agent→client methods never forwarded (P4)."""
56
+
57
+ CWD_CHECKED_METHODS = ("session/new", "session/load", "session/resume")
58
+ """Client→agent methods whose `cwd` is checked against the scope (P2), and which get the
59
+ platform's MCP servers (P8)."""
60
+
61
+
62
+ def acp_path(agent_id: str) -> str:
63
+ """URL path of an agent's ACP endpoint."""
64
+ return f"/v1/agents/{quote(agent_id, safe='')}/acp"
65
+
66
+
67
+ def session_acp_path(session_id: str) -> str:
68
+ """URL path of a Session's ACP endpoint (F.4)."""
69
+ return f"/v1/sessions/{quote(session_id, safe='')}/acp"
@@ -0,0 +1,147 @@
1
+ """Contract G — Addon (`handler.addon/v1`) and binding (`handler.binding/v1`),
2
+ docs/dev/design/addons.md.
3
+
4
+ An addon says how to use a kind of system (a tool image, skills, env, slots, egress); it is
5
+ platform-owned data in `addons/<name>/addon.toml`, like a harness descriptor. A binding is one
6
+ addon bound to one instance of that system with its settings and secret references; it is
7
+ user-owned TOML in `~/.config/handler-ai/bindings/<name>.toml`, like an account or a provider.
8
+ """
9
+
10
+ import tomllib
11
+ from typing import Annotated, Literal, Self
12
+
13
+ from pydantic import Field, model_validator
14
+
15
+ from .common import (
16
+ Document,
17
+ EgressEntry,
18
+ EnvMap,
19
+ EnvName,
20
+ SecretRef,
21
+ StrictDocument,
22
+ )
23
+ from .harness import ImageRepository, NonEmpty
24
+ from .sandbox import CA_ENV
25
+
26
+ ADDON_API_VERSION = "handler.addon/v1"
27
+ BINDING_API_VERSION = "handler.binding/v1"
28
+
29
+ # An addon's image is mounted read-only at <ADDONS_ROOT>/<name>; its `bin/` goes on PATH (G.2).
30
+ ADDONS_ROOT = "/opt/addons"
31
+ # Every addon image records what it installed here (relative to its root), like IMAGE_VERSION_FILE.
32
+ ADDON_VERSION_FILE = "version"
33
+
34
+ AddonName = Annotated[str, Field(pattern=r"^[a-z0-9][a-z0-9-]*$")]
35
+ BindingName = Annotated[str, Field(pattern=r"^[a-z0-9][a-z0-9_.-]*$")]
36
+ SlotName = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_]*$")]
37
+ CommandName = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.+-]*$")]
38
+
39
+ # Env vars the platform owns: an addon may not set them (PATH is extended through `bin/`; the CA
40
+ # variables point at the roots the sandbox trusts when its proxy gives secrets, A.3).
41
+ RESERVED_ENV = frozenset({"PATH", "HOME", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"}) | CA_ENV
42
+
43
+
44
+ class AddonSlot(Document):
45
+ """A setting or secret slot: the binding's value is exported as `env`."""
46
+
47
+ env: EnvName
48
+ required: bool = False
49
+ description: str | None = None
50
+
51
+
52
+ class AddonSecretSlot(AddonSlot):
53
+ """A secret slot: its value reaches the agent's sandbox as a phantom its proxy swaps back
54
+ towards `hosts` (E3, A.3), or with `delivery = "env"` as it is, for a tool that cannot be
55
+ given a phantom (it signs its requests with the secret, or speaks something else than HTTP)."""
56
+
57
+ hosts: list[EgressEntry] | None = None # where the secret goes: some of `egress` (default all)
58
+ delivery: Literal["proxy", "env"] = "proxy"
59
+
60
+
61
+ class AddonMcp(Document):
62
+ """An MCP server the addon provides, over stdio, started from one of its own `commands`
63
+ (installed at build time). Added to the agent's MCP servers under the addon's name."""
64
+
65
+ command: Annotated[list[NonEmpty], Field(min_length=1)]
66
+
67
+
68
+ class AddonManifest(Document):
69
+ api_version: Literal["handler.addon/v1"]
70
+ name: AddonName
71
+ description: str | None = None
72
+ image: ImageRepository | None = None # None: an addon without a tool (skills, env, egress)
73
+ commands: list[CommandName] = [] # exactly what the image's bin/ provides (checked at build)
74
+ check: Annotated[list[NonEmpty], Field(min_length=1)] | None = None # run by a rebuild (G.4)
75
+ env: EnvMap | None = None
76
+ settings: dict[SlotName, AddonSlot] = {}
77
+ secret_slots: dict[SlotName, AddonSecretSlot] = {}
78
+ egress: list[EgressEntry] = []
79
+ mcp: AddonMcp | None = None
80
+
81
+ @model_validator(mode="after")
82
+ def _consistent(self) -> Self:
83
+ if self.image is None and (self.commands or self.check):
84
+ raise ValueError("'commands' and 'check' need an 'image'")
85
+ if self.mcp and self.mcp.command[0] not in self.commands:
86
+ raise ValueError("'mcp.command' must start with one of the addon's 'commands'")
87
+ if self.image is not None and not self.commands:
88
+ raise ValueError("an addon with an 'image' must declare its 'commands'")
89
+ if len(set(self.commands)) != len(self.commands):
90
+ raise ValueError("'commands' has duplicates")
91
+ names = [
92
+ *(self.env or {}),
93
+ *(s.env for s in self.settings.values()),
94
+ *(s.env for s in self.secret_slots.values()),
95
+ ]
96
+ if reserved := sorted(set(names) & RESERVED_ENV):
97
+ raise ValueError(f"env vars owned by the platform: {', '.join(reserved)}")
98
+ if len(set(names)) != len(names):
99
+ raise ValueError("an env var is set twice (env, settings and secretSlots must differ)")
100
+ if clash := sorted(self.settings.keys() & self.secret_slots.keys()):
101
+ raise ValueError(f"slot names used for both a setting and a secret: {', '.join(clash)}")
102
+ for name, slot in self.secret_slots.items():
103
+ if slot.hosts is not None and slot.delivery == "env":
104
+ raise ValueError(f"secret {name}: 'hosts' are for the proxy, not 'env' delivery")
105
+ if outside := sorted(set(slot.hosts or []) - set(self.egress)):
106
+ raise ValueError(f"secret {name}: hosts not in 'egress': {', '.join(outside)}")
107
+ return self
108
+
109
+ def secret_hosts(self, slot: str) -> list[str] | None:
110
+ """Where a secret slot's value goes through the proxy (A.3); None: given as it is."""
111
+ declared = self.secret_slots[slot]
112
+ if declared.delivery == "env":
113
+ return None
114
+ return list(self.egress if declared.hosts is None else declared.hosts)
115
+
116
+ @property
117
+ def mount_target(self) -> str:
118
+ return f"{ADDONS_ROOT}/{self.name}"
119
+
120
+ @property
121
+ def bin_dir(self) -> str:
122
+ return f"{self.mount_target}/bin"
123
+
124
+
125
+ class Binding(StrictDocument):
126
+ api_version: Literal["handler.binding/v1"]
127
+ addon: AddonName
128
+ description: str | None = None
129
+ settings: dict[SlotName, str] | None = None
130
+ secrets: dict[SlotName, SecretRef] | None = None # references only, never values (E2)
131
+
132
+ @classmethod
133
+ def from_toml(cls, text: str) -> "Binding":
134
+ return cls.model_validate(tomllib.loads(text))
135
+
136
+
137
+ def check_binding(binding: Binding, manifest: AddonManifest) -> None:
138
+ """G1: a binding only fills slots its addon declares, and fills every required one.
139
+ Raises ValueError with a readable message."""
140
+ for kind, bound, slots in (
141
+ ("setting", binding.settings or {}, manifest.settings),
142
+ ("secret", binding.secrets or {}, manifest.secret_slots),
143
+ ):
144
+ if unknown := sorted(bound.keys() - slots.keys()):
145
+ raise ValueError(f"{kind} {', '.join(unknown)} not declared by addon '{manifest.name}'")
146
+ if missing := sorted(n for n, s in slots.items() if s.required and n not in bound):
147
+ raise ValueError(f"required {kind} {', '.join(missing)} is missing")
@@ -0,0 +1,75 @@
1
+ """Contract E — what an agent runs as: a harness, the account (`handler.account/v1`, account.py) or
2
+ the provider (`handler.provider/v1`, provider.py) its model calls use, and a model. An agent (D.8)
3
+ names them; `AgentSpec` is them resolved (the account's login and plan, the provider and its files
4
+ for this harness): what `render` takes (B.3)."""
5
+
6
+ from typing import Annotated, Self
7
+
8
+ from pydantic import Field, model_validator
9
+
10
+ from .account import Account
11
+ from .common import FileSource, HostPath, ResourceName, StrictDocument
12
+ from .provider import Provider
13
+
14
+ HarnessName = Annotated[str, Field(min_length=1, pattern=r"^[a-z0-9][a-z0-9-]*$")]
15
+
16
+
17
+ class NeutralConfig(StrictDocument):
18
+ """Harness-neutral agent configuration (B.2)."""
19
+
20
+ model: Annotated[str, Field(min_length=1)] | None = None
21
+ account_connectors: bool = False # decision 1: off by default (the account's `connectors`)
22
+
23
+
24
+ class AgentSpec(StrictDocument):
25
+ """An agent's harness with its account or its provider (exactly one), resolved."""
26
+
27
+ harness: HarnessName
28
+ account: ResourceName | None = None # its name (accounts/<name>.toml)...
29
+ provider: ResourceName | None = None # ...or this one's (providers/<name>.toml)
30
+ # the account's credential file, when its harness's login leaves one and it exists (set by
31
+ # the control plane, which keeps it); a token login is resolved at launch
32
+ account_login: HostPath | None = None
33
+ account_plan: str | None = None # the account's plan (told to the harness, B.3)
34
+ provider_spec: Provider | None = None
35
+ files: dict[str, FileSource] | None = None # the provider's for this harness (config slots)
36
+ config: NeutralConfig = NeutralConfig()
37
+
38
+ @model_validator(mode="after")
39
+ def _account_or_provider(self) -> Self:
40
+ if (self.account is None) == (self.provider is None):
41
+ raise ValueError("name exactly one of 'account' (its login) and 'provider' (its model)")
42
+ return self
43
+
44
+ @classmethod
45
+ def resolve(
46
+ cls,
47
+ harness: str,
48
+ model: str | None = None,
49
+ *,
50
+ account: tuple[str, Account] | None = None,
51
+ provider: tuple[str, Provider] | None = None,
52
+ ) -> "AgentSpec":
53
+ """`account` / `provider`: (name, document). ValueError when the account is another
54
+ harness's."""
55
+ if account is not None:
56
+ name, found = account
57
+ if found.harness != harness:
58
+ other = found.harness
59
+ raise ValueError(f"account '{name}' is for harness '{other}', not '{harness}'")
60
+ return cls(
61
+ harness=harness,
62
+ account=name,
63
+ account_plan=found.plan,
64
+ config=NeutralConfig(model=model, account_connectors=found.connectors),
65
+ )
66
+ if provider is None:
67
+ raise ValueError("name exactly one of 'account' (its login) and 'provider' (its model)")
68
+ name, spec = provider
69
+ return cls(
70
+ harness=harness,
71
+ provider=name,
72
+ provider_spec=spec,
73
+ files=(spec.files or {}).get(harness),
74
+ config=NeutralConfig(model=model),
75
+ )
@@ -0,0 +1,285 @@
1
+ """Contract I — Automations (`handler.automation/v1`): *when this happens, run that*. When a session
2
+ stops or a schedule comes, handlerd runs the automation's run: the user's script in a sandbox (which
3
+ may call models through handlerd's `llm` service, and whose files handlerd appends to knowledge
4
+ notes), an agent with an interface (its result goes to the inbox), or a mini app's route.
5
+
6
+ `Automation` is hand-written TOML (`<config>/automations/<name>/automation.toml`): unknown keys
7
+ are errors. The rest is exchanged: the Control API's views and requests (I.9), and the `llm/v1`
8
+ service's one tool, `llm_complete` (I.5).
9
+ """
10
+
11
+ import tomllib
12
+ from typing import Annotated, Any, Literal, Self
13
+
14
+ from pydantic import AfterValidator, AwareDatetime, Field, model_validator
15
+
16
+ from .common import Document, StrictDocument
17
+ from .miniapp import AppName
18
+
19
+ AUTOMATION_API_VERSION = "handler.automation/v1"
20
+ AUTOMATION_FILE = "automation.toml"
21
+
22
+ AutomationName = Annotated[str, Field(pattern=r"^[a-z0-9][a-z0-9-]{0,63}$")]
23
+ AutomationEvent = Literal["session.stopped"] # an event `when` (I.2); a schedule is the other
24
+ # a provider's model, "provider/model": the provider's name, then its model's id
25
+ LlmModel = Annotated[str, Field(pattern=r"^[a-z0-9][a-z0-9_.-]*/.+$")]
26
+ RunState = Literal["queued", "running", "succeeded", "failed", "cancelled", "interrupted"]
27
+ Day = Literal["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
28
+
29
+ MIN_EVERY_MINUTES = 5
30
+ DEFAULT_TIMEOUT_SEC = 600 # a script's (I.3)
31
+ MAX_LLM_INPUT = 256 * 1024 # system + prompt + input, in bytes (I.5)
32
+ MAX_LLM_SCHEMA = 64 * 1024
33
+ MAX_APPEND = 256 * 1024 # one `from` file (I.6)
34
+
35
+
36
+ def _output_path(p: str) -> str:
37
+ if p.startswith("/") or ".." in p.split("/"):
38
+ raise ValueError("must be relative to the run's output folder, without '..'")
39
+ return p
40
+
41
+
42
+ class Schedule(StrictDocument):
43
+ """A `when` that is a time (I.2): every N minutes or hours, or at a local time of day."""
44
+
45
+ every: Annotated[str, Field(pattern=r"^[1-9]\d{0,3}[mh]$")] | None = None # "30m", "2h"
46
+ at: Annotated[str, Field(pattern=r"^([01]\d|2[0-3]):[0-5]\d$")] | None = None # local time
47
+ days: list[Day] | None = None # with `at`; default every day
48
+
49
+ @model_validator(mode="after")
50
+ def _one_rule(self) -> Self:
51
+ if (self.every is None) == (self.at is None):
52
+ raise ValueError("a schedule has either 'every' or 'at'")
53
+ if self.days is not None and self.at is None:
54
+ raise ValueError("'days' goes with 'at'")
55
+ if not (self.days is None or self.days):
56
+ raise ValueError("'days' lists at least one day")
57
+ if self.every_minutes is not None and self.every_minutes < MIN_EVERY_MINUTES:
58
+ raise ValueError(f"'every' is at least {MIN_EVERY_MINUTES}m")
59
+ return self
60
+
61
+ @property
62
+ def every_minutes(self) -> int | None:
63
+ if self.every is None:
64
+ return None
65
+ return int(self.every[:-1]) * (60 if self.every.endswith("h") else 1)
66
+
67
+ @property
68
+ def label(self) -> str:
69
+ """ "every 1h", "at 08:30", "at 08:30 mon,fri"."""
70
+ if self.every is not None:
71
+ return f"every {self.every}"
72
+ return " ".join(x for x in (f"at {self.at}", ",".join(self.days or [])) if x)
73
+
74
+
75
+ class AutomationOnly(StrictDocument):
76
+ """The sessions an automation is for: by their project's name, their agent's; absent: any."""
77
+
78
+ project: list[str] | None = None
79
+ agent: list[str] | None = None
80
+
81
+
82
+ class AutomationServices(StrictDocument):
83
+ llm: list[LlmModel] = [] # the models the script may call (I.5)
84
+
85
+
86
+ class AutomationAppend(StrictDocument):
87
+ """A file the run wrote, appended by handlerd to a knowledge note once the run succeeded
88
+ (I.6)."""
89
+
90
+ from_: Annotated[str, Field(alias="from", min_length=1), AfterValidator(_output_path)]
91
+ to: Annotated[str, Field(min_length=1)] # a markdown note, relative to the knowledge root
92
+
93
+
94
+ class AutomationSubject(StrictDocument):
95
+ skill: Annotated[str, Field(min_length=1)] # a skill of the library (D.17)
96
+
97
+
98
+ class AutomationAgentInput(StrictDocument):
99
+ """What the agent's run is started with (D.18 `SessionInput`)."""
100
+
101
+ fields: dict[str, Any] = {}
102
+ subject: AutomationSubject | None = None
103
+
104
+
105
+ class AutomationAgentRun(StrictDocument):
106
+ """`run = { agent, project?, input? }`: a run of an agent with an interface (I.3, D.18)."""
107
+
108
+ agent: Annotated[str, Field(min_length=1)] # its name
109
+ project: str | None = None # its project's name; absent: an agent of no project
110
+ input: AutomationAgentInput = AutomationAgentInput()
111
+
112
+
113
+ class AutomationAppCall(StrictDocument):
114
+ """`run = { app, call }`: a call to a mini app's route, as the owner (I.3, H.9)."""
115
+
116
+ app: AppName
117
+ call: Annotated[str, Field(pattern=r"^POST /api/[A-Za-z0-9/_.-]*$")]
118
+
119
+
120
+ class Automation(StrictDocument):
121
+ """automation.toml (I.1)."""
122
+
123
+ api_version: Literal["handler.automation/v1"]
124
+ description: str | None = None
125
+ when: AutomationEvent | Schedule
126
+ only: AutomationOnly | None = None # with `session.stopped`
127
+ # a script: a command line (`sh -c`), in its folder; or an agent run; or an app's route
128
+ run: Annotated[str, Field(min_length=1)] | AutomationAgentRun | AutomationAppCall
129
+ timeout_sec: Annotated[int, Field(ge=1, le=3600)] | None = None # a script's, default 600
130
+ services: AutomationServices | None = None # a script's
131
+ append: list[AutomationAppend] = [] # a script's
132
+
133
+ @model_validator(mode="after")
134
+ def _fits(self) -> Self:
135
+ if self.only is not None and self.schedule is not None:
136
+ raise ValueError("'only' goes with when = \"session.stopped\"")
137
+ if self.script is None:
138
+ for key, value in (
139
+ ("timeoutSec", self.timeout_sec),
140
+ ("services", self.services),
141
+ ("append", self.append or None),
142
+ ):
143
+ if value is not None:
144
+ raise ValueError(f"'{key}' goes with a script run")
145
+ return self
146
+
147
+ @property
148
+ def schedule(self) -> Schedule | None:
149
+ return self.when if isinstance(self.when, Schedule) else None
150
+
151
+ @property
152
+ def script(self) -> str | None:
153
+ return self.run if isinstance(self.run, str) else None
154
+
155
+ @property
156
+ def agent_run(self) -> AutomationAgentRun | None:
157
+ return self.run if isinstance(self.run, AutomationAgentRun) else None
158
+
159
+ @property
160
+ def app_call(self) -> AutomationAppCall | None:
161
+ return self.run if isinstance(self.run, AutomationAppCall) else None
162
+
163
+ @classmethod
164
+ def from_toml(cls, text: str) -> Self:
165
+ return cls.model_validate(tomllib.loads(text))
166
+
167
+
168
+ class AutomationLlmUsage(Document):
169
+ calls: int = 0
170
+ in_tokens: int = 0
171
+ out_tokens: int = 0
172
+
173
+
174
+ class AutomationAppended(Document):
175
+ to: str
176
+ bytes: int
177
+ text: str | None = None # a try's preview: the first 4 KiB of what it would append
178
+
179
+
180
+ class AutomationRun(Document):
181
+ id: str # "a_" + 16 hex
182
+ automation: AutomationName
183
+ trial: bool = False
184
+ session_id: str | None = None # the stopped session it read (I.4)
185
+ seq_from: int | None = None
186
+ seq_to: int | None = None
187
+ slot: AwareDatetime | None = None # the schedule's slot it ran for
188
+ missed: int = 0 # slots before it, missed while handlerd was not running (only the last runs)
189
+ agent_session_id: str | None = None # an agent run's session (D.18)
190
+ state: RunState
191
+ exit_code: int | None = None
192
+ ms: int | None = None
193
+ llm: AutomationLlmUsage = AutomationLlmUsage()
194
+ appended: list[AutomationAppended] = []
195
+ error: str | None = None
196
+ created_at: AwareDatetime
197
+ ended_at: AwareDatetime | None = None
198
+
199
+
200
+ class AutomationView(Document):
201
+ name: AutomationName
202
+ description: str | None = None
203
+ definition: Automation | None = None # absent when it does not parse
204
+ directory: str # its folder, where its files are edited
205
+ files: list[str] = [] # relative to it
206
+ problems: list[str] = []
207
+ enabled: bool = False
208
+ permissions: list[str] = [] # what it may touch and what starts it, in sentences (I.7)
209
+ warnings: list[str] = [] # what to know before turning it on (an account's terms, I.7)
210
+ next_at: AwareDatetime | None = None # a scheduled one's next slot, while it is on
211
+ pending: int = 0 # runs waiting
212
+ behind: AutomationName | None = None # what runs now, when its runs wait (one at a time)
213
+ capped: bool = False # the daily cap is reached
214
+ last_run: AutomationRun | None = None
215
+
216
+
217
+ class AutomationExample(Document):
218
+ """An example automation handler ships (I.1), copied by `POST /v1/automations`."""
219
+
220
+ name: AutomationName
221
+ description: str | None = None
222
+
223
+
224
+ class CreateAutomationRequest(Document):
225
+ """A new automation, off: a shipped example copied, or a definition handler writes as its
226
+ automation.toml (the web UI's form, I.1). With `enable`, turned on at once (I.7)."""
227
+
228
+ name: AutomationName
229
+ from_: Annotated[AutomationName | None, Field(alias="from")] = None # a shipped example
230
+ definition: Automation | None = None
231
+ enable: bool = False
232
+
233
+ @model_validator(mode="after")
234
+ def _one_source(self) -> Self:
235
+ if (self.from_ is None) == (self.definition is None):
236
+ raise ValueError("give either 'from' (an example) or 'definition'")
237
+ if self.from_ is not None and self.enable:
238
+ raise ValueError("an example is created off: edit it, then turn it on")
239
+ return self
240
+
241
+
242
+ class AutomationDefinitionRequest(Document):
243
+ """A definition to check (`POST /v1/automations/check`) or to write over an automation's
244
+ automation.toml (`PUT /v1/automations/{name}`: the web UI's form, I.1)."""
245
+
246
+ definition: Automation
247
+
248
+
249
+ class AutomationCheck(Document):
250
+ """What a definition would do before it is saved (I.9): what `AutomationView` would say."""
251
+
252
+ problems: list[str] = []
253
+ warnings: list[str] = []
254
+ permissions: list[str] = []
255
+ next_at: AwareDatetime | None = None # a schedule's first slot if it were turned on now
256
+
257
+
258
+ class TryAutomationRequest(Document):
259
+ session_id: str | None = None # the session read, for `when = "session.stopped"` (I.8)
260
+
261
+
262
+ class LlmUsage(StrictDocument):
263
+ in_tokens: int
264
+ out_tokens: int
265
+
266
+
267
+ class LlmRequest(StrictDocument):
268
+ """`llm_complete`'s input (I.5)."""
269
+
270
+ model: LlmModel # one of the automation's `services.llm`
271
+ system: str | None = None
272
+ prompt: Annotated[str, Field(min_length=1)]
273
+ input: str | None = None
274
+ schema_: Annotated[dict[str, Any] | None, Field(alias="schema")] = None
275
+
276
+
277
+ class LlmResponse(StrictDocument):
278
+ """`llm_complete`'s output (I.5)."""
279
+
280
+ valid: bool
281
+ value: Any = None # with a schema: the parsed answer
282
+ text: str | None = None # without one: the answer
283
+ error: str | None = None # why it is not valid (its JSON, after the repair turn)
284
+ usage: LlmUsage
285
+ ms: int