coze_lab 0.1.27 → 0.1.28
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 +879 -83
- package/package.json +4 -1
- package/scripts/claude-code/cozeloop_hook.py +56 -19
- package/scripts/codex/cozeloop_hook.py +47 -14
- package/scripts/openclaw/dist/cozeloop-exporter.js +11 -3
- package/scripts/openclaw/dist/index.js +1 -0
- package/scripts/openclaw/openclaw.plugin.json +6 -1
- package/scripts/shared/cozeloop_refresh.py +8 -3
package/index.js
CHANGED
|
@@ -228,15 +228,21 @@ def _refresh_token(refresh_token: str) -> Optional[str]:
|
|
|
228
228
|
req = urllib.request.Request(
|
|
229
229
|
f"{_COZE_API}/api/permission/oauth2/token",
|
|
230
230
|
data=payload,
|
|
231
|
-
headers={
|
|
231
|
+
headers={
|
|
232
|
+
"Content-Type": "application/json",
|
|
233
|
+
"x-tt-env": "ppe_cozelab",
|
|
234
|
+
"x-use-ppe": "1",
|
|
235
|
+
},
|
|
232
236
|
)
|
|
233
237
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
234
238
|
data = json.loads(resp.read())
|
|
235
239
|
if data.get("access_token"):
|
|
240
|
+
existing = _load_credentials() or {}
|
|
236
241
|
creds = {
|
|
237
242
|
"access_token": data["access_token"],
|
|
238
243
|
"refresh_token": data.get("refresh_token", refresh_token),
|
|
239
244
|
"expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
|
|
245
|
+
"workspace_id": existing.get("workspace_id", ""),
|
|
240
246
|
}
|
|
241
247
|
_save_credentials(creds)
|
|
242
248
|
debug_log("Token refreshed successfully.")
|
|
@@ -245,22 +251,34 @@ def _refresh_token(refresh_token: str) -> Optional[str]:
|
|
|
245
251
|
debug_log(f"Token refresh failed: {e}")
|
|
246
252
|
return None
|
|
247
253
|
|
|
254
|
+
def _token_from_credentials() -> Optional[str]:
|
|
255
|
+
creds = _load_credentials()
|
|
256
|
+
if not creds:
|
|
257
|
+
return None
|
|
258
|
+
expires_at_sec = creds.get("expires_at", 0) / 1000
|
|
259
|
+
remaining = expires_at_sec - time.time()
|
|
260
|
+
if remaining > _REFRESH_THRESHOLD:
|
|
261
|
+
debug_log(f"Cached token valid, expires in {int(remaining)}s.")
|
|
262
|
+
return creds.get("access_token")
|
|
263
|
+
if creds.get("refresh_token"):
|
|
264
|
+
debug_log(f"Token expiring in {int(remaining)}s, refreshing...")
|
|
265
|
+
new_token = _refresh_token(creds["refresh_token"])
|
|
266
|
+
if new_token:
|
|
267
|
+
return new_token
|
|
268
|
+
debug_log("Refresh failed.")
|
|
269
|
+
return None
|
|
270
|
+
|
|
248
271
|
def get_fresh_token() -> Optional[str]:
|
|
249
|
-
"""Return a valid access token, refreshing if needed.
|
|
272
|
+
"""Return a valid access token, refreshing if needed."""
|
|
273
|
+
env_token = os.environ.get("COZELOOP_API_TOKEN")
|
|
274
|
+
env_coze_token = os.environ.get("COZE_API_TOKEN")
|
|
275
|
+
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
276
|
+
if is_cloud:
|
|
277
|
+
return env_token or env_coze_token or _token_from_credentials()
|
|
250
278
|
creds = _load_credentials()
|
|
251
279
|
if creds:
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if remaining > _REFRESH_THRESHOLD:
|
|
255
|
-
debug_log(f"Cached token valid, expires in {int(remaining)}s.")
|
|
256
|
-
return creds["access_token"]
|
|
257
|
-
if creds.get("refresh_token"):
|
|
258
|
-
debug_log(f"Token expiring in {int(remaining)}s, refreshing...")
|
|
259
|
-
new_token = _refresh_token(creds["refresh_token"])
|
|
260
|
-
if new_token:
|
|
261
|
-
return new_token
|
|
262
|
-
debug_log("Refresh failed, falling back to env var.")
|
|
263
|
-
return os.environ.get("COZELOOP_API_TOKEN")
|
|
280
|
+
return _token_from_credentials()
|
|
281
|
+
return env_token or env_coze_token
|
|
264
282
|
|
|
265
283
|
# -------------------------------------------------------------------------
|
|
266
284
|
|
|
@@ -1346,6 +1364,125 @@ from typing import Optional, List, Dict, Any
|
|
|
1346
1364
|
_COZELOOP_CLIENT_ID = "46371084383473718052118955183420.app.coze"
|
|
1347
1365
|
_COZE_API = "https://api.coze.cn"
|
|
1348
1366
|
_REFRESH_THRESHOLD = 10 * 60
|
|
1367
|
+
_DEFAULT_WORKSPACE_ID = "7644910356078837760" # hardcoded spaceID fallback
|
|
1368
|
+
_OTEL_SUFFIX = "/v1/loop/opentelemetry"
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
# --- coze-context parsing -------------------------------------------------
|
|
1372
|
+
# User messages may embed a block like:
|
|
1373
|
+
# <coze-context>
|
|
1374
|
+
# account_id: 0
|
|
1375
|
+
# agent_id: 7644920552473395499
|
|
1376
|
+
# session_id: 7644919579054997796
|
|
1377
|
+
# message_id: 04dd5246-...
|
|
1378
|
+
# </coze-context>
|
|
1379
|
+
# We parse its key:value pairs and inject them into the trace.
|
|
1380
|
+
|
|
1381
|
+
_COZE_CTX_OPEN = "<coze-context>"
|
|
1382
|
+
_COZE_CTX_CLOSE = "</coze-context>"
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def parse_coze_context(text: str) -> Dict[str, str]:
|
|
1386
|
+
"""Extract the LAST <coze-context> block's key:value pairs from text.
|
|
1387
|
+
|
|
1388
|
+
Returns {} if no block is present. Tag keys are prefixed with
|
|
1389
|
+
'coze_' by the caller; here we return raw keys as written.
|
|
1390
|
+
"""
|
|
1391
|
+
if not text or _COZE_CTX_OPEN not in text:
|
|
1392
|
+
return {}
|
|
1393
|
+
# Take the last occurrence (latest context wins).
|
|
1394
|
+
open_idx = text.rfind(_COZE_CTX_OPEN)
|
|
1395
|
+
close_idx = text.find(_COZE_CTX_CLOSE, open_idx)
|
|
1396
|
+
if close_idx == -1:
|
|
1397
|
+
return {}
|
|
1398
|
+
body = text[open_idx + len(_COZE_CTX_OPEN):close_idx]
|
|
1399
|
+
# The block may arrive with real newlines, OR with literal backslash-n
|
|
1400
|
+
# (e.g. when the whole message is an embedded JSON string that was never
|
|
1401
|
+
# un-escaped). Normalize both forms before splitting into lines.
|
|
1402
|
+
body = body.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n")
|
|
1403
|
+
result: Dict[str, str] = {}
|
|
1404
|
+
for line in body.splitlines():
|
|
1405
|
+
line = line.strip()
|
|
1406
|
+
if not line or ":" not in line:
|
|
1407
|
+
continue
|
|
1408
|
+
key, _, value = line.partition(":")
|
|
1409
|
+
key = key.strip()
|
|
1410
|
+
value = value.strip()
|
|
1411
|
+
if key:
|
|
1412
|
+
result[key] = value
|
|
1413
|
+
return result
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def coze_context_tags(text: str) -> Dict[str, str]:
|
|
1417
|
+
"""Return coze-context kv as trace tags, prefixed with 'coze_'."""
|
|
1418
|
+
return {f"coze_{k}": v for k, v in parse_coze_context(text).items()}
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def turn_coze_context(turn: Dict[str, Any]) -> Dict[str, str]:
|
|
1422
|
+
"""Extract coze-context from a grouped turn with fallbacks for Codex rollout shapes."""
|
|
1423
|
+
texts = [turn.get("user_message_text", "")]
|
|
1424
|
+
for msg in turn.get("input_messages", []):
|
|
1425
|
+
if isinstance(msg, dict):
|
|
1426
|
+
texts.append(str(msg.get("content", "")))
|
|
1427
|
+
user_payload = turn.get("user_message")
|
|
1428
|
+
if isinstance(user_payload, dict):
|
|
1429
|
+
texts.append(extract_message_content_text(user_payload))
|
|
1430
|
+
for text in texts:
|
|
1431
|
+
ctx = parse_coze_context(text)
|
|
1432
|
+
if ctx:
|
|
1433
|
+
return ctx
|
|
1434
|
+
return {}
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
# --- trace upload failure / logid capture ---------------------------------
|
|
1438
|
+
def _extract_logid(msg: str) -> str:
|
|
1439
|
+
"""Pull the server logid out of an SDK error message, if present.
|
|
1440
|
+
|
|
1441
|
+
SDK failure messages embed it as 'logid=XXXX' (sometimes within brackets).
|
|
1442
|
+
"""
|
|
1443
|
+
if not msg:
|
|
1444
|
+
return ""
|
|
1445
|
+
marker = "logid="
|
|
1446
|
+
idx = msg.find(marker)
|
|
1447
|
+
if idx == -1:
|
|
1448
|
+
return ""
|
|
1449
|
+
rest = msg[idx + len(marker):]
|
|
1450
|
+
logid = []
|
|
1451
|
+
for ch in rest:
|
|
1452
|
+
if ch.isalnum():
|
|
1453
|
+
logid.append(ch)
|
|
1454
|
+
else:
|
|
1455
|
+
break
|
|
1456
|
+
return "".join(logid)
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _make_finish_event_processor(upload_events: Optional[List[str]] = None):
|
|
1460
|
+
"""Return a trace_finish_event_processor that surfaces failures + logid.
|
|
1461
|
+
|
|
1462
|
+
The CozeLoop SDK calls this for each flush event; on failure we print the
|
|
1463
|
+
server logid to stderr so it can be handed to platform support for tracing
|
|
1464
|
+
the root cause (e.g. via \`bytedcli log get-logid-log <logid>\`).
|
|
1465
|
+
"""
|
|
1466
|
+
def _processor(info):
|
|
1467
|
+
try:
|
|
1468
|
+
if not getattr(info, "is_event_fail", False):
|
|
1469
|
+
hook_log("upload success")
|
|
1470
|
+
return
|
|
1471
|
+
detail = getattr(info, "detail_msg", "") or ""
|
|
1472
|
+
if upload_events is not None:
|
|
1473
|
+
upload_events.append(detail or "trace export failed")
|
|
1474
|
+
logid = _extract_logid(detail)
|
|
1475
|
+
if logid:
|
|
1476
|
+
hook_log(f"upload failed logid={logid} detail={detail[:500]}")
|
|
1477
|
+
print(f"[CozeLoop] 上报失败 logid={logid} (可用 bytedcli log get-logid-log {logid} 排查)", file=sys.stderr)
|
|
1478
|
+
else:
|
|
1479
|
+
hook_log(f"upload failed detail={detail[:500]}")
|
|
1480
|
+
print(f"[CozeLoop] 上报失败: {detail[:300]}", file=sys.stderr)
|
|
1481
|
+
except Exception:
|
|
1482
|
+
pass
|
|
1483
|
+
return _processor
|
|
1484
|
+
|
|
1485
|
+
|
|
1349
1486
|
|
|
1350
1487
|
def _get_credentials_path() -> Path:
|
|
1351
1488
|
return Path.home() / ".cozeloop" / "credentials.json"
|
|
@@ -1375,15 +1512,21 @@ def _refresh_token(refresh_tok: str):
|
|
|
1375
1512
|
req = urllib.request.Request(
|
|
1376
1513
|
f"{_COZE_API}/api/permission/oauth2/token",
|
|
1377
1514
|
data=payload,
|
|
1378
|
-
headers={
|
|
1515
|
+
headers={
|
|
1516
|
+
"Content-Type": "application/json",
|
|
1517
|
+
"x-tt-env": "ppe_cozelab",
|
|
1518
|
+
"x-use-ppe": "1",
|
|
1519
|
+
},
|
|
1379
1520
|
)
|
|
1380
1521
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
1381
1522
|
data = json.loads(resp.read())
|
|
1382
1523
|
if data.get("access_token"):
|
|
1524
|
+
existing = _load_credentials() or {}
|
|
1383
1525
|
creds = {
|
|
1384
1526
|
"access_token": data["access_token"],
|
|
1385
1527
|
"refresh_token": data.get("refresh_token", refresh_tok),
|
|
1386
|
-
"expires_at": data.get("expires_in", 0) * 1000 # unix timestamp in seconds
|
|
1528
|
+
"expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
|
|
1529
|
+
"workspace_id": existing.get("workspace_id", ""),
|
|
1387
1530
|
}
|
|
1388
1531
|
_save_credentials(creds)
|
|
1389
1532
|
return creds["access_token"]
|
|
@@ -1391,37 +1534,168 @@ def _refresh_token(refresh_tok: str):
|
|
|
1391
1534
|
pass
|
|
1392
1535
|
return None
|
|
1393
1536
|
|
|
1537
|
+
|
|
1538
|
+
def _normalize_api_base_url(url: str) -> str:
|
|
1539
|
+
base = (url or "").strip().rstrip("/")
|
|
1540
|
+
if base.endswith(_OTEL_SUFFIX + "/v1/traces"):
|
|
1541
|
+
return base[:-len(_OTEL_SUFFIX + "/v1/traces")].rstrip("/")
|
|
1542
|
+
if base.endswith("/api/v1/loop/opentelemetry/v1/traces"):
|
|
1543
|
+
return base[:-len("/v1/loop/opentelemetry/v1/traces")].rstrip("/")
|
|
1544
|
+
if base.endswith(_OTEL_SUFFIX):
|
|
1545
|
+
return base[:-len(_OTEL_SUFFIX)].rstrip("/")
|
|
1546
|
+
if base.endswith("/api/v1/loop/opentelemetry"):
|
|
1547
|
+
return base[:-len("/v1/loop/opentelemetry")].rstrip("/")
|
|
1548
|
+
if base.endswith("/api/v1"):
|
|
1549
|
+
return base[:-len("/v1")].rstrip("/")
|
|
1550
|
+
return base
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def get_api_base_url() -> str:
|
|
1554
|
+
return _normalize_api_base_url(
|
|
1555
|
+
os.environ.get("COZELOOP_API_BASE_URL", "")
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def _token_from_credentials():
|
|
1560
|
+
creds = _load_credentials()
|
|
1561
|
+
if not creds:
|
|
1562
|
+
return None
|
|
1563
|
+
remaining = creds.get("expires_at", 0) / 1000 - time.time()
|
|
1564
|
+
if remaining > _REFRESH_THRESHOLD:
|
|
1565
|
+
return creds.get("access_token")
|
|
1566
|
+
if creds.get("refresh_token"):
|
|
1567
|
+
new_token = _refresh_token(creds["refresh_token"])
|
|
1568
|
+
if new_token:
|
|
1569
|
+
return new_token
|
|
1570
|
+
return None
|
|
1571
|
+
|
|
1572
|
+
|
|
1394
1573
|
def get_fresh_token():
|
|
1574
|
+
env_token = os.environ.get("COZELOOP_API_TOKEN")
|
|
1575
|
+
env_coze_token = os.environ.get("COZE_API_TOKEN")
|
|
1576
|
+
is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
|
|
1577
|
+
if is_cloud:
|
|
1578
|
+
return env_token or env_coze_token or _token_from_credentials()
|
|
1579
|
+
|
|
1580
|
+
# Local onboard used to write a short-lived access token into cozeloop.env.
|
|
1581
|
+
# If credentials exist but cannot be refreshed, do not fall back to that stale
|
|
1582
|
+
# env token; failing closed keeps state from advancing on a known-bad token.
|
|
1395
1583
|
creds = _load_credentials()
|
|
1396
1584
|
if creds:
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
if
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1585
|
+
return _token_from_credentials()
|
|
1586
|
+
if env_token and not env_coze_token:
|
|
1587
|
+
token = _token_from_credentials()
|
|
1588
|
+
if token:
|
|
1589
|
+
return token
|
|
1590
|
+
return env_token
|
|
1591
|
+
if env_token:
|
|
1592
|
+
return env_token
|
|
1593
|
+
if env_coze_token:
|
|
1594
|
+
return env_coze_token
|
|
1595
|
+
return _token_from_credentials()
|
|
1405
1596
|
# -------------------------------------------------------------------------
|
|
1406
1597
|
|
|
1407
1598
|
# --- SDK Import ---
|
|
1408
|
-
|
|
1599
|
+
# 最低版本:set_finish_time 是 cozeloop SDK 0.1.25 才引入的方法,云端预装旧版(<=0.1.24)
|
|
1600
|
+
# 调用会抛 AttributeError 并使整条 trace 上报失败。统一用能力探测(hasattr)判定,与
|
|
1601
|
+
# _set_finish_time_safe 兜底口径一致,避免版本号字符串比较的边界问题。
|
|
1602
|
+
_MIN_COZELOOP_SPEC = "cozeloop>=0.1.28"
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
def _cozeloop_capable():
|
|
1606
|
+
"""已装 cozeloop 是否具备 set_finish_time 能力。未装/异常返回 None(无法判定)。"""
|
|
1607
|
+
try:
|
|
1608
|
+
import cozeloop # noqa: F401
|
|
1609
|
+
except ImportError:
|
|
1610
|
+
return None
|
|
1611
|
+
try:
|
|
1612
|
+
return hasattr(cozeloop.Span, "set_finish_time")
|
|
1613
|
+
except Exception:
|
|
1614
|
+
return False
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _ensure_cozeloop_sdk():
|
|
1618
|
+
"""确保 cozeloop 可 import 且尽量满足 set_finish_time 能力。
|
|
1619
|
+
|
|
1620
|
+
返回 True 表示 cozeloop 可 import(不保证版本达标——能力不足时由
|
|
1621
|
+
_set_finish_time_safe 兜底,不阻断上报);返回 False 表示完全无法 import。
|
|
1622
|
+
"""
|
|
1623
|
+
capable = _cozeloop_capable()
|
|
1624
|
+
if capable is True:
|
|
1625
|
+
return True
|
|
1626
|
+
# 已装但能力不足(capable is False)→ 需升级;未装(None)→ 需安装。
|
|
1627
|
+
import subprocess
|
|
1628
|
+
import importlib
|
|
1629
|
+
import site
|
|
1630
|
+
# 能力不足时强制升级到带下限的版本;未装时直接装下限版本。
|
|
1631
|
+
pkg = _MIN_COZELOOP_SPEC
|
|
1632
|
+
base_flags = ["--quiet", "--disable-pip-version-check", "--upgrade"]
|
|
1633
|
+
attempts = (
|
|
1634
|
+
[*base_flags, pkg],
|
|
1635
|
+
[*base_flags, "--break-system-packages", pkg],
|
|
1636
|
+
[*base_flags, "--break-system-packages", "--user", pkg],
|
|
1637
|
+
)
|
|
1638
|
+
for extra in attempts:
|
|
1639
|
+
try:
|
|
1640
|
+
subprocess.run(
|
|
1641
|
+
[sys.executable, "-m", "pip", "install", *extra],
|
|
1642
|
+
timeout=180, check=True,
|
|
1643
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
1644
|
+
)
|
|
1645
|
+
except Exception:
|
|
1646
|
+
continue
|
|
1647
|
+
try:
|
|
1648
|
+
importlib.reload(site)
|
|
1649
|
+
user_site = site.getusersitepackages()
|
|
1650
|
+
for p in ([user_site] if isinstance(user_site, str) else list(user_site)):
|
|
1651
|
+
if p and p not in sys.path:
|
|
1652
|
+
sys.path.insert(0, p)
|
|
1653
|
+
importlib.invalidate_caches()
|
|
1654
|
+
import cozeloop # noqa: F401
|
|
1655
|
+
print("[CozeLoop] cozeloop SDK installed/upgraded at runtime.", file=sys.stderr)
|
|
1656
|
+
return True
|
|
1657
|
+
except ImportError:
|
|
1658
|
+
continue
|
|
1659
|
+
# 升级没成功,但只要原本能 import(capable is False)就继续——兜底会处理能力缺失。
|
|
1660
|
+
return capable is False
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
if _ensure_cozeloop_sdk():
|
|
1409
1664
|
import cozeloop
|
|
1410
1665
|
from cozeloop.spec.tracespec import (
|
|
1411
1666
|
Runtime, ModelInput, ModelMessage, ModelToolChoice,
|
|
1412
1667
|
ModelOutput, ModelChoice, ModelToolCall, ModelToolCallFunction,
|
|
1413
1668
|
ModelMessagePart, ModelMessagePartType
|
|
1414
1669
|
)
|
|
1415
|
-
|
|
1416
|
-
print("Error: cozeloop SDK not found
|
|
1670
|
+
else:
|
|
1671
|
+
print("Error: cozeloop SDK not found and auto-install failed. Try: pip install cozeloop", file=sys.stderr)
|
|
1417
1672
|
sys.exit(1)
|
|
1418
1673
|
|
|
1419
1674
|
# --- Configuration ---
|
|
1420
1675
|
DEBUG = os.environ.get("CC_COZELOOP_DEBUG", "").lower() == "true"
|
|
1421
1676
|
|
|
1422
1677
|
|
|
1678
|
+
def _log_file_path() -> str:
|
|
1679
|
+
return os.environ.get("COZELOOP_HOOK_LOG", "").strip()
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def hook_log(message: str):
|
|
1683
|
+
"""Append one diagnostic line to the hook log, if configured."""
|
|
1684
|
+
log_path = _log_file_path()
|
|
1685
|
+
if not log_path:
|
|
1686
|
+
return
|
|
1687
|
+
try:
|
|
1688
|
+
p = Path(log_path).expanduser()
|
|
1689
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1690
|
+
with p.open("a", encoding="utf-8") as f:
|
|
1691
|
+
f.write(f"{datetime.now().isoformat()} {message}\n")
|
|
1692
|
+
except Exception:
|
|
1693
|
+
pass
|
|
1694
|
+
|
|
1695
|
+
|
|
1423
1696
|
def debug_log(message: str):
|
|
1424
1697
|
"""Print debug message if debug mode is enabled."""
|
|
1698
|
+
hook_log(f"DEBUG {message}")
|
|
1425
1699
|
if DEBUG:
|
|
1426
1700
|
print(f"[COZELOOP_HOOK_DEBUG] {datetime.now().isoformat()} - {message}", file=sys.stderr)
|
|
1427
1701
|
|
|
@@ -1509,6 +1783,101 @@ def read_rollout_messages(transcript_path: str, start_line: int = 0) -> List[Dic
|
|
|
1509
1783
|
return entries
|
|
1510
1784
|
|
|
1511
1785
|
|
|
1786
|
+
def _add_unique_path(paths: List[Path], p: Optional[Path]):
|
|
1787
|
+
if not p:
|
|
1788
|
+
return
|
|
1789
|
+
try:
|
|
1790
|
+
resolved = p.expanduser().resolve()
|
|
1791
|
+
except Exception:
|
|
1792
|
+
return
|
|
1793
|
+
if resolved not in paths:
|
|
1794
|
+
paths.append(resolved)
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
def _candidate_codex_homes() -> List[Path]:
|
|
1798
|
+
homes: List[Path] = []
|
|
1799
|
+
_add_unique_path(homes, Path(os.environ["CODEX_HOME"]) if os.environ.get("CODEX_HOME") else None)
|
|
1800
|
+
|
|
1801
|
+
log_path = _log_file_path()
|
|
1802
|
+
if log_path:
|
|
1803
|
+
try:
|
|
1804
|
+
log_parent = Path(log_path).expanduser().resolve().parent
|
|
1805
|
+
if log_parent.name == "hooks":
|
|
1806
|
+
_add_unique_path(homes, log_parent.parent)
|
|
1807
|
+
except Exception:
|
|
1808
|
+
pass
|
|
1809
|
+
|
|
1810
|
+
_add_unique_path(homes, Path.home() / ".codex")
|
|
1811
|
+
|
|
1812
|
+
agents_root = Path.home() / ".coze" / "agents"
|
|
1813
|
+
try:
|
|
1814
|
+
if agents_root.exists():
|
|
1815
|
+
for child in agents_root.iterdir():
|
|
1816
|
+
_add_unique_path(homes, child / "codex-home")
|
|
1817
|
+
except Exception:
|
|
1818
|
+
pass
|
|
1819
|
+
|
|
1820
|
+
return homes
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def _latest_file(paths: List[Path]) -> Optional[Path]:
|
|
1824
|
+
existing = []
|
|
1825
|
+
for p in paths:
|
|
1826
|
+
try:
|
|
1827
|
+
if p.is_file():
|
|
1828
|
+
existing.append(p)
|
|
1829
|
+
except Exception:
|
|
1830
|
+
pass
|
|
1831
|
+
if not existing:
|
|
1832
|
+
return None
|
|
1833
|
+
return max(existing, key=lambda p: p.stat().st_mtime)
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
def find_latest_transcript() -> Optional[str]:
|
|
1837
|
+
"""Best-effort fallback when Codex does not pass hook stdin."""
|
|
1838
|
+
candidates: List[Path] = []
|
|
1839
|
+
for codex_home in _candidate_codex_homes():
|
|
1840
|
+
sessions_dir = codex_home / "sessions"
|
|
1841
|
+
if not sessions_dir.exists():
|
|
1842
|
+
continue
|
|
1843
|
+
try:
|
|
1844
|
+
candidates.extend(sessions_dir.rglob("rollout-*.jsonl"))
|
|
1845
|
+
except Exception as e:
|
|
1846
|
+
hook_log(f"fallback scan failed dir={sessions_dir} error={repr(e)}")
|
|
1847
|
+
|
|
1848
|
+
latest = _latest_file(candidates)
|
|
1849
|
+
if latest:
|
|
1850
|
+
return str(latest)
|
|
1851
|
+
|
|
1852
|
+
for codex_home in _candidate_codex_homes():
|
|
1853
|
+
sessions_dir = codex_home / "sessions"
|
|
1854
|
+
if not sessions_dir.exists():
|
|
1855
|
+
continue
|
|
1856
|
+
try:
|
|
1857
|
+
candidates.extend(sessions_dir.rglob("*.jsonl"))
|
|
1858
|
+
except Exception:
|
|
1859
|
+
pass
|
|
1860
|
+
|
|
1861
|
+
latest = _latest_file(candidates)
|
|
1862
|
+
return str(latest) if latest else None
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def recover_hook_input(reason: str) -> Optional[Dict[str, Any]]:
|
|
1866
|
+
transcript_path = find_latest_transcript()
|
|
1867
|
+
if not transcript_path:
|
|
1868
|
+
hook_log(f"fallback failed reason={reason} no transcript found")
|
|
1869
|
+
print(f"[CozeLoop] Hook input missing ({reason}); no Codex transcript found.", file=sys.stderr)
|
|
1870
|
+
return None
|
|
1871
|
+
hook_log(f"fallback transcript reason={reason} path={transcript_path}")
|
|
1872
|
+
print(f"[CozeLoop] Hook input missing ({reason}); fallback transcript: {transcript_path}", file=sys.stderr)
|
|
1873
|
+
return {
|
|
1874
|
+
"hook_event_name": "Stop",
|
|
1875
|
+
"session_id": "",
|
|
1876
|
+
"transcript_path": transcript_path,
|
|
1877
|
+
"input_fallback": reason,
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
|
|
1512
1881
|
def parse_session_meta(entries: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
1513
1882
|
"""Extract session identity from session_meta entry."""
|
|
1514
1883
|
result = {
|
|
@@ -1564,6 +1933,8 @@ def is_real_user_message(payload: Dict[str, Any]) -> bool:
|
|
|
1564
1933
|
if item.get("type") != "input_text":
|
|
1565
1934
|
continue
|
|
1566
1935
|
text = item.get("text", "")
|
|
1936
|
+
if parse_coze_context(text):
|
|
1937
|
+
return True
|
|
1567
1938
|
if text.startswith("<environment_context>"):
|
|
1568
1939
|
continue
|
|
1569
1940
|
if text.startswith("<permissions instructions>"):
|
|
@@ -1582,11 +1953,12 @@ def extract_user_text(payload: Dict[str, Any]) -> str:
|
|
|
1582
1953
|
for item in payload.get("content", []):
|
|
1583
1954
|
if isinstance(item, dict) and item.get("type") == "input_text":
|
|
1584
1955
|
text = item.get("text", "")
|
|
1585
|
-
if (
|
|
1956
|
+
if (parse_coze_context(text) or
|
|
1957
|
+
(not text.startswith("<environment_context>") and
|
|
1586
1958
|
not text.startswith("<permissions instructions>") and
|
|
1587
|
-
not text.startswith("<turn_aborted>")):
|
|
1959
|
+
not text.startswith("<turn_aborted>"))):
|
|
1588
1960
|
parts.append(text)
|
|
1589
|
-
return "
|
|
1961
|
+
return "\n".join(parts)
|
|
1590
1962
|
|
|
1591
1963
|
|
|
1592
1964
|
def extract_assistant_text(payload: Dict[str, Any]) -> str:
|
|
@@ -1595,7 +1967,7 @@ def extract_assistant_text(payload: Dict[str, Any]) -> str:
|
|
|
1595
1967
|
for item in payload.get("content", []):
|
|
1596
1968
|
if isinstance(item, dict) and item.get("type") in ("output_text", "text"):
|
|
1597
1969
|
parts.append(item.get("text", ""))
|
|
1598
|
-
return "
|
|
1970
|
+
return "\n".join(parts)
|
|
1599
1971
|
|
|
1600
1972
|
|
|
1601
1973
|
def extract_message_content_text(payload: Dict[str, Any]) -> str:
|
|
@@ -1607,7 +1979,7 @@ def extract_message_content_text(payload: Dict[str, Any]) -> str:
|
|
|
1607
1979
|
text = item.get("text", "")
|
|
1608
1980
|
if text:
|
|
1609
1981
|
parts.append(text)
|
|
1610
|
-
return "
|
|
1982
|
+
return "\n".join(parts)
|
|
1611
1983
|
|
|
1612
1984
|
|
|
1613
1985
|
def truncate_text(text: str, limit: int = 12000) -> str:
|
|
@@ -1619,6 +1991,93 @@ def truncate_text(text: str, limit: int = 12000) -> str:
|
|
|
1619
1991
|
|
|
1620
1992
|
# --- Message Grouping ---
|
|
1621
1993
|
|
|
1994
|
+
def _parse_ts(obj):
|
|
1995
|
+
"""从 codex entry/payload 的 timestamp 解析 datetime(带时区)。失败返回 None。
|
|
1996
|
+
|
|
1997
|
+
Codex rollout JSONL 每条 entry 顶层带 ISO8601 timestamp。建 span 时用它做
|
|
1998
|
+
start_time,避免回放时所有 span 挤在几毫秒内。
|
|
1999
|
+
"""
|
|
2000
|
+
if not isinstance(obj, dict):
|
|
2001
|
+
return None
|
|
2002
|
+
ts = obj.get("timestamp") or obj.get("_ts")
|
|
2003
|
+
if not ts or not isinstance(ts, str):
|
|
2004
|
+
return None
|
|
2005
|
+
try:
|
|
2006
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
2007
|
+
except Exception:
|
|
2008
|
+
return None
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
def _ts_ms(dt):
|
|
2012
|
+
"""datetime → 毫秒时间戳(int);None → None。"""
|
|
2013
|
+
return int(dt.timestamp() * 1000) if dt is not None else None
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
def _set_finish_time_safe(span, dt):
|
|
2017
|
+
"""安全设置 span 结束时间。
|
|
2018
|
+
|
|
2019
|
+
set_finish_time 是 cozeloop SDK 0.1.25+ 的方法,云端旧版没有;缺失或异常都不能
|
|
2020
|
+
让整条 trace 上报失败(real_*_ms tag 仍保留耗时信息)。
|
|
2021
|
+
"""
|
|
2022
|
+
if dt is None:
|
|
2023
|
+
return
|
|
2024
|
+
fn = getattr(span, "set_finish_time", None)
|
|
2025
|
+
if fn is None:
|
|
2026
|
+
return
|
|
2027
|
+
try:
|
|
2028
|
+
fn(dt)
|
|
2029
|
+
except Exception:
|
|
2030
|
+
pass
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
def _max_dt(*values):
|
|
2034
|
+
result = None
|
|
2035
|
+
for value in values:
|
|
2036
|
+
if value is not None and (result is None or value > result):
|
|
2037
|
+
result = value
|
|
2038
|
+
return result
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
def _parse_ts_value(value):
|
|
2042
|
+
return _parse_ts({"_ts": value}) if value else None
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
def _turn_timestamps(turn):
|
|
2046
|
+
values = []
|
|
2047
|
+
for item in [turn.get("user_message"), *turn.get("assistant_messages", [])]:
|
|
2048
|
+
dt = _parse_ts(item)
|
|
2049
|
+
if dt:
|
|
2050
|
+
values.append(dt)
|
|
2051
|
+
for item in [*turn.get("tool_calls", []), *turn.get("tool_results", [])]:
|
|
2052
|
+
dt = _parse_ts(item)
|
|
2053
|
+
if dt:
|
|
2054
|
+
values.append(dt)
|
|
2055
|
+
for sc in turn.get("subagent_calls", []):
|
|
2056
|
+
for key in ("_start_ts", "_end_ts"):
|
|
2057
|
+
dt = _parse_ts_value(sc.get(key))
|
|
2058
|
+
if dt:
|
|
2059
|
+
values.append(dt)
|
|
2060
|
+
return values
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
def _turn_bounds(turn):
|
|
2064
|
+
values = _turn_timestamps(turn)
|
|
2065
|
+
if not values:
|
|
2066
|
+
return None, None
|
|
2067
|
+
return min(values), max(values)
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
def _assistant_bounds(turn):
|
|
2071
|
+
values = []
|
|
2072
|
+
for item in turn.get("assistant_messages", []):
|
|
2073
|
+
dt = _parse_ts(item)
|
|
2074
|
+
if dt:
|
|
2075
|
+
values.append(dt)
|
|
2076
|
+
if not values:
|
|
2077
|
+
return None, None
|
|
2078
|
+
return min(values), max(values)
|
|
2079
|
+
|
|
2080
|
+
|
|
1622
2081
|
def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
1623
2082
|
"""Group raw JSONL entries into conversation turns.
|
|
1624
2083
|
|
|
@@ -1641,6 +2100,9 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
1641
2100
|
for entry in entries:
|
|
1642
2101
|
entry_type = entry.get("type")
|
|
1643
2102
|
payload = entry.get("payload", {})
|
|
2103
|
+
# 把 entry 顶层 timestamp 注入 payload,供后续建 span 取真实时间
|
|
2104
|
+
if isinstance(payload, dict) and entry.get("timestamp") and "_ts" not in payload:
|
|
2105
|
+
payload["_ts"] = entry.get("timestamp")
|
|
1644
2106
|
|
|
1645
2107
|
# --- Turn lifecycle events ---
|
|
1646
2108
|
if entry_type == "event_msg":
|
|
@@ -1725,6 +2187,8 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
1725
2187
|
"message": args.get("message", ""),
|
|
1726
2188
|
"model": args.get("model"),
|
|
1727
2189
|
"result": None,
|
|
2190
|
+
"_start_ts": payload.get("_ts"),
|
|
2191
|
+
"_end_ts": None,
|
|
1728
2192
|
}
|
|
1729
2193
|
current_turn["subagent_calls"].append(subagent_call)
|
|
1730
2194
|
pending_calls[call_id] = {"kind": "spawn", "subagent_call": subagent_call}
|
|
@@ -1732,14 +2196,16 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
1732
2196
|
pending_calls[call_id] = {
|
|
1733
2197
|
"kind": "wait",
|
|
1734
2198
|
"ids": args.get("ids", []),
|
|
2199
|
+
"_start_ts": payload.get("_ts"),
|
|
1735
2200
|
}
|
|
1736
2201
|
else:
|
|
1737
2202
|
current_turn["tool_calls"].append({
|
|
1738
2203
|
"call_id": call_id,
|
|
1739
2204
|
"name": name,
|
|
1740
2205
|
"input": args,
|
|
2206
|
+
"_ts": payload.get("_ts"),
|
|
1741
2207
|
})
|
|
1742
|
-
pending_calls[call_id] = {"kind": "tool"}
|
|
2208
|
+
pending_calls[call_id] = {"kind": "tool", "_start_ts": payload.get("_ts")}
|
|
1743
2209
|
|
|
1744
2210
|
elif item_type == "function_call_output":
|
|
1745
2211
|
if current_turn is None:
|
|
@@ -1759,6 +2225,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
1759
2225
|
subagent_call["nickname"] = out.get("nickname")
|
|
1760
2226
|
except (json.JSONDecodeError, TypeError, AttributeError):
|
|
1761
2227
|
pass
|
|
2228
|
+
subagent_call["_end_ts"] = payload.get("_ts")
|
|
1762
2229
|
|
|
1763
2230
|
elif kind == "wait":
|
|
1764
2231
|
try:
|
|
@@ -1771,6 +2238,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
1771
2238
|
for sc in current_turn["subagent_calls"]:
|
|
1772
2239
|
if sc.get("agent_id") == agent_id and sc.get("result") is None:
|
|
1773
2240
|
sc["result"] = result_text
|
|
2241
|
+
sc["_end_ts"] = payload.get("_ts")
|
|
1774
2242
|
break
|
|
1775
2243
|
except (json.JSONDecodeError, TypeError, AttributeError):
|
|
1776
2244
|
pass
|
|
@@ -1779,6 +2247,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
1779
2247
|
current_turn["tool_results"].append({
|
|
1780
2248
|
"call_id": call_id,
|
|
1781
2249
|
"output": raw_output,
|
|
2250
|
+
"_ts": payload.get("_ts"),
|
|
1782
2251
|
})
|
|
1783
2252
|
|
|
1784
2253
|
if current_turn is not None:
|
|
@@ -1830,20 +2299,71 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1830
2299
|
token = get_fresh_token()
|
|
1831
2300
|
if token:
|
|
1832
2301
|
os.environ["COZELOOP_API_TOKEN"] = token
|
|
1833
|
-
|
|
2302
|
+
hook_log(f"token resolved prefix={token[:12]}...")
|
|
2303
|
+
print(f"[CozeLoop] Token 获取成功 ({token[:12]}...)", file=sys.stderr)
|
|
2304
|
+
else:
|
|
2305
|
+
hook_log(
|
|
2306
|
+
"token missing "
|
|
2307
|
+
f"has_cozeloop_token={bool(os.environ.get('COZELOOP_API_TOKEN'))} "
|
|
2308
|
+
f"has_coze_token={bool(os.environ.get('COZE_API_TOKEN'))} "
|
|
2309
|
+
f"api_base_url={bool(get_api_base_url())}"
|
|
2310
|
+
)
|
|
2311
|
+
print("[CozeLoop] 警告: 未找到有效 Token,上报可能失败", file=sys.stderr)
|
|
2312
|
+
creds = _load_credentials()
|
|
2313
|
+
workspace_id = (creds or {}).get("workspace_id") or os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
|
|
2314
|
+
os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
|
|
2315
|
+
hook_log(f"workspace_id={workspace_id}")
|
|
2316
|
+
upload_events: List[str] = []
|
|
2317
|
+
client_kwargs = {
|
|
2318
|
+
"ultra_large_report": True,
|
|
2319
|
+
"upload_timeout": 120,
|
|
2320
|
+
"trace_finish_event_processor": _make_finish_event_processor(upload_events),
|
|
2321
|
+
}
|
|
2322
|
+
api_base_url = get_api_base_url()
|
|
2323
|
+
if api_base_url:
|
|
2324
|
+
client_kwargs["api_base_url"] = api_base_url
|
|
2325
|
+
hook_log(f"api_base_url={api_base_url}")
|
|
2326
|
+
if workspace_id:
|
|
2327
|
+
client_kwargs["workspace_id"] = workspace_id
|
|
2328
|
+
if token:
|
|
2329
|
+
client_kwargs["api_token"] = token
|
|
2330
|
+
client = cozeloop.new_client(**client_kwargs)
|
|
1834
2331
|
ctx: List[Dict[str, Any]] = list(history_context) if history_context else []
|
|
1835
2332
|
|
|
1836
2333
|
try:
|
|
1837
|
-
|
|
2334
|
+
# 整体时间范围:所有 user/assistant payload 的真实 timestamp 极值
|
|
2335
|
+
_all_ts = []
|
|
2336
|
+
for _t in turns:
|
|
2337
|
+
_all_ts.extend(_turn_timestamps(_t))
|
|
2338
|
+
root_start_dt = min(_all_ts) if _all_ts else None
|
|
2339
|
+
root_end_dt = max(_all_ts) if _all_ts else None
|
|
2340
|
+
|
|
2341
|
+
with client.start_span(name="codex_request", span_type="main", start_time=root_start_dt) as root_span:
|
|
1838
2342
|
root_span.set_runtime(Runtime(library="codex-cli"))
|
|
1839
|
-
|
|
2343
|
+
root_tags = {
|
|
1840
2344
|
"thread_id": session_id,
|
|
1841
2345
|
"total_turns": len(turns),
|
|
1842
2346
|
"source": "codex_cli",
|
|
1843
|
-
}
|
|
1844
|
-
|
|
2347
|
+
}
|
|
2348
|
+
if root_start_dt is not None and root_end_dt is not None:
|
|
2349
|
+
_set_finish_time_safe(root_span, root_end_dt)
|
|
2350
|
+
root_tags["real_start_ms"] = _ts_ms(root_start_dt)
|
|
2351
|
+
root_tags["real_end_ms"] = _ts_ms(root_end_dt)
|
|
2352
|
+
root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
|
|
2353
|
+
root_baggage = {
|
|
1845
2354
|
"thread_id": session_id,
|
|
1846
|
-
}
|
|
2355
|
+
}
|
|
2356
|
+
# Inject coze-context kv (last occurrence across turns wins).
|
|
2357
|
+
coze_tags = {}
|
|
2358
|
+
for turn in turns:
|
|
2359
|
+
t = {f"coze_{k}": v for k, v in turn_coze_context(turn).items()}
|
|
2360
|
+
if t:
|
|
2361
|
+
coze_tags = t
|
|
2362
|
+
if coze_tags:
|
|
2363
|
+
root_tags.update(coze_tags)
|
|
2364
|
+
root_baggage.update(coze_tags)
|
|
2365
|
+
root_span.set_tags(root_tags)
|
|
2366
|
+
root_span.set_baggage(root_baggage)
|
|
1847
2367
|
|
|
1848
2368
|
# Set root span input: all user messages
|
|
1849
2369
|
root_input_parts = []
|
|
@@ -1852,7 +2372,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1852
2372
|
if text:
|
|
1853
2373
|
root_input_parts.append(text)
|
|
1854
2374
|
if root_input_parts:
|
|
1855
|
-
root_span.set_input(truncate_text("
|
|
2375
|
+
root_span.set_input(truncate_text("\n\n".join(root_input_parts)))
|
|
1856
2376
|
|
|
1857
2377
|
# Set root span output: all assistant messages
|
|
1858
2378
|
root_output_parts = []
|
|
@@ -1862,25 +2382,47 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1862
2382
|
if assistant_text:
|
|
1863
2383
|
root_output_parts.append(assistant_text)
|
|
1864
2384
|
if root_output_parts:
|
|
1865
|
-
root_span.set_output(truncate_text("
|
|
2385
|
+
root_span.set_output(truncate_text("\n\n".join(root_output_parts)))
|
|
1866
2386
|
|
|
1867
2387
|
# Process each turn
|
|
1868
2388
|
for i, turn in enumerate(turns):
|
|
1869
2389
|
try:
|
|
1870
|
-
|
|
2390
|
+
# turn 真实时间:start=user payload 时间;end=最后一条 assistant payload 时间
|
|
2391
|
+
turn_start_dt, turn_end_dt = _turn_bounds(turn)
|
|
2392
|
+
|
|
2393
|
+
with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
|
|
1871
2394
|
turn_span.set_runtime(Runtime(library="codex-cli"))
|
|
1872
|
-
|
|
2395
|
+
_turn_tags = {
|
|
1873
2396
|
"thread_id": session_id,
|
|
1874
2397
|
"turn_index": i,
|
|
1875
2398
|
"turn_id": turn.get("turn_id", ""),
|
|
1876
2399
|
"source": "codex_cli",
|
|
1877
|
-
}
|
|
2400
|
+
}
|
|
2401
|
+
if turn_start_dt is not None and turn_end_dt is not None:
|
|
2402
|
+
_set_finish_time_safe(turn_span, turn_end_dt)
|
|
2403
|
+
_turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
|
|
2404
|
+
_turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
|
|
2405
|
+
_turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
|
|
2406
|
+
turn_span.set_tags(_turn_tags)
|
|
1878
2407
|
|
|
1879
2408
|
# --- Model span for assistant response ---
|
|
1880
2409
|
if turn.get("assistant_messages"):
|
|
1881
|
-
|
|
2410
|
+
# model span start:第一条 assistant payload 时间,回退到 turn 起点
|
|
2411
|
+
_model_start_dt, _model_end_dt = _assistant_bounds(turn)
|
|
2412
|
+
if _model_start_dt is None:
|
|
2413
|
+
_model_start_dt = turn_start_dt
|
|
2414
|
+
if _model_end_dt is None:
|
|
2415
|
+
_model_end_dt = turn_end_dt
|
|
2416
|
+
with client.start_span(name="assistant_response", span_type="model", start_time=_model_start_dt) as model_span:
|
|
1882
2417
|
model_span.set_runtime(Runtime(library="codex-cli"))
|
|
1883
2418
|
model_span.set_model_name(model_name)
|
|
2419
|
+
if _model_start_dt is not None and _model_end_dt is not None:
|
|
2420
|
+
_set_finish_time_safe(model_span, _model_end_dt)
|
|
2421
|
+
model_span.set_tags({
|
|
2422
|
+
"real_start_ms": _ts_ms(_model_start_dt),
|
|
2423
|
+
"real_end_ms": _ts_ms(_model_end_dt),
|
|
2424
|
+
"latency_ms": _ts_ms(_model_end_dt) - _ts_ms(_model_start_dt),
|
|
2425
|
+
})
|
|
1884
2426
|
|
|
1885
2427
|
# Build input messages: history + current turn input
|
|
1886
2428
|
turn_input = turn.get("input_messages", [])
|
|
@@ -1948,17 +2490,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1948
2490
|
# --- Tool call spans ---
|
|
1949
2491
|
for tool_call in turn.get("tool_calls", []):
|
|
1950
2492
|
tool_name = tool_call.get("name", "unknown")
|
|
1951
|
-
|
|
2493
|
+
tool_start_dt = _parse_ts(tool_call) or turn_start_dt
|
|
2494
|
+
tool_end_dt = None
|
|
2495
|
+
call_id = tool_call.get("call_id")
|
|
2496
|
+
for result in turn.get("tool_results", []):
|
|
2497
|
+
if result.get("call_id") == call_id:
|
|
2498
|
+
tool_end_dt = _parse_ts(result)
|
|
2499
|
+
break
|
|
2500
|
+
tool_finish_dt = _max_dt(tool_end_dt, tool_start_dt)
|
|
2501
|
+
with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=tool_start_dt) as tool_span:
|
|
1952
2502
|
tool_span.set_runtime(Runtime(library="codex-cli"))
|
|
1953
|
-
|
|
2503
|
+
tool_tags = {
|
|
1954
2504
|
"tool_name": tool_name,
|
|
1955
|
-
"call_id":
|
|
1956
|
-
}
|
|
2505
|
+
"call_id": call_id,
|
|
2506
|
+
}
|
|
2507
|
+
if tool_start_dt is not None and tool_finish_dt is not None:
|
|
2508
|
+
_set_finish_time_safe(tool_span, tool_finish_dt)
|
|
2509
|
+
tool_tags["real_start_ms"] = _ts_ms(tool_start_dt)
|
|
2510
|
+
tool_tags["real_end_ms"] = _ts_ms(tool_finish_dt)
|
|
2511
|
+
tool_tags["latency_ms"] = _ts_ms(tool_finish_dt) - _ts_ms(tool_start_dt)
|
|
2512
|
+
tool_span.set_tags(tool_tags)
|
|
1957
2513
|
tool_span.set_input(
|
|
1958
2514
|
json.dumps(tool_call.get("input", {}), ensure_ascii=False)[:2000]
|
|
1959
2515
|
)
|
|
1960
2516
|
# Find matching tool result
|
|
1961
|
-
call_id = tool_call.get("call_id")
|
|
1962
2517
|
for result in turn.get("tool_results", []):
|
|
1963
2518
|
if result.get("call_id") == call_id:
|
|
1964
2519
|
output = result.get("output", "")
|
|
@@ -1971,15 +2526,24 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1971
2526
|
for sc in turn.get("subagent_calls", []):
|
|
1972
2527
|
agent_id = sc.get("agent_id") or "unknown"
|
|
1973
2528
|
nickname = sc.get("nickname") or agent_id
|
|
2529
|
+
subagent_start_dt = _parse_ts_value(sc.get("_start_ts")) or turn_start_dt
|
|
2530
|
+
subagent_end_dt = _parse_ts_value(sc.get("_end_ts")) or turn_end_dt
|
|
2531
|
+
subagent_finish_dt = _max_dt(subagent_end_dt, subagent_start_dt)
|
|
1974
2532
|
|
|
1975
|
-
with client.start_span(name=f"subagent_{nickname}", span_type="agent") as subagent_span:
|
|
2533
|
+
with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=subagent_start_dt) as subagent_span:
|
|
1976
2534
|
subagent_span.set_runtime(Runtime(library="codex-cli"))
|
|
1977
|
-
|
|
2535
|
+
subagent_tags = {
|
|
1978
2536
|
"agent_id": agent_id,
|
|
1979
2537
|
"agent_nickname": nickname,
|
|
1980
2538
|
"agent_role": sc.get("role") or "",
|
|
1981
2539
|
"agent_model": sc.get("model") or "",
|
|
1982
|
-
}
|
|
2540
|
+
}
|
|
2541
|
+
if subagent_start_dt is not None and subagent_finish_dt is not None:
|
|
2542
|
+
_set_finish_time_safe(subagent_span, subagent_finish_dt)
|
|
2543
|
+
subagent_tags["real_start_ms"] = _ts_ms(subagent_start_dt)
|
|
2544
|
+
subagent_tags["real_end_ms"] = _ts_ms(subagent_finish_dt)
|
|
2545
|
+
subagent_tags["latency_ms"] = _ts_ms(subagent_finish_dt) - _ts_ms(subagent_start_dt)
|
|
2546
|
+
subagent_span.set_tags(subagent_tags)
|
|
1983
2547
|
subagent_span.set_input(sc.get("message", "")[:2000])
|
|
1984
2548
|
|
|
1985
2549
|
# Load and include saved subagent turn data
|
|
@@ -1989,20 +2553,45 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
1989
2553
|
sa_model = sa_data.get("model_name", "codex")
|
|
1990
2554
|
|
|
1991
2555
|
for si, sa_turn in enumerate(sa_turns):
|
|
1992
|
-
|
|
2556
|
+
sa_turn_start_dt, sa_turn_end_dt = _turn_bounds(sa_turn)
|
|
2557
|
+
if sa_turn_start_dt is None:
|
|
2558
|
+
sa_turn_start_dt = subagent_start_dt
|
|
2559
|
+
if sa_turn_end_dt is None:
|
|
2560
|
+
sa_turn_end_dt = sa_turn_start_dt
|
|
2561
|
+
sa_turn_finish_dt = _max_dt(sa_turn_end_dt, sa_turn_start_dt)
|
|
2562
|
+
|
|
2563
|
+
with client.start_span(name=f"turn_{si}", span_type="main", start_time=sa_turn_start_dt) as sa_turn_span:
|
|
1993
2564
|
sa_turn_span.set_runtime(Runtime(library="codex-cli"))
|
|
1994
|
-
|
|
2565
|
+
sa_turn_tags = {
|
|
1995
2566
|
"turn_index": si,
|
|
1996
2567
|
"turn_id": sa_turn.get("turn_id", ""),
|
|
1997
2568
|
"agent_name": nickname,
|
|
1998
|
-
}
|
|
2569
|
+
}
|
|
2570
|
+
if sa_turn_start_dt is not None and sa_turn_finish_dt is not None:
|
|
2571
|
+
_set_finish_time_safe(sa_turn_span, sa_turn_finish_dt)
|
|
2572
|
+
sa_turn_tags["real_start_ms"] = _ts_ms(sa_turn_start_dt)
|
|
2573
|
+
sa_turn_tags["real_end_ms"] = _ts_ms(sa_turn_finish_dt)
|
|
2574
|
+
sa_turn_tags["latency_ms"] = _ts_ms(sa_turn_finish_dt) - _ts_ms(sa_turn_start_dt)
|
|
2575
|
+
sa_turn_span.set_tags(sa_turn_tags)
|
|
1999
2576
|
|
|
2000
2577
|
# Subagent model span
|
|
2001
2578
|
if sa_turn.get("assistant_messages"):
|
|
2002
|
-
|
|
2579
|
+
sa_model_start_dt, sa_model_end_dt = _assistant_bounds(sa_turn)
|
|
2580
|
+
if sa_model_start_dt is None:
|
|
2581
|
+
sa_model_start_dt = sa_turn_start_dt
|
|
2582
|
+
if sa_model_end_dt is None:
|
|
2583
|
+
sa_model_end_dt = sa_turn_end_dt
|
|
2584
|
+
sa_model_finish_dt = _max_dt(sa_model_end_dt, sa_model_start_dt)
|
|
2585
|
+
with client.start_span(name="assistant_response", span_type="model", start_time=sa_model_start_dt) as sa_model_span:
|
|
2003
2586
|
sa_model_span.set_runtime(Runtime(library="codex-cli"))
|
|
2004
2587
|
sa_model_span.set_model_name(sa_model)
|
|
2005
|
-
|
|
2588
|
+
sa_model_tags = {"agent_name": nickname}
|
|
2589
|
+
if sa_model_start_dt is not None and sa_model_finish_dt is not None:
|
|
2590
|
+
_set_finish_time_safe(sa_model_span, sa_model_finish_dt)
|
|
2591
|
+
sa_model_tags["real_start_ms"] = _ts_ms(sa_model_start_dt)
|
|
2592
|
+
sa_model_tags["real_end_ms"] = _ts_ms(sa_model_finish_dt)
|
|
2593
|
+
sa_model_tags["latency_ms"] = _ts_ms(sa_model_finish_dt) - _ts_ms(sa_model_start_dt)
|
|
2594
|
+
sa_model_span.set_tags(sa_model_tags)
|
|
2006
2595
|
|
|
2007
2596
|
sa_input = sa_turn.get("input_messages", [])
|
|
2008
2597
|
if not sa_input:
|
|
@@ -2046,17 +2635,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
2046
2635
|
# Subagent tool spans
|
|
2047
2636
|
for sa_tc in sa_turn.get("tool_calls", []):
|
|
2048
2637
|
sa_tool_name = sa_tc.get("name", "unknown")
|
|
2049
|
-
|
|
2638
|
+
sa_tool_start_dt = _parse_ts(sa_tc) or sa_turn_start_dt
|
|
2639
|
+
sa_tool_end_dt = None
|
|
2640
|
+
sa_cid = sa_tc.get("call_id")
|
|
2641
|
+
for sa_r in sa_turn.get("tool_results", []):
|
|
2642
|
+
if sa_r.get("call_id") == sa_cid:
|
|
2643
|
+
sa_tool_end_dt = _parse_ts(sa_r)
|
|
2644
|
+
break
|
|
2645
|
+
sa_tool_finish_dt = _max_dt(sa_tool_end_dt, sa_tool_start_dt)
|
|
2646
|
+
with client.start_span(name=f"tool_{sa_tool_name}", span_type="tool", start_time=sa_tool_start_dt) as sa_tool_span:
|
|
2050
2647
|
sa_tool_span.set_runtime(Runtime(library="codex-cli"))
|
|
2051
|
-
|
|
2648
|
+
sa_tool_tags = {
|
|
2052
2649
|
"tool_name": sa_tool_name,
|
|
2053
|
-
"call_id":
|
|
2650
|
+
"call_id": sa_cid,
|
|
2054
2651
|
"agent_name": nickname,
|
|
2055
|
-
}
|
|
2652
|
+
}
|
|
2653
|
+
if sa_tool_start_dt is not None and sa_tool_finish_dt is not None:
|
|
2654
|
+
_set_finish_time_safe(sa_tool_span, sa_tool_finish_dt)
|
|
2655
|
+
sa_tool_tags["real_start_ms"] = _ts_ms(sa_tool_start_dt)
|
|
2656
|
+
sa_tool_tags["real_end_ms"] = _ts_ms(sa_tool_finish_dt)
|
|
2657
|
+
sa_tool_tags["latency_ms"] = _ts_ms(sa_tool_finish_dt) - _ts_ms(sa_tool_start_dt)
|
|
2658
|
+
sa_tool_span.set_tags(sa_tool_tags)
|
|
2056
2659
|
sa_tool_span.set_input(
|
|
2057
2660
|
json.dumps(sa_tc.get("input", {}), ensure_ascii=False)[:2000]
|
|
2058
2661
|
)
|
|
2059
|
-
sa_cid = sa_tc.get("call_id")
|
|
2060
2662
|
for sa_r in sa_turn.get("tool_results", []):
|
|
2061
2663
|
if sa_r.get("call_id") == sa_cid:
|
|
2062
2664
|
sa_out = sa_r.get("output", "")
|
|
@@ -2086,15 +2688,22 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
2086
2688
|
debug_log(f"Error processing turn {i}: {e}")
|
|
2087
2689
|
continue
|
|
2088
2690
|
|
|
2691
|
+
hook_log(f"processed turns={len(turns)} session_id={session_id}")
|
|
2089
2692
|
debug_log(f"Successfully processed {len(turns)} turn(s) for session {session_id}")
|
|
2090
2693
|
|
|
2091
2694
|
except Exception as e:
|
|
2695
|
+
hook_log(f"send exception={repr(e)}")
|
|
2092
2696
|
debug_log(f"An error occurred while sending traces to CozeLoop: {e}")
|
|
2093
2697
|
return None
|
|
2094
2698
|
finally:
|
|
2095
2699
|
client.close()
|
|
2700
|
+
hook_log("client closed")
|
|
2096
2701
|
debug_log("CozeLoop client closed.")
|
|
2097
2702
|
|
|
2703
|
+
if upload_events:
|
|
2704
|
+
hook_log(f"upload failed state not advanced failures={len(upload_events)} detail={upload_events[-1][:500]}")
|
|
2705
|
+
return None
|
|
2706
|
+
|
|
2098
2707
|
return ctx
|
|
2099
2708
|
|
|
2100
2709
|
|
|
@@ -2102,10 +2711,13 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
2102
2711
|
|
|
2103
2712
|
def main():
|
|
2104
2713
|
"""Main entry point for the Codex CozeLoop hook."""
|
|
2714
|
+
print("[CozeLoop] Hook triggered (Codex).", file=sys.stderr)
|
|
2715
|
+
hook_log("hook triggered")
|
|
2105
2716
|
debug_log("Codex CozeLoop hook started.")
|
|
2106
2717
|
|
|
2107
2718
|
# Check if tracing is enabled
|
|
2108
2719
|
if os.environ.get("TRACE_TO_COZELOOP", "").lower() == "false":
|
|
2720
|
+
hook_log("skip trace disabled")
|
|
2109
2721
|
debug_log("TRACE_TO_COZELOOP is set to 'false', skipping")
|
|
2110
2722
|
return
|
|
2111
2723
|
|
|
@@ -2113,24 +2725,41 @@ def main():
|
|
|
2113
2725
|
try:
|
|
2114
2726
|
raw_input = sys.stdin.read().strip()
|
|
2115
2727
|
if not raw_input:
|
|
2728
|
+
hook_log("stdin empty, trying fallback")
|
|
2116
2729
|
debug_log("No input received from stdin")
|
|
2117
|
-
|
|
2118
|
-
|
|
2730
|
+
hook_input = recover_hook_input("empty_stdin")
|
|
2731
|
+
if not hook_input:
|
|
2732
|
+
return
|
|
2733
|
+
else:
|
|
2734
|
+
hook_input = json.loads(raw_input)
|
|
2119
2735
|
except Exception as e:
|
|
2736
|
+
hook_log(f"stdin parse error={repr(e)}, trying fallback")
|
|
2120
2737
|
debug_log(f"Error reading hook input from stdin: {e}")
|
|
2121
|
-
|
|
2738
|
+
hook_input = recover_hook_input("stdin_parse_error")
|
|
2739
|
+
if not hook_input:
|
|
2740
|
+
return
|
|
2122
2741
|
|
|
2123
2742
|
debug_log(f"Hook input: {json.dumps(hook_input, ensure_ascii=False)}")
|
|
2124
2743
|
|
|
2125
2744
|
# Get transcript path
|
|
2126
2745
|
transcript_path = hook_input.get("transcript_path")
|
|
2127
2746
|
if not transcript_path:
|
|
2747
|
+
hook_log("missing transcript_path, trying fallback")
|
|
2128
2748
|
debug_log("No transcript_path in hook input")
|
|
2129
|
-
|
|
2749
|
+
recovered = recover_hook_input("missing_transcript_path")
|
|
2750
|
+
if not recovered:
|
|
2751
|
+
return
|
|
2752
|
+
hook_input = recovered
|
|
2753
|
+
transcript_path = hook_input.get("transcript_path")
|
|
2130
2754
|
|
|
2131
2755
|
if not os.path.exists(transcript_path):
|
|
2756
|
+
hook_log(f"transcript not found path={transcript_path}, trying fallback")
|
|
2132
2757
|
debug_log(f"Transcript file not found: {transcript_path}")
|
|
2133
|
-
|
|
2758
|
+
recovered = recover_hook_input("transcript_not_found")
|
|
2759
|
+
if not recovered:
|
|
2760
|
+
return
|
|
2761
|
+
hook_input = recovered
|
|
2762
|
+
transcript_path = hook_input.get("transcript_path")
|
|
2134
2763
|
|
|
2135
2764
|
# Load state
|
|
2136
2765
|
state_file = get_state_file_path(transcript_path)
|
|
@@ -2140,9 +2769,11 @@ def main():
|
|
|
2140
2769
|
entries = read_rollout_messages(transcript_path, state["last_processed_line"])
|
|
2141
2770
|
|
|
2142
2771
|
if not entries:
|
|
2772
|
+
hook_log(f"skip no new entries transcript={transcript_path}")
|
|
2143
2773
|
debug_log("No new entries to process")
|
|
2144
2774
|
return
|
|
2145
2775
|
|
|
2776
|
+
hook_log(f"read entries={len(entries)} from_line={state['last_processed_line']} transcript={transcript_path}")
|
|
2146
2777
|
debug_log(f"Read {len(entries)} new entries from line {state['last_processed_line']}")
|
|
2147
2778
|
|
|
2148
2779
|
# Parse session identity
|
|
@@ -2192,11 +2823,20 @@ def main():
|
|
|
2192
2823
|
last_line = max(e.get("_line_number", 0) for e in entries) + 1
|
|
2193
2824
|
state["last_processed_line"] = last_line
|
|
2194
2825
|
save_state(state_file, state)
|
|
2826
|
+
hook_log(f"subagent saved session_id={session_id} turns={len(turns[-1:])} last_line={last_line}")
|
|
2195
2827
|
debug_log("Subagent data saved, hook completed")
|
|
2196
2828
|
return
|
|
2197
2829
|
|
|
2198
|
-
# Send turns to CozeLoop
|
|
2830
|
+
# Send turns to CozeLoop — only if at least one turn carries coze-context.
|
|
2199
2831
|
if turns:
|
|
2832
|
+
has_coze_ctx = any(
|
|
2833
|
+
turn_coze_context(t)
|
|
2834
|
+
for t in turns
|
|
2835
|
+
)
|
|
2836
|
+
if not has_coze_ctx:
|
|
2837
|
+
hook_log(f"skip no coze-context turns={len(turns)} session_id={session_id}")
|
|
2838
|
+
debug_log("No coze-context found in any turn, skipping upload.")
|
|
2839
|
+
return
|
|
2200
2840
|
history_context = state.get("conversation_history", [])
|
|
2201
2841
|
updated_history = send_turns_to_cozeloop(
|
|
2202
2842
|
turns, session_id, model_name,
|
|
@@ -2207,12 +2847,16 @@ def main():
|
|
|
2207
2847
|
state["last_processed_line"] = last_line
|
|
2208
2848
|
state["conversation_history"] = updated_history
|
|
2209
2849
|
save_state(state_file, state)
|
|
2850
|
+
hook_log(f"state advanced last_line={last_line} session_id={session_id}")
|
|
2210
2851
|
debug_log(f"State updated, last processed line: {last_line}")
|
|
2211
2852
|
else:
|
|
2853
|
+
hook_log(f"send failed state not advanced session_id={session_id}")
|
|
2212
2854
|
debug_log("Send failed, state not advanced")
|
|
2213
2855
|
else:
|
|
2856
|
+
hook_log(f"skip no turns session_id={session_id}")
|
|
2214
2857
|
debug_log("No turns to send")
|
|
2215
2858
|
|
|
2859
|
+
hook_log("hook completed")
|
|
2216
2860
|
debug_log("Codex CozeLoop hook completed.")
|
|
2217
2861
|
|
|
2218
2862
|
|
|
@@ -3526,10 +4170,12 @@ async function _refreshToken(refreshTok) {
|
|
|
3526
4170
|
try {
|
|
3527
4171
|
const d = JSON.parse(buf);
|
|
3528
4172
|
if (d.access_token) {
|
|
4173
|
+
const existing = _loadCreds() ?? {};
|
|
3529
4174
|
const creds = {
|
|
3530
4175
|
access_token: d.access_token,
|
|
3531
4176
|
refresh_token: d.refresh_token ?? refreshTok,
|
|
3532
4177
|
expires_at: (d.expires_in ?? 0) * 1000, // unix timestamp in seconds
|
|
4178
|
+
workspace_id: existing.workspace_id ?? "",
|
|
3533
4179
|
};
|
|
3534
4180
|
_saveCreds(creds);
|
|
3535
4181
|
resolve(creds.access_token);
|
|
@@ -3553,7 +4199,7 @@ async function getRefreshedToken(currentAuthorization) {
|
|
|
3553
4199
|
const newToken = await _refreshToken(creds.refresh_token);
|
|
3554
4200
|
if (newToken) return \`Bearer \${newToken}\`;
|
|
3555
4201
|
}
|
|
3556
|
-
return
|
|
4202
|
+
return null;
|
|
3557
4203
|
}
|
|
3558
4204
|
// ─────────────────────────────────────────────────────────────────────────
|
|
3559
4205
|
|
|
@@ -3625,6 +4271,9 @@ export class CozeloopExporter {
|
|
|
3625
4271
|
this.provider = null;
|
|
3626
4272
|
this.tracer = null;
|
|
3627
4273
|
}
|
|
4274
|
+
} else if (fresh === null) {
|
|
4275
|
+
this.api.logger.error("[CozeloopTrace] Local credentials exist but token refresh failed; refusing to reuse stale authorization.");
|
|
4276
|
+
this.config.authorization = "";
|
|
3628
4277
|
}
|
|
3629
4278
|
}
|
|
3630
4279
|
async ensureInitialized() {
|
|
@@ -4138,13 +4787,19 @@ def refresh(rt):
|
|
|
4138
4787
|
try:
|
|
4139
4788
|
body = json.dumps({"grant_type":"refresh_token","client_id":CLIENT_ID,"refresh_token":rt}).encode()
|
|
4140
4789
|
req = urllib.request.Request(f"{COZE_API}/api/permission/oauth2/token",
|
|
4141
|
-
data=body, headers={
|
|
4790
|
+
data=body, headers={
|
|
4791
|
+
"Content-Type":"application/json",
|
|
4792
|
+
"x-tt-env":"ppe_cozelab",
|
|
4793
|
+
"x-use-ppe":"1",
|
|
4794
|
+
})
|
|
4142
4795
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
4143
4796
|
d = json.loads(r.read())
|
|
4144
4797
|
if d.get("access_token"):
|
|
4798
|
+
existing = load() or {}
|
|
4145
4799
|
save({"access_token":d["access_token"],
|
|
4146
4800
|
"refresh_token":d.get("refresh_token",rt),
|
|
4147
|
-
"expires_at":d.get("expires_in",0)*1000
|
|
4801
|
+
"expires_at":d.get("expires_in",0)*1000,
|
|
4802
|
+
"workspace_id":existing.get("workspace_id","")})
|
|
4148
4803
|
return True
|
|
4149
4804
|
except Exception as e:
|
|
4150
4805
|
print(f"[cozeloop_refresh] refresh failed: {e}", file=sys.stderr)
|
|
@@ -4176,6 +4831,7 @@ function parseArgs() {
|
|
|
4176
4831
|
if (arg === '--refresh') { args['refresh'] = true; continue; }
|
|
4177
4832
|
if (arg === '--verify') { args['verify'] = true; continue; }
|
|
4178
4833
|
if (arg === '--cloud') { args['cloud'] = true; continue; }
|
|
4834
|
+
if (arg === '--force') { args['force'] = true; continue; }
|
|
4179
4835
|
const m = arg.match(/^--([^=]+)=(.+)$/);
|
|
4180
4836
|
if (m) args[m[1]] = m[2];
|
|
4181
4837
|
}
|
|
@@ -4237,6 +4893,7 @@ function validateArgs(args) {
|
|
|
4237
4893
|
'codex-home': args['codex-home'],
|
|
4238
4894
|
pairCode: args['pair-code'],
|
|
4239
4895
|
cloud: true,
|
|
4896
|
+
force: !!args['force'],
|
|
4240
4897
|
};
|
|
4241
4898
|
}
|
|
4242
4899
|
// config.json 缺失:回退到显式 --agent
|
|
@@ -4255,6 +4912,7 @@ function validateArgs(args) {
|
|
|
4255
4912
|
'codex-home': args['codex-home'],
|
|
4256
4913
|
pairCode: args['pair-code'],
|
|
4257
4914
|
cloud: true,
|
|
4915
|
+
force: !!args['force'],
|
|
4258
4916
|
};
|
|
4259
4917
|
}
|
|
4260
4918
|
|
|
@@ -4269,6 +4927,7 @@ function validateArgs(args) {
|
|
|
4269
4927
|
'codex-home': args['codex-home'],
|
|
4270
4928
|
pairCode: args['pair-code'],
|
|
4271
4929
|
cloud: !!args['cloud'],
|
|
4930
|
+
force: !!args['force'],
|
|
4272
4931
|
};
|
|
4273
4932
|
}
|
|
4274
4933
|
|
|
@@ -4280,6 +4939,9 @@ function validateArgs(args) {
|
|
|
4280
4939
|
' --agent=claude-code | codex | openclaw (全局配置)',
|
|
4281
4940
|
' --agent-id=<id> (按 ~/.coze/agents/<id> 的 framework 自动路由)',
|
|
4282
4941
|
'',
|
|
4942
|
+
'Flags:',
|
|
4943
|
+
' --force 强制重装(OpenClaw 跳过幂等检查,无条件重写插件 + 重装依赖 + 重启 gateway)',
|
|
4944
|
+
'',
|
|
4283
4945
|
'Other commands:',
|
|
4284
4946
|
' --status Show authorization status',
|
|
4285
4947
|
' --login Login (Device Code flow)',
|
|
@@ -4299,7 +4961,7 @@ function validateArgs(args) {
|
|
|
4299
4961
|
' --agent=openclaw',
|
|
4300
4962
|
]);
|
|
4301
4963
|
}
|
|
4302
|
-
return { agent: args['agent'], 'codex-home': args['codex-home'], pairCode: args['pair-code'], cloud: !!args['cloud'] };
|
|
4964
|
+
return { agent: args['agent'], 'codex-home': args['codex-home'], pairCode: args['pair-code'], cloud: !!args['cloud'], force: !!args['force'] };
|
|
4303
4965
|
}
|
|
4304
4966
|
|
|
4305
4967
|
// ─── 4. Agent detection ──────────────────────────────────────────────────────
|
|
@@ -4562,6 +5224,7 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
|
|
|
4562
5224
|
if (cloud) {
|
|
4563
5225
|
const loopToken = readEnv('COZELOOP_API_TOKEN');
|
|
4564
5226
|
const cozeToken = readEnv('COZE_API_TOKEN');
|
|
5227
|
+
existing.env.COZELAB_ONBOARD_CLOUD = '1';
|
|
4565
5228
|
if (loopToken) {
|
|
4566
5229
|
existing.env.COZELOOP_API_TOKEN = loopToken;
|
|
4567
5230
|
delete existing.env.COZE_API_TOKEN;
|
|
@@ -4570,8 +5233,9 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
|
|
|
4570
5233
|
delete existing.env.COZELOOP_API_TOKEN;
|
|
4571
5234
|
}
|
|
4572
5235
|
} else {
|
|
4573
|
-
existing.env.COZELOOP_API_TOKEN
|
|
5236
|
+
delete existing.env.COZELOOP_API_TOKEN;
|
|
4574
5237
|
delete existing.env.COZE_API_TOKEN;
|
|
5238
|
+
delete existing.env.COZELAB_ONBOARD_CLOUD;
|
|
4575
5239
|
}
|
|
4576
5240
|
const loopBaseUrl = readEnv('COZELOOP_API_BASE_URL');
|
|
4577
5241
|
const otelEndpoint = readEnv('OTEL_ENDPOINT');
|
|
@@ -4600,8 +5264,8 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
|
|
|
4600
5264
|
|
|
4601
5265
|
// writeCodexHook 把 hook 写进指定的 CODEX_HOME。codexHome 缺省 ~/.codex;
|
|
4602
5266
|
// 传入动态目录(如 coze-bridge 的 /tmp/coze-bridge-codex-home-xxx)即可 per-agent 生效。
|
|
4603
|
-
//
|
|
4604
|
-
//
|
|
5267
|
+
// 本地模式不把短期 token 写入 cozeloop.env;Hook 运行时读取 ~/.cozeloop/credentials.json。
|
|
5268
|
+
// cloud=true 时写 COZELAB_ONBOARD_CLOUD,并带入 sandbox 注入的 trace token。
|
|
4605
5269
|
function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
|
|
4606
5270
|
const home = codexHome || path.join(os.homedir(), '.codex');
|
|
4607
5271
|
const hooksDir = path.join(home, 'hooks');
|
|
@@ -4624,13 +5288,12 @@ function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
|
|
|
4624
5288
|
if (cloud) {
|
|
4625
5289
|
const loopToken = readEnv('COZELOOP_API_TOKEN');
|
|
4626
5290
|
const cozeToken = readEnv('COZE_API_TOKEN');
|
|
5291
|
+
envLines.push(shellEnvLine('COZELAB_ONBOARD_CLOUD', '1'));
|
|
4627
5292
|
if (loopToken) {
|
|
4628
5293
|
envLines.push(shellEnvLine('COZELOOP_API_TOKEN', loopToken));
|
|
4629
5294
|
} else if (cozeToken) {
|
|
4630
5295
|
envLines.push(shellEnvLine('COZE_API_TOKEN', cozeToken));
|
|
4631
5296
|
}
|
|
4632
|
-
} else {
|
|
4633
|
-
envLines.push(shellEnvLine('COZELOOP_API_TOKEN', token));
|
|
4634
5297
|
}
|
|
4635
5298
|
envLines.push(shellEnvLine('CODEX_HOME', home));
|
|
4636
5299
|
envLines.push(shellEnvLine('COZELOOP_HOOK_LOG', logFile));
|
|
@@ -4740,7 +5403,7 @@ function normalizeCozeloopApiBaseUrl(raw) {
|
|
|
4740
5403
|
return base.slice(0, -'/v1/loop/opentelemetry'.length).replace(/\/+$/, '');
|
|
4741
5404
|
}
|
|
4742
5405
|
if (base.endsWith('/api/v1/loop/opentelemetry')) {
|
|
4743
|
-
return base.slice(0, -'/loop/opentelemetry'.length).replace(/\/+$/, '');
|
|
5406
|
+
return base.slice(0, -'/v1/loop/opentelemetry'.length).replace(/\/+$/, '');
|
|
4744
5407
|
}
|
|
4745
5408
|
if (base.endsWith('/api/v1')) {
|
|
4746
5409
|
return base.slice(0, -'/v1'.length).replace(/\/+$/, '');
|
|
@@ -4790,6 +5453,7 @@ function applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud)
|
|
|
4790
5453
|
pcfg.endpoint = getOtelEndpointBase(cloud);
|
|
4791
5454
|
pcfg.workspaceId = workspaceId;
|
|
4792
5455
|
pcfg.debug = true;
|
|
5456
|
+
pcfg.disableLocalCredentials = !!cloud;
|
|
4793
5457
|
// 插件代码版本:参与幂等比对,升级插件(bump scripts/openclaw/package.json version)后
|
|
4794
5458
|
// 强制触发重写+重装+重启,避免云端 pluginDir 滞留旧插件 dist。
|
|
4795
5459
|
if (OPENCLAW_PLUGIN_VERSION) {
|
|
@@ -5155,6 +5819,117 @@ async function verifyTraceReport(token, workspaceId, pairCode, tracesUrl) {
|
|
|
5155
5819
|
return { success, status: res.status, body: res.body, traceId, pairCode: pair };
|
|
5156
5820
|
}
|
|
5157
5821
|
|
|
5822
|
+
// ── OpenClaw 专属上报链路校验 ──────────────────────────────────────────────
|
|
5823
|
+
// 为什么单独一条:claude-code/codex 的 verify 用主流程刚 getValidToken() 刷新过的有效
|
|
5824
|
+
// token 直发,而 openclaw 运行时上报用的是【写死在 openclaw.json 插件 config.authorization
|
|
5825
|
+
// 里的静态 token】。两者是不同 token —— 插件那个失效(401/4100)时通用 verify 照样 ok,
|
|
5826
|
+
// 这就是“verify=ok 但实际查不到 trace”假象的根因。本函数改为读插件实际配置的 token 打
|
|
5827
|
+
// ingest,真实反映运行时会不会 401。
|
|
5828
|
+
//
|
|
5829
|
+
// cloud/local 兼容:插件配置位置都在 resolveHomeDir(cloud)/.openclaw/openclaw.json,
|
|
5830
|
+
// endpoint 都走 getOtelEndpointBase(cloud),逻辑统一。差异在 token 刷新:
|
|
5831
|
+
// - local:disableLocalCredentials=false,插件会读 ~/.cozeloop/credentials.json 自动刷新,
|
|
5832
|
+
// 所以额外检测【实际加载的插件是否含刷新逻辑 getRefreshedToken】,无则告警。
|
|
5833
|
+
// - cloud:disableLocalCredentials=true,插件只用写死的 token、不刷新,token 失效需重注入,
|
|
5834
|
+
// 刷新能力检测对 cloud 无意义(跳过)。
|
|
5835
|
+
async function verifyOpenClawTraceLink(cloud) {
|
|
5836
|
+
const home = resolveHomeDir(cloud);
|
|
5837
|
+
const configPath = path.join(home, '.openclaw', 'openclaw.json');
|
|
5838
|
+
let pcfg = null;
|
|
5839
|
+
try {
|
|
5840
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
5841
|
+
pcfg = cfg?.plugins?.entries?.['openclaw-cozeloop-trace']?.config || null;
|
|
5842
|
+
} catch (e) {
|
|
5843
|
+
warn(`无法读取 openclaw.json 插件配置: ${e.message}`);
|
|
5844
|
+
return { success: false, status: 0, body: `read ${configPath} failed: ${e.message}` };
|
|
5845
|
+
}
|
|
5846
|
+
if (!pcfg || !pcfg.authorization) {
|
|
5847
|
+
warn('openclaw 插件未配置 authorization,无法校验上报链路。');
|
|
5848
|
+
return { success: false, status: 0, body: 'plugin authorization missing' };
|
|
5849
|
+
}
|
|
5850
|
+
|
|
5851
|
+
// 1) 用插件【实际配置的】token 打 ingest(空 spans)——这才是运行时真实用的那个 token。
|
|
5852
|
+
const authHeader = pcfg.authorization; // 形如 "Bearer czu_xxx"
|
|
5853
|
+
const tracesUrl = (pcfg.endpoint
|
|
5854
|
+
? `${String(pcfg.endpoint).replace(/\/+$/, '')}/v1/traces`
|
|
5855
|
+
: getOtelTracesUrl(cloud));
|
|
5856
|
+
const workspaceId = pcfg.workspaceId || WORKSPACE_ID;
|
|
5857
|
+
let res;
|
|
5858
|
+
try {
|
|
5859
|
+
res = await httpsPost(
|
|
5860
|
+
tracesUrl,
|
|
5861
|
+
{ resourceSpans: [] },
|
|
5862
|
+
{ Authorization: authHeader, 'cozeloop-workspace-id': String(workspaceId) },
|
|
5863
|
+
);
|
|
5864
|
+
} catch (e) {
|
|
5865
|
+
warn(`openclaw 插件 token 上报探测失败: ${e.message}`);
|
|
5866
|
+
return { success: false, status: 0, body: e.message };
|
|
5867
|
+
}
|
|
5868
|
+
const success = res.status >= 200 && res.status < 300;
|
|
5869
|
+
const tokenPrefix = authHeader.replace(/^Bearer\s+/i, '').slice(0, 12);
|
|
5870
|
+
|
|
5871
|
+
if (success) {
|
|
5872
|
+
ok(`openclaw 插件实际 token 上报正常 (token=${tokenPrefix}..., HTTP ${res.status})`);
|
|
5873
|
+
} else {
|
|
5874
|
+
warn(`openclaw 插件实际 token 上报失败: HTTP ${res.status} (token=${tokenPrefix}...)`);
|
|
5875
|
+
const snippet = (res.body || '').slice(0, 300);
|
|
5876
|
+
if (snippet) console.log(snippet);
|
|
5877
|
+
// 4100/401 = 该 token 已失效。指出根因与修复方式。
|
|
5878
|
+
if (res.status === 401 || /\b4100\b/.test(res.body || '')) {
|
|
5879
|
+
info('插件配置的 token 已失效。运行时上报会 401 → OTLP 抛 unhandled rejection → gateway 崩溃 → span 丢失。');
|
|
5880
|
+
info('修复:重跑 `node index.js --agent=openclaw --force` 写入新 token(local 会从 ~/.cozeloop 自动刷新)。');
|
|
5881
|
+
}
|
|
5882
|
+
}
|
|
5883
|
+
|
|
5884
|
+
// 2) 仅 local:检测实际加载的插件是否具备 token 自动刷新能力。
|
|
5885
|
+
// cloud 主动 disableLocalCredentials,不刷新,检测无意义。
|
|
5886
|
+
if (!cloud) {
|
|
5887
|
+
const refreshOk = openClawPluginHasRefresh(home);
|
|
5888
|
+
if (refreshOk === true) {
|
|
5889
|
+
ok('openclaw 插件具备 token 自动刷新能力 (getRefreshedToken)。');
|
|
5890
|
+
} else if (refreshOk === false) {
|
|
5891
|
+
warn('本机加载的 openclaw 插件【无 token 刷新逻辑】,token 过期后会反复 401 崩 gateway。');
|
|
5892
|
+
info('修复:重跑 `node index.js --agent=openclaw --force` 安装带刷新逻辑的新插件。');
|
|
5893
|
+
}
|
|
5894
|
+
// refreshOk === null:定位不到插件文件,不下结论(不误报)。
|
|
5895
|
+
}
|
|
5896
|
+
|
|
5897
|
+
return { success, status: res.status, body: res.body || '' };
|
|
5898
|
+
}
|
|
5899
|
+
|
|
5900
|
+
// 检测本机【实际加载的】openclaw trace 插件是否含 token 刷新逻辑(getRefreshedToken)。
|
|
5901
|
+
// 返回 true=有 / false=无 / null=定位不到插件文件(不下结论)。
|
|
5902
|
+
// 探测顺序:openclaw plugins list 给出的真实路径 > onboard 安装位置 ~/.cozeloop/openclaw-plugin
|
|
5903
|
+
// > 历史手改位置 ~/.openclaw/workspace/cozeloop-trace-fix。
|
|
5904
|
+
function openClawPluginHasRefresh(home) {
|
|
5905
|
+
const candidates = [];
|
|
5906
|
+
// openclaw plugins list 拿实际加载路径(最准——能发现 cozeloop-trace-fix 这类残留旧插件)
|
|
5907
|
+
try {
|
|
5908
|
+
const { execSync } = require('child_process');
|
|
5909
|
+
const out = execSync('openclaw plugins list', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
5910
|
+
for (const line of out.split(/\r?\n/)) {
|
|
5911
|
+
if (!/cozeloop|trace/i.test(line)) continue;
|
|
5912
|
+
const m = line.match(/(\/[^\s'"]+)/); // 抓行内绝对路径
|
|
5913
|
+
if (m) candidates.push(m[1]);
|
|
5914
|
+
}
|
|
5915
|
+
} catch { /* CLI 不可用则回退已知路径 */ }
|
|
5916
|
+
candidates.push(path.join(home, '.cozeloop', 'openclaw-plugin'));
|
|
5917
|
+
candidates.push(path.join(home, '.openclaw', 'workspace', 'cozeloop-trace-fix'));
|
|
5918
|
+
|
|
5919
|
+
let foundAny = false;
|
|
5920
|
+
for (const base of candidates) {
|
|
5921
|
+
for (const rel of ['dist/cozeloop-exporter.js', 'dist/index.js', 'cozeloop-exporter.js', 'index.js']) {
|
|
5922
|
+
const f = path.isAbsolute(rel) ? rel : path.join(base, rel);
|
|
5923
|
+
try {
|
|
5924
|
+
if (!fs.existsSync(f)) continue;
|
|
5925
|
+
foundAny = true;
|
|
5926
|
+
if (fs.readFileSync(f, 'utf8').includes('getRefreshedToken')) return true;
|
|
5927
|
+
} catch { /* ignore */ }
|
|
5928
|
+
}
|
|
5929
|
+
}
|
|
5930
|
+
return foundAny ? false : null;
|
|
5931
|
+
}
|
|
5932
|
+
|
|
5158
5933
|
function httpsGet(url, headers) {
|
|
5159
5934
|
return new Promise((resolve, reject) => {
|
|
5160
5935
|
// 合并 PPE 泳道 header
|
|
@@ -5486,7 +6261,22 @@ async function main() {
|
|
|
5486
6261
|
const token = await getValidToken(); // 无凭证会自动走登录/刷新
|
|
5487
6262
|
console.log('');
|
|
5488
6263
|
const result = await verifyTraceReport(token, WORKSPACE_ID, args.pairCode, getOtelTracesUrl(false));
|
|
5489
|
-
|
|
6264
|
+
// 若本机装了 openclaw 插件,额外校验插件【实际配置的静态 token】——通用 verify 用刚刷新的
|
|
6265
|
+
// token 测不到它。任一失败则整体判失败(exit 1)。
|
|
6266
|
+
let ocOk = true;
|
|
6267
|
+
const ocConfigPath = path.join(resolveHomeDir(false), '.openclaw', 'openclaw.json');
|
|
6268
|
+
if (fs.existsSync(ocConfigPath)) {
|
|
6269
|
+
try {
|
|
6270
|
+
const cfg = JSON.parse(fs.readFileSync(ocConfigPath, 'utf8'));
|
|
6271
|
+
if (cfg?.plugins?.entries?.['openclaw-cozeloop-trace']?.config?.authorization) {
|
|
6272
|
+
console.log('');
|
|
6273
|
+
info('检测到 openclaw cozeloop-trace 插件,校验其实际 token...');
|
|
6274
|
+
const ocRes = await verifyOpenClawTraceLink(false);
|
|
6275
|
+
ocOk = ocRes.success;
|
|
6276
|
+
}
|
|
6277
|
+
} catch { /* 读不了就跳过 openclaw 校验 */ }
|
|
6278
|
+
}
|
|
6279
|
+
process.exit(result.success && ocOk ? 0 : 1);
|
|
5490
6280
|
}
|
|
5491
6281
|
|
|
5492
6282
|
const { agent } = args;
|
|
@@ -5616,9 +6406,15 @@ async function main() {
|
|
|
5616
6406
|
|
|
5617
6407
|
// Step 5: Verify trace reporting end-to-end
|
|
5618
6408
|
info('Step 5/5: 验证 trace 上报链路...');
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
6409
|
+
// openclaw 走专属校验:claude-code/codex 的 verify 用主流程刚 getValidToken() 刷新过的
|
|
6410
|
+
// 有效 token 直发,测不到 openclaw 插件【写死在 openclaw.json 里的静态 token】是否失效
|
|
6411
|
+
// (插件不读这个临时 token)。openclaw 必须用插件实际配置的 authorization 打 ingest,
|
|
6412
|
+
// 才能真实反映运行时上报会不会 401。cloud/local 配置位置一致,统一走这条。
|
|
6413
|
+
const verifyResult = agent === 'openclaw'
|
|
6414
|
+
? await verifyOpenClawTraceLink(args.cloud)
|
|
6415
|
+
: args.cloud
|
|
6416
|
+
? await verifyTraceReportViaSdk(token, WORKSPACE_ID, args.pairCode, pythonCmd || 'python3', tokenSource)
|
|
6417
|
+
: await verifyTraceReport(token, WORKSPACE_ID, args.pairCode, getOtelTracesUrl(false));
|
|
5622
6418
|
if (verifyResult.success) {
|
|
5623
6419
|
cloudResult.verify = 'ok';
|
|
5624
6420
|
} else if (CLOUD_MODE) {
|