@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.
@@ -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