agentforge-framework 0.2.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.
- agentforge_framework/.claude-plugin/plugin.json +4 -0
- agentforge_framework/__init__.py +3 -0
- agentforge_framework/agents/__init__.py +92 -0
- agentforge_framework/agents/architect.py +146 -0
- agentforge_framework/agents/implementer.py +162 -0
- agentforge_framework/agents/orchestrator.py +588 -0
- agentforge_framework/agents/reviewer.py +335 -0
- agentforge_framework/agents/security.py +138 -0
- agentforge_framework/agents/tester.py +125 -0
- agentforge_framework/cli.py +461 -0
- agentforge_framework/context/__init__.py +1 -0
- agentforge_framework/context/extractors/__init__.py +76 -0
- agentforge_framework/context/extractors/base.py +47 -0
- agentforge_framework/context/extractors/python.py +65 -0
- agentforge_framework/context/extractors/sql.py +121 -0
- agentforge_framework/context/extractors/yaml.py +59 -0
- agentforge_framework/context/prompt.py +104 -0
- agentforge_framework/context/resolver.py +185 -0
- agentforge_framework/core/__init__.py +1 -0
- agentforge_framework/core/commands.py +170 -0
- agentforge_framework/core/config.py +90 -0
- agentforge_framework/core/contracts.py +875 -0
- agentforge_framework/core/gates.py +333 -0
- agentforge_framework/core/issues.py +697 -0
- agentforge_framework/core/plan_format.py +272 -0
- agentforge_framework/core/process.py +141 -0
- agentforge_framework/core/project.py +262 -0
- agentforge_framework/core/registry.py +455 -0
- agentforge_framework/core/repo.py +185 -0
- agentforge_framework/core/router.py +1 -0
- agentforge_framework/core/runtime.py +639 -0
- agentforge_framework/core/skills.py +255 -0
- agentforge_framework/core/workflow.py +215 -0
- agentforge_framework/plugins/__init__.py +35 -0
- agentforge_framework/plugins/databricks/__init__.py +86 -0
- agentforge_framework/plugins/pyspark/__init__.py +57 -0
- agentforge_framework/plugins/python/__init__.py +45 -0
- agentforge_framework/plugins/sql/__init__.py +377 -0
- agentforge_framework/providers/__init__.py +48 -0
- agentforge_framework/providers/base.py +248 -0
- agentforge_framework/providers/claude.py +159 -0
- agentforge_framework/providers/codex.py +139 -0
- agentforge_framework/skills/MANIFEST.yaml +157 -0
- agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
- agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
- agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
- agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
- agentforge_framework/skills/grilling/SKILL.md +28 -0
- agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
- agentforge_framework/skills/to-spec/SKILL.md +75 -0
- agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
- agentforge_framework/skills/to-tickets/SKILL.md +105 -0
- agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
- agentforge_framework/skills/unslop/SKILL.md +131 -0
- agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
- agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
- agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
- agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
- agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
- agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
- agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
- agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
- agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
- agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
- agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
- agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
- agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
- agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
- agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
- agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
- agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
- agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
- agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
- agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
- agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
- agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
- agentforge_framework/skills/write-plainly/SKILL.md +94 -0
- agentforge_framework/workflows/bugfix.yaml +8 -0
- agentforge_framework/workflows/feature.yaml +16 -0
- agentforge_framework/workflows/review.yaml +10 -0
- agentforge_framework-0.2.0.dist-info/METADATA +321 -0
- agentforge_framework-0.2.0.dist-info/RECORD +89 -0
- agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
- agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
- agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""The Reviewer: one comment a human reads instead of four they reconstruct.
|
|
2
|
+
|
|
3
|
+
It compares what changed against the frozen Plan, says whether the Run did what
|
|
4
|
+
it said it would, and writes the prose that a human reads at Sign-off.
|
|
5
|
+
|
|
6
|
+
That prose is scanned before it is posted. The `unslop` scanners are
|
|
7
|
+
deterministic and no model is involved in judging them, so a finding is a fact
|
|
8
|
+
about the text rather than an opinion about it; the Reviewer is handed its own
|
|
9
|
+
findings and rewrites, twice at most.
|
|
10
|
+
|
|
11
|
+
The first draft is written with `write-plainly` in front of it, because a
|
|
12
|
+
rewrite only reaches a phrase. `silhouette_scan` flags the shape of the document
|
|
13
|
+
— an outline previewed and then fulfilled, paragraphs opened on rotating
|
|
14
|
+
discourse cues, an ending that loops back to the opening's vocabulary — and no
|
|
15
|
+
substitution fixes any of those. Prose that scans dirty for a structural reason
|
|
16
|
+
can burn all three attempts and post dirty anyway, so the cheapest place to
|
|
17
|
+
spend the doctrine is before the first draft rather than after it.
|
|
18
|
+
|
|
19
|
+
The scan is a Command and not a Gate. Prose that still scans dirty on the third
|
|
20
|
+
attempt is posted anyway with its report attached, because holding a finished
|
|
21
|
+
Run on a cosmetic check trades a real cost for a stylistic one. The report goes
|
|
22
|
+
to the Run Log either way — thin prose that nobody can diagnose is how a check
|
|
23
|
+
like this quietly stops meaning anything.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import tempfile
|
|
29
|
+
from dataclasses import replace
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from ..context.prompt import render_context_block
|
|
33
|
+
from ..core.contracts import (
|
|
34
|
+
AgentResult,
|
|
35
|
+
ContextPack,
|
|
36
|
+
ModelTier,
|
|
37
|
+
Outcome,
|
|
38
|
+
Plan,
|
|
39
|
+
Role,
|
|
40
|
+
Usage,
|
|
41
|
+
)
|
|
42
|
+
from ..core.plan_format import RESULT_CLOSE, RESULT_OPEN
|
|
43
|
+
from ..core.skills import UnslopReport, render_report, run_unslop
|
|
44
|
+
from .implementer import render_steps
|
|
45
|
+
|
|
46
|
+
#: Rewrites after the first attempt. Three attempts in total, then the prose is
|
|
47
|
+
#: posted as it stands: a fourth try costs another invocation to improve a
|
|
48
|
+
#: sentence nobody has asked to be perfect.
|
|
49
|
+
MAX_REWRITES = 2
|
|
50
|
+
|
|
51
|
+
#: Delivered before the first draft and nowhere else. It is AgentForge's own,
|
|
52
|
+
#: derived from what the three scanners enforce rather than from the vendored
|
|
53
|
+
#: `unslop` doctrine — that doctrine lives in `references/`, which is not
|
|
54
|
+
#: vendored (see `skills/MANIFEST.yaml`), so shipping SKILL.md as a Fragment
|
|
55
|
+
#: would hand the Reviewer a routing table pointing at files nobody has.
|
|
56
|
+
WRITING_SKILLS = ("write-plainly",)
|
|
57
|
+
|
|
58
|
+
#: The tier a rewrite runs at, which is not the tier the review runs at. Two
|
|
59
|
+
#: different jobs share this Step: judging a diff against a frozen Plan, and
|
|
60
|
+
#: applying findings that already name the phrase, the line, and a replacement.
|
|
61
|
+
#: The Role's declared tier is chosen for the first. Paying it for the second
|
|
62
|
+
#: buys a stronger model to run find-and-replace, twice. See ADR-0004.
|
|
63
|
+
REWRITE_TIER = ModelTier.CHEAP
|
|
64
|
+
|
|
65
|
+
INSTRUCTIONS = """\
|
|
66
|
+
You are the Reviewer in AgentForge. You are the last Role to speak before a \
|
|
67
|
+
human reads this Run, and what you write is what they read.
|
|
68
|
+
|
|
69
|
+
Compare what the Run changed against the frozen Plan and say plainly whether it \
|
|
70
|
+
did what it said it would: which steps were carried out, which were not, and \
|
|
71
|
+
anything done that the Plan did not ask for.
|
|
72
|
+
|
|
73
|
+
Then write the documentation a human needs at Sign-off -- what changed, why, and \
|
|
74
|
+
what to look at first. Write nothing to the repository; your work is this \
|
|
75
|
+
report.
|
|
76
|
+
|
|
77
|
+
Write like a colleague explaining the change to another colleague. Say what \
|
|
78
|
+
happened. Do not pad, do not hedge every claim, and do not summarize the \
|
|
79
|
+
summary.\
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
PROMPT = """\
|
|
83
|
+
{instructions}
|
|
84
|
+
|
|
85
|
+
## The frozen Plan
|
|
86
|
+
|
|
87
|
+
{summary}
|
|
88
|
+
|
|
89
|
+
### Steps
|
|
90
|
+
|
|
91
|
+
{steps}
|
|
92
|
+
{context}
|
|
93
|
+
## Working directory
|
|
94
|
+
|
|
95
|
+
{cwd}
|
|
96
|
+
|
|
97
|
+
You are on the branch the change was made on. Read the files the Plan names and \
|
|
98
|
+
compare them against what it asked for. Change nothing and commit nothing.
|
|
99
|
+
|
|
100
|
+
## Required output
|
|
101
|
+
|
|
102
|
+
End your reply with this block and nothing after it:
|
|
103
|
+
|
|
104
|
+
{result_open}
|
|
105
|
+
```json
|
|
106
|
+
{{
|
|
107
|
+
"outcome": "completed",
|
|
108
|
+
"summary": "one line: does the change match the Plan",
|
|
109
|
+
"detail": "the review a human reads at Sign-off",
|
|
110
|
+
"files_changed": []
|
|
111
|
+
}}
|
|
112
|
+
```
|
|
113
|
+
{result_close}
|
|
114
|
+
|
|
115
|
+
Use `"outcome": "escalated"` only if the Plan cannot be reviewed against the \
|
|
116
|
+
repository at all -- it names files that are not there, or nothing was changed.\
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
REWRITE = """\
|
|
120
|
+
{instructions}
|
|
121
|
+
|
|
122
|
+
## Your previous review
|
|
123
|
+
|
|
124
|
+
{prose}
|
|
125
|
+
|
|
126
|
+
## What a scanner found in it
|
|
127
|
+
|
|
128
|
+
{findings}
|
|
129
|
+
|
|
130
|
+
These are findings about the writing, not about the change you reviewed. Each \
|
|
131
|
+
one names the line and what to do about it. Rewrite the review to say the same \
|
|
132
|
+
things without them.
|
|
133
|
+
|
|
134
|
+
Keep every claim you made about the change. Losing a fact to fix a phrase is a \
|
|
135
|
+
worse review, and the review is the point.
|
|
136
|
+
|
|
137
|
+
## Required output
|
|
138
|
+
|
|
139
|
+
End your reply with this block and nothing after it:
|
|
140
|
+
|
|
141
|
+
{result_open}
|
|
142
|
+
```json
|
|
143
|
+
{{
|
|
144
|
+
"outcome": "completed",
|
|
145
|
+
"summary": "one line: does the change match the Plan",
|
|
146
|
+
"detail": "the rewritten review",
|
|
147
|
+
"files_changed": []
|
|
148
|
+
}}
|
|
149
|
+
```
|
|
150
|
+
{result_close}
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
#: The Reviewer runs `deep`. It speaks last, and what it writes is the whole of
|
|
154
|
+
#: what a human reads at Sign-off: whether the Run did what it said it would, and
|
|
155
|
+
#: what to look at first. A thin review is one nobody can act on, and nothing
|
|
156
|
+
#: downstream catches it — the next thing after this Role is a person deciding
|
|
157
|
+
#: whether to merge. Its rewrites run at `REWRITE_TIER` instead.
|
|
158
|
+
REVIEWER = Role(
|
|
159
|
+
name="reviewer",
|
|
160
|
+
tier=ModelTier.DEEP,
|
|
161
|
+
instructions=INSTRUCTIONS,
|
|
162
|
+
skills=WRITING_SKILLS,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_prompt(
|
|
167
|
+
plan: Plan,
|
|
168
|
+
context: ContextPack,
|
|
169
|
+
cwd: Path,
|
|
170
|
+
role: Role = REVIEWER,
|
|
171
|
+
) -> str:
|
|
172
|
+
return PROMPT.format(
|
|
173
|
+
instructions=role.instructions,
|
|
174
|
+
summary=plan.summary.strip(),
|
|
175
|
+
steps=render_steps(plan),
|
|
176
|
+
context=render_context_block(context),
|
|
177
|
+
cwd=cwd,
|
|
178
|
+
result_open=RESULT_OPEN,
|
|
179
|
+
result_close=RESULT_CLOSE,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def build_rewrite_prompt(prose: str, report: UnslopReport, role: Role = REVIEWER) -> str:
|
|
184
|
+
"""The second and third attempts: the Reviewer's own prose and its findings.
|
|
185
|
+
|
|
186
|
+
The scanners' own output and nothing else. A skill's doctrine inlined here
|
|
187
|
+
would be AgentForge teaching a Role to write, when each finding already
|
|
188
|
+
carries the phrase, the line, and a suggestion.
|
|
189
|
+
"""
|
|
190
|
+
findings = "\n".join(render_report(report)) or "_the scanners reported nothing_"
|
|
191
|
+
return REWRITE.format(
|
|
192
|
+
instructions=role.instructions,
|
|
193
|
+
prose=prose.strip(),
|
|
194
|
+
findings=findings,
|
|
195
|
+
result_open=RESULT_OPEN,
|
|
196
|
+
result_close=RESULT_CLOSE,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def prose_of(result: AgentResult) -> str:
|
|
201
|
+
"""What the Reviewer wrote, as one piece of text to be scanned.
|
|
202
|
+
|
|
203
|
+
The summary is part of it. It is the line most people read and the least
|
|
204
|
+
likely to have been thought about twice.
|
|
205
|
+
"""
|
|
206
|
+
return "\n\n".join(part for part in (result.summary.strip(), result.detail.strip()) if part)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class Reviewer:
|
|
210
|
+
"""One Reviewer invocation, plus up to two rewrites of its own prose."""
|
|
211
|
+
|
|
212
|
+
def __init__(self, provider) -> None:
|
|
213
|
+
self.provider = provider
|
|
214
|
+
|
|
215
|
+
def run(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
plan: Plan,
|
|
219
|
+
context: ContextPack,
|
|
220
|
+
cwd: Path,
|
|
221
|
+
role: Role = REVIEWER,
|
|
222
|
+
tier: ModelTier | None = None,
|
|
223
|
+
) -> AgentResult:
|
|
224
|
+
tier = tier or role.tier
|
|
225
|
+
result = self.provider.invoke(
|
|
226
|
+
role=role,
|
|
227
|
+
prompt=build_prompt(plan, context, cwd, role),
|
|
228
|
+
context=context,
|
|
229
|
+
tier=tier,
|
|
230
|
+
cwd=cwd,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Every invocation this Role makes, not only the one it hands back. A
|
|
234
|
+
# rewrite is cheap and there can be two of them, and a Run Log that
|
|
235
|
+
# reported the last one's price would understate the Reviewer forever.
|
|
236
|
+
spent = [result.usage]
|
|
237
|
+
|
|
238
|
+
# A rewrite is a different job from the review and is priced as one, and
|
|
239
|
+
# it is briefed as one too. The skill is dropped along with the tier:
|
|
240
|
+
# every finding a rewrite acts on already names the phrase, the line, and
|
|
241
|
+
# a replacement, so re-delivering the doctrine would spend context
|
|
242
|
+
# teaching a Role to write while asking it to run find-and-replace.
|
|
243
|
+
rewriter = replace(role, tier=REWRITE_TIER, skills=())
|
|
244
|
+
|
|
245
|
+
attempt = 1
|
|
246
|
+
report = self._scan(result)
|
|
247
|
+
while report is not None and not report.clean and attempt <= MAX_REWRITES:
|
|
248
|
+
rewritten = self.provider.invoke(
|
|
249
|
+
role=rewriter,
|
|
250
|
+
prompt=build_rewrite_prompt(prose_of(result), report, rewriter),
|
|
251
|
+
context=context,
|
|
252
|
+
tier=REWRITE_TIER,
|
|
253
|
+
cwd=cwd,
|
|
254
|
+
)
|
|
255
|
+
attempt += 1
|
|
256
|
+
spent.append(rewritten.usage)
|
|
257
|
+
if rewritten.outcome is not Outcome.COMPLETED:
|
|
258
|
+
# A rewrite that could not be produced is the Run's business,
|
|
259
|
+
# not the scanner's. Hand it back as it came, priced at what the
|
|
260
|
+
# whole Role spent getting there.
|
|
261
|
+
return replace(rewritten, usage=Usage.combine(spent))
|
|
262
|
+
result, report = rewritten, self._scan(rewritten)
|
|
263
|
+
|
|
264
|
+
# The tier the Run Log reports is the one the review was written at. A
|
|
265
|
+
# result carries whatever tier produced it, so without this a `deep`
|
|
266
|
+
# review reads as `cheap` for the only reason that a phrase was fixed.
|
|
267
|
+
usage = Usage.combine(spent)
|
|
268
|
+
if report is None:
|
|
269
|
+
return replace(result, tier=tier, usage=usage)
|
|
270
|
+
return replace(
|
|
271
|
+
result,
|
|
272
|
+
tier=tier,
|
|
273
|
+
usage=usage,
|
|
274
|
+
detail=_with_report(result.detail, report, attempt),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def _scan(self, result: AgentResult) -> UnslopReport | None:
|
|
278
|
+
"""Scan what the Reviewer wrote, or nothing if it did not review.
|
|
279
|
+
|
|
280
|
+
The prose is written outside the working tree on purpose. ADR-0015 keeps
|
|
281
|
+
an undeclared file out of the commit, but it does not make it disappear:
|
|
282
|
+
a scratch file left in the repository is reported to the human at
|
|
283
|
+
Sign-off as something an Agent wrote and nobody asked for.
|
|
284
|
+
"""
|
|
285
|
+
if result.outcome is not Outcome.COMPLETED:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
prose = prose_of(result)
|
|
289
|
+
if not prose:
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
runner = getattr(self.provider, "runner", None)
|
|
293
|
+
with tempfile.TemporaryDirectory(prefix="agentforge-review-") as directory:
|
|
294
|
+
path = Path(directory) / "review.md"
|
|
295
|
+
path.write_text(prose, encoding="utf-8")
|
|
296
|
+
return run_unslop(path, runner=runner)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _with_report(detail: str, report: UnslopReport, attempt: int) -> str:
|
|
300
|
+
"""The review, then what the scanners made of it. See ADR-0002: the Run Log
|
|
301
|
+
is the only place a later reader looks."""
|
|
302
|
+
# Where the money went, for a reader wondering why a `deep` Role's entry
|
|
303
|
+
# mentions three attempts: only the first was written at that tier.
|
|
304
|
+
rewritten = "" if attempt == 1 else f", rewritten at `{REWRITE_TIER}`"
|
|
305
|
+
tries = f"attempt {attempt} of {MAX_REWRITES + 1}{rewritten}"
|
|
306
|
+
if report.clean:
|
|
307
|
+
headline = f"**`unslop` scan** — clean on {tries}."
|
|
308
|
+
lines = [headline]
|
|
309
|
+
if report.failed:
|
|
310
|
+
lines += ["", *render_report(report)]
|
|
311
|
+
else:
|
|
312
|
+
lines = [
|
|
313
|
+
(
|
|
314
|
+
f"**`unslop` scan** — {report.violations} finding(s) still standing after "
|
|
315
|
+
f"{tries}. Posted anyway: the scan is a Command and not a Gate, and a "
|
|
316
|
+
"finished Run does not wait on a sentence."
|
|
317
|
+
),
|
|
318
|
+
"",
|
|
319
|
+
*render_report(report),
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
return "\n\n".join(part for part in (detail.strip(), "---", "\n".join(lines)) if part)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
__all__ = [
|
|
326
|
+
"INSTRUCTIONS",
|
|
327
|
+
"MAX_REWRITES",
|
|
328
|
+
"REVIEWER",
|
|
329
|
+
"REWRITE_TIER",
|
|
330
|
+
"WRITING_SKILLS",
|
|
331
|
+
"Reviewer",
|
|
332
|
+
"build_prompt",
|
|
333
|
+
"build_rewrite_prompt",
|
|
334
|
+
"prose_of",
|
|
335
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""The Security Role: audits the change, and fixes nothing.
|
|
2
|
+
|
|
3
|
+
It runs at `deep` per ADR-0004, which is the one tier decision in the project
|
|
4
|
+
that is not about cost. A missed finding is silent — nobody reviews the audit
|
|
5
|
+
that did not happen — so the Role that looks for what nobody asked about is the
|
|
6
|
+
Role that cannot be run cheaply.
|
|
7
|
+
|
|
8
|
+
Its output is a list of Findings rather than prose, because the Gate downstream
|
|
9
|
+
of it has to tell "audited, nothing found" from "did not audit", and a paragraph
|
|
10
|
+
cannot be asked that question. What blocks the Run is the presence of a Finding
|
|
11
|
+
and never the wording of one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..context.prompt import render_context_block
|
|
19
|
+
from ..core.contracts import AgentResult, ContextPack, ModelTier, Plan, Role
|
|
20
|
+
from ..core.plan_format import RESULT_CLOSE, RESULT_OPEN
|
|
21
|
+
from .implementer import render_steps
|
|
22
|
+
|
|
23
|
+
INSTRUCTIONS = """\
|
|
24
|
+
You are the Security Role in AgentForge. Another Role wrote the code you are \
|
|
25
|
+
reading and is not available to answer questions.
|
|
26
|
+
|
|
27
|
+
Audit the change described by the frozen Plan against production standards: \
|
|
28
|
+
injected input reaching a query or a shell, credentials and tokens in source or \
|
|
29
|
+
in logs, data crossing a boundary it should not, permissions widened, and \
|
|
30
|
+
anything a regulated shop would refuse at merge time.
|
|
31
|
+
|
|
32
|
+
Change nothing. You are not the Role that fixes what you find -- a human decides \
|
|
33
|
+
what happens next, and an audit that edits the code it is auditing cannot be \
|
|
34
|
+
trusted about either.
|
|
35
|
+
|
|
36
|
+
Report every finding with the file and line to look at, what could go wrong \
|
|
37
|
+
there, and why that matters in this repository. "Potential injection risk" as \
|
|
38
|
+
the whole message is not a finding; it is a category, and it sends a human \
|
|
39
|
+
looking for something you have already found.\
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
PROMPT = """\
|
|
43
|
+
{instructions}
|
|
44
|
+
|
|
45
|
+
## The frozen Plan
|
|
46
|
+
|
|
47
|
+
{summary}
|
|
48
|
+
|
|
49
|
+
### Steps
|
|
50
|
+
|
|
51
|
+
{steps}
|
|
52
|
+
{context}
|
|
53
|
+
## Working directory
|
|
54
|
+
|
|
55
|
+
{cwd}
|
|
56
|
+
|
|
57
|
+
You are on the branch the change was made on. Read the files the Plan names and \
|
|
58
|
+
the code they reach into. Commit nothing and edit nothing.
|
|
59
|
+
|
|
60
|
+
## Required output
|
|
61
|
+
|
|
62
|
+
End your reply with this block and nothing after it:
|
|
63
|
+
|
|
64
|
+
{result_open}
|
|
65
|
+
```json
|
|
66
|
+
{{
|
|
67
|
+
"outcome": "completed",
|
|
68
|
+
"summary": "one line: how many findings, and the most serious of them",
|
|
69
|
+
"detail": "anything a human needs that did not belong in a finding",
|
|
70
|
+
"findings": [
|
|
71
|
+
{{
|
|
72
|
+
"location": "src/loader.py:42",
|
|
73
|
+
"risk": "what could go wrong here",
|
|
74
|
+
"rationale": "why that matters in this repository"
|
|
75
|
+
}}
|
|
76
|
+
]
|
|
77
|
+
}}
|
|
78
|
+
```
|
|
79
|
+
{result_close}
|
|
80
|
+
|
|
81
|
+
A clean audit is `"outcome": "completed"` with `"findings": []`. Report that \
|
|
82
|
+
when you looked and found nothing, and never when you could not look -- a Gate \
|
|
83
|
+
downstream reads the difference, and an empty list means the change was audited.
|
|
84
|
+
|
|
85
|
+
Use `"outcome": "escalated"` only when the Plan does not match the repository, \
|
|
86
|
+
or when you could not audit the change at all. Say which in `summary`.\
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
#: Security runs `deep`: a finding nobody makes is a finding nobody reviews.
|
|
90
|
+
SECURITY = Role(name="security", tier=ModelTier.DEEP, instructions=INSTRUCTIONS)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_prompt(
|
|
94
|
+
plan: Plan,
|
|
95
|
+
context: ContextPack,
|
|
96
|
+
cwd: Path,
|
|
97
|
+
role: Role = SECURITY,
|
|
98
|
+
) -> str:
|
|
99
|
+
return PROMPT.format(
|
|
100
|
+
instructions=role.instructions,
|
|
101
|
+
summary=plan.summary.strip(),
|
|
102
|
+
steps=render_steps(plan),
|
|
103
|
+
context=render_context_block(context),
|
|
104
|
+
cwd=cwd,
|
|
105
|
+
result_open=RESULT_OPEN,
|
|
106
|
+
result_close=RESULT_CLOSE,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Security:
|
|
111
|
+
"""One Security invocation through the shared Provider port.
|
|
112
|
+
|
|
113
|
+
No denial path, unlike the Tester. Auditing is reading, and ADR-0007 leaves
|
|
114
|
+
reading open — a Run with commands shut still gets its audit.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, provider) -> None:
|
|
118
|
+
self.provider = provider
|
|
119
|
+
|
|
120
|
+
def run(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
plan: Plan,
|
|
124
|
+
context: ContextPack,
|
|
125
|
+
cwd: Path,
|
|
126
|
+
role: Role = SECURITY,
|
|
127
|
+
tier: ModelTier | None = None,
|
|
128
|
+
) -> AgentResult:
|
|
129
|
+
return self.provider.invoke(
|
|
130
|
+
role=role,
|
|
131
|
+
prompt=build_prompt(plan, context, cwd, role),
|
|
132
|
+
context=context,
|
|
133
|
+
tier=tier or role.tier,
|
|
134
|
+
cwd=cwd,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
__all__ = ["INSTRUCTIONS", "SECURITY", "Security", "build_prompt"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The Tester: turns the frozen Plan's acceptance claims into executed tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..context.prompt import render_context_block
|
|
8
|
+
from ..core.contracts import AgentResult, ContextPack, ModelTier, Outcome, Plan, Role
|
|
9
|
+
from ..core.plan_format import RESULT_CLOSE, RESULT_OPEN
|
|
10
|
+
from .implementer import render_steps
|
|
11
|
+
|
|
12
|
+
INSTRUCTIONS = """\
|
|
13
|
+
You are the Tester in AgentForge. Work from the frozen Plan, not from the human's \
|
|
14
|
+
original Task and not from a new interpretation of what the change should be.
|
|
15
|
+
|
|
16
|
+
Write test cases for the Plan's acceptance criteria, exercise relevant edge cases, \
|
|
17
|
+
and run the repository's test suite. Do not repair implementation flaws. Report \
|
|
18
|
+
them as findings and escalate so a human can decide what changes next. Never \
|
|
19
|
+
report completed unless the suite actually ran and passed.\
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
PROMPT = """\
|
|
23
|
+
{instructions}
|
|
24
|
+
|
|
25
|
+
## The frozen Plan
|
|
26
|
+
|
|
27
|
+
{summary}
|
|
28
|
+
|
|
29
|
+
### Steps
|
|
30
|
+
|
|
31
|
+
{steps}
|
|
32
|
+
{context}
|
|
33
|
+
## Working directory
|
|
34
|
+
|
|
35
|
+
{cwd}
|
|
36
|
+
|
|
37
|
+
You are on the same branch the Implementer used. Commit nothing; AgentForge \
|
|
38
|
+
commits for you, and it commits a file it does not yet track only if the plan \
|
|
39
|
+
above names it or your `files_changed` does. List every test file you create \
|
|
40
|
+
there, or it will not reach the pull request. What your commands write and you \
|
|
41
|
+
do not list -- bytecode, coverage data, a cache directory -- stays out of the \
|
|
42
|
+
commit, which is deliberate: do not clean up after them.
|
|
43
|
+
|
|
44
|
+
## Required output
|
|
45
|
+
|
|
46
|
+
End your reply with this block and nothing after it:
|
|
47
|
+
|
|
48
|
+
{result_open}
|
|
49
|
+
```json
|
|
50
|
+
{{
|
|
51
|
+
"outcome": "completed",
|
|
52
|
+
"summary": "one line stating which suite ran and its result",
|
|
53
|
+
"detail": "findings, or that no flaws were found",
|
|
54
|
+
"files_changed": ["tests/path.py"]
|
|
55
|
+
}}
|
|
56
|
+
```
|
|
57
|
+
{result_close}
|
|
58
|
+
|
|
59
|
+
Use `"outcome": "escalated"` when the suite exposes an implementation flaw or \
|
|
60
|
+
cannot be run. Name each finding and the acceptance criterion it prevents you \
|
|
61
|
+
from verifying. Reading tests is not a substitute for running them.\
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
#: The Tester runs `cheap`: the suite is the authority on pass or fail, and this
|
|
65
|
+
#: Role reports what it saw rather than deciding it. The trade is deliberate and
|
|
66
|
+
#: recorded in ADR-0004 — reasoning about an edge case nobody wrote a test for is
|
|
67
|
+
#: the part that gets worse here, and it is the part a human reads the findings
|
|
68
|
+
#: for anyway.
|
|
69
|
+
TESTER = Role(name="tester", tier=ModelTier.CHEAP, instructions=INSTRUCTIONS)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_prompt(
|
|
73
|
+
plan: Plan,
|
|
74
|
+
context: ContextPack,
|
|
75
|
+
cwd: Path,
|
|
76
|
+
role: Role = TESTER,
|
|
77
|
+
) -> str:
|
|
78
|
+
return PROMPT.format(
|
|
79
|
+
instructions=role.instructions,
|
|
80
|
+
summary=plan.summary.strip(),
|
|
81
|
+
steps=render_steps(plan),
|
|
82
|
+
context=render_context_block(context),
|
|
83
|
+
cwd=cwd,
|
|
84
|
+
result_open=RESULT_OPEN,
|
|
85
|
+
result_close=RESULT_CLOSE,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Tester:
|
|
90
|
+
"""One Tester invocation through the shared Provider port."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, provider) -> None:
|
|
93
|
+
self.provider = provider
|
|
94
|
+
|
|
95
|
+
def run(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
plan: Plan,
|
|
99
|
+
context: ContextPack,
|
|
100
|
+
cwd: Path,
|
|
101
|
+
role: Role = TESTER,
|
|
102
|
+
tier: ModelTier | None = None,
|
|
103
|
+
) -> AgentResult:
|
|
104
|
+
tier = tier or role.tier
|
|
105
|
+
if not getattr(self.provider, "allow_commands", False):
|
|
106
|
+
return AgentResult(
|
|
107
|
+
role=role.name,
|
|
108
|
+
tier=tier,
|
|
109
|
+
outcome=Outcome.ESCALATED,
|
|
110
|
+
summary="command execution is denied, so the Tester cannot run the suite",
|
|
111
|
+
detail=(
|
|
112
|
+
"The test-suite acceptance criteria were not verified. Re-run "
|
|
113
|
+
"`agentforge implement` with `--allow-commands`."
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
return self.provider.invoke(
|
|
117
|
+
role=role,
|
|
118
|
+
prompt=build_prompt(plan, context, cwd, role),
|
|
119
|
+
context=context,
|
|
120
|
+
tier=tier,
|
|
121
|
+
cwd=cwd,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["INSTRUCTIONS", "TESTER", "Tester", "build_prompt"]
|