@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. 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}'"