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,875 @@
|
|
|
1
|
+
"""The shared vocabulary of AgentForge, as data.
|
|
2
|
+
|
|
3
|
+
Every other module imports from here and nothing here imports from them. The
|
|
4
|
+
dataclasses carry no behavior beyond serialization, because their serialized
|
|
5
|
+
shape is a compatibility surface: ADR-0003 makes the Plan an interface that
|
|
6
|
+
every Role parses out of an Issue body someone may have filed a week ago.
|
|
7
|
+
|
|
8
|
+
Terms are defined in `CONTEXT.md`. This file is where they acquire a shape.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
14
|
+
from dataclasses import dataclass, field, replace
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
# Only for the annotations on `Extractor.read` and `Validator.check`.
|
|
20
|
+
# Imported under the guard so that the rule this module's own docstring
|
|
21
|
+
# states stays true at runtime: everything imports from here and nothing
|
|
22
|
+
# here imports from them.
|
|
23
|
+
from ..context.extractors.base import Extraction
|
|
24
|
+
from .gates import GateContext
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
#: Bumped when a field is removed or its meaning changes. Added fields with
|
|
29
|
+
#: defaults do not require a bump, because an older Issue still parses.
|
|
30
|
+
PLAN_FORMAT_VERSION = 1
|
|
31
|
+
|
|
32
|
+
#: The Workflow an Issue runs when its plan block names none. Every Issue filed
|
|
33
|
+
#: before Workflows existed reads as `feature`, which is what those Runs did.
|
|
34
|
+
DEFAULT_WORKFLOW = "feature"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ModelTier(StrEnum):
|
|
38
|
+
"""The class of model a Role runs on, named by intent. See ADR-0004.
|
|
39
|
+
|
|
40
|
+
Nothing outside a Provider adapter maps these onto a model identifier.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
DEEP = "deep"
|
|
44
|
+
STANDARD = "standard"
|
|
45
|
+
CHEAP = "cheap"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Outcome(StrEnum):
|
|
49
|
+
"""How an Agent finished.
|
|
50
|
+
|
|
51
|
+
``ESCALATED`` is a result, not an exception: ADR-0003 requires a Role that
|
|
52
|
+
finds the plan wrong to stop rather than improvise, and the runtime needs to
|
|
53
|
+
tell that apart from a Role that simply crashed.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
COMPLETED = "completed"
|
|
57
|
+
ESCALATED = "escalated"
|
|
58
|
+
FAILED = "failed"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GateVerdict(StrEnum):
|
|
62
|
+
"""What a Gate says when it is asked.
|
|
63
|
+
|
|
64
|
+
Deliberately not an ``Outcome``. An Outcome is a Role's verdict on its own
|
|
65
|
+
work; a Gate is not an Agent and judges somebody else's. ``ERRORED`` is the
|
|
66
|
+
Gate that could not decide — which is not the same as deciding no, because a
|
|
67
|
+
Gate with nothing to clear cannot be cleared by waiting.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
CLEARED = "cleared"
|
|
71
|
+
BLOCKED = "blocked"
|
|
72
|
+
ERRORED = "errored"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class GateEntry:
|
|
77
|
+
"""One Gate's verdict, as the Run Log carries it. See ADR-0008.
|
|
78
|
+
|
|
79
|
+
`step` is the 1-based position of the Step this Gate follows. Unlike a
|
|
80
|
+
result's position — which `current_step` derives — a Gate's is the only thing
|
|
81
|
+
that says which Gate spoke, so it is recorded rather than re-derived.
|
|
82
|
+
|
|
83
|
+
`invalidates` names the Role whose output this verdict was drawn from, and is
|
|
84
|
+
empty when the Gate judged nobody's: a Security Gate reads the Security
|
|
85
|
+
Agent's findings, while a human Gate reads a human. A blocked verdict naming
|
|
86
|
+
a Role un-retires that Role's Step, which is what stops the next Run from
|
|
87
|
+
re-reading a verdict the human has already acted on.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
kind: str
|
|
91
|
+
verdict: GateVerdict
|
|
92
|
+
step: int = 0
|
|
93
|
+
invalidates: str = ""
|
|
94
|
+
summary: str = ""
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def blocked(self) -> bool:
|
|
98
|
+
return self.verdict is GateVerdict.BLOCKED
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def errored(self) -> bool:
|
|
102
|
+
return self.verdict is GateVerdict.ERRORED
|
|
103
|
+
|
|
104
|
+
def to_dict(self) -> dict:
|
|
105
|
+
return {
|
|
106
|
+
"kind": self.kind,
|
|
107
|
+
"verdict": str(self.verdict),
|
|
108
|
+
"step": self.step,
|
|
109
|
+
"invalidates": self.invalidates,
|
|
110
|
+
"summary": self.summary,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_dict(cls, data: dict) -> GateEntry:
|
|
115
|
+
return cls(
|
|
116
|
+
kind=str(data["kind"]),
|
|
117
|
+
verdict=GateVerdict(data["verdict"]),
|
|
118
|
+
step=int(data.get("step") or 0),
|
|
119
|
+
invalidates=str(data.get("invalidates") or ""),
|
|
120
|
+
summary=str(data.get("summary") or ""),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class RunStatus(StrEnum):
|
|
125
|
+
"""Where a Run stands. Carried on the Issue as a label, per ADR-0002.
|
|
126
|
+
|
|
127
|
+
Three of these are ways for a Run to stop, and CONTEXT.md keeps them apart
|
|
128
|
+
because the reader's next move differs in each. ``SUSPENDED`` is a Gate the
|
|
129
|
+
Run can still clear, and nobody need do anything. ``HALTED`` is what an
|
|
130
|
+
Escalation or an errored Gate produces: the completed steps stand and a
|
|
131
|
+
human decides. ``FAILED`` is a Run AgentForge could not finish at all.
|
|
132
|
+
|
|
133
|
+
``HALTED`` was ``ESCALATED`` until the glossary settled that an Escalation
|
|
134
|
+
is the verdict a Role reports and Halted the state it produces. The old
|
|
135
|
+
label is still read; see ``LEGACY_LABELS``.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
PLANNED = "planned"
|
|
139
|
+
RUNNING = "running"
|
|
140
|
+
SUSPENDED = "suspended"
|
|
141
|
+
HALTED = "halted"
|
|
142
|
+
AWAITING_SIGNOFF = "awaiting-signoff"
|
|
143
|
+
FAILED = "failed"
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def label(self) -> str:
|
|
147
|
+
return f"agentforge:{self.value}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
#: Labels an earlier AgentForge applied, and what they mean now. Read, never
|
|
151
|
+
#: written: issues labelled `agentforge:escalated` were open when the rename
|
|
152
|
+
#: landed, and a Run that cannot read its own state back is the one thing
|
|
153
|
+
#: ADR-0002 does not survive.
|
|
154
|
+
LEGACY_LABELS: dict[str, RunStatus] = {"agentforge:escalated": RunStatus.HALTED}
|
|
155
|
+
|
|
156
|
+
#: Every status label AgentForge may find on an Issue, so a caller can reconcile
|
|
157
|
+
#: them without knowing the naming scheme. Retired labels are in here so that a
|
|
158
|
+
#: Run clears them rather than leaving an Issue wearing two answers.
|
|
159
|
+
RUN_LABELS = tuple(status.label for status in RunStatus) + tuple(LEGACY_LABELS)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class Task:
|
|
164
|
+
"""A unit of software work stated by a human, in a human's words.
|
|
165
|
+
|
|
166
|
+
A Task reaches the Orchestrator and stops there. ADR-0003 keeps the original
|
|
167
|
+
phrasing away from downstream Roles, which execute the Plan instead.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
statement: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class PlanStep:
|
|
175
|
+
"""One unit of the Plan, written to be executed without re-interpretation."""
|
|
176
|
+
|
|
177
|
+
id: str
|
|
178
|
+
intent: str
|
|
179
|
+
files: tuple[str, ...] = ()
|
|
180
|
+
acceptance: str = ""
|
|
181
|
+
|
|
182
|
+
def to_dict(self) -> dict:
|
|
183
|
+
return {
|
|
184
|
+
"id": self.id,
|
|
185
|
+
"intent": self.intent,
|
|
186
|
+
"files": list(self.files),
|
|
187
|
+
"acceptance": self.acceptance,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def from_dict(cls, data: dict) -> PlanStep:
|
|
192
|
+
return cls(
|
|
193
|
+
id=str(data["id"]),
|
|
194
|
+
intent=str(data["intent"]),
|
|
195
|
+
files=tuple(data.get("files") or ()),
|
|
196
|
+
acceptance=str(data.get("acceptance") or ""),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class Plan:
|
|
202
|
+
"""What the Orchestrator decided, frozen once written. See ADR-0003.
|
|
203
|
+
|
|
204
|
+
The fields are deliberately dull. A Plan that needs interpreting is a Plan
|
|
205
|
+
that gets interpreted differently by each Role that reads it.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
summary: str
|
|
209
|
+
steps: tuple[PlanStep, ...] = ()
|
|
210
|
+
constraints: tuple[str, ...] = ()
|
|
211
|
+
|
|
212
|
+
def to_dict(self) -> dict:
|
|
213
|
+
return {
|
|
214
|
+
"summary": self.summary,
|
|
215
|
+
"steps": [step.to_dict() for step in self.steps],
|
|
216
|
+
"constraints": list(self.constraints),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def from_dict(cls, data: dict) -> Plan:
|
|
221
|
+
return cls(
|
|
222
|
+
summary=str(data["summary"]),
|
|
223
|
+
steps=tuple(PlanStep.from_dict(step) for step in data.get("steps") or ()),
|
|
224
|
+
constraints=tuple(data.get("constraints") or ()),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True)
|
|
229
|
+
class Role:
|
|
230
|
+
"""A named specialization with a fixed job, model tier, prompt, and skills.
|
|
231
|
+
|
|
232
|
+
A Role is a definition, not a running thing. `instructions` is the standing
|
|
233
|
+
job description; the task-specific prompt is assembled per invocation.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
name: str
|
|
237
|
+
tier: ModelTier
|
|
238
|
+
instructions: str = ""
|
|
239
|
+
skills: tuple[str, ...] = ()
|
|
240
|
+
|
|
241
|
+
def at_tier(self, tier: ModelTier) -> Role:
|
|
242
|
+
"""The same Role with its tier overridden, per user request or config."""
|
|
243
|
+
return replace(self, tier=tier)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class Roster:
|
|
248
|
+
"""The ordered list of Roles an Issue requires.
|
|
249
|
+
|
|
250
|
+
Serialization carries names and tiers only. Instructions are code, not
|
|
251
|
+
contract, so they do not travel in an Issue body where they would be noise
|
|
252
|
+
to the human reading it and stale to the Agent parsing it.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
roles: tuple[Role, ...] = ()
|
|
256
|
+
|
|
257
|
+
def __iter__(self):
|
|
258
|
+
return iter(self.roles)
|
|
259
|
+
|
|
260
|
+
def __len__(self) -> int:
|
|
261
|
+
return len(self.roles)
|
|
262
|
+
|
|
263
|
+
def names(self) -> tuple[str, ...]:
|
|
264
|
+
return tuple(role.name for role in self.roles)
|
|
265
|
+
|
|
266
|
+
def tiers(self) -> dict[str, ModelTier]:
|
|
267
|
+
"""The tier each named Role runs at, as the Roster table promises it.
|
|
268
|
+
|
|
269
|
+
Keyed by name rather than by position, matching how `align_to_workflow`
|
|
270
|
+
collapses a requested Roster onto a Workflow. A Workflow naming one Role
|
|
271
|
+
twice therefore runs both Steps at the one tier the table shows, which
|
|
272
|
+
is what the table says and the only thing a reader could conclude from
|
|
273
|
+
it. See ADR-0014.
|
|
274
|
+
"""
|
|
275
|
+
return {role.name: role.tier for role in self.roles}
|
|
276
|
+
|
|
277
|
+
def to_dict(self) -> list[dict]:
|
|
278
|
+
return [{"role": role.name, "tier": str(role.tier)} for role in self.roles]
|
|
279
|
+
|
|
280
|
+
@classmethod
|
|
281
|
+
def from_dict(cls, data: list[dict], resolve) -> Roster:
|
|
282
|
+
"""Rebuild a Roster, resolving each name through `resolve(name) -> Role`."""
|
|
283
|
+
roles = []
|
|
284
|
+
for entry in data or ():
|
|
285
|
+
role = resolve(str(entry["role"]))
|
|
286
|
+
tier = entry.get("tier")
|
|
287
|
+
roles.append(role.at_tier(ModelTier(tier)) if tier else role)
|
|
288
|
+
return cls(tuple(roles))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class Fragment:
|
|
293
|
+
"""Conventions a Plugin contributes to the prompts of the Roles it names.
|
|
294
|
+
|
|
295
|
+
`roles` empty means every Role. A Fragment is text and nothing else: it is
|
|
296
|
+
inlined into the Context Pack handed to a Step, so it reaches an Agent the
|
|
297
|
+
same way whatever Provider is driving. See ADR-0016.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
text: str
|
|
301
|
+
roles: tuple[str, ...] = ()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass(frozen=True)
|
|
305
|
+
class Extractor:
|
|
306
|
+
"""A per-language reader a Plugin contributes, and the suffixes it claims.
|
|
307
|
+
|
|
308
|
+
`read` has the signature every built-in extractor has — text in, `Extraction`
|
|
309
|
+
out — because a Plugin's reader is not a second kind of thing. It is a pure
|
|
310
|
+
function of one file's contents: it never opens a second file and never sees
|
|
311
|
+
a path, which is what keeps it testable against a recorded fixture and what
|
|
312
|
+
stops it from making the pack depend on the machine resolving it.
|
|
313
|
+
|
|
314
|
+
Claiming a suffix a built-in already reads is the point rather than a
|
|
315
|
+
conflict. A `.sql` file in a dbt project has dependencies a generic SQL read
|
|
316
|
+
cannot see, and the Plugin that knows about dbt is the one that should
|
|
317
|
+
answer for it. See ADR-0010 and ADR-0016.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
suffixes: tuple[str, ...]
|
|
321
|
+
read: Callable[[str], Extraction]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass(frozen=True)
|
|
325
|
+
class Validator:
|
|
326
|
+
"""A Gate kind a Plugin contributes, and the predicate that evaluates it.
|
|
327
|
+
|
|
328
|
+
`check` has the signature every shipped Gate has — a `GateContext` in, a
|
|
329
|
+
`GateEntry` out — because a Plugin's Gate is not a second kind of thing. It
|
|
330
|
+
is handed the Command Runner and the working tree like any other, so a
|
|
331
|
+
validator that shells out to a parser has what it needs, and it returns
|
|
332
|
+
cleared, blocked, or errored with the same meanings: blocked suspends a Run
|
|
333
|
+
that can still clear, errored halts one that cannot.
|
|
334
|
+
|
|
335
|
+
A validator that cannot evaluate returns an errored `GateEntry` rather than
|
|
336
|
+
raising. A Plugin degrades a Run and never ends it, which is the bargain
|
|
337
|
+
`core.registry` makes at activation and `context.extractors` makes when a
|
|
338
|
+
reader raises.
|
|
339
|
+
|
|
340
|
+
`kind` is the name a Workflow's YAML writes. It cannot be one of the shipped
|
|
341
|
+
kinds: `human`, `tests`, and `security` mean what the shipped Workflows say
|
|
342
|
+
they mean, and a Plugin that could redefine `human` could make a human Gate
|
|
343
|
+
stop stopping. See ADR-0018.
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
kind: str
|
|
347
|
+
check: Callable[[GateContext], GateEntry]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True)
|
|
351
|
+
class FileTemplate:
|
|
352
|
+
"""One file a Command writes: where it goes, and what is in it.
|
|
353
|
+
|
|
354
|
+
Both are `string.Template` sources, so `$name` and `${name}` are the
|
|
355
|
+
placeholders and a literal dollar is `$$`. Not `str.format`, because a dbt
|
|
356
|
+
model is Jinja and a template full of `{{ ref(...) }}` would have to double
|
|
357
|
+
every brace it already has — a trap that fires the first time somebody adds
|
|
358
|
+
a macro to a template that looked fine.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
path: str
|
|
362
|
+
text: str
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@dataclass(frozen=True)
|
|
366
|
+
class Command:
|
|
367
|
+
"""A repeated chore, expressed so that running it needs no inference.
|
|
368
|
+
|
|
369
|
+
Two shapes, and a Command may be both. `templates` are files it writes;
|
|
370
|
+
`argv` is the argument vector it runs through the Command Runner. Scaffolding
|
|
371
|
+
a dbt model is a Command; deciding whether the model is correct is not, and
|
|
372
|
+
nothing here consults a model, reads the repository, or makes a choice.
|
|
373
|
+
|
|
374
|
+
`arguments` names its positional parameters in order, which is what
|
|
375
|
+
`agentforge run <command> orders` binds against and what a wrong number of
|
|
376
|
+
arguments is reported against. Every placeholder in `templates` and `argv`
|
|
377
|
+
is one of these names, substituted the same way in both.
|
|
378
|
+
|
|
379
|
+
Data, like every other contribution: a Command carries no callable, so what
|
|
380
|
+
it will do is readable without running it. See ADR-0019.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
name: str
|
|
384
|
+
summary: str = ""
|
|
385
|
+
arguments: tuple[str, ...] = ()
|
|
386
|
+
templates: tuple[FileTemplate, ...] = ()
|
|
387
|
+
argv: tuple[str, ...] = ()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@dataclass(frozen=True)
|
|
391
|
+
class Plugin:
|
|
392
|
+
"""A bundle of domain knowledge for one technology, as data.
|
|
393
|
+
|
|
394
|
+
No behaviour: a Plugin declares what it answers for and what it contributes,
|
|
395
|
+
and `core.registry` does the deciding. Every contribution field is optional,
|
|
396
|
+
so a Plugin carrying only Fragments is legal and is what the `python` Plugin
|
|
397
|
+
is, while one carrying no Fragment at all is equally legal and is what `sql`
|
|
398
|
+
is — it reads files and contributes a Gate kind, and says nothing to a Role.
|
|
399
|
+
|
|
400
|
+
`suffixes`, `root_markers`, and `imports` are the three ways a Plugin is
|
|
401
|
+
detected. A suffix answers for the blast radius the frozen Plan names; a
|
|
402
|
+
root marker answers for the repository itself — a `pyproject.toml` says
|
|
403
|
+
Python whatever one Plan happens to touch; an import answers for what a file
|
|
404
|
+
in that blast radius actually uses, because `.py` says nothing about whether
|
|
405
|
+
a module is a Spark job. All three are declarations rather than predicates,
|
|
406
|
+
so a Plugin stays data and `agentforge init` can write down what detection
|
|
407
|
+
already computed. See ADR-0017.
|
|
408
|
+
|
|
409
|
+
Detection and contribution are separate on purpose. `suffixes` says when
|
|
410
|
+
this Plugin is active; an `Extractor`'s own suffixes say what it reads once
|
|
411
|
+
it is. The `sql` Plugin activates on `.sql` and a `dbt_project.yml`, and
|
|
412
|
+
then reads the schema YAML beside the models — which it would be wrong to
|
|
413
|
+
activate for on its own.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
name: str
|
|
417
|
+
suffixes: tuple[str, ...] = ()
|
|
418
|
+
root_markers: tuple[str, ...] = ()
|
|
419
|
+
#: Top-level module names whose import activates this Plugin — `pyspark`
|
|
420
|
+
#: matches both `import pyspark` and `from pyspark.sql import functions`.
|
|
421
|
+
#: Read out of the Python files the blast radius names, which is the only
|
|
422
|
+
#: place an import means anything.
|
|
423
|
+
imports: tuple[str, ...] = ()
|
|
424
|
+
fragments: tuple[Fragment, ...] = ()
|
|
425
|
+
extractors: tuple[Extractor, ...] = ()
|
|
426
|
+
validators: tuple[Validator, ...] = ()
|
|
427
|
+
commands: tuple[Command, ...] = ()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@dataclass(frozen=True)
|
|
431
|
+
class ContextPack:
|
|
432
|
+
"""The bounded set of files, symbols, and conventions handed to an Agent.
|
|
433
|
+
|
|
434
|
+
Two things fill one in. The Orchestrator declares what it believes the work
|
|
435
|
+
touches, and that travels in the Issue body; `context.resolver` resolves
|
|
436
|
+
that declaration against the frozen Plan and the repository at the start of
|
|
437
|
+
a Run, which is the pack an Agent is actually handed. See ADR-0010.
|
|
438
|
+
|
|
439
|
+
A pack is a head start and never a boundary. A Role that needs a file the
|
|
440
|
+
pack does not name reads it, so a resolver mistake costs tokens rather than
|
|
441
|
+
correctness.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
files: tuple[str, ...] = ()
|
|
445
|
+
symbols: tuple[str, ...] = ()
|
|
446
|
+
conventions: tuple[str, ...] = ()
|
|
447
|
+
#: What those files reach for outside themselves — a module's imports, a
|
|
448
|
+
#: query's source tables. Written by the resolver rather than declared by
|
|
449
|
+
#: the Orchestrator: it is read out of the files, and a Role reads it to
|
|
450
|
+
#: find out what its change can break.
|
|
451
|
+
references: tuple[str, ...] = ()
|
|
452
|
+
#: What the active Plugins contribute to this Step's Role, folded in by the
|
|
453
|
+
#: runtime just before the Agent is invoked. Kept apart from `conventions`
|
|
454
|
+
#: because the two have different authors and a reader should be able to
|
|
455
|
+
#: tell them apart: `conventions` is the Orchestrator's judgement about this
|
|
456
|
+
#: Task, and this is what the repository's technology is held to regardless
|
|
457
|
+
#: of Task. Per Role, so it is absent from the Run-level pack the Run Log
|
|
458
|
+
#: records, and absent from `to_dict` because it never travels in an Issue
|
|
459
|
+
#: body. See ADR-0016.
|
|
460
|
+
fragments: tuple[str, ...] = ()
|
|
461
|
+
|
|
462
|
+
def __bool__(self) -> bool:
|
|
463
|
+
"""Whether the pack carries anything at all.
|
|
464
|
+
|
|
465
|
+
The runtime asks this to tell a resolved pack from the empty one a Run
|
|
466
|
+
started with, and every call site spelling out the four fields is the
|
|
467
|
+
same question asked four ways — one of which gets forgotten the next
|
|
468
|
+
time a field is added.
|
|
469
|
+
"""
|
|
470
|
+
return bool(
|
|
471
|
+
self.files
|
|
472
|
+
or self.symbols
|
|
473
|
+
or self.conventions
|
|
474
|
+
or self.references
|
|
475
|
+
or self.fragments
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def to_dict(self) -> dict:
|
|
479
|
+
"""The pack as it travels in an Issue body.
|
|
480
|
+
|
|
481
|
+
`fragments` is deliberately absent. It is resolved per Step from the
|
|
482
|
+
Plugins active for the repository the Run is in, so writing it into the
|
|
483
|
+
Issue would freeze one machine's answer into the stable surface
|
|
484
|
+
(ADR-0011) and hand the next Run conventions it may not be held to.
|
|
485
|
+
"""
|
|
486
|
+
return {
|
|
487
|
+
"files": list(self.files),
|
|
488
|
+
"symbols": list(self.symbols),
|
|
489
|
+
"conventions": list(self.conventions),
|
|
490
|
+
"references": list(self.references),
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
@classmethod
|
|
494
|
+
def from_dict(cls, data: dict | None) -> ContextPack:
|
|
495
|
+
data = data or {}
|
|
496
|
+
return cls(
|
|
497
|
+
files=tuple(data.get("files") or ()),
|
|
498
|
+
symbols=tuple(data.get("symbols") or ()),
|
|
499
|
+
conventions=tuple(data.get("conventions") or ()),
|
|
500
|
+
references=tuple(data.get("references") or ()),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@dataclass(frozen=True)
|
|
505
|
+
class Usage:
|
|
506
|
+
"""What one Provider invocation consumed, in whatever unit its CLI reports.
|
|
507
|
+
|
|
508
|
+
Every figure is optional and none of them defaults to zero, because the
|
|
509
|
+
Providers disagree about what they will tell you: `claude` reports dollars
|
|
510
|
+
and a token split, `codex` prints one token count and no price, and a CLI
|
|
511
|
+
may report nothing at all. A zero would make all three look like a free
|
|
512
|
+
invocation, so absent stays absent and a total can say how much of itself
|
|
513
|
+
is missing. See ADR-0009.
|
|
514
|
+
|
|
515
|
+
`provider` names the CLI the figures came from, so a Run Log line can say
|
|
516
|
+
why a dollar figure is missing rather than leaving a blank where one would
|
|
517
|
+
have been.
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
provider: str = ""
|
|
521
|
+
input_tokens: int | None = None
|
|
522
|
+
output_tokens: int | None = None
|
|
523
|
+
#: One figure for the whole invocation, for a CLI that reports no split.
|
|
524
|
+
total_tokens: int | None = None
|
|
525
|
+
cost_usd: float | None = None
|
|
526
|
+
|
|
527
|
+
@property
|
|
528
|
+
def tokens(self) -> int | None:
|
|
529
|
+
"""Every token this invocation used, however the CLI broke them down."""
|
|
530
|
+
if self.total_tokens is not None:
|
|
531
|
+
return self.total_tokens
|
|
532
|
+
if self.input_tokens is None and self.output_tokens is None:
|
|
533
|
+
return None
|
|
534
|
+
return (self.input_tokens or 0) + (self.output_tokens or 0)
|
|
535
|
+
|
|
536
|
+
@property
|
|
537
|
+
def reported(self) -> bool:
|
|
538
|
+
"""Whether the Provider said anything at all about what this cost."""
|
|
539
|
+
return self.cost_usd is not None or self.tokens is not None
|
|
540
|
+
|
|
541
|
+
@classmethod
|
|
542
|
+
def combine(cls, usages: Iterable[Usage | None]) -> Usage:
|
|
543
|
+
"""Add up what a Run spent, keeping absent figures absent.
|
|
544
|
+
|
|
545
|
+
The split is dropped: a Run whose Steps report a mix of split and
|
|
546
|
+
unsplit counts has no honest input/output total, and one token figure
|
|
547
|
+
that is true beats two that are assembled.
|
|
548
|
+
"""
|
|
549
|
+
cost: float | None = None
|
|
550
|
+
tokens: int | None = None
|
|
551
|
+
providers = set()
|
|
552
|
+
|
|
553
|
+
for usage in usages:
|
|
554
|
+
if usage is None:
|
|
555
|
+
continue
|
|
556
|
+
if usage.provider:
|
|
557
|
+
providers.add(usage.provider)
|
|
558
|
+
if usage.cost_usd is not None:
|
|
559
|
+
cost = (cost or 0.0) + usage.cost_usd
|
|
560
|
+
if usage.tokens is not None:
|
|
561
|
+
tokens = (tokens or 0) + usage.tokens
|
|
562
|
+
|
|
563
|
+
return cls(
|
|
564
|
+
provider=providers.pop() if len(providers) == 1 else "",
|
|
565
|
+
total_tokens=tokens,
|
|
566
|
+
cost_usd=cost,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def to_dict(self) -> dict:
|
|
570
|
+
"""Only what was reported. An absent key is the absent figure."""
|
|
571
|
+
payload: dict = {}
|
|
572
|
+
if self.provider:
|
|
573
|
+
payload["provider"] = self.provider
|
|
574
|
+
for name in ("input_tokens", "output_tokens", "total_tokens"):
|
|
575
|
+
value = getattr(self, name)
|
|
576
|
+
if value is not None:
|
|
577
|
+
payload[name] = int(value)
|
|
578
|
+
if self.cost_usd is not None:
|
|
579
|
+
payload["cost_usd"] = float(self.cost_usd)
|
|
580
|
+
return payload
|
|
581
|
+
|
|
582
|
+
@classmethod
|
|
583
|
+
def from_dict(cls, data: dict | None) -> Usage | None:
|
|
584
|
+
"""A usage record, or `None` where a Run Log entry carries none."""
|
|
585
|
+
if not data:
|
|
586
|
+
return None
|
|
587
|
+
return cls(
|
|
588
|
+
provider=str(data.get("provider") or ""),
|
|
589
|
+
input_tokens=_number(data.get("input_tokens"), int),
|
|
590
|
+
output_tokens=_number(data.get("output_tokens"), int),
|
|
591
|
+
total_tokens=_number(data.get("total_tokens"), int),
|
|
592
|
+
cost_usd=_number(data.get("cost_usd"), float),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _number(value: object, cast):
|
|
597
|
+
"""A figure a Run Log carried, or `None` if it carried nothing usable.
|
|
598
|
+
|
|
599
|
+
A human edits Issue bodies, and a cost line that crashed a resumed Run
|
|
600
|
+
would make the measurement more expensive than the thing it measures.
|
|
601
|
+
"""
|
|
602
|
+
if value is None or isinstance(value, bool):
|
|
603
|
+
return None
|
|
604
|
+
try:
|
|
605
|
+
return cast(value)
|
|
606
|
+
except (TypeError, ValueError):
|
|
607
|
+
return None
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@dataclass(frozen=True)
|
|
611
|
+
class Finding:
|
|
612
|
+
"""One thing an Agent found and did not fix.
|
|
613
|
+
|
|
614
|
+
Three fields rather than a sentence, because "potential injection risk" as
|
|
615
|
+
the whole message is what this shape exists to prevent: a human needs to
|
|
616
|
+
know where to look, what could go wrong there, and why that matters in this
|
|
617
|
+
repository rather than in general.
|
|
618
|
+
|
|
619
|
+
A finding is not an Escalation. The plan was executable and was executed;
|
|
620
|
+
this is something noticed on the way, and what a Gate does about it is the
|
|
621
|
+
Gate's business.
|
|
622
|
+
"""
|
|
623
|
+
|
|
624
|
+
location: str
|
|
625
|
+
risk: str
|
|
626
|
+
rationale: str = ""
|
|
627
|
+
|
|
628
|
+
def to_dict(self) -> dict:
|
|
629
|
+
return {"location": self.location, "risk": self.risk, "rationale": self.rationale}
|
|
630
|
+
|
|
631
|
+
@classmethod
|
|
632
|
+
def from_dict(cls, data: dict) -> Finding:
|
|
633
|
+
return cls(
|
|
634
|
+
location=str(data.get("location") or ""),
|
|
635
|
+
risk=str(data.get("risk") or ""),
|
|
636
|
+
rationale=str(data.get("rationale") or ""),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
@classmethod
|
|
640
|
+
def coerce(cls, value: object) -> Finding:
|
|
641
|
+
"""A finding as a Role reported it, however it reported it.
|
|
642
|
+
|
|
643
|
+
A model asked for three fields sometimes answers with a sentence.
|
|
644
|
+
Dropping those would clear a Gate that should have blocked, so a bare
|
|
645
|
+
string becomes a finding with no location rather than no finding at all.
|
|
646
|
+
"""
|
|
647
|
+
if isinstance(value, dict):
|
|
648
|
+
return cls.from_dict(value)
|
|
649
|
+
return cls(location="", risk=str(value).strip())
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@dataclass(frozen=True)
|
|
653
|
+
class AgentResult:
|
|
654
|
+
"""What one Agent invocation produced.
|
|
655
|
+
|
|
656
|
+
`summary` is the line a human reads in the Run Log. For an escalation it is
|
|
657
|
+
the reason the Plan could not be executed.
|
|
658
|
+
|
|
659
|
+
`findings` is what the Agent noticed and left for somebody else. Empty means
|
|
660
|
+
it looked and found nothing, which is why a Role that could not look at all
|
|
661
|
+
escalates instead: a Gate reading this cannot tell the two apart otherwise.
|
|
662
|
+
|
|
663
|
+
`usage` is what this invocation consumed, and it hangs here rather than on
|
|
664
|
+
the Run because that is the granularity a tiering decision is made at: a
|
|
665
|
+
Run's total says the Run was expensive, and only a per-Role figure says
|
|
666
|
+
which Role to move.
|
|
667
|
+
"""
|
|
668
|
+
|
|
669
|
+
role: str
|
|
670
|
+
tier: ModelTier
|
|
671
|
+
outcome: Outcome
|
|
672
|
+
summary: str
|
|
673
|
+
detail: str = ""
|
|
674
|
+
files_changed: tuple[str, ...] = ()
|
|
675
|
+
findings: tuple[Finding, ...] = ()
|
|
676
|
+
usage: Usage | None = None
|
|
677
|
+
|
|
678
|
+
#: The adapter's full text output. Transport only — it carries the
|
|
679
|
+
#: Orchestrator's plan block out of a Provider invocation and gives a
|
|
680
|
+
#: failure something to show. Deliberately absent from `to_dict`, because
|
|
681
|
+
#: the Run Log is read by humans and re-parsed by later Runs, and a full
|
|
682
|
+
#: transcript in every comment would ruin both.
|
|
683
|
+
raw: str = field(default="", compare=False, repr=False)
|
|
684
|
+
|
|
685
|
+
@property
|
|
686
|
+
def escalated(self) -> bool:
|
|
687
|
+
return self.outcome is Outcome.ESCALATED
|
|
688
|
+
|
|
689
|
+
@property
|
|
690
|
+
def ok(self) -> bool:
|
|
691
|
+
return self.outcome is Outcome.COMPLETED
|
|
692
|
+
|
|
693
|
+
def to_dict(self) -> dict:
|
|
694
|
+
payload = {
|
|
695
|
+
"role": self.role,
|
|
696
|
+
"tier": str(self.tier),
|
|
697
|
+
"outcome": str(self.outcome),
|
|
698
|
+
"summary": self.summary,
|
|
699
|
+
"detail": self.detail,
|
|
700
|
+
"files_changed": list(self.files_changed),
|
|
701
|
+
}
|
|
702
|
+
# Written only when there are any: every Implementer result in the Run
|
|
703
|
+
# Log would otherwise carry an empty list saying it found nothing, which
|
|
704
|
+
# is not something the Implementer was asked.
|
|
705
|
+
if self.findings:
|
|
706
|
+
payload["findings"] = [finding.to_dict() for finding in self.findings]
|
|
707
|
+
# Same rule, for the same reason: a Provider that reported nothing
|
|
708
|
+
# writes no key, so a later reader can tell silence from a free Run.
|
|
709
|
+
if self.usage is not None and self.usage.to_dict():
|
|
710
|
+
payload["usage"] = self.usage.to_dict()
|
|
711
|
+
return payload
|
|
712
|
+
|
|
713
|
+
@classmethod
|
|
714
|
+
def from_dict(cls, data: dict) -> AgentResult:
|
|
715
|
+
return cls(
|
|
716
|
+
role=str(data["role"]),
|
|
717
|
+
tier=ModelTier(data["tier"]),
|
|
718
|
+
outcome=Outcome(data["outcome"]),
|
|
719
|
+
summary=str(data.get("summary") or ""),
|
|
720
|
+
detail=str(data.get("detail") or ""),
|
|
721
|
+
files_changed=tuple(data.get("files_changed") or ()),
|
|
722
|
+
findings=tuple(Finding.coerce(item) for item in data.get("findings") or ()),
|
|
723
|
+
usage=Usage.from_dict(data.get("usage")),
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def retirement(
|
|
728
|
+
items: Sequence[T], done: Sequence[str], name_of: Callable[[T], str]
|
|
729
|
+
) -> tuple[bool, ...]:
|
|
730
|
+
"""Which items a completed result has already retired, in order.
|
|
731
|
+
|
|
732
|
+
One completed result retires one entry, so a sequence naming the same Role
|
|
733
|
+
twice resumes into the second occurrence rather than skipping both.
|
|
734
|
+
|
|
735
|
+
This is the rule; `outstanding` is the view of it the Roster and the Workflow
|
|
736
|
+
ask for. The runtime asks for the flags instead, because it walks every Step
|
|
737
|
+
— a Step behind the Run still has a Gate the Run has to pass through.
|
|
738
|
+
"""
|
|
739
|
+
unclaimed = list(done)
|
|
740
|
+
flags = []
|
|
741
|
+
for item in items:
|
|
742
|
+
name = name_of(item)
|
|
743
|
+
retired = name in unclaimed
|
|
744
|
+
if retired:
|
|
745
|
+
unclaimed.remove(name)
|
|
746
|
+
flags.append(retired)
|
|
747
|
+
return tuple(flags)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def outstanding(
|
|
751
|
+
items: Sequence[T], done: Sequence[str], name_of: Callable[[T], str]
|
|
752
|
+
) -> tuple[T, ...]:
|
|
753
|
+
"""Items not yet retired by a completed result, in order.
|
|
754
|
+
|
|
755
|
+
Shared by the Roster and the Workflow because both ask the same question of
|
|
756
|
+
the same Run Log.
|
|
757
|
+
"""
|
|
758
|
+
flags = retirement(items, done, name_of)
|
|
759
|
+
return tuple(item for item, retired in zip(items, flags, strict=True) if not retired)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _drop_last(names: list[str], name: str) -> None:
|
|
763
|
+
"""Remove the most recent occurrence, which is the one a Gate just judged."""
|
|
764
|
+
for index in range(len(names) - 1, -1, -1):
|
|
765
|
+
if names[index] == name:
|
|
766
|
+
del names[index]
|
|
767
|
+
return
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@dataclass(frozen=True)
|
|
771
|
+
class RunState:
|
|
772
|
+
"""One execution of one Roster against one Issue.
|
|
773
|
+
|
|
774
|
+
Every field is recoverable from the Issue alone — body for the Plan and the
|
|
775
|
+
Roster, comments for the results, labels for the status. That is ADR-0002's
|
|
776
|
+
claim, and `core.issues.run_state` is where it gets cashed.
|
|
777
|
+
"""
|
|
778
|
+
|
|
779
|
+
issue: int
|
|
780
|
+
plan: Plan
|
|
781
|
+
roster: Roster
|
|
782
|
+
context: ContextPack = ContextPack()
|
|
783
|
+
results: tuple[AgentResult, ...] = ()
|
|
784
|
+
gates: tuple[GateEntry, ...] = ()
|
|
785
|
+
status: RunStatus = RunStatus.PLANNED
|
|
786
|
+
branch: str = ""
|
|
787
|
+
pull_request: str = ""
|
|
788
|
+
workflow: str = DEFAULT_WORKFLOW
|
|
789
|
+
|
|
790
|
+
@property
|
|
791
|
+
def done_roles(self) -> tuple[str, ...]:
|
|
792
|
+
"""Roles that finished the job, in Run Log order.
|
|
793
|
+
|
|
794
|
+
An escalation is not done. A human corrects the plan block and runs
|
|
795
|
+
`agentforge implement` again, and the Role that escalated is the one
|
|
796
|
+
that has to run — so only completed results retire a Roster entry.
|
|
797
|
+
|
|
798
|
+
A Gate that blocked on a Role's output un-retires it again: the verdict
|
|
799
|
+
was drawn from work a human has since changed, and a Run that resumed
|
|
800
|
+
past it would re-read the same stale finding forever. The Gate entries
|
|
801
|
+
are counted rather than interleaved with the results, because a Gate's
|
|
802
|
+
verdict always trails the Step it judged — the last matching entry is
|
|
803
|
+
the one it was drawn from, and no cursor is needed to say so.
|
|
804
|
+
"""
|
|
805
|
+
done = [result.role for result in self.results if result.ok]
|
|
806
|
+
for entry in self.gates:
|
|
807
|
+
if entry.blocked and entry.invalidates in done:
|
|
808
|
+
_drop_last(done, entry.invalidates)
|
|
809
|
+
return tuple(done)
|
|
810
|
+
|
|
811
|
+
@property
|
|
812
|
+
def remaining(self) -> tuple[Role, ...]:
|
|
813
|
+
"""Roles that have not yet completed, in Roster order."""
|
|
814
|
+
return outstanding(tuple(self.roster), self.done_roles, lambda role: role.name)
|
|
815
|
+
|
|
816
|
+
@property
|
|
817
|
+
def current_step(self) -> int:
|
|
818
|
+
"""The 1-based position of the Step the Run is on. Derived, never stored.
|
|
819
|
+
|
|
820
|
+
A cursor kept alongside the Run Log would be a second answer to a
|
|
821
|
+
question the Run Log already answers, and the two would disagree the
|
|
822
|
+
first time a human edited the Issue — so the count of retired Steps is
|
|
823
|
+
the only answer there is. A Role that escalated or failed did not retire
|
|
824
|
+
its Step, which is why a halted Run is still standing on the Step that
|
|
825
|
+
halted it, and why re-running resumes there.
|
|
826
|
+
"""
|
|
827
|
+
return len(self.done_roles) + 1
|
|
828
|
+
|
|
829
|
+
@property
|
|
830
|
+
def escalation(self) -> AgentResult | None:
|
|
831
|
+
"""The Escalation that stopped this Run, if one did.
|
|
832
|
+
|
|
833
|
+
The last entry rather than the first: the Run Log keeps every attempt,
|
|
834
|
+
and a Role that escalated, had its plan block corrected, and then
|
|
835
|
+
completed did not stop anything.
|
|
836
|
+
"""
|
|
837
|
+
last = self.results[-1] if self.results else None
|
|
838
|
+
return last if last is not None and last.escalated else None
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@dataclass(frozen=True)
|
|
842
|
+
class PlanDocument:
|
|
843
|
+
"""The machine-readable half of an Issue body: everything an Agent needs.
|
|
844
|
+
|
|
845
|
+
Kept separate from `RunState` because this is what gets written once and
|
|
846
|
+
frozen, while a Run's results accumulate around it.
|
|
847
|
+
"""
|
|
848
|
+
|
|
849
|
+
plan: Plan
|
|
850
|
+
roster: Roster
|
|
851
|
+
context: ContextPack = ContextPack()
|
|
852
|
+
version: int = PLAN_FORMAT_VERSION
|
|
853
|
+
notes: tuple[str, ...] = field(default=())
|
|
854
|
+
workflow: str = DEFAULT_WORKFLOW
|
|
855
|
+
|
|
856
|
+
def to_dict(self) -> dict:
|
|
857
|
+
return {
|
|
858
|
+
"version": self.version,
|
|
859
|
+
"plan": self.plan.to_dict(),
|
|
860
|
+
"roster": self.roster.to_dict(),
|
|
861
|
+
"context": self.context.to_dict(),
|
|
862
|
+
"notes": list(self.notes),
|
|
863
|
+
"workflow": self.workflow,
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
@classmethod
|
|
867
|
+
def from_dict(cls, data: dict, resolve) -> PlanDocument:
|
|
868
|
+
return cls(
|
|
869
|
+
plan=Plan.from_dict(data["plan"]),
|
|
870
|
+
roster=Roster.from_dict(data.get("roster") or [], resolve),
|
|
871
|
+
context=ContextPack.from_dict(data.get("context")),
|
|
872
|
+
version=int(data.get("version", PLAN_FORMAT_VERSION)),
|
|
873
|
+
notes=tuple(data.get("notes") or ()),
|
|
874
|
+
workflow=str(data.get("workflow") or DEFAULT_WORKFLOW),
|
|
875
|
+
)
|