handler-ai 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- handler_ai-0.1.0.dist-info/METADATA +60 -0
- handler_ai-0.1.0.dist-info/RECORD +234 -0
- handler_ai-0.1.0.dist-info/WHEEL +4 -0
- handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
- handler_cli/__init__.py +1 -0
- handler_cli/account_commands.py +314 -0
- handler_cli/addon_commands.py +128 -0
- handler_cli/agent_commands.py +139 -0
- handler_cli/app_commands.py +175 -0
- handler_cli/app_window.py +85 -0
- handler_cli/automation_commands.py +309 -0
- handler_cli/bridge.py +42 -0
- handler_cli/cli.py +326 -0
- handler_cli/client.py +81 -0
- handler_cli/daemon_commands.py +108 -0
- handler_cli/image_commands.py +102 -0
- handler_cli/inbox_commands.py +33 -0
- handler_cli/output.py +12 -0
- handler_cli/project_commands.py +63 -0
- handler_cli/provider_commands.py +113 -0
- handler_cli/secret_commands.py +64 -0
- handler_cli/session_bridge.py +93 -0
- handler_cli/session_commands.py +485 -0
- handler_cli/setup.py +120 -0
- handler_cli/terminal.py +280 -0
- handler_cli/terminal_client.py +37 -0
- handler_contracts/__init__.py +642 -0
- handler_contracts/account.py +36 -0
- handler_contracts/acp_endpoint.py +69 -0
- handler_contracts/addon.py +147 -0
- handler_contracts/agent_spec.py +75 -0
- handler_contracts/automation.py +285 -0
- handler_contracts/common.py +229 -0
- handler_contracts/control.py +1208 -0
- handler_contracts/harness.py +300 -0
- handler_contracts/miniapp.py +141 -0
- handler_contracts/provider.py +82 -0
- handler_contracts/remote.py +167 -0
- handler_contracts/sandbox.py +199 -0
- handler_contracts/schemas.py +313 -0
- handler_contracts/terminal.py +32 -0
- handler_control/__init__.py +7 -0
- handler_control/access.py +62 -0
- handler_control/accounts.py +279 -0
- handler_control/addon_install.py +268 -0
- handler_control/addons.py +118 -0
- handler_control/agent_state.py +46 -0
- handler_control/agents.py +257 -0
- handler_control/api/__init__.py +137 -0
- handler_control/api/accounts.py +231 -0
- handler_control/api/acp.py +116 -0
- handler_control/api/addons.py +95 -0
- handler_control/api/agents.py +150 -0
- handler_control/api/apps.py +104 -0
- handler_control/api/automations.py +128 -0
- handler_control/api/bindings.py +105 -0
- handler_control/api/builtin_agents.py +22 -0
- handler_control/api/caches.py +79 -0
- handler_control/api/catalog.py +197 -0
- handler_control/api/common.py +205 -0
- handler_control/api/contexts.py +88 -0
- handler_control/api/folders.py +51 -0
- handler_control/api/images.py +164 -0
- handler_control/api/inbox.py +21 -0
- handler_control/api/projects.py +170 -0
- handler_control/api/providers.py +107 -0
- handler_control/api/remote.py +45 -0
- handler_control/api/secrets.py +177 -0
- handler_control/api/sessions.py +192 -0
- handler_control/api/skills.py +270 -0
- handler_control/app_api.py +84 -0
- handler_control/app_http.py +214 -0
- handler_control/app_remote.py +206 -0
- handler_control/apps.py +867 -0
- handler_control/auth.py +48 -0
- handler_control/automation_config.py +255 -0
- handler_control/automations.py +1134 -0
- handler_control/bindings.py +149 -0
- handler_control/builds.py +341 -0
- handler_control/builtin_agents.py +82 -0
- handler_control/caches.py +113 -0
- handler_control/checks.py +287 -0
- handler_control/compose.py +317 -0
- handler_control/config_files.py +41 -0
- handler_control/contexts.py +75 -0
- handler_control/daemon.py +64 -0
- handler_control/data/automation/bin/handler-llm +85 -0
- handler_control/data/automations/env-issues/automation.toml +17 -0
- handler_control/data/automations/env-issues/extract.py +73 -0
- handler_control/data/automations/env-issues/findings.schema.json +28 -0
- handler_control/data/automations/env-issues/prompt.md +19 -0
- handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
- handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
- handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
- handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
- handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
- handler_control/data/builtin-agents/skill-review/interface.json +83 -0
- handler_control/data/interfaces/llm/v1/interface.toml +17 -0
- handler_control/data/interfaces/llm/v1/tools.json +138 -0
- handler_control/data/templates/README.md +10 -0
- handler_control/data/templates/providers/anthropic-api.toml +18 -0
- handler_control/data/templates/providers/llamacpp.toml +210 -0
- handler_control/data/templates/providers/mistral-api.toml +14 -0
- handler_control/data/templates/providers/ollama.toml +10 -0
- handler_control/data/templates/providers/openai-api.toml +13 -0
- handler_control/data/templates/providers/openrouter.toml +10 -0
- handler_control/desktop.py +68 -0
- handler_control/display.py +62 -0
- handler_control/files.py +23 -0
- handler_control/folders.py +68 -0
- handler_control/git_read.py +39 -0
- handler_control/image_check.py +322 -0
- handler_control/image_updates.py +174 -0
- handler_control/inbox.py +70 -0
- handler_control/interfaces.py +151 -0
- handler_control/knowledge.py +237 -0
- handler_control/launcher.py +356 -0
- handler_control/llm.py +194 -0
- handler_control/migrations/__init__.py +62 -0
- handler_control/policy.py +305 -0
- handler_control/projects.py +266 -0
- handler_control/providers.py +180 -0
- handler_control/relay.py +133 -0
- handler_control/remote.py +888 -0
- handler_control/remote_crypto.py +97 -0
- handler_control/runtime.py +140 -0
- handler_control/sandbox_process.py +224 -0
- handler_control/schedules.py +58 -0
- handler_control/secrets.py +242 -0
- handler_control/service_router.py +180 -0
- handler_control/services.py +92 -0
- handler_control/session_files.py +429 -0
- handler_control/sessions.py +1116 -0
- handler_control/settings.py +129 -0
- handler_control/shortcuts.py +80 -0
- handler_control/skill_scan.py +88 -0
- handler_control/skill_sources.py +61 -0
- handler_control/skills.py +378 -0
- handler_control/terminal.py +79 -0
- handler_control/terminals.py +108 -0
- handler_control/transcript.py +174 -0
- handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
- handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
- handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- handler_control/web-ui/favicon.svg +1 -0
- handler_control/web-ui/index.html +15 -0
- handler_control/webpush.py +90 -0
- handler_control/webui.py +105 -0
- handler_harness/__init__.py +13 -0
- handler_harness/addons.py +129 -0
- handler_harness/catalog.py +116 -0
- handler_harness/cli.py +109 -0
- handler_harness/data/addons/README.md +44 -0
- handler_harness/data/addons/miniapp/Containerfile +20 -0
- handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
- handler_harness/data/addons/miniapp/addon.toml +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
- handler_harness/data/catalog/README.md +12 -0
- handler_harness/data/catalog/playwright/Containerfile +28 -0
- handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
- handler_harness/data/catalog/playwright/addon.toml +25 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
- handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
- handler_harness/data/catalog/shopify/Containerfile +22 -0
- handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
- handler_harness/data/catalog/shopify/addon.toml +39 -0
- handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
- handler_harness/data/handler-sandbox-env +30 -0
- handler_harness/data/harnesses/README.md +47 -0
- handler_harness/data/harnesses/claude-code/Containerfile +27 -0
- handler_harness/data/harnesses/claude-code/harness.toml +95 -0
- handler_harness/data/harnesses/codex/Containerfile +26 -0
- handler_harness/data/harnesses/codex/harness.toml +103 -0
- handler_harness/data/harnesses/pi/Containerfile +27 -0
- handler_harness/data/harnesses/pi/harness.toml +65 -0
- handler_harness/data/harnesses/vibe/Containerfile +19 -0
- handler_harness/data/harnesses/vibe/harness.toml +96 -0
- handler_harness/data/toolbox/Containerfile +56 -0
- handler_harness/data/toolbox/README.md +24 -0
- handler_harness/data/toolbox/toolbox.toml +41 -0
- handler_harness/images.py +62 -0
- handler_harness/providers.py +300 -0
- handler_harness/render.py +472 -0
- handler_sandbox_podman/__init__.py +1 -0
- handler_sandbox_podman/caches.py +97 -0
- handler_sandbox_podman/check.py +121 -0
- handler_sandbox_podman/cli.py +239 -0
- handler_sandbox_podman/egress.py +118 -0
- handler_sandbox_podman/images.py +199 -0
- handler_sandbox_podman/launch.py +474 -0
- handler_sandbox_podman/podman.py +108 -0
- handler_sandbox_podman/proxy/Containerfile +6 -0
- handler_sandbox_podman/proxy/addon.py +255 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Providers — contract E, D.12: `~/.config/handler-ai/providers/<name>.toml`, an endpoint for
|
|
2
|
+
each protocol it speaks, its key and its models, one per file, name = file stem. Written through the
|
|
3
|
+
Control API (from a template of `templates/providers/`, or edited), or by hand. Through the API,
|
|
4
|
+
its key goes to the secret store (`providers/<name>/apiKey`); a hand-written file may name an
|
|
5
|
+
`env`, `file` or `command` key instead, which the API keeps as it is and never writes.
|
|
6
|
+
|
|
7
|
+
Status: `ready` when its key, if it has one, resolves; `incomplete` with the problem otherwise;
|
|
8
|
+
`invalid` when the file is not a valid provider.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import tomlkit
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from handler_contracts import (
|
|
18
|
+
PROVIDER_API_VERSION,
|
|
19
|
+
CommandSecret,
|
|
20
|
+
Provider,
|
|
21
|
+
ProviderRequest,
|
|
22
|
+
ProviderTemplate,
|
|
23
|
+
StoreSecret,
|
|
24
|
+
)
|
|
25
|
+
from handler_harness import catalog
|
|
26
|
+
|
|
27
|
+
from . import secrets
|
|
28
|
+
from .files import describe, write_private
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderError(ValueError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class LoadedProvider:
|
|
37
|
+
name: str
|
|
38
|
+
path: Path
|
|
39
|
+
provider: Provider | None
|
|
40
|
+
error: str | None = None # set when the file is invalid (its agents are then unusable)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load(path: Path) -> LoadedProvider:
|
|
44
|
+
name = path.stem
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
return LoadedProvider(name, path, None, f"no provider '{name}' ({path})")
|
|
47
|
+
try:
|
|
48
|
+
provider = Provider.from_toml(path.read_text())
|
|
49
|
+
except ValidationError as e:
|
|
50
|
+
return LoadedProvider(name, path, None, describe(e))
|
|
51
|
+
except (OSError, ValueError) as e: # unreadable file, TOML syntax error
|
|
52
|
+
return LoadedProvider(name, path, None, str(e))
|
|
53
|
+
return LoadedProvider(name, path, provider)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get(directory: Path, name: str) -> LoadedProvider:
|
|
57
|
+
if "/" in name or name.startswith("."):
|
|
58
|
+
return LoadedProvider(name, directory / name, None, f"invalid provider name '{name}'")
|
|
59
|
+
return load(directory / f"{name}.toml")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_all(directory: Path) -> list[LoadedProvider]:
|
|
63
|
+
if not directory.is_dir():
|
|
64
|
+
return []
|
|
65
|
+
return [load(p) for p in sorted(directory.glob("*.toml"))]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def problems(provider: Provider, store: secrets.SecretStore) -> list[str]:
|
|
69
|
+
"""What keeps it from being used as it is: a key that does not resolve. Resolving a `command`
|
|
70
|
+
key runs it: only `env` and `file` ones are checked."""
|
|
71
|
+
ref = provider.api_key
|
|
72
|
+
if ref is None or isinstance(ref, CommandSecret):
|
|
73
|
+
return []
|
|
74
|
+
try:
|
|
75
|
+
secrets.resolve_all({"apiKey": ref}, store)
|
|
76
|
+
except secrets.SecretError as e:
|
|
77
|
+
return [f"apiKey: {e}"]
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def harnesses(provider: Provider) -> list[str]:
|
|
82
|
+
"""The harnesses that speak one of its protocols."""
|
|
83
|
+
return [
|
|
84
|
+
h.name
|
|
85
|
+
for h in catalog.all_harnesses()
|
|
86
|
+
if provider.endpoint_for(h.descriptor.provider_protocols) is not None
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def templates(templates_dir: Path) -> list[ProviderTemplate]:
|
|
91
|
+
return [
|
|
92
|
+
ProviderTemplate(
|
|
93
|
+
name=loaded.name,
|
|
94
|
+
description=p.description,
|
|
95
|
+
endpoints=dict(p.endpoints),
|
|
96
|
+
models=p.models,
|
|
97
|
+
needs_key=p.api_key is not None,
|
|
98
|
+
files=p.files,
|
|
99
|
+
)
|
|
100
|
+
for loaded in load_all(templates_dir / "providers")
|
|
101
|
+
if (p := loaded.provider) is not None
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def key_path(name: str) -> str:
|
|
106
|
+
return f"providers/{name}/apiKey"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save(
|
|
110
|
+
directory: Path,
|
|
111
|
+
store: secrets.SecretStore,
|
|
112
|
+
request: ProviderRequest,
|
|
113
|
+
old: LoadedProvider | None,
|
|
114
|
+
) -> LoadedProvider:
|
|
115
|
+
"""Create the provider (`old` None) or replace `old`, renaming it to `request.name`: its key
|
|
116
|
+
follows. A new key goes to the secret store; without one, the key it has is kept."""
|
|
117
|
+
name = request.name
|
|
118
|
+
target = directory / f"{name}.toml"
|
|
119
|
+
if (old is None or old.name != name) and target.exists():
|
|
120
|
+
raise ProviderError(f"provider '{name}' exists")
|
|
121
|
+
key = old.provider.api_key if old is not None and old.provider is not None else None
|
|
122
|
+
files = old.provider.files if old is not None and old.provider is not None else None
|
|
123
|
+
if request.files is not None:
|
|
124
|
+
files = request.files
|
|
125
|
+
if old is not None and old.name != name:
|
|
126
|
+
store.rename(f"providers/{old.name}", f"providers/{name}")
|
|
127
|
+
if isinstance(key, StoreSecret) and key.secret == key_path(old.name):
|
|
128
|
+
key = StoreSecret(secret=key_path(name))
|
|
129
|
+
if request.key is not None:
|
|
130
|
+
store.put(key_path(name), request.key, f"key of the provider {name}")
|
|
131
|
+
key = StoreSecret(secret=key_path(name))
|
|
132
|
+
elif request.remove_key:
|
|
133
|
+
store.delete(key_path(name))
|
|
134
|
+
key = None
|
|
135
|
+
provider = Provider(
|
|
136
|
+
api_version=PROVIDER_API_VERSION,
|
|
137
|
+
description=request.description,
|
|
138
|
+
endpoints=request.endpoints,
|
|
139
|
+
api_key=key,
|
|
140
|
+
models=request.models,
|
|
141
|
+
files=files,
|
|
142
|
+
contexts=list(dict.fromkeys(request.contexts)),
|
|
143
|
+
)
|
|
144
|
+
write_private(target, _toml(provider))
|
|
145
|
+
if old is not None and old.name != name:
|
|
146
|
+
old.path.unlink(missing_ok=True)
|
|
147
|
+
return load(target)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def delete(directory: Path, store: secrets.SecretStore, name: str) -> None:
|
|
151
|
+
(directory / f"{name}.toml").unlink(missing_ok=True)
|
|
152
|
+
store.delete(f"providers/{name}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _toml(provider: Provider) -> str:
|
|
156
|
+
data = provider.model_dump(by_alias=True, exclude_defaults=True, mode="json")
|
|
157
|
+
doc = tomlkit.document()
|
|
158
|
+
for field in ("apiVersion", "description", "contexts"):
|
|
159
|
+
if field in data:
|
|
160
|
+
doc[field] = data[field]
|
|
161
|
+
if "apiKey" in data:
|
|
162
|
+
key = tomlkit.inline_table()
|
|
163
|
+
key.update(data["apiKey"])
|
|
164
|
+
doc["apiKey"] = key
|
|
165
|
+
doc["endpoints"] = data["endpoints"]
|
|
166
|
+
models = tomlkit.aot()
|
|
167
|
+
for model in data["models"]:
|
|
168
|
+
models.append(tomlkit.item(model))
|
|
169
|
+
doc["models"] = models
|
|
170
|
+
if "files" in data: # [files.<harness>] slot = { content | file | env = … }
|
|
171
|
+
files = tomlkit.table(is_super_table=True)
|
|
172
|
+
for harness, slots in data["files"].items():
|
|
173
|
+
table = tomlkit.table()
|
|
174
|
+
for slot, source in slots.items():
|
|
175
|
+
entry = tomlkit.inline_table()
|
|
176
|
+
entry.update(source)
|
|
177
|
+
table[slot] = entry
|
|
178
|
+
files[harness] = table
|
|
179
|
+
doc["files"] = files
|
|
180
|
+
return tomlkit.dumps(doc)
|
handler_control/relay.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""ACP relay — contract C transport: one WebSocket ↔ one agent process, through the policy.
|
|
2
|
+
|
|
3
|
+
Security rules of the transport itself (on top of the policy):
|
|
4
|
+
- only single JSON objects are forwarded; batches and unparseable lines are dropped, so the
|
|
5
|
+
policy always sees every message the agent could act on;
|
|
6
|
+
- forwarded messages are re-serialized from what the policy inspected (no parser differential);
|
|
7
|
+
- one JSON-RPC message per WebSocket frame; binary frames close with 1003.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
17
|
+
|
|
18
|
+
from handler_contracts import AcpCloseCode
|
|
19
|
+
|
|
20
|
+
from .agents import AgentRecord, AgentRegistry
|
|
21
|
+
from .launcher import StartedAgent
|
|
22
|
+
from .policy import AcpPolicy, ToAgent, ToClient, parse
|
|
23
|
+
from .sandbox_process import read_stderr, stop
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("handler.relay")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Relay:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
ws: WebSocket,
|
|
32
|
+
agent: StartedAgent,
|
|
33
|
+
policy: AcpPolicy,
|
|
34
|
+
record: AgentRecord,
|
|
35
|
+
registry: AgentRegistry,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.ws = ws
|
|
38
|
+
self.agent = agent
|
|
39
|
+
self.proc: asyncio.subprocess.Process = agent.process # ty: ignore[invalid-assignment]
|
|
40
|
+
self.policy = policy
|
|
41
|
+
self.record = record
|
|
42
|
+
self.registry = registry
|
|
43
|
+
self.ws_lock = asyncio.Lock()
|
|
44
|
+
self.agent_lock = asyncio.Lock()
|
|
45
|
+
self.close_code: int = AcpCloseCode.NORMAL
|
|
46
|
+
self.close_reason = ""
|
|
47
|
+
|
|
48
|
+
async def to_client(self, msg: dict[str, Any]) -> None:
|
|
49
|
+
async with self.ws_lock:
|
|
50
|
+
await self.ws.send_text(json.dumps(msg))
|
|
51
|
+
|
|
52
|
+
async def to_agent(self, msg: dict[str, Any]) -> None:
|
|
53
|
+
assert self.proc.stdin is not None
|
|
54
|
+
async with self.agent_lock:
|
|
55
|
+
self.proc.stdin.write(json.dumps(msg).encode() + b"\n")
|
|
56
|
+
await self.proc.stdin.drain()
|
|
57
|
+
|
|
58
|
+
async def execute(self, actions: list[ToAgent | ToClient]) -> None:
|
|
59
|
+
for action in actions:
|
|
60
|
+
if isinstance(action, ToAgent):
|
|
61
|
+
await self.to_agent(action.msg)
|
|
62
|
+
else:
|
|
63
|
+
await self.to_client(action.msg)
|
|
64
|
+
|
|
65
|
+
# ── the three pumps ─────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
async def client_to_agent(self) -> None:
|
|
68
|
+
while True:
|
|
69
|
+
event = await self.ws.receive()
|
|
70
|
+
if event["type"] == "websocket.disconnect":
|
|
71
|
+
return
|
|
72
|
+
if event.get("bytes") is not None:
|
|
73
|
+
self.close_code, self.close_reason = AcpCloseCode.UNSUPPORTED_DATA, "binary frame"
|
|
74
|
+
return
|
|
75
|
+
for line in (event.get("text") or "").splitlines():
|
|
76
|
+
if not line.strip():
|
|
77
|
+
continue
|
|
78
|
+
msg = parse(line)
|
|
79
|
+
if msg is None:
|
|
80
|
+
log.warning(
|
|
81
|
+
"[%s] dropped a non-object client message", self.agent.connection_id
|
|
82
|
+
)
|
|
83
|
+
continue
|
|
84
|
+
await self.execute(self.policy.from_client(msg))
|
|
85
|
+
|
|
86
|
+
async def agent_to_client(self) -> None:
|
|
87
|
+
assert self.proc.stdout is not None
|
|
88
|
+
while True:
|
|
89
|
+
try:
|
|
90
|
+
line = await self.proc.stdout.readline()
|
|
91
|
+
except (ValueError, asyncio.LimitOverrunError):
|
|
92
|
+
self.close_code, self.close_reason = (
|
|
93
|
+
AcpCloseCode.MESSAGE_TOO_BIG,
|
|
94
|
+
"agent message too big",
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
if not line:
|
|
98
|
+
code = await self.proc.wait()
|
|
99
|
+
self.close_code = AcpCloseCode.AGENT_EXITED
|
|
100
|
+
self.close_reason = f"agent exited ({code})"
|
|
101
|
+
return
|
|
102
|
+
msg = parse(line.decode(errors="replace"))
|
|
103
|
+
if msg is None:
|
|
104
|
+
log.warning("[%s] dropped a non-object agent message", self.agent.connection_id)
|
|
105
|
+
continue
|
|
106
|
+
await self.execute(self.policy.from_agent(msg))
|
|
107
|
+
|
|
108
|
+
# ── lifecycle (C.3) ─────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
async def serve(self) -> None:
|
|
111
|
+
pumps = [
|
|
112
|
+
asyncio.create_task(self.client_to_agent()),
|
|
113
|
+
asyncio.create_task(self.agent_to_client()),
|
|
114
|
+
]
|
|
115
|
+
cid = self.agent.connection_id
|
|
116
|
+
stderr = asyncio.create_task(
|
|
117
|
+
read_stderr(self.proc, cid, lambda e: self.registry.add_event(self.record, cid, e))
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
await asyncio.wait(pumps, return_when=asyncio.FIRST_COMPLETED)
|
|
121
|
+
except WebSocketDisconnect:
|
|
122
|
+
pass
|
|
123
|
+
finally:
|
|
124
|
+
for task in pumps:
|
|
125
|
+
task.cancel()
|
|
126
|
+
await self.stop_agent()
|
|
127
|
+
stderr.cancel()
|
|
128
|
+
with contextlib.suppress(Exception):
|
|
129
|
+
await self.ws.close(code=self.close_code, reason=self.close_reason[:120])
|
|
130
|
+
|
|
131
|
+
async def stop_agent(self) -> None:
|
|
132
|
+
"""Closing the connection stops the sandbox (C.3)."""
|
|
133
|
+
await stop(self.proc)
|