@matt82198/aesop 0.3.2 → 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.
@@ -35,7 +35,6 @@ Claude agent from plain Python.
35
35
  stdlib-only, ASCII-only, Windows + Linux safe.
36
36
  """
37
37
 
38
- import subprocess
39
38
  from typing import Optional
40
39
 
41
40
  from agent_driver import (
@@ -50,6 +49,7 @@ from agent_driver import (
50
49
  WorkerStatus,
51
50
  WORKER_UNKNOWN,
52
51
  )
52
+ from proc_util import run_shell_bounded
53
53
 
54
54
 
55
55
  # Default abstract-role -> Anthropic model mapping. Workers are Haiku by policy
@@ -75,11 +75,12 @@ class ClaudeCodeDriver(AgentDriver):
75
75
 
76
76
  name = "claude-code"
77
77
 
78
- def __init__(self, model_map: Optional[dict] = None):
78
+ def __init__(self, model_map: Optional[dict] = None, timeout_s: float = 120.0):
79
79
  # Copy so callers cannot mutate our defaults; fall back per-role.
80
80
  self._model_map = dict(_DEFAULT_MODEL_MAP)
81
81
  if model_map:
82
82
  self._model_map.update(model_map)
83
+ self._timeout_s = timeout_s
83
84
 
84
85
  # -- Operation 1: capability probe (concrete) --------------------------
85
86
  def probe_capabilities(self) -> DriverCapabilities:
@@ -149,22 +150,13 @@ class ClaudeCodeDriver(AgentDriver):
149
150
  real subprocess so tooling/tests get genuine behavior. `shell` is
150
151
  advisory -- we always execute through the platform shell so the same
151
152
  call works on Windows and Linux.
153
+
154
+ Timeouts truly bound wall-clock (RS-A F1): on expiry the WHOLE process
155
+ tree is killed (taskkill /T on Windows, killpg on POSIX) and we return
156
+ exit 124 promptly, preserving any partial output captured before the
157
+ kill (RS-A F7). See proc_util.run_shell_bounded.
152
158
  """
153
- try:
154
- completed = subprocess.run(
155
- command,
156
- cwd=cwd,
157
- shell=True,
158
- capture_output=True,
159
- text=True,
160
- )
161
- return CommandResult(
162
- exit_code=completed.returncode,
163
- stdout=completed.stdout or "",
164
- stderr=completed.stderr or "",
165
- )
166
- except OSError as exc:
167
- return CommandResult(exit_code=127, stdout="", stderr=str(exc))
159
+ return run_shell_bounded(command, cwd=cwd, timeout_s=self._timeout_s)
168
160
 
169
161
  # -- Operation 5: model selection (concrete) ---------------------------
170
162
  def resolve_model(self, role: str) -> str:
@@ -34,11 +34,12 @@ stdlib-only, ASCII-only, Windows + Linux safe.
34
34
  import hashlib
35
35
  import json
36
36
  import os
37
- import subprocess
38
37
  import time
39
38
  from pathlib import Path
40
39
  from typing import Dict, Optional
41
40
 
41
+ from proc_util import run_shell_bounded
42
+
42
43
  from agent_driver import (
43
44
  AgentDriver,
44
45
  CommandResult,
@@ -176,6 +177,7 @@ class CodexDriver(AgentDriver):
176
177
  max_retries: int = 2,
177
178
  timeout_s: float = 120.0,
178
179
  allow_unverified_models: bool = False,
180
+ command_timeout_s: Optional[float] = None,
179
181
  ):
180
182
  """Initialize the CodexDriver with optional overrides.
181
183
 
@@ -185,9 +187,12 @@ class CodexDriver(AgentDriver):
185
187
  now: callable returning time.time() for testing (default=time.time).
186
188
  max_owned_bytes: max total bytes of owned files before pre-dispatch fail (default 200KB).
187
189
  max_retries: max in-turn retries on malformed JSON (default 2).
188
- timeout_s: HTTP timeout in seconds (default 120).
190
+ timeout_s: HTTP transport timeout + worker_status stall threshold, seconds (default 120).
189
191
  allow_unverified_models: if True, allow models not known to support json_schema
190
192
  (default False). Set to True only for experimental backends.
193
+ command_timeout_s: run_command wall-clock bound, seconds. Defaults to
194
+ timeout_s when unset, but is separately settable so raising the
195
+ HTTP timeout never silently raises the command timeout (RS-A F7).
191
196
 
192
197
  Raises:
193
198
  ValueError: if any mapped model is not in JSON_SCHEMA_CAPABLE and
@@ -207,11 +212,28 @@ class CodexDriver(AgentDriver):
207
212
  f"Pass allow_unverified_models=True to override (for experimental backends)."
208
213
  )
209
214
 
210
- self._transport = transport or default_openai_transport
211
215
  self._now = now or time.time
212
216
  self._max_owned_bytes = max_owned_bytes
213
217
  self._max_retries = max_retries
214
218
  self._timeout_s = timeout_s
219
+ # run_command gets its OWN knob (falls back to timeout_s) so the HTTP
220
+ # timeout and the command wall-clock bound are independently tunable.
221
+ self._command_timeout_s = (
222
+ command_timeout_s if command_timeout_s is not None else timeout_s
223
+ )
224
+
225
+ # Transport wiring. When falling back to the default transport, BIND the
226
+ # configured timeout: default_openai_transport has its own timeout_s=120
227
+ # default, so without this wrapper a configured timeout_s was silently
228
+ # ignored for the HTTP call (it only fed worker_status stall math).
229
+ if transport is not None:
230
+ self._transport = transport
231
+ elif default_openai_transport is not None:
232
+ self._transport = lambda payload: default_openai_transport(
233
+ payload, timeout_s=self._timeout_s
234
+ )
235
+ else:
236
+ self._transport = None
215
237
 
216
238
  # In-memory registry of worker status (worker_id -> {start_time, last_output_time, result}).
217
239
  self._worker_registry: Dict[str, dict] = {}
@@ -419,12 +441,21 @@ class CodexDriver(AgentDriver):
419
441
  structured = None
420
442
  last_error = None
421
443
  last_content = ""
444
+ last_failure_was_transport = False
422
445
 
423
446
  for attempt in range(self._max_retries + 1):
447
+ # Transport call. Network/auth/HTTP failures are NOT JSON
448
+ # validation errors: the model never produced output, so a
449
+ # schema nudge is meaningless. Retry (covers transients) but
450
+ # without appending nudge messages, and label honestly.
424
451
  try:
425
- # Call transport.
426
452
  response = self._transport(payload)
453
+ except Exception as exc:
454
+ last_error = f"transport error: {exc}"
455
+ last_failure_was_transport = True
456
+ continue
427
457
 
458
+ try:
428
459
  # Extract and parse JSON.
429
460
  if "choices" not in response or not response["choices"]:
430
461
  raise ValueError("no choices in response")
@@ -437,8 +468,9 @@ class CodexDriver(AgentDriver):
437
468
  # Success: break out of retry loop.
438
469
  break
439
470
 
440
- except (json.JSONDecodeError, ValueError, KeyError, Exception) as exc:
471
+ except (json.JSONDecodeError, ValueError, KeyError) as exc:
441
472
  last_error = str(exc)
473
+ last_failure_was_transport = False
442
474
  # If we have retries left, append error feedback and retry.
443
475
  if attempt < self._max_retries:
444
476
  # Before appending retry messages, check if total payload would exceed budget.
@@ -479,21 +511,41 @@ class CodexDriver(AgentDriver):
479
511
  )
480
512
  continue
481
513
 
482
- # If validation still failed after all retries.
514
+ # If transport or validation still failed after all retries.
483
515
  if structured is None:
516
+ if last_failure_was_transport:
517
+ error_msg = (
518
+ f"transport failed after {self._max_retries + 1} "
519
+ f"attempts: {last_error}"
520
+ )
521
+ else:
522
+ error_msg = (
523
+ f"structured output validation failed after "
524
+ f"{self._max_retries + 1} attempts: {last_error}"
525
+ )
484
526
  return WorkerResult(
485
527
  worker_id=worker_id,
486
528
  status=WORKER_FAILED,
487
529
  ok=False,
488
- error=f"structured output validation failed after {self._max_retries + 1} attempts: {last_error}",
530
+ error=error_msg,
489
531
  text=last_content,
490
532
  )
491
533
 
492
534
  # 8. Ownership enforcement: all returned paths must be in owned_files.
535
+ # Match under the SAME cross-platform policy as the read side
536
+ # (backslashes are separators on every OS): a Windows-authored
537
+ # manifest owning "src\\util.py" must accept a model returning
538
+ # "src/util.py" instead of failing spuriously as out-of-scope.
539
+ # SECURITY: the write always uses the canonical owned entry (which
540
+ # passed containment above), never the model's raw string.
541
+ owned_lookup = {
542
+ p.replace("\\", "/"): p for p in request.owned_files
543
+ }
493
544
  files_to_write = []
494
545
  for file_entry in structured.get("files", []):
495
546
  path_str = file_entry.get("path", "")
496
- if path_str not in request.owned_files:
547
+ canonical = owned_lookup.get(path_str.replace("\\", "/"))
548
+ if canonical is None:
497
549
  # Distinguish: path not in the owned set (security/isolation violation).
498
550
  return WorkerResult(
499
551
  worker_id=worker_id,
@@ -501,21 +553,30 @@ class CodexDriver(AgentDriver):
501
553
  ok=False,
502
554
  error=f"out-of-scope: worker attempted to write {path_str} (not in owned set)",
503
555
  )
504
- files_to_write.append((path_str, file_entry["contents"]))
556
+ files_to_write.append((canonical, file_entry["contents"]))
505
557
 
506
558
  # 9. Apply (validate ALL before writing ANY).
507
559
  written_paths = []
508
560
  for path_str, new_contents in files_to_write:
509
- full_path = Path(request.workdir) / path_str
561
+ # Normalize separators for the write target: on POSIX a backslash
562
+ # is a literal filename char, so an owned path declared "src\c.py"
563
+ # must become "src/c.py" before Path() (mirrors the read path above).
564
+ # written_paths keeps the canonical path_str for record-keeping.
565
+ full_path = Path(request.workdir) / path_str.replace("\\", "/")
510
566
  try:
511
567
  full_path.write_text(new_contents, encoding="utf-8")
512
568
  written_paths.append(path_str)
513
569
  except OSError as exc:
514
570
  # Distinguish: owned path exists but write failed (OS error).
571
+ # HONESTY: report the files ALREADY written before the
572
+ # failure -- a mid-loop failure leaves the tree partially
573
+ # modified with no rollback, and the orchestrator must know
574
+ # which files are dirty rather than believing none changed.
515
575
  return WorkerResult(
516
576
  worker_id=worker_id,
517
577
  status=WORKER_FAILED,
518
578
  ok=False,
579
+ files_written=tuple(written_paths),
519
580
  error=f"write_failed: {path_str}: {exc}",
520
581
  )
521
582
 
@@ -624,22 +685,16 @@ class CodexDriver(AgentDriver):
624
685
 
625
686
  Real subprocess execution (not a worker tool). Used for tests, git,
626
687
  verification. Mirrors ClaudeCodeDriver.run_command for parity.
688
+
689
+ Timeouts truly bound wall-clock (RS-A F1): on expiry the WHOLE process
690
+ tree is killed (taskkill /T on Windows, killpg on POSIX) and we return
691
+ exit 124 promptly, preserving any partial output captured before the
692
+ kill (RS-A F7). Bounded by command_timeout_s (NOT the HTTP timeout).
693
+ See proc_util.run_shell_bounded.
627
694
  """
628
- try:
629
- completed = subprocess.run(
630
- command,
631
- cwd=cwd,
632
- shell=True,
633
- capture_output=True,
634
- text=True,
635
- )
636
- return CommandResult(
637
- exit_code=completed.returncode,
638
- stdout=completed.stdout or "",
639
- stderr=completed.stderr or "",
640
- )
641
- except OSError as exc:
642
- return CommandResult(exit_code=127, stdout="", stderr=str(exc))
695
+ return run_shell_bounded(
696
+ command, cwd=cwd, timeout_s=self._command_timeout_s
697
+ )
643
698
 
644
699
  # -- Operation 5: model selection (concrete) -------------------------
645
700
  def resolve_model(self, role: str) -> str: