openclaw-agent-dashboard 1.0.37 → 1.0.39

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.
@@ -73,6 +73,23 @@ class ActiveTask(BaseModel):
73
73
  featureId: Optional[str] = None # FEATURE_ID(如果有)
74
74
 
75
75
 
76
+ def _main_agent_status_for_collaboration(main_agent_id: str) -> str:
77
+ """
78
+ 协作流程里 PM 卡片状态:与 delegate 边是否激活同源——只认 subagents/runs.json 的活跃 run
79
+ (is_agent_working)+ 近期会话错误;不使用 calculate_agent_status 里的主会话 solo 启发式
80
+ (thinking / 未完成 tool / sessions 短窗),避免「连线已变实线仍显示工作中」。
81
+ 全局 /api/agents 仍用 calculate_agent_status,可继续反映「主会话自己在跑」的语义。
82
+ """
83
+ from data.session_reader import has_recent_errors
84
+ from data.subagent_reader import is_agent_working
85
+
86
+ if has_recent_errors(main_agent_id, minutes=5):
87
+ return 'error'
88
+ if is_agent_working(main_agent_id):
89
+ return 'working'
90
+ return 'idle'
91
+
92
+
76
93
  def _extract_feature_id(task_name: str) -> Optional[str]:
77
94
  """从任务名称中提取 FEATURE_ID"""
78
95
  if not task_name:
@@ -149,6 +166,14 @@ def _build_agent_active_tasks(
149
166
  if requester_agent_id and not agent_ids_equal(requester_agent_id, child_agent_id):
150
167
  task_item['childAgentId'] = child_agent_id
151
168
 
169
+ # 自派:派发者与执行者为同一 Agent(如 PM 派给自己)时,同一 run 只记一条,否则会显示「2 个并行任务」
170
+ if requester_agent_id and agent_ids_equal(requester_agent_id, child_agent_id):
171
+ if child_agent_id not in agent_active_tasks:
172
+ agent_active_tasks[child_agent_id] = []
173
+ self_task = {k: v for k, v in task_item.items() if k != 'childAgentId'}
174
+ agent_active_tasks[child_agent_id].append(self_task)
175
+ continue
176
+
152
177
  # 1. 添加到派发者(如果派发者是某个已知 agent)
153
178
  if requester_agent_id:
154
179
  if requester_agent_id not in agent_active_tasks:
@@ -197,8 +222,6 @@ class CollaborationFlow(BaseModel):
197
222
  depths: Optional[Dict[str, int]] = None # agentId -> 层级深度 (0=主, 1=子, 2=孙...)
198
223
  # 多任务并行展示:每个 Agent 的活跃任务列表
199
224
  agentActiveTasks: Optional[Dict[str, List[ActiveTask]]] = None
200
- # 多任务并行展示:每个 Agent 的活跃任务列表
201
- agentActiveTasks: Optional[Dict[str, List[ActiveTask]]] = None
202
225
 
203
226
 
204
227
  def _parse_agent_id(session_key: str) -> str:
@@ -340,30 +363,11 @@ def _enrich_main_agent_active_tasks_if_needed(
340
363
  main_agent_id: str,
341
364
  ) -> Dict[str, List[Dict[str, Any]]]:
342
365
  """
343
- Agent 仅更新主会话、subagents/runs 无条目时,agentActiveTasks 为空;
344
- 在仍为 working 时补一条会话摘要,便于卡片「并行任务」区有文案。
366
+ PM 协作状态已与 runs 对齐;不再在「无 subagent run」时用 calculate_agent_status
367
+ 伪造一条主会话任务,否则会出现徽章已空闲、卡片仍显示「当前任务」的矛盾。
368
+ 主会话 solo 执行中的语义由 /api/agents 等入口展示。
345
369
  """
346
- from status.status_calculator import calculate_agent_status, get_current_task
347
-
348
- if calculate_agent_status(main_agent_id) != 'working':
349
- return agent_active_tasks
350
- if agent_active_tasks.get(main_agent_id):
351
- return agent_active_tasks
352
- hint = get_current_task(main_agent_id)
353
- name = _clean_task_name(hint) if hint else ''
354
- if not name:
355
- return agent_active_tasks
356
- merged = dict(agent_active_tasks)
357
- merged[main_agent_id] = [
358
- {
359
- 'id': 'task-main-session',
360
- 'name': name,
361
- 'status': 'working',
362
- 'timestamp': None,
363
- 'featureId': None,
364
- }
365
- ]
366
- return merged
370
+ return agent_active_tasks
367
371
 
368
372
 
369
373
  def _get_agent_error_info(agent_id: str) -> Optional[Dict[str, Any]]:
@@ -574,14 +578,8 @@ async def get_collaboration():
574
578
  recent_calls = _get_recent_model_calls(30)
575
579
 
576
580
  main_display_name = (main_agent_config.get('name') if main_agent_config else None) or "主 Agent"
577
- # 与子 Agent 一致:基于 runs + session 活动判断;独立 PM 无 subagent run 时仍可能在工作
578
- main_raw = calculate_agent_status(main_agent_id)
579
- if main_raw == 'down':
580
- main_status = 'error'
581
- elif main_raw == 'working':
582
- main_status = 'working'
583
- else:
584
- main_status = 'idle'
581
+ # PM:与连线 activePath 一致,仅用 runs 活跃 + 会话错误(见 _main_agent_status_for_collaboration)
582
+ main_status = _main_agent_status_for_collaboration(main_agent_id)
585
583
 
586
584
  # 获取主 agent 的当前任务和错误信息
587
585
  main_current_task = ''
@@ -909,8 +907,17 @@ async def get_collaboration_dynamic():
909
907
  except Exception as e:
910
908
  logger.warning(f"Failed to get display status for {aid}: {e}")
911
909
 
912
- # Agent 状态已在上方循环中与 calculate_agent_status 一致,勿再用「有无 active_runs」覆盖
913
- # (独立 PM 仅更新主会话时 runs 可能为空,否则会误显示空闲)
910
+ # PM:覆盖为与连线同源的状态;并修正 dynamic 文案,避免 calculate_agent_status solo 与卡片矛盾
911
+ main_collab = _main_agent_status_for_collaboration(main_agent_id)
912
+ agent_statuses[main_agent_id] = main_collab
913
+ if main_collab == 'idle':
914
+ agent_dynamic_statuses[main_agent_id] = AgentDisplayStatus(
915
+ status='idle', display='空闲', duration=0, alert=False
916
+ )
917
+ elif main_collab == 'error':
918
+ agent_dynamic_statuses[main_agent_id] = AgentDisplayStatus(
919
+ status='idle', display='检测到错误', duration=0, alert=True
920
+ )
914
921
 
915
922
  # 处理活跃任务(简化:不在流程图中创建任务节点,任务信息由 agentActiveTasks 提供)
916
923
  # 任务详情在 Agent 卡片内显示,流程图只显示 Agent 之间的委托关系
@@ -129,10 +129,9 @@ def get_recent_messages(agent_id: str, limit: int = 10) -> List[Dict[str, Any]]:
129
129
  data = json.loads(line)
130
130
  if data.get('type') == 'message':
131
131
  messages.append(data.get('message', {}))
132
- if len(messages) >= limit:
133
- break
134
132
  except json.JSONDecodeError:
135
133
  continue
134
+ # 必须取尾部:原先在扫描到 limit 条就 break,会拿到「窗口内较早」的消息而非最新,导致 tool/ thinking 误判
136
135
  return messages[-limit:] if len(messages) > limit else messages
137
136
 
138
137
 
@@ -416,16 +415,26 @@ def get_latest_tool_call(agent_id: str) -> Optional[Dict[str, Any]]:
416
415
 
417
416
 
418
417
  def has_thinking_block(agent_id: str) -> bool:
419
- """检查最近消息是否有 thinking 块"""
420
- messages = get_recent_messages(agent_id, limit=5)
421
- for msg in reversed(messages):
422
- if msg.get('role') == 'assistant':
423
- content = msg.get('content', [])
424
- if isinstance(content, str):
425
- continue
426
- for c in content:
427
- if isinstance(c, dict) and c.get('type') == 'thinking':
428
- return True
418
+ """
419
+ 是否处于「当前回合的思考阶段」。
420
+ 仅看会话中**最后一条**消息:已完成回合的 assistant 往往在 content 里仍保留 thinking 块,
421
+ 若仍按「最近任意 assistant thinking」会长期误判为工作中。
422
+ """
423
+ messages = get_recent_messages(agent_id, limit=24)
424
+ if not messages:
425
+ return False
426
+ last = messages[-1]
427
+ if last.get('role') != 'assistant':
428
+ return False
429
+ # 已结束的一轮通常带 stopReason;此时 content 里的 thinking 只算历史,不算仍在思考
430
+ if last.get('stopReason'):
431
+ return False
432
+ content = last.get('content', [])
433
+ if isinstance(content, str):
434
+ return False
435
+ for c in content:
436
+ if isinstance(c, dict) and c.get('type') == 'thinking':
437
+ return True
429
438
  return False
430
439
 
431
440
 
@@ -471,8 +480,6 @@ def get_recent_messages_with_timestamp(agent_id: str, limit: int = 10) -> List[D
471
480
  'timestamp': msg.get('timestamp', 0),
472
481
  'data_timestamp': data.get('timestamp', ''),
473
482
  })
474
- if len(messages) >= limit:
475
- break
476
483
  except json.JSONDecodeError:
477
484
  continue
478
485
 
@@ -151,6 +151,7 @@ _AGENT_ID_TO_LABEL = {
151
151
  "analyst-agent": "分析师",
152
152
  "architect-agent": "架构师",
153
153
  "devops-agent": "运维",
154
+ "coder-agent": "开发",
154
155
  "project-manager": "项目经理",
155
156
  "test-agent": "测试",
156
157
  "frontend-agent": "前端",
@@ -423,10 +424,15 @@ def _rebuild_subagent_timeline_payload(
423
424
  result: Dict[str, Any],
424
425
  limit: int,
425
426
  round_mode: bool,
427
+ prefer_start: bool = False,
426
428
  ) -> None:
427
429
  """就地更新 result 的 steps / stats / rounds(步骤已为 dict 列表)。"""
428
430
  if len(steps) > limit:
429
- steps = steps[-limit:]
431
+ # 子任务需从「本次下发」起展示:保留 [:limit];主会话等仍用尾部 100
432
+ if prefer_start:
433
+ steps = steps[:limit]
434
+ else:
435
+ steps = steps[-limit:]
430
436
  result['steps'] = _pair_tool_calls_and_results(steps)
431
437
  total_duration = 0
432
438
  total_input = 0
@@ -452,24 +458,60 @@ def _rebuild_subagent_timeline_payload(
452
458
  result['roundMode'] = True
453
459
 
454
460
 
461
+ def _include_subagent_steps_before_run_anchor(
462
+ steps_in: List[Dict[str, Any]],
463
+ first_kept_index: int,
464
+ _anchor_ms: int,
465
+ cutoff: int,
466
+ ) -> int:
467
+ """
468
+ 从 runs 取的 startedAt 可能略晚于「PM/主控下发 user」的落盘时间,且其之间常有 thinking/工具 步;
469
+ 在第一个 ts>=cutoff 的步骤之前,把同一工作段内、早于 cutoff 的连续步骤并回(时间窗 20 分钟内)。
470
+ """
471
+ if first_kept_index <= 0:
472
+ return 0
473
+ t_first = (steps_in[first_kept_index].get('timestamp') or 0) if first_kept_index < len(steps_in) else 0
474
+ if not t_first:
475
+ return first_kept_index
476
+ i0 = first_kept_index
477
+ # 在「首条已跨 cutoff」的步之前,并回同一 burst 中所有更早的步骤(含 PM 的 user 与紧挨的工具链)
478
+ while i0 > 0:
479
+ tlo = (steps_in[i0 - 1].get('timestamp') or 0)
480
+ if tlo >= cutoff:
481
+ i0 -= 1
482
+ continue
483
+ if (t_first - tlo) <= 20 * 60 * 1000:
484
+ i0 -= 1
485
+ else:
486
+ break
487
+ return i0
488
+
489
+
455
490
  def _apply_subagent_run_anchor_to_result(
456
491
  result: Dict[str, Any],
457
492
  anchor_ms: int,
458
493
  limit: int,
459
494
  round_mode: bool,
460
495
  ) -> Dict[str, Any]:
461
- """去掉 anchor 之前的步骤;若过滤后为空则保留原步骤。"""
496
+ """去掉 anchor 之前无关步骤,并补回被锚点切掉的 PM/下发 user;若过滤后为空则保留原步骤。"""
462
497
  steps_in = result.get('steps') or []
463
498
  if not steps_in:
464
499
  return result
465
500
  cutoff = anchor_ms - _RUN_ANCHOR_SLACK_MS
466
- filtered = [s for s in steps_in if (s.get('timestamp') or 0) >= cutoff]
501
+ first_i = next(
502
+ (i for i, s in enumerate(steps_in) if (s.get('timestamp') or 0) >= cutoff),
503
+ len(steps_in),
504
+ )
505
+ if first_i >= len(steps_in):
506
+ return result
507
+ i0 = _include_subagent_steps_before_run_anchor(steps_in, first_i, anchor_ms, cutoff)
508
+ filtered = steps_in[i0:]
467
509
  if not filtered:
468
510
  return result
469
511
  result['runStartedAt'] = anchor_ms
470
512
  if result.get('steps') and filtered[0].get('timestamp') is not None:
471
513
  result['startedAt'] = filtered[0]['timestamp']
472
- _rebuild_subagent_timeline_payload(filtered, result, limit, round_mode)
514
+ _rebuild_subagent_timeline_payload(filtered, result, limit, round_mode, prefer_start=True)
473
515
  return result
474
516
 
475
517
 
@@ -605,7 +647,22 @@ def resolve_agent_session_jsonl(
605
647
  sid = ent.get('sessionId') or preferred_key
606
648
  return p, sid, preferred_key
607
649
 
608
- # 2) 目录下最新的 *.jsonl(mtime
650
+ # 2) sessions.json 的 updatedAt/lastMessageAt 选最近会话(在 glob mtime 之前)
651
+ # OpenClaw 在任务结束后可能从 runs.json 移除 run,此处仍可定位「最近活跃」子会话 jsonl。
652
+ # 多文件时比仅凭 *.jsonl 的 mtime 更稳,且与 4/24 当晚最晚更新 session 一致。
653
+ if agent_keys:
654
+ agent_keys.sort(
655
+ key=lambda k: (index_map[k].get('updatedAt') or index_map[k].get('lastMessageAt') or 0),
656
+ reverse=True,
657
+ )
658
+ for k in agent_keys:
659
+ ent = index_map[k]
660
+ p = resolve_session_jsonl_path(sessions_path, ent)
661
+ if p and p.is_file():
662
+ sid = ent.get('sessionId') or k
663
+ return p, sid, k
664
+
665
+ # 3) 无索引条目时:目录下最新的 *.jsonl(mtime)
609
666
  jsonl_files = list(sessions_path.glob("*.jsonl"))
610
667
  if jsonl_files:
611
668
  jsonl_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
@@ -631,19 +688,6 @@ def resolve_agent_session_jsonl(
631
688
  continue
632
689
  return f, sid, resolved_key
633
690
 
634
- # 3) 仅有 sessions.json 索引、尚无 glob 到的文件时:按 updatedAt 试解析路径
635
- if agent_keys:
636
- agent_keys.sort(
637
- key=lambda k: (index_map[k].get('updatedAt') or index_map[k].get('lastMessageAt') or 0),
638
- reverse=True,
639
- )
640
- for k in agent_keys:
641
- ent = index_map[k]
642
- p = resolve_session_jsonl_path(sessions_path, ent)
643
- if p and p.is_file():
644
- sid = ent.get('sessionId') or k
645
- return p, sid, k
646
-
647
691
  return None, None, None
648
692
 
649
693
 
@@ -686,12 +730,23 @@ def get_timeline_steps(
686
730
  if session_file and session_file.exists():
687
731
  req_key = session_key if session_key else resolved_key
688
732
  requester_info = _get_requester_info_for_session(agent_id, req_key)
689
- result = _parse_session_file(session_file, agent_id, session_id, limit, requester_info, round_mode)
690
- # Agent:从 runs.json 本次 run 的 startedAt 起展示(对应派发进子会话的时刻,含 PM 经链路下发)
691
- if not agent_ids_equal(agent_id, _get_main_agent_id()):
692
- anchor = _subagent_run_anchor_ms(agent_id, resolved_key)
693
- if anchor is not None:
694
- result = _apply_subagent_run_anchor_to_result(result, anchor, limit, round_mode)
733
+ is_sub = not agent_ids_equal(agent_id, _get_main_agent_id())
734
+ subagent_anchor: Optional[int] = None
735
+ if is_sub:
736
+ subagent_anchor = _subagent_run_anchor_ms(agent_id, resolved_key)
737
+ result = _parse_session_file(
738
+ session_file,
739
+ agent_id,
740
+ session_id,
741
+ limit,
742
+ requester_info,
743
+ round_mode,
744
+ is_subagent=is_sub,
745
+ subagent_anchor_ms=subagent_anchor,
746
+ )
747
+ # 子 Agent:从 runs.json 本次 run 的 startedAt 起展示(含 PM/主控经链路下发到 coder 等子 agent)
748
+ if is_sub and subagent_anchor is not None:
749
+ result = _apply_subagent_run_anchor_to_result(result, subagent_anchor, limit, round_mode)
695
750
  return result
696
751
  if agent_ids_equal(agent_id, _get_main_agent_id()):
697
752
  return _empty_main_agent_timeline(agent_id)
@@ -1224,33 +1279,147 @@ def _parse_session_lines(
1224
1279
  return steps, started_at, session_status
1225
1280
 
1226
1281
 
1282
+ # 子 agent 会话 jsonl 全量读的安全上限(仅防极端大文件 OOM,主逻辑不据此判断「从哪开始」)
1283
+ _SUBAGENT_READ_SAFETY_BYTES = 32 * 1024 * 1024
1284
+ # 自「首条 user」起再读多少行入内存(有 runs 时仍从文件头解析以便锚点;无 runs 时从首条 user 行起)
1285
+ _MAX_LINES_AFTER_TASK_START = 20000
1286
+
1287
+
1288
+ def _line_index_of_first_user_message(path: Path) -> Optional[int]:
1289
+ """
1290
+ 扫描 jsonl,返回第一条 type=message 且 role=user 的所在行号(0-based)。
1291
+ 用于「无 runs 锚点」时从 PM/主控下发(或 Subagent 首条 user)起构时序,而非按文件体积选 tail/head。
1292
+ """
1293
+ try:
1294
+ with open(path, 'r', encoding='utf-8') as f:
1295
+ for i, line in enumerate(f):
1296
+ if '"role"' not in line or 'user' not in line:
1297
+ continue
1298
+ try:
1299
+ d = json.loads(line.strip())
1300
+ except json.JSONDecodeError:
1301
+ continue
1302
+ if d.get('type') != 'message':
1303
+ continue
1304
+ if (d.get('message') or {}).get('role') == 'user':
1305
+ return i
1306
+ except (OSError, IOError):
1307
+ pass
1308
+ return None
1309
+
1310
+
1311
+ def _read_text_line_window(path: Path, start_line: int, max_lines: int) -> List[str]:
1312
+ """从第 start_line 行起,最多读 max_lines 行(供超大文件、无锚点时从首条 user 后解析)。"""
1313
+ out: List[str] = []
1314
+ try:
1315
+ with open(path, 'r', encoding='utf-8') as f:
1316
+ for i, line in enumerate(f):
1317
+ if i < start_line:
1318
+ continue
1319
+ out.append(line)
1320
+ if len(out) >= max_lines:
1321
+ break
1322
+ except (OSError, IOError):
1323
+ pass
1324
+ return out
1325
+
1326
+
1327
+ def _slice_subagent_steps_from_first_user(
1328
+ steps: List[TimelineStep],
1329
+ ) -> List[TimelineStep]:
1330
+ """从本会话中第一条 user(PM/主控下发到子 agent)起展示。"""
1331
+ for i, s in enumerate(steps):
1332
+ if s.type == StepType.USER.value:
1333
+ return steps[i:]
1334
+ return steps
1335
+
1336
+
1227
1337
  def _parse_session_file(
1228
1338
  session_file: Path,
1229
1339
  agent_id: str,
1230
1340
  session_id: Optional[str],
1231
1341
  limit: int,
1232
1342
  requester_info: Optional[Dict[str, str]] = None,
1233
- round_mode: bool = True
1343
+ round_mode: bool = True,
1344
+ is_subagent: bool = False,
1345
+ subagent_anchor_ms: Optional[int] = None,
1234
1346
  ) -> Dict[str, Any]:
1235
- """解析 session jsonl;大文件优先解析尾部窗口,步骤不足时再整文件解析。"""
1347
+ """
1348
+ 解析 session jsonl;大文件对主 agent 仍可用尾部窗口。
1349
+
1350
+ 子 agent:会话范围已由 resolve 指向「最近」jsonl。展示起点由语义决定——
1351
+ 有 runs 锚点用 startedAt 对齐;无锚点时从首条 user(PM/主控下发)起。仅对超大文件用 _SUBAGENT_READ_SAFETY_BYTES
1352
+ 与行数窗做**内存**保护,不作为「是否从 PM 起」的主判据。
1353
+ """
1236
1354
  path = session_file
1237
1355
  header_ts = _read_session_header_timestamp(path)
1238
- tail_lines = _read_jsonl_tail_line_slice(path)
1239
- if tail_lines is not None:
1240
- steps, started_at, session_status = _parse_session_lines(
1241
- tail_lines, requester_info, started_at_hint=header_ts
1242
- )
1243
- if len(steps) < limit:
1356
+ step_budget = limit
1357
+ if is_subagent:
1358
+ step_budget = max(5000, min(limit * 50, 20000))
1359
+ try:
1360
+ file_size = path.stat().st_size
1361
+ except OSError:
1362
+ file_size = 0
1363
+
1364
+ steps: List[TimelineStep] = []
1365
+ started_at: Optional[int] = header_ts
1366
+ session_status = "completed"
1367
+
1368
+ if is_subagent:
1369
+ if file_size == 0:
1370
+ steps, started_at, session_status = _parse_session_lines(
1371
+ [], requester_info, started_at_hint=header_ts
1372
+ )
1373
+ elif file_size <= _SUBAGENT_READ_SAFETY_BYTES:
1244
1374
  steps, started_at, session_status = _parse_session_lines(
1245
1375
  _read_text_lines(path), requester_info, started_at_hint=header_ts
1246
1376
  )
1377
+ if subagent_anchor_ms is None:
1378
+ steps = _slice_subagent_steps_from_first_user(steps)
1379
+ elif subagent_anchor_ms is not None:
1380
+ # 超大 + 有 run:以尾部为窗口(近期)再交给 get_timeline 的 _apply 锚定
1381
+ tail_lines = _read_jsonl_tail_line_slice(path)
1382
+ if tail_lines is not None:
1383
+ steps, started_at, session_status = _parse_session_lines(
1384
+ tail_lines, requester_info, started_at_hint=header_ts
1385
+ )
1386
+ else:
1387
+ steps, started_at, session_status = _parse_session_lines(
1388
+ _read_text_line_window(path, 0, _MAX_LINES_AFTER_TASK_START),
1389
+ requester_info, started_at_hint=header_ts
1390
+ )
1391
+ else:
1392
+ # 超大 + 无 run:先定位首条 user 行,自 PM/主控下发起读有限行
1393
+ uidx = _line_index_of_first_user_message(path)
1394
+ start = uidx if uidx is not None else 0
1395
+ part = _read_text_line_window(path, start, _MAX_LINES_AFTER_TASK_START)
1396
+ steps, started_at, session_status = _parse_session_lines(
1397
+ part, requester_info, started_at_hint=header_ts
1398
+ )
1399
+ steps = _slice_subagent_steps_from_first_user(steps)
1247
1400
  else:
1248
- steps, started_at, session_status = _parse_session_lines(
1249
- _read_text_lines(path), requester_info, started_at_hint=header_ts
1250
- )
1401
+ tail_lines = _read_jsonl_tail_line_slice(path)
1402
+ if tail_lines is not None:
1403
+ steps, started_at, session_status = _parse_session_lines(
1404
+ tail_lines, requester_info, started_at_hint=header_ts
1405
+ )
1406
+ if len(steps) < limit:
1407
+ steps, started_at, session_status = _parse_session_lines(
1408
+ _read_text_lines(path), requester_info, started_at_hint=header_ts
1409
+ )
1410
+ else:
1411
+ steps, started_at, session_status = _parse_session_lines(
1412
+ _read_text_lines(path), requester_info, started_at_hint=header_ts
1413
+ )
1251
1414
 
1252
- if len(steps) > limit:
1253
- steps = steps[-limit:]
1415
+ if len(steps) > step_budget:
1416
+ if is_subagent:
1417
+ steps = steps[:step_budget]
1418
+ else:
1419
+ steps = steps[-limit:]
1420
+
1421
+ if is_subagent and subagent_anchor_ms is None and len(steps) > limit:
1422
+ steps = steps[:limit]
1254
1423
 
1255
1424
  total_duration = 0
1256
1425
  total_input = 0