coze_lab 0.1.53 → 0.1.54

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.54",
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))
@@ -758,6 +804,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
758
804
  "user_message": None,
759
805
  "user_message_text": "",
760
806
  "assistant_messages": [],
807
+ "model_records": [],
761
808
  "tool_calls": [],
762
809
  "tool_results": [],
763
810
  "input_messages": [],
@@ -775,6 +822,17 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
775
822
  if current_turn is not None:
776
823
  info = payload.get("info") or {}
777
824
  current_turn["token_usage"] = info.get("last_token_usage", {})
825
+ elif msg_type in ("agent_reasoning", "agent_message") and current_turn is not None:
826
+ text = payload.get("text") if msg_type == "agent_reasoning" else payload.get("message")
827
+ if isinstance(text, str) and text:
828
+ current_turn["model_records"].append({
829
+ "content": text if msg_type == "agent_message" else "",
830
+ "reasoning": text if msg_type == "agent_reasoning" else "",
831
+ "protected": {},
832
+ "_ts": payload.get("_ts"),
833
+ "_line_number": entry.get("_line_number", -1),
834
+ "source": f"event_msg.{msg_type}",
835
+ })
778
836
  continue
779
837
 
780
838
  # --- Content items ---
@@ -795,6 +853,14 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
795
853
  elif role == "assistant":
796
854
  if current_turn is not None:
797
855
  current_turn["assistant_messages"].append(payload)
856
+ current_turn["model_records"].append({
857
+ "content": extract_assistant_text(payload),
858
+ "reasoning": "",
859
+ "protected": {},
860
+ "_ts": payload.get("_ts"),
861
+ "_line_number": entry.get("_line_number", -1),
862
+ "source": "response_item.message",
863
+ })
798
864
  elif role in ("developer", "system"):
799
865
  if current_turn is not None:
800
866
  current_turn["input_messages"].append({
@@ -810,16 +876,25 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
810
876
  "content": text,
811
877
  })
812
878
 
813
- elif item_type == "function_call":
879
+ elif item_type == "reasoning":
880
+ if current_turn is not None:
881
+ record = extract_reasoning_record(payload)
882
+ record["_line_number"] = entry.get("_line_number", -1)
883
+ current_turn["model_records"].append(record)
884
+
885
+ elif item_type in ("function_call", "custom_tool_call"):
814
886
  if current_turn is None:
815
887
  continue
816
888
  call_id = payload.get("call_id")
817
889
  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}
890
+ if item_type == "custom_tool_call":
891
+ args = payload.get("input", "")
892
+ else:
893
+ args_raw = payload.get("arguments", "{}")
894
+ try:
895
+ args = json.loads(args_raw)
896
+ except (json.JSONDecodeError, TypeError):
897
+ args = {"_raw": args_raw}
823
898
 
824
899
  if name == "spawn_agent":
825
900
  subagent_call = {
@@ -846,11 +921,12 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
846
921
  "call_id": call_id,
847
922
  "name": name,
848
923
  "input": args,
924
+ "custom": item_type == "custom_tool_call",
849
925
  "_ts": payload.get("_ts"),
850
926
  })
851
927
  pending_calls[call_id] = {"kind": "tool", "_start_ts": payload.get("_ts")}
852
928
 
853
- elif item_type == "function_call_output":
929
+ elif item_type in ("function_call_output", "custom_tool_call_output"):
854
930
  if current_turn is None:
855
931
  continue
856
932
  call_id = payload.get("call_id")
@@ -896,10 +972,35 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
896
972
  if current_turn is not None:
897
973
  turns.append(current_turn)
898
974
 
975
+ # Codex may duplicate visible commentary/reasoning in response_item and
976
+ # event_msg. Prefer response_item, then retain event-only fallbacks.
977
+ for turn in turns:
978
+ seen = set()
979
+ deduped = []
980
+ records = sorted(
981
+ turn.get("model_records", []),
982
+ key=lambda record: (
983
+ 1 if str(record.get("source", "")).startswith("event_msg.") else 0,
984
+ record.get("_line_number", -1),
985
+ ),
986
+ )
987
+ for record in records:
988
+ category = "reasoning" if record.get("reasoning") else "content"
989
+ text = record.get(category, "").strip()
990
+ key = (category, text)
991
+ if text and key in seen:
992
+ continue
993
+ if text:
994
+ seen.add(key)
995
+ deduped.append(record)
996
+ turn["model_records"] = sorted(
997
+ deduped, key=lambda record: record.get("_line_number", -1)
998
+ )
999
+
899
1000
  # Drop turns with no user input and no assistant response
900
1001
  turns = [
901
1002
  t for t in turns
902
- if t["user_message"] is not None or t["assistant_messages"]
1003
+ if t["user_message"] is not None or t["assistant_messages"] or t.get("model_records")
903
1004
  ]
904
1005
 
905
1006
  return turns
@@ -1439,7 +1540,8 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
1439
1540
  if turn.get("user_message_text"):
1440
1541
  first_user_text = turn["user_message_text"]
1441
1542
  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
1543
+ all_timestamps = [dt for turn in turns for dt in _turn_timestamps(turn)]
1544
+ root_start_dt = min(all_timestamps) if all_timestamps else None
1443
1545
  root_span = client.start_span(name="codex_request", span_type="main",
1444
1546
  start_time=root_start_dt, start_new_trace=True)
1445
1547
  root_span.set_runtime(Runtime(library="codex-cli"))
@@ -1460,6 +1562,83 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
1460
1562
  return None
1461
1563
  hook_log(f"[rt] root created trace_id={getattr(root_ctx,'trace_id','?')}")
1462
1564
 
1565
+ # Model-visible records use their own line watermark. They are not
1566
+ # coupled to tool completion because reasoning/commentary can occur in
1567
+ # turns with no tools at all.
1568
+ model_records = []
1569
+ for turn in turns:
1570
+ for record in turn.get("model_records", []):
1571
+ model_records.append((turn, record))
1572
+ model_records.sort(key=lambda item: item[1].get("_line_number", -1))
1573
+ last_model_line = state.get("rt_last_model_line", -1)
1574
+ model_sent = 0
1575
+ max_model_line = last_model_line
1576
+ for turn, record in model_records:
1577
+ line = record.get("_line_number", -1)
1578
+ if line <= last_model_line:
1579
+ continue
1580
+ content = record.get("content", "")
1581
+ reasoning = record.get("reasoning", "")
1582
+ protected = record.get("protected") or {}
1583
+ if not content and not reasoning and not protected:
1584
+ max_model_line = max(max_model_line, line)
1585
+ continue
1586
+ start_dt = _parse_ts_value(record.get("_ts"))
1587
+ mspan = client.start_span(
1588
+ name=f"model_content_{line}", span_type="model",
1589
+ start_time=start_dt, child_of=root_ctx,
1590
+ )
1591
+ mspan.set_runtime(Runtime(library="codex-cli"))
1592
+ try:
1593
+ mspan.set_model_name(model_name)
1594
+ except Exception:
1595
+ pass
1596
+ if turn.get("user_message_text"):
1597
+ mspan.set_input(ModelInput(
1598
+ messages=[_make_model_message("user", turn["user_message_text"])],
1599
+ tools=[],
1600
+ tool_choice=ModelToolChoice(type="", function=None),
1601
+ ))
1602
+ choice = ModelChoice(
1603
+ finish_reason="stop",
1604
+ index=0,
1605
+ message=ModelMessage(
1606
+ role="assistant",
1607
+ content=content,
1608
+ reasoning_content=reasoning,
1609
+ parts=[],
1610
+ name="",
1611
+ tool_calls=[],
1612
+ tool_call_id="",
1613
+ metadata={},
1614
+ ),
1615
+ )
1616
+ mspan.set_output(ModelOutput(choices=[choice]))
1617
+ tags = {
1618
+ "model_record_line": line,
1619
+ "model_record_source": record.get("source", "unknown"),
1620
+ }
1621
+ if protected:
1622
+ tags.update({
1623
+ "protected_reasoning_source": protected.get("source", ""),
1624
+ "protected_reasoning_count": protected.get("count", 0),
1625
+ "protected_reasoning_bytes": protected.get("bytes", 0),
1626
+ })
1627
+ mspan.set_tags(tags)
1628
+ if start_dt is not None:
1629
+ _set_finish_time_safe(mspan, start_dt)
1630
+ mspan.finish()
1631
+ client.flush()
1632
+ if upload_events:
1633
+ hook_log(f"[rt] upload failed after model flush line={line} detail={upload_events[-1][:500]}")
1634
+ if max_model_line > last_model_line:
1635
+ state["rt_last_model_line"] = max_model_line
1636
+ return None
1637
+ max_model_line = line
1638
+ model_sent += 1
1639
+ if max_model_line > last_model_line:
1640
+ state["rt_last_model_line"] = max_model_line
1641
+
1463
1642
  # 线性展开所有 tool_call(带 result 的才算完成),按全局序增量发
1464
1643
  flat = []
1465
1644
  for ti, turn in enumerate(turns):
@@ -1496,7 +1675,7 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
1496
1675
  except Exception: pass
1497
1676
  if start_dt and end_dt:
1498
1677
  _set_finish_time_safe(mspan, end_dt)
1499
- mspan.set_input(truncate_text(json.dumps(tc.get("input", {}), ensure_ascii=False)))
1678
+ mspan.set_input(truncate_text(_json_text(tc.get("input", {}))))
1500
1679
  mspan_ctx = client.get_span_from_header(mspan.to_header())
1501
1680
  mspan.finish()
1502
1681
 
@@ -1506,9 +1685,9 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
1506
1685
  if end_dt:
1507
1686
  _set_finish_time_safe(tspan, end_dt)
1508
1687
  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)))
1688
+ tspan.set_input(truncate_text(_json_text(tc.get("input", {}))))
1510
1689
  if result is not None:
1511
- tspan.set_output(truncate_text(str(result.get("output", ""))))
1690
+ tspan.set_output(truncate_text(_json_text(result.get("output", ""))))
1512
1691
  tspan.finish()
1513
1692
  sent += 1
1514
1693
  client.flush() # 每个 tool_call 立即 flush → 结束即可见
@@ -1601,7 +1780,7 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
1601
1780
  hook_log(f"[rt] upload failed state not advanced detail={upload_events[-1][:500]}")
1602
1781
  return None
1603
1782
 
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}")
1783
+ hook_log(f"[rt] sent {model_sent} model span(s), {sent} tool span(s), {token_sent} token span(s), last_model_line={state.get('rt_last_model_line', -1)}, last_tool={state['rt_last_tool']}, last_token_line={state.get('rt_last_token_line', -1)}, terminal={is_terminal}")
1605
1784
  return True
1606
1785
  except Exception as e:
1607
1786
  hook_log(f"[rt] error: {e}")