coze_lab 0.1.40 → 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
|
@@ -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:
|
|
@@ -1071,7 +1071,7 @@ def _build_history_messages(history_turns: List[Dict[str, Any]]) -> list:
|
|
|
1071
1071
|
|
|
1072
1072
|
# --- CozeLoop Trace Reporting ---
|
|
1073
1073
|
|
|
1074
|
-
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history_turns: Optional[List[Dict[str, Any]]] = None, retry_on_auth_failure: bool = True):
|
|
1074
|
+
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history_turns: Optional[List[Dict[str, Any]]] = None, retry_on_auth_failure: bool = True, coze_tags_override: Optional[Dict[str, str]] = None):
|
|
1075
1075
|
"""Send conversation turns to CozeLoop.
|
|
1076
1076
|
|
|
1077
1077
|
Span hierarchy:
|
|
@@ -1160,6 +1160,11 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1160
1160
|
t = coze_context_tags(um.get("content") if um else None)
|
|
1161
1161
|
if t:
|
|
1162
1162
|
coze_tags = t
|
|
1163
|
+
# 增量上报兜底:本批 turns 不含 <coze-context>(它只在首 turn)时,用 caller
|
|
1164
|
+
# 从 state 传入的 override,保证每个 turn 的 root span 都带 coze_message_id,
|
|
1165
|
+
# 否则后续增量 turn 的 root span 无法按 message_id 查到。
|
|
1166
|
+
if not coze_tags and coze_tags_override:
|
|
1167
|
+
coze_tags = dict(coze_tags_override)
|
|
1163
1168
|
# Drop empty-valued coze_* tags: the backend pairs traces by exact tag
|
|
1164
1169
|
# match (coze_message_id / coze_agent_id), where an empty string is
|
|
1165
1170
|
# indistinguishable from "absent" yet still bloats the span — never
|
|
@@ -1604,12 +1609,255 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
1604
1609
|
if new_token:
|
|
1605
1610
|
os.environ["COZELOOP_API_TOKEN"] = new_token
|
|
1606
1611
|
hook_log("retry upload once after forced token refresh")
|
|
1607
|
-
return send_turns_to_cozeloop(turns, session_id, history_turns, retry_on_auth_failure=False)
|
|
1612
|
+
return send_turns_to_cozeloop(turns, session_id, history_turns, retry_on_auth_failure=False, coze_tags_override=coze_tags_override)
|
|
1608
1613
|
return None
|
|
1609
1614
|
|
|
1610
1615
|
return True
|
|
1611
1616
|
|
|
1612
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
|
+
|
|
1613
1861
|
# --- Hook Input ---
|
|
1614
1862
|
|
|
1615
1863
|
def read_hook_stdin() -> Dict[str, Any]:
|
|
@@ -1669,28 +1917,19 @@ def main():
|
|
|
1669
1917
|
debug_log(f"Using conversation file: {conversation_file}")
|
|
1670
1918
|
print(f"[CozeLoop] 读取会话文件: {conversation_file}", file=sys.stderr)
|
|
1671
1919
|
|
|
1672
|
-
# Load state
|
|
1920
|
+
# Load state
|
|
1673
1921
|
state_file = get_state_file_path(conversation_file)
|
|
1674
1922
|
state = load_state(state_file)
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
#
|
|
1678
|
-
#
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
last_upload_ts = state.get("last_upload_ts", 0)
|
|
1683
|
-
if now_ts - last_upload_ts < INCREMENTAL_UPLOAD_MIN_INTERVAL:
|
|
1684
|
-
debug_log(f"throttled: event={hook_event} since_last={now_ts - last_upload_ts:.1f}s < {INCREMENTAL_UPLOAD_MIN_INTERVAL}s, skip")
|
|
1685
|
-
return
|
|
1686
|
-
|
|
1687
|
-
# Read new messages from the file
|
|
1688
|
-
new_messages = read_new_messages(conversation_file, last_processed_line)
|
|
1689
|
-
|
|
1690
|
-
# 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
|
|
1691
1930
|
session_id = hook_input.get("session_id")
|
|
1692
1931
|
if not session_id:
|
|
1693
|
-
for msg in
|
|
1932
|
+
for msg in all_messages:
|
|
1694
1933
|
if msg.get("sessionId"):
|
|
1695
1934
|
session_id = msg.get("sessionId")
|
|
1696
1935
|
break
|
|
@@ -1699,72 +1938,48 @@ def main():
|
|
|
1699
1938
|
session_id = state["session_id"]
|
|
1700
1939
|
else:
|
|
1701
1940
|
session_id = f"claude-code-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{os.getpid()}"
|
|
1702
|
-
debug_log(f"Generated new session ID: {session_id}")
|
|
1703
|
-
|
|
1704
1941
|
state["session_id"] = session_id
|
|
1705
|
-
debug_log(f"Session ID: {session_id}")
|
|
1942
|
+
debug_log(f"Session ID: {session_id}, event={hook_event}")
|
|
1706
1943
|
|
|
1707
|
-
if not
|
|
1708
|
-
debug_log("No
|
|
1944
|
+
if not all_messages:
|
|
1945
|
+
debug_log("No messages to process.")
|
|
1709
1946
|
return
|
|
1710
1947
|
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
historical_messages = read_new_messages(conversation_file, 0)
|
|
1717
|
-
historical_messages = [m for m in historical_messages if m.get("_line_number", 0) < last_processed_line]
|
|
1718
|
-
history_turns = group_messages_into_turns(historical_messages)
|
|
1719
|
-
debug_log(f"Loaded {len(history_turns)} historical turn(s) for context.")
|
|
1720
|
-
|
|
1721
|
-
# Group messages into turns and send to CozeLoop — only if coze-context present.
|
|
1722
|
-
turns = group_messages_into_turns(new_messages)
|
|
1723
|
-
if turns:
|
|
1724
|
-
# coze-context 只出现在首条用户消息里(cozelab 注入的 agent 才有)。增量 hook 触发时,
|
|
1725
|
-
# 新消息往往全是工具调用/结果(不带 coze-context),故必须连同历史 turns 一起判断——
|
|
1726
|
-
# 只要整个会话出现过 coze-context,就说明是被注入的 agent,后续增量都应上报。
|
|
1727
|
-
def _turn_has_ctx(turn):
|
|
1728
|
-
return bool(coze_context_tags(
|
|
1729
|
-
(turn.get("user_message", {}).get("message", {}) or {}).get("content")
|
|
1730
|
-
))
|
|
1731
|
-
has_coze_ctx = any(_turn_has_ctx(t) for t in turns) or any(_turn_has_ctx(t) for t in history_turns)
|
|
1732
|
-
if not has_coze_ctx:
|
|
1733
|
-
debug_log("No coze-context found in any turn (incl. history), skipping upload.")
|
|
1734
|
-
return
|
|
1735
|
-
|
|
1736
|
-
# turn 边界控制:中途事件(PostToolUse)触发时,最后一个 turn 往往仍在进行中
|
|
1737
|
-
# (后续还会追加 step)。若此刻就上报并推进其行号,同一逻辑 turn 会在下次触发时
|
|
1738
|
-
# 因缺了起始 user 消息而被拆成新的 root span。故中途事件只上报“已完成”的 turn
|
|
1739
|
-
# (= 除最后一个之外的所有 turn),把最后一个留到下次/收尾。终态事件(Stop)上报全部。
|
|
1740
|
-
if is_terminal_event:
|
|
1741
|
-
turns_to_send = turns
|
|
1742
|
-
else:
|
|
1743
|
-
turns_to_send = turns[:-1]
|
|
1744
|
-
if not turns_to_send:
|
|
1745
|
-
debug_log(f"event={hook_event}: no completed turn to send yet (turns={len(turns)}), defer")
|
|
1746
|
-
return
|
|
1747
|
-
|
|
1748
|
-
print(f"[CozeLoop] 开始上报: session={session_id}, event={hook_event}, turns={len(turns_to_send)}/{len(turns)}", file=sys.stderr)
|
|
1749
|
-
uploaded = send_turns_to_cozeloop(turns_to_send, session_id, history_turns)
|
|
1750
|
-
if uploaded is None:
|
|
1751
|
-
debug_log("Send failed, state not advanced.")
|
|
1752
|
-
return
|
|
1753
|
-
|
|
1754
|
-
# 推进 last_processed_line:只推进到已上报 turn 覆盖的最后一行。中途事件保留了最后一个
|
|
1755
|
-
# 未完成 turn,故推进到“倒数第二个 turn 的末行”,让未完成 turn 的所有行下次重新读取。
|
|
1756
|
-
if is_terminal_event:
|
|
1757
|
-
last_line_in_batch = max(msg.get("_line_number", 0) for msg in new_messages)
|
|
1758
|
-
state["last_processed_line"] = last_line_in_batch + 1
|
|
1759
|
-
else:
|
|
1760
|
-
# turns_to_send 是 turns[:-1],下一个未发送 turn 的起始行即新的水位线。
|
|
1761
|
-
next_turn_start = turns[-1].get("start_line", 0)
|
|
1762
|
-
state["last_processed_line"] = next_turn_start
|
|
1763
|
-
state["last_upload_ts"] = time.time()
|
|
1764
|
-
save_state(state_file, state)
|
|
1765
|
-
print(f"[CozeLoop] 上报完成 ✓ session={session_id}, turns={len(turns_to_send)}", file=sys.stderr)
|
|
1766
|
-
debug_log(f"State updated. event={hook_event} last_processed_line={state['last_processed_line']}")
|
|
1948
|
+
# 全量分组成 turns/steps
|
|
1949
|
+
turns = group_messages_into_turns(all_messages)
|
|
1950
|
+
if not turns:
|
|
1951
|
+
debug_log("No turns.")
|
|
1952
|
+
return
|
|
1767
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:
|
|
1966
|
+
_um = (_t.get("user_message", {}).get("message", {}) or {})
|
|
1967
|
+
_tags = {k: v for k, v in coze_context_tags(_um.get("content")).items() if isinstance(v, str) and v.strip()}
|
|
1968
|
+
if _tags:
|
|
1969
|
+
state["coze_tags"] = _tags
|
|
1970
|
+
break
|
|
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')}")
|
|
1768
1983
|
debug_log("Hook finished.")
|
|
1769
1984
|
|
|
1770
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
|
-
#
|
|
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 -------------------------------------------------
|
|
@@ -1077,7 +1077,8 @@ def _make_model_message(role: str, content: str = "", tool_calls: list = None,
|
|
|
1077
1077
|
|
|
1078
1078
|
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_name: str = "codex",
|
|
1079
1079
|
history_context: Optional[List[Dict[str, Any]]] = None,
|
|
1080
|
-
retry_on_auth_failure: bool = True
|
|
1080
|
+
retry_on_auth_failure: bool = True,
|
|
1081
|
+
coze_tags_override: Optional[Dict[str, str]] = None) -> Optional[List[Dict[str, Any]]]:
|
|
1081
1082
|
"""Send conversation turns to CozeLoop for tracing.
|
|
1082
1083
|
|
|
1083
1084
|
Span hierarchy:
|
|
@@ -1162,6 +1163,11 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1162
1163
|
t = {f"coze_{k}": v for k, v in turn_coze_context(turn).items()}
|
|
1163
1164
|
if t:
|
|
1164
1165
|
coze_tags = t
|
|
1166
|
+
# 增量上报兜底:本批 turns 不含 <coze-context>(它只在首 turn)时,用 caller 从
|
|
1167
|
+
# state 传入的 override,保证每个 turn 的 root span 都带 coze_message_id,否则后续
|
|
1168
|
+
# 增量 turn 的 root span 无法按 message_id 查到。
|
|
1169
|
+
if not coze_tags and coze_tags_override:
|
|
1170
|
+
coze_tags = dict(coze_tags_override)
|
|
1165
1171
|
# Drop empty-valued coze_* tags: the backend pairs traces by exact tag
|
|
1166
1172
|
# match (coze_message_id / coze_agent_id), where an empty string is
|
|
1167
1173
|
# indistinguishable from "absent" yet still bloats the span — never
|
|
@@ -1539,12 +1545,172 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1539
1545
|
model_name,
|
|
1540
1546
|
history_context,
|
|
1541
1547
|
retry_on_auth_failure=False,
|
|
1548
|
+
coze_tags_override=coze_tags_override,
|
|
1542
1549
|
)
|
|
1543
1550
|
return None
|
|
1544
1551
|
|
|
1545
1552
|
return ctx
|
|
1546
1553
|
|
|
1547
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
|
+
|
|
1548
1714
|
# --- Main Execution ---
|
|
1549
1715
|
|
|
1550
1716
|
def main():
|
|
@@ -1608,19 +1774,19 @@ def main():
|
|
|
1608
1774
|
state_file = get_state_file_path(transcript_path)
|
|
1609
1775
|
state = load_state(state_file)
|
|
1610
1776
|
|
|
1611
|
-
#
|
|
1612
|
-
|
|
1777
|
+
# 方案 B(实时):读【全量】entries 重建完整 turn/tool 序列。实时路径用全局 tool 水位线
|
|
1778
|
+
# state["rt_last_tool"] 增量,每次触发把新完成的 tool_call 发出,结束即可见,不节流。
|
|
1779
|
+
entries = read_rollout_messages(transcript_path, 0)
|
|
1613
1780
|
|
|
1614
1781
|
if not entries:
|
|
1615
|
-
hook_log(f"skip no
|
|
1616
|
-
debug_log("No
|
|
1782
|
+
hook_log(f"skip no entries transcript={transcript_path}")
|
|
1783
|
+
debug_log("No entries to process")
|
|
1617
1784
|
return
|
|
1618
1785
|
|
|
1619
|
-
hook_log(f"read entries={len(entries)}
|
|
1620
|
-
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}")
|
|
1621
1787
|
|
|
1622
1788
|
# Parse session identity
|
|
1623
|
-
all_entries_for_meta =
|
|
1789
|
+
all_entries_for_meta = entries
|
|
1624
1790
|
session_info = parse_session_meta(all_entries_for_meta)
|
|
1625
1791
|
|
|
1626
1792
|
session_id = session_info["session_id"] or hook_input.get("session_id", "")
|
|
@@ -1672,65 +1838,33 @@ def main():
|
|
|
1672
1838
|
|
|
1673
1839
|
# Send turns to CozeLoop — only if at least one turn carries coze-context.
|
|
1674
1840
|
if turns:
|
|
1675
|
-
# coze-context 只出现在首个 turn
|
|
1676
|
-
|
|
1677
|
-
# 曾见过 coze-context"标记:一旦见过,后续增量批次都视为应上报(与 claude-code 用
|
|
1678
|
-
# history_turns 判断等效)。
|
|
1679
|
-
seen_ctx = bool(state.get("seen_coze_context"))
|
|
1680
|
-
has_coze_ctx = seen_ctx or any(
|
|
1681
|
-
turn_coze_context(t)
|
|
1682
|
-
for t in turns
|
|
1683
|
-
)
|
|
1841
|
+
# coze-context 只出现在首个 turn。会话出现过即视为应上报。
|
|
1842
|
+
has_coze_ctx = any(turn_coze_context(t) for t in turns)
|
|
1684
1843
|
if not has_coze_ctx:
|
|
1685
1844
|
hook_log(f"skip no coze-context turns={len(turns)} session_id={session_id}")
|
|
1686
1845
|
debug_log("No coze-context found in any turn, skipping upload.")
|
|
1687
1846
|
return
|
|
1688
|
-
if not seen_ctx:
|
|
1689
|
-
state["seen_coze_context"] = True
|
|
1690
|
-
|
|
1691
|
-
# 节流:PostToolUse 在密集工具调用下高频触发。距上次上报不足间隔则跳过本次增量上报。
|
|
1692
|
-
# 终态事件(Stop/SubagentStop)永不被节流,保证 turn 结束时一定收尾。
|
|
1693
|
-
if not is_terminal_event:
|
|
1694
|
-
now_ts = time.time()
|
|
1695
|
-
last_upload_ts = state.get("last_upload_ts", 0)
|
|
1696
|
-
if now_ts - last_upload_ts < INCREMENTAL_UPLOAD_MIN_INTERVAL:
|
|
1697
|
-
hook_log(f"throttled event={hook_event} since_last={now_ts - last_upload_ts:.1f}s session_id={session_id}")
|
|
1698
|
-
debug_log(f"throttled: event={hook_event}, skip incremental upload")
|
|
1699
|
-
return
|
|
1700
1847
|
|
|
1701
|
-
#
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1848
|
+
# 持久化 coze_tags 到 state,供 root span 注入。
|
|
1849
|
+
if not state.get("coze_tags"):
|
|
1850
|
+
for _t in turns:
|
|
1851
|
+
_tags = {f"coze_{k}": v for k, v in turn_coze_context(_t).items() if isinstance(v, str) and v.strip()}
|
|
1852
|
+
if _tags:
|
|
1853
|
+
state["coze_tags"] = _tags
|
|
1854
|
+
break
|
|
1855
|
+
coze_tags_override = state.get("coze_tags") or None
|
|
1856
|
+
|
|
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")
|
|
1711
1864
|
return
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
turns_to_send, session_id, model_name,
|
|
1716
|
-
history_context=history_context,
|
|
1717
|
-
)
|
|
1718
|
-
if updated_history is not None:
|
|
1719
|
-
# 推进 last_processed_line:终态推进到所有 entry 末行;中途保留最后一个未完成 turn,
|
|
1720
|
-
# 推进到该 turn 的起始行,让它的所有 entry 下次重新读取。
|
|
1721
|
-
if is_terminal_event:
|
|
1722
|
-
last_line = max(e.get("_line_number", 0) for e in entries) + 1
|
|
1723
|
-
else:
|
|
1724
|
-
last_line = turns[-1].get("start_line", max(e.get("_line_number", 0) for e in entries) + 1)
|
|
1725
|
-
state["last_processed_line"] = last_line
|
|
1726
|
-
state["conversation_history"] = updated_history
|
|
1727
|
-
state["last_upload_ts"] = time.time()
|
|
1728
|
-
save_state(state_file, state)
|
|
1729
|
-
hook_log(f"state advanced event={hook_event} last_line={last_line} sent={len(turns_to_send)}/{len(turns)} session_id={session_id}")
|
|
1730
|
-
debug_log(f"State updated, last processed line: {last_line}")
|
|
1731
|
-
else:
|
|
1732
|
-
hook_log(f"send failed state not advanced session_id={session_id}")
|
|
1733
|
-
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')}")
|
|
1734
1868
|
else:
|
|
1735
1869
|
hook_log(f"skip no turns session_id={session_id}")
|
|
1736
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
|
|
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",
|