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.
- delegate_agent/__init__.py +5 -0
- delegate_agent/archived_logs.py +44 -0
- delegate_agent/argv_builders.py +423 -0
- delegate_agent/argv_utils.py +36 -0
- delegate_agent/capability_commands.py +99 -0
- delegate_agent/cli.py +987 -0
- delegate_agent/cli_parser.py +1627 -0
- delegate_agent/command_errors.py +29 -0
- delegate_agent/command_help.py +1264 -0
- delegate_agent/config.py +1117 -0
- delegate_agent/config_commands.py +143 -0
- delegate_agent/constants.py +50 -0
- delegate_agent/describe_payload.py +1018 -0
- delegate_agent/errors.py +32 -0
- delegate_agent/git_utils.py +180 -0
- delegate_agent/harness_events.py +719 -0
- delegate_agent/inspection_commands.py +104 -0
- delegate_agent/isolation.py +316 -0
- delegate_agent/json_types.py +18 -0
- delegate_agent/log_output.py +78 -0
- delegate_agent/private_io.py +109 -0
- delegate_agent/profile_commands.py +42 -0
- delegate_agent/profile_guard.py +116 -0
- delegate_agent/profiles.py +389 -0
- delegate_agent/prompt_instructions.py +39 -0
- delegate_agent/prompt_transport.py +12 -0
- delegate_agent/reasoning.py +916 -0
- delegate_agent/redaction.py +193 -0
- delegate_agent/rendering.py +573 -0
- delegate_agent/request_build.py +1681 -0
- delegate_agent/request_models.py +191 -0
- delegate_agent/retention.py +321 -0
- delegate_agent/run_metadata.py +78 -0
- delegate_agent/run_output_commands.py +645 -0
- delegate_agent/run_registry.py +747 -0
- delegate_agent/run_status.py +300 -0
- delegate_agent/runner.py +1830 -0
- delegate_agent/safe_workspace.py +821 -0
- delegate_agent/snapshot_view.py +229 -0
- delegate_agent/wait_cancel_commands.py +559 -0
- delegate_agent/worktree_commands.py +218 -0
- delegate_agent/worktree_execution.py +654 -0
- delegate_agent/worktree_gc.py +529 -0
- delegate_agent/worktree_mgmt.py +782 -0
- delegate_agent/worktree_records.py +232 -0
- delegate_agent/worktree_remove.py +547 -0
- delegate_agent/worktree_summary.py +242 -0
- delegate_agent/wsl.py +65 -0
- delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
- delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
- delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
- delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
- delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
- 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
|