delegate-agent-cli 0.11.0__py3-none-any.whl

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 (54) hide show
  1. delegate_agent/__init__.py +5 -0
  2. delegate_agent/archived_logs.py +44 -0
  3. delegate_agent/argv_builders.py +423 -0
  4. delegate_agent/argv_utils.py +36 -0
  5. delegate_agent/capability_commands.py +99 -0
  6. delegate_agent/cli.py +987 -0
  7. delegate_agent/cli_parser.py +1627 -0
  8. delegate_agent/command_errors.py +29 -0
  9. delegate_agent/command_help.py +1264 -0
  10. delegate_agent/config.py +1117 -0
  11. delegate_agent/config_commands.py +143 -0
  12. delegate_agent/constants.py +50 -0
  13. delegate_agent/describe_payload.py +1018 -0
  14. delegate_agent/errors.py +32 -0
  15. delegate_agent/git_utils.py +180 -0
  16. delegate_agent/harness_events.py +719 -0
  17. delegate_agent/inspection_commands.py +104 -0
  18. delegate_agent/isolation.py +316 -0
  19. delegate_agent/json_types.py +18 -0
  20. delegate_agent/log_output.py +78 -0
  21. delegate_agent/private_io.py +109 -0
  22. delegate_agent/profile_commands.py +42 -0
  23. delegate_agent/profile_guard.py +116 -0
  24. delegate_agent/profiles.py +389 -0
  25. delegate_agent/prompt_instructions.py +39 -0
  26. delegate_agent/prompt_transport.py +12 -0
  27. delegate_agent/reasoning.py +916 -0
  28. delegate_agent/redaction.py +193 -0
  29. delegate_agent/rendering.py +573 -0
  30. delegate_agent/request_build.py +1681 -0
  31. delegate_agent/request_models.py +191 -0
  32. delegate_agent/retention.py +321 -0
  33. delegate_agent/run_metadata.py +78 -0
  34. delegate_agent/run_output_commands.py +645 -0
  35. delegate_agent/run_registry.py +747 -0
  36. delegate_agent/run_status.py +300 -0
  37. delegate_agent/runner.py +1830 -0
  38. delegate_agent/safe_workspace.py +821 -0
  39. delegate_agent/snapshot_view.py +229 -0
  40. delegate_agent/wait_cancel_commands.py +559 -0
  41. delegate_agent/worktree_commands.py +218 -0
  42. delegate_agent/worktree_execution.py +654 -0
  43. delegate_agent/worktree_gc.py +529 -0
  44. delegate_agent/worktree_mgmt.py +782 -0
  45. delegate_agent/worktree_records.py +232 -0
  46. delegate_agent/worktree_remove.py +547 -0
  47. delegate_agent/worktree_summary.py +242 -0
  48. delegate_agent/wsl.py +65 -0
  49. delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
  50. delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
  51. delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
  52. delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
  53. delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
  54. delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,916 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Protocol, TypeAlias, cast
9
+
10
+ from delegate_agent import run_registry
11
+ from delegate_agent.json_types import JsonObject, JsonValue
12
+
13
+ ReasoningDeclaration: TypeAlias = Mapping[str, object]
14
+
15
+ # Bundled capabilities are a conservative fallback only. User config and a
16
+ # refreshed workspace cache take precedence so private/custom models do not
17
+ # require Delegate source changes, and stale bundled data can be bypassed.
18
+ BUNDLED_REASONING_CAPABILITIES: dict[str, dict[str, ReasoningDeclaration]] = {
19
+ "codex": {
20
+ "gpt-5.5": {
21
+ "supported": ("low", "medium", "high", "xhigh"),
22
+ "default": "medium",
23
+ },
24
+ "gpt-5.4": {
25
+ "supported": ("low", "medium", "high", "xhigh"),
26
+ "default": "medium",
27
+ },
28
+ "gpt-5.4-mini": {
29
+ "supported": ("low", "medium", "high", "xhigh"),
30
+ "default": "high",
31
+ },
32
+ "gpt-5.3-codex-spark": {
33
+ "supported": ("low", "medium", "high", "xhigh"),
34
+ "default": "medium",
35
+ },
36
+ },
37
+ "droid": {
38
+ "claude-opus-4-8": {
39
+ "supported": ("off", "low", "medium", "high", "xhigh", "max"),
40
+ "default": "high",
41
+ },
42
+ "claude-sonnet-4-6": {
43
+ "supported": ("off", "low", "medium", "high", "max"),
44
+ "default": "high",
45
+ },
46
+ "gpt-5.5": {
47
+ "supported": ("low", "medium", "high", "xhigh"),
48
+ "default": "medium",
49
+ },
50
+ "gemini-3.5-flash": {
51
+ "supported": ("minimal", "low", "medium", "high"),
52
+ "default": "high",
53
+ },
54
+ "glm-5.1": {"supported": ("off", "high"), "default": "high"},
55
+ "minimax-m2.7": {"supported": ("high",), "default": "high"},
56
+ },
57
+ }
58
+
59
+ # Argv builders compare a capability's transport against these constants, so
60
+ # they must stay in lockstep with the table below — hence named constants, not
61
+ # re-typed string literals at the emission sites.
62
+ TRANSPORT_CODEX_CONFIG = "codex-config"
63
+ TRANSPORT_DROID_FLAG = "droid-flag"
64
+ TRANSPORT_CURSOR_MODEL_SELECTION = "cursor-model-selection"
65
+ TRANSPORT_CLAUDE_EFFORT_FLAG = "claude-effort-flag"
66
+ CLAUDE_NATIVE_EFFORTS = ("low", "medium", "high", "xhigh", "max")
67
+ TRANSPORT_GROK_EFFORT_FLAG = "grok-effort-flag"
68
+ GROK_NATIVE_EFFORTS = CLAUDE_NATIVE_EFFORTS
69
+ INSPECT_REASONING_DISCOVERY_HINT = (
70
+ "Inspect `delegate --json models --summary` or "
71
+ "`delegate --json capabilities` for reasoning-effort support."
72
+ )
73
+ KIMI_UNSUPPORTED_REASONING_WARNING = "reasoning effort is not supported for kimi."
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class ReasoningProfile:
78
+ transport: str | None
79
+ strategy: str # "model-table" | "static-enum" | "effort-routing" | "unsupported"
80
+ static_efforts: tuple[str, ...] | None = None # static-enum (claude)
81
+ unsupported_warning: str | None = None # unsupported (kimi)
82
+
83
+
84
+ # Single source of truth for per-harness reasoning facts; the renderers below
85
+ # read from this table instead of re-encoding each fact. Row order matters:
86
+ # codex precedes droid so the derived model-table loop emits byte-identical
87
+ # output.
88
+ REASONING_PROFILES: dict[str, ReasoningProfile] = {
89
+ "codex": ReasoningProfile(TRANSPORT_CODEX_CONFIG, "model-table"),
90
+ "droid": ReasoningProfile(TRANSPORT_DROID_FLAG, "model-table"),
91
+ "cursor": ReasoningProfile(TRANSPORT_CURSOR_MODEL_SELECTION, "effort-routing"),
92
+ "claude": ReasoningProfile(
93
+ TRANSPORT_CLAUDE_EFFORT_FLAG,
94
+ "static-enum",
95
+ static_efforts=CLAUDE_NATIVE_EFFORTS,
96
+ ),
97
+ "grok": ReasoningProfile(
98
+ TRANSPORT_GROK_EFFORT_FLAG,
99
+ "static-enum",
100
+ static_efforts=GROK_NATIVE_EFFORTS,
101
+ ),
102
+ "kimi": ReasoningProfile(
103
+ None,
104
+ "unsupported",
105
+ unsupported_warning=KIMI_UNSUPPORTED_REASONING_WARNING,
106
+ ),
107
+ }
108
+
109
+ TRANSPORT_BY_HARNESS = {
110
+ h: p.transport
111
+ for h, p in REASONING_PROFILES.items()
112
+ if p.transport is not None and p.strategy != "static-enum"
113
+ }
114
+
115
+
116
+ def _alias_key_for_default_model(default_model: object) -> str:
117
+ return default_model if isinstance(default_model, str) and default_model else "(default)"
118
+
119
+
120
+ def _kimi_unsupported_reasoning_fields() -> JsonObject:
121
+ return {
122
+ "supported": None,
123
+ "source": "none",
124
+ "warning": REASONING_PROFILES["kimi"].unsupported_warning,
125
+ }
126
+
127
+
128
+ def _resolved_model_required_detail(harness: str) -> str:
129
+ if harness == "codex":
130
+ return (
131
+ "reasoning effort requires a resolved model — set codex.defaultModel "
132
+ "in config (or pass a model via run-input JSON)"
133
+ )
134
+ if harness == "droid":
135
+ return (
136
+ "reasoning effort requires a resolved model — configure the selected "
137
+ "Droid alias in droid.models"
138
+ )
139
+ return "reasoning effort requires a resolved model"
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class ReasoningCapability:
144
+ harness: str
145
+ model: str
146
+ effort: str
147
+ supported_efforts: tuple[str, ...]
148
+ default_effort: str | None
149
+ transport: str
150
+ source: str
151
+
152
+
153
+ class ReasoningCapabilityError(Exception):
154
+ def __init__(self, error: str, message: str) -> None:
155
+ super().__init__(message)
156
+ self.error = error
157
+ self.message = message
158
+
159
+
160
+ class ReasoningPayloadCarrier(Protocol):
161
+ reasoning_effort: str | None
162
+ reasoning_effort_source: str | None
163
+ reasoning_capability_source: str | None
164
+ reasoning_transport: str | None
165
+
166
+
167
+ def is_valid_effort_string(value: object) -> bool:
168
+ # Effort values land in child argv and inside a quoted Codex TOML override
169
+ # (model_reasoning_effort="…"), so quoting hazards are banned alongside
170
+ # whitespace.
171
+ return (
172
+ isinstance(value, str)
173
+ and bool(value)
174
+ and not any(ch.isspace() for ch in value)
175
+ and '"' not in value
176
+ and "\\" not in value
177
+ )
178
+
179
+
180
+ def normalize_effort(value: object) -> str:
181
+ if not is_valid_effort_string(value):
182
+ raise ReasoningCapabilityError(
183
+ "invalid_reasoning_effort",
184
+ "reasoning effort must be a non-empty string without whitespace, "
185
+ "double quotes, or backslashes.",
186
+ )
187
+ return cast(str, value)
188
+
189
+
190
+ def format_explicit_reasoning_effort_error(
191
+ *,
192
+ harness: str,
193
+ detail: str,
194
+ alias: str | None = None,
195
+ model: str | None = None,
196
+ effort: str | None = None,
197
+ supported: tuple[str, ...] | list[str] | None = None,
198
+ ) -> str:
199
+ context_parts: list[str] = []
200
+ if alias:
201
+ context_parts.append(f"alias {alias!r}")
202
+ context_parts.append(f"harness {harness}")
203
+ if model:
204
+ context_parts.append(f"model {model!r}")
205
+ message = detail
206
+ if context_parts:
207
+ message = f"{detail} ({', '.join(context_parts)})"
208
+ parts = [message]
209
+ if effort is not None:
210
+ parts.append(f"Requested effort: {effort!r}.")
211
+ if supported:
212
+ parts.append(f"Supported values: {', '.join(supported)}.")
213
+ parts.append(INSPECT_REASONING_DISCOVERY_HINT)
214
+ return " ".join(parts)
215
+
216
+
217
+ def resolve_native_effort(
218
+ harness: str,
219
+ requested_effort: str | None,
220
+ *,
221
+ alias: str | None = None,
222
+ model: str | None = None,
223
+ ) -> str | None:
224
+ """Validate static native --effort values for harnesses that expose one."""
225
+ if requested_effort is None:
226
+ return None
227
+ profile = REASONING_PROFILES[harness]
228
+ supported = profile.static_efforts or ()
229
+ effort = normalize_effort(requested_effort)
230
+ if effort not in supported:
231
+ raise ReasoningCapabilityError(
232
+ "unsupported_reasoning_effort",
233
+ format_explicit_reasoning_effort_error(
234
+ harness=harness,
235
+ alias=alias,
236
+ model=model,
237
+ effort=effort,
238
+ supported=supported,
239
+ detail="does not support reasoning effort",
240
+ ),
241
+ )
242
+ return effort
243
+
244
+
245
+ def resolve_claude_native_effort(
246
+ requested_effort: str | None,
247
+ *,
248
+ alias: str | None = None,
249
+ model: str | None = None,
250
+ ) -> str | None:
251
+ """Validate Claude Code's native --effort values.
252
+
253
+ Claude Code exposes reasoning effort as a process-level flag, not a
254
+ per-model capability declaration. Keep this separate from
255
+ ``resolve_reasoning_capability`` so user/cache model capability tables do
256
+ not imply Claude model catalog support.
257
+ """
258
+ return resolve_native_effort("claude", requested_effort, alias=alias, model=model)
259
+
260
+
261
+ def resolve_grok_native_effort(
262
+ requested_effort: str | None,
263
+ *,
264
+ alias: str | None = None,
265
+ model: str | None = None,
266
+ ) -> str | None:
267
+ """Validate Grok Build CLI native --effort values."""
268
+ return resolve_native_effort("grok", requested_effort, alias=alias, model=model)
269
+
270
+
271
+ def _as_models_map(source: JsonValue) -> dict[str, JsonObject]:
272
+ if not isinstance(source, dict):
273
+ return {}
274
+ models: dict[str, JsonObject] = {}
275
+ for model, declaration in source.items():
276
+ if isinstance(model, str) and model and isinstance(declaration, dict):
277
+ models[model] = declaration
278
+ return models
279
+
280
+
281
+ def _config_model_declarations(config: JsonObject, harness: str) -> dict[str, JsonObject]:
282
+ reasoning = config.get("reasoning")
283
+ if not isinstance(reasoning, dict):
284
+ return {}
285
+ capabilities = reasoning.get("capabilities")
286
+ if not isinstance(capabilities, dict):
287
+ return {}
288
+ return _as_models_map(capabilities.get(harness))
289
+
290
+
291
+ def _cache_model_declarations(cache: JsonObject | None, harness: str) -> dict[str, JsonObject]:
292
+ if not isinstance(cache, dict):
293
+ return {}
294
+ harnesses = cache.get("harnesses")
295
+ if not isinstance(harnesses, dict):
296
+ return {}
297
+ harness_decl = harnesses.get(harness)
298
+ if not isinstance(harness_decl, dict):
299
+ return {}
300
+ return _as_models_map(harness_decl.get("models"))
301
+
302
+
303
+ def _supported_tuple(declaration: ReasoningDeclaration) -> tuple[str, ...] | None:
304
+ raw = declaration.get("supported")
305
+ if isinstance(raw, tuple) and raw and all(isinstance(item, str) and item for item in raw):
306
+ return raw
307
+ if isinstance(raw, list) and raw and all(isinstance(item, str) and item for item in raw):
308
+ return tuple(raw)
309
+ return None
310
+
311
+
312
+ def _default_effort(declaration: ReasoningDeclaration, supported: tuple[str, ...]) -> str | None:
313
+ raw = declaration.get("default")
314
+ if raw is None:
315
+ return None
316
+ if isinstance(raw, str) and raw in supported:
317
+ return raw
318
+ return None
319
+
320
+
321
+ def _lookup_declaration(
322
+ *,
323
+ harness: str,
324
+ model: str,
325
+ config: JsonObject,
326
+ cache: JsonObject | None,
327
+ ) -> tuple[ReasoningDeclaration | None, str]:
328
+ for source, declarations in (
329
+ ("config", _config_model_declarations(config, harness)),
330
+ ("cache", _cache_model_declarations(cache, harness)),
331
+ ("bundled", BUNDLED_REASONING_CAPABILITIES.get(harness, {})),
332
+ ):
333
+ declaration = declarations.get(model)
334
+ if declaration is not None:
335
+ return declaration, source
336
+ return None, "none"
337
+
338
+
339
+ def resolve_reasoning_capability(
340
+ *,
341
+ harness: str,
342
+ model: str | None,
343
+ requested_effort: str | None,
344
+ config: JsonObject,
345
+ cache: JsonObject | None = None,
346
+ alias: str | None = None,
347
+ ) -> ReasoningCapability | None:
348
+ if requested_effort is None:
349
+ return None
350
+ effort = normalize_effort(requested_effort)
351
+ if harness not in TRANSPORT_BY_HARNESS:
352
+ raise ReasoningCapabilityError(
353
+ "unsupported_reasoning_effort",
354
+ format_explicit_reasoning_effort_error(
355
+ harness=harness,
356
+ alias=alias,
357
+ model=model,
358
+ detail="reasoning effort is not supported",
359
+ ),
360
+ )
361
+ if not model:
362
+ raise ReasoningCapabilityError(
363
+ "unsupported_reasoning_effort",
364
+ format_explicit_reasoning_effort_error(
365
+ harness=harness,
366
+ alias=alias,
367
+ detail=_resolved_model_required_detail(harness),
368
+ ),
369
+ )
370
+
371
+ declaration, source = _lookup_declaration(
372
+ harness=harness,
373
+ model=model,
374
+ config=config,
375
+ cache=cache,
376
+ )
377
+ if declaration is None:
378
+ raise ReasoningCapabilityError(
379
+ "unsupported_reasoning_effort",
380
+ format_explicit_reasoning_effort_error(
381
+ harness=harness,
382
+ alias=alias,
383
+ model=model,
384
+ effort=effort,
385
+ detail="has no declared reasoning-effort capability",
386
+ ),
387
+ )
388
+ supported = _supported_tuple(declaration)
389
+ if supported is None:
390
+ raise ReasoningCapabilityError(
391
+ "invalid_reasoning_config",
392
+ format_explicit_reasoning_effort_error(
393
+ harness=harness,
394
+ alias=alias,
395
+ model=model,
396
+ effort=effort,
397
+ detail="has malformed reasoning capability data",
398
+ ),
399
+ )
400
+ if effort not in supported:
401
+ raise ReasoningCapabilityError(
402
+ "unsupported_reasoning_effort",
403
+ format_explicit_reasoning_effort_error(
404
+ harness=harness,
405
+ alias=alias,
406
+ model=model,
407
+ effort=effort,
408
+ supported=supported,
409
+ detail="does not support reasoning effort",
410
+ ),
411
+ )
412
+ return ReasoningCapability(
413
+ harness=harness,
414
+ model=model,
415
+ effort=effort,
416
+ supported_efforts=supported,
417
+ default_effort=_default_effort(declaration, supported),
418
+ transport=TRANSPORT_BY_HARNESS[harness],
419
+ source=source,
420
+ )
421
+
422
+
423
+ def add_reasoning_payload_fields(payload: JsonObject, carrier: ReasoningPayloadCarrier) -> None:
424
+ """Emit the reasoning JSON fields from any carrier with the shared attribute names.
425
+
426
+ Used for both the CLI Request and the runner RunContext so dry-run payloads,
427
+ manifests, and snapshots cannot drift. The documented schema exposes both
428
+ requestedReasoningEffort and resolvedReasoningEffort; resolution never
429
+ rewrites the value today, so both keys carry the single internal effort.
430
+ """
431
+ effort = carrier.reasoning_effort
432
+ if effort is not None:
433
+ payload["requestedReasoningEffort"] = effort
434
+ payload["resolvedReasoningEffort"] = effort
435
+ if carrier.reasoning_effort_source is not None:
436
+ payload["reasoningEffortSource"] = carrier.reasoning_effort_source
437
+ if carrier.reasoning_capability_source is not None:
438
+ payload["reasoningCapabilitySource"] = carrier.reasoning_capability_source
439
+ if carrier.reasoning_transport is not None:
440
+ payload["reasoningTransport"] = carrier.reasoning_transport
441
+
442
+
443
+ def reasoning_capability_cache_path(workspace: str | Path) -> Path:
444
+ return Path(workspace) / ".delegate" / "capabilities" / "reasoning.json"
445
+
446
+
447
+ def load_reasoning_capability_cache(workspace: str | Path) -> JsonObject | None:
448
+ """Load the workspace capability cache, treating malformed payloads as absent.
449
+
450
+ The disk read is the trust boundary: a corrupt cache must not block runs
451
+ (config/bundled declarations may still resolve the model) and must not stop
452
+ `capabilities refresh` from overwriting it with fresh data.
453
+ """
454
+ try:
455
+ loaded = run_registry.read_json_object(reasoning_capability_cache_path(workspace))
456
+ except run_registry.RegistryJsonError:
457
+ return None
458
+ if loaded is None:
459
+ return None
460
+ try:
461
+ validate_cache_payload(loaded)
462
+ except ReasoningCapabilityError:
463
+ return None
464
+ return loaded
465
+
466
+
467
+ def _copy_payload_model(declaration: ReasoningDeclaration, *, source: str) -> JsonObject | None:
468
+ supported = _supported_tuple(declaration)
469
+ if supported is None:
470
+ return None
471
+ payload: JsonObject = {"supported": list(supported), "source": source}
472
+ default = _default_effort(declaration, supported)
473
+ if default is not None:
474
+ payload["default"] = default
475
+ return payload
476
+
477
+
478
+ def _overlay_payload_models(
479
+ payload: JsonObject, declarations: Mapping[str, ReasoningDeclaration], source: str
480
+ ) -> None:
481
+ for model, declaration in sorted(declarations.items()):
482
+ model_payload = _copy_payload_model(declaration, source=source)
483
+ if model_payload is not None:
484
+ payload[model] = model_payload
485
+
486
+
487
+ def _cursor_reasoning_models_payload(mappings: JsonValue) -> JsonObject:
488
+ models: JsonObject = {}
489
+ if not isinstance(mappings, dict):
490
+ return models
491
+ for effort, model in sorted(mappings.items()):
492
+ if not (isinstance(effort, str) and effort and isinstance(model, str) and model):
493
+ continue
494
+ model_payload = models.setdefault(model, {"supported": [], "source": "config"})
495
+ supported = model_payload["supported"]
496
+ if isinstance(supported, list):
497
+ supported.append(effort)
498
+ return models
499
+
500
+
501
+ def _model_reasoning_summary(
502
+ *,
503
+ harness: str,
504
+ alias: str,
505
+ model: str | None,
506
+ config: JsonObject,
507
+ cache: JsonObject | None,
508
+ config_default: str | None = None,
509
+ ) -> JsonObject:
510
+ payload: JsonObject = {"alias": alias}
511
+ if model:
512
+ payload["model"] = model
513
+ else:
514
+ payload["supported"] = None
515
+ payload["source"] = "none"
516
+ payload["warning"] = f"{harness} requires a resolved model for reasoning-effort support."
517
+ return payload
518
+
519
+ declaration, source = _lookup_declaration(
520
+ harness=harness,
521
+ model=model,
522
+ config=config,
523
+ cache=cache,
524
+ )
525
+ if declaration is None:
526
+ payload["supported"] = None
527
+ payload["source"] = "none"
528
+ payload["warning"] = (
529
+ f"no declared reasoning-effort capability for {harness} model {model!r}."
530
+ )
531
+ return payload
532
+
533
+ supported = _supported_tuple(declaration)
534
+ if supported is None:
535
+ payload["supported"] = None
536
+ payload["source"] = source
537
+ payload["warning"] = f"malformed reasoning capability data for {harness} model {model!r}."
538
+ return payload
539
+
540
+ payload["supported"] = list(supported)
541
+ payload["source"] = source
542
+ default = _default_effort(declaration, supported)
543
+ if default is not None:
544
+ payload["default"] = default
545
+ if config_default is not None:
546
+ payload["configDefault"] = config_default
547
+ return payload
548
+
549
+
550
+ def _cursor_alias_reasoning_summary(cursor: JsonObject) -> JsonObject:
551
+ default_model = cursor.get("defaultModel")
552
+ alias = _alias_key_for_default_model(default_model)
553
+ payload: JsonObject = {"alias": alias}
554
+ if isinstance(default_model, str) and default_model:
555
+ payload["defaultModel"] = default_model
556
+ mappings = cursor.get("reasoningEffortModels")
557
+ routing: list[JsonObject] = []
558
+ if isinstance(mappings, dict):
559
+ routing = [
560
+ {"effort": effort, "model": model}
561
+ for effort, model in sorted(mappings.items())
562
+ if isinstance(effort, str) and effort and isinstance(model, str) and model
563
+ ]
564
+ if routing:
565
+ payload["effortModelRouting"] = routing
566
+ payload["source"] = "config"
567
+ else:
568
+ payload["source"] = "none"
569
+ payload["warning"] = (
570
+ "cursor.reasoningEffortModels is empty; configure effort-to-model mappings."
571
+ )
572
+ default_effort = cursor.get("defaultReasoningEffort")
573
+ if isinstance(default_effort, str):
574
+ payload["configDefault"] = default_effort
575
+ return payload
576
+
577
+
578
+ def _claude_alias_reasoning_summary(claude: JsonObject) -> JsonObject:
579
+ default_model = claude.get("defaultModel")
580
+ alias = _alias_key_for_default_model(default_model)
581
+ payload: JsonObject = {
582
+ "alias": alias,
583
+ "supported": list(REASONING_PROFILES["claude"].static_efforts),
584
+ "source": "static",
585
+ "transport": REASONING_PROFILES["claude"].transport,
586
+ }
587
+ if isinstance(default_model, str) and default_model:
588
+ payload["model"] = default_model
589
+ default_effort = claude.get("defaultReasoningEffort")
590
+ if isinstance(default_effort, str):
591
+ payload["configDefault"] = default_effort
592
+ return payload
593
+
594
+
595
+ def _grok_alias_reasoning_summary(grok: JsonObject) -> JsonObject:
596
+ default_model = grok.get("defaultModel")
597
+ alias = _alias_key_for_default_model(default_model)
598
+ payload: JsonObject = {
599
+ "alias": alias,
600
+ "supported": list(REASONING_PROFILES["grok"].static_efforts),
601
+ "source": "static",
602
+ "transport": REASONING_PROFILES["grok"].transport,
603
+ }
604
+ if isinstance(default_model, str) and default_model:
605
+ payload["model"] = default_model
606
+ default_effort = grok.get("defaultReasoningEffort")
607
+ if isinstance(default_effort, str):
608
+ payload["configDefault"] = default_effort
609
+ return payload
610
+
611
+
612
+ def _kimi_alias_reasoning_summary(kimi: JsonObject) -> JsonObject:
613
+ default_model = kimi.get("defaultModel")
614
+ alias = _alias_key_for_default_model(default_model)
615
+ payload: JsonObject = {"alias": alias, **_kimi_unsupported_reasoning_fields()}
616
+ if isinstance(default_model, str) and default_model:
617
+ payload["model"] = default_model
618
+ return payload
619
+
620
+
621
+ def build_alias_reasoning_summaries(
622
+ config: JsonObject,
623
+ cache: JsonObject | None,
624
+ ) -> JsonObject:
625
+ summaries: JsonObject = {}
626
+
627
+ droid = config.get("droid")
628
+ droid_aliases: JsonObject = {}
629
+ if isinstance(droid, dict):
630
+ models = droid.get("models")
631
+ config_default = droid.get("defaultReasoningEffort")
632
+ default_effort = config_default if isinstance(config_default, str) else None
633
+ if isinstance(models, dict):
634
+ for alias, model_id in sorted(models.items()):
635
+ if not (
636
+ isinstance(alias, str) and alias and isinstance(model_id, str) and model_id
637
+ ):
638
+ continue
639
+ droid_aliases[alias] = _model_reasoning_summary(
640
+ harness="droid",
641
+ alias=alias,
642
+ model=model_id,
643
+ config=config,
644
+ cache=cache,
645
+ config_default=default_effort,
646
+ )
647
+ summaries["droid"] = droid_aliases
648
+
649
+ codex = config.get("codex")
650
+ codex_aliases: JsonObject = {}
651
+ if isinstance(codex, dict):
652
+ default_model = codex.get("defaultModel")
653
+ config_default = codex.get("defaultReasoningEffort")
654
+ default_effort = config_default if isinstance(config_default, str) else None
655
+ if isinstance(default_model, str) and default_model:
656
+ codex_aliases[default_model] = _model_reasoning_summary(
657
+ harness="codex",
658
+ alias=default_model,
659
+ model=default_model,
660
+ config=config,
661
+ cache=cache,
662
+ config_default=default_effort,
663
+ )
664
+ else:
665
+ codex_aliases["(unconfigured)"] = {
666
+ "alias": "(unconfigured)",
667
+ "supported": None,
668
+ "source": "none",
669
+ "warning": "codex.defaultModel is not set; reasoning effort requires a resolved model.",
670
+ }
671
+ summaries["codex"] = codex_aliases
672
+
673
+ cursor = config.get("cursor")
674
+ if isinstance(cursor, dict):
675
+ default_model = cursor.get("defaultModel")
676
+ alias_key = _alias_key_for_default_model(default_model)
677
+ summaries["cursor"] = {alias_key: _cursor_alias_reasoning_summary(cursor)}
678
+ else:
679
+ summaries["cursor"] = {}
680
+
681
+ claude = config.get("claude")
682
+ if isinstance(claude, dict):
683
+ default_model = claude.get("defaultModel")
684
+ alias_key = _alias_key_for_default_model(default_model)
685
+ summaries["claude"] = {alias_key: _claude_alias_reasoning_summary(claude)}
686
+ else:
687
+ summaries["claude"] = {}
688
+
689
+ grok = config.get("grok")
690
+ if isinstance(grok, dict):
691
+ default_model = grok.get("defaultModel")
692
+ alias_key = _alias_key_for_default_model(default_model)
693
+ summaries["grok"] = {alias_key: _grok_alias_reasoning_summary(grok)}
694
+ else:
695
+ summaries["grok"] = {}
696
+
697
+ kimi = config.get("kimi")
698
+ if isinstance(kimi, dict):
699
+ default_model = kimi.get("defaultModel")
700
+ alias_key = _alias_key_for_default_model(default_model)
701
+ summaries["kimi"] = {alias_key: _kimi_alias_reasoning_summary(kimi)}
702
+ else:
703
+ summaries["kimi"] = {}
704
+
705
+ return summaries
706
+
707
+
708
+ def build_reasoning_capabilities_payload(
709
+ config: JsonObject,
710
+ cache: JsonObject | None,
711
+ ) -> JsonObject:
712
+ harnesses: JsonObject = {}
713
+ for harness in [h for h, p in REASONING_PROFILES.items() if p.strategy == "model-table"]:
714
+ models_payload: JsonObject = {}
715
+ _overlay_payload_models(
716
+ models_payload,
717
+ BUNDLED_REASONING_CAPABILITIES.get(harness, {}),
718
+ "bundled",
719
+ )
720
+ _overlay_payload_models(models_payload, _cache_model_declarations(cache, harness), "cache")
721
+ _overlay_payload_models(
722
+ models_payload,
723
+ _config_model_declarations(config, harness),
724
+ "config",
725
+ )
726
+ harnesses[harness] = {
727
+ "transport": TRANSPORT_BY_HARNESS[harness],
728
+ "models": models_payload,
729
+ }
730
+
731
+ cursor = config.get("cursor")
732
+ mappings = cursor.get("reasoningEffortModels") if isinstance(cursor, dict) else None
733
+ harnesses["cursor"] = {
734
+ "transport": TRANSPORT_BY_HARNESS["cursor"],
735
+ "models": _cursor_reasoning_models_payload(mappings),
736
+ }
737
+ harnesses["claude"] = {
738
+ "transport": REASONING_PROFILES["claude"].transport,
739
+ "source": "static",
740
+ "supported": list(REASONING_PROFILES["claude"].static_efforts),
741
+ "models": {},
742
+ }
743
+ harnesses["grok"] = {
744
+ "transport": REASONING_PROFILES["grok"].transport,
745
+ "source": "static",
746
+ "supported": list(REASONING_PROFILES["grok"].static_efforts),
747
+ "models": {},
748
+ }
749
+ harnesses["kimi"] = {
750
+ "transport": REASONING_PROFILES["kimi"].transport,
751
+ **_kimi_unsupported_reasoning_fields(),
752
+ "models": {},
753
+ }
754
+ return {"harnesses": harnesses, "aliases": build_alias_reasoning_summaries(config, cache)}
755
+
756
+
757
+ def validate_cache_payload(cache: JsonObject) -> None:
758
+ harnesses = cache.get("harnesses")
759
+ if not isinstance(harnesses, dict):
760
+ raise ReasoningCapabilityError(
761
+ "invalid_reasoning_config",
762
+ "reasoning cache must contain a harnesses object.",
763
+ )
764
+ for harness, harness_decl in harnesses.items():
765
+ if harness not in TRANSPORT_BY_HARNESS or not isinstance(harness_decl, dict):
766
+ raise ReasoningCapabilityError(
767
+ "invalid_reasoning_config",
768
+ "reasoning cache harness entries must be objects for known harnesses.",
769
+ )
770
+ models = harness_decl.get("models")
771
+ if not isinstance(models, dict):
772
+ raise ReasoningCapabilityError(
773
+ "invalid_reasoning_config",
774
+ f"reasoning cache {harness}.models must be an object.",
775
+ )
776
+ for model, declaration in models.items():
777
+ if not isinstance(model, str) or not model or not isinstance(declaration, dict):
778
+ raise ReasoningCapabilityError(
779
+ "invalid_reasoning_config",
780
+ f"reasoning cache {harness}.models has malformed model declarations.",
781
+ )
782
+ supported = _supported_tuple(declaration)
783
+ if supported is None:
784
+ raise ReasoningCapabilityError(
785
+ "invalid_reasoning_config",
786
+ f"reasoning cache {harness}.{model}.supported must be non-empty strings.",
787
+ )
788
+ if (
789
+ declaration.get("default") is not None
790
+ and _default_effort(declaration, supported) is None
791
+ ):
792
+ raise ReasoningCapabilityError(
793
+ "invalid_reasoning_config",
794
+ f"reasoning cache {harness}.{model}.default must be in supported.",
795
+ )
796
+
797
+
798
+ def parse_codex_models_payload(raw: JsonObject) -> JsonObject:
799
+ models = raw.get("models")
800
+ if not isinstance(models, list):
801
+ raise ReasoningCapabilityError(
802
+ "capability_refresh_failed",
803
+ "codex model payload must contain a models array.",
804
+ )
805
+ target: JsonObject = {}
806
+ parsed: JsonObject = {"schema": 1, "harnesses": {"codex": {"models": target}}}
807
+ for item in models:
808
+ if not isinstance(item, dict):
809
+ raise ReasoningCapabilityError(
810
+ "capability_refresh_failed", "codex model entries must be objects."
811
+ )
812
+ slug = item.get("slug")
813
+ raw_supported = item.get("supported_reasoning_levels")
814
+ if not isinstance(slug, str) or not slug:
815
+ raise ReasoningCapabilityError(
816
+ "capability_refresh_failed", "codex model entries require slug."
817
+ )
818
+ supported: list[str] = []
819
+ if isinstance(raw_supported, list):
820
+ for level in raw_supported:
821
+ if isinstance(level, str) and level:
822
+ supported.append(level)
823
+ elif (
824
+ isinstance(level, dict)
825
+ and isinstance(level.get("effort"), str)
826
+ and level["effort"]
827
+ ):
828
+ supported.append(level["effort"])
829
+ if not supported:
830
+ raise ReasoningCapabilityError(
831
+ "capability_refresh_failed",
832
+ f"codex model {slug!r} has no supported reasoning levels.",
833
+ )
834
+ default = item.get("default_reasoning_level")
835
+ declaration: JsonObject = {"supported": supported}
836
+ if isinstance(default, str) and default:
837
+ declaration["default"] = default
838
+ target[slug] = declaration
839
+ validate_cache_payload(parsed)
840
+ return parsed
841
+
842
+
843
+ def refresh_reasoning_capabilities(
844
+ *,
845
+ cwd: str,
846
+ codex_binary: str = "codex",
847
+ env: dict[str, str] | None = None,
848
+ ) -> JsonObject:
849
+ try:
850
+ completed = subprocess.run( # nosec B603 - configured Codex binary is executed with shell=False and a timeout.
851
+ [codex_binary, "debug", "models"],
852
+ cwd=cwd,
853
+ env=env,
854
+ text=True,
855
+ capture_output=True,
856
+ check=False,
857
+ timeout=30,
858
+ )
859
+ except (OSError, subprocess.SubprocessError) as exc:
860
+ raise ReasoningCapabilityError(
861
+ "capability_refresh_failed",
862
+ f"could not run {codex_binary} debug models: {exc}",
863
+ ) from exc
864
+ if completed.returncode != 0:
865
+ stderr = completed.stderr.strip()
866
+ raise ReasoningCapabilityError(
867
+ "capability_refresh_failed",
868
+ f"{codex_binary} debug models failed: {stderr or completed.returncode}",
869
+ )
870
+ try:
871
+ raw: JsonValue = json.loads(completed.stdout)
872
+ except json.JSONDecodeError as exc:
873
+ raise ReasoningCapabilityError(
874
+ "capability_refresh_failed",
875
+ f"{codex_binary} debug models did not return JSON: {exc}",
876
+ ) from exc
877
+ if not isinstance(raw, dict):
878
+ raise ReasoningCapabilityError(
879
+ "capability_refresh_failed",
880
+ f"{codex_binary} debug models returned a non-object payload.",
881
+ )
882
+ return parse_codex_models_payload(raw)
883
+
884
+
885
+ def merge_reasoning_capability_cache(
886
+ existing: JsonObject | None,
887
+ refreshed: JsonObject,
888
+ ) -> JsonObject:
889
+ """Overlay refreshed harness declarations on the existing cache.
890
+
891
+ Inputs are already validated at their boundaries (parse_codex_models_payload
892
+ for refreshed data, load_reasoning_capability_cache for the existing file),
893
+ and write_reasoning_capability_cache validates the merged result.
894
+ """
895
+ harnesses: JsonObject = {}
896
+ if existing is not None:
897
+ existing_harnesses = existing.get("harnesses")
898
+ if isinstance(existing_harnesses, dict):
899
+ harnesses.update(existing_harnesses)
900
+ refreshed_harnesses = refreshed["harnesses"]
901
+ if not isinstance(refreshed_harnesses, dict):
902
+ raise ReasoningCapabilityError(
903
+ "capability_refresh_failed",
904
+ "refreshed reasoning cache must contain a harnesses object.",
905
+ )
906
+ harnesses.update(refreshed_harnesses)
907
+ return {"schema": 1, "harnesses": harnesses}
908
+
909
+
910
+ def write_reasoning_capability_cache(workspace: str | Path, cache: JsonObject) -> Path:
911
+ validate_cache_payload(cache)
912
+ path = reasoning_capability_cache_path(workspace)
913
+ # write_json_atomic applies the registry's owner-only file/dir modes, keeping
914
+ # this .delegate artifact as private as run manifests and logs.
915
+ run_registry.write_json_atomic(path, cache)
916
+ return path