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
agentic_runner/mcp.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""MCP config assembly and Runner-hosted MCP servers (PRD issue 58; 05, 17 A4, ADR-0015 §3).
|
|
2
|
+
|
|
3
|
+
**Assembly** is the seam ``mcp:<server>`` lands with (ADR-0011 §3, §8): per Directive,
|
|
4
|
+
each registry row bound to the Work Record's Product is decided against the Agent's
|
|
5
|
+
Effective Grant (:func:`agentic_runner_contracts.grants.decide_mcp_server`) and only a
|
|
6
|
+
granted server reaches the CLI's config. ``confirm`` asks the owner *before* the server is
|
|
7
|
+
written -- MCP has no per-call seam inside the CLI, so config assembly is the narrowest
|
|
8
|
+
place a confirmation can bite.
|
|
9
|
+
|
|
10
|
+
**Placement** follows 17 A4. A stdio server with no credential is written as a command:
|
|
11
|
+
the CLI spawns it, so it runs as the Contract's uid, a grandchild of the Runner, with the
|
|
12
|
+
Directive's own credential-free environment. A row naming a Credential Reference is
|
|
13
|
+
**Runner-hosted**: :class:`RunnerHostedServer` spawns it as the Runner's own uid with the
|
|
14
|
+
value in its env, and exposes it to the CLI as Streamable HTTP on loopback behind the
|
|
15
|
+
attempt's bearer -- the Agent talks to the server and never holds what the server holds.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import contextlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from types import TracebackType
|
|
28
|
+
from typing import Any, Final
|
|
29
|
+
|
|
30
|
+
from agentic_runner.tiny_http import bearer_matches, read_request, response_head, write_json
|
|
31
|
+
from agentic_runner.workers.mcp_config import McpServerEntry
|
|
32
|
+
from agentic_runner_contracts.grants import (
|
|
33
|
+
Decision,
|
|
34
|
+
GrantSnapshot,
|
|
35
|
+
McpServerDecision,
|
|
36
|
+
decide_mcp_server,
|
|
37
|
+
)
|
|
38
|
+
from agentic_runner_contracts.runner_registration import ToolServerHealth
|
|
39
|
+
from agentic_runner_contracts.runtime_context import McpServerSpec
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"MCP_ASSEMBLY_SOURCE",
|
|
43
|
+
"McpPlan",
|
|
44
|
+
"RunnerHostedServer",
|
|
45
|
+
"Spawner",
|
|
46
|
+
"ToolServerHealthLog",
|
|
47
|
+
"entry_for",
|
|
48
|
+
"plan_mcp",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# One Evidence Event per config assembly: granted, withheld and why (PRD issue 58).
|
|
52
|
+
MCP_ASSEMBLY_SOURCE: Final[str] = "runner.mcp_config_assembled"
|
|
53
|
+
|
|
54
|
+
# The one path a Runner-hosted server answers on, as Streamable HTTP's single endpoint.
|
|
55
|
+
MCP_PATH: Final[str] = "/mcp"
|
|
56
|
+
|
|
57
|
+
# What a Runner-hosted server inherits from the Runner besides its credential. A
|
|
58
|
+
# credential-bearing child must not also inherit the Runner's own environment (its
|
|
59
|
+
# Agent Token, a git token): only what a program needs to start.
|
|
60
|
+
_HOSTED_ENV_ALLOWLIST: Final[tuple[str, ...]] = ("PATH", "HOME", "LANG", "TZ")
|
|
61
|
+
_DEFAULT_CALL_TIMEOUT_SECONDS: Final[float] = 300.0
|
|
62
|
+
# The heartbeat's own bound on `tool_servers`.
|
|
63
|
+
_HEALTH_MAX: Final[int] = 64
|
|
64
|
+
|
|
65
|
+
Spawner = Callable[..., Awaitable[asyncio.subprocess.Process]]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class McpPlan:
|
|
70
|
+
"""What one Directive's config assembly decided, before anything is started."""
|
|
71
|
+
|
|
72
|
+
granted: tuple[tuple[McpServerSpec, McpServerDecision], ...] = ()
|
|
73
|
+
withheld: tuple[tuple[McpServerSpec, McpServerDecision, str], ...] = ()
|
|
74
|
+
to_confirm: tuple[tuple[McpServerSpec, McpServerDecision], ...] = ()
|
|
75
|
+
|
|
76
|
+
def evidence(self, *, egress_allow_list: Sequence[str] = ()) -> dict[str, object]:
|
|
77
|
+
return {
|
|
78
|
+
"event": "mcp.config_assembled",
|
|
79
|
+
"granted": [
|
|
80
|
+
{**decision.evidence(), "runner_hosted": spec.credential_reference is not None}
|
|
81
|
+
for spec, decision in self.granted
|
|
82
|
+
],
|
|
83
|
+
"withheld": [
|
|
84
|
+
{**decision.evidence(), "withheld_because": why}
|
|
85
|
+
for _, decision, why in self.withheld
|
|
86
|
+
],
|
|
87
|
+
"egress_allow_list": list(egress_allow_list),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def plan_mcp(
|
|
92
|
+
snapshot: GrantSnapshot,
|
|
93
|
+
servers: Sequence[McpServerSpec],
|
|
94
|
+
*,
|
|
95
|
+
consented: Collection[str] = (),
|
|
96
|
+
declined: Collection[str] = (),
|
|
97
|
+
) -> McpPlan:
|
|
98
|
+
"""Decide every bound server against the Effective Grant.
|
|
99
|
+
|
|
100
|
+
``consented`` / ``declined`` are the ``mcp:<server>`` names the owner has already
|
|
101
|
+
answered for this step; anything else that evaluates ``confirm`` is left in
|
|
102
|
+
``to_confirm`` for the caller to ask about before a single server is started.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
granted: list[tuple[McpServerSpec, McpServerDecision]] = []
|
|
106
|
+
withheld: list[tuple[McpServerSpec, McpServerDecision, str]] = []
|
|
107
|
+
to_confirm: list[tuple[McpServerSpec, McpServerDecision]] = []
|
|
108
|
+
for spec in servers:
|
|
109
|
+
decision = decide_mcp_server(snapshot, server=spec.slug, tools=spec.tools)
|
|
110
|
+
confirm = decision.decision is Decision.CONFIRM
|
|
111
|
+
if decision.decision is Decision.ALLOW or (confirm and decision.verb in consented):
|
|
112
|
+
granted.append((spec, decision))
|
|
113
|
+
elif confirm and decision.verb in declined:
|
|
114
|
+
withheld.append((spec, decision, "owner_declined"))
|
|
115
|
+
elif confirm:
|
|
116
|
+
to_confirm.append((spec, decision))
|
|
117
|
+
else:
|
|
118
|
+
withheld.append((spec, decision, "denied"))
|
|
119
|
+
return McpPlan(granted=tuple(granted), withheld=tuple(withheld), to_confirm=tuple(to_confirm))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def entry_for(
|
|
123
|
+
spec: McpServerSpec, *, hosted_url: str | None = None, bearer_env: str | None = None
|
|
124
|
+
) -> McpServerEntry:
|
|
125
|
+
"""The CLI's view of one granted server: its command, or the URL to call."""
|
|
126
|
+
|
|
127
|
+
if hosted_url is not None:
|
|
128
|
+
return McpServerEntry(
|
|
129
|
+
slug=spec.slug, url=hosted_url, bearer_token_env=bearer_env, required=spec.required
|
|
130
|
+
)
|
|
131
|
+
if spec.transport == "streamable_http":
|
|
132
|
+
return McpServerEntry(slug=spec.slug, url=str(spec.config["url"]), required=spec.required)
|
|
133
|
+
return McpServerEntry(
|
|
134
|
+
slug=spec.slug,
|
|
135
|
+
command=str(spec.config["command"]),
|
|
136
|
+
args=tuple(str(arg) for arg in _list(spec.config.get("args"))),
|
|
137
|
+
required=spec.required,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _list(value: object) -> list[object]:
|
|
142
|
+
return list(value) if isinstance(value, list) else []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class ToolServerHealthLog:
|
|
147
|
+
"""The latest start of each Runner-hosted Tool Server, for the heartbeat (issue 29).
|
|
148
|
+
|
|
149
|
+
Keyed by slug, not by Work Record: it is a fact about this host, like the load and
|
|
150
|
+
the slots, and a later Directive's start of the same server replaces the earlier one.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
clock: Callable[[], datetime] = field(default=lambda: datetime.now(UTC))
|
|
154
|
+
_latest: dict[str, ToolServerHealth] = field(default_factory=dict)
|
|
155
|
+
|
|
156
|
+
def record(self, slug: str, *, started: bool, at: datetime) -> None:
|
|
157
|
+
self._latest[slug] = ToolServerHealth(slug=slug, started=started, last_started_at=at)
|
|
158
|
+
|
|
159
|
+
def latest(self) -> list[ToolServerHealth]:
|
|
160
|
+
newest = sorted(self._latest.values(), key=lambda entry: entry.last_started_at)
|
|
161
|
+
return newest[-_HEALTH_MAX:]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class RunnerHostedServer:
|
|
165
|
+
"""One credentialed stdio server, spawned by the Runner and bridged to loopback HTTP.
|
|
166
|
+
|
|
167
|
+
The Runner speaks stdio JSON-RPC to the child and answers the CLI's Streamable HTTP
|
|
168
|
+
POSTs on ``127.0.0.1:<ephemeral>/mcp``, one request at a time, only for a caller
|
|
169
|
+
presenting the attempt's bearer. The whole bridge dies with the attempt, which is
|
|
170
|
+
what bounds the bearer (ADR-0011 §9).
|
|
171
|
+
|
|
172
|
+
No SSE stream: a request is answered with one ``application/json`` body, a
|
|
173
|
+
notification with ``202`` -- the subset of the transport both CLIs use for tool calls.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
spec: McpServerSpec,
|
|
179
|
+
*,
|
|
180
|
+
credential_env: Mapping[str, str],
|
|
181
|
+
token: str,
|
|
182
|
+
spawner: Spawner | None = None,
|
|
183
|
+
host: str = "127.0.0.1",
|
|
184
|
+
call_timeout_seconds: float = _DEFAULT_CALL_TIMEOUT_SECONDS,
|
|
185
|
+
health: ToolServerHealthLog | None = None,
|
|
186
|
+
) -> None:
|
|
187
|
+
self.spec = spec
|
|
188
|
+
self._credential_env = dict(credential_env)
|
|
189
|
+
self._token = token
|
|
190
|
+
self._spawner: Spawner = spawner or asyncio.create_subprocess_exec
|
|
191
|
+
self._host = host
|
|
192
|
+
self._timeout = call_timeout_seconds
|
|
193
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
194
|
+
self._server: asyncio.Server | None = None
|
|
195
|
+
self._lock = asyncio.Lock()
|
|
196
|
+
self._port = 0
|
|
197
|
+
self._health = health
|
|
198
|
+
self._started_at: datetime | None = None
|
|
199
|
+
self._reported = False
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def url(self) -> str:
|
|
203
|
+
return f"http://{self._host}:{self._port}{MCP_PATH}"
|
|
204
|
+
|
|
205
|
+
async def __aenter__(self) -> RunnerHostedServer:
|
|
206
|
+
env = {name: os.environ[name] for name in _HOSTED_ENV_ALLOWLIST if name in os.environ}
|
|
207
|
+
env.update(self._credential_env)
|
|
208
|
+
if self._health is not None:
|
|
209
|
+
self._started_at = self._health.clock()
|
|
210
|
+
# The Runner's own uid: no `user=` / `group=` here, deliberately. The credential
|
|
211
|
+
# is the Runner's to hold, and the Contract's uid must not be able to read the
|
|
212
|
+
# process that holds it (17 A4).
|
|
213
|
+
try:
|
|
214
|
+
self._process = await self._spawner(
|
|
215
|
+
str(self.spec.config["command"]),
|
|
216
|
+
*(str(arg) for arg in _list(self.spec.config.get("args"))),
|
|
217
|
+
env=env,
|
|
218
|
+
stdin=asyncio.subprocess.PIPE,
|
|
219
|
+
stdout=asyncio.subprocess.PIPE,
|
|
220
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
221
|
+
start_new_session=True,
|
|
222
|
+
)
|
|
223
|
+
except BaseException:
|
|
224
|
+
self._report(started=False)
|
|
225
|
+
raise
|
|
226
|
+
try:
|
|
227
|
+
self._server = await asyncio.start_server(self._serve, host=self._host, port=0)
|
|
228
|
+
except BaseException:
|
|
229
|
+
self._report(started=False)
|
|
230
|
+
await self._stop_process()
|
|
231
|
+
raise
|
|
232
|
+
self._port = int(self._server.sockets[0].getsockname()[1])
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
async def __aexit__(
|
|
236
|
+
self,
|
|
237
|
+
exc_type: type[BaseException] | None,
|
|
238
|
+
exc: BaseException | None,
|
|
239
|
+
traceback: TracebackType | None,
|
|
240
|
+
) -> None:
|
|
241
|
+
if self._server is not None:
|
|
242
|
+
self._server.close()
|
|
243
|
+
with contextlib.suppress(Exception):
|
|
244
|
+
await self._server.wait_closed()
|
|
245
|
+
# No call reached it: it came up if it was still running when the attempt ended.
|
|
246
|
+
process = self._process
|
|
247
|
+
self._report(started=process is not None and process.returncode is None)
|
|
248
|
+
await self._stop_process()
|
|
249
|
+
|
|
250
|
+
def _report(self, *, started: bool) -> None:
|
|
251
|
+
"""Record this start once: the first call's outcome, else the state at exit."""
|
|
252
|
+
|
|
253
|
+
if self._health is None or self._started_at is None or self._reported:
|
|
254
|
+
return
|
|
255
|
+
self._reported = True
|
|
256
|
+
self._health.record(self.spec.slug, started=started, at=self._started_at)
|
|
257
|
+
|
|
258
|
+
async def _stop_process(self) -> None:
|
|
259
|
+
process = self._process
|
|
260
|
+
if process is None or process.returncode is not None:
|
|
261
|
+
return
|
|
262
|
+
with contextlib.suppress(ProcessLookupError):
|
|
263
|
+
process.kill()
|
|
264
|
+
with contextlib.suppress(TimeoutError, ProcessLookupError):
|
|
265
|
+
await asyncio.wait_for(process.wait(), timeout=5)
|
|
266
|
+
|
|
267
|
+
async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
268
|
+
try:
|
|
269
|
+
request = await read_request(reader)
|
|
270
|
+
if isinstance(request, tuple):
|
|
271
|
+
await write_json(writer, request[0], request[1])
|
|
272
|
+
return
|
|
273
|
+
if not bearer_matches(request.authorization, self._token):
|
|
274
|
+
await write_json(writer, 401, {"detail": "the attempt bearer is required"})
|
|
275
|
+
return
|
|
276
|
+
if request.target.split("?", 1)[0] != MCP_PATH:
|
|
277
|
+
await write_json(writer, 404, {"detail": "not found"})
|
|
278
|
+
return
|
|
279
|
+
if request.method != "POST":
|
|
280
|
+
await write_json(writer, 405, {"detail": "only POST is served"})
|
|
281
|
+
return
|
|
282
|
+
try:
|
|
283
|
+
message = json.loads(request.body)
|
|
284
|
+
except ValueError:
|
|
285
|
+
await write_json(writer, 400, {"detail": "the body is not JSON"})
|
|
286
|
+
return
|
|
287
|
+
if not isinstance(message, dict):
|
|
288
|
+
await write_json(writer, 400, {"detail": "one JSON-RPC message per request"})
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
reply = await self._call(message)
|
|
292
|
+
except (TimeoutError, ConnectionError, RuntimeError):
|
|
293
|
+
self._report(started=False)
|
|
294
|
+
raise
|
|
295
|
+
if reply is None:
|
|
296
|
+
writer.write(response_head(202, content_type="application/json", content_length=0))
|
|
297
|
+
with contextlib.suppress(ConnectionError):
|
|
298
|
+
await writer.drain()
|
|
299
|
+
return
|
|
300
|
+
# Only an answer proves the server up: a notification is a write to a pipe,
|
|
301
|
+
# which a dead child's buffer can still take.
|
|
302
|
+
self._report(started=True)
|
|
303
|
+
await write_json(writer, 200, reply)
|
|
304
|
+
except TimeoutError:
|
|
305
|
+
await write_json(writer, 504, {"detail": "the MCP server did not answer in time"})
|
|
306
|
+
except (ConnectionError, RuntimeError):
|
|
307
|
+
await write_json(writer, 502, {"detail": "the MCP server is not running"})
|
|
308
|
+
finally:
|
|
309
|
+
writer.close()
|
|
310
|
+
with contextlib.suppress(Exception):
|
|
311
|
+
await writer.wait_closed()
|
|
312
|
+
|
|
313
|
+
async def _call(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
|
314
|
+
process = self._process
|
|
315
|
+
if process is None or process.stdin is None or process.stdout is None:
|
|
316
|
+
raise RuntimeError("the MCP server is not running")
|
|
317
|
+
stdout = process.stdout
|
|
318
|
+
expects_reply = "method" in message and "id" in message
|
|
319
|
+
async with self._lock:
|
|
320
|
+
process.stdin.write(json.dumps(message).encode() + b"\n")
|
|
321
|
+
await process.stdin.drain()
|
|
322
|
+
if not expects_reply:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
async def read_reply() -> dict[str, Any]:
|
|
326
|
+
while True:
|
|
327
|
+
line = await stdout.readline()
|
|
328
|
+
if not line:
|
|
329
|
+
raise ConnectionError("the MCP server closed its stdout")
|
|
330
|
+
try:
|
|
331
|
+
candidate = json.loads(line)
|
|
332
|
+
except ValueError:
|
|
333
|
+
continue
|
|
334
|
+
# Its own notifications and log lines are not this call's answer.
|
|
335
|
+
if (
|
|
336
|
+
isinstance(candidate, dict)
|
|
337
|
+
and "method" not in candidate
|
|
338
|
+
and candidate.get("id") == message["id"]
|
|
339
|
+
):
|
|
340
|
+
return candidate
|
|
341
|
+
|
|
342
|
+
return await asyncio.wait_for(read_reply(), timeout=self._timeout)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""The per-Work-Record Message store and the wake signal (PRD issue 52, map ticket 10).
|
|
2
|
+
|
|
3
|
+
Where Channel bodies live. Decision 10 held, stricter than 13: Directive payloads cross
|
|
4
|
+
into the control plane unencrypted in Release 1, **Channel bodies never do**. One
|
|
5
|
+
Conversation per Work Record, append-only, keyed by Channel; owned by the Runner
|
|
6
|
+
*process* and reached by the Agent subprocess only through its attempt socket
|
|
7
|
+
(``agentic-runner message send | list``), so a Contract's uid never reads the file
|
|
8
|
+
(17 A1). The tree therefore lives under the Runner's own state directory -- never under
|
|
9
|
+
the Contract's uid-owned Workspace tree -- with ``0700`` directories and ``0600`` files.
|
|
10
|
+
|
|
11
|
+
Lifetime: kept for the Organisation's one retention period and dropped by the same
|
|
12
|
+
sweep that deletes Workspaces. Contract termination **seals** the Contract's stores: the
|
|
13
|
+
org retains, the user loses access, the marker carries the state so a pull-through can
|
|
14
|
+
say why it was refused.
|
|
15
|
+
|
|
16
|
+
Three things leave this module, and this docstring is where the partition is stated:
|
|
17
|
+
``MessageMetadata`` as Evidence (no body), :data:`PENDING_MESSAGES_SIGNAL` as a Temporal
|
|
18
|
+
signal (ids and a count), and a ``TranscriptDelivery`` over the Runner's signed stream
|
|
19
|
+
when the liable user asks. Nothing else reads a body.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Final, Literal, Protocol
|
|
31
|
+
from uuid import UUID, uuid4
|
|
32
|
+
|
|
33
|
+
from temporalio.client import Client
|
|
34
|
+
|
|
35
|
+
from agentic_runner_contracts.channel_messages import (
|
|
36
|
+
PENDING_MESSAGES_SIGNAL,
|
|
37
|
+
MessageEnvelope,
|
|
38
|
+
MessageKind,
|
|
39
|
+
MessageReference,
|
|
40
|
+
PendingMessagesSignal,
|
|
41
|
+
ReferenceKind,
|
|
42
|
+
TranscriptDelivery,
|
|
43
|
+
TranscriptRequest,
|
|
44
|
+
)
|
|
45
|
+
from agentic_runner_contracts.public_metadata import OUTCOME_KINDS, workflow_id
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"MESSAGES_DIR",
|
|
49
|
+
"SEALED_MARKER",
|
|
50
|
+
"MessageStore",
|
|
51
|
+
"TemporalWorkflowSignaller",
|
|
52
|
+
"WorkflowSignaller",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
MESSAGES_DIR: Final = "messages"
|
|
56
|
+
SEALED_MARKER: Final = "SEALED"
|
|
57
|
+
_DIR_MODE: Final = 0o700
|
|
58
|
+
_FILE_MODE: Final = 0o600
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WorkflowSignaller(Protocol):
|
|
62
|
+
async def signal(self, pending: PendingMessagesSignal) -> None:
|
|
63
|
+
"""Wake the Work Record's workflow with ids and a count, nothing else."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TemporalWorkflowSignaller:
|
|
67
|
+
"""The wake signal over the Runner's own namespace connection.
|
|
68
|
+
|
|
69
|
+
A Runner polls exactly one namespace -- its Organisation's (map ticket 15 §2) -- and
|
|
70
|
+
the client it polls with is the one this sends on, so the signal cannot land in any
|
|
71
|
+
other Organisation's namespace by construction. The workflow id is built by the
|
|
72
|
+
Public Metadata builder like every Temporal-visible name (ADR-0010 §6).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, client: Client) -> None:
|
|
76
|
+
self._client = client
|
|
77
|
+
|
|
78
|
+
async def signal(self, pending: PendingMessagesSignal) -> None:
|
|
79
|
+
handle = self._client.get_workflow_handle(
|
|
80
|
+
workflow_id(outcome_kind=OUTCOME_KINDS[0], work_record_id=UUID(pending.work_record_id))
|
|
81
|
+
)
|
|
82
|
+
await handle.signal(PENDING_MESSAGES_SIGNAL, pending)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MessageStore:
|
|
86
|
+
"""``<root>/<contract_id>/<work_record_id>.jsonl``, one envelope per line."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, root: Path) -> None:
|
|
89
|
+
self._root = root.resolve(strict=False)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def root(self) -> Path:
|
|
93
|
+
return self._root
|
|
94
|
+
|
|
95
|
+
def append(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
contract_id: str,
|
|
99
|
+
work_record_id: str,
|
|
100
|
+
sender_agent_id: str,
|
|
101
|
+
channel_id: str,
|
|
102
|
+
kind: MessageKind,
|
|
103
|
+
body: str,
|
|
104
|
+
references: list[MessageReference],
|
|
105
|
+
now: datetime | None = None,
|
|
106
|
+
recipient_agent_id: str | None = None,
|
|
107
|
+
recipient_role: str | None = None,
|
|
108
|
+
verdict: Literal["block", "clear"] | None = None,
|
|
109
|
+
head_sha: str | None = None,
|
|
110
|
+
) -> MessageEnvelope:
|
|
111
|
+
"""Store one envelope. Raises ``PermissionError`` on a sealed Contract.
|
|
112
|
+
|
|
113
|
+
A `result` that names no addressee answers the `message` it references, so its
|
|
114
|
+
recipient is that Message's sender (PRD issue 53: `result` wakes the requester).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
self._require_open(contract_id)
|
|
118
|
+
if kind is MessageKind.RESULT and not recipient_agent_id and not recipient_role:
|
|
119
|
+
recipient_agent_id = self._requester_of(contract_id, work_record_id, references)
|
|
120
|
+
envelope = MessageEnvelope(
|
|
121
|
+
message_id=uuid4(),
|
|
122
|
+
sender_agent_id=UUID(sender_agent_id),
|
|
123
|
+
channel_id=UUID(channel_id),
|
|
124
|
+
work_record_id=UUID(work_record_id),
|
|
125
|
+
kind=kind,
|
|
126
|
+
references=list(references),
|
|
127
|
+
size_bytes=len(body.encode("utf-8")),
|
|
128
|
+
sent_at=now or datetime.now(UTC),
|
|
129
|
+
recipient_agent_id=UUID(recipient_agent_id) if recipient_agent_id else None,
|
|
130
|
+
recipient_role=recipient_role or None,
|
|
131
|
+
verdict=verdict,
|
|
132
|
+
head_sha=head_sha or None,
|
|
133
|
+
body=body,
|
|
134
|
+
)
|
|
135
|
+
path = self._path(contract_id, work_record_id)
|
|
136
|
+
_ensure_dir(path.parent)
|
|
137
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
138
|
+
handle.write(envelope.model_dump_json() + "\n")
|
|
139
|
+
path.chmod(_FILE_MODE)
|
|
140
|
+
return envelope
|
|
141
|
+
|
|
142
|
+
def read(
|
|
143
|
+
self, *, contract_id: str, work_record_id: str, channel_id: str | None = None
|
|
144
|
+
) -> list[MessageEnvelope]:
|
|
145
|
+
"""The Conversation, or one Channel of it. Raises ``PermissionError`` when sealed."""
|
|
146
|
+
|
|
147
|
+
self._require_open(contract_id)
|
|
148
|
+
return self._read(contract_id, work_record_id, channel_id)
|
|
149
|
+
|
|
150
|
+
def depth(self, *, contract_id: str, work_record_id: str, channel_id: str) -> int:
|
|
151
|
+
return len(self._read(contract_id, work_record_id, channel_id))
|
|
152
|
+
|
|
153
|
+
def pending(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
contract_id: str,
|
|
157
|
+
work_record_id: str,
|
|
158
|
+
channel_id: str,
|
|
159
|
+
agent_id: str,
|
|
160
|
+
roles: tuple[str, ...] = (),
|
|
161
|
+
) -> list[MessageEnvelope]:
|
|
162
|
+
"""What one member has not yet seen: every Message on the Channel after its
|
|
163
|
+
cursor that is addressed to it -- by id, by a role it holds, or to nobody in
|
|
164
|
+
particular (a `note` to the Swarm) -- and not its own (PRD issue 53)."""
|
|
165
|
+
|
|
166
|
+
self._require_open(contract_id)
|
|
167
|
+
cursor = self._cursors(contract_id, work_record_id).get(agent_id, "")
|
|
168
|
+
envelopes = self._read(contract_id, work_record_id, channel_id)
|
|
169
|
+
unseen = _after(envelopes, cursor)
|
|
170
|
+
me = UUID(agent_id)
|
|
171
|
+
return [
|
|
172
|
+
envelope
|
|
173
|
+
for envelope in unseen
|
|
174
|
+
if envelope.sender_agent_id != me
|
|
175
|
+
and (
|
|
176
|
+
envelope.recipient_agent_id == me
|
|
177
|
+
or (envelope.recipient_role is not None and envelope.recipient_role in roles)
|
|
178
|
+
or (envelope.recipient_agent_id is None and envelope.recipient_role is None)
|
|
179
|
+
)
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
def mark_consumed(
|
|
183
|
+
self, *, contract_id: str, work_record_id: str, agent_id: str, through: MessageEnvelope
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Advance one member's cursor: the Directive that folded ``through`` in ran."""
|
|
186
|
+
|
|
187
|
+
cursors = self._cursors(contract_id, work_record_id)
|
|
188
|
+
cursors[agent_id] = str(through.message_id)
|
|
189
|
+
path = self._cursor_path(contract_id, work_record_id)
|
|
190
|
+
_ensure_dir(path.parent)
|
|
191
|
+
path.write_text(json.dumps(cursors), encoding="utf-8")
|
|
192
|
+
path.chmod(_FILE_MODE)
|
|
193
|
+
|
|
194
|
+
def transcript(self, request: TranscriptRequest) -> TranscriptDelivery:
|
|
195
|
+
"""The pull-through's answer: every body, or the Contract state that seals them."""
|
|
196
|
+
|
|
197
|
+
contract_id = str(request.contract_id)
|
|
198
|
+
sealed = self.sealed_state(contract_id)
|
|
199
|
+
if sealed is not None:
|
|
200
|
+
return TranscriptDelivery(
|
|
201
|
+
request_id=request.request_id,
|
|
202
|
+
work_record_id=request.work_record_id,
|
|
203
|
+
refused=sealed,
|
|
204
|
+
)
|
|
205
|
+
return TranscriptDelivery(
|
|
206
|
+
request_id=request.request_id,
|
|
207
|
+
work_record_id=request.work_record_id,
|
|
208
|
+
messages=self._read(contract_id, str(request.work_record_id), None),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------- lifecycle
|
|
212
|
+
|
|
213
|
+
def seal(self, contract_id: str, *, contract_state: str) -> int:
|
|
214
|
+
"""Seal every store of the Contract (map ticket 10): kept, unreadable, until retention.
|
|
215
|
+
|
|
216
|
+
Returns how many Work Record stores the seal covers. Idempotent, and a Contract
|
|
217
|
+
with no store gets no marker -- there is nothing to retain.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
contract_dir = self._root / contract_id
|
|
221
|
+
stores = _stores(contract_dir)
|
|
222
|
+
if not stores:
|
|
223
|
+
return 0
|
|
224
|
+
marker = contract_dir / SEALED_MARKER
|
|
225
|
+
marker.write_text(json.dumps({"contract_state": contract_state}), encoding="utf-8")
|
|
226
|
+
marker.chmod(_FILE_MODE)
|
|
227
|
+
return len(stores)
|
|
228
|
+
|
|
229
|
+
def sealed_state(self, contract_id: str) -> str | None:
|
|
230
|
+
marker = self._root / contract_id / SEALED_MARKER
|
|
231
|
+
if not marker.is_file():
|
|
232
|
+
return None
|
|
233
|
+
try:
|
|
234
|
+
loaded = json.loads(marker.read_text(encoding="utf-8"))
|
|
235
|
+
except ValueError:
|
|
236
|
+
return "terminated"
|
|
237
|
+
state = loaded.get("contract_state") if isinstance(loaded, Mapping) else None
|
|
238
|
+
return str(state) if state else "terminated"
|
|
239
|
+
|
|
240
|
+
def held(self) -> list[tuple[str, str]]:
|
|
241
|
+
"""Every ``(contract_id, work_record_id)`` with a store on this Runner."""
|
|
242
|
+
|
|
243
|
+
if not self._root.is_dir():
|
|
244
|
+
return []
|
|
245
|
+
return [
|
|
246
|
+
(contract_dir.name, store.stem)
|
|
247
|
+
for contract_dir in sorted(self._root.iterdir())
|
|
248
|
+
if contract_dir.is_dir()
|
|
249
|
+
for store in _stores(contract_dir)
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
def remove(self, contract_id: str, work_record_id: str) -> bool:
|
|
253
|
+
"""The retention drop. A Contract whose last store went takes its seal with it."""
|
|
254
|
+
|
|
255
|
+
path = self._path(contract_id, work_record_id)
|
|
256
|
+
if not path.is_file():
|
|
257
|
+
return False
|
|
258
|
+
path.unlink()
|
|
259
|
+
self._cursor_path(contract_id, work_record_id).unlink(missing_ok=True)
|
|
260
|
+
contract_dir = path.parent
|
|
261
|
+
if not _stores(contract_dir):
|
|
262
|
+
shutil.rmtree(contract_dir, ignore_errors=True)
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
# ------------------------------------------------------------- internals
|
|
266
|
+
|
|
267
|
+
def _require_open(self, contract_id: str) -> None:
|
|
268
|
+
sealed = self.sealed_state(contract_id)
|
|
269
|
+
if sealed is not None:
|
|
270
|
+
raise PermissionError(
|
|
271
|
+
f"the Message store of Contract {contract_id} is sealed ({sealed})"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _path(self, contract_id: str, work_record_id: str) -> Path:
|
|
275
|
+
# Both segments are platform ids (UUIDs) by the time they reach here; refusing
|
|
276
|
+
# anything else keeps a path from escaping the root.
|
|
277
|
+
return self._root / str(UUID(contract_id)) / f"{UUID(work_record_id)}.jsonl"
|
|
278
|
+
|
|
279
|
+
def _cursor_path(self, contract_id: str, work_record_id: str) -> Path:
|
|
280
|
+
return self._path(contract_id, work_record_id).with_suffix(".cursors.json")
|
|
281
|
+
|
|
282
|
+
def _cursors(self, contract_id: str, work_record_id: str) -> dict[str, str]:
|
|
283
|
+
path = self._cursor_path(contract_id, work_record_id)
|
|
284
|
+
if not path.is_file():
|
|
285
|
+
return {}
|
|
286
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
287
|
+
return {str(k): str(v) for k, v in loaded.items()} if isinstance(loaded, Mapping) else {}
|
|
288
|
+
|
|
289
|
+
def _requester_of(
|
|
290
|
+
self, contract_id: str, work_record_id: str, references: list[MessageReference]
|
|
291
|
+
) -> str | None:
|
|
292
|
+
answered = {ref.value for ref in references if ref.kind is ReferenceKind.MESSAGE}
|
|
293
|
+
if not answered:
|
|
294
|
+
return None
|
|
295
|
+
return next(
|
|
296
|
+
(
|
|
297
|
+
str(envelope.sender_agent_id)
|
|
298
|
+
for envelope in self._read(contract_id, work_record_id, None)
|
|
299
|
+
if str(envelope.message_id) in answered
|
|
300
|
+
),
|
|
301
|
+
None,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _read(
|
|
305
|
+
self, contract_id: str, work_record_id: str, channel_id: str | None
|
|
306
|
+
) -> list[MessageEnvelope]:
|
|
307
|
+
path = self._path(contract_id, work_record_id)
|
|
308
|
+
if not path.is_file():
|
|
309
|
+
return []
|
|
310
|
+
envelopes = [
|
|
311
|
+
MessageEnvelope.model_validate_json(line)
|
|
312
|
+
for line in path.read_text(encoding="utf-8").splitlines()
|
|
313
|
+
if line.strip()
|
|
314
|
+
]
|
|
315
|
+
if channel_id is None:
|
|
316
|
+
return envelopes
|
|
317
|
+
wanted = UUID(channel_id)
|
|
318
|
+
return [envelope for envelope in envelopes if envelope.channel_id == wanted]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _stores(contract_dir: Path) -> list[Path]:
|
|
322
|
+
if not contract_dir.is_dir():
|
|
323
|
+
return []
|
|
324
|
+
return sorted(entry for entry in contract_dir.iterdir() if entry.suffix == ".jsonl")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _after(envelopes: list[MessageEnvelope], cursor: str) -> list[MessageEnvelope]:
|
|
328
|
+
if not cursor:
|
|
329
|
+
return envelopes
|
|
330
|
+
for index, envelope in enumerate(envelopes):
|
|
331
|
+
if str(envelope.message_id) == cursor:
|
|
332
|
+
return envelopes[index + 1 :]
|
|
333
|
+
return envelopes
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _ensure_dir(contract_dir: Path) -> None:
|
|
337
|
+
"""Root and Contract directory alike: the Runner's own, closed to every other uid."""
|
|
338
|
+
|
|
339
|
+
for directory in (contract_dir.parent, contract_dir):
|
|
340
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
os.chmod(directory, _DIR_MODE)
|
agentic_runner/py.typed
ADDED
|
File without changes
|