Aksel Joonas Reedi lee101 commited on
Commit
5ab7c4e
·
unverified ·
1 Parent(s): 1481358

Fix parallel-research display: per-call stats in CLI and web UI (#49)

Browse files

When multiple research sub-agents ran concurrently, the CLI collapsed
them into a single flickering slot and every web-UI tool card showed
the aggregate across all agents — so 3 parallel calls looked like 1.

CLI (agent/utils/terminal_display.py):
- Replace the global SubAgentDisplay singleton with
SubAgentDisplayManager that tracks each agent by agent_id and renders
one block per agent with independent (tool count · tokens · elapsed).
- 4 rolling tool-call lines per agent (was 2).
- When 2+ agents are live, switch to a compact one-line-per-agent layout
(label + stats + most-recent tool). Detailed 4-line rolling view only
when a single agent is active. Keeps the live region small enough to
fit on one terminal page so cursor-up/erase doesn't drift when content
would otherwise scroll into scrollback.
- Clip every live-region line to terminal width (ANSI-aware) so a
wrapped line can't corrupt the cursor-up math on narrow terminals.
- On completion, erase the live block and freeze a single ✓ summary
line above the live region with final stats.

Backend:
- Pass agent_id / label through tool_log events end-to-end
(research_tool.py). Derive agent_id from tool_call_id instead of
md5(task) so two parallel calls with identical task strings don't
collide, and so the frontend can match each research tool card to
its own agent state.
- Thread tool_call_id through the non-approval parallel tool-execution
path in agent_loop.py (it was being dropped there, so research_handler
never saw it on the hot path).
- Mirror agent_id / label forwarding in the headless event handler
(agent/main.py).

Frontend:
- Per-session researchAgents map in the store keyed by agent_id
(agentStore.ts). Forward agent_id / label from the SSE transport
and useAgentChat.onToolLog.
- Each research tool card looks up researchAgents[tool.toolCallId] and
renders only its own stats chip + rolling step list (was: aggregated
across all agents, shown on every card).
- Replace useElapsed (can't be called per-card inside a map) with a
single top-level useSecondTick; each card computes elapsed
synchronously from its own startedAt.
- Stable EMPTY_AGENTS constant instead of `?? {}` in the selector,
fixing a useMemo exhaustive-deps warning.

Co-authored-by: Lee Penkman <leepenkman@gmail.com>

agent/core/agent_loop.py CHANGED
@@ -690,7 +690,7 @@ class Handlers:
690
  if not valid:
691
  return (tc, name, args, err, False)
692
  out, ok = await session.tool_router.call_tool(
693
- name, args, session=session
694
  )
695
  return (tc, name, args, out, ok)
696
 
 
690
  if not valid:
691
  return (tc, name, args, err, False)
692
  out, ok = await session.tool_router.call_tool(
693
+ name, args, session=session, tool_call_id=tc.id
694
  )
695
  return (tc, name, args, out, ok)
696
 
agent/main.py CHANGED
@@ -451,7 +451,9 @@ async def event_listener(
451
  tool = event.data.get("tool", "") if event.data else ""
452
  log = event.data.get("log", "") if event.data else ""
453
  if log:
454
- print_tool_log(tool, log)
 
 
455
  elif event.event_type == "tool_state_change":
456
  pass # visual noise — approval flow handles this
457
  elif event.event_type == "error":
@@ -1204,10 +1206,10 @@ async def headless_main(
1204
  stream_buf = _StreamBuffer(console)
1205
  _hl_last_tool = [None]
1206
  _hl_sub_id = [1]
1207
- # Research sub-agent tool calls are buffered and dumped once the sub-agent
1208
- # finishes, instead of streaming via the live redrawing SubAgentDisplay.
1209
- _hl_research_calls: list[str] = []
1210
- _hl_in_research = [False]
1211
 
1212
  while True:
1213
  event = await event_queue.get()
@@ -1243,26 +1245,34 @@ async def headless_main(
1243
  if not log:
1244
  pass
1245
  elif tool == "research":
1246
- # Buffer research sub-agent activity; on completion, dump a
1247
- # single static block that mirrors the live overlay's styling
1248
- # without its line-erasing redraws (unfit for non-TTY output).
 
 
 
 
 
1249
  if log == "Starting research sub-agent...":
1250
- _hl_in_research[0] = True
1251
- _hl_research_calls.clear()
 
 
1252
  elif log == "Research complete.":
1253
- _hl_in_research[0] = False
1254
- f = get_console().file
1255
- f.write(" \033[38;2;255;200;80m▸ research\033[0m\n")
1256
- for call in _hl_research_calls:
1257
- f.write(f" \033[2m{call}\033[0m\n")
1258
- f.flush()
1259
- _hl_research_calls.clear()
1260
  elif log.startswith("tokens:") or log.startswith("tools:"):
1261
  pass # stats updates — only useful for the live display
1262
- elif _hl_in_research[0]:
1263
- _hl_research_calls.append(log)
1264
  else:
1265
- print_tool_log(tool, log)
 
1266
  else:
1267
  print_tool_log(tool, log)
1268
  elif event.event_type == "approval_required":
 
451
  tool = event.data.get("tool", "") if event.data else ""
452
  log = event.data.get("log", "") if event.data else ""
453
  if log:
454
+ agent_id = event.data.get("agent_id", "") if event.data else ""
455
+ label = event.data.get("label", "") if event.data else ""
456
+ print_tool_log(tool, log, agent_id=agent_id, label=label)
457
  elif event.event_type == "tool_state_change":
458
  pass # visual noise — approval flow handles this
459
  elif event.event_type == "error":
 
1206
  stream_buf = _StreamBuffer(console)
1207
  _hl_last_tool = [None]
1208
  _hl_sub_id = [1]
1209
+ # Research sub-agent tool calls are buffered per agent_id and dumped as
1210
+ # a static block once each sub-agent finishes, instead of streaming via
1211
+ # the live redrawing SubAgentDisplayManager (which is TTY-only).
1212
+ _hl_research_buffers: dict[str, dict] = {}
1213
 
1214
  while True:
1215
  event = await event_queue.get()
 
1245
  if not log:
1246
  pass
1247
  elif tool == "research":
1248
+ # Headless mode: buffer research sub-agent activity per-agent,
1249
+ # then dump each as a static block on completion. The live
1250
+ # SubAgentDisplayManager uses terminal cursor tricks that are
1251
+ # unfit for non-TTY output, but parallel agents still need
1252
+ # distinct output so we key buffers by agent_id.
1253
+ agent_id = event.data.get("agent_id", "") if event.data else ""
1254
+ label = event.data.get("label", "") if event.data else ""
1255
+ aid = agent_id or "research"
1256
  if log == "Starting research sub-agent...":
1257
+ _hl_research_buffers[aid] = {
1258
+ "label": label or "research",
1259
+ "calls": [],
1260
+ }
1261
  elif log == "Research complete.":
1262
+ buf = _hl_research_buffers.pop(aid, None)
1263
+ if buf is not None:
1264
+ f = get_console().file
1265
+ f.write(f" \033[38;2;255;200;80m▸ {buf['label']}\033[0m\n")
1266
+ for call in buf["calls"]:
1267
+ f.write(f" \033[2m{call}\033[0m\n")
1268
+ f.flush()
1269
  elif log.startswith("tokens:") or log.startswith("tools:"):
1270
  pass # stats updates — only useful for the live display
1271
+ elif aid in _hl_research_buffers:
1272
+ _hl_research_buffers[aid]["calls"].append(log)
1273
  else:
1274
+ # Orphan event (Start was missed) — fall back to raw print
1275
+ print_tool_log(tool, log, agent_id=agent_id, label=label)
1276
  else:
1277
  print_tool_log(tool, log)
1278
  elif event.event_type == "approval_required":
agent/tools/research_tool.py CHANGED
@@ -222,7 +222,7 @@ def _get_research_model(main_model: str) -> str:
222
 
223
 
224
  async def research_handler(
225
- arguments: dict[str, Any], session=None, **_kw
226
  ) -> tuple[str, bool]:
227
  """Execute a research sub-agent with its own context."""
228
  task = arguments.get("task", "")
@@ -259,11 +259,28 @@ async def research_handler(
259
  if spec["function"]["name"] in RESEARCH_TOOL_NAMES
260
  ]
261
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  async def _log(text: str) -> None:
263
  """Send a progress event to the UI so it doesn't look frozen."""
264
  try:
265
  await session.send_event(
266
- Event(event_type="tool_log", data={"tool": "research", "log": text})
 
 
 
 
 
267
  )
268
  except Exception:
269
  pass
 
222
 
223
 
224
  async def research_handler(
225
+ arguments: dict[str, Any], session=None, tool_call_id: str | None = None, **_kw
226
  ) -> tuple[str, bool]:
227
  """Execute a research sub-agent with its own context."""
228
  task = arguments.get("task", "")
 
259
  if spec["function"]["name"] in RESEARCH_TOOL_NAMES
260
  ]
261
 
262
+ # Unique ID + short label so parallel agents show separate status lines.
263
+ # Use the tool_call_id when available — it's unique per invocation and lets
264
+ # the frontend match a research tool card to its agent state. Fall back to
265
+ # uuid for offline/test paths. Previously used md5(task), which collided
266
+ # when the same task string was researched in parallel.
267
+ if tool_call_id:
268
+ _agent_id = tool_call_id
269
+ else:
270
+ import uuid
271
+ _agent_id = uuid.uuid4().hex[:8]
272
+ _agent_label = "research: " + (task[:50] + "…" if len(task) > 50 else task)
273
+
274
  async def _log(text: str) -> None:
275
  """Send a progress event to the UI so it doesn't look frozen."""
276
  try:
277
  await session.send_event(
278
+ Event(event_type="tool_log", data={
279
+ "tool": "research",
280
+ "log": text,
281
+ "agent_id": _agent_id,
282
+ "label": _agent_label,
283
+ })
284
  )
285
  except Exception:
286
  pass
agent/utils/terminal_display.py CHANGED
@@ -2,6 +2,8 @@
2
  Terminal display utilities — rich-powered CLI formatting.
3
  """
4
 
 
 
5
  from rich.console import Console
6
  from rich.markdown import Heading, Markdown
7
  from rich.panel import Panel
@@ -19,6 +21,42 @@ class _LeftHeading(Heading):
19
 
20
  Markdown.elements["heading_open"] = _LeftHeading
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  _THEME = Theme({
23
  "tool.name": "bold rgb(255,200,80)",
24
  "tool.args": "dim",
@@ -129,74 +167,102 @@ def print_tool_output(output: str, success: bool, truncate: bool = True) -> None
129
  _console.print(f"[{style}]{indented}[/{style}]")
130
 
131
 
132
- class SubAgentDisplay:
133
- """Live-updating display: header with stats (ticks every second) + rolling 2-line tool calls."""
 
 
 
 
 
134
 
135
- _MAX_VISIBLE = 2
136
 
137
  def __init__(self):
138
- self._calls: list[str] = []
139
- self._tool_count = 0
140
- self._token_count = 0
141
- self._start_time: float | None = None
142
  self._lines_on_screen = 0
143
  self._ticker_task = None
144
 
145
- def start(self) -> None:
146
- """Begin the display with a 1-second ticker."""
147
  import asyncio
148
  import time
149
- self._calls = []
150
- self._tool_count = 0
151
- self._token_count = 0
152
- self._start_time = time.monotonic()
 
 
 
 
 
153
  self._redraw()
154
- self._ticker_task = asyncio.ensure_future(self._tick())
155
 
156
- def set_tokens(self, tokens: int) -> None:
157
- self._token_count = tokens
158
- # no redraw — ticker handles it
159
-
160
- def set_tool_count(self, count: int) -> None:
161
- self._tool_count = count
162
- # no redraw — ticker handles it
163
-
164
- def add_call(self, tool_desc: str) -> None:
165
- self._calls.append(tool_desc)
166
- self._redraw()
167
-
168
- def clear(self) -> None:
169
- if self._ticker_task:
170
- self._ticker_task.cancel()
171
- self._ticker_task = None
 
 
 
172
  self._erase()
 
 
 
 
 
173
  self._lines_on_screen = 0
174
- self._calls = []
175
- self._start_time = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
  async def _tick(self) -> None:
178
  import asyncio
179
  try:
180
  while True:
181
  await asyncio.sleep(1.0)
182
- self._redraw()
 
183
  except asyncio.CancelledError:
184
  pass
185
 
186
- def _format_stats(self) -> str:
 
187
  import time
188
- if self._start_time is None:
 
189
  return ""
190
- elapsed = time.monotonic() - self._start_time
191
  if elapsed < 60:
192
  time_str = f"{elapsed:.0f}s"
193
  else:
194
  time_str = f"{elapsed / 60:.0f}m {elapsed % 60:.0f}s"
195
- if self._token_count >= 1000:
196
- tok_str = f"{self._token_count / 1000:.1f}k"
197
- else:
198
- tok_str = str(self._token_count)
199
- return f"{self._tool_count} tool uses · {tok_str} tokens · {time_str}"
200
 
201
  def _erase(self) -> None:
202
  if self._lines_on_screen > 0:
@@ -205,42 +271,66 @@ class SubAgentDisplay:
205
  f.write("\033[A\033[K")
206
  f.flush()
207
 
208
- def _redraw(self) -> None:
209
- f = _console.file
210
- self._erase()
211
- lines = []
212
- # Header: research (stats)
213
- stats = self._format_stats()
214
- header = f"{_I}\033[38;2;255;200;80m▸ research\033[0m"
 
 
 
 
 
 
215
  if stats:
216
  header += f" \033[2m({stats})\033[0m"
217
- lines.append(header)
218
- # Last 2 tool calls, gray
219
- visible = self._calls[-self._MAX_VISIBLE:]
 
 
 
 
 
 
220
  for desc in visible:
221
  lines.append(f"{_I} \033[2m{desc}\033[0m")
 
 
 
 
 
 
 
 
 
 
 
222
  for line in lines:
223
  f.write(line + "\n")
224
  f.flush()
225
  self._lines_on_screen = len(lines)
226
 
227
 
228
- _subagent_display = SubAgentDisplay()
229
 
230
 
231
- def print_tool_log(tool: str, log: str) -> None:
232
  """Handle tool log events — sub-agent calls get the rolling display."""
233
  if tool == "research":
 
234
  if log == "Starting research sub-agent...":
235
- _subagent_display.start()
236
  elif log == "Research complete.":
237
- _subagent_display.clear()
238
  elif log.startswith("tokens:"):
239
- _subagent_display.set_tokens(int(log[7:]))
240
  elif log.startswith("tools:"):
241
- _subagent_display.set_tool_count(int(log[6:]))
242
  else:
243
- _subagent_display.add_call(log)
244
  else:
245
  _console.print(f"{_I}[dim]{tool}: {log}[/dim]")
246
 
 
2
  Terminal display utilities — rich-powered CLI formatting.
3
  """
4
 
5
+ import re
6
+
7
  from rich.console import Console
8
  from rich.markdown import Heading, Markdown
9
  from rich.panel import Panel
 
21
 
22
  Markdown.elements["heading_open"] = _LeftHeading
23
 
24
+
25
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
26
+
27
+
28
+ def _clip_to_width(s: str, width: int) -> str:
29
+ """Truncate a string to `width` visible columns, preserving ANSI styles.
30
+
31
+ Needed for the sub-agent live redraw: cursor-up-and-erase assumes one
32
+ logical line == one terminal row. If a line wraps, cursor-up undershoots
33
+ and the next redraw corrupts the display. Truncating prevents wrap.
34
+ """
35
+ if width <= 0:
36
+ return s
37
+ out: list[str] = []
38
+ visible = 0
39
+ i = 0
40
+ # Reserve 1 char for the trailing ellipsis
41
+ limit = width - 1
42
+ truncated = False
43
+ while i < len(s):
44
+ m = _ANSI_RE.match(s, i)
45
+ if m:
46
+ out.append(m.group())
47
+ i = m.end()
48
+ continue
49
+ if visible >= limit:
50
+ truncated = True
51
+ break
52
+ out.append(s[i])
53
+ visible += 1
54
+ i += 1
55
+ if truncated:
56
+ # Strip styles (so ellipsis isn't left hanging inside a style run)
57
+ out.append("\033[0m…")
58
+ return "".join(out)
59
+
60
  _THEME = Theme({
61
  "tool.name": "bold rgb(255,200,80)",
62
  "tool.args": "dim",
 
167
  _console.print(f"[{style}]{indented}[/{style}]")
168
 
169
 
170
+ class SubAgentDisplayManager:
171
+ """Manages multiple concurrent sub-agent displays.
172
+
173
+ Each agent gets its own stats and rolling tool-call log.
174
+ All agents are rendered together so terminal escape-code
175
+ erase/redraw stays consistent.
176
+ """
177
 
178
+ _MAX_VISIBLE = 4 # tool-call lines shown per agent
179
 
180
  def __init__(self):
181
+ self._agents: dict[str, dict] = {} # agent_id -> state dict
 
 
 
182
  self._lines_on_screen = 0
183
  self._ticker_task = None
184
 
185
+ def start(self, agent_id: str, label: str = "research") -> None:
 
186
  import asyncio
187
  import time
188
+ self._agents[agent_id] = {
189
+ "label": label,
190
+ "calls": [],
191
+ "tool_count": 0,
192
+ "token_count": 0,
193
+ "start_time": time.monotonic(),
194
+ }
195
+ if not self._ticker_task:
196
+ self._ticker_task = asyncio.ensure_future(self._tick())
197
  self._redraw()
 
198
 
199
+ def set_tokens(self, agent_id: str, tokens: int) -> None:
200
+ if agent_id in self._agents:
201
+ self._agents[agent_id]["token_count"] = tokens
202
+
203
+ def set_tool_count(self, agent_id: str, count: int) -> None:
204
+ if agent_id in self._agents:
205
+ self._agents[agent_id]["tool_count"] = count
206
+
207
+ def add_call(self, agent_id: str, tool_desc: str) -> None:
208
+ if agent_id in self._agents:
209
+ self._agents[agent_id]["calls"].append(tool_desc)
210
+ self._redraw()
211
+
212
+ def clear(self, agent_id: str) -> None:
213
+ # On completion: erase the live region, freeze a single-line summary
214
+ # for this agent ("✓ research: … (stats)") above the live region so
215
+ # the user sees each sub-agent finish cleanly without the tool-call
216
+ # noise, then redraw remaining live agents.
217
+ agent = self._agents.pop(agent_id, None)
218
  self._erase()
219
+ if agent is not None:
220
+ width = max(10, _console.width)
221
+ line = _clip_to_width(self._render_completion_line(agent), width)
222
+ _console.file.write(line + "\n")
223
+ _console.file.flush()
224
  self._lines_on_screen = 0
225
+ if not self._agents:
226
+ if self._ticker_task:
227
+ self._ticker_task.cancel()
228
+ self._ticker_task = None
229
+ else:
230
+ self._redraw()
231
+
232
+ @staticmethod
233
+ def _render_completion_line(agent: dict) -> str:
234
+ stats = SubAgentDisplayManager._format_stats(agent)
235
+ label = agent["label"]
236
+ # dim green check + dim label; stats in parens
237
+ line = f"{_I}\033[38;2;120;200;140m✓\033[0m \033[2m{label}\033[0m"
238
+ if stats:
239
+ line += f" \033[2m({stats})\033[0m"
240
+ return line
241
 
242
  async def _tick(self) -> None:
243
  import asyncio
244
  try:
245
  while True:
246
  await asyncio.sleep(1.0)
247
+ if self._agents:
248
+ self._redraw()
249
  except asyncio.CancelledError:
250
  pass
251
 
252
+ @staticmethod
253
+ def _format_stats(agent: dict) -> str:
254
  import time
255
+ start = agent["start_time"]
256
+ if start is None:
257
  return ""
258
+ elapsed = time.monotonic() - start
259
  if elapsed < 60:
260
  time_str = f"{elapsed:.0f}s"
261
  else:
262
  time_str = f"{elapsed / 60:.0f}m {elapsed % 60:.0f}s"
263
+ tok = agent["token_count"]
264
+ tok_str = f"{tok / 1000:.1f}k" if tok >= 1000 else str(tok)
265
+ return f"{agent['tool_count']} tool uses · {tok_str} tokens · {time_str}"
 
 
266
 
267
  def _erase(self) -> None:
268
  if self._lines_on_screen > 0:
 
271
  f.write("\033[A\033[K")
272
  f.flush()
273
 
274
+ def _render_agent_lines(self, agent: dict, compact: bool = False) -> list[str]:
275
+ """Render one agent's block.
276
+
277
+ compact=True → single line (label + stats + most-recent tool name);
278
+ compact=False header + up to _MAX_VISIBLE rolling tool-call lines.
279
+ We use compact mode when multiple agents are live so the total live
280
+ region stays small enough to fit on one screen. Otherwise cursor-up
281
+ can't reach lines that have scrolled into scrollback, and every
282
+ redraw pollutes history with a stale copy.
283
+ """
284
+ stats = self._format_stats(agent)
285
+ label = agent["label"]
286
+ header = f"{_I}\033[38;2;255;200;80m▸ {label}\033[0m"
287
  if stats:
288
  header += f" \033[2m({stats})\033[0m"
289
+ if compact:
290
+ latest = agent["calls"][-1] if agent["calls"] else ""
291
+ if latest:
292
+ # Strip long json tails for the inline view
293
+ short = latest.split(" ")[0] if " " in latest else latest
294
+ header += f" \033[2m·\033[0m \033[2m{short}\033[0m"
295
+ return [header]
296
+ lines = [header]
297
+ visible = agent["calls"][-self._MAX_VISIBLE:]
298
  for desc in visible:
299
  lines.append(f"{_I} \033[2m{desc}\033[0m")
300
+ return lines
301
+
302
+ def _redraw(self) -> None:
303
+ f = _console.file
304
+ self._erase()
305
+ compact = len(self._agents) > 1
306
+ width = max(10, _console.width)
307
+ lines: list[str] = []
308
+ for agent in self._agents.values():
309
+ for ln in self._render_agent_lines(agent, compact=compact):
310
+ lines.append(_clip_to_width(ln, width))
311
  for line in lines:
312
  f.write(line + "\n")
313
  f.flush()
314
  self._lines_on_screen = len(lines)
315
 
316
 
317
+ _subagent_display = SubAgentDisplayManager()
318
 
319
 
320
+ def print_tool_log(tool: str, log: str, agent_id: str = "", label: str = "") -> None:
321
  """Handle tool log events — sub-agent calls get the rolling display."""
322
  if tool == "research":
323
+ aid = agent_id or "research"
324
  if log == "Starting research sub-agent...":
325
+ _subagent_display.start(aid, label or "research")
326
  elif log == "Research complete.":
327
+ _subagent_display.clear(aid)
328
  elif log.startswith("tokens:"):
329
+ _subagent_display.set_tokens(aid, int(log[7:]))
330
  elif log.startswith("tools:"):
331
+ _subagent_display.set_tool_count(aid, int(log[6:]))
332
  else:
333
+ _subagent_display.add_call(aid, log)
334
  else:
335
  _console.print(f"{_I}[dim]{tool}: {log}[/dim]")
336
 
frontend/src/components/Chat/ToolCallGroup.tsx CHANGED
@@ -7,7 +7,7 @@ import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
7
  import LaunchIcon from '@mui/icons-material/Launch';
8
  import SendIcon from '@mui/icons-material/Send';
9
  import BlockIcon from '@mui/icons-material/Block';
10
- import { useAgentStore } from '@/store/agentStore';
11
  import { useLayoutStore } from '@/store/layoutStore';
12
  import { logger } from '@/utils/logger';
13
  import { RESEARCH_MAX_STEPS } from '@/lib/research-store';
@@ -36,16 +36,22 @@ interface ToolCallGroupProps {
36
  // Research sub-steps (inline under the research tool row)
37
  // ---------------------------------------------------------------------------
38
 
39
- /** Hook that ticks every second while startedAt is set, returning elapsed seconds. */
40
- function useElapsed(startedAt: number | null): number | null {
41
- const [elapsed, setElapsed] = useState<number | null>(null);
 
 
42
  useEffect(() => {
43
- if (startedAt === null) { setElapsed(null); return; }
44
- setElapsed(Math.round((Date.now() - startedAt) / 1000));
45
- const id = setInterval(() => setElapsed(Math.round((Date.now() - startedAt) / 1000)), 1000);
46
  return () => clearInterval(id);
47
- }, [startedAt]);
48
- return elapsed;
 
 
 
 
 
49
  }
50
 
51
  /** Format token count like the CLI: "12.4k" or "800". */
@@ -172,9 +178,8 @@ function formatResearchStep(raw: string): { label: string } {
172
  return { label: step.replace(/^▸\s*/, '') };
173
  }
174
 
175
- /** Rolling 2-line display of research sub-tool calls hidden when complete. */
176
- function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boolean }) {
177
- if (!isRunning) return null;
178
  const visible = steps.slice(-RESEARCH_MAX_STEPS);
179
  if (visible.length === 0) return null;
180
 
@@ -215,9 +220,6 @@ function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boole
215
  );
216
  }
217
 
218
- // Stable reference to avoid infinite re-renders from Zustand selectors
219
- const EMPTY_STEPS: string[] = [];
220
-
221
  // ---------------------------------------------------------------------------
222
  // Hardware pricing ($/hr) — from HF Spaces & Jobs pricing
223
  // ---------------------------------------------------------------------------
@@ -512,17 +514,22 @@ function InlineApproval({
512
  // Main component
513
  // ---------------------------------------------------------------------------
514
 
 
 
515
  export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
516
  const { setPanel, lockPanel, getJobUrl, getEditedScript, setJobStatus, getJobStatus, setToolError, getToolError, setToolRejected, getToolRejected } = useAgentStore();
517
- const researchSteps = useAgentStore(s => {
518
  const activeId = s.activeSessionId;
519
- return activeId ? (s.sessionStates[activeId]?.researchSteps) : undefined;
520
- }) ?? EMPTY_STEPS;
521
- const researchStats = useAgentStore(s => {
522
- const activeId = s.activeSessionId;
523
- return activeId ? s.sessionStates[activeId]?.researchStats : undefined;
524
- }) ?? { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null };
525
- const liveElapsed = useElapsed(researchStats.startedAt);
 
 
 
526
  const isProcessing = useAgentStore(s => s.isProcessing);
527
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
528
 
@@ -964,13 +971,17 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
964
 
965
  {/* Status chip (non hf_jobs, or hf_jobs without final status) */}
966
  {(() => {
967
- // Research tool: override chip label with live stats (but not if cancelled/done)
 
 
 
968
  const researchDone = cancelled || state === 'output-available' || state === 'output-error' || state === 'output-denied';
969
- const researchLabel = tool.toolName === 'research' && !researchDone
970
- ? researchChipLabel(researchStats, liveElapsed)
971
- : (tool.toolName === 'research' && researchDone && researchStats.finalElapsed !== null)
972
- ? researchChipLabel({ ...researchStats, startedAt: null }, null)
973
- : null;
 
974
  const chipLabel = researchLabel || label;
975
  if (!chipLabel || (tool.toolName === 'hf_jobs' && jobMeta.jobStatus)) return null;
976
 
@@ -1048,11 +1059,8 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
1048
  </Stack>
1049
 
1050
  {/* Research sub-agent rolling steps (visible only while running) */}
1051
- {tool.toolName === 'research' && !cancelled && state !== 'output-available' && state !== 'output-error' && state !== 'output-denied' && (
1052
- <ResearchSteps
1053
- steps={researchSteps}
1054
- isRunning={researchStats.startedAt !== null}
1055
- />
1056
  )}
1057
 
1058
  {/* Per-tool approval: undecided */}
 
7
  import LaunchIcon from '@mui/icons-material/Launch';
8
  import SendIcon from '@mui/icons-material/Send';
9
  import BlockIcon from '@mui/icons-material/Block';
10
+ import { useAgentStore, type ResearchAgentState } from '@/store/agentStore';
11
  import { useLayoutStore } from '@/store/layoutStore';
12
  import { logger } from '@/utils/logger';
13
  import { RESEARCH_MAX_STEPS } from '@/lib/research-store';
 
36
  // Research sub-steps (inline under the research tool row)
37
  // ---------------------------------------------------------------------------
38
 
39
+ /** Hook that forces a re-render every second while enabled used so each
40
+ * research card can compute its own elapsed seconds synchronously from
41
+ * Date.now() without needing its own timer. */
42
+ function useSecondTick(enabled: boolean): void {
43
+ const [, setTick] = useState(0);
44
  useEffect(() => {
45
+ if (!enabled) return;
46
+ const id = setInterval(() => setTick(t => t + 1), 1000);
 
47
  return () => clearInterval(id);
48
+ }, [enabled]);
49
+ }
50
+
51
+ /** Compute elapsed seconds from startedAt (or null). Call under useSecondTick. */
52
+ function computeElapsed(startedAt: number | null): number | null {
53
+ if (startedAt === null) return null;
54
+ return Math.round((Date.now() - startedAt) / 1000);
55
  }
56
 
57
  /** Format token count like the CLI: "12.4k" or "800". */
 
178
  return { label: step.replace(/^▸\s*/, '') };
179
  }
180
 
181
+ /** Rolling display of research sub-tool calls for a single agent. */
182
+ function ResearchSteps({ steps }: { steps: string[] }) {
 
183
  const visible = steps.slice(-RESEARCH_MAX_STEPS);
184
  if (visible.length === 0) return null;
185
 
 
220
  );
221
  }
222
 
 
 
 
223
  // ---------------------------------------------------------------------------
224
  // Hardware pricing ($/hr) — from HF Spaces & Jobs pricing
225
  // ---------------------------------------------------------------------------
 
514
  // Main component
515
  // ---------------------------------------------------------------------------
516
 
517
+ const EMPTY_AGENTS: Record<string, ResearchAgentState> = {};
518
+
519
  export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
520
  const { setPanel, lockPanel, getJobUrl, getEditedScript, setJobStatus, getJobStatus, setToolError, getToolError, setToolRejected, getToolRejected } = useAgentStore();
521
+ const researchAgents = useAgentStore(s => {
522
  const activeId = s.activeSessionId;
523
+ return (activeId && s.sessionStates[activeId]?.researchAgents) || EMPTY_AGENTS;
524
+ });
525
+ // Tick once per second while any research agent is running so each card's
526
+ // elapsed seconds update in real time.
527
+ const anyResearchRunning = useMemo(
528
+ () => Object.values(researchAgents).some(a => a.stats.startedAt !== null),
529
+ [researchAgents],
530
+ );
531
+ useSecondTick(anyResearchRunning);
532
+
533
  const isProcessing = useAgentStore(s => s.isProcessing);
534
  const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
535
 
 
971
 
972
  {/* Status chip (non hf_jobs, or hf_jobs without final status) */}
973
  {(() => {
974
+ // Research tool: override chip label with this card's agent stats
975
+ const agentState: ResearchAgentState | undefined = tool.toolName === 'research'
976
+ ? researchAgents[tool.toolCallId]
977
+ : undefined;
978
  const researchDone = cancelled || state === 'output-available' || state === 'output-error' || state === 'output-denied';
979
+ const liveElapsed = agentState ? computeElapsed(agentState.stats.startedAt) : null;
980
+ const researchLabel = tool.toolName === 'research' && agentState
981
+ ? (researchDone && agentState.stats.finalElapsed !== null
982
+ ? researchChipLabel({ ...agentState.stats, startedAt: null }, null)
983
+ : researchChipLabel(agentState.stats, liveElapsed))
984
+ : null;
985
  const chipLabel = researchLabel || label;
986
  if (!chipLabel || (tool.toolName === 'hf_jobs' && jobMeta.jobStatus)) return null;
987
 
 
1059
  </Stack>
1060
 
1061
  {/* Research sub-agent rolling steps (visible only while running) */}
1062
+ {tool.toolName === 'research' && !cancelled && state !== 'output-available' && state !== 'output-error' && state !== 'output-denied' && researchAgents[tool.toolCallId] && (
1063
+ <ResearchSteps steps={researchAgents[tool.toolCallId].steps} />
 
 
 
1064
  )}
1065
 
1066
  {/* Per-tool approval: undecided */}
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -86,46 +86,63 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
86
  useLayoutStore.getState().setRightPanelOpen(true);
87
  }
88
  },
89
- onToolLog: (tool: string, log: string) => {
90
- // Research sub-agent: parse stats vs step logs
91
  if (tool === 'research') {
 
92
  const sessState = useAgentStore.getState().getSessionState(sessionId);
93
- const stats = { ...sessState.researchStats };
 
94
 
95
  if (log === 'Starting research sub-agent...') {
96
- const newStats = { toolCount: 0, tokenCount: 0, startedAt: Date.now(), finalElapsed: null };
 
 
 
 
 
 
 
97
  updateSession(sessionId, {
98
- researchSteps: [],
99
- researchStats: newStats,
100
- activityStatus: { type: 'tool', toolName: 'research', description: log },
 
101
  });
102
- saveResearch(sessionId, [], newStats);
103
  } else if (log.startsWith('tokens:')) {
104
- stats.tokenCount = parseInt(log.slice(7), 10);
105
- updateSession(sessionId, { researchStats: stats });
106
- saveResearch(sessionId, sessState.researchSteps, stats);
107
  } else if (log.startsWith('tools:')) {
108
- stats.toolCount = parseInt(log.slice(6), 10);
109
- updateSession(sessionId, { researchStats: stats });
110
- saveResearch(sessionId, sessState.researchSteps, stats);
111
  } else if (log === 'Research complete.') {
112
- const elapsed = stats.startedAt
113
- ? Math.round((Date.now() - stats.startedAt) / 1000)
114
  : null;
115
- const doneStats = { ...stats, startedAt: null, finalElapsed: elapsed };
 
 
116
  updateSession(sessionId, {
117
- researchStats: doneStats,
 
118
  activityStatus: { type: 'tool', toolName: 'research', description: log },
119
  });
120
- clearResearch(sessionId);
 
121
  } else {
122
- // Regular tool call step — append (trim to max)
123
- const steps = [...sessState.researchSteps, log].slice(-RESEARCH_MAX_STEPS);
 
 
124
  updateSession(sessionId, {
125
- researchSteps: steps,
 
126
  activityStatus: { type: 'tool', toolName: 'research', description: log },
127
  });
128
- saveResearch(sessionId, steps, stats);
129
  }
130
  return;
131
  }
 
86
  useLayoutStore.getState().setRightPanelOpen(true);
87
  }
88
  },
89
+ onToolLog: (tool: string, log: string, agentId?: string, label?: string) => {
90
+ // Research sub-agent: parse stats vs step logs (per-agent)
91
  if (tool === 'research') {
92
+ const aid = agentId || 'research';
93
  const sessState = useAgentStore.getState().getSessionState(sessionId);
94
+ const agents = { ...sessState.researchAgents };
95
+ const agent = agents[aid] || { label: label || 'research', steps: [], stats: { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null } };
96
 
97
  if (log === 'Starting research sub-agent...') {
98
+ agents[aid] = {
99
+ label: label || 'research',
100
+ steps: [],
101
+ stats: { toolCount: 0, tokenCount: 0, startedAt: Date.now(), finalElapsed: null },
102
+ };
103
+ // Also update legacy flat fields (aggregate of all agents)
104
+ const allSteps = Object.values(agents).flatMap(a => a.steps);
105
+ const anyRunning = Object.values(agents).some(a => a.stats.startedAt !== null);
106
  updateSession(sessionId, {
107
+ researchAgents: agents,
108
+ researchSteps: allSteps.slice(-RESEARCH_MAX_STEPS),
109
+ researchStats: anyRunning ? agents[aid].stats : sessState.researchStats,
110
+ activityStatus: { type: 'tool', toolName: 'research', description: label || log },
111
  });
112
+ saveResearch(sessionId, allSteps.slice(-RESEARCH_MAX_STEPS), agents[aid].stats);
113
  } else if (log.startsWith('tokens:')) {
114
+ agent.stats = { ...agent.stats, tokenCount: parseInt(log.slice(7), 10) };
115
+ agents[aid] = agent;
116
+ updateSession(sessionId, { researchAgents: agents });
117
  } else if (log.startsWith('tools:')) {
118
+ agent.stats = { ...agent.stats, toolCount: parseInt(log.slice(6), 10) };
119
+ agents[aid] = agent;
120
+ updateSession(sessionId, { researchAgents: agents });
121
  } else if (log === 'Research complete.') {
122
+ const elapsed = agent.stats.startedAt
123
+ ? Math.round((Date.now() - agent.stats.startedAt) / 1000)
124
  : null;
125
+ agent.stats = { ...agent.stats, startedAt: null, finalElapsed: elapsed };
126
+ agents[aid] = agent;
127
+ const anyRunning = Object.values(agents).some(a => a.stats.startedAt !== null);
128
  updateSession(sessionId, {
129
+ researchAgents: agents,
130
+ researchStats: anyRunning ? sessState.researchStats : agent.stats,
131
  activityStatus: { type: 'tool', toolName: 'research', description: log },
132
  });
133
+ // Clear persistence only when ALL agents are done
134
+ if (!anyRunning) clearResearch(sessionId);
135
  } else {
136
+ // Regular tool call step — append to this agent
137
+ agent.steps = [...agent.steps, log].slice(-RESEARCH_MAX_STEPS);
138
+ agents[aid] = agent;
139
+ const allSteps = Object.values(agents).flatMap(a => a.steps);
140
  updateSession(sessionId, {
141
+ researchAgents: agents,
142
+ researchSteps: allSteps.slice(-RESEARCH_MAX_STEPS),
143
  activityStatus: { type: 'tool', toolName: 'research', description: log },
144
  });
145
+ saveResearch(sessionId, allSteps.slice(-RESEARCH_MAX_STEPS), agent.stats);
146
  }
147
  return;
148
  }
frontend/src/lib/sse-chat-transport.ts CHANGED
@@ -23,7 +23,7 @@ export interface SideChannelCallbacks {
23
  onUndoComplete: () => void;
24
  onCompacted: (oldTokens: number, newTokens: number) => void;
25
  onPlanUpdate: (plan: Array<{ id: string; content: string; status: string }>) => void;
26
- onToolLog: (tool: string, log: string) => void;
27
  onConnectionChange: (connected: boolean) => void;
28
  onSessionDead: (sessionId: string) => void;
29
  onApprovalRequired: (tools: Array<{ tool: string; arguments: Record<string, unknown>; tool_call_id: string }>) => void;
@@ -131,6 +131,8 @@ function createEventToChunkStream(sideChannel: SideChannelCallbacks): TransformS
131
  sideChannel.onToolLog(
132
  (event.data?.tool as string) || '',
133
  (event.data?.log as string) || '',
 
 
134
  );
135
  break;
136
 
 
23
  onUndoComplete: () => void;
24
  onCompacted: (oldTokens: number, newTokens: number) => void;
25
  onPlanUpdate: (plan: Array<{ id: string; content: string; status: string }>) => void;
26
+ onToolLog: (tool: string, log: string, agentId?: string, label?: string) => void;
27
  onConnectionChange: (connected: boolean) => void;
28
  onSessionDead: (sessionId: string) => void;
29
  onApprovalRequired: (tools: Array<{ tool: string; arguments: Record<string, unknown>; tool_call_id: string }>) => void;
 
131
  sideChannel.onToolLog(
132
  (event.data?.tool as string) || '',
133
  (event.data?.log as string) || '',
134
+ (event.data?.agent_id as string) || '',
135
+ (event.data?.label as string) || '',
136
  );
137
  break;
138
 
frontend/src/store/agentStore.ts CHANGED
@@ -53,6 +53,19 @@ export type ActivityStatus =
53
  | { type: 'streaming' }
54
  | { type: 'cancelled' };
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  /** State that is tracked per-session (each session has its own copy). */
57
  export interface PerSessionState {
58
  isProcessing: boolean;
@@ -61,12 +74,16 @@ export interface PerSessionState {
61
  panelView: PanelView;
62
  panelEditable: boolean;
63
  plan: PlanItem[];
64
- /** Steps completed by the research sub-agent (tool_log events). */
 
 
65
  researchSteps: string[];
66
- /** Live stats from the research sub-agent. */
67
- researchStats: { toolCount: number; tokenCount: number; startedAt: number | null; finalElapsed: number | null };
68
  }
69
 
 
 
70
  const defaultSessionState: PerSessionState = {
71
  isProcessing: false,
72
  activityStatus: { type: 'idle' },
@@ -74,8 +91,9 @@ const defaultSessionState: PerSessionState = {
74
  panelView: 'script',
75
  panelEditable: false,
76
  plan: [],
 
77
  researchSteps: [],
78
- researchStats: { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null },
79
  };
80
 
81
  interface AgentStore {
@@ -299,8 +317,9 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
299
  panelView: state.panelView,
300
  panelEditable: state.panelEditable,
301
  plan: state.plan,
 
302
  researchSteps: state.sessionStates[state.activeSessionId]?.researchSteps ?? [],
303
- researchStats: state.sessionStates[state.activeSessionId]?.researchStats ?? defaultSessionState.researchStats,
304
  };
305
  }
306
 
 
53
  | { type: 'streaming' }
54
  | { type: 'cancelled' };
55
 
56
+ export interface ResearchAgentStats {
57
+ toolCount: number;
58
+ tokenCount: number;
59
+ startedAt: number | null;
60
+ finalElapsed: number | null;
61
+ }
62
+
63
+ export interface ResearchAgentState {
64
+ label: string;
65
+ steps: string[];
66
+ stats: ResearchAgentStats;
67
+ }
68
+
69
  /** State that is tracked per-session (each session has its own copy). */
70
  export interface PerSessionState {
71
  isProcessing: boolean;
 
74
  panelView: PanelView;
75
  panelEditable: boolean;
76
  plan: PlanItem[];
77
+ /** Per-agent research state, keyed by agent_id. */
78
+ researchAgents: Record<string, ResearchAgentState>;
79
+ /** @deprecated kept for backward compat selectors — use researchAgents instead */
80
  researchSteps: string[];
81
+ /** @deprecated kept for backward compat selectors — use researchAgents instead */
82
+ researchStats: ResearchAgentStats;
83
  }
84
 
85
+ const defaultResearchStats: ResearchAgentStats = { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null };
86
+
87
  const defaultSessionState: PerSessionState = {
88
  isProcessing: false,
89
  activityStatus: { type: 'idle' },
 
91
  panelView: 'script',
92
  panelEditable: false,
93
  plan: [],
94
+ researchAgents: {},
95
  researchSteps: [],
96
+ researchStats: { ...defaultResearchStats },
97
  };
98
 
99
  interface AgentStore {
 
317
  panelView: state.panelView,
318
  panelEditable: state.panelEditable,
319
  plan: state.plan,
320
+ researchAgents: state.sessionStates[state.activeSessionId]?.researchAgents ?? {},
321
  researchSteps: state.sessionStates[state.activeSessionId]?.researchSteps ?? [],
322
+ researchStats: state.sessionStates[state.activeSessionId]?.researchStats ?? { ...defaultResearchStats },
323
  };
324
  }
325