coze_lab 0.1.35 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js
CHANGED
|
@@ -2,11 +2,8 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
// ─── 0. Constants ─────────────────────────────────────────────────────────────
|
|
5
|
-
const CLIENT_ID = '
|
|
6
|
-
const WORKSPACE_ID = '
|
|
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';
|
|
5
|
+
const CLIENT_ID = '08972682140163281554629748278108.app.coze';
|
|
6
|
+
const WORKSPACE_ID = '7649231955045072915';
|
|
10
7
|
const COZE_API = 'https://api.coze.cn';
|
|
11
8
|
const CREDS_PATH = require('path').join(require('os').homedir(), '.cozeloop', 'credentials.json');
|
|
12
9
|
const PACKAGE_VERSION = require('./package.json').version;
|
|
@@ -139,4687 +136,6 @@ function loadOpenclawFiles() {
|
|
|
139
136
|
}
|
|
140
137
|
|
|
141
138
|
|
|
142
|
-
// ─── 2b. Embedded OpenClaw plugin files ─────────────────────────────────────
|
|
143
|
-
|
|
144
|
-
// ─── 2c. Embedded refresh script ──────────────────────────────────────────────
|
|
145
|
-
// ─── 2. Embedded Python hook scripts ─────────────────────────────────────────
|
|
146
|
-
const CLAUDE_CODE_HOOK_PY = `\
|
|
147
|
-
#!/usr/bin/env python3
|
|
148
|
-
# -*- coding: utf-8 -*-
|
|
149
|
-
|
|
150
|
-
"""
|
|
151
|
-
CozeLoop Hook for Claude Code
|
|
152
|
-
|
|
153
|
-
This hook integrates Claude Code with CozeLoop for tracing and observability.
|
|
154
|
-
It captures conversation interactions from the local .jsonl file and sends them
|
|
155
|
-
as traces to the CozeLoop platform.
|
|
156
|
-
|
|
157
|
-
Usage:
|
|
158
|
-
1. Place this script in \`~/.claude/hooks/cozeloop_hook.py\`.
|
|
159
|
-
2. Configure the hook in \`~/.claude/settings.json\`.
|
|
160
|
-
3. Set environment variables \`COZELOOP_WORKSPACE_ID\` and \`COZELOOP_API_TOKEN\`
|
|
161
|
-
in your project's \`.claude/settings.local.json\`.
|
|
162
|
-
4. Run Claude Code as normal - traces will be sent automatically.
|
|
163
|
-
"""
|
|
164
|
-
|
|
165
|
-
import json
|
|
166
|
-
import os
|
|
167
|
-
import sys
|
|
168
|
-
import glob
|
|
169
|
-
import hashlib
|
|
170
|
-
import time
|
|
171
|
-
import urllib.request
|
|
172
|
-
import urllib.error
|
|
173
|
-
from datetime import datetime
|
|
174
|
-
from pathlib import Path
|
|
175
|
-
from typing import Optional, List, Dict, Any
|
|
176
|
-
|
|
177
|
-
# --- SDK Import ---
|
|
178
|
-
try:
|
|
179
|
-
import cozeloop
|
|
180
|
-
from cozeloop.spec.tracespec import (
|
|
181
|
-
Runtime, ModelInput, ModelMessage, ModelToolChoice,
|
|
182
|
-
ModelOutput, ModelChoice, ModelToolCall, ModelToolCallFunction,
|
|
183
|
-
ModelMessagePart, ModelMessagePartType
|
|
184
|
-
)
|
|
185
|
-
except ImportError:
|
|
186
|
-
print("Error: cozeloop SDK not found. Please install it with: pip install cozeloop", file=sys.stderr)
|
|
187
|
-
sys.exit(1)
|
|
188
|
-
|
|
189
|
-
# --- Configuration ---
|
|
190
|
-
DEBUG = os.environ.get("CC_COZELOOP_DEBUG", "").lower() == "true"
|
|
191
|
-
_COZELOOP_CLIENT_ID = "46371084383473718052118955183420.app.coze"
|
|
192
|
-
_COZE_API = "https://api.coze.cn"
|
|
193
|
-
_REFRESH_THRESHOLD = 10 * 60 # refresh when < 10 minutes remain
|
|
194
|
-
|
|
195
|
-
def debug_log(message: str):
|
|
196
|
-
"""Print debug message if debug mode is enabled."""
|
|
197
|
-
if DEBUG:
|
|
198
|
-
print(f"[COZELOOP_HOOK_DEBUG] {datetime.now().isoformat()} - {message}", file=sys.stderr)
|
|
199
|
-
|
|
200
|
-
# --- Token refresh --------------------------------------------------------
|
|
201
|
-
|
|
202
|
-
def _get_credentials_path() -> Path:
|
|
203
|
-
return Path.home() / ".cozeloop" / "credentials.json"
|
|
204
|
-
|
|
205
|
-
def _load_credentials() -> Optional[Dict]:
|
|
206
|
-
path = _get_credentials_path()
|
|
207
|
-
if not path.exists():
|
|
208
|
-
return None
|
|
209
|
-
try:
|
|
210
|
-
return json.loads(path.read_text())
|
|
211
|
-
except Exception:
|
|
212
|
-
return None
|
|
213
|
-
|
|
214
|
-
def _save_credentials(creds: Dict):
|
|
215
|
-
path = _get_credentials_path()
|
|
216
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
-
path.write_text(json.dumps(creds, indent=2))
|
|
218
|
-
os.chmod(path, 0o600)
|
|
219
|
-
|
|
220
|
-
def _refresh_token(refresh_token: str) -> Optional[str]:
|
|
221
|
-
"""Call Coze refresh token API. Returns new access_token or None on failure."""
|
|
222
|
-
try:
|
|
223
|
-
payload = json.dumps({
|
|
224
|
-
"grant_type": "refresh_token",
|
|
225
|
-
"client_id": _COZELOOP_CLIENT_ID,
|
|
226
|
-
"refresh_token": refresh_token,
|
|
227
|
-
}).encode()
|
|
228
|
-
req = urllib.request.Request(
|
|
229
|
-
f"{_COZE_API}/api/permission/oauth2/token",
|
|
230
|
-
data=payload,
|
|
231
|
-
headers={
|
|
232
|
-
"Content-Type": "application/json",
|
|
233
|
-
"x-tt-env": "ppe_cozelab",
|
|
234
|
-
"x-use-ppe": "1",
|
|
235
|
-
},
|
|
236
|
-
)
|
|
237
|
-
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
238
|
-
data = json.loads(resp.read())
|
|
239
|
-
if data.get("access_token"):
|
|
240
|
-
existing = _load_credentials() or {}
|
|
241
|
-
creds = {
|
|
242
|
-
"access_token": data["access_token"],
|
|
243
|
-
"refresh_token": data.get("refresh_token", refresh_token),
|
|
244
|
-
"expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
|
|
245
|
-
"workspace_id": existing.get("workspace_id", ""),
|
|
246
|
-
}
|
|
247
|
-
_save_credentials(creds)
|
|
248
|
-
debug_log("Token refreshed successfully.")
|
|
249
|
-
return creds["access_token"]
|
|
250
|
-
except Exception as e:
|
|
251
|
-
debug_log(f"Token refresh failed: {e}")
|
|
252
|
-
return None
|
|
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
|
-
|
|
271
|
-
def get_fresh_token() -> Optional[str]:
|
|
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()
|
|
278
|
-
creds = _load_credentials()
|
|
279
|
-
if creds:
|
|
280
|
-
return _token_from_credentials()
|
|
281
|
-
return env_token or env_coze_token
|
|
282
|
-
|
|
283
|
-
# -------------------------------------------------------------------------
|
|
284
|
-
|
|
285
|
-
# --- State Management ---
|
|
286
|
-
|
|
287
|
-
def get_state_file_path(conversation_file: str) -> str:
|
|
288
|
-
"""Get the state file path for tracking processed messages."""
|
|
289
|
-
state_dir = Path.home() / ".claude" / "cozeloop_state"
|
|
290
|
-
state_dir.mkdir(parents=True, exist_ok=True)
|
|
291
|
-
file_hash = hashlib.md5(conversation_file.encode()).hexdigest()[:12]
|
|
292
|
-
return str(state_dir / f"state_{file_hash}.json")
|
|
293
|
-
|
|
294
|
-
def load_state(state_file: str) -> Dict[str, Any]:
|
|
295
|
-
"""Load the processing state from file."""
|
|
296
|
-
if os.path.exists(state_file):
|
|
297
|
-
try:
|
|
298
|
-
with open(state_file, 'r') as f:
|
|
299
|
-
return json.load(f)
|
|
300
|
-
except (json.JSONDecodeError, IOError) as e:
|
|
301
|
-
debug_log(f"Error loading state: {e}")
|
|
302
|
-
return {"last_processed_line": 0, "session_id": None}
|
|
303
|
-
|
|
304
|
-
def save_state(state_file: str, state: Dict[str, Any]):
|
|
305
|
-
"""Save the processing state to file."""
|
|
306
|
-
try:
|
|
307
|
-
with open(state_file, 'w') as f:
|
|
308
|
-
json.dump(state, f, indent=2)
|
|
309
|
-
except IOError as e:
|
|
310
|
-
debug_log(f"Error saving state: {e}")
|
|
311
|
-
|
|
312
|
-
# --- Conversation File Handling ---
|
|
313
|
-
|
|
314
|
-
def find_latest_conversation_file() -> Optional[str]:
|
|
315
|
-
"""Find the most recently modified conversation file in ~/.claude/projects/."""
|
|
316
|
-
claude_dir = Path.home() / ".claude" / "projects"
|
|
317
|
-
if not claude_dir.exists():
|
|
318
|
-
debug_log(f"Claude projects directory not found: {claude_dir}")
|
|
319
|
-
return None
|
|
320
|
-
|
|
321
|
-
jsonl_files = list(claude_dir.rglob("*.jsonl"))
|
|
322
|
-
if not jsonl_files:
|
|
323
|
-
debug_log("No conversation files (*.jsonl) found.")
|
|
324
|
-
return None
|
|
325
|
-
|
|
326
|
-
latest_file = max(jsonl_files, key=lambda p: p.stat().st_mtime)
|
|
327
|
-
debug_log(f"Found latest conversation file: {latest_file}")
|
|
328
|
-
return str(latest_file)
|
|
329
|
-
|
|
330
|
-
def read_new_messages(file_path: str, start_line: int = 0) -> List[Dict[str, Any]]:
|
|
331
|
-
"""Read new messages from a conversation file since the last processed line."""
|
|
332
|
-
messages = []
|
|
333
|
-
try:
|
|
334
|
-
with open(file_path, 'r', encoding='utf-8') as f:
|
|
335
|
-
for i, line in enumerate(f):
|
|
336
|
-
if i < start_line:
|
|
337
|
-
continue
|
|
338
|
-
line = line.strip()
|
|
339
|
-
if line:
|
|
340
|
-
try:
|
|
341
|
-
msg = json.loads(line)
|
|
342
|
-
msg['_line_number'] = i
|
|
343
|
-
messages.append(msg)
|
|
344
|
-
except json.JSONDecodeError:
|
|
345
|
-
debug_log(f"Skipping malformed JSON on line {i+1}")
|
|
346
|
-
except (IOError, FileNotFoundError) as e:
|
|
347
|
-
debug_log(f"Error reading conversation file: {e}")
|
|
348
|
-
return messages
|
|
349
|
-
|
|
350
|
-
# --- Content Helpers ---
|
|
351
|
-
|
|
352
|
-
def is_empty_content(content: Any) -> bool:
|
|
353
|
-
"""Return True if content carries no meaningful data."""
|
|
354
|
-
if content is None:
|
|
355
|
-
return True
|
|
356
|
-
if isinstance(content, str):
|
|
357
|
-
return content.strip() == ""
|
|
358
|
-
if isinstance(content, list):
|
|
359
|
-
if len(content) == 0:
|
|
360
|
-
return True
|
|
361
|
-
if len(content) == 1 and isinstance(content[0], dict) and content[0].get("type") == "text" and content[0].get("text", "").strip() == "":
|
|
362
|
-
return True
|
|
363
|
-
return False
|
|
364
|
-
|
|
365
|
-
def format_content(content: Any, truncate: int = 4096) -> str:
|
|
366
|
-
"""Format message content for trace display."""
|
|
367
|
-
if isinstance(content, str):
|
|
368
|
-
return content[:truncate]
|
|
369
|
-
if isinstance(content, dict):
|
|
370
|
-
return json.dumps(content, ensure_ascii=False)[:truncate]
|
|
371
|
-
if isinstance(content, list):
|
|
372
|
-
return json.dumps(content, ensure_ascii=False)[:truncate]
|
|
373
|
-
return str(content)[:truncate]
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
# --- Message Parsing and Grouping ---
|
|
377
|
-
|
|
378
|
-
def is_tool_result_message(msg: Dict[str, Any]) -> bool:
|
|
379
|
-
"""Check if a message is a tool_result (not a real user input)."""
|
|
380
|
-
content = msg.get("message", {}).get("content", [])
|
|
381
|
-
return isinstance(content, list) and any(
|
|
382
|
-
isinstance(item, dict) and item.get("type") == "tool_result"
|
|
383
|
-
for item in content
|
|
384
|
-
)
|
|
385
|
-
|
|
386
|
-
def extract_tool_result_from_message(msg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
387
|
-
"""Extract tool_result items from a user message."""
|
|
388
|
-
content = msg.get("message", {}).get("content", [])
|
|
389
|
-
if isinstance(content, list):
|
|
390
|
-
return [item for item in content if isinstance(item, dict) and item.get("type") == "tool_result"]
|
|
391
|
-
return []
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
def _extract_progress_inner_message(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
395
|
-
"""Extract the inner conversation message from a progress (sub-agent) message.
|
|
396
|
-
|
|
397
|
-
Progress messages have the inner message nested at data.message.message.
|
|
398
|
-
Returns a dict with keys: role, content, id, parentToolUseID, or None if not valid.
|
|
399
|
-
"""
|
|
400
|
-
data = msg.get("data", {})
|
|
401
|
-
outer_msg = data.get("message", {})
|
|
402
|
-
inner_msg = outer_msg.get("message", {})
|
|
403
|
-
if not inner_msg:
|
|
404
|
-
return None
|
|
405
|
-
|
|
406
|
-
role = inner_msg.get("role")
|
|
407
|
-
content = inner_msg.get("content")
|
|
408
|
-
if not role or content is None:
|
|
409
|
-
return None
|
|
410
|
-
|
|
411
|
-
return {
|
|
412
|
-
"role": role,
|
|
413
|
-
"content": content,
|
|
414
|
-
"id": inner_msg.get("id"),
|
|
415
|
-
"usage": inner_msg.get("usage", {}),
|
|
416
|
-
"model": inner_msg.get("model"),
|
|
417
|
-
"parentToolUseID": msg.get("parentToolUseID"),
|
|
418
|
-
"agentId": data.get("agentId", ""),
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
def _group_subagent_steps(progress_msgs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
423
|
-
"""Group sub-agent progress messages into steps (same logic as top-level).
|
|
424
|
-
|
|
425
|
-
Each step is an assistant message (model call) + its tool_calls + tool_results.
|
|
426
|
-
Returns list of steps in the same format as turn["steps"], but with simplified
|
|
427
|
-
assistant_message structure.
|
|
428
|
-
"""
|
|
429
|
-
steps = []
|
|
430
|
-
|
|
431
|
-
for pmsg in progress_msgs:
|
|
432
|
-
role = pmsg.get("role")
|
|
433
|
-
content = pmsg.get("content", [])
|
|
434
|
-
|
|
435
|
-
if role == "user":
|
|
436
|
-
# Could be tool_result or user input for the sub-agent
|
|
437
|
-
if isinstance(content, list):
|
|
438
|
-
has_tool_result = any(
|
|
439
|
-
isinstance(item, dict) and item.get("type") == "tool_result"
|
|
440
|
-
for item in content
|
|
441
|
-
)
|
|
442
|
-
if has_tool_result and steps:
|
|
443
|
-
for item in content:
|
|
444
|
-
if isinstance(item, dict) and item.get("type") == "tool_result":
|
|
445
|
-
steps[-1]["tool_results"].append(item)
|
|
446
|
-
# Skip non-tool-result user messages (sub-agent prompt)
|
|
447
|
-
continue
|
|
448
|
-
|
|
449
|
-
if role == "assistant":
|
|
450
|
-
tool_calls = []
|
|
451
|
-
if isinstance(content, list):
|
|
452
|
-
for item in content:
|
|
453
|
-
if isinstance(item, dict) and item.get("type") == "tool_use":
|
|
454
|
-
tool_calls.append(item)
|
|
455
|
-
|
|
456
|
-
msg_id = pmsg.get("id")
|
|
457
|
-
last_step = steps[-1] if steps else None
|
|
458
|
-
last_msg_id = last_step.get("_msg_id") if last_step else None
|
|
459
|
-
|
|
460
|
-
if last_step and msg_id and msg_id == last_msg_id:
|
|
461
|
-
# Same API response — merge
|
|
462
|
-
existing = last_step["assistant_message"].get("message", {}).get("content", [])
|
|
463
|
-
if isinstance(existing, list) and isinstance(content, list):
|
|
464
|
-
existing.extend(content)
|
|
465
|
-
last_step["tool_calls"].extend(tool_calls)
|
|
466
|
-
usage = pmsg.get("usage", {})
|
|
467
|
-
if usage.get("input_tokens", 0) > 0 or usage.get("output_tokens", 0) > 0:
|
|
468
|
-
last_step["assistant_message"]["message"]["usage"] = usage
|
|
469
|
-
else:
|
|
470
|
-
steps.append({
|
|
471
|
-
"assistant_message": {
|
|
472
|
-
"message": {
|
|
473
|
-
"role": "assistant",
|
|
474
|
-
"content": content,
|
|
475
|
-
"id": msg_id,
|
|
476
|
-
"model": pmsg.get("model", ""),
|
|
477
|
-
"usage": pmsg.get("usage", {}),
|
|
478
|
-
}
|
|
479
|
-
},
|
|
480
|
-
"tool_calls": tool_calls,
|
|
481
|
-
"tool_results": [],
|
|
482
|
-
"_msg_id": msg_id,
|
|
483
|
-
})
|
|
484
|
-
|
|
485
|
-
return steps
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
def group_messages_into_turns(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
489
|
-
"""Group messages into conversation turns (user -> assistant -> tool_results).
|
|
490
|
-
|
|
491
|
-
A turn represents a complete interaction cycle starting from a real user input.
|
|
492
|
-
Within each turn, we track individual "steps" -- each step is a single model
|
|
493
|
-
invocation (assistant message) paired with the tool_results it triggered.
|
|
494
|
-
|
|
495
|
-
This captures the full chain:
|
|
496
|
-
user_input -> model_call_1 (tool_use) -> tool_result -> model_call_2 (tool_use)
|
|
497
|
-
-> tool_result -> ... -> model_call_N (final text)
|
|
498
|
-
|
|
499
|
-
Each step has:
|
|
500
|
-
- assistant_message: the assistant's response (one API call)
|
|
501
|
-
- tool_calls: tool_use items from this assistant message
|
|
502
|
-
- tool_results: matching tool_result items from the following user message(s)
|
|
503
|
-
|
|
504
|
-
Sub-agent (Task tool) progress messages are parsed and stored as
|
|
505
|
-
sub_steps on the step containing the parent tool call.
|
|
506
|
-
"""
|
|
507
|
-
turns = []
|
|
508
|
-
current_turn = None
|
|
509
|
-
|
|
510
|
-
# First pass: collect progress messages grouped by parentToolUseID,
|
|
511
|
-
# and collect toolUseResult usage keyed by tool_use_id.
|
|
512
|
-
subagent_progress: Dict[str, List[Dict[str, Any]]] = {}
|
|
513
|
-
tool_use_result_usage: Dict[str, Dict[str, Any]] = {}
|
|
514
|
-
for msg in messages:
|
|
515
|
-
if msg.get("type") == "progress":
|
|
516
|
-
inner = _extract_progress_inner_message(msg)
|
|
517
|
-
if inner and inner.get("parentToolUseID"):
|
|
518
|
-
parent_id = inner["parentToolUseID"]
|
|
519
|
-
if parent_id not in subagent_progress:
|
|
520
|
-
subagent_progress[parent_id] = []
|
|
521
|
-
subagent_progress[parent_id].append(inner)
|
|
522
|
-
# Collect toolUseResult usage from tool_result messages
|
|
523
|
-
tur = msg.get("toolUseResult")
|
|
524
|
-
if isinstance(tur, dict) and tur.get("usage"):
|
|
525
|
-
message = msg.get("message", {})
|
|
526
|
-
content = message.get("content", [])
|
|
527
|
-
if isinstance(content, list):
|
|
528
|
-
for item in content:
|
|
529
|
-
if isinstance(item, dict) and item.get("type") == "tool_result":
|
|
530
|
-
tid = item.get("tool_use_id", "")
|
|
531
|
-
if tid:
|
|
532
|
-
tool_use_result_usage[tid] = tur["usage"]
|
|
533
|
-
|
|
534
|
-
# Second pass: build turns from user/assistant messages
|
|
535
|
-
for msg in messages:
|
|
536
|
-
msg_type = msg.get("type")
|
|
537
|
-
role = msg.get("role")
|
|
538
|
-
message = msg.get("message", {})
|
|
539
|
-
message_role = message.get("role", "")
|
|
540
|
-
|
|
541
|
-
# Skip non-conversation messages
|
|
542
|
-
if msg_type in ("progress", "system", "file-history-snapshot"):
|
|
543
|
-
continue
|
|
544
|
-
|
|
545
|
-
# Check if this is a user message
|
|
546
|
-
is_user_msg = msg_type == "user" or role == "user" or message_role == "user"
|
|
547
|
-
|
|
548
|
-
if is_user_msg:
|
|
549
|
-
# Check if this is a tool_result message (should not start a new turn)
|
|
550
|
-
if is_tool_result_message(msg):
|
|
551
|
-
# Attach tool results to the last step of the current turn
|
|
552
|
-
if current_turn and current_turn["steps"]:
|
|
553
|
-
tool_results = extract_tool_result_from_message(msg)
|
|
554
|
-
current_turn["steps"][-1]["tool_results"].extend(tool_results)
|
|
555
|
-
else:
|
|
556
|
-
# This is a real user input, start a new turn
|
|
557
|
-
if current_turn:
|
|
558
|
-
turns.append(current_turn)
|
|
559
|
-
current_turn = {
|
|
560
|
-
"user_message": msg,
|
|
561
|
-
"steps": [],
|
|
562
|
-
"start_line": msg.get("_line_number", 0)
|
|
563
|
-
}
|
|
564
|
-
elif msg_type == "assistant" or role == "assistant" or message_role == "assistant":
|
|
565
|
-
if current_turn:
|
|
566
|
-
# Extract tool_use items from this line's content
|
|
567
|
-
tool_calls = []
|
|
568
|
-
content = message.get("content", [])
|
|
569
|
-
if isinstance(content, list):
|
|
570
|
-
for item in content:
|
|
571
|
-
if isinstance(item, dict) and item.get("type") == "tool_use":
|
|
572
|
-
tool_calls.append(item)
|
|
573
|
-
|
|
574
|
-
# Claude Code writes text and tool_use from the same API response
|
|
575
|
-
# as separate JSONL lines sharing the same message.id.
|
|
576
|
-
# Merge them into a single step.
|
|
577
|
-
msg_id = message.get("id")
|
|
578
|
-
last_step = current_turn["steps"][-1] if current_turn["steps"] else None
|
|
579
|
-
last_msg_id = (last_step["assistant_message"].get("message", {}).get("id")
|
|
580
|
-
if last_step else None)
|
|
581
|
-
|
|
582
|
-
if last_step and msg_id and msg_id == last_msg_id:
|
|
583
|
-
# Same API response — merge content into the existing step
|
|
584
|
-
existing_content = last_step["assistant_message"].get("message", {}).get("content", [])
|
|
585
|
-
if isinstance(existing_content, list) and isinstance(content, list):
|
|
586
|
-
existing_content.extend(content)
|
|
587
|
-
last_step["tool_calls"].extend(tool_calls)
|
|
588
|
-
# Carry over usage from the later line (earlier line typically has zeros)
|
|
589
|
-
usage = message.get("usage", {})
|
|
590
|
-
if usage.get("input_tokens", 0) > 0 or usage.get("output_tokens", 0) > 0:
|
|
591
|
-
last_step["assistant_message"]["message"]["usage"] = usage
|
|
592
|
-
else:
|
|
593
|
-
# New API response — create a new step
|
|
594
|
-
current_turn["steps"].append({
|
|
595
|
-
"assistant_message": msg,
|
|
596
|
-
"tool_calls": tool_calls,
|
|
597
|
-
"tool_results": [],
|
|
598
|
-
})
|
|
599
|
-
|
|
600
|
-
# Don't forget the last turn
|
|
601
|
-
if current_turn:
|
|
602
|
-
turns.append(current_turn)
|
|
603
|
-
|
|
604
|
-
# Third pass: attach sub-agent steps, agentId, and total usage to their parent tool calls
|
|
605
|
-
for turn in turns:
|
|
606
|
-
for step in turn["steps"]:
|
|
607
|
-
for tc in step["tool_calls"]:
|
|
608
|
-
tool_id = tc.get("id", "")
|
|
609
|
-
if tool_id in subagent_progress:
|
|
610
|
-
progress_msgs = subagent_progress[tool_id]
|
|
611
|
-
tc["_sub_steps"] = _group_subagent_steps(progress_msgs)
|
|
612
|
-
# Extract agentId (same for all messages under this parent)
|
|
613
|
-
for pm in progress_msgs:
|
|
614
|
-
if pm.get("agentId"):
|
|
615
|
-
tc["_agent_id"] = pm["agentId"]
|
|
616
|
-
break
|
|
617
|
-
# Attach total usage from toolUseResult for token distribution
|
|
618
|
-
if tool_id in tool_use_result_usage:
|
|
619
|
-
tc["_total_usage"] = tool_use_result_usage[tool_id]
|
|
620
|
-
|
|
621
|
-
return turns
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
# --- CozeLoop Message Helpers ---
|
|
625
|
-
|
|
626
|
-
def _make_message(role: str, content: str = "", tool_calls: list = None,
|
|
627
|
-
tool_call_id: str = "", parts: list = None) -> ModelMessage:
|
|
628
|
-
"""Helper to create a CozeLoop ModelMessage with default fields."""
|
|
629
|
-
return ModelMessage(
|
|
630
|
-
role=role,
|
|
631
|
-
content=content,
|
|
632
|
-
reasoning_content="",
|
|
633
|
-
parts=parts or [],
|
|
634
|
-
name="",
|
|
635
|
-
tool_calls=tool_calls or [],
|
|
636
|
-
tool_call_id=tool_call_id or "",
|
|
637
|
-
metadata={}
|
|
638
|
-
)
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
def _format_tool_output(result_content: Any, max_len: int = 2000) -> str:
|
|
642
|
-
"""Format tool result content for span output.
|
|
643
|
-
|
|
644
|
-
When content is a list (e.g. Task tool results with multiple text blocks),
|
|
645
|
-
extract and join text parts instead of dumping raw JSON.
|
|
646
|
-
"""
|
|
647
|
-
if isinstance(result_content, str):
|
|
648
|
-
if len(result_content) > max_len:
|
|
649
|
-
return result_content[:max_len] + "..."
|
|
650
|
-
return result_content
|
|
651
|
-
|
|
652
|
-
if isinstance(result_content, list):
|
|
653
|
-
text_parts = []
|
|
654
|
-
for item in result_content:
|
|
655
|
-
if isinstance(item, dict):
|
|
656
|
-
if item.get("type") == "text":
|
|
657
|
-
text_parts.append(item.get("text", ""))
|
|
658
|
-
else:
|
|
659
|
-
# Non-text items: serialize compactly
|
|
660
|
-
text_parts.append(json.dumps(item, ensure_ascii=False))
|
|
661
|
-
elif isinstance(item, str):
|
|
662
|
-
text_parts.append(item)
|
|
663
|
-
joined = "\\n".join(text_parts)
|
|
664
|
-
if len(joined) > max_len:
|
|
665
|
-
return joined[:max_len] + "..."
|
|
666
|
-
return joined
|
|
667
|
-
|
|
668
|
-
s = str(result_content)
|
|
669
|
-
if len(s) > max_len:
|
|
670
|
-
return s[:max_len] + "..."
|
|
671
|
-
return s
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
def _make_tool_result_message(result_content: Any, tool_call_id: str = "") -> ModelMessage:
|
|
675
|
-
"""Create a role='tool' ModelMessage for model input.
|
|
676
|
-
|
|
677
|
-
When result_content is a list, items go into parts (not content) to avoid
|
|
678
|
-
dumping raw JSON into the content field.
|
|
679
|
-
"""
|
|
680
|
-
if isinstance(result_content, list):
|
|
681
|
-
parts_list = []
|
|
682
|
-
for item in result_content:
|
|
683
|
-
if isinstance(item, dict):
|
|
684
|
-
item_type = item.get("type", "text")
|
|
685
|
-
if item_type == "text":
|
|
686
|
-
parts_list.append(ModelMessagePart(type=ModelMessagePartType.TEXT, text=item.get("text", "")))
|
|
687
|
-
else:
|
|
688
|
-
parts_list.append(ModelMessagePart(
|
|
689
|
-
type=ModelMessagePartType.TEXT,
|
|
690
|
-
text=json.dumps(item, ensure_ascii=False)[:4096]
|
|
691
|
-
))
|
|
692
|
-
elif isinstance(item, str):
|
|
693
|
-
parts_list.append(ModelMessagePart(type=ModelMessagePartType.TEXT, text=item))
|
|
694
|
-
return _make_message(
|
|
695
|
-
role="tool",
|
|
696
|
-
content="",
|
|
697
|
-
tool_call_id=tool_call_id,
|
|
698
|
-
parts=parts_list
|
|
699
|
-
)
|
|
700
|
-
|
|
701
|
-
# String or other scalar
|
|
702
|
-
return _make_message(
|
|
703
|
-
role="tool",
|
|
704
|
-
content=format_content(result_content),
|
|
705
|
-
tool_call_id=tool_call_id
|
|
706
|
-
)
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
def _raw_content_to_input_message(raw_content: Any, role: str) -> List[ModelMessage]:
|
|
710
|
-
"""Convert raw Claude content to CozeLoop ModelMessage(s) suitable for model input.
|
|
711
|
-
|
|
712
|
-
When content is a list:
|
|
713
|
-
- tool_use items -> ModelMessage.tool_calls (as ModelToolCall objects)
|
|
714
|
-
- tool_result items -> separate ModelMessage(role="tool") per result
|
|
715
|
-
- all other items (text, etc.) -> ModelMessage.parts (as ModelMessagePart objects)
|
|
716
|
-
- ModelMessage.content = empty when parts are used (avoid duplication)
|
|
717
|
-
|
|
718
|
-
When content is a string:
|
|
719
|
-
- Simple ModelMessage with content
|
|
720
|
-
"""
|
|
721
|
-
if isinstance(raw_content, str):
|
|
722
|
-
return [_make_message(role, format_content(raw_content))]
|
|
723
|
-
|
|
724
|
-
if not isinstance(raw_content, list):
|
|
725
|
-
return [_make_message(role, format_content(raw_content))]
|
|
726
|
-
|
|
727
|
-
# Check if content is all tool_result items
|
|
728
|
-
all_tool_results = all(
|
|
729
|
-
isinstance(item, dict) and item.get("type") == "tool_result"
|
|
730
|
-
for item in raw_content if isinstance(item, dict)
|
|
731
|
-
) and any(
|
|
732
|
-
isinstance(item, dict) and item.get("type") == "tool_result"
|
|
733
|
-
for item in raw_content
|
|
734
|
-
)
|
|
735
|
-
|
|
736
|
-
if all_tool_results:
|
|
737
|
-
messages = []
|
|
738
|
-
for item in raw_content:
|
|
739
|
-
if isinstance(item, dict) and item.get("type") == "tool_result":
|
|
740
|
-
result_content = item.get("content", "")
|
|
741
|
-
messages.append(_make_tool_result_message(
|
|
742
|
-
result_content,
|
|
743
|
-
tool_call_id=item.get("tool_use_id", "")
|
|
744
|
-
))
|
|
745
|
-
return messages
|
|
746
|
-
|
|
747
|
-
# Mixed content: split into tool_calls, parts, and text
|
|
748
|
-
tc_list = []
|
|
749
|
-
parts_list = []
|
|
750
|
-
text_parts = []
|
|
751
|
-
|
|
752
|
-
for item in raw_content:
|
|
753
|
-
if not isinstance(item, dict):
|
|
754
|
-
continue
|
|
755
|
-
item_type = item.get("type", "")
|
|
756
|
-
|
|
757
|
-
if item_type == "tool_use":
|
|
758
|
-
tc_list.append(ModelToolCall(
|
|
759
|
-
id=item.get("id", ""),
|
|
760
|
-
type="function",
|
|
761
|
-
function=ModelToolCallFunction(
|
|
762
|
-
name=item.get("name", ""),
|
|
763
|
-
arguments=json.dumps(item.get("input", {}), ensure_ascii=False) if isinstance(item.get("input"), dict) else str(item.get("input", ""))
|
|
764
|
-
)
|
|
765
|
-
))
|
|
766
|
-
elif item_type == "text":
|
|
767
|
-
t = item.get("text", "")
|
|
768
|
-
if t:
|
|
769
|
-
text_parts.append(t)
|
|
770
|
-
parts_list.append(ModelMessagePart(type=ModelMessagePartType.TEXT, text=t))
|
|
771
|
-
elif item_type in ("thinking", "redacted_thinking"):
|
|
772
|
-
pass # skip internal thinking from input history
|
|
773
|
-
else:
|
|
774
|
-
parts_list.append(ModelMessagePart(
|
|
775
|
-
type=ModelMessagePartType.TEXT,
|
|
776
|
-
text=json.dumps(item, ensure_ascii=False)[:4096]
|
|
777
|
-
))
|
|
778
|
-
|
|
779
|
-
content_text = "\\n".join(text_parts) if text_parts else ""
|
|
780
|
-
return [_make_message(
|
|
781
|
-
role=role,
|
|
782
|
-
content=content_text,
|
|
783
|
-
tool_calls=tc_list if tc_list else None,
|
|
784
|
-
parts=parts_list if parts_list else None
|
|
785
|
-
)]
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
def _build_history_messages(history_turns: List[Dict[str, Any]]) -> list:
|
|
789
|
-
"""Build cumulative history messages from previously processed turns."""
|
|
790
|
-
history_messages = []
|
|
791
|
-
for ht in (history_turns or []):
|
|
792
|
-
ht_user = ht.get("user_message", {}).get("message", {})
|
|
793
|
-
ht_user_content = ht_user.get("content") if ht_user else None
|
|
794
|
-
if ht_user and not is_empty_content(ht_user_content):
|
|
795
|
-
history_messages.append(_make_message("user", format_content(ht_user_content)))
|
|
796
|
-
for step in ht.get("steps", []):
|
|
797
|
-
msg = step.get("assistant_message", {})
|
|
798
|
-
asst_content = msg.get("message", {}).get("content")
|
|
799
|
-
if not is_empty_content(asst_content):
|
|
800
|
-
history_messages.extend(_raw_content_to_input_message(asst_content, "assistant"))
|
|
801
|
-
for tr in step.get("tool_results", []):
|
|
802
|
-
tr_content = tr.get("content", "")
|
|
803
|
-
history_messages.append(_make_tool_result_message(
|
|
804
|
-
tr_content,
|
|
805
|
-
tool_call_id=tr.get("tool_use_id", "")
|
|
806
|
-
))
|
|
807
|
-
return history_messages
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
# --- CozeLoop Trace Reporting ---
|
|
811
|
-
|
|
812
|
-
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history_turns: Optional[List[Dict[str, Any]]] = None):
|
|
813
|
-
"""Send conversation turns to CozeLoop.
|
|
814
|
-
|
|
815
|
-
Span hierarchy:
|
|
816
|
-
root_span (claude_code_request) [input=user_input, output=final_response]
|
|
817
|
-
+-- turn_span
|
|
818
|
-
|-- model_span (1st model call)
|
|
819
|
-
|-- tool_span / agent_span (tool call from 1st model response)
|
|
820
|
-
|-- model_span (2nd model call, after receiving tool result)
|
|
821
|
-
|-- tool_span / agent_span (tool call from 2nd model response)
|
|
822
|
-
|-- ...
|
|
823
|
-
+-- model_span (Nth model call, final text response)
|
|
824
|
-
"""
|
|
825
|
-
if not turns:
|
|
826
|
-
return
|
|
827
|
-
|
|
828
|
-
debug_log(f"Initializing CozeLoop client for session: {session_id}")
|
|
829
|
-
token = get_fresh_token()
|
|
830
|
-
if token:
|
|
831
|
-
os.environ["COZELOOP_API_TOKEN"] = token
|
|
832
|
-
client = cozeloop.new_client()
|
|
833
|
-
|
|
834
|
-
try:
|
|
835
|
-
with client.start_span(name="claude_code_request", span_type="main") as root_span:
|
|
836
|
-
root_span.set_runtime(Runtime(library="claude-code"))
|
|
837
|
-
root_span.set_tags({
|
|
838
|
-
"thread_id": session_id,
|
|
839
|
-
"total_turns": len(turns),
|
|
840
|
-
"source": "claude_code"
|
|
841
|
-
})
|
|
842
|
-
root_span.set_baggage({
|
|
843
|
-
"thread_id": session_id,
|
|
844
|
-
})
|
|
845
|
-
|
|
846
|
-
# Set root span input: first user message across all turns
|
|
847
|
-
first_user_content = None
|
|
848
|
-
for turn in turns:
|
|
849
|
-
um = turn.get("user_message", {}).get("message", {})
|
|
850
|
-
uc = um.get("content") if um else None
|
|
851
|
-
if not is_empty_content(uc):
|
|
852
|
-
first_user_content = uc
|
|
853
|
-
break
|
|
854
|
-
if first_user_content is not None:
|
|
855
|
-
root_span.set_input(format_content(first_user_content))
|
|
856
|
-
|
|
857
|
-
# Build cumulative history from previously processed turns
|
|
858
|
-
history_messages = _build_history_messages(history_turns)
|
|
859
|
-
|
|
860
|
-
# Process each turn as a child span under the root
|
|
861
|
-
for i, turn in enumerate(turns):
|
|
862
|
-
try:
|
|
863
|
-
steps = turn.get("steps", [])
|
|
864
|
-
total_steps = len(steps)
|
|
865
|
-
|
|
866
|
-
with client.start_span(name=f"turn_{i}", span_type="main") as turn_span:
|
|
867
|
-
turn_span.set_runtime(Runtime(library="claude-code"))
|
|
868
|
-
turn_span.set_tags({
|
|
869
|
-
"thread_id": session_id,
|
|
870
|
-
"turn_index": i,
|
|
871
|
-
"total_steps": total_steps,
|
|
872
|
-
"source": "claude_code",
|
|
873
|
-
})
|
|
874
|
-
|
|
875
|
-
# Extract user input for this turn
|
|
876
|
-
user_message = turn.get("user_message", {}).get("message", {})
|
|
877
|
-
user_raw_content = user_message.get("content") if user_message else None
|
|
878
|
-
|
|
879
|
-
# Build input context for the first model call in this turn
|
|
880
|
-
input_messages = list(history_messages)
|
|
881
|
-
if not is_empty_content(user_raw_content):
|
|
882
|
-
input_messages.append(_make_message("user", format_content(user_raw_content)))
|
|
883
|
-
|
|
884
|
-
# Process each step: model_span + tool_spans
|
|
885
|
-
for j, step in enumerate(steps):
|
|
886
|
-
assistant_msg = step.get("assistant_message", {})
|
|
887
|
-
assistant_message_obj = assistant_msg.get("message", {})
|
|
888
|
-
raw_content = assistant_message_obj.get("content", [])
|
|
889
|
-
model_name = assistant_message_obj.get("model", "claude-code")
|
|
890
|
-
|
|
891
|
-
# --- Create model span for this step ---
|
|
892
|
-
with client.start_span(name=f"model_call_{j}", span_type="model") as model_span:
|
|
893
|
-
model_span.set_runtime(Runtime(library="claude-code"))
|
|
894
|
-
model_span.set_model_name(model_name)
|
|
895
|
-
|
|
896
|
-
# Set input: accumulated context up to this point
|
|
897
|
-
model_span.set_input(ModelInput(
|
|
898
|
-
messages=list(input_messages),
|
|
899
|
-
tools=[],
|
|
900
|
-
tool_choice=ModelToolChoice(type="", function=None)
|
|
901
|
-
))
|
|
902
|
-
|
|
903
|
-
# Build output: text -> parts, tool_use -> tool_calls, thinking -> reasoning_content
|
|
904
|
-
text_parts = []
|
|
905
|
-
tool_call_list = []
|
|
906
|
-
parts_list = []
|
|
907
|
-
thinking_parts = []
|
|
908
|
-
if isinstance(raw_content, list):
|
|
909
|
-
for item in raw_content:
|
|
910
|
-
if not isinstance(item, dict):
|
|
911
|
-
continue
|
|
912
|
-
item_type = item.get("type", "")
|
|
913
|
-
if item_type == "text":
|
|
914
|
-
text = item.get("text", "")
|
|
915
|
-
if text:
|
|
916
|
-
text_parts.append(text)
|
|
917
|
-
parts_list.append(ModelMessagePart(type=ModelMessagePartType.TEXT, text=text))
|
|
918
|
-
elif item_type == "thinking":
|
|
919
|
-
thinking = item.get("thinking", "")
|
|
920
|
-
if thinking:
|
|
921
|
-
thinking_parts.append(thinking)
|
|
922
|
-
elif item_type == "redacted_thinking":
|
|
923
|
-
pass # encrypted, cannot extract
|
|
924
|
-
elif item_type == "tool_use":
|
|
925
|
-
tool_call_list.append(ModelToolCall(
|
|
926
|
-
id=item.get("id", ""),
|
|
927
|
-
type="function",
|
|
928
|
-
function=ModelToolCallFunction(
|
|
929
|
-
name=item.get("name", ""),
|
|
930
|
-
arguments=json.dumps(item.get("input", {}), ensure_ascii=False) if isinstance(item.get("input"), dict) else str(item.get("input", ""))
|
|
931
|
-
)
|
|
932
|
-
))
|
|
933
|
-
else:
|
|
934
|
-
parts_list.append(ModelMessagePart(
|
|
935
|
-
type=ModelMessagePartType.TEXT,
|
|
936
|
-
text=json.dumps(item, ensure_ascii=False)[:4096]
|
|
937
|
-
))
|
|
938
|
-
elif isinstance(raw_content, str) and raw_content:
|
|
939
|
-
text_parts.append(raw_content)
|
|
940
|
-
|
|
941
|
-
content_text = "\\n".join(text_parts) if text_parts else ""
|
|
942
|
-
reasoning_text = "\\n".join(thinking_parts) if thinking_parts else ""
|
|
943
|
-
finish_reason = "tool_calls" if tool_call_list else "stop"
|
|
944
|
-
|
|
945
|
-
output_choice = ModelChoice(
|
|
946
|
-
finish_reason=finish_reason,
|
|
947
|
-
index=0,
|
|
948
|
-
message=ModelMessage(
|
|
949
|
-
role="assistant",
|
|
950
|
-
content=content_text,
|
|
951
|
-
reasoning_content=reasoning_text,
|
|
952
|
-
parts=parts_list,
|
|
953
|
-
name="",
|
|
954
|
-
tool_calls=tool_call_list if tool_call_list else [],
|
|
955
|
-
tool_call_id="",
|
|
956
|
-
metadata={}
|
|
957
|
-
)
|
|
958
|
-
)
|
|
959
|
-
|
|
960
|
-
model_span.set_output(ModelOutput(choices=[output_choice]))
|
|
961
|
-
|
|
962
|
-
# Set token usage for this specific model call
|
|
963
|
-
usage = assistant_message_obj.get("usage", {})
|
|
964
|
-
input_tokens = usage.get("input_tokens", 0)
|
|
965
|
-
output_tokens = usage.get("output_tokens", 0)
|
|
966
|
-
cache_creation = usage.get("cache_creation_input_tokens", 0)
|
|
967
|
-
cache_read = usage.get("cache_read_input_tokens", 0)
|
|
968
|
-
if input_tokens > 0 or cache_creation > 0 or cache_read > 0:
|
|
969
|
-
model_span.set_input_tokens(input_tokens + cache_creation + cache_read)
|
|
970
|
-
if output_tokens > 0:
|
|
971
|
-
model_span.set_output_tokens(output_tokens)
|
|
972
|
-
|
|
973
|
-
# Add this assistant message to context for subsequent steps
|
|
974
|
-
if not is_empty_content(raw_content):
|
|
975
|
-
input_messages.extend(_raw_content_to_input_message(raw_content, "assistant"))
|
|
976
|
-
|
|
977
|
-
# --- Create tool spans for each tool call in this step ---
|
|
978
|
-
for tool_call in step.get("tool_calls", []):
|
|
979
|
-
tool_name = tool_call.get('name', 'unknown')
|
|
980
|
-
sub_steps = tool_call.get("_sub_steps", [])
|
|
981
|
-
agent_id = tool_call.get("_agent_id", "")
|
|
982
|
-
is_agent = bool(sub_steps)
|
|
983
|
-
|
|
984
|
-
# Task tool with sub-agent steps uses "agent" span type
|
|
985
|
-
span_type = "agent" if is_agent else "tool"
|
|
986
|
-
span_name = f"agent_{tool_name}" if is_agent else f"tool_{tool_name}"
|
|
987
|
-
|
|
988
|
-
with client.start_span(name=span_name, span_type=span_type) as tool_span:
|
|
989
|
-
tool_span.set_runtime(Runtime(library="claude-code"))
|
|
990
|
-
tags = {
|
|
991
|
-
"tool_name": tool_name,
|
|
992
|
-
"tool_call_id": tool_call.get("id"),
|
|
993
|
-
"step_index": j,
|
|
994
|
-
}
|
|
995
|
-
if is_agent:
|
|
996
|
-
tags["agent_name"] = agent_id
|
|
997
|
-
tool_span.set_tags(tags)
|
|
998
|
-
tool_span.set_input(
|
|
999
|
-
json.dumps(tool_call.get("input", {}), ensure_ascii=False)[:2000]
|
|
1000
|
-
)
|
|
1001
|
-
|
|
1002
|
-
# Find matching tool result
|
|
1003
|
-
tool_id = tool_call.get("id")
|
|
1004
|
-
for result in step.get("tool_results", []):
|
|
1005
|
-
if result.get("tool_use_id") == tool_id:
|
|
1006
|
-
result_content = result.get("content", "")
|
|
1007
|
-
tool_span.set_output(_format_tool_output(result_content))
|
|
1008
|
-
break
|
|
1009
|
-
|
|
1010
|
-
# If this tool call has sub-agent steps (e.g. Task tool),
|
|
1011
|
-
# create child spans for each sub-agent model call and tool call.
|
|
1012
|
-
if sub_steps:
|
|
1013
|
-
# Initialize sub-agent input with the prompt (first user message)
|
|
1014
|
-
sub_input_messages = []
|
|
1015
|
-
task_prompt = tool_call.get("input", {}).get("prompt", "")
|
|
1016
|
-
if task_prompt:
|
|
1017
|
-
sub_input_messages.append(_make_message("user", format_content(task_prompt)))
|
|
1018
|
-
|
|
1019
|
-
# Distribute total usage evenly across sub-agent model steps.
|
|
1020
|
-
total_usage = tool_call.get("_total_usage", {})
|
|
1021
|
-
total_in = (total_usage.get("input_tokens", 0)
|
|
1022
|
-
+ total_usage.get("cache_creation_input_tokens", 0)
|
|
1023
|
-
+ total_usage.get("cache_read_input_tokens", 0))
|
|
1024
|
-
total_out = total_usage.get("output_tokens", 0)
|
|
1025
|
-
n_model_steps = len(sub_steps)
|
|
1026
|
-
per_step_in = total_in // n_model_steps if n_model_steps > 0 else 0
|
|
1027
|
-
per_step_out = total_out // n_model_steps if n_model_steps > 0 else 0
|
|
1028
|
-
# Give remainder to the last step
|
|
1029
|
-
remainder_in = total_in - per_step_in * n_model_steps if n_model_steps > 0 else 0
|
|
1030
|
-
remainder_out = total_out - per_step_out * n_model_steps if n_model_steps > 0 else 0
|
|
1031
|
-
|
|
1032
|
-
for sk, sub_step in enumerate(sub_steps):
|
|
1033
|
-
sub_asst = sub_step.get("assistant_message", {}).get("message", {})
|
|
1034
|
-
sub_content = sub_asst.get("content", [])
|
|
1035
|
-
sub_model = sub_asst.get("model") or "claude-code"
|
|
1036
|
-
|
|
1037
|
-
# Sub-agent model span
|
|
1038
|
-
with client.start_span(name=f"subagent_model_{sk}", span_type="model") as sub_model_span:
|
|
1039
|
-
sub_model_span.set_runtime(Runtime(library="claude-code"))
|
|
1040
|
-
sub_model_span.set_model_name(sub_model)
|
|
1041
|
-
sub_model_span.set_tags({"agent_name": agent_id})
|
|
1042
|
-
|
|
1043
|
-
# Set input: accumulated sub-agent context
|
|
1044
|
-
sub_model_span.set_input(ModelInput(
|
|
1045
|
-
messages=list(sub_input_messages),
|
|
1046
|
-
tools=[],
|
|
1047
|
-
tool_choice=ModelToolChoice(type="", function=None)
|
|
1048
|
-
))
|
|
1049
|
-
|
|
1050
|
-
# Build output for sub-agent model call
|
|
1051
|
-
sub_text_parts = []
|
|
1052
|
-
sub_tc_list = []
|
|
1053
|
-
sub_parts_list = []
|
|
1054
|
-
sub_thinking_parts = []
|
|
1055
|
-
if isinstance(sub_content, list):
|
|
1056
|
-
for item in sub_content:
|
|
1057
|
-
if not isinstance(item, dict):
|
|
1058
|
-
continue
|
|
1059
|
-
item_type = item.get("type", "")
|
|
1060
|
-
if item_type == "text":
|
|
1061
|
-
t = item.get("text", "")
|
|
1062
|
-
if t:
|
|
1063
|
-
sub_text_parts.append(t)
|
|
1064
|
-
sub_parts_list.append(ModelMessagePart(type=ModelMessagePartType.TEXT, text=t))
|
|
1065
|
-
elif item_type == "thinking":
|
|
1066
|
-
t = item.get("thinking", "")
|
|
1067
|
-
if t:
|
|
1068
|
-
sub_thinking_parts.append(t)
|
|
1069
|
-
elif item_type == "redacted_thinking":
|
|
1070
|
-
pass
|
|
1071
|
-
elif item_type == "tool_use":
|
|
1072
|
-
sub_tc_list.append(ModelToolCall(
|
|
1073
|
-
id=item.get("id", ""),
|
|
1074
|
-
type="function",
|
|
1075
|
-
function=ModelToolCallFunction(
|
|
1076
|
-
name=item.get("name", ""),
|
|
1077
|
-
arguments=json.dumps(item.get("input", {}), ensure_ascii=False) if isinstance(item.get("input"), dict) else str(item.get("input", ""))
|
|
1078
|
-
)
|
|
1079
|
-
))
|
|
1080
|
-
else:
|
|
1081
|
-
sub_parts_list.append(ModelMessagePart(
|
|
1082
|
-
type=ModelMessagePartType.TEXT,
|
|
1083
|
-
text=json.dumps(item, ensure_ascii=False)[:4096]
|
|
1084
|
-
))
|
|
1085
|
-
|
|
1086
|
-
sub_content_text = "\\n".join(sub_text_parts) if sub_text_parts else ""
|
|
1087
|
-
sub_reasoning_text = "\\n".join(sub_thinking_parts) if sub_thinking_parts else ""
|
|
1088
|
-
sub_finish = "tool_calls" if sub_tc_list else "stop"
|
|
1089
|
-
sub_model_span.set_output(ModelOutput(choices=[ModelChoice(
|
|
1090
|
-
finish_reason=sub_finish,
|
|
1091
|
-
index=0,
|
|
1092
|
-
message=ModelMessage(
|
|
1093
|
-
role="assistant",
|
|
1094
|
-
content=sub_content_text,
|
|
1095
|
-
reasoning_content=sub_reasoning_text,
|
|
1096
|
-
parts=sub_parts_list,
|
|
1097
|
-
name="",
|
|
1098
|
-
tool_calls=sub_tc_list if sub_tc_list else [],
|
|
1099
|
-
tool_call_id="",
|
|
1100
|
-
metadata={}
|
|
1101
|
-
)
|
|
1102
|
-
)]))
|
|
1103
|
-
|
|
1104
|
-
# Distribute tokens evenly; remainder goes to last step
|
|
1105
|
-
step_in = per_step_in + (remainder_in if sk == n_model_steps - 1 else 0)
|
|
1106
|
-
step_out = per_step_out + (remainder_out if sk == n_model_steps - 1 else 0)
|
|
1107
|
-
if step_in > 0:
|
|
1108
|
-
sub_model_span.set_input_tokens(step_in)
|
|
1109
|
-
if step_out > 0:
|
|
1110
|
-
sub_model_span.set_output_tokens(step_out)
|
|
1111
|
-
|
|
1112
|
-
# Add assistant output to sub-agent context
|
|
1113
|
-
if not is_empty_content(sub_content):
|
|
1114
|
-
sub_input_messages.extend(
|
|
1115
|
-
_raw_content_to_input_message(sub_content, "assistant")
|
|
1116
|
-
)
|
|
1117
|
-
|
|
1118
|
-
# Sub-agent tool spans
|
|
1119
|
-
for sub_tc in sub_step.get("tool_calls", []):
|
|
1120
|
-
with client.start_span(name=f"tool_{sub_tc.get('name', 'unknown')}", span_type="tool") as sub_tool_span:
|
|
1121
|
-
sub_tool_span.set_tags({
|
|
1122
|
-
"tool_name": sub_tc.get("name"),
|
|
1123
|
-
"tool_call_id": sub_tc.get("id"),
|
|
1124
|
-
"agent_name": agent_id,
|
|
1125
|
-
})
|
|
1126
|
-
sub_tool_span.set_runtime(Runtime(library="claude-code"))
|
|
1127
|
-
sub_tool_span.set_input(
|
|
1128
|
-
json.dumps(sub_tc.get("input", {}), ensure_ascii=False)[:2000]
|
|
1129
|
-
)
|
|
1130
|
-
|
|
1131
|
-
sub_tool_id = sub_tc.get("id")
|
|
1132
|
-
for sub_result in sub_step.get("tool_results", []):
|
|
1133
|
-
if sub_result.get("tool_use_id") == sub_tool_id:
|
|
1134
|
-
sr_content = sub_result.get("content", "")
|
|
1135
|
-
sub_tool_span.set_output(_format_tool_output(sr_content))
|
|
1136
|
-
break
|
|
1137
|
-
|
|
1138
|
-
# Add tool results to sub-agent context
|
|
1139
|
-
for sub_result in sub_step.get("tool_results", []):
|
|
1140
|
-
sr_content = sub_result.get("content", "")
|
|
1141
|
-
sub_input_messages.append(_make_tool_result_message(
|
|
1142
|
-
sr_content,
|
|
1143
|
-
tool_call_id=sub_result.get("tool_use_id", "")
|
|
1144
|
-
))
|
|
1145
|
-
|
|
1146
|
-
# Add tool results to context for subsequent model calls
|
|
1147
|
-
for result in step.get("tool_results", []):
|
|
1148
|
-
result_content = result.get("content", "")
|
|
1149
|
-
input_messages.append(_make_tool_result_message(
|
|
1150
|
-
result_content,
|
|
1151
|
-
tool_call_id=result.get("tool_use_id", "")
|
|
1152
|
-
))
|
|
1153
|
-
|
|
1154
|
-
# Append this turn's messages to history for subsequent turns
|
|
1155
|
-
if user_message and not is_empty_content(user_message.get("content")):
|
|
1156
|
-
history_messages.append(_make_message(
|
|
1157
|
-
"user", format_content(user_message.get("content"))
|
|
1158
|
-
))
|
|
1159
|
-
for step in steps:
|
|
1160
|
-
msg = step.get("assistant_message", {})
|
|
1161
|
-
asst_content = msg.get("message", {}).get("content")
|
|
1162
|
-
if not is_empty_content(asst_content):
|
|
1163
|
-
history_messages.extend(_raw_content_to_input_message(asst_content, "assistant"))
|
|
1164
|
-
for tr in step.get("tool_results", []):
|
|
1165
|
-
tr_content = tr.get("content", "")
|
|
1166
|
-
history_messages.append(_make_tool_result_message(
|
|
1167
|
-
tr_content,
|
|
1168
|
-
tool_call_id=tr.get("tool_use_id", "")
|
|
1169
|
-
))
|
|
1170
|
-
|
|
1171
|
-
except Exception as e:
|
|
1172
|
-
debug_log(f"Error processing turn {i}: {e}")
|
|
1173
|
-
continue
|
|
1174
|
-
|
|
1175
|
-
# Set root span output: last assistant text from the last step of the last turn
|
|
1176
|
-
last_output = None
|
|
1177
|
-
for turn in reversed(turns):
|
|
1178
|
-
for step in reversed(turn.get("steps", [])):
|
|
1179
|
-
asst = step.get("assistant_message", {}).get("message", {})
|
|
1180
|
-
content = asst.get("content", [])
|
|
1181
|
-
if isinstance(content, list):
|
|
1182
|
-
text_parts = [
|
|
1183
|
-
item.get("text", "")
|
|
1184
|
-
for item in content
|
|
1185
|
-
if isinstance(item, dict) and item.get("type") == "text" and item.get("text")
|
|
1186
|
-
]
|
|
1187
|
-
if text_parts:
|
|
1188
|
-
last_output = "\\n".join(text_parts)
|
|
1189
|
-
break
|
|
1190
|
-
elif isinstance(content, str) and content.strip():
|
|
1191
|
-
last_output = content
|
|
1192
|
-
break
|
|
1193
|
-
if last_output:
|
|
1194
|
-
break
|
|
1195
|
-
if last_output:
|
|
1196
|
-
root_span.set_output(format_content(last_output))
|
|
1197
|
-
|
|
1198
|
-
debug_log(f"Successfully processed {len(turns)} turn(s) for session {session_id}")
|
|
1199
|
-
|
|
1200
|
-
except Exception as e:
|
|
1201
|
-
debug_log(f"An error occurred while sending traces to CozeLoop: {e}")
|
|
1202
|
-
finally:
|
|
1203
|
-
# Crucial: close the client to ensure all buffered traces are sent.
|
|
1204
|
-
client.close()
|
|
1205
|
-
debug_log("CozeLoop client closed.")
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
# --- Hook Input ---
|
|
1209
|
-
|
|
1210
|
-
def read_hook_stdin() -> Dict[str, Any]:
|
|
1211
|
-
"""Read hook input from stdin (non-blocking).
|
|
1212
|
-
|
|
1213
|
-
Claude Code passes a JSON payload via stdin to hooks, containing fields like
|
|
1214
|
-
transcript_path, session_id, hook_event_name, etc.
|
|
1215
|
-
Returns empty dict if stdin is empty or not valid JSON.
|
|
1216
|
-
"""
|
|
1217
|
-
try:
|
|
1218
|
-
if not sys.stdin.isatty():
|
|
1219
|
-
data = sys.stdin.read().strip()
|
|
1220
|
-
if data:
|
|
1221
|
-
result = json.loads(data)
|
|
1222
|
-
debug_log(f"Read hook stdin: keys={list(result.keys())}")
|
|
1223
|
-
return result
|
|
1224
|
-
except Exception as e:
|
|
1225
|
-
debug_log(f"Error reading hook stdin: {e}")
|
|
1226
|
-
return {}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
# --- Main Execution ---
|
|
1230
|
-
|
|
1231
|
-
def main():
|
|
1232
|
-
"""Main entry point for the hook script."""
|
|
1233
|
-
debug_log("Hook started.")
|
|
1234
|
-
|
|
1235
|
-
# Check if tracing is enabled
|
|
1236
|
-
if os.environ.get("TRACE_TO_COZELOOP", "").lower() == "false":
|
|
1237
|
-
debug_log("TRACE_TO_COZELOOP is set to 'false', skipping")
|
|
1238
|
-
return
|
|
1239
|
-
|
|
1240
|
-
# Read hook input from stdin (Claude Code provides transcript_path, session_id, etc.)
|
|
1241
|
-
hook_input = read_hook_stdin()
|
|
1242
|
-
|
|
1243
|
-
# Determine conversation file: prefer stdin, fallback to file scan
|
|
1244
|
-
conversation_file = hook_input.get("transcript_path")
|
|
1245
|
-
if conversation_file:
|
|
1246
|
-
conversation_file = os.path.expanduser(conversation_file)
|
|
1247
|
-
if not os.path.exists(conversation_file):
|
|
1248
|
-
debug_log(f"transcript_path from stdin does not exist: {conversation_file}")
|
|
1249
|
-
conversation_file = None
|
|
1250
|
-
|
|
1251
|
-
if not conversation_file:
|
|
1252
|
-
conversation_file = find_latest_conversation_file()
|
|
1253
|
-
|
|
1254
|
-
if not conversation_file:
|
|
1255
|
-
debug_log("Execution skipped: No conversation file found.")
|
|
1256
|
-
return
|
|
1257
|
-
|
|
1258
|
-
debug_log(f"Using conversation file: {conversation_file}")
|
|
1259
|
-
|
|
1260
|
-
# Load state to know where to start reading
|
|
1261
|
-
state_file = get_state_file_path(conversation_file)
|
|
1262
|
-
state = load_state(state_file)
|
|
1263
|
-
last_processed_line = state.get("last_processed_line", 0)
|
|
1264
|
-
|
|
1265
|
-
# Read new messages from the file
|
|
1266
|
-
new_messages = read_new_messages(conversation_file, last_processed_line)
|
|
1267
|
-
|
|
1268
|
-
# Determine session ID: prefer stdin, then messages, then state, then generate
|
|
1269
|
-
session_id = hook_input.get("session_id")
|
|
1270
|
-
if not session_id:
|
|
1271
|
-
for msg in new_messages:
|
|
1272
|
-
if msg.get("sessionId"):
|
|
1273
|
-
session_id = msg.get("sessionId")
|
|
1274
|
-
break
|
|
1275
|
-
if not session_id:
|
|
1276
|
-
if state.get("session_id"):
|
|
1277
|
-
session_id = state["session_id"]
|
|
1278
|
-
else:
|
|
1279
|
-
session_id = f"claude-code-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{os.getpid()}"
|
|
1280
|
-
debug_log(f"Generated new session ID: {session_id}")
|
|
1281
|
-
|
|
1282
|
-
state["session_id"] = session_id
|
|
1283
|
-
debug_log(f"Session ID: {session_id}")
|
|
1284
|
-
|
|
1285
|
-
if not new_messages:
|
|
1286
|
-
debug_log("No new messages to process.")
|
|
1287
|
-
return
|
|
1288
|
-
|
|
1289
|
-
debug_log(f"Found {len(new_messages)} new messages.")
|
|
1290
|
-
|
|
1291
|
-
# Read historical messages to build context for model input
|
|
1292
|
-
history_turns = []
|
|
1293
|
-
if last_processed_line > 0:
|
|
1294
|
-
historical_messages = read_new_messages(conversation_file, 0)
|
|
1295
|
-
historical_messages = [m for m in historical_messages if m.get("_line_number", 0) < last_processed_line]
|
|
1296
|
-
history_turns = group_messages_into_turns(historical_messages)
|
|
1297
|
-
debug_log(f"Loaded {len(history_turns)} historical turn(s) for context.")
|
|
1298
|
-
|
|
1299
|
-
# Group messages into turns and send to CozeLoop
|
|
1300
|
-
turns = group_messages_into_turns(new_messages)
|
|
1301
|
-
if turns:
|
|
1302
|
-
send_turns_to_cozeloop(turns, session_id, history_turns)
|
|
1303
|
-
|
|
1304
|
-
# Update state with the new last processed line number
|
|
1305
|
-
last_line_in_batch = max(msg.get("_line_number", 0) for msg in new_messages)
|
|
1306
|
-
state["last_processed_line"] = last_line_in_batch + 1
|
|
1307
|
-
save_state(state_file, state)
|
|
1308
|
-
debug_log(f"State updated. Last processed line: {state['last_processed_line']}")
|
|
1309
|
-
|
|
1310
|
-
debug_log("Hook finished.")
|
|
1311
|
-
|
|
1312
|
-
if __name__ == "__main__":
|
|
1313
|
-
main()
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
`;
|
|
1318
|
-
|
|
1319
|
-
const CODEX_HOOK_PY = `\
|
|
1320
|
-
#!/usr/bin/env python3
|
|
1321
|
-
"""
|
|
1322
|
-
CozeLoop Hook for Codex CLI
|
|
1323
|
-
|
|
1324
|
-
This hook integrates OpenAI Codex CLI with CozeLoop for tracing and observability.
|
|
1325
|
-
It captures conversation interactions from the rollout JSONL file and sends them
|
|
1326
|
-
to the CozeLoop platform for analysis.
|
|
1327
|
-
|
|
1328
|
-
Usage:
|
|
1329
|
-
1. Copy this script to ~/.codex/hooks/cozeloop_hook.py
|
|
1330
|
-
2. Register the hook in ~/.codex/hooks.json
|
|
1331
|
-
3. Set environment variables: COZELOOP_WORKSPACE_ID, COZELOOP_API_TOKEN
|
|
1332
|
-
4. Run Codex CLI as normal - traces will be sent automatically on each turn end
|
|
1333
|
-
|
|
1334
|
-
Hook input (via stdin):
|
|
1335
|
-
{
|
|
1336
|
-
"hook_event_name": "Stop",
|
|
1337
|
-
"session_id": "...",
|
|
1338
|
-
"turn_id": "...",
|
|
1339
|
-
"transcript_path": "/Users/.../.codex/sessions/YYYY/MM/DD/rollout-xxx.jsonl"
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
Subagent support:
|
|
1343
|
-
When Codex spawns subagents, each subagent gets its own rollout file with:
|
|
1344
|
-
session_meta.source = {"subagent": {"thread_spawn": {"parent_thread_id": "..."}}}
|
|
1345
|
-
Subagent hooks do NOT report traces directly. Instead they save their
|
|
1346
|
-
processed turn data to a per-agent file under ~/.codex/cozeloop_state/.
|
|
1347
|
-
When the parent session's hook runs, it reads those saved files and includes
|
|
1348
|
-
the subagent spans inside the same trace, producing a single trace per
|
|
1349
|
-
conversation that contains both the main agent and all of its subagents.
|
|
1350
|
-
"""
|
|
1351
|
-
|
|
1352
|
-
import json
|
|
1353
|
-
import os
|
|
1354
|
-
import sys
|
|
1355
|
-
import hashlib
|
|
1356
|
-
import time
|
|
1357
|
-
import urllib.request
|
|
1358
|
-
import urllib.error
|
|
1359
|
-
from datetime import datetime
|
|
1360
|
-
from pathlib import Path
|
|
1361
|
-
from typing import Optional, List, Dict, Any
|
|
1362
|
-
|
|
1363
|
-
# --- Token refresh --------------------------------------------------------
|
|
1364
|
-
_COZELOOP_CLIENT_ID = "46371084383473718052118955183420.app.coze"
|
|
1365
|
-
_COZE_API = "https://api.coze.cn"
|
|
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
|
-
|
|
1486
|
-
|
|
1487
|
-
def _get_credentials_path() -> Path:
|
|
1488
|
-
return Path.home() / ".cozeloop" / "credentials.json"
|
|
1489
|
-
|
|
1490
|
-
def _load_credentials():
|
|
1491
|
-
path = _get_credentials_path()
|
|
1492
|
-
if not path.exists():
|
|
1493
|
-
return None
|
|
1494
|
-
try:
|
|
1495
|
-
return json.loads(path.read_text())
|
|
1496
|
-
except Exception:
|
|
1497
|
-
return None
|
|
1498
|
-
|
|
1499
|
-
def _save_credentials(creds):
|
|
1500
|
-
path = _get_credentials_path()
|
|
1501
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1502
|
-
path.write_text(json.dumps(creds, indent=2))
|
|
1503
|
-
os.chmod(path, 0o600)
|
|
1504
|
-
|
|
1505
|
-
def _refresh_token(refresh_tok: str):
|
|
1506
|
-
try:
|
|
1507
|
-
payload = json.dumps({
|
|
1508
|
-
"grant_type": "refresh_token",
|
|
1509
|
-
"client_id": _COZELOOP_CLIENT_ID,
|
|
1510
|
-
"refresh_token": refresh_tok,
|
|
1511
|
-
}).encode()
|
|
1512
|
-
req = urllib.request.Request(
|
|
1513
|
-
f"{_COZE_API}/api/permission/oauth2/token",
|
|
1514
|
-
data=payload,
|
|
1515
|
-
headers={
|
|
1516
|
-
"Content-Type": "application/json",
|
|
1517
|
-
"x-tt-env": "ppe_cozelab",
|
|
1518
|
-
"x-use-ppe": "1",
|
|
1519
|
-
},
|
|
1520
|
-
)
|
|
1521
|
-
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
1522
|
-
data = json.loads(resp.read())
|
|
1523
|
-
if data.get("access_token"):
|
|
1524
|
-
existing = _load_credentials() or {}
|
|
1525
|
-
creds = {
|
|
1526
|
-
"access_token": data["access_token"],
|
|
1527
|
-
"refresh_token": data.get("refresh_token", refresh_tok),
|
|
1528
|
-
"expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
|
|
1529
|
-
"workspace_id": existing.get("workspace_id", ""),
|
|
1530
|
-
}
|
|
1531
|
-
_save_credentials(creds)
|
|
1532
|
-
return creds["access_token"]
|
|
1533
|
-
except Exception:
|
|
1534
|
-
pass
|
|
1535
|
-
return None
|
|
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
|
-
|
|
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.
|
|
1583
|
-
creds = _load_credentials()
|
|
1584
|
-
if creds:
|
|
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()
|
|
1596
|
-
# -------------------------------------------------------------------------
|
|
1597
|
-
|
|
1598
|
-
# --- SDK Import ---
|
|
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():
|
|
1664
|
-
import cozeloop
|
|
1665
|
-
from cozeloop.spec.tracespec import (
|
|
1666
|
-
Runtime, ModelInput, ModelMessage, ModelToolChoice,
|
|
1667
|
-
ModelOutput, ModelChoice, ModelToolCall, ModelToolCallFunction,
|
|
1668
|
-
ModelMessagePart, ModelMessagePartType
|
|
1669
|
-
)
|
|
1670
|
-
else:
|
|
1671
|
-
print("Error: cozeloop SDK not found and auto-install failed. Try: pip install cozeloop", file=sys.stderr)
|
|
1672
|
-
sys.exit(1)
|
|
1673
|
-
|
|
1674
|
-
# --- Configuration ---
|
|
1675
|
-
DEBUG = os.environ.get("CC_COZELOOP_DEBUG", "").lower() == "true"
|
|
1676
|
-
|
|
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
|
-
|
|
1696
|
-
def debug_log(message: str):
|
|
1697
|
-
"""Print debug message if debug mode is enabled."""
|
|
1698
|
-
hook_log(f"DEBUG {message}")
|
|
1699
|
-
if DEBUG:
|
|
1700
|
-
print(f"[COZELOOP_HOOK_DEBUG] {datetime.now().isoformat()} - {message}", file=sys.stderr)
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
# --- State Management ---
|
|
1704
|
-
|
|
1705
|
-
def get_state_file_path(transcript_path: str) -> str:
|
|
1706
|
-
"""Get the state file path for tracking processed lines."""
|
|
1707
|
-
state_dir = Path.home() / ".codex" / "cozeloop_state"
|
|
1708
|
-
state_dir.mkdir(parents=True, exist_ok=True)
|
|
1709
|
-
file_hash = hashlib.md5(transcript_path.encode()).hexdigest()[:12]
|
|
1710
|
-
return str(state_dir / f"state_{file_hash}.json")
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
def get_subagent_data_file(agent_session_id: str) -> str:
|
|
1714
|
-
"""Get the file path for storing subagent turn data."""
|
|
1715
|
-
state_dir = Path.home() / ".codex" / "cozeloop_state"
|
|
1716
|
-
state_dir.mkdir(parents=True, exist_ok=True)
|
|
1717
|
-
return str(state_dir / f"subagent_{agent_session_id}.json")
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
def save_subagent_data(agent_session_id: str, data: Dict[str, Any]):
|
|
1721
|
-
"""Save subagent turn data for later inclusion by parent hook."""
|
|
1722
|
-
path = get_subagent_data_file(agent_session_id)
|
|
1723
|
-
try:
|
|
1724
|
-
with open(path, "w") as f:
|
|
1725
|
-
json.dump(data, f, ensure_ascii=False)
|
|
1726
|
-
debug_log(f"Saved subagent data for {agent_session_id}")
|
|
1727
|
-
except Exception as e:
|
|
1728
|
-
debug_log(f"Error saving subagent data for {agent_session_id}: {e}")
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
def load_subagent_data(agent_session_id: str) -> Optional[Dict[str, Any]]:
|
|
1732
|
-
"""Load previously saved subagent turn data."""
|
|
1733
|
-
path = get_subagent_data_file(agent_session_id)
|
|
1734
|
-
if os.path.exists(path):
|
|
1735
|
-
try:
|
|
1736
|
-
with open(path, "r") as f:
|
|
1737
|
-
return json.load(f)
|
|
1738
|
-
except Exception as e:
|
|
1739
|
-
debug_log(f"Error loading subagent data for {agent_session_id}: {e}")
|
|
1740
|
-
return None
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
def load_state(state_file: str) -> Dict[str, Any]:
|
|
1744
|
-
"""Load the processing state from file."""
|
|
1745
|
-
if os.path.exists(state_file):
|
|
1746
|
-
try:
|
|
1747
|
-
with open(state_file, 'r') as f:
|
|
1748
|
-
return json.load(f)
|
|
1749
|
-
except Exception as e:
|
|
1750
|
-
debug_log(f"Error loading state: {e}")
|
|
1751
|
-
return {"last_processed_line": 0, "session_id": None, "conversation_history": []}
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
def save_state(state_file: str, state: Dict[str, Any]):
|
|
1755
|
-
"""Save the processing state to file."""
|
|
1756
|
-
try:
|
|
1757
|
-
with open(state_file, 'w') as f:
|
|
1758
|
-
json.dump(state, f)
|
|
1759
|
-
except Exception as e:
|
|
1760
|
-
debug_log(f"Error saving state: {e}")
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
# --- Rollout File Parsing ---
|
|
1764
|
-
|
|
1765
|
-
def read_rollout_messages(transcript_path: str, start_line: int = 0) -> List[Dict[str, Any]]:
|
|
1766
|
-
"""Read raw JSONL entries from the rollout file starting from a given line."""
|
|
1767
|
-
entries = []
|
|
1768
|
-
try:
|
|
1769
|
-
with open(transcript_path, 'r') as f:
|
|
1770
|
-
for i, line in enumerate(f):
|
|
1771
|
-
if i < start_line:
|
|
1772
|
-
continue
|
|
1773
|
-
line = line.strip()
|
|
1774
|
-
if line:
|
|
1775
|
-
try:
|
|
1776
|
-
entry = json.loads(line)
|
|
1777
|
-
entry['_line_number'] = i
|
|
1778
|
-
entries.append(entry)
|
|
1779
|
-
except json.JSONDecodeError as e:
|
|
1780
|
-
debug_log(f"Error parsing line {i}: {e}")
|
|
1781
|
-
except Exception as e:
|
|
1782
|
-
debug_log(f"Error reading rollout file: {e}")
|
|
1783
|
-
return entries
|
|
1784
|
-
|
|
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
|
-
|
|
1881
|
-
def parse_session_meta(entries: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
1882
|
-
"""Extract session identity from session_meta entry."""
|
|
1883
|
-
result = {
|
|
1884
|
-
"session_id": None,
|
|
1885
|
-
"parent_session_id": None,
|
|
1886
|
-
"agent_nickname": None,
|
|
1887
|
-
"agent_role": None,
|
|
1888
|
-
"is_subagent": False,
|
|
1889
|
-
"subagent_content_start_line": None,
|
|
1890
|
-
}
|
|
1891
|
-
for entry in entries:
|
|
1892
|
-
if entry.get("type") != "session_meta":
|
|
1893
|
-
continue
|
|
1894
|
-
p = entry.get("payload", {})
|
|
1895
|
-
result["session_id"] = p.get("id")
|
|
1896
|
-
result["agent_nickname"] = p.get("agent_nickname")
|
|
1897
|
-
result["agent_role"] = p.get("agent_role")
|
|
1898
|
-
|
|
1899
|
-
source = p.get("source", "")
|
|
1900
|
-
if isinstance(source, dict):
|
|
1901
|
-
thread_spawn = source.get("subagent", {}).get("thread_spawn", {})
|
|
1902
|
-
parent_id = thread_spawn.get("parent_thread_id")
|
|
1903
|
-
if parent_id:
|
|
1904
|
-
result["parent_session_id"] = parent_id
|
|
1905
|
-
result["is_subagent"] = True
|
|
1906
|
-
break
|
|
1907
|
-
|
|
1908
|
-
if result["is_subagent"]:
|
|
1909
|
-
meta_count = 0
|
|
1910
|
-
for entry in entries:
|
|
1911
|
-
if entry.get("type") == "session_meta":
|
|
1912
|
-
meta_count += 1
|
|
1913
|
-
if meta_count == 2:
|
|
1914
|
-
result["subagent_content_start_line"] = entry.get("_line_number", 0) + 1
|
|
1915
|
-
break
|
|
1916
|
-
|
|
1917
|
-
return result
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
# --- Message Content Helpers ---
|
|
1921
|
-
|
|
1922
|
-
def is_real_user_message(payload: Dict[str, Any]) -> bool:
|
|
1923
|
-
"""Check whether a response_item/message(user) entry is a real user input."""
|
|
1924
|
-
if payload.get("role") != "user":
|
|
1925
|
-
return False
|
|
1926
|
-
content = payload.get("content", [])
|
|
1927
|
-
if not isinstance(content, list):
|
|
1928
|
-
return False
|
|
1929
|
-
|
|
1930
|
-
for item in content:
|
|
1931
|
-
if not isinstance(item, dict):
|
|
1932
|
-
continue
|
|
1933
|
-
if item.get("type") != "input_text":
|
|
1934
|
-
continue
|
|
1935
|
-
text = item.get("text", "")
|
|
1936
|
-
if parse_coze_context(text):
|
|
1937
|
-
return True
|
|
1938
|
-
if text.startswith("<environment_context>"):
|
|
1939
|
-
continue
|
|
1940
|
-
if text.startswith("<permissions instructions>"):
|
|
1941
|
-
continue
|
|
1942
|
-
if text.startswith("<turn_aborted>"):
|
|
1943
|
-
continue
|
|
1944
|
-
if text.strip():
|
|
1945
|
-
return True
|
|
1946
|
-
|
|
1947
|
-
return False
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
def extract_user_text(payload: Dict[str, Any]) -> str:
|
|
1951
|
-
"""Extract the visible text from a user message payload."""
|
|
1952
|
-
parts = []
|
|
1953
|
-
for item in payload.get("content", []):
|
|
1954
|
-
if isinstance(item, dict) and item.get("type") == "input_text":
|
|
1955
|
-
text = item.get("text", "")
|
|
1956
|
-
if (parse_coze_context(text) or
|
|
1957
|
-
(not text.startswith("<environment_context>") and
|
|
1958
|
-
not text.startswith("<permissions instructions>") and
|
|
1959
|
-
not text.startswith("<turn_aborted>"))):
|
|
1960
|
-
parts.append(text)
|
|
1961
|
-
return "\n".join(parts)
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
def extract_assistant_text(payload: Dict[str, Any]) -> str:
|
|
1965
|
-
"""Extract visible text from an assistant message payload."""
|
|
1966
|
-
parts = []
|
|
1967
|
-
for item in payload.get("content", []):
|
|
1968
|
-
if isinstance(item, dict) and item.get("type") in ("output_text", "text"):
|
|
1969
|
-
parts.append(item.get("text", ""))
|
|
1970
|
-
return "\n".join(parts)
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
def extract_message_content_text(payload: Dict[str, Any]) -> str:
|
|
1974
|
-
"""Extract all text content from a message payload regardless of role."""
|
|
1975
|
-
parts = []
|
|
1976
|
-
for item in payload.get("content", []):
|
|
1977
|
-
if not isinstance(item, dict):
|
|
1978
|
-
continue
|
|
1979
|
-
text = item.get("text", "")
|
|
1980
|
-
if text:
|
|
1981
|
-
parts.append(text)
|
|
1982
|
-
return "\n".join(parts)
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
def truncate_text(text: str, limit: int = 12000) -> str:
|
|
1986
|
-
"""Truncate text to a maximum length."""
|
|
1987
|
-
if len(text) <= limit:
|
|
1988
|
-
return text
|
|
1989
|
-
return text[:limit] + "..."
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
# --- Message Grouping ---
|
|
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
|
-
|
|
2081
|
-
def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
2082
|
-
"""Group raw JSONL entries into conversation turns.
|
|
2083
|
-
|
|
2084
|
-
Turn lifecycle:
|
|
2085
|
-
- Opened by: event_msg / task_started
|
|
2086
|
-
- Closed by: event_msg / task_complete (or next task_started)
|
|
2087
|
-
|
|
2088
|
-
Within a turn we collect:
|
|
2089
|
-
- user_message : first real user input
|
|
2090
|
-
- assistant_messages : response_item/message role=assistant items
|
|
2091
|
-
- tool_calls : response_item/function_call items (excl. spawn/wait_agent)
|
|
2092
|
-
- tool_results : response_item/function_call_output items (excl. spawn/wait)
|
|
2093
|
-
- input_messages : messages sent as model input up to the user message
|
|
2094
|
-
- subagent_calls : spawn_agent calls with their agent_id and final result
|
|
2095
|
-
"""
|
|
2096
|
-
turns = []
|
|
2097
|
-
current_turn: Optional[Dict[str, Any]] = None
|
|
2098
|
-
pending_calls: Dict[str, Dict[str, Any]] = {}
|
|
2099
|
-
|
|
2100
|
-
for entry in entries:
|
|
2101
|
-
entry_type = entry.get("type")
|
|
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")
|
|
2106
|
-
|
|
2107
|
-
# --- Turn lifecycle events ---
|
|
2108
|
-
if entry_type == "event_msg":
|
|
2109
|
-
msg_type = payload.get("type")
|
|
2110
|
-
if msg_type == "task_started":
|
|
2111
|
-
if current_turn is not None:
|
|
2112
|
-
turns.append(current_turn)
|
|
2113
|
-
current_turn = {
|
|
2114
|
-
"turn_id": payload.get("turn_id"),
|
|
2115
|
-
"user_message": None,
|
|
2116
|
-
"user_message_text": "",
|
|
2117
|
-
"assistant_messages": [],
|
|
2118
|
-
"tool_calls": [],
|
|
2119
|
-
"tool_results": [],
|
|
2120
|
-
"input_messages": [],
|
|
2121
|
-
"subagent_calls": [],
|
|
2122
|
-
"token_usage": {},
|
|
2123
|
-
"start_line": entry.get("_line_number", 0),
|
|
2124
|
-
}
|
|
2125
|
-
pending_calls = {}
|
|
2126
|
-
elif msg_type == "task_complete":
|
|
2127
|
-
if current_turn is not None:
|
|
2128
|
-
turns.append(current_turn)
|
|
2129
|
-
current_turn = None
|
|
2130
|
-
pending_calls = {}
|
|
2131
|
-
elif msg_type == "token_count":
|
|
2132
|
-
if current_turn is not None:
|
|
2133
|
-
info = payload.get("info") or {}
|
|
2134
|
-
current_turn["token_usage"] = info.get("last_token_usage", {})
|
|
2135
|
-
continue
|
|
2136
|
-
|
|
2137
|
-
# --- Content items ---
|
|
2138
|
-
if entry_type == "response_item":
|
|
2139
|
-
item_type = payload.get("type")
|
|
2140
|
-
|
|
2141
|
-
if item_type == "message":
|
|
2142
|
-
role = payload.get("role")
|
|
2143
|
-
if role == "user" and is_real_user_message(payload):
|
|
2144
|
-
if current_turn is not None and current_turn["user_message"] is None:
|
|
2145
|
-
current_turn["user_message"] = payload
|
|
2146
|
-
current_turn["user_message_text"] = extract_user_text(payload)
|
|
2147
|
-
if current_turn is not None:
|
|
2148
|
-
current_turn["input_messages"].append({
|
|
2149
|
-
"role": "user",
|
|
2150
|
-
"content": extract_user_text(payload),
|
|
2151
|
-
})
|
|
2152
|
-
elif role == "assistant":
|
|
2153
|
-
if current_turn is not None:
|
|
2154
|
-
current_turn["assistant_messages"].append(payload)
|
|
2155
|
-
elif role in ("developer", "system"):
|
|
2156
|
-
if current_turn is not None:
|
|
2157
|
-
current_turn["input_messages"].append({
|
|
2158
|
-
"role": role,
|
|
2159
|
-
"content": extract_message_content_text(payload),
|
|
2160
|
-
})
|
|
2161
|
-
else:
|
|
2162
|
-
if current_turn is not None:
|
|
2163
|
-
text = extract_message_content_text(payload)
|
|
2164
|
-
if text:
|
|
2165
|
-
current_turn["input_messages"].append({
|
|
2166
|
-
"role": role or "user",
|
|
2167
|
-
"content": text,
|
|
2168
|
-
})
|
|
2169
|
-
|
|
2170
|
-
elif item_type == "function_call":
|
|
2171
|
-
if current_turn is None:
|
|
2172
|
-
continue
|
|
2173
|
-
call_id = payload.get("call_id")
|
|
2174
|
-
name = payload.get("name", "")
|
|
2175
|
-
args_raw = payload.get("arguments", "{}")
|
|
2176
|
-
try:
|
|
2177
|
-
args = json.loads(args_raw)
|
|
2178
|
-
except (json.JSONDecodeError, TypeError):
|
|
2179
|
-
args = {"_raw": args_raw}
|
|
2180
|
-
|
|
2181
|
-
if name == "spawn_agent":
|
|
2182
|
-
subagent_call = {
|
|
2183
|
-
"call_id": call_id,
|
|
2184
|
-
"agent_id": None,
|
|
2185
|
-
"nickname": None,
|
|
2186
|
-
"role": args.get("agent_type"),
|
|
2187
|
-
"message": args.get("message", ""),
|
|
2188
|
-
"model": args.get("model"),
|
|
2189
|
-
"result": None,
|
|
2190
|
-
"_start_ts": payload.get("_ts"),
|
|
2191
|
-
"_end_ts": None,
|
|
2192
|
-
}
|
|
2193
|
-
current_turn["subagent_calls"].append(subagent_call)
|
|
2194
|
-
pending_calls[call_id] = {"kind": "spawn", "subagent_call": subagent_call}
|
|
2195
|
-
elif name == "wait_agent":
|
|
2196
|
-
pending_calls[call_id] = {
|
|
2197
|
-
"kind": "wait",
|
|
2198
|
-
"ids": args.get("ids", []),
|
|
2199
|
-
"_start_ts": payload.get("_ts"),
|
|
2200
|
-
}
|
|
2201
|
-
else:
|
|
2202
|
-
current_turn["tool_calls"].append({
|
|
2203
|
-
"call_id": call_id,
|
|
2204
|
-
"name": name,
|
|
2205
|
-
"input": args,
|
|
2206
|
-
"_ts": payload.get("_ts"),
|
|
2207
|
-
})
|
|
2208
|
-
pending_calls[call_id] = {"kind": "tool", "_start_ts": payload.get("_ts")}
|
|
2209
|
-
|
|
2210
|
-
elif item_type == "function_call_output":
|
|
2211
|
-
if current_turn is None:
|
|
2212
|
-
continue
|
|
2213
|
-
call_id = payload.get("call_id")
|
|
2214
|
-
raw_output = payload.get("output", "")
|
|
2215
|
-
|
|
2216
|
-
pending = pending_calls.get(call_id, {})
|
|
2217
|
-
kind = pending.get("kind", "tool")
|
|
2218
|
-
|
|
2219
|
-
if kind == "spawn":
|
|
2220
|
-
subagent_call = pending.get("subagent_call")
|
|
2221
|
-
if subagent_call is not None:
|
|
2222
|
-
try:
|
|
2223
|
-
out = json.loads(raw_output) if isinstance(raw_output, str) else raw_output
|
|
2224
|
-
subagent_call["agent_id"] = out.get("agent_id")
|
|
2225
|
-
subagent_call["nickname"] = out.get("nickname")
|
|
2226
|
-
except (json.JSONDecodeError, TypeError, AttributeError):
|
|
2227
|
-
pass
|
|
2228
|
-
subagent_call["_end_ts"] = payload.get("_ts")
|
|
2229
|
-
|
|
2230
|
-
elif kind == "wait":
|
|
2231
|
-
try:
|
|
2232
|
-
out = json.loads(raw_output) if isinstance(raw_output, str) else raw_output
|
|
2233
|
-
status = out.get("status", {}) if isinstance(out, dict) else {}
|
|
2234
|
-
for agent_id, agent_status in status.items():
|
|
2235
|
-
result_text = None
|
|
2236
|
-
if isinstance(agent_status, dict):
|
|
2237
|
-
result_text = agent_status.get("completed")
|
|
2238
|
-
for sc in current_turn["subagent_calls"]:
|
|
2239
|
-
if sc.get("agent_id") == agent_id and sc.get("result") is None:
|
|
2240
|
-
sc["result"] = result_text
|
|
2241
|
-
sc["_end_ts"] = payload.get("_ts")
|
|
2242
|
-
break
|
|
2243
|
-
except (json.JSONDecodeError, TypeError, AttributeError):
|
|
2244
|
-
pass
|
|
2245
|
-
|
|
2246
|
-
else:
|
|
2247
|
-
current_turn["tool_results"].append({
|
|
2248
|
-
"call_id": call_id,
|
|
2249
|
-
"output": raw_output,
|
|
2250
|
-
"_ts": payload.get("_ts"),
|
|
2251
|
-
})
|
|
2252
|
-
|
|
2253
|
-
if current_turn is not None:
|
|
2254
|
-
turns.append(current_turn)
|
|
2255
|
-
|
|
2256
|
-
# Drop turns with no user input and no assistant response
|
|
2257
|
-
turns = [
|
|
2258
|
-
t for t in turns
|
|
2259
|
-
if t["user_message"] is not None or t["assistant_messages"]
|
|
2260
|
-
]
|
|
2261
|
-
|
|
2262
|
-
return turns
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
# --- CozeLoop Trace Reporting ---
|
|
2266
|
-
|
|
2267
|
-
def _make_model_message(role: str, content: str = "", tool_calls: list = None,
|
|
2268
|
-
tool_call_id: str = "") -> ModelMessage:
|
|
2269
|
-
"""Helper to create a CozeLoop ModelMessage."""
|
|
2270
|
-
return ModelMessage(
|
|
2271
|
-
role=role,
|
|
2272
|
-
content=content,
|
|
2273
|
-
reasoning_content="",
|
|
2274
|
-
parts=[],
|
|
2275
|
-
name="",
|
|
2276
|
-
tool_calls=tool_calls or [],
|
|
2277
|
-
tool_call_id=tool_call_id or "",
|
|
2278
|
-
metadata={}
|
|
2279
|
-
)
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_name: str = "codex",
|
|
2283
|
-
history_context: Optional[List[Dict[str, Any]]] = None) -> Optional[List[Dict[str, Any]]]:
|
|
2284
|
-
"""Send conversation turns to CozeLoop for tracing.
|
|
2285
|
-
|
|
2286
|
-
Span hierarchy:
|
|
2287
|
-
root_span (codex_request) [input=user_input, output=final_response]
|
|
2288
|
-
+-- turn_span (turn_0, turn_1, ...)
|
|
2289
|
-
|-- model_span (assistant_response)
|
|
2290
|
-
|-- tool_span (tool calls)
|
|
2291
|
-
|-- subagent_span (subagent calls with nested turns)
|
|
2292
|
-
|
|
2293
|
-
Returns the updated history_context on success, or None on failure.
|
|
2294
|
-
"""
|
|
2295
|
-
if not turns:
|
|
2296
|
-
return history_context
|
|
2297
|
-
|
|
2298
|
-
debug_log(f"Initializing CozeLoop client for session: {session_id}")
|
|
2299
|
-
token = get_fresh_token()
|
|
2300
|
-
if token:
|
|
2301
|
-
os.environ["COZELOOP_API_TOKEN"] = token
|
|
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)
|
|
2331
|
-
ctx: List[Dict[str, Any]] = list(history_context) if history_context else []
|
|
2332
|
-
|
|
2333
|
-
try:
|
|
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:
|
|
2342
|
-
root_span.set_runtime(Runtime(library="codex-cli"))
|
|
2343
|
-
root_tags = {
|
|
2344
|
-
"thread_id": session_id,
|
|
2345
|
-
"total_turns": len(turns),
|
|
2346
|
-
"source": "codex_cli",
|
|
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 = {
|
|
2354
|
-
"thread_id": session_id,
|
|
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)
|
|
2367
|
-
|
|
2368
|
-
# Set root span input: all user messages
|
|
2369
|
-
root_input_parts = []
|
|
2370
|
-
for turn in turns:
|
|
2371
|
-
text = turn.get("user_message_text", "")
|
|
2372
|
-
if text:
|
|
2373
|
-
root_input_parts.append(text)
|
|
2374
|
-
if root_input_parts:
|
|
2375
|
-
root_span.set_input(truncate_text("\n\n".join(root_input_parts)))
|
|
2376
|
-
|
|
2377
|
-
# Set root span output: all assistant messages
|
|
2378
|
-
root_output_parts = []
|
|
2379
|
-
for turn in turns:
|
|
2380
|
-
for assistant_payload in turn.get("assistant_messages", []):
|
|
2381
|
-
assistant_text = extract_assistant_text(assistant_payload)
|
|
2382
|
-
if assistant_text:
|
|
2383
|
-
root_output_parts.append(assistant_text)
|
|
2384
|
-
if root_output_parts:
|
|
2385
|
-
root_span.set_output(truncate_text("\n\n".join(root_output_parts)))
|
|
2386
|
-
|
|
2387
|
-
# Process each turn
|
|
2388
|
-
for i, turn in enumerate(turns):
|
|
2389
|
-
try:
|
|
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:
|
|
2394
|
-
turn_span.set_runtime(Runtime(library="codex-cli"))
|
|
2395
|
-
_turn_tags = {
|
|
2396
|
-
"thread_id": session_id,
|
|
2397
|
-
"turn_index": i,
|
|
2398
|
-
"turn_id": turn.get("turn_id", ""),
|
|
2399
|
-
"source": "codex_cli",
|
|
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)
|
|
2407
|
-
|
|
2408
|
-
# --- Model span for assistant response ---
|
|
2409
|
-
if turn.get("assistant_messages"):
|
|
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:
|
|
2417
|
-
model_span.set_runtime(Runtime(library="codex-cli"))
|
|
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
|
-
})
|
|
2426
|
-
|
|
2427
|
-
# Build input messages: history + current turn input
|
|
2428
|
-
turn_input = turn.get("input_messages", [])
|
|
2429
|
-
if not turn_input:
|
|
2430
|
-
turn_input = [{"role": "user", "content": turn.get("user_message_text", "")}]
|
|
2431
|
-
input_messages = ctx + turn_input
|
|
2432
|
-
|
|
2433
|
-
model_messages = []
|
|
2434
|
-
for msg in input_messages:
|
|
2435
|
-
model_messages.append(_make_model_message(
|
|
2436
|
-
role=msg.get("role", "user"),
|
|
2437
|
-
content=msg.get("content", "")
|
|
2438
|
-
))
|
|
2439
|
-
|
|
2440
|
-
model_span.set_input(ModelInput(
|
|
2441
|
-
messages=model_messages,
|
|
2442
|
-
tools=[],
|
|
2443
|
-
tool_choice=ModelToolChoice(type="", function=None)
|
|
2444
|
-
))
|
|
2445
|
-
|
|
2446
|
-
# Build output choices
|
|
2447
|
-
choices = []
|
|
2448
|
-
for assistant_payload in turn["assistant_messages"]:
|
|
2449
|
-
assistant_text = extract_assistant_text(assistant_payload)
|
|
2450
|
-
# Extract tool calls from assistant content
|
|
2451
|
-
tc_list = []
|
|
2452
|
-
for item in assistant_payload.get("content", []):
|
|
2453
|
-
if isinstance(item, dict) and item.get("type") == "function_call":
|
|
2454
|
-
tc_list.append(ModelToolCall(
|
|
2455
|
-
id=item.get("call_id", ""),
|
|
2456
|
-
type="function",
|
|
2457
|
-
function=ModelToolCallFunction(
|
|
2458
|
-
name=item.get("name", ""),
|
|
2459
|
-
arguments=item.get("arguments", "")
|
|
2460
|
-
)
|
|
2461
|
-
))
|
|
2462
|
-
|
|
2463
|
-
finish_reason = "tool_calls" if tc_list else "stop"
|
|
2464
|
-
choices.append(ModelChoice(
|
|
2465
|
-
finish_reason=finish_reason,
|
|
2466
|
-
index=len(choices),
|
|
2467
|
-
message=ModelMessage(
|
|
2468
|
-
role="assistant",
|
|
2469
|
-
content=assistant_text,
|
|
2470
|
-
reasoning_content="",
|
|
2471
|
-
parts=[],
|
|
2472
|
-
name="",
|
|
2473
|
-
tool_calls=tc_list,
|
|
2474
|
-
tool_call_id="",
|
|
2475
|
-
metadata={}
|
|
2476
|
-
)
|
|
2477
|
-
))
|
|
2478
|
-
|
|
2479
|
-
model_span.set_output(ModelOutput(choices=choices))
|
|
2480
|
-
|
|
2481
|
-
# Set token usage
|
|
2482
|
-
token_usage = turn.get("token_usage", {})
|
|
2483
|
-
input_tokens = token_usage.get("input_tokens", 0)
|
|
2484
|
-
output_tokens = token_usage.get("output_tokens", 0)
|
|
2485
|
-
if input_tokens > 0:
|
|
2486
|
-
model_span.set_input_tokens(input_tokens)
|
|
2487
|
-
if output_tokens > 0:
|
|
2488
|
-
model_span.set_output_tokens(output_tokens)
|
|
2489
|
-
|
|
2490
|
-
# --- Tool call spans ---
|
|
2491
|
-
for tool_call in turn.get("tool_calls", []):
|
|
2492
|
-
tool_name = tool_call.get("name", "unknown")
|
|
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:
|
|
2502
|
-
tool_span.set_runtime(Runtime(library="codex-cli"))
|
|
2503
|
-
tool_tags = {
|
|
2504
|
-
"tool_name": tool_name,
|
|
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)
|
|
2513
|
-
tool_span.set_input(
|
|
2514
|
-
json.dumps(tool_call.get("input", {}), ensure_ascii=False)[:2000]
|
|
2515
|
-
)
|
|
2516
|
-
# Find matching tool result
|
|
2517
|
-
for result in turn.get("tool_results", []):
|
|
2518
|
-
if result.get("call_id") == call_id:
|
|
2519
|
-
output = result.get("output", "")
|
|
2520
|
-
if isinstance(output, str) and len(output) > 2000:
|
|
2521
|
-
output = output[:2000] + "..."
|
|
2522
|
-
tool_span.set_output(str(output))
|
|
2523
|
-
break
|
|
2524
|
-
|
|
2525
|
-
# --- Subagent spans ---
|
|
2526
|
-
for sc in turn.get("subagent_calls", []):
|
|
2527
|
-
agent_id = sc.get("agent_id") or "unknown"
|
|
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)
|
|
2532
|
-
|
|
2533
|
-
with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=subagent_start_dt) as subagent_span:
|
|
2534
|
-
subagent_span.set_runtime(Runtime(library="codex-cli"))
|
|
2535
|
-
subagent_tags = {
|
|
2536
|
-
"agent_id": agent_id,
|
|
2537
|
-
"agent_nickname": nickname,
|
|
2538
|
-
"agent_role": sc.get("role") or "",
|
|
2539
|
-
"agent_model": sc.get("model") or "",
|
|
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)
|
|
2547
|
-
subagent_span.set_input(sc.get("message", "")[:2000])
|
|
2548
|
-
|
|
2549
|
-
# Load and include saved subagent turn data
|
|
2550
|
-
sa_data = load_subagent_data(agent_id)
|
|
2551
|
-
if sa_data and sa_data.get("turns"):
|
|
2552
|
-
sa_turns = sa_data["turns"]
|
|
2553
|
-
sa_model = sa_data.get("model_name", "codex")
|
|
2554
|
-
|
|
2555
|
-
for si, sa_turn in enumerate(sa_turns):
|
|
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:
|
|
2564
|
-
sa_turn_span.set_runtime(Runtime(library="codex-cli"))
|
|
2565
|
-
sa_turn_tags = {
|
|
2566
|
-
"turn_index": si,
|
|
2567
|
-
"turn_id": sa_turn.get("turn_id", ""),
|
|
2568
|
-
"agent_name": nickname,
|
|
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)
|
|
2576
|
-
|
|
2577
|
-
# Subagent model span
|
|
2578
|
-
if sa_turn.get("assistant_messages"):
|
|
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:
|
|
2586
|
-
sa_model_span.set_runtime(Runtime(library="codex-cli"))
|
|
2587
|
-
sa_model_span.set_model_name(sa_model)
|
|
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)
|
|
2595
|
-
|
|
2596
|
-
sa_input = sa_turn.get("input_messages", [])
|
|
2597
|
-
if not sa_input:
|
|
2598
|
-
sa_input = [{"role": "user", "content": sa_turn.get("user_message_text", "")}]
|
|
2599
|
-
sa_model_messages = []
|
|
2600
|
-
for msg in sa_input:
|
|
2601
|
-
sa_model_messages.append(_make_model_message(
|
|
2602
|
-
role=msg.get("role", "user"),
|
|
2603
|
-
content=msg.get("content", "")
|
|
2604
|
-
))
|
|
2605
|
-
sa_model_span.set_input(ModelInput(
|
|
2606
|
-
messages=sa_model_messages,
|
|
2607
|
-
tools=[],
|
|
2608
|
-
tool_choice=ModelToolChoice(type="", function=None)
|
|
2609
|
-
))
|
|
2610
|
-
|
|
2611
|
-
sa_choices = []
|
|
2612
|
-
for ap in sa_turn["assistant_messages"]:
|
|
2613
|
-
sa_choices.append(ModelChoice(
|
|
2614
|
-
finish_reason="stop",
|
|
2615
|
-
index=len(sa_choices),
|
|
2616
|
-
message=ModelMessage(
|
|
2617
|
-
role="assistant",
|
|
2618
|
-
content=extract_assistant_text(ap),
|
|
2619
|
-
reasoning_content="",
|
|
2620
|
-
parts=[],
|
|
2621
|
-
name="",
|
|
2622
|
-
tool_calls=[],
|
|
2623
|
-
tool_call_id="",
|
|
2624
|
-
metadata={}
|
|
2625
|
-
)
|
|
2626
|
-
))
|
|
2627
|
-
sa_model_span.set_output(ModelOutput(choices=sa_choices))
|
|
2628
|
-
|
|
2629
|
-
sa_token = sa_turn.get("token_usage", {})
|
|
2630
|
-
if sa_token.get("input_tokens", 0) > 0:
|
|
2631
|
-
sa_model_span.set_input_tokens(sa_token["input_tokens"])
|
|
2632
|
-
if sa_token.get("output_tokens", 0) > 0:
|
|
2633
|
-
sa_model_span.set_output_tokens(sa_token["output_tokens"])
|
|
2634
|
-
|
|
2635
|
-
# Subagent tool spans
|
|
2636
|
-
for sa_tc in sa_turn.get("tool_calls", []):
|
|
2637
|
-
sa_tool_name = sa_tc.get("name", "unknown")
|
|
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:
|
|
2647
|
-
sa_tool_span.set_runtime(Runtime(library="codex-cli"))
|
|
2648
|
-
sa_tool_tags = {
|
|
2649
|
-
"tool_name": sa_tool_name,
|
|
2650
|
-
"call_id": sa_cid,
|
|
2651
|
-
"agent_name": nickname,
|
|
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)
|
|
2659
|
-
sa_tool_span.set_input(
|
|
2660
|
-
json.dumps(sa_tc.get("input", {}), ensure_ascii=False)[:2000]
|
|
2661
|
-
)
|
|
2662
|
-
for sa_r in sa_turn.get("tool_results", []):
|
|
2663
|
-
if sa_r.get("call_id") == sa_cid:
|
|
2664
|
-
sa_out = sa_r.get("output", "")
|
|
2665
|
-
if isinstance(sa_out, str) and len(sa_out) > 2000:
|
|
2666
|
-
sa_out = sa_out[:2000] + "..."
|
|
2667
|
-
sa_tool_span.set_output(str(sa_out))
|
|
2668
|
-
break
|
|
2669
|
-
|
|
2670
|
-
debug_log(f"Included {len(sa_turns)} subagent turns for {nickname} ({agent_id})")
|
|
2671
|
-
else:
|
|
2672
|
-
debug_log(f"No saved data found for subagent {nickname} ({agent_id})")
|
|
2673
|
-
|
|
2674
|
-
result_text = sc.get("result") or ""
|
|
2675
|
-
if len(result_text) > 2000:
|
|
2676
|
-
result_text = result_text[:2000] + "..."
|
|
2677
|
-
subagent_span.set_output(result_text)
|
|
2678
|
-
|
|
2679
|
-
# Update conversation context for subsequent turns
|
|
2680
|
-
if turn.get("user_message_text"):
|
|
2681
|
-
ctx.append({"role": "user", "content": turn["user_message_text"]})
|
|
2682
|
-
for assistant_payload in turn.get("assistant_messages", []):
|
|
2683
|
-
assistant_text = extract_assistant_text(assistant_payload)
|
|
2684
|
-
if assistant_text:
|
|
2685
|
-
ctx.append({"role": "assistant", "content": assistant_text})
|
|
2686
|
-
|
|
2687
|
-
except Exception as e:
|
|
2688
|
-
debug_log(f"Error processing turn {i}: {e}")
|
|
2689
|
-
continue
|
|
2690
|
-
|
|
2691
|
-
hook_log(f"processed turns={len(turns)} session_id={session_id}")
|
|
2692
|
-
debug_log(f"Successfully processed {len(turns)} turn(s) for session {session_id}")
|
|
2693
|
-
|
|
2694
|
-
except Exception as e:
|
|
2695
|
-
hook_log(f"send exception={repr(e)}")
|
|
2696
|
-
debug_log(f"An error occurred while sending traces to CozeLoop: {e}")
|
|
2697
|
-
return None
|
|
2698
|
-
finally:
|
|
2699
|
-
client.close()
|
|
2700
|
-
hook_log("client closed")
|
|
2701
|
-
debug_log("CozeLoop client closed.")
|
|
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
|
-
|
|
2707
|
-
return ctx
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
# --- Main Execution ---
|
|
2711
|
-
|
|
2712
|
-
def main():
|
|
2713
|
-
"""Main entry point for the Codex CozeLoop hook."""
|
|
2714
|
-
print("[CozeLoop] Hook triggered (Codex).", file=sys.stderr)
|
|
2715
|
-
hook_log("hook triggered")
|
|
2716
|
-
debug_log("Codex CozeLoop hook started.")
|
|
2717
|
-
|
|
2718
|
-
# Check if tracing is enabled
|
|
2719
|
-
if os.environ.get("TRACE_TO_COZELOOP", "").lower() == "false":
|
|
2720
|
-
hook_log("skip trace disabled")
|
|
2721
|
-
debug_log("TRACE_TO_COZELOOP is set to 'false', skipping")
|
|
2722
|
-
return
|
|
2723
|
-
|
|
2724
|
-
# Read hook input from stdin
|
|
2725
|
-
try:
|
|
2726
|
-
raw_input = sys.stdin.read().strip()
|
|
2727
|
-
if not raw_input:
|
|
2728
|
-
hook_log("stdin empty, trying fallback")
|
|
2729
|
-
debug_log("No input received from stdin")
|
|
2730
|
-
hook_input = recover_hook_input("empty_stdin")
|
|
2731
|
-
if not hook_input:
|
|
2732
|
-
return
|
|
2733
|
-
else:
|
|
2734
|
-
hook_input = json.loads(raw_input)
|
|
2735
|
-
except Exception as e:
|
|
2736
|
-
hook_log(f"stdin parse error={repr(e)}, trying fallback")
|
|
2737
|
-
debug_log(f"Error reading hook input from stdin: {e}")
|
|
2738
|
-
hook_input = recover_hook_input("stdin_parse_error")
|
|
2739
|
-
if not hook_input:
|
|
2740
|
-
return
|
|
2741
|
-
|
|
2742
|
-
debug_log(f"Hook input: {json.dumps(hook_input, ensure_ascii=False)}")
|
|
2743
|
-
|
|
2744
|
-
# Get transcript path
|
|
2745
|
-
transcript_path = hook_input.get("transcript_path")
|
|
2746
|
-
if not transcript_path:
|
|
2747
|
-
hook_log("missing transcript_path, trying fallback")
|
|
2748
|
-
debug_log("No transcript_path in hook input")
|
|
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")
|
|
2754
|
-
|
|
2755
|
-
if not os.path.exists(transcript_path):
|
|
2756
|
-
hook_log(f"transcript not found path={transcript_path}, trying fallback")
|
|
2757
|
-
debug_log(f"Transcript file not found: {transcript_path}")
|
|
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")
|
|
2763
|
-
|
|
2764
|
-
# Load state
|
|
2765
|
-
state_file = get_state_file_path(transcript_path)
|
|
2766
|
-
state = load_state(state_file)
|
|
2767
|
-
|
|
2768
|
-
# Read new entries
|
|
2769
|
-
entries = read_rollout_messages(transcript_path, state["last_processed_line"])
|
|
2770
|
-
|
|
2771
|
-
if not entries:
|
|
2772
|
-
hook_log(f"skip no new entries transcript={transcript_path}")
|
|
2773
|
-
debug_log("No new entries to process")
|
|
2774
|
-
return
|
|
2775
|
-
|
|
2776
|
-
hook_log(f"read entries={len(entries)} from_line={state['last_processed_line']} transcript={transcript_path}")
|
|
2777
|
-
debug_log(f"Read {len(entries)} new entries from line {state['last_processed_line']}")
|
|
2778
|
-
|
|
2779
|
-
# Parse session identity
|
|
2780
|
-
all_entries_for_meta = read_rollout_messages(transcript_path, 0)
|
|
2781
|
-
session_info = parse_session_meta(all_entries_for_meta)
|
|
2782
|
-
|
|
2783
|
-
session_id = session_info["session_id"] or hook_input.get("session_id", "")
|
|
2784
|
-
parent_session_id = session_info["parent_session_id"]
|
|
2785
|
-
agent_nickname = session_info["agent_nickname"]
|
|
2786
|
-
agent_role = session_info["agent_role"]
|
|
2787
|
-
is_subagent = session_info["is_subagent"]
|
|
2788
|
-
subagent_content_start = session_info.get("subagent_content_start_line")
|
|
2789
|
-
|
|
2790
|
-
# Filter subagent entries to only include their own content
|
|
2791
|
-
if is_subagent and subagent_content_start is not None:
|
|
2792
|
-
entries = [e for e in entries if e.get("_line_number", 0) >= subagent_content_start]
|
|
2793
|
-
debug_log(f"Filtered subagent entries from line {subagent_content_start}, {len(entries)} remaining")
|
|
2794
|
-
|
|
2795
|
-
# Determine model name
|
|
2796
|
-
model_name = "codex"
|
|
2797
|
-
for entry in entries:
|
|
2798
|
-
if entry.get("type") == "turn_context":
|
|
2799
|
-
model_name = entry.get("payload", {}).get("model", model_name)
|
|
2800
|
-
break
|
|
2801
|
-
|
|
2802
|
-
if not session_id:
|
|
2803
|
-
session_id = f"codex_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{os.getpid()}"
|
|
2804
|
-
|
|
2805
|
-
state["session_id"] = session_id
|
|
2806
|
-
debug_log(f"Session ID: {session_id}, parent: {parent_session_id}, "
|
|
2807
|
-
f"is_subagent: {is_subagent}, nickname: {agent_nickname}, model: {model_name}")
|
|
2808
|
-
|
|
2809
|
-
# Group entries into turns
|
|
2810
|
-
turns = group_messages_into_turns(entries)
|
|
2811
|
-
debug_log(f"Grouped into {len(turns)} turns")
|
|
2812
|
-
|
|
2813
|
-
# If this is a subagent, save data for parent to include later
|
|
2814
|
-
if is_subagent:
|
|
2815
|
-
save_subagent_data(session_id, {
|
|
2816
|
-
"session_id": session_id,
|
|
2817
|
-
"parent_session_id": parent_session_id,
|
|
2818
|
-
"agent_nickname": agent_nickname,
|
|
2819
|
-
"agent_role": agent_role,
|
|
2820
|
-
"model_name": model_name,
|
|
2821
|
-
"turns": turns[-1:],
|
|
2822
|
-
})
|
|
2823
|
-
last_line = max(e.get("_line_number", 0) for e in entries) + 1
|
|
2824
|
-
state["last_processed_line"] = last_line
|
|
2825
|
-
save_state(state_file, state)
|
|
2826
|
-
hook_log(f"subagent saved session_id={session_id} turns={len(turns[-1:])} last_line={last_line}")
|
|
2827
|
-
debug_log("Subagent data saved, hook completed")
|
|
2828
|
-
return
|
|
2829
|
-
|
|
2830
|
-
# Send turns to CozeLoop — only if at least one turn carries coze-context.
|
|
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
|
|
2840
|
-
history_context = state.get("conversation_history", [])
|
|
2841
|
-
updated_history = send_turns_to_cozeloop(
|
|
2842
|
-
turns, session_id, model_name,
|
|
2843
|
-
history_context=history_context,
|
|
2844
|
-
)
|
|
2845
|
-
if updated_history is not None:
|
|
2846
|
-
last_line = max(e.get("_line_number", 0) for e in entries) + 1
|
|
2847
|
-
state["last_processed_line"] = last_line
|
|
2848
|
-
state["conversation_history"] = updated_history
|
|
2849
|
-
save_state(state_file, state)
|
|
2850
|
-
hook_log(f"state advanced last_line={last_line} session_id={session_id}")
|
|
2851
|
-
debug_log(f"State updated, last processed line: {last_line}")
|
|
2852
|
-
else:
|
|
2853
|
-
hook_log(f"send failed state not advanced session_id={session_id}")
|
|
2854
|
-
debug_log("Send failed, state not advanced")
|
|
2855
|
-
else:
|
|
2856
|
-
hook_log(f"skip no turns session_id={session_id}")
|
|
2857
|
-
debug_log("No turns to send")
|
|
2858
|
-
|
|
2859
|
-
hook_log("hook completed")
|
|
2860
|
-
debug_log("Codex CozeLoop hook completed.")
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
if __name__ == "__main__":
|
|
2864
|
-
main()
|
|
2865
|
-
|
|
2866
|
-
`;
|
|
2867
|
-
|
|
2868
|
-
// ─── 2b. Embedded OpenClaw plugin files ─────────────────────────────────────
|
|
2869
|
-
const OPENCLAW_PLUGIN_FILES = {
|
|
2870
|
-
"dist/index.js": `import { CozeloopExporter } from "./cozeloop-exporter.js";
|
|
2871
|
-
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
2872
|
-
import { join, resolve } from "node:path";
|
|
2873
|
-
import { createRequire } from "node:module";
|
|
2874
|
-
const require = createRequire(import.meta.url);
|
|
2875
|
-
const { version: PLUGIN_VERSION } = require("../package.json");
|
|
2876
|
-
import { homedir } from "node:os";
|
|
2877
|
-
function generateId(length = 16) {
|
|
2878
|
-
const chars = "0123456789abcdef";
|
|
2879
|
-
let result = "";
|
|
2880
|
-
for (let i = 0; i < length; i++) {
|
|
2881
|
-
result += chars[Math.floor(Math.random() * chars.length)];
|
|
2882
|
-
}
|
|
2883
|
-
return result;
|
|
2884
|
-
}
|
|
2885
|
-
function safeClone(value) {
|
|
2886
|
-
if (typeof globalThis.structuredClone === "function") {
|
|
2887
|
-
return globalThis.structuredClone(value);
|
|
2888
|
-
}
|
|
2889
|
-
return JSON.parse(JSON.stringify(value));
|
|
2890
|
-
}
|
|
2891
|
-
function resolveOpenclawStateDir() {
|
|
2892
|
-
const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
2893
|
-
if (override)
|
|
2894
|
-
return resolve(override.startsWith("~") ? override.replace(/^~(?=$|[\\\\/])/, homedir()) : override);
|
|
2895
|
-
const home = homedir();
|
|
2896
|
-
const newDir = join(home, ".openclaw");
|
|
2897
|
-
try {
|
|
2898
|
-
if (existsSync(newDir))
|
|
2899
|
-
return newDir;
|
|
2900
|
-
}
|
|
2901
|
-
catch { /* ignore */ }
|
|
2902
|
-
for (const legacy of [".clawdbot", ".moldbot", ".moltbot"]) {
|
|
2903
|
-
const legacyDir = join(home, legacy);
|
|
2904
|
-
try {
|
|
2905
|
-
if (existsSync(legacyDir))
|
|
2906
|
-
return legacyDir;
|
|
2907
|
-
}
|
|
2908
|
-
catch { /* ignore */ }
|
|
2909
|
-
}
|
|
2910
|
-
return newDir;
|
|
2911
|
-
}
|
|
2912
|
-
function resolveAgentIdFromHookCtx(hookCtx) {
|
|
2913
|
-
const explicit = hookCtx.agentId?.trim()?.toLowerCase();
|
|
2914
|
-
if (explicit)
|
|
2915
|
-
return explicit;
|
|
2916
|
-
const sessionKey = hookCtx.sessionKey?.trim()?.toLowerCase();
|
|
2917
|
-
if (sessionKey) {
|
|
2918
|
-
const match = sessionKey.match(/^agent:([^:]+):/);
|
|
2919
|
-
if (match?.[1])
|
|
2920
|
-
return match[1];
|
|
2921
|
-
}
|
|
2922
|
-
return "main";
|
|
2923
|
-
}
|
|
2924
|
-
function resolveSessionFile(hookCtx) {
|
|
2925
|
-
try {
|
|
2926
|
-
const stateDir = resolveOpenclawStateDir();
|
|
2927
|
-
const agentId = resolveAgentIdFromHookCtx(hookCtx);
|
|
2928
|
-
const sessionsDir = join(stateDir, "agents", agentId, "sessions");
|
|
2929
|
-
const sessionId = (hookCtx.sessionId || "");
|
|
2930
|
-
let targetFile;
|
|
2931
|
-
const files = readdirSync(sessionsDir);
|
|
2932
|
-
if (sessionId) {
|
|
2933
|
-
for (const f of files) {
|
|
2934
|
-
if (!f.endsWith(".jsonl"))
|
|
2935
|
-
continue;
|
|
2936
|
-
if (f.includes(".deleted.") || f.includes(".reset."))
|
|
2937
|
-
continue;
|
|
2938
|
-
if (f.startsWith(sessionId)) {
|
|
2939
|
-
targetFile = join(sessionsDir, f);
|
|
2940
|
-
break;
|
|
2941
|
-
}
|
|
2942
|
-
}
|
|
2943
|
-
}
|
|
2944
|
-
if (!targetFile) {
|
|
2945
|
-
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.includes(".deleted.") && !f.includes(".reset."));
|
|
2946
|
-
if (jsonlFiles.length > 0) {
|
|
2947
|
-
targetFile = join(sessionsDir, jsonlFiles[jsonlFiles.length - 1]);
|
|
2948
|
-
}
|
|
2949
|
-
}
|
|
2950
|
-
return targetFile;
|
|
2951
|
-
}
|
|
2952
|
-
catch {
|
|
2953
|
-
return undefined;
|
|
2954
|
-
}
|
|
2955
|
-
}
|
|
2956
|
-
function formatAssistantOutput(content, stopReason) {
|
|
2957
|
-
const contentItems = Array.isArray(content) ? content : [];
|
|
2958
|
-
const toolCalls = [];
|
|
2959
|
-
const messageContent = [];
|
|
2960
|
-
for (const item of contentItems) {
|
|
2961
|
-
if (!item || typeof item !== "object") {
|
|
2962
|
-
messageContent.push(item);
|
|
2963
|
-
continue;
|
|
2964
|
-
}
|
|
2965
|
-
const itemType = item.type;
|
|
2966
|
-
if (itemType === "toolCall") {
|
|
2967
|
-
toolCalls.push({
|
|
2968
|
-
function: {
|
|
2969
|
-
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? item.input ?? {}),
|
|
2970
|
-
name: item.name ?? "",
|
|
2971
|
-
},
|
|
2972
|
-
id: item.id ?? "",
|
|
2973
|
-
type: "function",
|
|
2974
|
-
});
|
|
2975
|
-
}
|
|
2976
|
-
else if (itemType === "text") {
|
|
2977
|
-
messageContent.push({ type: "text", text: item.text ?? "" });
|
|
2978
|
-
}
|
|
2979
|
-
else if (itemType === "thinking") {
|
|
2980
|
-
messageContent.push({
|
|
2981
|
-
type: "thinking",
|
|
2982
|
-
thinking: item.thinking ?? "",
|
|
2983
|
-
signature: item.signature,
|
|
2984
|
-
});
|
|
2985
|
-
}
|
|
2986
|
-
else {
|
|
2987
|
-
messageContent.push(item);
|
|
2988
|
-
}
|
|
2989
|
-
}
|
|
2990
|
-
const message = {
|
|
2991
|
-
content: messageContent.length === 1 && messageContent[0]?.type === "text"
|
|
2992
|
-
? (messageContent[0].text ?? "")
|
|
2993
|
-
: messageContent,
|
|
2994
|
-
role: "assistant",
|
|
2995
|
-
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
2996
|
-
function_call: null,
|
|
2997
|
-
provider_specific_fields: null,
|
|
2998
|
-
};
|
|
2999
|
-
return {
|
|
3000
|
-
choices: [
|
|
3001
|
-
{
|
|
3002
|
-
finish_reason: stopReason === "toolUse" ? "tool_calls" : "stop",
|
|
3003
|
-
message,
|
|
3004
|
-
},
|
|
3005
|
-
],
|
|
3006
|
-
};
|
|
3007
|
-
}
|
|
3008
|
-
function convertAssistantContentForMessages(content) {
|
|
3009
|
-
if (!Array.isArray(content))
|
|
3010
|
-
return [{ type: "text", text: String(content ?? "") }];
|
|
3011
|
-
const result = [];
|
|
3012
|
-
for (const item of content) {
|
|
3013
|
-
if (!item || typeof item !== "object") {
|
|
3014
|
-
result.push(item);
|
|
3015
|
-
continue;
|
|
3016
|
-
}
|
|
3017
|
-
if (item.type === "toolCall") {
|
|
3018
|
-
result.push({
|
|
3019
|
-
type: "tool_use",
|
|
3020
|
-
id: item.id ?? "",
|
|
3021
|
-
name: item.name ?? "",
|
|
3022
|
-
input: item.arguments ?? item.input ?? {},
|
|
3023
|
-
});
|
|
3024
|
-
}
|
|
3025
|
-
else if (item.type === "thinking") {
|
|
3026
|
-
result.push({
|
|
3027
|
-
type: "thinking",
|
|
3028
|
-
thinking: item.thinking ?? "",
|
|
3029
|
-
signature: item.signature,
|
|
3030
|
-
});
|
|
3031
|
-
}
|
|
3032
|
-
else {
|
|
3033
|
-
result.push(item);
|
|
3034
|
-
}
|
|
3035
|
-
}
|
|
3036
|
-
return result;
|
|
3037
|
-
}
|
|
3038
|
-
function convertToolResultsForMessages(toolResultEntries) {
|
|
3039
|
-
const messages = [];
|
|
3040
|
-
for (const tr of toolResultEntries) {
|
|
3041
|
-
let textContent = "";
|
|
3042
|
-
if (Array.isArray(tr.content)) {
|
|
3043
|
-
const parts = tr.content;
|
|
3044
|
-
textContent = parts
|
|
3045
|
-
.filter((p) => p?.type === "text")
|
|
3046
|
-
.map((p) => String(p.text ?? ""))
|
|
3047
|
-
.join("\\n");
|
|
3048
|
-
}
|
|
3049
|
-
else if (typeof tr.content === "string") {
|
|
3050
|
-
textContent = tr.content;
|
|
3051
|
-
}
|
|
3052
|
-
else {
|
|
3053
|
-
textContent = JSON.stringify(tr.content);
|
|
3054
|
-
}
|
|
3055
|
-
messages.push({
|
|
3056
|
-
role: "tool",
|
|
3057
|
-
content: textContent,
|
|
3058
|
-
tool_call_id: tr.toolCallId ?? "",
|
|
3059
|
-
});
|
|
3060
|
-
}
|
|
3061
|
-
return messages;
|
|
3062
|
-
}
|
|
3063
|
-
function parseEntryWrittenAt(entry) {
|
|
3064
|
-
const ts = entry.timestamp;
|
|
3065
|
-
if (typeof ts === "string") {
|
|
3066
|
-
const ms = new Date(ts).getTime();
|
|
3067
|
-
if (!Number.isNaN(ms))
|
|
3068
|
-
return ms;
|
|
3069
|
-
}
|
|
3070
|
-
if (typeof ts === "number")
|
|
3071
|
-
return ts;
|
|
3072
|
-
return undefined;
|
|
3073
|
-
}
|
|
3074
|
-
function readCurrentTurnReactSequence(hookCtx) {
|
|
3075
|
-
try {
|
|
3076
|
-
const targetFile = resolveSessionFile(hookCtx);
|
|
3077
|
-
if (!targetFile)
|
|
3078
|
-
return { entries: [] };
|
|
3079
|
-
const raw = readFileSync(targetFile, "utf-8");
|
|
3080
|
-
const lines = raw.trim().split("\\n");
|
|
3081
|
-
let lastUserIdx = -1;
|
|
3082
|
-
let userWrittenAt;
|
|
3083
|
-
let userContent;
|
|
3084
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
3085
|
-
const line = lines[i].trim();
|
|
3086
|
-
if (!line)
|
|
3087
|
-
continue;
|
|
3088
|
-
try {
|
|
3089
|
-
const entry = JSON.parse(line);
|
|
3090
|
-
if (entry.type !== "message")
|
|
3091
|
-
continue;
|
|
3092
|
-
const msg = entry.message;
|
|
3093
|
-
if (msg?.role === "user") {
|
|
3094
|
-
lastUserIdx = i;
|
|
3095
|
-
userWrittenAt = parseEntryWrittenAt(entry);
|
|
3096
|
-
userContent = msg.content;
|
|
3097
|
-
break;
|
|
3098
|
-
}
|
|
3099
|
-
}
|
|
3100
|
-
catch {
|
|
3101
|
-
continue;
|
|
3102
|
-
}
|
|
3103
|
-
}
|
|
3104
|
-
if (lastUserIdx < 0)
|
|
3105
|
-
return { entries: [] };
|
|
3106
|
-
const entries = [];
|
|
3107
|
-
for (let i = lastUserIdx + 1; i < lines.length; i++) {
|
|
3108
|
-
const line = lines[i].trim();
|
|
3109
|
-
if (!line)
|
|
3110
|
-
continue;
|
|
3111
|
-
try {
|
|
3112
|
-
const entry = JSON.parse(line);
|
|
3113
|
-
if (entry.type !== "message")
|
|
3114
|
-
continue;
|
|
3115
|
-
const msg = entry.message;
|
|
3116
|
-
if (!msg)
|
|
3117
|
-
continue;
|
|
3118
|
-
const writtenAt = parseEntryWrittenAt(entry);
|
|
3119
|
-
if (msg.role === "assistant") {
|
|
3120
|
-
entries.push({
|
|
3121
|
-
type: "assistant",
|
|
3122
|
-
content: msg.content,
|
|
3123
|
-
provider: msg.provider,
|
|
3124
|
-
model: msg.model,
|
|
3125
|
-
usage: msg.usage,
|
|
3126
|
-
stopReason: msg.stopReason,
|
|
3127
|
-
timestamp: msg.timestamp,
|
|
3128
|
-
writtenAt,
|
|
3129
|
-
});
|
|
3130
|
-
}
|
|
3131
|
-
else if (msg.role === "toolResult") {
|
|
3132
|
-
entries.push({
|
|
3133
|
-
type: "toolResult",
|
|
3134
|
-
content: msg.content,
|
|
3135
|
-
toolCallId: msg.toolCallId,
|
|
3136
|
-
toolName: msg.toolName,
|
|
3137
|
-
isError: msg.isError,
|
|
3138
|
-
timestamp: msg.timestamp,
|
|
3139
|
-
writtenAt,
|
|
3140
|
-
});
|
|
3141
|
-
}
|
|
3142
|
-
}
|
|
3143
|
-
catch {
|
|
3144
|
-
continue;
|
|
3145
|
-
}
|
|
3146
|
-
}
|
|
3147
|
-
return { entries, userWrittenAt, userContent };
|
|
3148
|
-
}
|
|
3149
|
-
catch {
|
|
3150
|
-
return { entries: [] };
|
|
3151
|
-
}
|
|
3152
|
-
}
|
|
3153
|
-
/**
|
|
3154
|
-
* Check if a content value contains multimodal parts (non-text types like image).
|
|
3155
|
-
*/
|
|
3156
|
-
function hasMultimodalParts(content) {
|
|
3157
|
-
if (!Array.isArray(content))
|
|
3158
|
-
return false;
|
|
3159
|
-
return content.some((item) => item && typeof item === "object" && item.type && item.type !== "text");
|
|
3160
|
-
}
|
|
3161
|
-
/**
|
|
3162
|
-
* Convert session-file content parts to the expected span input format.
|
|
3163
|
-
*
|
|
3164
|
-
* Session file stores images as:
|
|
3165
|
-
* { type: "image", data: "<base64>", mimeType: "image/jpeg" }
|
|
3166
|
-
*
|
|
3167
|
-
* Span input expects:
|
|
3168
|
-
* { type: "image_url", image_url: { url: "data:image/jpeg;base64,<base64>", name: "", detail: "" } }
|
|
3169
|
-
*
|
|
3170
|
-
* If the total size of all image data exceeds 3 MB, images are replaced with
|
|
3171
|
-
* a placeholder text part to avoid oversized span input.
|
|
3172
|
-
*
|
|
3173
|
-
* Text parts are kept as-is.
|
|
3174
|
-
*/
|
|
3175
|
-
const IMAGE_SIZE_LIMIT = 1 * 1024 * 1024; // 1 MB
|
|
3176
|
-
function convertContentPartsForSpan(content) {
|
|
3177
|
-
if (!Array.isArray(content))
|
|
3178
|
-
return content;
|
|
3179
|
-
const parts = content;
|
|
3180
|
-
// Calculate total byte size of all image data (base64 length × 3/4 ≈ raw bytes)
|
|
3181
|
-
let totalImageBytes = 0;
|
|
3182
|
-
for (const part of parts) {
|
|
3183
|
-
if (part?.type === "image" && typeof part.data === "string") {
|
|
3184
|
-
totalImageBytes += Math.ceil(part.data.length * 3 / 4);
|
|
3185
|
-
}
|
|
3186
|
-
}
|
|
3187
|
-
const exceedsLimit = totalImageBytes > IMAGE_SIZE_LIMIT;
|
|
3188
|
-
return parts.map((part) => {
|
|
3189
|
-
if (!part || typeof part !== "object")
|
|
3190
|
-
return part;
|
|
3191
|
-
if (part.type === "image" && typeof part.data === "string") {
|
|
3192
|
-
if (exceedsLimit) {
|
|
3193
|
-
return {
|
|
3194
|
-
type: "text",
|
|
3195
|
-
text: "[image data removed due to large size - already processed by model]",
|
|
3196
|
-
};
|
|
3197
|
-
}
|
|
3198
|
-
const mimeType = part.mimeType || "image/png";
|
|
3199
|
-
return {
|
|
3200
|
-
type: "image_url",
|
|
3201
|
-
image_url: {
|
|
3202
|
-
url: \`data:\${mimeType};base64,\${part.data}\`,
|
|
3203
|
-
name: "",
|
|
3204
|
-
detail: "",
|
|
3205
|
-
},
|
|
3206
|
-
};
|
|
3207
|
-
}
|
|
3208
|
-
return part;
|
|
3209
|
-
});
|
|
3210
|
-
}
|
|
3211
|
-
function normalizeChannelId(input, defaultPlatform = "system") {
|
|
3212
|
-
if (!input || input === "unknown") {
|
|
3213
|
-
return \`\${defaultPlatform}/unknown\`;
|
|
3214
|
-
}
|
|
3215
|
-
if (input.includes("/")) {
|
|
3216
|
-
// 已标准化的两段格式(无冒号),直接返回
|
|
3217
|
-
if (!input.includes(":")) {
|
|
3218
|
-
return input;
|
|
3219
|
-
}
|
|
3220
|
-
// 复合格式:尝试提取飞书用户标识
|
|
3221
|
-
const feishuMatch = input.match(/(ou|oc|og)_[a-zA-Z0-9]+/);
|
|
3222
|
-
if (feishuMatch) {
|
|
3223
|
-
return \`feishu/\${feishuMatch[0]}\`;
|
|
3224
|
-
}
|
|
3225
|
-
// agent/xxx:yyy → agent/xxx
|
|
3226
|
-
const slashIdx = input.indexOf("/");
|
|
3227
|
-
const afterSlash = input.substring(slashIdx + 1);
|
|
3228
|
-
const colonIdx = afterSlash.indexOf(":");
|
|
3229
|
-
if (colonIdx > 0) {
|
|
3230
|
-
return \`\${input.substring(0, slashIdx)}/\${afterSlash.substring(0, colonIdx)}\`;
|
|
3231
|
-
}
|
|
3232
|
-
return input;
|
|
3233
|
-
}
|
|
3234
|
-
const prefix = input.split(/[_:]/)[0];
|
|
3235
|
-
switch (prefix) {
|
|
3236
|
-
case "ou":
|
|
3237
|
-
case "oc":
|
|
3238
|
-
case "og":
|
|
3239
|
-
return \`feishu/\${input}\`;
|
|
3240
|
-
case "user":
|
|
3241
|
-
case "chat":
|
|
3242
|
-
return \`feishu/\${input.slice(prefix.length + 1)}\`;
|
|
3243
|
-
case "agent":
|
|
3244
|
-
return \`agent/\${input.slice(6)}\`;
|
|
3245
|
-
default:
|
|
3246
|
-
return \`\${defaultPlatform}/\${input}\`;
|
|
3247
|
-
}
|
|
3248
|
-
}
|
|
3249
|
-
function resolveChannelId(ctx, eventFrom, defaultValue = "system/unknown") {
|
|
3250
|
-
if (ctx.conversationId && /^(user|chat):/.test(ctx.conversationId)) {
|
|
3251
|
-
return normalizeChannelId(ctx.conversationId);
|
|
3252
|
-
}
|
|
3253
|
-
if (eventFrom && /^feishu:/.test(eventFrom)) {
|
|
3254
|
-
const platformId = eventFrom.slice(7);
|
|
3255
|
-
return \`feishu/\${platformId}\`;
|
|
3256
|
-
}
|
|
3257
|
-
if (ctx.channelId && /^feishu\\/(ou|oc|og)_/.test(ctx.channelId)) {
|
|
3258
|
-
return ctx.channelId;
|
|
3259
|
-
}
|
|
3260
|
-
const raw = ctx.sessionKey || ctx.channelId || eventFrom || defaultValue;
|
|
3261
|
-
return normalizeChannelId(raw);
|
|
3262
|
-
}
|
|
3263
|
-
let lastUserChannelId;
|
|
3264
|
-
let lastUserTraceContext;
|
|
3265
|
-
let lastOpenclawSessionId;
|
|
3266
|
-
// Active agent context: set in before_agent_start, cleared in agent_end.
|
|
3267
|
-
// All hooks between these two (llm_input, llm_output, tool calls, messages)
|
|
3268
|
-
// use this to ensure every span lands in the same Trace.
|
|
3269
|
-
let activeAgentCtx;
|
|
3270
|
-
let activeAgentChannelId;
|
|
3271
|
-
// Latest user input captured from message_received, independent of any ctx.
|
|
3272
|
-
// Used by ensureRootSpan as a reliable fallback for the root span's input.
|
|
3273
|
-
let lastUserInput;
|
|
3274
|
-
let pendingToolCall;
|
|
3275
|
-
function resolveOpenclawSessionId(hookCtx, eventSessionId) {
|
|
3276
|
-
const raw = hookCtx.sessionId?.trim() || eventSessionId?.trim();
|
|
3277
|
-
if (!raw)
|
|
3278
|
-
return undefined;
|
|
3279
|
-
return raw;
|
|
3280
|
-
}
|
|
3281
|
-
const cozeloopTracePlugin = {
|
|
3282
|
-
id: "openclaw-cozeloop-trace",
|
|
3283
|
-
name: "OpenClaw CozeLoop Trace",
|
|
3284
|
-
version: PLUGIN_VERSION,
|
|
3285
|
-
description: "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
|
|
3286
|
-
activate(api) {
|
|
3287
|
-
const pluginConfig = api.pluginConfig || {};
|
|
3288
|
-
const authorization = pluginConfig.authorization;
|
|
3289
|
-
const workspaceId = pluginConfig.workspaceId;
|
|
3290
|
-
if (!authorization || !workspaceId) {
|
|
3291
|
-
api.logger.error("[CozeloopTrace] Missing required configuration: 'authorization' and 'workspaceId' must be provided");
|
|
3292
|
-
return;
|
|
3293
|
-
}
|
|
3294
|
-
const config = {
|
|
3295
|
-
endpoint: pluginConfig.endpoint || "https://api.coze.cn/v1/loop/opentelemetry",
|
|
3296
|
-
authorization,
|
|
3297
|
-
workspaceId,
|
|
3298
|
-
serviceName: pluginConfig.serviceName || "openclaw-agent",
|
|
3299
|
-
debug: pluginConfig.debug || false,
|
|
3300
|
-
batchSize: pluginConfig.batchSize || 10,
|
|
3301
|
-
batchInterval: pluginConfig.batchInterval || 5000,
|
|
3302
|
-
enabledHooks: pluginConfig.enabledHooks,
|
|
3303
|
-
};
|
|
3304
|
-
const exporter = new CozeloopExporter(api, config);
|
|
3305
|
-
const contextByChannelId = new Map();
|
|
3306
|
-
const contextByRunId = new Map();
|
|
3307
|
-
const shouldHookEnabled = (hookName) => {
|
|
3308
|
-
if (!config.enabledHooks)
|
|
3309
|
-
return true;
|
|
3310
|
-
return config.enabledHooks.includes(hookName);
|
|
3311
|
-
};
|
|
3312
|
-
const getContextByChannel = (channelId) => {
|
|
3313
|
-
return contextByChannelId.get(channelId);
|
|
3314
|
-
};
|
|
3315
|
-
const getContextByRun = (runId) => {
|
|
3316
|
-
return contextByRunId.get(runId);
|
|
3317
|
-
};
|
|
3318
|
-
const getOriginalChannelId = (runId) => {
|
|
3319
|
-
const ctx = contextByRunId.get(runId);
|
|
3320
|
-
return ctx?.originalChannelId || ctx?.channelId;
|
|
3321
|
-
};
|
|
3322
|
-
const startTurn = (runId, channelId, originalChannelId, openclawSessionId) => {
|
|
3323
|
-
const traceId = generateId(32);
|
|
3324
|
-
const ctx = {
|
|
3325
|
-
traceId,
|
|
3326
|
-
rootSpanId: generateId(16),
|
|
3327
|
-
runId,
|
|
3328
|
-
turnId: runId,
|
|
3329
|
-
channelId,
|
|
3330
|
-
originalChannelId: originalChannelId || channelId,
|
|
3331
|
-
openclawSessionId,
|
|
3332
|
-
};
|
|
3333
|
-
contextByChannelId.set(channelId, ctx);
|
|
3334
|
-
contextByRunId.set(runId, ctx);
|
|
3335
|
-
return ctx;
|
|
3336
|
-
};
|
|
3337
|
-
const endTurn = (channelId) => {
|
|
3338
|
-
const ctx = contextByChannelId.get(channelId);
|
|
3339
|
-
if (ctx) {
|
|
3340
|
-
contextByChannelId.delete(channelId);
|
|
3341
|
-
contextByRunId.delete(ctx.runId);
|
|
3342
|
-
}
|
|
3343
|
-
};
|
|
3344
|
-
const getOrCreateContext = (rawChannelId, runId, hookName) => {
|
|
3345
|
-
let channelId = rawChannelId;
|
|
3346
|
-
let activeCtx = getContextByChannel(rawChannelId);
|
|
3347
|
-
const effectiveRunId = runId || activeCtx?.runId || \`run-\${Date.now()}\`;
|
|
3348
|
-
if (rawChannelId.startsWith("agent/") && effectiveRunId) {
|
|
3349
|
-
const originalChannelId = getOriginalChannelId(effectiveRunId);
|
|
3350
|
-
if (originalChannelId) {
|
|
3351
|
-
channelId = originalChannelId;
|
|
3352
|
-
activeCtx = getContextByChannel(originalChannelId) || activeCtx;
|
|
3353
|
-
}
|
|
3354
|
-
}
|
|
3355
|
-
if (!activeCtx) {
|
|
3356
|
-
activeCtx = getContextByRun(effectiveRunId);
|
|
3357
|
-
}
|
|
3358
|
-
if (!activeCtx && rawChannelId.startsWith("agent/") && lastUserTraceContext) {
|
|
3359
|
-
activeCtx = lastUserTraceContext;
|
|
3360
|
-
channelId = lastUserChannelId || channelId;
|
|
3361
|
-
contextByChannelId.set(rawChannelId, activeCtx);
|
|
3362
|
-
contextByRunId.set(effectiveRunId, activeCtx);
|
|
3363
|
-
if (config.debug) {
|
|
3364
|
-
api.logger.info(\`[CozeloopTrace] LINKING agent to user context: hook=\${hookName}, agentChannel=\${rawChannelId}, userChannel=\${channelId}, traceId=\${activeCtx.traceId}\`);
|
|
3365
|
-
}
|
|
3366
|
-
}
|
|
3367
|
-
let isNew = false;
|
|
3368
|
-
if (!activeCtx) {
|
|
3369
|
-
activeCtx = startTurn(effectiveRunId, channelId, rawChannelId !== channelId ? rawChannelId : undefined);
|
|
3370
|
-
isNew = true;
|
|
3371
|
-
if (config.debug) {
|
|
3372
|
-
api.logger.info(\`[CozeloopTrace] NEW TraceContext created: hook=\${hookName}, channelId=\${channelId}, runId=\${effectiveRunId}, traceId=\${activeCtx.traceId}\`);
|
|
3373
|
-
}
|
|
3374
|
-
}
|
|
3375
|
-
else if (config.debug) {
|
|
3376
|
-
api.logger.info(\`[CozeloopTrace] REUSING TraceContext: hook=\${hookName}, channelId=\${channelId}, runId=\${effectiveRunId}, traceId=\${activeCtx.traceId}\`);
|
|
3377
|
-
}
|
|
3378
|
-
return { ctx: activeCtx, channelId, isNew };
|
|
3379
|
-
};
|
|
3380
|
-
// Resolve context for hooks that fire between before_agent_start and
|
|
3381
|
-
// agent_end. When an agent is active, always return that agent's context
|
|
3382
|
-
// so every span ends up in the same Trace regardless of channelId drift.
|
|
3383
|
-
const resolveActiveContext = (rawChannelId, runId, hookName) => {
|
|
3384
|
-
if (activeAgentCtx) {
|
|
3385
|
-
if (config.debug) {
|
|
3386
|
-
api.logger.info(\`[CozeloopTrace] Using activeAgentCtx for \${hookName}: traceId=\${activeAgentCtx.traceId}, rootSpanId=\${activeAgentCtx.rootSpanId}\`);
|
|
3387
|
-
}
|
|
3388
|
-
return { ctx: activeAgentCtx, channelId: activeAgentChannelId || rawChannelId };
|
|
3389
|
-
}
|
|
3390
|
-
const { ctx, channelId } = getOrCreateContext(rawChannelId, runId, hookName);
|
|
3391
|
-
return { ctx, channelId };
|
|
3392
|
-
};
|
|
3393
|
-
const createSpan = (ctx, channelId, name, type, startTime, endTime, attributes = {}, input, output, parentSpanId) => {
|
|
3394
|
-
return {
|
|
3395
|
-
name,
|
|
3396
|
-
type: type,
|
|
3397
|
-
startTime,
|
|
3398
|
-
endTime,
|
|
3399
|
-
attributes: {
|
|
3400
|
-
...attributes,
|
|
3401
|
-
"session.id": ctx.openclawSessionId || channelId,
|
|
3402
|
-
"run.id": ctx.runId,
|
|
3403
|
-
"turn.id": ctx.turnId,
|
|
3404
|
-
"openclaw.channel_id": channelId,
|
|
3405
|
-
},
|
|
3406
|
-
input,
|
|
3407
|
-
output,
|
|
3408
|
-
traceId: ctx.traceId,
|
|
3409
|
-
spanId: generateId(16),
|
|
3410
|
-
parentSpanId: parentSpanId || ctx.rootSpanId,
|
|
3411
|
-
};
|
|
3412
|
-
};
|
|
3413
|
-
const buildReactSpans = async (ctx, channelId, entries, initialInput, agentStartTime, userWrittenAt, skipCount, sessionUserContent) => {
|
|
3414
|
-
const entriesToSkip = skipCount || 0;
|
|
3415
|
-
const reactMessages = [];
|
|
3416
|
-
if (initialInput && typeof initialInput === "object") {
|
|
3417
|
-
const inputObj = initialInput;
|
|
3418
|
-
if ("messages" in inputObj && Array.isArray(inputObj.messages)) {
|
|
3419
|
-
for (const msg of inputObj.messages) {
|
|
3420
|
-
const m = { role: String(msg.role || ""), content: safeClone(msg.content) };
|
|
3421
|
-
if (msg.tool_call_id) {
|
|
3422
|
-
m.tool_call_id = String(msg.tool_call_id);
|
|
3423
|
-
}
|
|
3424
|
-
reactMessages.push(m);
|
|
3425
|
-
}
|
|
3426
|
-
}
|
|
3427
|
-
}
|
|
3428
|
-
// Enrich the last user message with multimodal content from the session
|
|
3429
|
-
// file. At llm_output time the session file is guaranteed to contain the
|
|
3430
|
-
// full user message including image parts.
|
|
3431
|
-
if (sessionUserContent && hasMultimodalParts(sessionUserContent)) {
|
|
3432
|
-
const converted = convertContentPartsForSpan(safeClone(sessionUserContent));
|
|
3433
|
-
if (config.debug) {
|
|
3434
|
-
// Log size check details
|
|
3435
|
-
const rawParts = sessionUserContent;
|
|
3436
|
-
let totalBytes = 0;
|
|
3437
|
-
let imageCount = 0;
|
|
3438
|
-
for (const p of rawParts) {
|
|
3439
|
-
if (p?.type === "image" && typeof p.data === "string") {
|
|
3440
|
-
imageCount++;
|
|
3441
|
-
totalBytes += Math.ceil(p.data.length * 3 / 4);
|
|
3442
|
-
}
|
|
3443
|
-
}
|
|
3444
|
-
const convertedParts = converted;
|
|
3445
|
-
const convertedTypes = convertedParts.map(p => String(p?.type ?? 'unknown'));
|
|
3446
|
-
api.logger.info(\`[CozeloopTrace] Multimodal enrichment: imageCount=\${imageCount}, totalImageBytes=\${totalBytes}, limit=\${IMAGE_SIZE_LIMIT}, exceedsLimit=\${totalBytes > IMAGE_SIZE_LIMIT}, convertedTypes=[\${convertedTypes.join(',')}]\`);
|
|
3447
|
-
}
|
|
3448
|
-
for (let mi = reactMessages.length - 1; mi >= 0; mi--) {
|
|
3449
|
-
if (reactMessages[mi].role === "user") {
|
|
3450
|
-
reactMessages[mi].content = converted;
|
|
3451
|
-
if (config.debug) {
|
|
3452
|
-
const parts = converted;
|
|
3453
|
-
api.logger.info(\`[CozeloopTrace] Enriched last user message in reactMessages with multimodal content: \${parts.length} parts, types=[\${parts.map(p => p.type).join(',')}]\`);
|
|
3454
|
-
}
|
|
3455
|
-
break;
|
|
3456
|
-
}
|
|
3457
|
-
}
|
|
3458
|
-
// Also update ctx.userInput so the root span carries multimodal content
|
|
3459
|
-
if (!hasMultimodalParts(ctx.userInput)) {
|
|
3460
|
-
ctx.userInput = converted;
|
|
3461
|
-
if (!lastUserInput || !hasMultimodalParts(lastUserInput)) {
|
|
3462
|
-
lastUserInput = converted;
|
|
3463
|
-
}
|
|
3464
|
-
}
|
|
3465
|
-
}
|
|
3466
|
-
else if (config.debug) {
|
|
3467
|
-
const isArray = Array.isArray(sessionUserContent);
|
|
3468
|
-
if (isArray) {
|
|
3469
|
-
const items = sessionUserContent;
|
|
3470
|
-
api.logger.info(\`[CozeloopTrace] Multimodal enrichment skipped: sessionUserContent=array[\${items.length}] types=[\${items.map(i => String(i?.type ?? typeof i)).join(',')}], hasMultimodal=false\`);
|
|
3471
|
-
}
|
|
3472
|
-
else {
|
|
3473
|
-
api.logger.info(\`[CozeloopTrace] Multimodal enrichment skipped: sessionUserContent=\${sessionUserContent === undefined ? 'undefined' : typeof sessionUserContent}\`);
|
|
3474
|
-
}
|
|
3475
|
-
}
|
|
3476
|
-
let reactRound = 0;
|
|
3477
|
-
let modelSpanCount = 0;
|
|
3478
|
-
let prevWrittenAt = userWrittenAt || agentStartTime;
|
|
3479
|
-
for (let i = 0; i < entries.length; i++) {
|
|
3480
|
-
const entry = entries[i];
|
|
3481
|
-
const entryWrittenAt = entry.writtenAt || prevWrittenAt;
|
|
3482
|
-
// Whether this entry was already exported in a previous llm_output call.
|
|
3483
|
-
const alreadyExported = i < entriesToSkip;
|
|
3484
|
-
if (entry.type === "assistant") {
|
|
3485
|
-
reactRound++;
|
|
3486
|
-
if (!alreadyExported) {
|
|
3487
|
-
modelSpanCount++;
|
|
3488
|
-
const provider = entry.provider || ctx.lastModelProvider || "unknown";
|
|
3489
|
-
const model = entry.model || ctx.lastModelId || "unknown";
|
|
3490
|
-
const spanStartTime = prevWrittenAt;
|
|
3491
|
-
const spanEndTime = entryWrittenAt;
|
|
3492
|
-
const modelSpan = createSpan(ctx, channelId, \`\${provider}/\${model}\`, "model", spanStartTime, spanEndTime, {
|
|
3493
|
-
"gen_ai.provider.name": provider,
|
|
3494
|
-
"gen_ai.request.model": model,
|
|
3495
|
-
"gen_ai.usage.input_tokens": entry.usage?.input ?? 0,
|
|
3496
|
-
"gen_ai.usage.output_tokens": entry.usage?.output ?? 0,
|
|
3497
|
-
"react_round": reactRound,
|
|
3498
|
-
}, { messages: reactMessages.map((msg) => safeClone(msg)) }, formatAssistantOutput(entry.content, entry.stopReason));
|
|
3499
|
-
await exporter.export(modelSpan);
|
|
3500
|
-
}
|
|
3501
|
-
reactMessages.push({
|
|
3502
|
-
role: "assistant",
|
|
3503
|
-
content: convertAssistantContentForMessages(entry.content),
|
|
3504
|
-
});
|
|
3505
|
-
prevWrittenAt = entryWrittenAt;
|
|
3506
|
-
}
|
|
3507
|
-
else if (entry.type === "toolResult") {
|
|
3508
|
-
if (!alreadyExported) {
|
|
3509
|
-
const toolSpanStartTime = prevWrittenAt;
|
|
3510
|
-
const toolSpanEndTime = entryWrittenAt;
|
|
3511
|
-
let toolInput = undefined;
|
|
3512
|
-
for (let j = i - 1; j >= 0; j--) {
|
|
3513
|
-
if (entries[j].type === "assistant") {
|
|
3514
|
-
const assistantContent = entries[j].content;
|
|
3515
|
-
if (Array.isArray(assistantContent)) {
|
|
3516
|
-
for (const item of assistantContent) {
|
|
3517
|
-
if (item?.type === "toolCall" && item.id === entry.toolCallId) {
|
|
3518
|
-
toolInput = { name: item.name, arguments: item.arguments ?? item.input };
|
|
3519
|
-
break;
|
|
3520
|
-
}
|
|
3521
|
-
}
|
|
3522
|
-
}
|
|
3523
|
-
break;
|
|
3524
|
-
}
|
|
3525
|
-
}
|
|
3526
|
-
const toolAttrs = {};
|
|
3527
|
-
if (entry.isError) {
|
|
3528
|
-
toolAttrs["error.msg"] = "tool returned error";
|
|
3529
|
-
}
|
|
3530
|
-
const toolSpan = createSpan(ctx, channelId, entry.toolName || "unknown_tool", "tool", toolSpanStartTime, toolSpanEndTime, toolAttrs, toolInput, entry.content);
|
|
3531
|
-
await exporter.export(toolSpan);
|
|
3532
|
-
}
|
|
3533
|
-
const consecutiveToolResults = [entry];
|
|
3534
|
-
let lastToolWrittenAt = entryWrittenAt;
|
|
3535
|
-
while (i + 1 < entries.length && entries[i + 1].type === "toolResult") {
|
|
3536
|
-
i++;
|
|
3537
|
-
const nextTr = entries[i];
|
|
3538
|
-
const nextAlreadyExported = i < entriesToSkip;
|
|
3539
|
-
consecutiveToolResults.push(nextTr);
|
|
3540
|
-
const nextWrittenAt = nextTr.writtenAt || lastToolWrittenAt;
|
|
3541
|
-
lastToolWrittenAt = nextWrittenAt;
|
|
3542
|
-
if (!nextAlreadyExported) {
|
|
3543
|
-
let nextToolInput = undefined;
|
|
3544
|
-
for (let j = i - 1; j >= 0; j--) {
|
|
3545
|
-
if (entries[j].type === "assistant") {
|
|
3546
|
-
const ac = entries[j].content;
|
|
3547
|
-
if (Array.isArray(ac)) {
|
|
3548
|
-
for (const item of ac) {
|
|
3549
|
-
if (item?.type === "toolCall" && item.id === nextTr.toolCallId) {
|
|
3550
|
-
nextToolInput = { name: item.name, arguments: item.arguments ?? item.input };
|
|
3551
|
-
break;
|
|
3552
|
-
}
|
|
3553
|
-
}
|
|
3554
|
-
}
|
|
3555
|
-
break;
|
|
3556
|
-
}
|
|
3557
|
-
}
|
|
3558
|
-
const nextToolAttrs = {};
|
|
3559
|
-
if (nextTr.isError) {
|
|
3560
|
-
nextToolAttrs["error.msg"] = "tool returned error";
|
|
3561
|
-
}
|
|
3562
|
-
const nextToolSpan = createSpan(ctx, channelId, nextTr.toolName || "unknown_tool", "tool", prevWrittenAt, nextWrittenAt, nextToolAttrs, nextToolInput, nextTr.content);
|
|
3563
|
-
await exporter.export(nextToolSpan);
|
|
3564
|
-
prevWrittenAt = nextWrittenAt;
|
|
3565
|
-
}
|
|
3566
|
-
}
|
|
3567
|
-
const toolResultMsgs = convertToolResultsForMessages(consecutiveToolResults);
|
|
3568
|
-
reactMessages.push(...toolResultMsgs);
|
|
3569
|
-
// Use the last tool's timestamp so the next model span starts after
|
|
3570
|
-
// all tools, not just after the first one.
|
|
3571
|
-
prevWrittenAt = lastToolWrittenAt;
|
|
3572
|
-
}
|
|
3573
|
-
}
|
|
3574
|
-
return modelSpanCount;
|
|
3575
|
-
};
|
|
3576
|
-
api.on("gateway_stop", async () => {
|
|
3577
|
-
await exporter.dispose();
|
|
3578
|
-
});
|
|
3579
|
-
if (shouldHookEnabled("gateway_start")) {
|
|
3580
|
-
api.on("gateway_start", async (event) => {
|
|
3581
|
-
const now = Date.now();
|
|
3582
|
-
const { ctx, channelId } = getOrCreateContext("system/gateway", undefined, "gateway_start");
|
|
3583
|
-
const span = createSpan(ctx, channelId, "gateway_start", "gateway", now, now, {
|
|
3584
|
-
"gateway.version": event.version || "unknown",
|
|
3585
|
-
"gateway.working_dir": event.workingDir || process.cwd(),
|
|
3586
|
-
});
|
|
3587
|
-
await exporter.export(span);
|
|
3588
|
-
if (config.debug) {
|
|
3589
|
-
api.logger.info(\`[CozeloopTrace] Exported gateway_start span, traceId=\${ctx.traceId}\`);
|
|
3590
|
-
}
|
|
3591
|
-
});
|
|
3592
|
-
}
|
|
3593
|
-
if (shouldHookEnabled("session_start")) {
|
|
3594
|
-
api.on("session_start", async (event, hookCtx) => {
|
|
3595
|
-
// Refresh token if expiring soon (< 10 min)
|
|
3596
|
-
await exporter.refreshAuthIfNeeded();
|
|
3597
|
-
const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
|
|
3598
|
-
if (config.debug) {
|
|
3599
|
-
api.logger.info(\`[CozeloopTrace] session_start: \${rawChannelId}\`);
|
|
3600
|
-
}
|
|
3601
|
-
const { ctx } = getOrCreateContext(rawChannelId, undefined, "session_start");
|
|
3602
|
-
const ocSessionId = resolveOpenclawSessionId(hookCtx, event.sessionId);
|
|
3603
|
-
if (ocSessionId) {
|
|
3604
|
-
ctx.openclawSessionId = ocSessionId;
|
|
3605
|
-
lastOpenclawSessionId = ocSessionId;
|
|
3606
|
-
}
|
|
3607
|
-
ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
|
|
3608
|
-
});
|
|
3609
|
-
}
|
|
3610
|
-
if (shouldHookEnabled("message_received")) {
|
|
3611
|
-
api.on("message_received", async (event, hookCtx) => {
|
|
3612
|
-
const rawChannelId = resolveChannelId(hookCtx, event.from || event.metadata?.senderId);
|
|
3613
|
-
if (config.debug) {
|
|
3614
|
-
api.logger.info(\`[CozeloopTrace] message_received hookCtx: \${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.from=\${event.from}\`);
|
|
3615
|
-
}
|
|
3616
|
-
const { ctx, channelId } = getOrCreateContext(rawChannelId, undefined, "message_received");
|
|
3617
|
-
const ocSessionId = resolveOpenclawSessionId(hookCtx);
|
|
3618
|
-
if (ocSessionId) {
|
|
3619
|
-
ctx.openclawSessionId = ocSessionId;
|
|
3620
|
-
lastOpenclawSessionId = ocSessionId;
|
|
3621
|
-
}
|
|
3622
|
-
ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
|
|
3623
|
-
let role = event.role;
|
|
3624
|
-
if (!role && event.from) {
|
|
3625
|
-
role = "user";
|
|
3626
|
-
}
|
|
3627
|
-
const isNonAgentChannel = !rawChannelId.startsWith("agent/");
|
|
3628
|
-
if (isNonAgentChannel) {
|
|
3629
|
-
if (role === "user" || !role) {
|
|
3630
|
-
lastUserChannelId = channelId;
|
|
3631
|
-
lastUserTraceContext = ctx;
|
|
3632
|
-
ctx.userInput = event.content;
|
|
3633
|
-
lastUserInput = event.content;
|
|
3634
|
-
if (config.debug) {
|
|
3635
|
-
api.logger.info(\`[CozeloopTrace] Saved user context: channelId=\${channelId}, traceId=\${ctx.traceId}\`);
|
|
3636
|
-
}
|
|
3637
|
-
}
|
|
3638
|
-
if (!ctx.userInput) {
|
|
3639
|
-
ctx.userInput = event.content;
|
|
3640
|
-
}
|
|
3641
|
-
if (!lastUserTraceContext) {
|
|
3642
|
-
lastUserTraceContext = ctx;
|
|
3643
|
-
lastUserChannelId = channelId;
|
|
3644
|
-
}
|
|
3645
|
-
}
|
|
3646
|
-
});
|
|
3647
|
-
}
|
|
3648
|
-
if (shouldHookEnabled("message_sending")) {
|
|
3649
|
-
api.on("message_sending", async (event, hookCtx) => {
|
|
3650
|
-
if (lastUserTraceContext) {
|
|
3651
|
-
lastUserTraceContext.lastOutput = event.content;
|
|
3652
|
-
if (config.debug) {
|
|
3653
|
-
api.logger.info(\`[CozeloopTrace] Captured output for root span: traceId=\${lastUserTraceContext.traceId}, content=\${typeof event.content === 'string' ? event.content.substring(0, 100) : 'non-string'}\`);
|
|
3654
|
-
}
|
|
3655
|
-
}
|
|
3656
|
-
else {
|
|
3657
|
-
const rawChannelId = resolveChannelId(hookCtx, event.to);
|
|
3658
|
-
const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sending");
|
|
3659
|
-
ctx.lastOutput = event.content;
|
|
3660
|
-
if (config.debug) {
|
|
3661
|
-
api.logger.info(\`[CozeloopTrace] Captured output (fallback) for root span: traceId=\${ctx.traceId}\`);
|
|
3662
|
-
}
|
|
3663
|
-
}
|
|
3664
|
-
});
|
|
3665
|
-
}
|
|
3666
|
-
if (shouldHookEnabled("message_sent")) {
|
|
3667
|
-
api.on("message_sent", async (event, hookCtx) => {
|
|
3668
|
-
if (event.content && event.success) {
|
|
3669
|
-
if (lastUserTraceContext) {
|
|
3670
|
-
lastUserTraceContext.lastOutput = event.content;
|
|
3671
|
-
if (config.debug) {
|
|
3672
|
-
api.logger.info(\`[CozeloopTrace] Captured output from message_sent: traceId=\${lastUserTraceContext.traceId}\`);
|
|
3673
|
-
}
|
|
3674
|
-
}
|
|
3675
|
-
else {
|
|
3676
|
-
const rawChannelId = resolveChannelId(hookCtx, event.to);
|
|
3677
|
-
const { ctx } = resolveActiveContext(rawChannelId, undefined, "message_sent");
|
|
3678
|
-
ctx.lastOutput = event.content;
|
|
3679
|
-
if (config.debug) {
|
|
3680
|
-
api.logger.info(\`[CozeloopTrace] Captured output from message_sent (fallback): traceId=\${ctx.traceId}\`);
|
|
3681
|
-
}
|
|
3682
|
-
}
|
|
3683
|
-
}
|
|
3684
|
-
});
|
|
3685
|
-
}
|
|
3686
|
-
let lastLlmInput = undefined;
|
|
3687
|
-
let lastLlmStartTime = undefined;
|
|
3688
|
-
let lastLlmSpanId = undefined;
|
|
3689
|
-
if (shouldHookEnabled("llm_input")) {
|
|
3690
|
-
api.on("llm_input", async (event, hookCtx) => {
|
|
3691
|
-
const rawChannelId = resolveChannelId(hookCtx);
|
|
3692
|
-
if (config.debug) {
|
|
3693
|
-
api.logger.info(\`[CozeloopTrace] llm_input hookCtx: \${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=\${event.runId}\`);
|
|
3694
|
-
}
|
|
3695
|
-
const { ctx } = resolveActiveContext(rawChannelId, event.runId, "llm_input");
|
|
3696
|
-
const ocSessionId = resolveOpenclawSessionId(hookCtx);
|
|
3697
|
-
if (ocSessionId) {
|
|
3698
|
-
ctx.openclawSessionId = ocSessionId;
|
|
3699
|
-
lastOpenclawSessionId = ocSessionId;
|
|
3700
|
-
}
|
|
3701
|
-
ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
|
|
3702
|
-
ctx.llmStartTime = Date.now();
|
|
3703
|
-
ctx.llmSpanId = generateId(16);
|
|
3704
|
-
ctx.lastModelProvider = event.provider;
|
|
3705
|
-
ctx.lastModelId = event.model;
|
|
3706
|
-
ctx.reactCount = 0;
|
|
3707
|
-
// If userInput was never set (no message_received hook fired), capture
|
|
3708
|
-
// the first llm prompt as the user input for the root span.
|
|
3709
|
-
if (!ctx.userInput && event.prompt) {
|
|
3710
|
-
ctx.userInput = event.prompt;
|
|
3711
|
-
if (!lastUserInput) {
|
|
3712
|
-
lastUserInput = event.prompt;
|
|
3713
|
-
}
|
|
3714
|
-
}
|
|
3715
|
-
// Fallback: ensure root + agent spans exist in case before_agent_start
|
|
3716
|
-
// was not fired (older OpenClaw versions or resumed sessions).
|
|
3717
|
-
const channelIdForSpans = activeAgentChannelId || rawChannelId;
|
|
3718
|
-
await ensureRootSpan(ctx, channelIdForSpans);
|
|
3719
|
-
await ensureAgentSpan(ctx, channelIdForSpans);
|
|
3720
|
-
const messages = [];
|
|
3721
|
-
if (event.systemPrompt) {
|
|
3722
|
-
messages.push({ role: "system", content: safeClone(event.systemPrompt) });
|
|
3723
|
-
}
|
|
3724
|
-
if (event.historyMessages && event.historyMessages.length > 0) {
|
|
3725
|
-
messages.push(...event.historyMessages.map((msg) => safeClone(msg)));
|
|
3726
|
-
}
|
|
3727
|
-
if (event.prompt) {
|
|
3728
|
-
messages.push({ role: "user", content: safeClone(event.prompt) });
|
|
3729
|
-
}
|
|
3730
|
-
const convertToolCallInPlace = (target) => {
|
|
3731
|
-
if (target.type !== "toolCall")
|
|
3732
|
-
return;
|
|
3733
|
-
target.type = "tool_use";
|
|
3734
|
-
if ("arguments" in target) {
|
|
3735
|
-
target.input = target.arguments;
|
|
3736
|
-
delete target.arguments;
|
|
3737
|
-
}
|
|
3738
|
-
};
|
|
3739
|
-
const convertToolCallDeepInPlace = (value) => {
|
|
3740
|
-
if (!value)
|
|
3741
|
-
return;
|
|
3742
|
-
if (Array.isArray(value)) {
|
|
3743
|
-
for (const item of value) {
|
|
3744
|
-
convertToolCallDeepInPlace(item);
|
|
3745
|
-
}
|
|
3746
|
-
return;
|
|
3747
|
-
}
|
|
3748
|
-
if (typeof value !== "object")
|
|
3749
|
-
return;
|
|
3750
|
-
const obj = value;
|
|
3751
|
-
convertToolCallInPlace(obj);
|
|
3752
|
-
if ("content" in obj) {
|
|
3753
|
-
convertToolCallDeepInPlace(obj.content);
|
|
3754
|
-
}
|
|
3755
|
-
};
|
|
3756
|
-
for (const message of messages) {
|
|
3757
|
-
convertToolCallDeepInPlace(message);
|
|
3758
|
-
if ("toolCallId" in message) {
|
|
3759
|
-
message.tool_call_id = message.toolCallId;
|
|
3760
|
-
delete message.toolCallId;
|
|
3761
|
-
}
|
|
3762
|
-
if (message.role === "toolResult") {
|
|
3763
|
-
message.role = "tool";
|
|
3764
|
-
}
|
|
3765
|
-
}
|
|
3766
|
-
ctx.llmInput = {
|
|
3767
|
-
"messages": messages,
|
|
3768
|
-
};
|
|
3769
|
-
lastLlmInput = ctx.llmInput;
|
|
3770
|
-
lastLlmStartTime = ctx.llmStartTime;
|
|
3771
|
-
lastLlmSpanId = ctx.llmSpanId;
|
|
3772
|
-
if (config.debug) {
|
|
3773
|
-
api.logger.info(\`[CozeloopTrace] LLM input started: \${event.provider}/\${event.model}, runId=\${event.runId}, traceId=\${ctx.traceId}\`);
|
|
3774
|
-
}
|
|
3775
|
-
});
|
|
3776
|
-
}
|
|
3777
|
-
if (shouldHookEnabled("llm_output")) {
|
|
3778
|
-
api.on("llm_output", async (event, hookCtx) => {
|
|
3779
|
-
const rawChannelId = resolveChannelId(hookCtx);
|
|
3780
|
-
if (config.debug) {
|
|
3781
|
-
api.logger.info(\`[CozeloopTrace][DEBUG] llm_output event.usage=\${JSON.stringify(event.usage)}\`);
|
|
3782
|
-
api.logger.info(\`[CozeloopTrace][DEBUG] llm_output event.lastAssistant=\${JSON.stringify(event.lastAssistant)}\`);
|
|
3783
|
-
api.logger.info(\`[CozeloopTrace][DEBUG] llm_output event keys=\${JSON.stringify(Object.keys(event))}\`);
|
|
3784
|
-
api.logger.info(\`[CozeloopTrace] llm_output hookCtx: \${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=\${event.runId}\`);
|
|
3785
|
-
}
|
|
3786
|
-
const { ctx, channelId } = resolveActiveContext(rawChannelId, event.runId, "llm_output");
|
|
3787
|
-
const now = Date.now();
|
|
3788
|
-
const startTime = ctx.llmStartTime || lastLlmStartTime || now;
|
|
3789
|
-
if (event.assistantTexts && event.assistantTexts.length > 0) {
|
|
3790
|
-
const outputText = event.assistantTexts.join("\\n");
|
|
3791
|
-
ctx.lastOutput = outputText;
|
|
3792
|
-
if (lastUserTraceContext) {
|
|
3793
|
-
lastUserTraceContext.lastOutput = outputText;
|
|
3794
|
-
}
|
|
3795
|
-
if (config.debug) {
|
|
3796
|
-
api.logger.info(\`[CozeloopTrace] Captured output from llm_output (will use last): traceId=\${ctx.traceId}, length=\${outputText.length}\`);
|
|
3797
|
-
}
|
|
3798
|
-
}
|
|
3799
|
-
const llmInput = ctx.llmInput || lastLlmInput;
|
|
3800
|
-
const llmSpanId = ctx.llmSpanId || lastLlmSpanId;
|
|
3801
|
-
if (config.debug) {
|
|
3802
|
-
api.logger.info(\`[CozeloopTrace] llm_output ctx: traceId=\${ctx.traceId}, rootSpanId=\${ctx.rootSpanId}, llmSpanId=\${llmSpanId || "none"}, hasInput=\${!!llmInput}\`);
|
|
3803
|
-
}
|
|
3804
|
-
let sessionBasedSuccess = false;
|
|
3805
|
-
try {
|
|
3806
|
-
const { entries, userWrittenAt, userContent } = readCurrentTurnReactSequence(hookCtx);
|
|
3807
|
-
const hasAssistantEntry = entries.some((e) => e.type === "assistant");
|
|
3808
|
-
if (entries.length > 0 && hasAssistantEntry) {
|
|
3809
|
-
const agentStart = ctx.agentStartTime || ctx.llmStartTime || lastLlmStartTime || now;
|
|
3810
|
-
const skipCount = ctx.sessionBasedExportedCount || 0;
|
|
3811
|
-
const modelCount = await buildReactSpans(ctx, channelId, entries, llmInput, agentStart, userWrittenAt, skipCount, userContent);
|
|
3812
|
-
if (modelCount > 0) {
|
|
3813
|
-
sessionBasedSuccess = true;
|
|
3814
|
-
ctx.sessionBasedSpansCreated = true;
|
|
3815
|
-
// Remember how many entries we exported so the next llm_output
|
|
3816
|
-
// call skips them and avoids duplicate spans.
|
|
3817
|
-
ctx.sessionBasedExportedCount = entries.length;
|
|
3818
|
-
if (config.debug) {
|
|
3819
|
-
api.logger.info(\`[CozeloopTrace] Session-based react spans created: modelCount=\${modelCount}, totalEntries=\${entries.length}, skipped=\${skipCount}, traceId=\${ctx.traceId}\`);
|
|
3820
|
-
}
|
|
3821
|
-
}
|
|
3822
|
-
}
|
|
3823
|
-
}
|
|
3824
|
-
catch {
|
|
3825
|
-
if (config.debug) {
|
|
3826
|
-
api.logger.info(\`[CozeloopTrace] Session-based span creation failed, falling back to hook data, traceId=\${ctx.traceId}\`);
|
|
3827
|
-
}
|
|
3828
|
-
}
|
|
3829
|
-
if (!sessionBasedSuccess) {
|
|
3830
|
-
if (ctx.pendingToolSpans) {
|
|
3831
|
-
for (const pts of ctx.pendingToolSpans) {
|
|
3832
|
-
const toolSpan = createSpan(ctx, channelId, pts.toolName, "tool", pts.toolStartTime, pts.toolEndTime, pts.toolError ? { "error.msg": String(pts.toolError) } : {}, pts.toolInput, pts.toolError ? { error: pts.toolError } : pts.toolOutput);
|
|
3833
|
-
toolSpan.spanId = pts.toolSpanId;
|
|
3834
|
-
await exporter.export(toolSpan);
|
|
3835
|
-
if (config.debug) {
|
|
3836
|
-
api.logger.info(\`[CozeloopTrace] Exported pending tool span (fallback): \${pts.toolName}, spanId=\${pts.toolSpanId}, traceId=\${ctx.traceId}\`);
|
|
3837
|
-
}
|
|
3838
|
-
}
|
|
3839
|
-
}
|
|
3840
|
-
const lastAssistantUsage = event.lastAssistant?.usage;
|
|
3841
|
-
const inputTokens = event.usage?.input ?? lastAssistantUsage?.input ?? 0;
|
|
3842
|
-
const outputTokens = event.usage?.output ?? lastAssistantUsage?.output ?? 0;
|
|
3843
|
-
const spanAttributes = {
|
|
3844
|
-
"gen_ai.provider.name": event.provider,
|
|
3845
|
-
"gen_ai.request.model": event.model,
|
|
3846
|
-
"gen_ai.usage.input_tokens": inputTokens,
|
|
3847
|
-
"gen_ai.usage.output_tokens": outputTokens,
|
|
3848
|
-
};
|
|
3849
|
-
const finalOutput = formatAssistantOutput(event.assistantTexts?.map((t) => ({ type: "text", text: t })) ?? [], "stop");
|
|
3850
|
-
const span = createSpan(ctx, channelId, \`\${event.provider}/\${event.model}\`, "model", startTime, now, spanAttributes, llmInput, finalOutput);
|
|
3851
|
-
if (llmSpanId) {
|
|
3852
|
-
span.spanId = llmSpanId;
|
|
3853
|
-
}
|
|
3854
|
-
if (config.debug) {
|
|
3855
|
-
api.logger.info(\`[CozeloopTrace] llm_output span created (fallback): spanId=\${span.spanId}, parentSpanId=\${span.parentSpanId}\`);
|
|
3856
|
-
}
|
|
3857
|
-
await exporter.export(span);
|
|
3858
|
-
if (config.debug) {
|
|
3859
|
-
api.logger.info(\`[CozeloopTrace] Exported LLM span (fallback): \${event.provider}/\${event.model}, duration=\${now - startTime}ms, traceId=\${ctx.traceId}\`);
|
|
3860
|
-
}
|
|
3861
|
-
}
|
|
3862
|
-
ctx.llmStartTime = undefined;
|
|
3863
|
-
ctx.llmSpanId = undefined;
|
|
3864
|
-
ctx.llmInput = undefined;
|
|
3865
|
-
ctx.reactCount = 0;
|
|
3866
|
-
ctx.pendingToolSpans = undefined;
|
|
3867
|
-
ctx.sessionBasedSpansCreated = undefined;
|
|
3868
|
-
lastLlmInput = undefined;
|
|
3869
|
-
lastLlmStartTime = undefined;
|
|
3870
|
-
lastLlmSpanId = undefined;
|
|
3871
|
-
});
|
|
3872
|
-
}
|
|
3873
|
-
if (shouldHookEnabled("before_tool_call")) {
|
|
3874
|
-
api.on("before_tool_call", async (event, hookCtx) => {
|
|
3875
|
-
const rawChannelId = resolveChannelId(hookCtx);
|
|
3876
|
-
if (config.debug) {
|
|
3877
|
-
api.logger.info(\`[CozeloopTrace] before_tool_call hookCtx: \${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=\${event.toolName}\`);
|
|
3878
|
-
}
|
|
3879
|
-
const { ctx, channelId } = resolveActiveContext(rawChannelId, undefined, "before_tool_call");
|
|
3880
|
-
pendingToolCall = {
|
|
3881
|
-
toolName: event.toolName,
|
|
3882
|
-
toolSpanId: generateId(16),
|
|
3883
|
-
toolStartTime: Date.now(),
|
|
3884
|
-
toolInput: event.params,
|
|
3885
|
-
traceContext: ctx,
|
|
3886
|
-
channelId: channelId,
|
|
3887
|
-
};
|
|
3888
|
-
ctx.reactCount = (ctx.reactCount || 0) + 1;
|
|
3889
|
-
if (config.debug) {
|
|
3890
|
-
api.logger.info(\`[CozeloopTrace] Tool call started: \${event.toolName}, spanId=\${pendingToolCall.toolSpanId}, traceId=\${ctx.traceId}\`);
|
|
3891
|
-
}
|
|
3892
|
-
});
|
|
3893
|
-
}
|
|
3894
|
-
if (shouldHookEnabled("after_tool_call")) {
|
|
3895
|
-
api.on("after_tool_call", async (event, hookCtx) => {
|
|
3896
|
-
if (config.debug) {
|
|
3897
|
-
api.logger.info(\`[CozeloopTrace] after_tool_call hookCtx: \${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=\${event.toolName}\`);
|
|
3898
|
-
}
|
|
3899
|
-
if (!pendingToolCall || pendingToolCall.toolName !== event.toolName) {
|
|
3900
|
-
if (config.debug) {
|
|
3901
|
-
api.logger.info(\`[CozeloopTrace] Skipping after_tool_call: no pending tool or name mismatch, toolName=\${event.toolName}, pending=\${pendingToolCall?.toolName}\`);
|
|
3902
|
-
}
|
|
3903
|
-
return;
|
|
3904
|
-
}
|
|
3905
|
-
const { toolName, toolSpanId, toolStartTime, toolInput, traceContext } = pendingToolCall;
|
|
3906
|
-
pendingToolCall = undefined;
|
|
3907
|
-
const now = Date.now();
|
|
3908
|
-
if (!traceContext.pendingToolSpans) {
|
|
3909
|
-
traceContext.pendingToolSpans = [];
|
|
3910
|
-
}
|
|
3911
|
-
traceContext.pendingToolSpans.push({
|
|
3912
|
-
toolName,
|
|
3913
|
-
toolSpanId,
|
|
3914
|
-
toolStartTime,
|
|
3915
|
-
toolEndTime: now,
|
|
3916
|
-
toolInput,
|
|
3917
|
-
toolOutput: event.error ? { error: event.error } : event.result,
|
|
3918
|
-
toolError: event.error ? String(event.error) : undefined,
|
|
3919
|
-
});
|
|
3920
|
-
if (config.debug) {
|
|
3921
|
-
api.logger.info(\`[CozeloopTrace] Collected pending tool span: \${toolName}, spanId=\${toolSpanId}, duration=\${now - toolStartTime}ms, traceId=\${traceContext.traceId}\`);
|
|
3922
|
-
}
|
|
3923
|
-
});
|
|
3924
|
-
}
|
|
3925
|
-
// Helper: finalize a trace — end agent span (if open), end root span, flush,
|
|
3926
|
-
// and clean up all state. Called from agent_end (normal path) and
|
|
3927
|
-
// session_end (fallback for old OpenClaw versions that don't emit agent_end).
|
|
3928
|
-
let traceFinalized = false;
|
|
3929
|
-
const finalizeTrace = (ctx, channelId, agentEndAttrs, agentOutput) => {
|
|
3930
|
-
if (traceFinalized)
|
|
3931
|
-
return;
|
|
3932
|
-
traceFinalized = true;
|
|
3933
|
-
const now = Date.now();
|
|
3934
|
-
// End agent span if still open.
|
|
3935
|
-
if (ctx.agentSpanId) {
|
|
3936
|
-
exporter.endSpanById(ctx.agentSpanId, now, agentEndAttrs || {}, agentOutput);
|
|
3937
|
-
if (config.debug) {
|
|
3938
|
-
api.logger.info(\`[CozeloopTrace] Ended agent span: spanId=\${ctx.agentSpanId}, traceId=\${ctx.traceId}\`);
|
|
3939
|
-
}
|
|
3940
|
-
ctx.agentSpanId = undefined;
|
|
3941
|
-
ctx.agentStartTime = undefined;
|
|
3942
|
-
}
|
|
3943
|
-
const rootSpanId = ctx.rootSpanId;
|
|
3944
|
-
const rootSpanStartTime = ctx.rootSpanStartTime;
|
|
3945
|
-
const userInput = ctx.userInput || (lastUserTraceContext ? lastUserTraceContext.userInput : undefined) || lastUserInput;
|
|
3946
|
-
const traceId = ctx.traceId;
|
|
3947
|
-
const hasRootSpan = !!rootSpanStartTime;
|
|
3948
|
-
const savedLastUserChannelId = lastUserChannelId;
|
|
3949
|
-
const originalChannelId = ctx.originalChannelId || channelId;
|
|
3950
|
-
setTimeout(async () => {
|
|
3951
|
-
if (hasRootSpan) {
|
|
3952
|
-
const finalOutput = ctx.lastOutput || (lastUserTraceContext ? lastUserTraceContext.lastOutput : undefined);
|
|
3953
|
-
if (config.debug) {
|
|
3954
|
-
api.logger.info(\`[CozeloopTrace] Ending root span with input=\${userInput ? 'present' : 'missing'}, output=\${finalOutput ? 'present' : 'missing'}\`);
|
|
3955
|
-
}
|
|
3956
|
-
const endTime = Date.now();
|
|
3957
|
-
exporter.endSpanById(rootSpanId, endTime, {
|
|
3958
|
-
"request.duration_ms": endTime - (rootSpanStartTime || 0),
|
|
3959
|
-
}, finalOutput, userInput);
|
|
3960
|
-
if (config.debug) {
|
|
3961
|
-
api.logger.info(\`[CozeloopTrace] Ended root span: spanId=\${rootSpanId}, duration=\${endTime - (rootSpanStartTime || 0)}ms, traceId=\${traceId}\`);
|
|
3962
|
-
}
|
|
3963
|
-
}
|
|
3964
|
-
await exporter.flush();
|
|
3965
|
-
exporter.endTrace(rootSpanId);
|
|
3966
|
-
if (activeAgentCtx === ctx) {
|
|
3967
|
-
activeAgentCtx = undefined;
|
|
3968
|
-
activeAgentChannelId = undefined;
|
|
3969
|
-
}
|
|
3970
|
-
if (savedLastUserChannelId) {
|
|
3971
|
-
endTurn(savedLastUserChannelId);
|
|
3972
|
-
}
|
|
3973
|
-
if (originalChannelId && originalChannelId !== savedLastUserChannelId) {
|
|
3974
|
-
endTurn(originalChannelId);
|
|
3975
|
-
}
|
|
3976
|
-
lastUserChannelId = undefined;
|
|
3977
|
-
lastUserTraceContext = undefined;
|
|
3978
|
-
lastUserInput = undefined;
|
|
3979
|
-
traceFinalized = false;
|
|
3980
|
-
}, 200);
|
|
3981
|
-
};
|
|
3982
|
-
// Helper: ensure root openclaw_request span is started for a given context.
|
|
3983
|
-
// Must be called before creating the agent span so that the exporter's
|
|
3984
|
-
// currentRootContext is set and the agent span becomes a proper child.
|
|
3985
|
-
const ensureRootSpan = async (ctx, channelId) => {
|
|
3986
|
-
// Check both: rootSpanStartTime indicates we created a root span before,
|
|
3987
|
-
// but the exporter's traceContexts may have been cleaned up by a previous
|
|
3988
|
-
// turn's deferred endTrace(). If the exporter no longer has the entry we
|
|
3989
|
-
// must recreate the root span.
|
|
3990
|
-
if (ctx.rootSpanStartTime && exporter.hasTraceContext(ctx.rootSpanId)) {
|
|
3991
|
-
return;
|
|
3992
|
-
}
|
|
3993
|
-
const now = Date.now();
|
|
3994
|
-
ctx.rootSpanStartTime = now;
|
|
3995
|
-
// Generate a fresh rootSpanId when the old one was cleaned up, so we
|
|
3996
|
-
// don't collide with the previous turn's IDs.
|
|
3997
|
-
const isRebuild = !exporter.hasTraceContext(ctx.rootSpanId);
|
|
3998
|
-
if (isRebuild) {
|
|
3999
|
-
ctx.rootSpanId = generateId(16);
|
|
4000
|
-
// This is a new turn reusing a stale ctx — clear the previous turn's
|
|
4001
|
-
// userInput, exported count, and agent span so we don't carry over
|
|
4002
|
-
// stale state. The agent span must be recreated under the new root.
|
|
4003
|
-
ctx.userInput = undefined;
|
|
4004
|
-
ctx.sessionBasedExportedCount = undefined;
|
|
4005
|
-
ctx.agentSpanId = undefined;
|
|
4006
|
-
ctx.agentStartTime = undefined;
|
|
4007
|
-
}
|
|
4008
|
-
// Resolve user input: prefer ctx.userInput set by this turn's
|
|
4009
|
-
// message_received, fall back to lastUserTraceContext, then lastUserInput.
|
|
4010
|
-
if (!ctx.userInput) {
|
|
4011
|
-
ctx.userInput = lastUserTraceContext?.userInput || lastUserInput;
|
|
4012
|
-
}
|
|
4013
|
-
const rootSpanData = {
|
|
4014
|
-
name: "openclaw_request",
|
|
4015
|
-
type: "entry",
|
|
4016
|
-
startTime: now,
|
|
4017
|
-
attributes: {
|
|
4018
|
-
"session.id": ctx.openclawSessionId || channelId,
|
|
4019
|
-
"run.id": ctx.runId,
|
|
4020
|
-
"turn.id": ctx.turnId,
|
|
4021
|
-
"openclaw.channel_id": channelId,
|
|
4022
|
-
},
|
|
4023
|
-
input: ctx.userInput,
|
|
4024
|
-
traceId: ctx.traceId,
|
|
4025
|
-
spanId: ctx.rootSpanId,
|
|
4026
|
-
};
|
|
4027
|
-
await exporter.startSpan(rootSpanData, ctx.rootSpanId);
|
|
4028
|
-
if (config.debug) {
|
|
4029
|
-
api.logger.info(\`[CozeloopTrace] ensureRootSpan: created root span, rootSpanId=\${ctx.rootSpanId}, traceContextsHas=\${exporter.hasTraceContext(ctx.rootSpanId)}\`);
|
|
4030
|
-
}
|
|
4031
|
-
};
|
|
4032
|
-
// Helper: ensure the agent span exists for a given context.
|
|
4033
|
-
// Safe to call multiple times — only creates the span once.
|
|
4034
|
-
const ensureAgentSpan = async (ctx, channelId, agentId) => {
|
|
4035
|
-
if (ctx.agentSpanId)
|
|
4036
|
-
return;
|
|
4037
|
-
const effectiveAgentId = agentId || "main";
|
|
4038
|
-
const now = Date.now();
|
|
4039
|
-
ctx.agentStartTime = now;
|
|
4040
|
-
ctx.agentSpanId = generateId(16);
|
|
4041
|
-
const spanData = {
|
|
4042
|
-
name: effectiveAgentId,
|
|
4043
|
-
type: "agent",
|
|
4044
|
-
startTime: now,
|
|
4045
|
-
attributes: {
|
|
4046
|
-
"agent.id": effectiveAgentId,
|
|
4047
|
-
"session.id": ctx.openclawSessionId || channelId,
|
|
4048
|
-
"run.id": ctx.runId,
|
|
4049
|
-
"turn.id": ctx.turnId,
|
|
4050
|
-
"openclaw.channel_id": channelId,
|
|
4051
|
-
},
|
|
4052
|
-
traceId: ctx.traceId,
|
|
4053
|
-
spanId: ctx.agentSpanId,
|
|
4054
|
-
parentSpanId: ctx.rootSpanId,
|
|
4055
|
-
};
|
|
4056
|
-
await exporter.startSpan(spanData, ctx.agentSpanId);
|
|
4057
|
-
// Set active agent context so all subsequent hooks use the same Trace.
|
|
4058
|
-
activeAgentCtx = ctx;
|
|
4059
|
-
activeAgentChannelId = channelId;
|
|
4060
|
-
if (config.debug) {
|
|
4061
|
-
api.logger.info(\`[CozeloopTrace] ensureAgentSpan: created agent span, agentId=\${effectiveAgentId}, spanId=\${ctx.agentSpanId}, traceId=\${ctx.traceId}\`);
|
|
4062
|
-
}
|
|
4063
|
-
};
|
|
4064
|
-
if (shouldHookEnabled("before_agent_start")) {
|
|
4065
|
-
api.on("before_agent_start", async (event, hookCtx) => {
|
|
4066
|
-
const rawChannelId = resolveChannelId(hookCtx);
|
|
4067
|
-
const agentId = hookCtx.agentId || event.agentId || "main";
|
|
4068
|
-
if (config.debug) {
|
|
4069
|
-
api.logger.info(\`[CozeloopTrace] before_agent_start hookCtx: \${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId, agentId: hookCtx.agentId })}, event.agentId=\${event.agentId}\`);
|
|
4070
|
-
}
|
|
4071
|
-
const { ctx, channelId } = getOrCreateContext(rawChannelId, undefined, "before_agent_start");
|
|
4072
|
-
const ocSessionId = resolveOpenclawSessionId(hookCtx);
|
|
4073
|
-
if (ocSessionId) {
|
|
4074
|
-
ctx.openclawSessionId = ocSessionId;
|
|
4075
|
-
lastOpenclawSessionId = ocSessionId;
|
|
4076
|
-
}
|
|
4077
|
-
ctx.openclawSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
|
|
4078
|
-
await ensureRootSpan(ctx, channelId);
|
|
4079
|
-
await ensureAgentSpan(ctx, channelId, agentId);
|
|
4080
|
-
});
|
|
4081
|
-
}
|
|
4082
|
-
if (shouldHookEnabled("agent_end")) {
|
|
4083
|
-
api.on("agent_end", async (event, hookCtx) => {
|
|
4084
|
-
const rawChannelId = resolveChannelId(hookCtx);
|
|
4085
|
-
if (config.debug) {
|
|
4086
|
-
api.logger.info(\`[CozeloopTrace] agent_end hookCtx: \${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}\`);
|
|
4087
|
-
}
|
|
4088
|
-
// Use activeAgentCtx if available, otherwise fall back to resolution.
|
|
4089
|
-
const ctx = activeAgentCtx || getOrCreateContext(rawChannelId, undefined, "agent_end").ctx;
|
|
4090
|
-
const channelId = activeAgentChannelId || rawChannelId;
|
|
4091
|
-
finalizeTrace(ctx, channelId, {
|
|
4092
|
-
"agent.duration_ms": event.durationMs || 0,
|
|
4093
|
-
"agent.message_count": event.messageCount || 0,
|
|
4094
|
-
"agent.tool_call_count": event.toolCallCount || 0,
|
|
4095
|
-
"agent.total_tokens": event.usage?.total || 0,
|
|
4096
|
-
}, { usage: event.usage, cost: event.cost });
|
|
4097
|
-
});
|
|
4098
|
-
}
|
|
4099
|
-
// Fallback: on session_end, if agent_end was never fired (old OpenClaw
|
|
4100
|
-
// versions), finalize the trace here so that agent + root spans get ended
|
|
4101
|
-
// and exported.
|
|
4102
|
-
if (shouldHookEnabled("session_end")) {
|
|
4103
|
-
api.on("session_end", async (event, hookCtx) => {
|
|
4104
|
-
const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
|
|
4105
|
-
if (config.debug) {
|
|
4106
|
-
api.logger.info(\`[CozeloopTrace] session_end: \${rawChannelId}\`);
|
|
4107
|
-
}
|
|
4108
|
-
const ctx = activeAgentCtx || lastUserTraceContext;
|
|
4109
|
-
if (ctx && ctx.rootSpanStartTime) {
|
|
4110
|
-
const channelId = activeAgentChannelId || lastUserChannelId || rawChannelId;
|
|
4111
|
-
if (config.debug) {
|
|
4112
|
-
api.logger.info(\`[CozeloopTrace] session_end: finalizing trace as fallback, traceId=\${ctx.traceId}\`);
|
|
4113
|
-
}
|
|
4114
|
-
finalizeTrace(ctx, channelId);
|
|
4115
|
-
}
|
|
4116
|
-
else {
|
|
4117
|
-
const { channelId } = getOrCreateContext(rawChannelId, undefined, "session_end");
|
|
4118
|
-
endTurn(channelId);
|
|
4119
|
-
}
|
|
4120
|
-
});
|
|
4121
|
-
}
|
|
4122
|
-
api.logger.info(\`[CozeloopTrace] Plugin activated (endpoint: \${config.endpoint}, workspace: \${config.workspaceId})\`);
|
|
4123
|
-
},
|
|
4124
|
-
};
|
|
4125
|
-
export default cozeloopTracePlugin;
|
|
4126
|
-
`,
|
|
4127
|
-
"dist/cozeloop-exporter.js": `import { trace, context, SpanKind, SpanStatusCode } from "@opentelemetry/api";
|
|
4128
|
-
import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
4129
|
-
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
|
4130
|
-
import { Resource } from "@opentelemetry/resources";
|
|
4131
|
-
import { ATTR_SERVICE_NAME, ATTR_SERVICE_INSTANCE_ID } from "@opentelemetry/semantic-conventions";
|
|
4132
|
-
import { hostname } from "os";
|
|
4133
|
-
import { basename, join } from "path";
|
|
4134
|
-
import { createRequire } from "node:module";
|
|
4135
|
-
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
4136
|
-
import { homedir } from "os";
|
|
4137
|
-
import https from "https";
|
|
4138
|
-
|
|
4139
|
-
const require = createRequire(import.meta.url);
|
|
4140
|
-
const { version: PLUGIN_VERSION } = require("../package.json");
|
|
4141
|
-
|
|
4142
|
-
// ── Token refresh helpers ─────────────────────────────────────────────────
|
|
4143
|
-
const _CLIENT_ID = "46371084383473718052118955183420.app.coze";
|
|
4144
|
-
const _COZE_API = "https://api.coze.cn";
|
|
4145
|
-
const _REFRESH_THRESHOLD_MS = 10 * 60 * 1000;
|
|
4146
|
-
const _CREDS_PATH = join(homedir(), ".cozeloop", "credentials.json");
|
|
4147
|
-
|
|
4148
|
-
function _loadCreds() {
|
|
4149
|
-
try { return JSON.parse(readFileSync(_CREDS_PATH, "utf8")); }
|
|
4150
|
-
catch { return null; }
|
|
4151
|
-
}
|
|
4152
|
-
|
|
4153
|
-
function _saveCreds(c) {
|
|
4154
|
-
try {
|
|
4155
|
-
mkdirSync(join(homedir(), ".cozeloop"), { recursive: true });
|
|
4156
|
-
writeFileSync(_CREDS_PATH, JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
4157
|
-
} catch { /* non-fatal */ }
|
|
4158
|
-
}
|
|
4159
|
-
|
|
4160
|
-
async function _refreshToken(refreshTok) {
|
|
4161
|
-
return new Promise((resolve) => {
|
|
4162
|
-
const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
|
|
4163
|
-
const req = https.request(\`\${_COZE_API}/api/permission/oauth2/token\`, {
|
|
4164
|
-
method: "POST",
|
|
4165
|
-
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "x-tt-env": "ppe_cozelab", "x-use-ppe": "1" },
|
|
4166
|
-
}, (res) => {
|
|
4167
|
-
let buf = "";
|
|
4168
|
-
res.on("data", c => buf += c);
|
|
4169
|
-
res.on("end", () => {
|
|
4170
|
-
try {
|
|
4171
|
-
const d = JSON.parse(buf);
|
|
4172
|
-
if (d.access_token) {
|
|
4173
|
-
const existing = _loadCreds() ?? {};
|
|
4174
|
-
const creds = {
|
|
4175
|
-
access_token: d.access_token,
|
|
4176
|
-
refresh_token: d.refresh_token ?? refreshTok,
|
|
4177
|
-
expires_at: (d.expires_in ?? 0) * 1000, // unix timestamp in seconds
|
|
4178
|
-
workspace_id: existing.workspace_id ?? "",
|
|
4179
|
-
};
|
|
4180
|
-
_saveCreds(creds);
|
|
4181
|
-
resolve(creds.access_token);
|
|
4182
|
-
} else { resolve(null); }
|
|
4183
|
-
} catch { resolve(null); }
|
|
4184
|
-
});
|
|
4185
|
-
});
|
|
4186
|
-
req.on("error", () => resolve(null));
|
|
4187
|
-
req.setTimeout(10000, () => { req.destroy(); resolve(null); });
|
|
4188
|
-
req.write(body);
|
|
4189
|
-
req.end();
|
|
4190
|
-
});
|
|
4191
|
-
}
|
|
4192
|
-
|
|
4193
|
-
async function getRefreshedToken(currentAuthorization) {
|
|
4194
|
-
const creds = _loadCreds();
|
|
4195
|
-
if (!creds) return currentAuthorization; // no creds file, keep as-is
|
|
4196
|
-
const remaining = (creds.expires_at ?? 0) - Date.now();
|
|
4197
|
-
if (remaining > _REFRESH_THRESHOLD_MS) return \`Bearer \${creds.access_token}\`;
|
|
4198
|
-
if (creds.refresh_token) {
|
|
4199
|
-
const newToken = await _refreshToken(creds.refresh_token);
|
|
4200
|
-
if (newToken) return \`Bearer \${newToken}\`;
|
|
4201
|
-
}
|
|
4202
|
-
return null;
|
|
4203
|
-
}
|
|
4204
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
4205
|
-
|
|
4206
|
-
export class CozeloopExporter {
|
|
4207
|
-
config;
|
|
4208
|
-
api;
|
|
4209
|
-
provider = null;
|
|
4210
|
-
tracer = null;
|
|
4211
|
-
initialized = false;
|
|
4212
|
-
initPromise = null;
|
|
4213
|
-
// Per-trace context: keyed by the plugin-level rootSpanId so that
|
|
4214
|
-
// concurrent or overlapping traces never stomp on each other.
|
|
4215
|
-
traceContexts = new Map();
|
|
4216
|
-
openSpans = new Map();
|
|
4217
|
-
// Extra attributes derived from environment variables, applied to every span.
|
|
4218
|
-
envAttributes = {};
|
|
4219
|
-
constructor(api, config) {
|
|
4220
|
-
this.api = api;
|
|
4221
|
-
this.config = config;
|
|
4222
|
-
this.envAttributes = this.parseEnvAttributes();
|
|
4223
|
-
}
|
|
4224
|
-
parseEnvAttributes() {
|
|
4225
|
-
const attrs = {};
|
|
4226
|
-
// COZE_PROJECT_ID -> project_id
|
|
4227
|
-
const projectId = process.env.COZE_PROJECT_ID?.trim();
|
|
4228
|
-
if (projectId) {
|
|
4229
|
-
attrs["project_id"] = projectId;
|
|
4230
|
-
}
|
|
4231
|
-
// COZELOOP_UDF_TAGS -> udf_ prefixed keys
|
|
4232
|
-
const tagsRaw = process.env.COZELOOP_UDF_TAGS?.trim();
|
|
4233
|
-
if (tagsRaw) {
|
|
4234
|
-
const pairs = tagsRaw.split(",");
|
|
4235
|
-
for (const pair of pairs) {
|
|
4236
|
-
const eqIdx = pair.indexOf("=");
|
|
4237
|
-
if (eqIdx < 0) {
|
|
4238
|
-
this.api.logger.error(\`[CozeloopTrace] Invalid COZELOOP_UDF_TAGS entry (missing '='): "\${pair}"\`);
|
|
4239
|
-
continue;
|
|
4240
|
-
}
|
|
4241
|
-
const key = pair.substring(0, eqIdx);
|
|
4242
|
-
const value = pair.substring(eqIdx + 1);
|
|
4243
|
-
// Validate: key and value must not contain '=' or ','
|
|
4244
|
-
if (key.includes("=") || key.includes(",")) {
|
|
4245
|
-
this.api.logger.error(\`[CozeloopTrace] Invalid COZELOOP_UDF_TAGS key contains '=' or ',': "\${key}"\`);
|
|
4246
|
-
continue;
|
|
4247
|
-
}
|
|
4248
|
-
if (value.includes("=") || value.includes(",")) {
|
|
4249
|
-
this.api.logger.error(\`[CozeloopTrace] Invalid COZELOOP_UDF_TAGS value contains '=' or ',': "\${value}"\`);
|
|
4250
|
-
continue;
|
|
4251
|
-
}
|
|
4252
|
-
if (!key) {
|
|
4253
|
-
this.api.logger.error(\`[CozeloopTrace] Invalid COZELOOP_UDF_TAGS entry (empty key): "\${pair}"\`);
|
|
4254
|
-
continue;
|
|
4255
|
-
}
|
|
4256
|
-
attrs[\`udf_\${key}\`] = value;
|
|
4257
|
-
}
|
|
4258
|
-
}
|
|
4259
|
-
return attrs;
|
|
4260
|
-
}
|
|
4261
|
-
async refreshAuthIfNeeded() {
|
|
4262
|
-
const fresh = await getRefreshedToken(this.config.authorization);
|
|
4263
|
-
if (fresh && fresh !== this.config.authorization) {
|
|
4264
|
-
this.api.logger.info("[CozeloopTrace] Token refreshed, re-initializing exporter...");
|
|
4265
|
-
this.config.authorization = fresh;
|
|
4266
|
-
// Reset so initialize() re-creates the exporter with the new token
|
|
4267
|
-
this.initialized = false;
|
|
4268
|
-
this.initPromise = null;
|
|
4269
|
-
if (this.provider) {
|
|
4270
|
-
try { await this.provider.shutdown(); } catch { /* ignore */ }
|
|
4271
|
-
this.provider = null;
|
|
4272
|
-
this.tracer = null;
|
|
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 = "";
|
|
4277
|
-
}
|
|
4278
|
-
}
|
|
4279
|
-
async ensureInitialized() {
|
|
4280
|
-
if (this.initialized)
|
|
4281
|
-
return;
|
|
4282
|
-
if (this.initPromise)
|
|
4283
|
-
return this.initPromise;
|
|
4284
|
-
this.initPromise = this.initialize();
|
|
4285
|
-
await this.initPromise;
|
|
4286
|
-
}
|
|
4287
|
-
async initialize() {
|
|
4288
|
-
this.api.logger.info(\`[CozeloopTrace] Initializing exporter...\`);
|
|
4289
|
-
const instanceName = this.config.serviceName || basename(process.cwd()) || "openclaw-agent";
|
|
4290
|
-
const instanceId = \`\${instanceName}@\${hostname()}:\${process.pid}\`;
|
|
4291
|
-
const resource = new Resource({
|
|
4292
|
-
[ATTR_SERVICE_NAME]: this.config.serviceName,
|
|
4293
|
-
[ATTR_SERVICE_INSTANCE_ID]: instanceId,
|
|
4294
|
-
"host.name": hostname(),
|
|
4295
|
-
});
|
|
4296
|
-
const authorization = this.config.authorization;
|
|
4297
|
-
const workspaceId = this.config.workspaceId;
|
|
4298
|
-
this.api.logger.info(\`[CozeloopTrace] Using authorization, workspaceId=\${workspaceId}, tokenLength=\${authorization?.length}\`);
|
|
4299
|
-
const exporter = new OTLPTraceExporter({
|
|
4300
|
-
url: \`\${this.config.endpoint}/v1/traces\`,
|
|
4301
|
-
headers: {
|
|
4302
|
-
"Authorization": authorization,
|
|
4303
|
-
"cozeloop-workspace-id": workspaceId,
|
|
4304
|
-
"x-tt-env": "ppe_cozelab",
|
|
4305
|
-
"x-use-ppe": "1",
|
|
4306
|
-
},
|
|
4307
|
-
});
|
|
4308
|
-
this.provider = new BasicTracerProvider({ resource });
|
|
4309
|
-
this.provider.addSpanProcessor(new BatchSpanProcessor(exporter, {
|
|
4310
|
-
maxQueueSize: 100,
|
|
4311
|
-
maxExportBatchSize: this.config.batchSize || 10,
|
|
4312
|
-
scheduledDelayMillis: this.config.batchInterval || 5000,
|
|
4313
|
-
}));
|
|
4314
|
-
// Do NOT call this.provider.register() — it sets the global TracerProvider
|
|
4315
|
-
// singleton, so if the plugin is activated more than once (e.g. gateway +
|
|
4316
|
-
// plugins subsystem), the second instance would silently get a NOOP tracer
|
|
4317
|
-
// while its hooks override those of the first instance, causing all trace
|
|
4318
|
-
// operations to become no-ops. Instead, obtain the tracer directly from
|
|
4319
|
-
// our own provider instance.
|
|
4320
|
-
this.tracer = this.provider.getTracer("openclaw-cozeloop-trace", PLUGIN_VERSION);
|
|
4321
|
-
this.initialized = true;
|
|
4322
|
-
this.api.logger.info(\`[CozeloopTrace] Exporter initialized with Authorization, workspaceId=\${workspaceId}\`);
|
|
4323
|
-
}
|
|
4324
|
-
async startSpan(spanData, spanId) {
|
|
4325
|
-
try {
|
|
4326
|
-
await this.ensureInitialized();
|
|
4327
|
-
this.doStartSpan(spanData, spanId);
|
|
4328
|
-
}
|
|
4329
|
-
catch (err) {
|
|
4330
|
-
this.api.logger.error(\`[CozeloopTrace] Failed to start span: \${err}\`);
|
|
4331
|
-
}
|
|
4332
|
-
}
|
|
4333
|
-
doStartSpan(spanData, spanId) {
|
|
4334
|
-
if (!this.tracer)
|
|
4335
|
-
return;
|
|
4336
|
-
const spanKind = this.getSpanKind(spanData.type);
|
|
4337
|
-
const isRoot = !spanData.parentSpanId;
|
|
4338
|
-
const isAgent = spanData.type === "agent";
|
|
4339
|
-
// Resolve parent context:
|
|
4340
|
-
// - Root spans: no parent, use active context.
|
|
4341
|
-
// - Agent/child spans: look up traceContexts by parentSpanId (which is
|
|
4342
|
-
// always the rootSpanId set by index.ts createSpan / ensureRootSpan).
|
|
4343
|
-
const traceCtx = spanData.parentSpanId
|
|
4344
|
-
? this.traceContexts.get(spanData.parentSpanId)
|
|
4345
|
-
: undefined;
|
|
4346
|
-
if (!isRoot && !traceCtx && this.config.debug) {
|
|
4347
|
-
const keys = Array.from(this.traceContexts.keys());
|
|
4348
|
-
this.api.logger.info(\`[CozeloopTrace] doStartSpan() cannot find parent context: \` +
|
|
4349
|
-
\`parentSpanId=\${spanData.parentSpanId}, spanName=\${spanData.name}, type=\${spanData.type}, \` +
|
|
4350
|
-
\`traceContextKeys=[\${keys.join(",")}]\`);
|
|
4351
|
-
}
|
|
4352
|
-
let parentContext;
|
|
4353
|
-
if (isRoot) {
|
|
4354
|
-
parentContext = context.active();
|
|
4355
|
-
}
|
|
4356
|
-
else if (isAgent) {
|
|
4357
|
-
parentContext = traceCtx?.rootContext || context.active();
|
|
4358
|
-
}
|
|
4359
|
-
else {
|
|
4360
|
-
parentContext = traceCtx?.agentContext || traceCtx?.rootContext || context.active();
|
|
4361
|
-
}
|
|
4362
|
-
const runtimeTag = {
|
|
4363
|
-
language: "nodejs",
|
|
4364
|
-
library: "openclaw",
|
|
4365
|
-
};
|
|
4366
|
-
if (process.env.COZELOOP_SCENE) {
|
|
4367
|
-
runtimeTag.scene = process.env.COZELOOP_SCENE;
|
|
4368
|
-
}
|
|
4369
|
-
const systemTagRuntime = JSON.stringify(runtimeTag);
|
|
4370
|
-
const span = this.tracer.startSpan(spanData.name, {
|
|
4371
|
-
kind: spanKind,
|
|
4372
|
-
startTime: spanData.startTime,
|
|
4373
|
-
attributes: {
|
|
4374
|
-
"cozeloop.span_type": spanData.type,
|
|
4375
|
-
"cozeloop.system_tag_runtime": systemTagRuntime,
|
|
4376
|
-
...this.envAttributes,
|
|
4377
|
-
...this.flattenAttributes(spanData.attributes),
|
|
4378
|
-
},
|
|
4379
|
-
}, parentContext);
|
|
4380
|
-
if (isRoot) {
|
|
4381
|
-
const rootContext = trace.setSpan(context.active(), span);
|
|
4382
|
-
this.traceContexts.set(spanId, { rootSpan: span, rootContext });
|
|
4383
|
-
if (this.config.debug) {
|
|
4384
|
-
const sc = span.spanContext();
|
|
4385
|
-
this.api.logger.info(\`[CozeloopTrace] Created ROOT span: name=\${spanData.name}, traceId=\${sc.traceId}, spanId=\${sc.spanId}\`);
|
|
4386
|
-
}
|
|
4387
|
-
}
|
|
4388
|
-
if (isAgent && traceCtx) {
|
|
4389
|
-
traceCtx.agentSpan = span;
|
|
4390
|
-
traceCtx.agentContext = trace.setSpan(traceCtx.rootContext, span);
|
|
4391
|
-
if (this.config.debug) {
|
|
4392
|
-
const sc = span.spanContext();
|
|
4393
|
-
this.api.logger.info(\`[CozeloopTrace] Created AGENT span: name=\${spanData.name}, traceId=\${sc.traceId}, spanId=\${sc.spanId}\`);
|
|
4394
|
-
}
|
|
4395
|
-
}
|
|
4396
|
-
this.setSpanInputOutput(span, spanData);
|
|
4397
|
-
this.openSpans.set(spanId, span);
|
|
4398
|
-
if (this.config.debug && !isRoot && !isAgent) {
|
|
4399
|
-
const spanContext = span.spanContext();
|
|
4400
|
-
this.api.logger.info(\`[CozeloopTrace] Started span: name=\${spanData.name}, type=\${spanData.type}, \` +
|
|
4401
|
-
\`traceId=\${spanContext.traceId}, spanId=\${spanContext.spanId}\`);
|
|
4402
|
-
}
|
|
4403
|
-
}
|
|
4404
|
-
endSpanById(spanId, endTime, additionalAttrs, output, input) {
|
|
4405
|
-
const span = this.openSpans.get(spanId);
|
|
4406
|
-
if (!span) {
|
|
4407
|
-
if (this.config.debug) {
|
|
4408
|
-
this.api.logger.info(\`[CozeloopTrace] Span not found for ending: spanId=\${spanId}\`);
|
|
4409
|
-
}
|
|
4410
|
-
return;
|
|
4411
|
-
}
|
|
4412
|
-
if (additionalAttrs) {
|
|
4413
|
-
for (const [key, value] of Object.entries(additionalAttrs)) {
|
|
4414
|
-
if (value !== undefined && value !== null) {
|
|
4415
|
-
span.setAttribute(key, value);
|
|
4416
|
-
}
|
|
4417
|
-
}
|
|
4418
|
-
}
|
|
4419
|
-
if (input !== undefined) {
|
|
4420
|
-
const inputStr = typeof input === "string" ? input : JSON.stringify(input);
|
|
4421
|
-
span.setAttribute("cozeloop.input", inputStr.substring(0, 3200000));
|
|
4422
|
-
}
|
|
4423
|
-
if (output !== undefined) {
|
|
4424
|
-
const outputStr = typeof output === "string" ? output : JSON.stringify(output);
|
|
4425
|
-
span.setAttribute("cozeloop.output", outputStr.substring(0, 3200000));
|
|
4426
|
-
}
|
|
4427
|
-
span.setStatus({ code: SpanStatusCode.OK });
|
|
4428
|
-
span.end(endTime || Date.now());
|
|
4429
|
-
this.openSpans.delete(spanId);
|
|
4430
|
-
if (this.config.debug) {
|
|
4431
|
-
const sc = span.spanContext();
|
|
4432
|
-
this.api.logger.info(\`[CozeloopTrace] Ended span: spanId=\${spanId}, traceId=\${sc.traceId}\`);
|
|
4433
|
-
}
|
|
4434
|
-
}
|
|
4435
|
-
async export(spanData) {
|
|
4436
|
-
try {
|
|
4437
|
-
await this.ensureInitialized();
|
|
4438
|
-
if (!this.tracer)
|
|
4439
|
-
return;
|
|
4440
|
-
const spanKind = this.getSpanKind(spanData.type);
|
|
4441
|
-
const isRoot = !spanData.parentSpanId;
|
|
4442
|
-
const isAgent = spanData.type === "agent";
|
|
4443
|
-
const traceCtx = spanData.parentSpanId
|
|
4444
|
-
? this.traceContexts.get(spanData.parentSpanId)
|
|
4445
|
-
: undefined;
|
|
4446
|
-
if (!isRoot && !traceCtx) {
|
|
4447
|
-
// Only warn for span types that are expected to be inside a trace
|
|
4448
|
-
// (agent, model, tool). message/session/gateway spans may fire before
|
|
4449
|
-
// the root span is created and that is normal.
|
|
4450
|
-
const criticalTypes = new Set(["agent", "model", "tool"]);
|
|
4451
|
-
if (criticalTypes.has(spanData.type) && this.config.debug) {
|
|
4452
|
-
const keys = Array.from(this.traceContexts.keys());
|
|
4453
|
-
this.api.logger.info(\`[CozeloopTrace] export() cannot find parent context: \` +
|
|
4454
|
-
\`parentSpanId=\${spanData.parentSpanId}, spanName=\${spanData.name}, type=\${spanData.type}, \` +
|
|
4455
|
-
\`traceContextKeys=[\${keys.join(",")}]\`);
|
|
4456
|
-
}
|
|
4457
|
-
}
|
|
4458
|
-
let parentContext;
|
|
4459
|
-
if (isRoot) {
|
|
4460
|
-
parentContext = context.active();
|
|
4461
|
-
}
|
|
4462
|
-
else if (isAgent) {
|
|
4463
|
-
parentContext = traceCtx?.rootContext || context.active();
|
|
4464
|
-
}
|
|
4465
|
-
else {
|
|
4466
|
-
parentContext = traceCtx?.agentContext || traceCtx?.rootContext || context.active();
|
|
4467
|
-
}
|
|
4468
|
-
const runtimeTag = {
|
|
4469
|
-
language: "nodejs",
|
|
4470
|
-
library: "openclaw",
|
|
4471
|
-
};
|
|
4472
|
-
if (process.env.COZELOOP_SCENE) {
|
|
4473
|
-
runtimeTag.scene = process.env.COZELOOP_SCENE;
|
|
4474
|
-
}
|
|
4475
|
-
const systemTagRuntime = JSON.stringify(runtimeTag);
|
|
4476
|
-
const span = this.tracer.startSpan(spanData.name, {
|
|
4477
|
-
kind: spanKind,
|
|
4478
|
-
startTime: spanData.startTime,
|
|
4479
|
-
attributes: {
|
|
4480
|
-
"cozeloop.span_type": spanData.type,
|
|
4481
|
-
"cozeloop.system_tag_runtime": systemTagRuntime,
|
|
4482
|
-
...this.envAttributes,
|
|
4483
|
-
...this.flattenAttributes(spanData.attributes),
|
|
4484
|
-
},
|
|
4485
|
-
}, parentContext);
|
|
4486
|
-
if (isRoot) {
|
|
4487
|
-
const rootContext = trace.setSpan(context.active(), span);
|
|
4488
|
-
const spanId = spanData.spanId || "export-root";
|
|
4489
|
-
this.traceContexts.set(spanId, { rootSpan: span, rootContext });
|
|
4490
|
-
if (this.config.debug) {
|
|
4491
|
-
const sc = span.spanContext();
|
|
4492
|
-
this.api.logger.info(\`[CozeloopTrace] Created ROOT span: name=\${spanData.name}, traceId=\${sc.traceId}, spanId=\${sc.spanId}\`);
|
|
4493
|
-
}
|
|
4494
|
-
}
|
|
4495
|
-
this.setSpanInputOutput(span, spanData);
|
|
4496
|
-
const hasError = spanData.attributes["error"] === true || spanData.attributes["tool.error"] === true;
|
|
4497
|
-
if (hasError) {
|
|
4498
|
-
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
4499
|
-
}
|
|
4500
|
-
else {
|
|
4501
|
-
span.setStatus({ code: SpanStatusCode.OK });
|
|
4502
|
-
}
|
|
4503
|
-
span.end(spanData.endTime || Date.now());
|
|
4504
|
-
if (this.config.debug) {
|
|
4505
|
-
const spanContext = span.spanContext();
|
|
4506
|
-
this.api.logger.info(\`[CozeloopTrace] Created span: name=\${spanData.name}, type=\${spanData.type}, \` +
|
|
4507
|
-
\`traceId=\${spanContext.traceId}, spanId=\${spanContext.spanId}, isRoot=\${isRoot}\`);
|
|
4508
|
-
}
|
|
4509
|
-
}
|
|
4510
|
-
catch (err) {
|
|
4511
|
-
this.api.logger.error(\`[CozeloopTrace] Failed to export span: \${err}\`);
|
|
4512
|
-
}
|
|
4513
|
-
}
|
|
4514
|
-
setSpanInputOutput(span, spanData) {
|
|
4515
|
-
if (spanData.input !== undefined) {
|
|
4516
|
-
const inputStr = typeof spanData.input === "string"
|
|
4517
|
-
? spanData.input
|
|
4518
|
-
: JSON.stringify(spanData.input);
|
|
4519
|
-
span.setAttribute("cozeloop.input", inputStr.substring(0, 3200000));
|
|
4520
|
-
}
|
|
4521
|
-
if (spanData.output !== undefined) {
|
|
4522
|
-
const outputStr = typeof spanData.output === "string"
|
|
4523
|
-
? spanData.output
|
|
4524
|
-
: JSON.stringify(spanData.output);
|
|
4525
|
-
span.setAttribute("cozeloop.output", outputStr.substring(0, 3200000));
|
|
4526
|
-
}
|
|
4527
|
-
}
|
|
4528
|
-
hasTraceContext(rootSpanId) {
|
|
4529
|
-
return this.traceContexts.has(rootSpanId);
|
|
4530
|
-
}
|
|
4531
|
-
endTrace(rootSpanId) {
|
|
4532
|
-
if (rootSpanId) {
|
|
4533
|
-
this.traceContexts.delete(rootSpanId);
|
|
4534
|
-
}
|
|
4535
|
-
else {
|
|
4536
|
-
this.traceContexts.clear();
|
|
4537
|
-
this.openSpans.clear();
|
|
4538
|
-
}
|
|
4539
|
-
if (this.config.debug) {
|
|
4540
|
-
this.api.logger.info(\`[CozeloopTrace] Trace ended, context cleared\${rootSpanId ? \` for rootSpanId=\${rootSpanId}\` : ' (all)'}\`);
|
|
4541
|
-
}
|
|
4542
|
-
}
|
|
4543
|
-
getSpanKind(type) {
|
|
4544
|
-
switch (type) {
|
|
4545
|
-
case "entry":
|
|
4546
|
-
case "gateway":
|
|
4547
|
-
return SpanKind.SERVER;
|
|
4548
|
-
case "model":
|
|
4549
|
-
return SpanKind.CLIENT;
|
|
4550
|
-
case "tool":
|
|
4551
|
-
return SpanKind.CLIENT;
|
|
4552
|
-
default:
|
|
4553
|
-
return SpanKind.INTERNAL;
|
|
4554
|
-
}
|
|
4555
|
-
}
|
|
4556
|
-
flattenAttributes(attrs) {
|
|
4557
|
-
const result = {};
|
|
4558
|
-
for (const [key, value] of Object.entries(attrs)) {
|
|
4559
|
-
if (value !== undefined && value !== null) {
|
|
4560
|
-
result[key] = value;
|
|
4561
|
-
}
|
|
4562
|
-
}
|
|
4563
|
-
return result;
|
|
4564
|
-
}
|
|
4565
|
-
async flush() {
|
|
4566
|
-
if (this.provider) {
|
|
4567
|
-
await this.provider.forceFlush();
|
|
4568
|
-
}
|
|
4569
|
-
}
|
|
4570
|
-
async dispose() {
|
|
4571
|
-
if (this.provider) {
|
|
4572
|
-
await this.provider.shutdown();
|
|
4573
|
-
}
|
|
4574
|
-
}
|
|
4575
|
-
}
|
|
4576
|
-
`,
|
|
4577
|
-
"dist/span-manager.js": `function generateId(length = 16) {
|
|
4578
|
-
const chars = "0123456789abcdef";
|
|
4579
|
-
let result = "";
|
|
4580
|
-
for (let i = 0; i < length; i++) {
|
|
4581
|
-
result += chars[Math.floor(Math.random() * chars.length)];
|
|
4582
|
-
}
|
|
4583
|
-
return result;
|
|
4584
|
-
}
|
|
4585
|
-
export class SpanManager {
|
|
4586
|
-
activeSpans = new Map();
|
|
4587
|
-
sessionTraceMap = new Map();
|
|
4588
|
-
turnSpanMap = new Map();
|
|
4589
|
-
generateTraceId() {
|
|
4590
|
-
return generateId(32);
|
|
4591
|
-
}
|
|
4592
|
-
generateSpanId() {
|
|
4593
|
-
return generateId(16);
|
|
4594
|
-
}
|
|
4595
|
-
getOrCreateTraceId(sessionId) {
|
|
4596
|
-
let traceId = this.sessionTraceMap.get(sessionId);
|
|
4597
|
-
if (!traceId) {
|
|
4598
|
-
traceId = this.generateTraceId();
|
|
4599
|
-
this.sessionTraceMap.set(sessionId, traceId);
|
|
4600
|
-
}
|
|
4601
|
-
return traceId;
|
|
4602
|
-
}
|
|
4603
|
-
startSpan(sessionId, name, type, attributes = {}, input, parentSpanId) {
|
|
4604
|
-
const traceId = this.getOrCreateTraceId(sessionId);
|
|
4605
|
-
const spanId = this.generateSpanId();
|
|
4606
|
-
const span = {
|
|
4607
|
-
name,
|
|
4608
|
-
type,
|
|
4609
|
-
startTime: Date.now(),
|
|
4610
|
-
attributes: {
|
|
4611
|
-
...attributes,
|
|
4612
|
-
"session.id": sessionId,
|
|
4613
|
-
},
|
|
4614
|
-
input,
|
|
4615
|
-
parentSpanId,
|
|
4616
|
-
traceId,
|
|
4617
|
-
spanId,
|
|
4618
|
-
};
|
|
4619
|
-
this.activeSpans.set(spanId, span);
|
|
4620
|
-
return span;
|
|
4621
|
-
}
|
|
4622
|
-
endSpan(spanId, output, additionalAttributes) {
|
|
4623
|
-
const span = this.activeSpans.get(spanId);
|
|
4624
|
-
if (!span)
|
|
4625
|
-
return undefined;
|
|
4626
|
-
span.endTime = Date.now();
|
|
4627
|
-
span.output = output;
|
|
4628
|
-
if (additionalAttributes) {
|
|
4629
|
-
Object.assign(span.attributes, additionalAttributes);
|
|
4630
|
-
}
|
|
4631
|
-
this.activeSpans.delete(spanId);
|
|
4632
|
-
return span;
|
|
4633
|
-
}
|
|
4634
|
-
getSpan(spanId) {
|
|
4635
|
-
return this.activeSpans.get(spanId);
|
|
4636
|
-
}
|
|
4637
|
-
setTurnSpan(turnId, spanId) {
|
|
4638
|
-
this.turnSpanMap.set(turnId, spanId);
|
|
4639
|
-
}
|
|
4640
|
-
getTurnSpanId(turnId) {
|
|
4641
|
-
return this.turnSpanMap.get(turnId);
|
|
4642
|
-
}
|
|
4643
|
-
clearSession(sessionId) {
|
|
4644
|
-
this.sessionTraceMap.delete(sessionId);
|
|
4645
|
-
for (const [turnId, spanId] of this.turnSpanMap.entries()) {
|
|
4646
|
-
const span = this.activeSpans.get(spanId);
|
|
4647
|
-
if (span && span.attributes["session.id"] === sessionId) {
|
|
4648
|
-
this.turnSpanMap.delete(turnId);
|
|
4649
|
-
this.activeSpans.delete(spanId);
|
|
4650
|
-
}
|
|
4651
|
-
}
|
|
4652
|
-
}
|
|
4653
|
-
}
|
|
4654
|
-
`,
|
|
4655
|
-
"dist/types.js": `export {};
|
|
4656
|
-
`,
|
|
4657
|
-
"openclaw.plugin.json": `{
|
|
4658
|
-
"id": "openclaw-cozeloop-trace",
|
|
4659
|
-
"name": "OpenClaw CozeLoop Trace",
|
|
4660
|
-
"version": "0.1.12",
|
|
4661
|
-
"description": "Report OpenClaw execution traces to CozeLoop via OpenTelemetry",
|
|
4662
|
-
"type": "plugin",
|
|
4663
|
-
"entry": "./dist/index.js",
|
|
4664
|
-
"configSchema": {
|
|
4665
|
-
"type": "object",
|
|
4666
|
-
"properties": {
|
|
4667
|
-
"endpoint": {
|
|
4668
|
-
"type": "string",
|
|
4669
|
-
"default": "https://api.coze.cn/v1/loop/opentelemetry",
|
|
4670
|
-
"description": "CozeLoop OTLP endpoint URL"
|
|
4671
|
-
},
|
|
4672
|
-
"authorization": {
|
|
4673
|
-
"type": "string",
|
|
4674
|
-
"default": "",
|
|
4675
|
-
"description": "Authorization header value"
|
|
4676
|
-
},
|
|
4677
|
-
"workspaceId": {
|
|
4678
|
-
"type": "string",
|
|
4679
|
-
"default": "",
|
|
4680
|
-
"description": "Cozeloop workspace ID"
|
|
4681
|
-
},
|
|
4682
|
-
"serviceName": {
|
|
4683
|
-
"type": "string",
|
|
4684
|
-
"default": "openclaw-agent",
|
|
4685
|
-
"description": "Service name for traces"
|
|
4686
|
-
},
|
|
4687
|
-
"debug": {
|
|
4688
|
-
"type": "boolean",
|
|
4689
|
-
"default": false,
|
|
4690
|
-
"description": "Enable debug logging"
|
|
4691
|
-
},
|
|
4692
|
-
"batchSize": {
|
|
4693
|
-
"type": "number",
|
|
4694
|
-
"default": 10,
|
|
4695
|
-
"description": "Number of spans to buffer before sending"
|
|
4696
|
-
},
|
|
4697
|
-
"batchInterval": {
|
|
4698
|
-
"type": "number",
|
|
4699
|
-
"default": 5000,
|
|
4700
|
-
"description": "Maximum time (ms) to wait before sending buffered spans"
|
|
4701
|
-
},
|
|
4702
|
-
"enabledHooks": {
|
|
4703
|
-
"type": "array",
|
|
4704
|
-
"items": {
|
|
4705
|
-
"type": "string"
|
|
4706
|
-
},
|
|
4707
|
-
"description": "List of hooks to enable (if not set, all hooks are enabled)"
|
|
4708
|
-
}
|
|
4709
|
-
}
|
|
4710
|
-
}
|
|
4711
|
-
}
|
|
4712
|
-
`,
|
|
4713
|
-
"package.json": `{
|
|
4714
|
-
"name": "@cozeloop/openclaw-cozeloop-trace",
|
|
4715
|
-
"version": "0.1.12",
|
|
4716
|
-
"description": "OpenClaw Plugin for reporting traces to CozeLoop via OpenTelemetry",
|
|
4717
|
-
"type": "module",
|
|
4718
|
-
"main": "dist/index.js",
|
|
4719
|
-
"exports": {
|
|
4720
|
-
".": "./dist/index.js"
|
|
4721
|
-
},
|
|
4722
|
-
"openclaw": {
|
|
4723
|
-
"extensions": [
|
|
4724
|
-
"./dist/index.js"
|
|
4725
|
-
]
|
|
4726
|
-
},
|
|
4727
|
-
"scripts": {
|
|
4728
|
-
"build": "tsc",
|
|
4729
|
-
"dev": "tsc --watch",
|
|
4730
|
-
"clean": "rm -rf dist",
|
|
4731
|
-
"prepublishOnly": "npm run build"
|
|
4732
|
-
},
|
|
4733
|
-
"files": [
|
|
4734
|
-
"dist",
|
|
4735
|
-
"openclaw.plugin.json"
|
|
4736
|
-
],
|
|
4737
|
-
"keywords": [
|
|
4738
|
-
"openclaw",
|
|
4739
|
-
"plugin",
|
|
4740
|
-
"cozeloop",
|
|
4741
|
-
"tracing",
|
|
4742
|
-
"opentelemetry"
|
|
4743
|
-
],
|
|
4744
|
-
"author": "",
|
|
4745
|
-
"license": "MIT",
|
|
4746
|
-
"devDependencies": {
|
|
4747
|
-
"@types/node": "^20.0.0",
|
|
4748
|
-
"typescript": "^5.0.0"
|
|
4749
|
-
},
|
|
4750
|
-
"dependencies": {
|
|
4751
|
-
"@opentelemetry/api": "^1.7.0",
|
|
4752
|
-
"@opentelemetry/exporter-trace-otlp-proto": "^0.48.0",
|
|
4753
|
-
"@opentelemetry/resources": "^1.22.0",
|
|
4754
|
-
"@opentelemetry/sdk-trace-base": "^1.22.0",
|
|
4755
|
-
"@opentelemetry/semantic-conventions": "^1.22.0"
|
|
4756
|
-
}
|
|
4757
|
-
}
|
|
4758
|
-
`,
|
|
4759
|
-
};
|
|
4760
|
-
|
|
4761
|
-
// ─── 2c. Embedded refresh script ──────────────────────────────────────────────
|
|
4762
|
-
const COZELOOP_REFRESH_PY = `\
|
|
4763
|
-
#!/usr/bin/env python3
|
|
4764
|
-
"""
|
|
4765
|
-
CozeLoop token refresh hook — runs at SessionStart.
|
|
4766
|
-
Checks credentials.json and refreshes the token if expiring soon.
|
|
4767
|
-
Writes the fresh token back so subsequent Stop hooks pick it up.
|
|
4768
|
-
"""
|
|
4769
|
-
import json, os, sys, time, urllib.request
|
|
4770
|
-
from pathlib import Path
|
|
4771
|
-
|
|
4772
|
-
CLIENT_ID = "46371084383473718052118955183420.app.coze"
|
|
4773
|
-
COZE_API = "https://api.coze.cn"
|
|
4774
|
-
THRESHOLD = 10 * 60 # 10 minutes
|
|
4775
|
-
CREDS = Path.home() / ".cozeloop" / "credentials.json"
|
|
4776
|
-
|
|
4777
|
-
def load():
|
|
4778
|
-
try: return json.loads(CREDS.read_text())
|
|
4779
|
-
except: return None
|
|
4780
|
-
|
|
4781
|
-
def save(c):
|
|
4782
|
-
CREDS.parent.mkdir(parents=True, exist_ok=True)
|
|
4783
|
-
CREDS.write_text(json.dumps(c, indent=2))
|
|
4784
|
-
os.chmod(CREDS, 0o600)
|
|
4785
|
-
|
|
4786
|
-
def refresh(rt):
|
|
4787
|
-
try:
|
|
4788
|
-
body = json.dumps({"grant_type":"refresh_token","client_id":CLIENT_ID,"refresh_token":rt}).encode()
|
|
4789
|
-
req = urllib.request.Request(f"{COZE_API}/api/permission/oauth2/token",
|
|
4790
|
-
data=body, headers={
|
|
4791
|
-
"Content-Type":"application/json",
|
|
4792
|
-
"x-tt-env":"ppe_cozelab",
|
|
4793
|
-
"x-use-ppe":"1",
|
|
4794
|
-
})
|
|
4795
|
-
with urllib.request.urlopen(req, timeout=10) as r:
|
|
4796
|
-
d = json.loads(r.read())
|
|
4797
|
-
if d.get("access_token"):
|
|
4798
|
-
existing = load() or {}
|
|
4799
|
-
save({"access_token":d["access_token"],
|
|
4800
|
-
"refresh_token":d.get("refresh_token",rt),
|
|
4801
|
-
"expires_at":d.get("expires_in",0)*1000,
|
|
4802
|
-
"workspace_id":existing.get("workspace_id","")})
|
|
4803
|
-
return True
|
|
4804
|
-
except Exception as e:
|
|
4805
|
-
print(f"[cozeloop_refresh] refresh failed: {e}", file=sys.stderr)
|
|
4806
|
-
return False
|
|
4807
|
-
|
|
4808
|
-
def main():
|
|
4809
|
-
creds = load()
|
|
4810
|
-
if not creds: return
|
|
4811
|
-
remaining = (creds.get("expires_at",0)/1000) - time.time()
|
|
4812
|
-
if remaining > THRESHOLD: return # still fresh
|
|
4813
|
-
print(f"[cozeloop_refresh] token expiring in {int(remaining)}s, refreshing...", file=sys.stderr)
|
|
4814
|
-
if creds.get("refresh_token"):
|
|
4815
|
-
if refresh(creds["refresh_token"]):
|
|
4816
|
-
print("[cozeloop_refresh] token refreshed OK", file=sys.stderr)
|
|
4817
|
-
else:
|
|
4818
|
-
print("[cozeloop_refresh] refresh failed, token may expire soon", file=sys.stderr)
|
|
4819
|
-
|
|
4820
|
-
main()
|
|
4821
|
-
|
|
4822
|
-
`;
|
|
4823
139
|
|
|
4824
140
|
// ─── 3. Param parsing ────────────────────────────────────────────────────────
|
|
4825
141
|
function parseArgs() {
|
|
@@ -5249,9 +565,9 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
|
|
|
5249
565
|
existing.env.OTEL_ENDPOINT = otelEndpoint;
|
|
5250
566
|
delete existing.env.COZELOOP_API_BASE_URL;
|
|
5251
567
|
}
|
|
5252
|
-
// PPE
|
|
5253
|
-
existing.env.x_tt_env
|
|
5254
|
-
existing.env.x_use_ppe
|
|
568
|
+
// 切正式环境:移除历史注入的 PPE 泳道 env(旧版本曾写入 x_tt_env/x_use_ppe)
|
|
569
|
+
delete existing.env.x_tt_env;
|
|
570
|
+
delete existing.env.x_use_ppe;
|
|
5255
571
|
return existing;
|
|
5256
572
|
});
|
|
5257
573
|
try {
|
|
@@ -5308,9 +624,6 @@ function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
|
|
|
5308
624
|
} else if (otelEndpoint) {
|
|
5309
625
|
envLines.push(shellEnvLine('OTEL_ENDPOINT', otelEndpoint));
|
|
5310
626
|
}
|
|
5311
|
-
// PPE 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
|
|
5312
|
-
envLines.push(shellEnvLine('x_tt_env', PPE_TT_ENV));
|
|
5313
|
-
envLines.push(shellEnvLine('x_use_ppe', PPE_USE_PPE));
|
|
5314
627
|
const envContent = envLines.join('\n') + '\n';
|
|
5315
628
|
try {
|
|
5316
629
|
fs.writeFileSync(envFile, envContent, { mode: 0o600 });
|
|
@@ -5653,7 +966,6 @@ function httpsPost(url, body, extraHeaders) {
|
|
|
5653
966
|
const req = https.request(
|
|
5654
967
|
{ hostname: u.hostname, port: u.port || undefined, path: u.pathname + u.search, method: 'POST',
|
|
5655
968
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data),
|
|
5656
|
-
'x-tt-env': PPE_TT_ENV, 'x-use-ppe': PPE_USE_PPE,
|
|
5657
969
|
...(extraHeaders || {}) } },
|
|
5658
970
|
(res) => {
|
|
5659
971
|
let buf = '';
|
|
@@ -5738,8 +1050,6 @@ def http_diag():
|
|
|
5738
1050
|
headers={
|
|
5739
1051
|
"Content-Type": "application/json",
|
|
5740
1052
|
"Authorization": "Bearer " + os.environ.get("COZELOOP_API_TOKEN", ""),
|
|
5741
|
-
"x-tt-env": os.environ.get("x_tt_env", ""),
|
|
5742
|
-
"x-use-ppe": os.environ.get("x_use_ppe", ""),
|
|
5743
1053
|
},
|
|
5744
1054
|
method="POST",
|
|
5745
1055
|
)
|
|
@@ -5789,8 +1099,6 @@ except Exception as e:
|
|
|
5789
1099
|
COZELOOP_API_TOKEN: token,
|
|
5790
1100
|
COZELOOP_PAIR_CODE: pair,
|
|
5791
1101
|
COZELOOP_TOKEN_SOURCE: tokenSource || '',
|
|
5792
|
-
x_tt_env: PPE_TT_ENV,
|
|
5793
|
-
x_use_ppe: PPE_USE_PPE,
|
|
5794
1102
|
};
|
|
5795
1103
|
if (apiBase) env.COZELOOP_API_BASE_URL = apiBase;
|
|
5796
1104
|
// 打出 SDK 实际使用的 ingest base,便于核对云端注入的 api_base_url 是否正确(无 /api 残留)。
|
|
@@ -6042,8 +1350,7 @@ function openClawPluginHasRefresh(home) {
|
|
|
6042
1350
|
|
|
6043
1351
|
function httpsGet(url, headers) {
|
|
6044
1352
|
return new Promise((resolve, reject) => {
|
|
6045
|
-
|
|
6046
|
-
const h = { ...(headers || {}), 'x-tt-env': PPE_TT_ENV, 'x-use-ppe': PPE_USE_PPE };
|
|
1353
|
+
const h = { ...(headers || {}) };
|
|
6047
1354
|
const req = https.get(url, { headers: h }, (res) => {
|
|
6048
1355
|
let buf = '';
|
|
6049
1356
|
res.on('data', c => buf += c);
|