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,719 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from typing import Literal
7
+
8
+ from delegate_agent.json_types import JsonObject, JsonValue
9
+
10
+ RecoveryQuality = Literal[
11
+ "explicit_completion",
12
+ "substantive_assistant_fallback",
13
+ "housekeeping_fallback",
14
+ ]
15
+
16
+ _HOUSEKEEPING_PATTERNS = (
17
+ re.compile(r"plan is up-to-date", re.IGNORECASE),
18
+ re.compile(r"final report was delivered", re.IGNORECASE),
19
+ re.compile(r"delivered in the previous message", re.IGNORECASE),
20
+ re.compile(r"nothing (?:else|more) to (?:do|report)", re.IGNORECASE),
21
+ re.compile(
22
+ r"already (?:delivered|sent|provided)(?: the)?(?: final)? report",
23
+ re.IGNORECASE,
24
+ ),
25
+ )
26
+
27
+ _PROGRESS_PATTERNS = (
28
+ re.compile(r"^I'll start by\b", re.IGNORECASE | re.MULTILINE),
29
+ re.compile(r"^Working\.{3}$", re.IGNORECASE | re.MULTILINE),
30
+ re.compile(r"^Let me (?:check|read|look|investigate|start)\b", re.IGNORECASE | re.MULTILINE),
31
+ re.compile(
32
+ "^I(?: am|'m|\u2019m)\\s+(?:still\\s+)?(?:checking|investigating|reading|working)\\b",
33
+ re.IGNORECASE | re.MULTILINE,
34
+ ),
35
+ )
36
+
37
+ _STATUS_LINE_PATTERN = re.compile(
38
+ r"^Status:\s*(?:completed|failed|blocked)\b",
39
+ re.IGNORECASE | re.MULTILINE,
40
+ )
41
+ _REPORT_HEADER_PATTERN = re.compile(
42
+ r"^##\s+(?:Summary|What I did|Verification|Files changed)\b",
43
+ re.IGNORECASE | re.MULTILINE,
44
+ )
45
+
46
+
47
+ def is_housekeeping_assistant_text(text: str) -> bool:
48
+ stripped = text.strip()
49
+ if not stripped:
50
+ return False
51
+ if is_substantive_assistant_text(stripped):
52
+ return False
53
+ for pattern in _HOUSEKEEPING_PATTERNS:
54
+ if pattern.search(stripped):
55
+ return True
56
+ return any(pattern.search(stripped) for pattern in _PROGRESS_PATTERNS)
57
+
58
+
59
+ def is_substantive_assistant_text(text: str) -> bool:
60
+ stripped = text.strip()
61
+ if not stripped:
62
+ return False
63
+ if _STATUS_LINE_PATTERN.search(stripped):
64
+ return True
65
+ if stripped.startswith("Verdict:"):
66
+ return True
67
+ if _REPORT_HEADER_PATTERN.search(stripped):
68
+ return True
69
+ bullet_lines = [line for line in stripped.splitlines() if line.strip().startswith("- ")]
70
+ if len(bullet_lines) >= 2:
71
+ if len(stripped) < 80:
72
+ for pattern in _HOUSEKEEPING_PATTERNS:
73
+ if pattern.search(stripped):
74
+ return False
75
+ for pattern in _PROGRESS_PATTERNS:
76
+ if pattern.search(stripped):
77
+ return False
78
+ return True
79
+ if len(bullet_lines) == 1 and len(stripped) >= 80:
80
+ return True
81
+ if len(stripped) >= 200 and "\n" in stripped:
82
+ return True
83
+ for pattern in _HOUSEKEEPING_PATTERNS:
84
+ if pattern.search(stripped):
85
+ return False
86
+ for pattern in _PROGRESS_PATTERNS:
87
+ if pattern.search(stripped):
88
+ return False
89
+ return False
90
+
91
+
92
+ def assistant_recovery_quality_for_text(text: str) -> RecoveryQuality:
93
+ if is_substantive_assistant_text(text):
94
+ return "substantive_assistant_fallback"
95
+ return "housekeeping_fallback"
96
+
97
+
98
+ # Result-quality taxonomy shared by the runner (write-time classification) and
99
+ # run-output (read-time classification). Centralized here so both channels emit
100
+ # identical warning text for the same quality verdict.
101
+ RESULT_QUALITY_OK = "ok"
102
+ RESULT_QUALITY_HOUSEKEEPING = "housekeeping_noop"
103
+ RESULT_QUALITY_EMPTY = "empty"
104
+ RESULT_QUALITY_SUSPECT_SHORT = "suspect_short"
105
+ RESULT_QUALITY_NO_ASSISTANT_TEXT = "no_assistant_text"
106
+
107
+
108
+ def quality_warning(quality: str, *, harness: str | None = None) -> str | None:
109
+ """Render a human-readable warning for a result-quality verdict.
110
+
111
+ Returns None for ``ok`` so callers can use a truthy check to decide whether
112
+ to emit anything. The text is shared by the runner and run-output paths so a
113
+ given verdict produces the same warning regardless of which channel
114
+ classifies it.
115
+ """
116
+ if quality == RESULT_QUALITY_OK:
117
+ return None
118
+ if quality == RESULT_QUALITY_HOUSEKEEPING:
119
+ if harness == "droid":
120
+ return (
121
+ "resultQuality=housekeeping_noop: completion report looks like a Droid "
122
+ "no-op; rerun with a blunter findings-only prompt or reroute to codex/cursor."
123
+ )
124
+ return (
125
+ "resultQuality=housekeeping_noop: completion report looks like housekeeping; "
126
+ "rerun with a blunter findings-only prompt or inspect stdout/stderr."
127
+ )
128
+ if quality == RESULT_QUALITY_EMPTY:
129
+ return (
130
+ "resultQuality=empty: child exited 0 but wrote no completion report; "
131
+ "inspect stdout/stderr or rerun with stricter report instructions."
132
+ )
133
+ if quality == RESULT_QUALITY_SUSPECT_SHORT:
134
+ return (
135
+ "resultQuality=suspect_short: safe-mode completion report is under 200 chars; "
136
+ "inspect stdout/stderr or reroute if it lacks findings."
137
+ )
138
+ if quality == RESULT_QUALITY_NO_ASSISTANT_TEXT:
139
+ return (
140
+ "resultQuality=no_assistant_text: structured stream contained no assistant text; "
141
+ "inspect stdout/stderr or reroute to a different lane."
142
+ )
143
+ return f"resultQuality={quality}"
144
+
145
+
146
+ ASSISTANT_TEXT_LIMIT = 30_000
147
+ ASSISTANT_TEXT_HEAD = 20_000
148
+ ASSISTANT_TEXT_TAIL = 10_000
149
+ EVENT_LIMIT = 500
150
+ EVENT_HEAD = 100
151
+ EVENT_TAIL = 400
152
+
153
+ # Harnesses whose streams emit assistant messages that are safe to
154
+ # surface as a recovered completion report when a run dies before its final
155
+ # completion event. Codex is excluded on purpose: its agent_message events can
156
+ # be preamble ("I'll start by..."), and only a message sealed by turn.completed
157
+ # is the real answer.
158
+ ASSISTANT_RECOVERY_HARNESSES = frozenset({"cursor", "droid", "kimi", "claude", "grok"})
159
+
160
+
161
+ @dataclass
162
+ class NormalizedEvent:
163
+ kind: str
164
+ tool: str | None = None
165
+ target: str | None = None
166
+ path: str | None = None
167
+ status: str | None = None
168
+ message: str | None = None
169
+
170
+ def to_dict(self) -> JsonObject:
171
+ payload: JsonObject = {"kind": self.kind}
172
+ if self.tool is not None:
173
+ payload["tool"] = self.tool
174
+ if self.target is not None:
175
+ payload["target"] = self.target
176
+ if self.path is not None:
177
+ payload["path"] = self.path
178
+ if self.status is not None:
179
+ payload["status"] = self.status
180
+ if self.message is not None:
181
+ payload["message"] = self.message
182
+ return payload
183
+
184
+
185
+ _CANCELLED_REASONS = {"abort", "aborted", "cancel", "cancelled", "canceled", "interrupted"}
186
+ _FAILED_REASONS = {"error", "errored", "fail", "failed", "failure"}
187
+
188
+
189
+ def _normalize_terminal_status(value: JsonValue) -> str | None:
190
+ if not isinstance(value, str) or not value.strip():
191
+ return None
192
+ normalized = re.sub(r"[^a-z]", "", value.lower())
193
+ if normalized in _CANCELLED_REASONS:
194
+ return "cancelled"
195
+ if normalized in _FAILED_REASONS:
196
+ return "failed"
197
+ if normalized in {"endturn", "stop", "complete", "completed", "done", "success", "succeeded"}:
198
+ return "succeeded"
199
+ return None
200
+
201
+
202
+ @dataclass
203
+ class StreamAccumulator:
204
+ harness: str | None = None
205
+ assistant_chunks: list[str] = field(default_factory=list)
206
+ events: list[NormalizedEvent] = field(default_factory=list)
207
+ completion_text: str | None = None
208
+ current: str | None = None
209
+ _assistant_text_cache: str | None = field(default=None, repr=False)
210
+ _codex_completion_candidate: str | None = field(default=None, repr=False)
211
+ _last_recoverable_assistant_text: str | None = field(default=None, repr=False)
212
+ _last_substantive_assistant_text: str | None = field(default=None, repr=False)
213
+ _pending_tool_uses: dict[str, tuple[str, str | None]] = field(default_factory=dict, repr=False)
214
+ _grok_text_buffer: str = field(default="", repr=False)
215
+ _grok_current_line: str = field(default="", repr=False)
216
+ terminal_event: JsonObject | None = None
217
+ terminal_status: str | None = None
218
+ structured_events_seen: int = 0
219
+
220
+ def _record_terminal_event(
221
+ self,
222
+ *,
223
+ event: str,
224
+ status: str,
225
+ reason: str | None = None,
226
+ ) -> None:
227
+ self.terminal_status = status
228
+ payload: JsonObject = {"event": event, "status": status}
229
+ if reason:
230
+ payload["reason"] = reason
231
+ self.terminal_event = payload
232
+ self.events.append(NormalizedEvent(kind="run.completed", status=status, message=reason))
233
+
234
+ def _invalidate_assistant_text_cache(self) -> None:
235
+ self._assistant_text_cache = None
236
+
237
+ def ingest_line(self, line: str) -> None:
238
+ stripped = line.strip()
239
+ if not stripped:
240
+ return
241
+ try:
242
+ payload: JsonValue = json.loads(stripped)
243
+ except json.JSONDecodeError:
244
+ self._ingest_text_fallback(stripped)
245
+ return
246
+ if not isinstance(payload, dict):
247
+ self._ingest_text_fallback(stripped)
248
+ return
249
+ self.structured_events_seen += 1
250
+ self._ingest_object(payload)
251
+
252
+ def _ingest_text_fallback(self, text: str) -> None:
253
+ bounded = text if len(text) <= 500 else text[:500] + "…"
254
+ self.events.append(NormalizedEvent(kind="text", message=bounded))
255
+ self.current = _bounded_current_line(bounded)
256
+
257
+ def _ingest_object(self, payload: JsonObject) -> None:
258
+ event_type = payload.get("type")
259
+ if not isinstance(event_type, str):
260
+ self._ingest_role_content_message(payload)
261
+ return
262
+ if event_type == "reasoning":
263
+ return
264
+ if event_type == "thought":
265
+ return
266
+ if event_type == "text" and self.harness == "grok":
267
+ self._ingest_grok_text(payload)
268
+ return
269
+ if event_type == "end" and self.harness == "grok":
270
+ self._ingest_grok_end(payload)
271
+ return
272
+ if event_type == "error" and self.harness == "grok":
273
+ self._ingest_grok_error(payload)
274
+ return
275
+ if event_type == "tool_result":
276
+ return
277
+ if event_type == "system":
278
+ self._ingest_system(payload)
279
+ return
280
+ if event_type == "message":
281
+ self._ingest_message(payload)
282
+ return
283
+ if event_type == "tool_call":
284
+ self._ingest_tool_call(payload)
285
+ return
286
+ if event_type == "completion":
287
+ self._ingest_completion(payload)
288
+ return
289
+ # assistant/user/result are the stream-json envelope shared by the Cursor
290
+ # and Claude Code harnesses; tool_call.* is Cursor-specific (Claude reports
291
+ # tool activity via tool_use/tool_result content blocks instead).
292
+ if event_type == "assistant":
293
+ self._ingest_assistant_event(payload)
294
+ return
295
+ if event_type == "user":
296
+ self._ingest_user_event(payload)
297
+ return
298
+ if event_type in ("tool_call.started", "tool_call.completed"):
299
+ self._ingest_cursor_tool(payload, event_type)
300
+ return
301
+ if event_type == "result":
302
+ self._ingest_result_event(payload)
303
+ return
304
+ if event_type in ("turn.failed", "turn.error"):
305
+ self._record_terminal_event(event=event_type, status="failed")
306
+ return
307
+ if event_type in ("turn.cancelled", "turn.canceled"):
308
+ self._record_terminal_event(event=event_type, status="cancelled")
309
+ return
310
+ if event_type in ("item.started", "item.completed"):
311
+ self._ingest_codex_item(payload, completed=event_type == "item.completed")
312
+ return
313
+ if event_type == "turn.completed":
314
+ self._ingest_codex_turn_completed()
315
+ return
316
+ if event_type == "turn.started":
317
+ self._codex_completion_candidate = None
318
+ return
319
+
320
+ def _ingest_system(self, payload: JsonObject) -> None:
321
+ cwd = payload.get("cwd")
322
+ if isinstance(cwd, str) and cwd:
323
+ self.current = f"session cwd {cwd}"
324
+
325
+ def _ingest_message(self, payload: JsonObject) -> None:
326
+ role = payload.get("role")
327
+ if role not in (None, "assistant"):
328
+ return
329
+ text = _extract_text(payload.get("content"))
330
+ if text:
331
+ self._record_recoverable_assistant_text(text)
332
+
333
+ def _ingest_role_content_message(self, payload: JsonObject) -> None:
334
+ role = payload.get("role")
335
+ if role != "assistant":
336
+ return
337
+ text = _extract_text(payload.get("content"))
338
+ if text:
339
+ self._record_recoverable_assistant_text(text)
340
+
341
+ def _ingest_assistant_event(self, payload: JsonObject) -> None:
342
+ message = payload.get("message")
343
+ if isinstance(message, dict):
344
+ content = message.get("content")
345
+ text = _extract_text(content)
346
+ if text:
347
+ self._record_recoverable_assistant_text(text)
348
+ if isinstance(content, list):
349
+ for block in content:
350
+ if not isinstance(block, dict):
351
+ continue
352
+ if block.get("type") == "tool_use":
353
+ tool = _string_field(block, "name") or "tool"
354
+ target = _tool_use_target(block)
355
+ tool_id = _string_field(block, "id")
356
+ if tool_id:
357
+ self._pending_tool_uses[tool_id] = (tool, target)
358
+ self.events.append(
359
+ NormalizedEvent(
360
+ kind="tool.started",
361
+ tool=tool,
362
+ target=target,
363
+ path=target,
364
+ )
365
+ )
366
+ self.current = _tool_current(tool, target)
367
+
368
+ def _ingest_user_event(self, payload: JsonObject) -> None:
369
+ message = payload.get("message")
370
+ if not isinstance(message, dict):
371
+ return
372
+ content = message.get("content")
373
+ if not isinstance(content, list):
374
+ return
375
+ for block in content:
376
+ if not isinstance(block, dict):
377
+ continue
378
+ if block.get("type") != "tool_result":
379
+ continue
380
+ tool_id = _string_field(block, "tool_use_id")
381
+ tool, target = (
382
+ self._pending_tool_uses.pop(tool_id, (None, None)) if tool_id else (None, None)
383
+ )
384
+ tool = tool or "tool"
385
+ status = "error" if block.get("is_error") is True else "success"
386
+ self.events.append(
387
+ NormalizedEvent(
388
+ kind="tool.completed",
389
+ tool=tool,
390
+ target=target,
391
+ path=target,
392
+ status=status,
393
+ )
394
+ )
395
+ self.current = _tool_current(tool, target)
396
+
397
+ def _record_recoverable_assistant_text(self, text: str) -> None:
398
+ stripped = self._record_assistant_text(text)
399
+ if stripped:
400
+ self._last_recoverable_assistant_text = stripped
401
+ if is_substantive_assistant_text(stripped):
402
+ self._last_substantive_assistant_text = stripped
403
+
404
+ def _record_successful_completion_text(self, text: str) -> None:
405
+ self._record_assistant_text(text, completion=True)
406
+ self._record_terminal_event(event="completion", status="succeeded")
407
+
408
+ def _ingest_completion(self, payload: JsonObject) -> None:
409
+ final_text = payload.get("finalText")
410
+ if isinstance(final_text, str) and final_text.strip():
411
+ self._record_successful_completion_text(final_text)
412
+
413
+ def _ingest_result_event(self, payload: JsonObject) -> None:
414
+ result = payload.get("result")
415
+ if isinstance(result, str) and result.strip():
416
+ if payload.get("is_error") is True:
417
+ self._record_terminal_event(event="result", status="failed")
418
+ self._record_recoverable_assistant_text(result)
419
+ return
420
+ self._record_successful_completion_text(result)
421
+
422
+ def _ingest_codex_item(self, payload: JsonObject, *, completed: bool) -> None:
423
+ item = payload.get("item")
424
+ if not isinstance(item, dict):
425
+ return
426
+ item_type = item.get("type")
427
+ if item_type == "agent_message":
428
+ if not completed:
429
+ return
430
+ text = _extract_text(item.get("text")) or _extract_text(item.get("content"))
431
+ if text:
432
+ stripped = self._record_assistant_text(text)
433
+ self._codex_completion_candidate = stripped
434
+ else:
435
+ self._codex_completion_candidate = None
436
+ return
437
+ if item_type == "command_execution":
438
+ self._ingest_codex_command_execution(item, completed=completed)
439
+
440
+ def _ingest_codex_command_execution(self, item: JsonObject, *, completed: bool) -> None:
441
+ command = _string_field(item, "command")
442
+ status = _codex_command_status(_string_field(item, "status"), completed=completed)
443
+ kind = "tool.completed" if completed else "tool.started"
444
+ # Clear the completion candidate: an agent_message followed by a command is
445
+ # preamble/progress, not the turn's final answer. Only a message emitted
446
+ # after the last tool activity (then sealed by turn.completed) is promoted,
447
+ # which is the shape real Codex runs produce. Promoting a pre-command
448
+ # message would surface an intro line ("I'll start by…") as the report.
449
+ self._codex_completion_candidate = None
450
+ self.events.append(
451
+ NormalizedEvent(
452
+ kind=kind,
453
+ tool="command_execution",
454
+ target=command,
455
+ status=status,
456
+ )
457
+ )
458
+ self.current = _tool_current("command_execution", command)
459
+
460
+ def _ingest_codex_turn_completed(self) -> None:
461
+ if self._codex_completion_candidate:
462
+ self.completion_text = self._codex_completion_candidate
463
+
464
+ def _ingest_grok_text(self, payload: JsonObject) -> None:
465
+ data = payload.get("data")
466
+ if not isinstance(data, str) or not data:
467
+ return
468
+ self._grok_text_buffer += data
469
+ if "\n" in data:
470
+ self._grok_current_line = data.rsplit("\n", 1)[1]
471
+ else:
472
+ self._grok_current_line += data
473
+ self.current = _bounded_current_line(self._grok_current_line.strip())
474
+ self._invalidate_assistant_text_cache()
475
+
476
+ def _ingest_grok_end(self, payload: JsonObject) -> None:
477
+ text = self._grok_text_buffer.strip()
478
+ self._grok_text_buffer = ""
479
+ self._grok_current_line = ""
480
+ self._invalidate_assistant_text_cache()
481
+ if text:
482
+ # The thought/text/end stream shape and the "EndTurn" success spelling
483
+ # are validated against grok 0.2.73; non-success stopReason spellings
484
+ # (MaxTokens/Refusal/etc.) are best-effort, so classification is
485
+ # conservative — anything not recognized as success stays recoverable.
486
+ stop_reason = payload.get("stopReason")
487
+ terminal_status = _normalize_terminal_status(stop_reason)
488
+ if _grok_stop_reason_succeeded(stop_reason):
489
+ self._record_successful_completion_text(text)
490
+ elif terminal_status in {"cancelled", "failed"}:
491
+ self._record_terminal_event(
492
+ event="grok.end",
493
+ status=terminal_status,
494
+ reason=stop_reason if isinstance(stop_reason, str) else None,
495
+ )
496
+ self._record_recoverable_assistant_text(text)
497
+ else:
498
+ self._record_recoverable_assistant_text(text)
499
+ elif _grok_stop_reason_succeeded(payload.get("stopReason")):
500
+ self._record_terminal_event(event="grok.end", status="succeeded")
501
+ else:
502
+ terminal_status = _normalize_terminal_status(payload.get("stopReason"))
503
+ if terminal_status in {"cancelled", "failed"}:
504
+ reason = payload.get("stopReason")
505
+ self._record_terminal_event(
506
+ event="grok.end",
507
+ status=terminal_status,
508
+ reason=reason if isinstance(reason, str) else None,
509
+ )
510
+
511
+ def _ingest_grok_error(self, payload: JsonObject) -> None:
512
+ # Grok streaming-json emits {"type":"error","message":...} on failure and
513
+ # then exits nonzero, so the runner already marks the run failed via exit
514
+ # code. Surface the message (plus any partial buffered text) as recoverable
515
+ # assistant text so it lands in the snapshot instead of being dropped.
516
+ partial = self._grok_text_buffer.strip()
517
+ self._grok_text_buffer = ""
518
+ self._grok_current_line = ""
519
+ self._invalidate_assistant_text_cache()
520
+ if partial:
521
+ self._record_recoverable_assistant_text(partial)
522
+ message = payload.get("message")
523
+ if isinstance(message, str) and message.strip():
524
+ text = message.strip()
525
+ self._record_recoverable_assistant_text(text)
526
+ self.current = _bounded_current_line(text)
527
+ self._record_terminal_event(event="grok.error", status="failed")
528
+
529
+ def _ingest_tool_call(self, payload: JsonObject) -> None:
530
+ tool = _string_field(payload, "tool", "name", "toolName") or "tool"
531
+ target = _tool_target(payload)
532
+ kind = "tool.started"
533
+ self.events.append(
534
+ NormalizedEvent(kind=kind, tool=tool, target=target, path=target),
535
+ )
536
+ self.current = _tool_current(tool, target)
537
+
538
+ def _ingest_cursor_tool(self, payload: JsonObject, event_type: str) -> None:
539
+ tool_call = payload.get("tool_call")
540
+ if not isinstance(tool_call, dict):
541
+ return
542
+ tool = _string_field(tool_call, "name", "tool") or "tool"
543
+ target = _tool_target(tool_call)
544
+ kind = "tool.completed" if event_type.endswith("completed") else "tool.started"
545
+ status = "success" if kind == "tool.completed" else None
546
+ self.events.append(
547
+ NormalizedEvent(kind=kind, tool=tool, target=target, path=target, status=status),
548
+ )
549
+ self.current = _tool_current(tool, target)
550
+
551
+ def _record_assistant_text(self, text: str, *, completion: bool = False) -> str | None:
552
+ stripped = text.strip()
553
+ if not stripped:
554
+ return None
555
+ if completion:
556
+ self.completion_text = stripped
557
+ self.assistant_chunks.append(stripped)
558
+ self._invalidate_assistant_text_cache()
559
+ self.current = _current_from_text(stripped)
560
+ return stripped
561
+
562
+ @property
563
+ def assistant_text(self) -> str:
564
+ if self._assistant_text_cache is None:
565
+ base = "\n\n".join(chunk for chunk in self.assistant_chunks if chunk).strip()
566
+ grok = self._grok_text_buffer.strip()
567
+ if grok:
568
+ base = f"{base}\n\n{grok}" if base else grok
569
+ self._assistant_text_cache = base
570
+ return self._assistant_text_cache
571
+
572
+ def bounded_assistant_text(self) -> tuple[str, JsonObject]:
573
+ text = self.assistant_text
574
+ if len(text) <= ASSISTANT_TEXT_LIMIT:
575
+ meta = {
576
+ "assistantText": text,
577
+ "assistantTextChars": len(text),
578
+ "assistantTextTruncated": False,
579
+ "assistantTextLimitChars": ASSISTANT_TEXT_LIMIT,
580
+ "assistantTextOmittedMiddleChars": 0,
581
+ }
582
+ return text, meta
583
+ head = text[:ASSISTANT_TEXT_HEAD]
584
+ tail = text[-ASSISTANT_TEXT_TAIL:]
585
+ omitted = len(text) - ASSISTANT_TEXT_HEAD - ASSISTANT_TEXT_TAIL
586
+ bounded = f"{head}\n\n… [{omitted} chars omitted] …\n\n{tail}"
587
+ meta = {
588
+ "assistantText": bounded,
589
+ "assistantTextChars": len(text),
590
+ "assistantTextTruncated": True,
591
+ "assistantTextLimitChars": ASSISTANT_TEXT_LIMIT,
592
+ "assistantTextOmittedMiddleChars": max(omitted, 0),
593
+ }
594
+ return bounded, meta
595
+
596
+ @property
597
+ def recoverable_assistant_text(self) -> str | None:
598
+ self._refresh_grok_recovery_text()
599
+ if self._last_substantive_assistant_text:
600
+ return self._last_substantive_assistant_text
601
+ return self._last_recoverable_assistant_text
602
+
603
+ def _refresh_grok_recovery_text(self) -> None:
604
+ text = self._grok_text_buffer.strip()
605
+ if not text:
606
+ return
607
+ self._last_recoverable_assistant_text = text
608
+ if is_substantive_assistant_text(text):
609
+ self._last_substantive_assistant_text = text
610
+
611
+ def assistant_recovery_quality(self) -> RecoveryQuality | None:
612
+ if self.completion_text:
613
+ return "explicit_completion"
614
+ text = self.recoverable_assistant_text
615
+ if not text:
616
+ return None
617
+ return assistant_recovery_quality_for_text(text)
618
+
619
+ def bounded_recent_events(self) -> tuple[list[JsonObject], JsonObject]:
620
+ serialized = [event.to_dict() for event in self.events]
621
+ total = len(serialized)
622
+ if total <= EVENT_LIMIT:
623
+ meta = {
624
+ "eventsTotal": total,
625
+ "eventsTruncated": False,
626
+ "eventsLimit": EVENT_LIMIT,
627
+ "eventsOmittedMiddle": 0,
628
+ }
629
+ return serialized, meta
630
+ head = serialized[:EVENT_HEAD]
631
+ tail = serialized[-EVENT_TAIL:]
632
+ omitted = total - EVENT_HEAD - EVENT_TAIL
633
+ recent_events = head + tail
634
+ meta = {
635
+ "eventsTotal": total,
636
+ "eventsTruncated": True,
637
+ "eventsLimit": EVENT_LIMIT,
638
+ "eventsOmittedMiddle": max(omitted, 0),
639
+ }
640
+ return recent_events, meta
641
+
642
+
643
+ def _extract_text(content: JsonValue) -> str:
644
+ if isinstance(content, str):
645
+ return content.strip()
646
+ if isinstance(content, list):
647
+ parts: list[str] = []
648
+ for item in content:
649
+ if isinstance(item, str):
650
+ parts.append(item)
651
+ elif isinstance(item, dict):
652
+ text = item.get("text")
653
+ if isinstance(text, str):
654
+ parts.append(text)
655
+ return "\n".join(part for part in parts if part).strip()
656
+ return ""
657
+
658
+
659
+ def _string_field(payload: JsonObject, *keys: str) -> str | None:
660
+ for key in keys:
661
+ value = payload.get(key)
662
+ if isinstance(value, str) and value.strip():
663
+ return value.strip()
664
+ return None
665
+
666
+
667
+ def _grok_stop_reason_succeeded(value: JsonValue) -> bool:
668
+ if not isinstance(value, str):
669
+ return False
670
+ normalized = re.sub(r"[^a-z]", "", value.lower())
671
+ return normalized in {"endturn", "stop", "complete", "done"}
672
+
673
+
674
+ def _codex_command_status(status: str | None, *, completed: bool) -> str | None:
675
+ if not completed:
676
+ return status
677
+ if status == "completed":
678
+ return "success"
679
+ return status
680
+
681
+
682
+ def _tool_use_target(block: JsonObject) -> str | None:
683
+ tool_input = block.get("input")
684
+ if not isinstance(tool_input, dict):
685
+ return None
686
+ for key in ("command", "file_path", "path", "pattern", "url"):
687
+ value = tool_input.get(key)
688
+ if isinstance(value, str) and value.strip():
689
+ return value.strip()
690
+ return None
691
+
692
+
693
+ def _tool_target(payload: JsonObject) -> str | None:
694
+ for key in ("path", "file", "command", "target", "uri"):
695
+ value = payload.get(key)
696
+ if isinstance(value, str) and value.strip():
697
+ return value.strip()
698
+ args = payload.get("args")
699
+ if isinstance(args, dict):
700
+ for key in ("path", "file", "command", "target"):
701
+ value = args.get(key)
702
+ if isinstance(value, str) and value.strip():
703
+ return value.strip()
704
+ return None
705
+
706
+
707
+ def _tool_current(tool: str, target: str | None) -> str:
708
+ if target:
709
+ return f"{tool} {target}"
710
+ return tool
711
+
712
+
713
+ def _current_from_text(text: str) -> str:
714
+ line = text.strip().splitlines()[-1] if text.strip() else ""
715
+ return _bounded_current_line(line)
716
+
717
+
718
+ def _bounded_current_line(line: str) -> str:
719
+ return (line[:120] + "…") if len(line) > 120 else line