Spaces:
Running on CPU Upgrade
Fix parallel-research display: per-call stats in CLI and web UI (#49)
Browse filesWhen 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 +1 -1
- agent/main.py +30 -20
- agent/tools/research_tool.py +19 -2
- agent/utils/terminal_display.py +148 -58
- frontend/src/components/Chat/ToolCallGroup.tsx +42 -34
- frontend/src/hooks/useAgentChat.ts +40 -23
- frontend/src/lib/sse-chat-transport.ts +3 -1
- frontend/src/store/agentStore.ts +24 -5
|
@@ -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 |
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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
|
| 1208 |
-
# finishes, instead of streaming via
|
| 1209 |
-
|
| 1210 |
-
|
| 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 |
-
#
|
| 1247 |
-
#
|
| 1248 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1249 |
if log == "Starting research sub-agent...":
|
| 1250 |
-
|
| 1251 |
-
|
|
|
|
|
|
|
| 1252 |
elif log == "Research complete.":
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
elif log.startswith("tokens:") or log.startswith("tools:"):
|
| 1261 |
pass # stats updates — only useful for the live display
|
| 1262 |
-
elif
|
| 1263 |
-
|
| 1264 |
else:
|
| 1265 |
-
|
|
|
|
| 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":
|
|
@@ -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={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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
|
| 133 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
_MAX_VISIBLE =
|
| 136 |
|
| 137 |
def __init__(self):
|
| 138 |
-
self.
|
| 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.
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
self._redraw()
|
| 154 |
-
self._ticker_task = asyncio.ensure_future(self._tick())
|
| 155 |
|
| 156 |
-
def set_tokens(self, tokens: int) -> None:
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def set_tool_count(self, count: int) -> None:
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
def add_call(self, tool_desc: str) -> None:
|
| 165 |
-
self.
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
| 172 |
self._erase()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
self._lines_on_screen = 0
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
async def _tick(self) -> None:
|
| 178 |
import asyncio
|
| 179 |
try:
|
| 180 |
while True:
|
| 181 |
await asyncio.sleep(1.0)
|
| 182 |
-
self.
|
|
|
|
| 183 |
except asyncio.CancelledError:
|
| 184 |
pass
|
| 185 |
|
| 186 |
-
|
|
|
|
| 187 |
import time
|
| 188 |
-
|
|
|
|
| 189 |
return ""
|
| 190 |
-
elapsed = time.monotonic() -
|
| 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 |
-
|
| 196 |
-
|
| 197 |
-
|
| 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
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
if stats:
|
| 216 |
header += f" \033[2m({stats})\033[0m"
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
|
|
@@ -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
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
| 42 |
useEffect(() => {
|
| 43 |
-
if (
|
| 44 |
-
|
| 45 |
-
const id = setInterval(() => setElapsed(Math.round((Date.now() - startedAt) / 1000)), 1000);
|
| 46 |
return () => clearInterval(id);
|
| 47 |
-
}, [
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 176 |
-
function ResearchSteps({ steps
|
| 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
|
| 518 |
const activeId = s.activeSessionId;
|
| 519 |
-
return activeId
|
| 520 |
-
})
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 968 |
const researchDone = cancelled || state === 'output-available' || state === 'output-error' || state === 'output-denied';
|
| 969 |
-
const
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
|
|
|
| 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 */}
|
|
@@ -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
|
|
|
|
| 94 |
|
| 95 |
if (log === 'Starting research sub-agent...') {
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
updateSession(sessionId, {
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
| 101 |
});
|
| 102 |
-
saveResearch(sessionId,
|
| 103 |
} else if (log.startsWith('tokens:')) {
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
} else if (log.startsWith('tools:')) {
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
} else if (log === 'Research complete.') {
|
| 112 |
-
const elapsed = stats.startedAt
|
| 113 |
-
? Math.round((Date.now() - stats.startedAt) / 1000)
|
| 114 |
: null;
|
| 115 |
-
|
|
|
|
|
|
|
| 116 |
updateSession(sessionId, {
|
| 117 |
-
|
|
|
|
| 118 |
activityStatus: { type: 'tool', toolName: 'research', description: log },
|
| 119 |
});
|
| 120 |
-
|
|
|
|
| 121 |
} else {
|
| 122 |
-
// Regular tool call step — append
|
| 123 |
-
|
|
|
|
|
|
|
| 124 |
updateSession(sessionId, {
|
| 125 |
-
|
|
|
|
| 126 |
activityStatus: { type: 'tool', toolName: 'research', description: log },
|
| 127 |
});
|
| 128 |
-
saveResearch(sessionId,
|
| 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 |
}
|
|
@@ -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 |
|
|
@@ -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 |
-
/**
|
|
|
|
|
|
|
| 65 |
researchSteps: string[];
|
| 66 |
-
/**
|
| 67 |
-
researchStats:
|
| 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: {
|
| 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 ??
|
| 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 |
|