@julioborges/gantry 1.0.5 → 1.1.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.
@@ -116,6 +116,122 @@ def discover_antigravity_models(runner: Callable[..., subprocess.CompletedProces
116
116
  return models
117
117
 
118
118
 
119
+ _DISCOVERY_CACHE: dict[str, list[dict[str, Any]]] = {}
120
+
121
+
122
+ def clear_discovery_cache() -> None:
123
+ """Clear in-memory discovery cache."""
124
+ _DISCOVERY_CACHE.clear()
125
+
126
+
127
+ def parse_codex_models_output(
128
+ raw_output: str,
129
+ metadata_lookup: dict[str, Any] | None = None,
130
+ ) -> list[dict[str, Any]]:
131
+ """Parse stdout from `codex models` into a structured list of model dicts without inventing windows."""
132
+ cleaned = clean_ansi(raw_output)
133
+ models: list[dict[str, Any]] = []
134
+ seen: set[str] = set()
135
+
136
+ for line in cleaned.splitlines():
137
+ line = line.strip()
138
+ if not line or line.startswith("Fetching") or line.startswith("Usage:") or line.startswith("Models:"):
139
+ continue
140
+ parts = line.split(None, 1)
141
+ if not parts:
142
+ continue
143
+ model_id = parts[0].strip().strip("-*• ")
144
+ if not model_id:
145
+ continue
146
+ display_name = parts[1].strip() if len(parts) > 1 else model_id
147
+
148
+ if model_id in seen:
149
+ continue
150
+ seen.add(model_id)
151
+
152
+ entry: dict[str, Any] = {
153
+ "id": model_id,
154
+ "name": display_name,
155
+ }
156
+
157
+ if metadata_lookup and model_id in metadata_lookup:
158
+ meta = metadata_lookup[model_id]
159
+ if isinstance(meta, dict):
160
+ if "contextWindow" in meta:
161
+ entry["contextWindow"] = meta["contextWindow"]
162
+ if "supportedEfforts" in meta:
163
+ entry["supportedEfforts"] = meta["supportedEfforts"]
164
+ if "effort" in meta:
165
+ entry["effort"] = meta["effort"]
166
+
167
+ models.append(entry)
168
+
169
+ return models
170
+
171
+
172
+ def get_codex_version(runner: Callable[..., Any] | None = None) -> str:
173
+ """Extract version from `codex --version`."""
174
+ codex_path = shutil.which("codex")
175
+ if not codex_path and runner is None:
176
+ raise DiscoveryError("Missing discovery: codex CLI not found in PATH")
177
+
178
+ run_cmd = runner or subprocess.run
179
+ try:
180
+ proc = run_cmd([codex_path or "codex", "--version"], capture_output=True, text=True, check=False)
181
+ except OSError as exc:
182
+ raise DiscoveryError(f"Missing discovery: failed to run codex --version: {exc}") from exc
183
+
184
+ if proc.returncode != 0:
185
+ err = proc.stderr.strip() or proc.stdout.strip()
186
+ raise DiscoveryError(f"Missing discovery: codex --version returned exit code {proc.returncode}: {err}")
187
+
188
+ cleaned = clean_ansi(proc.stdout or proc.stderr).strip()
189
+ match = re.search(r"(\d+\.\d+(?:\.\d+)?)", cleaned)
190
+ if not match:
191
+ raise DiscoveryError(f"Missing discovery: could not parse codex version from {cleaned!r}")
192
+ return match.group(1)
193
+
194
+
195
+ def discover_codex_models(
196
+ runner: Callable[..., Any] | None = None,
197
+ use_cache: bool = True,
198
+ ) -> list[dict[str, Any]]:
199
+ """Discover executable models via `codex models` with metadata lookup and caching."""
200
+ if use_cache and "codex" in _DISCOVERY_CACHE and runner is None:
201
+ return list(_DISCOVERY_CACHE["codex"])
202
+
203
+ codex_path = shutil.which("codex")
204
+ if not codex_path and runner is None:
205
+ raise DiscoveryError("Missing discovery: codex CLI not found in PATH")
206
+
207
+ run_cmd = runner or subprocess.run
208
+ try:
209
+ proc = run_cmd([codex_path or "codex", "models"], capture_output=True, text=True, check=False)
210
+ except OSError as exc:
211
+ raise DiscoveryError(f"Missing discovery: failed to run codex models: {exc}") from exc
212
+
213
+ if proc.returncode != 0:
214
+ err = proc.stderr.strip() or proc.stdout.strip()
215
+ raise DiscoveryError(f"Missing discovery: codex models returned exit code {proc.returncode}: {err}")
216
+
217
+ cap_file = Path(__file__).resolve().parents[1] / "capabilities" / "codex.json"
218
+ metadata_lookup = {}
219
+ if cap_file.exists():
220
+ try:
221
+ metadata_lookup = json.loads(cap_file.read_text(encoding="utf-8")).get("models", {})
222
+ except Exception:
223
+ pass
224
+
225
+ models = parse_codex_models_output(proc.stdout, metadata_lookup=metadata_lookup)
226
+ if not models:
227
+ raise DiscoveryError("Missing discovery: codex models returned an empty model list")
228
+
229
+ if use_cache and runner is None:
230
+ _DISCOVERY_CACHE["codex"] = models
231
+
232
+ return models
233
+
234
+
119
235
  def discover_models(harness: str, runner: Callable[..., Any] | None = None) -> list[dict[str, Any]]:
120
236
  """Discover executable models for the given harness; fail closed on missing discovery."""
121
237
  h = (harness or "").lower()
@@ -143,14 +259,7 @@ def discover_models(harness: str, runner: Callable[..., Any] | None = None) -> l
143
259
  return [{"id": m, "contextWindow": d["contextWindow"]} for m, d in cap.get("models", {}).items()]
144
260
  raise DiscoveryError("Missing discovery: opencode capability declaration missing")
145
261
  elif h == "codex":
146
- codex_path = shutil.which("codex")
147
- if not codex_path:
148
- raise DiscoveryError("Missing discovery: codex CLI not found in PATH")
149
- cap_file = Path(__file__).resolve().parents[1] / "capabilities" / "codex.json"
150
- if cap_file.exists():
151
- cap = json.loads(cap_file.read_text(encoding="utf-8"))
152
- return [{"id": m, "contextWindow": d["contextWindow"]} for m, d in cap.get("models", {}).items()]
153
- raise DiscoveryError("Missing discovery: codex capability declaration missing")
262
+ return discover_codex_models(runner=runner)
154
263
 
155
264
  raise DiscoveryError(f"Missing discovery: unhandled harness {harness}")
156
265
 
@@ -63,6 +63,10 @@ class ProtocolFailureError(Exception):
63
63
  """Raised when an external role result is missing, undecodable or fails the schema contract."""
64
64
 
65
65
 
66
+ class ExecutionFailureError(RuntimeError):
67
+ """Raised when an external harness command fails at runtime (crash, timeout, non-zero exit)."""
68
+
69
+
66
70
  def parse_semver(version_str: str) -> tuple[int, int, int]:
67
71
  match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", version_str)
68
72
  if not match:
@@ -73,11 +77,67 @@ def parse_semver(version_str: str) -> tuple[int, int, int]:
73
77
  return (major, minor, patch)
74
78
 
75
79
 
80
+ def validate_codex_auth(runner: Callable[..., Any] | None = None) -> dict[str, Any]:
81
+ """Validate Codex authentication via `codex login status`."""
82
+ cli_name = CLI_NAMES.get("codex", "codex")
83
+ if runner is None and not shutil.which(cli_name):
84
+ return {
85
+ "valid": False,
86
+ "error": f"Codex CLI binary '{cli_name}' not found on PATH. Please install Codex CLI or ensure it is in your PATH.",
87
+ }
88
+
89
+ run_cmd = runner or subprocess.run
90
+ try:
91
+ try:
92
+ proc = run_cmd([cli_name, "login", "status"], capture_output=True, text=True, check=False)
93
+ except TypeError:
94
+ proc = run_cmd([cli_name, "login", "status"])
95
+ except FileNotFoundError:
96
+ return {
97
+ "valid": False,
98
+ "error": f"Codex CLI binary '{cli_name}' not found on PATH. Please install Codex CLI or ensure it is in your PATH.",
99
+ }
100
+ except Exception as exc:
101
+ return {
102
+ "valid": False,
103
+ "error": f"Codex CLI authentication check failed: {exc}. Run 'codex login' to authenticate.",
104
+ }
105
+
106
+ if proc.returncode != 0:
107
+ err = (getattr(proc, "stderr", "") or "").strip() or (getattr(proc, "stdout", "") or "").strip()
108
+ return {
109
+ "valid": False,
110
+ "error": f"Codex CLI is unauthenticated ({err or f'exit code {proc.returncode}'}). Run 'codex login' to authenticate.",
111
+ }
112
+
113
+ stdout = (getattr(proc, "stdout", "") or "").strip().lower()
114
+ if "not logged in" in stdout or "unauthenticated" in stdout or "no credentials" in stdout:
115
+ return {
116
+ "valid": False,
117
+ "error": f"Codex CLI is unauthenticated ({proc.stdout.strip()}). Run 'codex login' to authenticate.",
118
+ }
119
+
120
+ return {"valid": True}
121
+
122
+
76
123
  def validate_harness_version(
77
124
  harness: str,
78
125
  runner: Callable[..., Any] | None = None,
79
126
  ) -> dict[str, Any]:
80
127
  cli_name = CLI_NAMES.get(harness, harness)
128
+ if runner is None and not shutil.which(cli_name):
129
+ if harness == "codex":
130
+ return {
131
+ "valid": False,
132
+ "version": None,
133
+ "error": f"Codex CLI binary '{cli_name}' not found on PATH. Please install Codex CLI or ensure it is in your PATH.",
134
+ }
135
+ return {
136
+ "valid": False,
137
+ "version": None,
138
+ "error": f"Harness CLI '{cli_name}' not found on PATH",
139
+ }
140
+
81
141
  try:
82
142
  if runner:
83
143
  try:
@@ -87,6 +147,12 @@ def validate_harness_version(
87
147
  else:
88
148
  proc = subprocess.run([cli_name, "--version"], capture_output=True, text=True, check=False)
89
149
  if proc.returncode != 0:
150
+ if harness == "codex":
151
+ return {
152
+ "valid": False,
153
+ "version": None,
154
+ "error": f"Harness CLI {cli_name} authentication/execution validation failed: return code {proc.returncode}. Run 'codex login' to authenticate.",
155
+ }
90
156
  return {
91
157
  "valid": False,
92
158
  "version": None,
@@ -105,6 +171,18 @@ def validate_harness_version(
105
171
  }
106
172
  ver_formatted = f"{version_tuple[0]}.{version_tuple[1]}.{version_tuple[2]}"
107
173
  return {"valid": True, "version": ver_formatted}
174
+ except FileNotFoundError:
175
+ if harness == "codex":
176
+ return {
177
+ "valid": False,
178
+ "version": None,
179
+ "error": f"Codex CLI binary '{cli_name}' not found on PATH. Please install Codex CLI or ensure it is in your PATH.",
180
+ }
181
+ return {
182
+ "valid": False,
183
+ "version": None,
184
+ "error": f"Harness CLI '{cli_name}' not found on PATH",
185
+ }
108
186
  except Exception as exc:
109
187
  return {
110
188
  "valid": False,
@@ -320,6 +398,14 @@ def validate_selection(
320
398
  "role": role,
321
399
  "error": ver_check["error"],
322
400
  }
401
+ if harness == "codex":
402
+ auth_check = validate_codex_auth(runner=runner)
403
+ if not auth_check["valid"]:
404
+ return {
405
+ "valid": False,
406
+ "role": role,
407
+ "error": auth_check["error"],
408
+ }
323
409
 
324
410
  return {"valid": True, "role": role}
325
411
 
@@ -344,7 +430,10 @@ def build_dispatch_command(
344
430
  elif h == "opencode":
345
431
  return ["opencode", "run", prompt, "--model", model]
346
432
  elif h == "codex":
347
- return ["codex", "exec", prompt, "--model", model]
433
+ cmd = ["codex", "exec", prompt, "--model", model]
434
+ if effort:
435
+ cmd.extend(["--effort", effort])
436
+ return cmd
348
437
  else:
349
438
  raise ValueError(f"Unsupported harness: {harness}")
350
439
 
@@ -387,15 +476,9 @@ def parse_and_validate_result(
387
476
  if not raw_output or not raw_output.strip():
388
477
  raise ProtocolFailureError(f"Protocol failure: empty output returned for role {role}")
389
478
 
390
- text = raw_output.strip()
391
- if text.startswith("```json") and text.endswith("```"):
392
- text = text[7:-3].strip()
393
- elif text.startswith("```") and text.endswith("```"):
394
- text = text[3:-3].strip()
395
-
396
479
  try:
397
- data = json.loads(text)
398
- except json.JSONDecodeError as exc:
480
+ data = result.extract_json(raw_output)
481
+ except (json.JSONDecodeError, ValueError) as exc:
399
482
  raise ProtocolFailureError(f"Protocol failure: invalid JSON output for role {role}: {exc}") from exc
400
483
 
401
484
  if not isinstance(data, dict):
@@ -456,6 +539,7 @@ def dispatch_role(
456
539
  unit_id: str | None = None,
457
540
  state_root: str | None = None,
458
541
  retry_on_invalid: bool = True,
542
+ timeout: int | float | None = None,
459
543
  ) -> dict[str, Any]:
460
544
  """Execute a bounded role invocation in the selected native harness."""
461
545
  cwd_path = Path(cwd).resolve()
@@ -481,6 +565,10 @@ def dispatch_role(
481
565
  if not eff_val["valid"]:
482
566
  raise ValueError(eff_val["error"])
483
567
 
568
+ eff_timeout = timeout
569
+ if eff_timeout is None and isinstance(policy, dict):
570
+ eff_timeout = policy.get("execution", {}).get("timeout")
571
+
484
572
  if run_id and unit_id:
485
573
  try:
486
574
  root = runlog.state_root(state_root)
@@ -508,10 +596,25 @@ def dispatch_role(
508
596
 
509
597
  cmd = build_dispatch_command(harness, model, prompt, effort=effort)
510
598
  run_fn = runner or subprocess.run
511
- proc = run_fn(cmd, cwd=str(cwd_path), capture_output=True, text=True, check=False)
599
+ run_kwargs: dict[str, Any] = {"cwd": str(cwd_path), "capture_output": True, "text": True, "check": False}
600
+ if eff_timeout is not None:
601
+ run_kwargs["timeout"] = eff_timeout
602
+
603
+ try:
604
+ try:
605
+ proc = run_fn(cmd, **run_kwargs)
606
+ except TypeError:
607
+ proc = run_fn(cmd, cwd=str(cwd_path))
608
+ except subprocess.TimeoutExpired as exc:
609
+ raise ExecutionFailureError(f"Harness {harness} execution timed out after {eff_timeout}s: {exc}") from exc
610
+ except Exception as exc:
611
+ if isinstance(exc, (ExecutionFailureError, UnsupportedHarnessVersionError, ModelFallbackError, ProtocolFailureError)):
612
+ raise
613
+ raise ExecutionFailureError(f"Harness {harness} execution failed to start or crashed: {exc}") from exc
614
+
512
615
  if proc.returncode != 0:
513
- err = (proc.stderr or "").strip() or (proc.stdout or "").strip()
514
- raise RuntimeError(f"Harness {harness} execution failed (exit {proc.returncode}): {err}")
616
+ err = (getattr(proc, "stderr", "") or "").strip() or (getattr(proc, "stdout", "") or "").strip()
617
+ raise ExecutionFailureError(f"Harness {harness} execution failed (exit {proc.returncode}): {err}")
515
618
 
516
619
  check_model_fallback(model, proc.stdout)
517
620
 
@@ -523,9 +626,21 @@ def dispatch_role(
523
626
  if retry_on_invalid:
524
627
  retry_prompt = f"{prompt}\n\nYour prior result was invalid: {exc}\nReturn the complete {role} result contract."
525
628
  retry_cmd = build_dispatch_command(harness, model, retry_prompt, effort=effort)
526
- proc2 = run_fn(retry_cmd, cwd=str(cwd_path), capture_output=True, text=True, check=False)
629
+ try:
630
+ try:
631
+ proc2 = run_fn(retry_cmd, **run_kwargs)
632
+ except TypeError:
633
+ proc2 = run_fn(retry_cmd, cwd=str(cwd_path))
634
+ except subprocess.TimeoutExpired as exc2:
635
+ raise ExecutionFailureError(f"Harness {harness} retry execution timed out after {eff_timeout}s: {exc2}") from exc2
636
+ except Exception as exc2:
637
+ if isinstance(exc2, (ExecutionFailureError, UnsupportedHarnessVersionError, ModelFallbackError, ProtocolFailureError)):
638
+ raise
639
+ raise ExecutionFailureError(f"Harness {harness} retry failed to start or crashed: {exc2}") from exc2
640
+
527
641
  if proc2.returncode != 0:
528
- raise RuntimeError(f"Harness {harness} retry failed (exit {proc2.returncode}): {(proc2.stderr or '').strip()}")
642
+ err2 = (getattr(proc2, "stderr", "") or "").strip() or (getattr(proc2, "stdout", "") or "").strip()
643
+ raise ExecutionFailureError(f"Harness {harness} retry failed (exit {proc2.returncode}): {err2}")
529
644
  check_model_fallback(model, proc2.stdout)
530
645
  data = parse_and_validate_result(role, proc2.stdout)
531
646
  if role in ("critic", "requirement-critic", "plan-critic"):
@@ -685,6 +800,7 @@ def main() -> int:
685
800
  dispatch_parser.add_argument("--run-id", help="Run ID for logging")
686
801
  dispatch_parser.add_argument("--unit-id", help="Unit ID for logging")
687
802
  dispatch_parser.add_argument("--state-root", help="State root directory")
803
+ dispatch_parser.add_argument("--timeout", type=float, help="execution timeout in seconds")
688
804
  dispatch_parser.add_argument("--json", action="store_true", help="output JSON")
689
805
 
690
806
  failures_parser = subparsers.add_parser("unresolved-failures", help="check for unresolved paused executions")
@@ -750,6 +866,7 @@ def main() -> int:
750
866
  run_id=getattr(args, "run_id", None),
751
867
  unit_id=getattr(args, "unit_id", None),
752
868
  state_root=getattr(args, "state_root", None),
869
+ timeout=getattr(args, "timeout", None),
753
870
  )
754
871
  print(json.dumps(res, indent=2))
755
872
  return 0
@@ -569,8 +569,14 @@ def main() -> int:
569
569
 
570
570
  payload = read_payload()
571
571
  cwd = Path(args.cwd).resolve()
572
+ name = tool_name(payload) if payload else ""
573
+ normalized_name = re.sub(r"[^a-z]", "", name)
572
574
 
573
575
  def allow() -> int:
576
+ if args.event == "PostToolUse" and (not args.run_id or normalized_name != "invokesubagent"):
577
+ if args.json:
578
+ print("{}")
579
+ return 0
574
580
  if args.json:
575
581
  print(json.dumps({"decision": "allow"}, separators=(",", ":")))
576
582
  else: