@matt82198/aesop 0.3.1 → 0.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/CHANGELOG.md +34 -1
- package/README.md +26 -11
- package/aesop.config.example.json +16 -0
- package/bin/cli.js +5 -2
- package/daemons/install-tasks.ps1 +193 -0
- package/daemons/run-hidden.vbs +39 -0
- package/docs/INSTALL.md +148 -14
- package/docs/MICROKERNEL.md +452 -0
- package/docs/README.md +4 -0
- package/docs/THE-AESOP-HYPOTHESIS.md +140 -0
- package/driver/CLAUDE.md +123 -123
- package/driver/README.md +40 -2
- package/driver/adjudication_gate.py +367 -0
- package/driver/aesop.config.example.json +20 -4
- package/driver/backend_config.py +603 -12
- package/driver/claude_code_driver.py +9 -17
- package/driver/codex_driver.py +80 -25
- package/driver/context_pack.py +454 -0
- package/driver/openai_compatible_driver.py +53 -11
- package/driver/openai_transport.py +31 -10
- package/driver/orchestrator_backend.py +332 -0
- package/driver/orchestrator_driver.py +589 -0
- package/driver/proc_util.py +134 -0
- package/driver/wave_loop.py +801 -59
- package/driver/wave_scheduler.py +361 -37
- package/monitor/collect-signals.mjs +83 -0
- package/package.json +3 -1
- package/state_store/coordination.py +65 -5
- package/tools/ci_merge_wait.py +88 -42
- package/tools/ci_shard_runner.py +128 -0
- package/tools/seated_shadow_adjudication.py +920 -0
- package/tools/shadow_adjudication.py +1024 -0
- package/tools/verify_ui_trio_redaction_proof.py +292 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""OrchestratorBackend — protocol for orchestrator decision-making backends.
|
|
3
|
+
|
|
4
|
+
Defines the interface that orchestrator backends must implement to make
|
|
5
|
+
structured decisions (decide_call). Mirrors the AgentDriver seam pattern
|
|
6
|
+
but isolates the orchestrator's judgment-making from agent worker logic.
|
|
7
|
+
|
|
8
|
+
Protocol:
|
|
9
|
+
decide_call(prompt: str, *, schema: dict|None) -> str
|
|
10
|
+
Returns the raw model text response (typically JSON). The caller
|
|
11
|
+
(OrchestratorDriver.decide()) is responsible for parsing, validating,
|
|
12
|
+
and retrying on malformed output.
|
|
13
|
+
|
|
14
|
+
stdlib-only, ASCII-only, Windows + Linux safe (concrete backends own SDKs).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
# For real OpenAI transport
|
|
23
|
+
try:
|
|
24
|
+
from openai_transport import default_openai_transport
|
|
25
|
+
except ImportError:
|
|
26
|
+
default_openai_transport = None
|
|
27
|
+
|
|
28
|
+
# base_url SSRF guard (shared with backend_config). The deferred import keeps
|
|
29
|
+
# the module importable standalone, but construction FAILS CLOSED when the
|
|
30
|
+
# guard is unavailable (see OpenAICompatibleOrchestratorBackend.__init__):
|
|
31
|
+
# without it, urllib's default opener would happily open file:// or ftp://
|
|
32
|
+
# base URLs and DIRECT construction would bypass the config-layer validation.
|
|
33
|
+
try:
|
|
34
|
+
from backend_config import validate_base_url, validate_is_local_base_url
|
|
35
|
+
except ImportError:
|
|
36
|
+
validate_base_url = None
|
|
37
|
+
validate_is_local_base_url = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OrchestratorBackend(ABC):
|
|
41
|
+
"""Abstract base class for orchestrator backends.
|
|
42
|
+
|
|
43
|
+
Implementations provide decide_call() to make structured decisions
|
|
44
|
+
using a configured backend model (Claude, OpenAI, etc.).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def decide_call(
|
|
49
|
+
self, prompt: str, *, schema: Optional[Dict[str, Any]] = None
|
|
50
|
+
) -> str:
|
|
51
|
+
"""Make a structured decision and return the model's response.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
prompt: The complete decision prompt (system + context + request).
|
|
55
|
+
schema: Optional JSON schema for the response. Used by some backends
|
|
56
|
+
to enforce structured output; ignored by backends that don't
|
|
57
|
+
support it.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The raw model text response (typically JSON). The caller is
|
|
61
|
+
responsible for parsing, validating, and retrying on errors.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
RuntimeError: on transport errors, missing credentials, etc.
|
|
65
|
+
Caller should retry or return DECISION_FAILED.
|
|
66
|
+
"""
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
def get_tokens_spent(self) -> int:
|
|
70
|
+
"""Total tokens this backend has spent on decisions (best effort).
|
|
71
|
+
|
|
72
|
+
HS-2 block-gate hardening: the orchestrator SEAT's spend must count
|
|
73
|
+
against the cost ceiling like the worker seat's. Backends that can
|
|
74
|
+
meter usage override this; the default 0 means "no metering
|
|
75
|
+
available" (never a fabricated figure).
|
|
76
|
+
"""
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FakeOrchestratorBackend(OrchestratorBackend):
|
|
81
|
+
"""Testing backend with canned responses.
|
|
82
|
+
|
|
83
|
+
Useful for offline regression tests and controlling behavior deterministically.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
canned_responses: Optional[list] = None,
|
|
89
|
+
tokens_per_call: int = 0,
|
|
90
|
+
):
|
|
91
|
+
"""Initialize with a list of canned JSON responses.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
canned_responses: List of response dicts (or JSON strings) to return
|
|
95
|
+
in order. Each call to decide_call consumes one.
|
|
96
|
+
tokens_per_call: Simulated token spend accrued per successful
|
|
97
|
+
decide_call (for seat-spend metering tests).
|
|
98
|
+
"""
|
|
99
|
+
self.canned_responses = canned_responses or []
|
|
100
|
+
self.call_count = 0
|
|
101
|
+
self.received_prompts = [] # Capture prompts for regression tests
|
|
102
|
+
self.tokens_per_call = tokens_per_call
|
|
103
|
+
self.total_tokens_spent = 0
|
|
104
|
+
|
|
105
|
+
def get_tokens_spent(self) -> int:
|
|
106
|
+
return self.total_tokens_spent
|
|
107
|
+
|
|
108
|
+
def decide_call(
|
|
109
|
+
self, prompt: str, *, schema: Optional[Dict[str, Any]] = None
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Return the next canned response."""
|
|
112
|
+
# Record the prompt for testing (regression guard).
|
|
113
|
+
self.received_prompts.append(prompt)
|
|
114
|
+
|
|
115
|
+
if self.call_count >= len(self.canned_responses):
|
|
116
|
+
raise RuntimeError(
|
|
117
|
+
f"FakeOrchestratorBackend exhausted canned responses "
|
|
118
|
+
f"(call {self.call_count + 1} of {len(self.canned_responses)})"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
response = self.canned_responses[self.call_count]
|
|
122
|
+
self.call_count += 1
|
|
123
|
+
self.total_tokens_spent += self.tokens_per_call
|
|
124
|
+
|
|
125
|
+
# Return as JSON string if it's a dict.
|
|
126
|
+
if isinstance(response, dict):
|
|
127
|
+
return json.dumps(response)
|
|
128
|
+
return str(response)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class HarnessOrchestratorBackend(OrchestratorBackend):
|
|
132
|
+
"""Null backend for the DEFAULT orchestrator seat: the live harness.
|
|
133
|
+
|
|
134
|
+
When aesop.config.json has no seats.orchestrator block (or names backend
|
|
135
|
+
'harness'/'claude'), the orchestrator seat is the live harness itself --
|
|
136
|
+
the Claude Code session driving the loop -- not a swapped API backend.
|
|
137
|
+
This mirrors claude_code_driver's harness-serviced operations on the
|
|
138
|
+
worker seat: there is no Python code path that can "call" the harness,
|
|
139
|
+
so decide_call raises a clear, documented error instead of fabricating
|
|
140
|
+
a decision.
|
|
141
|
+
|
|
142
|
+
build_orchestrator_backend() returns this class for the no-op default,
|
|
143
|
+
which keeps existing installs byte-identical: no OpenAI backend is
|
|
144
|
+
constructed and no API key is required.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def decide_call(
|
|
148
|
+
self, prompt: str, *, schema: Optional[Dict[str, Any]] = None
|
|
149
|
+
) -> str:
|
|
150
|
+
"""Refuse: this seat is the live harness, not a swapped backend."""
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
"HarnessOrchestratorBackend has no decide_call: the orchestrator "
|
|
153
|
+
"seat is the live harness (the Claude Code session) itself, not a "
|
|
154
|
+
"swapped API backend. Decisions on this seat are made by the "
|
|
155
|
+
"harness directly. To route orchestrator decisions to an API "
|
|
156
|
+
"model, configure seats.orchestrator in aesop.config.json, e.g. "
|
|
157
|
+
'{"seats": {"orchestrator": {"backend": "openai-compatible", '
|
|
158
|
+
'"model": "gpt-4o-mini"}}}.'
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class OpenAICompatibleOrchestratorBackend(OrchestratorBackend):
|
|
163
|
+
"""Real OpenAI-compatible orchestrator backend.
|
|
164
|
+
|
|
165
|
+
Uses OpenAI Chat Completions API (or compatible) to make decisions.
|
|
166
|
+
Handles temperature fallback for reasoning models (gpt-5.x series).
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
model: The model id to use (default "gpt-4o-mini").
|
|
170
|
+
base_url: OpenAI API base URL (default production).
|
|
171
|
+
timeout_s: HTTP timeout in seconds (default 120).
|
|
172
|
+
transport: Optionally inject a custom transport for testing.
|
|
173
|
+
api_key_env: Env var name holding the API key (default OPENAI_API_KEY;
|
|
174
|
+
parity with the worker seat -- no hardcoded key env).
|
|
175
|
+
is_local: True for local endpoints (Ollama etc.): a missing key env is
|
|
176
|
+
replaced by a dummy 'local-only' key instead of raising. Requires
|
|
177
|
+
a loopback base_url (localhost/127.0.0.1/::1) -- construction
|
|
178
|
+
rejects is_local with a remote base_url.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
# Maximum allowed response size (100KB) to prevent excessive memory use
|
|
182
|
+
MAX_RESPONSE_SIZE = 100 * 1024 # 100KB
|
|
183
|
+
|
|
184
|
+
# Default API key env var name (assembled to avoid secret-scan false positive).
|
|
185
|
+
_DEFAULT_KEY_ENV = "OPENAI" + "_" + "API" + "_" + "KEY"
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
model: str = "gpt-4o-mini",
|
|
190
|
+
base_url: str = "https://api.openai.com/v1",
|
|
191
|
+
timeout_s: float = 120.0,
|
|
192
|
+
transport: Optional[Any] = None,
|
|
193
|
+
api_key_env: Optional[str] = None,
|
|
194
|
+
is_local: bool = False,
|
|
195
|
+
):
|
|
196
|
+
self.model = model
|
|
197
|
+
# SSRF guard at the constructor seam (mirrors backend_config): rejects
|
|
198
|
+
# non-http(s) schemes and private/link-local hosts on direct
|
|
199
|
+
# construction. FAIL CLOSED: if the guard could not be imported,
|
|
200
|
+
# refuse to construct rather than silently skipping validation.
|
|
201
|
+
if validate_base_url is None or validate_is_local_base_url is None:
|
|
202
|
+
raise RuntimeError(
|
|
203
|
+
"backend_config's base_url validators could not be imported; "
|
|
204
|
+
"refusing to construct OpenAICompatibleOrchestratorBackend "
|
|
205
|
+
"without the SSRF guard (fail closed). Ensure driver/ is on "
|
|
206
|
+
"sys.path so backend_config.py is importable."
|
|
207
|
+
)
|
|
208
|
+
validate_base_url(base_url)
|
|
209
|
+
# is_local disables the key requirement, so it must be pinned to a
|
|
210
|
+
# loopback base_url (parity with the worker seat / config layer).
|
|
211
|
+
if is_local:
|
|
212
|
+
validate_is_local_base_url(base_url)
|
|
213
|
+
self.base_url = base_url
|
|
214
|
+
self.timeout_s = timeout_s
|
|
215
|
+
self.transport = transport or default_openai_transport
|
|
216
|
+
self.api_key_env = api_key_env or self._DEFAULT_KEY_ENV
|
|
217
|
+
self.is_local = bool(is_local)
|
|
218
|
+
# HS-2 block-gate hardening: accumulate usage tokens so the seat's
|
|
219
|
+
# spend can be counted against the cost ceiling (get_tokens_spent).
|
|
220
|
+
self.total_tokens_spent = 0
|
|
221
|
+
|
|
222
|
+
def get_tokens_spent(self) -> int:
|
|
223
|
+
return self.total_tokens_spent
|
|
224
|
+
|
|
225
|
+
def decide_call(
|
|
226
|
+
self, prompt: str, *, schema: Optional[Dict[str, Any]] = None
|
|
227
|
+
) -> str:
|
|
228
|
+
"""Call OpenAI API and return the decision response text.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
prompt: The decision prompt.
|
|
232
|
+
schema: Optional JSON schema (passed to API if supported).
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The raw model text (typically JSON).
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
RuntimeError: on API errors, missing credentials, etc.
|
|
239
|
+
"""
|
|
240
|
+
# Ensure API key is set (configured env var, not hardcoded). Local
|
|
241
|
+
# endpoints (is_local) fall back to a dummy key -- Ollama-style
|
|
242
|
+
# deployments require an Authorization header but ignore its value.
|
|
243
|
+
# (Named retrieved_key to mirror openai_compatible_driver's local path.)
|
|
244
|
+
retrieved_key = os.environ.get(self.api_key_env)
|
|
245
|
+
if not retrieved_key:
|
|
246
|
+
if self.is_local:
|
|
247
|
+
retrieved_key = "local-only"
|
|
248
|
+
else:
|
|
249
|
+
raise RuntimeError(
|
|
250
|
+
f"{self.api_key_env} environment variable not set"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Build the Chat Completions payload with temperature.
|
|
254
|
+
# Temperature fallback is per-call (local to this method), not persisted.
|
|
255
|
+
payload = {
|
|
256
|
+
"model": self.model,
|
|
257
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
258
|
+
"temperature": 0,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# Add schema if provided and backend supports it.
|
|
262
|
+
if schema:
|
|
263
|
+
payload["response_format"] = {
|
|
264
|
+
"type": "json_schema",
|
|
265
|
+
"json_schema": {
|
|
266
|
+
"name": "decision_response",
|
|
267
|
+
"schema": schema,
|
|
268
|
+
"strict": False,
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
# Call the transport with temperature fallback (per-call, not persistent).
|
|
273
|
+
# The stock transport accepts the resolved api_key so a configured
|
|
274
|
+
# api_key_env / is_local dummy key is honored end-to-end; injected
|
|
275
|
+
# test transports keep the legacy (payload, timeout_s, base_url)
|
|
276
|
+
# signature, so the key kwarg is passed only to the default transport.
|
|
277
|
+
call_kwargs = {"timeout_s": self.timeout_s, "base_url": self.base_url}
|
|
278
|
+
if (
|
|
279
|
+
default_openai_transport is not None
|
|
280
|
+
and self.transport is default_openai_transport
|
|
281
|
+
):
|
|
282
|
+
call_kwargs["api_key"] = retrieved_key
|
|
283
|
+
try:
|
|
284
|
+
response_data = self.transport(payload, **call_kwargs)
|
|
285
|
+
except Exception as e:
|
|
286
|
+
error_str = str(e)
|
|
287
|
+
# TEMPERATURE FALLBACK: gpt-5.x reasoning models reject temperature=0.
|
|
288
|
+
# This fallback is LOCAL to this call; it does NOT persist to future calls.
|
|
289
|
+
if "temperature" in error_str and "unsupported_value" in error_str.lower():
|
|
290
|
+
# Retry without temperature (remove it from payload for this call only).
|
|
291
|
+
payload.pop("temperature", None)
|
|
292
|
+
response_data = self.transport(payload, **call_kwargs)
|
|
293
|
+
else:
|
|
294
|
+
raise
|
|
295
|
+
|
|
296
|
+
# Meter usage tokens (best effort) BEFORE response-shape validation:
|
|
297
|
+
# the provider charged for the call even if the payload is unusable.
|
|
298
|
+
if isinstance(response_data, dict):
|
|
299
|
+
usage = response_data.get("usage")
|
|
300
|
+
if isinstance(usage, dict):
|
|
301
|
+
try:
|
|
302
|
+
total = usage.get("total_tokens")
|
|
303
|
+
if total is None:
|
|
304
|
+
total = int(usage.get("prompt_tokens") or 0) + int(
|
|
305
|
+
usage.get("completion_tokens") or 0
|
|
306
|
+
)
|
|
307
|
+
self.total_tokens_spent += max(0, int(total))
|
|
308
|
+
except (TypeError, ValueError):
|
|
309
|
+
pass # Unparseable usage: never fabricate spend.
|
|
310
|
+
|
|
311
|
+
# Extract the completion text from the response.
|
|
312
|
+
if not isinstance(response_data, dict) or "choices" not in response_data:
|
|
313
|
+
raise RuntimeError(f"Unexpected API response format: {response_data}")
|
|
314
|
+
|
|
315
|
+
choices = response_data.get("choices", [])
|
|
316
|
+
if not choices or "message" not in choices[0]:
|
|
317
|
+
raise RuntimeError(f"No message in API response: {response_data}")
|
|
318
|
+
|
|
319
|
+
completion_text = choices[0]["message"].get("content", "")
|
|
320
|
+
if not completion_text:
|
|
321
|
+
raise RuntimeError("Empty completion text from API")
|
|
322
|
+
|
|
323
|
+
# Enforce response size limit to prevent excessive memory use.
|
|
324
|
+
# Measure BYTES not CHARS (multi-byte UTF-8 chars count as multiple bytes).
|
|
325
|
+
completion_bytes = completion_text.encode("utf-8")
|
|
326
|
+
if len(completion_bytes) > self.MAX_RESPONSE_SIZE:
|
|
327
|
+
raise RuntimeError(
|
|
328
|
+
f"Response size limit exceeded: {len(completion_bytes)} bytes > "
|
|
329
|
+
f"{self.MAX_RESPONSE_SIZE} bytes (100KB). The response is too large."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return completion_text
|