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,588 @@
|
|
|
1
|
+
"""The Orchestrator: the only Role that reasons.
|
|
2
|
+
|
|
3
|
+
It turns a Task into an Issue. It resolves what it needs about the project,
|
|
4
|
+
chooses a Roster, and writes a Plan detailed enough to execute without further
|
|
5
|
+
interpretation. Everything downstream executes; nothing downstream re-plans
|
|
6
|
+
(ADR-0003), which is why this Role runs at the `deep` tier and why its output
|
|
7
|
+
quality is the ceiling on the system's.
|
|
8
|
+
|
|
9
|
+
Three of its behaviors are load-bearing and all three are tested:
|
|
10
|
+
|
|
11
|
+
- **The interview.** A half-formed Task is pushed on while the human is still
|
|
12
|
+
at the keyboard, because ADR-0003 freezes the plan the moment it is filed and
|
|
13
|
+
this is the last cheap moment to ask anything.
|
|
14
|
+
- **Roster selection** clamps whatever the model asked for down to the Roles
|
|
15
|
+
that exist, and records what it dropped where a human will see it.
|
|
16
|
+
- **Ambiguity escalates.** A Task the Orchestrator cannot plan confidently stops
|
|
17
|
+
here rather than being guessed at.
|
|
18
|
+
|
|
19
|
+
The interview is rounds of one-shot invocations rather than a conversation.
|
|
20
|
+
ADR-0001 gives the Provider port no session and no history, so each round is
|
|
21
|
+
handed the transcript so far and answers with the questions it still has. That
|
|
22
|
+
is also what makes the interview testable without a CLI installed: it is the
|
|
23
|
+
same port, invoked more than once.
|
|
24
|
+
|
|
25
|
+
Nothing interactive attached means no interview. A Run in CI has nobody to ask,
|
|
26
|
+
and a planner that blocked on input that will never arrive would hang rather
|
|
27
|
+
than degrade.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import Callable, Sequence
|
|
33
|
+
from dataclasses import dataclass, replace
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import TYPE_CHECKING
|
|
36
|
+
|
|
37
|
+
from ..core.contracts import (
|
|
38
|
+
DEFAULT_WORKFLOW,
|
|
39
|
+
AgentResult,
|
|
40
|
+
ContextPack,
|
|
41
|
+
ModelTier,
|
|
42
|
+
Outcome,
|
|
43
|
+
Plan,
|
|
44
|
+
PlanDocument,
|
|
45
|
+
Role,
|
|
46
|
+
Roster,
|
|
47
|
+
Task,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING: # `core.workflow` imports this package, so the cycle stays deferred.
|
|
51
|
+
from ..core.workflow import Workflow
|
|
52
|
+
from ..core.plan_format import (
|
|
53
|
+
PLAN_CLOSE,
|
|
54
|
+
PLAN_OPEN,
|
|
55
|
+
RESULT_CLOSE,
|
|
56
|
+
RESULT_OPEN,
|
|
57
|
+
PlanFormatError,
|
|
58
|
+
extract_plan_payload,
|
|
59
|
+
extract_result_block,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
#: How the Orchestrator reaches the human: one question in, one answer out.
|
|
63
|
+
#: `None` back ends the interview, and the Orchestrator plans with what it has —
|
|
64
|
+
#: a Task that was already clear should not cost a conversation.
|
|
65
|
+
Interviewer = Callable[[str], str | None]
|
|
66
|
+
|
|
67
|
+
#: Rounds of questions before the Orchestrator plans with what it has. The human
|
|
68
|
+
#: can stop sooner and the Orchestrator can declare itself ready sooner; this is
|
|
69
|
+
#: only the backstop against a model that always has one more question, and each
|
|
70
|
+
#: round it does not ask is a `deep` invocation nobody pays for.
|
|
71
|
+
MAX_ROUNDS = 3
|
|
72
|
+
|
|
73
|
+
#: What the interview adds for its own invocations. `grill-with-docs` is the
|
|
74
|
+
#: interview and the writing-down as one job; it has nothing to say to a
|
|
75
|
+
#: planning pass with nobody in the room, so it is declared per invocation
|
|
76
|
+
#: rather than on the Role. It travels the Capability Tier path like every other
|
|
77
|
+
#: skill (ADR-0005), and expands into the two it composes wherever a Provider
|
|
78
|
+
#: cannot invoke a skill by name.
|
|
79
|
+
INTERVIEW_SKILLS = ("grill-with-docs",)
|
|
80
|
+
|
|
81
|
+
INSTRUCTIONS = """\
|
|
82
|
+
You are the Orchestrator in AgentForge. You reason once, and everything after \
|
|
83
|
+
you executes without re-planning.
|
|
84
|
+
|
|
85
|
+
Your output is a frozen execution contract. The Roles that read it will not see \
|
|
86
|
+
the human's original wording, will not re-scope the work, and cannot ask you a \
|
|
87
|
+
question. A step that needs interpreting will be interpreted differently by \
|
|
88
|
+
each Role that reads it.
|
|
89
|
+
|
|
90
|
+
Write a plan that names files, states what changes in each, and says how each \
|
|
91
|
+
step is known to be done.\
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
PROMPT = """\
|
|
95
|
+
{instructions}
|
|
96
|
+
|
|
97
|
+
## The Task
|
|
98
|
+
|
|
99
|
+
{task}
|
|
100
|
+
{interview}
|
|
101
|
+
## The repository
|
|
102
|
+
|
|
103
|
+
You are running in {cwd}. Read whatever you need in order to plan accurately -- \
|
|
104
|
+
existing structure, conventions, tests, and any CONTEXT.md, AGENTS.md, or \
|
|
105
|
+
docs/adr/ the project keeps. Do not change any files. This is a planning pass.
|
|
106
|
+
|
|
107
|
+
## Workflows you may choose
|
|
108
|
+
|
|
109
|
+
{workflows}
|
|
110
|
+
|
|
111
|
+
Name one in the plan block. It decides which Roles run and in what order, so the
|
|
112
|
+
Roster you write is that Workflow's Roles -- choose the Workflow that fits the
|
|
113
|
+
Task rather than assembling a Roster of your own. You may still move a Role to a
|
|
114
|
+
different Model Tier.
|
|
115
|
+
|
|
116
|
+
## Roles the Workflows draw on
|
|
117
|
+
|
|
118
|
+
{roles}
|
|
119
|
+
|
|
120
|
+
## What you do not do
|
|
121
|
+
|
|
122
|
+
You have no issue tracker and no triage labels. AgentForge files exactly one \
|
|
123
|
+
Issue from the block you write below, under its own label, through its own \
|
|
124
|
+
GitHub boundary. Do not publish anything, do not open an issue, and do not \
|
|
125
|
+
apply a label -- a second Issue filed from inside a planning pass is one nobody \
|
|
126
|
+
is tracking.
|
|
127
|
+
|
|
128
|
+
## Required output
|
|
129
|
+
|
|
130
|
+
End your reply with these two blocks, in this order, and nothing after them.
|
|
131
|
+
|
|
132
|
+
Write acceptance criteria a Role can check by reading the repository. Agents \
|
|
133
|
+
run no commands unless a human opens that gate for a Run (ADR-0007), so a \
|
|
134
|
+
criterion phrased as "run the suite and paste the output" is one the executing \
|
|
135
|
+
Role will have to report it could not verify. If a step genuinely cannot be \
|
|
136
|
+
judged without running something, say so in the criterion itself rather than \
|
|
137
|
+
assuming it will be run.
|
|
138
|
+
|
|
139
|
+
First the plan:
|
|
140
|
+
|
|
141
|
+
{plan_open}
|
|
142
|
+
```json
|
|
143
|
+
{{
|
|
144
|
+
"version": 1,
|
|
145
|
+
"plan": {{
|
|
146
|
+
"summary": "one paragraph a human can judge the work by",
|
|
147
|
+
"steps": [
|
|
148
|
+
{{
|
|
149
|
+
"id": "s1",
|
|
150
|
+
"intent": "what changes and why",
|
|
151
|
+
"files": ["path/one.py"],
|
|
152
|
+
"acceptance": "how this step is known to be done, checkable by reading the repository"
|
|
153
|
+
}}
|
|
154
|
+
],
|
|
155
|
+
"constraints": ["anything the executing Role must not do"]
|
|
156
|
+
}},
|
|
157
|
+
"workflow": "feature",
|
|
158
|
+
"roster": [{{"role": "implementer", "tier": "standard"}}],
|
|
159
|
+
"context": {{
|
|
160
|
+
"files": ["files a Role must read"],
|
|
161
|
+
"symbols": ["functions or classes the work touches"],
|
|
162
|
+
"conventions": ["project conventions the work must follow"]
|
|
163
|
+
}}
|
|
164
|
+
}}
|
|
165
|
+
```
|
|
166
|
+
{plan_close}
|
|
167
|
+
|
|
168
|
+
Then your own verdict:
|
|
169
|
+
|
|
170
|
+
{result_open}
|
|
171
|
+
```json
|
|
172
|
+
{{"outcome": "completed", "summary": "one line describing the plan you wrote"}}
|
|
173
|
+
```
|
|
174
|
+
{result_close}
|
|
175
|
+
|
|
176
|
+
If the Task is too ambiguous to plan without guessing, write the verdict block \
|
|
177
|
+
with `"outcome": "escalated"` and a summary naming exactly what you need the \
|
|
178
|
+
human to decide. Omit the plan block in that case. The human is still at the \
|
|
179
|
+
keyboard right now; that will not be true when this plan is executed.\
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
INTERVIEW = """\
|
|
183
|
+
You are the Orchestrator in AgentForge, interviewing the human who typed this \
|
|
184
|
+
Task before you write anything down.
|
|
185
|
+
|
|
186
|
+
Ask only about what would change the plan. A question whose answer you could \
|
|
187
|
+
find by reading the repository is a question you should not be asking, and one \
|
|
188
|
+
whose answer would not change a single step is worse.
|
|
189
|
+
|
|
190
|
+
## The Task
|
|
191
|
+
|
|
192
|
+
{task}
|
|
193
|
+
|
|
194
|
+
## The repository
|
|
195
|
+
|
|
196
|
+
You are running in {cwd}. Read whatever you need.
|
|
197
|
+
|
|
198
|
+
{glossary}
|
|
199
|
+
|
|
200
|
+
## What you have asked so far
|
|
201
|
+
|
|
202
|
+
{transcript}
|
|
203
|
+
|
|
204
|
+
## Required output
|
|
205
|
+
|
|
206
|
+
End your reply with this block and nothing after it:
|
|
207
|
+
|
|
208
|
+
{result_open}
|
|
209
|
+
```json
|
|
210
|
+
{{
|
|
211
|
+
"outcome": "completed",
|
|
212
|
+
"summary": "one line on what is still unclear, or that nothing is",
|
|
213
|
+
"questions": ["one question per entry, in the order you want them asked"]
|
|
214
|
+
}}
|
|
215
|
+
```
|
|
216
|
+
{result_close}
|
|
217
|
+
|
|
218
|
+
An empty `questions` list means you have enough to plan. Say that as soon as it \
|
|
219
|
+
is true: this is the last cheap moment to ask, and it is also the human's time.\
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
GLOSSARY_PRESENT = """\
|
|
223
|
+
This project keeps a glossary at `{path}`. Resolve the Task's terms against it \
|
|
224
|
+
rather than against ordinary usage, and when the human settles a term that is \
|
|
225
|
+
not in it, record the decision there in the format the file already uses. \
|
|
226
|
+
Somebody asking the same question next week should find the answer instead of \
|
|
227
|
+
you. Change nothing else in the repository -- this is not the work.\
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
GLOSSARY_ABSENT = """\
|
|
231
|
+
This project keeps no glossary. Resolve the Task's terms against how the \
|
|
232
|
+
repository itself uses them, and do not start a glossary during an interview.\
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
#: The Orchestrator runs at `deep`: it pays for all downstream reasoning once.
|
|
236
|
+
#: `domain-modeling` is standing equipment rather than interview-only: resolving
|
|
237
|
+
#: a Task's terms against the project's own vocabulary is what it does whether
|
|
238
|
+
#: or not anybody is in the room.
|
|
239
|
+
ORCHESTRATOR = Role(
|
|
240
|
+
name="orchestrator",
|
|
241
|
+
tier=ModelTier.DEEP,
|
|
242
|
+
instructions=INSTRUCTIONS,
|
|
243
|
+
#: What a planning pass works with: the project's vocabulary, the synthesis
|
|
244
|
+
#: of a conversation into a spec, and the breakdown of that spec into work.
|
|
245
|
+
#: Both `to-` skills end by publishing to a tracker and labelling what they
|
|
246
|
+
#: filed, which is AgentForge's job — the prompt says so, and ADR-0007's
|
|
247
|
+
#: default-deny means a planning pass cannot reach `gh` to do it anyway.
|
|
248
|
+
skills=("domain-modeling", "to-spec", "to-tickets"),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True)
|
|
253
|
+
class Exchange:
|
|
254
|
+
"""One question the Orchestrator asked and what came back."""
|
|
255
|
+
|
|
256
|
+
question: str
|
|
257
|
+
answer: str
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def render_transcript(exchanges: Sequence[Exchange]) -> str:
|
|
261
|
+
"""The interview so far, as the next round is handed it."""
|
|
262
|
+
if not exchanges:
|
|
263
|
+
return "_Nothing yet. This is the first round._"
|
|
264
|
+
return "\n\n".join(
|
|
265
|
+
f"**You asked:** {e.question.strip()}\n**They answered:** {e.answer.strip()}"
|
|
266
|
+
for e in exchanges
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def glossary_section(cwd: Path) -> str:
|
|
271
|
+
"""What to tell the interview about the project's vocabulary.
|
|
272
|
+
|
|
273
|
+
The path rather than the text: the Agent is already in the repository and
|
|
274
|
+
can read it, and a glossary inlined into every round is paid for in every
|
|
275
|
+
round.
|
|
276
|
+
"""
|
|
277
|
+
path = Path(cwd) / "CONTEXT.md"
|
|
278
|
+
if path.is_file():
|
|
279
|
+
return GLOSSARY_PRESENT.format(path="CONTEXT.md")
|
|
280
|
+
return GLOSSARY_ABSENT
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True)
|
|
284
|
+
class Planned:
|
|
285
|
+
"""What a planning pass produced: a document, or a reason there is none."""
|
|
286
|
+
|
|
287
|
+
result: AgentResult
|
|
288
|
+
document: PlanDocument | None = None
|
|
289
|
+
#: The interview behind the plan, empty when there was nobody to interview.
|
|
290
|
+
#: Kept so the caller can say what it cost and what was asked.
|
|
291
|
+
interview: tuple[Exchange, ...] = ()
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def escalated(self) -> bool:
|
|
295
|
+
return self.document is None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class Orchestrator:
|
|
299
|
+
"""Runs one planning pass and hands back something fileable."""
|
|
300
|
+
|
|
301
|
+
def __init__(self, provider, tier: ModelTier | None = None) -> None:
|
|
302
|
+
self.provider = provider
|
|
303
|
+
self.tier = tier or ORCHESTRATOR.tier
|
|
304
|
+
|
|
305
|
+
def interview(
|
|
306
|
+
self, task: Task, cwd: Path, interviewer: Interviewer
|
|
307
|
+
) -> tuple[Exchange, ...]:
|
|
308
|
+
"""Ask until there is nothing worth asking, the human stops, or the cap.
|
|
309
|
+
|
|
310
|
+
Each round is one invocation handed the whole transcript, because the
|
|
311
|
+
port has no memory (ADR-0001). A round that comes back with no questions
|
|
312
|
+
ends the interview: the Orchestrator saying it has enough is the outcome
|
|
313
|
+
this is for, not a fallback.
|
|
314
|
+
|
|
315
|
+
A round that fails to answer in the required shape also ends it. The
|
|
316
|
+
planning pass is what has to work, and an interview that cannot be
|
|
317
|
+
parsed is a reason to stop asking rather than a reason to stop.
|
|
318
|
+
"""
|
|
319
|
+
role = replace(ORCHESTRATOR.at_tier(self.tier), skills=INTERVIEW_SKILLS)
|
|
320
|
+
exchanges: list[Exchange] = []
|
|
321
|
+
|
|
322
|
+
for _ in range(MAX_ROUNDS):
|
|
323
|
+
result = self.provider.invoke(
|
|
324
|
+
role=role,
|
|
325
|
+
prompt=INTERVIEW.format(
|
|
326
|
+
task=task.statement.strip(),
|
|
327
|
+
cwd=cwd,
|
|
328
|
+
glossary=glossary_section(cwd),
|
|
329
|
+
transcript=render_transcript(exchanges),
|
|
330
|
+
result_open=RESULT_OPEN,
|
|
331
|
+
result_close=RESULT_CLOSE,
|
|
332
|
+
),
|
|
333
|
+
context=ContextPack(),
|
|
334
|
+
tier=self.tier,
|
|
335
|
+
cwd=cwd,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
questions = _questions(result)
|
|
339
|
+
if not questions:
|
|
340
|
+
break
|
|
341
|
+
|
|
342
|
+
for question in questions:
|
|
343
|
+
answer = interviewer(question)
|
|
344
|
+
if answer is None:
|
|
345
|
+
# The human ended it. What they have already answered still
|
|
346
|
+
# counts; a plan is better for three answers than for none.
|
|
347
|
+
return tuple(exchanges)
|
|
348
|
+
exchanges.append(Exchange(question=question, answer=answer))
|
|
349
|
+
|
|
350
|
+
return tuple(exchanges)
|
|
351
|
+
|
|
352
|
+
def build_prompt(
|
|
353
|
+
self, task: Task, cwd: Path, exchanges: Sequence[Exchange] = ()
|
|
354
|
+
) -> str:
|
|
355
|
+
from ..core.workflow import available_workflows
|
|
356
|
+
from . import KNOWN_TIERS, ROLES
|
|
357
|
+
|
|
358
|
+
definitions = "\n".join(
|
|
359
|
+
f"- `{workflow.name}`: " + ", ".join(step.role for step in workflow.steps)
|
|
360
|
+
for workflow in available_workflows()
|
|
361
|
+
if workflow.steps
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
available = "\n".join(
|
|
365
|
+
f"- `{name}` (default tier `{ROLES[name].tier}`)"
|
|
366
|
+
for name in sorted(ROLES)
|
|
367
|
+
if name != ORCHESTRATOR.name
|
|
368
|
+
)
|
|
369
|
+
deferred = sorted(set(KNOWN_TIERS) - set(ROLES))
|
|
370
|
+
if deferred:
|
|
371
|
+
available += (
|
|
372
|
+
"\n\nNot yet implemented, so do not put them in the Roster: "
|
|
373
|
+
+ ", ".join(f"`{name}`" for name in deferred)
|
|
374
|
+
+ "."
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
interview = ""
|
|
378
|
+
if exchanges:
|
|
379
|
+
interview = (
|
|
380
|
+
"\n## What the human told you when you asked\n\n"
|
|
381
|
+
+ render_transcript(exchanges)
|
|
382
|
+
+ "\n\nThese answers are the Task now. The Roles downstream will not see "
|
|
383
|
+
"them, so anything here that changes a step belongs in the step.\n"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return PROMPT.format(
|
|
387
|
+
instructions=ORCHESTRATOR.instructions,
|
|
388
|
+
task=task.statement.strip(),
|
|
389
|
+
interview=interview,
|
|
390
|
+
cwd=cwd,
|
|
391
|
+
workflows=definitions,
|
|
392
|
+
roles=available,
|
|
393
|
+
plan_open=PLAN_OPEN,
|
|
394
|
+
plan_close=PLAN_CLOSE,
|
|
395
|
+
result_open=RESULT_OPEN,
|
|
396
|
+
result_close=RESULT_CLOSE,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def plan(
|
|
400
|
+
self, task: Task, cwd: Path, interviewer: Interviewer | None = None
|
|
401
|
+
) -> Planned:
|
|
402
|
+
"""Interview if there is anybody to interview, then plan once.
|
|
403
|
+
|
|
404
|
+
No interviewer is the single-shot path, unchanged: a scheduled Run has
|
|
405
|
+
nobody at the keyboard, and blocking on input that will never arrive is
|
|
406
|
+
the one failure mode worse than planning from an underspecified Task.
|
|
407
|
+
"""
|
|
408
|
+
exchanges = self.interview(task, cwd, interviewer) if interviewer else ()
|
|
409
|
+
|
|
410
|
+
role = ORCHESTRATOR.at_tier(self.tier)
|
|
411
|
+
result = self.provider.invoke(
|
|
412
|
+
role=role,
|
|
413
|
+
prompt=self.build_prompt(task, cwd, exchanges),
|
|
414
|
+
context=ContextPack(),
|
|
415
|
+
tier=self.tier,
|
|
416
|
+
cwd=cwd,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if result.outcome is not Outcome.COMPLETED:
|
|
420
|
+
return Planned(result=result, interview=exchanges)
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
document = build_document(result.raw)
|
|
424
|
+
except PlanFormatError as exc:
|
|
425
|
+
return Planned(
|
|
426
|
+
result=AgentResult(
|
|
427
|
+
role=role.name,
|
|
428
|
+
tier=self.tier,
|
|
429
|
+
outcome=Outcome.FAILED,
|
|
430
|
+
summary=f"the Orchestrator reported success but wrote no usable plan: {exc}",
|
|
431
|
+
detail=result.raw,
|
|
432
|
+
raw=result.raw,
|
|
433
|
+
),
|
|
434
|
+
interview=exchanges,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
return Planned(result=result, document=document, interview=exchanges)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _questions(result: AgentResult) -> tuple[str, ...]:
|
|
441
|
+
"""The questions one interview round came back with.
|
|
442
|
+
|
|
443
|
+
Read out of the result block rather than out of a marker of its own. An
|
|
444
|
+
interview never reaches an Issue, so this is a prompt convention rather than
|
|
445
|
+
a compatibility surface, and a fourth marker would have to be maintained
|
|
446
|
+
like one.
|
|
447
|
+
"""
|
|
448
|
+
if result.outcome is not Outcome.COMPLETED:
|
|
449
|
+
return ()
|
|
450
|
+
payload = extract_result_block(result.raw) or {}
|
|
451
|
+
asked = payload.get("questions")
|
|
452
|
+
if not isinstance(asked, list):
|
|
453
|
+
return ()
|
|
454
|
+
return tuple(str(q).strip() for q in asked if str(q).strip())
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def build_document(text: str) -> PlanDocument:
|
|
458
|
+
"""Turn an Orchestrator's raw output into the document that gets filed."""
|
|
459
|
+
payload = extract_plan_payload(text)
|
|
460
|
+
plan = Plan.from_dict(payload["plan"])
|
|
461
|
+
workflow, workflow_notes = select_workflow(payload.get("workflow"))
|
|
462
|
+
requested, roster_notes = select_roster(payload.get("roster") or [])
|
|
463
|
+
roster, aligned_notes = align_to_workflow(requested, workflow)
|
|
464
|
+
context = ContextPack.from_dict(payload.get("context"))
|
|
465
|
+
return PlanDocument(
|
|
466
|
+
plan=plan,
|
|
467
|
+
roster=roster,
|
|
468
|
+
context=context,
|
|
469
|
+
workflow=workflow.name,
|
|
470
|
+
notes=workflow_notes + roster_notes + aligned_notes,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def select_workflow(requested) -> tuple[Workflow, tuple[str, ...]]:
|
|
475
|
+
"""The Workflow named in the plan block, or the default with a note.
|
|
476
|
+
|
|
477
|
+
Validated here rather than at `implement` time. An Issue naming a Workflow
|
|
478
|
+
nobody has is an Issue that fails a week later on somebody else's machine,
|
|
479
|
+
and the human who could have corrected it is at the keyboard now.
|
|
480
|
+
"""
|
|
481
|
+
from ..core.workflow import WorkflowError, available_workflows, load_workflow
|
|
482
|
+
|
|
483
|
+
name = str(requested or "").strip().lower()
|
|
484
|
+
if not name:
|
|
485
|
+
return load_workflow(DEFAULT_WORKFLOW), ()
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
return load_workflow(name), ()
|
|
489
|
+
except WorkflowError:
|
|
490
|
+
available = ", ".join(w.name for w in available_workflows())
|
|
491
|
+
note = (
|
|
492
|
+
f"The Orchestrator named the `{name}` Workflow, which does not exist "
|
|
493
|
+
f"({available}). The Issue runs `{DEFAULT_WORKFLOW}` instead."
|
|
494
|
+
)
|
|
495
|
+
return load_workflow(DEFAULT_WORKFLOW), (note,)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def align_to_workflow(roster: Roster, workflow: Workflow) -> tuple[Roster, tuple[str, ...]]:
|
|
499
|
+
"""The Roles that will actually run, in the order the Workflow runs them.
|
|
500
|
+
|
|
501
|
+
The runtime walks the Workflow, not the Roster, so a Roster that disagreed
|
|
502
|
+
with it would be a promise the Run does not keep — and the Roster table is
|
|
503
|
+
what a human reads to find out who is about to touch their repository.
|
|
504
|
+
|
|
505
|
+
Tiers survive the alignment: choosing the Workflow is the Orchestrator's
|
|
506
|
+
judgement about the shape of the Task, and moving a Role up a tier is its
|
|
507
|
+
judgement about the difficulty of this one. The Roster written here is the
|
|
508
|
+
one the runtime resolves each Step's tier from, so that judgement is what
|
|
509
|
+
runs and the table is what a human can rely on. See ADR-0014.
|
|
510
|
+
"""
|
|
511
|
+
from . import resolve_role
|
|
512
|
+
|
|
513
|
+
tiers = roster.tiers()
|
|
514
|
+
roles = []
|
|
515
|
+
for step in workflow.steps:
|
|
516
|
+
role = resolve_role(step.role)
|
|
517
|
+
tier = step.tier or tiers.get(role.name)
|
|
518
|
+
roles.append(role.at_tier(tier) if tier else role)
|
|
519
|
+
|
|
520
|
+
dropped = sorted(set(tiers) - {step.role for step in workflow.steps})
|
|
521
|
+
notes = tuple(
|
|
522
|
+
f"The Orchestrator asked for the `{name}` Role, which the "
|
|
523
|
+
f"`{workflow.name}` Workflow does not run. It is not in the Roster."
|
|
524
|
+
for name in dropped
|
|
525
|
+
)
|
|
526
|
+
return Roster(tuple(roles)), notes
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def select_roster(requested) -> tuple[Roster, tuple[str, ...]]:
|
|
530
|
+
"""Clamp a requested Roster to the Roles that exist.
|
|
531
|
+
|
|
532
|
+
A model asked to plan a schema migration will reach for a Tester and a
|
|
533
|
+
Security Role, and it is right to. Dropping them silently would leave a
|
|
534
|
+
human reading the Issue believing work is scheduled that never runs, so
|
|
535
|
+
every drop becomes a note in the Issue body.
|
|
536
|
+
|
|
537
|
+
The Implementer is appended when nothing executable survives: the pipe is
|
|
538
|
+
what M1 is proving, and an Issue nobody can implement proves nothing.
|
|
539
|
+
"""
|
|
540
|
+
from . import IMPLEMENTER, KNOWN_TIERS, ROLES
|
|
541
|
+
|
|
542
|
+
roles: list[Role] = []
|
|
543
|
+
notes: list[str] = []
|
|
544
|
+
seen: set[str] = set()
|
|
545
|
+
|
|
546
|
+
for entry in requested:
|
|
547
|
+
name = str(entry.get("role", "")).strip().lower() if isinstance(entry, dict) else str(entry)
|
|
548
|
+
if not name or name == ORCHESTRATOR.name:
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
if name not in ROLES:
|
|
552
|
+
if name in KNOWN_TIERS:
|
|
553
|
+
notes.append(
|
|
554
|
+
f"The Orchestrator asked for the `{name}` Role, which has a declared "
|
|
555
|
+
"tier but is not implemented yet. It was dropped from the Roster."
|
|
556
|
+
)
|
|
557
|
+
else:
|
|
558
|
+
notes.append(f"Unknown Role `{name}` requested by the Orchestrator; dropped.")
|
|
559
|
+
continue
|
|
560
|
+
|
|
561
|
+
if name in seen:
|
|
562
|
+
continue
|
|
563
|
+
seen.add(name)
|
|
564
|
+
|
|
565
|
+
role = ROLES[name]
|
|
566
|
+
tier = entry.get("tier") if isinstance(entry, dict) else None
|
|
567
|
+
roles.append(role.at_tier(ModelTier(tier)) if tier else role)
|
|
568
|
+
|
|
569
|
+
if not roles:
|
|
570
|
+
notes.append(
|
|
571
|
+
"No implemented Role survived Roster selection, so the Implementer was added. "
|
|
572
|
+
"M1 runs a single Role end to end."
|
|
573
|
+
)
|
|
574
|
+
roles.append(IMPLEMENTER)
|
|
575
|
+
|
|
576
|
+
return Roster(tuple(roles)), tuple(notes)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
__all__ = [
|
|
580
|
+
"INSTRUCTIONS",
|
|
581
|
+
"ORCHESTRATOR",
|
|
582
|
+
"Orchestrator",
|
|
583
|
+
"Planned",
|
|
584
|
+
"align_to_workflow",
|
|
585
|
+
"build_document",
|
|
586
|
+
"select_roster",
|
|
587
|
+
"select_workflow",
|
|
588
|
+
]
|