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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -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
- # Build output: text -> parts, tool_use -> tool_calls, thinking -> reasoning_content
1084
- text_parts = []
1085
- tool_call_list = []
1086
- parts_list = []
1087
- thinking_parts = []
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
- # Build output for sub-agent model call
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
- text_parts = []
1585
- if isinstance(raw_content, list):
1586
- for it in raw_content:
1587
- if isinstance(it, dict) and it.get("type") == "text" and it.get("text"):
1588
- text_parts.append(it["text"])
1589
- elif isinstance(raw_content, str):
1590
- text_parts.append(raw_content)
1591
- mspan.set_input(format_content((assistant_msg.get("message", {}) or {}).get("content")))
1592
- if text_parts:
1593
- mspan.set_output("\n".join(text_parts))
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 extract_token_events(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
699
- """扫 rollout event_msg/token_count 事件, 与 turn 完全解耦.
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
- 每条事件独立采集, 依赖 _line_number 做全局水位线幂等. 只收 last_token_usage 非空的事件
702
- (空的是心跳/占位, 不代表真实模型调用). total_token_usage 一并保留供 tag 上报.
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
- "line": entry.get("_line_number", 0),
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
- return events
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 == "function_call":
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
- args_raw = payload.get("arguments", "{}")
819
- try:
820
- args = json.loads(args_raw)
821
- except (json.JSONDecodeError, TypeError):
822
- args = {"_raw": args_raw}
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 == "function_call_output":
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
- # --- 实时 step 级上报(方案 B:每个 tool_call 结束即可见)---------------------
1618
+ # --- 实时 model invocation 上报 -------------------------------------------
1384
1619
  #
1385
1620
  # 与 claude-code 同架构:一个会话 = 一条稳定 trace。首次建 root(codex_request)立即 finish 并存
1386
- # to_header() 到 state["rt_root_header"];之后每次触发把【新完成的 tool_call】(有 result 的)作为
1387
- # tool span 挂到固定 root、当场 flush。codex 的实时单元是 tool_call(turn 内平铺,按 call_id 关联
1388
- # result)。全局水位线 state["rt_last_tool"] 跟踪已发的 tool_call 数。
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, is_terminal=False, token_events=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
- root_start_dt = _parse_ts_value(turns[0].get("tool_calls", [{}])[0].get("_ts")) if turns and turns[0].get("tool_calls") else None
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
- # 线性展开所有 tool_call(带 result 的才算完成),按全局序增量发
1464
- flat = []
1465
- for ti, turn in enumerate(turns):
1466
- results = {r.get("call_id"): r for r in turn.get("tool_results", [])}
1467
- for tc in turn.get("tool_calls", []):
1468
- cid = tc.get("call_id")
1469
- flat.append({"tc": tc, "result": results.get(cid), "turn_index": ti})
1470
- # 只发已完成(有 result)的,且全局最后一个若无 result 则留到下次
1471
- completed = []
1472
- for idx, item in enumerate(flat):
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(f"[rt] batch throttle {since:.1f}s<{REALTIME_BATCH_INTERVAL}s defer {len(new_items)} tool(s)")
1483
- new_items = []
1484
- sent = 0
1485
- for item in new_items:
1486
- tc = item["tc"]; result = item["result"]
1487
- gidx = last_tool + sent
1488
- tname = tc.get("name", "unknown")
1489
- start_dt = _parse_ts_value(tc.get("_ts"))
1490
- end_dt = _parse_ts_value(result.get("_ts")) if result else None
1491
- # 每个 tool_call 用一个 model_call_+tool 子树(与 claude-code 视觉一致)
1492
- mspan = client.start_span(name=f"model_call_{gidx}", span_type="model",
1493
- start_time=start_dt, child_of=root_ctx)
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: mspan.set_model_name(model_name)
1496
- except Exception: pass
1497
- if start_dt and end_dt:
1498
- _set_finish_time_safe(mspan, end_dt)
1499
- mspan.set_input(truncate_text(json.dumps(tc.get("input", {}), ensure_ascii=False)))
1500
- mspan_ctx = client.get_span_from_header(mspan.to_header())
1501
- mspan.finish()
1502
-
1503
- tspan = client.start_span(name=f"tool_{tname}", span_type="tool",
1504
- start_time=start_dt, child_of=mspan_ctx)
1505
- tspan.set_runtime(Runtime(library="codex-cli"))
1506
- if end_dt:
1507
- _set_finish_time_safe(tspan, end_dt)
1508
- tspan.set_tags({"tool_name": tname, "tool_call_id": tc.get("call_id"), "step_index": gidx})
1509
- tspan.set_input(truncate_text(json.dumps(tc.get("input", {}), ensure_ascii=False)))
1510
- if result is not None:
1511
- tspan.set_output(truncate_text(str(result.get("output", ""))))
1512
- tspan.finish()
1513
- sent += 1
1514
- client.flush() # 每个 tool_call 立即 flush → 结束即可见
1515
- if upload_events:
1516
- hook_log(f"[rt] upload failed after tool flush detail={upload_events[-1][:500]}")
1517
- return None
1518
-
1519
- state["rt_last_tool"] = last_tool + sent
1520
- if sent > 0:
1521
- state["rt_last_upload_ts"] = time.time()
1522
-
1523
- # --- token_count 独立增量, 与 tool span 同一 root 下, 按行号水位线幂等 ---
1524
- # 每条 token_count 挂一个 model_usage_<line> span:
1525
- # last_token_usage → set_input_tokens/set_output_tokens (SDK 官方求和字段)
1526
- # total_token_usage → tags (只观察, 不参与后端聚合, 否则会重复累加)
1527
- # 只在 flush 成功后推进 rt_last_token_line, 保证上报失败下次能重发.
1528
- token_sent = 0
1529
- last_token_line = state.get("rt_last_token_line", -1)
1530
- pending_tokens = [ev for ev in (token_events or []) if ev["line"] > last_token_line]
1531
- max_flushed_line = last_token_line
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: tk_span.set_input_tokens(in_tk)
1548
- except Exception: pass
1783
+ try:
1784
+ mspan.set_input_tokens(in_tk)
1785
+ except Exception:
1786
+ pass
1549
1787
  if out_tk > 0:
1550
- try: tk_span.set_output_tokens(out_tk)
1551
- except Exception: pass
1552
- tk_tags = {
1553
- "token_event_line": line,
1554
- "token_source": "codex_token_count",
1555
- "cached_input_tokens": _usage_int(last, "cached_input_tokens"),
1556
- "reasoning_output_tokens": _usage_int(last, "reasoning_output_tokens"),
1557
- "total_tokens": _usage_int(last, "total_tokens"),
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
- tk_tags["total_input_tokens"] = _usage_int(total, "input_tokens")
1561
- tk_tags["total_output_tokens"] = _usage_int(total, "output_tokens")
1562
- tk_tags["total_cached_input_tokens"] = _usage_int(total, "cached_input_tokens")
1563
- tk_tags["total_reasoning_output_tokens"] = _usage_int(total, "reasoning_output_tokens")
1564
- tk_tags["total_all_tokens"] = _usage_int(total, "total_tokens")
1565
- tk_span.set_tags(tk_tags)
1566
- tk_span.finish()
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(f"[rt] upload failed after token flush line={line} detail={upload_events[-1][:500]}")
1570
- if max_flushed_line > last_token_line:
1571
- state["rt_last_token_line"] = max_flushed_line
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
- max_flushed_line = line
1574
- token_sent += 1
1575
- if token_sent > 0:
1576
- state["rt_last_token_line"] = max_flushed_line
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 total_tools={state['rt_last_tool']}")
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(f"[rt] sent {sent} tool span(s), {token_sent} token span(s), last_tool={state['rt_last_tool']}, last_token_line={state.get('rt_last_token_line', -1)}, terminal={is_terminal}")
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
- # 方案 B(实时):读【全量】entries 重建完整 turn/tool 序列。实时路径用全局 tool 水位线
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
- # 独立扫 token_count 事件, 与 turn 解耦, 走独立行号水位线上报.
1723
- token_events = extract_token_events(entries)
1724
- debug_log(f"Grouped into {len(turns)} turns, {len(token_events)} token events")
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 实时:把新完成的 tool_call 作为 span 挂稳定 root,结束即可见。不节流。
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
- token_events=token_events)
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(f"rt state saved event={hook_event} last_tool={state.get('rt_last_tool')} session_id={session_id}")
1772
- debug_log(f"State saved. rt_last_tool={state.get('rt_last_tool')}")
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")