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.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- 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: ...
|