@matt82198/aesop 0.1.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/CHANGELOG.md +117 -2
- package/README.md +59 -218
- package/bin/cli.js +168 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +74 -4
- package/docs/PORTING.md +166 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +372 -0
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +150 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +673 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +179 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +254 -0
- package/driver/wave_loop.py +1408 -0
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +131 -33
- package/mcp/server.mjs +320 -4
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +22 -14
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +4 -2
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/store.py +185 -73
- package/state_store/write_api.py +462 -0
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +166 -43
- package/tools/cost_econ.py +480 -0
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +188 -26
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +523 -0
- package/tools/portability_check.py +206 -0
- package/tools/proposals.mjs +47 -2
- package/tools/reconcile.py +7 -4
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +207 -65
- package/tools/self_stats.py +20 -0
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +779 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +340 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +81 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +383 -55
- package/ui/quality_scorecard.py +232 -0
- package/ui/sse.py +3 -3
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +383 -0
- package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AgentDriver -- the backend-portability seam for aesop's wave loop.
|
|
3
|
+
|
|
4
|
+
Aesop's orchestration core (the wave/flat-dispatch cycle) is written against
|
|
5
|
+
Claude Code's Workflow harness. To run the same wave algorithm on Codex or an
|
|
6
|
+
open-model runner, the orchestration loop must talk to its execution backend
|
|
7
|
+
through ONE narrow interface instead of calling Claude-Code-specific functions
|
|
8
|
+
directly. That interface is `AgentDriver`.
|
|
9
|
+
|
|
10
|
+
Grounded in the design spike (conductor3/plans/spike-multitool-portability.md),
|
|
11
|
+
which distils everything the wave loop needs from a backend down to a handful of
|
|
12
|
+
operations. This module encodes those as an abstract base class (ABC) with five
|
|
13
|
+
abstract methods plus honest capability metadata.
|
|
14
|
+
|
|
15
|
+
THE FIVE OPERATIONS (what the wave loop needs from ANY backend)
|
|
16
|
+
---------------------------------------------------------------
|
|
17
|
+
1. probe_capabilities() -> DriverCapabilities
|
|
18
|
+
Honest self-report: does this backend do parallel dispatch, native
|
|
19
|
+
tool-use, structured output, worktree isolation, cost tracking? What is
|
|
20
|
+
its realistic tool-use accuracy, and therefore which verification tier
|
|
21
|
+
does the orchestrator owe it? Everything else keys off this.
|
|
22
|
+
|
|
23
|
+
2. dispatch_worker(request) -> WorkerResult
|
|
24
|
+
Spawn ONE isolated worker against a prompt, a set of owned files, and a
|
|
25
|
+
working directory. The worker is the unit that (per the spike) may read
|
|
26
|
+
files, write files, run a shell command, and return a STRUCTURED result.
|
|
27
|
+
Whether the worker does those itself or the orchestrator does them on the
|
|
28
|
+
worker's behalf is a per-backend fact reported by probe_capabilities()
|
|
29
|
+
(worker_filesystem_access / worker_shell_access / structured_output).
|
|
30
|
+
|
|
31
|
+
3. worker_status(worker_id) -> WorkerStatus
|
|
32
|
+
Liveness / stall detection. The watchdog needs to tell "still working"
|
|
33
|
+
from "wedged" so it can relaunch. Backends without native status must
|
|
34
|
+
approximate (heartbeat age, last-output age).
|
|
35
|
+
|
|
36
|
+
4. run_command(command, cwd, shell) -> CommandResult
|
|
37
|
+
ORCHESTRATOR-side command execution: tests, git, verification. This is
|
|
38
|
+
distinct from a worker running a shell command -- it is the main thread
|
|
39
|
+
checking the fleet's work, and every backend must support it (the
|
|
40
|
+
orchestrator always runs on a real host).
|
|
41
|
+
|
|
42
|
+
5. resolve_model(role) -> str
|
|
43
|
+
Map an abstract role ("worker" / "setup" / "verify") to a concrete model
|
|
44
|
+
id for this backend. Claude Code returns "haiku"/"sonnet"; Codex maps to
|
|
45
|
+
"gpt-3.5-turbo"/"gpt-4-turbo"; open-model maps to "mistral:latest", etc.
|
|
46
|
+
|
|
47
|
+
INVARIANTS
|
|
48
|
+
----------
|
|
49
|
+
* The wave loop calls ONLY AgentDriver methods -- never `agent()`, `parallel()`,
|
|
50
|
+
Bash/Read/Write tools, or `budget.spent()` directly. That is the whole point
|
|
51
|
+
of the seam.
|
|
52
|
+
* probe_capabilities() must be HONEST. A backend that cannot do native parallel
|
|
53
|
+
dispatch reports parallel_dispatch=False so the orchestrator supplies its own
|
|
54
|
+
event loop. Lying here corrupts every downstream verification decision.
|
|
55
|
+
* Weaker workers (lower tool_use_accuracy) => higher recommended verification
|
|
56
|
+
tier. Cheaper/weaker backends RAISE, not lower, the orchestrator's burden.
|
|
57
|
+
* stdlib-only, ASCII-only, Windows + Linux safe. No provider SDKs imported at
|
|
58
|
+
this layer; concrete adapters own their own dependencies.
|
|
59
|
+
|
|
60
|
+
This file defines the CONTRACT only. Concrete adapters live alongside it:
|
|
61
|
+
claude_code_driver.py -- reference implementation (Claude Code parity)
|
|
62
|
+
codex_driver.py -- honest stub for the `codex` CLI backend
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from abc import ABC, abstractmethod
|
|
66
|
+
from dataclasses import dataclass, field
|
|
67
|
+
from typing import Dict, Optional, Tuple
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------
|
|
71
|
+
# Status / role vocabularies (plain string constants -- no enum ceremony,
|
|
72
|
+
# stays trivially JSON- and log-friendly across backends).
|
|
73
|
+
# --------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
# Worker lifecycle states reported by worker_status().
|
|
76
|
+
WORKER_RUNNING = "running"
|
|
77
|
+
WORKER_DONE = "done"
|
|
78
|
+
WORKER_STALLED = "stalled"
|
|
79
|
+
WORKER_FAILED = "failed"
|
|
80
|
+
WORKER_UNKNOWN = "unknown"
|
|
81
|
+
|
|
82
|
+
WORKER_STATES = (
|
|
83
|
+
WORKER_RUNNING,
|
|
84
|
+
WORKER_DONE,
|
|
85
|
+
WORKER_STALLED,
|
|
86
|
+
WORKER_FAILED,
|
|
87
|
+
WORKER_UNKNOWN,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Abstract model roles the wave loop asks for; resolve_model() maps these to
|
|
91
|
+
# concrete backend model ids.
|
|
92
|
+
ROLE_WORKER = "worker"
|
|
93
|
+
ROLE_SETUP = "setup"
|
|
94
|
+
ROLE_VERIFY = "verify"
|
|
95
|
+
|
|
96
|
+
MODEL_ROLES = (ROLE_WORKER, ROLE_SETUP, ROLE_VERIFY)
|
|
97
|
+
|
|
98
|
+
# Effort hints a dispatch may carry (advisory; backends map to their own knobs).
|
|
99
|
+
EFFORT_QUICK = "quick"
|
|
100
|
+
EFFORT_NORMAL = "normal"
|
|
101
|
+
EFFORT_THOROUGH = "thorough"
|
|
102
|
+
|
|
103
|
+
EFFORT_LEVELS = (EFFORT_QUICK, EFFORT_NORMAL, EFFORT_THOROUGH)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------
|
|
107
|
+
# Capability probe payload.
|
|
108
|
+
# --------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class DriverCapabilities:
|
|
113
|
+
"""Honest self-description of a backend, returned by probe_capabilities().
|
|
114
|
+
|
|
115
|
+
The orchestrator reads this ONCE up front and adapts its strategy: whether
|
|
116
|
+
to supply its own parallel event loop, whether to validate every worker's
|
|
117
|
+
JSON, how aggressively to spot-check, and how many repair rounds to budget.
|
|
118
|
+
|
|
119
|
+
Fields are deliberately booleans + a single accuracy float + a recommended
|
|
120
|
+
tier, so the orchestrator's branching stays simple and auditable.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
# Human/log-facing backend name, e.g. "claude-code", "codex", "open-model".
|
|
124
|
+
name: str
|
|
125
|
+
|
|
126
|
+
# Can the backend run multiple workers concurrently by itself? If False,
|
|
127
|
+
# the orchestrator must drive parallelism from its own event loop.
|
|
128
|
+
parallel_dispatch: bool = False
|
|
129
|
+
|
|
130
|
+
# Can a dispatched WORKER read/write files on its own (vs. the orchestrator
|
|
131
|
+
# injecting file contents into the prompt and writing results back)?
|
|
132
|
+
worker_filesystem_access: bool = False
|
|
133
|
+
|
|
134
|
+
# Can a dispatched WORKER run shell commands on its own?
|
|
135
|
+
worker_shell_access: bool = False
|
|
136
|
+
|
|
137
|
+
# Does the backend natively return schema-valid structured output, or must
|
|
138
|
+
# the orchestrator parse/repair free text into JSON?
|
|
139
|
+
structured_output: bool = False
|
|
140
|
+
|
|
141
|
+
# Does the backend give each worker an isolated git worktree, or must the
|
|
142
|
+
# orchestrator fall back to temp-dir isolation?
|
|
143
|
+
worktree_isolation: bool = False
|
|
144
|
+
|
|
145
|
+
# Does the backend report real token spend per call (vs. estimate-only)?
|
|
146
|
+
native_cost_tracking: bool = False
|
|
147
|
+
|
|
148
|
+
# Can the backend report worker liveness natively, or must stall detection
|
|
149
|
+
# be approximated from heartbeat/output age?
|
|
150
|
+
native_stall_detection: bool = False
|
|
151
|
+
|
|
152
|
+
# Honest realistic tool-use / structured-output success rate in [0.0, 1.0].
|
|
153
|
+
# This is the load-bearing number: it drives the verification tier.
|
|
154
|
+
tool_use_accuracy: float = 0.0
|
|
155
|
+
|
|
156
|
+
# Recommended orchestrator verification tier (1 = light spot-check ...
|
|
157
|
+
# 4 = validate everything + heavy spot-check). Derived from accuracy but
|
|
158
|
+
# stated explicitly so config/audit can read it without recomputing.
|
|
159
|
+
recommended_verification_tier: int = 4
|
|
160
|
+
|
|
161
|
+
# Concrete model ids this backend can dispatch, best-effort.
|
|
162
|
+
available_models: Tuple[str, ...] = field(default_factory=tuple)
|
|
163
|
+
|
|
164
|
+
# Free-form honesty notes: known limitations, required external harness,
|
|
165
|
+
# opaque cost meters, etc. Surfaced in logs and config docs.
|
|
166
|
+
notes: str = ""
|
|
167
|
+
|
|
168
|
+
def summary(self) -> str:
|
|
169
|
+
"""One-line ASCII summary for logs/dashboards."""
|
|
170
|
+
return (
|
|
171
|
+
"{name}: parallel={p} wfs={f} wsh={s} structured={o} "
|
|
172
|
+
"worktree={w} cost={c} stall={d} acc={a:.2f} tier={t}".format(
|
|
173
|
+
name=self.name,
|
|
174
|
+
p=int(self.parallel_dispatch),
|
|
175
|
+
f=int(self.worker_filesystem_access),
|
|
176
|
+
s=int(self.worker_shell_access),
|
|
177
|
+
o=int(self.structured_output),
|
|
178
|
+
w=int(self.worktree_isolation),
|
|
179
|
+
c=int(self.native_cost_tracking),
|
|
180
|
+
d=int(self.native_stall_detection),
|
|
181
|
+
a=self.tool_use_accuracy,
|
|
182
|
+
t=self.recommended_verification_tier,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --------------------------------------------------------------------------
|
|
188
|
+
# Dispatch request / result payloads.
|
|
189
|
+
# --------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class WorkerRequest:
|
|
194
|
+
"""One unit of work handed to dispatch_worker().
|
|
195
|
+
|
|
196
|
+
Bundles the prompt with the isolation contract (owned_files + workdir) that
|
|
197
|
+
aesop's single-writer discipline depends on: a worker touches ONLY the files
|
|
198
|
+
it owns, inside its own workdir. `result_schema`, when given, is the JSON
|
|
199
|
+
schema the worker's structured result should satisfy.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
prompt: str
|
|
203
|
+
owned_files: Tuple[str, ...] = field(default_factory=tuple)
|
|
204
|
+
workdir: str = "."
|
|
205
|
+
model: Optional[str] = None # concrete id, or None to resolve by role
|
|
206
|
+
role: str = ROLE_WORKER # abstract role for resolve_model()
|
|
207
|
+
label: Optional[str] = None # short name for logging
|
|
208
|
+
phase: Optional[str] = None # orchestration phase (Build, Ship, ...)
|
|
209
|
+
result_schema: Optional[Dict] = None # structured-output schema, if any
|
|
210
|
+
effort: str = EFFORT_NORMAL
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class WorkerResult:
|
|
215
|
+
"""Structured outcome of a dispatch_worker() call.
|
|
216
|
+
|
|
217
|
+
`structured` is the parsed/validated result object (may be None if the
|
|
218
|
+
backend produced only free text). `files_written` records what the worker
|
|
219
|
+
(or the orchestrator on its behalf) changed, for verification. `ok` indicates
|
|
220
|
+
the worker dispatched and produced valid, ownership-clean output. It NEVER
|
|
221
|
+
implies tests passed — verification is the caller's job (see wave_bridge, which
|
|
222
|
+
sets `verified` only from run_command exit 0). `verified` is an optional field
|
|
223
|
+
used by the orchestrator to track whether this result has been independently
|
|
224
|
+
verified (e.g., via test exit code). The bridge is the ONLY place that sets
|
|
225
|
+
verified=True, and only when a test passes (exit 0).
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
worker_id: str
|
|
229
|
+
status: str = WORKER_UNKNOWN # one of WORKER_STATES
|
|
230
|
+
ok: bool = False
|
|
231
|
+
structured: Optional[Dict] = None
|
|
232
|
+
text: str = ""
|
|
233
|
+
files_written: Tuple[str, ...] = field(default_factory=tuple)
|
|
234
|
+
tokens_spent: Optional[int] = None # None => backend does not report spend
|
|
235
|
+
error: Optional[str] = None
|
|
236
|
+
verified: Optional[bool] = None # None => not yet verified; True/False set by orchestrator (e.g., wave_bridge)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class WorkerStatus:
|
|
241
|
+
"""Liveness snapshot for a worker, returned by worker_status()."""
|
|
242
|
+
|
|
243
|
+
worker_id: str
|
|
244
|
+
state: str = WORKER_UNKNOWN # one of WORKER_STATES
|
|
245
|
+
stalled: bool = False
|
|
246
|
+
age_s: float = 0.0 # seconds since last observed progress
|
|
247
|
+
detail: str = ""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass
|
|
251
|
+
class CommandResult:
|
|
252
|
+
"""Result of an orchestrator-side run_command()."""
|
|
253
|
+
|
|
254
|
+
exit_code: int
|
|
255
|
+
stdout: str = ""
|
|
256
|
+
stderr: str = ""
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def ok(self) -> bool:
|
|
260
|
+
return self.exit_code == 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# --------------------------------------------------------------------------
|
|
264
|
+
# The interface.
|
|
265
|
+
# --------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class AgentDriver(ABC):
|
|
269
|
+
"""Abstract backend the wave loop dispatches through.
|
|
270
|
+
|
|
271
|
+
Subclass and implement the five abstract methods to add a backend. The
|
|
272
|
+
orchestration algorithm is written against THIS type and nothing else, so a
|
|
273
|
+
new backend is a new subclass -- no changes to the wave loop.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
#: Stable backend identifier; subclasses should override.
|
|
277
|
+
name: str = "abstract"
|
|
278
|
+
|
|
279
|
+
# -- Operation 1: capability probe -------------------------------------
|
|
280
|
+
@abstractmethod
|
|
281
|
+
def probe_capabilities(self) -> DriverCapabilities:
|
|
282
|
+
"""Report -- honestly -- what this backend can and cannot do.
|
|
283
|
+
|
|
284
|
+
Called once up front. The orchestrator keys its parallelism, output
|
|
285
|
+
validation, spot-check rate, and repair budget off the returned
|
|
286
|
+
DriverCapabilities. Must not lie: an over-optimistic probe corrupts
|
|
287
|
+
every downstream verification decision.
|
|
288
|
+
"""
|
|
289
|
+
raise NotImplementedError
|
|
290
|
+
|
|
291
|
+
# -- Operation 2: dispatch an isolated worker --------------------------
|
|
292
|
+
@abstractmethod
|
|
293
|
+
def dispatch_worker(self, request: WorkerRequest) -> WorkerResult:
|
|
294
|
+
"""Spawn ONE isolated worker for `request` and return its result.
|
|
295
|
+
|
|
296
|
+
The worker operates on request.owned_files within request.workdir. It
|
|
297
|
+
may read files, write files, run a shell command, and return a
|
|
298
|
+
structured result -- the extent to which it does these itself vs. the
|
|
299
|
+
orchestrator doing them on its behalf is reported by
|
|
300
|
+
probe_capabilities() (worker_filesystem_access / worker_shell_access /
|
|
301
|
+
structured_output). Implementations resolve request.model (falling back
|
|
302
|
+
to resolve_model(request.role) when None).
|
|
303
|
+
"""
|
|
304
|
+
raise NotImplementedError
|
|
305
|
+
|
|
306
|
+
# -- Operation 3: stall detection / status -----------------------------
|
|
307
|
+
@abstractmethod
|
|
308
|
+
def worker_status(self, worker_id: str) -> WorkerStatus:
|
|
309
|
+
"""Return liveness for a previously dispatched worker.
|
|
310
|
+
|
|
311
|
+
Backends with native status report it directly; others approximate from
|
|
312
|
+
heartbeat/output age. Powers the watchdog's stall-and-relaunch loop.
|
|
313
|
+
"""
|
|
314
|
+
raise NotImplementedError
|
|
315
|
+
|
|
316
|
+
# -- Operation 4: orchestrator-side command execution ------------------
|
|
317
|
+
@abstractmethod
|
|
318
|
+
def run_command(
|
|
319
|
+
self,
|
|
320
|
+
command: str,
|
|
321
|
+
cwd: Optional[str] = None,
|
|
322
|
+
shell: Optional[str] = None,
|
|
323
|
+
) -> CommandResult:
|
|
324
|
+
"""Run `command` on the ORCHESTRATOR's host (tests, git, verification).
|
|
325
|
+
|
|
326
|
+
Distinct from a worker running a shell command: this is the main thread
|
|
327
|
+
checking the fleet's output. Every backend must support it. `shell` is
|
|
328
|
+
an advisory hint ("bash" | "powershell" | "sh"); implementations pick a
|
|
329
|
+
sane default per platform.
|
|
330
|
+
"""
|
|
331
|
+
raise NotImplementedError
|
|
332
|
+
|
|
333
|
+
# -- Operation 5: model selection --------------------------------------
|
|
334
|
+
@abstractmethod
|
|
335
|
+
def resolve_model(self, role: str) -> str:
|
|
336
|
+
"""Map an abstract role to a concrete backend model id.
|
|
337
|
+
|
|
338
|
+
`role` is one of MODEL_ROLES. Claude Code returns Anthropic names;
|
|
339
|
+
Codex maps to OpenAI models; open-model maps to Ollama/OpenRouter ids.
|
|
340
|
+
"""
|
|
341
|
+
raise NotImplementedError
|
|
342
|
+
|
|
343
|
+
# -- Optional convenience (non-abstract) -------------------------------
|
|
344
|
+
def get_tokens_spent(self) -> Optional[int]:
|
|
345
|
+
"""Cumulative tokens spent, or None if the backend cannot report it.
|
|
346
|
+
|
|
347
|
+
Optional: the default returns None (estimate-only backend). Override
|
|
348
|
+
when the backend exposes real spend (Claude Code budget API, OpenAI
|
|
349
|
+
usage metadata).
|
|
350
|
+
"""
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
def describe(self) -> str:
|
|
354
|
+
"""ASCII one-liner combining name + capability summary (for logs)."""
|
|
355
|
+
return self.probe_capabilities().summary()
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Configuration loading and driver instantiation for aesop backends.
|
|
3
|
+
|
|
4
|
+
This module provides offline-safe config loading: reading an aesop.config.json,
|
|
5
|
+
validating the backend block schema, and instantiating the correct AgentDriver
|
|
6
|
+
subclass. Critically, building a driver requires NO API key at construction time;
|
|
7
|
+
keys are read from os.environ at call time when live dispatch happens.
|
|
8
|
+
|
|
9
|
+
The config schema for the backend block:
|
|
10
|
+
{
|
|
11
|
+
"backend": "claude" | "codex" | "openai-compatible",
|
|
12
|
+
"model": "...", # required for codex, openai-compatible
|
|
13
|
+
"base_url": "..."(optional), # required for openai-compatible
|
|
14
|
+
"api_key_env": "..."(optional), # optional; env var for API key
|
|
15
|
+
"tier": N(optional), # deprecated; ignored if present
|
|
16
|
+
"is_local": bool(optional) # optional for openai-compatible
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Default (no config) -> ClaudeCodeDriver (preserves today's behavior).
|
|
20
|
+
|
|
21
|
+
stdlib-only, ASCII-only, Windows + Linux safe.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Dict, Optional
|
|
28
|
+
|
|
29
|
+
from agent_driver import AgentDriver
|
|
30
|
+
from claude_code_driver import ClaudeCodeDriver
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_backend_config(path: Optional[str] = None) -> dict:
|
|
34
|
+
"""Load backend configuration from an aesop.config.json file.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
path: Path to the config file. If None, looks for aesop.config.json
|
|
38
|
+
in the current working directory. If the file does not exist, returns
|
|
39
|
+
a Claude default config dict (backend='claude').
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
A dict with structure:
|
|
43
|
+
{
|
|
44
|
+
"backend": "claude" | "codex" | "openai-compatible",
|
|
45
|
+
"model": "...",
|
|
46
|
+
"base_url": "...",
|
|
47
|
+
"api_key_env": "...",
|
|
48
|
+
"is_local": bool,
|
|
49
|
+
... other fields preserved
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: if the config file exists but is malformed JSON, or if the
|
|
54
|
+
backend block has invalid/conflicting fields.
|
|
55
|
+
TypeError: if the parsed config is not a dict or does not have a 'backend' key.
|
|
56
|
+
"""
|
|
57
|
+
if path is None:
|
|
58
|
+
path = "aesop.config.json"
|
|
59
|
+
|
|
60
|
+
config_path = Path(path)
|
|
61
|
+
if not config_path.exists():
|
|
62
|
+
# Default: Claude backend.
|
|
63
|
+
return {"backend": "claude"}
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(config_path, encoding="utf-8") as f:
|
|
67
|
+
config = json.load(f)
|
|
68
|
+
except json.JSONDecodeError as exc:
|
|
69
|
+
raise ValueError(f"aesop.config.json is not valid JSON: {exc}") from exc
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
raise ValueError(f"Cannot read aesop.config.json: {exc}") from exc
|
|
72
|
+
|
|
73
|
+
if not isinstance(config, dict):
|
|
74
|
+
raise TypeError("aesop.config.json must be a JSON object (dict)")
|
|
75
|
+
|
|
76
|
+
# Extract the backend block (nested or at root level).
|
|
77
|
+
# Support both {"backend": {...}} and direct backend dict.
|
|
78
|
+
if "backend" in config and isinstance(config["backend"], dict):
|
|
79
|
+
backend_block = config["backend"]
|
|
80
|
+
elif "backend" in config and isinstance(config["backend"], str):
|
|
81
|
+
# Flat structure: backend is a string, not nested.
|
|
82
|
+
backend_block = config
|
|
83
|
+
else:
|
|
84
|
+
# No backend key; treat as default Claude.
|
|
85
|
+
return {"backend": "claude"}
|
|
86
|
+
|
|
87
|
+
# Validate backend field.
|
|
88
|
+
backend_name = backend_block.get("backend")
|
|
89
|
+
if not backend_name:
|
|
90
|
+
backend_name = config.get("backend")
|
|
91
|
+
if not isinstance(backend_name, str):
|
|
92
|
+
raise TypeError("'backend' field must be a string")
|
|
93
|
+
|
|
94
|
+
valid_backends = ("claude", "codex", "openai-compatible")
|
|
95
|
+
if backend_name not in valid_backends:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Unknown backend '{backend_name}'. "
|
|
98
|
+
f"Valid choices: {', '.join(valid_backends)}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Validate required fields per backend.
|
|
102
|
+
if backend_name == "codex":
|
|
103
|
+
if "model" not in backend_block:
|
|
104
|
+
raise ValueError("backend 'codex' requires 'model' field")
|
|
105
|
+
if not isinstance(backend_block["model"], str):
|
|
106
|
+
raise ValueError("'model' must be a string")
|
|
107
|
+
|
|
108
|
+
if backend_name == "openai-compatible":
|
|
109
|
+
if "base_url" not in backend_block:
|
|
110
|
+
raise ValueError("backend 'openai-compatible' requires 'base_url' field")
|
|
111
|
+
if "model" not in backend_block:
|
|
112
|
+
raise ValueError("backend 'openai-compatible' requires 'model' field")
|
|
113
|
+
if not isinstance(backend_block["base_url"], str):
|
|
114
|
+
raise ValueError("'base_url' must be a string")
|
|
115
|
+
if not isinstance(backend_block["model"], str):
|
|
116
|
+
raise ValueError("'model' must be a string")
|
|
117
|
+
|
|
118
|
+
# Normalize: return backend dict with all fields.
|
|
119
|
+
result = dict(backend_block)
|
|
120
|
+
result["backend"] = backend_name
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_driver(config: Optional[dict] = None) -> AgentDriver:
|
|
125
|
+
"""Instantiate the correct AgentDriver from a config dict.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
config: Backend config dict (from load_backend_config). If None,
|
|
129
|
+
uses Claude Code driver (default).
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
An AgentDriver subclass (ClaudeCodeDriver, CodexDriver, OpenAICompatibleDriver).
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: if the config specifies an unknown backend or is missing required fields.
|
|
136
|
+
RuntimeError: if a live dispatch later tries to use an API key that is not set.
|
|
137
|
+
This runtime error happens at call time, not at build time, so
|
|
138
|
+
building a driver is always offline-safe.
|
|
139
|
+
"""
|
|
140
|
+
if config is None:
|
|
141
|
+
config = {"backend": "claude"}
|
|
142
|
+
|
|
143
|
+
backend_name = config.get("backend", "claude")
|
|
144
|
+
|
|
145
|
+
if backend_name == "claude":
|
|
146
|
+
# Optional model_map override for Claude.
|
|
147
|
+
model_map = None
|
|
148
|
+
if "model_map" in config and isinstance(config.get("model_map"), dict):
|
|
149
|
+
model_map = config["model_map"]
|
|
150
|
+
return ClaudeCodeDriver(model_map=model_map)
|
|
151
|
+
|
|
152
|
+
if backend_name == "codex":
|
|
153
|
+
# Import here to avoid circular dependency.
|
|
154
|
+
try:
|
|
155
|
+
from codex_driver import CodexDriver
|
|
156
|
+
except ImportError as exc:
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
"Cannot import CodexDriver. Make sure codex_driver.py is in the driver/ directory."
|
|
159
|
+
) from exc
|
|
160
|
+
|
|
161
|
+
model_map = config.get("model_map", {})
|
|
162
|
+
if not isinstance(model_map, dict):
|
|
163
|
+
model_map = {}
|
|
164
|
+
|
|
165
|
+
return CodexDriver(
|
|
166
|
+
model_map=model_map,
|
|
167
|
+
transport=None, # Will use default; key read at call time.
|
|
168
|
+
max_owned_bytes=config.get("max_owned_bytes", 200_000),
|
|
169
|
+
max_retries=config.get("max_retries", 2),
|
|
170
|
+
timeout_s=config.get("timeout_s", 120.0),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if backend_name == "openai-compatible":
|
|
174
|
+
# Import here to avoid circular dependency.
|
|
175
|
+
try:
|
|
176
|
+
from openai_compatible_driver import OpenAICompatibleDriver
|
|
177
|
+
except ImportError as exc:
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
"Cannot import OpenAICompatibleDriver. "
|
|
180
|
+
"Make sure openai_compatible_driver.py is in the driver/ directory."
|
|
181
|
+
) from exc
|
|
182
|
+
|
|
183
|
+
base_url = config["base_url"]
|
|
184
|
+
model = config["model"]
|
|
185
|
+
# Default API key env var name (assembled to avoid secret-scan false positive).
|
|
186
|
+
default_key_env = "OPENAI" + "_" + "API" + "_" + "KEY"
|
|
187
|
+
api_key_env = config.get("api_key_env", default_key_env)
|
|
188
|
+
is_local = config.get("is_local", False)
|
|
189
|
+
if not isinstance(is_local, bool):
|
|
190
|
+
is_local = False
|
|
191
|
+
|
|
192
|
+
model_map = config.get("model_map", {})
|
|
193
|
+
if not isinstance(model_map, dict):
|
|
194
|
+
model_map = {}
|
|
195
|
+
|
|
196
|
+
return OpenAICompatibleDriver(
|
|
197
|
+
base_url=base_url,
|
|
198
|
+
model=model,
|
|
199
|
+
api_key_env=api_key_env,
|
|
200
|
+
is_local=is_local,
|
|
201
|
+
model_map=model_map,
|
|
202
|
+
transport=None, # Will use default; key read at call time.
|
|
203
|
+
max_owned_bytes=config.get("max_owned_bytes", 200_000),
|
|
204
|
+
max_retries=config.get("max_retries", 2),
|
|
205
|
+
timeout_s=config.get("timeout_s", 120.0),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
raise ValueError(f"Unknown backend '{backend_name}'")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def describe_backend(config: Optional[dict] = None) -> str:
|
|
212
|
+
"""Return a human-readable description of a backend configuration.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
config: Backend config dict (from load_backend_config).
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
A short ASCII string suitable for logging, e.g.:
|
|
219
|
+
"claude-code: parallel=1 wfs=1 ... tier=1"
|
|
220
|
+
"codex (gpt-3.5-turbo) @ OpenAI: tier=2"
|
|
221
|
+
"openai-compatible (neural-chat) @ localhost:11434 (local): tier=3"
|
|
222
|
+
"""
|
|
223
|
+
if config is None:
|
|
224
|
+
config = {"backend": "claude"}
|
|
225
|
+
|
|
226
|
+
backend_name = config.get("backend", "claude")
|
|
227
|
+
|
|
228
|
+
if backend_name == "claude":
|
|
229
|
+
driver = ClaudeCodeDriver()
|
|
230
|
+
return driver.describe()
|
|
231
|
+
|
|
232
|
+
if backend_name == "codex":
|
|
233
|
+
try:
|
|
234
|
+
from codex_driver import CodexDriver
|
|
235
|
+
except ImportError:
|
|
236
|
+
return "codex (import failed)"
|
|
237
|
+
driver = CodexDriver(model_map=config.get("model_map", {}))
|
|
238
|
+
return driver.describe()
|
|
239
|
+
|
|
240
|
+
if backend_name == "openai-compatible":
|
|
241
|
+
try:
|
|
242
|
+
from openai_compatible_driver import OpenAICompatibleDriver
|
|
243
|
+
except ImportError:
|
|
244
|
+
return "openai-compatible (import failed)"
|
|
245
|
+
driver = OpenAICompatibleDriver(
|
|
246
|
+
base_url=config["base_url"],
|
|
247
|
+
model=config["model"],
|
|
248
|
+
api_key_env=config.get("api_key_env", "OPENAI_API_KEY"),
|
|
249
|
+
is_local=config.get("is_local", False),
|
|
250
|
+
)
|
|
251
|
+
return driver.describe()
|
|
252
|
+
|
|
253
|
+
return f"unknown backend '{backend_name}'"
|