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.
Files changed (89) hide show
  1. agentforge_framework/.claude-plugin/plugin.json +4 -0
  2. agentforge_framework/__init__.py +3 -0
  3. agentforge_framework/agents/__init__.py +92 -0
  4. agentforge_framework/agents/architect.py +146 -0
  5. agentforge_framework/agents/implementer.py +162 -0
  6. agentforge_framework/agents/orchestrator.py +588 -0
  7. agentforge_framework/agents/reviewer.py +335 -0
  8. agentforge_framework/agents/security.py +138 -0
  9. agentforge_framework/agents/tester.py +125 -0
  10. agentforge_framework/cli.py +461 -0
  11. agentforge_framework/context/__init__.py +1 -0
  12. agentforge_framework/context/extractors/__init__.py +76 -0
  13. agentforge_framework/context/extractors/base.py +47 -0
  14. agentforge_framework/context/extractors/python.py +65 -0
  15. agentforge_framework/context/extractors/sql.py +121 -0
  16. agentforge_framework/context/extractors/yaml.py +59 -0
  17. agentforge_framework/context/prompt.py +104 -0
  18. agentforge_framework/context/resolver.py +185 -0
  19. agentforge_framework/core/__init__.py +1 -0
  20. agentforge_framework/core/commands.py +170 -0
  21. agentforge_framework/core/config.py +90 -0
  22. agentforge_framework/core/contracts.py +875 -0
  23. agentforge_framework/core/gates.py +333 -0
  24. agentforge_framework/core/issues.py +697 -0
  25. agentforge_framework/core/plan_format.py +272 -0
  26. agentforge_framework/core/process.py +141 -0
  27. agentforge_framework/core/project.py +262 -0
  28. agentforge_framework/core/registry.py +455 -0
  29. agentforge_framework/core/repo.py +185 -0
  30. agentforge_framework/core/router.py +1 -0
  31. agentforge_framework/core/runtime.py +639 -0
  32. agentforge_framework/core/skills.py +255 -0
  33. agentforge_framework/core/workflow.py +215 -0
  34. agentforge_framework/plugins/__init__.py +35 -0
  35. agentforge_framework/plugins/databricks/__init__.py +86 -0
  36. agentforge_framework/plugins/pyspark/__init__.py +57 -0
  37. agentforge_framework/plugins/python/__init__.py +45 -0
  38. agentforge_framework/plugins/sql/__init__.py +377 -0
  39. agentforge_framework/providers/__init__.py +48 -0
  40. agentforge_framework/providers/base.py +248 -0
  41. agentforge_framework/providers/claude.py +159 -0
  42. agentforge_framework/providers/codex.py +139 -0
  43. agentforge_framework/skills/MANIFEST.yaml +157 -0
  44. agentforge_framework/skills/NOTICE +49 -0
  45. agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
  46. agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  47. agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
  48. agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
  49. agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
  50. agentforge_framework/skills/grilling/SKILL.md +28 -0
  51. agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
  52. agentforge_framework/skills/to-spec/SKILL.md +75 -0
  53. agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
  54. agentforge_framework/skills/to-tickets/SKILL.md +105 -0
  55. agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
  56. agentforge_framework/skills/unslop/SKILL.md +131 -0
  57. agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
  58. agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
  59. agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
  60. agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
  61. agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
  62. agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
  63. agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
  64. agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
  65. agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
  66. agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
  67. agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
  68. agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
  69. agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
  70. agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
  71. agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
  72. agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
  73. agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
  74. agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
  75. agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
  76. agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
  77. agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
  78. agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
  79. agentforge_framework/skills/write-plainly/SKILL.md +94 -0
  80. agentforge_framework/workflows/bugfix.yaml +8 -0
  81. agentforge_framework/workflows/feature.yaml +16 -0
  82. agentforge_framework/workflows/review.yaml +10 -0
  83. agentforge_framework-0.2.0.dist-info/METADATA +321 -0
  84. agentforge_framework-0.2.0.dist-info/RECORD +89 -0
  85. agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
  86. agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
  87. agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
  88. agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
  89. 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"]