coze_lab 0.1.54 → 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.54",
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",
@@ -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,114 @@ 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 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
768
901
 
769
902
 
770
903
  def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -923,6 +1056,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
923
1056
  "input": args,
924
1057
  "custom": item_type == "custom_tool_call",
925
1058
  "_ts": payload.get("_ts"),
1059
+ "_line_number": entry.get("_line_number", -1),
926
1060
  })
927
1061
  pending_calls[call_id] = {"kind": "tool", "_start_ts": payload.get("_ts")}
928
1062
 
@@ -1481,14 +1615,15 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1481
1615
  return ctx
1482
1616
 
1483
1617
 
1484
- # --- 实时 step 级上报(方案 B:每个 tool_call 结束即可见)---------------------
1618
+ # --- 实时 model invocation 上报 -------------------------------------------
1485
1619
  #
1486
1620
  # 与 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 数。
1621
+ # to_header() 到 state["rt_root_header"];之后按 token_count 边界把模型可见内容、推理、工具调用和
1622
+ # last_token_usage 合成 model_call span,工具执行作为其子 span。全局行号水位线
1623
+ # state["rt_last_invocation_line"] 保证重复 hook 不重发。
1490
1624
 
1491
- 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):
1492
1627
  import cozeloop
1493
1628
  from cozeloop.spec.tracespec import Runtime
1494
1629
  token = get_fresh_token()
@@ -1562,197 +1697,162 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
1562
1697
  return None
1563
1698
  hook_log(f"[rt] root created trace_id={getattr(root_ctx,'trace_id','?')}")
1564
1699
 
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"))
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:
1710
+ since = time.time() - state.get("rt_last_upload_ts", 0)
1711
+ if since < REALTIME_BATCH_INTERVAL:
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
1587
1740
  mspan = client.start_span(
1588
- name=f"model_content_{line}", span_type="model",
1589
- start_time=start_dt, child_of=root_ctx,
1741
+ name=f"model_call_{invocation_sent + model_call_base}",
1742
+ span_type="model", start_time=start_dt, child_of=root_ctx,
1590
1743
  )
1591
1744
  mspan.set_runtime(Runtime(library="codex-cli"))
1592
1745
  try:
1593
1746
  mspan.set_model_name(model_name)
1594
1747
  except Exception:
1595
1748
  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),
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", "")
1601
1756
  ))
1602
- choice = ModelChoice(
1603
- finish_reason="stop",
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",
1604
1766
  index=0,
1605
1767
  message=ModelMessage(
1606
1768
  role="assistant",
1607
- content=content,
1608
- reasoning_content=reasoning,
1769
+ content=invocation.get("content", ""),
1770
+ reasoning_content=invocation.get("reasoning", ""),
1609
1771
  parts=[],
1610
1772
  name="",
1611
- tool_calls=[],
1773
+ tool_calls=model_tool_calls,
1612
1774
  tool_call_id="",
1613
1775
  metadata={},
1614
1776
  ),
1615
- )
1616
- mspan.set_output(ModelOutput(choices=[choice]))
1777
+ )]))
1778
+
1779
+ usage = invocation.get("usage") or {}
1780
+ in_tk = _usage_int(usage, "input_tokens")
1781
+ out_tk = _usage_int(usage, "output_tokens")
1782
+ if in_tk > 0:
1783
+ try:
1784
+ mspan.set_input_tokens(in_tk)
1785
+ except Exception:
1786
+ pass
1787
+ if out_tk > 0:
1788
+ try:
1789
+ mspan.set_output_tokens(out_tk)
1790
+ except Exception:
1791
+ pass
1792
+
1617
1793
  tags = {
1618
- "model_record_line": line,
1619
- "model_record_source": record.get("source", "unknown"),
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"),
1620
1799
  }
1621
- if protected:
1800
+ total = invocation.get("total_usage") or {}
1801
+ if total:
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"):
1622
1811
  tags.update({
1623
1812
  "protected_reasoning_source": protected.get("source", ""),
1624
1813
  "protected_reasoning_count": protected.get("count", 0),
1625
1814
  "protected_reasoning_bytes": protected.get("bytes", 0),
1626
1815
  })
1627
1816
  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
1817
  mspan_ctx = client.get_span_from_header(mspan.to_header())
1680
1818
  mspan.finish()
1681
1819
 
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:
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", {}))))
1690
1840
  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()
1841
+ tspan.finish()
1842
+ tool_sent += 1
1701
1843
 
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
1844
  client.flush()
1747
1845
  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
1846
+ hook_log(
1847
+ f"[rt] upload failed after invocation flush line={line} "
1848
+ f"detail={upload_events[-1][:500]}"
1849
+ )
1751
1850
  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
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
1756
1856
  state["rt_last_upload_ts"] = time.time()
1757
1857
 
1758
1858
  if is_terminal:
@@ -1774,13 +1874,17 @@ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_
1774
1874
  if upload_events:
1775
1875
  hook_log(f"[rt] upload failed after final flush detail={upload_events[-1][:500]}")
1776
1876
  return None
1777
- 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)}")
1778
1878
 
1779
1879
  if upload_events:
1780
1880
  hook_log(f"[rt] upload failed state not advanced detail={upload_events[-1][:500]}")
1781
1881
  return None
1782
1882
 
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}")
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
+ )
1784
1888
  return True
1785
1889
  except Exception as e:
1786
1890
  hook_log(f"[rt] error: {e}")
@@ -1855,8 +1959,7 @@ def main():
1855
1959
  state_file = get_state_file_path(transcript_path)
1856
1960
  state = load_state(state_file)
1857
1961
 
1858
- # 方案 B(实时):读【全量】entries 重建完整 turn/tool 序列。实时路径用全局 tool 水位线
1859
- # state["rt_last_tool"] 增量,每次触发把新完成的 tool_call 发出,结束即可见,不节流。
1962
+ # 实时路径读取全量 entries 重建 invocation 序列,再用 token 边界行号水位线增量上报。
1860
1963
  entries = read_rollout_messages(transcript_path, 0)
1861
1964
 
1862
1965
  if not entries:
@@ -1898,9 +2001,13 @@ def main():
1898
2001
 
1899
2002
  # Group entries into turns
1900
2003
  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")
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
+ )
1904
2011
 
1905
2012
  # If this is a subagent, save data for parent to include later
1906
2013
  if is_subagent:
@@ -1937,18 +2044,25 @@ def main():
1937
2044
  break
1938
2045
  coze_tags_override = state.get("coze_tags") or None
1939
2046
 
1940
- # 方案 B 实时:把新完成的 tool_call 作为 span 挂稳定 root,结束即可见。不节流。
2047
+ # 方案 B 实时:按 token_count 边界把内容、推理、工具调用和 usage 合成 model_call。
1941
2048
  ok = send_steps_realtime(turns, session_id, state, model_name=model_name,
1942
2049
  coze_tags_override=coze_tags_override,
1943
2050
  is_terminal=is_terminal_event,
1944
- token_events=token_events)
2051
+ model_invocations=model_invocations)
1945
2052
  if ok is None:
1946
2053
  hook_log(f"realtime send failed session_id={session_id}")
1947
2054
  debug_log("Realtime send failed, state not advanced")
1948
2055
  return
1949
2056
  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')}")
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
+ )
1952
2066
  else:
1953
2067
  hook_log(f"skip no turns session_id={session_id}")
1954
2068
  debug_log("No turns to send")