openclaw-agent-dashboard 1.0.4

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 (111) hide show
  1. package/.github/workflows/release.yml +56 -0
  2. package/README.md +302 -0
  3. package/docs/CHANGELOG_AGENT_MODIFICATIONS.md +132 -0
  4. package/docs/RELEASE-LATEST.md +189 -0
  5. package/docs/RELEASE-MODEL-CONFIG.md +95 -0
  6. package/docs/release-guide.md +259 -0
  7. package/docs/release-operations-manual.md +167 -0
  8. package/docs/specs/tr3-install-system.md +580 -0
  9. package/docs/windows-collaboration-model-paths-troubleshooting.md +0 -0
  10. package/frontend/index.html +12 -0
  11. package/frontend/package-lock.json +1240 -0
  12. package/frontend/package.json +19 -0
  13. package/frontend/src/App.vue +331 -0
  14. package/frontend/src/components/AgentCard.vue +796 -0
  15. package/frontend/src/components/AgentConfigPanel.vue +539 -0
  16. package/frontend/src/components/AgentDetailPanel.vue +738 -0
  17. package/frontend/src/components/ErrorAnalysisView.vue +546 -0
  18. package/frontend/src/components/ErrorCenterPanel.vue +844 -0
  19. package/frontend/src/components/PerformanceMonitor.vue +515 -0
  20. package/frontend/src/components/SettingsPanel.vue +236 -0
  21. package/frontend/src/components/TokenAnalysisPanel.vue +683 -0
  22. package/frontend/src/components/chain/ChainEdge.vue +85 -0
  23. package/frontend/src/components/chain/ChainNode.vue +166 -0
  24. package/frontend/src/components/chain/TaskChainView.vue +425 -0
  25. package/frontend/src/components/chain/index.ts +3 -0
  26. package/frontend/src/components/chain/types.ts +70 -0
  27. package/frontend/src/components/collaboration/CollaborationFlowSection.vue +1032 -0
  28. package/frontend/src/components/collaboration/CollaborationFlowWrapper.vue +113 -0
  29. package/frontend/src/components/performance/PerformancePanel.vue +119 -0
  30. package/frontend/src/components/performance/PerformanceSection.vue +1137 -0
  31. package/frontend/src/components/tasks/TaskStatusSection.vue +973 -0
  32. package/frontend/src/components/timeline/TimelineConnector.vue +31 -0
  33. package/frontend/src/components/timeline/TimelineRound.vue +135 -0
  34. package/frontend/src/components/timeline/TimelineStep.vue +691 -0
  35. package/frontend/src/components/timeline/TimelineToolLink.vue +109 -0
  36. package/frontend/src/components/timeline/TimelineView.vue +540 -0
  37. package/frontend/src/components/timeline/index.ts +5 -0
  38. package/frontend/src/components/timeline/types.ts +120 -0
  39. package/frontend/src/composables/index.ts +7 -0
  40. package/frontend/src/composables/useDebounce.ts +48 -0
  41. package/frontend/src/composables/useRealtime.ts +52 -0
  42. package/frontend/src/composables/useState.ts +52 -0
  43. package/frontend/src/composables/useThrottle.ts +46 -0
  44. package/frontend/src/composables/useVirtualScroll.ts +106 -0
  45. package/frontend/src/main.ts +4 -0
  46. package/frontend/src/managers/EventDispatcher.ts +127 -0
  47. package/frontend/src/managers/RealtimeDataManager.ts +293 -0
  48. package/frontend/src/managers/StateManager.ts +128 -0
  49. package/frontend/src/managers/index.ts +5 -0
  50. package/frontend/src/types/collaboration.ts +135 -0
  51. package/frontend/src/types/index.ts +20 -0
  52. package/frontend/src/types/performance.ts +105 -0
  53. package/frontend/src/types/task.ts +38 -0
  54. package/frontend/vite.config.ts +18 -0
  55. package/package.json +22 -0
  56. package/plugin/README.md +99 -0
  57. package/plugin/config.json.example +1 -0
  58. package/plugin/index.js +250 -0
  59. package/plugin/openclaw.plugin.json +17 -0
  60. package/plugin/package.json +21 -0
  61. package/scripts/build-plugin.js +67 -0
  62. package/scripts/bundle.sh +62 -0
  63. package/scripts/install-plugin.sh +162 -0
  64. package/scripts/install-python-deps.js +346 -0
  65. package/scripts/install-python-deps.sh +226 -0
  66. package/scripts/install.js +512 -0
  67. package/scripts/install.sh +367 -0
  68. package/scripts/lib/common.js +490 -0
  69. package/scripts/lib/common.sh +137 -0
  70. package/scripts/release-pack.sh +110 -0
  71. package/scripts/start.js +50 -0
  72. package/scripts/test_available_models.py +284 -0
  73. package/scripts/test_websocket_ping.py +44 -0
  74. package/src/backend/agents.py +73 -0
  75. package/src/backend/api/__init__.py +1 -0
  76. package/src/backend/api/agent_config_api.py +90 -0
  77. package/src/backend/api/agents.py +73 -0
  78. package/src/backend/api/agents_config.py +75 -0
  79. package/src/backend/api/chains.py +126 -0
  80. package/src/backend/api/collaboration.py +902 -0
  81. package/src/backend/api/debug_paths.py +39 -0
  82. package/src/backend/api/error_analysis.py +146 -0
  83. package/src/backend/api/errors.py +281 -0
  84. package/src/backend/api/performance.py +784 -0
  85. package/src/backend/api/subagents.py +770 -0
  86. package/src/backend/api/timeline.py +144 -0
  87. package/src/backend/api/websocket.py +251 -0
  88. package/src/backend/collaboration.py +405 -0
  89. package/src/backend/data/__init__.py +1 -0
  90. package/src/backend/data/agent_config_manager.py +270 -0
  91. package/src/backend/data/chain_reader.py +299 -0
  92. package/src/backend/data/config_reader.py +153 -0
  93. package/src/backend/data/error_analyzer.py +430 -0
  94. package/src/backend/data/session_reader.py +445 -0
  95. package/src/backend/data/subagent_reader.py +244 -0
  96. package/src/backend/data/task_history.py +118 -0
  97. package/src/backend/data/timeline_reader.py +981 -0
  98. package/src/backend/errors.py +63 -0
  99. package/src/backend/main.py +89 -0
  100. package/src/backend/mechanism_reader.py +131 -0
  101. package/src/backend/mechanisms.py +32 -0
  102. package/src/backend/performance.py +474 -0
  103. package/src/backend/requirements.txt +5 -0
  104. package/src/backend/session_reader.py +238 -0
  105. package/src/backend/status/__init__.py +1 -0
  106. package/src/backend/status/error_detector.py +122 -0
  107. package/src/backend/status/status_calculator.py +301 -0
  108. package/src/backend/status_calculator.py +121 -0
  109. package/src/backend/subagent_reader.py +229 -0
  110. package/src/backend/watchers/__init__.py +4 -0
  111. package/src/backend/watchers/file_watcher.py +159 -0
@@ -0,0 +1,445 @@
1
+ """
2
+ 会话读取器 - 读取 sessions/*.jsonl 和 sessions.json
3
+ """
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import List, Dict, Any, Optional
8
+
9
+
10
+ from data.config_reader import get_openclaw_root
11
+
12
+
13
+ def get_agent_sessions_path(agent_id: str) -> Optional[Path]:
14
+ """获取 Agent 的 sessions 目录"""
15
+ sessions_path = get_openclaw_root() / "agents" / agent_id / "sessions"
16
+ if not sessions_path.exists():
17
+ return None
18
+ return sessions_path
19
+
20
+
21
+ def get_latest_session_file(agent_id: str) -> Optional[Path]:
22
+ """获取最新的 session 文件"""
23
+ sessions_path = get_agent_sessions_path(agent_id)
24
+ if not sessions_path:
25
+ return None
26
+
27
+ jsonl_files = list(sessions_path.glob("*.jsonl"))
28
+ if not jsonl_files:
29
+ return None
30
+
31
+ # 按修改时间排序,取最新的
32
+ jsonl_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
33
+ return jsonl_files[0]
34
+
35
+
36
+ def _read_tail_lines(filepath: Path, max_lines: int) -> List[str]:
37
+ """从文件尾部读取最多 max_lines 行,避免全量遍历大文件"""
38
+ try:
39
+ with open(filepath, 'rb') as f:
40
+ f.seek(0, 2)
41
+ size = f.tell()
42
+ if size == 0:
43
+ return []
44
+ # 读取末尾约 512KB,通常足够覆盖 500 行
45
+ to_read = min(512 * 1024, size)
46
+ f.seek(size - to_read)
47
+ buf = f.read(to_read)
48
+ lines = buf.split(b'\n')
49
+ # 若未从文件头开始读,首行可能是断行,丢弃
50
+ if size > to_read and lines:
51
+ lines = lines[1:]
52
+ decoded = [ln.decode('utf-8', errors='replace') for ln in lines[-max_lines:] if ln]
53
+ return decoded
54
+ except (IOError, OSError):
55
+ return []
56
+
57
+
58
+ def get_recent_messages(agent_id: str, limit: int = 10) -> List[Dict[str, Any]]:
59
+ """获取最近的会话消息(尾部读取,避免全量遍历大 jsonl)"""
60
+ session_file = get_latest_session_file(agent_id)
61
+ if not session_file:
62
+ return []
63
+ # 多读一些行以过滤 type!=message 的行
64
+ raw_lines = _read_tail_lines(session_file, max(limit * 5, 500))
65
+ messages = []
66
+ for line in raw_lines:
67
+ line = line.strip()
68
+ if not line:
69
+ continue
70
+ try:
71
+ data = json.loads(line)
72
+ if data.get('type') == 'message':
73
+ messages.append(data.get('message', {}))
74
+ if len(messages) >= limit:
75
+ break
76
+ except json.JSONDecodeError:
77
+ continue
78
+ return messages[-limit:] if len(messages) > limit else messages
79
+
80
+
81
+ def has_recent_errors(agent_id: str, minutes: int = 5) -> bool:
82
+ """检查最近是否有错误"""
83
+ messages = get_recent_messages(agent_id, limit=50)
84
+
85
+ import time
86
+ cutoff_time = int(time.time() * 1000) - (minutes * 60 * 1000)
87
+
88
+ for msg in messages:
89
+ if msg.get('stopReason') == 'error':
90
+ timestamp = msg.get('timestamp', 0)
91
+ if timestamp > cutoff_time:
92
+ return True
93
+
94
+ return False
95
+
96
+
97
+ def get_last_error(agent_id: str) -> Optional[Dict[str, Any]]:
98
+ """获取最近的错误信息"""
99
+ messages = get_recent_messages(agent_id, limit=100)
100
+
101
+ for msg in reversed(messages):
102
+ if msg.get('stopReason') == 'error':
103
+ return {
104
+ 'type': detect_error_type(msg.get('errorMessage', '')),
105
+ 'message': msg.get('errorMessage', ''),
106
+ 'timestamp': msg.get('timestamp', 0)
107
+ }
108
+
109
+ return None
110
+
111
+
112
+ def detect_error_type(error_msg: str) -> str:
113
+ """检测错误类型"""
114
+ error_msg_lower = (error_msg or '').lower()
115
+
116
+ if '429' in error_msg or 'rate limit' in error_msg_lower:
117
+ return 'rate-limit'
118
+ elif 'token' in error_msg_lower or 'context' in error_msg_lower:
119
+ return 'token-limit'
120
+ elif 'timeout' in error_msg_lower or '超时' in error_msg_lower:
121
+ return 'timeout'
122
+ elif '余额不足' in (error_msg or ''):
123
+ return 'quota'
124
+ else:
125
+ return 'unknown'
126
+
127
+
128
+ def get_session_updated_at(agent_id: str) -> int:
129
+ """
130
+ 获取 Agent 会话的最后更新时间(sessions.json 中 updatedAt 的最大值)
131
+ 用于判断「最近 5 分钟是否有 session 活动」
132
+ """
133
+ sessions_index = get_openclaw_root() / "agents" / agent_id / "sessions" / "sessions.json"
134
+ if not sessions_index.exists():
135
+ return 0
136
+
137
+ try:
138
+ with open(sessions_index, 'r', encoding='utf-8') as f:
139
+ data = json.load(f)
140
+ if not isinstance(data, dict):
141
+ return 0
142
+ max_ts = 0
143
+ for entry in data.values():
144
+ if isinstance(entry, dict):
145
+ ts = entry.get('updatedAt', 0)
146
+ if isinstance(ts, (int, float)) and ts > max_ts:
147
+ max_ts = int(ts)
148
+ return max_ts
149
+ except (json.JSONDecodeError, IOError):
150
+ return 0
151
+
152
+
153
+ def has_recent_session_activity(agent_id: str, minutes: int = 5) -> bool:
154
+ """检查 Agent 最近 N 分钟内是否有 session 活动"""
155
+ import time
156
+ updated_at = get_session_updated_at(agent_id)
157
+ if not updated_at:
158
+ return False
159
+ cutoff = int(time.time() * 1000) - (minutes * 60 * 1000)
160
+ return updated_at > cutoff
161
+
162
+
163
+ def get_session_turns(agent_id: str, session_key: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
164
+ """
165
+ 解析 jsonl 获取会话轮次,每轮包含 user/assistant/toolResult 及 usage
166
+ 返回格式: [{ turnIndex, role, content, usage?, toolCalls?, stopReason?, timestamp }]
167
+ """
168
+ sessions_index = get_openclaw_root() / "agents" / agent_id / "sessions" / "sessions.json"
169
+ if not sessions_index.exists():
170
+ return []
171
+
172
+ session_file: Optional[Path] = None
173
+ if session_key:
174
+ try:
175
+ with open(sessions_index, 'r', encoding='utf-8') as f:
176
+ index_data = json.load(f)
177
+ entry = index_data.get(session_key) if isinstance(index_data, dict) else None
178
+ if entry and isinstance(entry, dict):
179
+ sf = entry.get('sessionFile')
180
+ sid = entry.get('sessionId')
181
+ if sf:
182
+ session_file = Path(sf)
183
+ elif sid:
184
+ session_file = get_openclaw_root() / "agents" / agent_id / "sessions" / f"{sid}.jsonl"
185
+ except (json.JSONDecodeError, IOError):
186
+ pass
187
+
188
+ if not session_file or not session_file.exists():
189
+ session_file = get_latest_session_file(agent_id)
190
+
191
+ if not session_file:
192
+ return []
193
+
194
+ turns: List[Dict[str, Any]] = []
195
+ turn_index = 0
196
+
197
+ with open(session_file, 'r', encoding='utf-8') as f:
198
+ for line in f:
199
+ try:
200
+ data = json.loads(line.strip())
201
+ if data.get('type') != 'message':
202
+ continue
203
+ msg = data.get('message', {})
204
+ role = msg.get('role')
205
+ if not role:
206
+ continue
207
+
208
+ turn: Dict[str, Any] = {
209
+ 'turnIndex': turn_index,
210
+ 'role': role,
211
+ 'timestamp': msg.get('timestamp') or data.get('timestamp'),
212
+ 'content': [],
213
+ 'usage': msg.get('usage'),
214
+ 'stopReason': msg.get('stopReason'),
215
+ 'toolCalls': [],
216
+ 'toolName': None,
217
+ }
218
+
219
+ content = msg.get('content', [])
220
+ if isinstance(content, str):
221
+ content = [{'type': 'text', 'text': content}]
222
+
223
+ for c in content:
224
+ if not isinstance(c, dict):
225
+ continue
226
+ ct = c.get('type')
227
+ if ct == 'text':
228
+ if role == 'toolResult':
229
+ turn['content'].append({
230
+ 'type': 'toolResult',
231
+ 'content': c.get('text', ''),
232
+ 'status': msg.get('details', {}).get('status'),
233
+ 'error': msg.get('details', {}).get('error'),
234
+ })
235
+ else:
236
+ turn['content'].append({'type': 'text', 'text': c.get('text', '')})
237
+ elif ct == 'thinking':
238
+ turn['content'].append({'type': 'thinking', 'text': c.get('thinking', '')})
239
+ elif ct == 'toolCall':
240
+ turn['toolCalls'].append({
241
+ 'name': c.get('name'),
242
+ 'arguments': c.get('arguments'),
243
+ 'id': c.get('id'),
244
+ })
245
+ turn['toolName'] = c.get('name')
246
+
247
+ if role == 'toolResult':
248
+ turn['toolName'] = msg.get('toolName')
249
+ if not turn['content']:
250
+ details = msg.get('details', {})
251
+ turn['content'].append({
252
+ 'type': 'toolResult',
253
+ 'status': details.get('status'),
254
+ 'error': details.get('error'),
255
+ })
256
+
257
+ turns.append(turn)
258
+ turn_index += 1
259
+
260
+ except (json.JSONDecodeError, KeyError):
261
+ continue
262
+
263
+ return turns[-limit:] if len(turns) > limit else turns
264
+
265
+
266
+ def get_latest_tool_call(agent_id: str) -> Optional[Dict[str, Any]]:
267
+ """
268
+ 获取最近的工具调用(检查是否已完成)
269
+
270
+ Returns:
271
+ {'id': str, 'name': str, 'hasResult': bool} or None
272
+ """
273
+ messages = get_recent_messages(agent_id, limit=30)
274
+
275
+ # 收集所有 toolCall 和 toolResult
276
+ tool_calls = {} # id -> {name, hasResult}
277
+ tool_results = set() # toolCallIds that have results
278
+
279
+ for msg in messages:
280
+ if msg.get('role') == 'assistant':
281
+ content = msg.get('content', [])
282
+ if isinstance(content, str):
283
+ content = [{'type': 'text', 'text': content}]
284
+ for c in content:
285
+ if isinstance(c, dict) and c.get('type') == 'toolCall':
286
+ tool_id = c.get('id')
287
+ tool_name = c.get('name')
288
+ if tool_id:
289
+ tool_calls[tool_id] = {'name': tool_name, 'hasResult': False}
290
+ elif msg.get('role') == 'toolResult':
291
+ # toolResult 通过 toolCallId 关联
292
+ tool_call_id = msg.get('toolCallId') or msg.get('tool_call_id')
293
+ if tool_call_id:
294
+ tool_results.add(tool_call_id)
295
+
296
+ # 标记已有结果的 toolCall
297
+ for tool_id in tool_calls:
298
+ if tool_id in tool_results:
299
+ tool_calls[tool_id]['hasResult'] = True
300
+
301
+ # 返回最后一个未完成的 toolCall
302
+ for tool_id in reversed(list(tool_calls.keys())):
303
+ info = tool_calls[tool_id]
304
+ if not info['hasResult']:
305
+ return {
306
+ 'id': tool_id,
307
+ 'name': info['name'],
308
+ 'hasResult': False
309
+ }
310
+
311
+ # 所有 toolCall 都已完成,返回最后一个(表示刚完成)
312
+ if tool_calls:
313
+ last_id = list(tool_calls.keys())[-1]
314
+ return {
315
+ 'id': last_id,
316
+ 'name': tool_calls[last_id]['name'],
317
+ 'hasResult': True
318
+ }
319
+
320
+ return None
321
+
322
+
323
+ def has_thinking_block(agent_id: str) -> bool:
324
+ """检查最近消息是否有 thinking 块"""
325
+ messages = get_recent_messages(agent_id, limit=5)
326
+ for msg in reversed(messages):
327
+ if msg.get('role') == 'assistant':
328
+ content = msg.get('content', [])
329
+ if isinstance(content, str):
330
+ continue
331
+ for c in content:
332
+ if isinstance(c, dict) and c.get('type') == 'thinking':
333
+ return True
334
+ return False
335
+
336
+
337
+ def get_latest_assistant_message(agent_id: str) -> Optional[Dict[str, Any]]:
338
+ """获取最近的 assistant 消息"""
339
+ messages = get_recent_messages(agent_id, limit=10)
340
+ for msg in reversed(messages):
341
+ if msg.get('role') == 'assistant':
342
+ return msg
343
+ return None
344
+
345
+
346
+ def get_recent_messages_with_timestamp(agent_id: str, limit: int = 10) -> List[Dict[str, Any]]:
347
+ """
348
+ 获取最近的会话消息(包含时间戳)
349
+
350
+ Args:
351
+ agent_id: Agent ID
352
+ limit: 返回消息数量限制
353
+
354
+ Returns:
355
+ [{'message': {...}, 'timestamp': int, 'data_timestamp': str}, ...]
356
+ - timestamp: 消息中的时间戳(毫秒)
357
+ - data_timestamp: JSONL 行的 timestamp 字段(ISO 格式字符串)
358
+ """
359
+ session_file = get_latest_session_file(agent_id)
360
+ if not session_file:
361
+ return []
362
+
363
+ raw_lines = _read_tail_lines(session_file, max(limit * 5, 500))
364
+ messages = []
365
+
366
+ for line in raw_lines:
367
+ line = line.strip()
368
+ if not line:
369
+ continue
370
+ try:
371
+ data = json.loads(line)
372
+ if data.get('type') == 'message':
373
+ msg = data.get('message', {})
374
+ messages.append({
375
+ 'message': msg,
376
+ 'timestamp': msg.get('timestamp', 0),
377
+ 'data_timestamp': data.get('timestamp', ''),
378
+ })
379
+ if len(messages) >= limit:
380
+ break
381
+ except json.JSONDecodeError:
382
+ continue
383
+
384
+ return messages[-limit:] if len(messages) > limit else messages
385
+
386
+
387
+ def get_pending_tool_call_with_timestamp(agent_id: str) -> Optional[Dict[str, Any]]:
388
+ """
389
+ 获取待处理的工具调用(包含时间戳)
390
+
391
+ 复用 get_latest_tool_call 的匹配逻辑,但返回消息级别的时间戳
392
+
393
+ Args:
394
+ agent_id: Agent ID
395
+
396
+ Returns:
397
+ {'id': str, 'name': str, 'hasResult': bool, 'timestamp': int} or None
398
+ - timestamp: 工具调用时的时间戳(毫秒)
399
+ """
400
+ messages = get_recent_messages_with_timestamp(agent_id, limit=30)
401
+
402
+ tool_calls = {} # id -> {name, timestamp, hasResult}
403
+ tool_results = set()
404
+
405
+ for item in messages:
406
+ msg = item.get('message', {})
407
+ ts = item.get('timestamp', 0)
408
+
409
+ if msg.get('role') == 'assistant':
410
+ content = msg.get('content', [])
411
+ if isinstance(content, str):
412
+ continue
413
+ for c in content:
414
+ if isinstance(c, dict) and c.get('type') == 'toolCall':
415
+ tool_id = c.get('id')
416
+ if tool_id:
417
+ tool_calls[tool_id] = {
418
+ 'id': tool_id,
419
+ 'name': c.get('name', 'unknown'),
420
+ 'timestamp': ts,
421
+ 'hasResult': False
422
+ }
423
+
424
+ elif msg.get('role') == 'toolResult':
425
+ tool_call_id = msg.get('toolCallId') or msg.get('tool_call_id')
426
+ if tool_call_id:
427
+ tool_results.add(tool_call_id)
428
+
429
+ # 标记已有结果的 toolCall
430
+ for tool_id in tool_calls:
431
+ if tool_id in tool_results:
432
+ tool_calls[tool_id]['hasResult'] = True
433
+
434
+ # 返回最后一个未完成的 toolCall
435
+ for tool_id in reversed(list(tool_calls.keys())):
436
+ info = tool_calls[tool_id]
437
+ if not info['hasResult']:
438
+ return info
439
+
440
+ # 所有 toolCall 都已完成,返回最后一个
441
+ if tool_calls:
442
+ last_id = list(tool_calls.keys())[-1]
443
+ return tool_calls[last_id]
444
+
445
+ return None
@@ -0,0 +1,244 @@
1
+ """
2
+ 子代理运行读取器 - 读取 subagents/runs.json
3
+ """
4
+ import json
5
+ from pathlib import Path
6
+ from typing import List, Dict, Any, Optional
7
+
8
+ from data.config_reader import get_openclaw_root
9
+
10
+
11
+ def load_subagent_runs() -> List[Dict[str, Any]]:
12
+ """加载子代理运行记录
13
+
14
+ OpenClaw runs.json 格式: {"version": 2, "runs": { runId: record }}
15
+ """
16
+ runs_path = get_openclaw_root() / "subagents" / "runs.json"
17
+ if not runs_path.exists():
18
+ return []
19
+
20
+ with open(runs_path, 'r', encoding='utf-8') as f:
21
+ data = json.load(f)
22
+
23
+ runs = data.get('runs', {})
24
+ if isinstance(runs, dict):
25
+ return list(runs.values())
26
+ return runs if isinstance(runs, list) else []
27
+
28
+
29
+ def get_active_runs() -> List[Dict[str, Any]]:
30
+ """获取活跃的运行(未结束)"""
31
+ runs = load_subagent_runs()
32
+ return [run for run in runs if run.get('endedAt') is None]
33
+
34
+
35
+ def get_agent_runs(agent_id: str, limit: int = 10) -> List[Dict[str, Any]]:
36
+ """获取指定 Agent 的运行记录"""
37
+ runs = load_subagent_runs()
38
+ agent_runs = []
39
+
40
+ for run in runs:
41
+ child_key = run.get('childSessionKey', '')
42
+ if f'agent:{agent_id}:' in child_key:
43
+ agent_runs.append(run)
44
+
45
+ # 按开始时间倒序
46
+ agent_runs.sort(key=lambda x: x.get('startedAt', 0), reverse=True)
47
+ return agent_runs[:limit]
48
+
49
+
50
+ def is_agent_working(agent_id: str) -> bool:
51
+ """
52
+ 判断 Agent 是否在工作中
53
+ - 作为执行者:childSessionKey 包含 agent:{agent_id}:
54
+ - 作为派发者:requesterSessionKey 包含 agent:{agent_id}:(主 Agent 等待子 Agent 完成)
55
+ """
56
+ active_runs = get_active_runs()
57
+ for run in active_runs:
58
+ child_key = run.get('childSessionKey', '')
59
+ requester_key = run.get('requesterSessionKey', '')
60
+ if f'agent:{agent_id}:' in child_key:
61
+ return True
62
+ if f'agent:{agent_id}:' in requester_key:
63
+ return True
64
+ return False
65
+
66
+
67
+ def get_waiting_child_agent(agent_id: str) -> Optional[str]:
68
+ """
69
+ 获取正在等待的子代理名称
70
+
71
+ 当 Agent 作为 requester 派发任务给子 Agent 时,
72
+ 该 Agent 正在等待子 Agent 完成任务。
73
+
74
+ Args:
75
+ agent_id: Agent ID
76
+
77
+ Returns:
78
+ 子代理 ID,如果没有则返回 None
79
+ """
80
+ active_runs = get_active_runs()
81
+ for run in active_runs:
82
+ requester_key = run.get('requesterSessionKey', '')
83
+ # 检查这个 agent 是否是 requester(即它在等待子 agent)
84
+ if f'agent:{agent_id}:' in requester_key:
85
+ child_key = run.get('childSessionKey', '')
86
+ if child_key and ':' in child_key:
87
+ parts = child_key.split(':')
88
+ if len(parts) >= 2 and parts[0] == 'agent':
89
+ return parts[1]
90
+ return None
91
+
92
+
93
+ def get_agent_output_for_run(child_session_key: str, max_chars: int = 10000) -> Optional[str]:
94
+ """
95
+ 从子 Agent 的 session 文件中提取最后一次 assistant 消息的文本输出。
96
+ 用于任务成功时展示 Agent 的执行结果。
97
+
98
+ Args:
99
+ child_session_key: 格式 agent:<agentId>:subagent:<uuid>
100
+ max_chars: 最大返回字符数,超出则截断
101
+
102
+ Returns:
103
+ Agent 的文本输出,若无法获取则返回 None
104
+ """
105
+ if not child_session_key or ':' not in child_session_key:
106
+ return None
107
+ parts = child_session_key.split(':')
108
+ if len(parts) < 2 or parts[0] != 'agent':
109
+ return None
110
+ agent_id = parts[1]
111
+
112
+ openclaw_path = get_openclaw_root()
113
+ sessions_index = openclaw_path / "agents" / agent_id / "sessions" / "sessions.json"
114
+ if not sessions_index.exists():
115
+ return None
116
+
117
+ try:
118
+ with open(sessions_index, 'r', encoding='utf-8') as f:
119
+ index_data = json.load(f)
120
+ entry = index_data.get(child_session_key)
121
+ if not entry:
122
+ return None
123
+ session_file = entry.get('sessionFile')
124
+ session_id = entry.get('sessionId')
125
+ if not session_file and not session_id:
126
+ return None
127
+ if not session_file:
128
+ sessions_dir = openclaw_path / "agents" / agent_id / "sessions"
129
+ session_file = str(sessions_dir / f"{session_id}.jsonl")
130
+
131
+ session_path = Path(session_file)
132
+ if not session_path.exists():
133
+ return None
134
+
135
+ last_text = None
136
+ with open(session_path, 'r', encoding='utf-8') as f:
137
+ for line in f:
138
+ try:
139
+ data = json.loads(line)
140
+ if data.get('type') != 'message':
141
+ continue
142
+ msg = data.get('message', {})
143
+ if msg.get('role') != 'assistant':
144
+ continue
145
+ content = msg.get('content', [])
146
+ for c in content:
147
+ if isinstance(c, dict) and c.get('type') == 'text':
148
+ text = c.get('text', '')
149
+ if text and text.strip():
150
+ last_text = text
151
+ break
152
+ except (json.JSONDecodeError, KeyError):
153
+ continue
154
+
155
+ if not last_text or not last_text.strip():
156
+ return None
157
+ if len(last_text) > max_chars:
158
+ return last_text[:max_chars] + '\n\n...(输出已截断)'
159
+ return last_text
160
+ except Exception as e:
161
+ print(f"get_agent_output_for_run 失败: {e}")
162
+ return None
163
+
164
+
165
+ def get_agent_files_for_run(child_session_key: str) -> List[str]:
166
+ """
167
+ 从子 Agent 的 session 中提取本次任务生成/修改的文件路径。
168
+ 解析 write、edit 等工具调用中的 path 参数。
169
+
170
+ Returns:
171
+ 去重后的文件路径列表
172
+ """
173
+ if not child_session_key or ':' not in child_session_key:
174
+ return []
175
+ parts = child_session_key.split(':')
176
+ if len(parts) < 2 or parts[0] != 'agent':
177
+ return []
178
+ agent_id = parts[1]
179
+
180
+ openclaw_path = get_openclaw_root()
181
+ sessions_index = openclaw_path / "agents" / agent_id / "sessions" / "sessions.json"
182
+ if not sessions_index.exists():
183
+ return []
184
+
185
+ try:
186
+ with open(sessions_index, 'r', encoding='utf-8') as f:
187
+ index_data = json.load(f)
188
+ entry = index_data.get(child_session_key)
189
+ if not entry:
190
+ return []
191
+ session_file = entry.get('sessionFile')
192
+ session_id = entry.get('sessionId')
193
+ if not session_file and not session_id:
194
+ return []
195
+ if not session_file:
196
+ sessions_dir = openclaw_path / "agents" / agent_id / "sessions"
197
+ session_file = str(sessions_dir / f"{session_id}.jsonl")
198
+
199
+ session_path = Path(session_file)
200
+ if not session_path.exists():
201
+ return []
202
+
203
+ file_paths: List[str] = []
204
+ file_tools = ('write', 'edit')
205
+
206
+ with open(session_path, 'r', encoding='utf-8') as f:
207
+ for line in f:
208
+ try:
209
+ data = json.loads(line)
210
+ if data.get('type') != 'message':
211
+ continue
212
+ msg = data.get('message', {})
213
+ if msg.get('role') != 'assistant':
214
+ continue
215
+ content = msg.get('content', [])
216
+ for c in content:
217
+ if not isinstance(c, dict) or c.get('type') != 'toolCall':
218
+ continue
219
+ name = c.get('name', '')
220
+ if name not in file_tools:
221
+ continue
222
+ args = c.get('arguments', {})
223
+ if isinstance(args, str):
224
+ try:
225
+ args = json.loads(args)
226
+ except json.JSONDecodeError:
227
+ continue
228
+ path = args.get('path') or args.get('file_path')
229
+ if path and isinstance(path, str) and path.strip():
230
+ file_paths.append(path.strip())
231
+ except (json.JSONDecodeError, KeyError):
232
+ continue
233
+
234
+ # 去重并保持顺序
235
+ seen = set()
236
+ result = []
237
+ for p in file_paths:
238
+ if p not in seen:
239
+ seen.add(p)
240
+ result.append(p)
241
+ return result
242
+ except Exception as e:
243
+ print(f"get_agent_files_for_run 失败: {e}")
244
+ return []