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,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
|