openclaw-agent-dashboard 1.0.39 → 1.0.40

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.
Files changed (54) hide show
  1. package/dashboard/api/agent_config_api.py +28 -7
  2. package/dashboard/api/agents.py +48 -10
  3. package/dashboard/api/agents_config.py +5 -1
  4. package/dashboard/api/chains.py +25 -5
  5. package/dashboard/api/collaboration.py +10 -9
  6. package/dashboard/api/debug_paths.py +5 -1
  7. package/dashboard/api/error_analysis.py +29 -11
  8. package/dashboard/api/errors.py +27 -11
  9. package/dashboard/api/fortify_routes.py +80 -0
  10. package/dashboard/api/input_safety.py +60 -0
  11. package/dashboard/api/performance.py +73 -53
  12. package/dashboard/api/subagents.py +95 -99
  13. package/dashboard/api/timeline.py +24 -3
  14. package/dashboard/api/version.py +2 -0
  15. package/dashboard/api/websocket.py +9 -7
  16. package/dashboard/core/__init__.py +1 -0
  17. package/dashboard/core/config_fortify.py +112 -0
  18. package/dashboard/core/error_handler.py +339 -0
  19. package/dashboard/core/fallback_manager.py +70 -0
  20. package/dashboard/core/safe_api_error.py +76 -0
  21. package/dashboard/core/schemas/__init__.py +16 -0
  22. package/dashboard/core/schemas/base.py +43 -0
  23. package/dashboard/core/schemas/session_schema.py +40 -0
  24. package/dashboard/core/schemas/subagent_schema.py +23 -0
  25. package/dashboard/data/agent_config_manager.py +6 -4
  26. package/dashboard/data/chain_reader.py +16 -12
  27. package/dashboard/data/error_analyzer.py +15 -11
  28. package/dashboard/data/session_reader.py +268 -46
  29. package/dashboard/data/subagent_reader.py +74 -49
  30. package/dashboard/data/timeline_reader.py +35 -49
  31. package/dashboard/main.py +24 -2
  32. package/dashboard/mechanism_reader.py +4 -5
  33. package/dashboard/mechanisms.py +2 -2
  34. package/dashboard/pytest.ini +3 -0
  35. package/dashboard/requirements.txt +5 -0
  36. package/dashboard/status/cache_fp_probe.py +40 -0
  37. package/dashboard/status/status_cache.py +199 -72
  38. package/dashboard/status/status_calculator.py +50 -30
  39. package/dashboard/tests/conftest.py +84 -0
  40. package/dashboard/tests/test_api_contracts.py +372 -0
  41. package/dashboard/tests/test_bench_fortify.py +176 -0
  42. package/dashboard/tests/test_fortify.py +741 -0
  43. package/dashboard/utils/__init__.py +1 -0
  44. package/dashboard/utils/data_repair.py +210 -0
  45. package/dashboard/watchers/file_watcher.py +367 -77
  46. package/openclaw.plugin.json +1 -1
  47. package/package.json +1 -1
  48. package/dashboard/agents.py +0 -74
  49. package/dashboard/collaboration.py +0 -407
  50. package/dashboard/errors.py +0 -63
  51. package/dashboard/performance.py +0 -474
  52. package/dashboard/session_reader.py +0 -240
  53. package/dashboard/status_calculator.py +0 -121
  54. package/dashboard/subagent_reader.py +0 -232
@@ -0,0 +1,60 @@
1
+ """
2
+ 外部路径与 Query 边界校验(NFR-S-002)。
3
+ 拒绝路径逃逸与过长输入,避免异常 agent_id / session 片段误触文件逻辑。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from fastapi import HTTPException
8
+
9
+ _MAX_AGENT_ID_LEN = 128
10
+ _MAX_SESSION_KEY_LEN = 512
11
+ _MAX_RUN_CHAIN_ID_LEN = 128
12
+ _MAX_SESSION_FILE_SEGMENT = 256
13
+
14
+
15
+ def require_safe_agent_id(agent_id: str) -> str:
16
+ s = (agent_id or "").strip()
17
+ if not s or len(s) > _MAX_AGENT_ID_LEN:
18
+ raise HTTPException(status_code=400, detail="invalid agent_id")
19
+ if "\x00" in s:
20
+ raise HTTPException(status_code=400, detail="invalid agent_id")
21
+ if ".." in s:
22
+ raise HTTPException(status_code=400, detail="invalid agent_id")
23
+ for ch in ("/", "\\"):
24
+ if ch in s:
25
+ raise HTTPException(status_code=400, detail="invalid agent_id")
26
+ low = s.lower()
27
+ if "%2f" in low or "%5c" in low or "%2e%2e" in low:
28
+ raise HTTPException(status_code=400, detail="invalid agent_id")
29
+ return s
30
+
31
+
32
+ def require_safe_session_key(session_key: str | None) -> str | None:
33
+ if session_key is None:
34
+ return None
35
+ s = session_key.strip()
36
+ if not s:
37
+ return None
38
+ if len(s) > _MAX_SESSION_KEY_LEN or "\x00" in s:
39
+ raise HTTPException(status_code=400, detail="invalid session_key")
40
+ if ".." in s or "/" in s or "\\" in s:
41
+ raise HTTPException(status_code=400, detail="invalid session_key")
42
+ return s
43
+
44
+
45
+ def require_safe_session_file_segment(session_file: str) -> str:
46
+ s = (session_file or "").strip()
47
+ if not s or len(s) > _MAX_SESSION_FILE_SEGMENT:
48
+ raise HTTPException(status_code=400, detail="invalid session_file")
49
+ if "\x00" in s or ".." in s or "/" in s or "\\" in s:
50
+ raise HTTPException(status_code=400, detail="invalid session_file")
51
+ return s
52
+
53
+
54
+ def require_safe_run_or_chain_id(value: str, *, name: str = "id") -> str:
55
+ s = (value or "").strip()
56
+ if not s or len(s) > _MAX_RUN_CHAIN_ID_LEN:
57
+ raise HTTPException(status_code=400, detail=f"invalid {name}")
58
+ if "\x00" in s or ".." in s or "/" in s or "\\" in s:
59
+ raise HTTPException(status_code=400, detail=f"invalid {name}")
60
+ return s
@@ -10,7 +10,9 @@ from pathlib import Path
10
10
  from datetime import datetime, timedelta, timezone
11
11
  from zoneinfo import ZoneInfo
12
12
 
13
- from data.session_reader import normalize_sessions_index
13
+ from data.session_reader import normalize_sessions_index, _load_sessions_index_file
14
+ from core.error_handler import record_error
15
+ from utils.data_repair import parse_session_jsonl_line
14
16
 
15
17
  # 详情展示使用 Asia/Shanghai 时区
16
18
  TZ_DISPLAY = ZoneInfo('Asia/Shanghai')
@@ -103,23 +105,23 @@ def parse_session_file_with_details(session_path: Path, agent_id: str) -> List[D
103
105
  with open(session_path, 'r', encoding='utf-8') as f:
104
106
  for line in f:
105
107
  try:
106
- data = json.loads(line)
107
- if data.get('type') != 'message':
108
+ data, msg = parse_session_jsonl_line(line)
109
+ if not data or data.get('type') != 'message' or not msg:
108
110
  continue
109
- msg = data.get('message', {})
110
- if not msg:
111
- continue
112
-
111
+
113
112
  msg_id = data.get('id', '')
114
113
  id_to_msg[msg_id] = {'data': data, 'msg': msg}
115
-
114
+
116
115
  if msg.get('role') != 'assistant':
117
116
  continue
118
117
  if 'usage' not in msg:
119
118
  continue
120
-
119
+
120
+ ts_raw = data.get('timestamp')
121
+ if not ts_raw:
122
+ continue
121
123
  try:
122
- ts = datetime.fromisoformat(data['timestamp'].replace('Z', '+00:00'))
124
+ ts = datetime.fromisoformat(str(ts_raw).replace('Z', '+00:00'))
123
125
  except Exception:
124
126
  continue
125
127
 
@@ -163,7 +165,7 @@ def parse_session_file_with_details(session_path: Path, agent_id: str) -> List[D
163
165
  continue
164
166
  return records
165
167
  except Exception as e:
166
- print(f"解析 session 详情失败 {session_path}: {e}")
168
+ record_error("io-error", f"{session_path}: {e}", "performance:parse_session_details", exc=e)
167
169
  return []
168
170
 
169
171
 
@@ -180,37 +182,43 @@ def parse_session_file(session_path: Path, range_hours: int = 1) -> List[Dict]:
180
182
  with open(session_path, 'r', encoding='utf-8') as f:
181
183
  for line in f:
182
184
  try:
183
- data = json.loads(line)
184
-
185
- # 只处理有 usage 和 timestamp 的消息
186
- if 'message' in data and 'usage' in data['message'] and 'timestamp' in data:
187
- usage = data['message']['usage']
188
- tokens = usage.get('totalTokens', 0) or 0
189
- is_request = data.get('message', {}).get('role') == 'assistant'
190
-
191
- try:
192
- timestamp = datetime.fromisoformat(data['timestamp'].replace('Z', '+00:00'))
193
-
194
- # 根据 range_hours 过滤时间范围,0 表示不过滤
195
- if range_hours > 0:
196
- now = datetime.now(timezone.utc)
197
- time_ago = now - timedelta(hours=range_hours)
198
- if timestamp < time_ago:
199
- continue
185
+ envelope, msg = parse_session_jsonl_line(line)
186
+ if (
187
+ not envelope
188
+ or envelope.get('type') != 'message'
189
+ or not msg
190
+ or 'usage' not in msg
191
+ or not envelope.get('timestamp')
192
+ ):
193
+ continue
194
+ usage = msg['usage']
195
+ tokens = usage.get('totalTokens', 0) or 0
196
+ is_request = msg.get('role') == 'assistant'
200
197
 
201
- messages.append({
202
- 'timestamp': timestamp,
203
- 'tokens': tokens,
204
- 'is_request': is_request
205
- })
206
- except:
207
- pass
208
- except:
198
+ try:
199
+ timestamp = datetime.fromisoformat(
200
+ str(envelope['timestamp']).replace('Z', '+00:00')
201
+ )
202
+
203
+ if range_hours > 0:
204
+ now = datetime.now(timezone.utc)
205
+ time_ago = now - timedelta(hours=range_hours)
206
+ if timestamp < time_ago:
207
+ continue
208
+
209
+ messages.append({
210
+ 'timestamp': timestamp,
211
+ 'tokens': tokens,
212
+ 'is_request': is_request
213
+ })
214
+ except Exception:
215
+ pass
216
+ except Exception:
209
217
  continue
210
218
 
211
219
  return messages
212
220
  except Exception as e:
213
- print(f"解析 session 文件失败 {session_path}: {e}")
221
+ record_error("io-error", f"{session_path}: {e}", "performance:parse_session_file", exc=e)
214
222
  return []
215
223
 
216
224
 
@@ -491,9 +499,7 @@ async def get_minute_details(
491
499
  }
492
500
  }
493
501
  except Exception as e:
494
- print(f"获取调用详情失败: {e}")
495
- import traceback
496
- traceback.print_exc()
502
+ record_error("unknown", str(e), "performance:get_minute_details", exc=e)
497
503
  return {'timeWindow': '', 'calls': [], 'totalCalls': 0, 'totalTokens': 0, 'summary': {'avgTokens': 0}, 'agents': [], 'pagination': {'total': 0, 'limit': limit, 'hasMore': False}}
498
504
 
499
505
 
@@ -629,19 +635,33 @@ async def get_tokens_analysis(range: str = "all"):
629
635
  with open(session_file, 'r', encoding='utf-8') as f:
630
636
  for line in f:
631
637
  try:
632
- data = json.loads(line)
633
- if data.get('type') != 'message':
638
+ envelope, msg = parse_session_jsonl_line(line)
639
+ if (
640
+ envelope is None
641
+ or envelope.get('type') != 'message'
642
+ or msg is None
643
+ ):
634
644
  continue
635
- msg = data.get('message', {})
636
645
  if msg.get('role') != 'assistant' or 'usage' not in msg:
637
646
  continue
638
647
 
639
- try:
640
- ts = datetime.fromisoformat(data['timestamp'].replace('Z', '+00:00'))
641
- except:
642
- continue
643
-
644
- if ts < time_ago:
648
+ ts_raw = envelope.get('timestamp') or msg.get('timestamp')
649
+ ts = None
650
+ if isinstance(ts_raw, (int, float)):
651
+ v = float(ts_raw)
652
+ ts = datetime.fromtimestamp(
653
+ (v / 1000.0) if v > 1e12 else v, tz=timezone.utc
654
+ )
655
+ elif isinstance(ts_raw, str):
656
+ try:
657
+ ts = datetime.fromisoformat(
658
+ ts_raw.replace('Z', '+00:00')
659
+ )
660
+ if ts.tzinfo is None:
661
+ ts = ts.replace(tzinfo=timezone.utc)
662
+ except ValueError:
663
+ ts = None
664
+ if ts is None or ts < time_ago:
645
665
  continue
646
666
 
647
667
  usage = msg['usage']
@@ -706,9 +726,8 @@ async def get_tokens_analysis(range: str = "all"):
706
726
  continue
707
727
 
708
728
  try:
709
- with open(sessions_index, 'r', encoding='utf-8') as f:
710
- data = json.load(f)
711
- if not isinstance(data, dict):
729
+ data = _load_sessions_index_file(sessions_index)
730
+ if not data:
712
731
  continue
713
732
 
714
733
  agent_total = {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}
@@ -739,7 +758,8 @@ async def get_tokens_analysis(range: str = "all"):
739
758
  result["summary"]["output"] += agent_total["output"]
740
759
  result["summary"]["cacheRead"] += agent_total["cacheRead"]
741
760
  result["summary"]["cacheWrite"] += agent_total["cacheWrite"]
742
- except Exception:
761
+ except Exception as e:
762
+ record_error("unknown", str(e), "performance:tokens_analysis_agent", exc=e)
743
763
  continue
744
764
 
745
765
  # 计算汇总
@@ -17,7 +17,14 @@ from data.subagent_reader import (
17
17
  get_agent_files_for_run
18
18
  )
19
19
  from data.task_history import merge_with_history
20
- from data.session_reader import normalize_sessions_index, resolve_session_jsonl_path
20
+ from data.session_reader import (
21
+ normalize_sessions_index,
22
+ resolve_session_jsonl_path,
23
+ _load_sessions_index_file,
24
+ )
25
+ from utils.data_repair import parse_session_jsonl_line
26
+ from core.error_handler import record_error
27
+ from core.safe_api_error import safe_client_string
21
28
  import time
22
29
 
23
30
  router = APIRouter()
@@ -108,9 +115,7 @@ async def get_subagents():
108
115
 
109
116
  return result
110
117
  except Exception as e:
111
- print(f"Error in get_subagents: {e}")
112
- import traceback
113
- traceback.print_exc()
118
+ record_error("unknown", str(e), "api:subagents:get_subagents", exc=e)
114
119
  return []
115
120
 
116
121
 
@@ -139,7 +144,7 @@ async def get_active_subagents():
139
144
 
140
145
  return result
141
146
  except Exception as e:
142
- print(f"Error in get_active_subagents: {e}")
147
+ record_error("unknown", str(e), "api:subagents:get_active_subagents", exc=e)
143
148
  return []
144
149
 
145
150
 
@@ -149,7 +154,8 @@ def _get_agent_name(agent_id: str) -> str:
149
154
  from data.config_reader import get_agent_config
150
155
  config = get_agent_config(agent_id)
151
156
  return config.get('name', agent_id) if config else agent_id
152
- except Exception:
157
+ except Exception as e:
158
+ record_error("unknown", str(e), "api:subagents:agent_name", exc=e)
153
159
  return agent_id
154
160
 
155
161
 
@@ -161,7 +167,8 @@ def _get_agent_workspace(agent_id: str) -> Optional[str]:
161
167
  from data.config_reader import get_agent_config
162
168
  config = get_agent_config(agent_id)
163
169
  return config.get('workspace') if config else None
164
- except Exception:
170
+ except Exception as e:
171
+ record_error("unknown", str(e), "api:subagents:workspace", exc=e)
165
172
  return None
166
173
 
167
174
 
@@ -269,8 +276,9 @@ def _get_session_message_count(child_session_key: str) -> int:
269
276
  if not sessions_index.exists():
270
277
  return 0
271
278
 
272
- with open(sessions_index, 'r', encoding='utf-8') as f:
273
- index_data = json.load(f)
279
+ index_data = _load_sessions_index_file(sessions_index)
280
+ if not index_data:
281
+ return 0
274
282
  index_map = normalize_sessions_index(index_data)
275
283
  entry = index_map.get(child_session_key)
276
284
  if not entry:
@@ -283,15 +291,12 @@ def _get_session_message_count(child_session_key: str) -> int:
283
291
  count = 0
284
292
  with open(session_path, 'r', encoding='utf-8') as f:
285
293
  for line in f:
286
- try:
287
- data = json.loads(line)
288
- if data.get('type') == 'message':
289
- count += 1
290
- except json.JSONDecodeError:
291
- continue
294
+ envelope, msg = parse_session_jsonl_line(line)
295
+ if envelope and envelope.get('type') == 'message' and msg is not None:
296
+ count += 1
292
297
  return count
293
298
  except Exception as e:
294
- print(f"_get_session_message_count 失败: {e}")
299
+ record_error("io-error", str(e), "api:subagents:session_message_count", exc=e)
295
300
  return 0
296
301
 
297
302
 
@@ -360,8 +365,9 @@ def _extract_subtasks_from_session(child_session_key: str) -> List[Dict[str, Any
360
365
  if not sessions_index.exists():
361
366
  return []
362
367
 
363
- with open(sessions_index, 'r', encoding='utf-8') as f:
364
- index_data = json.load(f)
368
+ index_data = _load_sessions_index_file(sessions_index)
369
+ if not index_data:
370
+ return []
365
371
  index_map = normalize_sessions_index(index_data)
366
372
  entry = index_map.get(child_session_key)
367
373
  if not entry:
@@ -377,10 +383,9 @@ def _extract_subtasks_from_session(child_session_key: str) -> List[Dict[str, Any
377
383
  with open(session_path, 'r', encoding='utf-8') as f:
378
384
  for line in f:
379
385
  try:
380
- data = json.loads(line)
381
- if data.get('type') != 'message':
386
+ envelope, msg = parse_session_jsonl_line(line)
387
+ if not envelope or envelope.get('type') != 'message' or not msg:
382
388
  continue
383
- msg = data.get('message', {})
384
389
  if msg.get('role') != 'assistant':
385
390
  continue
386
391
  content = msg.get('content', [])
@@ -396,7 +401,6 @@ def _extract_subtasks_from_session(child_session_key: str) -> List[Dict[str, Any
396
401
  args = json.loads(args)
397
402
  except json.JSONDecodeError:
398
403
  continue
399
- # 提取子任务信息
400
404
  task_desc = args.get('task') or args.get('prompt') or args.get('instruction', '')
401
405
  sub_agent_id = args.get('agentId') or args.get('agent') or args.get('agent_id', '')
402
406
  if task_desc and task_desc not in seen_tasks:
@@ -404,14 +408,14 @@ def _extract_subtasks_from_session(child_session_key: str) -> List[Dict[str, Any
404
408
  subtasks.append({
405
409
  'task': task_desc[:200] if len(task_desc) > 200 else task_desc,
406
410
  'agentId': sub_agent_id,
407
- 'status': 'unknown' # 无法从 session 确定状态
411
+ 'status': 'unknown'
408
412
  })
409
- except (json.JSONDecodeError, KeyError):
413
+ except (KeyError, TypeError):
410
414
  continue
411
415
 
412
416
  return subtasks[:5] # 最多返回 5 个子任务
413
417
  except Exception as e:
414
- print(f"_extract_subtasks_from_session 失败: {e}")
418
+ record_error("io-error", str(e), "api:subagents:extract_subtasks", exc=e)
415
419
  return []
416
420
 
417
421
 
@@ -490,9 +494,7 @@ async def get_tasks():
490
494
  t['agentWorkspace'] = _get_agent_workspace(t['agentId'])
491
495
  return {'tasks': tasks}
492
496
  except Exception as e:
493
- print(f"Error in get_tasks: {e}")
494
- import traceback
495
- traceback.print_exc()
497
+ record_error("unknown", str(e), "api:subagents:get_tasks", exc=e)
496
498
  return {'tasks': []}
497
499
 
498
500
 
@@ -521,8 +523,9 @@ def _extract_timeline_from_session(child_session_key: str) -> List[Dict[str, Any
521
523
  if not sessions_index.exists():
522
524
  return []
523
525
 
524
- with open(sessions_index, 'r', encoding='utf-8') as f:
525
- index_data = json.load(f)
526
+ index_data = _load_sessions_index_file(sessions_index)
527
+ if not index_data:
528
+ return []
526
529
  index_map = normalize_sessions_index(index_data)
527
530
  entry = index_map.get(child_session_key)
528
531
  if not entry:
@@ -541,16 +544,15 @@ def _extract_timeline_from_session(child_session_key: str) -> List[Dict[str, Any
541
544
  if event_count >= max_events:
542
545
  break
543
546
  try:
544
- data = json.loads(line)
545
- ts = data.get('timestamp')
546
- # 确保 ts 是整数毫秒时间戳
547
+ envelope, msg = parse_session_jsonl_line(line)
548
+ if not envelope or envelope.get('type') != 'message' or not msg:
549
+ continue
550
+ ts = envelope.get('timestamp')
547
551
  if isinstance(ts, str):
548
- # ISO 格式转毫秒时间戳
549
552
  try:
550
- from datetime import datetime
551
- # 处理 ISO 格式:2026-03-07T04:07:25.262Z
553
+ from datetime import datetime as _dt
552
554
  if 'T' in ts:
553
- dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
555
+ dt = _dt.fromisoformat(ts.replace('Z', '+00:00'))
554
556
  ts = int(dt.timestamp() * 1000)
555
557
  else:
556
558
  ts = int(ts)
@@ -561,68 +563,61 @@ def _extract_timeline_from_session(child_session_key: str) -> List[Dict[str, Any
561
563
  else:
562
564
  ts = 0
563
565
 
564
- if data.get('type') == 'message':
565
- msg = data.get('message', {})
566
- role = msg.get('role', '')
567
- content = msg.get('content', [])
568
-
569
- if role == 'user':
570
- # 用户消息(任务开始)
571
- for c in content:
572
- if isinstance(c, dict) and c.get('type') == 'text':
573
- text = c.get('text', '')[:100]
574
- timeline.append({
575
- 'time': ts,
576
- 'type': 'start',
577
- 'description': f'收到任务: {text}...' if len(c.get('text', '')) > 100 else f'收到任务: {text}'
578
- })
579
- event_count += 1
580
- break
581
-
582
- elif role == 'assistant':
583
- # 助手响应中的工具调用
584
- for c in content:
585
- if not isinstance(c, dict):
586
- continue
587
- if c.get('type') == 'toolCall':
588
- tool_name = c.get('name', 'unknown')
589
- args = c.get('arguments', {})
590
- if isinstance(args, str):
591
- try:
592
- args = json.loads(args)
593
- except json.JSONDecodeError:
594
- args = {}
595
-
596
- # 生成描述
597
- desc = _describe_tool_call(tool_name, args)
598
- timeline.append({
599
- 'time': ts,
600
- 'type': 'tool',
601
- 'tool': tool_name,
602
- 'description': desc
603
- })
604
- event_count += 1
605
-
606
- elif c.get('type') == 'text':
607
- # 文本响应(可能是最终答案)
608
- text = c.get('text', '')
609
- if text.strip() and len(text) > 50:
610
- # 简单判断是否是最终答案
611
- keywords = ['完成', '成功', 'finished', 'done', 'result', '总结']
612
- if any(kw in text.lower() for kw in keywords):
613
- timeline.append({
614
- 'time': ts,
615
- 'type': 'response',
616
- 'description': f'输出结果 ({len(text)} 字符)'
617
- })
618
- event_count += 1
619
-
620
- except (json.JSONDecodeError, KeyError):
566
+ role = msg.get('role', '')
567
+ content = msg.get('content', [])
568
+
569
+ if role == 'user':
570
+ for c in content:
571
+ if isinstance(c, dict) and c.get('type') == 'text':
572
+ text = c.get('text', '')[:100]
573
+ timeline.append({
574
+ 'time': ts,
575
+ 'type': 'start',
576
+ 'description': f'收到任务: {text}...' if len(c.get('text', '')) > 100 else f'收到任务: {text}'
577
+ })
578
+ event_count += 1
579
+ break
580
+
581
+ elif role == 'assistant':
582
+ for c in content:
583
+ if not isinstance(c, dict):
584
+ continue
585
+ if c.get('type') == 'toolCall':
586
+ tool_name = c.get('name', 'unknown')
587
+ args = c.get('arguments', {})
588
+ if isinstance(args, str):
589
+ try:
590
+ args = json.loads(args)
591
+ except json.JSONDecodeError:
592
+ args = {}
593
+
594
+ desc = _describe_tool_call(tool_name, args)
595
+ timeline.append({
596
+ 'time': ts,
597
+ 'type': 'tool',
598
+ 'tool': tool_name,
599
+ 'description': desc
600
+ })
601
+ event_count += 1
602
+
603
+ elif c.get('type') == 'text':
604
+ text = c.get('text', '')
605
+ if text.strip() and len(text) > 50:
606
+ keywords = ['完成', '成功', 'finished', 'done', 'result', '总结']
607
+ if any(kw in text.lower() for kw in keywords):
608
+ timeline.append({
609
+ 'time': ts,
610
+ 'type': 'response',
611
+ 'description': f'输出结果 ({len(text)} 字符)'
612
+ })
613
+ event_count += 1
614
+
615
+ except (KeyError, TypeError, ValueError):
621
616
  continue
622
617
 
623
618
  return timeline
624
619
  except Exception as e:
625
- print(f"_extract_timeline_from_session 失败: {e}")
620
+ record_error("io-error", str(e), "api:subagents:extract_timeline", exc=e)
626
621
  return []
627
622
 
628
623
 
@@ -674,6 +669,9 @@ async def get_task_timeline(run_id: str):
674
669
  Returns:
675
670
  时间线事件列表
676
671
  """
672
+ from api.input_safety import require_safe_run_or_chain_id
673
+
674
+ require_safe_run_or_chain_id(run_id, name="run_id")
677
675
  try:
678
676
  # 从 runs.json 查找对应的 session key
679
677
  all_runs = load_subagent_runs()
@@ -750,7 +748,5 @@ async def get_task_timeline(run_id: str):
750
748
 
751
749
  return {'timeline': timeline, 'runId': run_id}
752
750
  except Exception as e:
753
- print(f"Error in get_task_timeline: {e}")
754
- import traceback
755
- traceback.print_exc()
756
- return {'timeline': [], 'error': str(e)}
751
+ record_error("unknown", str(e), "api:subagents:get_task_timeline", exc=e)
752
+ return {'timeline': [], 'error': safe_client_string(str(e))}
@@ -12,6 +12,9 @@ from pathlib import Path
12
12
  LOG = logging.getLogger(__name__)
13
13
  sys.path.append(str(Path(__file__).parent.parent))
14
14
 
15
+ from api.input_safety import require_safe_agent_id, require_safe_session_key
16
+ from core.error_handler import record_error
17
+ from core.safe_api_error import safe_api_error_detail
15
18
  from data.timeline_reader import get_timeline_steps, StepType, StepStatus
16
19
  from data.config_reader import get_agent_config
17
20
 
@@ -70,12 +73,18 @@ async def get_timeline(
70
73
  - 工具调用及结果
71
74
  - 错误信息
72
75
  """
76
+ require_safe_agent_id(agent_id)
77
+ session_key = require_safe_session_key(session_key)
73
78
  agent_info = get_agent_config(agent_id)
74
79
  if not agent_info:
75
80
  raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
76
81
 
77
82
  t0 = time.perf_counter()
78
- result = get_timeline_steps(agent_id, session_key, limit)
83
+ try:
84
+ result = get_timeline_steps(agent_id, session_key, limit)
85
+ except Exception as e:
86
+ record_error("unknown", str(e), "api:timeline:get", exc=e)
87
+ raise HTTPException(status_code=500, detail=safe_api_error_detail(e)) from e
79
88
  elapsed_ms = (time.perf_counter() - t0) * 1000
80
89
  if elapsed_ms >= 200.0:
81
90
  LOG.info(
@@ -111,10 +120,16 @@ async def get_timeline_steps_only(
111
120
 
112
121
  可按步骤类型过滤
113
122
  """
123
+ require_safe_agent_id(agent_id)
124
+ session_key = require_safe_session_key(session_key)
114
125
  if not get_agent_config(agent_id):
115
126
  raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
116
127
 
117
- result = get_timeline_steps(agent_id, session_key, limit)
128
+ try:
129
+ result = get_timeline_steps(agent_id, session_key, limit)
130
+ except Exception as e:
131
+ record_error("unknown", str(e), "api:timeline:steps", exc=e)
132
+ raise HTTPException(status_code=500, detail=safe_api_error_detail(e)) from e
118
133
  steps = result.get('steps', [])
119
134
 
120
135
  # 类型过滤
@@ -131,10 +146,16 @@ async def get_timeline_summary(agent_id: str, session_key: Optional[str] = Query
131
146
 
132
147
  快速查看会话概览,不返回详细步骤
133
148
  """
149
+ require_safe_agent_id(agent_id)
150
+ session_key = require_safe_session_key(session_key)
134
151
  if not get_agent_config(agent_id):
135
152
  raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
136
153
 
137
- result = get_timeline_steps(agent_id, session_key, limit=10) # 只需基本信息
154
+ try:
155
+ result = get_timeline_steps(agent_id, session_key, limit=10) # 只需基本信息
156
+ except Exception as e:
157
+ record_error("unknown", str(e), "api:timeline:summary", exc=e)
158
+ raise HTTPException(status_code=500, detail=safe_api_error_detail(e)) from e
138
159
 
139
160
  # 统计各类型步骤数量
140
161
  steps = result.get('steps', [])