devcouncil 0.2.0 → 0.3.1
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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- package/uv.lock +1 -1
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""SkillOpt: a text-space optimization loop for DevCouncil skill + guidance documents.
|
|
2
|
+
|
|
3
|
+
This is a DevCouncil-native implementation of the loop popularized by Microsoft
|
|
4
|
+
SkillOpt (https://github.com/microsoft/SkillOpt): a document is treated as the
|
|
5
|
+
trainable state of a frozen agent and improved over epochs by
|
|
6
|
+
|
|
7
|
+
rollout -> reflect -> aggregate -> propose-edits -> update -> validate -> evaluate
|
|
8
|
+
|
|
9
|
+
Unlike weight-space training, the only thing that changes is markdown text. DevCouncil
|
|
10
|
+
has **two** such artifacts that steer a coding agent: the *guidance* (an agent
|
|
11
|
+
profile's prompt preamble) and the *skill* document. They are optimized **together,
|
|
12
|
+
simultaneously** — each epoch the optimizer proposes a single batch of bounded edits
|
|
13
|
+
that may touch either document, the combined candidate runs the rollout, and the
|
|
14
|
+
*whole* candidate is accepted only when it strictly improves a held-out validation
|
|
15
|
+
score (the validation gate). Co-optimizing them in one loop keeps guidance and skill
|
|
16
|
+
mutually consistent instead of drifting apart across two separate runs.
|
|
17
|
+
|
|
18
|
+
Each epoch a *target* agent runs the current documents on the training tasks
|
|
19
|
+
(rollout), the trajectories are scored (reflect), the low-scoring cases are aggregated
|
|
20
|
+
into feedback, and an *optimizer* model proposes a small number of add/delete/replace
|
|
21
|
+
edits across both documents. Rejected edits are remembered so the optimizer doesn't
|
|
22
|
+
re-propose them.
|
|
23
|
+
|
|
24
|
+
Both the rollout and the edit proposer are pluggable callables so the loop is fully
|
|
25
|
+
testable without a live model; :func:`make_llm_rollout` and :func:`make_llm_optimizer`
|
|
26
|
+
provide the default :class:`~devcouncil.llm.router.ModelRouter`-backed implementations.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
from collections.abc import Awaitable, Callable
|
|
36
|
+
from dataclasses import asdict, dataclass, field
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Literal
|
|
40
|
+
|
|
41
|
+
from pydantic import BaseModel, Field
|
|
42
|
+
|
|
43
|
+
# Reuse the small list-coercion helper from the agent-profile optimizer so both
|
|
44
|
+
# optimizers share one eval-dataset field contract (required_terms, forbidden_terms…).
|
|
45
|
+
from devcouncil.optimization.gepa_agent import _string_list
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# The two co-optimized document slots. ``guidance`` is the agent profile preamble that
|
|
50
|
+
# steers the agent; ``skill`` is the engineering skill document. Edits without an
|
|
51
|
+
# explicit target default to ``skill``.
|
|
52
|
+
GUIDANCE: Literal["guidance"] = "guidance"
|
|
53
|
+
SKILL: Literal["skill"] = "skill"
|
|
54
|
+
DOC_TARGETS = (GUIDANCE, SKILL)
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Edit model — the optimizer's bounded action space (add / delete / replace),
|
|
58
|
+
# targeting either the guidance or the skill document.
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SkillEdit(BaseModel):
|
|
63
|
+
"""One bounded edit to a co-optimized document body.
|
|
64
|
+
|
|
65
|
+
``target`` selects which document the edit applies to (``guidance`` or ``skill``).
|
|
66
|
+
``replace`` swaps the first occurrence of ``find`` with ``text``; ``delete``
|
|
67
|
+
removes the first occurrence of ``find``; ``add`` inserts ``text`` after the
|
|
68
|
+
first occurrence of ``find`` (or appends it when ``find`` is empty). Anchoring on
|
|
69
|
+
existing text keeps every edit local and reviewable, which is what lets the
|
|
70
|
+
validation gate attribute a score change to a specific batch of edits.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
op: Literal["add", "delete", "replace"]
|
|
74
|
+
target: Literal["guidance", "skill"] = SKILL
|
|
75
|
+
find: str = ""
|
|
76
|
+
text: str = ""
|
|
77
|
+
reason: str = ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SkillEditProposal(BaseModel):
|
|
81
|
+
"""A batch of edits proposed by the optimizer for one epoch, spanning both docs."""
|
|
82
|
+
|
|
83
|
+
edits: list[SkillEdit] = Field(default_factory=list)
|
|
84
|
+
rationale: str = ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _edit_signature(edit: SkillEdit) -> str:
|
|
88
|
+
"""Stable identity for the rejected-edit buffer (target + op + anchor + payload).
|
|
89
|
+
|
|
90
|
+
Fields are JSON-encoded rather than space-joined so distinct edits can't collide
|
|
91
|
+
on an ambiguous field boundary (e.g. find='a', text='b c' vs find='a b', text='c').
|
|
92
|
+
A collision would let a rejected edit silently suppress a different, untried one.
|
|
93
|
+
"""
|
|
94
|
+
raw = json.dumps([edit.target, edit.op, edit.find.strip(), edit.text.strip()])
|
|
95
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _batch_signature(edits: list[SkillEdit]) -> str:
|
|
99
|
+
"""Order-independent identity for a *combination* of edits.
|
|
100
|
+
|
|
101
|
+
When the validation gate rejects a multi-edit batch, only this combined signature is
|
|
102
|
+
blacklisted — not each component — so a genuinely good edit that was merely dragged
|
|
103
|
+
below the gate by a bad partner stays eligible to be re-proposed in a different
|
|
104
|
+
combination. The select step uses it to avoid re-applying the identical losing batch."""
|
|
105
|
+
raw = json.dumps(sorted(_edit_signature(edit) for edit in edits))
|
|
106
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _apply_one(body: str, edit: SkillEdit) -> str | None:
|
|
110
|
+
"""Apply a single edit to one document body; return new body or None if no-op."""
|
|
111
|
+
find = edit.find
|
|
112
|
+
if edit.op == "add":
|
|
113
|
+
text = edit.text.strip("\n")
|
|
114
|
+
if not text:
|
|
115
|
+
return None
|
|
116
|
+
if find and find in body:
|
|
117
|
+
idx = body.index(find) + len(find)
|
|
118
|
+
candidate = body[:idx] + "\n\n" + text + body[idx:]
|
|
119
|
+
elif not find:
|
|
120
|
+
candidate = body.rstrip("\n") + "\n\n" + text + "\n"
|
|
121
|
+
else:
|
|
122
|
+
return None # anchor requested but absent -> skip
|
|
123
|
+
elif edit.op == "delete":
|
|
124
|
+
if not find or find not in body:
|
|
125
|
+
return None
|
|
126
|
+
candidate = body.replace(find, "", 1)
|
|
127
|
+
else: # replace
|
|
128
|
+
if not find or find not in body:
|
|
129
|
+
return None
|
|
130
|
+
candidate = body.replace(find, edit.text, 1)
|
|
131
|
+
return None if candidate == body else candidate
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_permanently_redundant(edit: SkillEdit, docs: dict[str, str]) -> bool:
|
|
135
|
+
"""Whether an edit no-ops for a reason that can never change as the docs evolve.
|
|
136
|
+
|
|
137
|
+
Used to decide what the no-op branch may add to the rejected-edit buffer: an empty
|
|
138
|
+
append or an identity replace will *always* no-op and is safe to blacklist forever.
|
|
139
|
+
An edit that no-ops only because its ``find`` anchor isn't present *yet* is NOT
|
|
140
|
+
redundant — a later accepted edit may introduce that anchor — so it stays eligible.
|
|
141
|
+
"""
|
|
142
|
+
body = docs.get(edit.target)
|
|
143
|
+
if body is None:
|
|
144
|
+
return False
|
|
145
|
+
if edit.op == "add":
|
|
146
|
+
return not edit.text.strip("\n") # only an empty append is permanently a no-op
|
|
147
|
+
if edit.op == "replace":
|
|
148
|
+
return bool(edit.find) and edit.find in body and edit.find == edit.text
|
|
149
|
+
return False # a delete only no-ops when its anchor is absent (may appear later)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def apply_edits(docs: dict[str, str], edits: list[SkillEdit]) -> tuple[dict[str, str], list[SkillEdit]]:
|
|
153
|
+
"""Apply ``edits`` to the right document in ``docs``, skipping ones that no-op.
|
|
154
|
+
|
|
155
|
+
Returns updated copies of the document bodies and the subset of edits that
|
|
156
|
+
actually changed something. A hallucinated anchor (or an edit targeting a missing
|
|
157
|
+
document) is skipped rather than corrupting the documents.
|
|
158
|
+
"""
|
|
159
|
+
updated = dict(docs)
|
|
160
|
+
applied: list[SkillEdit] = []
|
|
161
|
+
for edit in edits:
|
|
162
|
+
if edit.target not in updated:
|
|
163
|
+
continue
|
|
164
|
+
result = _apply_one(updated[edit.target], edit)
|
|
165
|
+
if result is None:
|
|
166
|
+
continue
|
|
167
|
+
updated[edit.target] = result
|
|
168
|
+
applied.append(edit)
|
|
169
|
+
return updated, applied
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Loop configuration and records.
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class SkillOptConfig:
|
|
179
|
+
"""Hyper-parameters for the SkillOpt loop.
|
|
180
|
+
|
|
181
|
+
``max_edits_per_epoch`` is the textual learning-rate budget — the cap on how many
|
|
182
|
+
edits may land in a single epoch (across both documents combined). ``min_improvement``
|
|
183
|
+
is the validation gate margin; the default ``0.0`` means *strictly greater*
|
|
184
|
+
validation score is required to accept a candidate. ``rollout_concurrency`` bounds how
|
|
185
|
+
many rollouts in one evaluation run concurrently (rollouts are independent LLM calls;
|
|
186
|
+
results are still collected in task order so the mean and feedback stay deterministic).
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
epochs: int = 5
|
|
190
|
+
max_edits_per_epoch: int = 3
|
|
191
|
+
val_fraction: float = 0.5
|
|
192
|
+
min_improvement: float = 0.0
|
|
193
|
+
seed: int = 0
|
|
194
|
+
rollout_concurrency: int = 8
|
|
195
|
+
# Stop after this many consecutive *unproductive* epochs (a no-op proposal or a batch
|
|
196
|
+
# already known-rejected). A stuck optimizer otherwise burns one LLM call per remaining
|
|
197
|
+
# epoch for zero gain. Gate-rejected epochs are productive (they grow the rejected set
|
|
198
|
+
# and force exploration) and do NOT count toward this patience.
|
|
199
|
+
noop_patience: int = 2
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class EpochRecord:
|
|
204
|
+
epoch: int
|
|
205
|
+
train_score: float
|
|
206
|
+
val_score_before: float
|
|
207
|
+
val_score_after: float | None
|
|
208
|
+
proposed_edits: int
|
|
209
|
+
applied_edits: int
|
|
210
|
+
edited_targets: list[str]
|
|
211
|
+
accepted: bool
|
|
212
|
+
note: str = ""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class SkillOptResult:
|
|
217
|
+
skill_name: str
|
|
218
|
+
seed_docs: dict[str, str]
|
|
219
|
+
best_docs: dict[str, str]
|
|
220
|
+
seed_val_score: float
|
|
221
|
+
best_val_score: float
|
|
222
|
+
epochs: list[EpochRecord] = field(default_factory=list)
|
|
223
|
+
accepted_edit_count: int = 0
|
|
224
|
+
rejected_edit_count: int = 0
|
|
225
|
+
train_size: int = 0
|
|
226
|
+
val_size: int = 0
|
|
227
|
+
artifact_path: Path | None = None
|
|
228
|
+
applied: bool = False
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def improved(self) -> bool:
|
|
232
|
+
return self.best_val_score > self.seed_val_score
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def best_skill_body(self) -> str:
|
|
236
|
+
return self.best_docs.get(SKILL, "")
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def best_guidance_body(self) -> str:
|
|
240
|
+
return self.best_docs.get(GUIDANCE, "")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Pluggable hooks. ``RolloutFn(docs, task) -> trajectory text`` where ``docs`` maps
|
|
244
|
+
# each document name to its current body; the trajectory is then handed to
|
|
245
|
+
# ``ScoreFn(task, trajectory) -> score`` which should return a value in [0, 1] (the loop
|
|
246
|
+
# clamps to that range, so a custom scorer with a wider range can't break the gate).
|
|
247
|
+
RolloutFn = Callable[[dict[str, str], dict[str, Any]], Awaitable[str]]
|
|
248
|
+
ScoreFn = Callable[[dict[str, Any], str], float]
|
|
249
|
+
OptimizerFn = Callable[[dict[str, str], list[dict[str, Any]], list[str], str], Awaitable[SkillEditProposal]]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _clamp01(value: float) -> float:
|
|
253
|
+
return 0.0 if value < 0.0 else 1.0 if value > 1.0 else value
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Default scoring — term coverage on the rollout trajectory.
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def default_score(task: dict[str, Any], trajectory: str) -> float:
|
|
262
|
+
"""Score a rollout by required/forbidden term coverage and expected substrings.
|
|
263
|
+
|
|
264
|
+
Returns a neutral ``0.5`` when a task carries no scoring signal so an unlabeled
|
|
265
|
+
task never silently drags the mean to zero.
|
|
266
|
+
"""
|
|
267
|
+
text = trajectory.lower()
|
|
268
|
+
required = _string_list(task, "required_terms", "expected_terms", "must_include")
|
|
269
|
+
forbidden = _string_list(task, "forbidden_terms", "must_avoid")
|
|
270
|
+
expected = _string_list(task, "expected", "expected_output", "expected_substrings")
|
|
271
|
+
|
|
272
|
+
if not (required or forbidden or expected):
|
|
273
|
+
return 0.5
|
|
274
|
+
if not trajectory.strip():
|
|
275
|
+
return 0.0
|
|
276
|
+
|
|
277
|
+
parts: list[tuple[float, float]] = [] # (score, weight)
|
|
278
|
+
if required:
|
|
279
|
+
hits = sum(1 for term in required if term.lower() in text)
|
|
280
|
+
parts.append((hits / len(required), 0.6))
|
|
281
|
+
if expected:
|
|
282
|
+
hits = sum(1 for term in expected if term.lower() in text)
|
|
283
|
+
parts.append((hits / len(expected), 0.3))
|
|
284
|
+
if forbidden:
|
|
285
|
+
hits = sum(1 for term in forbidden if term.lower() in text)
|
|
286
|
+
parts.append((1.0 - hits / len(forbidden), 0.2))
|
|
287
|
+
|
|
288
|
+
total_weight = sum(weight for _, weight in parts)
|
|
289
|
+
score = sum(value * weight for value, weight in parts) / total_weight
|
|
290
|
+
return max(0.0, min(1.0, score))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _task_prompt(task: dict[str, Any]) -> str:
|
|
294
|
+
for key in ("prompt", "task", "goal", "instruction", "question"):
|
|
295
|
+
value = task.get(key)
|
|
296
|
+
if isinstance(value, str) and value.strip():
|
|
297
|
+
return value.strip()
|
|
298
|
+
return ""
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _split_dataset(
|
|
302
|
+
dataset: list[dict[str, Any]], val_fraction: float, seed: int
|
|
303
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
304
|
+
"""Deterministically split into (train, val).
|
|
305
|
+
|
|
306
|
+
A stable hash of each example id (salted by ``seed``) decides the side, so the
|
|
307
|
+
same dataset + seed always yields the same split without importing ``random`` or
|
|
308
|
+
depending on input order. At least one example is always kept on each side.
|
|
309
|
+
"""
|
|
310
|
+
if len(dataset) < 2:
|
|
311
|
+
return dataset, dataset
|
|
312
|
+
val: list[dict[str, Any]] = []
|
|
313
|
+
train: list[dict[str, Any]] = []
|
|
314
|
+
for index, item in enumerate(dataset):
|
|
315
|
+
key = f"{seed}:{item.get('id', index)}".encode("utf-8")
|
|
316
|
+
bucket = int.from_bytes(hashlib.sha256(key).digest()[:4], "big") / 0xFFFFFFFF
|
|
317
|
+
(val if bucket < val_fraction else train).append(item)
|
|
318
|
+
# Keep at least one example on each side. Both fallbacks *move* (pop) rather than
|
|
319
|
+
# copy, so the same example never lands in both splits — a copy would leak a
|
|
320
|
+
# validation example into training and quietly defeat the held-out gate.
|
|
321
|
+
if not val:
|
|
322
|
+
val.append(train.pop())
|
|
323
|
+
if not train:
|
|
324
|
+
train.append(val.pop())
|
|
325
|
+
return train, val
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# The loop.
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def _evaluate(
|
|
334
|
+
docs: dict[str, str],
|
|
335
|
+
tasks: list[dict[str, Any]],
|
|
336
|
+
rollout: RolloutFn,
|
|
337
|
+
score: ScoreFn,
|
|
338
|
+
concurrency: int = 8,
|
|
339
|
+
) -> tuple[float, list[tuple[dict[str, Any], str, float]]]:
|
|
340
|
+
# Rollouts are independent LLM round-trips, so run them concurrently under a semaphore.
|
|
341
|
+
# ``gather`` preserves input order, so trajectories realign with ``tasks`` and the mean
|
|
342
|
+
# and downstream ``_reflect`` ordering remain deterministic regardless of finish order.
|
|
343
|
+
if not tasks:
|
|
344
|
+
return 0.0, []
|
|
345
|
+
sem = asyncio.Semaphore(max(1, concurrency))
|
|
346
|
+
|
|
347
|
+
async def _one(task: dict[str, Any]) -> str:
|
|
348
|
+
async with sem:
|
|
349
|
+
try:
|
|
350
|
+
return await rollout(docs, task)
|
|
351
|
+
except Exception:
|
|
352
|
+
# A single flaky rollout (transient network error, a raising custom
|
|
353
|
+
# RolloutFn) must not abort the whole run and discard every accepted
|
|
354
|
+
# improvement. Degrade to an empty trajectory — deterministically scored
|
|
355
|
+
# 0.0 for a labeled task — instead of propagating.
|
|
356
|
+
logger.warning("rollout failed for task %s; scoring it 0.0", task.get("id"), exc_info=True)
|
|
357
|
+
return ""
|
|
358
|
+
|
|
359
|
+
trajectories = await asyncio.gather(*(_one(task) for task in tasks))
|
|
360
|
+
# Clamp into the documented [0, 1] contract so a misbehaving custom ScoreFn can't break
|
|
361
|
+
# the loop's control flow (the >= 1.0 early-stop and the _reflect "perfect" threshold).
|
|
362
|
+
rollouts = [
|
|
363
|
+
(task, traj, _clamp01(score(task, traj))) for task, traj in zip(tasks, trajectories)
|
|
364
|
+
]
|
|
365
|
+
mean = sum(item[2] for item in rollouts) / len(rollouts)
|
|
366
|
+
return mean, rollouts
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _term_gaps(task: dict[str, Any], trajectory: str) -> dict[str, list[str]]:
|
|
370
|
+
"""Per-case scoring gaps: which expected terms are missing and which forbidden ones
|
|
371
|
+
are present in the trajectory. This is the *actionable* signal — it tells the
|
|
372
|
+
optimizer exactly what to add or remove — versus dumping the full term lists. Derived
|
|
373
|
+
from the same term contract the default scorer uses; empty for custom datasets with no
|
|
374
|
+
term fields (those fall back to the trajectory excerpt)."""
|
|
375
|
+
text = trajectory.lower()
|
|
376
|
+
required = _string_list(task, "required_terms", "expected_terms", "must_include")
|
|
377
|
+
expected = _string_list(task, "expected", "expected_output", "expected_substrings")
|
|
378
|
+
forbidden = _string_list(task, "forbidden_terms", "must_avoid")
|
|
379
|
+
return {
|
|
380
|
+
"missing_required": [t for t in required if t.lower() not in text],
|
|
381
|
+
"missing_expected": [t for t in expected if t.lower() not in text],
|
|
382
|
+
"present_forbidden": [t for t in forbidden if t.lower() in text],
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _reflect(rollouts: list[tuple[dict[str, Any], str, float]]) -> list[dict[str, Any]]:
|
|
387
|
+
"""Aggregate the weakest rollouts into compact feedback for the optimizer.
|
|
388
|
+
|
|
389
|
+
Worst-scoring first, so the optimizer spends its bounded edit budget on the
|
|
390
|
+
cases that are actually failing. Each case carries the concrete scoring gaps
|
|
391
|
+
(missing/forbidden terms) so the optimizer can target them directly.
|
|
392
|
+
"""
|
|
393
|
+
feedback: list[dict[str, Any]] = []
|
|
394
|
+
for task, trajectory, value in sorted(rollouts, key=lambda item: item[2]):
|
|
395
|
+
if value >= 1.0:
|
|
396
|
+
continue
|
|
397
|
+
case = {
|
|
398
|
+
"task": _task_prompt(task),
|
|
399
|
+
"score": round(value, 3),
|
|
400
|
+
"observed_failure": task.get("observed_failure", ""),
|
|
401
|
+
"desired_behavior": task.get("desired_behavior", ""),
|
|
402
|
+
"trajectory_excerpt": trajectory.strip()[:400],
|
|
403
|
+
}
|
|
404
|
+
gaps = _term_gaps(task, trajectory)
|
|
405
|
+
case.update({key: terms for key, terms in gaps.items() if terms}) # only non-empty gaps
|
|
406
|
+
feedback.append(case)
|
|
407
|
+
return feedback
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
async def optimize_skill(
|
|
411
|
+
*,
|
|
412
|
+
skill_name: str,
|
|
413
|
+
docs: dict[str, str],
|
|
414
|
+
dataset: list[dict[str, Any]],
|
|
415
|
+
rollout: RolloutFn,
|
|
416
|
+
optimizer: OptimizerFn,
|
|
417
|
+
score: ScoreFn = default_score,
|
|
418
|
+
config: SkillOptConfig | None = None,
|
|
419
|
+
) -> SkillOptResult:
|
|
420
|
+
"""Co-optimize a skill document and its guidance preamble over the SkillOpt loop.
|
|
421
|
+
|
|
422
|
+
``docs`` maps document names (typically :data:`GUIDANCE` and :data:`SKILL`) to their
|
|
423
|
+
current bodies; both are improved **simultaneously**. ``rollout`` runs the target
|
|
424
|
+
agent with a candidate set of documents on one task; ``optimizer`` turns aggregated
|
|
425
|
+
feedback into a :class:`SkillEditProposal` whose edits may target either document.
|
|
426
|
+
Neither needs a live model — inject fakes to test the loop, or use
|
|
427
|
+
:func:`make_llm_rollout` / :func:`make_llm_optimizer` for the router-backed defaults.
|
|
428
|
+
"""
|
|
429
|
+
cfg = config or SkillOptConfig()
|
|
430
|
+
seed_docs = dict(docs)
|
|
431
|
+
train, val = _split_dataset(dataset, cfg.val_fraction, cfg.seed)
|
|
432
|
+
|
|
433
|
+
seed_val_score, _ = await _evaluate(seed_docs, val, rollout, score, cfg.rollout_concurrency)
|
|
434
|
+
best_docs = dict(seed_docs)
|
|
435
|
+
best_val_score = seed_val_score
|
|
436
|
+
|
|
437
|
+
rejected: set[str] = set()
|
|
438
|
+
rejected_count = 0
|
|
439
|
+
accepted_count = 0
|
|
440
|
+
epochs: list[EpochRecord] = []
|
|
441
|
+
# Memoized train evaluation for the current best_docs. The train rollouts are the
|
|
442
|
+
# dominant cost of the loop, and best_docs only changes when the validation gate
|
|
443
|
+
# accepts a candidate — so a rejected/no-op epoch would otherwise re-run a
|
|
444
|
+
# byte-identical train _evaluate. Cache (train_score, feedback) and invalidate it
|
|
445
|
+
# exactly where best_docs is reassigned below.
|
|
446
|
+
cached_train: tuple[float, list[dict[str, Any]]] | None = None
|
|
447
|
+
consecutive_noops = 0
|
|
448
|
+
|
|
449
|
+
for epoch in range(1, cfg.epochs + 1):
|
|
450
|
+
if best_val_score >= 1.0:
|
|
451
|
+
break # nothing left to gain on the validation set
|
|
452
|
+
|
|
453
|
+
if cached_train is None:
|
|
454
|
+
train_score, train_rollouts = await _evaluate(
|
|
455
|
+
best_docs, train, rollout, score, cfg.rollout_concurrency
|
|
456
|
+
)
|
|
457
|
+
cached_train = (train_score, _reflect(train_rollouts))
|
|
458
|
+
train_score, feedback = cached_train
|
|
459
|
+
if not feedback:
|
|
460
|
+
epochs.append(
|
|
461
|
+
EpochRecord(epoch, train_score, best_val_score, None, 0, 0, [], False, "no failing train tasks")
|
|
462
|
+
)
|
|
463
|
+
break
|
|
464
|
+
|
|
465
|
+
proposal = await optimizer(best_docs, feedback, sorted(rejected), skill_name)
|
|
466
|
+
# Select: drop already-rejected edits, then clamp to the learning-rate budget.
|
|
467
|
+
fresh = [e for e in proposal.edits if _edit_signature(e) not in rejected]
|
|
468
|
+
budgeted = fresh[: cfg.max_edits_per_epoch]
|
|
469
|
+
|
|
470
|
+
candidate_docs, applied = apply_edits(best_docs, budgeted)
|
|
471
|
+
# A multi-edit batch already rejected by the gate must not be re-applied verbatim;
|
|
472
|
+
# its components stay individually eligible (see _batch_signature), so the optimizer
|
|
473
|
+
# can recombine them, but the exact losing combination is skipped.
|
|
474
|
+
known_rejected_batch = len(applied) > 1 and _batch_signature(applied) in rejected
|
|
475
|
+
if not applied or candidate_docs == best_docs or known_rejected_batch:
|
|
476
|
+
# Unproductive epoch. Blacklist only permanently-redundant single edits (empty
|
|
477
|
+
# append / identity replace); an edit whose anchor is merely absent now may
|
|
478
|
+
# apply once a later epoch introduces it, so keep it eligible.
|
|
479
|
+
if not known_rejected_batch:
|
|
480
|
+
for edit in budgeted:
|
|
481
|
+
if _is_permanently_redundant(edit, best_docs):
|
|
482
|
+
rejected.add(_edit_signature(edit))
|
|
483
|
+
note = "known-rejected batch" if known_rejected_batch else "no-op proposal"
|
|
484
|
+
epochs.append(
|
|
485
|
+
EpochRecord(epoch, train_score, best_val_score, None, len(proposal.edits), 0, [], False, note)
|
|
486
|
+
)
|
|
487
|
+
consecutive_noops += 1
|
|
488
|
+
if consecutive_noops >= cfg.noop_patience:
|
|
489
|
+
break # optimizer is stuck producing nothing applicable
|
|
490
|
+
continue
|
|
491
|
+
|
|
492
|
+
consecutive_noops = 0 # an applied batch (accepted or gate-rejected) is productive
|
|
493
|
+
val_after, _ = await _evaluate(candidate_docs, val, rollout, score, cfg.rollout_concurrency)
|
|
494
|
+
accepted = val_after > best_val_score + cfg.min_improvement
|
|
495
|
+
edited_targets = sorted({str(edit.target) for edit in applied})
|
|
496
|
+
if accepted:
|
|
497
|
+
score_before = best_val_score
|
|
498
|
+
best_docs = candidate_docs
|
|
499
|
+
best_val_score = val_after
|
|
500
|
+
cached_train = None # best_docs changed -> the train eval must be recomputed
|
|
501
|
+
accepted_count += len(applied)
|
|
502
|
+
note = "accepted"
|
|
503
|
+
else:
|
|
504
|
+
score_before = best_val_score
|
|
505
|
+
# Gate rejected the batch. Attribute the blame at the right granularity: a lone
|
|
506
|
+
# edit is blacklisted directly; a multi-edit batch blacklists only its
|
|
507
|
+
# *combination* so a good edit isn't banned for a bad partner's sake.
|
|
508
|
+
if len(applied) == 1:
|
|
509
|
+
rejected.add(_edit_signature(applied[0]))
|
|
510
|
+
else:
|
|
511
|
+
rejected.add(_batch_signature(applied))
|
|
512
|
+
rejected_count += len(applied)
|
|
513
|
+
note = "rejected by validation gate"
|
|
514
|
+
|
|
515
|
+
epochs.append(
|
|
516
|
+
EpochRecord(
|
|
517
|
+
epoch, train_score, score_before, val_after,
|
|
518
|
+
len(proposal.edits), len(applied), edited_targets, accepted, note,
|
|
519
|
+
)
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
return SkillOptResult(
|
|
523
|
+
skill_name=skill_name,
|
|
524
|
+
seed_docs=seed_docs,
|
|
525
|
+
best_docs=best_docs,
|
|
526
|
+
seed_val_score=seed_val_score,
|
|
527
|
+
best_val_score=best_val_score,
|
|
528
|
+
epochs=epochs,
|
|
529
|
+
accepted_edit_count=accepted_count,
|
|
530
|
+
rejected_edit_count=rejected_count,
|
|
531
|
+
train_size=len(train),
|
|
532
|
+
val_size=len(val),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
# Router-backed default rollout + optimizer.
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
DEFAULT_OBJECTIVE = (
|
|
541
|
+
"Improve the guidance preamble and the engineering skill document together so a "
|
|
542
|
+
"coding agent following them produces correct, in-scope, well-verified work on the "
|
|
543
|
+
"evaluation tasks. Keep both documents compact and actionable; prefer small, "
|
|
544
|
+
"targeted edits over rewrites, and keep guidance and skill mutually consistent."
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class _AgentAnswer(BaseModel):
|
|
549
|
+
answer: str = ""
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _compose_context(docs: dict[str, str]) -> str:
|
|
553
|
+
blocks = []
|
|
554
|
+
guidance = docs.get(GUIDANCE, "").strip()
|
|
555
|
+
skill = docs.get(SKILL, "").strip()
|
|
556
|
+
if guidance:
|
|
557
|
+
blocks.append(f"<guidance>\n{guidance}\n</guidance>")
|
|
558
|
+
if skill:
|
|
559
|
+
blocks.append(f"<skill>\n{skill}\n</skill>")
|
|
560
|
+
for name, body in docs.items():
|
|
561
|
+
if name in DOC_TARGETS or not body.strip():
|
|
562
|
+
continue
|
|
563
|
+
blocks.append(f"<{name}>\n{body.strip()}\n</{name}>")
|
|
564
|
+
return "\n\n".join(blocks)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _pick_role(role_config: dict[str, Any], preferred: tuple[str, ...]) -> str:
|
|
568
|
+
for role in preferred:
|
|
569
|
+
if role in role_config:
|
|
570
|
+
return role
|
|
571
|
+
if role_config:
|
|
572
|
+
return next(iter(role_config))
|
|
573
|
+
raise ValueError("Router has no configured roles for SkillOpt.")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def make_llm_rollout(
|
|
577
|
+
router: Any,
|
|
578
|
+
*,
|
|
579
|
+
preferred_roles: tuple[str, ...] = ("skill_target", "arbiter", "planner_a"),
|
|
580
|
+
) -> RolloutFn:
|
|
581
|
+
"""Build a rollout that runs the target agent (an LLM) with both documents in context."""
|
|
582
|
+
role = _pick_role(router.role_config, preferred_roles)
|
|
583
|
+
|
|
584
|
+
async def rollout(docs: dict[str, str], task: dict[str, Any]) -> str:
|
|
585
|
+
system = (
|
|
586
|
+
"You are a coding agent. Follow this guidance and engineering skill exactly "
|
|
587
|
+
"when responding.\n\n" + _compose_context(docs)
|
|
588
|
+
)
|
|
589
|
+
messages = [
|
|
590
|
+
{"role": "system", "content": system},
|
|
591
|
+
{"role": "user", "content": _task_prompt(task) or "Complete the task."},
|
|
592
|
+
]
|
|
593
|
+
result = await router.complete_structured(
|
|
594
|
+
role, messages, _AgentAnswer, fallback=_AgentAnswer(answer="")
|
|
595
|
+
)
|
|
596
|
+
return result.answer
|
|
597
|
+
|
|
598
|
+
return rollout
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def make_llm_optimizer(
|
|
602
|
+
router: Any,
|
|
603
|
+
*,
|
|
604
|
+
objective: str = DEFAULT_OBJECTIVE,
|
|
605
|
+
preferred_roles: tuple[str, ...] = ("skill_optimizer", "arbiter", "critic_a"),
|
|
606
|
+
) -> OptimizerFn:
|
|
607
|
+
"""Build an optimizer that proposes bounded edits across guidance + skill at once."""
|
|
608
|
+
role = _pick_role(router.role_config, preferred_roles)
|
|
609
|
+
|
|
610
|
+
async def optimizer(
|
|
611
|
+
docs: dict[str, str], feedback: list[dict[str, Any]], rejected: list[str], skill_name: str
|
|
612
|
+
) -> SkillEditProposal:
|
|
613
|
+
system = (
|
|
614
|
+
"You optimize the documents that steer a coding agent. "
|
|
615
|
+
f"{objective}\n\n"
|
|
616
|
+
"You may edit two documents simultaneously: 'guidance' (the agent preamble) and "
|
|
617
|
+
"'skill' (the engineering skill). Set each edit's 'target' accordingly. Propose a "
|
|
618
|
+
"SMALL set of bounded edits. Each edit anchors on existing text via 'find'. Use "
|
|
619
|
+
"op=replace to swap 'find' for 'text', op=add to insert 'text' after 'find' (empty "
|
|
620
|
+
"'find' appends), op=delete to remove 'find'. Do not rewrite a whole document."
|
|
621
|
+
)
|
|
622
|
+
user = json.dumps(
|
|
623
|
+
{
|
|
624
|
+
"skill_name": skill_name,
|
|
625
|
+
"documents": {name: docs.get(name, "") for name in DOC_TARGETS if name in docs},
|
|
626
|
+
"failing_cases": feedback,
|
|
627
|
+
"previously_rejected_edit_ids": rejected,
|
|
628
|
+
},
|
|
629
|
+
indent=2,
|
|
630
|
+
)
|
|
631
|
+
messages = [
|
|
632
|
+
{"role": "system", "content": system},
|
|
633
|
+
{"role": "user", "content": user},
|
|
634
|
+
]
|
|
635
|
+
return await router.complete_structured(
|
|
636
|
+
role, messages, SkillEditProposal, fallback=SkillEditProposal()
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
return optimizer
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ---------------------------------------------------------------------------
|
|
643
|
+
# Artifact persistence.
|
|
644
|
+
# ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def default_artifact_path(project_root: Path, skill_name: str) -> Path:
|
|
648
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
649
|
+
safe = skill_name.replace("/", "-").replace("\\", "-")
|
|
650
|
+
return project_root / ".devcouncil" / "optimizations" / f"{timestamp}-{safe}-skillopt.json"
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def write_result_artifact(path: Path, result: SkillOptResult, *, objective: str, dataset_path: str) -> None:
|
|
654
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
655
|
+
payload = {
|
|
656
|
+
"optimizer": "devcouncil.skillopt",
|
|
657
|
+
"skill": result.skill_name,
|
|
658
|
+
"objective": objective,
|
|
659
|
+
"dataset_path": dataset_path,
|
|
660
|
+
"train_size": result.train_size,
|
|
661
|
+
"val_size": result.val_size,
|
|
662
|
+
"seed_val_score": result.seed_val_score,
|
|
663
|
+
"best_val_score": result.best_val_score,
|
|
664
|
+
"improved": result.improved,
|
|
665
|
+
"accepted_edit_count": result.accepted_edit_count,
|
|
666
|
+
"rejected_edit_count": result.rejected_edit_count,
|
|
667
|
+
"epochs": [asdict(record) for record in result.epochs],
|
|
668
|
+
"seed_docs": result.seed_docs,
|
|
669
|
+
"best_docs": result.best_docs,
|
|
670
|
+
"applied": result.applied,
|
|
671
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
672
|
+
}
|
|
673
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|