archforge-optimizer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- archforge/__init__.py +76 -0
- archforge/__main__.py +10 -0
- archforge/architect.py +442 -0
- archforge/cli.py +881 -0
- archforge/config.py +140 -0
- archforge/config_init.py +150 -0
- archforge/diff.py +206 -0
- archforge/engine.py +444 -0
- archforge/gatekeeper.py +290 -0
- archforge/host/__init__.py +20 -0
- archforge/host/adapters/__init__.py +41 -0
- archforge/host/adapters/base.py +311 -0
- archforge/host/adapters/helpers.py +163 -0
- archforge/host/adapters/langgraph.py +726 -0
- archforge/host/base.py +105 -0
- archforge/host/fake.py +380 -0
- archforge/judge/__init__.py +20 -0
- archforge/judge/base.py +257 -0
- archforge/judge/scripted.py +145 -0
- archforge/lint.py +180 -0
- archforge/llm/__init__.py +65 -0
- archforge/llm/_common.py +94 -0
- archforge/llm/anthropic.py +90 -0
- archforge/llm/base.py +90 -0
- archforge/llm/gemini.py +112 -0
- archforge/llm/groq.py +63 -0
- archforge/llm/openai.py +63 -0
- archforge/llm/scripted.py +134 -0
- archforge/middleware.py +181 -0
- archforge/models.py +435 -0
- archforge/mutate.py +214 -0
- archforge/otel.py +613 -0
- archforge/runlog.py +103 -0
- archforge/runner.py +153 -0
- archforge/spec_builder.py +126 -0
- archforge/stores/__init__.py +22 -0
- archforge/stores/_jsonl.py +81 -0
- archforge/stores/attempt_store.py +161 -0
- archforge/stores/spec_store.py +188 -0
- archforge/stores/trace_store.py +42 -0
- archforge/suite.py +248 -0
- archforge/userconfig.py +144 -0
- archforge_optimizer-0.1.0.dist-info/METADATA +420 -0
- archforge_optimizer-0.1.0.dist-info/RECORD +47 -0
- archforge_optimizer-0.1.0.dist-info/WHEEL +4 -0
- archforge_optimizer-0.1.0.dist-info/entry_points.txt +2 -0
- archforge_optimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
archforge/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""ArchForge — a self-improving meta-layer over multi-agent systems.
|
|
2
|
+
|
|
3
|
+
The **adapter kit** (the public API) lets an external project wrap any MAS as a
|
|
4
|
+
``HostAdapter`` and evolve it; the core organs (Engine, Architect, Judge,
|
|
5
|
+
Gatekeeper, stores) drive the Propose-Evaluate-Commit loop. See
|
|
6
|
+
``docs/superpowers/specs/2026-07-30-adapter-kit-design.md`` for the kit design.
|
|
7
|
+
|
|
8
|
+
Quick adapter sketch:
|
|
9
|
+
|
|
10
|
+
from archforge import (
|
|
11
|
+
BaseHostAdapter, BaseAgent, CallResult,
|
|
12
|
+
SpecBuilder, SEQUENCE, JOIN, run_loop, RunnerConfig,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
class MyAgent(BaseAgent):
|
|
16
|
+
def call(self, prompt, cfg) -> CallResult: # the one per-node hook
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
class MyAdapter(BaseHostAdapter):
|
|
20
|
+
def make_agent(self, node) -> BaseAgent: ...
|
|
21
|
+
|
|
22
|
+
spec = (SpecBuilder().node(...).edge("a","b", kind=SEQUENCE).build())
|
|
23
|
+
r = run_loop(MyAdapter(), spec, suite, config=RunnerConfig(provider="gemini"))
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
# ── the contract (host/base.py) — already the seam; re-exported so an adapter
|
|
28
|
+
# author imports everything from one place (`archforge`). Unchanged modules.
|
|
29
|
+
from archforge.host.base import Agent, AgentResponse, HostMAS, Runnable, Task
|
|
30
|
+
|
|
31
|
+
# ── the adapter kit (host/adapters) — what every adapter subclasses / uses.
|
|
32
|
+
from archforge.host.adapters import (
|
|
33
|
+
BaseAgent,
|
|
34
|
+
BaseHostAdapter,
|
|
35
|
+
BasePipeline,
|
|
36
|
+
CallResult,
|
|
37
|
+
KnobVote,
|
|
38
|
+
RunContext,
|
|
39
|
+
cfg_decay,
|
|
40
|
+
cfg_as_kwargs,
|
|
41
|
+
estimate_tokens,
|
|
42
|
+
run_id,
|
|
43
|
+
topo_order,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# ── Spec bootstrap DSL (spec_builder.py) — the universal floor.
|
|
47
|
+
from archforge.spec_builder import (
|
|
48
|
+
CONDITIONAL,
|
|
49
|
+
EdgeType,
|
|
50
|
+
FANOUT,
|
|
51
|
+
JOIN,
|
|
52
|
+
SEQUENCE,
|
|
53
|
+
SpecBuildError,
|
|
54
|
+
SpecBuilder,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# ── the run entrypoint (runner.py) — wraps the Engine for embedders.
|
|
58
|
+
from archforge.runner import RunnerConfig, run_cycle, run_loop
|
|
59
|
+
|
|
60
|
+
from archforge.config import VERSION as __version__ # noqa: F401 (public API)
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
# contract
|
|
64
|
+
"HostMAS", "Agent", "Runnable", "Task", "AgentResponse",
|
|
65
|
+
# kit
|
|
66
|
+
"BaseHostAdapter", "BaseAgent", "BasePipeline", "CallResult",
|
|
67
|
+
"RunContext", "KnobVote", "cfg_decay", "cfg_as_kwargs",
|
|
68
|
+
"run_id", "topo_order", "estimate_tokens",
|
|
69
|
+
# Spec DSL
|
|
70
|
+
"SpecBuilder", "SpecBuildError", "EdgeType",
|
|
71
|
+
"JOIN", "FANOUT", "SEQUENCE", "CONDITIONAL",
|
|
72
|
+
# runner
|
|
73
|
+
"RunnerConfig", "run_cycle", "run_loop",
|
|
74
|
+
# version
|
|
75
|
+
"__version__",
|
|
76
|
+
]
|
archforge/__main__.py
ADDED
archforge/architect.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""The Architect — the P-E-C "Propose" step (spec §3, §4, §6).
|
|
2
|
+
|
|
3
|
+
One cycle, one change. `next_attempt(...)` returns an `ArchitectResult`:
|
|
4
|
+
|
|
5
|
+
* status="proposed" — a candidate Spec (incumbent + ONE mutation) + a `Change`
|
|
6
|
+
record ready for the orchestrator to evaluate/commit.
|
|
7
|
+
* status="lint_rejected" — the candidate the Architect formed is structurally
|
|
8
|
+
invalid; the linter's reasons are returned. The
|
|
9
|
+
candidate NEVER reaches the SuiteRunner (spec E5).
|
|
10
|
+
* status="plateau" — no non-dedup-blocked proposal remains; the loop should
|
|
11
|
+
stop (spec E7/E8).
|
|
12
|
+
|
|
13
|
+
Boundaries that keep the optimizer safe:
|
|
14
|
+
* The Architect is WRITE-FREE over stores. It READS AttemptStore for dedup
|
|
15
|
+
(E7) but never appends an Attempt — the orchestrator does that, so
|
|
16
|
+
proposal and commitment stay cleanly separated.
|
|
17
|
+
* `Change` is the persisted *metadata* (kind/target/diff/rationale/scope); the
|
|
18
|
+
concrete edit lives in the candidate Spec returned alongside it, built with
|
|
19
|
+
`archforge.mutate`. dedup keys off (parent, kind, target).
|
|
20
|
+
* Structural change kinds auto-tag `scope=STRUCTURAL` (defined in
|
|
21
|
+
archforge.models) — that tag, not the Architect, drives the hybrid gate (I4).
|
|
22
|
+
|
|
23
|
+
The real `Architect` spends ONE LLM call per cycle (the locked budget); the
|
|
24
|
+
`ScriptedArchitect` decides the proposal deterministically for tests.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Any, Literal, Protocol, Sequence, runtime_checkable
|
|
30
|
+
|
|
31
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
32
|
+
|
|
33
|
+
import archforge.models as m
|
|
34
|
+
from archforge.lint import LintError, lint
|
|
35
|
+
from archforge.llm.base import LLMClient, Message, Role
|
|
36
|
+
from archforge.mutate import apply_change
|
|
37
|
+
from archforge.stores.attempt_store import AttemptStore
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
# Credit assignment — where did the run lose points?
|
|
42
|
+
# --------------------------------------------------------------------------- #
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CreditAssignment(BaseModel):
|
|
46
|
+
"""The node/route blamed for the incumbent's rubric loss (Architect/credit-assignment input)."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
node_id: str | None = None # the agent most responsible for the loss
|
|
51
|
+
route: str | None = None # "from->to" if a route is to blame
|
|
52
|
+
sub_rubric: str | None = None # the dimension lost
|
|
53
|
+
severity: float = 0.0 # 1.0 - blamed score, in [0, 1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def credit_assign(
|
|
57
|
+
incumbent: m.Spec,
|
|
58
|
+
worst_task_scores: Sequence[m.RunScore],
|
|
59
|
+
) -> CreditAssignment | None:
|
|
60
|
+
"""Localize the rubric loss to a node from per-step StepScores.
|
|
61
|
+
|
|
62
|
+
Deterministic: across the worst task's repeat scores, average each node's
|
|
63
|
+
sub-rubric scores; return the (node, dimension) with the lowest mean. Returns
|
|
64
|
+
None if no step_scores were recorded (nothing to assign blame to).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
if not worst_task_scores:
|
|
68
|
+
return None
|
|
69
|
+
# sum per (node, dimension) across repeats
|
|
70
|
+
acc: dict[str, dict[str, list[float]]] = {}
|
|
71
|
+
for score in worst_task_scores:
|
|
72
|
+
for ss in score.step_scores:
|
|
73
|
+
dims = acc.setdefault(ss.node_id, {})
|
|
74
|
+
for dim, val in ss.sub_rubrics.items():
|
|
75
|
+
dims.setdefault(dim, []).append(val)
|
|
76
|
+
if not acc:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# find the (node, dim) with the lowest mean
|
|
80
|
+
worst_node, worst_dim, worst_mean = None, None, 1.0
|
|
81
|
+
for node_id, dims in acc.items():
|
|
82
|
+
for dim, vals in dims.items():
|
|
83
|
+
mean = sum(vals) / len(vals)
|
|
84
|
+
if mean < worst_mean:
|
|
85
|
+
worst_mean, worst_node, worst_dim = mean, node_id, dim
|
|
86
|
+
if worst_node is None:
|
|
87
|
+
return None
|
|
88
|
+
return CreditAssignment(node_id=worst_node, sub_rubric=worst_dim,
|
|
89
|
+
severity=1.0 - worst_mean)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --------------------------------------------------------------------------- #
|
|
93
|
+
# Result + Proposal types
|
|
94
|
+
# --------------------------------------------------------------------------- #
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
ArchitectStatus = Literal["proposed", "lint_rejected", "plateau"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ArchitectProposal(BaseModel):
|
|
101
|
+
"""A concrete candidate (incumbent + one mutation) + its Change metadata."""
|
|
102
|
+
|
|
103
|
+
model_config = ConfigDict(extra="forbid")
|
|
104
|
+
|
|
105
|
+
candidate: m.Spec
|
|
106
|
+
change: m.Change
|
|
107
|
+
blame: CreditAssignment | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ArchitectResult(BaseModel):
|
|
111
|
+
"""The discriminated outcome of one P-E-C Propose step."""
|
|
112
|
+
|
|
113
|
+
model_config = ConfigDict(extra="forbid")
|
|
114
|
+
|
|
115
|
+
status: ArchitectStatus
|
|
116
|
+
proposal: ArchitectProposal | None = None
|
|
117
|
+
rejected_reasons: list[LintError] = Field(default_factory=list)
|
|
118
|
+
note: str | None = None # why we rejected/plateaued (for the report)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def proposed(self) -> bool:
|
|
122
|
+
return self.status == "proposed"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------- #
|
|
126
|
+
# Architect protocol — real + scripted both satisfy this
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@runtime_checkable
|
|
131
|
+
class ArchitectProtocol(Protocol):
|
|
132
|
+
def next_attempt(
|
|
133
|
+
self,
|
|
134
|
+
incumbent: m.Spec,
|
|
135
|
+
worst_task_scores: Sequence[m.RunScore],
|
|
136
|
+
*,
|
|
137
|
+
attempt_store: AttemptStore,
|
|
138
|
+
) -> ArchitectResult: ...
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --------------------------------------------------------------------------- #
|
|
142
|
+
# Real Architect — one LLM call per cycle
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Architect:
|
|
147
|
+
"""LLM-backed P-E-C proposer. One `complete()` call per `next_attempt`."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, llm: LLMClient, *, model: str) -> None:
|
|
150
|
+
self._llm = llm
|
|
151
|
+
self._model = model
|
|
152
|
+
|
|
153
|
+
def next_attempt(
|
|
154
|
+
self,
|
|
155
|
+
incumbent: m.Spec,
|
|
156
|
+
worst_task_scores: Sequence[m.RunScore],
|
|
157
|
+
*,
|
|
158
|
+
attempt_store: AttemptStore,
|
|
159
|
+
) -> ArchitectResult:
|
|
160
|
+
blame = credit_assign(incumbent, worst_task_scores)
|
|
161
|
+
proposal = self._propose(incumbent, blame)
|
|
162
|
+
if proposal is None:
|
|
163
|
+
return _plateau("architect produced no proposal")
|
|
164
|
+
|
|
165
|
+
change = _change_from_payload(incumbent, proposal)
|
|
166
|
+
if change is None:
|
|
167
|
+
return _plateau("architect returned an unparseable change")
|
|
168
|
+
|
|
169
|
+
# Kind gate (shared): prompt_edit/model_swap apply ONLY to llm nodes.
|
|
170
|
+
mismatch = _kind_mismatch_note(incumbent, change)
|
|
171
|
+
if mismatch is not None:
|
|
172
|
+
return _plateau(mismatch)
|
|
173
|
+
|
|
174
|
+
# The LLM nests the edit under `payload`; mutate reads the inner fields.
|
|
175
|
+
edit = proposal.get("payload")
|
|
176
|
+
if not isinstance(edit, dict):
|
|
177
|
+
edit = {k: v for k, v in proposal.items() if k not in {"kind", "target", "rationale"}}
|
|
178
|
+
|
|
179
|
+
# structural mistakes that survive this far -> lint_rejected (E5)
|
|
180
|
+
try:
|
|
181
|
+
candidate = apply_change(incumbent, change, edit)
|
|
182
|
+
except Exception as exc: # noqa: BLE001 — mutate is defensive
|
|
183
|
+
return _lint_rejected(_as_lint_errors(f"mutation failed: {exc}"))
|
|
184
|
+
|
|
185
|
+
errors = lint(candidate)
|
|
186
|
+
if errors:
|
|
187
|
+
return _lint_rejected(errors)
|
|
188
|
+
|
|
189
|
+
# dedup: skip a change already rejected/rolled-back on (parent, kind, target) (E7)
|
|
190
|
+
blocked = attempt_store.blocking(
|
|
191
|
+
incumbent.spec_id or incumbent.compute_spec_id(),
|
|
192
|
+
change.kind.value, change.target,
|
|
193
|
+
)
|
|
194
|
+
if blocked:
|
|
195
|
+
return _plateau(
|
|
196
|
+
f"(parent={incumbent.spec_id}, kind={change.kind.value}, "
|
|
197
|
+
f"target={change.target}) already tried: "
|
|
198
|
+
f"{[a.attempt_id for a in blocked]}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return ArchitectResult(
|
|
202
|
+
status="proposed",
|
|
203
|
+
proposal=ArchitectProposal(candidate=candidate, change=change, blame=blame),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# --------------------------------------------------------------- internals
|
|
207
|
+
def _propose(
|
|
208
|
+
self, incumbent: m.Spec, blame: CreditAssignment | None
|
|
209
|
+
) -> dict[str, Any] | None:
|
|
210
|
+
"""One LLM call → a structured change payload. None on no proposal.
|
|
211
|
+
|
|
212
|
+
A valid-JSON-but-not-a-change response (no `kind`) is *not* a provider
|
|
213
|
+
outage: the model answered, just without a usable change this cycle. We
|
|
214
|
+
return the dict and let `_change_from_payload` turn the missing `kind`
|
|
215
|
+
into a `None` → plateau (E7/E8). A true outage (non-JSON) raises from the
|
|
216
|
+
client itself and propagates as `LLMError` for the loop to retry (E9).
|
|
217
|
+
"""
|
|
218
|
+
messages = self._build_messages(incumbent, blame)
|
|
219
|
+
completion = self._llm.complete(
|
|
220
|
+
messages, model=self._model, temperature=0.2, response_format="json",
|
|
221
|
+
)
|
|
222
|
+
parsed = completion.parsed
|
|
223
|
+
return parsed if isinstance(parsed, dict) else None
|
|
224
|
+
|
|
225
|
+
def _build_messages(
|
|
226
|
+
self, incumbent: m.Spec, blame: CreditAssignment | None
|
|
227
|
+
) -> list[Message]:
|
|
228
|
+
blame_text = (
|
|
229
|
+
f"Blame: node='{blame.node_id}', sub_rubric='{blame.sub_rubric}', "
|
|
230
|
+
f"severity={blame.severity:.2f}.\n"
|
|
231
|
+
if blame else "No clear blame node (runs near-perfect). Be conservative.\n"
|
|
232
|
+
)
|
|
233
|
+
return [
|
|
234
|
+
Message(
|
|
235
|
+
role=Role.SYSTEM,
|
|
236
|
+
content=(
|
|
237
|
+
"You are the Architect of a multi-agent pipeline optimizer. "
|
|
238
|
+
"Propose EXACTLY ONE small change to improve the incumbent Spec. "
|
|
239
|
+
"Return JSON: {kind, target, rationale, payload}. `kind` ∈ "
|
|
240
|
+
"{prompt_edit, knob, add_node, remove_node, rewire, model_swap}. "
|
|
241
|
+
"`target` = node_id (or 'from,to' for rewire). `payload` carries "
|
|
242
|
+
"the edit: {prompt} | {knobs:{...}} | {model} | {node, wiring} | "
|
|
243
|
+
"{remove:[from,to], add:[from,to,type]}. Each Spec node has a "
|
|
244
|
+
"`kind` ∈ {llm, rule, retriever, tool, symbolic} and a `tunable` "
|
|
245
|
+
"allowlist of the EXTRA knobs it owns. `prompt_edit`/`model_swap` "
|
|
246
|
+
"apply ONLY to `llm` nodes; `knob` may set `temperature`/`retries`/"
|
|
247
|
+
"`max_tokens` on ANY node, plus any EXTRA key listed in that node's "
|
|
248
|
+
"`tunable` (any other extra key is rejected); for non-`llm` nodes "
|
|
249
|
+
"prefer `knob` edits to the node's own params (`top_k`,`threshold`,…) "
|
|
250
|
+
"or graph edits. Make the change address the blame."
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
Message(
|
|
254
|
+
role=Role.USER,
|
|
255
|
+
content=(
|
|
256
|
+
f"INCUMBENT SPEC (nodes + edges):\n{_spec_summary(incumbent)}\n\n"
|
|
257
|
+
+ blame_text
|
|
258
|
+
+ "Return the one-change JSON."
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --------------------------------------------------------------------------- #
|
|
265
|
+
# ScriptedArchitect — deterministic, for tests
|
|
266
|
+
# --------------------------------------------------------------------------- #
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class ScriptedArchitect:
|
|
270
|
+
"""Deterministic Architect whose proposals are scripted by the test.
|
|
271
|
+
|
|
272
|
+
`propose(change, payload)` queues a proposal (popped per next_attempt). Use
|
|
273
|
+
`force_lint_error()` to make the next proposal intentionally malformed (E5).
|
|
274
|
+
`force_plateau()` makes the next call return a plateau. If the queue is empty
|
|
275
|
+
when next_attempt is called, it plateaus.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
def __init__(self) -> None:
|
|
279
|
+
self._queue: list[tuple[m.Change, dict[str, Any]]] = []
|
|
280
|
+
self._lint_error: bool = False
|
|
281
|
+
self._force_plateau: bool = False
|
|
282
|
+
self.calls: list[m.Spec] = [] # incumbents seen, for assertions
|
|
283
|
+
|
|
284
|
+
def propose(self, change: m.Change, payload: dict[str, Any]) -> "ScriptedArchitect":
|
|
285
|
+
self._queue.append((change, payload))
|
|
286
|
+
return self
|
|
287
|
+
|
|
288
|
+
def force_lint_error(self) -> "ScriptedArchitect":
|
|
289
|
+
self._lint_error = True
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
def force_plateau(self) -> "ScriptedArchitect":
|
|
293
|
+
self._force_plateau = True
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
def next_attempt(
|
|
297
|
+
self,
|
|
298
|
+
incumbent: m.Spec,
|
|
299
|
+
worst_task_scores: Sequence[m.RunScore],
|
|
300
|
+
*,
|
|
301
|
+
attempt_store: AttemptStore,
|
|
302
|
+
) -> ArchitectResult:
|
|
303
|
+
self.calls.append(incumbent)
|
|
304
|
+
if self._force_plateau:
|
|
305
|
+
self._force_plateau = False
|
|
306
|
+
return _plateau("scripted plateau")
|
|
307
|
+
if not self._queue:
|
|
308
|
+
return _plateau("no scripted proposal left")
|
|
309
|
+
|
|
310
|
+
change, payload = self._queue.pop(0)
|
|
311
|
+
if self._lint_error:
|
|
312
|
+
self._lint_error = False
|
|
313
|
+
# build something the linter will reject (orphan edge to a ghost node)
|
|
314
|
+
return _lint_rejected(_as_lint_errors("scripted malformed candidate"))
|
|
315
|
+
|
|
316
|
+
blame = credit_assign(incumbent, worst_task_scores)
|
|
317
|
+
|
|
318
|
+
# Kind gate (shared with the real Architect): prompt_edit/model_swap on a
|
|
319
|
+
# non-llm node plateaus deterministically — nothing committed. Mirrors the
|
|
320
|
+
# real path's `_kind_mismatch_note` so a scripted E2E exercises the same
|
|
321
|
+
# rule (the plan's verification step 5).
|
|
322
|
+
mismatch = _kind_mismatch_note(incumbent, change)
|
|
323
|
+
if mismatch is not None:
|
|
324
|
+
return _plateau(mismatch)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
candidate = apply_change(incumbent, change, payload)
|
|
328
|
+
except Exception as exc: # noqa: BLE001
|
|
329
|
+
return _lint_rejected(_as_lint_errors(f"mutation failed: {exc}"))
|
|
330
|
+
|
|
331
|
+
errors = lint(candidate)
|
|
332
|
+
if errors:
|
|
333
|
+
return _lint_rejected(errors)
|
|
334
|
+
|
|
335
|
+
blocked = attempt_store.blocking(
|
|
336
|
+
incumbent.spec_id or incumbent.compute_spec_id(),
|
|
337
|
+
change.kind.value, change.target,
|
|
338
|
+
)
|
|
339
|
+
if blocked:
|
|
340
|
+
return _plateau(
|
|
341
|
+
f"scripted change already tried on (parent={incumbent.spec_id}, "
|
|
342
|
+
f"kind={change.kind.value}, target={change.target})"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return ArchitectResult(
|
|
346
|
+
status="proposed",
|
|
347
|
+
proposal=ArchitectProposal(candidate=candidate, change=change, blame=blame),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --------------------------------------------------------------------------- #
|
|
352
|
+
# helpers
|
|
353
|
+
# --------------------------------------------------------------------------- #
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _change_from_payload(incumbent: m.Spec, payload: dict[str, Any]) -> m.Change | None:
|
|
357
|
+
"""Build a Change (metadata) from the LLM's structured proposal + rationale."""
|
|
358
|
+
try:
|
|
359
|
+
kind = m.ChangeKind(payload["kind"])
|
|
360
|
+
target = str(payload["target"])
|
|
361
|
+
rationale = str(payload.get("rationale", ""))
|
|
362
|
+
except (KeyError, ValueError):
|
|
363
|
+
return None
|
|
364
|
+
diff = _describe_diff(kind, target, payload.get("payload", payload))
|
|
365
|
+
return m.Change.for_kind(kind, target, diff, rationale)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _describe_diff(kind: m.ChangeKind, target: str, payload: Any) -> str:
|
|
369
|
+
if kind is m.ChangeKind.PROMPT_EDIT:
|
|
370
|
+
return f"rewrite system_prompt of '{target}'"
|
|
371
|
+
if kind is m.ChangeKind.KNOB:
|
|
372
|
+
return f"tune knobs of '{target}'"
|
|
373
|
+
if kind is m.ChangeKind.MODEL_SWAP:
|
|
374
|
+
return f"swap model on '{target}'"
|
|
375
|
+
if kind is m.ChangeKind.ADD_NODE:
|
|
376
|
+
return f"add node '{target}'"
|
|
377
|
+
if kind is m.ChangeKind.REMOVE_NODE:
|
|
378
|
+
return f"remove node '{target}'"
|
|
379
|
+
if kind is m.ChangeKind.REWIRE:
|
|
380
|
+
return f"rewire edge at '{target}'"
|
|
381
|
+
return f"{kind.value} on '{target}'"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _node_opt(spec: m.Spec, node_id: str) -> m.Node | None:
|
|
385
|
+
"""Safe node lookup that returns None instead of raising (decoupled from
|
|
386
|
+
mutate's private `_node`). Used by the kind gate so a missing target falls
|
|
387
|
+
through to the normal apply/lint path instead of being mis-plateaued here."""
|
|
388
|
+
return next((n for n in spec.nodes if n.node_id == node_id), None)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _kind_mismatch_note(spec: m.Spec, change: m.Change) -> str | None:
|
|
392
|
+
"""Kind gate for prompt_edit/model_swap: they apply ONLY to ``llm`` nodes.
|
|
393
|
+
|
|
394
|
+
A change of one of those kinds aimed at a non-llm node is valid metadata
|
|
395
|
+
(not "unparseable") but a kind mismatch, so the candidate should plateau with
|
|
396
|
+
a precise note (same E7/E8 outcome, nothing committed) rather than mutate
|
|
397
|
+
silently editing an empty prompt/model on a node that ignores it (a no-op
|
|
398
|
+
candidate masquerading as a real change). Returns the plateau note when the
|
|
399
|
+
change must be dropped, or ``None`` to proceed. A missing target is NOT a
|
|
400
|
+
kind mismatch — return None so it falls through to apply/lint below, which
|
|
401
|
+
rejects the structural defect instead of being mis-plateaued here.
|
|
402
|
+
|
|
403
|
+
Shared by the real ``Architect`` and ``ScriptedArchitect`` so the rule is
|
|
404
|
+
enforced uniformly and is deterministically testable through the scripted path.
|
|
405
|
+
"""
|
|
406
|
+
if change.kind not in (m.ChangeKind.PROMPT_EDIT, m.ChangeKind.MODEL_SWAP):
|
|
407
|
+
return None
|
|
408
|
+
tgt = _node_opt(spec, change.target)
|
|
409
|
+
if tgt is None or tgt.kind is m.NodeKind.LLM:
|
|
410
|
+
return None
|
|
411
|
+
return (f"{change.kind.value} on non-llm node '{change.target}' "
|
|
412
|
+
f"(kind={tgt.kind.value})")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _spec_summary(spec: m.Spec) -> str:
|
|
416
|
+
nodes = "\n".join(
|
|
417
|
+
f"- {n.node_id} [{n.role}] kind={n.kind.value} model={n.model or '—'} "
|
|
418
|
+
f"tunable={list(n.knobs.tunable) or '—'} prompt={n.system_prompt!r}"
|
|
419
|
+
for n in spec.nodes
|
|
420
|
+
)
|
|
421
|
+
edges = "\n".join(f"- {e.from_} -> {e.to} ({e.type.value})" for e in spec.edges)
|
|
422
|
+
return f"NODES:\n{nodes}\nEDGES:\n{edges}"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _plateau(note: str) -> ArchitectResult:
|
|
426
|
+
return ArchitectResult(status="plateau", note=note)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _lint_rejected(reasons: list[LintError]) -> ArchitectResult:
|
|
430
|
+
return ArchitectResult(status="lint_rejected", rejected_reasons=reasons,
|
|
431
|
+
note="candidate failed the Spec Linter")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _as_lint_errors(text: str) -> list[LintError]:
|
|
435
|
+
return [LintError(code="self_loop", message=text, location=None)]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
__all__ = [
|
|
439
|
+
"CreditAssignment", "credit_assign",
|
|
440
|
+
"ArchitectProposal", "ArchitectResult", "ArchitectStatus",
|
|
441
|
+
"ArchitectProtocol", "Architect", "ScriptedArchitect",
|
|
442
|
+
]
|