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,197 @@
|
|
|
1
|
+
"""Grounding — putting the capability's own context in front of the session before turn one.
|
|
2
|
+
|
|
3
|
+
THE MECHANISM, AND WHY IT IS THIS ONE. `CLAUDE.md` at the working directory's root is loaded by
|
|
4
|
+
the `claude` CLI **before the first turn**, and its `@relative/path.md` imports are resolved
|
|
5
|
+
eagerly at the same moment. Verified live in this actor's exact invocation shape (`--print
|
|
6
|
+
--output-format stream-json --permission-mode acceptEdits`): a question only the fetched context
|
|
7
|
+
could answer came back in `num_turns: 1` with zero tool calls. Nothing was read, because nothing
|
|
8
|
+
needed to be — it was already in the window.
|
|
9
|
+
|
|
10
|
+
WHAT THIS REPLACES. The previous arrangement fetched the same envelopes into a tempdir **beside**
|
|
11
|
+
the clone and asked the session, in prose, to "read it before you start". That is not broken —
|
|
12
|
+
reads outside the cwd work and no permission denial was ever recorded — but it is *advisory*.
|
|
13
|
+
Whether the context entered the window at all was the model's choice, re-made every session, and
|
|
14
|
+
a session that skipped it looked exactly like one that had read it. Writing the envelopes inside
|
|
15
|
+
the clone and naming them from a generated `CLAUDE.md` turns grounding from a request into a
|
|
16
|
+
precondition.
|
|
17
|
+
|
|
18
|
+
WHY INSIDE THE CLONE, SPECIFICALLY. A `@`-import resolves relative to the file that contains it.
|
|
19
|
+
An envelope in a sibling tempdir can be *described* to a session but never imported by one, so
|
|
20
|
+
the sibling-tempdir arrangement could not have been fixed by writing a better prompt.
|
|
21
|
+
|
|
22
|
+
TWO TIERS, AND WHY THE CHOICE IS A MEASUREMENT. `load: eager` costs its full token weight on
|
|
23
|
+
every session, unconditionally, whether or not the task touches what it describes. `load:
|
|
24
|
+
on-demand` costs one line — its `answers:` and its path — and the session pays the rest only if
|
|
25
|
+
it opens the file. Neither is the right default in general; the right one for a given source is
|
|
26
|
+
whatever its measured envelope size says. One such read was already recorded as putting "40 kB of
|
|
27
|
+
JSON on screen", so this is not a hypothetical budget.
|
|
28
|
+
|
|
29
|
+
CONTAINMENT IS UNAFFECTED. The handler stages only `components[].path`, so everything written
|
|
30
|
+
here — the `.foundry/` envelopes and the generated `CLAUDE.md` alike — is never staged, never
|
|
31
|
+
committed, and dies with the clone. No `.gitignore` entry is needed, and adding one would be a
|
|
32
|
+
second place to state a boundary that is already stated once.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import subprocess
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from papeete_actor_synchronous_messaging.engine import EngineError
|
|
41
|
+
|
|
42
|
+
from .config import CapabilityConfig, Grounding
|
|
43
|
+
|
|
44
|
+
CLAUDE_MD = "CLAUDE.md"
|
|
45
|
+
|
|
46
|
+
DEFAULT_FETCH_TIMEOUT_S = 120
|
|
47
|
+
|
|
48
|
+
# The marker that lets a generated block be told apart from prose a repo wrote for itself. It is
|
|
49
|
+
# not parsed — nothing here ever edits a previous block, because every clone is fresh — but a
|
|
50
|
+
# human reading a session's clone should be able to see instantly which half is machine-written.
|
|
51
|
+
_BEGIN = "<!-- foundry-implementation-actor: generated grounding -->"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ground(config: CapabilityConfig, clone_dir: Path, *,
|
|
55
|
+
timeout: int = DEFAULT_FETCH_TIMEOUT_S) -> list[Grounding]:
|
|
56
|
+
"""Fetch every `ground_in` source, write it into the clone, and render `CLAUDE.md`.
|
|
57
|
+
|
|
58
|
+
Returns the entries that were grounded, in declaration order. Raises `EngineError` if any
|
|
59
|
+
fetch fails — a session grounded in half its context is worse than one that never started,
|
|
60
|
+
because only the second is visible.
|
|
61
|
+
"""
|
|
62
|
+
for entry in config.ground_in:
|
|
63
|
+
envelope = fetch(config, entry, timeout=timeout)
|
|
64
|
+
write_envelope(config, entry, clone_dir, envelope)
|
|
65
|
+
render_claude_md(config, clone_dir)
|
|
66
|
+
return list(config.ground_in)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fetch(config: CapabilityConfig, entry: Grounding, *,
|
|
70
|
+
timeout: int = DEFAULT_FETCH_TIMEOUT_S) -> str:
|
|
71
|
+
"""Run one entry's `fetch:` argv and return its stdout.
|
|
72
|
+
|
|
73
|
+
This package knows no knowledge tool by name. It knows "run this, ground the session in what
|
|
74
|
+
comes back" — which is why a fourth source is a YAML entry rather than a change here.
|
|
75
|
+
"""
|
|
76
|
+
argv = config.expand(entry.fetch)
|
|
77
|
+
try:
|
|
78
|
+
result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
|
79
|
+
except FileNotFoundError as e:
|
|
80
|
+
raise EngineError(
|
|
81
|
+
f"grounding '{entry.name}': '{argv[0]}' is not on PATH. The sidecar names the tools "
|
|
82
|
+
f"this capability grounds itself in; installing them is the consuming image's job, "
|
|
83
|
+
f"not this package's."
|
|
84
|
+
) from e
|
|
85
|
+
except subprocess.TimeoutExpired as e:
|
|
86
|
+
raise EngineError(
|
|
87
|
+
f"grounding '{entry.name}': {' '.join(argv)} timed out after {timeout}s"
|
|
88
|
+
) from e
|
|
89
|
+
if result.returncode != 0:
|
|
90
|
+
raise EngineError(
|
|
91
|
+
f"grounding '{entry.name}': {' '.join(argv)} failed: {result.stderr.strip()}"
|
|
92
|
+
)
|
|
93
|
+
return result.stdout
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def write_envelope(config: CapabilityConfig, entry: Grounding, clone_dir: Path,
|
|
97
|
+
envelope: str) -> Path:
|
|
98
|
+
"""Write one fetched envelope into the clone, as Markdown wrapping the raw payload.
|
|
99
|
+
|
|
100
|
+
Markdown rather than the bare `.json` the previous arrangement wrote, for one reason: a
|
|
101
|
+
`CLAUDE.md` `@`-import pulls in a file whole, so the file has to carry its own title and its
|
|
102
|
+
own "what this answers" line, or the session receives a wall of JSON with no idea which
|
|
103
|
+
question it settles. The payload itself is untouched inside the fence.
|
|
104
|
+
"""
|
|
105
|
+
destination = clone_dir / entry.into
|
|
106
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
destination.write_text(
|
|
108
|
+
f"# {entry.name}\n\n"
|
|
109
|
+
f"{entry.answers}\n\n"
|
|
110
|
+
f"Fetched fresh for this session by `{' '.join(config.expand(entry.fetch))}`. Read it as "
|
|
111
|
+
f"given — it is this capability's own standing context, not something to re-derive.\n\n"
|
|
112
|
+
f"```json\n{_pretty(envelope)}\n```\n"
|
|
113
|
+
)
|
|
114
|
+
return destination
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def render_claude_md(config: CapabilityConfig, clone_dir: Path) -> Path:
|
|
118
|
+
"""Write the clone's `CLAUDE.md`, or append to one the repo already commits.
|
|
119
|
+
|
|
120
|
+
APPEND, NEVER OVERWRITE. The capability repo has no `CLAUDE.md` of its own today, which is
|
|
121
|
+
exactly why this is safe to introduce now — there is nothing to clobber, so the clean-slate
|
|
122
|
+
case is the one being exercised. The day one is committed, it is the repo's own standing
|
|
123
|
+
guidance for anyone working in it, and silently replacing it with a generated block would
|
|
124
|
+
remove the very thing a session most needs to obey.
|
|
125
|
+
"""
|
|
126
|
+
body = _claude_md_body(config)
|
|
127
|
+
path = clone_dir / CLAUDE_MD
|
|
128
|
+
if path.exists():
|
|
129
|
+
existing = path.read_text().rstrip("\n")
|
|
130
|
+
path.write_text(f"{existing}\n\n---\n\n{body}")
|
|
131
|
+
else:
|
|
132
|
+
path.write_text(body)
|
|
133
|
+
return path
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _claude_md_body(config: CapabilityConfig) -> str:
|
|
137
|
+
eager = [g for g in config.ground_in if g.eager]
|
|
138
|
+
on_demand = [g for g in config.ground_in if not g.eager]
|
|
139
|
+
|
|
140
|
+
lines = [
|
|
141
|
+
_BEGIN,
|
|
142
|
+
"",
|
|
143
|
+
f"# {config.capability} — context for this session",
|
|
144
|
+
"",
|
|
145
|
+
"You are working inside a private clone made for one task. The context below was fetched "
|
|
146
|
+
"fresh for this session from this capability's own knowledge registry. It is authoritative: "
|
|
147
|
+
"read it as given rather than re-deriving what it already answers.",
|
|
148
|
+
"",
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
if eager:
|
|
152
|
+
lines += ["## Standing context", ""]
|
|
153
|
+
for entry in eager:
|
|
154
|
+
lines.append(f"{entry.answers}:")
|
|
155
|
+
lines.append("")
|
|
156
|
+
lines.append(f"@{entry.into}")
|
|
157
|
+
lines.append("")
|
|
158
|
+
|
|
159
|
+
if on_demand:
|
|
160
|
+
lines += [
|
|
161
|
+
"## Available on demand",
|
|
162
|
+
"",
|
|
163
|
+
"Not loaded. Open the file if the task needs what it answers.",
|
|
164
|
+
"",
|
|
165
|
+
]
|
|
166
|
+
for entry in on_demand:
|
|
167
|
+
lines.append(f"- **{entry.name}** — {entry.answers}. `{entry.into}`")
|
|
168
|
+
lines.append("")
|
|
169
|
+
|
|
170
|
+
lines += [
|
|
171
|
+
"## Boundaries",
|
|
172
|
+
"",
|
|
173
|
+
f"Write ONLY under {_join(config.writes_only_under)}. Nothing else in this clone is yours "
|
|
174
|
+
f"to change, and a staged path outside those roots is refused rather than committed.",
|
|
175
|
+
"",
|
|
176
|
+
"Do not `git add`, `git commit`, or `git push` — that is handled outside this session.",
|
|
177
|
+
"",
|
|
178
|
+
]
|
|
179
|
+
return "\n".join(lines) + "\n"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _pretty(envelope: str) -> str:
|
|
183
|
+
"""Pretty-print a JSON envelope; pass anything else through untouched.
|
|
184
|
+
|
|
185
|
+
The fetch contract is "whatever the tool prints", not "JSON" — a source that emits Markdown or
|
|
186
|
+
plain text is equally groundable, and reformatting is a convenience for the JSON case, never a
|
|
187
|
+
requirement placed on the tool.
|
|
188
|
+
"""
|
|
189
|
+
text = envelope.strip()
|
|
190
|
+
try:
|
|
191
|
+
return json.dumps(json.loads(text), indent=2, ensure_ascii=False)
|
|
192
|
+
except (json.JSONDecodeError, ValueError):
|
|
193
|
+
return text
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _join(items) -> str:
|
|
197
|
+
return ", ".join(items)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""`implement_task` — the deterministic, auditable half of an implementation door.
|
|
2
|
+
|
|
3
|
+
`ClaudeCodeEngine.judge()` embodies the creative decision: what to build, grounded in the caller's
|
|
4
|
+
own payload and in the capability's own standing context. Everything here is mechanical and never
|
|
5
|
+
trusted to the engine's own judgement:
|
|
6
|
+
|
|
7
|
+
- **Containment.** `git add <component root>` for each declared component — never `-A`, never a
|
|
8
|
+
bare `.` — then assert every `git diff --cached --name-only` path starts with one of those
|
|
9
|
+
roots. This check, not the session's own write-boundary instruction, is what actually enforces
|
|
10
|
+
the write boundary. A staged path outside it is refused rather than committed.
|
|
11
|
+
- **The correlation id.** `TASK-NNN` is threaded through the branch name (from the engine), the
|
|
12
|
+
commit message, and — via `correlation.py`, bound at the top of this door — every log record
|
|
13
|
+
this actor emits for the request, alongside the trace id its caller propagated.
|
|
14
|
+
- **Publishing.** For each component the commit actually touched, this actor builds an image in
|
|
15
|
+
the cluster's shared buildkit and pushes it to the registry, named and versioned by convention
|
|
16
|
+
(`papeete_version.compute`) — no explicit tag is ever handed to a caller. No Docker daemon is
|
|
17
|
+
involved anywhere: `buildctl` reaches `BUILDKIT_HOST`, which is why an actor running this can
|
|
18
|
+
be an ordinary Pod.
|
|
19
|
+
- **Never opens a PR.** This door accepts, pushes, and publishes. Rendering a verdict and opening
|
|
20
|
+
a pull request belongs to whichever actor orchestrates the pipeline, once its other members
|
|
21
|
+
have also confirmed.
|
|
22
|
+
|
|
23
|
+
THE BOUNDARY IS READ, NOT RESTATED. `WRITES_ONLY_UNDER` used to be a module constant here, kept
|
|
24
|
+
in sync by hand with the sidecar's own declaration of the same fact. It is now
|
|
25
|
+
`config.writes_only_under`, derived from `components[].path`. There is one source, and this file
|
|
26
|
+
is not it.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from papeete_version.version import compute as compute_version
|
|
36
|
+
from papeete_version.version import normalize_name
|
|
37
|
+
|
|
38
|
+
from . import correlation
|
|
39
|
+
from .config import CapabilityConfig
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HandlerError(RuntimeError):
|
|
43
|
+
"""Raised for anything that stops this door short — including a containment violation.
|
|
44
|
+
|
|
45
|
+
`Actor.receive()` turns an uncaught exception from a handler into a `Refusal`
|
|
46
|
+
(`{self.name}'s own handler for '{offer.id}' failed: {e}`), which the HTTP binding replies as
|
|
47
|
+
400 — the same path an undeclared door or a schema violation already takes. A containment
|
|
48
|
+
breach is therefore refused, never silently swallowed or half-committed.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_implement_task(config: CapabilityConfig):
|
|
53
|
+
"""Bind one capability's config to the `implement-task` handler.
|
|
54
|
+
|
|
55
|
+
A factory rather than a module-level function reading `actor.engines["claude-code"]`: the
|
|
56
|
+
engine's registered name comes from the sidecar, so looking the config up through a hardcoded
|
|
57
|
+
engine key would reintroduce exactly the literal this package exists to remove.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def implement_task(actor, payload: dict, from_: str, judged: dict | None = None) -> dict:
|
|
61
|
+
# Bound again here, not only in the engine's own `judge()`: a refusal path reaches this
|
|
62
|
+
# door with `judged=None`, having never entered the engine at all, and that refusal is
|
|
63
|
+
# exactly the record you want carrying a task id.
|
|
64
|
+
correlation.bind(correlation_id=correlation.correlation_id(),
|
|
65
|
+
task_id=payload.get("task_id"), door="implement-task", caller=from_)
|
|
66
|
+
|
|
67
|
+
if judged is None or not judged.get("implemented"):
|
|
68
|
+
reason = (judged or {}).get("reason", "not eligible")
|
|
69
|
+
correlation.event("implement-task-refused", because=reason)
|
|
70
|
+
return {"accepted": False, "because": reason}
|
|
71
|
+
|
|
72
|
+
task_id = payload["task_id"]
|
|
73
|
+
clone_dir = Path(judged["clone_dir"])
|
|
74
|
+
branch = judged["branch"]
|
|
75
|
+
summary = judged.get("summary", "")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
with correlation.stage("containment-commit",
|
|
79
|
+
writes_only_under=list(config.writes_only_under)):
|
|
80
|
+
components = _containment_commit(config, clone_dir, task_id, summary)
|
|
81
|
+
correlation.event("components-touched", components=components)
|
|
82
|
+
with correlation.stage("push-branch", branch=branch, repo=config.source_repo):
|
|
83
|
+
_push(config, clone_dir, branch, _github_token(actor, config))
|
|
84
|
+
images = []
|
|
85
|
+
for component in components:
|
|
86
|
+
with correlation.stage("publish-image", component=component):
|
|
87
|
+
images.append(_publish_image(config, clone_dir, component, task_id))
|
|
88
|
+
correlation.event("image-published", component=component, image=images[-1])
|
|
89
|
+
return {"accepted": True, "branch": branch, "images": images}
|
|
90
|
+
finally:
|
|
91
|
+
shutil.rmtree(clone_dir, ignore_errors=True)
|
|
92
|
+
|
|
93
|
+
return implement_task
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _github_token(actor, config: CapabilityConfig) -> str:
|
|
97
|
+
engine = actor.engines.get(config.engine)
|
|
98
|
+
token = getattr(engine, "github_token", None) or os.environ.get("GITHUB_TOKEN")
|
|
99
|
+
if not token:
|
|
100
|
+
raise HandlerError("no GITHUB_TOKEN available (neither on the engine nor the environment)")
|
|
101
|
+
return token
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _redact(text: str, secret: str) -> str:
|
|
105
|
+
return text.replace(secret, "***")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _run(clone_dir: Path, args: list[str], *, redact: str | None = None) -> str:
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(args, cwd=clone_dir, check=True, capture_output=True, text=True)
|
|
111
|
+
except subprocess.CalledProcessError as e:
|
|
112
|
+
stderr = _redact(e.stderr, redact) if redact else e.stderr
|
|
113
|
+
raise HandlerError(f"{' '.join(args[:2])} failed: {stderr}") from e
|
|
114
|
+
return result.stdout
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _containment_commit(config: CapabilityConfig, clone_dir: Path,
|
|
118
|
+
task_id: str, summary: str) -> list[str]:
|
|
119
|
+
boundary = config.writes_only_under
|
|
120
|
+
for path in boundary:
|
|
121
|
+
# `git add` errors hard (pathspec did not match any files) on a prefix that doesn't exist
|
|
122
|
+
# on disk AT ALL, not just one with zero changes — verified live against a sibling actor
|
|
123
|
+
# that hit exactly this for a tests/ root before any task had touched it. The guard is
|
|
124
|
+
# cheap and keeps two containment checks in different repos genuinely identical.
|
|
125
|
+
if (clone_dir / path).exists():
|
|
126
|
+
_run(clone_dir, ["git", "add", path])
|
|
127
|
+
staged = [line for line in _run(clone_dir, ["git", "diff", "--cached", "--name-only"])
|
|
128
|
+
.splitlines() if line]
|
|
129
|
+
|
|
130
|
+
offending = [p for p in staged if not any(p.startswith(prefix) for prefix in boundary)]
|
|
131
|
+
if offending:
|
|
132
|
+
_run(clone_dir, ["git", "reset"])
|
|
133
|
+
raise HandlerError(
|
|
134
|
+
f"{task_id}: refusing to commit — staged path(s) outside "
|
|
135
|
+
f"{', '.join(boundary)}: {offending}"
|
|
136
|
+
)
|
|
137
|
+
if not staged:
|
|
138
|
+
raise HandlerError(
|
|
139
|
+
f"{task_id}: nothing staged under {', '.join(boundary)} — the session made no change "
|
|
140
|
+
f"there"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
message = f"feat({task_id}): implemented by {config.actor_name}\n\nTask: {task_id}"
|
|
144
|
+
if summary:
|
|
145
|
+
message += f"\n\n{summary}"
|
|
146
|
+
_run(clone_dir, [
|
|
147
|
+
"git", "-c", f"user.name={config.git_author_name}",
|
|
148
|
+
"-c", f"user.email={config.git_author_email}",
|
|
149
|
+
"commit", "-m", message,
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
# Which component a staged path belongs to is resolved by LONGEST declared prefix, not by
|
|
153
|
+
# taking the path's first segment. The first-segment shortcut is correct only while every
|
|
154
|
+
# component root is one segment deep, and reports the wrong component — silently — the day
|
|
155
|
+
# one of them is `src/gateway/`.
|
|
156
|
+
return config.components_for(staged)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _push(config: CapabilityConfig, clone_dir: Path, branch: str, token: str) -> None:
|
|
160
|
+
push_url = f"https://x-access-token:{token}@github.com/{config.source_repo}.git"
|
|
161
|
+
_run(clone_dir, ["git", "push", "--force", push_url, f"HEAD:refs/heads/{branch}"],
|
|
162
|
+
redact=token)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _image_registry() -> str:
|
|
166
|
+
registry = os.environ.get("IMAGE_REGISTRY")
|
|
167
|
+
if not registry:
|
|
168
|
+
raise HandlerError(
|
|
169
|
+
"no IMAGE_REGISTRY set — this actor publishes to a registry, and refuses to build an "
|
|
170
|
+
"image nothing else could ever pull"
|
|
171
|
+
)
|
|
172
|
+
return registry.rstrip("/")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _publish_image(config: CapabilityConfig, clone_dir: Path,
|
|
176
|
+
component: str, task_id: str) -> str:
|
|
177
|
+
"""Build this component's own image in the cluster's shared buildkit and push it — versioned
|
|
178
|
+
by convention, never an explicitly-passed tag.
|
|
179
|
+
|
|
180
|
+
No Docker daemon and no docker socket: `buildctl` talks to the buildkitd Service named by
|
|
181
|
+
`BUILDKIT_HOST`, which holds the push credential itself. The build runs where buildkitd runs;
|
|
182
|
+
only the ref comes back.
|
|
183
|
+
|
|
184
|
+
Pushed rather than left local because the pod that runs this image is not this pod — it is a
|
|
185
|
+
container in an ephemeral namespace the orchestrating actor stands up later, which can only
|
|
186
|
+
reach the image through a registry.
|
|
187
|
+
|
|
188
|
+
THE REF IS A CONTRACT WITH ACTORS THIS PACKAGE NEVER SEES. Its peers recompute the identical
|
|
189
|
+
string and parse it back apart, so it is `config.image_ref` output or nothing. Never invent a
|
|
190
|
+
tag scheme here.
|
|
191
|
+
"""
|
|
192
|
+
declared = config.component_for(f"{component}/")
|
|
193
|
+
if declared is None: # unreachable via `components_for`, cheap to hold
|
|
194
|
+
raise HandlerError(f"{component}: not a declared component of {config.capability}")
|
|
195
|
+
|
|
196
|
+
dockerfile_dir = clone_dir / declared.dockerfile
|
|
197
|
+
if not dockerfile_dir.is_dir():
|
|
198
|
+
# Late failure here is expensive: the commit has already landed and the branch is already
|
|
199
|
+
# pushed. Saying which declared path is missing beats `buildctl`'s own error, which names
|
|
200
|
+
# a temp path the operator has no way to map back to the sidecar.
|
|
201
|
+
raise HandlerError(
|
|
202
|
+
f"{component}: declared dockerfile directory '{declared.dockerfile}' does not exist "
|
|
203
|
+
f"in the clone — the sidecar and the repo disagree"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
version = compute_version(
|
|
207
|
+
folder=clone_dir / declared.path.rstrip("/"),
|
|
208
|
+
name=config.image_name(component),
|
|
209
|
+
label="feature",
|
|
210
|
+
feature_name=normalize_name(task_id),
|
|
211
|
+
)
|
|
212
|
+
image = config.image_ref(_image_registry(), component, version)
|
|
213
|
+
_run(clone_dir, [
|
|
214
|
+
"buildctl", "build",
|
|
215
|
+
"--frontend", "dockerfile.v0",
|
|
216
|
+
"--local", f"context={declared.path.rstrip('/')}",
|
|
217
|
+
"--local", f"dockerfile={declared.dockerfile}",
|
|
218
|
+
"--output", f"type=image,name={image},push=true",
|
|
219
|
+
])
|
|
220
|
+
return image
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# agentic-context.schema.yaml — the foundry-implementation-actor/agentic-context contract.
|
|
2
|
+
#
|
|
3
|
+
# WHAT THIS FILE IS FOR. One capability's own declaration of what a non-deterministic door should
|
|
4
|
+
# ground itself in, where it may write, and what it publishes. The machinery in this package
|
|
5
|
+
# carries no capability literal of its own — every identifier it needs is DERIVED from `capability`
|
|
6
|
+
# and `source_repo` below (see `config.py`), so a second capability instantiates the same actor by
|
|
7
|
+
# writing this file and nothing else.
|
|
8
|
+
#
|
|
9
|
+
# THE CONTRACT IS COMMITTED SOURCE, NOT A FETCHED GATE. It lives inside the package, the same path
|
|
10
|
+
# in a source checkout and in an installed wheel, so `foundry-implementation-actor lint` needs no
|
|
11
|
+
# network and no credential. Same discipline as papeete-actor's own manifest schema.
|
|
12
|
+
#
|
|
13
|
+
# ─── WHY A SIDECAR AND NOT THE CARD ───────────────────────────────────────────────────────────
|
|
14
|
+
# `papeete_actor_synchronous_messaging.card` opens exactly four hardcoded filenames and never
|
|
15
|
+
# globs, so `Actor.from_card()` will never see this file. That is deliberate: this is the actor's
|
|
16
|
+
# own operational configuration, not part of any published card contract, and it is loaded by this
|
|
17
|
+
# package's `config.py` directly.
|
|
18
|
+
#
|
|
19
|
+
# ─── WHY `writes_only_under` IS NOT DECLARED HERE ─────────────────────────────────────────────
|
|
20
|
+
# It used to be, beside `components:`, and the two drifted — the sidecar declared one list while
|
|
21
|
+
# the file that actually ENFORCES containment carried its own hardcoded copy. There is now exactly
|
|
22
|
+
# one source: `components[].path`. A boundary that can be stated twice will eventually be stated
|
|
23
|
+
# differently.
|
|
24
|
+
|
|
25
|
+
contract: foundry-implementation-actor/agentic-context/v1
|
|
26
|
+
|
|
27
|
+
kind: agentic-context
|
|
28
|
+
|
|
29
|
+
location: actor-agentic-context.yaml # at the actor's folder root, beside actor.yaml
|
|
30
|
+
|
|
31
|
+
required:
|
|
32
|
+
- context
|
|
33
|
+
- engine
|
|
34
|
+
- capability
|
|
35
|
+
- source_repo
|
|
36
|
+
- registry_repo
|
|
37
|
+
- components
|
|
38
|
+
- ground_in
|
|
39
|
+
|
|
40
|
+
fields:
|
|
41
|
+
|
|
42
|
+
context:
|
|
43
|
+
type: string
|
|
44
|
+
const: foundry-implementation-actor/agentic-context/v1
|
|
45
|
+
doc: >-
|
|
46
|
+
The contract this file claims. A file declaring some other value is read, warned, and not
|
|
47
|
+
checked further — UNMIGRATED is not the same as non-conformant, and migration is each
|
|
48
|
+
pair's own act.
|
|
49
|
+
|
|
50
|
+
engine:
|
|
51
|
+
type: string
|
|
52
|
+
doc: >-
|
|
53
|
+
The engine name this actor's non-deterministic door names on its card. Registered under
|
|
54
|
+
exactly this key in `Actor.from_card(engines={...})`.
|
|
55
|
+
|
|
56
|
+
capability:
|
|
57
|
+
type: string
|
|
58
|
+
doc: >-
|
|
59
|
+
The capability id, in its canonical dotted form with the `CAP` segment present — e.g.
|
|
60
|
+
`<ENT>.<DOMAIN>.CAP.<TYPE>.<NNN>.<CODE>`. Every other rendering of the id is derived from
|
|
61
|
+
this one; none is declared. See `config.py` for the full table.
|
|
62
|
+
|
|
63
|
+
source_repo:
|
|
64
|
+
type: string
|
|
65
|
+
doc: >-
|
|
66
|
+
`<owner>/<repo>` of the repository this actor clones, writes to, and pushes a branch to.
|
|
67
|
+
The actor's own name and its git commit identity are derived from the repo half.
|
|
68
|
+
|
|
69
|
+
registry_repo:
|
|
70
|
+
type: string
|
|
71
|
+
doc: >-
|
|
72
|
+
`<owner>/<repo>` of the knowledge registry the `ground_in` fetches resolve through. There is
|
|
73
|
+
NO default: a fetch that silently reaches a fallback registry is worse than one that fails.
|
|
74
|
+
|
|
75
|
+
components:
|
|
76
|
+
type: array
|
|
77
|
+
doc: >-
|
|
78
|
+
The units this actor may write to and publish. Each entry declares its own boundary, its own
|
|
79
|
+
test suite, and its own Dockerfile context — the three things that were hardcoded, in three
|
|
80
|
+
different files, before this contract existed.
|
|
81
|
+
items:
|
|
82
|
+
required: [name, path, tests, dockerfile]
|
|
83
|
+
fields:
|
|
84
|
+
name:
|
|
85
|
+
type: string
|
|
86
|
+
doc: The component's own name. Appears in the published image ref.
|
|
87
|
+
path:
|
|
88
|
+
type: string
|
|
89
|
+
doc: >-
|
|
90
|
+
Its root inside the repo, trailing slash included. The union of these IS the write
|
|
91
|
+
boundary — nothing else declares it. May be more than one segment deep; the
|
|
92
|
+
component a staged path belongs to is resolved by longest prefix, not by taking
|
|
93
|
+
the path's first segment.
|
|
94
|
+
tests:
|
|
95
|
+
type: string
|
|
96
|
+
doc: >-
|
|
97
|
+
The component's own test suite, named in the session's prompt so the instruction to
|
|
98
|
+
iterate it green is not a hardcoded literal in an f-string.
|
|
99
|
+
dockerfile:
|
|
100
|
+
type: string
|
|
101
|
+
doc: >-
|
|
102
|
+
The directory holding this component's Dockerfile, as `buildctl --local dockerfile=`
|
|
103
|
+
takes it. Must exist in the clone, or publishing this component fails late, after a
|
|
104
|
+
commit and a push have already landed.
|
|
105
|
+
|
|
106
|
+
ground_in:
|
|
107
|
+
type: array
|
|
108
|
+
doc: >-
|
|
109
|
+
What the session is grounded in, before its first turn. Each entry is a command to run and a
|
|
110
|
+
place to put its output inside the clone — this package hardcodes no knowledge tool. A
|
|
111
|
+
fourth knowledge source is an entry here, not a change to this package.
|
|
112
|
+
items:
|
|
113
|
+
required: [name, answers, fetch, into, load]
|
|
114
|
+
fields:
|
|
115
|
+
name:
|
|
116
|
+
type: string
|
|
117
|
+
doc: Short identifier for the source. Titles its section in the rendered Markdown.
|
|
118
|
+
answers:
|
|
119
|
+
type: string
|
|
120
|
+
doc: >-
|
|
121
|
+
One line saying what question this source answers. It is not decoration: for an
|
|
122
|
+
on-demand entry it is the ONLY thing the session sees, and what it decides to read
|
|
123
|
+
the file on.
|
|
124
|
+
fetch:
|
|
125
|
+
type: array
|
|
126
|
+
doc: >-
|
|
127
|
+
argv to run, as a list. `{capability}`, `{registry_repo}` and `{source_repo}` are
|
|
128
|
+
substituted from the fields above. Non-zero exit, a missing binary, or a timeout all
|
|
129
|
+
fail the request before any session time is spent.
|
|
130
|
+
into:
|
|
131
|
+
type: string
|
|
132
|
+
doc: >-
|
|
133
|
+
Repo-relative path to write the envelope to, INSIDE the clone. Inside, because a
|
|
134
|
+
CLAUDE.md `@`-import resolves relative to the file that contains it — an envelope in
|
|
135
|
+
a sibling tempdir cannot be imported, only described.
|
|
136
|
+
load:
|
|
137
|
+
type: string
|
|
138
|
+
enum: [eager, on-demand]
|
|
139
|
+
doc: >-
|
|
140
|
+
`eager` is `@`-imported by the generated CLAUDE.md and enters the window before turn
|
|
141
|
+
one, unconditionally, every session. `on-demand` is listed by its `answers` line and
|
|
142
|
+
its path, and costs nothing until the session opens it. Choose from a measurement of
|
|
143
|
+
the envelope, not from taste.
|