prizmkit 1.1.145 → 1.1.147
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.
- package/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +25 -13
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +145 -49
- package/bundled/dev-pipeline/scripts/parse-stream-progress.py +566 -4
- package/bundled/dev-pipeline/tests/live_ai_cli_log_smoke.py +206 -0
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +279 -12
- package/bundled/dev-pipeline/tests/test_unified_cli.py +455 -0
- package/bundled/skills/_metadata.json +1 -1
- package/bundled/skills/app-planner/SKILL.md +59 -15
- package/bundled/skills/app-planner/references/generated-plan-review.md +29 -2
- package/bundled/skills/app-planner/references/project-brief-guide.md +24 -5
- package/bundled/skills/bug-planner/SKILL.md +63 -36
- package/bundled/skills/bug-planner/references/generated-plan-review.md +29 -3
- package/bundled/skills/feature-planner/SKILL.md +45 -30
- package/bundled/skills/feature-planner/assets/planning-guide.md +20 -7
- package/bundled/skills/feature-planner/references/completeness-review.md +57 -39
- package/bundled/skills/feature-planner/references/generated-plan-review.md +29 -3
- package/bundled/skills/refactor-planner/SKILL.md +43 -28
- package/bundled/skills/refactor-planner/references/fast-path.md +4 -3
- package/bundled/skills/refactor-planner/references/generated-plan-review.md +29 -3
- package/bundled/skills/refactor-planner/references/planning-phases.md +26 -8
- package/package.json +1 -1
|
@@ -23,7 +23,8 @@ import signal
|
|
|
23
23
|
import sys
|
|
24
24
|
import tempfile
|
|
25
25
|
import time
|
|
26
|
-
from collections import Counter
|
|
26
|
+
from collections import Counter, deque
|
|
27
|
+
from dataclasses import dataclass
|
|
27
28
|
from datetime import datetime, timezone
|
|
28
29
|
from pathlib import Path
|
|
29
30
|
|
|
@@ -198,6 +199,517 @@ def _normalize_status(status, default="unknown"):
|
|
|
198
199
|
return text
|
|
199
200
|
|
|
200
201
|
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class SemanticLogEvent:
|
|
204
|
+
"""One provider-neutral semantic fragment destined for session.log."""
|
|
205
|
+
|
|
206
|
+
kind: str
|
|
207
|
+
payload: str = ""
|
|
208
|
+
stream_id: str = ""
|
|
209
|
+
mode: str = "complete"
|
|
210
|
+
deduplicate: bool = False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SemanticLogRenderer:
|
|
214
|
+
"""Render semantic fragments without consulting their source format."""
|
|
215
|
+
|
|
216
|
+
LABELS = {
|
|
217
|
+
"think": "[THINK]",
|
|
218
|
+
"action": "[ACTION]",
|
|
219
|
+
"observation": "[OBSERVATION]",
|
|
220
|
+
"error": "[ERROR]",
|
|
221
|
+
"final": "[FINAL]",
|
|
222
|
+
"session_end": "[SESSION_END]",
|
|
223
|
+
"output": "[OUTPUT]",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
def __init__(self):
|
|
227
|
+
self._active_stream = ""
|
|
228
|
+
|
|
229
|
+
def _close_active(self):
|
|
230
|
+
if not self._active_stream:
|
|
231
|
+
return ""
|
|
232
|
+
self._active_stream = ""
|
|
233
|
+
return "\n"
|
|
234
|
+
|
|
235
|
+
def render(self, event):
|
|
236
|
+
label = self.LABELS.get(event.kind, "[OUTPUT]")
|
|
237
|
+
payload = str(event.payload or "")
|
|
238
|
+
mode = event.mode or "complete"
|
|
239
|
+
stream_id = event.stream_id or f"{event.kind}:anonymous"
|
|
240
|
+
|
|
241
|
+
if mode == "complete":
|
|
242
|
+
prefix = self._close_active()
|
|
243
|
+
suffix = "" if payload.endswith("\n") else "\n"
|
|
244
|
+
spacer = " " if payload else ""
|
|
245
|
+
return f"{prefix}{label}{spacer}{payload}{suffix}"
|
|
246
|
+
|
|
247
|
+
if mode == "start":
|
|
248
|
+
prefix = self._close_active()
|
|
249
|
+
self._active_stream = stream_id
|
|
250
|
+
return f"{prefix}{label} {payload}"
|
|
251
|
+
|
|
252
|
+
if mode == "delta":
|
|
253
|
+
if self._active_stream == stream_id:
|
|
254
|
+
return payload
|
|
255
|
+
prefix = self._close_active()
|
|
256
|
+
self._active_stream = stream_id
|
|
257
|
+
return f"{prefix}{label} {payload}"
|
|
258
|
+
|
|
259
|
+
if mode == "end":
|
|
260
|
+
if self._active_stream == stream_id:
|
|
261
|
+
self._active_stream = ""
|
|
262
|
+
return f"{payload}\n"
|
|
263
|
+
if not payload:
|
|
264
|
+
return ""
|
|
265
|
+
prefix = self._close_active()
|
|
266
|
+
suffix = "" if payload.endswith("\n") else "\n"
|
|
267
|
+
return f"{prefix}{label} {payload}{suffix}"
|
|
268
|
+
|
|
269
|
+
return self.render(SemanticLogEvent(event.kind, payload, stream_id, "complete"))
|
|
270
|
+
|
|
271
|
+
def finish(self):
|
|
272
|
+
return self._close_active()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class SemanticLogNormalizer:
|
|
276
|
+
"""Adapt supported structured shapes into one compact semantic log stream."""
|
|
277
|
+
|
|
278
|
+
_SUCCESS_STOPS = {"stop", "end_turn", "completed", "success"}
|
|
279
|
+
_ERROR_STOPS = {"error", "aborted", "failed", "cancelled", "canceled"}
|
|
280
|
+
|
|
281
|
+
def __init__(self):
|
|
282
|
+
self.renderer = SemanticLogRenderer()
|
|
283
|
+
self._scope = 0
|
|
284
|
+
self._block_streams = {}
|
|
285
|
+
self._stream_buffers = {}
|
|
286
|
+
self._snapshots = {}
|
|
287
|
+
self._recent_digests = deque()
|
|
288
|
+
self._recent_digest_set = set()
|
|
289
|
+
self._recent_action_ids = deque()
|
|
290
|
+
self._recent_action_id_set = set()
|
|
291
|
+
self._recent_output_ids = deque()
|
|
292
|
+
self._recent_output_id_set = set()
|
|
293
|
+
self._max_recent = 256
|
|
294
|
+
self._terminal_emitted = False
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def _json_text(value):
|
|
298
|
+
if isinstance(value, str):
|
|
299
|
+
return value
|
|
300
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
301
|
+
|
|
302
|
+
@classmethod
|
|
303
|
+
def _value_text(cls, value):
|
|
304
|
+
if value in (None, ""):
|
|
305
|
+
return ""
|
|
306
|
+
if isinstance(value, str):
|
|
307
|
+
return value
|
|
308
|
+
if isinstance(value, list):
|
|
309
|
+
return "".join(cls._value_text(item) for item in value)
|
|
310
|
+
if isinstance(value, dict):
|
|
311
|
+
for key in ("text", "output", "content"):
|
|
312
|
+
if key in value:
|
|
313
|
+
return cls._value_text(value.get(key))
|
|
314
|
+
return cls._json_text(value)
|
|
315
|
+
return str(value)
|
|
316
|
+
|
|
317
|
+
def _digest(self, kind, payload):
|
|
318
|
+
canonical_payload = str(payload or "").rstrip()
|
|
319
|
+
return hashlib.sha1(f"{self._scope}\0{kind}\0{canonical_payload}".encode("utf-8")).hexdigest()
|
|
320
|
+
|
|
321
|
+
def _remember_digest(self, kind, payload):
|
|
322
|
+
if not payload:
|
|
323
|
+
return
|
|
324
|
+
digest = self._digest(kind, payload)
|
|
325
|
+
if digest in self._recent_digest_set:
|
|
326
|
+
return
|
|
327
|
+
if len(self._recent_digests) >= self._max_recent:
|
|
328
|
+
old = self._recent_digests.popleft()
|
|
329
|
+
self._recent_digest_set.discard(old)
|
|
330
|
+
self._recent_digests.append(digest)
|
|
331
|
+
self._recent_digest_set.add(digest)
|
|
332
|
+
|
|
333
|
+
def _is_recent(self, kind, payload):
|
|
334
|
+
return bool(payload) and self._digest(kind, payload) in self._recent_digest_set
|
|
335
|
+
|
|
336
|
+
def _remember_action_id(self, action_id):
|
|
337
|
+
action_id = str(action_id or "")
|
|
338
|
+
if not action_id or action_id in self._recent_action_id_set:
|
|
339
|
+
return
|
|
340
|
+
if len(self._recent_action_ids) >= self._max_recent:
|
|
341
|
+
old = self._recent_action_ids.popleft()
|
|
342
|
+
self._recent_action_id_set.discard(old)
|
|
343
|
+
self._recent_action_ids.append(action_id)
|
|
344
|
+
self._recent_action_id_set.add(action_id)
|
|
345
|
+
|
|
346
|
+
def _remember_output_id(self, output_id):
|
|
347
|
+
output_id = str(output_id or "")
|
|
348
|
+
if not output_id or output_id in self._recent_output_id_set:
|
|
349
|
+
return
|
|
350
|
+
if len(self._recent_output_ids) >= self._max_recent:
|
|
351
|
+
old = self._recent_output_ids.popleft()
|
|
352
|
+
self._recent_output_id_set.discard(old)
|
|
353
|
+
self._recent_output_ids.append(output_id)
|
|
354
|
+
self._recent_output_id_set.add(output_id)
|
|
355
|
+
|
|
356
|
+
def _emit(self, event):
|
|
357
|
+
payload = str(event.payload or "")
|
|
358
|
+
if event.mode == "complete":
|
|
359
|
+
if event.deduplicate and self._is_recent(event.kind, payload):
|
|
360
|
+
return ""
|
|
361
|
+
if event.deduplicate:
|
|
362
|
+
self._remember_digest(event.kind, payload)
|
|
363
|
+
return self.renderer.render(event)
|
|
364
|
+
|
|
365
|
+
stream_id = event.stream_id or f"{event.kind}:anonymous"
|
|
366
|
+
if event.mode == "start":
|
|
367
|
+
self._stream_buffers[stream_id] = payload
|
|
368
|
+
elif event.mode == "delta":
|
|
369
|
+
self._stream_buffers[stream_id] = self._stream_buffers.get(stream_id, "") + payload
|
|
370
|
+
elif event.mode == "end":
|
|
371
|
+
complete = self._stream_buffers.pop(stream_id, "") + payload
|
|
372
|
+
self._remember_digest(event.kind, complete)
|
|
373
|
+
return self.renderer.render(event)
|
|
374
|
+
|
|
375
|
+
def _complete(self, kind, payload, *, deduplicate=False):
|
|
376
|
+
payload = str(payload or "")
|
|
377
|
+
if not payload:
|
|
378
|
+
return ""
|
|
379
|
+
return self._emit(SemanticLogEvent(kind, payload, deduplicate=deduplicate))
|
|
380
|
+
|
|
381
|
+
def _session_end(self, payload):
|
|
382
|
+
if self._terminal_emitted:
|
|
383
|
+
return ""
|
|
384
|
+
self._terminal_emitted = True
|
|
385
|
+
return self._complete("session_end", payload)
|
|
386
|
+
|
|
387
|
+
def _stream(self, kind, stream_id, mode, payload=""):
|
|
388
|
+
return self._emit(SemanticLogEvent(kind, str(payload or ""), stream_id, mode))
|
|
389
|
+
|
|
390
|
+
def _snapshot(self, kind, stream_id, value, final=False):
|
|
391
|
+
"""Render cumulative snapshots while retaining only one active full value."""
|
|
392
|
+
value = str(value or "")
|
|
393
|
+
previous = self._snapshots.get(stream_id)
|
|
394
|
+
rendered = ""
|
|
395
|
+
if previous is None:
|
|
396
|
+
self._snapshots[stream_id] = value
|
|
397
|
+
if value:
|
|
398
|
+
rendered += self.renderer.render(SemanticLogEvent(kind, value, stream_id, "start"))
|
|
399
|
+
elif value == previous:
|
|
400
|
+
pass
|
|
401
|
+
elif value.startswith(previous):
|
|
402
|
+
self._snapshots[stream_id] = value
|
|
403
|
+
rendered += self.renderer.render(
|
|
404
|
+
SemanticLogEvent(kind, value[len(previous):], stream_id, "delta")
|
|
405
|
+
)
|
|
406
|
+
else:
|
|
407
|
+
rendered += self.renderer.render(SemanticLogEvent(kind, "", stream_id, "end"))
|
|
408
|
+
self._snapshots[stream_id] = value
|
|
409
|
+
if value:
|
|
410
|
+
rendered += self.renderer.render(SemanticLogEvent(kind, value, stream_id, "start"))
|
|
411
|
+
if final:
|
|
412
|
+
rendered += self.renderer.render(SemanticLogEvent(kind, "", stream_id, "end"))
|
|
413
|
+
self._snapshots.pop(stream_id, None)
|
|
414
|
+
return rendered
|
|
415
|
+
|
|
416
|
+
def _message_parts(self, message):
|
|
417
|
+
if not isinstance(message, dict):
|
|
418
|
+
return ""
|
|
419
|
+
rendered = ""
|
|
420
|
+
content = message.get("content")
|
|
421
|
+
if isinstance(content, str):
|
|
422
|
+
rendered += self._complete("final", content)
|
|
423
|
+
elif isinstance(content, list):
|
|
424
|
+
for block in content:
|
|
425
|
+
if not isinstance(block, dict):
|
|
426
|
+
continue
|
|
427
|
+
block_type = str(block.get("type") or "")
|
|
428
|
+
if block_type in {"text", "output_text"}:
|
|
429
|
+
rendered += self._complete(
|
|
430
|
+
"final",
|
|
431
|
+
block.get("text") or block.get("content") or "",
|
|
432
|
+
deduplicate=True,
|
|
433
|
+
)
|
|
434
|
+
elif block_type in {"thinking", "reasoning"}:
|
|
435
|
+
rendered += self._complete(
|
|
436
|
+
"think",
|
|
437
|
+
block.get("thinking") or block.get("text") or "",
|
|
438
|
+
deduplicate=True,
|
|
439
|
+
)
|
|
440
|
+
elif block_type == "tool_use":
|
|
441
|
+
action_id = block.get("id") or block.get("tool_use_id")
|
|
442
|
+
self._remember_action_id(action_id)
|
|
443
|
+
rendered += self._complete(
|
|
444
|
+
"action",
|
|
445
|
+
f"{block.get('name') or 'tool'} {self._json_text(block.get('input') or {})}",
|
|
446
|
+
)
|
|
447
|
+
elif block_type == "tool_result":
|
|
448
|
+
rendered += self._complete("observation", self._value_text(block.get("content")))
|
|
449
|
+
return rendered
|
|
450
|
+
|
|
451
|
+
def _tool_result_parts(self, message):
|
|
452
|
+
if not isinstance(message, dict):
|
|
453
|
+
return ""
|
|
454
|
+
rendered = ""
|
|
455
|
+
content = message.get("content")
|
|
456
|
+
if not isinstance(content, list):
|
|
457
|
+
return rendered
|
|
458
|
+
for block in content:
|
|
459
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
460
|
+
rendered += self._complete("observation", self._value_text(block.get("content")))
|
|
461
|
+
return rendered
|
|
462
|
+
|
|
463
|
+
def _pi_message_update(self, event):
|
|
464
|
+
update = event.get("assistantMessageEvent")
|
|
465
|
+
if not isinstance(update, dict):
|
|
466
|
+
return ""
|
|
467
|
+
subtype = str(update.get("type") or "")
|
|
468
|
+
index = update.get("contentIndex", 0)
|
|
469
|
+
stream_id = f"turn:{self._scope}:content:{index}"
|
|
470
|
+
if subtype == "thinking_start":
|
|
471
|
+
return self._stream("think", f"{stream_id}:think", "start")
|
|
472
|
+
if subtype == "thinking_delta":
|
|
473
|
+
return self._stream("think", f"{stream_id}:think", "delta", update.get("delta"))
|
|
474
|
+
if subtype == "thinking_end":
|
|
475
|
+
return self._stream("think", f"{stream_id}:think", "end")
|
|
476
|
+
if subtype == "text_start":
|
|
477
|
+
return self._stream("final", f"{stream_id}:final", "start")
|
|
478
|
+
if subtype == "text_delta":
|
|
479
|
+
return self._stream("final", f"{stream_id}:final", "delta", update.get("delta"))
|
|
480
|
+
if subtype == "text_end":
|
|
481
|
+
return self._stream("final", f"{stream_id}:final", "end")
|
|
482
|
+
if subtype == "toolcall_start":
|
|
483
|
+
partial = update.get("partial")
|
|
484
|
+
blocks = partial.get("content") if isinstance(partial, dict) else []
|
|
485
|
+
block = blocks[index] if isinstance(blocks, list) and isinstance(index, int) and index < len(blocks) else {}
|
|
486
|
+
action_id = block.get("id") if isinstance(block, dict) else ""
|
|
487
|
+
self._remember_action_id(action_id)
|
|
488
|
+
prefix = f"{block.get('name') or 'tool'} " if isinstance(block, dict) else "tool "
|
|
489
|
+
return self._stream("action", f"{stream_id}:action", "start", prefix)
|
|
490
|
+
if subtype == "toolcall_delta":
|
|
491
|
+
return self._stream("action", f"{stream_id}:action", "delta", update.get("delta"))
|
|
492
|
+
if subtype == "toolcall_end":
|
|
493
|
+
tool_call = update.get("toolCall")
|
|
494
|
+
if isinstance(tool_call, dict):
|
|
495
|
+
self._remember_action_id(tool_call.get("id"))
|
|
496
|
+
return self._stream("action", f"{stream_id}:action", "end")
|
|
497
|
+
return ""
|
|
498
|
+
|
|
499
|
+
def _content_block_event(self, event):
|
|
500
|
+
event_type = str(event.get("type") or "")
|
|
501
|
+
index = event.get("index", 0)
|
|
502
|
+
stream_id = f"block:{self._scope}:{index}"
|
|
503
|
+
if event_type == "content_block_start":
|
|
504
|
+
block = event.get("content_block") if isinstance(event.get("content_block"), dict) else {}
|
|
505
|
+
block_type = str(block.get("type") or "")
|
|
506
|
+
if block_type in {"thinking", "reasoning"}:
|
|
507
|
+
self._block_streams[index] = ("think", stream_id)
|
|
508
|
+
return self._stream("think", stream_id, "start", block.get("thinking") or "")
|
|
509
|
+
if block_type == "text":
|
|
510
|
+
self._block_streams[index] = ("final", stream_id)
|
|
511
|
+
return self._stream("final", stream_id, "start", block.get("text") or "")
|
|
512
|
+
if block_type == "tool_use":
|
|
513
|
+
self._block_streams[index] = ("action", stream_id)
|
|
514
|
+
self._remember_action_id(block.get("id"))
|
|
515
|
+
return self._stream("action", stream_id, "start", f"{block.get('name') or 'tool'} ")
|
|
516
|
+
return ""
|
|
517
|
+
if event_type == "content_block_delta":
|
|
518
|
+
delta = event.get("delta") if isinstance(event.get("delta"), dict) else {}
|
|
519
|
+
delta_type = str(delta.get("type") or "")
|
|
520
|
+
if delta_type in {"thinking_delta", "reasoning_delta"}:
|
|
521
|
+
kind = "think"
|
|
522
|
+
payload = delta.get("thinking") or delta.get("text") or ""
|
|
523
|
+
elif delta_type == "text_delta":
|
|
524
|
+
kind = "final"
|
|
525
|
+
payload = delta.get("text") or ""
|
|
526
|
+
elif delta_type == "input_json_delta":
|
|
527
|
+
kind = "action"
|
|
528
|
+
payload = delta.get("partial_json") or ""
|
|
529
|
+
else:
|
|
530
|
+
return ""
|
|
531
|
+
active_kind, active_id = self._block_streams.get(index, (kind, stream_id))
|
|
532
|
+
return self._stream(active_kind, active_id, "delta", payload)
|
|
533
|
+
if event_type == "content_block_stop":
|
|
534
|
+
kind, active_id = self._block_streams.pop(index, ("output", stream_id))
|
|
535
|
+
return self._stream(kind, active_id, "end")
|
|
536
|
+
return ""
|
|
537
|
+
|
|
538
|
+
def process_text(self, text):
|
|
539
|
+
text = str(text or "")
|
|
540
|
+
return self._complete("output", text.rstrip("\n")) if text.strip() else ""
|
|
541
|
+
|
|
542
|
+
def process_event(self, event):
|
|
543
|
+
if not isinstance(event, dict):
|
|
544
|
+
return self.process_text(event)
|
|
545
|
+
event_type = str(event.get("type") or "")
|
|
546
|
+
rendered = ""
|
|
547
|
+
|
|
548
|
+
if event_type == "turn_start" or event_type == "turn.started":
|
|
549
|
+
self._scope += 1
|
|
550
|
+
|
|
551
|
+
if event_type == "message_update" and isinstance(event.get("assistantMessageEvent"), dict):
|
|
552
|
+
return self._pi_message_update(event)
|
|
553
|
+
|
|
554
|
+
if event_type == "message_end":
|
|
555
|
+
message = event.get("message")
|
|
556
|
+
if isinstance(message, dict):
|
|
557
|
+
role = str(message.get("role") or "")
|
|
558
|
+
if role == "assistant":
|
|
559
|
+
rendered += self._message_parts(message)
|
|
560
|
+
elif role == "toolResult":
|
|
561
|
+
output_id = str(message.get("toolCallId") or "")
|
|
562
|
+
if output_id not in self._recent_output_id_set:
|
|
563
|
+
self._remember_output_id(output_id)
|
|
564
|
+
rendered += self._complete("observation", self._value_text(message.get("content")))
|
|
565
|
+
stop_reason = str(message.get("stopReason") or "")
|
|
566
|
+
if stop_reason in self._ERROR_STOPS:
|
|
567
|
+
rendered += self._complete("error", message.get("errorMessage") or stop_reason)
|
|
568
|
+
return rendered
|
|
569
|
+
|
|
570
|
+
if event_type in {"turn_end", "agent_settled", "agent_start", "session", "thread.started", "turn.started"}:
|
|
571
|
+
return ""
|
|
572
|
+
|
|
573
|
+
if event_type == "tool_execution_start":
|
|
574
|
+
action_id = str(event.get("toolCallId") or event.get("id") or "")
|
|
575
|
+
if action_id in self._recent_action_id_set:
|
|
576
|
+
return ""
|
|
577
|
+
self._remember_action_id(action_id)
|
|
578
|
+
return self._complete(
|
|
579
|
+
"action",
|
|
580
|
+
f"{event.get('toolName') or 'tool'} {self._json_text(event.get('args') or {})}",
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
if event_type == "tool_execution_update":
|
|
584
|
+
stream_id = f"tool:{event.get('toolCallId') or event.get('id') or 'active'}:output"
|
|
585
|
+
return self._snapshot("observation", stream_id, self._value_text(event.get("partialResult")))
|
|
586
|
+
|
|
587
|
+
if event_type == "tool_execution_end":
|
|
588
|
+
output_id = str(event.get("toolCallId") or event.get("id") or "")
|
|
589
|
+
self._remember_output_id(output_id)
|
|
590
|
+
stream_id = f"tool:{output_id or 'active'}:output"
|
|
591
|
+
result_text = self._value_text(event.get("result"))
|
|
592
|
+
had_output = stream_id in self._snapshots and bool(self._snapshots.get(stream_id))
|
|
593
|
+
rendered += self._snapshot("observation", stream_id, result_text, final=True)
|
|
594
|
+
if event.get("isError"):
|
|
595
|
+
error_payload = f"{event.get('toolName') or 'tool'} failed"
|
|
596
|
+
if not had_output and result_text:
|
|
597
|
+
error_payload = f"{error_payload}: {result_text}"
|
|
598
|
+
rendered += self._complete("error", error_payload)
|
|
599
|
+
return rendered
|
|
600
|
+
|
|
601
|
+
if event_type == "auto_retry_start":
|
|
602
|
+
message = event.get("errorMessage") or event.get("finalError") or "retrying transient error"
|
|
603
|
+
return self._complete("error", f"retry attempt {event.get('attempt') or '?'}: {message}")
|
|
604
|
+
if event_type in {"auto_retry_end", "queue_update"}:
|
|
605
|
+
return ""
|
|
606
|
+
if event_type == "compaction_start":
|
|
607
|
+
return self._complete("action", "context compaction started")
|
|
608
|
+
if event_type == "compaction_end":
|
|
609
|
+
if event.get("errorMessage") or event.get("finalError"):
|
|
610
|
+
return self._complete("error", event.get("errorMessage") or event.get("finalError"))
|
|
611
|
+
return self._complete("observation", "context compaction completed")
|
|
612
|
+
|
|
613
|
+
if event_type == "agent_end":
|
|
614
|
+
messages = event.get("messages") if isinstance(event.get("messages"), list) else []
|
|
615
|
+
last_message = messages[-1] if messages and isinstance(messages[-1], dict) else {}
|
|
616
|
+
stop_reason = str(last_message.get("stopReason") or "")
|
|
617
|
+
will_retry = bool(event.get("willRetry"))
|
|
618
|
+
if will_retry:
|
|
619
|
+
return ""
|
|
620
|
+
status = "error" if stop_reason in self._ERROR_STOPS else "success"
|
|
621
|
+
details = f"status={status}"
|
|
622
|
+
if stop_reason:
|
|
623
|
+
details += f" stop_reason={stop_reason}"
|
|
624
|
+
return self._session_end(details)
|
|
625
|
+
|
|
626
|
+
if event_type in {"content_block_start", "content_block_delta", "content_block_stop"}:
|
|
627
|
+
return self._content_block_event(event)
|
|
628
|
+
if event_type == "message_start":
|
|
629
|
+
self._scope += 1
|
|
630
|
+
return ""
|
|
631
|
+
if event_type == "message_stop":
|
|
632
|
+
return self.renderer.finish()
|
|
633
|
+
|
|
634
|
+
if event_type == "assistant":
|
|
635
|
+
self._scope += 1
|
|
636
|
+
return self._message_parts(event.get("message"))
|
|
637
|
+
if event_type == "message" and isinstance(event.get("sessionId"), str):
|
|
638
|
+
if event.get("role") == "assistant":
|
|
639
|
+
self._scope += 1
|
|
640
|
+
return self._message_parts(event)
|
|
641
|
+
return ""
|
|
642
|
+
if event_type == "tool_result":
|
|
643
|
+
return self._complete("observation", self._value_text(event.get("content")))
|
|
644
|
+
if event_type == "user" and not isinstance(event.get("sessionId"), str):
|
|
645
|
+
return self._tool_result_parts(event.get("message"))
|
|
646
|
+
if event_type == "function_call":
|
|
647
|
+
self._remember_action_id(event.get("id") or event.get("call_id"))
|
|
648
|
+
return self._complete(
|
|
649
|
+
"action",
|
|
650
|
+
f"{event.get('name') or 'tool'} {self._value_text(event.get('arguments'))}",
|
|
651
|
+
)
|
|
652
|
+
if event_type == "function_call_result":
|
|
653
|
+
return self._complete("observation", self._value_text(event.get("output") or event.get("result")))
|
|
654
|
+
|
|
655
|
+
if event_type in {"item.started", "item.completed"}:
|
|
656
|
+
item = event.get("item") if isinstance(event.get("item"), dict) else {}
|
|
657
|
+
item_type = str(item.get("type") or "")
|
|
658
|
+
item_id = str(item.get("id") or item.get("call_id") or _stable_subagent_digest(item))
|
|
659
|
+
if item_type in {"reasoning", "analysis"} and event_type == "item.completed":
|
|
660
|
+
return self._complete("think", self._value_text(item.get("text") or item.get("summary") or ""))
|
|
661
|
+
if item_type == "agent_message" and event_type == "item.completed":
|
|
662
|
+
return self._complete("final", item.get("text") or "")
|
|
663
|
+
if item_type in {"command_execution", "mcp_tool_call", "collab_tool_call"}:
|
|
664
|
+
command = item.get("command") or item.get("tool") or item.get("name") or item_type
|
|
665
|
+
if event_type == "item.started":
|
|
666
|
+
self._remember_action_id(item_id)
|
|
667
|
+
return self._complete("action", self._value_text(command))
|
|
668
|
+
if item_id not in self._recent_action_id_set:
|
|
669
|
+
self._remember_action_id(item_id)
|
|
670
|
+
rendered += self._complete("action", self._value_text(command))
|
|
671
|
+
output = item.get("aggregated_output") or item.get("output") or item.get("result")
|
|
672
|
+
rendered += self._complete("observation", self._value_text(output))
|
|
673
|
+
if str(item.get("status") or "") in self._ERROR_STOPS:
|
|
674
|
+
rendered += self._complete("error", self._value_text(item.get("error") or "command failed"))
|
|
675
|
+
return rendered
|
|
676
|
+
if event_type == "item.completed" and item_type in {"file_change", "web_search"}:
|
|
677
|
+
return self._complete("action", self._value_text(item))
|
|
678
|
+
return ""
|
|
679
|
+
|
|
680
|
+
if event_type == "turn.completed":
|
|
681
|
+
return self._session_end("status=success")
|
|
682
|
+
if event_type == "turn.failed":
|
|
683
|
+
error_text = self._value_text(event.get("error") or event.get("message") or "turn failed")
|
|
684
|
+
return self._complete("error", error_text) + self._session_end("status=error")
|
|
685
|
+
if event_type == "result":
|
|
686
|
+
subtype = str(event.get("subtype") or event.get("result_subtype") or "")
|
|
687
|
+
normalized_subtype = subtype.lower()
|
|
688
|
+
is_error = bool(event.get("is_error")) or normalized_subtype in self._ERROR_STOPS or normalized_subtype.startswith(("error", "failed", "cancel"))
|
|
689
|
+
result_text = self._value_text(event.get("result") or event.get("message"))
|
|
690
|
+
if result_text:
|
|
691
|
+
rendered += self._complete(
|
|
692
|
+
"error" if is_error else "final",
|
|
693
|
+
result_text,
|
|
694
|
+
deduplicate=not is_error,
|
|
695
|
+
)
|
|
696
|
+
rendered += self._session_end(
|
|
697
|
+
f"status={'error' if is_error else 'success'}" + (f" stop_reason={event.get('stop_reason')}" if event.get("stop_reason") else ""),
|
|
698
|
+
)
|
|
699
|
+
return rendered
|
|
700
|
+
if event_type == "error" or event.get("error"):
|
|
701
|
+
return self._complete("error", self._value_text(event.get("error") or event.get("message") or "runtime error"))
|
|
702
|
+
return ""
|
|
703
|
+
|
|
704
|
+
def finish(self):
|
|
705
|
+
rendered = self.renderer.finish()
|
|
706
|
+
if not self._terminal_emitted:
|
|
707
|
+
rendered += self._session_end("status=stream_closed")
|
|
708
|
+
self._stream_buffers.clear()
|
|
709
|
+
self._snapshots.clear()
|
|
710
|
+
return rendered
|
|
711
|
+
|
|
712
|
+
|
|
201
713
|
class ProgressTracker:
|
|
202
714
|
"""Tracks progress state from stream-json events."""
|
|
203
715
|
|
|
@@ -254,6 +766,7 @@ class ProgressTracker:
|
|
|
254
766
|
self._in_tool_use = False
|
|
255
767
|
self._current_tool_input_parts = []
|
|
256
768
|
self._current_tool_id = ""
|
|
769
|
+
self._pi_retrying_terminal_error = False
|
|
257
770
|
|
|
258
771
|
def process_event(self, event):
|
|
259
772
|
"""Process a single stream-json event and update state.
|
|
@@ -294,6 +807,15 @@ class ProgressTracker:
|
|
|
294
807
|
self.event_format = "pi-json"
|
|
295
808
|
if event_type in {"agent_start", "turn_start", "message_start", "message_update", "tool_execution_start", "tool_execution_update", "compaction_start", "auto_retry_start"}:
|
|
296
809
|
self.is_active = True
|
|
810
|
+
if event_type == "agent_start" and self._pi_retrying_terminal_error:
|
|
811
|
+
self.result_subtype = ""
|
|
812
|
+
self.stop_reason = ""
|
|
813
|
+
self.last_result_is_error = False
|
|
814
|
+
self.api_error_status = None
|
|
815
|
+
self.api_error_code = ""
|
|
816
|
+
self.fatal_error_code = ""
|
|
817
|
+
self.terminal_result_text = ""
|
|
818
|
+
self.terminal_success_at = ""
|
|
297
819
|
if event_type == "turn_start":
|
|
298
820
|
self.message_count += 1
|
|
299
821
|
elif event_type in {"message_update", "message_end"}:
|
|
@@ -355,13 +877,42 @@ class ProgressTracker:
|
|
|
355
877
|
elif event_type == "agent_end":
|
|
356
878
|
self.current_tool = None
|
|
357
879
|
self.is_active = False
|
|
358
|
-
if (
|
|
880
|
+
messages = event.get("messages") if isinstance(event.get("messages"), list) else []
|
|
881
|
+
final_message = messages[-1] if messages and isinstance(messages[-1], dict) else {}
|
|
882
|
+
final_stop_reason = str(final_message.get("stopReason") or self.stop_reason or "")
|
|
883
|
+
will_retry = bool(event.get("willRetry"))
|
|
884
|
+
if will_retry:
|
|
885
|
+
self._pi_retrying_terminal_error = bool(
|
|
886
|
+
self.result_subtype == "error"
|
|
887
|
+
or self.last_result_is_error
|
|
888
|
+
or self.fatal_error_code
|
|
889
|
+
)
|
|
890
|
+
elif final_stop_reason not in {"error", "aborted", "failed", "cancelled", "canceled"} and (
|
|
359
891
|
self.result_subtype != "error"
|
|
360
|
-
|
|
361
|
-
and not self.fatal_error_code
|
|
892
|
+
or self._pi_retrying_terminal_error
|
|
362
893
|
):
|
|
363
894
|
self.result_subtype = "success"
|
|
895
|
+
self.stop_reason = final_stop_reason or self.stop_reason
|
|
896
|
+
self.last_result_is_error = False
|
|
897
|
+
if self._pi_retrying_terminal_error:
|
|
898
|
+
self.api_error_status = None
|
|
899
|
+
self.api_error_code = ""
|
|
900
|
+
self.fatal_error_code = ""
|
|
901
|
+
final_text = ""
|
|
902
|
+
content = final_message.get("content") if isinstance(final_message, dict) else None
|
|
903
|
+
if isinstance(content, str):
|
|
904
|
+
final_text = content
|
|
905
|
+
elif isinstance(content, list):
|
|
906
|
+
final_text = "\n".join(
|
|
907
|
+
str(item.get("text") or "")
|
|
908
|
+
for item in content
|
|
909
|
+
if isinstance(item, dict) and item.get("type") == "text"
|
|
910
|
+
)
|
|
911
|
+
if final_text.strip():
|
|
912
|
+
self.terminal_result_text = final_text[:1000]
|
|
913
|
+
self.last_text_snippet = final_text.strip()[-120:]
|
|
364
914
|
self.terminal_success_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
915
|
+
self._pi_retrying_terminal_error = False
|
|
365
916
|
return
|
|
366
917
|
|
|
367
918
|
# ── Codex exec --json JSONL format ──────────────────────────
|
|
@@ -412,12 +963,23 @@ class ProgressTracker:
|
|
|
412
963
|
|
|
413
964
|
elif event_type == "turn.completed":
|
|
414
965
|
self.current_tool = None
|
|
966
|
+
self.is_active = False
|
|
967
|
+
if not self.fatal_error_code:
|
|
968
|
+
self.result_subtype = "success"
|
|
969
|
+
self.stop_reason = "completed"
|
|
970
|
+
self.last_result_is_error = False
|
|
971
|
+
self.terminal_success_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
415
972
|
|
|
416
973
|
elif event_type == "turn.failed":
|
|
417
974
|
error = event.get("error") or event.get("message") or "Codex turn failed"
|
|
418
975
|
self.errors.append(str(error))
|
|
419
976
|
self._detect_terminal_error(str(error))
|
|
977
|
+
self.result_subtype = "error"
|
|
978
|
+
self.stop_reason = "failed"
|
|
979
|
+
self.last_result_is_error = True
|
|
980
|
+
self.terminal_result_text = str(error)[:1000]
|
|
420
981
|
self.current_tool = None
|
|
982
|
+
self.is_active = False
|
|
421
983
|
|
|
422
984
|
elif event_type == "error":
|
|
423
985
|
error = event.get("error") or event.get("message") or "Unknown error"
|