foundry-implementation-actor 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.
- foundry_implementation_actor/__init__.py +38 -0
- foundry_implementation_actor/cli.py +107 -0
- foundry_implementation_actor/config.py +405 -0
- foundry_implementation_actor/correlation.py +193 -0
- foundry_implementation_actor/engine.py +434 -0
- foundry_implementation_actor/grounding.py +197 -0
- foundry_implementation_actor/handler.py +220 -0
- foundry_implementation_actor/schemas/agentic-context.schema.yaml +143 -0
- foundry_implementation_actor-0.1.0.dist-info/METADATA +217 -0
- foundry_implementation_actor-0.1.0.dist-info/RECORD +12 -0
- foundry_implementation_actor-0.1.0.dist-info/WHEEL +4 -0
- foundry_implementation_actor-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Correlation ids — the one place this actor decides what identifies a unit of work.
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS. Before it, exactly one call site (the engine's own `_invoke_claude`) passed
|
|
4
|
+
`extra={"task_id": ...}`, so the session transcript was the only thing in Loki that could be
|
|
5
|
+
drilled to a task. Everything else this actor logs — the framework's own `do_POST door=…` access
|
|
6
|
+
line, every git/buildctl step, every failure — arrived with no identity at all, which is exactly
|
|
7
|
+
when you most want one. This module makes carrying the ids the default rather than a thing each
|
|
8
|
+
call site has to remember.
|
|
9
|
+
|
|
10
|
+
TWO IDS, AND WHERE EACH COMES FROM.
|
|
11
|
+
|
|
12
|
+
* `correlation_id` — THE TRACE ID, not a second identifier invented here. One orchestration
|
|
13
|
+
call is one trace: the ORCHESTRATING actor's own `_call_door` injects W3C `traceparent`, and
|
|
14
|
+
`HttpMailbox.do_POST` extracts it into the SERVER span it opens around `Actor.receive()` — so
|
|
15
|
+
every actor in the pipeline, across every retry attempt, is already inside one trace before a
|
|
16
|
+
line of this module runs. Reading `trace.get_current_span()` and formatting its
|
|
17
|
+
trace id is therefore not "generating a correlation id", it is *naming the one that already
|
|
18
|
+
crossed the wire*. It also means a `correlation_id` in Loki pastes straight into Tempo.
|
|
19
|
+
A locally-minted uuid is the fallback for the no-SDK / no-active-span case only.
|
|
20
|
+
* `task_id` — `TASK-NNN`, off the caller's own payload. Spans retries and every peer, where the
|
|
21
|
+
trace id spans one orchestration run; neither subsumes the other, so both are carried.
|
|
22
|
+
|
|
23
|
+
A CALL THAT NEVER REACHES A HANDLER still gets a `correlation_id`. `Actor.receive()` refuses an
|
|
24
|
+
undeclared door or a schema-violating payload before any handler runs, and the binding answers an
|
|
25
|
+
unknown path or an unparseable body without ever calling `receive()` — so nothing of ours binds
|
|
26
|
+
for any of them. `CorrelationFilter` therefore falls back to the active span's trace id, which
|
|
27
|
+
since ADR-PASH-0005 (`papeete-actor-synchronous-messaging-http` 0.4.0) is open across every one of
|
|
28
|
+
those paths. That fallback is the whole reason the binding needed changing: a consumer can stamp
|
|
29
|
+
its own records, never the ones written on its behalf before its code ran.
|
|
30
|
+
|
|
31
|
+
WHY A HANDLER FILTER, NOT `extra=` AT EVERY CALL SITE. A `logging.Filter` on the ROOT LOGGER would
|
|
32
|
+
only see records logged directly to it — a record from any named logger reaches the root's
|
|
33
|
+
HANDLERS without ever being filtered by the root logger itself. So `install()` attaches to the
|
|
34
|
+
handlers, where every record does pass, including ones from code this repo does not own
|
|
35
|
+
(`papeete_actor_synchronous_messaging_http.mailbox`'s access log, most importantly). Nothing has
|
|
36
|
+
to opt in, which is the whole point: correlation ids everywhere means everywhere, not everywhere
|
|
37
|
+
someone remembered.
|
|
38
|
+
|
|
39
|
+
WHY `bind()` NEVER RESETS. `HttpMailbox` serves on a `ThreadingHTTPServer` — one fresh thread per
|
|
40
|
+
request, never pooled — and a `ContextVar` set inside a thread is invisible to every other thread
|
|
41
|
+
and dies with it. So request scope is thread scope here, for free, and NOT resetting is what lets
|
|
42
|
+
the ids reach the lines emitted *after* the handler returns: `do_POST`'s own access log sits in a
|
|
43
|
+
`finally` outside the span, and would otherwise be the one line in the whole request with no
|
|
44
|
+
identity. A second `bind()` in the same thread overwrites, which is what an attempt counter wants.
|
|
45
|
+
"""
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import contextvars
|
|
49
|
+
import json
|
|
50
|
+
import logging
|
|
51
|
+
import time
|
|
52
|
+
import uuid
|
|
53
|
+
from contextlib import contextmanager
|
|
54
|
+
|
|
55
|
+
try: # the same guard papeete-actor-synchronous-messaging-
|
|
56
|
+
from opentelemetry import trace # http's own _tracing.py keeps: the API is a no-op
|
|
57
|
+
except ImportError: # without an SDK, and absent entirely in a bare test
|
|
58
|
+
trace = None # environment. Neither is an error here.
|
|
59
|
+
|
|
60
|
+
_FIELDS: contextvars.ContextVar[dict] = contextvars.ContextVar("papeete_correlation", default={})
|
|
61
|
+
|
|
62
|
+
STEP_LOGGER = "pipeline"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _trace_id() -> str | None:
|
|
66
|
+
"""The active span's trace id, or None — no SDK, no span, or an invalid context."""
|
|
67
|
+
if trace is None:
|
|
68
|
+
return None
|
|
69
|
+
context = trace.get_current_span().get_span_context()
|
|
70
|
+
return format(context.trace_id, "032x") if context.is_valid else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def correlation_id() -> str:
|
|
74
|
+
"""The active trace id, or a fresh uuid when there is no SDK/span to read one from."""
|
|
75
|
+
return _trace_id() or uuid.uuid4().hex
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def current() -> dict:
|
|
79
|
+
return dict(_FIELDS.get())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def bind(**fields) -> None:
|
|
83
|
+
"""Add ids to THIS THREAD's correlation context, for the rest of the thread's life.
|
|
84
|
+
|
|
85
|
+
Deliberately not a context manager — see this module's own docstring for why the binding
|
|
86
|
+
outliving the handler is the point, not an oversight."""
|
|
87
|
+
_FIELDS.set({**_FIELDS.get(), **{k: v for k, v in fields.items() if v is not None}})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CorrelationFilter(logging.Filter):
|
|
91
|
+
"""Stamps every record passing a handler with the current context's ids.
|
|
92
|
+
|
|
93
|
+
Never overwrites: a call site that passed its own `extra={"task_id": ...}` keeps it, so this
|
|
94
|
+
is additive to existing behaviour rather than a replacement for it. The stamped attributes
|
|
95
|
+
are ordinary record attributes, which is precisely what OTel's own `LoggingHandler` turns
|
|
96
|
+
into OTLP log attributes — and Loki, in turn, into structured metadata a `| task_id = "…"`
|
|
97
|
+
matcher filters on with no parser in front of it."""
|
|
98
|
+
|
|
99
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
100
|
+
for key, value in _FIELDS.get().items():
|
|
101
|
+
if not hasattr(record, key):
|
|
102
|
+
setattr(record, key, value)
|
|
103
|
+
if not hasattr(record, "correlation_id"):
|
|
104
|
+
# NOTHING BOUND — which is precisely the shape of a call the framework turned away
|
|
105
|
+
# before any handler of ours could run: an undeclared door, a payload the card's own
|
|
106
|
+
# `request_schema` rejects, a body that is not JSON. Since ADR-PASH-0005 the HTTP
|
|
107
|
+
# binding holds its SERVER span open across all of those, parented on the caller's
|
|
108
|
+
# own `traceparent`, so the active trace id here is the very value `bind()` would
|
|
109
|
+
# have used. Falling back to it files those records under the run that caused them
|
|
110
|
+
# instead of under nothing at all. `task_id` genuinely cannot be recovered this way
|
|
111
|
+
# and is left absent: a payload the schema rejected may not carry one.
|
|
112
|
+
fallback = _trace_id()
|
|
113
|
+
if fallback:
|
|
114
|
+
record.correlation_id = fallback
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# The ids Loki gets as structured metadata are invisible in `kubectl logs`, which is still the
|
|
119
|
+
# first place anyone looks when the telemetry backend itself is what's under suspicion.
|
|
120
|
+
CONSOLE_IDS = ("correlation_id", "task_id", "attempt")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ConsoleFormatter(logging.Formatter):
|
|
124
|
+
"""Appends whichever correlation ids a record actually carries.
|
|
125
|
+
|
|
126
|
+
A plain `%(task_id)s` in the format string would instead raise `Formatting field not found`
|
|
127
|
+
for every record emitted outside a request — the startup line, anything on a thread that never
|
|
128
|
+
bound — which is exactly how a logging change takes a process down. Nothing is defaulted onto
|
|
129
|
+
the record either: an id that isn't there stays absent, so the OTLP side never carries a
|
|
130
|
+
placeholder value the dashboard would then have to filter back out."""
|
|
131
|
+
|
|
132
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
133
|
+
line = super().format(record)
|
|
134
|
+
ids = " ".join(
|
|
135
|
+
f"{key}={getattr(record, key)}" for key in CONSOLE_IDS if hasattr(record, key)
|
|
136
|
+
)
|
|
137
|
+
return f"{line} [{ids}]" if ids else line
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def install() -> None:
|
|
141
|
+
"""Attach the filter to every handler on the root logger.
|
|
142
|
+
|
|
143
|
+
Call AFTER `papeete_observability.configure()` and after any console handler is added — this
|
|
144
|
+
walks the handlers that exist at the moment it runs."""
|
|
145
|
+
correlation_filter = CorrelationFilter()
|
|
146
|
+
for handler in logging.getLogger().handlers:
|
|
147
|
+
handler.addFilter(correlation_filter)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ── the step vocabulary ───────────────────────────────────────────────────────────────────────
|
|
151
|
+
#
|
|
152
|
+
# One JSON object per step, so the dashboard reads them with the same `| json` it already uses for
|
|
153
|
+
# the session transcript, and tells the two apart by `event`. `phase` is start / ok / failed —
|
|
154
|
+
# a start line with no matching ok is a step that was still running when the pod went away, which
|
|
155
|
+
# a duration-only record could never show.
|
|
156
|
+
|
|
157
|
+
def _emit(level: int, record: dict) -> None:
|
|
158
|
+
# `level` goes INTO the JSON body as well as onto the record. Severity does reach Loki through
|
|
159
|
+
# OTLP, but exactly which field it lands in is the ingester's business, not something a
|
|
160
|
+
# dashboard query should be pinned to; a `level` the emitter wrote itself is one `| json` away
|
|
161
|
+
# in every backend, which is what the product dashboard's own failure panel filters on.
|
|
162
|
+
body = {"level": logging.getLevelName(level).lower(), **record}
|
|
163
|
+
logging.getLogger(STEP_LOGGER).log(level, json.dumps(body, default=str, ensure_ascii=False))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def event(name: str, *, level: int = logging.INFO, **fields) -> None:
|
|
167
|
+
"""A point in the pipeline with no duration — a verdict, a published ref, a PR url.
|
|
168
|
+
|
|
169
|
+
`level` is keyword-only — a best-effort step that failed without stopping the run
|
|
170
|
+
(`logging.WARNING`) still reads as one `event` to the dashboard's `| json`, and lands in the
|
|
171
|
+
body as `level` (see `_emit`), so `**fields` must not carry a key of that name."""
|
|
172
|
+
_emit(level, {"event": "event", "step": name, **fields})
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@contextmanager
|
|
176
|
+
def stage(name: str, **fields):
|
|
177
|
+
"""A step with a beginning and an end, logged as both — and as `failed` with the exception's
|
|
178
|
+
own text if it raises, before the exception continues on its way untouched."""
|
|
179
|
+
started = time.monotonic()
|
|
180
|
+
_emit(logging.INFO, {"event": "step", "step": name, "phase": "start", **fields})
|
|
181
|
+
try:
|
|
182
|
+
yield
|
|
183
|
+
except BaseException as e: # noqa: BLE001 — re-raised below
|
|
184
|
+
_emit(logging.ERROR, {
|
|
185
|
+
"event": "step", "step": name, "phase": "failed",
|
|
186
|
+
"duration_ms": round((time.monotonic() - started) * 1000),
|
|
187
|
+
"error": f"{type(e).__name__}: {e}", **fields,
|
|
188
|
+
})
|
|
189
|
+
raise
|
|
190
|
+
_emit(logging.INFO, {
|
|
191
|
+
"event": "step", "step": name, "phase": "ok",
|
|
192
|
+
"duration_ms": round((time.monotonic() - started) * 1000), **fields,
|
|
193
|
+
})
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""`ClaudeCodeEngine` — judgement by running a headless Claude Code session against a fresh clone.
|
|
2
|
+
|
|
3
|
+
WHY A CUSTOM ENGINE, NOT `papeete_actor_synchronous_messaging.engine.resolve("claude")`. The
|
|
4
|
+
built-in `ClaudeEngine` shells out to the raw `anthropic` Python SDK — `anthropic.Anthropic()`,
|
|
5
|
+
resolving `ANTHROPIC_API_KEY`, then `ANTHROPIC_AUTH_TOKEN`, then an `ant auth login` profile.
|
|
6
|
+
Every one of those is a metered API credential. Subscription OAuth tokens (`claude setup-token`
|
|
7
|
+
→ `CLAUDE_CODE_OAUTH_TOKEN`) are scoped to the `claude` CLI / claude.ai specifically and are NOT
|
|
8
|
+
among the credentials the raw SDK will resolve. So the only way to spend a subscription's own
|
|
9
|
+
credit rather than a separate metered key is to shell out to the `claude` CLI itself, which is
|
|
10
|
+
what this file does. It still satisfies the `Engine` port (`name` + `judge(system, prompt,
|
|
11
|
+
schema=None) -> dict`) — the port makes no promise about how long or heavy a judgement is.
|
|
12
|
+
|
|
13
|
+
**Do not set `ANTHROPIC_API_KEY` (or `ANTHROPIC_AUTH_TOKEN`) in a container running this engine.**
|
|
14
|
+
In `claude -p` non-interactive mode an API key present in the environment is ALWAYS preferred over
|
|
15
|
+
`CLAUDE_CODE_OAUTH_TOKEN`, silently routing every session through metered billing instead. There is
|
|
16
|
+
no warning and no visible difference in the transcript; the only symptom is the bill.
|
|
17
|
+
|
|
18
|
+
PAYLOAD-DRIVEN, NOT CARD-DRIVEN. The caller supplies everything a session needs to work — this
|
|
19
|
+
engine never clones a repo merely to look up what a task IS. A missing required field is refused
|
|
20
|
+
by the framework's own schema gate before any engine time is spent, so there is no eligibility
|
|
21
|
+
check here either.
|
|
22
|
+
|
|
23
|
+
CARRIES NO CAPABILITY LITERAL. Every identifier it needs comes from `CapabilityConfig`, which
|
|
24
|
+
derives all of them from one capability id and one repo (see `config.py`). The system prompt is
|
|
25
|
+
never invented here either: `Actor.judge()` builds it from the card's own `means:`/`completion:`
|
|
26
|
+
prose and hands it in as `system`, which this engine passes straight through via
|
|
27
|
+
`--append-system-prompt`, unmodified.
|
|
28
|
+
|
|
29
|
+
WHAT THIS ENGINE DOES NOT DO. It never commits, pushes, or opens a pull request — that is
|
|
30
|
+
`handler.py`'s job, and `handler.py`'s own containment check is the actual enforcement of the
|
|
31
|
+
write boundary this engine's prompt only *asks* the session to respect. On success it
|
|
32
|
+
deliberately does NOT clean up its own clone: the handler still needs it to commit and push, and
|
|
33
|
+
is the one that removes it, on every path including containment failure.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import os
|
|
40
|
+
import shutil
|
|
41
|
+
import subprocess
|
|
42
|
+
import tempfile
|
|
43
|
+
import threading
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from papeete_actor_synchronous_messaging.engine import EngineError
|
|
47
|
+
|
|
48
|
+
from . import correlation, grounding
|
|
49
|
+
from .config import CapabilityConfig
|
|
50
|
+
|
|
51
|
+
DEFAULT_CLONE_TIMEOUT_S = 120
|
|
52
|
+
DEFAULT_SESSION_TIMEOUT_S = 1800
|
|
53
|
+
DEFAULT_MAX_TURNS = 60
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── stream-json projection: keeping every emitted log line inside Loki's max_line_size ────────
|
|
57
|
+
#
|
|
58
|
+
# WHY PROJECT RATHER THAN LOG THE RAW EVENT. `--output-format stream-json` emits one JSON object
|
|
59
|
+
# per turn — the inner conversation this actor's audit trail is made of. Two of its fields are
|
|
60
|
+
# unbounded: `tool_use.input` (for `Write`, the whole file body; for `Edit`, both sides of the
|
|
61
|
+
# replacement) and `tool_result.content` (for `Read`/`Grep`/`Bash`, the whole output). Nothing
|
|
62
|
+
# else is: a `text` block is a few hundred bytes. So clipping those two, and only those, keeps
|
|
63
|
+
# the entire conversation readable while bounding every line — and what gets clipped is
|
|
64
|
+
# recoverable from the branch `handler.py` pushes anyway.
|
|
65
|
+
#
|
|
66
|
+
# WHY IT MATTERS. Loki's `max_line_size` is 256KB with `max_line_size_truncate: false` — an
|
|
67
|
+
# oversized line is REJECTED OUTRIGHT, not trimmed, so a single large `Read` would silently
|
|
68
|
+
# delete exactly the turn worth reading while leaving the rest of the session intact. The budget
|
|
69
|
+
# below is a quarter of that, which also sidesteps Loki parsing `KB` as 1000 rather than 1024.
|
|
70
|
+
#
|
|
71
|
+
# AND WHY NOT RECORD ATTRIBUTES. The OTel `LoggingHandler` maps the formatted message to the OTLP
|
|
72
|
+
# body (the line Loki measures) and record attributes to log-record attributes, which land in Loki
|
|
73
|
+
# structured metadata under their own separate caps (`max_structured_metadata_size: 64KB`,
|
|
74
|
+
# `max_structured_metadata_entries_count: 128`). Payload cannot be smuggled out of the line limit
|
|
75
|
+
# by moving it there — that channel is for correlation ids, which `correlation.py` stamps onto
|
|
76
|
+
# every record this process emits without any call site here passing them.
|
|
77
|
+
|
|
78
|
+
LINE_BUDGET = 64 * 1024 # bytes per emitted log line
|
|
79
|
+
BLOB_HEAD = 2000 # bytes kept from the front of an unbounded value
|
|
80
|
+
BLOB_TAIL = 2000 # ...and from the back: a Bash failure lives in the tail, not the head
|
|
81
|
+
PROSE = 8000 # text/thinking/result — prose IS the audit trail, give it more room
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _clip(value, head: int = BLOB_HEAD, tail: int = BLOB_TAIL) -> str:
|
|
85
|
+
"""Head+tail slice of a value, budgeted in BYTES (not characters — the limit Loki enforces
|
|
86
|
+
is on the UTF-8 encoded line), with the true size recorded in the marker."""
|
|
87
|
+
if not isinstance(value, str):
|
|
88
|
+
value = json.dumps(value, default=str)
|
|
89
|
+
raw = value.encode("utf-8", "replace")
|
|
90
|
+
if len(raw) <= head + tail:
|
|
91
|
+
return value
|
|
92
|
+
return (raw[:head].decode("utf-8", "replace")
|
|
93
|
+
+ f"\n…[clipped {len(raw) - head - tail} of {len(raw)} bytes]…\n"
|
|
94
|
+
+ raw[-tail:].decode("utf-8", "replace"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _project(event: dict) -> dict | None:
|
|
98
|
+
"""One stream-json event -> a compact dict, or None to drop it entirely."""
|
|
99
|
+
kind = event.get("type")
|
|
100
|
+
|
|
101
|
+
if kind == "system" and event.get("subtype") == "init":
|
|
102
|
+
# The raw init event is ~2.2KB of tools/skills/slash_commands inventory. Four fields of
|
|
103
|
+
# it are worth keeping — `session_id` is the join key to the CLI's own full transcript,
|
|
104
|
+
# which it writes to $HOME/.claude/projects/<slug>/<session_id>.jsonl regardless of
|
|
105
|
+
# --output-format.
|
|
106
|
+
return {"event": "init", "session_id": event.get("session_id"),
|
|
107
|
+
"model": event.get("model"), "cwd": event.get("cwd")}
|
|
108
|
+
|
|
109
|
+
if kind == "result":
|
|
110
|
+
usage = event.get("usage") or {}
|
|
111
|
+
return {"event": "result", "session_id": event.get("session_id"),
|
|
112
|
+
"subtype": event.get("subtype"), "is_error": event.get("is_error"),
|
|
113
|
+
"num_turns": event.get("num_turns"), "duration_ms": event.get("duration_ms"),
|
|
114
|
+
"cost_usd": event.get("total_cost_usd"),
|
|
115
|
+
"input_tokens": usage.get("input_tokens"),
|
|
116
|
+
"output_tokens": usage.get("output_tokens"),
|
|
117
|
+
"result": _clip(event.get("result", ""), PROSE, PROSE)}
|
|
118
|
+
|
|
119
|
+
if kind not in ("assistant", "user"):
|
|
120
|
+
return None # rate_limit_event and friends carry no audit value
|
|
121
|
+
|
|
122
|
+
blocks = []
|
|
123
|
+
for block in (event.get("message") or {}).get("content", []):
|
|
124
|
+
if not isinstance(block, dict):
|
|
125
|
+
continue
|
|
126
|
+
btype = block.get("type")
|
|
127
|
+
if btype in ("text", "thinking"):
|
|
128
|
+
blocks.append({btype: _clip(block.get(btype, ""), PROSE, PROSE)})
|
|
129
|
+
elif btype == "tool_use":
|
|
130
|
+
blocks.append({"tool_use": block.get("name"), "id": block.get("id"),
|
|
131
|
+
"input": {k: _clip(v) for k, v in (block.get("input") or {}).items()}})
|
|
132
|
+
elif btype == "tool_result":
|
|
133
|
+
# `content` is a str for a text result and a list of blocks otherwise — `_clip`
|
|
134
|
+
# json-dumps the latter rather than this guessing at its shape.
|
|
135
|
+
blocks.append({"tool_result": block.get("tool_use_id"),
|
|
136
|
+
"is_error": block.get("is_error", False),
|
|
137
|
+
"content": _clip(block.get("content", ""))})
|
|
138
|
+
return {"event": kind, "blocks": blocks} if blocks else None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _line(record: dict) -> str:
|
|
142
|
+
"""Serialize, then hard-enforce the budget.
|
|
143
|
+
|
|
144
|
+
The per-field clipping in `_project` is what keeps lines small; this is what makes "no line
|
|
145
|
+
exceeds the budget" a property of the code rather than a hope — a `tool_use` carrying a
|
|
146
|
+
hundred just-under-threshold keys would otherwise slip through the sum."""
|
|
147
|
+
line = json.dumps(record, default=str, ensure_ascii=False)
|
|
148
|
+
if len(line.encode("utf-8")) > LINE_BUDGET:
|
|
149
|
+
half = LINE_BUDGET // 2 - 200
|
|
150
|
+
line = json.dumps({"event": record.get("event"), "over_budget": True,
|
|
151
|
+
"clipped": _clip(line, half, half)}, ensure_ascii=False)
|
|
152
|
+
return line
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _redact(text: str, secret: str | None) -> str:
|
|
156
|
+
return text.replace(secret, "***") if secret else text
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _payload_from_prompt(prompt: str) -> dict:
|
|
160
|
+
"""Recover the full payload dict from `Actor.judge()`'s own fixed prompt format
|
|
161
|
+
(`papeete_actor_synchronous_messaging.actor.Actor.judge`):
|
|
162
|
+
|
|
163
|
+
f"verb: {verb}\\ndoor: {offer.id}\\n{as_prompt_json(situation)}"
|
|
164
|
+
|
|
165
|
+
where `situation["payload"]` is exactly what the caller sent at this door.
|
|
166
|
+
"""
|
|
167
|
+
lines = prompt.split("\n", 2)
|
|
168
|
+
if len(lines) < 3:
|
|
169
|
+
raise EngineError(f"prompt does not follow Actor.judge()'s fixed format: {prompt!r}")
|
|
170
|
+
try:
|
|
171
|
+
situation = json.loads(lines[2])
|
|
172
|
+
return situation["payload"]
|
|
173
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
174
|
+
raise EngineError(f"could not recover payload from prompt: {e}") from e
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ClaudeCodeEngine:
|
|
178
|
+
"""Judgement by shelling out to the `claude` CLI, against a fresh private clone."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, config: CapabilityConfig, *,
|
|
181
|
+
github_token: str | None = None, claude_bin: str = "claude",
|
|
182
|
+
clone_timeout: int = DEFAULT_CLONE_TIMEOUT_S,
|
|
183
|
+
fetch_timeout: int = grounding.DEFAULT_FETCH_TIMEOUT_S,
|
|
184
|
+
session_timeout: int = DEFAULT_SESSION_TIMEOUT_S,
|
|
185
|
+
max_turns: int = DEFAULT_MAX_TURNS):
|
|
186
|
+
self.config = config
|
|
187
|
+
# The engine's `name` is what the card's door names, so it comes from the sidecar rather
|
|
188
|
+
# than being fixed here — the port requires the attribute, not a particular value.
|
|
189
|
+
self.name = config.engine
|
|
190
|
+
|
|
191
|
+
self.github_token = github_token or os.environ.get("GITHUB_TOKEN")
|
|
192
|
+
if not self.github_token:
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
"ClaudeCodeEngine needs GITHUB_TOKEN in the environment — a fine-grained PAT with "
|
|
195
|
+
f"contents:write on {config.source_repo}, plus read-only contents on "
|
|
196
|
+
f"{config.registry_repo} and whatever else this capability's `ground_in` fetches "
|
|
197
|
+
"resolve through."
|
|
198
|
+
)
|
|
199
|
+
self.claude_bin = claude_bin
|
|
200
|
+
self.clone_timeout = clone_timeout
|
|
201
|
+
self.fetch_timeout = fetch_timeout
|
|
202
|
+
self.session_timeout = session_timeout
|
|
203
|
+
self.max_turns = max_turns
|
|
204
|
+
self._configure_git_credentials()
|
|
205
|
+
|
|
206
|
+
# ── the one Engine method ──────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
def judge(self, *, system: str, prompt: str, schema: dict | None = None) -> dict:
|
|
209
|
+
payload = _payload_from_prompt(prompt)
|
|
210
|
+
task_id = payload["task_id"]
|
|
211
|
+
# The FIRST thing this door does, before anything that could fail: from here to the end
|
|
212
|
+
# of this request's own thread, every record — this module's, handler.py's, and the HTTP
|
|
213
|
+
# binding's own access line — carries both ids. `correlation_id()` reads the trace id the
|
|
214
|
+
# orchestrating actor already propagated, so every actor in the pipeline agrees on it
|
|
215
|
+
# without any of them passing it (see correlation.py).
|
|
216
|
+
correlation.bind(correlation_id=correlation.correlation_id(), task_id=task_id)
|
|
217
|
+
|
|
218
|
+
clone_dir = Path(tempfile.mkdtemp(prefix=self.config.clone_prefix(task_id)))
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
with correlation.stage("clone-code", repo=self.config.source_repo):
|
|
222
|
+
self._clone(clone_dir)
|
|
223
|
+
|
|
224
|
+
branch = f"impl/{task_id}"
|
|
225
|
+
self._git(clone_dir, ["checkout", "-b", branch])
|
|
226
|
+
|
|
227
|
+
# Grounding is a PRECONDITION, not a request — see grounding.py. It runs before the
|
|
228
|
+
# prompt is even built, and a failure here stops the request before any session time
|
|
229
|
+
# is spent, which is the cheapest place for it to stop.
|
|
230
|
+
for entry in self.config.ground_in:
|
|
231
|
+
with correlation.stage(f"ground-{entry.name}", into=entry.into, load=entry.load):
|
|
232
|
+
envelope = grounding.fetch(self.config, entry, timeout=self.fetch_timeout)
|
|
233
|
+
grounding.write_envelope(self.config, entry, clone_dir, envelope)
|
|
234
|
+
grounding.render_claude_md(self.config, clone_dir)
|
|
235
|
+
|
|
236
|
+
situational_prompt = self._situational_prompt(payload)
|
|
237
|
+
with correlation.stage("claude-session", branch=branch,
|
|
238
|
+
max_turns=self.max_turns, timeout_s=self.session_timeout):
|
|
239
|
+
summary = self._invoke_claude(clone_dir, system, situational_prompt)
|
|
240
|
+
except BaseException:
|
|
241
|
+
# Every failure path removes the clone. Success does not: the clone is handed off
|
|
242
|
+
# live, and `handler.py` is what removes it once it has committed and pushed — or
|
|
243
|
+
# refused for containment.
|
|
244
|
+
_rmtree(clone_dir)
|
|
245
|
+
raise
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"implemented": True,
|
|
249
|
+
"clone_dir": str(clone_dir),
|
|
250
|
+
"branch": branch,
|
|
251
|
+
"summary": summary,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# ── git ─────────────────────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
def _configure_git_credentials(self) -> None:
|
|
257
|
+
"""Make GITHUB_TOKEN available to subprocesses that do their own git clones.
|
|
258
|
+
|
|
259
|
+
A `ground_in` tool typically delegates git auth entirely to git's own credential
|
|
260
|
+
resolution and never takes a token itself. A global URL rewrite is the one hook available
|
|
261
|
+
to make those clones use this token too, without patching the tool. Idempotent; safe to
|
|
262
|
+
call on every construction.
|
|
263
|
+
"""
|
|
264
|
+
subprocess.run(
|
|
265
|
+
["git", "config", "--global",
|
|
266
|
+
f"url.https://x-access-token:{self.github_token}@github.com/.insteadOf",
|
|
267
|
+
"https://github.com/"],
|
|
268
|
+
check=True, capture_output=True, text=True,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _clone(self, dest: Path) -> None:
|
|
272
|
+
# Full clone, not --depth 1: `papeete_version.compute()`'s own `semver_base()` does `git
|
|
273
|
+
# describe --tags --match <name>/v*` against this clone, which needs the matching tag's
|
|
274
|
+
# commit reachable in local history — a shallow clone only has the tip commit, and breaks
|
|
275
|
+
# the moment the tag isn't that exact commit (verified live: it worked only by accident
|
|
276
|
+
# while the repo's origin/main was still a single commit).
|
|
277
|
+
url = f"https://x-access-token:{self.github_token}@github.com/{self.config.source_repo}.git"
|
|
278
|
+
try:
|
|
279
|
+
subprocess.run(
|
|
280
|
+
["git", "clone", url, str(dest)],
|
|
281
|
+
check=True, capture_output=True, text=True, timeout=self.clone_timeout,
|
|
282
|
+
)
|
|
283
|
+
except subprocess.CalledProcessError as e:
|
|
284
|
+
raise EngineError(
|
|
285
|
+
f"could not clone {self.config.source_repo}: "
|
|
286
|
+
f"{_redact(e.stderr, self.github_token)}"
|
|
287
|
+
) from e
|
|
288
|
+
except subprocess.TimeoutExpired as e:
|
|
289
|
+
raise EngineError(
|
|
290
|
+
f"cloning {self.config.source_repo} timed out after {self.clone_timeout}s"
|
|
291
|
+
) from e
|
|
292
|
+
|
|
293
|
+
def _git(self, clone_dir: Path, args: list[str]) -> str:
|
|
294
|
+
try:
|
|
295
|
+
result = subprocess.run(
|
|
296
|
+
["git", *args], cwd=clone_dir, check=True, capture_output=True, text=True,
|
|
297
|
+
)
|
|
298
|
+
except subprocess.CalledProcessError as e:
|
|
299
|
+
raise EngineError(
|
|
300
|
+
f"git {' '.join(args)} failed: {_redact(e.stderr, self.github_token)}"
|
|
301
|
+
) from e
|
|
302
|
+
return result.stdout
|
|
303
|
+
|
|
304
|
+
# ── situational prompt, built from the caller's own payload ───────────────────────────
|
|
305
|
+
|
|
306
|
+
def _situational_prompt(self, payload: dict) -> str:
|
|
307
|
+
"""The task, and nothing else.
|
|
308
|
+
|
|
309
|
+
NOTE WHAT IS ABSENT: any instruction to go and read the capability's context. That used
|
|
310
|
+
to be a paragraph here, naming two files in a sibling tempdir and asking the session to
|
|
311
|
+
read them "before you start". It is a precondition now — the generated `CLAUDE.md` is
|
|
312
|
+
loaded before turn one — so asking for it again would be asking for something already
|
|
313
|
+
done, in the one place a session is most likely to take the instruction literally and
|
|
314
|
+
spend a turn on it.
|
|
315
|
+
"""
|
|
316
|
+
config = self.config
|
|
317
|
+
task_id = payload["task_id"]
|
|
318
|
+
boundary = ", ".join(config.writes_only_under)
|
|
319
|
+
|
|
320
|
+
sections = [
|
|
321
|
+
f"# Implement {task_id} for {config.capability}: {payload['title']}\n\n"
|
|
322
|
+
f"You are working inside your own private clone (branch already checked out). "
|
|
323
|
+
f"Write ONLY under {boundary} — nothing else in this clone is yours to change. "
|
|
324
|
+
f"Do not `git add`, `git commit`, or `git push` — that is handled outside this "
|
|
325
|
+
f"session. Iterate the test suite(s) of whichever component(s) you touch "
|
|
326
|
+
f"({', '.join(config.test_paths)}) until green before you finish.\n\n"
|
|
327
|
+
f"Scope this session to {task_id}'s own Definition of Done against the CURRENT state "
|
|
328
|
+
f"of {boundary} — extend what's already there for whichever component(s) this task "
|
|
329
|
+
f"touches, don't re-derive or re-verify the whole capability's surface from scratch."
|
|
330
|
+
]
|
|
331
|
+
if payload.get("context"):
|
|
332
|
+
sections.append(f"## Context\n{payload['context']}")
|
|
333
|
+
sections.append(
|
|
334
|
+
"## Definition of done\n"
|
|
335
|
+
+ "\n".join(f"- {item}" for item in payload["definition_of_done"])
|
|
336
|
+
)
|
|
337
|
+
if payload.get("remediation_context"):
|
|
338
|
+
sections.append(
|
|
339
|
+
"## Remediation — the prior attempt's failing test criteria\n"
|
|
340
|
+
f"{payload['remediation_context']}"
|
|
341
|
+
)
|
|
342
|
+
return "\n\n".join(sections)
|
|
343
|
+
|
|
344
|
+
# ── the judgement itself: a claude -p session against the checked-out clone ────────────
|
|
345
|
+
|
|
346
|
+
def _invoke_claude(self, clone_dir: Path, system: str, situational_prompt: str) -> str:
|
|
347
|
+
"""Run the session, streaming every turn to the log as it happens.
|
|
348
|
+
|
|
349
|
+
`--output-format stream-json --verbose` rather than `--output-format json`: the latter
|
|
350
|
+
emits ONE object at the end, holding only the final assistant text, so the whole inner
|
|
351
|
+
conversation — what was read, what was run, what was decided — existed nowhere durable
|
|
352
|
+
once the pod went away. The projection above is what makes streaming it affordable.
|
|
353
|
+
|
|
354
|
+
`Popen` rather than `subprocess.run`: reading line by line is what lets each turn be
|
|
355
|
+
logged as it happens rather than after the session ends, and it stops a 30-minute
|
|
356
|
+
session's entire output being buffered in a pod capped at 2Gi.
|
|
357
|
+
"""
|
|
358
|
+
cmd = [
|
|
359
|
+
self.claude_bin, "--print", "--output-format", "stream-json", "--verbose",
|
|
360
|
+
"--append-system-prompt", system,
|
|
361
|
+
"--permission-mode", "acceptEdits",
|
|
362
|
+
"--allowedTools", "Bash,Read,Edit,Write,Glob,Grep",
|
|
363
|
+
"--max-turns", str(self.max_turns),
|
|
364
|
+
situational_prompt,
|
|
365
|
+
]
|
|
366
|
+
# stderr to a temp file, not a second pipe: nothing drains a second pipe while the
|
|
367
|
+
# stdout loop below runs, so a chatty stderr would fill its buffer and deadlock the
|
|
368
|
+
# session. Merging it into stdout is not an option either — it would corrupt the stream.
|
|
369
|
+
with tempfile.TemporaryFile("w+") as errfile:
|
|
370
|
+
try:
|
|
371
|
+
proc = subprocess.Popen(cmd, cwd=clone_dir, stdout=subprocess.PIPE,
|
|
372
|
+
stderr=errfile, text=True, bufsize=1)
|
|
373
|
+
except FileNotFoundError as e:
|
|
374
|
+
raise EngineError(
|
|
375
|
+
f"'{self.claude_bin}' is not on PATH — install @anthropic-ai/claude-code"
|
|
376
|
+
) from e
|
|
377
|
+
|
|
378
|
+
# A watchdog, not a deadline checked per line: a session that hangs having emitted
|
|
379
|
+
# nothing would never reach another loop iteration to be checked, and `Popen` has no
|
|
380
|
+
# equivalent of `subprocess.run(timeout=...)` while iterating its output.
|
|
381
|
+
timed_out = threading.Event()
|
|
382
|
+
|
|
383
|
+
def _expire() -> None:
|
|
384
|
+
timed_out.set()
|
|
385
|
+
proc.kill()
|
|
386
|
+
|
|
387
|
+
watchdog = threading.Timer(self.session_timeout, _expire)
|
|
388
|
+
watchdog.start()
|
|
389
|
+
|
|
390
|
+
final: dict | None = None
|
|
391
|
+
try:
|
|
392
|
+
for raw in proc.stdout:
|
|
393
|
+
try:
|
|
394
|
+
event = json.loads(raw)
|
|
395
|
+
except json.JSONDecodeError:
|
|
396
|
+
continue # a non-JSON line is noise, never the session's result
|
|
397
|
+
record = _project(event)
|
|
398
|
+
if record is not None:
|
|
399
|
+
# No %-args: `logging` only applies %-formatting when args are passed,
|
|
400
|
+
# so a stray % in a file body cannot raise here. No `extra=` either —
|
|
401
|
+
# `correlation.bind()` in `judge()` above already stamps task_id and
|
|
402
|
+
# correlation_id onto every record emitted on this thread.
|
|
403
|
+
logging.info(_line(record))
|
|
404
|
+
if event.get("type") == "result":
|
|
405
|
+
final = event
|
|
406
|
+
proc.wait()
|
|
407
|
+
finally:
|
|
408
|
+
watchdog.cancel()
|
|
409
|
+
if proc.poll() is None:
|
|
410
|
+
proc.kill()
|
|
411
|
+
proc.wait()
|
|
412
|
+
proc.stdout.close()
|
|
413
|
+
|
|
414
|
+
if timed_out.is_set():
|
|
415
|
+
raise EngineError(
|
|
416
|
+
f"claude session for this task exceeded {self.session_timeout}s"
|
|
417
|
+
)
|
|
418
|
+
errfile.seek(0)
|
|
419
|
+
stderr = errfile.read()
|
|
420
|
+
|
|
421
|
+
if final is None:
|
|
422
|
+
raise EngineError(
|
|
423
|
+
f"claude (rc={proc.returncode}) produced no result event: {stderr[-2000:]}"
|
|
424
|
+
)
|
|
425
|
+
if final.get("is_error") or proc.returncode != 0:
|
|
426
|
+
raise EngineError(
|
|
427
|
+
f"claude session failed (subtype={final.get('subtype')}): "
|
|
428
|
+
f"{final.get('result', '')[:4000]}"
|
|
429
|
+
)
|
|
430
|
+
return final.get("result", "")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _rmtree(path: Path) -> None:
|
|
434
|
+
shutil.rmtree(path, ignore_errors=True)
|