coze_lab 0.1.25 → 0.1.27

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/index.js CHANGED
@@ -103,6 +103,18 @@ function successBox(lines) {
103
103
  // ─── 2. Script loading (scripts live as real files under ./scripts) ───────────
104
104
  const SCRIPTS_DIR = require('path').join(__dirname, 'scripts');
105
105
 
106
+ // OpenClaw 插件版本:来自 scripts/openclaw/package.json。写进 openclaw.json 的 pcfg,
107
+ // 参与 isOpenClawAlreadyInjected 幂等比对——插件代码升级(bump 该 version)后即使
108
+ // token/endpoint/workspace 不变也会被判定为“需更新”,强制重写插件文件 + npm install +
109
+ // gateway restart,避免旧插件 dist 滞留在云端 pluginDir 里不生效。
110
+ const OPENCLAW_PLUGIN_VERSION = (() => {
111
+ try {
112
+ return require('./scripts/openclaw/package.json').version || '';
113
+ } catch {
114
+ return '';
115
+ }
116
+ })();
117
+
106
118
  // Read a single script file (Python hook / refresh) as a UTF-8 string.
107
119
  function readScript(relPath) {
108
120
  return require('fs').readFileSync(require('path').join(SCRIPTS_DIR, relPath), 'utf8');
@@ -4378,28 +4390,34 @@ function checkPython() {
4378
4390
  return pythonCmd;
4379
4391
  }
4380
4392
 
4381
- // Verify the `cozeloop` Python SDK is importable; auto-install if missing.
4382
- // Claude Code / Codex hooks `import cozeloop` at runtime without it the
4383
- // first Stop hook fails with "cozeloop SDK not found" long after onboarding.
4393
+ // Verify the `cozeloop` Python SDK is importable AND new enough; install/upgrade otherwise.
4394
+ // Claude Code / Codex hooks `import cozeloop` at runtime and call set_finish_time(0.1.25+)。
4395
+ // 旧版(<=0.1.24)能 import 但缺该方法,会让 hook 上报整条 trace 失败,所以这里用能力探测
4396
+ // (hasattr set_finish_time)而非单纯 import,并在不达标时升级到带下限的版本。
4397
+ // 注意:这是安装阶段的 python,可能不是跑 hook 的那个 python(已知坑),所以它只是双保险——
4398
+ // 真正的运行时拦截在 hook 脚本的 _ensure_cozeloop_sdk 里。
4399
+ const COZELOOP_MIN_SPEC = 'cozeloop>=0.1.28';
4400
+ // 探测脚本:import 成功且具备 set_finish_time 能力 → exit 0;否则非 0。
4401
+ const COZELOOP_CAPABLE_PROBE = `import cozeloop,sys; sys.exit(0 if hasattr(cozeloop.Span,'set_finish_time') else 3)`;
4384
4402
  function checkCozeloopSdk(pythonCmd) {
4385
4403
  try {
4386
- execSync(`${pythonCmd} -c "import cozeloop"`, { stdio: 'pipe' });
4404
+ execSync(`${pythonCmd} -c "${COZELOOP_CAPABLE_PROBE}"`, { stdio: 'pipe' });
4387
4405
  ok('cozeloop SDK — OK');
4388
4406
  return;
4389
- } catch { /* not installed try to install below */ }
4407
+ } catch { /* 未装或版本过旧下面安装/升级 */ }
4390
4408
 
4391
- info('cozeloop SDK 未安装,正在安装 (pip install cozeloop)...');
4409
+ info(`cozeloop SDK 不可用或版本过旧,正在安装/升级 (pip install -U '${COZELOOP_MIN_SPEC}')...`);
4392
4410
  try {
4393
- execSync(`${pythonCmd} -m pip install --quiet --upgrade cozeloop`, { stdio: 'pipe' });
4394
- // Confirm it imports now
4395
- execSync(`${pythonCmd} -c "import cozeloop"`, { stdio: 'pipe' });
4396
- ok('cozeloop SDK 安装成功');
4411
+ execSync(`${pythonCmd} -m pip install --quiet --upgrade '${COZELOOP_MIN_SPEC}'`, { stdio: 'pipe' });
4412
+ // Confirm it imports and is capable now
4413
+ execSync(`${pythonCmd} -c "${COZELOOP_CAPABLE_PROBE}"`, { stdio: 'pipe' });
4414
+ ok('cozeloop SDK 安装/升级成功');
4397
4415
  } catch (e) {
4398
4416
  warnBox([
4399
- '⚠ WARNING: 无法自动安装 cozeloop SDK',
4417
+ '⚠ WARNING: 无法自动安装/升级 cozeloop SDK 到所需版本',
4400
4418
  '',
4401
4419
  '请手动安装后再使用,否则 hook 触发时 trace 上报会失败:',
4402
- ` ${pythonCmd} -m pip install cozeloop`,
4420
+ ` ${pythonCmd} -m pip install -U '${COZELOOP_MIN_SPEC}'`,
4403
4421
  '',
4404
4422
  (e.stderr ? e.stderr.toString().trim() : e.message),
4405
4423
  ]);
@@ -4772,6 +4790,11 @@ function applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud)
4772
4790
  pcfg.endpoint = getOtelEndpointBase(cloud);
4773
4791
  pcfg.workspaceId = workspaceId;
4774
4792
  pcfg.debug = true;
4793
+ // 插件代码版本:参与幂等比对,升级插件(bump scripts/openclaw/package.json version)后
4794
+ // 强制触发重写+重装+重启,避免云端 pluginDir 滞留旧插件 dist。
4795
+ if (OPENCLAW_PLUGIN_VERSION) {
4796
+ pcfg.pluginVersion = OPENCLAW_PLUGIN_VERSION;
4797
+ }
4775
4798
  // per-agent trace 放行:把当前 agentId 并入 traceAgentIds(去重、归一为小写,
4776
4799
  // 与插件侧 resolveAgentIdFromHookCtx 的归一一致)。无 agentId(全局模式)则不动
4777
4800
  // allowlist —— 空 allowlist 表示全部放行。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -29,19 +29,43 @@ from pathlib import Path
29
29
  from typing import Optional, List, Dict, Any
30
30
 
31
31
  # --- SDK Import ---
32
- def _ensure_cozeloop_sdk():
32
+ # 最低版本:set_finish_time 是 cozeloop SDK 0.1.25 才引入的方法,云端预装旧版(<=0.1.24)
33
+ # 调用会抛 AttributeError 并使整条 trace 上报失败。统一用能力探测(hasattr)判定,与
34
+ # _set_finish_time_safe 兜底口径一致,避免版本号字符串比较的边界问题。
35
+ _MIN_COZELOOP_SPEC = "cozeloop>=0.1.28"
36
+
37
+
38
+ def _cozeloop_capable():
39
+ """已装 cozeloop 是否具备 set_finish_time 能力。未装/异常返回 None(无法判定)。"""
33
40
  try:
34
41
  import cozeloop # noqa: F401
35
- return True
36
42
  except ImportError:
37
- pass
43
+ return None
44
+ try:
45
+ return hasattr(cozeloop.Span, "set_finish_time")
46
+ except Exception:
47
+ return False
48
+
49
+
50
+ def _ensure_cozeloop_sdk():
51
+ """确保 cozeloop 可 import 且尽量满足 set_finish_time 能力。
52
+
53
+ 返回 True 表示 cozeloop 可 import(不保证版本达标——能力不足时由
54
+ _set_finish_time_safe 兜底,不阻断上报);返回 False 表示完全无法 import。
55
+ """
56
+ capable = _cozeloop_capable()
57
+ if capable is True:
58
+ return True
59
+ # 已装但能力不足(capable is False)→ 需升级;未装(None)→ 需安装。
38
60
  import subprocess
39
61
  import importlib
40
62
  import site
63
+ pkg = _MIN_COZELOOP_SPEC
64
+ base_flags = ["--quiet", "--disable-pip-version-check", "--upgrade"]
41
65
  attempts = (
42
- ["--quiet", "--disable-pip-version-check", "cozeloop"],
43
- ["--quiet", "--disable-pip-version-check", "--break-system-packages", "cozeloop"],
44
- ["--quiet", "--disable-pip-version-check", "--break-system-packages", "--user", "cozeloop"],
66
+ [*base_flags, pkg],
67
+ [*base_flags, "--break-system-packages", pkg],
68
+ [*base_flags, "--break-system-packages", "--user", pkg],
45
69
  )
46
70
  for extra in attempts:
47
71
  try:
@@ -60,11 +84,12 @@ def _ensure_cozeloop_sdk():
60
84
  sys.path.insert(0, p)
61
85
  importlib.invalidate_caches()
62
86
  import cozeloop # noqa: F401
63
- print("[CozeLoop] cozeloop SDK auto-installed at runtime.", file=sys.stderr)
87
+ print("[CozeLoop] cozeloop SDK installed/upgraded at runtime.", file=sys.stderr)
64
88
  return True
65
89
  except ImportError:
66
90
  continue
67
- return False
91
+ # 升级没成功,但只要原本能 import(capable is False)就继续——兜底会处理能力缺失。
92
+ return capable is False
68
93
 
69
94
 
70
95
  if _ensure_cozeloop_sdk():
@@ -539,6 +564,23 @@ def _ts_ms(dt):
539
564
  return int(dt.timestamp() * 1000) if dt is not None else None
540
565
 
541
566
 
567
+ def _set_finish_time_safe(span, dt):
568
+ """安全设置 span 结束时间。
569
+
570
+ set_finish_time 是 cozeloop SDK 0.1.25+ 的方法,云端旧版没有;缺失或异常都不能
571
+ 让整条 trace 上报失败(real_*_ms tag 仍保留耗时信息)。
572
+ """
573
+ if dt is None:
574
+ return
575
+ fn = getattr(span, "set_finish_time", None)
576
+ if fn is None:
577
+ return
578
+ try:
579
+ fn(dt)
580
+ except Exception:
581
+ pass
582
+
583
+
542
584
  def group_messages_into_turns(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
543
585
  """Group messages into conversation turns (user -> assistant -> tool_results).
544
586
 
@@ -926,7 +968,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
926
968
  }
927
969
  # 真实耗时写 tag(SDK finish 不支持显式结束时间,duration 用 tag 兜底)
928
970
  if root_start_dt is not None and root_end_dt is not None:
929
- root_span.set_finish_time(root_end_dt)
971
+ _set_finish_time_safe(root_span, root_end_dt)
930
972
  root_tags["real_start_ms"] = _ts_ms(root_start_dt)
931
973
  root_tags["real_end_ms"] = _ts_ms(root_end_dt)
932
974
  root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
@@ -985,7 +1027,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
985
1027
  "source": "claude_code",
986
1028
  }
987
1029
  if turn_start_dt is not None and turn_end_dt is not None:
988
- turn_span.set_finish_time(turn_end_dt)
1030
+ _set_finish_time_safe(turn_span, turn_end_dt)
989
1031
  _turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
990
1032
  _turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
991
1033
  _turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
@@ -1019,7 +1061,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
1019
1061
  model_span.set_runtime(Runtime(library="claude-code"))
1020
1062
  model_span.set_model_name(model_name)
1021
1063
  if step_start_dt is not None and step_end_dt is not None:
1022
- model_span.set_finish_time(step_end_dt)
1064
+ _set_finish_time_safe(model_span, step_end_dt)
1023
1065
  model_span.set_tags({
1024
1066
  "real_start_ms": _ts_ms(step_start_dt),
1025
1067
  "real_end_ms": _ts_ms(step_end_dt),
@@ -1132,7 +1174,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
1132
1174
  tool_span.set_runtime(Runtime(library="claude-code"))
1133
1175
  # 设真实完成时间,让每个 tool 的 duration 反映实际耗时。
1134
1176
  if tool_end_dt is not None:
1135
- tool_span.set_finish_time(tool_end_dt)
1177
+ _set_finish_time_safe(tool_span, tool_end_dt)
1136
1178
  tags = {
1137
1179
  "tool_name": tool_name,
1138
1180
  "tool_call_id": tool_call.get("id"),
@@ -249,19 +249,44 @@ def get_fresh_token():
249
249
  # -------------------------------------------------------------------------
250
250
 
251
251
  # --- SDK Import ---
252
- def _ensure_cozeloop_sdk():
252
+ # 最低版本:set_finish_time 是 cozeloop SDK 0.1.25 才引入的方法,云端预装旧版(<=0.1.24)
253
+ # 调用会抛 AttributeError 并使整条 trace 上报失败。统一用能力探测(hasattr)判定,与
254
+ # _set_finish_time_safe 兜底口径一致,避免版本号字符串比较的边界问题。
255
+ _MIN_COZELOOP_SPEC = "cozeloop>=0.1.28"
256
+
257
+
258
+ def _cozeloop_capable():
259
+ """已装 cozeloop 是否具备 set_finish_time 能力。未装/异常返回 None(无法判定)。"""
253
260
  try:
254
261
  import cozeloop # noqa: F401
255
- return True
256
262
  except ImportError:
257
- pass
263
+ return None
264
+ try:
265
+ return hasattr(cozeloop.Span, "set_finish_time")
266
+ except Exception:
267
+ return False
268
+
269
+
270
+ def _ensure_cozeloop_sdk():
271
+ """确保 cozeloop 可 import 且尽量满足 set_finish_time 能力。
272
+
273
+ 返回 True 表示 cozeloop 可 import(不保证版本达标——能力不足时由
274
+ _set_finish_time_safe 兜底,不阻断上报);返回 False 表示完全无法 import。
275
+ """
276
+ capable = _cozeloop_capable()
277
+ if capable is True:
278
+ return True
279
+ # 已装但能力不足(capable is False)→ 需升级;未装(None)→ 需安装。
258
280
  import subprocess
259
281
  import importlib
260
282
  import site
283
+ # 能力不足时强制升级到带下限的版本;未装时直接装下限版本。
284
+ pkg = _MIN_COZELOOP_SPEC
285
+ base_flags = ["--quiet", "--disable-pip-version-check", "--upgrade"]
261
286
  attempts = (
262
- ["--quiet", "--disable-pip-version-check", "cozeloop"],
263
- ["--quiet", "--disable-pip-version-check", "--break-system-packages", "cozeloop"],
264
- ["--quiet", "--disable-pip-version-check", "--break-system-packages", "--user", "cozeloop"],
287
+ [*base_flags, pkg],
288
+ [*base_flags, "--break-system-packages", pkg],
289
+ [*base_flags, "--break-system-packages", "--user", pkg],
265
290
  )
266
291
  for extra in attempts:
267
292
  try:
@@ -280,11 +305,12 @@ def _ensure_cozeloop_sdk():
280
305
  sys.path.insert(0, p)
281
306
  importlib.invalidate_caches()
282
307
  import cozeloop # noqa: F401
283
- print("[CozeLoop] cozeloop SDK auto-installed at runtime.", file=sys.stderr)
308
+ print("[CozeLoop] cozeloop SDK installed/upgraded at runtime.", file=sys.stderr)
284
309
  return True
285
310
  except ImportError:
286
311
  continue
287
- return False
312
+ # 升级没成功,但只要原本能 import(capable is False)就继续——兜底会处理能力缺失。
313
+ return capable is False
288
314
 
289
315
 
290
316
  if _ensure_cozeloop_sdk():
@@ -640,6 +666,71 @@ def _ts_ms(dt):
640
666
  return int(dt.timestamp() * 1000) if dt is not None else None
641
667
 
642
668
 
669
+ def _set_finish_time_safe(span, dt):
670
+ """安全设置 span 结束时间。
671
+
672
+ set_finish_time 是 cozeloop SDK 0.1.25+ 的方法,云端旧版没有;缺失或异常都不能
673
+ 让整条 trace 上报失败(real_*_ms tag 仍保留耗时信息)。
674
+ """
675
+ if dt is None:
676
+ return
677
+ fn = getattr(span, "set_finish_time", None)
678
+ if fn is None:
679
+ return
680
+ try:
681
+ fn(dt)
682
+ except Exception:
683
+ pass
684
+
685
+
686
+ def _max_dt(*values):
687
+ result = None
688
+ for value in values:
689
+ if value is not None and (result is None or value > result):
690
+ result = value
691
+ return result
692
+
693
+
694
+ def _parse_ts_value(value):
695
+ return _parse_ts({"_ts": value}) if value else None
696
+
697
+
698
+ def _turn_timestamps(turn):
699
+ values = []
700
+ for item in [turn.get("user_message"), *turn.get("assistant_messages", [])]:
701
+ dt = _parse_ts(item)
702
+ if dt:
703
+ values.append(dt)
704
+ for item in [*turn.get("tool_calls", []), *turn.get("tool_results", [])]:
705
+ dt = _parse_ts(item)
706
+ if dt:
707
+ values.append(dt)
708
+ for sc in turn.get("subagent_calls", []):
709
+ for key in ("_start_ts", "_end_ts"):
710
+ dt = _parse_ts_value(sc.get(key))
711
+ if dt:
712
+ values.append(dt)
713
+ return values
714
+
715
+
716
+ def _turn_bounds(turn):
717
+ values = _turn_timestamps(turn)
718
+ if not values:
719
+ return None, None
720
+ return min(values), max(values)
721
+
722
+
723
+ def _assistant_bounds(turn):
724
+ values = []
725
+ for item in turn.get("assistant_messages", []):
726
+ dt = _parse_ts(item)
727
+ if dt:
728
+ values.append(dt)
729
+ if not values:
730
+ return None, None
731
+ return min(values), max(values)
732
+
733
+
643
734
  def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
644
735
  """Group raw JSONL entries into conversation turns.
645
736
 
@@ -749,6 +840,8 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
749
840
  "message": args.get("message", ""),
750
841
  "model": args.get("model"),
751
842
  "result": None,
843
+ "_start_ts": payload.get("_ts"),
844
+ "_end_ts": None,
752
845
  }
753
846
  current_turn["subagent_calls"].append(subagent_call)
754
847
  pending_calls[call_id] = {"kind": "spawn", "subagent_call": subagent_call}
@@ -756,14 +849,16 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
756
849
  pending_calls[call_id] = {
757
850
  "kind": "wait",
758
851
  "ids": args.get("ids", []),
852
+ "_start_ts": payload.get("_ts"),
759
853
  }
760
854
  else:
761
855
  current_turn["tool_calls"].append({
762
856
  "call_id": call_id,
763
857
  "name": name,
764
858
  "input": args,
859
+ "_ts": payload.get("_ts"),
765
860
  })
766
- pending_calls[call_id] = {"kind": "tool"}
861
+ pending_calls[call_id] = {"kind": "tool", "_start_ts": payload.get("_ts")}
767
862
 
768
863
  elif item_type == "function_call_output":
769
864
  if current_turn is None:
@@ -783,6 +878,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
783
878
  subagent_call["nickname"] = out.get("nickname")
784
879
  except (json.JSONDecodeError, TypeError, AttributeError):
785
880
  pass
881
+ subagent_call["_end_ts"] = payload.get("_ts")
786
882
 
787
883
  elif kind == "wait":
788
884
  try:
@@ -795,6 +891,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
795
891
  for sc in current_turn["subagent_calls"]:
796
892
  if sc.get("agent_id") == agent_id and sc.get("result") is None:
797
893
  sc["result"] = result_text
894
+ sc["_end_ts"] = payload.get("_ts")
798
895
  break
799
896
  except (json.JSONDecodeError, TypeError, AttributeError):
800
897
  pass
@@ -803,6 +900,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
803
900
  current_turn["tool_results"].append({
804
901
  "call_id": call_id,
805
902
  "output": raw_output,
903
+ "_ts": payload.get("_ts"),
806
904
  })
807
905
 
808
906
  if current_turn is not None:
@@ -888,13 +986,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
888
986
  # 整体时间范围:所有 user/assistant payload 的真实 timestamp 极值
889
987
  _all_ts = []
890
988
  for _t in turns:
891
- _u = _parse_ts(_t.get("user_message"))
892
- if _u:
893
- _all_ts.append(_u)
894
- for _a in _t.get("assistant_messages", []):
895
- _ad = _parse_ts(_a)
896
- if _ad:
897
- _all_ts.append(_ad)
989
+ _all_ts.extend(_turn_timestamps(_t))
898
990
  root_start_dt = min(_all_ts) if _all_ts else None
899
991
  root_end_dt = max(_all_ts) if _all_ts else None
900
992
 
@@ -906,6 +998,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
906
998
  "source": "codex_cli",
907
999
  }
908
1000
  if root_start_dt is not None and root_end_dt is not None:
1001
+ _set_finish_time_safe(root_span, root_end_dt)
909
1002
  root_tags["real_start_ms"] = _ts_ms(root_start_dt)
910
1003
  root_tags["real_end_ms"] = _ts_ms(root_end_dt)
911
1004
  root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
@@ -947,14 +1040,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
947
1040
  for i, turn in enumerate(turns):
948
1041
  try:
949
1042
  # turn 真实时间:start=user payload 时间;end=最后一条 assistant payload 时间
950
- turn_start_dt = _parse_ts(turn.get("user_message"))
951
- turn_end_dt = None
952
- for _a in turn.get("assistant_messages", []):
953
- _ad = _parse_ts(_a)
954
- if _ad:
955
- turn_end_dt = _ad
956
- if turn_start_dt is None:
957
- turn_start_dt = turn_end_dt
1043
+ turn_start_dt, turn_end_dt = _turn_bounds(turn)
958
1044
 
959
1045
  with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
960
1046
  turn_span.set_runtime(Runtime(library="codex-cli"))
@@ -965,6 +1051,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
965
1051
  "source": "codex_cli",
966
1052
  }
967
1053
  if turn_start_dt is not None and turn_end_dt is not None:
1054
+ _set_finish_time_safe(turn_span, turn_end_dt)
968
1055
  _turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
969
1056
  _turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
970
1057
  _turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
@@ -973,21 +1060,20 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
973
1060
  # --- Model span for assistant response ---
974
1061
  if turn.get("assistant_messages"):
975
1062
  # model span start:第一条 assistant payload 时间,回退到 turn 起点
976
- _model_start_dt = None
977
- for _a in turn.get("assistant_messages", []):
978
- _model_start_dt = _parse_ts(_a)
979
- if _model_start_dt:
980
- break
1063
+ _model_start_dt, _model_end_dt = _assistant_bounds(turn)
981
1064
  if _model_start_dt is None:
982
1065
  _model_start_dt = turn_start_dt
1066
+ if _model_end_dt is None:
1067
+ _model_end_dt = turn_end_dt
983
1068
  with client.start_span(name="assistant_response", span_type="model", start_time=_model_start_dt) as model_span:
984
1069
  model_span.set_runtime(Runtime(library="codex-cli"))
985
1070
  model_span.set_model_name(model_name)
986
- if _model_start_dt is not None and turn_end_dt is not None:
1071
+ if _model_start_dt is not None and _model_end_dt is not None:
1072
+ _set_finish_time_safe(model_span, _model_end_dt)
987
1073
  model_span.set_tags({
988
1074
  "real_start_ms": _ts_ms(_model_start_dt),
989
- "real_end_ms": _ts_ms(turn_end_dt),
990
- "latency_ms": _ts_ms(turn_end_dt) - _ts_ms(_model_start_dt),
1075
+ "real_end_ms": _ts_ms(_model_end_dt),
1076
+ "latency_ms": _ts_ms(_model_end_dt) - _ts_ms(_model_start_dt),
991
1077
  })
992
1078
 
993
1079
  # Build input messages: history + current turn input
@@ -1056,17 +1142,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1056
1142
  # --- Tool call spans ---
1057
1143
  for tool_call in turn.get("tool_calls", []):
1058
1144
  tool_name = tool_call.get("name", "unknown")
1059
- with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=turn_start_dt) as tool_span:
1145
+ tool_start_dt = _parse_ts(tool_call) or turn_start_dt
1146
+ tool_end_dt = None
1147
+ call_id = tool_call.get("call_id")
1148
+ for result in turn.get("tool_results", []):
1149
+ if result.get("call_id") == call_id:
1150
+ tool_end_dt = _parse_ts(result)
1151
+ break
1152
+ tool_finish_dt = _max_dt(tool_end_dt, tool_start_dt)
1153
+ with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=tool_start_dt) as tool_span:
1060
1154
  tool_span.set_runtime(Runtime(library="codex-cli"))
1061
- tool_span.set_tags({
1155
+ tool_tags = {
1062
1156
  "tool_name": tool_name,
1063
- "call_id": tool_call.get("call_id"),
1064
- })
1157
+ "call_id": call_id,
1158
+ }
1159
+ if tool_start_dt is not None and tool_finish_dt is not None:
1160
+ _set_finish_time_safe(tool_span, tool_finish_dt)
1161
+ tool_tags["real_start_ms"] = _ts_ms(tool_start_dt)
1162
+ tool_tags["real_end_ms"] = _ts_ms(tool_finish_dt)
1163
+ tool_tags["latency_ms"] = _ts_ms(tool_finish_dt) - _ts_ms(tool_start_dt)
1164
+ tool_span.set_tags(tool_tags)
1065
1165
  tool_span.set_input(
1066
1166
  json.dumps(tool_call.get("input", {}), ensure_ascii=False)[:2000]
1067
1167
  )
1068
1168
  # Find matching tool result
1069
- call_id = tool_call.get("call_id")
1070
1169
  for result in turn.get("tool_results", []):
1071
1170
  if result.get("call_id") == call_id:
1072
1171
  output = result.get("output", "")
@@ -1079,15 +1178,24 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1079
1178
  for sc in turn.get("subagent_calls", []):
1080
1179
  agent_id = sc.get("agent_id") or "unknown"
1081
1180
  nickname = sc.get("nickname") or agent_id
1181
+ subagent_start_dt = _parse_ts_value(sc.get("_start_ts")) or turn_start_dt
1182
+ subagent_end_dt = _parse_ts_value(sc.get("_end_ts")) or turn_end_dt
1183
+ subagent_finish_dt = _max_dt(subagent_end_dt, subagent_start_dt)
1082
1184
 
1083
- with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=turn_start_dt) as subagent_span:
1185
+ with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=subagent_start_dt) as subagent_span:
1084
1186
  subagent_span.set_runtime(Runtime(library="codex-cli"))
1085
- subagent_span.set_tags({
1187
+ subagent_tags = {
1086
1188
  "agent_id": agent_id,
1087
1189
  "agent_nickname": nickname,
1088
1190
  "agent_role": sc.get("role") or "",
1089
1191
  "agent_model": sc.get("model") or "",
1090
- })
1192
+ }
1193
+ if subagent_start_dt is not None and subagent_finish_dt is not None:
1194
+ _set_finish_time_safe(subagent_span, subagent_finish_dt)
1195
+ subagent_tags["real_start_ms"] = _ts_ms(subagent_start_dt)
1196
+ subagent_tags["real_end_ms"] = _ts_ms(subagent_finish_dt)
1197
+ subagent_tags["latency_ms"] = _ts_ms(subagent_finish_dt) - _ts_ms(subagent_start_dt)
1198
+ subagent_span.set_tags(subagent_tags)
1091
1199
  subagent_span.set_input(sc.get("message", "")[:2000])
1092
1200
 
1093
1201
  # Load and include saved subagent turn data
@@ -1097,20 +1205,45 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1097
1205
  sa_model = sa_data.get("model_name", "codex")
1098
1206
 
1099
1207
  for si, sa_turn in enumerate(sa_turns):
1100
- with client.start_span(name=f"turn_{si}", span_type="main") as sa_turn_span:
1208
+ sa_turn_start_dt, sa_turn_end_dt = _turn_bounds(sa_turn)
1209
+ if sa_turn_start_dt is None:
1210
+ sa_turn_start_dt = subagent_start_dt
1211
+ if sa_turn_end_dt is None:
1212
+ sa_turn_end_dt = sa_turn_start_dt
1213
+ sa_turn_finish_dt = _max_dt(sa_turn_end_dt, sa_turn_start_dt)
1214
+
1215
+ with client.start_span(name=f"turn_{si}", span_type="main", start_time=sa_turn_start_dt) as sa_turn_span:
1101
1216
  sa_turn_span.set_runtime(Runtime(library="codex-cli"))
1102
- sa_turn_span.set_tags({
1217
+ sa_turn_tags = {
1103
1218
  "turn_index": si,
1104
1219
  "turn_id": sa_turn.get("turn_id", ""),
1105
1220
  "agent_name": nickname,
1106
- })
1221
+ }
1222
+ if sa_turn_start_dt is not None and sa_turn_finish_dt is not None:
1223
+ _set_finish_time_safe(sa_turn_span, sa_turn_finish_dt)
1224
+ sa_turn_tags["real_start_ms"] = _ts_ms(sa_turn_start_dt)
1225
+ sa_turn_tags["real_end_ms"] = _ts_ms(sa_turn_finish_dt)
1226
+ sa_turn_tags["latency_ms"] = _ts_ms(sa_turn_finish_dt) - _ts_ms(sa_turn_start_dt)
1227
+ sa_turn_span.set_tags(sa_turn_tags)
1107
1228
 
1108
1229
  # Subagent model span
1109
1230
  if sa_turn.get("assistant_messages"):
1110
- with client.start_span(name="assistant_response", span_type="model") as sa_model_span:
1231
+ sa_model_start_dt, sa_model_end_dt = _assistant_bounds(sa_turn)
1232
+ if sa_model_start_dt is None:
1233
+ sa_model_start_dt = sa_turn_start_dt
1234
+ if sa_model_end_dt is None:
1235
+ sa_model_end_dt = sa_turn_end_dt
1236
+ sa_model_finish_dt = _max_dt(sa_model_end_dt, sa_model_start_dt)
1237
+ with client.start_span(name="assistant_response", span_type="model", start_time=sa_model_start_dt) as sa_model_span:
1111
1238
  sa_model_span.set_runtime(Runtime(library="codex-cli"))
1112
1239
  sa_model_span.set_model_name(sa_model)
1113
- sa_model_span.set_tags({"agent_name": nickname})
1240
+ sa_model_tags = {"agent_name": nickname}
1241
+ if sa_model_start_dt is not None and sa_model_finish_dt is not None:
1242
+ _set_finish_time_safe(sa_model_span, sa_model_finish_dt)
1243
+ sa_model_tags["real_start_ms"] = _ts_ms(sa_model_start_dt)
1244
+ sa_model_tags["real_end_ms"] = _ts_ms(sa_model_finish_dt)
1245
+ sa_model_tags["latency_ms"] = _ts_ms(sa_model_finish_dt) - _ts_ms(sa_model_start_dt)
1246
+ sa_model_span.set_tags(sa_model_tags)
1114
1247
 
1115
1248
  sa_input = sa_turn.get("input_messages", [])
1116
1249
  if not sa_input:
@@ -1154,17 +1287,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1154
1287
  # Subagent tool spans
1155
1288
  for sa_tc in sa_turn.get("tool_calls", []):
1156
1289
  sa_tool_name = sa_tc.get("name", "unknown")
1157
- with client.start_span(name=f"tool_{sa_tool_name}", span_type="tool") as sa_tool_span:
1290
+ sa_tool_start_dt = _parse_ts(sa_tc) or sa_turn_start_dt
1291
+ sa_tool_end_dt = None
1292
+ sa_cid = sa_tc.get("call_id")
1293
+ for sa_r in sa_turn.get("tool_results", []):
1294
+ if sa_r.get("call_id") == sa_cid:
1295
+ sa_tool_end_dt = _parse_ts(sa_r)
1296
+ break
1297
+ sa_tool_finish_dt = _max_dt(sa_tool_end_dt, sa_tool_start_dt)
1298
+ with client.start_span(name=f"tool_{sa_tool_name}", span_type="tool", start_time=sa_tool_start_dt) as sa_tool_span:
1158
1299
  sa_tool_span.set_runtime(Runtime(library="codex-cli"))
1159
- sa_tool_span.set_tags({
1300
+ sa_tool_tags = {
1160
1301
  "tool_name": sa_tool_name,
1161
- "call_id": sa_tc.get("call_id"),
1302
+ "call_id": sa_cid,
1162
1303
  "agent_name": nickname,
1163
- })
1304
+ }
1305
+ if sa_tool_start_dt is not None and sa_tool_finish_dt is not None:
1306
+ _set_finish_time_safe(sa_tool_span, sa_tool_finish_dt)
1307
+ sa_tool_tags["real_start_ms"] = _ts_ms(sa_tool_start_dt)
1308
+ sa_tool_tags["real_end_ms"] = _ts_ms(sa_tool_finish_dt)
1309
+ sa_tool_tags["latency_ms"] = _ts_ms(sa_tool_finish_dt) - _ts_ms(sa_tool_start_dt)
1310
+ sa_tool_span.set_tags(sa_tool_tags)
1164
1311
  sa_tool_span.set_input(
1165
1312
  json.dumps(sa_tc.get("input", {}), ensure_ascii=False)[:2000]
1166
1313
  )
1167
- sa_cid = sa_tc.get("call_id")
1168
1314
  for sa_r in sa_turn.get("tool_results", []):
1169
1315
  if sa_r.get("call_id") == sa_cid:
1170
1316
  sa_out = sa_r.get("output", "")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cozeloop/openclaw-cozeloop-trace",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "OpenClaw Plugin for reporting traces to CozeLoop via OpenTelemetry",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",