coze_lab 0.1.54 → 0.1.56

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.54",
3
+ "version": "0.1.56",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -1465,6 +1465,33 @@ def _flatten_completed_steps(turns: List[Dict[str, Any]], is_terminal: bool = Fa
1465
1465
  return completed, len(flat)
1466
1466
 
1467
1467
 
1468
+ def _reconstructed_model_bounds(item: Dict[str, Any]):
1469
+ """Return request-ready -> assistant-emitted bounds for one Claude step."""
1470
+ turn = item.get("turn") or {}
1471
+ step = item.get("step") or {}
1472
+ step_index = item.get("step_index", 0)
1473
+ assistant_dt = _parse_ts(step.get("assistant_message"))
1474
+ start_dt = None
1475
+ if step_index > 0:
1476
+ previous_steps = turn.get("steps", [])
1477
+ if step_index <= len(previous_steps) - 1:
1478
+ previous = previous_steps[step_index - 1]
1479
+ result_times = [
1480
+ _parse_ts_str(result.get("_result_ts"))
1481
+ for result in previous.get("tool_results", [])
1482
+ ]
1483
+ result_times = [value for value in result_times if value is not None]
1484
+ if result_times:
1485
+ start_dt = max(result_times)
1486
+ else:
1487
+ start_dt = _parse_ts(previous.get("assistant_message"))
1488
+ if start_dt is None:
1489
+ start_dt = _parse_ts(turn.get("user_message"))
1490
+ if start_dt is None:
1491
+ start_dt = assistant_dt
1492
+ return start_dt, assistant_dt or start_dt
1493
+
1494
+
1468
1495
  def send_steps_realtime(turns, session_id, history_turns, state, coze_tags_override=None, is_terminal=False):
1469
1496
  """实时上报:把新完成的 step 作为 span 挂到稳定 root,结束即 flush。
1470
1497
 
@@ -1569,17 +1596,14 @@ def send_steps_realtime(turns, session_id, history_turns, state, coze_tags_overr
1569
1596
  amo = assistant_msg.get("message", {})
1570
1597
  model_name = amo.get("model", "claude-code")
1571
1598
  gidx = last_global + sent # 全局 step 序号,用于命名
1572
- step_start_dt = _parse_ts(assistant_msg)
1573
- step_end_dt = None
1574
- for r in step.get("tool_results", []):
1575
- te = _parse_ts_str(r.get("_result_ts"))
1576
- if te:
1577
- step_end_dt = te
1599
+ step_start_dt, step_end_dt = _reconstructed_model_bounds(item)
1600
+ assistant_dt = _parse_ts(assistant_msg) or step_end_dt
1578
1601
 
1579
1602
  # model_span 挂 root
1580
1603
  mspan = client.start_span(name=f"model_call_{gidx}", span_type="model",
1581
1604
  start_time=step_start_dt, child_of=root_ctx)
1582
1605
  mspan.set_runtime(Runtime(library="claude-code"))
1606
+ mspan.set_tags({"timing_source": "reconstructed"})
1583
1607
  try:
1584
1608
  mspan.set_model_name(model_name)
1585
1609
  except Exception:
@@ -1635,7 +1659,7 @@ def send_steps_realtime(turns, session_id, history_turns, state, coze_tags_overr
1635
1659
  tool_end_dt = _parse_ts_str(r.get("_result_ts"))
1636
1660
  break
1637
1661
  tspan = client.start_span(name=f"tool_{tname}", span_type="tool",
1638
- start_time=step_start_dt, child_of=mspan_ctx)
1662
+ start_time=assistant_dt, child_of=mspan_ctx)
1639
1663
  tspan.set_runtime(Runtime(library="claude-code"))
1640
1664
  if tool_end_dt is not None:
1641
1665
  _set_finish_time_safe(tspan, tool_end_dt)
@@ -741,13 +741,45 @@ def _assistant_bounds(turn):
741
741
  return min(values), max(values)
742
742
 
743
743
 
744
- def extract_token_events(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
745
- """扫 rollout event_msg/token_count 事件, 与 turn 完全解耦.
746
-
747
- 每条事件独立采集, 依赖 _line_number 做全局水位线幂等. 只收 last_token_usage 非空的事件
748
- (空的是心跳/占位, 不代表真实模型调用). total_token_usage 一并保留供 tag 上报.
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.
748
+
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.
749
753
  """
750
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
+ )
751
783
  for entry in entries:
752
784
  if entry.get("type") != "event_msg":
753
785
  continue
@@ -758,13 +790,132 @@ def extract_token_events(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
758
790
  last_usage = info.get("last_token_usage")
759
791
  if not isinstance(last_usage, dict) or not last_usage:
760
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
761
799
  events.append({
762
- "line": entry.get("_line_number", 0),
800
+ "kind": "usage",
801
+ "line": line,
763
802
  "timestamp": entry.get("timestamp"),
764
803
  "usage": last_usage,
765
804
  "total_usage": info.get("total_token_usage") or {},
805
+ "turn": boundary_turn,
766
806
  })
767
- 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 request_timestamp(turn: Optional[Dict[str, Any]]) -> Optional[str]:
826
+ if not turn:
827
+ return None
828
+ user_message = turn.get("user_message") or {}
829
+ return user_message.get("_ts") or turn.get("start_timestamp")
830
+
831
+ def append_text(category: str, value: Any):
832
+ if not isinstance(value, str):
833
+ return
834
+ text = value.strip()
835
+ if not text or text in seen[category]:
836
+ return
837
+ seen[category].add(text)
838
+ current[f"{category}_parts"].append(text)
839
+
840
+ def finish_current(boundary_line: int, boundary_ts: Optional[str],
841
+ usage: Optional[Dict[str, Any]] = None,
842
+ total_usage: Optional[Dict[str, Any]] = None):
843
+ has_payload = (
844
+ current["content_parts"] or current["reasoning_parts"]
845
+ or current["tool_calls"] or current["protected"].get("count")
846
+ or usage
847
+ )
848
+ if not has_payload:
849
+ return
850
+ current["line"] = boundary_line
851
+ if current["end_timestamp"] is None:
852
+ current["end_timestamp"] = boundary_ts
853
+ current["timing_source"] = "reconstructed_usage_fallback"
854
+ else:
855
+ current["timing_source"] = "reconstructed"
856
+ current["usage"] = usage or {}
857
+ current["total_usage"] = total_usage or {}
858
+ current["content"] = "\n".join(current.pop("content_parts"))
859
+ current["reasoning"] = "\n".join(current.pop("reasoning_parts"))
860
+ for tool_call in current["tool_calls"]:
861
+ tool_call["result"] = tool_results.get(tool_call.get("call_id"))
862
+ invocations.append(dict(current))
863
+
864
+ for event in events:
865
+ kind = event["kind"]
866
+ if kind == "usage":
867
+ if current["turn"] is None:
868
+ current["turn"] = event.get("turn")
869
+ if current["start_timestamp"] is None:
870
+ current["start_timestamp"] = event.get("timestamp")
871
+ finish_current(event["line"], event.get("timestamp"),
872
+ event.get("usage"), event.get("total_usage"))
873
+ current = {
874
+ "content_parts": [],
875
+ "reasoning_parts": [],
876
+ "protected": {"source": "", "count": 0, "bytes": 0},
877
+ "tool_calls": [],
878
+ "turn": None,
879
+ "start_timestamp": event.get("timestamp"),
880
+ "start_turn": event.get("turn"),
881
+ "end_timestamp": None,
882
+ "line": -1,
883
+ "usage": {},
884
+ "total_usage": {},
885
+ }
886
+ seen = {"content": set(), "reasoning": set()}
887
+ continue
888
+
889
+ current["turn"] = current["turn"] or event.get("turn")
890
+ if current.get("start_turn") is not None and current.get("start_turn") is not event.get("turn"):
891
+ current["start_timestamp"] = None
892
+ current["start_turn"] = event.get("turn")
893
+ current["start_timestamp"] = (
894
+ current["start_timestamp"]
895
+ or request_timestamp(event.get("turn"))
896
+ or event.get("timestamp")
897
+ )
898
+ current["end_timestamp"] = event.get("timestamp") or current["end_timestamp"]
899
+ current["line"] = max(current["line"], event.get("line", -1))
900
+ if kind == "record":
901
+ record = event["record"]
902
+ append_text("content", record.get("content"))
903
+ append_text("reasoning", record.get("reasoning"))
904
+ protected = record.get("protected") or {}
905
+ if protected.get("count"):
906
+ current["protected"]["source"] = protected.get("source", "")
907
+ current["protected"]["count"] += protected.get("count", 0)
908
+ current["protected"]["bytes"] += protected.get("bytes", 0)
909
+ elif kind == "tool":
910
+ current["tool_calls"].append(dict(event["tool_call"]))
911
+
912
+ if is_terminal and (
913
+ current["content_parts"] or current["reasoning_parts"]
914
+ or current["tool_calls"] or current["protected"].get("count")
915
+ ):
916
+ finish_current(current["line"], current["end_timestamp"])
917
+
918
+ return invocations
768
919
 
769
920
 
770
921
  def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -811,6 +962,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
811
962
  "subagent_calls": [],
812
963
  "token_usage": {},
813
964
  "start_line": entry.get("_line_number", 0),
965
+ "start_timestamp": payload.get("_ts"),
814
966
  }
815
967
  pending_calls = {}
816
968
  elif msg_type == "task_complete":
@@ -923,6 +1075,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
923
1075
  "input": args,
924
1076
  "custom": item_type == "custom_tool_call",
925
1077
  "_ts": payload.get("_ts"),
1078
+ "_line_number": entry.get("_line_number", -1),
926
1079
  })
927
1080
  pending_calls[call_id] = {"kind": "tool", "_start_ts": payload.get("_ts")}
928
1081
 
@@ -1481,14 +1634,15 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1481
1634
  return ctx
1482
1635
 
1483
1636
 
1484
- # --- 实时 step 级上报(方案 B:每个 tool_call 结束即可见)---------------------
1637
+ # --- 实时 model invocation 上报 -------------------------------------------
1485
1638
  #
1486
1639
  # 与 claude-code 同架构:一个会话 = 一条稳定 trace。首次建 root(codex_request)立即 finish 并存
1487
- # to_header() 到 state["rt_root_header"];之后每次触发把【新完成的 tool_call】(有 result 的)作为
1488
- # tool span 挂到固定 root、当场 flush。codex 的实时单元是 tool_call(turn 内平铺,按 call_id 关联
1489
- # result)。全局水位线 state["rt_last_tool"] 跟踪已发的 tool_call 数。
1640
+ # to_header() 到 state["rt_root_header"];之后按 token_count 边界把模型可见内容、推理、工具调用和
1641
+ # last_token_usage 合成 model_call span,工具执行作为其子 span。全局行号水位线
1642
+ # state["rt_last_invocation_line"] 保证重复 hook 不重发。
1490
1643
 
1491
- def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_override=None, is_terminal=False, token_events=None):
1644
+ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_override=None,
1645
+ is_terminal=False, model_invocations=None):
1492
1646
  import cozeloop
1493
1647
  from cozeloop.spec.tracespec import Runtime
1494
1648
  token = get_fresh_token()
@@ -1562,197 +1716,163 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
1562
1716
  return None
1563
1717
  hook_log(f"[rt] root created trace_id={getattr(root_ctx,'trace_id','?')}")
1564
1718
 
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"))
1719
+ # One atomic model span per Codex invocation. Upgrade existing sessions
1720
+ # without replaying events already emitted by the pre-0.1.55 watermarks.
1721
+ legacy_line = max(state.get("rt_last_model_line", -1),
1722
+ state.get("rt_last_token_line", -1))
1723
+ last_invocation_line = state.get("rt_last_invocation_line", legacy_line)
1724
+ pending_invocations = [
1725
+ invocation for invocation in (model_invocations or [])
1726
+ if invocation.get("line", -1) > last_invocation_line
1727
+ ]
1728
+ if pending_invocations and not is_terminal:
1729
+ since = time.time() - state.get("rt_last_upload_ts", 0)
1730
+ if since < REALTIME_BATCH_INTERVAL:
1731
+ hook_log(
1732
+ f"[rt] batch throttle {since:.1f}s<{REALTIME_BATCH_INTERVAL}s "
1733
+ f"defer {len(pending_invocations)} invocation(s)"
1734
+ )
1735
+ pending_invocations = []
1736
+
1737
+ invocation_sent = 0
1738
+ tool_sent = 0
1739
+ model_call_base = state.get(
1740
+ "rt_model_call_count", state.get("rt_last_tool", 0)
1741
+ )
1742
+ for invocation in pending_invocations:
1743
+ line = invocation.get("line", -1)
1744
+ turn = invocation.get("turn") or {}
1745
+ tool_calls = invocation.get("tool_calls") or []
1746
+ model_tool_calls = []
1747
+ for tool_call in tool_calls:
1748
+ model_tool_calls.append(ModelToolCall(
1749
+ id=tool_call.get("call_id", ""),
1750
+ type="function",
1751
+ function=ModelToolCallFunction(
1752
+ name=tool_call.get("name", ""),
1753
+ arguments=_json_text(tool_call.get("input", {})),
1754
+ ),
1755
+ ))
1756
+
1757
+ start_dt = _parse_ts_value(invocation.get("start_timestamp"))
1758
+ end_dt = _parse_ts_value(invocation.get("end_timestamp")) or start_dt
1587
1759
  mspan = client.start_span(
1588
- name=f"model_content_{line}", span_type="model",
1589
- start_time=start_dt, child_of=root_ctx,
1760
+ name=f"model_call_{invocation_sent + model_call_base}",
1761
+ span_type="model", start_time=start_dt, child_of=root_ctx,
1590
1762
  )
1591
1763
  mspan.set_runtime(Runtime(library="codex-cli"))
1592
1764
  try:
1593
1765
  mspan.set_model_name(model_name)
1594
1766
  except Exception:
1595
1767
  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),
1768
+ if start_dt is not None and end_dt is not None:
1769
+ _set_finish_time_safe(mspan, _max_dt(start_dt, end_dt))
1770
+
1771
+ input_messages = []
1772
+ for message in turn.get("input_messages", []):
1773
+ input_messages.append(_make_model_message(
1774
+ message.get("role", "user"), message.get("content", "")
1601
1775
  ))
1602
- choice = ModelChoice(
1603
- finish_reason="stop",
1776
+ if not input_messages and turn.get("user_message_text"):
1777
+ input_messages.append(_make_model_message("user", turn["user_message_text"]))
1778
+ mspan.set_input(ModelInput(
1779
+ messages=input_messages,
1780
+ tools=[],
1781
+ tool_choice=ModelToolChoice(type="", function=None),
1782
+ ))
1783
+ mspan.set_output(ModelOutput(choices=[ModelChoice(
1784
+ finish_reason="tool_calls" if model_tool_calls else "stop",
1604
1785
  index=0,
1605
1786
  message=ModelMessage(
1606
1787
  role="assistant",
1607
- content=content,
1608
- reasoning_content=reasoning,
1788
+ content=invocation.get("content", ""),
1789
+ reasoning_content=invocation.get("reasoning", ""),
1609
1790
  parts=[],
1610
1791
  name="",
1611
- tool_calls=[],
1792
+ tool_calls=model_tool_calls,
1612
1793
  tool_call_id="",
1613
1794
  metadata={},
1614
1795
  ),
1615
- )
1616
- mspan.set_output(ModelOutput(choices=[choice]))
1796
+ )]))
1797
+
1798
+ usage = invocation.get("usage") or {}
1799
+ in_tk = _usage_int(usage, "input_tokens")
1800
+ out_tk = _usage_int(usage, "output_tokens")
1801
+ if in_tk > 0:
1802
+ try:
1803
+ mspan.set_input_tokens(in_tk)
1804
+ except Exception:
1805
+ pass
1806
+ if out_tk > 0:
1807
+ try:
1808
+ mspan.set_output_tokens(out_tk)
1809
+ except Exception:
1810
+ pass
1811
+
1617
1812
  tags = {
1618
- "model_record_line": line,
1619
- "model_record_source": record.get("source", "unknown"),
1813
+ "invocation_boundary_line": line,
1814
+ "token_source": "codex_token_count" if usage else "unavailable",
1815
+ "timing_source": invocation.get("timing_source", "reconstructed"),
1816
+ "cached_input_tokens": _usage_int(usage, "cached_input_tokens"),
1817
+ "reasoning_output_tokens": _usage_int(usage, "reasoning_output_tokens"),
1818
+ "total_tokens": _usage_int(usage, "total_tokens"),
1620
1819
  }
1621
- if protected:
1820
+ total = invocation.get("total_usage") or {}
1821
+ if total:
1822
+ tags.update({
1823
+ "total_input_tokens": _usage_int(total, "input_tokens"),
1824
+ "total_output_tokens": _usage_int(total, "output_tokens"),
1825
+ "total_cached_input_tokens": _usage_int(total, "cached_input_tokens"),
1826
+ "total_reasoning_output_tokens": _usage_int(total, "reasoning_output_tokens"),
1827
+ "total_all_tokens": _usage_int(total, "total_tokens"),
1828
+ })
1829
+ protected = invocation.get("protected") or {}
1830
+ if protected.get("count"):
1622
1831
  tags.update({
1623
1832
  "protected_reasoning_source": protected.get("source", ""),
1624
1833
  "protected_reasoning_count": protected.get("count", 0),
1625
1834
  "protected_reasoning_bytes": protected.get("bytes", 0),
1626
1835
  })
1627
1836
  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
-
1642
- # 线性展开所有 tool_call(带 result 的才算完成),按全局序增量发
1643
- flat = []
1644
- for ti, turn in enumerate(turns):
1645
- results = {r.get("call_id"): r for r in turn.get("tool_results", [])}
1646
- for tc in turn.get("tool_calls", []):
1647
- cid = tc.get("call_id")
1648
- flat.append({"tc": tc, "result": results.get(cid), "turn_index": ti})
1649
- # 只发已完成(有 result)的,且全局最后一个若无 result 则留到下次
1650
- completed = []
1651
- for idx, item in enumerate(flat):
1652
- is_last = (idx == len(flat) - 1)
1653
- if item["result"] is not None or (not is_last):
1654
- completed.append(item)
1655
- last_tool = state.get("rt_last_tool", 0)
1656
- new_items = completed[last_tool:]
1657
- # 批量节流:非终态时距上次上报<5s 则本次不发,攒到下次一起发。终态不节流。
1658
- if new_items and not is_terminal:
1659
- since = time.time() - state.get("rt_last_upload_ts", 0)
1660
- if since < REALTIME_BATCH_INTERVAL:
1661
- hook_log(f"[rt] batch throttle {since:.1f}s<{REALTIME_BATCH_INTERVAL}s defer {len(new_items)} tool(s)")
1662
- new_items = []
1663
- sent = 0
1664
- for item in new_items:
1665
- tc = item["tc"]; result = item["result"]
1666
- gidx = last_tool + sent
1667
- tname = tc.get("name", "unknown")
1668
- start_dt = _parse_ts_value(tc.get("_ts"))
1669
- end_dt = _parse_ts_value(result.get("_ts")) if result else None
1670
- # 每个 tool_call 用一个 model_call_+tool 子树(与 claude-code 视觉一致)
1671
- mspan = client.start_span(name=f"model_call_{gidx}", span_type="model",
1672
- start_time=start_dt, child_of=root_ctx)
1673
- mspan.set_runtime(Runtime(library="codex-cli"))
1674
- try: mspan.set_model_name(model_name)
1675
- except Exception: pass
1676
- if start_dt and end_dt:
1677
- _set_finish_time_safe(mspan, end_dt)
1678
- mspan.set_input(truncate_text(_json_text(tc.get("input", {}))))
1679
1837
  mspan_ctx = client.get_span_from_header(mspan.to_header())
1680
1838
  mspan.finish()
1681
1839
 
1682
- tspan = client.start_span(name=f"tool_{tname}", span_type="tool",
1683
- start_time=start_dt, child_of=mspan_ctx)
1684
- tspan.set_runtime(Runtime(library="codex-cli"))
1685
- if end_dt:
1686
- _set_finish_time_safe(tspan, end_dt)
1687
- tspan.set_tags({"tool_name": tname, "tool_call_id": tc.get("call_id"), "step_index": gidx})
1688
- tspan.set_input(truncate_text(_json_text(tc.get("input", {}))))
1689
- if result is not None:
1840
+ for tool_call in tool_calls:
1841
+ result = tool_call.get("result")
1842
+ if result is None:
1843
+ continue
1844
+ tool_start_dt = _parse_ts_value(tool_call.get("_ts")) or start_dt
1845
+ tool_end_dt = _parse_ts_value(result.get("_ts")) or tool_start_dt
1846
+ tname = tool_call.get("name", "unknown")
1847
+ tspan = client.start_span(
1848
+ name=f"tool_{tname}", span_type="tool",
1849
+ start_time=tool_start_dt, child_of=mspan_ctx,
1850
+ )
1851
+ tspan.set_runtime(Runtime(library="codex-cli"))
1852
+ if tool_end_dt is not None:
1853
+ _set_finish_time_safe(tspan, _max_dt(tool_start_dt, tool_end_dt))
1854
+ tspan.set_tags({
1855
+ "tool_name": tname,
1856
+ "tool_call_id": tool_call.get("call_id"),
1857
+ "model_invocation_line": line,
1858
+ })
1859
+ tspan.set_input(truncate_text(_json_text(tool_call.get("input", {}))))
1690
1860
  tspan.set_output(truncate_text(_json_text(result.get("output", ""))))
1691
- tspan.finish()
1692
- sent += 1
1693
- client.flush() # 每个 tool_call 立即 flush → 结束即可见
1694
- if upload_events:
1695
- hook_log(f"[rt] upload failed after tool flush detail={upload_events[-1][:500]}")
1696
- return None
1697
-
1698
- state["rt_last_tool"] = last_tool + sent
1699
- if sent > 0:
1700
- state["rt_last_upload_ts"] = time.time()
1861
+ tspan.finish()
1862
+ tool_sent += 1
1701
1863
 
1702
- # --- token_count 独立增量, 与 tool span 同一 root 下, 按行号水位线幂等 ---
1703
- # 每条 token_count 挂一个 model_usage_<line> span:
1704
- # last_token_usage → set_input_tokens/set_output_tokens (SDK 官方求和字段)
1705
- # total_token_usage → tags (只观察, 不参与后端聚合, 否则会重复累加)
1706
- # 只在 flush 成功后推进 rt_last_token_line, 保证上报失败下次能重发.
1707
- token_sent = 0
1708
- last_token_line = state.get("rt_last_token_line", -1)
1709
- pending_tokens = [ev for ev in (token_events or []) if ev["line"] > last_token_line]
1710
- max_flushed_line = last_token_line
1711
- for ev in pending_tokens:
1712
- line = ev["line"]
1713
- last = ev.get("usage") or {}
1714
- total = ev.get("total_usage") or {}
1715
- in_tk = _usage_int(last, "input_tokens")
1716
- out_tk = _usage_int(last, "output_tokens")
1717
- start_dt = _parse_ts_value(ev.get("timestamp"))
1718
- tk_span = client.start_span(name=f"model_usage_{line}", span_type="model",
1719
- start_time=start_dt, child_of=root_ctx)
1720
- tk_span.set_runtime(Runtime(library="codex-cli"))
1721
- try: tk_span.set_model_name(model_name)
1722
- except Exception: pass
1723
- if start_dt is not None:
1724
- _set_finish_time_safe(tk_span, start_dt)
1725
- if in_tk > 0:
1726
- try: tk_span.set_input_tokens(in_tk)
1727
- except Exception: pass
1728
- if out_tk > 0:
1729
- try: tk_span.set_output_tokens(out_tk)
1730
- except Exception: pass
1731
- tk_tags = {
1732
- "token_event_line": line,
1733
- "token_source": "codex_token_count",
1734
- "cached_input_tokens": _usage_int(last, "cached_input_tokens"),
1735
- "reasoning_output_tokens": _usage_int(last, "reasoning_output_tokens"),
1736
- "total_tokens": _usage_int(last, "total_tokens"),
1737
- }
1738
- if total:
1739
- tk_tags["total_input_tokens"] = _usage_int(total, "input_tokens")
1740
- tk_tags["total_output_tokens"] = _usage_int(total, "output_tokens")
1741
- tk_tags["total_cached_input_tokens"] = _usage_int(total, "cached_input_tokens")
1742
- tk_tags["total_reasoning_output_tokens"] = _usage_int(total, "reasoning_output_tokens")
1743
- tk_tags["total_all_tokens"] = _usage_int(total, "total_tokens")
1744
- tk_span.set_tags(tk_tags)
1745
- tk_span.finish()
1746
1864
  client.flush()
1747
1865
  if upload_events:
1748
- hook_log(f"[rt] upload failed after token flush line={line} detail={upload_events[-1][:500]}")
1749
- if max_flushed_line > last_token_line:
1750
- state["rt_last_token_line"] = max_flushed_line
1866
+ hook_log(
1867
+ f"[rt] upload failed after invocation flush line={line} "
1868
+ f"detail={upload_events[-1][:500]}"
1869
+ )
1751
1870
  return None
1752
- max_flushed_line = line
1753
- token_sent += 1
1754
- if token_sent > 0:
1755
- state["rt_last_token_line"] = max_flushed_line
1871
+ state["rt_last_invocation_line"] = line
1872
+ invocation_sent += 1
1873
+
1874
+ if invocation_sent > 0:
1875
+ state["rt_model_call_count"] = model_call_base + invocation_sent
1756
1876
  state["rt_last_upload_ts"] = time.time()
1757
1877
 
1758
1878
  if is_terminal:
@@ -1774,13 +1894,17 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
1774
1894
  if upload_events:
1775
1895
  hook_log(f"[rt] upload failed after final flush detail={upload_events[-1][:500]}")
1776
1896
  return None
1777
- hook_log(f"[rt] finalized total_tools={state['rt_last_tool']}")
1897
+ hook_log(f"[rt] finalized total_model_calls={state.get('rt_model_call_count', 0)}")
1778
1898
 
1779
1899
  if upload_events:
1780
1900
  hook_log(f"[rt] upload failed state not advanced detail={upload_events[-1][:500]}")
1781
1901
  return None
1782
1902
 
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}")
1903
+ hook_log(
1904
+ f"[rt] sent {invocation_sent} model invocation(s), {tool_sent} tool span(s), "
1905
+ f"last_invocation_line={state.get('rt_last_invocation_line', -1)}, "
1906
+ f"terminal={is_terminal}"
1907
+ )
1784
1908
  return True
1785
1909
  except Exception as e:
1786
1910
  hook_log(f"[rt] error: {e}")
@@ -1855,8 +1979,7 @@ def main():
1855
1979
  state_file = get_state_file_path(transcript_path)
1856
1980
  state = load_state(state_file)
1857
1981
 
1858
- # 方案 B(实时):读【全量】entries 重建完整 turn/tool 序列。实时路径用全局 tool 水位线
1859
- # state["rt_last_tool"] 增量,每次触发把新完成的 tool_call 发出,结束即可见,不节流。
1982
+ # 实时路径读取全量 entries 重建 invocation 序列,再用 token 边界行号水位线增量上报。
1860
1983
  entries = read_rollout_messages(transcript_path, 0)
1861
1984
 
1862
1985
  if not entries:
@@ -1898,9 +2021,13 @@ def main():
1898
2021
 
1899
2022
  # Group entries into turns
1900
2023
  turns = group_messages_into_turns(entries)
1901
- # 独立扫 token_count 事件, 与 turn 解耦, 走独立行号水位线上报.
1902
- token_events = extract_token_events(entries)
1903
- debug_log(f"Grouped into {len(turns)} turns, {len(token_events)} token events")
2024
+ model_invocations = extract_model_invocations(
2025
+ turns, entries, is_terminal=is_terminal_event
2026
+ )
2027
+ debug_log(
2028
+ f"Grouped into {len(turns)} turns, "
2029
+ f"{len(model_invocations)} model invocations"
2030
+ )
1904
2031
 
1905
2032
  # If this is a subagent, save data for parent to include later
1906
2033
  if is_subagent:
@@ -1937,18 +2064,25 @@ def main():
1937
2064
  break
1938
2065
  coze_tags_override = state.get("coze_tags") or None
1939
2066
 
1940
- # 方案 B 实时:把新完成的 tool_call 作为 span 挂稳定 root,结束即可见。不节流。
2067
+ # 方案 B 实时:按 token_count 边界把内容、推理、工具调用和 usage 合成 model_call。
1941
2068
  ok = send_steps_realtime(turns, session_id, state, model_name=model_name,
1942
2069
  coze_tags_override=coze_tags_override,
1943
2070
  is_terminal=is_terminal_event,
1944
- token_events=token_events)
2071
+ model_invocations=model_invocations)
1945
2072
  if ok is None:
1946
2073
  hook_log(f"realtime send failed session_id={session_id}")
1947
2074
  debug_log("Realtime send failed, state not advanced")
1948
2075
  return
1949
2076
  save_state(state_file, state)
1950
- hook_log(f"rt state saved event={hook_event} last_tool={state.get('rt_last_tool')} session_id={session_id}")
1951
- debug_log(f"State saved. rt_last_tool={state.get('rt_last_tool')}")
2077
+ hook_log(
2078
+ f"rt state saved event={hook_event} "
2079
+ f"last_invocation_line={state.get('rt_last_invocation_line')} "
2080
+ f"session_id={session_id}"
2081
+ )
2082
+ debug_log(
2083
+ f"State saved. rt_last_invocation_line="
2084
+ f"{state.get('rt_last_invocation_line')}"
2085
+ )
1952
2086
  else:
1953
2087
  hook_log(f"skip no turns session_id={session_id}")
1954
2088
  debug_log("No turns to send")
@@ -870,6 +870,34 @@ const cozeloopTracePlugin = {
870
870
  parentSpanId: parentSpanId || ctx.rootSpanId,
871
871
  };
872
872
  };
873
+ const nativeTimingAttributes = (timing, fallbackSource = "reconstructed_session") => {
874
+ if (!timing)
875
+ return { timing_source: fallbackSource };
876
+ const attributes = {
877
+ timing_source: "native_model_call",
878
+ model_call_id: timing.callId,
879
+ model_call_outcome: timing.outcome,
880
+ };
881
+ if (timing.timeToFirstByteMs !== undefined)
882
+ attributes.time_to_first_byte_ms = timing.timeToFirstByteMs;
883
+ if (timing.requestPayloadBytes !== undefined)
884
+ attributes.request_payload_bytes = timing.requestPayloadBytes;
885
+ if (timing.responseStreamBytes !== undefined)
886
+ attributes.response_stream_bytes = timing.responseStreamBytes;
887
+ if (timing.errorCategory)
888
+ attributes.error_category = timing.errorCategory;
889
+ if (timing.failureKind)
890
+ attributes.failure_kind = timing.failureKind;
891
+ if (timing.outcome === "error")
892
+ attributes.error = true;
893
+ return attributes;
894
+ };
895
+ const consumeNativeModelTiming = (ctx) => {
896
+ const queue = ctx.completedNativeModelCalls;
897
+ if (!queue || queue.length === 0)
898
+ return undefined;
899
+ return queue.shift();
900
+ };
873
901
  const buildReactSpans = async (ctx, channelId, entries, initialInput, agentStartTime, userWrittenAt, skipCount, sessionUserContent) => {
874
902
  const entriesToSkip = skipCount || 0;
875
903
  const reactMessages = [];
@@ -947,8 +975,9 @@ const cozeloopTracePlugin = {
947
975
  modelSpanCount++;
948
976
  const provider = entry.provider || ctx.lastModelProvider || "unknown";
949
977
  const model = entry.model || ctx.lastModelId || "unknown";
950
- const spanStartTime = prevWrittenAt;
951
- const spanEndTime = entryWrittenAt;
978
+ const nativeTiming = consumeNativeModelTiming(ctx);
979
+ const spanStartTime = nativeTiming?.startTime ?? prevWrittenAt;
980
+ const spanEndTime = nativeTiming?.endTime ?? entryWrittenAt;
952
981
  const tokenUsage = readTokenUsage(entry.usage);
953
982
  const modelSpan = createSpan(ctx, channelId, `${provider}/${model}`, "model", spanStartTime, spanEndTime, {
954
983
  "gen_ai.provider.name": provider,
@@ -959,6 +988,7 @@ const cozeloopTracePlugin = {
959
988
  "input_tokens": tokenUsage.input,
960
989
  "output_tokens": tokenUsage.output,
961
990
  "react_round": reactRound,
991
+ ...nativeTimingAttributes(nativeTiming),
962
992
  }, { messages: reactMessages.map((msg) => safeClone(msg)) }, formatAssistantOutput(entry.content, entry.stopReason));
963
993
  await exporter.export(modelSpan);
964
994
  }
@@ -1161,9 +1191,47 @@ const cozeloopTracePlugin = {
1161
1191
  }
1162
1192
  });
1163
1193
  }
1164
- let lastLlmInput = undefined;
1165
- let lastLlmStartTime = undefined;
1166
- let lastLlmSpanId = undefined;
1194
+ if (shouldHookEnabled("model_call_started")) {
1195
+ on("model_call_started", async (event, hookCtx) => {
1196
+ const rawChannelId = resolveChannelId(hookCtx);
1197
+ const { ctx } = resolveActiveContext(rawChannelId, event.runId, "model_call_started", hookCtx);
1198
+ if (!ctx.nativeModelCallStarts)
1199
+ ctx.nativeModelCallStarts = new Map();
1200
+ ctx.nativeModelCallStarts.set(event.callId, {
1201
+ callId: event.callId,
1202
+ runId: event.runId,
1203
+ provider: event.provider,
1204
+ model: event.model,
1205
+ startTime: Date.now(),
1206
+ });
1207
+ });
1208
+ }
1209
+ if (shouldHookEnabled("model_call_ended")) {
1210
+ on("model_call_ended", async (event, hookCtx) => {
1211
+ const rawChannelId = resolveChannelId(hookCtx);
1212
+ const { ctx } = resolveActiveContext(rawChannelId, event.runId, "model_call_ended", hookCtx);
1213
+ const start = ctx.nativeModelCallStarts?.get(event.callId);
1214
+ if (!start)
1215
+ return;
1216
+ ctx.nativeModelCallStarts.delete(event.callId);
1217
+ const durationMs = Math.max(0, Number(event.durationMs) || 0);
1218
+ const endTime = Date.now();
1219
+ if (!ctx.completedNativeModelCalls)
1220
+ ctx.completedNativeModelCalls = [];
1221
+ ctx.completedNativeModelCalls.push({
1222
+ ...start,
1223
+ startTime: endTime - durationMs,
1224
+ endTime,
1225
+ durationMs,
1226
+ outcome: event.outcome,
1227
+ errorCategory: event.errorCategory,
1228
+ failureKind: event.failureKind,
1229
+ requestPayloadBytes: event.requestPayloadBytes,
1230
+ responseStreamBytes: event.responseStreamBytes,
1231
+ timeToFirstByteMs: event.timeToFirstByteMs,
1232
+ });
1233
+ });
1234
+ }
1167
1235
  if (shouldHookEnabled("llm_input")) {
1168
1236
  on("llm_input", async (event, hookCtx) => {
1169
1237
  const rawChannelId = resolveChannelId(hookCtx);
@@ -1248,9 +1316,6 @@ const cozeloopTracePlugin = {
1248
1316
  ctx.llmInput = {
1249
1317
  "messages": messages,
1250
1318
  };
1251
- lastLlmInput = ctx.llmInput;
1252
- lastLlmStartTime = ctx.llmStartTime;
1253
- lastLlmSpanId = ctx.llmSpanId;
1254
1319
  if (config.debug) {
1255
1320
  api.logger.info(`[CozeloopTrace] LLM input started: ${event.provider}/${event.model}, runId=${event.runId}, traceId=${ctx.traceId}`);
1256
1321
  }
@@ -1267,7 +1332,7 @@ const cozeloopTracePlugin = {
1267
1332
  }
1268
1333
  const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_output", hookCtx);
1269
1334
  const now = Date.now();
1270
- const startTime = ctx.llmStartTime || lastLlmStartTime || now;
1335
+ const startTime = ctx.llmStartTime || now;
1271
1336
  if (event.assistantTexts && event.assistantTexts.length > 0) {
1272
1337
  const outputText = event.assistantTexts.join("\n");
1273
1338
  ctx.lastOutput = outputText;
@@ -1279,8 +1344,8 @@ const cozeloopTracePlugin = {
1279
1344
  api.logger.info(`[CozeloopTrace] Captured output from llm_output (will use last): traceId=${ctx.traceId}, length=${outputText.length}`);
1280
1345
  }
1281
1346
  }
1282
- const llmInput = ctx.llmInput || lastLlmInput;
1283
- const llmSpanId = ctx.llmSpanId || lastLlmSpanId;
1347
+ const llmInput = ctx.llmInput;
1348
+ const llmSpanId = ctx.llmSpanId;
1284
1349
  if (config.debug) {
1285
1350
  api.logger.info(`[CozeloopTrace] llm_output ctx: traceId=${ctx.traceId}, rootSpanId=${ctx.rootSpanId}, llmSpanId=${llmSpanId || "none"}, hasInput=${!!llmInput}`);
1286
1351
  }
@@ -1289,7 +1354,7 @@ const cozeloopTracePlugin = {
1289
1354
  const { entries, userWrittenAt, userContent } = readCurrentTurnReactSequence(hookCtx);
1290
1355
  const hasAssistantEntry = entries.some((e) => e.type === "assistant");
1291
1356
  if (entries.length > 0 && hasAssistantEntry) {
1292
- const agentStart = ctx.agentStartTime || ctx.llmStartTime || lastLlmStartTime || now;
1357
+ const agentStart = ctx.agentStartTime || ctx.llmStartTime || now;
1293
1358
  const skipCount = ctx.sessionBasedExportedCount || 0;
1294
1359
  const modelCount = await buildReactSpans(ctx, channelId, entries, llmInput, agentStart, userWrittenAt, skipCount, userContent);
1295
1360
  if (modelCount > 0) {
@@ -1332,7 +1397,9 @@ const cozeloopTracePlugin = {
1332
1397
  "output_tokens": tokenUsage.output,
1333
1398
  };
1334
1399
  const finalOutput = formatAssistantOutput(event.assistantTexts?.map((t) => ({ type: "text", text: t })) ?? [], "stop");
1335
- const span = createSpan(ctx, channelId, `${event.provider}/${event.model}`, "model", startTime, now, spanAttributes, llmInput, finalOutput);
1400
+ const nativeTiming = consumeNativeModelTiming(ctx);
1401
+ Object.assign(spanAttributes, nativeTimingAttributes(nativeTiming, "llm_hooks_fallback"));
1402
+ const span = createSpan(ctx, channelId, `${event.provider}/${event.model}`, "model", nativeTiming?.startTime ?? startTime, nativeTiming?.endTime ?? now, spanAttributes, llmInput, finalOutput);
1336
1403
  if (llmSpanId) {
1337
1404
  span.spanId = llmSpanId;
1338
1405
  }
@@ -1350,9 +1417,6 @@ const cozeloopTracePlugin = {
1350
1417
  ctx.reactCount = 0;
1351
1418
  ctx.pendingToolSpans = undefined;
1352
1419
  ctx.sessionBasedSpansCreated = undefined;
1353
- lastLlmInput = undefined;
1354
- lastLlmStartTime = undefined;
1355
- lastLlmSpanId = undefined;
1356
1420
  });
1357
1421
  }
1358
1422
  if (shouldHookEnabled("before_tool_call")) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cozeloop-trace",
3
3
  "name": "OpenClaw CozeLoop Trace",
4
- "version": "0.1.21",
4
+ "version": "0.1.22",
5
5
  "description": "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
6
6
  "type": "plugin",
7
7
  "entry": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cozeloop/openclaw-cozeloop-trace",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "OpenClaw Plugin for reporting traces to CozeLoop via OpenTelemetry",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",