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.
- package/dashboard/api/agent_config_api.py +28 -7
- package/dashboard/api/agents.py +48 -10
- package/dashboard/api/agents_config.py +5 -1
- package/dashboard/api/chains.py +25 -5
- package/dashboard/api/collaboration.py +10 -9
- package/dashboard/api/debug_paths.py +5 -1
- package/dashboard/api/error_analysis.py +29 -11
- package/dashboard/api/errors.py +27 -11
- package/dashboard/api/fortify_routes.py +80 -0
- package/dashboard/api/input_safety.py +60 -0
- package/dashboard/api/performance.py +73 -53
- package/dashboard/api/subagents.py +95 -99
- package/dashboard/api/timeline.py +24 -3
- package/dashboard/api/version.py +2 -0
- package/dashboard/api/websocket.py +9 -7
- package/dashboard/core/__init__.py +1 -0
- package/dashboard/core/config_fortify.py +112 -0
- package/dashboard/core/error_handler.py +339 -0
- package/dashboard/core/fallback_manager.py +70 -0
- package/dashboard/core/safe_api_error.py +76 -0
- package/dashboard/core/schemas/__init__.py +16 -0
- package/dashboard/core/schemas/base.py +43 -0
- package/dashboard/core/schemas/session_schema.py +40 -0
- package/dashboard/core/schemas/subagent_schema.py +23 -0
- package/dashboard/data/agent_config_manager.py +6 -4
- package/dashboard/data/chain_reader.py +16 -12
- package/dashboard/data/error_analyzer.py +15 -11
- package/dashboard/data/session_reader.py +268 -46
- package/dashboard/data/subagent_reader.py +74 -49
- package/dashboard/data/timeline_reader.py +35 -49
- package/dashboard/main.py +24 -2
- package/dashboard/mechanism_reader.py +4 -5
- package/dashboard/mechanisms.py +2 -2
- package/dashboard/pytest.ini +3 -0
- package/dashboard/requirements.txt +5 -0
- package/dashboard/status/cache_fp_probe.py +40 -0
- package/dashboard/status/status_cache.py +199 -72
- package/dashboard/status/status_calculator.py +50 -30
- package/dashboard/tests/conftest.py +84 -0
- package/dashboard/tests/test_api_contracts.py +372 -0
- package/dashboard/tests/test_bench_fortify.py +176 -0
- package/dashboard/tests/test_fortify.py +741 -0
- package/dashboard/utils/__init__.py +1 -0
- package/dashboard/utils/data_repair.py +210 -0
- package/dashboard/watchers/file_watcher.py +367 -77
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/dashboard/agents.py +0 -74
- package/dashboard/collaboration.py +0 -407
- package/dashboard/errors.py +0 -63
- package/dashboard/performance.py +0 -474
- package/dashboard/session_reader.py +0 -240
- package/dashboard/status_calculator.py +0 -121
- 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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
if
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
710
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
381
|
-
if
|
|
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'
|
|
411
|
+
'status': 'unknown'
|
|
408
412
|
})
|
|
409
|
-
except (
|
|
413
|
+
except (KeyError, TypeError):
|
|
410
414
|
continue
|
|
411
415
|
|
|
412
416
|
return subtasks[:5] # 最多返回 5 个子任务
|
|
413
417
|
except Exception as e:
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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 =
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
desc
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', [])
|