coze_lab 0.1.53 → 0.1.55
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/package.json +1 -1
- package/scripts/claude-code/cozeloop_hook.py +110 -87
- package/scripts/codex/cozeloop_hook.py +428 -135
package/package.json
CHANGED
|
@@ -230,6 +230,7 @@ def _make_finish_event_processor(upload_events: Optional[List[str]] = None):
|
|
|
230
230
|
def _processor(info):
|
|
231
231
|
try:
|
|
232
232
|
if not getattr(info, "is_event_fail", False):
|
|
233
|
+
hook_log("upload success")
|
|
233
234
|
return
|
|
234
235
|
detail = getattr(info, "detail_msg", "") or ""
|
|
235
236
|
if upload_events is not None:
|
|
@@ -393,6 +394,73 @@ def format_content(content: Any, truncate: int = 4096) -> str:
|
|
|
393
394
|
return str(content)[:truncate]
|
|
394
395
|
|
|
395
396
|
|
|
397
|
+
def _normalize_assistant_content(content: Any) -> Dict[str, Any]:
|
|
398
|
+
"""Split supported Claude content without retaining protected payloads."""
|
|
399
|
+
result = {
|
|
400
|
+
"text": [],
|
|
401
|
+
"reasoning": [],
|
|
402
|
+
"tool_calls": [],
|
|
403
|
+
"parts": [],
|
|
404
|
+
"protected": {"source": "", "count": 0, "bytes": 0},
|
|
405
|
+
"unknown_types": {},
|
|
406
|
+
}
|
|
407
|
+
if isinstance(content, str):
|
|
408
|
+
if content:
|
|
409
|
+
result["text"].append(content)
|
|
410
|
+
return result
|
|
411
|
+
if not isinstance(content, list):
|
|
412
|
+
return result
|
|
413
|
+
for item in content:
|
|
414
|
+
if not isinstance(item, dict):
|
|
415
|
+
continue
|
|
416
|
+
item_type = item.get("type", "")
|
|
417
|
+
if item_type == "text":
|
|
418
|
+
text = item.get("text", "")
|
|
419
|
+
if text:
|
|
420
|
+
result["text"].append(text)
|
|
421
|
+
result["parts"].append(
|
|
422
|
+
ModelMessagePart(type=ModelMessagePartType.TEXT, text=text)
|
|
423
|
+
)
|
|
424
|
+
elif item_type == "thinking":
|
|
425
|
+
thinking = item.get("thinking", "")
|
|
426
|
+
if thinking:
|
|
427
|
+
result["reasoning"].append(thinking)
|
|
428
|
+
elif item_type == "redacted_thinking":
|
|
429
|
+
data = item.get("data", "")
|
|
430
|
+
if isinstance(data, str) and data:
|
|
431
|
+
result["protected"]["source"] = "redacted_thinking"
|
|
432
|
+
result["protected"]["count"] += 1
|
|
433
|
+
result["protected"]["bytes"] += len(data.encode("utf-8"))
|
|
434
|
+
elif item_type == "tool_use":
|
|
435
|
+
result["tool_calls"].append(ModelToolCall(
|
|
436
|
+
id=item.get("id", ""),
|
|
437
|
+
type="function",
|
|
438
|
+
function=ModelToolCallFunction(
|
|
439
|
+
name=item.get("name", ""),
|
|
440
|
+
arguments=(
|
|
441
|
+
json.dumps(item.get("input", {}), ensure_ascii=False)
|
|
442
|
+
if isinstance(item.get("input"), dict)
|
|
443
|
+
else str(item.get("input", ""))
|
|
444
|
+
),
|
|
445
|
+
),
|
|
446
|
+
))
|
|
447
|
+
else:
|
|
448
|
+
key = item_type or "unknown"
|
|
449
|
+
result["unknown_types"][key] = result["unknown_types"].get(key, 0) + 1
|
|
450
|
+
return result
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _set_protected_reasoning_tags(span, normalized: Dict[str, Any]):
|
|
454
|
+
protected = normalized.get("protected") or {}
|
|
455
|
+
if not protected.get("count"):
|
|
456
|
+
return
|
|
457
|
+
span.set_tags({
|
|
458
|
+
"protected_reasoning_source": protected.get("source", ""),
|
|
459
|
+
"protected_reasoning_count": protected.get("count", 0),
|
|
460
|
+
"protected_reasoning_bytes": protected.get("bytes", 0),
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
|
|
396
464
|
# --- Message Parsing and Grouping ---
|
|
397
465
|
|
|
398
466
|
def is_tool_result_message(msg: Dict[str, Any]) -> bool:
|
|
@@ -1080,44 +1148,11 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1080
1148
|
tool_choice=ModelToolChoice(type="", function=None)
|
|
1081
1149
|
))
|
|
1082
1150
|
|
|
1083
|
-
|
|
1084
|
-
text_parts = []
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
if isinstance(raw_content, list):
|
|
1089
|
-
for item in raw_content:
|
|
1090
|
-
if not isinstance(item, dict):
|
|
1091
|
-
continue
|
|
1092
|
-
item_type = item.get("type", "")
|
|
1093
|
-
if item_type == "text":
|
|
1094
|
-
text = item.get("text", "")
|
|
1095
|
-
if text:
|
|
1096
|
-
text_parts.append(text)
|
|
1097
|
-
parts_list.append(ModelMessagePart(type=ModelMessagePartType.TEXT, text=text))
|
|
1098
|
-
elif item_type == "thinking":
|
|
1099
|
-
thinking = item.get("thinking", "")
|
|
1100
|
-
if thinking:
|
|
1101
|
-
thinking_parts.append(thinking)
|
|
1102
|
-
elif item_type == "redacted_thinking":
|
|
1103
|
-
pass # encrypted, cannot extract
|
|
1104
|
-
elif item_type == "tool_use":
|
|
1105
|
-
tool_call_list.append(ModelToolCall(
|
|
1106
|
-
id=item.get("id", ""),
|
|
1107
|
-
type="function",
|
|
1108
|
-
function=ModelToolCallFunction(
|
|
1109
|
-
name=item.get("name", ""),
|
|
1110
|
-
arguments=json.dumps(item.get("input", {}), ensure_ascii=False) if isinstance(item.get("input"), dict) else str(item.get("input", ""))
|
|
1111
|
-
)
|
|
1112
|
-
))
|
|
1113
|
-
else:
|
|
1114
|
-
parts_list.append(ModelMessagePart(
|
|
1115
|
-
type=ModelMessagePartType.TEXT,
|
|
1116
|
-
text=json.dumps(item, ensure_ascii=False)[:4096]
|
|
1117
|
-
))
|
|
1118
|
-
elif isinstance(raw_content, str) and raw_content:
|
|
1119
|
-
text_parts.append(raw_content)
|
|
1120
|
-
|
|
1151
|
+
normalized = _normalize_assistant_content(raw_content)
|
|
1152
|
+
text_parts = normalized["text"]
|
|
1153
|
+
thinking_parts = normalized["reasoning"]
|
|
1154
|
+
tool_call_list = normalized["tool_calls"]
|
|
1155
|
+
parts_list = normalized["parts"]
|
|
1121
1156
|
content_text = "\n".join(text_parts) if text_parts else ""
|
|
1122
1157
|
reasoning_text = "\n".join(thinking_parts) if thinking_parts else ""
|
|
1123
1158
|
finish_reason = "tool_calls" if tool_call_list else "stop"
|
|
@@ -1138,6 +1173,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1138
1173
|
)
|
|
1139
1174
|
|
|
1140
1175
|
model_span.set_output(ModelOutput(choices=[output_choice]))
|
|
1176
|
+
_set_protected_reasoning_tags(model_span, normalized)
|
|
1141
1177
|
|
|
1142
1178
|
# Set token usage for this specific model call
|
|
1143
1179
|
usage = assistant_message_obj.get("usage", {})
|
|
@@ -1243,42 +1279,11 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1243
1279
|
tool_choice=ModelToolChoice(type="", function=None)
|
|
1244
1280
|
))
|
|
1245
1281
|
|
|
1246
|
-
|
|
1247
|
-
sub_text_parts = []
|
|
1248
|
-
sub_tc_list = []
|
|
1249
|
-
sub_parts_list = []
|
|
1250
|
-
sub_thinking_parts = []
|
|
1251
|
-
if isinstance(sub_content, list):
|
|
1252
|
-
for item in sub_content:
|
|
1253
|
-
if not isinstance(item, dict):
|
|
1254
|
-
continue
|
|
1255
|
-
item_type = item.get("type", "")
|
|
1256
|
-
if item_type == "text":
|
|
1257
|
-
t = item.get("text", "")
|
|
1258
|
-
if t:
|
|
1259
|
-
sub_text_parts.append(t)
|
|
1260
|
-
sub_parts_list.append(ModelMessagePart(type=ModelMessagePartType.TEXT, text=t))
|
|
1261
|
-
elif item_type == "thinking":
|
|
1262
|
-
t = item.get("thinking", "")
|
|
1263
|
-
if t:
|
|
1264
|
-
sub_thinking_parts.append(t)
|
|
1265
|
-
elif item_type == "redacted_thinking":
|
|
1266
|
-
pass
|
|
1267
|
-
elif item_type == "tool_use":
|
|
1268
|
-
sub_tc_list.append(ModelToolCall(
|
|
1269
|
-
id=item.get("id", ""),
|
|
1270
|
-
type="function",
|
|
1271
|
-
function=ModelToolCallFunction(
|
|
1272
|
-
name=item.get("name", ""),
|
|
1273
|
-
arguments=json.dumps(item.get("input", {}), ensure_ascii=False) if isinstance(item.get("input"), dict) else str(item.get("input", ""))
|
|
1274
|
-
)
|
|
1275
|
-
))
|
|
1276
|
-
else:
|
|
1277
|
-
sub_parts_list.append(ModelMessagePart(
|
|
1278
|
-
type=ModelMessagePartType.TEXT,
|
|
1279
|
-
text=json.dumps(item, ensure_ascii=False)[:4096]
|
|
1280
|
-
))
|
|
1281
|
-
|
|
1282
|
+
sub_normalized = _normalize_assistant_content(sub_content)
|
|
1283
|
+
sub_text_parts = sub_normalized["text"]
|
|
1284
|
+
sub_tc_list = sub_normalized["tool_calls"]
|
|
1285
|
+
sub_parts_list = sub_normalized["parts"]
|
|
1286
|
+
sub_thinking_parts = sub_normalized["reasoning"]
|
|
1282
1287
|
sub_content_text = "\n".join(sub_text_parts) if sub_text_parts else ""
|
|
1283
1288
|
sub_reasoning_text = "\n".join(sub_thinking_parts) if sub_thinking_parts else ""
|
|
1284
1289
|
sub_finish = "tool_calls" if sub_tc_list else "stop"
|
|
@@ -1296,6 +1301,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1296
1301
|
metadata={}
|
|
1297
1302
|
)
|
|
1298
1303
|
)]))
|
|
1304
|
+
_set_protected_reasoning_tags(sub_model_span, sub_normalized)
|
|
1299
1305
|
|
|
1300
1306
|
# Distribute tokens evenly; remainder goes to last step
|
|
1301
1307
|
step_in = per_step_in + (remainder_in if sk == n_model_steps - 1 else 0)
|
|
@@ -1435,7 +1441,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1435
1441
|
def _rt_state_key_header():
|
|
1436
1442
|
return "rt_root_header"
|
|
1437
1443
|
|
|
1438
|
-
def _flatten_completed_steps(turns: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
1444
|
+
def _flatten_completed_steps(turns: List[Dict[str, Any]], is_terminal: bool = False) -> List[Dict[str, Any]]:
|
|
1439
1445
|
"""把 turns 线性展开成 step 列表;只取【已完成】的 step。
|
|
1440
1446
|
|
|
1441
1447
|
一个 step(assistant 一次模型调用 + 其触发的 tool_results)视为已完成的判定:
|
|
@@ -1454,7 +1460,7 @@ def _flatten_completed_steps(turns: List[Dict[str, Any]]) -> List[Dict[str, Any]
|
|
|
1454
1460
|
for idx, item in enumerate(flat):
|
|
1455
1461
|
is_global_last = (idx == len(flat) - 1)
|
|
1456
1462
|
has_results = bool(item["step"].get("tool_results"))
|
|
1457
|
-
if (not is_global_last) or has_results:
|
|
1463
|
+
if is_terminal or (not is_global_last) or has_results:
|
|
1458
1464
|
completed.append(item)
|
|
1459
1465
|
return completed, len(flat)
|
|
1460
1466
|
|
|
@@ -1546,7 +1552,7 @@ def send_steps_realtime(turns, session_id, history_turns, state, coze_tags_overr
|
|
|
1546
1552
|
debug_log(f"[rt] root created+finished trace_id={getattr(root_ctx,'trace_id','?')}")
|
|
1547
1553
|
|
|
1548
1554
|
# ---- 发新完成的 step ----
|
|
1549
|
-
completed, total_steps = _flatten_completed_steps(turns)
|
|
1555
|
+
completed, total_steps = _flatten_completed_steps(turns, is_terminal=is_terminal)
|
|
1550
1556
|
last_global = state.get("rt_last_global_step", 0)
|
|
1551
1557
|
new_items = completed[last_global:]
|
|
1552
1558
|
# 批量节流:非终态时,距上次上报不足 REALTIME_BATCH_INTERVAL 秒则本次不发,
|
|
@@ -1581,16 +1587,33 @@ def send_steps_realtime(turns, session_id, history_turns, state, coze_tags_overr
|
|
|
1581
1587
|
if step_start_dt is not None and step_end_dt is not None:
|
|
1582
1588
|
_set_finish_time_safe(mspan, step_end_dt)
|
|
1583
1589
|
raw_content = amo.get("content", [])
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1590
|
+
normalized = _normalize_assistant_content(raw_content)
|
|
1591
|
+
turn_user = item["turn"].get("user_message", {}).get("message", {})
|
|
1592
|
+
turn_user_content = turn_user.get("content") if turn_user else None
|
|
1593
|
+
input_messages = list(_build_history_messages(history_turns or []))
|
|
1594
|
+
if not is_empty_content(turn_user_content):
|
|
1595
|
+
input_messages.append(_make_message("user", format_content(turn_user_content)))
|
|
1596
|
+
mspan.set_input(ModelInput(
|
|
1597
|
+
messages=input_messages,
|
|
1598
|
+
tools=[],
|
|
1599
|
+
tool_choice=ModelToolChoice(type="", function=None),
|
|
1600
|
+
))
|
|
1601
|
+
output_choice = ModelChoice(
|
|
1602
|
+
finish_reason="tool_calls" if normalized["tool_calls"] else "stop",
|
|
1603
|
+
index=0,
|
|
1604
|
+
message=ModelMessage(
|
|
1605
|
+
role="assistant",
|
|
1606
|
+
content="\n".join(normalized["text"]),
|
|
1607
|
+
reasoning_content="\n".join(normalized["reasoning"]),
|
|
1608
|
+
parts=normalized["parts"],
|
|
1609
|
+
name="",
|
|
1610
|
+
tool_calls=normalized["tool_calls"],
|
|
1611
|
+
tool_call_id="",
|
|
1612
|
+
metadata={},
|
|
1613
|
+
),
|
|
1614
|
+
)
|
|
1615
|
+
mspan.set_output(ModelOutput(choices=[output_choice]))
|
|
1616
|
+
_set_protected_reasoning_tags(mspan, normalized)
|
|
1594
1617
|
usage = amo.get("usage", {})
|
|
1595
1618
|
it_tok = _usage_int(usage, "input_tokens") + _usage_int(usage, "cache_creation_input_tokens") + _usage_int(usage, "cache_read_input_tokens")
|
|
1596
1619
|
if it_tok > 0:
|
|
@@ -575,6 +575,48 @@ def extract_assistant_text(payload: Dict[str, Any]) -> str:
|
|
|
575
575
|
return "\n".join(parts)
|
|
576
576
|
|
|
577
577
|
|
|
578
|
+
def _protected_reasoning_meta(source: str, payload: Any) -> Dict[str, Any]:
|
|
579
|
+
"""Return safe metadata for protected reasoning without retaining payload."""
|
|
580
|
+
if not isinstance(payload, str) or not payload:
|
|
581
|
+
return {}
|
|
582
|
+
return {
|
|
583
|
+
"source": source,
|
|
584
|
+
"count": 1,
|
|
585
|
+
"bytes": len(payload.encode("utf-8")),
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def extract_reasoning_record(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
590
|
+
"""Normalize a Codex reasoning item, excluding encrypted content itself."""
|
|
591
|
+
parts = [
|
|
592
|
+
item.get("text", "")
|
|
593
|
+
for item in payload.get("summary", [])
|
|
594
|
+
if isinstance(item, dict)
|
|
595
|
+
and item.get("type") == "summary_text"
|
|
596
|
+
and item.get("text")
|
|
597
|
+
]
|
|
598
|
+
return {
|
|
599
|
+
"content": "",
|
|
600
|
+
"reasoning": "\n".join(parts),
|
|
601
|
+
"protected": _protected_reasoning_meta(
|
|
602
|
+
"encrypted_content", payload.get("encrypted_content")
|
|
603
|
+
),
|
|
604
|
+
"_ts": payload.get("_ts"),
|
|
605
|
+
"_line_number": payload.get("_line_number", -1),
|
|
606
|
+
"source": "response_item.reasoning",
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _json_text(value: Any) -> str:
|
|
611
|
+
"""Render structured tool values deterministically for trace display."""
|
|
612
|
+
if isinstance(value, str):
|
|
613
|
+
return value
|
|
614
|
+
try:
|
|
615
|
+
return json.dumps(value, ensure_ascii=False)
|
|
616
|
+
except (TypeError, ValueError):
|
|
617
|
+
return str(value)
|
|
618
|
+
|
|
619
|
+
|
|
578
620
|
def extract_message_content_text(payload: Dict[str, Any]) -> str:
|
|
579
621
|
"""Extract all text content from a message payload regardless of role."""
|
|
580
622
|
parts = []
|
|
@@ -669,6 +711,10 @@ def _turn_timestamps(turn):
|
|
|
669
711
|
dt = _parse_ts(item)
|
|
670
712
|
if dt:
|
|
671
713
|
values.append(dt)
|
|
714
|
+
for item in turn.get("model_records", []):
|
|
715
|
+
dt = _parse_ts(item)
|
|
716
|
+
if dt:
|
|
717
|
+
values.append(dt)
|
|
672
718
|
for sc in turn.get("subagent_calls", []):
|
|
673
719
|
for key in ("_start_ts", "_end_ts"):
|
|
674
720
|
dt = _parse_ts_value(sc.get(key))
|
|
@@ -695,13 +741,45 @@ def _assistant_bounds(turn):
|
|
|
695
741
|
return min(values), max(values)
|
|
696
742
|
|
|
697
743
|
|
|
698
|
-
def
|
|
699
|
-
|
|
744
|
+
def extract_model_invocations(turns: List[Dict[str, Any]],
|
|
745
|
+
entries: List[Dict[str, Any]],
|
|
746
|
+
is_terminal: bool = False) -> List[Dict[str, Any]]:
|
|
747
|
+
"""Group visible model output, tool calls, and usage into model invocations.
|
|
700
748
|
|
|
701
|
-
|
|
702
|
-
|
|
749
|
+
Codex emits one non-empty ``token_count.last_token_usage`` after each model
|
|
750
|
+
invocation. It is the only reliable boundary shared by tool and no-tool
|
|
751
|
+
calls, so records since the previous boundary are reported together. The
|
|
752
|
+
cumulative ``total_token_usage`` is retained as metadata only.
|
|
703
753
|
"""
|
|
704
754
|
events: List[Dict[str, Any]] = []
|
|
755
|
+
tool_results: Dict[str, Dict[str, Any]] = {}
|
|
756
|
+
|
|
757
|
+
for turn in turns:
|
|
758
|
+
for result in turn.get("tool_results", []):
|
|
759
|
+
call_id = result.get("call_id")
|
|
760
|
+
if call_id:
|
|
761
|
+
tool_results[call_id] = result
|
|
762
|
+
for record in turn.get("model_records", []):
|
|
763
|
+
events.append({
|
|
764
|
+
"kind": "record",
|
|
765
|
+
"line": record.get("_line_number", -1),
|
|
766
|
+
"timestamp": record.get("_ts"),
|
|
767
|
+
"record": record,
|
|
768
|
+
"turn": turn,
|
|
769
|
+
})
|
|
770
|
+
for tool_call in turn.get("tool_calls", []):
|
|
771
|
+
events.append({
|
|
772
|
+
"kind": "tool",
|
|
773
|
+
"line": tool_call.get("_line_number", -1),
|
|
774
|
+
"timestamp": tool_call.get("_ts"),
|
|
775
|
+
"tool_call": tool_call,
|
|
776
|
+
"turn": turn,
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
turn_starts = sorted(
|
|
780
|
+
((turn.get("start_line", -1), turn) for turn in turns),
|
|
781
|
+
key=lambda item: item[0],
|
|
782
|
+
)
|
|
705
783
|
for entry in entries:
|
|
706
784
|
if entry.get("type") != "event_msg":
|
|
707
785
|
continue
|
|
@@ -712,13 +790,114 @@ def extract_token_events(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
712
790
|
last_usage = info.get("last_token_usage")
|
|
713
791
|
if not isinstance(last_usage, dict) or not last_usage:
|
|
714
792
|
continue
|
|
793
|
+
line = entry.get("_line_number", 0)
|
|
794
|
+
boundary_turn = None
|
|
795
|
+
for start_line, turn in turn_starts:
|
|
796
|
+
if start_line > line:
|
|
797
|
+
break
|
|
798
|
+
boundary_turn = turn
|
|
715
799
|
events.append({
|
|
716
|
-
"
|
|
800
|
+
"kind": "usage",
|
|
801
|
+
"line": line,
|
|
717
802
|
"timestamp": entry.get("timestamp"),
|
|
718
803
|
"usage": last_usage,
|
|
719
804
|
"total_usage": info.get("total_token_usage") or {},
|
|
805
|
+
"turn": boundary_turn,
|
|
720
806
|
})
|
|
721
|
-
|
|
807
|
+
|
|
808
|
+
events.sort(key=lambda item: (item.get("line", -1),
|
|
809
|
+
{"record": 0, "tool": 1, "usage": 2}.get(item.get("kind"), 3)))
|
|
810
|
+
invocations: List[Dict[str, Any]] = []
|
|
811
|
+
current: Dict[str, Any] = {
|
|
812
|
+
"content_parts": [],
|
|
813
|
+
"reasoning_parts": [],
|
|
814
|
+
"protected": {"source": "", "count": 0, "bytes": 0},
|
|
815
|
+
"tool_calls": [],
|
|
816
|
+
"turn": None,
|
|
817
|
+
"start_timestamp": None,
|
|
818
|
+
"end_timestamp": None,
|
|
819
|
+
"line": -1,
|
|
820
|
+
"usage": {},
|
|
821
|
+
"total_usage": {},
|
|
822
|
+
}
|
|
823
|
+
seen = {"content": set(), "reasoning": set()}
|
|
824
|
+
|
|
825
|
+
def append_text(category: str, value: Any):
|
|
826
|
+
if not isinstance(value, str):
|
|
827
|
+
return
|
|
828
|
+
text = value.strip()
|
|
829
|
+
if not text or text in seen[category]:
|
|
830
|
+
return
|
|
831
|
+
seen[category].add(text)
|
|
832
|
+
current[f"{category}_parts"].append(text)
|
|
833
|
+
|
|
834
|
+
def finish_current(boundary_line: int, boundary_ts: Optional[str],
|
|
835
|
+
usage: Optional[Dict[str, Any]] = None,
|
|
836
|
+
total_usage: Optional[Dict[str, Any]] = None):
|
|
837
|
+
has_payload = (
|
|
838
|
+
current["content_parts"] or current["reasoning_parts"]
|
|
839
|
+
or current["tool_calls"] or current["protected"].get("count")
|
|
840
|
+
or usage
|
|
841
|
+
)
|
|
842
|
+
if not has_payload:
|
|
843
|
+
return
|
|
844
|
+
current["line"] = boundary_line
|
|
845
|
+
current["end_timestamp"] = boundary_ts or current["end_timestamp"]
|
|
846
|
+
current["usage"] = usage or {}
|
|
847
|
+
current["total_usage"] = total_usage or {}
|
|
848
|
+
current["content"] = "\n".join(current.pop("content_parts"))
|
|
849
|
+
current["reasoning"] = "\n".join(current.pop("reasoning_parts"))
|
|
850
|
+
for tool_call in current["tool_calls"]:
|
|
851
|
+
tool_call["result"] = tool_results.get(tool_call.get("call_id"))
|
|
852
|
+
invocations.append(dict(current))
|
|
853
|
+
|
|
854
|
+
for event in events:
|
|
855
|
+
kind = event["kind"]
|
|
856
|
+
if kind == "usage":
|
|
857
|
+
if current["turn"] is None:
|
|
858
|
+
current["turn"] = event.get("turn")
|
|
859
|
+
if current["start_timestamp"] is None:
|
|
860
|
+
current["start_timestamp"] = event.get("timestamp")
|
|
861
|
+
finish_current(event["line"], event.get("timestamp"),
|
|
862
|
+
event.get("usage"), event.get("total_usage"))
|
|
863
|
+
current = {
|
|
864
|
+
"content_parts": [],
|
|
865
|
+
"reasoning_parts": [],
|
|
866
|
+
"protected": {"source": "", "count": 0, "bytes": 0},
|
|
867
|
+
"tool_calls": [],
|
|
868
|
+
"turn": None,
|
|
869
|
+
"start_timestamp": None,
|
|
870
|
+
"end_timestamp": None,
|
|
871
|
+
"line": -1,
|
|
872
|
+
"usage": {},
|
|
873
|
+
"total_usage": {},
|
|
874
|
+
}
|
|
875
|
+
seen = {"content": set(), "reasoning": set()}
|
|
876
|
+
continue
|
|
877
|
+
|
|
878
|
+
current["turn"] = current["turn"] or event.get("turn")
|
|
879
|
+
current["start_timestamp"] = current["start_timestamp"] or event.get("timestamp")
|
|
880
|
+
current["end_timestamp"] = event.get("timestamp") or current["end_timestamp"]
|
|
881
|
+
current["line"] = max(current["line"], event.get("line", -1))
|
|
882
|
+
if kind == "record":
|
|
883
|
+
record = event["record"]
|
|
884
|
+
append_text("content", record.get("content"))
|
|
885
|
+
append_text("reasoning", record.get("reasoning"))
|
|
886
|
+
protected = record.get("protected") or {}
|
|
887
|
+
if protected.get("count"):
|
|
888
|
+
current["protected"]["source"] = protected.get("source", "")
|
|
889
|
+
current["protected"]["count"] += protected.get("count", 0)
|
|
890
|
+
current["protected"]["bytes"] += protected.get("bytes", 0)
|
|
891
|
+
elif kind == "tool":
|
|
892
|
+
current["tool_calls"].append(dict(event["tool_call"]))
|
|
893
|
+
|
|
894
|
+
if is_terminal and (
|
|
895
|
+
current["content_parts"] or current["reasoning_parts"]
|
|
896
|
+
or current["tool_calls"] or current["protected"].get("count")
|
|
897
|
+
):
|
|
898
|
+
finish_current(current["line"], current["end_timestamp"])
|
|
899
|
+
|
|
900
|
+
return invocations
|
|
722
901
|
|
|
723
902
|
|
|
724
903
|
def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
@@ -758,6 +937,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
758
937
|
"user_message": None,
|
|
759
938
|
"user_message_text": "",
|
|
760
939
|
"assistant_messages": [],
|
|
940
|
+
"model_records": [],
|
|
761
941
|
"tool_calls": [],
|
|
762
942
|
"tool_results": [],
|
|
763
943
|
"input_messages": [],
|
|
@@ -775,6 +955,17 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
775
955
|
if current_turn is not None:
|
|
776
956
|
info = payload.get("info") or {}
|
|
777
957
|
current_turn["token_usage"] = info.get("last_token_usage", {})
|
|
958
|
+
elif msg_type in ("agent_reasoning", "agent_message") and current_turn is not None:
|
|
959
|
+
text = payload.get("text") if msg_type == "agent_reasoning" else payload.get("message")
|
|
960
|
+
if isinstance(text, str) and text:
|
|
961
|
+
current_turn["model_records"].append({
|
|
962
|
+
"content": text if msg_type == "agent_message" else "",
|
|
963
|
+
"reasoning": text if msg_type == "agent_reasoning" else "",
|
|
964
|
+
"protected": {},
|
|
965
|
+
"_ts": payload.get("_ts"),
|
|
966
|
+
"_line_number": entry.get("_line_number", -1),
|
|
967
|
+
"source": f"event_msg.{msg_type}",
|
|
968
|
+
})
|
|
778
969
|
continue
|
|
779
970
|
|
|
780
971
|
# --- Content items ---
|
|
@@ -795,6 +986,14 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
795
986
|
elif role == "assistant":
|
|
796
987
|
if current_turn is not None:
|
|
797
988
|
current_turn["assistant_messages"].append(payload)
|
|
989
|
+
current_turn["model_records"].append({
|
|
990
|
+
"content": extract_assistant_text(payload),
|
|
991
|
+
"reasoning": "",
|
|
992
|
+
"protected": {},
|
|
993
|
+
"_ts": payload.get("_ts"),
|
|
994
|
+
"_line_number": entry.get("_line_number", -1),
|
|
995
|
+
"source": "response_item.message",
|
|
996
|
+
})
|
|
798
997
|
elif role in ("developer", "system"):
|
|
799
998
|
if current_turn is not None:
|
|
800
999
|
current_turn["input_messages"].append({
|
|
@@ -810,16 +1009,25 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
810
1009
|
"content": text,
|
|
811
1010
|
})
|
|
812
1011
|
|
|
813
|
-
elif item_type == "
|
|
1012
|
+
elif item_type == "reasoning":
|
|
1013
|
+
if current_turn is not None:
|
|
1014
|
+
record = extract_reasoning_record(payload)
|
|
1015
|
+
record["_line_number"] = entry.get("_line_number", -1)
|
|
1016
|
+
current_turn["model_records"].append(record)
|
|
1017
|
+
|
|
1018
|
+
elif item_type in ("function_call", "custom_tool_call"):
|
|
814
1019
|
if current_turn is None:
|
|
815
1020
|
continue
|
|
816
1021
|
call_id = payload.get("call_id")
|
|
817
1022
|
name = payload.get("name", "")
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1023
|
+
if item_type == "custom_tool_call":
|
|
1024
|
+
args = payload.get("input", "")
|
|
1025
|
+
else:
|
|
1026
|
+
args_raw = payload.get("arguments", "{}")
|
|
1027
|
+
try:
|
|
1028
|
+
args = json.loads(args_raw)
|
|
1029
|
+
except (json.JSONDecodeError, TypeError):
|
|
1030
|
+
args = {"_raw": args_raw}
|
|
823
1031
|
|
|
824
1032
|
if name == "spawn_agent":
|
|
825
1033
|
subagent_call = {
|
|
@@ -846,11 +1054,13 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
846
1054
|
"call_id": call_id,
|
|
847
1055
|
"name": name,
|
|
848
1056
|
"input": args,
|
|
1057
|
+
"custom": item_type == "custom_tool_call",
|
|
849
1058
|
"_ts": payload.get("_ts"),
|
|
1059
|
+
"_line_number": entry.get("_line_number", -1),
|
|
850
1060
|
})
|
|
851
1061
|
pending_calls[call_id] = {"kind": "tool", "_start_ts": payload.get("_ts")}
|
|
852
1062
|
|
|
853
|
-
elif item_type
|
|
1063
|
+
elif item_type in ("function_call_output", "custom_tool_call_output"):
|
|
854
1064
|
if current_turn is None:
|
|
855
1065
|
continue
|
|
856
1066
|
call_id = payload.get("call_id")
|
|
@@ -896,10 +1106,35 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
896
1106
|
if current_turn is not None:
|
|
897
1107
|
turns.append(current_turn)
|
|
898
1108
|
|
|
1109
|
+
# Codex may duplicate visible commentary/reasoning in response_item and
|
|
1110
|
+
# event_msg. Prefer response_item, then retain event-only fallbacks.
|
|
1111
|
+
for turn in turns:
|
|
1112
|
+
seen = set()
|
|
1113
|
+
deduped = []
|
|
1114
|
+
records = sorted(
|
|
1115
|
+
turn.get("model_records", []),
|
|
1116
|
+
key=lambda record: (
|
|
1117
|
+
1 if str(record.get("source", "")).startswith("event_msg.") else 0,
|
|
1118
|
+
record.get("_line_number", -1),
|
|
1119
|
+
),
|
|
1120
|
+
)
|
|
1121
|
+
for record in records:
|
|
1122
|
+
category = "reasoning" if record.get("reasoning") else "content"
|
|
1123
|
+
text = record.get(category, "").strip()
|
|
1124
|
+
key = (category, text)
|
|
1125
|
+
if text and key in seen:
|
|
1126
|
+
continue
|
|
1127
|
+
if text:
|
|
1128
|
+
seen.add(key)
|
|
1129
|
+
deduped.append(record)
|
|
1130
|
+
turn["model_records"] = sorted(
|
|
1131
|
+
deduped, key=lambda record: record.get("_line_number", -1)
|
|
1132
|
+
)
|
|
1133
|
+
|
|
899
1134
|
# Drop turns with no user input and no assistant response
|
|
900
1135
|
turns = [
|
|
901
1136
|
t for t in turns
|
|
902
|
-
if t["user_message"] is not None or t["assistant_messages"]
|
|
1137
|
+
if t["user_message"] is not None or t["assistant_messages"] or t.get("model_records")
|
|
903
1138
|
]
|
|
904
1139
|
|
|
905
1140
|
return turns
|
|
@@ -1380,14 +1615,15 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1380
1615
|
return ctx
|
|
1381
1616
|
|
|
1382
1617
|
|
|
1383
|
-
# --- 实时
|
|
1618
|
+
# --- 实时 model invocation 上报 -------------------------------------------
|
|
1384
1619
|
#
|
|
1385
1620
|
# 与 claude-code 同架构:一个会话 = 一条稳定 trace。首次建 root(codex_request)立即 finish 并存
|
|
1386
|
-
# to_header() 到 state["rt_root_header"]
|
|
1387
|
-
#
|
|
1388
|
-
#
|
|
1621
|
+
# to_header() 到 state["rt_root_header"];之后按 token_count 边界把模型可见内容、推理、工具调用和
|
|
1622
|
+
# last_token_usage 合成 model_call span,工具执行作为其子 span。全局行号水位线
|
|
1623
|
+
# state["rt_last_invocation_line"] 保证重复 hook 不重发。
|
|
1389
1624
|
|
|
1390
|
-
def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_override=None,
|
|
1625
|
+
def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_override=None,
|
|
1626
|
+
is_terminal=False, model_invocations=None):
|
|
1391
1627
|
import cozeloop
|
|
1392
1628
|
from cozeloop.spec.tracespec import Runtime
|
|
1393
1629
|
token = get_fresh_token()
|
|
@@ -1439,7 +1675,8 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
|
|
|
1439
1675
|
if turn.get("user_message_text"):
|
|
1440
1676
|
first_user_text = turn["user_message_text"]
|
|
1441
1677
|
break
|
|
1442
|
-
|
|
1678
|
+
all_timestamps = [dt for turn in turns for dt in _turn_timestamps(turn)]
|
|
1679
|
+
root_start_dt = min(all_timestamps) if all_timestamps else None
|
|
1443
1680
|
root_span = client.start_span(name="codex_request", span_type="main",
|
|
1444
1681
|
start_time=root_start_dt, start_new_trace=True)
|
|
1445
1682
|
root_span.set_runtime(Runtime(library="codex-cli"))
|
|
@@ -1460,120 +1697,162 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
|
|
|
1460
1697
|
return None
|
|
1461
1698
|
hook_log(f"[rt] root created trace_id={getattr(root_ctx,'trace_id','?')}")
|
|
1462
1699
|
|
|
1463
|
-
#
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
is_last = (idx == len(flat) - 1)
|
|
1474
|
-
if item["result"] is not None or (not is_last):
|
|
1475
|
-
completed.append(item)
|
|
1476
|
-
last_tool = state.get("rt_last_tool", 0)
|
|
1477
|
-
new_items = completed[last_tool:]
|
|
1478
|
-
# 批量节流:非终态时距上次上报<5s 则本次不发,攒到下次一起发。终态不节流。
|
|
1479
|
-
if new_items and not is_terminal:
|
|
1700
|
+
# One atomic model span per Codex invocation. Upgrade existing sessions
|
|
1701
|
+
# without replaying events already emitted by the pre-0.1.55 watermarks.
|
|
1702
|
+
legacy_line = max(state.get("rt_last_model_line", -1),
|
|
1703
|
+
state.get("rt_last_token_line", -1))
|
|
1704
|
+
last_invocation_line = state.get("rt_last_invocation_line", legacy_line)
|
|
1705
|
+
pending_invocations = [
|
|
1706
|
+
invocation for invocation in (model_invocations or [])
|
|
1707
|
+
if invocation.get("line", -1) > last_invocation_line
|
|
1708
|
+
]
|
|
1709
|
+
if pending_invocations and not is_terminal:
|
|
1480
1710
|
since = time.time() - state.get("rt_last_upload_ts", 0)
|
|
1481
1711
|
if since < REALTIME_BATCH_INTERVAL:
|
|
1482
|
-
hook_log(
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1712
|
+
hook_log(
|
|
1713
|
+
f"[rt] batch throttle {since:.1f}s<{REALTIME_BATCH_INTERVAL}s "
|
|
1714
|
+
f"defer {len(pending_invocations)} invocation(s)"
|
|
1715
|
+
)
|
|
1716
|
+
pending_invocations = []
|
|
1717
|
+
|
|
1718
|
+
invocation_sent = 0
|
|
1719
|
+
tool_sent = 0
|
|
1720
|
+
model_call_base = state.get(
|
|
1721
|
+
"rt_model_call_count", state.get("rt_last_tool", 0)
|
|
1722
|
+
)
|
|
1723
|
+
for invocation in pending_invocations:
|
|
1724
|
+
line = invocation.get("line", -1)
|
|
1725
|
+
turn = invocation.get("turn") or {}
|
|
1726
|
+
tool_calls = invocation.get("tool_calls") or []
|
|
1727
|
+
model_tool_calls = []
|
|
1728
|
+
for tool_call in tool_calls:
|
|
1729
|
+
model_tool_calls.append(ModelToolCall(
|
|
1730
|
+
id=tool_call.get("call_id", ""),
|
|
1731
|
+
type="function",
|
|
1732
|
+
function=ModelToolCallFunction(
|
|
1733
|
+
name=tool_call.get("name", ""),
|
|
1734
|
+
arguments=_json_text(tool_call.get("input", {})),
|
|
1735
|
+
),
|
|
1736
|
+
))
|
|
1737
|
+
|
|
1738
|
+
start_dt = _parse_ts_value(invocation.get("start_timestamp"))
|
|
1739
|
+
end_dt = _parse_ts_value(invocation.get("end_timestamp")) or start_dt
|
|
1740
|
+
mspan = client.start_span(
|
|
1741
|
+
name=f"model_call_{invocation_sent + model_call_base}",
|
|
1742
|
+
span_type="model", start_time=start_dt, child_of=root_ctx,
|
|
1743
|
+
)
|
|
1494
1744
|
mspan.set_runtime(Runtime(library="codex-cli"))
|
|
1495
|
-
try:
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
for ev in pending_tokens:
|
|
1533
|
-
line = ev["line"]
|
|
1534
|
-
last = ev.get("usage") or {}
|
|
1535
|
-
total = ev.get("total_usage") or {}
|
|
1536
|
-
in_tk = _usage_int(last, "input_tokens")
|
|
1537
|
-
out_tk = _usage_int(last, "output_tokens")
|
|
1538
|
-
start_dt = _parse_ts_value(ev.get("timestamp"))
|
|
1539
|
-
tk_span = client.start_span(name=f"model_usage_{line}", span_type="model",
|
|
1540
|
-
start_time=start_dt, child_of=root_ctx)
|
|
1541
|
-
tk_span.set_runtime(Runtime(library="codex-cli"))
|
|
1542
|
-
try: tk_span.set_model_name(model_name)
|
|
1543
|
-
except Exception: pass
|
|
1544
|
-
if start_dt is not None:
|
|
1545
|
-
_set_finish_time_safe(tk_span, start_dt)
|
|
1745
|
+
try:
|
|
1746
|
+
mspan.set_model_name(model_name)
|
|
1747
|
+
except Exception:
|
|
1748
|
+
pass
|
|
1749
|
+
if start_dt is not None and end_dt is not None:
|
|
1750
|
+
_set_finish_time_safe(mspan, _max_dt(start_dt, end_dt))
|
|
1751
|
+
|
|
1752
|
+
input_messages = []
|
|
1753
|
+
for message in turn.get("input_messages", []):
|
|
1754
|
+
input_messages.append(_make_model_message(
|
|
1755
|
+
message.get("role", "user"), message.get("content", "")
|
|
1756
|
+
))
|
|
1757
|
+
if not input_messages and turn.get("user_message_text"):
|
|
1758
|
+
input_messages.append(_make_model_message("user", turn["user_message_text"]))
|
|
1759
|
+
mspan.set_input(ModelInput(
|
|
1760
|
+
messages=input_messages,
|
|
1761
|
+
tools=[],
|
|
1762
|
+
tool_choice=ModelToolChoice(type="", function=None),
|
|
1763
|
+
))
|
|
1764
|
+
mspan.set_output(ModelOutput(choices=[ModelChoice(
|
|
1765
|
+
finish_reason="tool_calls" if model_tool_calls else "stop",
|
|
1766
|
+
index=0,
|
|
1767
|
+
message=ModelMessage(
|
|
1768
|
+
role="assistant",
|
|
1769
|
+
content=invocation.get("content", ""),
|
|
1770
|
+
reasoning_content=invocation.get("reasoning", ""),
|
|
1771
|
+
parts=[],
|
|
1772
|
+
name="",
|
|
1773
|
+
tool_calls=model_tool_calls,
|
|
1774
|
+
tool_call_id="",
|
|
1775
|
+
metadata={},
|
|
1776
|
+
),
|
|
1777
|
+
)]))
|
|
1778
|
+
|
|
1779
|
+
usage = invocation.get("usage") or {}
|
|
1780
|
+
in_tk = _usage_int(usage, "input_tokens")
|
|
1781
|
+
out_tk = _usage_int(usage, "output_tokens")
|
|
1546
1782
|
if in_tk > 0:
|
|
1547
|
-
try:
|
|
1548
|
-
|
|
1783
|
+
try:
|
|
1784
|
+
mspan.set_input_tokens(in_tk)
|
|
1785
|
+
except Exception:
|
|
1786
|
+
pass
|
|
1549
1787
|
if out_tk > 0:
|
|
1550
|
-
try:
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
"
|
|
1557
|
-
"
|
|
1788
|
+
try:
|
|
1789
|
+
mspan.set_output_tokens(out_tk)
|
|
1790
|
+
except Exception:
|
|
1791
|
+
pass
|
|
1792
|
+
|
|
1793
|
+
tags = {
|
|
1794
|
+
"invocation_boundary_line": line,
|
|
1795
|
+
"token_source": "codex_token_count" if usage else "unavailable",
|
|
1796
|
+
"cached_input_tokens": _usage_int(usage, "cached_input_tokens"),
|
|
1797
|
+
"reasoning_output_tokens": _usage_int(usage, "reasoning_output_tokens"),
|
|
1798
|
+
"total_tokens": _usage_int(usage, "total_tokens"),
|
|
1558
1799
|
}
|
|
1800
|
+
total = invocation.get("total_usage") or {}
|
|
1559
1801
|
if total:
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1802
|
+
tags.update({
|
|
1803
|
+
"total_input_tokens": _usage_int(total, "input_tokens"),
|
|
1804
|
+
"total_output_tokens": _usage_int(total, "output_tokens"),
|
|
1805
|
+
"total_cached_input_tokens": _usage_int(total, "cached_input_tokens"),
|
|
1806
|
+
"total_reasoning_output_tokens": _usage_int(total, "reasoning_output_tokens"),
|
|
1807
|
+
"total_all_tokens": _usage_int(total, "total_tokens"),
|
|
1808
|
+
})
|
|
1809
|
+
protected = invocation.get("protected") or {}
|
|
1810
|
+
if protected.get("count"):
|
|
1811
|
+
tags.update({
|
|
1812
|
+
"protected_reasoning_source": protected.get("source", ""),
|
|
1813
|
+
"protected_reasoning_count": protected.get("count", 0),
|
|
1814
|
+
"protected_reasoning_bytes": protected.get("bytes", 0),
|
|
1815
|
+
})
|
|
1816
|
+
mspan.set_tags(tags)
|
|
1817
|
+
mspan_ctx = client.get_span_from_header(mspan.to_header())
|
|
1818
|
+
mspan.finish()
|
|
1819
|
+
|
|
1820
|
+
for tool_call in tool_calls:
|
|
1821
|
+
result = tool_call.get("result")
|
|
1822
|
+
if result is None:
|
|
1823
|
+
continue
|
|
1824
|
+
tool_start_dt = _parse_ts_value(tool_call.get("_ts")) or start_dt
|
|
1825
|
+
tool_end_dt = _parse_ts_value(result.get("_ts")) or tool_start_dt
|
|
1826
|
+
tname = tool_call.get("name", "unknown")
|
|
1827
|
+
tspan = client.start_span(
|
|
1828
|
+
name=f"tool_{tname}", span_type="tool",
|
|
1829
|
+
start_time=tool_start_dt, child_of=mspan_ctx,
|
|
1830
|
+
)
|
|
1831
|
+
tspan.set_runtime(Runtime(library="codex-cli"))
|
|
1832
|
+
if tool_end_dt is not None:
|
|
1833
|
+
_set_finish_time_safe(tspan, _max_dt(tool_start_dt, tool_end_dt))
|
|
1834
|
+
tspan.set_tags({
|
|
1835
|
+
"tool_name": tname,
|
|
1836
|
+
"tool_call_id": tool_call.get("call_id"),
|
|
1837
|
+
"model_invocation_line": line,
|
|
1838
|
+
})
|
|
1839
|
+
tspan.set_input(truncate_text(_json_text(tool_call.get("input", {}))))
|
|
1840
|
+
tspan.set_output(truncate_text(_json_text(result.get("output", ""))))
|
|
1841
|
+
tspan.finish()
|
|
1842
|
+
tool_sent += 1
|
|
1843
|
+
|
|
1567
1844
|
client.flush()
|
|
1568
1845
|
if upload_events:
|
|
1569
|
-
hook_log(
|
|
1570
|
-
|
|
1571
|
-
|
|
1846
|
+
hook_log(
|
|
1847
|
+
f"[rt] upload failed after invocation flush line={line} "
|
|
1848
|
+
f"detail={upload_events[-1][:500]}"
|
|
1849
|
+
)
|
|
1572
1850
|
return None
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1851
|
+
state["rt_last_invocation_line"] = line
|
|
1852
|
+
invocation_sent += 1
|
|
1853
|
+
|
|
1854
|
+
if invocation_sent > 0:
|
|
1855
|
+
state["rt_model_call_count"] = model_call_base + invocation_sent
|
|
1577
1856
|
state["rt_last_upload_ts"] = time.time()
|
|
1578
1857
|
|
|
1579
1858
|
if is_terminal:
|
|
@@ -1595,13 +1874,17 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
|
|
|
1595
1874
|
if upload_events:
|
|
1596
1875
|
hook_log(f"[rt] upload failed after final flush detail={upload_events[-1][:500]}")
|
|
1597
1876
|
return None
|
|
1598
|
-
hook_log(f"[rt] finalized
|
|
1877
|
+
hook_log(f"[rt] finalized total_model_calls={state.get('rt_model_call_count', 0)}")
|
|
1599
1878
|
|
|
1600
1879
|
if upload_events:
|
|
1601
1880
|
hook_log(f"[rt] upload failed state not advanced detail={upload_events[-1][:500]}")
|
|
1602
1881
|
return None
|
|
1603
1882
|
|
|
1604
|
-
hook_log(
|
|
1883
|
+
hook_log(
|
|
1884
|
+
f"[rt] sent {invocation_sent} model invocation(s), {tool_sent} tool span(s), "
|
|
1885
|
+
f"last_invocation_line={state.get('rt_last_invocation_line', -1)}, "
|
|
1886
|
+
f"terminal={is_terminal}"
|
|
1887
|
+
)
|
|
1605
1888
|
return True
|
|
1606
1889
|
except Exception as e:
|
|
1607
1890
|
hook_log(f"[rt] error: {e}")
|
|
@@ -1676,8 +1959,7 @@ def main():
|
|
|
1676
1959
|
state_file = get_state_file_path(transcript_path)
|
|
1677
1960
|
state = load_state(state_file)
|
|
1678
1961
|
|
|
1679
|
-
#
|
|
1680
|
-
# state["rt_last_tool"] 增量,每次触发把新完成的 tool_call 发出,结束即可见,不节流。
|
|
1962
|
+
# 实时路径读取全量 entries 重建 invocation 序列,再用 token 边界行号水位线增量上报。
|
|
1681
1963
|
entries = read_rollout_messages(transcript_path, 0)
|
|
1682
1964
|
|
|
1683
1965
|
if not entries:
|
|
@@ -1719,9 +2001,13 @@ def main():
|
|
|
1719
2001
|
|
|
1720
2002
|
# Group entries into turns
|
|
1721
2003
|
turns = group_messages_into_turns(entries)
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
2004
|
+
model_invocations = extract_model_invocations(
|
|
2005
|
+
turns, entries, is_terminal=is_terminal_event
|
|
2006
|
+
)
|
|
2007
|
+
debug_log(
|
|
2008
|
+
f"Grouped into {len(turns)} turns, "
|
|
2009
|
+
f"{len(model_invocations)} model invocations"
|
|
2010
|
+
)
|
|
1725
2011
|
|
|
1726
2012
|
# If this is a subagent, save data for parent to include later
|
|
1727
2013
|
if is_subagent:
|
|
@@ -1758,18 +2044,25 @@ def main():
|
|
|
1758
2044
|
break
|
|
1759
2045
|
coze_tags_override = state.get("coze_tags") or None
|
|
1760
2046
|
|
|
1761
|
-
# 方案 B
|
|
2047
|
+
# 方案 B 实时:按 token_count 边界把内容、推理、工具调用和 usage 合成 model_call。
|
|
1762
2048
|
ok = send_steps_realtime(turns, session_id, state, model_name=model_name,
|
|
1763
2049
|
coze_tags_override=coze_tags_override,
|
|
1764
2050
|
is_terminal=is_terminal_event,
|
|
1765
|
-
|
|
2051
|
+
model_invocations=model_invocations)
|
|
1766
2052
|
if ok is None:
|
|
1767
2053
|
hook_log(f"realtime send failed session_id={session_id}")
|
|
1768
2054
|
debug_log("Realtime send failed, state not advanced")
|
|
1769
2055
|
return
|
|
1770
2056
|
save_state(state_file, state)
|
|
1771
|
-
hook_log(
|
|
1772
|
-
|
|
2057
|
+
hook_log(
|
|
2058
|
+
f"rt state saved event={hook_event} "
|
|
2059
|
+
f"last_invocation_line={state.get('rt_last_invocation_line')} "
|
|
2060
|
+
f"session_id={session_id}"
|
|
2061
|
+
)
|
|
2062
|
+
debug_log(
|
|
2063
|
+
f"State saved. rt_last_invocation_line="
|
|
2064
|
+
f"{state.get('rt_last_invocation_line')}"
|
|
2065
|
+
)
|
|
1773
2066
|
else:
|
|
1774
2067
|
hook_log(f"skip no turns session_id={session_id}")
|
|
1775
2068
|
debug_log("No turns to send")
|