coze_lab 0.1.41 → 0.1.42

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.41",
3
+ "version": "0.1.42",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -127,9 +127,9 @@ _DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
127
127
  _COZE_CTX_OPEN = "<coze-context>"
128
128
  _COZE_CTX_CLOSE = "</coze-context>"
129
129
 
130
- # 中途事件(PostToolUse)增量上报的最小间隔(秒)。密集工具调用下用它节流,避免每秒多次 flush。
131
- # 终态事件(Stop)不受此限制。
132
- INCREMENTAL_UPLOAD_MIN_INTERVAL = float(os.environ.get("COZELOOP_INCREMENTAL_MIN_INTERVAL", "10"))
130
+ # 实时 step 上报的批量间隔(秒):距上次上报不足此值则跳过本次,把已结束的 span 攒到下次一起发,
131
+ # 最多每 REALTIME_BATCH_INTERVAL 秒一批,压低请求数。终态(Stop)不受限,保证收尾即时完整。
132
+ REALTIME_BATCH_INTERVAL = float(os.environ.get("COZELOOP_REALTIME_BATCH_INTERVAL", "5"))
133
133
 
134
134
 
135
135
  def _content_to_text(content: Any) -> str:
@@ -1615,6 +1615,249 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
1615
1615
  return True
1616
1616
 
1617
1617
 
1618
+ # --- 实时 step 级上报(方案 B:每个 step span 结束即可见)---------------------
1619
+ #
1620
+ # 设计:一个会话(thread)= 一条稳定 trace。首次触发时建 root span(claude_code_request)
1621
+ # 并把它的 to_header()(含 trace_id + root span_id + baggage)持久化到 state["rt_root_header"]。
1622
+ # 之后每次 hook 触发,从 header 重建 root context,把【自上次以来新完成的 step】作为
1623
+ # model_span(+其下 tool_span) 挂到固定 root 下、当场 flush —— 于是每个 step 结束即可查。
1624
+ # step 级水位线用 state["rt_last_global_step"] 跟踪(按 (turn_index, step_index) 线性展开)。
1625
+ #
1626
+ # 与整树 send_turns_to_cozeloop 的关系:实时路径独立成 trace,不依赖也不复用整树逻辑。
1627
+ # 终态(Stop)时调用 finalize_realtime_root 收尾(补 root 的 output、finish)。
1628
+
1629
+ def _rt_state_key_header():
1630
+ return "rt_root_header"
1631
+
1632
+ def _flatten_completed_steps(turns: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1633
+ """把 turns 线性展开成 step 列表;只取【已完成】的 step。
1634
+
1635
+ 一个 step(assistant 一次模型调用 + 其触发的 tool_results)视为已完成的判定:
1636
+ 它后面还有别的 step/turn(说明模型已经继续往下走),或它带有 tool_results。
1637
+ 最后一个 step 若没有 tool_results 且无后继,视为仍可能在进行中 —— 留到下次。
1638
+ 返回的每项含: turn, step, turn_index, step_index, is_last_known。
1639
+ """
1640
+ flat = []
1641
+ for ti, turn in enumerate(turns):
1642
+ steps = turn.get("steps", [])
1643
+ for si, step in enumerate(steps):
1644
+ flat.append({"turn": turn, "step": step, "turn_index": ti, "step_index": si})
1645
+ # 标记每个 step 是否“已完成”:非全局最后一个 step 一定已完成;
1646
+ # 全局最后一个 step 仅当它有 tool_results(工具已回结果)才算完成。
1647
+ completed = []
1648
+ for idx, item in enumerate(flat):
1649
+ is_global_last = (idx == len(flat) - 1)
1650
+ has_results = bool(item["step"].get("tool_results"))
1651
+ if (not is_global_last) or has_results:
1652
+ completed.append(item)
1653
+ return completed, len(flat)
1654
+
1655
+
1656
+ def send_steps_realtime(turns, session_id, history_turns, state, coze_tags_override=None, is_terminal=False):
1657
+ """实时上报:把新完成的 step 作为 span 挂到稳定 root,结束即 flush。
1658
+
1659
+ 返回 True 表示本次有推进(已发或已收尾),None 表示失败(state 不前进)。
1660
+ """
1661
+ token = get_fresh_token()
1662
+ if token:
1663
+ os.environ["COZELOOP_API_TOKEN"] = token
1664
+ workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
1665
+ client_kwargs = {"ultra_large_report": True, "upload_timeout": 120}
1666
+ if workspace_id:
1667
+ client_kwargs["workspace_id"] = workspace_id
1668
+ if token:
1669
+ client_kwargs["api_token"] = token
1670
+ api_base_url = get_api_base_url()
1671
+ if api_base_url:
1672
+ client_kwargs["api_base_url"] = api_base_url
1673
+
1674
+ try:
1675
+ client = cozeloop.new_client(**client_kwargs)
1676
+ except Exception as e:
1677
+ debug_log(f"[rt] new_client failed: {e}")
1678
+ return None
1679
+
1680
+ try:
1681
+ # ---- coze_tags(root 用,保证 root span 可按 message_id 查到)----
1682
+ coze_tags = {}
1683
+ for turn in list(turns) + list(history_turns or []):
1684
+ um = turn.get("user_message", {}).get("message", {})
1685
+ t = coze_context_tags(um.get("content") if um else None)
1686
+ if t:
1687
+ coze_tags = t
1688
+ break
1689
+ if not coze_tags and coze_tags_override:
1690
+ coze_tags = dict(coze_tags_override)
1691
+ coze_tags = {k: v for k, v in coze_tags.items() if isinstance(v, str) and v.strip()}
1692
+
1693
+ # ---- 取/建稳定 root ----
1694
+ root_header = state.get(_rt_state_key_header())
1695
+ root_ctx = None
1696
+ if root_header:
1697
+ try:
1698
+ root_ctx = client.get_span_from_header(root_header)
1699
+ except Exception as e:
1700
+ debug_log(f"[rt] rebuild root from header failed: {e}")
1701
+ root_ctx = None
1702
+ if root_ctx is None or not getattr(root_ctx, "trace_id", ""):
1703
+ # 首次:建 root span,立即发出(input=首条 user 文本),存 header。root 不在此处 finish,
1704
+ # 用 start_new_trace 确保独立一条 trace。
1705
+ first_user_text = ""
1706
+ for turn in turns:
1707
+ um = turn.get("user_message", {}).get("message", {})
1708
+ uc = um.get("content") if um else None
1709
+ if not is_empty_content(uc):
1710
+ first_user_text = format_content(uc)
1711
+ break
1712
+ root_start_dt = None
1713
+ if turns:
1714
+ root_start_dt = _parse_ts(turns[0].get("user_message"))
1715
+ root_span = client.start_span(name="claude_code_request", span_type="main",
1716
+ start_time=root_start_dt, start_new_trace=True)
1717
+ root_span.set_runtime(Runtime(library="claude-code"))
1718
+ rtags = {"thread_id": session_id, "source": "claude_code", "realtime": True}
1719
+ rtags.update(coze_tags)
1720
+ root_span.set_tags(rtags)
1721
+ rbag = {"thread_id": session_id}
1722
+ rbag.update(coze_tags)
1723
+ root_span.set_baggage(rbag)
1724
+ if first_user_text:
1725
+ root_span.set_input(first_user_text)
1726
+ state[_rt_state_key_header()] = root_span.to_header()
1727
+ root_ctx = client.get_span_from_header(state[_rt_state_key_header()])
1728
+ # root 立即 finish 落库(后端 ResolveTraceIDByMessageID 按 root_span 查,root 必须可查)。
1729
+ # 实测:root finish 后,子 span 仍可用其 header 挂到同一 trace_id 下,不影响后续增量。
1730
+ root_span.finish()
1731
+ client.flush()
1732
+ debug_log(f"[rt] root created+finished trace_id={getattr(root_ctx,'trace_id','?')}")
1733
+
1734
+ # ---- 发新完成的 step ----
1735
+ completed, total_steps = _flatten_completed_steps(turns)
1736
+ last_global = state.get("rt_last_global_step", 0)
1737
+ new_items = completed[last_global:]
1738
+ # 批量节流:非终态时,距上次上报不足 REALTIME_BATCH_INTERVAL 秒则本次不发,
1739
+ # 把已结束的 step 攒到下次触发一起发(最多每 5s 一批)。终态(Stop)不节流,立即收尾。
1740
+ if new_items and not is_terminal:
1741
+ since = time.time() - state.get("rt_last_upload_ts", 0)
1742
+ if since < REALTIME_BATCH_INTERVAL:
1743
+ debug_log(f"[rt] batch throttle: {since:.1f}s<{REALTIME_BATCH_INTERVAL}s, defer {len(new_items)} step(s)")
1744
+ new_items = []
1745
+ sent = 0
1746
+ for item in new_items:
1747
+ step = item["step"]
1748
+ assistant_msg = step.get("assistant_message", {})
1749
+ amo = assistant_msg.get("message", {})
1750
+ model_name = amo.get("model", "claude-code")
1751
+ gidx = last_global + sent # 全局 step 序号,用于命名
1752
+ step_start_dt = _parse_ts(assistant_msg)
1753
+ step_end_dt = None
1754
+ for r in step.get("tool_results", []):
1755
+ te = _parse_ts_str(r.get("_result_ts"))
1756
+ if te:
1757
+ step_end_dt = te
1758
+
1759
+ # model_span 挂 root
1760
+ mspan = client.start_span(name=f"model_call_{gidx}", span_type="model",
1761
+ start_time=step_start_dt, child_of=root_ctx)
1762
+ mspan.set_runtime(Runtime(library="claude-code"))
1763
+ try:
1764
+ mspan.set_model_name(model_name)
1765
+ except Exception:
1766
+ pass
1767
+ if step_start_dt is not None and step_end_dt is not None:
1768
+ _set_finish_time_safe(mspan, step_end_dt)
1769
+ raw_content = amo.get("content", [])
1770
+ text_parts = []
1771
+ if isinstance(raw_content, list):
1772
+ for it in raw_content:
1773
+ if isinstance(it, dict) and it.get("type") == "text" and it.get("text"):
1774
+ text_parts.append(it["text"])
1775
+ elif isinstance(raw_content, str):
1776
+ text_parts.append(raw_content)
1777
+ mspan.set_input(format_content((assistant_msg.get("message", {}) or {}).get("content")))
1778
+ if text_parts:
1779
+ mspan.set_output("\n".join(text_parts))
1780
+ usage = amo.get("usage", {})
1781
+ it_tok = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0)
1782
+ if it_tok > 0:
1783
+ try: mspan.set_input_tokens(it_tok)
1784
+ except Exception: pass
1785
+ if usage.get("output_tokens", 0) > 0:
1786
+ try: mspan.set_output_tokens(usage["output_tokens"])
1787
+ except Exception: pass
1788
+ mspan_ctx = client.get_span_from_header(mspan.to_header())
1789
+ mspan.finish()
1790
+
1791
+ # tool_span 挂 model_span
1792
+ for tc in step.get("tool_calls", []):
1793
+ tname = tc.get("name", "unknown")
1794
+ tid = tc.get("id")
1795
+ tool_end_dt = None
1796
+ for r in step.get("tool_results", []):
1797
+ if r.get("tool_use_id") == tid:
1798
+ tool_end_dt = _parse_ts_str(r.get("_result_ts"))
1799
+ break
1800
+ tspan = client.start_span(name=f"tool_{tname}", span_type="tool",
1801
+ start_time=step_start_dt, child_of=mspan_ctx)
1802
+ tspan.set_runtime(Runtime(library="claude-code"))
1803
+ if tool_end_dt is not None:
1804
+ _set_finish_time_safe(tspan, tool_end_dt)
1805
+ tspan.set_tags({"tool_name": tname, "tool_call_id": tid, "step_index": gidx})
1806
+ tspan.set_input(json.dumps(tc.get("input", {}), ensure_ascii=False)[:2000])
1807
+ for r in step.get("tool_results", []):
1808
+ if r.get("tool_use_id") == tid:
1809
+ tspan.set_output(_format_tool_output(r.get("content", "")))
1810
+ break
1811
+ tspan.finish()
1812
+ sent += 1
1813
+ # 每个 step 立即 flush —— 这是“结束即可见”的关键。
1814
+ client.flush()
1815
+
1816
+ new_last = last_global + sent
1817
+ state["rt_last_global_step"] = new_last
1818
+ if sent > 0:
1819
+ state["rt_last_upload_ts"] = time.time()
1820
+
1821
+ # ---- 收尾:终态时补 root output 并 finish ----
1822
+ if is_terminal:
1823
+ last_output = None
1824
+ for turn in reversed(turns):
1825
+ for step in reversed(turn.get("steps", [])):
1826
+ amo = step.get("assistant_message", {}).get("message", {})
1827
+ c = amo.get("content", [])
1828
+ if isinstance(c, list):
1829
+ tp = [x.get("text", "") for x in c if isinstance(x, dict) and x.get("type") == "text" and x.get("text")]
1830
+ if tp:
1831
+ last_output = "\n".join(tp); break
1832
+ elif isinstance(c, str) and c.strip():
1833
+ last_output = c; break
1834
+ if last_output:
1835
+ break
1836
+ if root_ctx is not None and last_output:
1837
+ # 重发一个同 trace 的收尾 span 承载最终输出(root 对象已不可用,用子 span 兜底)。
1838
+ fin = client.start_span(name="final_response", span_type="main", child_of=root_ctx)
1839
+ fin.set_runtime(Runtime(library="claude-code"))
1840
+ fin.set_output(last_output)
1841
+ fin.finish()
1842
+ client.flush()
1843
+ debug_log(f"[rt] finalized, total sent steps={new_last}")
1844
+
1845
+ debug_log(f"[rt] sent {sent} new step(s), last_global={new_last}/{total_steps}, terminal={is_terminal}")
1846
+ return True
1847
+ except Exception as e:
1848
+ debug_log(f"[rt] send_steps_realtime error: {e}")
1849
+ return None
1850
+ finally:
1851
+ try:
1852
+ client.flush()
1853
+ except Exception:
1854
+ pass
1855
+ try:
1856
+ client.close()
1857
+ except Exception:
1858
+ pass
1859
+
1860
+
1618
1861
  # --- Hook Input ---
1619
1862
 
1620
1863
  def read_hook_stdin() -> Dict[str, Any]:
@@ -1674,28 +1917,19 @@ def main():
1674
1917
  debug_log(f"Using conversation file: {conversation_file}")
1675
1918
  print(f"[CozeLoop] 读取会话文件: {conversation_file}", file=sys.stderr)
1676
1919
 
1677
- # Load state to know where to start reading
1920
+ # Load state
1678
1921
  state_file = get_state_file_path(conversation_file)
1679
1922
  state = load_state(state_file)
1680
- last_processed_line = state.get("last_processed_line", 0)
1681
-
1682
- # 节流:PostToolUse 在密集工具调用下会高频触发。距上次上报不足 INCREMENTAL_UPLOAD_MIN_INTERVAL
1683
- # 秒则跳过本次增量上报,避免每秒多次 flush 抬高上报量/成本。终态事件(Stop)永不被节流,
1684
- # 保证任务结束时一定收尾上报最后一批。
1685
- if not is_terminal_event:
1686
- now_ts = time.time()
1687
- last_upload_ts = state.get("last_upload_ts", 0)
1688
- if now_ts - last_upload_ts < INCREMENTAL_UPLOAD_MIN_INTERVAL:
1689
- debug_log(f"throttled: event={hook_event} since_last={now_ts - last_upload_ts:.1f}s < {INCREMENTAL_UPLOAD_MIN_INTERVAL}s, skip")
1690
- return
1691
-
1692
- # Read new messages from the file
1693
- new_messages = read_new_messages(conversation_file, last_processed_line)
1694
-
1695
- # Determine session ID: prefer stdin, then messages, then state, then generate
1923
+
1924
+ # 方案 B(实时 step 级上报):读【全量】消息重建完整 turn/step 序列。实时路径用全局 step
1925
+ # 水位线 state["rt_last_global_step"] 增量,不再用 last_processed_line(那是整树增量的水位线)。
1926
+ # 每次 hook 触发(PostToolUse=每次工具调用后)都把新完成的 step 发出,结束即可见,不节流。
1927
+ all_messages = read_new_messages(conversation_file, 0)
1928
+
1929
+ # Determine session ID
1696
1930
  session_id = hook_input.get("session_id")
1697
1931
  if not session_id:
1698
- for msg in new_messages:
1932
+ for msg in all_messages:
1699
1933
  if msg.get("sessionId"):
1700
1934
  session_id = msg.get("sessionId")
1701
1935
  break
@@ -1704,83 +1938,48 @@ def main():
1704
1938
  session_id = state["session_id"]
1705
1939
  else:
1706
1940
  session_id = f"claude-code-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{os.getpid()}"
1707
- debug_log(f"Generated new session ID: {session_id}")
1708
-
1709
1941
  state["session_id"] = session_id
1710
- debug_log(f"Session ID: {session_id}")
1942
+ debug_log(f"Session ID: {session_id}, event={hook_event}")
1711
1943
 
1712
- if not new_messages:
1713
- debug_log("No new messages to process.")
1944
+ if not all_messages:
1945
+ debug_log("No messages to process.")
1714
1946
  return
1715
1947
 
1716
- debug_log(f"Found {len(new_messages)} new messages.")
1717
-
1718
- # Read historical messages to build context for model input
1719
- history_turns = []
1720
- if last_processed_line > 0:
1721
- historical_messages = read_new_messages(conversation_file, 0)
1722
- historical_messages = [m for m in historical_messages if m.get("_line_number", 0) < last_processed_line]
1723
- history_turns = group_messages_into_turns(historical_messages)
1724
- debug_log(f"Loaded {len(history_turns)} historical turn(s) for context.")
1725
-
1726
- # Group messages into turns and send to CozeLoop — only if coze-context present.
1727
- turns = group_messages_into_turns(new_messages)
1728
- if turns:
1729
- # coze-context 只出现在首条用户消息里(cozelab 注入的 agent 才有)。增量 hook 触发时,
1730
- # 新消息往往全是工具调用/结果(不带 coze-context),故必须连同历史 turns 一起判断——
1731
- # 只要整个会话出现过 coze-context,就说明是被注入的 agent,后续增量都应上报。
1732
- def _turn_has_ctx(turn):
1733
- return bool(coze_context_tags(
1734
- (turn.get("user_message", {}).get("message", {}) or {}).get("content")
1735
- ))
1736
- has_coze_ctx = any(_turn_has_ctx(t) for t in turns) or any(_turn_has_ctx(t) for t in history_turns)
1737
- if not has_coze_ctx:
1738
- debug_log("No coze-context found in any turn (incl. history), skipping upload.")
1739
- return
1740
-
1741
- # 持久化 coze_tags 到 state:<coze-context> 只在首 turn,后续增量批次的 turns 不含它,
1742
- # 其 root span 会缺 coze_message_id 而无法按 message_id 查到。首次解析到就存 state,
1743
- # 后续批次作为 override 传给 send_turns,保证每个 turn 的 root span 都带 coze_* tag。
1744
- for _t in list(turns) + list(history_turns):
1948
+ # 全量分组成 turns/steps
1949
+ turns = group_messages_into_turns(all_messages)
1950
+ if not turns:
1951
+ debug_log("No turns.")
1952
+ return
1953
+
1954
+ # coze-context 判定:整个会话出现过即可(首 turn 注入)。
1955
+ def _turn_has_ctx(turn):
1956
+ return bool(coze_context_tags(
1957
+ (turn.get("user_message", {}).get("message", {}) or {}).get("content")
1958
+ ))
1959
+ if not any(_turn_has_ctx(t) for t in turns):
1960
+ debug_log("No coze-context in any turn, skipping upload.")
1961
+ return
1962
+
1963
+ # 持久化 coze_tags 到 state,供 root span 注入(保证可按 message_id 查到)。
1964
+ if not state.get("coze_tags"):
1965
+ for _t in turns:
1745
1966
  _um = (_t.get("user_message", {}).get("message", {}) or {})
1746
1967
  _tags = {k: v for k, v in coze_context_tags(_um.get("content")).items() if isinstance(v, str) and v.strip()}
1747
1968
  if _tags:
1748
1969
  state["coze_tags"] = _tags
1749
1970
  break
1750
- coze_tags_override = state.get("coze_tags") or None
1751
-
1752
- # turn 边界控制:中途事件(PostToolUse)触发时,最后一个 turn 往往仍在进行中
1753
- # (后续还会追加 step)。若此刻就上报并推进其行号,同一逻辑 turn 会在下次触发时
1754
- # 因缺了起始 user 消息而被拆成新的 root span。故中途事件只上报“已完成”的 turn
1755
- # (= 除最后一个之外的所有 turn),把最后一个留到下次/收尾。终态事件(Stop)上报全部。
1756
- if is_terminal_event:
1757
- turns_to_send = turns
1758
- else:
1759
- turns_to_send = turns[:-1]
1760
- if not turns_to_send:
1761
- debug_log(f"event={hook_event}: no completed turn to send yet (turns={len(turns)}), defer")
1762
- return
1763
-
1764
- print(f"[CozeLoop] 开始上报: session={session_id}, event={hook_event}, turns={len(turns_to_send)}/{len(turns)}", file=sys.stderr)
1765
- uploaded = send_turns_to_cozeloop(turns_to_send, session_id, history_turns, coze_tags_override=coze_tags_override)
1766
- if uploaded is None:
1767
- debug_log("Send failed, state not advanced.")
1768
- return
1769
-
1770
- # 推进 last_processed_line:只推进到已上报 turn 覆盖的最后一行。中途事件保留了最后一个
1771
- # 未完成 turn,故推进到“倒数第二个 turn 的末行”,让未完成 turn 的所有行下次重新读取。
1772
- if is_terminal_event:
1773
- last_line_in_batch = max(msg.get("_line_number", 0) for msg in new_messages)
1774
- state["last_processed_line"] = last_line_in_batch + 1
1775
- else:
1776
- # turns_to_send 是 turns[:-1],下一个未发送 turn 的起始行即新的水位线。
1777
- next_turn_start = turns[-1].get("start_line", 0)
1778
- state["last_processed_line"] = next_turn_start
1779
- state["last_upload_ts"] = time.time()
1780
- save_state(state_file, state)
1781
- print(f"[CozeLoop] 上报完成 ✓ session={session_id}, turns={len(turns_to_send)}", file=sys.stderr)
1782
- debug_log(f"State updated. event={hook_event} last_processed_line={state['last_processed_line']}")
1783
-
1971
+ coze_tags_override = state.get("coze_tags") or None
1972
+
1973
+ print(f"[CozeLoop] 实时上报: session={session_id}, event={hook_event}", file=sys.stderr)
1974
+ ok = send_steps_realtime(turns, session_id, [], state,
1975
+ coze_tags_override=coze_tags_override,
1976
+ is_terminal=is_terminal_event)
1977
+ if ok is None:
1978
+ debug_log("Realtime send failed, state not advanced.")
1979
+ return
1980
+ save_state(state_file, state)
1981
+ print(f"[CozeLoop] 实时上报完成 ✓ session={session_id}, last_step={state.get('rt_last_global_step')}", file=sys.stderr)
1982
+ debug_log(f"State saved. rt_last_global_step={state.get('rt_last_global_step')}")
1784
1983
  debug_log("Hook finished.")
1785
1984
 
1786
1985
  if __name__ == "__main__":
@@ -50,9 +50,9 @@ _REFRESH_LOCK_STALE = 30
50
50
  _DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
51
51
  _OTEL_SUFFIX = "/v1/loop/opentelemetry"
52
52
 
53
- # 中途事件(PostToolUse)增量上报的最小间隔(秒)。密集工具调用下用它节流,避免每次工具调用都 flush。
54
- # 终态事件(Stop/SubagentStop)不受此限制。
55
- INCREMENTAL_UPLOAD_MIN_INTERVAL = float(os.environ.get("COZELOOP_INCREMENTAL_MIN_INTERVAL", "10"))
53
+ # 实时 tool span 上报的批量间隔(秒):距上次上报不足此值则本次不发,把已结束的 tool span
54
+ # 攒到下次一起发(最多每 5s 一批)。终态(Stop/SubagentStop)不受限,立即收尾。
55
+ REALTIME_BATCH_INTERVAL = float(os.environ.get("COZELOOP_REALTIME_BATCH_INTERVAL", "5"))
56
56
 
57
57
 
58
58
  # --- coze-context parsing -------------------------------------------------
@@ -1552,6 +1552,165 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1552
1552
  return ctx
1553
1553
 
1554
1554
 
1555
+ # --- 实时 step 级上报(方案 B:每个 tool_call 结束即可见)---------------------
1556
+ #
1557
+ # 与 claude-code 同架构:一个会话 = 一条稳定 trace。首次建 root(codex_request)立即 finish 并存
1558
+ # to_header() 到 state["rt_root_header"];之后每次触发把【新完成的 tool_call】(有 result 的)作为
1559
+ # tool span 挂到固定 root、当场 flush。codex 的实时单元是 tool_call(turn 内平铺,按 call_id 关联
1560
+ # result)。全局水位线 state["rt_last_tool"] 跟踪已发的 tool_call 数。
1561
+
1562
+ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_override=None, is_terminal=False):
1563
+ import cozeloop
1564
+ from cozeloop.spec.tracespec import Runtime
1565
+ token = get_fresh_token()
1566
+ if token:
1567
+ os.environ["COZELOOP_API_TOKEN"] = token
1568
+ workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
1569
+ client_kwargs = {"ultra_large_report": True, "upload_timeout": 120}
1570
+ if workspace_id:
1571
+ client_kwargs["workspace_id"] = workspace_id
1572
+ if token:
1573
+ client_kwargs["api_token"] = token
1574
+ api_base_url = get_api_base_url()
1575
+ if api_base_url:
1576
+ client_kwargs["api_base_url"] = api_base_url
1577
+ try:
1578
+ client = cozeloop.new_client(**client_kwargs)
1579
+ except Exception as e:
1580
+ hook_log(f"[rt] new_client failed: {e}")
1581
+ return None
1582
+
1583
+ try:
1584
+ # coze_tags
1585
+ coze_tags = {}
1586
+ for turn in turns:
1587
+ t = {f"coze_{k}": v for k, v in turn_coze_context(turn).items()}
1588
+ if t:
1589
+ coze_tags = t
1590
+ break
1591
+ if not coze_tags and coze_tags_override:
1592
+ coze_tags = dict(coze_tags_override)
1593
+ coze_tags = {k: v for k, v in coze_tags.items() if isinstance(v, str) and v.strip()}
1594
+
1595
+ # 取/建稳定 root
1596
+ root_header = state.get("rt_root_header")
1597
+ root_ctx = None
1598
+ if root_header:
1599
+ try:
1600
+ root_ctx = client.get_span_from_header(root_header)
1601
+ except Exception:
1602
+ root_ctx = None
1603
+ if root_ctx is None or not getattr(root_ctx, "trace_id", ""):
1604
+ first_user_text = ""
1605
+ for turn in turns:
1606
+ if turn.get("user_message_text"):
1607
+ first_user_text = turn["user_message_text"]
1608
+ break
1609
+ root_start_dt = _parse_ts_value(turns[0].get("tool_calls", [{}])[0].get("_ts")) if turns and turns[0].get("tool_calls") else None
1610
+ root_span = client.start_span(name="codex_request", span_type="main",
1611
+ start_time=root_start_dt, start_new_trace=True)
1612
+ root_span.set_runtime(Runtime(library="codex-cli"))
1613
+ rtags = {"thread_id": session_id, "source": "codex_cli", "realtime": True}
1614
+ rtags.update(coze_tags)
1615
+ root_span.set_tags(rtags)
1616
+ rbag = {"thread_id": session_id}
1617
+ rbag.update(coze_tags)
1618
+ root_span.set_baggage(rbag)
1619
+ if first_user_text:
1620
+ root_span.set_input(truncate_text(first_user_text))
1621
+ state["rt_root_header"] = root_span.to_header()
1622
+ root_ctx = client.get_span_from_header(state["rt_root_header"])
1623
+ root_span.finish() # 立即 finish 落库(后端按 root_span 查),子 span 仍可挂同 trace
1624
+ client.flush()
1625
+ hook_log(f"[rt] root created trace_id={getattr(root_ctx,'trace_id','?')}")
1626
+
1627
+ # 线性展开所有 tool_call(带 result 的才算完成),按全局序增量发
1628
+ flat = []
1629
+ for ti, turn in enumerate(turns):
1630
+ results = {r.get("call_id"): r for r in turn.get("tool_results", [])}
1631
+ for tc in turn.get("tool_calls", []):
1632
+ cid = tc.get("call_id")
1633
+ flat.append({"tc": tc, "result": results.get(cid), "turn_index": ti})
1634
+ # 只发已完成(有 result)的,且全局最后一个若无 result 则留到下次
1635
+ completed = []
1636
+ for idx, item in enumerate(flat):
1637
+ is_last = (idx == len(flat) - 1)
1638
+ if item["result"] is not None or (not is_last):
1639
+ completed.append(item)
1640
+ last_tool = state.get("rt_last_tool", 0)
1641
+ new_items = completed[last_tool:]
1642
+ # 批量节流:非终态时距上次上报<5s 则本次不发,攒到下次一起发。终态不节流。
1643
+ if new_items and not is_terminal:
1644
+ since = time.time() - state.get("rt_last_upload_ts", 0)
1645
+ if since < REALTIME_BATCH_INTERVAL:
1646
+ hook_log(f"[rt] batch throttle {since:.1f}s<{REALTIME_BATCH_INTERVAL}s defer {len(new_items)} tool(s)")
1647
+ new_items = []
1648
+ sent = 0
1649
+ for item in new_items:
1650
+ tc = item["tc"]; result = item["result"]
1651
+ gidx = last_tool + sent
1652
+ tname = tc.get("name", "unknown")
1653
+ start_dt = _parse_ts_value(tc.get("_ts"))
1654
+ end_dt = _parse_ts_value(result.get("_ts")) if result else None
1655
+ # 每个 tool_call 用一个 model_call_+tool 子树(与 claude-code 视觉一致)
1656
+ mspan = client.start_span(name=f"model_call_{gidx}", span_type="model",
1657
+ start_time=start_dt, child_of=root_ctx)
1658
+ mspan.set_runtime(Runtime(library="codex-cli"))
1659
+ try: mspan.set_model_name(model_name)
1660
+ except Exception: pass
1661
+ if start_dt and end_dt:
1662
+ _set_finish_time_safe(mspan, end_dt)
1663
+ mspan.set_input(truncate_text(json.dumps(tc.get("input", {}), ensure_ascii=False)))
1664
+ mspan_ctx = client.get_span_from_header(mspan.to_header())
1665
+ mspan.finish()
1666
+
1667
+ tspan = client.start_span(name=f"tool_{tname}", span_type="tool",
1668
+ start_time=start_dt, child_of=mspan_ctx)
1669
+ tspan.set_runtime(Runtime(library="codex-cli"))
1670
+ if end_dt:
1671
+ _set_finish_time_safe(tspan, end_dt)
1672
+ tspan.set_tags({"tool_name": tname, "tool_call_id": tc.get("call_id"), "step_index": gidx})
1673
+ tspan.set_input(truncate_text(json.dumps(tc.get("input", {}), ensure_ascii=False)))
1674
+ if result is not None:
1675
+ tspan.set_output(truncate_text(str(result.get("output", ""))))
1676
+ tspan.finish()
1677
+ sent += 1
1678
+ client.flush() # 每个 tool_call 立即 flush → 结束即可见
1679
+
1680
+ state["rt_last_tool"] = last_tool + sent
1681
+ if sent > 0:
1682
+ state["rt_last_upload_ts"] = time.time()
1683
+
1684
+ if is_terminal:
1685
+ # 收尾:补最终 assistant 输出
1686
+ last_out = ""
1687
+ for turn in reversed(turns):
1688
+ for am in reversed(turn.get("assistant_messages", [])):
1689
+ txt = extract_assistant_text(am)
1690
+ if txt:
1691
+ last_out = txt; break
1692
+ if last_out:
1693
+ break
1694
+ if last_out:
1695
+ fin = client.start_span(name="final_response", span_type="main", child_of=root_ctx)
1696
+ fin.set_runtime(Runtime(library="codex-cli"))
1697
+ fin.set_output(truncate_text(last_out))
1698
+ fin.finish()
1699
+ client.flush()
1700
+ hook_log(f"[rt] finalized total_tools={state['rt_last_tool']}")
1701
+
1702
+ hook_log(f"[rt] sent {sent} tool span(s), last_tool={state['rt_last_tool']}, terminal={is_terminal}")
1703
+ return True
1704
+ except Exception as e:
1705
+ hook_log(f"[rt] error: {e}")
1706
+ return None
1707
+ finally:
1708
+ try: client.flush()
1709
+ except Exception: pass
1710
+ try: client.close()
1711
+ except Exception: pass
1712
+
1713
+
1555
1714
  # --- Main Execution ---
1556
1715
 
1557
1716
  def main():
@@ -1615,19 +1774,19 @@ def main():
1615
1774
  state_file = get_state_file_path(transcript_path)
1616
1775
  state = load_state(state_file)
1617
1776
 
1618
- # Read new entries
1619
- entries = read_rollout_messages(transcript_path, state["last_processed_line"])
1777
+ # 方案 B(实时):读【全量】entries 重建完整 turn/tool 序列。实时路径用全局 tool 水位线
1778
+ # state["rt_last_tool"] 增量,每次触发把新完成的 tool_call 发出,结束即可见,不节流。
1779
+ entries = read_rollout_messages(transcript_path, 0)
1620
1780
 
1621
1781
  if not entries:
1622
- hook_log(f"skip no new entries transcript={transcript_path}")
1623
- debug_log("No new entries to process")
1782
+ hook_log(f"skip no entries transcript={transcript_path}")
1783
+ debug_log("No entries to process")
1624
1784
  return
1625
1785
 
1626
- hook_log(f"read entries={len(entries)} from_line={state['last_processed_line']} transcript={transcript_path}")
1627
- debug_log(f"Read {len(entries)} new entries from line {state['last_processed_line']}")
1786
+ hook_log(f"read entries={len(entries)} (full) transcript={transcript_path}")
1628
1787
 
1629
1788
  # Parse session identity
1630
- all_entries_for_meta = read_rollout_messages(transcript_path, 0)
1789
+ all_entries_for_meta = entries
1631
1790
  session_info = parse_session_meta(all_entries_for_meta)
1632
1791
 
1633
1792
  session_id = session_info["session_id"] or hook_input.get("session_id", "")
@@ -1679,25 +1838,14 @@ def main():
1679
1838
 
1680
1839
  # Send turns to CozeLoop — only if at least one turn carries coze-context.
1681
1840
  if turns:
1682
- # coze-context 只出现在首个 turn 的 user 消息里。增量上报推进后,后续批次的 turns
1683
- # 已不含首 turn,直接判断会误判为"无 coze-context"而跳过。故用 state 持久化"本会话
1684
- # 曾见过 coze-context"标记:一旦见过,后续增量批次都视为应上报(与 claude-code 用
1685
- # history_turns 判断等效)。
1686
- seen_ctx = bool(state.get("seen_coze_context"))
1687
- has_coze_ctx = seen_ctx or any(
1688
- turn_coze_context(t)
1689
- for t in turns
1690
- )
1841
+ # coze-context 只出现在首个 turn。会话出现过即视为应上报。
1842
+ has_coze_ctx = any(turn_coze_context(t) for t in turns)
1691
1843
  if not has_coze_ctx:
1692
1844
  hook_log(f"skip no coze-context turns={len(turns)} session_id={session_id}")
1693
1845
  debug_log("No coze-context found in any turn, skipping upload.")
1694
1846
  return
1695
- if not seen_ctx:
1696
- state["seen_coze_context"] = True
1697
1847
 
1698
- # 持久化 coze_tags 到 state:<coze-context> 只在首 turn,后续增量批次不含它,其 root span
1699
- # 会缺 coze_message_id 无法按 message_id 查到。首次解析到就存 state,后续批次作为 override
1700
- # 传给 send_turns,保证每个 turn 的 root span 都带 coze_* tag。
1848
+ # 持久化 coze_tags 到 state,供 root span 注入。
1701
1849
  if not state.get("coze_tags"):
1702
1850
  for _t in turns:
1703
1851
  _tags = {f"coze_{k}": v for k, v in turn_coze_context(_t).items() if isinstance(v, str) and v.strip()}
@@ -1706,50 +1854,17 @@ def main():
1706
1854
  break
1707
1855
  coze_tags_override = state.get("coze_tags") or None
1708
1856
 
1709
- # 节流:PostToolUse 在密集工具调用下高频触发。距上次上报不足间隔则跳过本次增量上报。
1710
- # 终态事件(Stop/SubagentStop)永不被节流,保证 turn 结束时一定收尾。
1711
- if not is_terminal_event:
1712
- now_ts = time.time()
1713
- last_upload_ts = state.get("last_upload_ts", 0)
1714
- if now_ts - last_upload_ts < INCREMENTAL_UPLOAD_MIN_INTERVAL:
1715
- hook_log(f"throttled event={hook_event} since_last={now_ts - last_upload_ts:.1f}s session_id={session_id}")
1716
- debug_log(f"throttled: event={hook_event}, skip incremental upload")
1717
- return
1718
-
1719
- # turn 边界控制:中途事件(PostToolUse)触发时,最后一个 turn 往往仍在进行中
1720
- # (task_complete 尚未到达,后续还会追加内容)。只上报已完成 turn(turns[:-1]),
1721
- # 把最后一个留到下次/收尾,避免同一 turn 被拆成多个 trace。终态事件上报全部。
1722
- if is_terminal_event:
1723
- turns_to_send = turns
1724
- else:
1725
- turns_to_send = turns[:-1]
1726
- if not turns_to_send:
1727
- hook_log(f"defer no completed turn event={hook_event} turns={len(turns)} session_id={session_id}")
1728
- debug_log(f"event={hook_event}: no completed turn to send yet, defer")
1857
+ # 方案 B 实时:把新完成的 tool_call 作为 span 挂稳定 root,结束即可见。不节流。
1858
+ ok = send_steps_realtime(turns, session_id, state, model_name=model_name,
1859
+ coze_tags_override=coze_tags_override,
1860
+ is_terminal=is_terminal_event)
1861
+ if ok is None:
1862
+ hook_log(f"realtime send failed session_id={session_id}")
1863
+ debug_log("Realtime send failed, state not advanced")
1729
1864
  return
1730
-
1731
- history_context = state.get("conversation_history", [])
1732
- updated_history = send_turns_to_cozeloop(
1733
- turns_to_send, session_id, model_name,
1734
- history_context=history_context,
1735
- coze_tags_override=coze_tags_override,
1736
- )
1737
- if updated_history is not None:
1738
- # 推进 last_processed_line:终态推进到所有 entry 末行;中途保留最后一个未完成 turn,
1739
- # 推进到该 turn 的起始行,让它的所有 entry 下次重新读取。
1740
- if is_terminal_event:
1741
- last_line = max(e.get("_line_number", 0) for e in entries) + 1
1742
- else:
1743
- last_line = turns[-1].get("start_line", max(e.get("_line_number", 0) for e in entries) + 1)
1744
- state["last_processed_line"] = last_line
1745
- state["conversation_history"] = updated_history
1746
- state["last_upload_ts"] = time.time()
1747
- save_state(state_file, state)
1748
- hook_log(f"state advanced event={hook_event} last_line={last_line} sent={len(turns_to_send)}/{len(turns)} session_id={session_id}")
1749
- debug_log(f"State updated, last processed line: {last_line}")
1750
- else:
1751
- hook_log(f"send failed state not advanced session_id={session_id}")
1752
- debug_log("Send failed, state not advanced")
1865
+ save_state(state_file, state)
1866
+ hook_log(f"rt state saved event={hook_event} last_tool={state.get('rt_last_tool')} session_id={session_id}")
1867
+ debug_log(f"State saved. rt_last_tool={state.get('rt_last_tool')}")
1753
1868
  else:
1754
1869
  hook_log(f"skip no turns session_id={session_id}")
1755
1870
  debug_log("No turns to send")
@@ -633,8 +633,9 @@ export class CozeloopExporter {
633
633
  this.provider = new BasicTracerProvider({ resource });
634
634
  this.provider.addSpanProcessor(new BatchSpanProcessor(exporter, {
635
635
  maxQueueSize: 100,
636
- maxExportBatchSize: this.config.batchSize || 10,
637
- scheduledDelayMillis: this.config.batchInterval || 5000,
636
+ // 实时上报:每个 span 结束即发。maxExportBatchSize=1 + 短延迟,不攒批。
637
+ maxExportBatchSize: this.config.batchSize || 1,
638
+ scheduledDelayMillis: this.config.batchInterval || 500,
638
639
  }));
639
640
  // Do NOT call this.provider.register() — it sets the global TracerProvider
640
641
  // singleton, so if the plugin is activated more than once (e.g. gateway +
@@ -534,8 +534,10 @@ const cozeloopTracePlugin = {
534
534
  workspaceId,
535
535
  serviceName: pluginConfig.serviceName || "openclaw-agent",
536
536
  debug: pluginConfig.debug || false,
537
- batchSize: pluginConfig.batchSize || 10,
538
- batchInterval: pluginConfig.batchInterval || 5000,
537
+ // 实时上报:每个 span 结束即发,不攒批。batchSize=1 + 短延迟让 span 秒级可见。
538
+ // 如需降上报量可在 plugin config 显式调大 batchSize/batchInterval。
539
+ batchSize: pluginConfig.batchSize || 1,
540
+ batchInterval: pluginConfig.batchInterval || 500,
539
541
  enabledHooks: pluginConfig.enabledHooks,
540
542
  disableLocalCredentials: pluginConfig.disableLocalCredentials === true,
541
543
  logFile: pluginConfig.logFile,
@@ -35,13 +35,13 @@
35
35
  },
36
36
  "batchSize": {
37
37
  "type": "number",
38
- "default": 10,
39
- "description": "Number of spans to buffer before sending"
38
+ "default": 1,
39
+ "description": "Spans per export batch. Default 1 = 每个 span 结束即发(实时)。调大可降上报量但牺牲实时性。"
40
40
  },
41
41
  "batchInterval": {
42
42
  "type": "number",
43
- "default": 5000,
44
- "description": "Maximum time (ms) to wait before sending buffered spans"
43
+ "default": 500,
44
+ "description": "Max ms to wait before flushing. Default 500ms 配合 batchSize=1 实现秒级可见。"
45
45
  },
46
46
  "enabledHooks": {
47
47
  "type": "array",