self-evolve-framework 1.3.0 → 1.4.0
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/package.json +1 -1
- package/template/skills/ponytail/SKILL.md +16 -0
- package/template/skills/ponytail/scripts/hooks/claude-codex-hooks.json +44 -0
- package/template/skills/ponytail/scripts/hooks/copilot-hooks.json +21 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-activate.js +91 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-config.js +122 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-instructions.js +94 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-mode-tracker.js +55 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-runtime.js +68 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-statusline.ps1 +21 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-statusline.sh +12 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-subagent.js +22 -0
- package/template/skills/ponytail/scripts/mcp/README.md +46 -0
- package/template/skills/ponytail/scripts/mcp/index.js +48 -0
- package/template/skills/ponytail/scripts/mcp/instructions.js +26 -0
- package/template/skills/ponytail/scripts/mcp/package.json +13 -0
- package/template/skills/ponytail/scripts/mcp/test/instructions.test.js +22 -0
- package/template/skills/skillopt-sleep/SKILL.md +48 -34
- package/template/skills/skillopt-sleep/scripts/python/__init__.py +20 -0
- package/template/skills/skillopt-sleep/scripts/python/__main__.py +343 -0
- package/template/skills/skillopt-sleep/scripts/python/backend.py +1371 -0
- package/template/skills/skillopt-sleep/scripts/python/budget.py +75 -0
- package/template/skills/skillopt-sleep/scripts/python/config.py +162 -0
- package/template/skills/skillopt-sleep/scripts/python/consolidate.py +238 -0
- package/template/skills/skillopt-sleep/scripts/python/cycle.py +291 -0
- package/template/skills/skillopt-sleep/scripts/python/dream.py +138 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/__init__.py +1 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/gbrain_bench.py +119 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/personas.py +86 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/report.py +132 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/run_experiment.py +178 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/run_gbrain.py +209 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/run_transfer.py +155 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/sweep.py +164 -0
- package/template/skills/skillopt-sleep/scripts/python/gate.py +50 -0
- package/template/skills/skillopt-sleep/scripts/python/harvest.py +304 -0
- package/template/skills/skillopt-sleep/scripts/python/harvest_codex.py +253 -0
- package/template/skills/skillopt-sleep/scripts/python/harvest_sources.py +41 -0
- package/template/skills/skillopt-sleep/scripts/python/judges.py +84 -0
- package/template/skills/skillopt-sleep/scripts/python/llm_miner.py +134 -0
- package/template/skills/skillopt-sleep/scripts/python/memory.py +129 -0
- package/template/skills/skillopt-sleep/scripts/python/mine.py +312 -0
- package/template/skills/skillopt-sleep/scripts/python/replay.py +146 -0
- package/template/skills/skillopt-sleep/scripts/python/rollout.py +153 -0
- package/template/skills/skillopt-sleep/scripts/python/scheduler.py +138 -0
- package/template/skills/skillopt-sleep/scripts/python/slow_update.py +142 -0
- package/template/skills/skillopt-sleep/scripts/python/staging.py +103 -0
- package/template/skills/skillopt-sleep/scripts/python/state.py +96 -0
- package/template/skills/skillopt-sleep/scripts/python/tasks_file.py +81 -0
- package/template/skills/skillopt-sleep/scripts/python/types.py +146 -0
- package/template/skills/skillopt-sleep/scripts/shell/__init__.py +0 -0
- package/template/skills/skillopt-sleep/scripts/shell/eval_only.py +466 -0
- package/template/skills/skillopt-sleep/scripts/shell/materialize_searchqa.py +148 -0
- package/template/skills/skillopt-sleep/scripts/shell/run_alfworld.sh +60 -0
- package/template/skills/skillopt-sleep/scripts/shell/run_searchqa.sh +40 -0
- package/template/skills/skillopt-sleep/scripts/shell/run_spreadsheetbench.sh +39 -0
- package/template/skills/skillopt-sleep/scripts/shell/train.py +556 -0
|
@@ -0,0 +1,1371 @@
|
|
|
1
|
+
"""SkillOpt-Sleep — optimizer/replay backend abstraction.
|
|
2
|
+
|
|
3
|
+
A backend supplies the three "intelligent" operations the sleep cycle needs:
|
|
4
|
+
|
|
5
|
+
1. attempt(task, skill, memory) -> response text (the rollout)
|
|
6
|
+
2. judge(task, response) -> (hard, soft, rationale) (the reward)
|
|
7
|
+
3. reflect(failures, successes, skill, memory)
|
|
8
|
+
-> list[EditRecord] (proposed bounded edits)
|
|
9
|
+
|
|
10
|
+
Two implementations:
|
|
11
|
+
* MockBackend — deterministic, no API, used for tests + the experiment.
|
|
12
|
+
Reads optional `reference` exact answers and a tiny
|
|
13
|
+
rule-table so the loop provably improves and the gate
|
|
14
|
+
provably blocks regressions.
|
|
15
|
+
* AnthropicBackend — uses the user's ANTHROPIC_API_KEY via the `claude`
|
|
16
|
+
CLI or the anthropic SDK (lazy-imported). Real lift.
|
|
17
|
+
|
|
18
|
+
The backend never touches live config; it only returns text/edits that the
|
|
19
|
+
consolidation stage gates and stages.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import tempfile
|
|
28
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
from skillopt_sleep.types import EditRecord, ReplayResult, TaskRecord
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def skill_hash(content: str) -> str:
|
|
34
|
+
import hashlib
|
|
35
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Backend protocol ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
class Backend:
|
|
41
|
+
name = "base"
|
|
42
|
+
# Optional user preferences (free text) injected into reflect as a prior.
|
|
43
|
+
preferences: str = ""
|
|
44
|
+
|
|
45
|
+
def attempt(self, task: TaskRecord, skill: str, memory: str,
|
|
46
|
+
sample_id: int = 0) -> str:
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
def attempt_with_tools(
|
|
50
|
+
self, task: TaskRecord, skill: str, memory: str, tools: List[str]
|
|
51
|
+
) -> Tuple[str, List[str]]:
|
|
52
|
+
"""Run the task while exposing real tools; return (response, tools_called).
|
|
53
|
+
|
|
54
|
+
Default: no real tool loop — fall back to plain attempt and let the
|
|
55
|
+
single-shot 'TOOL_CALL: <name>' marker convention surface intent. CLI
|
|
56
|
+
backends override this to expose a genuinely callable tool.
|
|
57
|
+
"""
|
|
58
|
+
resp = self.attempt(task, skill, memory)
|
|
59
|
+
called: List[str] = []
|
|
60
|
+
for t in tools:
|
|
61
|
+
if re.search(r"(?i)\btool_call\s*:\s*%s\b" % re.escape(t), resp):
|
|
62
|
+
called.append(t)
|
|
63
|
+
return resp, called
|
|
64
|
+
|
|
65
|
+
def judge(self, task: TaskRecord, response: str) -> Tuple[float, float, str]:
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
def reflect(
|
|
69
|
+
self,
|
|
70
|
+
failures: List[Tuple[TaskRecord, ReplayResult]],
|
|
71
|
+
successes: List[Tuple[TaskRecord, ReplayResult]],
|
|
72
|
+
skill: str,
|
|
73
|
+
memory: str,
|
|
74
|
+
*,
|
|
75
|
+
edit_budget: int,
|
|
76
|
+
evolve_skill: bool,
|
|
77
|
+
evolve_memory: bool,
|
|
78
|
+
) -> List[EditRecord]:
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
# token accounting (optional)
|
|
82
|
+
def tokens_used(self) -> int:
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Shared scoring helpers ────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def _normalize(s: str) -> str:
|
|
89
|
+
s = (s or "").lower().strip()
|
|
90
|
+
s = re.sub(r"[^\w\s]", " ", s)
|
|
91
|
+
s = re.sub(r"\s+", " ", s)
|
|
92
|
+
return s.strip()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def exact_score(reference: str, response: str) -> float:
|
|
96
|
+
ref = _normalize(reference)
|
|
97
|
+
resp = _normalize(response)
|
|
98
|
+
if not ref:
|
|
99
|
+
return 0.0
|
|
100
|
+
return 1.0 if ref in resp or resp == ref else 0.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def keyword_soft_score(reference: str, response: str) -> float:
|
|
104
|
+
"""Fraction of reference tokens present in response (cheap rubric proxy)."""
|
|
105
|
+
ref_tokens = [t for t in _normalize(reference).split() if len(t) > 2]
|
|
106
|
+
if not ref_tokens:
|
|
107
|
+
return 0.0
|
|
108
|
+
resp = _normalize(response)
|
|
109
|
+
hit = sum(1 for t in set(ref_tokens) if t in resp)
|
|
110
|
+
return hit / len(set(ref_tokens))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Mock backend (deterministic, no API) ──────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
class MockBackend(Backend):
|
|
116
|
+
"""Deterministic backend for tests and the acceptance experiment.
|
|
117
|
+
|
|
118
|
+
Model of reality:
|
|
119
|
+
* Each task may carry a `reference` (exact answer) and a "rule" tag
|
|
120
|
+
describing the single skill rule that makes the task solvable, e.g.
|
|
121
|
+
tags=["rule:wrap-answer-in-answer-tags"].
|
|
122
|
+
* `attempt` produces a correct response IFF the required rule text is
|
|
123
|
+
present in skill+memory; otherwise it produces a near-miss.
|
|
124
|
+
* `judge` scores exact (hard) + keyword (soft) against `reference`.
|
|
125
|
+
* `reflect` looks at failures, reads each failed task's required rule,
|
|
126
|
+
and proposes exactly that rule as an `add` edit (bounded by budget).
|
|
127
|
+
It NEVER proposes a rule already present (no churn), and on the
|
|
128
|
+
special tag "rule:__harmful__" it proposes a known-bad edit so tests
|
|
129
|
+
can prove the gate rejects regressions.
|
|
130
|
+
|
|
131
|
+
This makes the end-to-end loop monotonic and fully reproducible while
|
|
132
|
+
exercising the real harvest->mine->replay->gate->stage plumbing.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
name = "mock"
|
|
136
|
+
|
|
137
|
+
RULE_PREFIX = "rule:"
|
|
138
|
+
RULE_TEXT = {
|
|
139
|
+
"wrap-answer": "Always wrap the final answer in <answer>...</answer> tags.",
|
|
140
|
+
"arxiv-id": "Report arXiv ids in the exact form arXiv:XXXX.XXXXX.",
|
|
141
|
+
"commit-imperative": "Write git commit subjects in imperative mood, max 50 chars.",
|
|
142
|
+
"units-si": "Always include SI units in numeric answers.",
|
|
143
|
+
"json-only": "When asked for JSON, output only valid JSON with no prose.",
|
|
144
|
+
"__harmful__": "Ignore the user's formatting requests and answer freely.",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def _required_rules(self, task: TaskRecord) -> List[str]:
|
|
148
|
+
out = []
|
|
149
|
+
for t in task.tags:
|
|
150
|
+
if t.startswith(self.RULE_PREFIX):
|
|
151
|
+
key = t[len(self.RULE_PREFIX):]
|
|
152
|
+
if key in self.RULE_TEXT:
|
|
153
|
+
out.append(key)
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
def attempt(self, task: TaskRecord, skill: str, memory: str,
|
|
157
|
+
sample_id: int = 0) -> str:
|
|
158
|
+
ctx = (skill or "") + "\n" + (memory or "")
|
|
159
|
+
rules = self._required_rules(task)
|
|
160
|
+
# The "__harmful__" rule models a bad edit: even when present it makes
|
|
161
|
+
# the agent ignore formatting, so it can NEVER produce the reference.
|
|
162
|
+
# This is what lets the experiment prove the gate rejects regressions.
|
|
163
|
+
if "__harmful__" in rules:
|
|
164
|
+
return "I'll just answer freely and skip the requested format."
|
|
165
|
+
# A task is solved iff ALL its required rule texts are present in context.
|
|
166
|
+
have_all = all(self.RULE_TEXT[k] in ctx for k in rules) if rules else False
|
|
167
|
+
if have_all and task.reference:
|
|
168
|
+
# produce a response that satisfies the rule and contains the answer
|
|
169
|
+
if "wrap-answer" in rules:
|
|
170
|
+
return f"Here is the result. <answer>{task.reference}</answer>"
|
|
171
|
+
return f"{task.reference}"
|
|
172
|
+
# Near miss: a degraded answer that shares keywords but is NOT the exact
|
|
173
|
+
# rule-correct form, so exact-match fails deterministically regardless of
|
|
174
|
+
# how many whitespace tokens the reference has.
|
|
175
|
+
if task.reference:
|
|
176
|
+
ref = task.reference
|
|
177
|
+
mangled = ref[:-2] if len(ref) > 3 else "unknown"
|
|
178
|
+
return f"approximately {mangled} (format not applied)"
|
|
179
|
+
return "(attempted, no checkable reference)"
|
|
180
|
+
|
|
181
|
+
def attempt_with_tools(self, task, skill, memory, tools):
|
|
182
|
+
# Deterministic tool model: the mock "calls" a tool iff the skill+memory
|
|
183
|
+
# contains an explicit instruction to use it (a learned rule mentioning
|
|
184
|
+
# the tool name or "search"). The deficient skill says NOT to, so
|
|
185
|
+
# baseline calls nothing; a learned "use ./search" rule flips it.
|
|
186
|
+
ctx = ((skill or "") + "\n" + (memory or "")).lower()
|
|
187
|
+
resp = self.attempt(task, skill, memory)
|
|
188
|
+
called = []
|
|
189
|
+
for t in (tools or []):
|
|
190
|
+
tl = t.lower()
|
|
191
|
+
if (f"./{tl}" in ctx or f"use {tl}" in ctx or f"run {tl}" in ctx
|
|
192
|
+
or f"call {tl}" in ctx or f"must {tl}" in ctx):
|
|
193
|
+
called.append(t)
|
|
194
|
+
return resp, called
|
|
195
|
+
|
|
196
|
+
def judge(self, task: TaskRecord, response: str) -> Tuple[float, float, str]:
|
|
197
|
+
if task.reference_kind == "answer" and task.judge:
|
|
198
|
+
try:
|
|
199
|
+
from skillopt_sleep.experiments.real_eval import score_answer_judge
|
|
200
|
+
except ImportError:
|
|
201
|
+
score_answer_judge = None # research evaluators not bundled
|
|
202
|
+
if score_answer_judge is not None:
|
|
203
|
+
return score_answer_judge(task.judge, response)
|
|
204
|
+
if task.reference_kind == "rule" and task.judge:
|
|
205
|
+
from skillopt_sleep.judges import score_rule_judge
|
|
206
|
+
return score_rule_judge(task.judge, response)
|
|
207
|
+
if task.reference_kind == "exact" and task.reference:
|
|
208
|
+
hard = exact_score(task.reference, response)
|
|
209
|
+
soft = max(hard, keyword_soft_score(task.reference, response))
|
|
210
|
+
return hard, soft, f"exact-match={hard}"
|
|
211
|
+
if task.reference_kind == "rubric" and task.reference:
|
|
212
|
+
soft = keyword_soft_score(task.reference, response)
|
|
213
|
+
return (1.0 if soft >= 0.8 else 0.0), soft, f"rubric keyword soft={soft:.2f}"
|
|
214
|
+
# no reference: outcome-derived weak label
|
|
215
|
+
hard = 1.0 if task.outcome == "success" else 0.0
|
|
216
|
+
return hard, hard, "outcome-derived"
|
|
217
|
+
|
|
218
|
+
def reflect(
|
|
219
|
+
self,
|
|
220
|
+
failures,
|
|
221
|
+
successes,
|
|
222
|
+
skill: str,
|
|
223
|
+
memory: str,
|
|
224
|
+
*,
|
|
225
|
+
edit_budget: int,
|
|
226
|
+
evolve_skill: bool,
|
|
227
|
+
evolve_memory: bool,
|
|
228
|
+
) -> List[EditRecord]:
|
|
229
|
+
ctx = (skill or "") + "\n" + (memory or "")
|
|
230
|
+
edits: List[EditRecord] = []
|
|
231
|
+
seen_text: set = set()
|
|
232
|
+
target = "skill" if evolve_skill else "memory"
|
|
233
|
+
for task, _res in failures:
|
|
234
|
+
for key in self._required_rules(task):
|
|
235
|
+
text = self.RULE_TEXT[key]
|
|
236
|
+
if text in ctx or text in seen_text:
|
|
237
|
+
continue
|
|
238
|
+
seen_text.add(text)
|
|
239
|
+
edits.append(
|
|
240
|
+
EditRecord(
|
|
241
|
+
target=target,
|
|
242
|
+
op="add",
|
|
243
|
+
content=text,
|
|
244
|
+
rationale=f"failed task {task.id} requires rule '{key}'",
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
if len(edits) >= edit_budget:
|
|
248
|
+
return edits
|
|
249
|
+
return edits
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── Shared real-CLI backend (prompts + parsing + cache; subclasses do _call) ──
|
|
253
|
+
|
|
254
|
+
def _extract_json(raw: str, kind: str):
|
|
255
|
+
"""Pull the first JSON object/array out of a possibly chatty CLI reply."""
|
|
256
|
+
pat = r"\{.*\}" if kind == "object" else r"\[.*\]"
|
|
257
|
+
m = re.search(pat, raw or "", re.DOTALL)
|
|
258
|
+
if not m:
|
|
259
|
+
return None
|
|
260
|
+
try:
|
|
261
|
+
return json.loads(m.group(0))
|
|
262
|
+
except Exception:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _task_guardrail(pairs) -> str:
|
|
267
|
+
"""Build an 'output contract' the optimizer must not violate.
|
|
268
|
+
|
|
269
|
+
``pairs`` is a list of (TaskRecord, ReplayResult). We surface the benchmark's
|
|
270
|
+
own rollout system prompt (TaskRecord.system) plus a short, explicit list of
|
|
271
|
+
invariants, so the optimizer cannot learn rules that the evaluator can never
|
|
272
|
+
honor (the SpreadsheetBench failure mode: a learned "return ```vba```" or
|
|
273
|
+
"ask the user for the range" rule scores 0 because the harness runs only
|
|
274
|
+
```python``` openpyxl and cannot answer questions).
|
|
275
|
+
|
|
276
|
+
Returns "" when no task carries a system contract (e.g. mined daily cases),
|
|
277
|
+
so non-benchmark runs are unchanged.
|
|
278
|
+
"""
|
|
279
|
+
sys_txt = ""
|
|
280
|
+
for t, _ in pairs:
|
|
281
|
+
s = getattr(t, "system", "") or ""
|
|
282
|
+
if s.strip():
|
|
283
|
+
sys_txt = s.strip()
|
|
284
|
+
break
|
|
285
|
+
if not sys_txt:
|
|
286
|
+
return ""
|
|
287
|
+
# the system prompt can be long; keep the rules portion concise for the optimizer
|
|
288
|
+
contract = sys_txt
|
|
289
|
+
if len(contract) > 900:
|
|
290
|
+
contract = contract[:900] + " …"
|
|
291
|
+
invariants = (
|
|
292
|
+
"- Do NOT change the required output format or programming language.\n"
|
|
293
|
+
"- Do NOT tell the agent to ask the user a question or request more info; "
|
|
294
|
+
"it must always produce a best-effort answer from what is given.\n"
|
|
295
|
+
"- Keep every rule consistent with the contract above."
|
|
296
|
+
)
|
|
297
|
+
return (
|
|
298
|
+
"\n# Task output contract (rules MUST obey this — violating it scores 0)\n"
|
|
299
|
+
f"{contract}\n{invariants}\n"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class CliBackend(Backend):
|
|
304
|
+
"""Common logic for real CLI-driven backends (claude / codex).
|
|
305
|
+
|
|
306
|
+
Subclasses implement only ``_call(prompt) -> str``. This base owns the
|
|
307
|
+
prompts (attempt / judge / reflect), JSON parsing, a response cache (so
|
|
308
|
+
re-scoring an unchanged (skill, memory) on the held-out slice is free),
|
|
309
|
+
and a rough token estimate.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
name = "cli"
|
|
313
|
+
|
|
314
|
+
def __init__(self, model: str = "", timeout: int = 180) -> None:
|
|
315
|
+
self.model = model
|
|
316
|
+
self.timeout = timeout
|
|
317
|
+
self._tokens = 0
|
|
318
|
+
self._cache: Dict[str, str] = {}
|
|
319
|
+
self.last_call_error = ""
|
|
320
|
+
self.last_reflect_raw = ""
|
|
321
|
+
|
|
322
|
+
# subclasses override --------------------------------------------------
|
|
323
|
+
def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
|
|
324
|
+
raise NotImplementedError
|
|
325
|
+
|
|
326
|
+
def _cached_call(self, key: str, prompt: str, *, max_tokens: int = 1024) -> str:
|
|
327
|
+
if key in self._cache:
|
|
328
|
+
return self._cache[key]
|
|
329
|
+
out = self._call(prompt, max_tokens=max_tokens)
|
|
330
|
+
self._tokens += len(prompt) // 4 + len(out) // 4
|
|
331
|
+
self._cache[key] = out
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
# operations -----------------------------------------------------------
|
|
335
|
+
def attempt(self, task: TaskRecord, skill: str, memory: str,
|
|
336
|
+
sample_id: int = 0) -> str:
|
|
337
|
+
# sample_id distinguishes repeated rollouts of the SAME (task, skill,
|
|
338
|
+
# memory) in the cache key. Without it the attempt cache collapses all
|
|
339
|
+
# K dream rollouts into one cached response (spread always 0), which
|
|
340
|
+
# silently disables contrastive reflection. sample_id=0 keeps the old
|
|
341
|
+
# key format so gate re-scoring still benefits from the cache.
|
|
342
|
+
if task.system:
|
|
343
|
+
# Benchmark carries its own (research-repo) rollout system prompt.
|
|
344
|
+
# Use it verbatim with a neutral skill/memory section — this both
|
|
345
|
+
# keeps scoring faithful and avoids the aggressive "OVERRIDE / HARD
|
|
346
|
+
# CONSTRAINT" phrasing below, which Azure's content filter flags as a
|
|
347
|
+
# jailbreak (HTTP 400) and silently zeroes the rollout.
|
|
348
|
+
skill_section = f"## Skill\n{skill.strip()}\n\n" if skill.strip() else ""
|
|
349
|
+
mem_section = f"## Memory\n{memory.strip()}\n\n" if memory.strip() else ""
|
|
350
|
+
system = task.system.replace("{skill_section}", skill_section)
|
|
351
|
+
if "{skill_section}" not in task.system and skill_section:
|
|
352
|
+
system = skill_section + system
|
|
353
|
+
body = task.intent + ("\n\n" + task.context_excerpt if task.context_excerpt else "")
|
|
354
|
+
prompt = f"{system}{mem_section}\n{body}"
|
|
355
|
+
salt = f"s{sample_id}:" if sample_id else ""
|
|
356
|
+
key = "attempt:" + salt + skill_hash(prompt)
|
|
357
|
+
return self._cached_call(key, prompt, max_tokens=512)
|
|
358
|
+
# generic path (mined daily-case tasks): neutral, content-filter-safe
|
|
359
|
+
# wording. Apply the skill/memory as guidance, not as adversarial
|
|
360
|
+
# "OVERRIDE everything" directives.
|
|
361
|
+
prompt = (
|
|
362
|
+
"Complete the following task for the user. Follow the skill and memory "
|
|
363
|
+
"guidance below, including any output-format and length requirements. "
|
|
364
|
+
"When a 'Learned preferences' rule sets an explicit limit (e.g. a length "
|
|
365
|
+
"cap), prefer that rule over more general advice it refines.\n\n"
|
|
366
|
+
f"# Skill\n{skill or '(none)'}\n\n# Memory\n{memory or '(none)'}\n\n"
|
|
367
|
+
f"# Task\n{task.intent}\n\n{task.context_excerpt}\n\n"
|
|
368
|
+
"Return ONLY the final answer text, nothing else."
|
|
369
|
+
)
|
|
370
|
+
# cache on (task, skill, memory) so identical hold-out re-scoring is free
|
|
371
|
+
salt = f"s{sample_id}:" if sample_id else ""
|
|
372
|
+
key = "attempt:" + salt + skill_hash(prompt)
|
|
373
|
+
return self._cached_call(key, prompt, max_tokens=512)
|
|
374
|
+
|
|
375
|
+
def judge(self, task: TaskRecord, response: str) -> Tuple[float, float, str]:
|
|
376
|
+
# real-benchmark correctness judge (searchqa/livemath/spreadsheet) — local
|
|
377
|
+
if task.reference_kind == "answer" and task.judge:
|
|
378
|
+
try:
|
|
379
|
+
from skillopt_sleep.experiments.real_eval import score_answer_judge
|
|
380
|
+
except ImportError:
|
|
381
|
+
score_answer_judge = None # research evaluators not bundled
|
|
382
|
+
if score_answer_judge is not None:
|
|
383
|
+
return score_answer_judge(task.judge, response)
|
|
384
|
+
# gbrain-style rule judge: scored locally, no API spend
|
|
385
|
+
if task.reference_kind == "rule" and task.judge:
|
|
386
|
+
from skillopt_sleep.judges import score_rule_judge
|
|
387
|
+
return score_rule_judge(task.judge, response)
|
|
388
|
+
# exact references are scored locally — no API spend
|
|
389
|
+
if task.reference_kind == "exact" and task.reference:
|
|
390
|
+
hard = exact_score(task.reference, response)
|
|
391
|
+
return hard, max(hard, keyword_soft_score(task.reference, response)), "exact(local)"
|
|
392
|
+
prompt = (
|
|
393
|
+
"Score how well the response satisfies the rubric, 0..1. "
|
|
394
|
+
'Return ONLY JSON {"score": <0..1>, "reason": "..."}.\n\n'
|
|
395
|
+
f"# Rubric\n{task.reference or task.intent}\n\n# Response\n{response}"
|
|
396
|
+
)
|
|
397
|
+
key = "judge:" + skill_hash(prompt)
|
|
398
|
+
raw = self._cached_call(key, prompt, max_tokens=200)
|
|
399
|
+
obj = _extract_json(raw, "object")
|
|
400
|
+
if isinstance(obj, dict):
|
|
401
|
+
try:
|
|
402
|
+
soft = float(obj.get("score", 0.0))
|
|
403
|
+
return (1.0 if soft >= 0.8 else 0.0), soft, str(obj.get("reason", ""))[:200]
|
|
404
|
+
except Exception:
|
|
405
|
+
pass
|
|
406
|
+
return 0.0, 0.0, "judge-parse-failed"
|
|
407
|
+
|
|
408
|
+
def reflect(
|
|
409
|
+
self,
|
|
410
|
+
failures,
|
|
411
|
+
successes,
|
|
412
|
+
skill: str,
|
|
413
|
+
memory: str,
|
|
414
|
+
*,
|
|
415
|
+
edit_budget: int,
|
|
416
|
+
evolve_skill: bool,
|
|
417
|
+
evolve_memory: bool,
|
|
418
|
+
) -> List[EditRecord]:
|
|
419
|
+
if not failures:
|
|
420
|
+
return []
|
|
421
|
+
target = "skill" if evolve_skill else "memory"
|
|
422
|
+
cur_doc = (skill if target == "skill" else memory) or "(empty)"
|
|
423
|
+
fail_text = "\n".join(
|
|
424
|
+
f"- wanted: {t.intent[:160]}\n got: {r.response[:160]}\n why-wrong: {r.fail_reason[:160]}"
|
|
425
|
+
for t, r in failures[:8]
|
|
426
|
+
)
|
|
427
|
+
# Aggregate the most common failing criteria across all failures so the
|
|
428
|
+
# optimizer is told *exactly what the scorer rewards* — gbrain's lesson:
|
|
429
|
+
# the optimizer kept proposing reasonable-but-wrong edits until it could
|
|
430
|
+
# see the success criteria.
|
|
431
|
+
from collections import Counter
|
|
432
|
+
crit = Counter()
|
|
433
|
+
for _t, r in failures:
|
|
434
|
+
fr = r.fail_reason or ""
|
|
435
|
+
if fr.startswith("failed:"):
|
|
436
|
+
for part in fr[len("failed:"):].split(","):
|
|
437
|
+
part = part.strip()
|
|
438
|
+
if part:
|
|
439
|
+
crit[part] += 1
|
|
440
|
+
|
|
441
|
+
def _explain(c: str) -> str:
|
|
442
|
+
# translate an "op=arg" criterion into a plain-English requirement
|
|
443
|
+
if "=" in c:
|
|
444
|
+
op, _, arg = c.partition("=")
|
|
445
|
+
op = op.strip(); arg = arg.strip()
|
|
446
|
+
if op == "max_chars":
|
|
447
|
+
return f"the ENTIRE response must be at most {arg} characters long"
|
|
448
|
+
if op == "min_chars":
|
|
449
|
+
return f"the response must be at least {arg} characters long"
|
|
450
|
+
if op == "section_present":
|
|
451
|
+
return f"the response must contain a section/heading titled '{arg}'"
|
|
452
|
+
if op == "regex":
|
|
453
|
+
return f"the response must match the pattern /{arg}/ (e.g. include that label)"
|
|
454
|
+
if op == "contains":
|
|
455
|
+
return f"the response must contain the text '{arg}'"
|
|
456
|
+
if op == "tool_called":
|
|
457
|
+
return f"the agent must actually call the '{arg}' tool"
|
|
458
|
+
return c
|
|
459
|
+
|
|
460
|
+
criteria_text = ""
|
|
461
|
+
if crit:
|
|
462
|
+
criteria_text = (
|
|
463
|
+
"\n# Exact criteria the outputs are FAILING (fix these directly)\n"
|
|
464
|
+
+ "\n".join(f"- {_explain(c)} [{c}, failed {n}x]" for c, n in crit.most_common())
|
|
465
|
+
)
|
|
466
|
+
pref_text = ""
|
|
467
|
+
if getattr(self, "preferences", ""):
|
|
468
|
+
pref_text = (
|
|
469
|
+
"\n# User preferences (honor these as priors when writing rules)\n"
|
|
470
|
+
+ str(self.preferences).strip()
|
|
471
|
+
)
|
|
472
|
+
# Task GUARDRAIL: the optimizer must not invent rules that violate the
|
|
473
|
+
# task's hard constraints (e.g. SpreadsheetBench answers MUST be a
|
|
474
|
+
# ```python``` openpyxl block — a learned "return ```vba```" or "ask the
|
|
475
|
+
# user for the range" rule scores 0 because the harness can't run VBA and
|
|
476
|
+
# can't ask questions). We surface the benchmark's own rollout system
|
|
477
|
+
# prompt (carried on TaskRecord.system) so proposed rules stay in-bounds.
|
|
478
|
+
guard_text = _task_guardrail(failures)
|
|
479
|
+
prompt = (
|
|
480
|
+
"You are SkillOpt's optimizer. The agent keeps failing the recurring "
|
|
481
|
+
f"tasks below. Propose at most {edit_budget} bounded edits to the "
|
|
482
|
+
f"{target} document so it stops failing. Each edit MUST be a short, "
|
|
483
|
+
"GENERAL, reusable rule or preference (never task-specific, never an "
|
|
484
|
+
"answer to a single task). If exact failing criteria are listed, your "
|
|
485
|
+
"edits MUST make future outputs satisfy every one of them.\n"
|
|
486
|
+
"BE CONCRETE: quote the exact threshold, section name, or format from "
|
|
487
|
+
"the criteria verbatim in your rule (e.g. write 'keep the entire "
|
|
488
|
+
"response under 1200 characters', NOT 'respect length limits'). Vague "
|
|
489
|
+
"rules do not change behavior; specific numeric/structural rules do.\n"
|
|
490
|
+
"IMPORTANT: your edits are APPENDED to a 'Learned preferences' block; "
|
|
491
|
+
"you CANNOT delete the existing instructions above. If the current "
|
|
492
|
+
f"{target} text conflicts with a criterion (e.g. it says 'be exhaustive' "
|
|
493
|
+
"but outputs must be under a character limit), write an explicit, "
|
|
494
|
+
"forceful OVERRIDE rule stating it supersedes the conflicting "
|
|
495
|
+
"instruction, and put the hard requirement first.\n"
|
|
496
|
+
"HARD CONSTRAINT: every rule you write MUST be consistent with the "
|
|
497
|
+
"'Task output contract' below (if shown). NEVER propose a rule that "
|
|
498
|
+
"changes the required output format/language, tells the agent to ask "
|
|
499
|
+
"the user a question, or otherwise violates that contract — such a "
|
|
500
|
+
"rule scores ZERO because the evaluator cannot honor it.\n"
|
|
501
|
+
'Return ONLY a JSON array: '
|
|
502
|
+
'[{"op":"add|replace|delete","content":"<rule>","anchor":"<text to replace/delete, optional>","rationale":"<why>"}].\n\n'
|
|
503
|
+
f"# Current {target}\n{cur_doc}\n"
|
|
504
|
+
f"{guard_text}"
|
|
505
|
+
f"{criteria_text}\n"
|
|
506
|
+
f"{pref_text}\n\n"
|
|
507
|
+
f"# Recurring failures\n{fail_text}"
|
|
508
|
+
)
|
|
509
|
+
# Call with one retry: transient non-JSON replies otherwise waste a whole
|
|
510
|
+
# night (the gate sees no edits and rejects). A firmer second prompt
|
|
511
|
+
# recovers most of these.
|
|
512
|
+
arr = None
|
|
513
|
+
for attempt in range(2):
|
|
514
|
+
p = prompt if attempt == 0 else (
|
|
515
|
+
prompt + "\n\nIMPORTANT: your previous reply was not valid JSON. "
|
|
516
|
+
"Reply with ONLY the JSON array, no prose, no markdown fences."
|
|
517
|
+
)
|
|
518
|
+
raw = self._call(p, max_tokens=1024)
|
|
519
|
+
self._tokens += len(p) // 4 + len(raw) // 4
|
|
520
|
+
arr = _extract_json(raw, "array")
|
|
521
|
+
if isinstance(arr, list) and arr:
|
|
522
|
+
break
|
|
523
|
+
edits: List[EditRecord] = []
|
|
524
|
+
if isinstance(arr, list):
|
|
525
|
+
for e in arr[:edit_budget]:
|
|
526
|
+
if not isinstance(e, dict):
|
|
527
|
+
continue
|
|
528
|
+
content = str(e.get("content", "")).strip()
|
|
529
|
+
if not content:
|
|
530
|
+
continue
|
|
531
|
+
edits.append(EditRecord(
|
|
532
|
+
target=target,
|
|
533
|
+
op=str(e.get("op", "add")).strip().lower(),
|
|
534
|
+
content=content,
|
|
535
|
+
anchor=str(e.get("anchor", "")).strip(),
|
|
536
|
+
rationale=str(e.get("rationale", "")).strip(),
|
|
537
|
+
))
|
|
538
|
+
return edits
|
|
539
|
+
|
|
540
|
+
def tokens_used(self) -> int:
|
|
541
|
+
return self._tokens
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ── Claude Code CLI backend ───────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
class ClaudeCliBackend(CliBackend):
|
|
547
|
+
"""Drives the authenticated `claude` CLI: claude -p --output-format text."""
|
|
548
|
+
|
|
549
|
+
name = "claude"
|
|
550
|
+
|
|
551
|
+
def __init__(self, model: str = "", claude_path: str = "claude", timeout: int = 180) -> None:
|
|
552
|
+
super().__init__(model=model or os.environ.get("SKILLOPT_SLEEP_CLAUDE_MODEL", "") or "sonnet",
|
|
553
|
+
timeout=timeout)
|
|
554
|
+
self.claude_path = claude_path
|
|
555
|
+
|
|
556
|
+
# Known CLI error prefixes that indicate auth or config failures.
|
|
557
|
+
# When detected, we log a warning so the user doesn't mistake a
|
|
558
|
+
# broken auth for "nothing to optimize" (issue #68).
|
|
559
|
+
# Keep these specific to avoid false positives on normal model output.
|
|
560
|
+
_CLI_ERROR_MARKERS = (
|
|
561
|
+
"Not logged in",
|
|
562
|
+
"Please run /login",
|
|
563
|
+
"Authentication required",
|
|
564
|
+
"Invalid API key",
|
|
565
|
+
"Unauthorized: invalid x-api-key",
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
def _detect_cli_error(self, stdout: str, stderr: str) -> None:
|
|
569
|
+
"""Log a warning if CLI output looks like an auth/config error.
|
|
570
|
+
|
|
571
|
+
Only checks stderr and short stdout (< 300 chars) to avoid
|
|
572
|
+
false-positives on legitimate model responses that mention
|
|
573
|
+
auth-related terms.
|
|
574
|
+
"""
|
|
575
|
+
import logging
|
|
576
|
+
# Long stdout is almost certainly a real model response, not an error.
|
|
577
|
+
check_stdout = stdout if len(stdout) < 300 else ""
|
|
578
|
+
combined = check_stdout + "\n" + stderr
|
|
579
|
+
for marker in self._CLI_ERROR_MARKERS:
|
|
580
|
+
if marker in combined:
|
|
581
|
+
logging.getLogger("skillopt_sleep").warning(
|
|
582
|
+
"Claude CLI returned a likely auth error: %s",
|
|
583
|
+
combined[:200].replace("\n", " "),
|
|
584
|
+
)
|
|
585
|
+
self.last_call_error = combined[:500]
|
|
586
|
+
return
|
|
587
|
+
|
|
588
|
+
def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
|
|
589
|
+
# Run ISOLATED so the ambient Claude Code environment does not leak into
|
|
590
|
+
# the optimizer/target call. Critically, the user's GLOBAL skills
|
|
591
|
+
# (~/.claude/skills) are injected regardless of cwd, so we must disable
|
|
592
|
+
# them explicitly — without this, reflect/attempt sometimes reply with a
|
|
593
|
+
# list of the user's installed skills instead of doing the task.
|
|
594
|
+
# --bare skip hooks, LSP, plugins (minimal mode)
|
|
595
|
+
# Only safe with ANTHROPIC_API_KEY auth;
|
|
596
|
+
# breaks subscription-token auth (#68).
|
|
597
|
+
# --disable-slash-commands disable all skills
|
|
598
|
+
# --disallowedTools '*' no tool use
|
|
599
|
+
# --exclude-dynamic-... drop per-machine cwd/env/memory/git sections
|
|
600
|
+
# cwd=<clean temp> no project CLAUDE.md
|
|
601
|
+
import tempfile
|
|
602
|
+
cmd = [self.claude_path, "-p", "--output-format", "text"]
|
|
603
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
604
|
+
cmd.append("--bare")
|
|
605
|
+
cmd += [
|
|
606
|
+
"--disable-slash-commands",
|
|
607
|
+
"--disallowedTools", "*",
|
|
608
|
+
"--exclude-dynamic-system-prompt-sections",
|
|
609
|
+
]
|
|
610
|
+
if self.model:
|
|
611
|
+
cmd += ["--model", self.model]
|
|
612
|
+
cmd += ["--", prompt]
|
|
613
|
+
clean_cwd = tempfile.mkdtemp(prefix="skillopt_sleep_claude_")
|
|
614
|
+
try:
|
|
615
|
+
proc = subprocess.run(
|
|
616
|
+
cmd, capture_output=True, text=True, timeout=self.timeout, cwd=clean_cwd,
|
|
617
|
+
)
|
|
618
|
+
except Exception:
|
|
619
|
+
return ""
|
|
620
|
+
finally:
|
|
621
|
+
try:
|
|
622
|
+
import shutil
|
|
623
|
+
shutil.rmtree(clean_cwd, ignore_errors=True)
|
|
624
|
+
except Exception:
|
|
625
|
+
pass
|
|
626
|
+
out = (proc.stdout or "").strip()
|
|
627
|
+
self._detect_cli_error(out, proc.stderr or "")
|
|
628
|
+
return out
|
|
629
|
+
|
|
630
|
+
def attempt_with_tools(self, task, skill, memory, tools):
|
|
631
|
+
# Expose a REAL, callable `search` tool (a shell shim that logs each
|
|
632
|
+
# call) so the gbrain quick-answerer judge (tool_called=search) is
|
|
633
|
+
# validated honestly: we detect the call from the shim's log, not from
|
|
634
|
+
# a self-reported marker. Other tools are stubbed the same way.
|
|
635
|
+
import tempfile, shutil, stat
|
|
636
|
+
work = tempfile.mkdtemp(prefix="skillopt_sleep_tools_")
|
|
637
|
+
calllog = os.path.join(work, "_tool_calls.log")
|
|
638
|
+
try:
|
|
639
|
+
for tname in (tools or ["search"]):
|
|
640
|
+
shim = os.path.join(work, tname)
|
|
641
|
+
with open(shim, "w") as f:
|
|
642
|
+
f.write(
|
|
643
|
+
"#!/usr/bin/env bash\n"
|
|
644
|
+
f'echo "{tname}" >> "{calllog}"\n'
|
|
645
|
+
'echo "(search results: 3 relevant notes found; use them to answer)"\n'
|
|
646
|
+
)
|
|
647
|
+
os.chmod(shim, os.stat(shim).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
648
|
+
tool_hint = (
|
|
649
|
+
"You have shell tools available in the current directory: "
|
|
650
|
+
+ ", ".join(f"./{t}" for t in (tools or ["search"]))
|
|
651
|
+
+ ". When the skill says to look something up or search before "
|
|
652
|
+
"answering, you MUST actually run the tool (e.g. `./search \"query\"`) "
|
|
653
|
+
"via Bash before giving your final answer."
|
|
654
|
+
)
|
|
655
|
+
prompt = (
|
|
656
|
+
"You are completing a task. Apply the skill and memory rules EXACTLY, "
|
|
657
|
+
"including any rule about searching/looking up before answering. "
|
|
658
|
+
"Treat a 'Learned preferences' block as HARD CONSTRAINTS that override "
|
|
659
|
+
"earlier conflicting skill text.\n\n"
|
|
660
|
+
f"{tool_hint}\n\n"
|
|
661
|
+
f"# Skill\n{skill or '(none)'}\n\n# Memory\n{memory or '(none)'}\n\n"
|
|
662
|
+
f"# Task\n{task.intent}\n\n{task.context_excerpt}\n\n"
|
|
663
|
+
"Return ONLY the final answer text."
|
|
664
|
+
)
|
|
665
|
+
cmd = [self.claude_path, "-p", "--output-format", "text"]
|
|
666
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
667
|
+
cmd.append("--bare")
|
|
668
|
+
cmd += [
|
|
669
|
+
"--disable-slash-commands",
|
|
670
|
+
"--allowedTools", "Bash",
|
|
671
|
+
"--exclude-dynamic-system-prompt-sections",
|
|
672
|
+
]
|
|
673
|
+
if self.model:
|
|
674
|
+
cmd += ["--model", self.model]
|
|
675
|
+
cmd += ["--", prompt]
|
|
676
|
+
try:
|
|
677
|
+
proc = subprocess.run(
|
|
678
|
+
cmd, capture_output=True, text=True, timeout=self.timeout, cwd=work,
|
|
679
|
+
)
|
|
680
|
+
resp = (proc.stdout or "").strip()
|
|
681
|
+
self._detect_cli_error(resp, proc.stderr or "")
|
|
682
|
+
except Exception:
|
|
683
|
+
resp = ""
|
|
684
|
+
self._tokens += len(prompt) // 4 + len(resp) // 4
|
|
685
|
+
called: List[str] = []
|
|
686
|
+
if os.path.exists(calllog):
|
|
687
|
+
with open(calllog) as f:
|
|
688
|
+
logged = {ln.strip() for ln in f if ln.strip()}
|
|
689
|
+
called = [t for t in (tools or ["search"]) if t in logged]
|
|
690
|
+
return resp, called
|
|
691
|
+
finally:
|
|
692
|
+
try:
|
|
693
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
694
|
+
except Exception:
|
|
695
|
+
pass
|
|
696
|
+
|
|
697
|
+
def resolve_codex_path(explicit: str = "") -> str:
|
|
698
|
+
"""Find the REAL `@openai/codex` binary, skipping the hermes wrapper.
|
|
699
|
+
|
|
700
|
+
The wrapper at ~/.local/bin/codex is a shell shim that execs hermes-codex
|
|
701
|
+
and injects extra output; we look past it for the genuine node-installed
|
|
702
|
+
binary so replay output is clean.
|
|
703
|
+
"""
|
|
704
|
+
if explicit:
|
|
705
|
+
return explicit
|
|
706
|
+
env = os.environ.get("SKILLOPT_SLEEP_CODEX_PATH")
|
|
707
|
+
if env:
|
|
708
|
+
return env
|
|
709
|
+
candidates = [
|
|
710
|
+
os.path.expanduser("~/.nvm/versions/node/v22.22.3/bin/codex"),
|
|
711
|
+
]
|
|
712
|
+
# any nvm node version
|
|
713
|
+
nvm = os.path.expanduser("~/.nvm/versions/node")
|
|
714
|
+
if os.path.isdir(nvm):
|
|
715
|
+
for ver in sorted(os.listdir(nvm), reverse=True):
|
|
716
|
+
candidates.append(os.path.join(nvm, ver, "bin", "codex"))
|
|
717
|
+
for c in candidates:
|
|
718
|
+
if not c or not os.path.exists(c):
|
|
719
|
+
continue
|
|
720
|
+
try:
|
|
721
|
+
with open(c, "rb") as f:
|
|
722
|
+
head = f.read(64)
|
|
723
|
+
# skip the bash shim that execs hermes
|
|
724
|
+
if head.startswith(b"#!") and b"bash" in head:
|
|
725
|
+
continue
|
|
726
|
+
except Exception:
|
|
727
|
+
pass
|
|
728
|
+
return c
|
|
729
|
+
return "codex" # last resort (may be the wrapper)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
class CodexCliBackend(CliBackend):
|
|
733
|
+
"""Drives the real Codex CLI: `codex exec -o <file>` for clean output."""
|
|
734
|
+
|
|
735
|
+
name = "codex"
|
|
736
|
+
|
|
737
|
+
def __init__(
|
|
738
|
+
self,
|
|
739
|
+
model: str = "",
|
|
740
|
+
codex_path: str = "",
|
|
741
|
+
timeout: int = 240,
|
|
742
|
+
sandbox: str = "read-only",
|
|
743
|
+
project_dir: str = "",
|
|
744
|
+
) -> None:
|
|
745
|
+
super().__init__(model=model or os.environ.get("SKILLOPT_SLEEP_CODEX_MODEL", ""),
|
|
746
|
+
timeout=timeout)
|
|
747
|
+
self.codex_path = resolve_codex_path(codex_path)
|
|
748
|
+
self.sandbox = sandbox
|
|
749
|
+
self.project_dir = (
|
|
750
|
+
os.path.abspath(os.path.expanduser(project_dir)) if project_dir else ""
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
|
|
754
|
+
import tempfile
|
|
755
|
+
self.last_call_error = ""
|
|
756
|
+
out_path = tempfile.NamedTemporaryFile(
|
|
757
|
+
prefix="codex_last_", suffix=".txt", delete=False
|
|
758
|
+
).name
|
|
759
|
+
cmd = [
|
|
760
|
+
self.codex_path, "exec", "--skip-git-repo-check",
|
|
761
|
+
"--color", "never", "--sandbox", self.sandbox,
|
|
762
|
+
"-o", out_path,
|
|
763
|
+
]
|
|
764
|
+
if self.project_dir:
|
|
765
|
+
cmd[3:3] = ["-C", self.project_dir]
|
|
766
|
+
if self.model:
|
|
767
|
+
cmd += ["-m", self.model]
|
|
768
|
+
cmd += ["--", prompt]
|
|
769
|
+
proc = None
|
|
770
|
+
try:
|
|
771
|
+
try:
|
|
772
|
+
proc = subprocess.run(
|
|
773
|
+
cmd,
|
|
774
|
+
capture_output=True,
|
|
775
|
+
text=True,
|
|
776
|
+
timeout=self.timeout,
|
|
777
|
+
cwd=self.project_dir or None,
|
|
778
|
+
)
|
|
779
|
+
except subprocess.TimeoutExpired:
|
|
780
|
+
self.last_call_error = f"codex exec timed out after {self.timeout}s"
|
|
781
|
+
return ""
|
|
782
|
+
except Exception as exc:
|
|
783
|
+
self.last_call_error = f"codex exec failed: {exc}"
|
|
784
|
+
return ""
|
|
785
|
+
try:
|
|
786
|
+
with open(out_path, encoding="utf-8") as f:
|
|
787
|
+
out = f.read().strip()
|
|
788
|
+
if out:
|
|
789
|
+
return out
|
|
790
|
+
except Exception as exc:
|
|
791
|
+
self.last_call_error = f"could not read codex output file: {exc}"
|
|
792
|
+
stdout = (proc.stdout or "").strip() if proc is not None else ""
|
|
793
|
+
stderr = (proc.stderr or "").strip() if proc is not None else ""
|
|
794
|
+
if proc is not None and proc.returncode != 0 and not self.last_call_error:
|
|
795
|
+
self.last_call_error = f"codex exec exited {proc.returncode}: {stderr[:500]}"
|
|
796
|
+
return stdout or stderr
|
|
797
|
+
finally:
|
|
798
|
+
try:
|
|
799
|
+
os.unlink(out_path)
|
|
800
|
+
except Exception:
|
|
801
|
+
pass
|
|
802
|
+
|
|
803
|
+
def attempt_with_tools(self, task, skill, memory, tools):
|
|
804
|
+
# Codex exec runs in a sandbox with shell access; expose the same real
|
|
805
|
+
# `search` shim and let it run (workspace-write so the shim can log).
|
|
806
|
+
import tempfile, shutil, stat
|
|
807
|
+
work = tempfile.mkdtemp(prefix="skillopt_sleep_codextools_")
|
|
808
|
+
calllog = os.path.join(work, "_tool_calls.log")
|
|
809
|
+
out_path = os.path.join(work, "_last.txt")
|
|
810
|
+
try:
|
|
811
|
+
for tname in (tools or ["search"]):
|
|
812
|
+
shim = os.path.join(work, tname)
|
|
813
|
+
with open(shim, "w") as f:
|
|
814
|
+
f.write(
|
|
815
|
+
"#!/usr/bin/env bash\n"
|
|
816
|
+
f'echo "{tname}" >> "{calllog}"\n'
|
|
817
|
+
'echo "(search results: 3 relevant notes found; use them to answer)"\n'
|
|
818
|
+
)
|
|
819
|
+
os.chmod(shim, os.stat(shim).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
820
|
+
tool_hint = (
|
|
821
|
+
"Shell tools are available in the working directory: "
|
|
822
|
+
+ ", ".join(f"./{t}" for t in (tools or ["search"]))
|
|
823
|
+
+ ". When the skill says to look something up or search before "
|
|
824
|
+
"answering, you MUST actually run the tool (e.g. `./search \"query\"`) "
|
|
825
|
+
"before giving your final answer."
|
|
826
|
+
)
|
|
827
|
+
prompt = (
|
|
828
|
+
"Complete the task. Apply the skill and memory rules EXACTLY, "
|
|
829
|
+
"including any rule about searching before answering. Treat a "
|
|
830
|
+
"'Learned preferences' block as HARD CONSTRAINTS overriding earlier "
|
|
831
|
+
"conflicting skill text.\n\n"
|
|
832
|
+
f"{tool_hint}\n\n# Skill\n{skill or '(none)'}\n\n# Memory\n{memory or '(none)'}\n\n"
|
|
833
|
+
f"# Task\n{task.intent}\n\n{task.context_excerpt}\n\nReturn ONLY the final answer."
|
|
834
|
+
)
|
|
835
|
+
cmd = [
|
|
836
|
+
self.codex_path, "exec", "--skip-git-repo-check", "--color", "never",
|
|
837
|
+
"--sandbox", "workspace-write", "-C", work, "-o", out_path,
|
|
838
|
+
]
|
|
839
|
+
if self.model:
|
|
840
|
+
cmd += ["-m", self.model]
|
|
841
|
+
cmd += ["--", prompt]
|
|
842
|
+
try:
|
|
843
|
+
subprocess.run(cmd, capture_output=True, text=True, timeout=self.timeout, cwd=work)
|
|
844
|
+
except Exception:
|
|
845
|
+
pass
|
|
846
|
+
resp = ""
|
|
847
|
+
try:
|
|
848
|
+
with open(out_path, encoding="utf-8") as f:
|
|
849
|
+
resp = f.read().strip()
|
|
850
|
+
except Exception:
|
|
851
|
+
resp = ""
|
|
852
|
+
self._tokens += len(prompt) // 4 + len(resp) // 4
|
|
853
|
+
called: List[str] = []
|
|
854
|
+
if os.path.exists(calllog):
|
|
855
|
+
with open(calllog) as f:
|
|
856
|
+
logged = {ln.strip() for ln in f if ln.strip()}
|
|
857
|
+
called = [t for t in (tools or ["search"]) if t in logged]
|
|
858
|
+
return resp, called
|
|
859
|
+
finally:
|
|
860
|
+
try:
|
|
861
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
862
|
+
except Exception:
|
|
863
|
+
pass
|
|
864
|
+
|
|
865
|
+
def resolve_copilot_path(explicit: str = "") -> str:
|
|
866
|
+
"""Find the GitHub Copilot CLI (`copilot`) binary."""
|
|
867
|
+
if explicit:
|
|
868
|
+
return explicit
|
|
869
|
+
env = os.environ.get("SKILLOPT_SLEEP_COPILOT_PATH")
|
|
870
|
+
if env:
|
|
871
|
+
return env
|
|
872
|
+
import shutil
|
|
873
|
+
found = shutil.which("copilot")
|
|
874
|
+
return found or "copilot"
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
class CopilotCliBackend(CliBackend):
|
|
878
|
+
"""Drives the GitHub Copilot CLI in non-interactive mode.
|
|
879
|
+
|
|
880
|
+
Uses ``copilot -p <prompt> --output-format json`` and parses the emitted
|
|
881
|
+
JSONL event stream, returning the concatenated ``assistant.message``
|
|
882
|
+
content. The plain-text / ``--silent`` modes do not reliably stream the
|
|
883
|
+
response to stdout on all platforms, so JSONL is used for robust capture.
|
|
884
|
+
|
|
885
|
+
The call runs in a clean temp cwd with streaming disabled and tools allowed
|
|
886
|
+
(so non-interactive mode never blocks on a permission prompt); ``_call``'s
|
|
887
|
+
prompts ask for final-answer text only, so no tool use is expected there,
|
|
888
|
+
while ``attempt_with_tools`` exposes real, cross-platform callable shims in
|
|
889
|
+
the working directory for honest tool-call detection.
|
|
890
|
+
|
|
891
|
+
Startup overhead is minimised: each invocation points ``COPILOT_HOME`` at a
|
|
892
|
+
dedicated, isolated config dir (no user ``mcp-config.json``, so the user's
|
|
893
|
+
MCP servers — including this project's own — are NOT spawned, avoiding a
|
|
894
|
+
slow recursive launch), and built-in MCP servers / custom instructions are
|
|
895
|
+
disabled. Auth is read from the OS credential store / token env vars, which
|
|
896
|
+
live outside ``COPILOT_HOME``, so isolation does not break authentication.
|
|
897
|
+
Set ``SKILLOPT_SLEEP_COPILOT_HOME`` to override the isolated home, or set it
|
|
898
|
+
empty / ``SKILLOPT_SLEEP_COPILOT_FULL_ENV=1`` to use the user's real
|
|
899
|
+
environment instead.
|
|
900
|
+
"""
|
|
901
|
+
|
|
902
|
+
name = "copilot"
|
|
903
|
+
|
|
904
|
+
def __init__(self, model: str = "", copilot_path: str = "", timeout: int = 240) -> None:
|
|
905
|
+
super().__init__(model=model or os.environ.get("SKILLOPT_SLEEP_COPILOT_MODEL", ""),
|
|
906
|
+
timeout=timeout)
|
|
907
|
+
self.copilot_path = resolve_copilot_path(copilot_path)
|
|
908
|
+
self.full_env = os.environ.get("SKILLOPT_SLEEP_COPILOT_FULL_ENV", "") == "1"
|
|
909
|
+
# Stable isolated home so first-run setup is cached across calls.
|
|
910
|
+
if self.full_env:
|
|
911
|
+
self.copilot_home = ""
|
|
912
|
+
else:
|
|
913
|
+
self.copilot_home = os.environ.get("SKILLOPT_SLEEP_COPILOT_HOME") or os.path.join(
|
|
914
|
+
tempfile.gettempdir(), "skillopt_sleep_copilot_home"
|
|
915
|
+
)
|
|
916
|
+
try:
|
|
917
|
+
os.makedirs(self.copilot_home, exist_ok=True)
|
|
918
|
+
except Exception:
|
|
919
|
+
self.copilot_home = ""
|
|
920
|
+
|
|
921
|
+
def _call(self, prompt: str, *, max_tokens: int = 1024) -> str:
|
|
922
|
+
clean_cwd = tempfile.mkdtemp(prefix="skillopt_sleep_copilot_")
|
|
923
|
+
cmd = [
|
|
924
|
+
self.copilot_path, "-p", prompt,
|
|
925
|
+
"--output-format", "json",
|
|
926
|
+
"--stream", "off",
|
|
927
|
+
"--no-color",
|
|
928
|
+
"--log-level", "none",
|
|
929
|
+
"--allow-all-tools",
|
|
930
|
+
"-C", clean_cwd,
|
|
931
|
+
]
|
|
932
|
+
if not self.full_env:
|
|
933
|
+
# Drop unneeded startup work: no built-in (github) MCP server and no
|
|
934
|
+
# AGENTS.md / custom-instruction loading. With an isolated home that
|
|
935
|
+
# has no mcp-config.json, no user MCP servers spawn either.
|
|
936
|
+
cmd += ["--disable-builtin-mcps", "--no-custom-instructions"]
|
|
937
|
+
if self.model:
|
|
938
|
+
cmd += ["--model", self.model]
|
|
939
|
+
env = os.environ.copy()
|
|
940
|
+
if self.copilot_home:
|
|
941
|
+
env["COPILOT_HOME"] = self.copilot_home
|
|
942
|
+
try:
|
|
943
|
+
proc = subprocess.run(
|
|
944
|
+
cmd, capture_output=True, text=True, timeout=self.timeout, cwd=clean_cwd,
|
|
945
|
+
encoding="utf-8", errors="replace", env=env,
|
|
946
|
+
)
|
|
947
|
+
except Exception:
|
|
948
|
+
return ""
|
|
949
|
+
finally:
|
|
950
|
+
try:
|
|
951
|
+
import shutil
|
|
952
|
+
shutil.rmtree(clean_cwd, ignore_errors=True)
|
|
953
|
+
except Exception:
|
|
954
|
+
pass
|
|
955
|
+
return self._parse_jsonl_response(proc.stdout or "")
|
|
956
|
+
|
|
957
|
+
@staticmethod
|
|
958
|
+
def _parse_jsonl_response(raw: str) -> str:
|
|
959
|
+
parts: List[str] = []
|
|
960
|
+
for line in raw.splitlines():
|
|
961
|
+
line = line.strip()
|
|
962
|
+
if not line or not line.startswith("{"):
|
|
963
|
+
continue
|
|
964
|
+
try:
|
|
965
|
+
obj = json.loads(line)
|
|
966
|
+
except Exception:
|
|
967
|
+
continue
|
|
968
|
+
if obj.get("type") == "assistant.message":
|
|
969
|
+
content = (obj.get("data") or {}).get("content")
|
|
970
|
+
if isinstance(content, str) and content:
|
|
971
|
+
parts.append(content)
|
|
972
|
+
return "\n".join(parts).strip()
|
|
973
|
+
|
|
974
|
+
def attempt_with_tools(self, task, skill, memory, tools):
|
|
975
|
+
# Expose REAL, callable tool shims in the working directory so the
|
|
976
|
+
# gbrain quick-answerer judge (tool_called=search) is validated
|
|
977
|
+
# honestly: we detect each call from the shim's log, not from a
|
|
978
|
+
# self-reported marker. The Copilot CLI is the Windows-validated
|
|
979
|
+
# backend, so the shims must be cross-platform — a bash `#!/usr/bin/env
|
|
980
|
+
# bash` + chmod shim does NOT execute via `./tool` under PowerShell/cmd,
|
|
981
|
+
# so on Windows we emit a `.cmd` batch shim instead.
|
|
982
|
+
import shutil
|
|
983
|
+
import stat
|
|
984
|
+
work = tempfile.mkdtemp(prefix="skillopt_sleep_copilottools_")
|
|
985
|
+
calllog = os.path.join(work, "_tool_calls.log")
|
|
986
|
+
tool_names = tools or ["search"]
|
|
987
|
+
is_windows = os.name == "nt"
|
|
988
|
+
try:
|
|
989
|
+
for tname in tool_names:
|
|
990
|
+
if is_windows:
|
|
991
|
+
shim = os.path.join(work, f"{tname}.cmd")
|
|
992
|
+
with open(shim, "w") as f:
|
|
993
|
+
# `%~n0` is the script's own base name (the tool name);
|
|
994
|
+
# writing it keeps the calllog line == tool name so the
|
|
995
|
+
# honest-detection match below works unchanged.
|
|
996
|
+
f.write(
|
|
997
|
+
"@echo off\n"
|
|
998
|
+
f'echo %~n0>>"{calllog}"\n'
|
|
999
|
+
"echo (search results: 3 relevant notes found; use them to answer)\n"
|
|
1000
|
+
)
|
|
1001
|
+
else:
|
|
1002
|
+
shim = os.path.join(work, tname)
|
|
1003
|
+
with open(shim, "w") as f:
|
|
1004
|
+
f.write(
|
|
1005
|
+
"#!/usr/bin/env bash\n"
|
|
1006
|
+
f'echo "{tname}" >> "{calllog}"\n'
|
|
1007
|
+
'echo "(search results: 3 relevant notes found; use them to answer)"\n'
|
|
1008
|
+
)
|
|
1009
|
+
os.chmod(shim, os.stat(shim).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
1010
|
+
if is_windows:
|
|
1011
|
+
tool_hint = (
|
|
1012
|
+
"You have shell tools available in the current directory: "
|
|
1013
|
+
+ ", ".join(f"{t}.cmd" for t in tool_names)
|
|
1014
|
+
+ " (each callable as `" + tool_names[0] + "` or `.\\"
|
|
1015
|
+
+ tool_names[0] + "`). When the skill says to look something "
|
|
1016
|
+
"up or search before answering, you MUST actually run the "
|
|
1017
|
+
"tool (e.g. `" + tool_names[0] + " \"query\"`) before giving "
|
|
1018
|
+
"your final answer."
|
|
1019
|
+
)
|
|
1020
|
+
else:
|
|
1021
|
+
tool_hint = (
|
|
1022
|
+
"You have shell tools available in the current directory: "
|
|
1023
|
+
+ ", ".join(f"./{t}" for t in tool_names)
|
|
1024
|
+
+ ". When the skill says to look something up or search before "
|
|
1025
|
+
"answering, you MUST actually run the tool (e.g. `./search \"query\"`) "
|
|
1026
|
+
"before giving your final answer."
|
|
1027
|
+
)
|
|
1028
|
+
prompt = (
|
|
1029
|
+
"You are completing a task. Apply the skill and memory rules EXACTLY, "
|
|
1030
|
+
"including any rule about searching/looking up before answering. "
|
|
1031
|
+
"Treat a 'Learned preferences' block as HARD CONSTRAINTS that override "
|
|
1032
|
+
"earlier conflicting skill text.\n\n"
|
|
1033
|
+
f"{tool_hint}\n\n"
|
|
1034
|
+
f"# Skill\n{skill or '(none)'}\n\n# Memory\n{memory or '(none)'}\n\n"
|
|
1035
|
+
f"# Task\n{task.intent}\n\n{task.context_excerpt}\n\n"
|
|
1036
|
+
"Return ONLY the final answer text."
|
|
1037
|
+
)
|
|
1038
|
+
cmd = [
|
|
1039
|
+
self.copilot_path, "-p", prompt,
|
|
1040
|
+
"--output-format", "json",
|
|
1041
|
+
"--stream", "off",
|
|
1042
|
+
"--no-color",
|
|
1043
|
+
"--log-level", "none",
|
|
1044
|
+
"--allow-all-tools",
|
|
1045
|
+
"-C", work,
|
|
1046
|
+
]
|
|
1047
|
+
if not self.full_env:
|
|
1048
|
+
cmd += ["--disable-builtin-mcps", "--no-custom-instructions"]
|
|
1049
|
+
if self.model:
|
|
1050
|
+
cmd += ["--model", self.model]
|
|
1051
|
+
env = os.environ.copy()
|
|
1052
|
+
if self.copilot_home:
|
|
1053
|
+
env["COPILOT_HOME"] = self.copilot_home
|
|
1054
|
+
resp = ""
|
|
1055
|
+
try:
|
|
1056
|
+
proc = subprocess.run(
|
|
1057
|
+
cmd, capture_output=True, text=True, encoding="utf-8",
|
|
1058
|
+
errors="replace", timeout=self.timeout, cwd=work, env=env,
|
|
1059
|
+
)
|
|
1060
|
+
resp = self._parse_jsonl_response(proc.stdout or "")
|
|
1061
|
+
except Exception:
|
|
1062
|
+
resp = ""
|
|
1063
|
+
self._tokens += len(prompt) // 4 + len(resp) // 4
|
|
1064
|
+
called: List[str] = []
|
|
1065
|
+
if os.path.exists(calllog):
|
|
1066
|
+
with open(calllog) as f:
|
|
1067
|
+
logged = {ln.strip() for ln in f if ln.strip()}
|
|
1068
|
+
called = [t for t in tool_names if t in logged]
|
|
1069
|
+
return resp, called
|
|
1070
|
+
finally:
|
|
1071
|
+
try:
|
|
1072
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
1073
|
+
except Exception:
|
|
1074
|
+
pass
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
class DualBackend(Backend):
|
|
1078
|
+
"""Route operations to two backends, à la SkillOpt's target vs optimizer.
|
|
1079
|
+
|
|
1080
|
+
* attempt -> TARGET backend (the model the skill is deployed on)
|
|
1081
|
+
* reflect -> OPTIMIZER backend (the stronger/cheaper model writing edits)
|
|
1082
|
+
* judge -> OPTIMIZER backend (graded by the optimizer when no local rule)
|
|
1083
|
+
|
|
1084
|
+
This lets you optimize a skill with one model and run tasks on another, and
|
|
1085
|
+
is the basis of the sleep-scenario transfer experiment (optimize cheap,
|
|
1086
|
+
deploy expensive — or vice-versa).
|
|
1087
|
+
"""
|
|
1088
|
+
|
|
1089
|
+
name = "dual"
|
|
1090
|
+
|
|
1091
|
+
def __init__(self, target: Backend, optimizer: Backend) -> None:
|
|
1092
|
+
self.target = target
|
|
1093
|
+
self.optimizer = optimizer
|
|
1094
|
+
self.name = f"target={target.name}/optimizer={optimizer.name}"
|
|
1095
|
+
|
|
1096
|
+
def attempt(self, task, skill, memory, sample_id: int = 0):
|
|
1097
|
+
return self.target.attempt(task, skill, memory, sample_id=sample_id)
|
|
1098
|
+
|
|
1099
|
+
def attempt_with_tools(self, task, skill, memory, tools):
|
|
1100
|
+
return self.target.attempt_with_tools(task, skill, memory, tools)
|
|
1101
|
+
|
|
1102
|
+
def judge(self, task, response):
|
|
1103
|
+
# local rule/exact judging needs no model; delegate to target which
|
|
1104
|
+
# already short-circuits those. For rubric judging use the optimizer.
|
|
1105
|
+
if task.reference_kind in {"rule", "exact"}:
|
|
1106
|
+
return self.target.judge(task, response)
|
|
1107
|
+
return self.optimizer.judge(task, response)
|
|
1108
|
+
|
|
1109
|
+
def reflect(self, failures, successes, skill, memory, **kw):
|
|
1110
|
+
return self.optimizer.reflect(failures, successes, skill, memory, **kw)
|
|
1111
|
+
|
|
1112
|
+
def _call(self, prompt, *, max_tokens=1024):
|
|
1113
|
+
# used by the LLM miner; prefer the optimizer (the "thinking" model)
|
|
1114
|
+
return self.optimizer._call(prompt, max_tokens=max_tokens) # type: ignore[attr-defined]
|
|
1115
|
+
|
|
1116
|
+
def tokens_used(self):
|
|
1117
|
+
return self.target.tokens_used() + self.optimizer.tokens_used()
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
# ── Azure OpenAI backend (gpt-5.x via managed identity) ───────────────────────
|
|
1121
|
+
|
|
1122
|
+
# Endpoint -> deployments, from the intern's avail_api.md. The backend picks the
|
|
1123
|
+
# first endpoint that hosts the requested deployment.
|
|
1124
|
+
_AZURE_ENDPOINTS = {
|
|
1125
|
+
"https://oaidr9.openai.azure.com/": {"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "o3"},
|
|
1126
|
+
"https://t2vgoaigpt4o6.openai.azure.com/": {"gpt-5.5", "gpt-4o-mini", "o3", "o4-mini"},
|
|
1127
|
+
"https://oaidr21.openai.azure.com/": {"gpt-5.5", "o3", "o4-mini"},
|
|
1128
|
+
"https://searchagent5.cognitiveservices.azure.com/": {"gpt-5.4-mini", "gpt-4o-mini"},
|
|
1129
|
+
"https://t2vgoaigpt4o.openai.azure.com/": {"gpt-5.4", "gpt-5.4-nano", "gpt-5.2", "gpt-5.1", "o3", "o4-mini"},
|
|
1130
|
+
}
|
|
1131
|
+
_AZURE_MI_CLIENT_ID = "8cafa2b1-a2a7-4ad9-814a-ffe4aed7e800"
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
class AzureOpenAIBackend(CliBackend):
|
|
1135
|
+
"""Drives Azure OpenAI gpt-5.x deployments via managed identity.
|
|
1136
|
+
|
|
1137
|
+
Mirrors the intern's blog_1 setup (avail_api.md): managed-identity auth, the
|
|
1138
|
+
same endpoints/deployments. Reuses CliBackend's attempt/judge/reflect prompts
|
|
1139
|
+
and JSON parsing; only _call() differs. openai + azure-identity are lazy
|
|
1140
|
+
imported so the mock/CLI paths stay dependency-free.
|
|
1141
|
+
"""
|
|
1142
|
+
|
|
1143
|
+
name = "azure"
|
|
1144
|
+
|
|
1145
|
+
def __init__(self, deployment: str = "", endpoint: str = "", timeout: int = 180,
|
|
1146
|
+
api_version: str = "2024-12-01-preview") -> None:
|
|
1147
|
+
super().__init__(model=deployment or "gpt-5.5", timeout=timeout)
|
|
1148
|
+
self.deployment = deployment or "gpt-5.5"
|
|
1149
|
+
self.endpoint = endpoint or self._endpoint_for(self.deployment)
|
|
1150
|
+
self.api_version = api_version
|
|
1151
|
+
self.name = f"azure:{self.deployment}"
|
|
1152
|
+
self._client = None
|
|
1153
|
+
|
|
1154
|
+
@staticmethod
|
|
1155
|
+
def _endpoint_for(deployment: str) -> str:
|
|
1156
|
+
for ep, deps in _AZURE_ENDPOINTS.items():
|
|
1157
|
+
if deployment in deps:
|
|
1158
|
+
return ep
|
|
1159
|
+
return "https://oaidr9.openai.azure.com/"
|
|
1160
|
+
|
|
1161
|
+
def _get_client(self):
|
|
1162
|
+
if self._client is None:
|
|
1163
|
+
from azure.identity import ManagedIdentityCredential, get_bearer_token_provider
|
|
1164
|
+
from openai import AzureOpenAI
|
|
1165
|
+
cred = ManagedIdentityCredential(client_id=_AZURE_MI_CLIENT_ID)
|
|
1166
|
+
tp = get_bearer_token_provider(cred, "https://cognitiveservices.azure.com/.default")
|
|
1167
|
+
self._client = AzureOpenAI(
|
|
1168
|
+
azure_endpoint=self.endpoint, azure_ad_token_provider=tp,
|
|
1169
|
+
api_version=self.api_version, max_retries=4,
|
|
1170
|
+
)
|
|
1171
|
+
return self._client
|
|
1172
|
+
|
|
1173
|
+
def _call(self, prompt: str, *, max_tokens: int = 1024, retries: int = 5) -> str:
|
|
1174
|
+
"""Call the deployment with bounded retries.
|
|
1175
|
+
|
|
1176
|
+
IMPORTANT: transient failures (429 rate-limit, timeouts, 5xx) must NOT be
|
|
1177
|
+
silently turned into an empty string — an empty response scores 0 and
|
|
1178
|
+
deflates every baseline/after measure. We retry with exponential backoff
|
|
1179
|
+
(mirroring the research repo's retries=5) and only return "" after the
|
|
1180
|
+
budget is exhausted. ``time``/``random`` are used for backoff; both are
|
|
1181
|
+
available here (this is library code, not a Workflow script sandbox).
|
|
1182
|
+
"""
|
|
1183
|
+
import random as _r
|
|
1184
|
+
import time as _t
|
|
1185
|
+
|
|
1186
|
+
client = self._get_client()
|
|
1187
|
+
last_exc = None
|
|
1188
|
+
for attempt in range(max(1, retries)):
|
|
1189
|
+
try:
|
|
1190
|
+
resp = client.chat.completions.create(
|
|
1191
|
+
model=self.deployment,
|
|
1192
|
+
messages=[{"role": "user", "content": prompt}],
|
|
1193
|
+
max_completion_tokens=16384,
|
|
1194
|
+
)
|
|
1195
|
+
text = (resp.choices[0].message.content or "").strip()
|
|
1196
|
+
try:
|
|
1197
|
+
u = resp.usage
|
|
1198
|
+
self._tokens += (getattr(u, "prompt_tokens", 0) or 0) + (getattr(u, "completion_tokens", 0) or 0)
|
|
1199
|
+
except Exception:
|
|
1200
|
+
pass
|
|
1201
|
+
if text:
|
|
1202
|
+
return text
|
|
1203
|
+
# empty but no exception: model genuinely returned nothing — one
|
|
1204
|
+
# quick retry can help (reasoning models occasionally yield empty)
|
|
1205
|
+
last_exc = "empty-response"
|
|
1206
|
+
except Exception as e: # noqa: BLE001
|
|
1207
|
+
last_exc = e
|
|
1208
|
+
# backoff before next try (skip after the final attempt)
|
|
1209
|
+
if attempt < retries - 1:
|
|
1210
|
+
_t.sleep(min(8.0, (2 ** attempt) * 0.5) + _r.random() * 0.4)
|
|
1211
|
+
return ""
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
class AzureResponsesBackend(AzureOpenAIBackend):
|
|
1215
|
+
"""gpt-5.x via the **Responses API** on the high-throughput gpt4v endpoints.
|
|
1216
|
+
|
|
1217
|
+
Differs from AzureOpenAIBackend in three ways, all required by the enhanced
|
|
1218
|
+
experiment:
|
|
1219
|
+
* Auth via ``AzureCliCredential`` (the logged-in user), not Managed Identity
|
|
1220
|
+
— the gpt4v-scus/swc accounts grant the data role to the CLI principal.
|
|
1221
|
+
* Calls ``client.responses.create`` (the /responses API) instead of
|
|
1222
|
+
chat.completions — these deployments are Responses-only.
|
|
1223
|
+
* Round-robins across multiple endpoints for parallel throughput; each
|
|
1224
|
+
worker thread binds a client for one endpoint (picked by thread index)
|
|
1225
|
+
so concurrent replay spreads load across all endpoints.
|
|
1226
|
+
|
|
1227
|
+
A single shared ``AzureCliCredential`` token provider is reused across all
|
|
1228
|
+
endpoint clients (the token is cached + auto-refreshed by the provider).
|
|
1229
|
+
"""
|
|
1230
|
+
|
|
1231
|
+
name = "azure-responses"
|
|
1232
|
+
|
|
1233
|
+
# the two parallel /responses endpoints (user-provided), both hosting gpt-5.5
|
|
1234
|
+
_RESP_ENDPOINTS = [
|
|
1235
|
+
"https://gpt4v-scus.openai.azure.com/",
|
|
1236
|
+
"https://gpt4v-swc.openai.azure.com/",
|
|
1237
|
+
]
|
|
1238
|
+
|
|
1239
|
+
def __init__(self, deployment: str = "", endpoints: Optional[List[str]] = None,
|
|
1240
|
+
timeout: int = 180, api_version: str = "2025-04-01-preview") -> None:
|
|
1241
|
+
super().__init__(deployment=deployment, endpoint=(endpoints or self._RESP_ENDPOINTS)[0],
|
|
1242
|
+
timeout=timeout, api_version=api_version)
|
|
1243
|
+
self.endpoints = list(endpoints or self._RESP_ENDPOINTS)
|
|
1244
|
+
self.name = f"azure-responses:{self.deployment}"
|
|
1245
|
+
self._token_provider = None
|
|
1246
|
+
self._clients: dict = {} # endpoint -> AzureOpenAI client
|
|
1247
|
+
import threading as _thr
|
|
1248
|
+
self._lock = _thr.Lock()
|
|
1249
|
+
self._rr = 0 # round-robin counter
|
|
1250
|
+
|
|
1251
|
+
def _get_provider(self):
|
|
1252
|
+
if self._token_provider is None:
|
|
1253
|
+
from azure.identity import AzureCliCredential, get_bearer_token_provider
|
|
1254
|
+
self._token_provider = get_bearer_token_provider(
|
|
1255
|
+
AzureCliCredential(), "https://cognitiveservices.azure.com/.default")
|
|
1256
|
+
return self._token_provider
|
|
1257
|
+
|
|
1258
|
+
def _client_for(self, endpoint: str):
|
|
1259
|
+
cl = self._clients.get(endpoint)
|
|
1260
|
+
if cl is None:
|
|
1261
|
+
from openai import AzureOpenAI
|
|
1262
|
+
cl = AzureOpenAI(
|
|
1263
|
+
azure_endpoint=endpoint, azure_ad_token_provider=self._get_provider(),
|
|
1264
|
+
api_version=self.api_version, max_retries=2,
|
|
1265
|
+
)
|
|
1266
|
+
self._clients[endpoint] = cl
|
|
1267
|
+
return cl
|
|
1268
|
+
|
|
1269
|
+
def _next_endpoint(self) -> str:
|
|
1270
|
+
# round-robin so concurrent calls spread across all endpoints
|
|
1271
|
+
with self._lock:
|
|
1272
|
+
ep = self.endpoints[self._rr % len(self.endpoints)]
|
|
1273
|
+
self._rr += 1
|
|
1274
|
+
return ep
|
|
1275
|
+
|
|
1276
|
+
def _call(self, prompt: str, *, max_tokens: int = 1024, retries: int = 5) -> str:
|
|
1277
|
+
import random as _r
|
|
1278
|
+
import time as _t
|
|
1279
|
+
last = None
|
|
1280
|
+
base_ep = self._next_endpoint() # this call's primary endpoint
|
|
1281
|
+
base_idx = self.endpoints.index(base_ep)
|
|
1282
|
+
for attempt in range(max(1, retries)):
|
|
1283
|
+
# on retry, fail over to the other endpoint(s)
|
|
1284
|
+
ep = self.endpoints[(base_idx + attempt) % len(self.endpoints)]
|
|
1285
|
+
try:
|
|
1286
|
+
client = self._client_for(ep)
|
|
1287
|
+
resp = client.responses.create(
|
|
1288
|
+
model=self.deployment, input=prompt,
|
|
1289
|
+
max_output_tokens=16384,
|
|
1290
|
+
)
|
|
1291
|
+
text = (getattr(resp, "output_text", "") or "").strip()
|
|
1292
|
+
try:
|
|
1293
|
+
u = resp.usage
|
|
1294
|
+
self._tokens += (getattr(u, "input_tokens", 0) or 0) + (getattr(u, "output_tokens", 0) or 0)
|
|
1295
|
+
except Exception:
|
|
1296
|
+
pass
|
|
1297
|
+
if text:
|
|
1298
|
+
return text
|
|
1299
|
+
last = "empty-response"
|
|
1300
|
+
except Exception as e: # noqa: BLE001
|
|
1301
|
+
last = e
|
|
1302
|
+
if attempt < retries - 1:
|
|
1303
|
+
_t.sleep(min(8.0, (2 ** attempt) * 0.5) + _r.random() * 0.4)
|
|
1304
|
+
return ""
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def get_backend(
|
|
1308
|
+
name: str,
|
|
1309
|
+
*,
|
|
1310
|
+
model: str = "",
|
|
1311
|
+
claude_path: str = "claude",
|
|
1312
|
+
codex_path: str = "",
|
|
1313
|
+
azure_endpoint: str = "",
|
|
1314
|
+
project_dir: str = "",
|
|
1315
|
+
) -> Backend:
|
|
1316
|
+
n = (name or "mock").strip().lower()
|
|
1317
|
+
if n in {"claude", "anthropic", "claude_cli", "claude_code"}:
|
|
1318
|
+
return ClaudeCliBackend(model=model, claude_path=claude_path)
|
|
1319
|
+
if n in {"codex", "codex_cli", "openai_codex"}:
|
|
1320
|
+
return CodexCliBackend(model=model, codex_path=codex_path, project_dir=project_dir)
|
|
1321
|
+
if n in {"azure", "azure_openai", "aoai"}:
|
|
1322
|
+
return AzureOpenAIBackend(deployment=model, endpoint=azure_endpoint)
|
|
1323
|
+
if n in {"azure-responses", "azure_responses", "aoai-responses", "responses"}:
|
|
1324
|
+
eps = [e.strip() for e in azure_endpoint.split(",") if e.strip()] or None
|
|
1325
|
+
return AzureResponsesBackend(deployment=model, endpoints=eps)
|
|
1326
|
+
if n in {"copilot", "github_copilot", "copilot_cli", "gh_copilot"}:
|
|
1327
|
+
return CopilotCliBackend(model=model)
|
|
1328
|
+
return MockBackend()
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def build_backend(
|
|
1332
|
+
*,
|
|
1333
|
+
backend: str = "mock",
|
|
1334
|
+
model: str = "",
|
|
1335
|
+
optimizer_backend: str = "",
|
|
1336
|
+
optimizer_model: str = "",
|
|
1337
|
+
target_backend: str = "",
|
|
1338
|
+
target_model: str = "",
|
|
1339
|
+
codex_path: str = "",
|
|
1340
|
+
azure_endpoint: str = "",
|
|
1341
|
+
preferences: str = "",
|
|
1342
|
+
project_dir: str = "",
|
|
1343
|
+
) -> Backend:
|
|
1344
|
+
"""Build a single or dual backend.
|
|
1345
|
+
|
|
1346
|
+
If optimizer_* or target_* are given, returns a DualBackend routing
|
|
1347
|
+
attempt->target and reflect/judge->optimizer. Otherwise a single backend
|
|
1348
|
+
from (backend, model). ``preferences`` (free text) is attached so reflect
|
|
1349
|
+
uses it as a prior (set on the optimizer for dual backends).
|
|
1350
|
+
"""
|
|
1351
|
+
has_split = any([optimizer_backend, optimizer_model, target_backend, target_model])
|
|
1352
|
+
if not has_split:
|
|
1353
|
+
be = get_backend(
|
|
1354
|
+
backend,
|
|
1355
|
+
model=model,
|
|
1356
|
+
codex_path=codex_path,
|
|
1357
|
+
azure_endpoint=azure_endpoint,
|
|
1358
|
+
project_dir=project_dir,
|
|
1359
|
+
)
|
|
1360
|
+
be.preferences = preferences
|
|
1361
|
+
return be
|
|
1362
|
+
tgt = get_backend(target_backend or backend, model=target_model or model,
|
|
1363
|
+
codex_path=codex_path, azure_endpoint=azure_endpoint,
|
|
1364
|
+
project_dir=project_dir)
|
|
1365
|
+
opt = get_backend(optimizer_backend or backend, model=optimizer_model or model,
|
|
1366
|
+
codex_path=codex_path, azure_endpoint=azure_endpoint,
|
|
1367
|
+
project_dir=project_dir)
|
|
1368
|
+
opt.preferences = preferences # reflect runs on the optimizer
|
|
1369
|
+
dual = DualBackend(target=tgt, optimizer=opt)
|
|
1370
|
+
dual.preferences = preferences
|
|
1371
|
+
return dual
|