agentic-runner 2.6.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 (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,388 @@
1
+ """Runtime-neutral machinery shared by Agent Runtimes.
2
+
3
+ The subprocess spawn (with process-group kill on timeout/cancel), workspace-under-root
4
+ validation, command hashing, and byte-bounding here are identical for every CLI runtime
5
+ (Codex, Claude Code, ...). Each runtime keeps its own secret-redaction patterns, since
6
+ the secrets it must scrub differ; this module holds only what is genuinely shared.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import hashlib
14
+ import os
15
+ import re
16
+ import signal
17
+ from collections.abc import Awaitable, Callable, Iterable
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Final
21
+
22
+ from agentic_runner.callback import CALLBACK_SOCKET_ENV, CALLBACK_TOKEN_ENV
23
+ from agentic_runner.egress import EGRESS_ENV_NAMES
24
+ from agentic_runner.llm_proxy import PROXY_ENV_NAMES
25
+ from agentic_runner.workers.command_policy import CommandPolicy, evaluate_command_policy
26
+ from agentic_runner.workers.contract_isolation import DirectiveSandbox
27
+
28
+ # Names no caller may set on a Directive's environment, whatever a Runner Hook exported
29
+ # (PRD issue 45). Each is a line the Runner itself draws: the per-Contract harness root
30
+ # and HOME (ADR-0015 §4), the TMPDIR inside the Contract's tree, the PATH the harness
31
+ # binary resolves on, the attempt's own callback socket and bearer, and the LLM proxy's
32
+ # endpoint and bearer (PRD issue 43), and the attempt's egress proxy (PRD issue 58). An
33
+ # `environment` hook that could rewrite them would move the Contract's config root
34
+ # somewhere another Contract can read, hand the Agent a socket and bearer of its own
35
+ # choosing, point the Agent's traffic at an endpoint that meters nothing and is charged to
36
+ # the funder anyway, or route it round the Profile's egress allow-list.
37
+ RESERVED_DIRECTIVE_ENV: Final[frozenset[str]] = frozenset(
38
+ {
39
+ "HOME",
40
+ "PATH",
41
+ "TMPDIR",
42
+ "CODEX_HOME",
43
+ "CLAUDE_CONFIG_DIR",
44
+ "ANTHROPIC_API_KEY",
45
+ CALLBACK_SOCKET_ENV,
46
+ CALLBACK_TOKEN_ENV,
47
+ *PROXY_ENV_NAMES,
48
+ *EGRESS_ENV_NAMES,
49
+ }
50
+ )
51
+
52
+
53
+ # The callback pair and the proxy pair are minted by the Runner and travel on this same
54
+ # channel, so they are the part of the reserved set that may land here. A *hook* is
55
+ # stopped from claiming those names earlier and elsewhere — `hooks._read_env_file` drops
56
+ # them as it reads the exports — which keeps one rule in each place instead of one
57
+ # conditional in both.
58
+ _EXTRA_ENV_DENYLIST: Final[frozenset[str]] = RESERVED_DIRECTIVE_ENV - {
59
+ CALLBACK_SOCKET_ENV,
60
+ CALLBACK_TOKEN_ENV,
61
+ *PROXY_ENV_NAMES,
62
+ *EGRESS_ENV_NAMES,
63
+ }
64
+
65
+
66
+ # Provider keys a runtime may otherwise put in the child environment. Dropped whenever
67
+ # the attempt routes through the Runner's LLM proxy instead.
68
+ _PROVIDER_KEY_ENV: Final[frozenset[str]] = frozenset({"ANTHROPIC_API_KEY", "OPENAI_API_KEY"})
69
+
70
+
71
+ def apply_extra_env(env: dict[str, str], extra: Iterable[tuple[str, str]]) -> dict[str, str]:
72
+ """Merge one attempt's extra environment into a Directive's, minus the reserved names.
73
+
74
+ Where the attempt carries an LLM proxy endpoint (PRD issue 43), the runtime's own
75
+ provider key is dropped from the child environment rather than left beside it: the
76
+ whole point of the relocated proxy is that the subprocess holds the attempt's bearer
77
+ and no provider key (ADR-0011 §9), and a key still in the env is a way around the
78
+ metering point. Done here because both runtimes route through this one call.
79
+ """
80
+
81
+ admitted = {name: value for name, value in extra if name not in _EXTRA_ENV_DENYLIST}
82
+ if PROXY_ENV_NAMES & set(admitted):
83
+ for name in _PROVIDER_KEY_ENV:
84
+ env.pop(name, None)
85
+ env.update(admitted)
86
+ return env
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class SubprocessResult:
91
+ exit_code: int
92
+ stdout: str
93
+ stderr: str
94
+ stdout_truncated: bool = False
95
+ stderr_truncated: bool = False
96
+
97
+
98
+ AsyncSubprocessRunner = Callable[
99
+ [list[str], Path, dict[str, str], str | None, int, int], Awaitable[SubprocessResult]
100
+ ]
101
+
102
+
103
+ async def run_subprocess_exec(
104
+ *,
105
+ argv: list[str],
106
+ cwd: Path,
107
+ env: dict[str, str],
108
+ stdin: str | None,
109
+ timeout_seconds: int,
110
+ output_limit_bytes: int,
111
+ sandbox: DirectiveSandbox | None = None,
112
+ ) -> SubprocessResult:
113
+ process = await asyncio.create_subprocess_exec(
114
+ *argv,
115
+ cwd=cwd,
116
+ env=env,
117
+ stdin=asyncio.subprocess.PIPE,
118
+ stdout=asyncio.subprocess.PIPE,
119
+ stderr=asyncio.subprocess.PIPE,
120
+ start_new_session=True,
121
+ **({} if sandbox is None else sandbox.spawn_kwargs()),
122
+ )
123
+ try:
124
+ stdout_bytes, stderr_bytes, stdout_truncated, stderr_truncated = await asyncio.wait_for(
125
+ _collect_process_output(process, stdin=stdin, output_limit_bytes=output_limit_bytes),
126
+ timeout=timeout_seconds,
127
+ )
128
+ except TimeoutError:
129
+ await _terminate_process_tree(process)
130
+ raise
131
+ except asyncio.CancelledError:
132
+ await _terminate_process_tree(process)
133
+ raise
134
+
135
+ return SubprocessResult(
136
+ exit_code=process.returncode or 0,
137
+ stdout=stdout_bytes.decode(errors="replace"),
138
+ stderr=stderr_bytes.decode(errors="replace"),
139
+ stdout_truncated=stdout_truncated,
140
+ stderr_truncated=stderr_truncated,
141
+ )
142
+
143
+
144
+ async def _collect_process_output(
145
+ process: asyncio.subprocess.Process,
146
+ *,
147
+ stdin: str | None,
148
+ output_limit_bytes: int,
149
+ ) -> tuple[bytes, bytes, bool, bool]:
150
+ if process.stdin is not None:
151
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError):
152
+ if stdin is not None:
153
+ process.stdin.write(stdin.encode())
154
+ await process.stdin.drain()
155
+ process.stdin.close()
156
+ await process.stdin.wait_closed()
157
+
158
+ stdout_task = asyncio.create_task(_read_stream_limited(process.stdout, output_limit_bytes))
159
+ stderr_task = asyncio.create_task(_read_stream_limited(process.stderr, output_limit_bytes))
160
+ try:
161
+ await process.wait()
162
+ stdout_bytes, stdout_truncated = await stdout_task
163
+ stderr_bytes, stderr_truncated = await stderr_task
164
+ except BaseException:
165
+ stdout_task.cancel()
166
+ stderr_task.cancel()
167
+ raise
168
+ return stdout_bytes, stderr_bytes, stdout_truncated, stderr_truncated
169
+
170
+
171
+ async def _read_stream_limited(
172
+ stream: asyncio.StreamReader | None,
173
+ limit_bytes: int,
174
+ ) -> tuple[bytes, bool]:
175
+ """Read a child's stream, bounded to the *last* ``limit_bytes``.
176
+
177
+ Keeping the head instead of the tail here silently dropped Codex's ``turn.completed``
178
+ and Claude Code's ``result`` message on any Directive whose output crossed the limit —
179
+ both harnesses put their final, JSONL/JSON usage event at the end of stdout
180
+ (``harness_usage.py``), and the head-bounded runtime evidence layer (``bound_text_tail``
181
+ in ``codex_runtime.py``/``claude_runtime.py``) already assumed the tail was what
182
+ survived. There is nothing left for a caller-side tail-bound to recover once the head
183
+ is what got kept here, so the read itself must keep the tail.
184
+ """
185
+
186
+ if stream is None:
187
+ return b"", False
188
+
189
+ tail = bytearray()
190
+ total_bytes = 0
191
+ while True:
192
+ chunk = await stream.read(4096)
193
+ if not chunk:
194
+ break
195
+ total_bytes += len(chunk)
196
+ tail.extend(chunk)
197
+ if len(tail) > limit_bytes:
198
+ del tail[: len(tail) - limit_bytes]
199
+ return bytes(tail), total_bytes > limit_bytes
200
+
201
+
202
+ async def _terminate_process_tree(process: asyncio.subprocess.Process) -> None:
203
+ if process.returncode is not None:
204
+ return
205
+ with contextlib.suppress(ProcessLookupError, PermissionError):
206
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
207
+ if process.returncode is None:
208
+ with contextlib.suppress(ProcessLookupError):
209
+ process.kill()
210
+ with contextlib.suppress(TimeoutError):
211
+ await asyncio.wait_for(process.wait(), timeout=2)
212
+
213
+
214
+ def is_relative_to(path: Path, root: Path) -> bool:
215
+ try:
216
+ path.relative_to(root)
217
+ except ValueError:
218
+ return False
219
+ return True
220
+
221
+
222
+ def workspace_id(workspace_path: Path, workspace_root: Path) -> str:
223
+ return workspace_path.relative_to(workspace_root).as_posix()
224
+
225
+
226
+ def hash_command(argv: list[str]) -> str:
227
+ return hashlib.sha256("\0".join(argv).encode()).hexdigest()
228
+
229
+
230
+ def redact_match(match: re.Match[str]) -> str:
231
+ if match.lastindex and match.lastindex >= 3:
232
+ return f"{match.group(1)}[REDACTED]{match.group(3)}"
233
+ if match.lastindex and match.lastindex >= 2:
234
+ return f"{match.group(1)}[REDACTED]"
235
+ return "[REDACTED]"
236
+
237
+
238
+ def bound_text(value: str, limit_bytes: int, notes: list[str], label: str) -> str:
239
+ encoded = value.encode()
240
+ if len(encoded) <= limit_bytes:
241
+ return value
242
+
243
+ notes.append(f"{label} truncated to {limit_bytes} bytes")
244
+ return encoded[:limit_bytes].decode(errors="ignore")
245
+
246
+
247
+ def bound_text_tail(value: str, limit_bytes: int, notes: list[str], label: str) -> str:
248
+ """Like ``bound_text`` but keeps the *end* of the text — where test runners put
249
+ the failure summary and tracebacks that a fix Directive actually needs."""
250
+ encoded = value.encode()
251
+ if len(encoded) <= limit_bytes:
252
+ return value
253
+
254
+ notes.append(f"{label} truncated to last {limit_bytes} bytes")
255
+ return encoded[-limit_bytes:].decode(errors="ignore")
256
+
257
+
258
+ def command_policy_refusal(
259
+ *,
260
+ argv: list[str],
261
+ program: str,
262
+ workspace_path: Path,
263
+ workspace_root: Path,
264
+ base_branch: str,
265
+ work_branch: str,
266
+ ) -> str | None:
267
+ """The Agent Runtime Profile's sandbox floor, applied to every Directive (ADR-0011 §12).
268
+
269
+ The subprocess the Runner is about to spawn is checked against
270
+ ``workers/command_policy.py`` before it runs: outside the grant model, not attenuable by
271
+ the user, and re-checked per Directive rather than once at configuration time, because
272
+ the worker settings that build the argv can change under a running worker. Returns the
273
+ refusal reason, or None when the floor allows the spawn.
274
+ """
275
+
276
+ decision = evaluate_command_policy(
277
+ argv=argv,
278
+ cwd=workspace_path,
279
+ workspace_root=workspace_root,
280
+ base_branch=base_branch,
281
+ work_branch=work_branch,
282
+ policy=CommandPolicy(runtime_program=program),
283
+ )
284
+ return None if decision.allowed else decision.reason
285
+
286
+
287
+ # Process-global strong references to detached launches' background drain tasks. A
288
+ # request/activity-scoped call is otherwise the only strong root, and task -> coroutine ->
289
+ # process is a cycle the garbage collector can reap once the caller returns (the event
290
+ # loop keeps only weak references to tasks) -- rooting them here keeps a detached child
291
+ # draining and reaped for as long as it runs.
292
+ _DETACHED_LAUNCH_DRAINS: set[asyncio.Task[None]] = set()
293
+
294
+
295
+ async def run_subprocess_launch_and_detach(
296
+ *,
297
+ argv: list[str],
298
+ cwd: Path,
299
+ env: dict[str, str],
300
+ is_prompt_complete: Callable[[str], bool],
301
+ prompt_timeout_seconds: float,
302
+ output_limit_bytes: int,
303
+ sandbox: DirectiveSandbox | None = None,
304
+ ) -> str:
305
+ """Spawn a long-running interactive CLI, return once its opening prompt is complete.
306
+
307
+ Unlike :func:`run_subprocess_exec` (run-to-completion), a device-code sign-in prints a
308
+ verification URL and one-time code and then *blocks*, polling the vendor until the
309
+ operator finishes the browser flow or the code expires (research/29). The caller needs
310
+ only the prompt, not that wait: reading it in an ``execute_workflow`` call held the
311
+ HTTP request (and, with no override, a 60s client timeout) open for however long the
312
+ operator took, and the funder learned the URL/code only after the whole wait finished
313
+ (PRD issue 31 review). This reads stdout only until ``is_prompt_complete`` accepts it,
314
+ then detaches the process -- draining its pipes in the background so it never blocks on
315
+ a full buffer -- so a later, separate read can check whether it finished.
316
+ """
317
+
318
+ try:
319
+ process = await asyncio.create_subprocess_exec(
320
+ *argv,
321
+ cwd=cwd,
322
+ env=env,
323
+ stdin=asyncio.subprocess.DEVNULL,
324
+ stdout=asyncio.subprocess.PIPE,
325
+ stderr=asyncio.subprocess.PIPE,
326
+ start_new_session=True,
327
+ **({} if sandbox is None else sandbox.spawn_kwargs()),
328
+ )
329
+ except OSError as error:
330
+ raise RuntimeError(f"process failed to start: {error.__class__.__name__}") from None
331
+
332
+ if process.stdout is None:
333
+ await _terminate_process_tree(process)
334
+ raise RuntimeError("process stdout stream was unavailable")
335
+
336
+ loop = asyncio.get_running_loop()
337
+ deadline = loop.time() + prompt_timeout_seconds
338
+ buffer = bytearray()
339
+ try:
340
+ while True:
341
+ remaining = deadline - loop.time()
342
+ if remaining <= 0:
343
+ raise TimeoutError("device-login prompt did not appear before the timeout")
344
+ try:
345
+ chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=remaining)
346
+ except TimeoutError:
347
+ raise TimeoutError(
348
+ "device-login prompt did not appear before the timeout"
349
+ ) from None
350
+ if not chunk:
351
+ raise RuntimeError(
352
+ f"process exited before printing its prompt (code {process.returncode})"
353
+ )
354
+ buffer.extend(chunk)
355
+ if len(buffer) > output_limit_bytes:
356
+ raise RuntimeError("process output exceeded the configured byte limit")
357
+ text = buffer.decode("utf-8", errors="replace")
358
+ if is_prompt_complete(text):
359
+ _detach(process)
360
+ return text
361
+ except BaseException:
362
+ await _terminate_process_tree(process)
363
+ raise
364
+
365
+
366
+ def _detach(process: asyncio.subprocess.Process) -> None:
367
+ # Static target (no closure over caller state) so the drain coroutine roots only
368
+ # through the module-level set below, not through whatever scheduled this launch.
369
+ task = asyncio.ensure_future(_drain_detached(process))
370
+ _DETACHED_LAUNCH_DRAINS.add(task)
371
+ task.add_done_callback(_DETACHED_LAUNCH_DRAINS.discard)
372
+
373
+
374
+ async def _drain_detached(process: asyncio.subprocess.Process) -> None:
375
+ # Best-effort: keeps the detached child's pipes empty so it never blocks on a full
376
+ # buffer, then reaps it. A failure here must never surface into a caller that already
377
+ # returned the prompt.
378
+ try:
379
+ streams = [s for s in (process.stdout, process.stderr) if s is not None]
380
+ await asyncio.gather(*(_drain_stream(stream) for stream in streams))
381
+ await process.wait()
382
+ except Exception: # noqa: BLE001 - best-effort background reap, see docstring above
383
+ return
384
+
385
+
386
+ async def _drain_stream(stream: asyncio.StreamReader) -> None:
387
+ while await stream.read(8192):
388
+ continue
@@ -0,0 +1,93 @@
1
+ """The AgentRuntime port: the single-turn contract every runtime implements.
2
+
3
+ Temporal owns the Ralph Loop (ADR-0007), so a runtime never runs its own loop — it
4
+ executes exactly one Directive and returns the result. This module defines the
5
+ runtime-neutral port and the data that crosses it, so a second runtime (Claude Code,
6
+ issue 11) can be added behind the same seam as Codex (ADR-0006).
7
+
8
+ Concrete runtimes are siblings of this module in ``agentic_runner.workers`` and are
9
+ injected into the activity adapter. They must never enter the workflow import graph (the
10
+ determinism sandbox); the deterministic workflow only ever invokes them by activity name.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from enum import StrEnum
17
+ from pathlib import Path
18
+ from typing import Protocol, runtime_checkable
19
+
20
+ from agentic_runner.workers.contract_isolation import DirectiveSandbox
21
+ from agentic_runner.workers.mcp_config import McpServerEntry
22
+
23
+
24
+ class AuthModel(StrEnum):
25
+ """How a runtime authenticates. Declared per-runtime so an unattended Persona can be
26
+ placed on stable API-key auth instead of an expiring browser session (ADR-0006)."""
27
+
28
+ DEVICE_LOGIN = "device_login"
29
+ API_KEY = "api_key"
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class DirectiveRequest:
34
+ """The input to one Directive: a single runtime turn in a prepared workspace."""
35
+
36
+ workspace_path: Path
37
+ prompt: str
38
+ base_branch: str
39
+ work_branch: str
40
+ # Which Contract's uid this turn runs as, where its harness config root is, and the
41
+ # floor it may not exceed (ADR-0015 §1). None only where the caller built no
42
+ # isolation at all (unit fakes); the composition root always supplies one.
43
+ sandbox: DirectiveSandbox | None = None
44
+ # The attempt's callback socket and bearer, plus whatever the `environment` Runner
45
+ # Hook exported (PRD issue 45). Pairs rather than a mapping so the request stays
46
+ # frozen; every runtime merges them through `_runtime_support.apply_extra_env`, which
47
+ # drops the names the Runner reserves for itself.
48
+ extra_env: tuple[tuple[str, str], ...] = ()
49
+ # The MCP servers the Agent's Effective Grant lets this Directive have (PRD issue
50
+ # 58). ``None`` -- no server is bound to the Work Record's Product -- leaves the CLI's
51
+ # own config untouched, so a Product without MCP runs byte-identically to before;
52
+ # ``()`` is "servers are bound, none granted", which still has to be said to the CLI
53
+ # so nothing ungranted is loaded from anywhere else.
54
+ mcp_servers: tuple[McpServerEntry, ...] | None = None
55
+ # The destinations this Directive may reach (the Profile's list plus what the Runner
56
+ # added), enforced by the attempt's egress proxy. Empty: no Profile list, so the
57
+ # runtime keeps its own network posture.
58
+ egress_allow_list: tuple[str, ...] = ()
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class DirectiveEvidence:
63
+ """Bounded, redacted record of one Directive, safe for control-plane storage."""
64
+
65
+ workspace_id: str
66
+ base_branch: str
67
+ work_branch: str
68
+ command_hash: str
69
+ guard_mode: str
70
+ notes: list[str] = field(default_factory=list)
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class DirectiveResult:
75
+ """The outcome of one Directive. ``exit_code`` 0 is success; a guard refusal is
76
+ signalled by ``exit_code`` 126 and an ``evidence.guard_mode`` starting with
77
+ ``"refused"`` — the discriminator the Ralph Loop branches on."""
78
+
79
+ exit_code: int
80
+ stdout: str
81
+ stderr: str
82
+ error: str
83
+ command_hash: str
84
+ evidence: DirectiveEvidence
85
+
86
+
87
+ @runtime_checkable
88
+ class AgentRuntime(Protocol):
89
+ """The pluggable engine an Agent uses to execute one Directive per step."""
90
+
91
+ auth_model: AuthModel
92
+
93
+ async def execute_directive(self, request: DirectiveRequest) -> DirectiveResult: ...