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,770 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Subagent API 路由
|
|
3
|
+
"""
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from typing import List, Optional, Dict, Any
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
sys.path.append(str(Path(__file__).parent.parent))
|
|
11
|
+
|
|
12
|
+
from data.subagent_reader import (
|
|
13
|
+
load_subagent_runs,
|
|
14
|
+
get_active_runs,
|
|
15
|
+
get_agent_runs,
|
|
16
|
+
get_agent_output_for_run,
|
|
17
|
+
get_agent_files_for_run
|
|
18
|
+
)
|
|
19
|
+
from data.task_history import merge_with_history
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
router = APIRouter()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SubagentRun(BaseModel):
|
|
26
|
+
runId: str
|
|
27
|
+
agentId: str
|
|
28
|
+
task: str
|
|
29
|
+
startedAt: int
|
|
30
|
+
startedAtFormatted: str
|
|
31
|
+
endedAt: Optional[int] = None
|
|
32
|
+
endedAtFormatted: Optional[str] = None
|
|
33
|
+
outcome: Optional[str] = None
|
|
34
|
+
runtime: Optional[str] = None
|
|
35
|
+
totalTokens: Optional[int] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_timestamp(timestamp: int) -> str:
|
|
39
|
+
"""格式化时间戳"""
|
|
40
|
+
if not timestamp:
|
|
41
|
+
return ''
|
|
42
|
+
dt = time.localtime(timestamp / 1000)
|
|
43
|
+
return time.strftime('%Y-%m-%d %H:%M:%S', dt)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def calculate_runtime(started_at: int, ended_at: Optional[int]) -> str:
|
|
47
|
+
"""计算运行时长"""
|
|
48
|
+
end = ended_at if ended_at else int(time.time() * 1000)
|
|
49
|
+
diff_seconds = (end - started_at) / 1000
|
|
50
|
+
|
|
51
|
+
if diff_seconds < 60:
|
|
52
|
+
return f"{int(diff_seconds)}秒"
|
|
53
|
+
elif diff_seconds < 3600:
|
|
54
|
+
return f"{int(diff_seconds / 60)}分钟"
|
|
55
|
+
else:
|
|
56
|
+
return f"{int(diff_seconds / 3600)}小时"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_agent_id(child_key: str) -> str:
|
|
60
|
+
"""从 childSessionKey 解析 agentId"""
|
|
61
|
+
# 格式: agent:devops-agent:subagent:uuid
|
|
62
|
+
parts = child_key.split(':')
|
|
63
|
+
if len(parts) >= 2 and parts[0] == 'agent':
|
|
64
|
+
return parts[1]
|
|
65
|
+
return ''
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def extract_outcome(outcome: Any) -> Optional[str]:
|
|
69
|
+
"""提取 outcome 字符串"""
|
|
70
|
+
if isinstance(outcome, str):
|
|
71
|
+
return outcome
|
|
72
|
+
if isinstance(outcome, dict):
|
|
73
|
+
return outcome.get('status')
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/subagents")
|
|
78
|
+
async def get_subagents():
|
|
79
|
+
"""获取当前子代理运行(活跃 + 最近完成)"""
|
|
80
|
+
try:
|
|
81
|
+
all_runs = load_subagent_runs()
|
|
82
|
+
|
|
83
|
+
# 按开始时间倒序,取前20个
|
|
84
|
+
all_runs.sort(key=lambda x: x.get('startedAt', 0), reverse=True)
|
|
85
|
+
recent_runs = all_runs[:20]
|
|
86
|
+
|
|
87
|
+
result = []
|
|
88
|
+
for run in recent_runs:
|
|
89
|
+
agent_id = parse_agent_id(run.get('childSessionKey', ''))
|
|
90
|
+
outcome = run.get('outcome')
|
|
91
|
+
|
|
92
|
+
result.append({
|
|
93
|
+
'runId': run.get('runId', ''),
|
|
94
|
+
'agentId': agent_id,
|
|
95
|
+
'task': run.get('task', ''),
|
|
96
|
+
'startedAt': run.get('startedAt', 0),
|
|
97
|
+
'startedAtFormatted': format_timestamp(run.get('startedAt', 0)),
|
|
98
|
+
'endedAt': run.get('endedAt'),
|
|
99
|
+
'endedAtFormatted': format_timestamp(run.get('endedAt')) if run.get('endedAt') else None,
|
|
100
|
+
'outcome': extract_outcome(outcome),
|
|
101
|
+
'runtime': calculate_runtime(
|
|
102
|
+
run.get('startedAt', 0),
|
|
103
|
+
run.get('endedAt')
|
|
104
|
+
),
|
|
105
|
+
'totalTokens': run.get('totalTokens')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return result
|
|
109
|
+
except Exception as e:
|
|
110
|
+
print(f"Error in get_subagents: {e}")
|
|
111
|
+
import traceback
|
|
112
|
+
traceback.print_exc()
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.get("/subagents/active")
|
|
117
|
+
async def get_active_subagents():
|
|
118
|
+
"""获取活跃的子代理运行"""
|
|
119
|
+
try:
|
|
120
|
+
active_runs = get_active_runs()
|
|
121
|
+
|
|
122
|
+
result = []
|
|
123
|
+
for run in active_runs:
|
|
124
|
+
agent_id = parse_agent_id(run.get('childSessionKey', ''))
|
|
125
|
+
|
|
126
|
+
result.append({
|
|
127
|
+
'runId': run.get('runId', ''),
|
|
128
|
+
'agentId': agent_id,
|
|
129
|
+
'task': run.get('task', ''),
|
|
130
|
+
'startedAt': run.get('startedAt', 0),
|
|
131
|
+
'startedAtFormatted': format_timestamp(run.get('startedAt', 0)),
|
|
132
|
+
'endedAt': None,
|
|
133
|
+
'endedAtFormatted': None,
|
|
134
|
+
'outcome': None,
|
|
135
|
+
'runtime': calculate_runtime(run.get('startedAt', 0), None),
|
|
136
|
+
'totalTokens': run.get('totalTokens')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
return result
|
|
140
|
+
except Exception as e:
|
|
141
|
+
print(f"Error in get_active_subagents: {e}")
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _get_agent_name(agent_id: str) -> str:
|
|
146
|
+
"""从配置获取 Agent 显示名称"""
|
|
147
|
+
try:
|
|
148
|
+
from data.config_reader import get_agent_config
|
|
149
|
+
config = get_agent_config(agent_id)
|
|
150
|
+
return config.get('name', agent_id) if config else agent_id
|
|
151
|
+
except Exception:
|
|
152
|
+
return agent_id
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _get_agent_workspace(agent_id: str) -> Optional[str]:
|
|
156
|
+
"""从配置获取 Agent 工作区路径"""
|
|
157
|
+
if not agent_id:
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
from data.config_reader import get_agent_config
|
|
161
|
+
config = get_agent_config(agent_id)
|
|
162
|
+
return config.get('workspace') if config else None
|
|
163
|
+
except Exception:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _map_run_status(run: Dict[str, Any]) -> str:
|
|
168
|
+
"""映射 run 状态为任务状态: pending/running/completed/failed"""
|
|
169
|
+
ended_at = run.get('endedAt')
|
|
170
|
+
if ended_at is None:
|
|
171
|
+
return 'running' # 执行中
|
|
172
|
+
|
|
173
|
+
outcome = run.get('outcome')
|
|
174
|
+
if isinstance(outcome, dict):
|
|
175
|
+
status = outcome.get('status', '')
|
|
176
|
+
if status == 'ok':
|
|
177
|
+
return 'completed'
|
|
178
|
+
if status in ('error', 'failed'):
|
|
179
|
+
return 'failed'
|
|
180
|
+
elif isinstance(outcome, str) and outcome.lower() in ('error', 'failed'):
|
|
181
|
+
return 'failed'
|
|
182
|
+
|
|
183
|
+
return 'completed'
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _extract_task_summary(task_raw: str) -> str:
|
|
187
|
+
"""从完整任务文本提取首行摘要(不截断)"""
|
|
188
|
+
if not task_raw or not task_raw.strip():
|
|
189
|
+
return 'Unknown Task'
|
|
190
|
+
lines = [ln.strip() for ln in task_raw.split('\n') if ln.strip()]
|
|
191
|
+
first = lines[0] if lines else task_raw.strip()
|
|
192
|
+
# 去除 markdown 粗体
|
|
193
|
+
if first.startswith('**') and '**' in first[2:]:
|
|
194
|
+
first = first.split('**', 2)[-1].strip()
|
|
195
|
+
return first
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _extract_task_path(task_raw: str) -> str | None:
|
|
199
|
+
"""从任务文本提取项目路径"""
|
|
200
|
+
if not task_raw:
|
|
201
|
+
return None
|
|
202
|
+
import re
|
|
203
|
+
# 匹配 **项目路径:** `path` 或 项目路径:path
|
|
204
|
+
m = re.search(r'\*\*项目路径[::]\*\*\s*`([^`]+)`', task_raw)
|
|
205
|
+
if m:
|
|
206
|
+
return m.group(1).strip()
|
|
207
|
+
m = re.search(r'项目路径[::]\s*`?([^`\n]+)`?', task_raw)
|
|
208
|
+
if m:
|
|
209
|
+
return m.group(1).strip()
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _sanitize_task_display(text: str) -> str:
|
|
214
|
+
"""去除任务展示中的 Markdown 符号:** 粗体、路径的 `` 反引号"""
|
|
215
|
+
if not text or not isinstance(text, str):
|
|
216
|
+
return text or ''
|
|
217
|
+
import re
|
|
218
|
+
# 去除 ** 粗体标记
|
|
219
|
+
s = re.sub(r'\*\*', '', text)
|
|
220
|
+
# 去除路径等外层的反引号:`path` -> path
|
|
221
|
+
s = re.sub(r'`([^`]+)`', r'\1', s)
|
|
222
|
+
return s
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _format_error_message(raw: str) -> str:
|
|
226
|
+
"""将原始错误信息转为更明确的用户可读描述"""
|
|
227
|
+
if not raw or not isinstance(raw, str):
|
|
228
|
+
return '未知'
|
|
229
|
+
raw = raw.strip().lower()
|
|
230
|
+
mapping = {
|
|
231
|
+
'terminated': '任务被终止(可能是超时或被用户取消)',
|
|
232
|
+
'timeout': '任务执行超时',
|
|
233
|
+
'cancelled': '任务已取消',
|
|
234
|
+
'canceled': '任务已取消',
|
|
235
|
+
'killed': '任务被终止',
|
|
236
|
+
'subagent-error': '子任务执行异常',
|
|
237
|
+
'error': '执行出错',
|
|
238
|
+
}
|
|
239
|
+
for key, desc in mapping.items():
|
|
240
|
+
if key in raw:
|
|
241
|
+
return desc
|
|
242
|
+
return raw.strip() or '未知'
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _get_session_message_count(child_session_key: str) -> int:
|
|
246
|
+
"""
|
|
247
|
+
从子 Agent 的 session 文件中统计消息数量。
|
|
248
|
+
用于估算任务进度。
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
child_session_key: 格式 agent:<agentId>:subagent:<uuid>
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
消息数量,若无法获取则返回 0
|
|
255
|
+
"""
|
|
256
|
+
if not child_session_key or ':' not in child_session_key:
|
|
257
|
+
return 0
|
|
258
|
+
parts = child_session_key.split(':')
|
|
259
|
+
if len(parts) < 2 or parts[0] != 'agent':
|
|
260
|
+
return 0
|
|
261
|
+
agent_id = parts[1]
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
from data.config_reader import get_openclaw_root
|
|
265
|
+
openclaw_path = get_openclaw_root()
|
|
266
|
+
sessions_index = openclaw_path / "agents" / agent_id / "sessions" / "sessions.json"
|
|
267
|
+
if not sessions_index.exists():
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
with open(sessions_index, 'r', encoding='utf-8') as f:
|
|
271
|
+
index_data = json.load(f)
|
|
272
|
+
entry = index_data.get(child_session_key)
|
|
273
|
+
if not entry:
|
|
274
|
+
return 0
|
|
275
|
+
session_file = entry.get('sessionFile')
|
|
276
|
+
session_id = entry.get('sessionId')
|
|
277
|
+
if not session_file and not session_id:
|
|
278
|
+
return 0
|
|
279
|
+
if not session_file:
|
|
280
|
+
sessions_dir = openclaw_path / "agents" / agent_id / "sessions"
|
|
281
|
+
session_file = str(sessions_dir / f"{session_id}.jsonl")
|
|
282
|
+
|
|
283
|
+
session_path = Path(session_file)
|
|
284
|
+
if not session_path.exists():
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
count = 0
|
|
288
|
+
with open(session_path, 'r', encoding='utf-8') as f:
|
|
289
|
+
for line in f:
|
|
290
|
+
try:
|
|
291
|
+
data = json.loads(line)
|
|
292
|
+
if data.get('type') == 'message':
|
|
293
|
+
count += 1
|
|
294
|
+
except json.JSONDecodeError:
|
|
295
|
+
continue
|
|
296
|
+
return count
|
|
297
|
+
except Exception as e:
|
|
298
|
+
print(f"_get_session_message_count 失败: {e}")
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _calculate_progress(run: Dict[str, Any]) -> int:
|
|
303
|
+
"""
|
|
304
|
+
计算任务的真实进度(0-100)。
|
|
305
|
+
|
|
306
|
+
进度估算逻辑:
|
|
307
|
+
- 已完成任务: 100%
|
|
308
|
+
- 运行中任务: 基于会话消息数量估算
|
|
309
|
+
- 0-5 条消息: 20%
|
|
310
|
+
- 6-15 条消息: 40%
|
|
311
|
+
- 16-30 条消息: 60%
|
|
312
|
+
- 31+ 条消息: 80%
|
|
313
|
+
- 最大 80%(运行中的任务不超过 80%)
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
run: 运行记录
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
进度百分比 (0-100)
|
|
320
|
+
"""
|
|
321
|
+
# 已完成任务直接返回 100
|
|
322
|
+
if run.get('endedAt'):
|
|
323
|
+
return 100
|
|
324
|
+
|
|
325
|
+
# 运行中任务,基于消息数量估算
|
|
326
|
+
child_key = run.get('childSessionKey', '')
|
|
327
|
+
if not child_key:
|
|
328
|
+
return 10 # 无法获取 session 时,给一个基础进度
|
|
329
|
+
|
|
330
|
+
msg_count = _get_session_message_count(child_key)
|
|
331
|
+
|
|
332
|
+
if msg_count <= 5:
|
|
333
|
+
return 20
|
|
334
|
+
elif msg_count <= 15:
|
|
335
|
+
return 40
|
|
336
|
+
elif msg_count <= 30:
|
|
337
|
+
return 60
|
|
338
|
+
else:
|
|
339
|
+
return 80
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _extract_subtasks_from_session(child_session_key: str) -> List[Dict[str, Any]]:
|
|
343
|
+
"""
|
|
344
|
+
从子 Agent 的 session 文件中提取嵌套的子任务(subagent 调用)。
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
child_session_key: 格式 agent:<agentId>:subagent:<uuid>
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
子任务列表,每个包含: {task, agentId, status}
|
|
351
|
+
"""
|
|
352
|
+
if not child_session_key or ':' not in child_session_key:
|
|
353
|
+
return []
|
|
354
|
+
parts = child_session_key.split(':')
|
|
355
|
+
if len(parts) < 2 or parts[0] != 'agent':
|
|
356
|
+
return []
|
|
357
|
+
agent_id = parts[1]
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
from data.config_reader import get_openclaw_root
|
|
361
|
+
openclaw_path = get_openclaw_root()
|
|
362
|
+
sessions_index = openclaw_path / "agents" / agent_id / "sessions" / "sessions.json"
|
|
363
|
+
if not sessions_index.exists():
|
|
364
|
+
return []
|
|
365
|
+
|
|
366
|
+
with open(sessions_index, 'r', encoding='utf-8') as f:
|
|
367
|
+
index_data = json.load(f)
|
|
368
|
+
entry = index_data.get(child_session_key)
|
|
369
|
+
if not entry:
|
|
370
|
+
return []
|
|
371
|
+
session_file = entry.get('sessionFile')
|
|
372
|
+
session_id = entry.get('sessionId')
|
|
373
|
+
if not session_file and not session_id:
|
|
374
|
+
return []
|
|
375
|
+
if not session_file:
|
|
376
|
+
sessions_dir = openclaw_path / "agents" / agent_id / "sessions"
|
|
377
|
+
session_file = str(sessions_dir / f"{session_id}.jsonl")
|
|
378
|
+
|
|
379
|
+
session_path = Path(session_file)
|
|
380
|
+
if not session_path.exists():
|
|
381
|
+
return []
|
|
382
|
+
|
|
383
|
+
subtasks: List[Dict[str, Any]] = []
|
|
384
|
+
seen_tasks = set()
|
|
385
|
+
|
|
386
|
+
with open(session_path, 'r', encoding='utf-8') as f:
|
|
387
|
+
for line in f:
|
|
388
|
+
try:
|
|
389
|
+
data = json.loads(line)
|
|
390
|
+
if data.get('type') != 'message':
|
|
391
|
+
continue
|
|
392
|
+
msg = data.get('message', {})
|
|
393
|
+
if msg.get('role') != 'assistant':
|
|
394
|
+
continue
|
|
395
|
+
content = msg.get('content', [])
|
|
396
|
+
for c in content:
|
|
397
|
+
if not isinstance(c, dict) or c.get('type') != 'toolCall':
|
|
398
|
+
continue
|
|
399
|
+
name = c.get('name', '')
|
|
400
|
+
if name not in ('subagent', 'delegate', 'spawn'):
|
|
401
|
+
continue
|
|
402
|
+
args = c.get('arguments', {})
|
|
403
|
+
if isinstance(args, str):
|
|
404
|
+
try:
|
|
405
|
+
args = json.loads(args)
|
|
406
|
+
except json.JSONDecodeError:
|
|
407
|
+
continue
|
|
408
|
+
# 提取子任务信息
|
|
409
|
+
task_desc = args.get('task') or args.get('prompt') or args.get('instruction', '')
|
|
410
|
+
sub_agent_id = args.get('agentId') or args.get('agent') or args.get('agent_id', '')
|
|
411
|
+
if task_desc and task_desc not in seen_tasks:
|
|
412
|
+
seen_tasks.add(task_desc)
|
|
413
|
+
subtasks.append({
|
|
414
|
+
'task': task_desc[:200] if len(task_desc) > 200 else task_desc,
|
|
415
|
+
'agentId': sub_agent_id,
|
|
416
|
+
'status': 'unknown' # 无法从 session 确定状态
|
|
417
|
+
})
|
|
418
|
+
except (json.JSONDecodeError, KeyError):
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
return subtasks[:5] # 最多返回 5 个子任务
|
|
422
|
+
except Exception as e:
|
|
423
|
+
print(f"_extract_subtasks_from_session 失败: {e}")
|
|
424
|
+
return []
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _run_to_task(run: Dict[str, Any]) -> Dict[str, Any]:
|
|
428
|
+
"""将 run 转为任务展示格式"""
|
|
429
|
+
agent_id = parse_agent_id(run.get('childSessionKey', ''))
|
|
430
|
+
outcome = run.get('outcome')
|
|
431
|
+
status = _map_run_status(run)
|
|
432
|
+
task_raw = run.get('task', 'Unknown Task')
|
|
433
|
+
task_name = _extract_task_summary(task_raw)
|
|
434
|
+
task_path = _extract_task_path(task_raw)
|
|
435
|
+
progress = _calculate_progress(run)
|
|
436
|
+
error_msg = None
|
|
437
|
+
if status == 'failed':
|
|
438
|
+
if isinstance(outcome, dict):
|
|
439
|
+
raw_err = outcome.get('error', outcome.get('message', outcome.get('reason', '任务失败')))
|
|
440
|
+
error_msg = _format_error_message(str(raw_err)) if raw_err else '任务失败'
|
|
441
|
+
elif isinstance(outcome, str):
|
|
442
|
+
error_msg = _format_error_message(outcome) if outcome.strip() else '任务失败'
|
|
443
|
+
task_display = task_raw if isinstance(task_raw, str) else str(task_raw)
|
|
444
|
+
task_display = _sanitize_task_display(task_display)
|
|
445
|
+
task_name = _sanitize_task_display(task_name)
|
|
446
|
+
result: Dict[str, Any] = {
|
|
447
|
+
'id': run.get('runId', ''),
|
|
448
|
+
'name': task_name,
|
|
449
|
+
'task': task_display,
|
|
450
|
+
'status': status,
|
|
451
|
+
'progress': progress,
|
|
452
|
+
'startTime': run.get('startedAt'),
|
|
453
|
+
'endTime': run.get('endedAt'),
|
|
454
|
+
'agentId': agent_id,
|
|
455
|
+
'agentName': _get_agent_name(agent_id),
|
|
456
|
+
'agentWorkspace': _get_agent_workspace(agent_id),
|
|
457
|
+
'error': error_msg,
|
|
458
|
+
'childSessionKey': run.get('childSessionKey')
|
|
459
|
+
}
|
|
460
|
+
if task_path:
|
|
461
|
+
result['taskPath'] = task_path
|
|
462
|
+
# 从 session 提取子任务(嵌套 subagent 调用)
|
|
463
|
+
child_key = run.get('childSessionKey', '')
|
|
464
|
+
if child_key:
|
|
465
|
+
subtasks = _extract_subtasks_from_session(child_key)
|
|
466
|
+
if subtasks:
|
|
467
|
+
result['subtasks'] = subtasks
|
|
468
|
+
# 任务成功时,从 session 提取 Agent 输出和生成的文件
|
|
469
|
+
if status == 'completed' and child_key:
|
|
470
|
+
output = get_agent_output_for_run(child_key)
|
|
471
|
+
if output:
|
|
472
|
+
result['output'] = output
|
|
473
|
+
files = get_agent_files_for_run(child_key)
|
|
474
|
+
if files:
|
|
475
|
+
result['generatedFiles'] = files
|
|
476
|
+
return result
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@router.get("/tasks")
|
|
480
|
+
async def get_tasks():
|
|
481
|
+
"""获取任务列表 - 合并 runs.json 与持久化历史,确保已完成任务不丢失"""
|
|
482
|
+
try:
|
|
483
|
+
all_runs = load_subagent_runs()
|
|
484
|
+
all_runs.sort(key=lambda x: x.get('startedAt', 0), reverse=True)
|
|
485
|
+
|
|
486
|
+
tasks = merge_with_history(all_runs, _run_to_task)
|
|
487
|
+
# 对历史任务补充缺失字段
|
|
488
|
+
for t in tasks:
|
|
489
|
+
if t.get('status') == 'completed' and t.get('childSessionKey'):
|
|
490
|
+
if not t.get('output'):
|
|
491
|
+
output = get_agent_output_for_run(t['childSessionKey'])
|
|
492
|
+
if output:
|
|
493
|
+
t['output'] = output
|
|
494
|
+
if not t.get('generatedFiles'):
|
|
495
|
+
files = get_agent_files_for_run(t['childSessionKey'])
|
|
496
|
+
if files:
|
|
497
|
+
t['generatedFiles'] = files
|
|
498
|
+
if not t.get('agentWorkspace') and t.get('agentId'):
|
|
499
|
+
t['agentWorkspace'] = _get_agent_workspace(t['agentId'])
|
|
500
|
+
return {'tasks': tasks}
|
|
501
|
+
except Exception as e:
|
|
502
|
+
print(f"Error in get_tasks: {e}")
|
|
503
|
+
import traceback
|
|
504
|
+
traceback.print_exc()
|
|
505
|
+
return {'tasks': []}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _extract_timeline_from_session(child_session_key: str) -> List[Dict[str, Any]]:
|
|
509
|
+
"""
|
|
510
|
+
从 session 文件中提取任务执行时间线。
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
child_session_key: 格式 agent:<agentId>:subagent:<uuid>
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
时间线事件列表,每个包含: {time, type, description}
|
|
517
|
+
"""
|
|
518
|
+
if not child_session_key or ':' not in child_session_key:
|
|
519
|
+
return []
|
|
520
|
+
parts = child_session_key.split(':')
|
|
521
|
+
if len(parts) < 2 or parts[0] != 'agent':
|
|
522
|
+
return []
|
|
523
|
+
agent_id = parts[1]
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
from data.config_reader import get_openclaw_root
|
|
527
|
+
openclaw_path = get_openclaw_root()
|
|
528
|
+
sessions_index = openclaw_path / "agents" / agent_id / "sessions" / "sessions.json"
|
|
529
|
+
if not sessions_index.exists():
|
|
530
|
+
return []
|
|
531
|
+
|
|
532
|
+
with open(sessions_index, 'r', encoding='utf-8') as f:
|
|
533
|
+
index_data = json.load(f)
|
|
534
|
+
entry = index_data.get(child_session_key)
|
|
535
|
+
if not entry:
|
|
536
|
+
return []
|
|
537
|
+
session_file = entry.get('sessionFile')
|
|
538
|
+
session_id = entry.get('sessionId')
|
|
539
|
+
if not session_file and not session_id:
|
|
540
|
+
return []
|
|
541
|
+
if not session_file:
|
|
542
|
+
sessions_dir = openclaw_path / "agents" / agent_id / "sessions"
|
|
543
|
+
session_file = str(sessions_dir / f"{session_id}.jsonl")
|
|
544
|
+
|
|
545
|
+
session_path = Path(session_file)
|
|
546
|
+
if not session_path.exists():
|
|
547
|
+
return []
|
|
548
|
+
|
|
549
|
+
timeline: List[Dict[str, Any]] = []
|
|
550
|
+
event_count = 0
|
|
551
|
+
max_events = 50 # 限制最大事件数
|
|
552
|
+
|
|
553
|
+
with open(session_path, 'r', encoding='utf-8') as f:
|
|
554
|
+
for line in f:
|
|
555
|
+
if event_count >= max_events:
|
|
556
|
+
break
|
|
557
|
+
try:
|
|
558
|
+
data = json.loads(line)
|
|
559
|
+
ts = data.get('timestamp')
|
|
560
|
+
# 确保 ts 是整数毫秒时间戳
|
|
561
|
+
if isinstance(ts, str):
|
|
562
|
+
# ISO 格式转毫秒时间戳
|
|
563
|
+
try:
|
|
564
|
+
from datetime import datetime
|
|
565
|
+
# 处理 ISO 格式:2026-03-07T04:07:25.262Z
|
|
566
|
+
if 'T' in ts:
|
|
567
|
+
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
|
568
|
+
ts = int(dt.timestamp() * 1000)
|
|
569
|
+
else:
|
|
570
|
+
ts = int(ts)
|
|
571
|
+
except (ValueError, TypeError):
|
|
572
|
+
ts = 0
|
|
573
|
+
elif isinstance(ts, (int, float)):
|
|
574
|
+
ts = int(ts)
|
|
575
|
+
else:
|
|
576
|
+
ts = 0
|
|
577
|
+
|
|
578
|
+
if data.get('type') == 'message':
|
|
579
|
+
msg = data.get('message', {})
|
|
580
|
+
role = msg.get('role', '')
|
|
581
|
+
content = msg.get('content', [])
|
|
582
|
+
|
|
583
|
+
if role == 'user':
|
|
584
|
+
# 用户消息(任务开始)
|
|
585
|
+
for c in content:
|
|
586
|
+
if isinstance(c, dict) and c.get('type') == 'text':
|
|
587
|
+
text = c.get('text', '')[:100]
|
|
588
|
+
timeline.append({
|
|
589
|
+
'time': ts,
|
|
590
|
+
'type': 'start',
|
|
591
|
+
'description': f'收到任务: {text}...' if len(c.get('text', '')) > 100 else f'收到任务: {text}'
|
|
592
|
+
})
|
|
593
|
+
event_count += 1
|
|
594
|
+
break
|
|
595
|
+
|
|
596
|
+
elif role == 'assistant':
|
|
597
|
+
# 助手响应中的工具调用
|
|
598
|
+
for c in content:
|
|
599
|
+
if not isinstance(c, dict):
|
|
600
|
+
continue
|
|
601
|
+
if c.get('type') == 'toolCall':
|
|
602
|
+
tool_name = c.get('name', 'unknown')
|
|
603
|
+
args = c.get('arguments', {})
|
|
604
|
+
if isinstance(args, str):
|
|
605
|
+
try:
|
|
606
|
+
args = json.loads(args)
|
|
607
|
+
except json.JSONDecodeError:
|
|
608
|
+
args = {}
|
|
609
|
+
|
|
610
|
+
# 生成描述
|
|
611
|
+
desc = _describe_tool_call(tool_name, args)
|
|
612
|
+
timeline.append({
|
|
613
|
+
'time': ts,
|
|
614
|
+
'type': 'tool',
|
|
615
|
+
'tool': tool_name,
|
|
616
|
+
'description': desc
|
|
617
|
+
})
|
|
618
|
+
event_count += 1
|
|
619
|
+
|
|
620
|
+
elif c.get('type') == 'text':
|
|
621
|
+
# 文本响应(可能是最终答案)
|
|
622
|
+
text = c.get('text', '')
|
|
623
|
+
if text.strip() and len(text) > 50:
|
|
624
|
+
# 简单判断是否是最终答案
|
|
625
|
+
keywords = ['完成', '成功', 'finished', 'done', 'result', '总结']
|
|
626
|
+
if any(kw in text.lower() for kw in keywords):
|
|
627
|
+
timeline.append({
|
|
628
|
+
'time': ts,
|
|
629
|
+
'type': 'response',
|
|
630
|
+
'description': f'输出结果 ({len(text)} 字符)'
|
|
631
|
+
})
|
|
632
|
+
event_count += 1
|
|
633
|
+
|
|
634
|
+
except (json.JSONDecodeError, KeyError):
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
return timeline
|
|
638
|
+
except Exception as e:
|
|
639
|
+
print(f"_extract_timeline_from_session 失败: {e}")
|
|
640
|
+
return []
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _describe_tool_call(tool_name: str, args: Dict[str, Any]) -> str:
|
|
644
|
+
"""生成工具调用的可读描述"""
|
|
645
|
+
tool_descriptions = {
|
|
646
|
+
'read': '读取文件',
|
|
647
|
+
'write': '写入文件',
|
|
648
|
+
'edit': '编辑文件',
|
|
649
|
+
'bash': '执行命令',
|
|
650
|
+
'grep': '搜索内容',
|
|
651
|
+
'glob': '查找文件',
|
|
652
|
+
'webfetch': '获取网页',
|
|
653
|
+
'websearch': '网络搜索',
|
|
654
|
+
'subagent': '派发子任务',
|
|
655
|
+
'delegate': '委托任务',
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
base_desc = tool_descriptions.get(tool_name.lower(), f'调用 {tool_name}')
|
|
659
|
+
|
|
660
|
+
# 添加更多上下文
|
|
661
|
+
if tool_name.lower() in ('read', 'write', 'edit'):
|
|
662
|
+
path = args.get('path') or args.get('file_path', '')
|
|
663
|
+
if path:
|
|
664
|
+
filename = Path(path).name if isinstance(path, str) else str(path)
|
|
665
|
+
return f'{base_desc}: {filename}'
|
|
666
|
+
elif tool_name.lower() == 'bash':
|
|
667
|
+
cmd = args.get('command', '') or args.get('cmd', '')
|
|
668
|
+
if cmd:
|
|
669
|
+
cmd_short = cmd[:50] + '...' if len(cmd) > 50 else cmd
|
|
670
|
+
return f'{base_desc}: {cmd_short}'
|
|
671
|
+
elif tool_name.lower() in ('subagent', 'delegate'):
|
|
672
|
+
task = args.get('task', '') or args.get('prompt', '')
|
|
673
|
+
if task:
|
|
674
|
+
task_short = task[:50] + '...' if len(task) > 50 else task
|
|
675
|
+
return f'{base_desc}: {task_short}'
|
|
676
|
+
|
|
677
|
+
return base_desc
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
@router.get("/tasks/{run_id}/timeline")
|
|
681
|
+
async def get_task_timeline(run_id: str):
|
|
682
|
+
"""
|
|
683
|
+
获取任务执行时间线。
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
run_id: 任务运行 ID
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
时间线事件列表
|
|
690
|
+
"""
|
|
691
|
+
try:
|
|
692
|
+
# 从 runs.json 查找对应的 session key
|
|
693
|
+
all_runs = load_subagent_runs()
|
|
694
|
+
target_run = None
|
|
695
|
+
for run in all_runs:
|
|
696
|
+
if run.get('runId') == run_id:
|
|
697
|
+
target_run = run
|
|
698
|
+
break
|
|
699
|
+
|
|
700
|
+
if not target_run:
|
|
701
|
+
# 尝试从历史记录查找
|
|
702
|
+
from data.task_history import load_task_history
|
|
703
|
+
history = load_task_history()
|
|
704
|
+
for record in history:
|
|
705
|
+
if record.get('id') == run_id:
|
|
706
|
+
target_run = record
|
|
707
|
+
break
|
|
708
|
+
|
|
709
|
+
if not target_run:
|
|
710
|
+
return {'timeline': [], 'error': 'Task not found'}
|
|
711
|
+
|
|
712
|
+
child_key = target_run.get('childSessionKey', '')
|
|
713
|
+
if not child_key:
|
|
714
|
+
return {'timeline': [], 'error': 'No session key'}
|
|
715
|
+
|
|
716
|
+
timeline = _extract_timeline_from_session(child_key)
|
|
717
|
+
|
|
718
|
+
# 添加任务开始和结束事件
|
|
719
|
+
# 兼容两种字段名:startedAt (runs.json) 和 startTime (task_history)
|
|
720
|
+
started_at = target_run.get('startedAt') or target_run.get('startTime')
|
|
721
|
+
ended_at = target_run.get('endedAt') or target_run.get('endTime')
|
|
722
|
+
outcome = target_run.get('outcome')
|
|
723
|
+
|
|
724
|
+
if started_at:
|
|
725
|
+
timeline.insert(0, {
|
|
726
|
+
'time': started_at,
|
|
727
|
+
'type': 'created',
|
|
728
|
+
'description': '任务创建'
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
if ended_at:
|
|
732
|
+
if isinstance(outcome, dict) and outcome.get('status') == 'ok':
|
|
733
|
+
end_type = 'completed'
|
|
734
|
+
end_desc = '任务完成'
|
|
735
|
+
elif isinstance(outcome, dict) and outcome.get('status') in ('error', 'failed'):
|
|
736
|
+
end_type = 'failed'
|
|
737
|
+
err_msg = outcome.get('error') or outcome.get('message') or '任务失败'
|
|
738
|
+
end_desc = f'任务失败: {err_msg}'
|
|
739
|
+
elif isinstance(outcome, str) and outcome.lower() in ('error', 'failed'):
|
|
740
|
+
end_type = 'failed'
|
|
741
|
+
end_desc = f'任务失败: {outcome}'
|
|
742
|
+
else:
|
|
743
|
+
end_type = 'completed'
|
|
744
|
+
end_desc = '任务结束'
|
|
745
|
+
|
|
746
|
+
timeline.append({
|
|
747
|
+
'time': ended_at,
|
|
748
|
+
'type': end_type,
|
|
749
|
+
'description': end_desc
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
# 按时间排序(确保 time 是数字)
|
|
753
|
+
def get_sort_time(x):
|
|
754
|
+
t = x.get('time', 0)
|
|
755
|
+
if isinstance(t, (int, float)):
|
|
756
|
+
return int(t)
|
|
757
|
+
elif isinstance(t, str):
|
|
758
|
+
try:
|
|
759
|
+
return int(t)
|
|
760
|
+
except ValueError:
|
|
761
|
+
return 0
|
|
762
|
+
return 0
|
|
763
|
+
timeline.sort(key=get_sort_time)
|
|
764
|
+
|
|
765
|
+
return {'timeline': timeline, 'runId': run_id}
|
|
766
|
+
except Exception as e:
|
|
767
|
+
print(f"Error in get_task_timeline: {e}")
|
|
768
|
+
import traceback
|
|
769
|
+
traceback.print_exc()
|
|
770
|
+
return {'timeline': [], 'error': str(e)}
|