shikiru-cli 0.2.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 (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,767 @@
1
+ """Codex JSONL turn parser and process sequence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from datetime import datetime
8
+ from typing import TYPE_CHECKING, Any, NamedTuple, cast
9
+
10
+ from shikiru.agents.contracts import (
11
+ AgentSignal,
12
+ Compacted,
13
+ FailureKind,
14
+ Message,
15
+ QuotaReported,
16
+ QuotaWindow,
17
+ SessionStarted,
18
+ ToolFinished,
19
+ ToolOutcome,
20
+ ToolStarted,
21
+ TurnEnded,
22
+ TurnFailed,
23
+ TurnSpec,
24
+ Usage,
25
+ UsageReported,
26
+ exhausted_reset,
27
+ )
28
+ from shikiru.agents.sdk.turn import (
29
+ EMPTY_OUTPUT,
30
+ TurnProcess,
31
+ TurnProcessResult,
32
+ TurnProcessSequence,
33
+ )
34
+ from shikiru.plugins.agents.codex.home import codex_home
35
+ from shikiru.plugins.agents.codex.options import (
36
+ AUTO_COMPACT_LIMIT_CONFIG,
37
+ CODEX_COMMAND,
38
+ CONFIG_FLAG,
39
+ EXEC_SUBCOMMAND,
40
+ JSON_FLAG,
41
+ MODEL_FLAG,
42
+ MODELS_CACHE_FILENAME,
43
+ REASONING_EFFORT_CONFIG,
44
+ RESUME_SUBCOMMAND,
45
+ USAGE_EVENT_TYPE,
46
+ )
47
+ from shikiru.plugins.agents.codex.quota import CodexQuotaProbe, QuotaObservation
48
+ from shikiru.plugins.agents.codex.sandbox import sandbox_config_args
49
+ from shikiru.plugins.agents.codex.usage import latest_token_count, rollout_usage
50
+
51
+ if TYPE_CHECKING:
52
+ from collections.abc import Callable, Sequence
53
+ from pathlib import Path
54
+
55
+ from shikiru.agents.sdk.reset_time import ResetTimeParser
56
+ from shikiru.clock import Clock
57
+
58
+ _TYPE_FIELD = "type"
59
+ _PHASE_START = "start"
60
+ _PHASE_PROMPT = "prompt"
61
+ _PHASE_RETRY = "retry"
62
+ _THREAD_STARTED = "thread.started"
63
+ _THREAD_ID_FIELD = "thread_id"
64
+ _ITEM_STARTED = "item.started"
65
+ _ITEM_COMPLETED = "item.completed"
66
+ _ITEM_FIELD = "item"
67
+ _ITEM_ID_FIELD = "id"
68
+ _ITEM_TYPE_FIELD = "type"
69
+ _ITEM_STATUS_FIELD = "status"
70
+ _EXIT_CODE_FIELD = "exit_code"
71
+ _FAILED_STATUS = "failed"
72
+ _AGENT_MESSAGE_TYPE = "agent_message"
73
+ _MESSAGE_TEXT_FIELD = "text"
74
+ _TURN_COMPLETED = "turn.completed"
75
+ _TURN_FAILED = "turn.failed"
76
+ _ERROR_TYPE = "error"
77
+ _ERROR_FIELD = "error"
78
+ _PAYLOAD_FIELD = "payload"
79
+ _USAGE_FIELD = "usage"
80
+ _INFO_FIELD = "info"
81
+ _INPUT_TOKENS_FIELD = "input_tokens"
82
+ _CACHED_INPUT_TOKENS_FIELD = "cached_input_tokens"
83
+ _OUTPUT_TOKENS_FIELD = "output_tokens"
84
+ _LAST_USAGE_FIELD = "last_token_usage"
85
+ _STATUS_FIELD = "status"
86
+ _CODE_FIELD = "code"
87
+ _MESSAGE_FIELD = "message"
88
+ _RESET_AT_FIELD = "resets_at"
89
+ _EVENT_MESSAGE_TYPE = "event_msg"
90
+ _COMPACTED_TYPE = "compacted"
91
+ _PRE_TOKENS_FIELD = "pre_tokens"
92
+ _POST_TOKENS_FIELD = "post_tokens"
93
+ _MODELS_FIELD = "models"
94
+ _MODEL_SLUG_FIELD = "slug"
95
+ _MODEL_CONTEXT_WINDOW_FIELD = "context_window"
96
+ _TURN_CONTEXT_TYPE = "turn_context"
97
+ _MODEL_FIELD = "model"
98
+ _CONTEXT_WINDOW_INFO_FIELD = "model_context_window"
99
+ _PERCENT = 100
100
+ _UNAVAILABLE_STATUS_MIN = 500
101
+ _UNAVAILABLE_STATUS_MAX = 599
102
+ _PAYMENT_REQUIRED_STATUS = 402
103
+ _RATE_LIMIT_STATUS = 429
104
+ _AUTH_STATUSES = (401, 403)
105
+ _CAPACITY_TEXT = ("at capacity",)
106
+ _UNAVAILABLE_TEXT = ("overloaded", "temporarily unavailable", "service unavailable")
107
+ _CONTEXT_TEXT = (
108
+ "context_length_exceeded",
109
+ "context exceeded",
110
+ "context length",
111
+ "context window",
112
+ "too many tokens",
113
+ )
114
+ _CONTEXT_LENGTH_EXCEEDED_CODE = "context_length_exceeded"
115
+ _QUOTA_TEXT = ("usage_limit_reached", "usage limit", "rate_limit", "rate limit", "quota")
116
+ _AUTH_TEXT = ("authentication", "unauthorized", "invalid api key")
117
+ _UNKNOWN_ERROR_MESSAGE = "Codex turn ended without a structured result"
118
+ _MALFORMED_STREAM_MESSAGE = "Codex emitted malformed JSONL"
119
+ _ROLLOUT_GLOB = "rollout-*.jsonl"
120
+ _COMMAND_EXECUTION_TYPE = "command_execution"
121
+ _FILE_CHANGE_TYPE = "file_change"
122
+ _MCP_TOOL_CALL_TYPE = "mcp_tool_call"
123
+ _COMMAND_TOOL_NAME = "command"
124
+ _FILE_CHANGE_TOOL_NAME = "file_change"
125
+ _MCP_TOOL_NAME = "mcp_tool"
126
+ _ZERO_TOKENS = 0
127
+ _ZERO_EXIT_CODE = 0
128
+ _UNKNOWN_COST = "unknown"
129
+
130
+
131
+ class CodexTurnParser:
132
+ """Map Codex JSONL events and its rollout into agent signals."""
133
+
134
+ def __init__(
135
+ self,
136
+ *,
137
+ session_id: str | None,
138
+ initial_usage: Usage | None,
139
+ home: Path,
140
+ rollout_path: Path | None = None,
141
+ clock: Clock,
142
+ reset_time: ResetTimeParser,
143
+ ) -> None:
144
+ self._session_id = session_id
145
+ self._initial_usage = initial_usage or Usage(
146
+ _ZERO_TOKENS,
147
+ _ZERO_TOKENS,
148
+ _ZERO_TOKENS,
149
+ _ZERO_TOKENS,
150
+ None,
151
+ _UNKNOWN_COST,
152
+ None,
153
+ )
154
+ self._home = home
155
+ self._rollout_override = rollout_path
156
+ self._clock = clock
157
+ self._reset_time = reset_time
158
+ self._quota_probe = CodexQuotaProbe(home, clock, reset_time)
159
+ self._latest_streamed_quota: tuple[QuotaWindow, ...] = ()
160
+ self._tools: dict[str, str] = {}
161
+ self._last_message = ""
162
+ self._turn_usage: Mapping[str, Any] | None = None
163
+ self._failure: Mapping[str, Any] | None = None
164
+ self._malformed = False
165
+ self._terminal = False
166
+ self._did_start_thread = False
167
+ self._did_complete = False
168
+ self._context_exceeded = False
169
+ self._rollout = rollout_path or _rollout_path(home, session_id)
170
+ self._initial_compaction_count = (
171
+ _compaction_count(_read_rollout(self._rollout)) if session_id is not None else 0
172
+ )
173
+ self._handlers = {
174
+ _THREAD_STARTED: self._thread_started,
175
+ _ITEM_STARTED: self._item_started,
176
+ _ITEM_COMPLETED: self._item_completed,
177
+ _TURN_COMPLETED: self._turn_completed,
178
+ _TURN_FAILED: self._record_failure,
179
+ _ERROR_TYPE: self._record_failure,
180
+ }
181
+
182
+ @property
183
+ def context_exceeded(self) -> bool:
184
+ """Return whether this process failed because its context was full."""
185
+ return self._context_exceeded
186
+
187
+ def feed(self, line: str) -> list[AgentSignal]:
188
+ if self._terminal:
189
+ return []
190
+ try:
191
+ raw = json.loads(line)
192
+ except json.JSONDecodeError:
193
+ self._malformed = True
194
+ return []
195
+ if not isinstance(raw, Mapping):
196
+ self._malformed = True
197
+ return []
198
+ payload = cast("Mapping[str, Any]", raw)
199
+ handler = self._handlers.get(cast("str", payload.get(_TYPE_FIELD)))
200
+ signals = handler(payload) if handler is not None else []
201
+ observation = self._quota_probe.parse_observation(line, self._clock.now())
202
+ if observation is not None:
203
+ self._latest_streamed_quota = observation.windows
204
+ signals.append(QuotaReported(observation.windows))
205
+ return signals
206
+
207
+ def _turn_completed(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
208
+ self._did_complete = True
209
+ self._turn_usage = _mapping(payload.get(_USAGE_FIELD))
210
+ message = payload.get(_MESSAGE_TEXT_FIELD)
211
+ if isinstance(message, str) and message:
212
+ self._last_message = message
213
+ return []
214
+
215
+ def _record_failure(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
216
+ error = payload.get(_ERROR_FIELD)
217
+ self._failure = _mapping(error) or (
218
+ {_MESSAGE_FIELD: error} if isinstance(error, str) else payload
219
+ )
220
+ return []
221
+
222
+ def finish(
223
+ self, exit_code: int, stderr: str, *, interrupted: bool = False
224
+ ) -> list[AgentSignal]:
225
+ if self._terminal:
226
+ return []
227
+ self._terminal = True
228
+ if self._malformed:
229
+ return [TurnFailed(FailureKind.UNKNOWN, _MALFORMED_STREAM_MESSAGE, None)]
230
+ if interrupted and not self._did_start_thread:
231
+ return self._finish_interrupted_before_session(exit_code, stderr)
232
+ if self._rollout is None:
233
+ self._rollout = _rollout_path(self._home, self._session_id)
234
+ entries = _read_rollout(self._rollout)
235
+ signals: list[AgentSignal] = list(
236
+ _compaction_signals(entries, self._initial_compaction_count)
237
+ )
238
+ quota: Sequence[QuotaWindow] = self._latest_streamed_quota
239
+ rollout_quota = _quota_observation(
240
+ self._rollout, entries, self._home, self._clock, self._reset_time
241
+ )
242
+ if rollout_quota is not None and _quota_is_newer(rollout_quota, quota):
243
+ rollout_reading = rollout_quota.windows
244
+ if _quota_reading(rollout_reading) != _quota_reading(quota):
245
+ signals.append(QuotaReported(rollout_reading))
246
+ quota = rollout_reading
247
+ usage = _reported_usage(entries, self._turn_usage, self._initial_usage)
248
+ if usage is not None:
249
+ signals.append(UsageReported(usage))
250
+ if self._failure is not None:
251
+ signals.append(self._failed_signal(self._failure, stderr, quota))
252
+ elif self._did_complete:
253
+ signals.append(TurnEnded(self._last_message))
254
+ else:
255
+ signals.extend(
256
+ self._finish_without_result(exit_code, stderr, quota, interrupted=interrupted)
257
+ )
258
+ return signals
259
+
260
+ def _finish_interrupted_before_session(self, exit_code: int, stderr: str) -> list[AgentSignal]:
261
+ if self._failure is not None:
262
+ return [self._failed_signal(self._failure, stderr, self._latest_streamed_quota)]
263
+ if self._did_complete:
264
+ return [TurnEnded(self._last_message)]
265
+ return self._finish_without_result(
266
+ exit_code, stderr, self._latest_streamed_quota, interrupted=True
267
+ )
268
+
269
+ def _finish_without_result(
270
+ self,
271
+ exit_code: int,
272
+ stderr: str,
273
+ quota: Sequence[QuotaWindow],
274
+ *,
275
+ interrupted: bool,
276
+ ) -> list[AgentSignal]:
277
+ failure = _classify_error(stderr, None, "")
278
+ if failure is None:
279
+ if interrupted:
280
+ return []
281
+ return [
282
+ TurnFailed(
283
+ FailureKind.UNKNOWN,
284
+ f"{_UNKNOWN_ERROR_MESSAGE}; process exited with status {exit_code}; "
285
+ f"stderr tail: {stderr or EMPTY_OUTPUT}",
286
+ None,
287
+ )
288
+ ]
289
+ self._context_exceeded = failure.context_exceeded
290
+ reset = (
291
+ self._reset_time.parse(stderr) or exhausted_reset(quota, self._clock.now())
292
+ if failure.kind is FailureKind.QUOTA
293
+ else None
294
+ )
295
+ return [TurnFailed(failure.kind, stderr.strip(), reset)]
296
+
297
+ def _thread_started(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
298
+ session_id = payload.get(_THREAD_ID_FIELD)
299
+ if not isinstance(session_id, str) or not session_id.strip():
300
+ return []
301
+ self._did_start_thread = True
302
+ self._session_id = session_id
303
+ self._rollout = self._rollout_override or _rollout_path(self._home, session_id)
304
+ return [SessionStarted(session_id)]
305
+
306
+ def _item_started(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
307
+ item = _mapping(payload.get(_ITEM_FIELD))
308
+ if item is None:
309
+ return []
310
+ item_id = item.get(_ITEM_ID_FIELD)
311
+ item_type = item.get(_ITEM_TYPE_FIELD)
312
+ if not isinstance(item_id, str) or not isinstance(item_type, str):
313
+ return []
314
+ name = _tool_name(item_type)
315
+ if name is None:
316
+ return []
317
+ self._tools[item_id] = name
318
+ return [ToolStarted(name, _tool_detail(item))]
319
+
320
+ def _item_completed(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
321
+ item = _mapping(payload.get(_ITEM_FIELD))
322
+ if item is None:
323
+ return []
324
+ signals: list[AgentSignal] = []
325
+ item_id = item.get(_ITEM_ID_FIELD)
326
+ if isinstance(item_id, str):
327
+ name = self._tools.pop(item_id, None)
328
+ if name is not None:
329
+ item_type = item.get(_ITEM_TYPE_FIELD)
330
+ exit_code = (
331
+ _command_exit_code(item) if item_type == _COMMAND_EXECUTION_TYPE else None
332
+ )
333
+ outcome = _tool_outcome(item.get(_ITEM_STATUS_FIELD), exit_code)
334
+ signals.append(ToolFinished(name, outcome, exit_code))
335
+ if item.get(_ITEM_TYPE_FIELD) == _AGENT_MESSAGE_TYPE:
336
+ text = item.get(_MESSAGE_TEXT_FIELD)
337
+ if isinstance(text, str):
338
+ self._last_message = text
339
+ signals.append(Message(text))
340
+ return signals
341
+
342
+ def _failed_signal(
343
+ self,
344
+ payload: Mapping[str, Any],
345
+ stderr: str,
346
+ quota: Sequence[QuotaWindow],
347
+ ) -> TurnFailed:
348
+ message, status, code, reset_value = _error_details(payload)
349
+ classification = _classify_error(message, status, code)
350
+ error_reset = (
351
+ reset_value
352
+ if isinstance(reset_value, datetime)
353
+ else self._reset_time.parse(str(reset_value))
354
+ if reset_value is not None
355
+ else None
356
+ )
357
+ reset = (
358
+ error_reset
359
+ or self._reset_time.parse(message)
360
+ or exhausted_reset(quota, self._clock.now())
361
+ )
362
+ kind = FailureKind.UNKNOWN if classification is None else classification.kind
363
+ self._context_exceeded = classification is not None and classification.context_exceeded
364
+ detail = message or _UNKNOWN_ERROR_MESSAGE
365
+ if stderr:
366
+ detail = f"{detail}; stderr: {stderr.strip()}"
367
+ return TurnFailed(kind, detail, reset if kind is FailureKind.QUOTA else None)
368
+
369
+
370
+ class CodexTurnSequence(TurnProcessSequence):
371
+ """Build Codex prompt and one context-failure retry process."""
372
+
373
+ def __init__(
374
+ self,
375
+ spec: TurnSpec,
376
+ *,
377
+ args: Sequence[str],
378
+ environment: Callable[[TurnSpec], Mapping[str, str]],
379
+ clock: Clock,
380
+ reset_time: ResetTimeParser,
381
+ home: Path | None = None,
382
+ git_dirs: Sequence[Path],
383
+ compact_at_pct: float,
384
+ model_context_windows: Mapping[str, int] | None = None,
385
+ ) -> None:
386
+ self._spec = spec
387
+ self._args = tuple(args)
388
+ self._environment = environment
389
+ self._clock = clock
390
+ self._reset_time = reset_time
391
+ self._home = home or codex_home()
392
+ self._git_dirs = tuple(git_dirs)
393
+ self._compact_at_pct = compact_at_pct
394
+ self._session_id = spec.session_id
395
+ self._phase = _PHASE_START
396
+ self._usage = spec.usage
397
+ self._parser: CodexTurnParser | None = None
398
+ self._context_window = context_window(
399
+ self._home,
400
+ spec.rung.model,
401
+ spec.session_id,
402
+ fallback_context_windows=model_context_windows,
403
+ )
404
+
405
+ def start(self, spec: TurnSpec) -> TurnProcess:
406
+ self._phase = _PHASE_PROMPT
407
+ return self._process(spec)
408
+
409
+ def advance(self, spec: TurnSpec, previous: TurnProcessResult) -> TurnProcess | None:
410
+ self._remember_state(previous.signals)
411
+ terminal = _terminal(previous.signals)
412
+ if (
413
+ self._phase == _PHASE_PROMPT
414
+ and isinstance(terminal, TurnFailed)
415
+ and self._parser is not None
416
+ and self._parser.context_exceeded
417
+ ):
418
+ self._phase = _PHASE_RETRY
419
+ return self._process(spec)
420
+ return None
421
+
422
+ def _process(self, spec: TurnSpec) -> TurnProcess:
423
+ argv = [CODEX_COMMAND, EXEC_SUBCOMMAND]
424
+ if self._session_id is not None:
425
+ argv.extend([RESUME_SUBCOMMAND, self._session_id])
426
+ argv.append(JSON_FLAG)
427
+ argv.extend([MODEL_FLAG, spec.rung.model])
428
+ if spec.rung.effort is not None:
429
+ argv.extend([CONFIG_FLAG, f'{REASONING_EFFORT_CONFIG}="{spec.rung.effort}"'])
430
+ token_limit = self._token_limit()
431
+ if token_limit is not None:
432
+ argv.extend([CONFIG_FLAG, f"{AUTO_COMPACT_LIMIT_CONFIG}={token_limit}"])
433
+ roots = _unique_paths((spec.run_dir, *self._git_dirs, *spec.add_dirs))
434
+ argv.extend(sandbox_config_args(roots))
435
+ argv.extend(self._args)
436
+ argv.append(spec.prompt)
437
+ parser = CodexTurnParser(
438
+ session_id=self._session_id,
439
+ initial_usage=self._usage,
440
+ home=self._home,
441
+ clock=self._clock,
442
+ reset_time=self._reset_time,
443
+ )
444
+ self._parser = parser
445
+ return TurnProcess(tuple(argv), spec.cwd, self._environment(spec), parser)
446
+
447
+ def _token_limit(self) -> int | None:
448
+ if self._context_window is None:
449
+ return None
450
+ return int(self._context_window * self._compact_at_pct / _PERCENT)
451
+
452
+ def _remember_state(self, signals: Sequence[AgentSignal]) -> None:
453
+ session_ids = [
454
+ signal.session_id for signal in signals if isinstance(signal, SessionStarted)
455
+ ]
456
+ if session_ids:
457
+ self._session_id = session_ids[-1]
458
+ usages = [signal.usage for signal in signals if isinstance(signal, UsageReported)]
459
+ if usages:
460
+ self._usage = usages[-1]
461
+
462
+
463
+ def context_window(
464
+ home: Path,
465
+ model: str,
466
+ session_id: str | None = None,
467
+ *,
468
+ fallback_context_windows: Mapping[str, int] | None = None,
469
+ ) -> int | None:
470
+ """Return the observed or cached window, then a validated bundled-catalog window."""
471
+ observed = _observed_window(_read_rollout(_rollout_path(home, session_id)), model)
472
+ if observed is not None:
473
+ return observed
474
+ cached = _cached_context_window(home, model)
475
+ if cached is not None:
476
+ return cached
477
+ return None if fallback_context_windows is None else fallback_context_windows.get(model)
478
+
479
+
480
+ def _observed_window(entries: Sequence[Mapping[str, Any]], model: str) -> int | None:
481
+ current_model: object = None
482
+ window: int | None = None
483
+ window_model: object = None
484
+ for entry in entries:
485
+ payload = _mapping(entry.get(_PAYLOAD_FIELD))
486
+ if payload is None:
487
+ continue
488
+ if entry.get(_TYPE_FIELD) == _TURN_CONTEXT_TYPE:
489
+ current_model = payload.get(_MODEL_FIELD)
490
+ elif payload.get(_TYPE_FIELD) == USAGE_EVENT_TYPE:
491
+ info = _mapping(payload.get(_INFO_FIELD))
492
+ value = info.get(_CONTEXT_WINDOW_INFO_FIELD) if info is not None else None
493
+ if isinstance(value, int):
494
+ window, window_model = value, current_model
495
+ return window if window_model == model else None
496
+
497
+
498
+ def _cached_context_window(home: Path, model: str) -> int | None:
499
+ path = home / MODELS_CACHE_FILENAME
500
+ if not path.is_file():
501
+ return None
502
+ raw = json.loads(path.read_text(encoding="utf-8"))
503
+ if not isinstance(raw, Mapping):
504
+ return None
505
+ raw_mapping = cast("Mapping[str, Any]", raw)
506
+ models = raw_mapping.get(_MODELS_FIELD)
507
+ if not isinstance(models, list):
508
+ return None
509
+ for item in cast("list[object]", models):
510
+ entry = _mapping(item)
511
+ if entry is None or entry.get(_MODEL_SLUG_FIELD) != model:
512
+ continue
513
+ value = entry.get(_MODEL_CONTEXT_WINDOW_FIELD)
514
+ return value if isinstance(value, int) and value > 0 else None
515
+ return None
516
+
517
+
518
+ def _rollout_path(home: Path, session_id: str | None) -> Path | None:
519
+ if session_id is None:
520
+ return None
521
+ sessions = home / "sessions"
522
+ matches = list(sessions.rglob(f"rollout-*-{session_id}.jsonl")) if sessions.is_dir() else []
523
+ return max(matches, key=lambda path: path.stat().st_mtime_ns) if matches else None
524
+
525
+
526
+ def _read_rollout(path: Path | None) -> list[Mapping[str, Any]]:
527
+ if path is None or not path.is_file():
528
+ return []
529
+ entries: list[Mapping[str, Any]] = []
530
+ for line in path.read_text(encoding="utf-8").splitlines():
531
+ try:
532
+ raw = json.loads(line)
533
+ except json.JSONDecodeError:
534
+ continue
535
+ if isinstance(raw, Mapping):
536
+ entries.append(cast("Mapping[str, Any]", raw))
537
+ return entries
538
+
539
+
540
+ def _compaction_count(entries: Sequence[Mapping[str, Any]]) -> int:
541
+ return sum(1 for entry in entries if _rollout_type(entry) == _COMPACTED_TYPE)
542
+
543
+
544
+ def _compaction_signals(
545
+ entries: Sequence[Mapping[str, Any]], previous_count: int
546
+ ) -> list[Compacted]:
547
+ compacted = [
548
+ (index, entry)
549
+ for index, entry in enumerate(entries)
550
+ if _rollout_type(entry) == _COMPACTED_TYPE
551
+ ]
552
+ return [
553
+ _compaction_signal(entries, index, entry) for index, entry in compacted[previous_count:]
554
+ ]
555
+
556
+
557
+ def _compaction_signal(
558
+ entries: Sequence[Mapping[str, Any]], index: int, entry: Mapping[str, Any]
559
+ ) -> Compacted:
560
+ payload = _mapping(entry.get(_PAYLOAD_FIELD)) or entry
561
+ pre_tokens = _optional_integer(payload, _PRE_TOKENS_FIELD)
562
+ post_tokens = _optional_integer(payload, _POST_TOKENS_FIELD)
563
+ return Compacted(
564
+ pre_tokens=(
565
+ pre_tokens
566
+ if pre_tokens is not None
567
+ else _nearby_token_count(entries, index, before=True)
568
+ ),
569
+ post_tokens=(
570
+ post_tokens
571
+ if post_tokens is not None
572
+ else _nearby_token_count(entries, index, before=False)
573
+ ),
574
+ )
575
+
576
+
577
+ def _nearby_token_count(
578
+ entries: Sequence[Mapping[str, Any]], compacted_index: int, *, before: bool
579
+ ) -> int | None:
580
+ candidates = (
581
+ reversed(entries[:compacted_index]) if before else iter(entries[compacted_index + 1 :])
582
+ )
583
+ for entry in candidates:
584
+ if _rollout_type(entry) != USAGE_EVENT_TYPE:
585
+ continue
586
+ payload = _mapping(entry.get(_PAYLOAD_FIELD))
587
+ info = _mapping(payload.get(_INFO_FIELD)) if payload is not None else None
588
+ last_usage = _mapping(info.get(_LAST_USAGE_FIELD)) if info is not None else None
589
+ value = _optional_integer(last_usage, _INPUT_TOKENS_FIELD) if last_usage else None
590
+ if value is not None and (before or value > _ZERO_TOKENS):
591
+ return value
592
+ return None
593
+
594
+
595
+ def _rollout_type(entry: Mapping[str, Any]) -> object:
596
+ if entry.get(_TYPE_FIELD) != _EVENT_MESSAGE_TYPE:
597
+ return entry.get(_TYPE_FIELD)
598
+ payload = _mapping(entry.get(_PAYLOAD_FIELD))
599
+ return payload.get(_TYPE_FIELD) if payload is not None else None
600
+
601
+
602
+ def _quota_observation(
603
+ path: Path | None,
604
+ entries: Sequence[Mapping[str, Any]],
605
+ home: Path,
606
+ clock: Clock,
607
+ reset_time: ResetTimeParser,
608
+ ) -> QuotaObservation | None:
609
+ if path is None or not entries:
610
+ return None
611
+ probe = CodexQuotaProbe(home, clock, reset_time)
612
+ return probe.parse_observation(path.read_text(encoding="utf-8"), clock.now())
613
+
614
+
615
+ def _quota_reading(
616
+ windows: Sequence[QuotaWindow],
617
+ ) -> tuple[tuple[str, float, datetime | None], ...]:
618
+ return tuple((window.name, window.used_pct, window.resets_at) for window in windows)
619
+
620
+
621
+ def _quota_is_newer(candidate: QuotaObservation, current: Sequence[QuotaWindow]) -> bool:
622
+ if not current:
623
+ return True
624
+ if candidate.timestamp is None:
625
+ return False
626
+ current_time = max(window.observed_at for window in current)
627
+ return candidate.timestamp > current_time
628
+
629
+
630
+ def _reported_usage(
631
+ entries: Sequence[Mapping[str, Any]],
632
+ turn_usage: Mapping[str, Any] | None,
633
+ previous: Usage,
634
+ ) -> Usage | None:
635
+ latest = latest_token_count(entries)
636
+ if latest is not None:
637
+ return rollout_usage(entries)
638
+ token_fields = (
639
+ _INPUT_TOKENS_FIELD,
640
+ _OUTPUT_TOKENS_FIELD,
641
+ _CACHED_INPUT_TOKENS_FIELD,
642
+ )
643
+ if turn_usage is not None and any(field in turn_usage for field in token_fields):
644
+ return _usage_from_turn(turn_usage, previous)
645
+ return None
646
+
647
+
648
+ def _usage_from_turn(payload: Mapping[str, Any], previous: Usage) -> Usage:
649
+ def token_value(key: str, fallback: int) -> int:
650
+ value = payload.get(key)
651
+ return int(value) if isinstance(value, int | float) else fallback
652
+
653
+ return Usage(
654
+ input_tokens=token_value(_INPUT_TOKENS_FIELD, previous.input_tokens),
655
+ output_tokens=token_value(_OUTPUT_TOKENS_FIELD, previous.output_tokens),
656
+ cache_read_tokens=token_value(_CACHED_INPUT_TOKENS_FIELD, previous.cache_read_tokens),
657
+ cache_write_tokens=previous.cache_write_tokens,
658
+ cost_usd=None,
659
+ cost_basis="unknown",
660
+ context_used_pct=previous.context_used_pct,
661
+ )
662
+
663
+
664
+ def _error_details(
665
+ payload: Mapping[str, Any],
666
+ ) -> tuple[str, int | None, str, object | None]:
667
+ nested = payload.get(_MESSAGE_FIELD)
668
+ if isinstance(nested, str):
669
+ try:
670
+ decoded = json.loads(nested)
671
+ except json.JSONDecodeError:
672
+ decoded = None
673
+ if isinstance(decoded, Mapping):
674
+ payload = cast("Mapping[str, Any]", decoded)
675
+ message = payload.get(_MESSAGE_FIELD)
676
+ status_value = payload.get(_STATUS_FIELD)
677
+ status = status_value if isinstance(status_value, int) else None
678
+ code = payload.get(_CODE_FIELD)
679
+ message_text = message if isinstance(message, str) else ""
680
+ code_text = code if isinstance(code, str) else ""
681
+ reset_value = payload.get(_RESET_AT_FIELD)
682
+ return message_text or code_text, status, code_text, reset_value
683
+
684
+
685
+ class _Classification(NamedTuple):
686
+ kind: FailureKind
687
+ context_exceeded: bool = False
688
+
689
+
690
+ def _classify_error(message: str, status: int | None, code: str) -> _Classification | None:
691
+ text = f"{code} {message}".lower()
692
+ if (
693
+ status == _PAYMENT_REQUIRED_STATUS
694
+ or status in _AUTH_STATUSES
695
+ or any(token in text for token in _AUTH_TEXT)
696
+ ):
697
+ return _Classification(FailureKind.AUTH)
698
+ if code.lower() != _CONTEXT_LENGTH_EXCEEDED_CODE and any(
699
+ token in text for token in _CAPACITY_TEXT
700
+ ):
701
+ return _Classification(FailureKind.UNAVAILABLE)
702
+ if status == _RATE_LIMIT_STATUS or any(token in text for token in _QUOTA_TEXT):
703
+ return _Classification(FailureKind.QUOTA)
704
+ if (status is not None and _UNAVAILABLE_STATUS_MIN <= status <= _UNAVAILABLE_STATUS_MAX) or any(
705
+ token in text for token in _UNAVAILABLE_TEXT
706
+ ):
707
+ return _Classification(FailureKind.UNAVAILABLE)
708
+ if any(token in text for token in _CONTEXT_TEXT):
709
+ return _Classification(FailureKind.OTHER, context_exceeded=True)
710
+ return None
711
+
712
+
713
+ def _mapping(value: object) -> Mapping[str, Any] | None:
714
+ return cast("Mapping[str, Any]", value) if isinstance(value, Mapping) else None
715
+
716
+
717
+ def _optional_integer(payload: Mapping[str, Any], key: str) -> int | None:
718
+ value = payload.get(key)
719
+ return value if isinstance(value, int) else None
720
+
721
+
722
+ def _tool_name(item_type: str) -> str | None:
723
+ return {
724
+ _COMMAND_EXECUTION_TYPE: _COMMAND_TOOL_NAME,
725
+ _FILE_CHANGE_TYPE: _FILE_CHANGE_TOOL_NAME,
726
+ _MCP_TOOL_CALL_TYPE: _MCP_TOOL_NAME,
727
+ }.get(item_type)
728
+
729
+
730
+ def _tool_detail(item: Mapping[str, Any]) -> str:
731
+ value = item.get("command")
732
+ if isinstance(value, str):
733
+ return value
734
+ return ""
735
+
736
+
737
+ def _command_exit_code(item: Mapping[str, Any]) -> int | None:
738
+ value = item.get(_EXIT_CODE_FIELD)
739
+ return value if type(value) is int else None
740
+
741
+
742
+ def _tool_outcome(status: object, exit_code: int | None) -> ToolOutcome | None:
743
+ if status == _FAILED_STATUS:
744
+ return ToolOutcome.FAILURE
745
+ if exit_code is None:
746
+ return None
747
+ return ToolOutcome.SUCCESS if exit_code == _ZERO_EXIT_CODE else ToolOutcome.FAILURE
748
+
749
+
750
+ def _unique_paths(paths: Sequence[Path | None]) -> tuple[Path, ...]:
751
+ unique: list[Path] = []
752
+ seen: set[Path] = set()
753
+ for path in paths:
754
+ if path is None:
755
+ continue
756
+ normalized = path.resolve()
757
+ if normalized not in seen:
758
+ seen.add(normalized)
759
+ unique.append(path)
760
+ return tuple(unique)
761
+
762
+
763
+ def _terminal(signals: Sequence[AgentSignal]) -> TurnEnded | TurnFailed | None:
764
+ return next(
765
+ (signal for signal in reversed(signals) if isinstance(signal, (TurnEnded, TurnFailed))),
766
+ None,
767
+ )