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.
- package/.github/workflows/release.yml +56 -0
- package/README.md +302 -0
- package/docs/CHANGELOG_AGENT_MODIFICATIONS.md +132 -0
- package/docs/RELEASE-LATEST.md +189 -0
- package/docs/RELEASE-MODEL-CONFIG.md +95 -0
- package/docs/release-guide.md +259 -0
- package/docs/release-operations-manual.md +167 -0
- package/docs/specs/tr3-install-system.md +580 -0
- package/docs/windows-collaboration-model-paths-troubleshooting.md +0 -0
- package/frontend/index.html +12 -0
- package/frontend/package-lock.json +1240 -0
- package/frontend/package.json +19 -0
- package/frontend/src/App.vue +331 -0
- package/frontend/src/components/AgentCard.vue +796 -0
- package/frontend/src/components/AgentConfigPanel.vue +539 -0
- package/frontend/src/components/AgentDetailPanel.vue +738 -0
- package/frontend/src/components/ErrorAnalysisView.vue +546 -0
- package/frontend/src/components/ErrorCenterPanel.vue +844 -0
- package/frontend/src/components/PerformanceMonitor.vue +515 -0
- package/frontend/src/components/SettingsPanel.vue +236 -0
- package/frontend/src/components/TokenAnalysisPanel.vue +683 -0
- package/frontend/src/components/chain/ChainEdge.vue +85 -0
- package/frontend/src/components/chain/ChainNode.vue +166 -0
- package/frontend/src/components/chain/TaskChainView.vue +425 -0
- package/frontend/src/components/chain/index.ts +3 -0
- package/frontend/src/components/chain/types.ts +70 -0
- package/frontend/src/components/collaboration/CollaborationFlowSection.vue +1032 -0
- package/frontend/src/components/collaboration/CollaborationFlowWrapper.vue +113 -0
- package/frontend/src/components/performance/PerformancePanel.vue +119 -0
- package/frontend/src/components/performance/PerformanceSection.vue +1137 -0
- package/frontend/src/components/tasks/TaskStatusSection.vue +973 -0
- package/frontend/src/components/timeline/TimelineConnector.vue +31 -0
- package/frontend/src/components/timeline/TimelineRound.vue +135 -0
- package/frontend/src/components/timeline/TimelineStep.vue +691 -0
- package/frontend/src/components/timeline/TimelineToolLink.vue +109 -0
- package/frontend/src/components/timeline/TimelineView.vue +540 -0
- package/frontend/src/components/timeline/index.ts +5 -0
- package/frontend/src/components/timeline/types.ts +120 -0
- package/frontend/src/composables/index.ts +7 -0
- package/frontend/src/composables/useDebounce.ts +48 -0
- package/frontend/src/composables/useRealtime.ts +52 -0
- package/frontend/src/composables/useState.ts +52 -0
- package/frontend/src/composables/useThrottle.ts +46 -0
- package/frontend/src/composables/useVirtualScroll.ts +106 -0
- package/frontend/src/main.ts +4 -0
- package/frontend/src/managers/EventDispatcher.ts +127 -0
- package/frontend/src/managers/RealtimeDataManager.ts +293 -0
- package/frontend/src/managers/StateManager.ts +128 -0
- package/frontend/src/managers/index.ts +5 -0
- package/frontend/src/types/collaboration.ts +135 -0
- package/frontend/src/types/index.ts +20 -0
- package/frontend/src/types/performance.ts +105 -0
- package/frontend/src/types/task.ts +38 -0
- package/frontend/vite.config.ts +18 -0
- package/package.json +22 -0
- package/plugin/README.md +99 -0
- package/plugin/config.json.example +1 -0
- package/plugin/index.js +250 -0
- package/plugin/openclaw.plugin.json +17 -0
- package/plugin/package.json +21 -0
- package/scripts/build-plugin.js +67 -0
- package/scripts/bundle.sh +62 -0
- package/scripts/install-plugin.sh +162 -0
- package/scripts/install-python-deps.js +346 -0
- package/scripts/install-python-deps.sh +226 -0
- package/scripts/install.js +512 -0
- package/scripts/install.sh +367 -0
- package/scripts/lib/common.js +490 -0
- package/scripts/lib/common.sh +137 -0
- package/scripts/release-pack.sh +110 -0
- package/scripts/start.js +50 -0
- package/scripts/test_available_models.py +284 -0
- package/scripts/test_websocket_ping.py +44 -0
- package/src/backend/agents.py +73 -0
- package/src/backend/api/__init__.py +1 -0
- package/src/backend/api/agent_config_api.py +90 -0
- package/src/backend/api/agents.py +73 -0
- package/src/backend/api/agents_config.py +75 -0
- package/src/backend/api/chains.py +126 -0
- package/src/backend/api/collaboration.py +902 -0
- package/src/backend/api/debug_paths.py +39 -0
- package/src/backend/api/error_analysis.py +146 -0
- package/src/backend/api/errors.py +281 -0
- package/src/backend/api/performance.py +784 -0
- package/src/backend/api/subagents.py +770 -0
- package/src/backend/api/timeline.py +144 -0
- package/src/backend/api/websocket.py +251 -0
- package/src/backend/collaboration.py +405 -0
- package/src/backend/data/__init__.py +1 -0
- package/src/backend/data/agent_config_manager.py +270 -0
- package/src/backend/data/chain_reader.py +299 -0
- package/src/backend/data/config_reader.py +153 -0
- package/src/backend/data/error_analyzer.py +430 -0
- package/src/backend/data/session_reader.py +445 -0
- package/src/backend/data/subagent_reader.py +244 -0
- package/src/backend/data/task_history.py +118 -0
- package/src/backend/data/timeline_reader.py +981 -0
- package/src/backend/errors.py +63 -0
- package/src/backend/main.py +89 -0
- package/src/backend/mechanism_reader.py +131 -0
- package/src/backend/mechanisms.py +32 -0
- package/src/backend/performance.py +474 -0
- package/src/backend/requirements.txt +5 -0
- package/src/backend/session_reader.py +238 -0
- package/src/backend/status/__init__.py +1 -0
- package/src/backend/status/error_detector.py +122 -0
- package/src/backend/status/status_calculator.py +301 -0
- package/src/backend/status_calculator.py +121 -0
- package/src/backend/subagent_reader.py +229 -0
- package/src/backend/watchers/__init__.py +4 -0
- 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 []
|