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 = '46371084383473718052118955183420.app.coze';
6
- const WORKSPACE_ID = '7644910356078837760';
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 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
5253
- existing.env.x_tt_env = PPE_TT_ENV;
5254
- existing.env.x_use_ppe = PPE_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
- // 合并 PPE 泳道 header
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);