coze_lab 0.1.41 → 0.1.43
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
|
@@ -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
|
-
#
|
|
131
|
-
#
|
|
132
|
-
|
|
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:
|
|
@@ -550,6 +550,19 @@ def read_new_messages(file_path: str, start_line: int = 0) -> List[Dict[str, Any
|
|
|
550
550
|
|
|
551
551
|
# --- Content Helpers ---
|
|
552
552
|
|
|
553
|
+
def _usage_int(usage: Any, key: str) -> int:
|
|
554
|
+
"""Read a token count from a usage dict, treating missing/None/非数字 一律为 0。
|
|
555
|
+
|
|
556
|
+
Claude Code transcript 里 usage 的 cache_* 等字段常【存在但值为 null】,dict.get(key, 0)
|
|
557
|
+
对显式 null 返回 None 而非 0,后续 None + int / None > 0 会抛 TypeError,导致整个实时
|
|
558
|
+
上报失败、trace 查不到。这里统一兜底。
|
|
559
|
+
"""
|
|
560
|
+
if not isinstance(usage, dict):
|
|
561
|
+
return 0
|
|
562
|
+
v = usage.get(key)
|
|
563
|
+
return v if isinstance(v, int) else 0
|
|
564
|
+
|
|
565
|
+
|
|
553
566
|
def is_empty_content(content: Any) -> bool:
|
|
554
567
|
"""Return True if content carries no meaningful data."""
|
|
555
568
|
if content is None:
|
|
@@ -676,7 +689,7 @@ def _group_subagent_steps(progress_msgs: List[Dict[str, Any]]) -> List[Dict[str,
|
|
|
676
689
|
existing.extend(content)
|
|
677
690
|
last_step["tool_calls"].extend(tool_calls)
|
|
678
691
|
usage = pmsg.get("usage", {})
|
|
679
|
-
if usage
|
|
692
|
+
if _usage_int(usage, "input_tokens") > 0 or _usage_int(usage, "output_tokens") > 0:
|
|
680
693
|
last_step["assistant_message"]["message"]["usage"] = usage
|
|
681
694
|
else:
|
|
682
695
|
steps.append({
|
|
@@ -849,7 +862,7 @@ def group_messages_into_turns(messages: List[Dict[str, Any]]) -> List[Dict[str,
|
|
|
849
862
|
last_step["tool_calls"].extend(tool_calls)
|
|
850
863
|
# Carry over usage from the later line (earlier line typically has zeros)
|
|
851
864
|
usage = message.get("usage", {})
|
|
852
|
-
if usage
|
|
865
|
+
if _usage_int(usage, "input_tokens") > 0 or _usage_int(usage, "output_tokens") > 0:
|
|
853
866
|
last_step["assistant_message"]["message"]["usage"] = usage
|
|
854
867
|
else:
|
|
855
868
|
# New API response — create a new step
|
|
@@ -1329,10 +1342,10 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1329
1342
|
|
|
1330
1343
|
# Set token usage for this specific model call
|
|
1331
1344
|
usage = assistant_message_obj.get("usage", {})
|
|
1332
|
-
input_tokens = usage
|
|
1333
|
-
output_tokens = usage
|
|
1334
|
-
cache_creation = usage
|
|
1335
|
-
cache_read = usage
|
|
1345
|
+
input_tokens = _usage_int(usage, "input_tokens")
|
|
1346
|
+
output_tokens = _usage_int(usage, "output_tokens")
|
|
1347
|
+
cache_creation = _usage_int(usage, "cache_creation_input_tokens")
|
|
1348
|
+
cache_read = _usage_int(usage, "cache_read_input_tokens")
|
|
1336
1349
|
if input_tokens > 0 or cache_creation > 0 or cache_read > 0:
|
|
1337
1350
|
model_span.set_input_tokens(input_tokens + cache_creation + cache_read)
|
|
1338
1351
|
if output_tokens > 0:
|
|
@@ -1402,10 +1415,10 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1402
1415
|
|
|
1403
1416
|
# Distribute total usage evenly across sub-agent model steps.
|
|
1404
1417
|
total_usage = tool_call.get("_total_usage", {})
|
|
1405
|
-
total_in = (total_usage
|
|
1406
|
-
+ total_usage
|
|
1407
|
-
+ total_usage
|
|
1408
|
-
total_out = total_usage
|
|
1418
|
+
total_in = (_usage_int(total_usage, "input_tokens")
|
|
1419
|
+
+ _usage_int(total_usage, "cache_creation_input_tokens")
|
|
1420
|
+
+ _usage_int(total_usage, "cache_read_input_tokens"))
|
|
1421
|
+
total_out = _usage_int(total_usage, "output_tokens")
|
|
1409
1422
|
n_model_steps = len(sub_steps)
|
|
1410
1423
|
per_step_in = total_in // n_model_steps if n_model_steps > 0 else 0
|
|
1411
1424
|
per_step_out = total_out // n_model_steps if n_model_steps > 0 else 0
|
|
@@ -1615,6 +1628,249 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1615
1628
|
return True
|
|
1616
1629
|
|
|
1617
1630
|
|
|
1631
|
+
# --- 实时 step 级上报(方案 B:每个 step span 结束即可见)---------------------
|
|
1632
|
+
#
|
|
1633
|
+
# 设计:一个会话(thread)= 一条稳定 trace。首次触发时建 root span(claude_code_request)
|
|
1634
|
+
# 并把它的 to_header()(含 trace_id + root span_id + baggage)持久化到 state["rt_root_header"]。
|
|
1635
|
+
# 之后每次 hook 触发,从 header 重建 root context,把【自上次以来新完成的 step】作为
|
|
1636
|
+
# model_span(+其下 tool_span) 挂到固定 root 下、当场 flush —— 于是每个 step 结束即可查。
|
|
1637
|
+
# step 级水位线用 state["rt_last_global_step"] 跟踪(按 (turn_index, step_index) 线性展开)。
|
|
1638
|
+
#
|
|
1639
|
+
# 与整树 send_turns_to_cozeloop 的关系:实时路径独立成 trace,不依赖也不复用整树逻辑。
|
|
1640
|
+
# 终态(Stop)时调用 finalize_realtime_root 收尾(补 root 的 output、finish)。
|
|
1641
|
+
|
|
1642
|
+
def _rt_state_key_header():
|
|
1643
|
+
return "rt_root_header"
|
|
1644
|
+
|
|
1645
|
+
def _flatten_completed_steps(turns: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
1646
|
+
"""把 turns 线性展开成 step 列表;只取【已完成】的 step。
|
|
1647
|
+
|
|
1648
|
+
一个 step(assistant 一次模型调用 + 其触发的 tool_results)视为已完成的判定:
|
|
1649
|
+
它后面还有别的 step/turn(说明模型已经继续往下走),或它带有 tool_results。
|
|
1650
|
+
最后一个 step 若没有 tool_results 且无后继,视为仍可能在进行中 —— 留到下次。
|
|
1651
|
+
返回的每项含: turn, step, turn_index, step_index, is_last_known。
|
|
1652
|
+
"""
|
|
1653
|
+
flat = []
|
|
1654
|
+
for ti, turn in enumerate(turns):
|
|
1655
|
+
steps = turn.get("steps", [])
|
|
1656
|
+
for si, step in enumerate(steps):
|
|
1657
|
+
flat.append({"turn": turn, "step": step, "turn_index": ti, "step_index": si})
|
|
1658
|
+
# 标记每个 step 是否“已完成”:非全局最后一个 step 一定已完成;
|
|
1659
|
+
# 全局最后一个 step 仅当它有 tool_results(工具已回结果)才算完成。
|
|
1660
|
+
completed = []
|
|
1661
|
+
for idx, item in enumerate(flat):
|
|
1662
|
+
is_global_last = (idx == len(flat) - 1)
|
|
1663
|
+
has_results = bool(item["step"].get("tool_results"))
|
|
1664
|
+
if (not is_global_last) or has_results:
|
|
1665
|
+
completed.append(item)
|
|
1666
|
+
return completed, len(flat)
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def send_steps_realtime(turns, session_id, history_turns, state, coze_tags_override=None, is_terminal=False):
|
|
1670
|
+
"""实时上报:把新完成的 step 作为 span 挂到稳定 root,结束即 flush。
|
|
1671
|
+
|
|
1672
|
+
返回 True 表示本次有推进(已发或已收尾),None 表示失败(state 不前进)。
|
|
1673
|
+
"""
|
|
1674
|
+
token = get_fresh_token()
|
|
1675
|
+
if token:
|
|
1676
|
+
os.environ["COZELOOP_API_TOKEN"] = token
|
|
1677
|
+
workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
1678
|
+
client_kwargs = {"ultra_large_report": True, "upload_timeout": 120}
|
|
1679
|
+
if workspace_id:
|
|
1680
|
+
client_kwargs["workspace_id"] = workspace_id
|
|
1681
|
+
if token:
|
|
1682
|
+
client_kwargs["api_token"] = token
|
|
1683
|
+
api_base_url = get_api_base_url()
|
|
1684
|
+
if api_base_url:
|
|
1685
|
+
client_kwargs["api_base_url"] = api_base_url
|
|
1686
|
+
|
|
1687
|
+
try:
|
|
1688
|
+
client = cozeloop.new_client(**client_kwargs)
|
|
1689
|
+
except Exception as e:
|
|
1690
|
+
debug_log(f"[rt] new_client failed: {e}")
|
|
1691
|
+
return None
|
|
1692
|
+
|
|
1693
|
+
try:
|
|
1694
|
+
# ---- coze_tags(root 用,保证 root span 可按 message_id 查到)----
|
|
1695
|
+
coze_tags = {}
|
|
1696
|
+
for turn in list(turns) + list(history_turns or []):
|
|
1697
|
+
um = turn.get("user_message", {}).get("message", {})
|
|
1698
|
+
t = coze_context_tags(um.get("content") if um else None)
|
|
1699
|
+
if t:
|
|
1700
|
+
coze_tags = t
|
|
1701
|
+
break
|
|
1702
|
+
if not coze_tags and coze_tags_override:
|
|
1703
|
+
coze_tags = dict(coze_tags_override)
|
|
1704
|
+
coze_tags = {k: v for k, v in coze_tags.items() if isinstance(v, str) and v.strip()}
|
|
1705
|
+
|
|
1706
|
+
# ---- 取/建稳定 root ----
|
|
1707
|
+
root_header = state.get(_rt_state_key_header())
|
|
1708
|
+
root_ctx = None
|
|
1709
|
+
if root_header:
|
|
1710
|
+
try:
|
|
1711
|
+
root_ctx = client.get_span_from_header(root_header)
|
|
1712
|
+
except Exception as e:
|
|
1713
|
+
debug_log(f"[rt] rebuild root from header failed: {e}")
|
|
1714
|
+
root_ctx = None
|
|
1715
|
+
if root_ctx is None or not getattr(root_ctx, "trace_id", ""):
|
|
1716
|
+
# 首次:建 root span,立即发出(input=首条 user 文本),存 header。root 不在此处 finish,
|
|
1717
|
+
# 用 start_new_trace 确保独立一条 trace。
|
|
1718
|
+
first_user_text = ""
|
|
1719
|
+
for turn in turns:
|
|
1720
|
+
um = turn.get("user_message", {}).get("message", {})
|
|
1721
|
+
uc = um.get("content") if um else None
|
|
1722
|
+
if not is_empty_content(uc):
|
|
1723
|
+
first_user_text = format_content(uc)
|
|
1724
|
+
break
|
|
1725
|
+
root_start_dt = None
|
|
1726
|
+
if turns:
|
|
1727
|
+
root_start_dt = _parse_ts(turns[0].get("user_message"))
|
|
1728
|
+
root_span = client.start_span(name="claude_code_request", span_type="main",
|
|
1729
|
+
start_time=root_start_dt, start_new_trace=True)
|
|
1730
|
+
root_span.set_runtime(Runtime(library="claude-code"))
|
|
1731
|
+
rtags = {"thread_id": session_id, "source": "claude_code", "realtime": True}
|
|
1732
|
+
rtags.update(coze_tags)
|
|
1733
|
+
root_span.set_tags(rtags)
|
|
1734
|
+
rbag = {"thread_id": session_id}
|
|
1735
|
+
rbag.update(coze_tags)
|
|
1736
|
+
root_span.set_baggage(rbag)
|
|
1737
|
+
if first_user_text:
|
|
1738
|
+
root_span.set_input(first_user_text)
|
|
1739
|
+
state[_rt_state_key_header()] = root_span.to_header()
|
|
1740
|
+
root_ctx = client.get_span_from_header(state[_rt_state_key_header()])
|
|
1741
|
+
# root 立即 finish 落库(后端 ResolveTraceIDByMessageID 按 root_span 查,root 必须可查)。
|
|
1742
|
+
# 实测:root finish 后,子 span 仍可用其 header 挂到同一 trace_id 下,不影响后续增量。
|
|
1743
|
+
root_span.finish()
|
|
1744
|
+
client.flush()
|
|
1745
|
+
debug_log(f"[rt] root created+finished trace_id={getattr(root_ctx,'trace_id','?')}")
|
|
1746
|
+
|
|
1747
|
+
# ---- 发新完成的 step ----
|
|
1748
|
+
completed, total_steps = _flatten_completed_steps(turns)
|
|
1749
|
+
last_global = state.get("rt_last_global_step", 0)
|
|
1750
|
+
new_items = completed[last_global:]
|
|
1751
|
+
# 批量节流:非终态时,距上次上报不足 REALTIME_BATCH_INTERVAL 秒则本次不发,
|
|
1752
|
+
# 把已结束的 step 攒到下次触发一起发(最多每 5s 一批)。终态(Stop)不节流,立即收尾。
|
|
1753
|
+
if new_items and not is_terminal:
|
|
1754
|
+
since = time.time() - state.get("rt_last_upload_ts", 0)
|
|
1755
|
+
if since < REALTIME_BATCH_INTERVAL:
|
|
1756
|
+
debug_log(f"[rt] batch throttle: {since:.1f}s<{REALTIME_BATCH_INTERVAL}s, defer {len(new_items)} step(s)")
|
|
1757
|
+
new_items = []
|
|
1758
|
+
sent = 0
|
|
1759
|
+
for item in new_items:
|
|
1760
|
+
step = item["step"]
|
|
1761
|
+
assistant_msg = step.get("assistant_message", {})
|
|
1762
|
+
amo = assistant_msg.get("message", {})
|
|
1763
|
+
model_name = amo.get("model", "claude-code")
|
|
1764
|
+
gidx = last_global + sent # 全局 step 序号,用于命名
|
|
1765
|
+
step_start_dt = _parse_ts(assistant_msg)
|
|
1766
|
+
step_end_dt = None
|
|
1767
|
+
for r in step.get("tool_results", []):
|
|
1768
|
+
te = _parse_ts_str(r.get("_result_ts"))
|
|
1769
|
+
if te:
|
|
1770
|
+
step_end_dt = te
|
|
1771
|
+
|
|
1772
|
+
# model_span 挂 root
|
|
1773
|
+
mspan = client.start_span(name=f"model_call_{gidx}", span_type="model",
|
|
1774
|
+
start_time=step_start_dt, child_of=root_ctx)
|
|
1775
|
+
mspan.set_runtime(Runtime(library="claude-code"))
|
|
1776
|
+
try:
|
|
1777
|
+
mspan.set_model_name(model_name)
|
|
1778
|
+
except Exception:
|
|
1779
|
+
pass
|
|
1780
|
+
if step_start_dt is not None and step_end_dt is not None:
|
|
1781
|
+
_set_finish_time_safe(mspan, step_end_dt)
|
|
1782
|
+
raw_content = amo.get("content", [])
|
|
1783
|
+
text_parts = []
|
|
1784
|
+
if isinstance(raw_content, list):
|
|
1785
|
+
for it in raw_content:
|
|
1786
|
+
if isinstance(it, dict) and it.get("type") == "text" and it.get("text"):
|
|
1787
|
+
text_parts.append(it["text"])
|
|
1788
|
+
elif isinstance(raw_content, str):
|
|
1789
|
+
text_parts.append(raw_content)
|
|
1790
|
+
mspan.set_input(format_content((assistant_msg.get("message", {}) or {}).get("content")))
|
|
1791
|
+
if text_parts:
|
|
1792
|
+
mspan.set_output("\n".join(text_parts))
|
|
1793
|
+
usage = amo.get("usage", {})
|
|
1794
|
+
it_tok = _usage_int(usage, "input_tokens") + _usage_int(usage, "cache_creation_input_tokens") + _usage_int(usage, "cache_read_input_tokens")
|
|
1795
|
+
if it_tok > 0:
|
|
1796
|
+
try: mspan.set_input_tokens(it_tok)
|
|
1797
|
+
except Exception: pass
|
|
1798
|
+
if _usage_int(usage, "output_tokens") > 0:
|
|
1799
|
+
try: mspan.set_output_tokens(_usage_int(usage, "output_tokens"))
|
|
1800
|
+
except Exception: pass
|
|
1801
|
+
mspan_ctx = client.get_span_from_header(mspan.to_header())
|
|
1802
|
+
mspan.finish()
|
|
1803
|
+
|
|
1804
|
+
# tool_span 挂 model_span
|
|
1805
|
+
for tc in step.get("tool_calls", []):
|
|
1806
|
+
tname = tc.get("name", "unknown")
|
|
1807
|
+
tid = tc.get("id")
|
|
1808
|
+
tool_end_dt = None
|
|
1809
|
+
for r in step.get("tool_results", []):
|
|
1810
|
+
if r.get("tool_use_id") == tid:
|
|
1811
|
+
tool_end_dt = _parse_ts_str(r.get("_result_ts"))
|
|
1812
|
+
break
|
|
1813
|
+
tspan = client.start_span(name=f"tool_{tname}", span_type="tool",
|
|
1814
|
+
start_time=step_start_dt, child_of=mspan_ctx)
|
|
1815
|
+
tspan.set_runtime(Runtime(library="claude-code"))
|
|
1816
|
+
if tool_end_dt is not None:
|
|
1817
|
+
_set_finish_time_safe(tspan, tool_end_dt)
|
|
1818
|
+
tspan.set_tags({"tool_name": tname, "tool_call_id": tid, "step_index": gidx})
|
|
1819
|
+
tspan.set_input(json.dumps(tc.get("input", {}), ensure_ascii=False)[:2000])
|
|
1820
|
+
for r in step.get("tool_results", []):
|
|
1821
|
+
if r.get("tool_use_id") == tid:
|
|
1822
|
+
tspan.set_output(_format_tool_output(r.get("content", "")))
|
|
1823
|
+
break
|
|
1824
|
+
tspan.finish()
|
|
1825
|
+
sent += 1
|
|
1826
|
+
# 每个 step 立即 flush —— 这是“结束即可见”的关键。
|
|
1827
|
+
client.flush()
|
|
1828
|
+
|
|
1829
|
+
new_last = last_global + sent
|
|
1830
|
+
state["rt_last_global_step"] = new_last
|
|
1831
|
+
if sent > 0:
|
|
1832
|
+
state["rt_last_upload_ts"] = time.time()
|
|
1833
|
+
|
|
1834
|
+
# ---- 收尾:终态时补 root output 并 finish ----
|
|
1835
|
+
if is_terminal:
|
|
1836
|
+
last_output = None
|
|
1837
|
+
for turn in reversed(turns):
|
|
1838
|
+
for step in reversed(turn.get("steps", [])):
|
|
1839
|
+
amo = step.get("assistant_message", {}).get("message", {})
|
|
1840
|
+
c = amo.get("content", [])
|
|
1841
|
+
if isinstance(c, list):
|
|
1842
|
+
tp = [x.get("text", "") for x in c if isinstance(x, dict) and x.get("type") == "text" and x.get("text")]
|
|
1843
|
+
if tp:
|
|
1844
|
+
last_output = "\n".join(tp); break
|
|
1845
|
+
elif isinstance(c, str) and c.strip():
|
|
1846
|
+
last_output = c; break
|
|
1847
|
+
if last_output:
|
|
1848
|
+
break
|
|
1849
|
+
if root_ctx is not None and last_output:
|
|
1850
|
+
# 重发一个同 trace 的收尾 span 承载最终输出(root 对象已不可用,用子 span 兜底)。
|
|
1851
|
+
fin = client.start_span(name="final_response", span_type="main", child_of=root_ctx)
|
|
1852
|
+
fin.set_runtime(Runtime(library="claude-code"))
|
|
1853
|
+
fin.set_output(last_output)
|
|
1854
|
+
fin.finish()
|
|
1855
|
+
client.flush()
|
|
1856
|
+
debug_log(f"[rt] finalized, total sent steps={new_last}")
|
|
1857
|
+
|
|
1858
|
+
debug_log(f"[rt] sent {sent} new step(s), last_global={new_last}/{total_steps}, terminal={is_terminal}")
|
|
1859
|
+
return True
|
|
1860
|
+
except Exception as e:
|
|
1861
|
+
debug_log(f"[rt] send_steps_realtime error: {e}")
|
|
1862
|
+
return None
|
|
1863
|
+
finally:
|
|
1864
|
+
try:
|
|
1865
|
+
client.flush()
|
|
1866
|
+
except Exception:
|
|
1867
|
+
pass
|
|
1868
|
+
try:
|
|
1869
|
+
client.close()
|
|
1870
|
+
except Exception:
|
|
1871
|
+
pass
|
|
1872
|
+
|
|
1873
|
+
|
|
1618
1874
|
# --- Hook Input ---
|
|
1619
1875
|
|
|
1620
1876
|
def read_hook_stdin() -> Dict[str, Any]:
|
|
@@ -1674,28 +1930,19 @@ def main():
|
|
|
1674
1930
|
debug_log(f"Using conversation file: {conversation_file}")
|
|
1675
1931
|
print(f"[CozeLoop] 读取会话文件: {conversation_file}", file=sys.stderr)
|
|
1676
1932
|
|
|
1677
|
-
# Load state
|
|
1933
|
+
# Load state
|
|
1678
1934
|
state_file = get_state_file_path(conversation_file)
|
|
1679
1935
|
state = load_state(state_file)
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
#
|
|
1683
|
-
#
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
|
1936
|
+
|
|
1937
|
+
# 方案 B(实时 step 级上报):读【全量】消息重建完整 turn/step 序列。实时路径用全局 step
|
|
1938
|
+
# 水位线 state["rt_last_global_step"] 增量,不再用 last_processed_line(那是整树增量的水位线)。
|
|
1939
|
+
# 每次 hook 触发(PostToolUse=每次工具调用后)都把新完成的 step 发出,结束即可见,不节流。
|
|
1940
|
+
all_messages = read_new_messages(conversation_file, 0)
|
|
1941
|
+
|
|
1942
|
+
# Determine session ID
|
|
1696
1943
|
session_id = hook_input.get("session_id")
|
|
1697
1944
|
if not session_id:
|
|
1698
|
-
for msg in
|
|
1945
|
+
for msg in all_messages:
|
|
1699
1946
|
if msg.get("sessionId"):
|
|
1700
1947
|
session_id = msg.get("sessionId")
|
|
1701
1948
|
break
|
|
@@ -1704,83 +1951,48 @@ def main():
|
|
|
1704
1951
|
session_id = state["session_id"]
|
|
1705
1952
|
else:
|
|
1706
1953
|
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
1954
|
state["session_id"] = session_id
|
|
1710
|
-
debug_log(f"Session ID: {session_id}")
|
|
1955
|
+
debug_log(f"Session ID: {session_id}, event={hook_event}")
|
|
1711
1956
|
|
|
1712
|
-
if not
|
|
1713
|
-
debug_log("No
|
|
1957
|
+
if not all_messages:
|
|
1958
|
+
debug_log("No messages to process.")
|
|
1714
1959
|
return
|
|
1715
1960
|
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
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):
|
|
1961
|
+
# 全量分组成 turns/steps
|
|
1962
|
+
turns = group_messages_into_turns(all_messages)
|
|
1963
|
+
if not turns:
|
|
1964
|
+
debug_log("No turns.")
|
|
1965
|
+
return
|
|
1966
|
+
|
|
1967
|
+
# coze-context 判定:整个会话出现过即可(首 turn 注入)。
|
|
1968
|
+
def _turn_has_ctx(turn):
|
|
1969
|
+
return bool(coze_context_tags(
|
|
1970
|
+
(turn.get("user_message", {}).get("message", {}) or {}).get("content")
|
|
1971
|
+
))
|
|
1972
|
+
if not any(_turn_has_ctx(t) for t in turns):
|
|
1973
|
+
debug_log("No coze-context in any turn, skipping upload.")
|
|
1974
|
+
return
|
|
1975
|
+
|
|
1976
|
+
# 持久化 coze_tags 到 state,供 root span 注入(保证可按 message_id 查到)。
|
|
1977
|
+
if not state.get("coze_tags"):
|
|
1978
|
+
for _t in turns:
|
|
1745
1979
|
_um = (_t.get("user_message", {}).get("message", {}) or {})
|
|
1746
1980
|
_tags = {k: v for k, v in coze_context_tags(_um.get("content")).items() if isinstance(v, str) and v.strip()}
|
|
1747
1981
|
if _tags:
|
|
1748
1982
|
state["coze_tags"] = _tags
|
|
1749
1983
|
break
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
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
|
-
|
|
1984
|
+
coze_tags_override = state.get("coze_tags") or None
|
|
1985
|
+
|
|
1986
|
+
print(f"[CozeLoop] 实时上报: session={session_id}, event={hook_event}", file=sys.stderr)
|
|
1987
|
+
ok = send_steps_realtime(turns, session_id, [], state,
|
|
1988
|
+
coze_tags_override=coze_tags_override,
|
|
1989
|
+
is_terminal=is_terminal_event)
|
|
1990
|
+
if ok is None:
|
|
1991
|
+
debug_log("Realtime send failed, state not advanced.")
|
|
1992
|
+
return
|
|
1993
|
+
save_state(state_file, state)
|
|
1994
|
+
print(f"[CozeLoop] 实时上报完成 ✓ session={session_id}, last_step={state.get('rt_last_global_step')}", file=sys.stderr)
|
|
1995
|
+
debug_log(f"State saved. rt_last_global_step={state.get('rt_last_global_step')}")
|
|
1784
1996
|
debug_log("Hook finished.")
|
|
1785
1997
|
|
|
1786
1998
|
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
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
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 -------------------------------------------------
|
|
@@ -787,6 +787,18 @@ def truncate_text(text: str, limit: int = 12000) -> str:
|
|
|
787
787
|
|
|
788
788
|
# --- Message Grouping ---
|
|
789
789
|
|
|
790
|
+
def _usage_int(usage, key):
|
|
791
|
+
"""从 token_usage dict 读 token 数,missing/None/非数字 一律按 0。
|
|
792
|
+
|
|
793
|
+
transcript 里 token_usage 字段可能【存在但值为 null】,dict.get(key, 0) 对显式 null
|
|
794
|
+
返回 None 而非 0,后续 None > 0 / None + int 会抛 TypeError 中断上报。这里统一兜底。
|
|
795
|
+
"""
|
|
796
|
+
if not isinstance(usage, dict):
|
|
797
|
+
return 0
|
|
798
|
+
v = usage.get(key)
|
|
799
|
+
return v if isinstance(v, int) else 0
|
|
800
|
+
|
|
801
|
+
|
|
790
802
|
def _parse_ts(obj):
|
|
791
803
|
"""从 codex entry/payload 的 timestamp 解析 datetime(带时区)。失败返回 None。
|
|
792
804
|
|
|
@@ -1299,8 +1311,8 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1299
1311
|
|
|
1300
1312
|
# Set token usage
|
|
1301
1313
|
token_usage = turn.get("token_usage", {})
|
|
1302
|
-
input_tokens = token_usage
|
|
1303
|
-
output_tokens = token_usage
|
|
1314
|
+
input_tokens = _usage_int(token_usage, "input_tokens")
|
|
1315
|
+
output_tokens = _usage_int(token_usage, "output_tokens")
|
|
1304
1316
|
if input_tokens > 0:
|
|
1305
1317
|
model_span.set_input_tokens(input_tokens)
|
|
1306
1318
|
if output_tokens > 0:
|
|
@@ -1446,10 +1458,12 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1446
1458
|
sa_model_span.set_output(ModelOutput(choices=sa_choices))
|
|
1447
1459
|
|
|
1448
1460
|
sa_token = sa_turn.get("token_usage", {})
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
if
|
|
1452
|
-
sa_model_span.
|
|
1461
|
+
sa_in = _usage_int(sa_token, "input_tokens")
|
|
1462
|
+
sa_out = _usage_int(sa_token, "output_tokens")
|
|
1463
|
+
if sa_in > 0:
|
|
1464
|
+
sa_model_span.set_input_tokens(sa_in)
|
|
1465
|
+
if sa_out > 0:
|
|
1466
|
+
sa_model_span.set_output_tokens(sa_out)
|
|
1453
1467
|
|
|
1454
1468
|
# Subagent tool spans
|
|
1455
1469
|
for sa_tc in sa_turn.get("tool_calls", []):
|
|
@@ -1552,6 +1566,165 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1552
1566
|
return ctx
|
|
1553
1567
|
|
|
1554
1568
|
|
|
1569
|
+
# --- 实时 step 级上报(方案 B:每个 tool_call 结束即可见)---------------------
|
|
1570
|
+
#
|
|
1571
|
+
# 与 claude-code 同架构:一个会话 = 一条稳定 trace。首次建 root(codex_request)立即 finish 并存
|
|
1572
|
+
# to_header() 到 state["rt_root_header"];之后每次触发把【新完成的 tool_call】(有 result 的)作为
|
|
1573
|
+
# tool span 挂到固定 root、当场 flush。codex 的实时单元是 tool_call(turn 内平铺,按 call_id 关联
|
|
1574
|
+
# result)。全局水位线 state["rt_last_tool"] 跟踪已发的 tool_call 数。
|
|
1575
|
+
|
|
1576
|
+
def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_override=None, is_terminal=False):
|
|
1577
|
+
import cozeloop
|
|
1578
|
+
from cozeloop.spec.tracespec import Runtime
|
|
1579
|
+
token = get_fresh_token()
|
|
1580
|
+
if token:
|
|
1581
|
+
os.environ["COZELOOP_API_TOKEN"] = token
|
|
1582
|
+
workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
1583
|
+
client_kwargs = {"ultra_large_report": True, "upload_timeout": 120}
|
|
1584
|
+
if workspace_id:
|
|
1585
|
+
client_kwargs["workspace_id"] = workspace_id
|
|
1586
|
+
if token:
|
|
1587
|
+
client_kwargs["api_token"] = token
|
|
1588
|
+
api_base_url = get_api_base_url()
|
|
1589
|
+
if api_base_url:
|
|
1590
|
+
client_kwargs["api_base_url"] = api_base_url
|
|
1591
|
+
try:
|
|
1592
|
+
client = cozeloop.new_client(**client_kwargs)
|
|
1593
|
+
except Exception as e:
|
|
1594
|
+
hook_log(f"[rt] new_client failed: {e}")
|
|
1595
|
+
return None
|
|
1596
|
+
|
|
1597
|
+
try:
|
|
1598
|
+
# coze_tags
|
|
1599
|
+
coze_tags = {}
|
|
1600
|
+
for turn in turns:
|
|
1601
|
+
t = {f"coze_{k}": v for k, v in turn_coze_context(turn).items()}
|
|
1602
|
+
if t:
|
|
1603
|
+
coze_tags = t
|
|
1604
|
+
break
|
|
1605
|
+
if not coze_tags and coze_tags_override:
|
|
1606
|
+
coze_tags = dict(coze_tags_override)
|
|
1607
|
+
coze_tags = {k: v for k, v in coze_tags.items() if isinstance(v, str) and v.strip()}
|
|
1608
|
+
|
|
1609
|
+
# 取/建稳定 root
|
|
1610
|
+
root_header = state.get("rt_root_header")
|
|
1611
|
+
root_ctx = None
|
|
1612
|
+
if root_header:
|
|
1613
|
+
try:
|
|
1614
|
+
root_ctx = client.get_span_from_header(root_header)
|
|
1615
|
+
except Exception:
|
|
1616
|
+
root_ctx = None
|
|
1617
|
+
if root_ctx is None or not getattr(root_ctx, "trace_id", ""):
|
|
1618
|
+
first_user_text = ""
|
|
1619
|
+
for turn in turns:
|
|
1620
|
+
if turn.get("user_message_text"):
|
|
1621
|
+
first_user_text = turn["user_message_text"]
|
|
1622
|
+
break
|
|
1623
|
+
root_start_dt = _parse_ts_value(turns[0].get("tool_calls", [{}])[0].get("_ts")) if turns and turns[0].get("tool_calls") else None
|
|
1624
|
+
root_span = client.start_span(name="codex_request", span_type="main",
|
|
1625
|
+
start_time=root_start_dt, start_new_trace=True)
|
|
1626
|
+
root_span.set_runtime(Runtime(library="codex-cli"))
|
|
1627
|
+
rtags = {"thread_id": session_id, "source": "codex_cli", "realtime": True}
|
|
1628
|
+
rtags.update(coze_tags)
|
|
1629
|
+
root_span.set_tags(rtags)
|
|
1630
|
+
rbag = {"thread_id": session_id}
|
|
1631
|
+
rbag.update(coze_tags)
|
|
1632
|
+
root_span.set_baggage(rbag)
|
|
1633
|
+
if first_user_text:
|
|
1634
|
+
root_span.set_input(truncate_text(first_user_text))
|
|
1635
|
+
state["rt_root_header"] = root_span.to_header()
|
|
1636
|
+
root_ctx = client.get_span_from_header(state["rt_root_header"])
|
|
1637
|
+
root_span.finish() # 立即 finish 落库(后端按 root_span 查),子 span 仍可挂同 trace
|
|
1638
|
+
client.flush()
|
|
1639
|
+
hook_log(f"[rt] root created trace_id={getattr(root_ctx,'trace_id','?')}")
|
|
1640
|
+
|
|
1641
|
+
# 线性展开所有 tool_call(带 result 的才算完成),按全局序增量发
|
|
1642
|
+
flat = []
|
|
1643
|
+
for ti, turn in enumerate(turns):
|
|
1644
|
+
results = {r.get("call_id"): r for r in turn.get("tool_results", [])}
|
|
1645
|
+
for tc in turn.get("tool_calls", []):
|
|
1646
|
+
cid = tc.get("call_id")
|
|
1647
|
+
flat.append({"tc": tc, "result": results.get(cid), "turn_index": ti})
|
|
1648
|
+
# 只发已完成(有 result)的,且全局最后一个若无 result 则留到下次
|
|
1649
|
+
completed = []
|
|
1650
|
+
for idx, item in enumerate(flat):
|
|
1651
|
+
is_last = (idx == len(flat) - 1)
|
|
1652
|
+
if item["result"] is not None or (not is_last):
|
|
1653
|
+
completed.append(item)
|
|
1654
|
+
last_tool = state.get("rt_last_tool", 0)
|
|
1655
|
+
new_items = completed[last_tool:]
|
|
1656
|
+
# 批量节流:非终态时距上次上报<5s 则本次不发,攒到下次一起发。终态不节流。
|
|
1657
|
+
if new_items and not is_terminal:
|
|
1658
|
+
since = time.time() - state.get("rt_last_upload_ts", 0)
|
|
1659
|
+
if since < REALTIME_BATCH_INTERVAL:
|
|
1660
|
+
hook_log(f"[rt] batch throttle {since:.1f}s<{REALTIME_BATCH_INTERVAL}s defer {len(new_items)} tool(s)")
|
|
1661
|
+
new_items = []
|
|
1662
|
+
sent = 0
|
|
1663
|
+
for item in new_items:
|
|
1664
|
+
tc = item["tc"]; result = item["result"]
|
|
1665
|
+
gidx = last_tool + sent
|
|
1666
|
+
tname = tc.get("name", "unknown")
|
|
1667
|
+
start_dt = _parse_ts_value(tc.get("_ts"))
|
|
1668
|
+
end_dt = _parse_ts_value(result.get("_ts")) if result else None
|
|
1669
|
+
# 每个 tool_call 用一个 model_call_+tool 子树(与 claude-code 视觉一致)
|
|
1670
|
+
mspan = client.start_span(name=f"model_call_{gidx}", span_type="model",
|
|
1671
|
+
start_time=start_dt, child_of=root_ctx)
|
|
1672
|
+
mspan.set_runtime(Runtime(library="codex-cli"))
|
|
1673
|
+
try: mspan.set_model_name(model_name)
|
|
1674
|
+
except Exception: pass
|
|
1675
|
+
if start_dt and end_dt:
|
|
1676
|
+
_set_finish_time_safe(mspan, end_dt)
|
|
1677
|
+
mspan.set_input(truncate_text(json.dumps(tc.get("input", {}), ensure_ascii=False)))
|
|
1678
|
+
mspan_ctx = client.get_span_from_header(mspan.to_header())
|
|
1679
|
+
mspan.finish()
|
|
1680
|
+
|
|
1681
|
+
tspan = client.start_span(name=f"tool_{tname}", span_type="tool",
|
|
1682
|
+
start_time=start_dt, child_of=mspan_ctx)
|
|
1683
|
+
tspan.set_runtime(Runtime(library="codex-cli"))
|
|
1684
|
+
if end_dt:
|
|
1685
|
+
_set_finish_time_safe(tspan, end_dt)
|
|
1686
|
+
tspan.set_tags({"tool_name": tname, "tool_call_id": tc.get("call_id"), "step_index": gidx})
|
|
1687
|
+
tspan.set_input(truncate_text(json.dumps(tc.get("input", {}), ensure_ascii=False)))
|
|
1688
|
+
if result is not None:
|
|
1689
|
+
tspan.set_output(truncate_text(str(result.get("output", ""))))
|
|
1690
|
+
tspan.finish()
|
|
1691
|
+
sent += 1
|
|
1692
|
+
client.flush() # 每个 tool_call 立即 flush → 结束即可见
|
|
1693
|
+
|
|
1694
|
+
state["rt_last_tool"] = last_tool + sent
|
|
1695
|
+
if sent > 0:
|
|
1696
|
+
state["rt_last_upload_ts"] = time.time()
|
|
1697
|
+
|
|
1698
|
+
if is_terminal:
|
|
1699
|
+
# 收尾:补最终 assistant 输出
|
|
1700
|
+
last_out = ""
|
|
1701
|
+
for turn in reversed(turns):
|
|
1702
|
+
for am in reversed(turn.get("assistant_messages", [])):
|
|
1703
|
+
txt = extract_assistant_text(am)
|
|
1704
|
+
if txt:
|
|
1705
|
+
last_out = txt; break
|
|
1706
|
+
if last_out:
|
|
1707
|
+
break
|
|
1708
|
+
if last_out:
|
|
1709
|
+
fin = client.start_span(name="final_response", span_type="main", child_of=root_ctx)
|
|
1710
|
+
fin.set_runtime(Runtime(library="codex-cli"))
|
|
1711
|
+
fin.set_output(truncate_text(last_out))
|
|
1712
|
+
fin.finish()
|
|
1713
|
+
client.flush()
|
|
1714
|
+
hook_log(f"[rt] finalized total_tools={state['rt_last_tool']}")
|
|
1715
|
+
|
|
1716
|
+
hook_log(f"[rt] sent {sent} tool span(s), last_tool={state['rt_last_tool']}, terminal={is_terminal}")
|
|
1717
|
+
return True
|
|
1718
|
+
except Exception as e:
|
|
1719
|
+
hook_log(f"[rt] error: {e}")
|
|
1720
|
+
return None
|
|
1721
|
+
finally:
|
|
1722
|
+
try: client.flush()
|
|
1723
|
+
except Exception: pass
|
|
1724
|
+
try: client.close()
|
|
1725
|
+
except Exception: pass
|
|
1726
|
+
|
|
1727
|
+
|
|
1555
1728
|
# --- Main Execution ---
|
|
1556
1729
|
|
|
1557
1730
|
def main():
|
|
@@ -1615,19 +1788,19 @@ def main():
|
|
|
1615
1788
|
state_file = get_state_file_path(transcript_path)
|
|
1616
1789
|
state = load_state(state_file)
|
|
1617
1790
|
|
|
1618
|
-
#
|
|
1619
|
-
|
|
1791
|
+
# 方案 B(实时):读【全量】entries 重建完整 turn/tool 序列。实时路径用全局 tool 水位线
|
|
1792
|
+
# state["rt_last_tool"] 增量,每次触发把新完成的 tool_call 发出,结束即可见,不节流。
|
|
1793
|
+
entries = read_rollout_messages(transcript_path, 0)
|
|
1620
1794
|
|
|
1621
1795
|
if not entries:
|
|
1622
|
-
hook_log(f"skip no
|
|
1623
|
-
debug_log("No
|
|
1796
|
+
hook_log(f"skip no entries transcript={transcript_path}")
|
|
1797
|
+
debug_log("No entries to process")
|
|
1624
1798
|
return
|
|
1625
1799
|
|
|
1626
|
-
hook_log(f"read entries={len(entries)}
|
|
1627
|
-
debug_log(f"Read {len(entries)} new entries from line {state['last_processed_line']}")
|
|
1800
|
+
hook_log(f"read entries={len(entries)} (full) transcript={transcript_path}")
|
|
1628
1801
|
|
|
1629
1802
|
# Parse session identity
|
|
1630
|
-
all_entries_for_meta =
|
|
1803
|
+
all_entries_for_meta = entries
|
|
1631
1804
|
session_info = parse_session_meta(all_entries_for_meta)
|
|
1632
1805
|
|
|
1633
1806
|
session_id = session_info["session_id"] or hook_input.get("session_id", "")
|
|
@@ -1679,25 +1852,14 @@ def main():
|
|
|
1679
1852
|
|
|
1680
1853
|
# Send turns to CozeLoop — only if at least one turn carries coze-context.
|
|
1681
1854
|
if turns:
|
|
1682
|
-
# coze-context 只出现在首个 turn
|
|
1683
|
-
|
|
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
|
-
)
|
|
1855
|
+
# coze-context 只出现在首个 turn。会话出现过即视为应上报。
|
|
1856
|
+
has_coze_ctx = any(turn_coze_context(t) for t in turns)
|
|
1691
1857
|
if not has_coze_ctx:
|
|
1692
1858
|
hook_log(f"skip no coze-context turns={len(turns)} session_id={session_id}")
|
|
1693
1859
|
debug_log("No coze-context found in any turn, skipping upload.")
|
|
1694
1860
|
return
|
|
1695
|
-
if not seen_ctx:
|
|
1696
|
-
state["seen_coze_context"] = True
|
|
1697
1861
|
|
|
1698
|
-
# 持久化 coze_tags 到 state
|
|
1699
|
-
# 会缺 coze_message_id 无法按 message_id 查到。首次解析到就存 state,后续批次作为 override
|
|
1700
|
-
# 传给 send_turns,保证每个 turn 的 root span 都带 coze_* tag。
|
|
1862
|
+
# 持久化 coze_tags 到 state,供 root span 注入。
|
|
1701
1863
|
if not state.get("coze_tags"):
|
|
1702
1864
|
for _t in turns:
|
|
1703
1865
|
_tags = {f"coze_{k}": v for k, v in turn_coze_context(_t).items() if isinstance(v, str) and v.strip()}
|
|
@@ -1706,50 +1868,17 @@ def main():
|
|
|
1706
1868
|
break
|
|
1707
1869
|
coze_tags_override = state.get("coze_tags") or None
|
|
1708
1870
|
|
|
1709
|
-
#
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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")
|
|
1871
|
+
# 方案 B 实时:把新完成的 tool_call 作为 span 挂稳定 root,结束即可见。不节流。
|
|
1872
|
+
ok = send_steps_realtime(turns, session_id, state, model_name=model_name,
|
|
1873
|
+
coze_tags_override=coze_tags_override,
|
|
1874
|
+
is_terminal=is_terminal_event)
|
|
1875
|
+
if ok is None:
|
|
1876
|
+
hook_log(f"realtime send failed session_id={session_id}")
|
|
1877
|
+
debug_log("Realtime send failed, state not advanced")
|
|
1729
1878
|
return
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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")
|
|
1879
|
+
save_state(state_file, state)
|
|
1880
|
+
hook_log(f"rt state saved event={hook_event} last_tool={state.get('rt_last_tool')} session_id={session_id}")
|
|
1881
|
+
debug_log(f"State saved. rt_last_tool={state.get('rt_last_tool')}")
|
|
1753
1882
|
else:
|
|
1754
1883
|
hook_log(f"skip no turns session_id={session_id}")
|
|
1755
1884
|
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
|
|
637
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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":
|
|
39
|
-
"description": "
|
|
38
|
+
"default": 1,
|
|
39
|
+
"description": "Spans per export batch. Default 1 = 每个 span 结束即发(实时)。调大可降上报量但牺牲实时性。"
|
|
40
40
|
},
|
|
41
41
|
"batchInterval": {
|
|
42
42
|
"type": "number",
|
|
43
|
-
"default":
|
|
44
|
-
"description": "
|
|
43
|
+
"default": 500,
|
|
44
|
+
"description": "Max ms to wait before flushing. Default 500ms 配合 batchSize=1 实现秒级可见。"
|
|
45
45
|
},
|
|
46
46
|
"enabledHooks": {
|
|
47
47
|
"type": "array",
|