coze_lab 0.1.4 → 0.1.6
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/README.md +7 -7
- package/index.js +366 -78
- package/package.json +1 -1
- package/scripts/claude-code/cozeloop_hook.py +77 -7
- package/scripts/codex/cozeloop_hook.py +80 -8
- package/scripts/openclaw/dist/cozeloop-exporter.js +3 -1
- package/scripts/openclaw/dist/index.js +114 -16
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# coze_lab
|
|
2
2
|
|
|
3
3
|
Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop.
|
|
4
4
|
|
|
@@ -6,13 +6,13 @@ Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to Coz
|
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
# First-time setup — triggers browser OAuth authorization
|
|
9
|
-
npx
|
|
9
|
+
npx coze_lab --agent=<type>
|
|
10
10
|
|
|
11
11
|
# Auth-only commands (no agent configuration)
|
|
12
|
-
npx
|
|
13
|
-
npx
|
|
14
|
-
npx
|
|
15
|
-
npx
|
|
12
|
+
npx coze_lab --login # Device Code login only
|
|
13
|
+
npx coze_lab --status # Show current authorization status
|
|
14
|
+
npx coze_lab --refresh # Force-refresh the access token
|
|
15
|
+
npx coze_lab --logout # Clear cached credentials
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
### Parameters
|
|
@@ -62,7 +62,7 @@ OAuth tokens are stored in `~/.cozeloop/credentials.json` (mode 600).
|
|
|
62
62
|
|
|
63
63
|
This means traces continue to upload without interruption even across token expiry, as long as the refresh token is still valid.
|
|
64
64
|
|
|
65
|
-
**For OpenClaw**, the token is read from `openclaw.json` at startup. Re-run `npx
|
|
65
|
+
**For OpenClaw**, the token is read from `openclaw.json` at startup. Re-run `npx coze_lab --agent=openclaw` to refresh it if OpenClaw reports auth errors.
|
|
66
66
|
|
|
67
67
|
## Supported versions
|
|
68
68
|
|
package/index.js
CHANGED
|
@@ -4,13 +4,41 @@
|
|
|
4
4
|
// ─── 0. Constants ─────────────────────────────────────────────────────────────
|
|
5
5
|
const CLIENT_ID = '46371084383473718052118955183420.app.coze';
|
|
6
6
|
const WORKSPACE_ID = '7644910356078837760';
|
|
7
|
-
//
|
|
8
|
-
const
|
|
7
|
+
// PPE 泳道:所有 cozeloop 上报 / OAuth 请求都带这两个 header(SDK 读 x_tt_env/x_use_ppe 环境变量自动注入)。
|
|
8
|
+
const PPE_TT_ENV = 'ppe_cozelab';
|
|
9
|
+
const PPE_USE_PPE = '1';
|
|
9
10
|
const COZE_API = 'https://api.coze.cn';
|
|
10
11
|
const CREDS_PATH = require('path').join(require('os').homedir(), '.cozeloop', 'credentials.json');
|
|
11
12
|
// Refresh when less than 10 minutes remain
|
|
12
13
|
const REFRESH_THRESHOLD_MS = 10 * 60 * 1000;
|
|
13
14
|
|
|
15
|
+
// ─── 1. Cloud structured output ──────────────────────────────────────────────
|
|
16
|
+
// 云端(--cloud)模式:在 stdout 输出一行机器可读结果 COZE_LAB_RESULT={...},
|
|
17
|
+
// 供管理后台解析判定(inject/verify/logid/message),不依赖中文文案。
|
|
18
|
+
let CLOUD_MODE = false;
|
|
19
|
+
const cloudResult = { inject: 'skip', verify: 'skip', logid: '', message: '' };
|
|
20
|
+
|
|
21
|
+
// errorBox 在云端模式下抛此异常(而非 process.exit),由 main 外层统一收尾。
|
|
22
|
+
class CloudAbort extends Error {}
|
|
23
|
+
|
|
24
|
+
// 从 onboard 失败输出 / verify response body 中提取 server logid。
|
|
25
|
+
// 兼容两种形态:JSON 里的 "logid":"xxx" / detail.logid,以及裸 logid=xxx。
|
|
26
|
+
function extractLogid(text) {
|
|
27
|
+
if (!text) return '';
|
|
28
|
+
let m = text.match(/"logid"\s*:\s*"([a-zA-Z0-9]+)"/);
|
|
29
|
+
if (m) return m[1];
|
|
30
|
+
m = text.match(/logid[=:\s]+([a-zA-Z0-9]+)/i);
|
|
31
|
+
return m ? m[1] : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 输出结构化结果行(仅云端模式)。
|
|
35
|
+
function emitCloudResult() {
|
|
36
|
+
if (!CLOUD_MODE) return;
|
|
37
|
+
// message 压成单行,避免破坏 COZE_LAB_RESULT 单行约定。
|
|
38
|
+
const safe = { ...cloudResult, message: String(cloudResult.message || '').replace(/\s+/g, ' ').slice(0, 500) };
|
|
39
|
+
console.log('COZE_LAB_RESULT=' + JSON.stringify(safe));
|
|
40
|
+
}
|
|
41
|
+
|
|
14
42
|
// ─── 1. Color helpers ────────────────────────────────────────────────────────
|
|
15
43
|
const C = {
|
|
16
44
|
reset: '\x1b[0m',
|
|
@@ -40,6 +68,10 @@ function box(lines, color) {
|
|
|
40
68
|
|
|
41
69
|
function errorBox(lines) {
|
|
42
70
|
box(lines, C.red);
|
|
71
|
+
// 云端模式下不直接退出,改抛异常,让 main 外层统一输出 COZE_LAB_RESULT 结构化结果。
|
|
72
|
+
if (CLOUD_MODE) {
|
|
73
|
+
throw new CloudAbort(lines.join(' '));
|
|
74
|
+
}
|
|
43
75
|
process.exit(1);
|
|
44
76
|
}
|
|
45
77
|
|
|
@@ -3457,7 +3489,7 @@ async function _refreshToken(refreshTok) {
|
|
|
3457
3489
|
const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
|
|
3458
3490
|
const req = https.request(\`\${_COZE_API}/api/permission/oauth2/token\`, {
|
|
3459
3491
|
method: "POST",
|
|
3460
|
-
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
|
3492
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "x-tt-env": "ppe_cozelab", "x-use-ppe": "1" },
|
|
3461
3493
|
}, (res) => {
|
|
3462
3494
|
let buf = "";
|
|
3463
3495
|
res.on("data", c => buf += c);
|
|
@@ -3591,6 +3623,8 @@ export class CozeloopExporter {
|
|
|
3591
3623
|
headers: {
|
|
3592
3624
|
"Authorization": authorization,
|
|
3593
3625
|
"cozeloop-workspace-id": workspaceId,
|
|
3626
|
+
"x-tt-env": "ppe_cozelab",
|
|
3627
|
+
"x-use-ppe": "1",
|
|
3594
3628
|
},
|
|
3595
3629
|
});
|
|
3596
3630
|
this.provider = new BasicTracerProvider({ resource });
|
|
@@ -4111,6 +4145,8 @@ function parseArgs() {
|
|
|
4111
4145
|
if (arg === '--login') { args['login'] = true; continue; }
|
|
4112
4146
|
if (arg === '--status') { args['status'] = true; continue; }
|
|
4113
4147
|
if (arg === '--refresh') { args['refresh'] = true; continue; }
|
|
4148
|
+
if (arg === '--verify') { args['verify'] = true; continue; }
|
|
4149
|
+
if (arg === '--cloud') { args['cloud'] = true; continue; }
|
|
4114
4150
|
const m = arg.match(/^--([^=]+)=(.+)$/);
|
|
4115
4151
|
if (m) args[m[1]] = m[2];
|
|
4116
4152
|
}
|
|
@@ -4119,26 +4155,72 @@ function parseArgs() {
|
|
|
4119
4155
|
|
|
4120
4156
|
const VALID_AGENTS = ['claude-code', 'codex', 'openclaw'];
|
|
4121
4157
|
|
|
4158
|
+
// resolveAgent 读 ~/.coze/agents/<agentId>/config.json,返回 { framework, workspace, agentId, root }。
|
|
4159
|
+
// 目录或 config 不存在、framework 非法时直接报错退出。
|
|
4160
|
+
function resolveAgent(agentId) {
|
|
4161
|
+
const root = path.join(os.homedir(), '.coze', 'agents', agentId);
|
|
4162
|
+
const configPath = path.join(root, 'config.json');
|
|
4163
|
+
if (!fs.existsSync(configPath)) {
|
|
4164
|
+
errorBox([
|
|
4165
|
+
`ERROR: agent "${agentId}" 不存在`,
|
|
4166
|
+
'',
|
|
4167
|
+
`未找到 ${configPath}`,
|
|
4168
|
+
'请确认 agentId 正确,或先用 coze-bridge 创建该 agent。',
|
|
4169
|
+
]);
|
|
4170
|
+
}
|
|
4171
|
+
let cfg;
|
|
4172
|
+
try {
|
|
4173
|
+
cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
4174
|
+
} catch (e) {
|
|
4175
|
+
errorBox([`ERROR: 解析 ${configPath} 失败`, '', e.message]);
|
|
4176
|
+
}
|
|
4177
|
+
const framework = cfg.framework;
|
|
4178
|
+
if (!VALID_AGENTS.includes(framework)) {
|
|
4179
|
+
errorBox([
|
|
4180
|
+
`ERROR: agent "${agentId}" 的 framework="${framework}" 不受支持`,
|
|
4181
|
+
'',
|
|
4182
|
+
`支持的类型: ${VALID_AGENTS.join(', ')}`,
|
|
4183
|
+
]);
|
|
4184
|
+
}
|
|
4185
|
+
return { framework, workspace: cfg.workspace || '', agentId, root };
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4122
4188
|
function validateArgs(args) {
|
|
4123
4189
|
if (args['logout']) return { logout: true };
|
|
4124
4190
|
if (args['login']) return { login: true };
|
|
4125
4191
|
if (args['status']) return { status: true };
|
|
4126
4192
|
if (args['refresh']) return { refresh: true };
|
|
4193
|
+
if (args['verify']) return { verify: true, pairCode: args['pair-code'] };
|
|
4194
|
+
|
|
4195
|
+
// 优先 --agent-id:读 ~/.coze/agents/<id>/config.json 的 framework 自动路由。
|
|
4196
|
+
if (args['agent-id']) {
|
|
4197
|
+
const resolved = resolveAgent(args['agent-id']);
|
|
4198
|
+
return {
|
|
4199
|
+
agent: resolved.framework,
|
|
4200
|
+
agentId: resolved.agentId,
|
|
4201
|
+
workspace: resolved.workspace,
|
|
4202
|
+
agentRoot: resolved.root,
|
|
4203
|
+
'codex-home': args['codex-home'],
|
|
4204
|
+
pairCode: args['pair-code'],
|
|
4205
|
+
cloud: !!args['cloud'],
|
|
4206
|
+
};
|
|
4207
|
+
}
|
|
4127
4208
|
|
|
4128
4209
|
if (!args['agent']) {
|
|
4129
4210
|
errorBox([
|
|
4130
|
-
'ERROR: --agent
|
|
4211
|
+
'ERROR: --agent 或 --agent-id 至少提供一个',
|
|
4131
4212
|
'',
|
|
4132
4213
|
'Usage:',
|
|
4133
|
-
' --agent=claude-code',
|
|
4134
|
-
' --agent
|
|
4135
|
-
' --agent=openclaw',
|
|
4214
|
+
' --agent=claude-code | codex | openclaw (全局配置)',
|
|
4215
|
+
' --agent-id=<id> (按 ~/.coze/agents/<id> 的 framework 自动路由)',
|
|
4136
4216
|
'',
|
|
4137
4217
|
'Other commands:',
|
|
4138
4218
|
' --status Show authorization status',
|
|
4139
4219
|
' --login Login (Device Code flow)',
|
|
4140
4220
|
' --refresh Force refresh access token',
|
|
4141
4221
|
' --logout Clear cached credentials',
|
|
4222
|
+
' --verify Send a test trace to verify the reporting pipeline',
|
|
4223
|
+
' 可带 --pair-code=<值> 写入 trace metadata(缺省自动生成),供查询方回查',
|
|
4142
4224
|
]);
|
|
4143
4225
|
}
|
|
4144
4226
|
if (!VALID_AGENTS.includes(args['agent'])) {
|
|
@@ -4151,7 +4233,7 @@ function validateArgs(args) {
|
|
|
4151
4233
|
' --agent=openclaw',
|
|
4152
4234
|
]);
|
|
4153
4235
|
}
|
|
4154
|
-
return { agent: args['agent'] };
|
|
4236
|
+
return { agent: args['agent'], 'codex-home': args['codex-home'], pairCode: args['pair-code'], cloud: !!args['cloud'] };
|
|
4155
4237
|
}
|
|
4156
4238
|
|
|
4157
4239
|
// ─── 4. Agent detection ──────────────────────────────────────────────────────
|
|
@@ -4297,7 +4379,7 @@ function checkVersionWhitelist(agent, version) {
|
|
|
4297
4379
|
'Trace reporting may not work correctly.',
|
|
4298
4380
|
'Please upgrade and re-run:',
|
|
4299
4381
|
` ${UPGRADE_CMD[agent]}`,
|
|
4300
|
-
` npx
|
|
4382
|
+
` npx coze_lab --agent=${agent} ...`,
|
|
4301
4383
|
]);
|
|
4302
4384
|
}
|
|
4303
4385
|
|
|
@@ -4339,22 +4421,33 @@ function mergeJson(filepath, mergeFn) {
|
|
|
4339
4421
|
return mergeFn(existing);
|
|
4340
4422
|
}
|
|
4341
4423
|
|
|
4342
|
-
|
|
4424
|
+
// writeClaudeCodeHook 配置 Claude Code 的 hook。
|
|
4425
|
+
// configBaseDir 缺省 process.cwd()(全局/项目级);传入 agent 的 workspace 则 per-agent:
|
|
4426
|
+
// settings.json + 凭证写进 <configBaseDir>/.claude,仅该 agent(以此为 cwd 启动)生效。
|
|
4427
|
+
// cloud=true 时不把明文 token 写进 settings.local.json —— 云端 hook 运行时直接读
|
|
4428
|
+
// 环境变量 COZE_API_TOKEN(见 scripts/claude-code/cozeloop_hook.py 的 get_fresh_token)。
|
|
4429
|
+
function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cloud) {
|
|
4343
4430
|
const hooksDir = path.join(os.homedir(), '.claude', 'hooks');
|
|
4344
4431
|
const hookScript = path.join(hooksDir, 'cozeloop_hook.py');
|
|
4345
|
-
const
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4432
|
+
const baseDir = configBaseDir || process.cwd();
|
|
4433
|
+
// per-agent 时 settings.json 写进 agent 的 .claude;全局时仍写 ~/.claude/settings.json。
|
|
4434
|
+
const claudeDir = configBaseDir
|
|
4435
|
+
? path.join(baseDir, '.claude')
|
|
4436
|
+
: path.join(os.homedir(), '.claude');
|
|
4437
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
4438
|
+
const localSettingsPath = path.join(baseDir, '.claude', 'settings.local.json');
|
|
4439
|
+
|
|
4440
|
+
// 1. Write Python hook scripts (trace + refresh) — 脚本放全局 ~/.claude/hooks,可共享
|
|
4349
4441
|
ensureDir(hooksDir);
|
|
4350
4442
|
writeHookScript(hookScript, readScript('claude-code/cozeloop_hook.py'));
|
|
4351
4443
|
const refreshScript = path.join(hooksDir, 'cozeloop_refresh.py');
|
|
4352
4444
|
writeHookScript(refreshScript, readScript('shared/cozeloop_refresh.py'));
|
|
4353
4445
|
|
|
4354
|
-
// 2. Merge
|
|
4355
|
-
const hookCmd = `${pythonCmd}
|
|
4356
|
-
const refreshCmd = `${pythonCmd}
|
|
4446
|
+
// 2. Merge settings.json — Stop (trace) + UserPromptSubmit (refresh)。用绝对路径。
|
|
4447
|
+
const hookCmd = `${pythonCmd} ${hookScript}`;
|
|
4448
|
+
const refreshCmd = `${pythonCmd} ${refreshScript}`;
|
|
4357
4449
|
|
|
4450
|
+
ensureDir(claudeDir);
|
|
4358
4451
|
const settings = mergeJson(settingsPath, (existing) => {
|
|
4359
4452
|
if (!existing.hooks) existing.hooks = {};
|
|
4360
4453
|
|
|
@@ -4379,14 +4472,23 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
|
|
|
4379
4472
|
} catch (e) {
|
|
4380
4473
|
errorBox([`ERROR: Cannot write ${settingsPath}`, '', e.message]);
|
|
4381
4474
|
}
|
|
4382
|
-
ok(`
|
|
4475
|
+
ok(`Hook registered in ${settingsPath}`);
|
|
4383
4476
|
|
|
4384
|
-
// 3. Write
|
|
4385
|
-
ensureDir(path.join(
|
|
4477
|
+
// 3. Write credentials into <baseDir>/.claude/settings.local.json
|
|
4478
|
+
ensureDir(path.join(baseDir, '.claude'));
|
|
4386
4479
|
const localSettings = mergeJson(localSettingsPath, (existing) => {
|
|
4387
4480
|
if (!existing.env) existing.env = {};
|
|
4388
4481
|
existing.env.COZELOOP_WORKSPACE_ID = workspaceId;
|
|
4389
|
-
|
|
4482
|
+
// 云端:token 由 sandbox 注入到环境变量 COZE_API_TOKEN,hook 运行时直接读,
|
|
4483
|
+
// 不在配置文件落明文 token。本地:写入 OAuth 拿到的 token。
|
|
4484
|
+
if (cloud) {
|
|
4485
|
+
delete existing.env.COZELOOP_API_TOKEN;
|
|
4486
|
+
} else {
|
|
4487
|
+
existing.env.COZELOOP_API_TOKEN = patToken;
|
|
4488
|
+
}
|
|
4489
|
+
// PPE 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
|
|
4490
|
+
existing.env.x_tt_env = PPE_TT_ENV;
|
|
4491
|
+
existing.env.x_use_ppe = PPE_USE_PPE;
|
|
4390
4492
|
return existing;
|
|
4391
4493
|
});
|
|
4392
4494
|
try {
|
|
@@ -4400,11 +4502,16 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
|
|
|
4400
4502
|
return { hookScript, settingsPath, localSettingsPath };
|
|
4401
4503
|
}
|
|
4402
4504
|
|
|
4403
|
-
|
|
4404
|
-
|
|
4505
|
+
// writeCodexHook 把 hook 写进指定的 CODEX_HOME。codexHome 缺省 ~/.codex;
|
|
4506
|
+
// 传入动态目录(如 coze-bridge 的 /tmp/coze-bridge-codex-home-xxx)即可 per-agent 生效。
|
|
4507
|
+
// cloud=true 时 cozeloop.env 不写死 token —— 云端 hook 运行时直接读环境变量
|
|
4508
|
+
// COZE_API_TOKEN(见 scripts/codex/cozeloop_hook.py 的 get_fresh_token)。
|
|
4509
|
+
function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
|
|
4510
|
+
const home = codexHome || path.join(os.homedir(), '.codex');
|
|
4511
|
+
const hooksDir = path.join(home, 'hooks');
|
|
4405
4512
|
const hookScript = path.join(hooksDir, 'cozeloop_hook.py');
|
|
4406
4513
|
const envFile = path.join(hooksDir, 'cozeloop.env');
|
|
4407
|
-
const hooksJson = path.join(
|
|
4514
|
+
const hooksJson = path.join(home, 'hooks.json');
|
|
4408
4515
|
|
|
4409
4516
|
// 1. Write Python hook scripts (trace + refresh)
|
|
4410
4517
|
// Token is read from ~/.cozeloop/credentials.json at runtime
|
|
@@ -4414,11 +4521,18 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
|
|
|
4414
4521
|
writeHookScript(refreshScript, readScript('shared/cozeloop_refresh.py'));
|
|
4415
4522
|
|
|
4416
4523
|
// 2. Write env file with chmod 600
|
|
4417
|
-
|
|
4524
|
+
// 云端(cloud):不落明文 token,hook 运行时从环境变量 COZE_API_TOKEN 读取。
|
|
4525
|
+
const envLines = [
|
|
4418
4526
|
`COZELOOP_WORKSPACE_ID=${workspaceId}`,
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4527
|
+
];
|
|
4528
|
+
if (!cloud) {
|
|
4529
|
+
envLines.push(`COZELOOP_API_TOKEN=${token}`);
|
|
4530
|
+
}
|
|
4531
|
+
envLines.push('TRACE_TO_COZELOOP=true');
|
|
4532
|
+
// PPE 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
|
|
4533
|
+
envLines.push(`x_tt_env=${PPE_TT_ENV}`);
|
|
4534
|
+
envLines.push(`x_use_ppe=${PPE_USE_PPE}`);
|
|
4535
|
+
const envContent = envLines.join('\n') + '\n';
|
|
4422
4536
|
try {
|
|
4423
4537
|
fs.writeFileSync(envFile, envContent, { mode: 0o600 });
|
|
4424
4538
|
} catch (e) {
|
|
@@ -4427,8 +4541,9 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
|
|
|
4427
4541
|
ok(`Credentials written to ${envFile} (chmod 600)`);
|
|
4428
4542
|
|
|
4429
4543
|
// 3. Merge hooks.json — Stop (trace) + SessionStart (refresh)
|
|
4430
|
-
|
|
4431
|
-
const
|
|
4544
|
+
// 命令用绝对路径(CODEX_HOME 不一定是 ~/.codex)。
|
|
4545
|
+
const hookCmd = `set -a && . ${envFile} && set +a && ${pythonCmd} ${hookScript}`;
|
|
4546
|
+
const refreshCmd = `${pythonCmd} ${refreshScript}`;
|
|
4432
4547
|
|
|
4433
4548
|
const hooks = mergeJson(hooksJson, (existing) => {
|
|
4434
4549
|
if (!existing.hooks) existing.hooks = {};
|
|
@@ -4455,12 +4570,16 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
|
|
|
4455
4570
|
errorBox([`ERROR: Cannot write ${hooksJson}`, '', e.message]);
|
|
4456
4571
|
}
|
|
4457
4572
|
ok(`Hook registered in ${hooksJson}`);
|
|
4458
|
-
warn('Codex hook trust:
|
|
4573
|
+
warn('Codex hook trust: 首次启动 Codex 会提示 "Hooks need review"。在该提示按 t(Trust all and continue),或在会话内运行 /hooks 后按 t,即可一次性信任全部 hook 启用 trace 上报。');
|
|
4459
4574
|
|
|
4460
4575
|
return { hookScript, envFile, hooksJson };
|
|
4461
4576
|
}
|
|
4462
4577
|
|
|
4463
|
-
|
|
4578
|
+
// writeOpenClawHook 配置 OpenClaw 的 cozeloop-trace 插件(全局装在 ~/.openclaw)。
|
|
4579
|
+
// agentId 非空时并入 plugins.entries[...].config.traceAgentIds allowlist —— 插件运行时
|
|
4580
|
+
// 用 resolveAgentIdFromHookCtx 取当前 agentId,仅 allowlist 内的 agent 才上报 trace。
|
|
4581
|
+
// allowlist 为空(本地全局模式)= 全部放行,向后兼容。
|
|
4582
|
+
function writeOpenClawHook(token, workspaceId, agentId) {
|
|
4464
4583
|
const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');
|
|
4465
4584
|
const pluginDir = path.join(os.homedir(), '.cozeloop', 'openclaw-plugin');
|
|
4466
4585
|
|
|
@@ -4538,12 +4657,25 @@ function writeOpenClawHook(token, workspaceId) {
|
|
|
4538
4657
|
existing.plugins.entries[PLUGIN].enabled = true;
|
|
4539
4658
|
// hooks.allowConversationAccess required for 2026.5+ to access session content
|
|
4540
4659
|
existing.plugins.entries[PLUGIN].hooks = { allowConversationAccess: true };
|
|
4541
|
-
existing.plugins.entries[PLUGIN].config = {
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4660
|
+
if (!existing.plugins.entries[PLUGIN].config) existing.plugins.entries[PLUGIN].config = {};
|
|
4661
|
+
const pcfg = existing.plugins.entries[PLUGIN].config;
|
|
4662
|
+
pcfg.authorization = `Bearer ${token}`;
|
|
4663
|
+
pcfg.endpoint = 'https://api.coze.cn/v1/loop/opentelemetry';
|
|
4664
|
+
pcfg.workspaceId = workspaceId;
|
|
4665
|
+
pcfg.debug = true;
|
|
4666
|
+
// per-agent trace 放行:把当前 agentId 并入 traceAgentIds(去重、归一为小写,
|
|
4667
|
+
// 与插件侧 resolveAgentIdFromHookCtx 的归一一致)。无 agentId(全局模式)则不动
|
|
4668
|
+
// allowlist —— 空 allowlist 表示全部放行。
|
|
4669
|
+
if (agentId) {
|
|
4670
|
+
const norm = String(agentId).trim().toLowerCase();
|
|
4671
|
+
// 读 existing 时一并归一(小写、去空),与插件侧 resolveAgentIdFromHookCtx 一致,
|
|
4672
|
+
// 防手工编辑混入大写条目导致去重失效。
|
|
4673
|
+
const list = (Array.isArray(pcfg.traceAgentIds) ? pcfg.traceAgentIds : [])
|
|
4674
|
+
.map((s) => String(s).trim().toLowerCase())
|
|
4675
|
+
.filter(Boolean);
|
|
4676
|
+
if (norm && !list.includes(norm)) list.push(norm);
|
|
4677
|
+
pcfg.traceAgentIds = list;
|
|
4678
|
+
}
|
|
4547
4679
|
return existing;
|
|
4548
4680
|
});
|
|
4549
4681
|
|
|
@@ -4553,19 +4685,33 @@ function writeOpenClawHook(token, workspaceId) {
|
|
|
4553
4685
|
errorBox([`ERROR: Cannot write ${configPath}`, '', e.message]);
|
|
4554
4686
|
}
|
|
4555
4687
|
ok(`OpenClaw plugin configured in ${configPath}`);
|
|
4688
|
+
|
|
4689
|
+
// 自动重启 gateway,让新插件配置立即生效(不依赖 plugins install 的隐式重启)。
|
|
4690
|
+
try {
|
|
4691
|
+
const { execSync } = require('child_process');
|
|
4692
|
+
info('Restarting OpenClaw gateway to apply hook changes...');
|
|
4693
|
+
execSync('openclaw gateway restart', { stdio: 'pipe' });
|
|
4694
|
+
ok('OpenClaw gateway restarted');
|
|
4695
|
+
} catch (e) {
|
|
4696
|
+
warn(`gateway restart 失败,请手动执行: openclaw gateway restart(${e.message})`);
|
|
4697
|
+
}
|
|
4698
|
+
|
|
4556
4699
|
return { configPath, pluginDir };
|
|
4557
4700
|
}
|
|
4558
4701
|
|
|
4559
4702
|
// ─── 8. Auth — Device Code OAuth + token store ───────────────────────────────
|
|
4560
4703
|
const https = require('https');
|
|
4704
|
+
const crypto = require('crypto');
|
|
4561
4705
|
|
|
4562
|
-
function httpsPost(url, body) {
|
|
4706
|
+
function httpsPost(url, body, extraHeaders) {
|
|
4563
4707
|
return new Promise((resolve, reject) => {
|
|
4564
4708
|
const data = JSON.stringify(body);
|
|
4565
4709
|
const u = new URL(url);
|
|
4566
4710
|
const req = https.request(
|
|
4567
4711
|
{ hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
|
|
4568
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data)
|
|
4712
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data),
|
|
4713
|
+
'x-tt-env': PPE_TT_ENV, 'x-use-ppe': PPE_USE_PPE,
|
|
4714
|
+
...(extraHeaders || {}) } },
|
|
4569
4715
|
(res) => {
|
|
4570
4716
|
let buf = '';
|
|
4571
4717
|
res.on('data', c => buf += c);
|
|
@@ -4579,9 +4725,71 @@ function httpsPost(url, body) {
|
|
|
4579
4725
|
});
|
|
4580
4726
|
}
|
|
4581
4727
|
|
|
4728
|
+
// 真实发一条最小 OTLP trace 到 CozeLoop,验证上报链路是否打通。
|
|
4729
|
+
// 只看 HTTP 状态码(2xx=通),不回查 trace 是否落库——回查由外部查询方完成。
|
|
4730
|
+
// pairCode 写进 span 的 pair_code attribute,供查询方按该字段过滤回查;缺省自动生成。
|
|
4731
|
+
// 不在函数内退出,退出行为交给调用方(主流程 Step 6 / 独立命令 --verify)。
|
|
4732
|
+
async function verifyTraceReport(token, workspaceId, pairCode) {
|
|
4733
|
+
const traceId = crypto.randomBytes(16).toString('hex'); // 32 hex chars
|
|
4734
|
+
const spanId = crypto.randomBytes(8).toString('hex'); // 16 hex chars
|
|
4735
|
+
const nowNs = String(Date.now() * 1_000_000); // OTLP 要求纳秒 unix 时间(字符串)
|
|
4736
|
+
// 缺省自动生成 12 位 hex 配对码;查询方按 field_name="pair_code" 回查。
|
|
4737
|
+
const pair = pairCode || crypto.randomBytes(6).toString('hex');
|
|
4738
|
+
|
|
4739
|
+
const otlpBody = {
|
|
4740
|
+
resourceSpans: [{
|
|
4741
|
+
resource: {
|
|
4742
|
+
attributes: [
|
|
4743
|
+
{ key: 'service.name', value: { stringValue: 'cozelab-onboard' } },
|
|
4744
|
+
],
|
|
4745
|
+
},
|
|
4746
|
+
scopeSpans: [{
|
|
4747
|
+
scope: { name: 'cozelab-onboard-selfcheck' },
|
|
4748
|
+
spans: [{
|
|
4749
|
+
traceId,
|
|
4750
|
+
spanId,
|
|
4751
|
+
name: 'cozelab-onboard-selfcheck',
|
|
4752
|
+
kind: 1,
|
|
4753
|
+
startTimeUnixNano: nowNs,
|
|
4754
|
+
endTimeUnixNano: nowNs,
|
|
4755
|
+
status: { code: 1 },
|
|
4756
|
+
attributes: [
|
|
4757
|
+
{ key: 'pair_code', value: { stringValue: pair } },
|
|
4758
|
+
],
|
|
4759
|
+
}],
|
|
4760
|
+
}],
|
|
4761
|
+
}],
|
|
4762
|
+
};
|
|
4763
|
+
|
|
4764
|
+
let res;
|
|
4765
|
+
try {
|
|
4766
|
+
res = await httpsPost(
|
|
4767
|
+
`${COZE_API}/v1/loop/opentelemetry/v1/traces`,
|
|
4768
|
+
otlpBody,
|
|
4769
|
+
{ Authorization: `Bearer ${token}`, 'cozeloop-workspace-id': workspaceId },
|
|
4770
|
+
);
|
|
4771
|
+
} catch (e) {
|
|
4772
|
+
warn(`trace 上报失败: ${e.message}`);
|
|
4773
|
+
return { success: false, status: 0, body: e.message, traceId, pairCode: pair };
|
|
4774
|
+
}
|
|
4775
|
+
|
|
4776
|
+
const success = res.status >= 200 && res.status < 300;
|
|
4777
|
+
if (success) {
|
|
4778
|
+
ok(`trace 上报成功 (traceId=${traceId}, pair_code=${pair})`);
|
|
4779
|
+
info(`查询方可用 pair_code=${pair} 在 CozeLoop 回查确认该 trace 已落库。`);
|
|
4780
|
+
} else {
|
|
4781
|
+
warn(`trace 上报失败: HTTP ${res.status}`);
|
|
4782
|
+
const snippet = (res.body || '').slice(0, 300);
|
|
4783
|
+
if (snippet) console.log(snippet);
|
|
4784
|
+
}
|
|
4785
|
+
return { success, status: res.status, body: res.body, traceId, pairCode: pair };
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4582
4788
|
function httpsGet(url, headers) {
|
|
4583
4789
|
return new Promise((resolve, reject) => {
|
|
4584
|
-
|
|
4790
|
+
// 合并 PPE 泳道 header
|
|
4791
|
+
const h = { ...(headers || {}), 'x-tt-env': PPE_TT_ENV, 'x-use-ppe': PPE_USE_PPE };
|
|
4792
|
+
const req = https.get(url, { headers: h }, (res) => {
|
|
4585
4793
|
let buf = '';
|
|
4586
4794
|
res.on('data', c => buf += c);
|
|
4587
4795
|
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
|
@@ -4834,22 +5042,11 @@ function authStatus() {
|
|
|
4834
5042
|
box(lines, statusColor);
|
|
4835
5043
|
}
|
|
4836
5044
|
|
|
4837
|
-
// ── Verify token is usable ────────────────────────────────────────────────
|
|
4838
|
-
async function verifyWorkspace(token) {
|
|
4839
|
-
info('验证 Token 有效性...');
|
|
4840
|
-
// Token validity is guaranteed by the Device Code OAuth flow.
|
|
4841
|
-
// We skip a separate API call here since the OAuth app may not have
|
|
4842
|
-
// Account.listWorkspace permission — the token itself is the proof.
|
|
4843
|
-
if (!token) return null;
|
|
4844
|
-
ok(`Token 有效,工作空间 ${WORKSPACE_ID} 确认。`);
|
|
4845
|
-
return token;
|
|
4846
|
-
}
|
|
4847
|
-
|
|
4848
5045
|
// ─── 9. Main ─────────────────────────────────────────────────────────────────
|
|
4849
5046
|
const NEXT_STEP = {
|
|
4850
|
-
'claude-code': '
|
|
4851
|
-
'codex': '
|
|
4852
|
-
'openclaw': '
|
|
5047
|
+
'claude-code': 'Hook 已写入。Claude Code 会自动热重载 hooks,当前会话即刻生效,无需 /new 或重启。',
|
|
5048
|
+
'codex': 'Hook 已写入。Codex 在会话启动时加载 hook,当前会话不会即时生效;请重开 Codex 会话(已配置 SessionStart hook,新会话自动加载)。',
|
|
5049
|
+
'openclaw': 'OpenClaw gateway 已自动重启,trace 即刻生效。',
|
|
4853
5050
|
};
|
|
4854
5051
|
|
|
4855
5052
|
async function main() {
|
|
@@ -4914,24 +5111,53 @@ async function main() {
|
|
|
4914
5111
|
return;
|
|
4915
5112
|
}
|
|
4916
5113
|
|
|
4917
|
-
|
|
5114
|
+
if (args.verify) {
|
|
5115
|
+
info('验证 trace 上报链路...');
|
|
5116
|
+
const token = await getValidToken(); // 无凭证会自动走登录/刷新
|
|
5117
|
+
console.log('');
|
|
5118
|
+
const result = await verifyTraceReport(token, WORKSPACE_ID, args.pairCode);
|
|
5119
|
+
process.exit(result.success ? 0 : 1);
|
|
5120
|
+
}
|
|
4918
5121
|
|
|
4919
|
-
|
|
4920
|
-
//
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
5122
|
+
const { agent } = args;
|
|
5123
|
+
// 云端模式:开启结构化输出 + errorBox 抛异常(而非 exit)。
|
|
5124
|
+
CLOUD_MODE = !!args.cloud;
|
|
5125
|
+
// per-agent 路由时 agent 的 workspace(claude-code 用它做配置根目录)。
|
|
5126
|
+
const agentWorkspace = args.workspace || '';
|
|
5127
|
+
if (args.agentId) {
|
|
5128
|
+
info(`目标 agent: ${args.agentId} (framework=${agent}, workspace=${agentWorkspace || 'N/A'})`);
|
|
5129
|
+
console.log('');
|
|
5130
|
+
}
|
|
4924
5131
|
|
|
4925
|
-
|
|
5132
|
+
// Step 1: Authorize.
|
|
5133
|
+
// 云端(--cloud):token 取自环境变量 COZE_API_TOKEN,跳过 OAuth / credentials.json。
|
|
5134
|
+
// 本地:load cached → refresh → device code。
|
|
5135
|
+
let token;
|
|
5136
|
+
if (args.cloud) {
|
|
5137
|
+
info('Step 1/5: 云端模式,从环境变量 COZE_API_TOKEN 读取 token...');
|
|
5138
|
+
token = process.env.COZE_API_TOKEN;
|
|
5139
|
+
if (!token) {
|
|
5140
|
+
errorBox([
|
|
5141
|
+
'ERROR: --cloud 模式要求环境变量 COZE_API_TOKEN',
|
|
5142
|
+
'',
|
|
5143
|
+
'云端 sandbox 应在进程环境中注入 COZE_API_TOKEN。',
|
|
5144
|
+
'未检测到该变量,无法配置 trace 上报。',
|
|
5145
|
+
]);
|
|
5146
|
+
}
|
|
5147
|
+
ok('已从环境变量读取 COZE_API_TOKEN');
|
|
5148
|
+
} else {
|
|
5149
|
+
info('Step 1/5: 检查授权状态...');
|
|
5150
|
+
token = await getValidToken();
|
|
5151
|
+
}
|
|
4926
5152
|
console.log('');
|
|
4927
5153
|
|
|
4928
|
-
// Step
|
|
4929
|
-
info('Step
|
|
5154
|
+
// Step 2: Detect agent binary
|
|
5155
|
+
info('Step 2/5: Detecting agent...');
|
|
4930
5156
|
const version = detectAgent(agent);
|
|
4931
5157
|
console.log('');
|
|
4932
5158
|
|
|
4933
|
-
// Step
|
|
4934
|
-
info('Step
|
|
5159
|
+
// Step 3: Environment checks
|
|
5160
|
+
info('Step 3/5: Checking environment...');
|
|
4935
5161
|
let pythonCmd = null;
|
|
4936
5162
|
if (agent !== 'openclaw') {
|
|
4937
5163
|
pythonCmd = checkPython();
|
|
@@ -4940,29 +5166,48 @@ async function main() {
|
|
|
4940
5166
|
checkVersionWhitelist(agent, version);
|
|
4941
5167
|
console.log('');
|
|
4942
5168
|
|
|
4943
|
-
// Step
|
|
4944
|
-
info('Step
|
|
5169
|
+
// Step 4: Write hook configuration
|
|
5170
|
+
info('Step 4/5: Writing hook configuration...');
|
|
5171
|
+
let written = {};
|
|
4945
5172
|
if (agent === 'claude-code') {
|
|
4946
|
-
|
|
5173
|
+
// per-agent 路由时把配置写进 agent 的 workspace(仅该 agent 生效)。
|
|
5174
|
+
// 云端按 agentId 路由但 workspace 为空会导致 settings.json 落 ~/.claude、
|
|
5175
|
+
// settings.local.json 落进程 cwd —— per-agent 隔离失效。此时显式报错。
|
|
5176
|
+
if (args.agentId && !agentWorkspace) {
|
|
5177
|
+
errorBox([
|
|
5178
|
+
`ERROR: agent "${args.agentId}" 的 config.json 缺少 workspace 字段`,
|
|
5179
|
+
'',
|
|
5180
|
+
'claude-code per-agent 配置需要明确的 workspace 目录,',
|
|
5181
|
+
'否则 hook 配置会落到全局/进程目录,无法做到 per-agent 隔离。',
|
|
5182
|
+
]);
|
|
5183
|
+
}
|
|
5184
|
+
written = writeClaudeCodeHook(token, WORKSPACE_ID, pythonCmd, args.agentId ? agentWorkspace : undefined, args.cloud);
|
|
4947
5185
|
} else if (agent === 'codex') {
|
|
4948
|
-
|
|
5186
|
+
// CODEX_HOME 来源优先级:--codex-home= > 环境变量 CODEX_HOME > ~/.codex(缺省)。
|
|
5187
|
+
const codexHome = args['codex-home'] || process.env.CODEX_HOME || undefined;
|
|
5188
|
+
if (codexHome) info(`Codex 配置目录: ${codexHome}`);
|
|
5189
|
+
written = writeCodexHook(token, WORKSPACE_ID, pythonCmd, codexHome, args.cloud);
|
|
4949
5190
|
} else {
|
|
4950
|
-
|
|
5191
|
+
// openclaw:云端用 traceAgentIds allowlist 做 per-agent 放行。
|
|
5192
|
+
written = writeOpenClawHook(token, WORKSPACE_ID, args.agentId) || {};
|
|
4951
5193
|
}
|
|
5194
|
+
// 走到这里说明 detectAgent / 环境检查 / 写 hook 配置全部成功 → 注入成功。
|
|
5195
|
+
cloudResult.inject = 'ok';
|
|
4952
5196
|
console.log('');
|
|
4953
5197
|
|
|
4954
|
-
// Success summary
|
|
5198
|
+
// Success summary(用实际写入路径,per-agent / 自定义 CODEX_HOME 时也准确)
|
|
4955
5199
|
const summaryLines = [
|
|
4956
5200
|
`Agent: ${agent} v${version}`,
|
|
4957
5201
|
];
|
|
5202
|
+
if (args.agentId) summaryLines.push(`Agent ID: ${args.agentId}`);
|
|
4958
5203
|
if (agent === 'claude-code') {
|
|
4959
|
-
summaryLines.push(`Hook script: ~/.claude/hooks/cozeloop_hook.py`);
|
|
4960
|
-
summaryLines.push(`Config: ~/.claude/settings.json`);
|
|
4961
|
-
summaryLines.push(`Credentials: .claude/settings.local.json`);
|
|
5204
|
+
summaryLines.push(`Hook script: ${written.hookScript || '~/.claude/hooks/cozeloop_hook.py'}`);
|
|
5205
|
+
summaryLines.push(`Config: ${written.settingsPath || '~/.claude/settings.json'}`);
|
|
5206
|
+
summaryLines.push(`Credentials: ${written.localSettingsPath || '.claude/settings.local.json'}`);
|
|
4962
5207
|
} else if (agent === 'codex') {
|
|
4963
|
-
summaryLines.push(`Hook script: ~/.codex/hooks/cozeloop_hook.py`);
|
|
4964
|
-
summaryLines.push(`Config: ~/.codex/hooks.json`);
|
|
4965
|
-
summaryLines.push(`Credentials: ~/.codex/hooks/cozeloop.env`);
|
|
5208
|
+
summaryLines.push(`Hook script: ${written.hookScript || '~/.codex/hooks/cozeloop_hook.py'}`);
|
|
5209
|
+
summaryLines.push(`Config: ${written.hooksJson || '~/.codex/hooks.json'}`);
|
|
5210
|
+
summaryLines.push(`Credentials: ${written.envFile || '~/.codex/hooks/cozeloop.env'}`);
|
|
4966
5211
|
} else {
|
|
4967
5212
|
summaryLines.push(`Config: ~/.openclaw/openclaw.json`);
|
|
4968
5213
|
}
|
|
@@ -4979,9 +5224,52 @@ async function main() {
|
|
|
4979
5224
|
info('确保 CozeLoop Token 具备 Trace ingest 权限,否则 hook 触发但上报会失败。');
|
|
4980
5225
|
console.log('');
|
|
4981
5226
|
|
|
5227
|
+
// Step 5: Verify trace reporting end-to-end
|
|
5228
|
+
info('Step 5/5: 验证 trace 上报链路...');
|
|
5229
|
+
const verifyResult = await verifyTraceReport(token, WORKSPACE_ID, args.pairCode);
|
|
5230
|
+
if (verifyResult.success) {
|
|
5231
|
+
cloudResult.verify = 'ok';
|
|
5232
|
+
} else if (CLOUD_MODE) {
|
|
5233
|
+
// 云端:注入已成功,验证失败不阻断(放行),记录结果供后台弹 warning。
|
|
5234
|
+
cloudResult.verify = 'fail';
|
|
5235
|
+
cloudResult.logid = extractLogid(verifyResult.body) || cloudResult.logid;
|
|
5236
|
+
cloudResult.message = `trace 上报自检失败 HTTP ${verifyResult.status}: ${(verifyResult.body || '').slice(0, 200)}`;
|
|
5237
|
+
warn('trace 上报自检失败,但 hook 配置已写入(云端放行)。');
|
|
5238
|
+
console.log('');
|
|
5239
|
+
emitCloudResult();
|
|
5240
|
+
successBox(summaryLines);
|
|
5241
|
+
return;
|
|
5242
|
+
} else {
|
|
5243
|
+
errorBox([
|
|
5244
|
+
'ERROR: trace 上报自检失败',
|
|
5245
|
+
'',
|
|
5246
|
+
`HTTP ${verifyResult.status}`,
|
|
5247
|
+
(verifyResult.body || '').slice(0, 300),
|
|
5248
|
+
'',
|
|
5249
|
+
'hook 配置已写入,但上报链路未打通。',
|
|
5250
|
+
'请确认 CozeLoop Token 具备 Trace ingest 权限,再重试。',
|
|
5251
|
+
]); // errorBox 内部 process.exit(1)
|
|
5252
|
+
}
|
|
5253
|
+
console.log('');
|
|
5254
|
+
|
|
5255
|
+
emitCloudResult();
|
|
4982
5256
|
successBox(summaryLines);
|
|
4983
5257
|
}
|
|
4984
5258
|
|
|
4985
5259
|
main().catch(e => {
|
|
5260
|
+
// 云端模式:失败时输出结构化结果(含 logid),exit 非 0 但带 COZE_LAB_RESULT 行。
|
|
5261
|
+
if (CLOUD_MODE) {
|
|
5262
|
+
if (cloudResult.inject !== 'ok') {
|
|
5263
|
+
// 写 hook 配置前的任何失败 → 注入失败。
|
|
5264
|
+
cloudResult.inject = 'fail';
|
|
5265
|
+
} else if (cloudResult.verify === 'skip') {
|
|
5266
|
+
// 注入已成功,但验证阶段异常崩溃(未走到正常的 ok/fail 判定)→ 记为验证失败。
|
|
5267
|
+
cloudResult.verify = 'fail';
|
|
5268
|
+
}
|
|
5269
|
+
if (!cloudResult.message) cloudResult.message = e && e.message ? e.message : 'unexpected failure';
|
|
5270
|
+
if (!cloudResult.logid) cloudResult.logid = extractLogid(cloudResult.message);
|
|
5271
|
+
emitCloudResult();
|
|
5272
|
+
process.exit(1);
|
|
5273
|
+
}
|
|
4986
5274
|
errorBox(['ERROR: Unexpected failure', '', e.message]);
|
|
4987
5275
|
});
|
package/package.json
CHANGED
|
@@ -224,7 +224,8 @@ def get_fresh_token() -> Optional[str]:
|
|
|
224
224
|
if new_token:
|
|
225
225
|
return new_token
|
|
226
226
|
debug_log("Refresh failed, falling back to env var.")
|
|
227
|
-
|
|
227
|
+
# Cloud sandbox: token lives in COZE_API_TOKEN (no credentials.json / refresh).
|
|
228
|
+
return os.environ.get("COZELOOP_API_TOKEN") or os.environ.get("COZE_API_TOKEN")
|
|
228
229
|
|
|
229
230
|
# -------------------------------------------------------------------------
|
|
230
231
|
|
|
@@ -431,6 +432,29 @@ def _group_subagent_steps(progress_msgs: List[Dict[str, Any]]) -> List[Dict[str,
|
|
|
431
432
|
return steps
|
|
432
433
|
|
|
433
434
|
|
|
435
|
+
def _parse_ts(msg):
|
|
436
|
+
"""从 transcript msg 的 timestamp 字段解析出 datetime(带时区)。失败返回 None。
|
|
437
|
+
|
|
438
|
+
Claude Code transcript 每条 user/assistant 消息都带 ISO8601 timestamp
|
|
439
|
+
(如 "2026-06-01T13:51:55.450Z")。建 span 时用它做 start_time,
|
|
440
|
+
避免回放时所有 span 挤在几毫秒内、duration 全是几毫秒。
|
|
441
|
+
"""
|
|
442
|
+
if not isinstance(msg, dict):
|
|
443
|
+
return None
|
|
444
|
+
ts = msg.get("timestamp")
|
|
445
|
+
if not ts or not isinstance(ts, str):
|
|
446
|
+
return None
|
|
447
|
+
try:
|
|
448
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
449
|
+
except Exception:
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _ts_ms(dt):
|
|
454
|
+
"""datetime → 毫秒时间戳(int);None → None。"""
|
|
455
|
+
return int(dt.timestamp() * 1000) if dt is not None else None
|
|
456
|
+
|
|
457
|
+
|
|
434
458
|
def group_messages_into_turns(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
435
459
|
"""Group messages into conversation turns (user -> assistant -> tool_results).
|
|
436
460
|
|
|
@@ -793,13 +817,31 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
793
817
|
client = cozeloop.new_client(**client_kwargs)
|
|
794
818
|
|
|
795
819
|
try:
|
|
796
|
-
|
|
820
|
+
# 整体时间范围:第一条 user 消息 → 最后一条 assistant 消息(真实 transcript 时间)
|
|
821
|
+
_all_ts = []
|
|
822
|
+
for _t in turns:
|
|
823
|
+
_u = _parse_ts(_t.get("user_message"))
|
|
824
|
+
if _u:
|
|
825
|
+
_all_ts.append(_u)
|
|
826
|
+
for _s in _t.get("steps", []):
|
|
827
|
+
_a = _parse_ts(_s.get("assistant_message"))
|
|
828
|
+
if _a:
|
|
829
|
+
_all_ts.append(_a)
|
|
830
|
+
root_start_dt = min(_all_ts) if _all_ts else None
|
|
831
|
+
root_end_dt = max(_all_ts) if _all_ts else None
|
|
832
|
+
|
|
833
|
+
with client.start_span(name="claude_code_request", span_type="main", start_time=root_start_dt) as root_span:
|
|
797
834
|
root_span.set_runtime(Runtime(library="claude-code"))
|
|
798
835
|
root_tags = {
|
|
799
836
|
"thread_id": session_id,
|
|
800
837
|
"total_turns": len(turns),
|
|
801
838
|
"source": "claude_code"
|
|
802
839
|
}
|
|
840
|
+
# 真实耗时写 tag(SDK finish 不支持显式结束时间,duration 用 tag 兜底)
|
|
841
|
+
if root_start_dt is not None and root_end_dt is not None:
|
|
842
|
+
root_tags["real_start_ms"] = _ts_ms(root_start_dt)
|
|
843
|
+
root_tags["real_end_ms"] = _ts_ms(root_end_dt)
|
|
844
|
+
root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
|
|
803
845
|
root_baggage = {
|
|
804
846
|
"thread_id": session_id,
|
|
805
847
|
}
|
|
@@ -836,14 +878,29 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
836
878
|
steps = turn.get("steps", [])
|
|
837
879
|
total_steps = len(steps)
|
|
838
880
|
|
|
839
|
-
|
|
881
|
+
# turn 真实时间:start=user 消息时间,end=最后一个 step 的 assistant 时间
|
|
882
|
+
turn_start_dt = _parse_ts(turn.get("user_message"))
|
|
883
|
+
turn_end_dt = None
|
|
884
|
+
for _s in steps:
|
|
885
|
+
_a = _parse_ts(_s.get("assistant_message"))
|
|
886
|
+
if _a:
|
|
887
|
+
turn_end_dt = _a
|
|
888
|
+
if turn_start_dt is None:
|
|
889
|
+
turn_start_dt = turn_end_dt
|
|
890
|
+
|
|
891
|
+
with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
|
|
840
892
|
turn_span.set_runtime(Runtime(library="claude-code"))
|
|
841
|
-
|
|
893
|
+
_turn_tags = {
|
|
842
894
|
"thread_id": session_id,
|
|
843
895
|
"turn_index": i,
|
|
844
896
|
"total_steps": total_steps,
|
|
845
897
|
"source": "claude_code",
|
|
846
|
-
}
|
|
898
|
+
}
|
|
899
|
+
if turn_start_dt is not None and turn_end_dt is not None:
|
|
900
|
+
_turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
|
|
901
|
+
_turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
|
|
902
|
+
_turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
|
|
903
|
+
turn_span.set_tags(_turn_tags)
|
|
847
904
|
|
|
848
905
|
# Extract user input for this turn
|
|
849
906
|
user_message = turn.get("user_message", {}).get("message", {})
|
|
@@ -861,10 +918,23 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
861
918
|
raw_content = assistant_message_obj.get("content", [])
|
|
862
919
|
model_name = assistant_message_obj.get("model", "claude-code")
|
|
863
920
|
|
|
921
|
+
# step 真实时间:start=本 step assistant 时间;end=下一 step assistant 时间(即本次模型调用+工具的耗时),无下一 step 则用 turn_end
|
|
922
|
+
step_start_dt = _parse_ts(assistant_msg)
|
|
923
|
+
if j + 1 < len(steps):
|
|
924
|
+
step_end_dt = _parse_ts(steps[j + 1].get("assistant_message")) or turn_end_dt
|
|
925
|
+
else:
|
|
926
|
+
step_end_dt = turn_end_dt
|
|
927
|
+
|
|
864
928
|
# --- Create model span for this step ---
|
|
865
|
-
with client.start_span(name=f"model_call_{j}", span_type="model") as model_span:
|
|
929
|
+
with client.start_span(name=f"model_call_{j}", span_type="model", start_time=step_start_dt) as model_span:
|
|
866
930
|
model_span.set_runtime(Runtime(library="claude-code"))
|
|
867
931
|
model_span.set_model_name(model_name)
|
|
932
|
+
if step_start_dt is not None and step_end_dt is not None:
|
|
933
|
+
model_span.set_tags({
|
|
934
|
+
"real_start_ms": _ts_ms(step_start_dt),
|
|
935
|
+
"real_end_ms": _ts_ms(step_end_dt),
|
|
936
|
+
"latency_ms": _ts_ms(step_end_dt) - _ts_ms(step_start_dt),
|
|
937
|
+
})
|
|
868
938
|
|
|
869
939
|
# Set input: accumulated context up to this point
|
|
870
940
|
model_span.set_input(ModelInput(
|
|
@@ -958,7 +1028,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
|
|
|
958
1028
|
span_type = "agent" if is_agent else "tool"
|
|
959
1029
|
span_name = f"agent_{tool_name}" if is_agent else f"tool_{tool_name}"
|
|
960
1030
|
|
|
961
|
-
with client.start_span(name=span_name, span_type=span_type) as tool_span:
|
|
1031
|
+
with client.start_span(name=span_name, span_type=span_type, start_time=step_start_dt) as tool_span:
|
|
962
1032
|
tool_span.set_runtime(Runtime(library="claude-code"))
|
|
963
1033
|
tags = {
|
|
964
1034
|
"tool_name": tool_name,
|
|
@@ -197,7 +197,8 @@ def get_fresh_token():
|
|
|
197
197
|
new_token = _refresh_token(creds["refresh_token"])
|
|
198
198
|
if new_token:
|
|
199
199
|
return new_token
|
|
200
|
-
|
|
200
|
+
# Cloud sandbox: token lives in COZE_API_TOKEN (no credentials.json / refresh).
|
|
201
|
+
return os.environ.get("COZELOOP_API_TOKEN") or os.environ.get("COZE_API_TOKEN")
|
|
201
202
|
# -------------------------------------------------------------------------
|
|
202
203
|
|
|
203
204
|
# --- SDK Import ---
|
|
@@ -415,6 +416,28 @@ def truncate_text(text: str, limit: int = 12000) -> str:
|
|
|
415
416
|
|
|
416
417
|
# --- Message Grouping ---
|
|
417
418
|
|
|
419
|
+
def _parse_ts(obj):
|
|
420
|
+
"""从 codex entry/payload 的 timestamp 解析 datetime(带时区)。失败返回 None。
|
|
421
|
+
|
|
422
|
+
Codex rollout JSONL 每条 entry 顶层带 ISO8601 timestamp。建 span 时用它做
|
|
423
|
+
start_time,避免回放时所有 span 挤在几毫秒内。
|
|
424
|
+
"""
|
|
425
|
+
if not isinstance(obj, dict):
|
|
426
|
+
return None
|
|
427
|
+
ts = obj.get("timestamp") or obj.get("_ts")
|
|
428
|
+
if not ts or not isinstance(ts, str):
|
|
429
|
+
return None
|
|
430
|
+
try:
|
|
431
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
432
|
+
except Exception:
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _ts_ms(dt):
|
|
437
|
+
"""datetime → 毫秒时间戳(int);None → None。"""
|
|
438
|
+
return int(dt.timestamp() * 1000) if dt is not None else None
|
|
439
|
+
|
|
440
|
+
|
|
418
441
|
def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
419
442
|
"""Group raw JSONL entries into conversation turns.
|
|
420
443
|
|
|
@@ -437,6 +460,9 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
|
|
|
437
460
|
for entry in entries:
|
|
438
461
|
entry_type = entry.get("type")
|
|
439
462
|
payload = entry.get("payload", {})
|
|
463
|
+
# 把 entry 顶层 timestamp 注入 payload,供后续建 span 取真实时间
|
|
464
|
+
if isinstance(payload, dict) and entry.get("timestamp") and "_ts" not in payload:
|
|
465
|
+
payload["_ts"] = entry.get("timestamp")
|
|
440
466
|
|
|
441
467
|
# --- Turn lifecycle events ---
|
|
442
468
|
if entry_type == "event_msg":
|
|
@@ -645,13 +671,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
645
671
|
ctx: List[Dict[str, Any]] = list(history_context) if history_context else []
|
|
646
672
|
|
|
647
673
|
try:
|
|
648
|
-
|
|
674
|
+
# 整体时间范围:所有 user/assistant payload 的真实 timestamp 极值
|
|
675
|
+
_all_ts = []
|
|
676
|
+
for _t in turns:
|
|
677
|
+
_u = _parse_ts(_t.get("user_message"))
|
|
678
|
+
if _u:
|
|
679
|
+
_all_ts.append(_u)
|
|
680
|
+
for _a in _t.get("assistant_messages", []):
|
|
681
|
+
_ad = _parse_ts(_a)
|
|
682
|
+
if _ad:
|
|
683
|
+
_all_ts.append(_ad)
|
|
684
|
+
root_start_dt = min(_all_ts) if _all_ts else None
|
|
685
|
+
root_end_dt = max(_all_ts) if _all_ts else None
|
|
686
|
+
|
|
687
|
+
with client.start_span(name="codex_request", span_type="main", start_time=root_start_dt) as root_span:
|
|
649
688
|
root_span.set_runtime(Runtime(library="codex-cli"))
|
|
650
689
|
root_tags = {
|
|
651
690
|
"thread_id": session_id,
|
|
652
691
|
"total_turns": len(turns),
|
|
653
692
|
"source": "codex_cli",
|
|
654
693
|
}
|
|
694
|
+
if root_start_dt is not None and root_end_dt is not None:
|
|
695
|
+
root_tags["real_start_ms"] = _ts_ms(root_start_dt)
|
|
696
|
+
root_tags["real_end_ms"] = _ts_ms(root_end_dt)
|
|
697
|
+
root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
|
|
655
698
|
root_baggage = {
|
|
656
699
|
"thread_id": session_id,
|
|
657
700
|
}
|
|
@@ -689,20 +732,49 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
689
732
|
# Process each turn
|
|
690
733
|
for i, turn in enumerate(turns):
|
|
691
734
|
try:
|
|
692
|
-
|
|
735
|
+
# turn 真实时间:start=user payload 时间;end=最后一条 assistant payload 时间
|
|
736
|
+
turn_start_dt = _parse_ts(turn.get("user_message"))
|
|
737
|
+
turn_end_dt = None
|
|
738
|
+
for _a in turn.get("assistant_messages", []):
|
|
739
|
+
_ad = _parse_ts(_a)
|
|
740
|
+
if _ad:
|
|
741
|
+
turn_end_dt = _ad
|
|
742
|
+
if turn_start_dt is None:
|
|
743
|
+
turn_start_dt = turn_end_dt
|
|
744
|
+
|
|
745
|
+
with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
|
|
693
746
|
turn_span.set_runtime(Runtime(library="codex-cli"))
|
|
694
|
-
|
|
747
|
+
_turn_tags = {
|
|
695
748
|
"thread_id": session_id,
|
|
696
749
|
"turn_index": i,
|
|
697
750
|
"turn_id": turn.get("turn_id", ""),
|
|
698
751
|
"source": "codex_cli",
|
|
699
|
-
}
|
|
752
|
+
}
|
|
753
|
+
if turn_start_dt is not None and turn_end_dt is not None:
|
|
754
|
+
_turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
|
|
755
|
+
_turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
|
|
756
|
+
_turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
|
|
757
|
+
turn_span.set_tags(_turn_tags)
|
|
700
758
|
|
|
701
759
|
# --- Model span for assistant response ---
|
|
702
760
|
if turn.get("assistant_messages"):
|
|
703
|
-
|
|
761
|
+
# model span start:第一条 assistant payload 时间,回退到 turn 起点
|
|
762
|
+
_model_start_dt = None
|
|
763
|
+
for _a in turn.get("assistant_messages", []):
|
|
764
|
+
_model_start_dt = _parse_ts(_a)
|
|
765
|
+
if _model_start_dt:
|
|
766
|
+
break
|
|
767
|
+
if _model_start_dt is None:
|
|
768
|
+
_model_start_dt = turn_start_dt
|
|
769
|
+
with client.start_span(name="assistant_response", span_type="model", start_time=_model_start_dt) as model_span:
|
|
704
770
|
model_span.set_runtime(Runtime(library="codex-cli"))
|
|
705
771
|
model_span.set_model_name(model_name)
|
|
772
|
+
if _model_start_dt is not None and turn_end_dt is not None:
|
|
773
|
+
model_span.set_tags({
|
|
774
|
+
"real_start_ms": _ts_ms(_model_start_dt),
|
|
775
|
+
"real_end_ms": _ts_ms(turn_end_dt),
|
|
776
|
+
"latency_ms": _ts_ms(turn_end_dt) - _ts_ms(_model_start_dt),
|
|
777
|
+
})
|
|
706
778
|
|
|
707
779
|
# Build input messages: history + current turn input
|
|
708
780
|
turn_input = turn.get("input_messages", [])
|
|
@@ -770,7 +842,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
770
842
|
# --- Tool call spans ---
|
|
771
843
|
for tool_call in turn.get("tool_calls", []):
|
|
772
844
|
tool_name = tool_call.get("name", "unknown")
|
|
773
|
-
with client.start_span(name=f"tool_{tool_name}", span_type="tool") as tool_span:
|
|
845
|
+
with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=turn_start_dt) as tool_span:
|
|
774
846
|
tool_span.set_runtime(Runtime(library="codex-cli"))
|
|
775
847
|
tool_span.set_tags({
|
|
776
848
|
"tool_name": tool_name,
|
|
@@ -794,7 +866,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
|
|
|
794
866
|
agent_id = sc.get("agent_id") or "unknown"
|
|
795
867
|
nickname = sc.get("nickname") or agent_id
|
|
796
868
|
|
|
797
|
-
with client.start_span(name=f"subagent_{nickname}", span_type="agent") as subagent_span:
|
|
869
|
+
with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=turn_start_dt) as subagent_span:
|
|
798
870
|
subagent_span.set_runtime(Runtime(library="codex-cli"))
|
|
799
871
|
subagent_span.set_tags({
|
|
800
872
|
"agent_id": agent_id,
|
|
@@ -36,7 +36,7 @@ async function _refreshToken(refreshTok) {
|
|
|
36
36
|
const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
|
|
37
37
|
const req = https.request(`${_COZE_API}/api/permission/oauth2/token`, {
|
|
38
38
|
method: "POST",
|
|
39
|
-
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
|
39
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "x-tt-env": "ppe_cozelab", "x-use-ppe": "1" },
|
|
40
40
|
}, (res) => {
|
|
41
41
|
let buf = "";
|
|
42
42
|
res.on("data", c => buf += c);
|
|
@@ -170,6 +170,8 @@ export class CozeloopExporter {
|
|
|
170
170
|
headers: {
|
|
171
171
|
"Authorization": authorization,
|
|
172
172
|
"cozeloop-workspace-id": workspaceId,
|
|
173
|
+
"x-tt-env": "ppe_cozelab",
|
|
174
|
+
"x-use-ppe": "1",
|
|
173
175
|
},
|
|
174
176
|
});
|
|
175
177
|
this.provider = new BasicTracerProvider({ resource });
|
|
@@ -446,6 +446,32 @@ function resolveChannelId(ctx, eventFrom, defaultValue = "system/unknown") {
|
|
|
446
446
|
let lastUserChannelId;
|
|
447
447
|
let lastUserTraceContext;
|
|
448
448
|
let lastOpenclawSessionId;
|
|
449
|
+
// Session-level coze-context cache. In a Coze session only the FIRST user
|
|
450
|
+
// message carries the <coze-context> block; /loop follow-ups and later turns
|
|
451
|
+
// do not. We cache the most recently seen context (per openclaw session id, plus
|
|
452
|
+
// a global last-seen fallback) so that ensureRootSpan can still attach the
|
|
453
|
+
// trace to the right Coze session on those later turns instead of dropping it.
|
|
454
|
+
const cozeContextBySession = new Map();
|
|
455
|
+
let lastCozeContext;
|
|
456
|
+
function rememberCozeContext(input, sessionId) {
|
|
457
|
+
const parsed = parseCozeContext(input);
|
|
458
|
+
if (Object.keys(parsed).length === 0)
|
|
459
|
+
return parsed;
|
|
460
|
+
lastCozeContext = parsed;
|
|
461
|
+
if (sessionId)
|
|
462
|
+
cozeContextBySession.set(sessionId, parsed);
|
|
463
|
+
return parsed;
|
|
464
|
+
}
|
|
465
|
+
function resolveCozeContext(input, sessionId) {
|
|
466
|
+
// Prefer context parsed from this turn's input; fall back to the per-session
|
|
467
|
+
// cache, then the global last-seen context.
|
|
468
|
+
const fromInput = parseCozeContext(input);
|
|
469
|
+
if (Object.keys(fromInput).length > 0)
|
|
470
|
+
return fromInput;
|
|
471
|
+
if (sessionId && cozeContextBySession.has(sessionId))
|
|
472
|
+
return cozeContextBySession.get(sessionId);
|
|
473
|
+
return lastCozeContext || {};
|
|
474
|
+
}
|
|
449
475
|
// Active agent context: set in before_agent_start, cleared in agent_end.
|
|
450
476
|
// All hooks between these two (llm_input, llm_output, tool calls, messages)
|
|
451
477
|
// use this to ensure every span lands in the same Trace.
|
|
@@ -485,6 +511,31 @@ const cozeloopTracePlugin = {
|
|
|
485
511
|
enabledHooks: pluginConfig.enabledHooks,
|
|
486
512
|
};
|
|
487
513
|
const exporter = new CozeloopExporter(api, config);
|
|
514
|
+
// per-agent trace 放行:traceAgentIds 为 onboard 写入的 allowlist(小写归一)。
|
|
515
|
+
// 非空时,仅 allowlist 内的 agentId 上报 trace;为空 / 未配置则全部放行(本地全局模式)。
|
|
516
|
+
const traceAgentIds = Array.isArray(pluginConfig.traceAgentIds)
|
|
517
|
+
? pluginConfig.traceAgentIds.map((s) => String(s).trim().toLowerCase()).filter(Boolean)
|
|
518
|
+
: [];
|
|
519
|
+
const isAgentAllowed = (hookCtx) => {
|
|
520
|
+
if (traceAgentIds.length === 0)
|
|
521
|
+
return true; // 空 allowlist = 全部放行
|
|
522
|
+
const agentId = resolveAgentIdFromHookCtx(hookCtx || {});
|
|
523
|
+
const allowed = traceAgentIds.includes(agentId);
|
|
524
|
+
if (!allowed && config.debug) {
|
|
525
|
+
api.logger.info(`[CozeloopTrace] agent "${agentId}" not in traceAgentIds, skip trace`);
|
|
526
|
+
}
|
|
527
|
+
return allowed;
|
|
528
|
+
};
|
|
529
|
+
// 包裹 api.on:带 hookCtx 的 hook 在 agentId 不在 allowlist 时直接跳过,
|
|
530
|
+
// 不进入任何上报逻辑。gateway_start/stop 等无 hookCtx 的生命周期 hook 不拦截。
|
|
531
|
+
const rawOn = api.on.bind(api);
|
|
532
|
+
const on = (name, handler) => {
|
|
533
|
+
rawOn(name, async (event, hookCtx) => {
|
|
534
|
+
if (hookCtx !== undefined && !isAgentAllowed(hookCtx))
|
|
535
|
+
return;
|
|
536
|
+
return handler(event, hookCtx);
|
|
537
|
+
});
|
|
538
|
+
};
|
|
488
539
|
const contextByChannelId = new Map();
|
|
489
540
|
const contextByRunId = new Map();
|
|
490
541
|
const shouldHookEnabled = (hookName) => {
|
|
@@ -761,6 +812,14 @@ const cozeloopTracePlugin = {
|
|
|
761
812
|
});
|
|
762
813
|
if (shouldHookEnabled("gateway_start")) {
|
|
763
814
|
api.on("gateway_start", async (event) => {
|
|
815
|
+
// gateway_start 是进程级事件、无 agent 归属。云端 per-agent 模式
|
|
816
|
+
// (traceAgentIds 非空)下不上报这条无归属 span,避免越过 allowlist。
|
|
817
|
+
if (traceAgentIds.length > 0) {
|
|
818
|
+
if (config.debug) {
|
|
819
|
+
api.logger.info("[CozeloopTrace] traceAgentIds set, skip gateway_start span");
|
|
820
|
+
}
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
764
823
|
const now = Date.now();
|
|
765
824
|
const { ctx, channelId } = getOrCreateContext("system/gateway", undefined, "gateway_start");
|
|
766
825
|
const span = createSpan(ctx, channelId, "gateway_start", "gateway", now, now, {
|
|
@@ -774,7 +833,7 @@ const cozeloopTracePlugin = {
|
|
|
774
833
|
});
|
|
775
834
|
}
|
|
776
835
|
if (shouldHookEnabled("session_start")) {
|
|
777
|
-
|
|
836
|
+
on("session_start", async (event, hookCtx) => {
|
|
778
837
|
// Refresh token if expiring soon (< 10 min)
|
|
779
838
|
await exporter.refreshAuthIfNeeded();
|
|
780
839
|
const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
|
|
@@ -791,7 +850,7 @@ const cozeloopTracePlugin = {
|
|
|
791
850
|
});
|
|
792
851
|
}
|
|
793
852
|
if (shouldHookEnabled("message_received")) {
|
|
794
|
-
|
|
853
|
+
on("message_received", async (event, hookCtx) => {
|
|
795
854
|
const rawChannelId = resolveChannelId(hookCtx, event.from || event.metadata?.senderId);
|
|
796
855
|
if (config.debug) {
|
|
797
856
|
api.logger.info(`[CozeloopTrace] message_received hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.from=${event.from}`);
|
|
@@ -814,6 +873,9 @@ const cozeloopTracePlugin = {
|
|
|
814
873
|
lastUserTraceContext = ctx;
|
|
815
874
|
ctx.userInput = event.content;
|
|
816
875
|
lastUserInput = event.content;
|
|
876
|
+
// Cache any coze-context as soon as we see the user input,
|
|
877
|
+
// so later /loop turns (which carry none) can inherit it.
|
|
878
|
+
rememberCozeContext(event.content, ctx.openclawSessionId || lastOpenclawSessionId);
|
|
817
879
|
if (config.debug) {
|
|
818
880
|
api.logger.info(`[CozeloopTrace] Saved user context: channelId=${channelId}, traceId=${ctx.traceId}`);
|
|
819
881
|
}
|
|
@@ -829,7 +891,7 @@ const cozeloopTracePlugin = {
|
|
|
829
891
|
});
|
|
830
892
|
}
|
|
831
893
|
if (shouldHookEnabled("message_sending")) {
|
|
832
|
-
|
|
894
|
+
on("message_sending", async (event, hookCtx) => {
|
|
833
895
|
if (lastUserTraceContext) {
|
|
834
896
|
lastUserTraceContext.lastOutput = event.content;
|
|
835
897
|
if (config.debug) {
|
|
@@ -847,7 +909,7 @@ const cozeloopTracePlugin = {
|
|
|
847
909
|
});
|
|
848
910
|
}
|
|
849
911
|
if (shouldHookEnabled("message_sent")) {
|
|
850
|
-
|
|
912
|
+
on("message_sent", async (event, hookCtx) => {
|
|
851
913
|
if (event.content && event.success) {
|
|
852
914
|
if (lastUserTraceContext) {
|
|
853
915
|
lastUserTraceContext.lastOutput = event.content;
|
|
@@ -870,7 +932,7 @@ const cozeloopTracePlugin = {
|
|
|
870
932
|
let lastLlmStartTime = undefined;
|
|
871
933
|
let lastLlmSpanId = undefined;
|
|
872
934
|
if (shouldHookEnabled("llm_input")) {
|
|
873
|
-
|
|
935
|
+
on("llm_input", async (event, hookCtx) => {
|
|
874
936
|
const rawChannelId = resolveChannelId(hookCtx);
|
|
875
937
|
if (config.debug) {
|
|
876
938
|
api.logger.info(`[CozeloopTrace] llm_input hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=${event.runId}`);
|
|
@@ -895,6 +957,9 @@ const cozeloopTracePlugin = {
|
|
|
895
957
|
lastUserInput = event.prompt;
|
|
896
958
|
}
|
|
897
959
|
}
|
|
960
|
+
// Cache coze-context from the prompt too (covers sessions where no
|
|
961
|
+
// message_received fired), so /loop follow-ups can inherit it.
|
|
962
|
+
rememberCozeContext(event.prompt, ctx.openclawSessionId || lastOpenclawSessionId);
|
|
898
963
|
// Fallback: ensure root + agent spans exist in case before_agent_start
|
|
899
964
|
// was not fired (older OpenClaw versions or resumed sessions).
|
|
900
965
|
const channelIdForSpans = activeAgentChannelId || rawChannelId;
|
|
@@ -958,7 +1023,7 @@ const cozeloopTracePlugin = {
|
|
|
958
1023
|
});
|
|
959
1024
|
}
|
|
960
1025
|
if (shouldHookEnabled("llm_output")) {
|
|
961
|
-
|
|
1026
|
+
on("llm_output", async (event, hookCtx) => {
|
|
962
1027
|
const rawChannelId = resolveChannelId(hookCtx);
|
|
963
1028
|
if (config.debug) {
|
|
964
1029
|
api.logger.info(`[CozeloopTrace][DEBUG] llm_output event.usage=${JSON.stringify(event.usage)}`);
|
|
@@ -1054,7 +1119,7 @@ const cozeloopTracePlugin = {
|
|
|
1054
1119
|
});
|
|
1055
1120
|
}
|
|
1056
1121
|
if (shouldHookEnabled("before_tool_call")) {
|
|
1057
|
-
|
|
1122
|
+
on("before_tool_call", async (event, hookCtx) => {
|
|
1058
1123
|
const rawChannelId = resolveChannelId(hookCtx);
|
|
1059
1124
|
if (config.debug) {
|
|
1060
1125
|
api.logger.info(`[CozeloopTrace] before_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
|
|
@@ -1075,7 +1140,7 @@ const cozeloopTracePlugin = {
|
|
|
1075
1140
|
});
|
|
1076
1141
|
}
|
|
1077
1142
|
if (shouldHookEnabled("after_tool_call")) {
|
|
1078
|
-
|
|
1143
|
+
on("after_tool_call", async (event, hookCtx) => {
|
|
1079
1144
|
if (config.debug) {
|
|
1080
1145
|
api.logger.info(`[CozeloopTrace] after_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
|
|
1081
1146
|
}
|
|
@@ -1162,13 +1227,46 @@ const cozeloopTracePlugin = {
|
|
|
1162
1227
|
traceFinalized = false;
|
|
1163
1228
|
}, 200);
|
|
1164
1229
|
};
|
|
1230
|
+
// OpenClaw runtime 周期性发送的心跳轮询消息,不是真实对话,整条 trace 丢弃。
|
|
1231
|
+
const HEARTBEAT_INPUT = "[OpenClaw heartbeat poll]";
|
|
1232
|
+
const isHeartbeatInput = (input) => {
|
|
1233
|
+
if (typeof input === "string") {
|
|
1234
|
+
return input.trim() === HEARTBEAT_INPUT;
|
|
1235
|
+
}
|
|
1236
|
+
// content 可能是 [{type:'text', text:'...'}] 形式
|
|
1237
|
+
if (Array.isArray(input)) {
|
|
1238
|
+
return input.some((p) => p && typeof p === "object"
|
|
1239
|
+
&& typeof p.text === "string" && p.text.trim() === HEARTBEAT_INPUT);
|
|
1240
|
+
}
|
|
1241
|
+
return false;
|
|
1242
|
+
};
|
|
1165
1243
|
// Helper: ensure root openclaw_request span is started for a given context.
|
|
1166
1244
|
// Must be called before creating the agent span so that the exporter's
|
|
1167
1245
|
// currentRootContext is set and the agent span becomes a proper child.
|
|
1168
1246
|
const ensureRootSpan = async (ctx, channelId) => {
|
|
1169
|
-
//
|
|
1170
|
-
|
|
1171
|
-
|
|
1247
|
+
// 心跳轮询消息:不是真实对话,整条 trace 不上报(沿用下方“无 coze-context 即 return”的同款范式)。
|
|
1248
|
+
const heartbeatInput = ctx.userInput
|
|
1249
|
+
|| lastUserTraceContext?.userInput || lastUserInput;
|
|
1250
|
+
if (isHeartbeatInput(heartbeatInput)) {
|
|
1251
|
+
if (config.debug) {
|
|
1252
|
+
api.logger.info(`[CozeloopTrace] skip heartbeat poll trace, traceId=${ctx.traceId}`);
|
|
1253
|
+
}
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
// Resolve coze-context with SESSION-LEVEL inheritance: only the first
|
|
1257
|
+
// user message of a Coze session carries the <coze-context> block, so
|
|
1258
|
+
// /loop follow-ups and later turns have none. Fall back to the per-
|
|
1259
|
+
// session cache (and global last-seen) instead of dropping the trace.
|
|
1260
|
+
// Only skip when NO context has ever been seen for this session.
|
|
1261
|
+
const ocSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
|
|
1262
|
+
rememberCozeContext(ctx.userInput, ocSessionId);
|
|
1263
|
+
let cozeCtx = resolveCozeContext(ctx.userInput, ocSessionId);
|
|
1264
|
+
if (Object.keys(cozeCtx).length === 0) {
|
|
1265
|
+
// Try the fallback user inputs too before giving up.
|
|
1266
|
+
const fallbackInput = lastUserTraceContext?.userInput || lastUserInput;
|
|
1267
|
+
cozeCtx = resolveCozeContext(fallbackInput, ocSessionId);
|
|
1268
|
+
}
|
|
1269
|
+
if (Object.keys(cozeCtx).length === 0) {
|
|
1172
1270
|
return;
|
|
1173
1271
|
}
|
|
1174
1272
|
// Check both: rootSpanStartTime indicates we created a root span before,
|
|
@@ -1207,7 +1305,7 @@ const cozeloopTracePlugin = {
|
|
|
1207
1305
|
"run.id": ctx.runId,
|
|
1208
1306
|
"turn.id": ctx.turnId,
|
|
1209
1307
|
"openclaw.channel_id": channelId,
|
|
1210
|
-
...
|
|
1308
|
+
...cozeCtx,
|
|
1211
1309
|
},
|
|
1212
1310
|
input: ctx.userInput,
|
|
1213
1311
|
traceId: ctx.traceId,
|
|
@@ -1237,7 +1335,7 @@ const cozeloopTracePlugin = {
|
|
|
1237
1335
|
"run.id": ctx.runId,
|
|
1238
1336
|
"turn.id": ctx.turnId,
|
|
1239
1337
|
"openclaw.channel_id": channelId,
|
|
1240
|
-
...
|
|
1338
|
+
...resolveCozeContext(ctx.userInput, ctx.openclawSessionId || lastOpenclawSessionId),
|
|
1241
1339
|
},
|
|
1242
1340
|
traceId: ctx.traceId,
|
|
1243
1341
|
spanId: ctx.agentSpanId,
|
|
@@ -1252,7 +1350,7 @@ const cozeloopTracePlugin = {
|
|
|
1252
1350
|
}
|
|
1253
1351
|
};
|
|
1254
1352
|
if (shouldHookEnabled("before_agent_start")) {
|
|
1255
|
-
|
|
1353
|
+
on("before_agent_start", async (event, hookCtx) => {
|
|
1256
1354
|
const rawChannelId = resolveChannelId(hookCtx);
|
|
1257
1355
|
const agentId = hookCtx.agentId || event.agentId || "main";
|
|
1258
1356
|
if (config.debug) {
|
|
@@ -1270,7 +1368,7 @@ const cozeloopTracePlugin = {
|
|
|
1270
1368
|
});
|
|
1271
1369
|
}
|
|
1272
1370
|
if (shouldHookEnabled("agent_end")) {
|
|
1273
|
-
|
|
1371
|
+
on("agent_end", async (event, hookCtx) => {
|
|
1274
1372
|
const rawChannelId = resolveChannelId(hookCtx);
|
|
1275
1373
|
if (config.debug) {
|
|
1276
1374
|
api.logger.info(`[CozeloopTrace] agent_end hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}`);
|
|
@@ -1290,7 +1388,7 @@ const cozeloopTracePlugin = {
|
|
|
1290
1388
|
// versions), finalize the trace here so that agent + root spans get ended
|
|
1291
1389
|
// and exported.
|
|
1292
1390
|
if (shouldHookEnabled("session_end")) {
|
|
1293
|
-
|
|
1391
|
+
on("session_end", async (event, hookCtx) => {
|
|
1294
1392
|
const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
|
|
1295
1393
|
if (config.debug) {
|
|
1296
1394
|
api.logger.info(`[CozeloopTrace] session_end: ${rawChannelId}`);
|