From 8390ef8731112547f969878269121c127ba690e3 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 15:23:31 -0700 Subject: [PATCH 01/11] fix: google sheet tool support json string input --- .../google_sheets_tool/google_sheets_tool.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py b/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py index 4435b5db..195ab917 100644 --- a/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py +++ b/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py @@ -391,7 +391,7 @@ def register_tools( def google_sheets_update_values( spreadsheet_id: str, range_name: str, - values: list[list[Any]], + values: list[list[Any]] | str, value_input_option: str = "USER_ENTERED", # Tracking parameters (injected by framework, ignored by tool) workspace_id: str | None = None, @@ -405,13 +405,19 @@ def register_tools( Args: spreadsheet_id: The spreadsheet ID (from the URL) range_name: The A1 notation range (e.g., "Sheet1!A1:B10") - values: 2D array of values to write + values: 2D array of values to write. Accepts a list or a JSON string. value_input_option: How to interpret input (USER_ENTERED parses, RAW stores as-is) Returns: Dict with update result or error """ + # Accept stringified JSON and deserialize + import json + if isinstance(values, str): + values = json.loads(values) + if not isinstance(values, list): + return {"error": f"values must be a 2D list or JSON string, got {type(values).__name__}"} client = _get_client() if isinstance(client, dict): return client @@ -426,7 +432,7 @@ def register_tools( def google_sheets_append_values( spreadsheet_id: str, range_name: str, - values: list[list[Any]], + values: list[list[Any]] | str, value_input_option: str = "USER_ENTERED", # Tracking parameters (injected by framework, ignored by tool) workspace_id: str | None = None, @@ -440,13 +446,19 @@ def register_tools( Args: spreadsheet_id: The spreadsheet ID (from the URL) range_name: The A1 notation range (e.g., "Sheet1!A1") - values: 2D array of values to append + values: 2D array of values to append. Accepts a list or a JSON string. value_input_option: How to interpret input (USER_ENTERED parses, RAW stores as-is) Returns: Dict with append result or error """ + # Accept stringified JSON and deserialize + import json + if isinstance(values, str): + values = json.loads(values) + if not isinstance(values, list): + return {"error": f"values must be a 2D list or JSON string, got {type(values).__name__}"} client = _get_client() if isinstance(client, dict): return client From 2f21e9eb4b5f0c9f83d1be517ee2e266c842b651 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 15:24:12 -0700 Subject: [PATCH 02/11] fix: session reload preamble --- core/framework/graph/prompt_composer.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/core/framework/graph/prompt_composer.py b/core/framework/graph/prompt_composer.py index f9ba1287..29e26914 100644 --- a/core/framework/graph/prompt_composer.py +++ b/core/framework/graph/prompt_composer.py @@ -152,6 +152,8 @@ def compose_system_prompt( accounts_prompt: str | None = None, skills_catalog_prompt: str | None = None, protocols_prompt: str | None = None, + execution_preamble: str | None = None, + node_type_preamble: str | None = None, ) -> str: """Compose the multi-layer system prompt. @@ -162,6 +164,10 @@ def compose_system_prompt( accounts_prompt: Connected accounts block (sits between identity and narrative). skills_catalog_prompt: Available skills catalog XML (Agent Skills standard). protocols_prompt: Default skill operational protocols section. + execution_preamble: EXECUTION_SCOPE_PREAMBLE for worker nodes + (prepended before focus so the LLM knows its pipeline scope). + node_type_preamble: Node-type-specific preamble, e.g. GCU browser + best-practices prompt (prepended before focus). Returns: Composed system prompt with all layers present, plus current datetime. @@ -188,6 +194,15 @@ def compose_system_prompt( if narrative: parts.append(f"\n--- Context (what has happened so far) ---\n{narrative}") + # Execution scope preamble (worker nodes — tells the LLM it is one + # step in a multi-node pipeline and should not overreach) + if execution_preamble: + parts.append(f"\n{execution_preamble}") + + # Node-type preamble (e.g. GCU browser best-practices) + if node_type_preamble: + parts.append(f"\n{node_type_preamble}") + # Layer 3: Focus (current phase directive) if focus_prompt: parts.append(f"\n--- Current Focus ---\n{focus_prompt}") From a852cb91bfa4054cb51f838fbafa6f225a349fb6 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 15:24:30 -0700 Subject: [PATCH 03/11] fix: non-blocking memory consolidation --- core/framework/server/session_manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/framework/server/session_manager.py b/core/framework/server/session_manager.py index 9b32f9f0..bdb17f1b 100644 --- a/core/framework/server/session_manager.py +++ b/core/framework/server/session_manager.py @@ -1034,8 +1034,11 @@ class SessionManager: async def _on_compaction(_event) -> None: from framework.agents.queen.queen_memory import consolidate_queen_memory - await consolidate_queen_memory( - session.id, _consolidation_session_dir, _consolidation_llm + asyncio.create_task( + consolidate_queen_memory( + session.id, _consolidation_session_dir, _consolidation_llm + ), + name=f"queen-memory-consolidation-{session.id}", ) from framework.runtime.event_bus import EventType as _ET From 7a9b9666c4bf1db7894394f5dc3f789b39238976 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 15:25:04 -0700 Subject: [PATCH 04/11] fix: refresh system prompt with preamble --- core/framework/graph/event_loop_node.py | 75 ++++++++++++++++++++----- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/core/framework/graph/event_loop_node.py b/core/framework/graph/event_loop_node.py index 97ceda79..72b8a53e 100644 --- a/core/framework/graph/event_loop_node.py +++ b/core/framework/graph/event_loop_node.py @@ -533,12 +533,28 @@ class EventLoopNode(NodeProtocol): _restored_recent_responses = restored.recent_responses _restored_tool_fingerprints = restored.recent_tool_fingerprints - # Refresh the system prompt with full 3-layer composition. - # The stored prompt may be stale after code changes or when - # runtime-injected context (e.g. worker identity) has changed. - # On resume, we rebuild identity + narrative + focus so the LLM - # understands the session history, not just the node directive. - from framework.graph.prompt_composer import compose_system_prompt + # Refresh the system prompt with full composition including + # execution preamble and node-type preamble. The stored + # prompt may be stale after code changes or when runtime- + # injected context (e.g. worker identity) has changed. + from framework.graph.prompt_composer import ( + EXECUTION_SCOPE_PREAMBLE, + compose_system_prompt, + ) + + _exec_preamble = None + if ( + not ctx.is_subagent_mode + and ctx.node_spec.node_type in ("event_loop", "gcu") + and ctx.node_spec.output_keys + ): + _exec_preamble = EXECUTION_SCOPE_PREAMBLE + + _node_type_preamble = None + if ctx.node_spec.node_type == "gcu": + from framework.graph.gcu import GCU_BROWSER_SYSTEM_PROMPT + + _node_type_preamble = GCU_BROWSER_SYSTEM_PROMPT _current_prompt = compose_system_prompt( identity_prompt=ctx.identity_prompt or None, @@ -547,6 +563,8 @@ class EventLoopNode(NodeProtocol): accounts_prompt=ctx.accounts_prompt or None, skills_catalog_prompt=ctx.skills_catalog_prompt or None, protocols_prompt=ctx.protocols_prompt or None, + execution_preamble=_exec_preamble, + node_type_preamble=_node_type_preamble, ) if conversation.system_prompt != _current_prompt: conversation.update_system_prompt(_current_prompt) @@ -2486,6 +2504,27 @@ class EventLoopNode(NodeProtocol): results_by_id[tc.tool_use_id] = result elif tc.tool_name == "delegate_to_sub_agent": + # Guard: in continuous mode the LLM may see delegate + # calls from a previous node's conversation history and + # attempt to re-use the tool on a node that doesn't own + # it. Only accept if the tool was actually offered. + if not any(t.name == "delegate_to_sub_agent" for t in tools): + logger.warning( + "[%s] LLM called delegate_to_sub_agent but tool " + "was not offered to this node — rejecting", + node_id, + ) + result = ToolResult( + tool_use_id=tc.tool_use_id, + content=( + "ERROR: delegate_to_sub_agent is not available " + "on this node. This tool belongs to a different " + "node in the workflow." + ), + is_error=True, + ) + results_by_id[tc.tool_use_id] = result + continue # --- Framework-level subagent delegation --- # Queue for parallel execution in Phase 2 logger.info( @@ -5155,7 +5194,20 @@ class EventLoopNode(NodeProtocol): write_keys=[], # Read-only! ) - # 2b. Set up report callback (one-way channel to parent / event bus) + # 2b. Compute instance counter early so node_id is available for the + # report callback and the NodeContext. Each delegation to the same + # agent_id gets a unique suffix (instance 1 has no suffix for backward + # compat; instance 2+ appends ":N"). + self._subagent_instance_counter.setdefault(agent_id, 0) + self._subagent_instance_counter[agent_id] += 1 + _sa_instance = self._subagent_instance_counter[agent_id] + if _sa_instance > 1: + sa_node_id = f"{ctx.node_id}:subagent:{agent_id}:{_sa_instance}" + else: + sa_node_id = f"{ctx.node_id}:subagent:{agent_id}" + subagent_instance = str(_sa_instance) + + # 2c. Set up report callback (one-way channel to parent / event bus) subagent_reports: list[dict] = [] async def _report_callback( @@ -5168,7 +5220,7 @@ class EventLoopNode(NodeProtocol): if self._event_bus: await self._event_bus.emit_subagent_report( stream_id=ctx.node_id, - node_id=f"{ctx.node_id}:subagent:{agent_id}", + node_id=sa_node_id, subagent_id=agent_id, message=message, data=data, @@ -5258,7 +5310,7 @@ class EventLoopNode(NodeProtocol): max_iter = min(self._config.max_iterations, 10) subagent_ctx = NodeContext( runtime=ctx.runtime, - node_id=f"{ctx.node_id}:subagent:{agent_id}", + node_id=sa_node_id, node_spec=subagent_spec, memory=scoped_memory, input_data={"task": task, **parent_data}, @@ -5286,10 +5338,7 @@ class EventLoopNode(NodeProtocol): # Derive a conversation store for the subagent from the parent's store. # Each invocation gets a unique path so that repeated delegate calls # (e.g. one per profile) don't restore a stale completed conversation. - self._subagent_instance_counter.setdefault(agent_id, 0) - self._subagent_instance_counter[agent_id] += 1 - subagent_instance = str(self._subagent_instance_counter[agent_id]) - + # (Instance counter was computed earlier in step 2b.) subagent_conv_store = None if self._conversation_store is not None: from framework.storage.conversation_store import FileConversationStore From d284c5d790085cc09078466347a804f9eef1f9f7 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 15:25:21 -0700 Subject: [PATCH 05/11] feat: parallel execution display --- core/frontend/src/components/ChatPanel.tsx | 75 +++- .../src/components/ParallelSubagentBubble.tsx | 324 ++++++++++++++++++ core/frontend/src/lib/chat-helpers.ts | 4 + core/frontend/src/pages/workspace.tsx | 4 + 4 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 core/frontend/src/components/ParallelSubagentBubble.tsx diff --git a/core/frontend/src/components/ChatPanel.tsx b/core/frontend/src/components/ChatPanel.tsx index 0e02082d..8952ec8f 100644 --- a/core/frontend/src/components/ChatPanel.tsx +++ b/core/frontend/src/components/ChatPanel.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useRef, useEffect } from "react"; +import { memo, useState, useRef, useEffect, useMemo } from "react"; import { Send, Square, Crown, Cpu, Check, Loader2 } from "lucide-react"; export interface ContextUsageEntry { @@ -10,6 +10,7 @@ export interface ContextUsageEntry { import MarkdownContent from "@/components/MarkdownContent"; import QuestionWidget from "@/components/QuestionWidget"; import MultiQuestionWidget from "@/components/MultiQuestionWidget"; +import ParallelSubagentBubble, { type SubagentGroup } from "@/components/ParallelSubagentBubble"; export interface ChatMessage { id: string; @@ -25,6 +26,10 @@ export interface ChatMessage { createdAt?: number; /** Queen phase active when this message was created */ phase?: "planning" | "building" | "staging" | "running"; + /** Backend node_id that produced this message — used for subagent grouping */ + nodeId?: string; + /** Backend execution_id for this message */ + executionId?: string; } interface ChatPanelProps { @@ -269,6 +274,58 @@ export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting return true; }); + // Group consecutive subagent messages into parallel bubbles. + // A message is a "subagent message" if its nodeId contains ":subagent:". + // Consecutive runs of subagent messages with 2+ distinct nodeIds become + // a ParallelSubagentBubble; runs with only 1 nodeId render normally. + type RenderItem = + | { kind: "message"; msg: ChatMessage } + | { kind: "parallel"; groupId: string; groups: SubagentGroup[] }; + + const renderItems = useMemo(() => { + const items: RenderItem[] = []; + let i = 0; + while (i < threadMessages.length) { + const msg = threadMessages[i]; + const isSubagent = msg.nodeId?.includes(":subagent:"); + if (!isSubagent) { + items.push({ kind: "message", msg }); + i++; + continue; + } + // Start collecting a consecutive subagent run + const runStart = i; + while ( + i < threadMessages.length && + threadMessages[i].nodeId?.includes(":subagent:") + ) { + i++; + } + const runMsgs = threadMessages.slice(runStart, i); + // Group by nodeId. With the backend fix, each subagent instance + // gets a unique nodeId (e.g. "node:subagent:agent:2" for instance 2). + const byNode = new Map(); + for (const m of runMsgs) { + const nid = m.nodeId!; + if (!byNode.has(nid)) byNode.set(nid, []); + byNode.get(nid)!.push(m); + } + // Always fold subagent messages into a consolidated bubble — + // even a single subagent gets the folded view with one square. + const groups: SubagentGroup[] = []; + for (const [nodeId, msgs] of byNode) { + groups.push({ + nodeId, + messages: msgs, + contextUsage: contextUsage?.[nodeId], + }); + } + const groupId = `par-${runMsgs[0].id}`; + items.push({ kind: "parallel", groupId, groups }); + } + return items; + }, [threadMessages, contextUsage]); + // Mark current thread as read useEffect(() => { const count = messages.filter((m) => m.thread === activeThread).length; @@ -314,11 +371,17 @@ export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting {/* Messages */}
- {threadMessages.map((msg) => ( -
- -
- ))} + {renderItems.map((item) => + item.kind === "parallel" ? ( +
+ +
+ ) : ( +
+ +
+ ) + )} {/* Show typing indicator while waiting for first queen response (disabled + empty chat) */} {(isWaiting || (disabled && threadMessages.length === 0)) && ( diff --git a/core/frontend/src/components/ParallelSubagentBubble.tsx b/core/frontend/src/components/ParallelSubagentBubble.tsx new file mode 100644 index 00000000..8a0c4d7d --- /dev/null +++ b/core/frontend/src/components/ParallelSubagentBubble.tsx @@ -0,0 +1,324 @@ +import { memo, useState, useRef, useEffect } from "react"; +import { ChevronDown, ChevronUp, Cpu } from "lucide-react"; +import type { ChatMessage, ContextUsageEntry } from "@/components/ChatPanel"; +import MarkdownContent from "@/components/MarkdownContent"; + +const workerColor = "hsl(220,60%,55%)"; + +/** Palette for distinguishing individual subagent squares */ +const SUBAGENT_COLORS = [ + "hsl(220,60%,55%)", // blue + "hsl(260,50%,55%)", // purple + "hsl(180,50%,45%)", // teal + "hsl(30,70%,50%)", // orange + "hsl(340,55%,50%)", // rose + "hsl(150,45%,45%)", // green + "hsl(45,80%,50%)", // amber + "hsl(290,45%,55%)", // violet +]; + +function colorForIndex(i: number): string { + return SUBAGENT_COLORS[i % SUBAGENT_COLORS.length]; +} + +/** Extract a short display label from a subagent node_id like "parentNode:subagent:myAgent" */ +function subagentLabel(nodeId: string): string { + const parts = nodeId.split(":subagent:"); + if (parts.length >= 2) { + // Title-case the agent ID portion + return parts[1] + .replace(/[_-]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); + } + return nodeId + .replace(/[_-]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); +} + +export interface SubagentGroup { + /** Unique node_id for this subagent (includes instance suffix for duplicates) */ + nodeId: string; + /** All chat messages from this subagent (stream snapshots, tool pills, etc.) */ + messages: ChatMessage[]; + /** Context window usage for this subagent's event loop */ + contextUsage?: ContextUsageEntry; +} + +interface ParallelSubagentBubbleProps { + /** Grouped subagent data — one entry per parallel subagent */ + groups: SubagentGroup[]; + /** ID for the overall group — used for expand/collapse persistence */ + groupId: string; +} + +/** A single subagent square in the folded view */ +function SubagentSquare({ + group, + index, + isLatest, + label, +}: { + group: SubagentGroup; + index: number; + isLatest: boolean; + /** Display label — may include instance number for duplicates */ + label: string; +}) { + const color = colorForIndex(index); + const fillPct = group.contextUsage?.usagePct ?? 0; + const msgCount = group.messages.filter( + (m) => m.type !== "tool_status" && m.role === "worker" + ).length; + + return ( +
+ {/* Message count badge */} + + {msgCount} + + + {/* Color-filled square — fill level = context window usage */} +
+
+ + {fillPct}% + +
+ + {/* Subagent label */} + + {label} + +
+ ); +} + +/** Return the last element of an array (compat with ES2021 targets). */ +function last(arr: T[]): T | undefined { + return arr[arr.length - 1]; +} + +/** Stable unique key for a group. */ +function groupKey(g: SubagentGroup): string { + return g.nodeId; +} + +const ParallelSubagentBubble = memo(function ParallelSubagentBubble({ + groups, +}: ParallelSubagentBubbleProps) { + const [expanded, setExpanded] = useState(false); + const previewRef = useRef(null); + + // Compute display labels — append instance number when multiple groups + // share the same nodeId (e.g. 3× browser-researcher → #1, #2, #3). + const labels: string[] = (() => { + const countByNode = new Map(); + for (const g of groups) { + countByNode.set(g.nodeId, (countByNode.get(g.nodeId) ?? 0) + 1); + } + const indexByNode = new Map(); + return groups.map((g) => { + const base = subagentLabel(g.nodeId); + if ((countByNode.get(g.nodeId) ?? 1) <= 1) return base; + const idx = (indexByNode.get(g.nodeId) ?? 0) + 1; + indexByNode.set(g.nodeId, idx); + return `${base} #${idx}`; + }); + })(); + + // Find the subagent that most recently received a stream update + const latestIdx = groups.reduce((bestIdx, g, i) => { + const filtered = g.messages.filter((m) => m.type !== "tool_status"); + const lastMsg = last(filtered); + if (!lastMsg) return bestIdx; + if (bestIdx < 0) return i; + const bestFiltered = groups[bestIdx].messages.filter((m) => m.type !== "tool_status"); + const bestLast = last(bestFiltered); + if (!bestLast) return i; + return (lastMsg.createdAt ?? 0) >= (bestLast.createdAt ?? 0) ? i : bestIdx; + }, -1); + + const latestGroup = latestIdx >= 0 ? groups[latestIdx] : null; + + const latestContent = latestGroup + ? last(latestGroup.messages.filter((m) => m.type !== "tool_status"))?.content ?? "" + : ""; + + const latestLabel = latestIdx >= 0 ? labels[latestIdx] : ""; + + // Auto-scroll the preview window to bottom when content changes + useEffect(() => { + if (previewRef.current) { + previewRef.current.scrollTop = previewRef.current.scrollHeight; + } + }, [latestContent]); + + if (groups.length === 0) return null; + + return ( +
+ {/* Left icon */} +
+ +
+ +
+ {/* Header */} +
+ + {groups.length === 1 ? "Sub-agent" : "Parallel Agents"} + + + {groups.length} running + + +
+ + {expanded ? ( + /* ── Expanded view: show individual subagent messages ── */ +
+ {groups.map((group, gi) => { + const color = colorForIndex(gi); + const streamMsgs = group.messages.filter( + (m) => m.type !== "tool_status" + ); + const lastContent = last(streamMsgs)?.content ?? ""; + return ( +
+
+ + + {labels[gi]} + + {group.contextUsage && ( + + {group.contextUsage.usagePct}% ctx + + )} +
+ {lastContent && ( +
+ +
+ )} +
+ ); + })} +
+ ) : ( + /* ── Folded view: preview window + squares ── */ +
+ {/* Preview window: latest stream content */} +
+ {latestContent ? ( +
+
+ = 0 + ? colorForIndex(latestIdx) + : workerColor, + }} + /> + + {latestLabel} + +
+
+ +
+
+ ) : ( +
+ + + +
+ )} +
+ + {/* Subagent squares row */} +
+ {groups.map((group, i) => ( + + ))} +
+
+ )} +
+
+ ); +}, +(prev, next) => + prev.groupId === next.groupId && + prev.groups.length === next.groups.length && + prev.groups.every( + (g, i) => + g.nodeId === next.groups[i].nodeId && + g.messages.length === next.groups[i].messages.length && + last(g.messages)?.content === last(next.groups[i].messages)?.content && + g.contextUsage?.usagePct === next.groups[i].contextUsage?.usagePct + )); + +export default ParallelSubagentBubble; diff --git a/core/frontend/src/lib/chat-helpers.ts b/core/frontend/src/lib/chat-helpers.ts index dbccbe7a..9a332153 100644 --- a/core/frontend/src/lib/chat-helpers.ts +++ b/core/frontend/src/lib/chat-helpers.ts @@ -72,6 +72,8 @@ export function sseEventToChatMessage( role: "worker", thread, createdAt, + nodeId: event.node_id || undefined, + executionId: event.execution_id || undefined, }; } @@ -110,6 +112,8 @@ export function sseEventToChatMessage( role: "worker", thread, createdAt, + nodeId: event.node_id || undefined, + executionId: event.execution_id || undefined, }; } diff --git a/core/frontend/src/pages/workspace.tsx b/core/frontend/src/pages/workspace.tsx index 19092558..2554f90a 100644 --- a/core/frontend/src/pages/workspace.tsx +++ b/core/frontend/src/pages/workspace.tsx @@ -2011,6 +2011,8 @@ export default function Workspace() { role, thread: agentType, createdAt: eventCreatedAt, + nodeId: event.node_id || undefined, + executionId: event.execution_id || undefined, }); return { ...prev, @@ -2082,6 +2084,8 @@ export default function Workspace() { role, thread: agentType, createdAt: eventCreatedAt, + nodeId: event.node_id || undefined, + executionId: event.execution_id || undefined, }); return { ...prev, From 684e0d8dc6fb19e3e3aee6ba16c0e5fdf0b9b1f3 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 16:58:00 -0700 Subject: [PATCH 06/11] fix: no memory consolidation for worker --- core/framework/server/session_manager.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/framework/server/session_manager.py b/core/framework/server/session_manager.py index bdb17f1b..b6d7760f 100644 --- a/core/framework/server/session_manager.py +++ b/core/framework/server/session_manager.py @@ -1032,6 +1032,10 @@ class SessionManager: _consolidation_session_dir = queen_dir async def _on_compaction(_event) -> None: + # Only consolidate on queen compactions — worker and subagent + # compactions are frequent and don't warrant a memory update. + if getattr(_event, "stream_id", None) != "queen": + return from framework.agents.queen.queen_memory import consolidate_queen_memory asyncio.create_task( From 0772b4d3000b8d4426e6020fabf7ab4db6f2d949 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 16:58:34 -0700 Subject: [PATCH 07/11] feat: better subagent interleave logic --- core/frontend/src/components/ChatPanel.tsx | 79 ++++++---- .../src/components/ParallelSubagentBubble.tsx | 135 +++++++++++++++++- 2 files changed, 179 insertions(+), 35 deletions(-) diff --git a/core/frontend/src/components/ChatPanel.tsx b/core/frontend/src/components/ChatPanel.tsx index 8952ec8f..619b1ef1 100644 --- a/core/frontend/src/components/ChatPanel.tsx +++ b/core/frontend/src/components/ChatPanel.tsx @@ -274,10 +274,10 @@ export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting return true; }); - // Group consecutive subagent messages into parallel bubbles. - // A message is a "subagent message" if its nodeId contains ":subagent:". - // Consecutive runs of subagent messages with 2+ distinct nodeIds become - // a ParallelSubagentBubble; runs with only 1 nodeId render normally. + // Group subagent messages into parallel bubbles. + // A subagent message has nodeId containing ":subagent:". + // The run only ends on hard boundaries (user messages, run_dividers) + // so interleaved queen/tool/system messages don't fragment the bubble. type RenderItem = | { kind: "message"; msg: ChatMessage } | { kind: "parallel"; groupId: string; groups: SubagentGroup[] }; @@ -293,35 +293,56 @@ export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting i++; continue; } - // Start collecting a consecutive subagent run - const runStart = i; - while ( - i < threadMessages.length && - threadMessages[i].nodeId?.includes(":subagent:") - ) { + + // Start a subagent run. Collect all subagent messages, allowing + // non-subagent messages in between (they render as normal items + // before the bubble). Only break on hard boundaries. + const subagentMsgs: ChatMessage[] = []; + const interleaved: { idx: number; msg: ChatMessage }[] = []; + const firstId = msg.id; + + while (i < threadMessages.length) { + const m = threadMessages[i]; + const isSa = m.nodeId?.includes(":subagent:"); + + if (isSa) { + subagentMsgs.push(m); + i++; + continue; + } + + // Hard boundary — stop the run + if (m.type === "user" || m.type === "run_divider") break; + + // Soft interruption (queen output, system, tool_status) — + // render it normally but keep the subagent run going + interleaved.push({ idx: items.length + interleaved.length, msg: m }); i++; } - const runMsgs = threadMessages.slice(runStart, i); - // Group by nodeId. With the backend fix, each subagent instance - // gets a unique nodeId (e.g. "node:subagent:agent:2" for instance 2). - const byNode = new Map(); - for (const m of runMsgs) { - const nid = m.nodeId!; - if (!byNode.has(nid)) byNode.set(nid, []); - byNode.get(nid)!.push(m); + + // Emit interleaved messages first (before the bubble) + for (const { msg: im } of interleaved) { + items.push({ kind: "message", msg: im }); } - // Always fold subagent messages into a consolidated bubble — - // even a single subagent gets the folded view with one square. - const groups: SubagentGroup[] = []; - for (const [nodeId, msgs] of byNode) { - groups.push({ - nodeId, - messages: msgs, - contextUsage: contextUsage?.[nodeId], - }); + + // Build the single parallel bubble from all collected subagent msgs + if (subagentMsgs.length > 0) { + const byNode = new Map(); + for (const m of subagentMsgs) { + const nid = m.nodeId!; + if (!byNode.has(nid)) byNode.set(nid, []); + byNode.get(nid)!.push(m); + } + const groups: SubagentGroup[] = []; + for (const [nodeId, msgs] of byNode) { + groups.push({ + nodeId, + messages: msgs, + contextUsage: contextUsage?.[nodeId], + }); + } + items.push({ kind: "parallel", groupId: `par-${firstId}`, groups }); } - const groupId = `par-${runMsgs[0].id}`; - items.push({ kind: "parallel", groupId, groups }); } return items; }, [threadMessages, contextUsage]); diff --git a/core/frontend/src/components/ParallelSubagentBubble.tsx b/core/frontend/src/components/ParallelSubagentBubble.tsx index 8a0c4d7d..e79e4adc 100644 --- a/core/frontend/src/components/ParallelSubagentBubble.tsx +++ b/core/frontend/src/components/ParallelSubagentBubble.tsx @@ -1,8 +1,119 @@ -import { memo, useState, useRef, useEffect } from "react"; +import { memo, useState, useRef, useEffect, useCallback } from "react"; import { ChevronDown, ChevronUp, Cpu } from "lucide-react"; import type { ChatMessage, ContextUsageEntry } from "@/components/ChatPanel"; import MarkdownContent from "@/components/MarkdownContent"; +// --------------------------------------------------------------------------- +// Matrix rain canvas for subagent squares +// --------------------------------------------------------------------------- + +/** Render a matrix-rain effect inside a canvas. Drops only fall within the + * filled region (bottom `fillPct`% of the canvas). */ +function MatrixRainCanvas({ + color, + fillPct, + width, + height, +}: { + color: string; + fillPct: number; + width: number; + height: number; +}) { + const canvasRef = useRef(null); + const stateRef = useRef<{ + columns: number[]; + frameId: number; + lastTick: number; + } | null>(null); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = width * dpr; + const h = height * dpr; + + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } + + const fontSize = 7 * dpr; + const cols = Math.floor(w / fontSize); + const fillH = (Math.min(fillPct, 100) / 100) * h; + const topOfFill = h - fillH; + + // Init columns + if (!stateRef.current || stateRef.current.columns.length !== cols) { + stateRef.current = { + columns: Array.from({ length: cols }, () => + topOfFill + Math.random() * fillH + ), + frameId: 0, + lastTick: 0, + }; + } + + const state = stateRef.current; + const now = performance.now(); + // Tick at ~8 fps for a slow, ambient feel + if (now - state.lastTick < 125) { + state.frameId = requestAnimationFrame(draw); + return; + } + state.lastTick = now; + + // Fade previous frame + ctx.fillStyle = "rgba(0,0,0,0.25)"; + ctx.fillRect(0, 0, w, h); + + ctx.font = `${fontSize}px monospace`; + ctx.fillStyle = color; + ctx.globalAlpha = 0.8; + + for (let i = 0; i < cols; i++) { + // Random character (digits, symbols, katakana-ish) + const chars = "01.:+*#>|~"; + const char = chars[Math.floor(Math.random() * chars.length)]; + const x = i * fontSize; + const y = state.columns[i]; + + if (y >= topOfFill && y <= h) { + ctx.fillText(char, x, y); + } + + // Advance drop; reset when past bottom + state.columns[i] += fontSize; + if (state.columns[i] > h || Math.random() > 0.96) { + state.columns[i] = topOfFill; + } + } + + ctx.globalAlpha = 1.0; + state.frameId = requestAnimationFrame(draw); + }, [color, fillPct, width, height]); + + useEffect(() => { + const frameId = requestAnimationFrame(draw); + return () => { + cancelAnimationFrame(frameId); + if (stateRef.current) cancelAnimationFrame(stateRef.current.frameId); + }; + }, [draw]); + + return ( + + ); +} + const workerColor = "hsl(220,60%,55%)"; /** Palette for distinguishing individual subagent squares */ @@ -85,27 +196,39 @@ function SubagentSquare({ {msgCount} - {/* Color-filled square — fill level = context window usage */} + {/* Pixel-rain square — matrix rain within the filled region */}
+ {/* Dim base fill so there's something visible even without animation */}
+ {/* Matrix rain canvas */} + + {/* Percentage overlay */} {fillPct}% From a995818db2b0e1f609110258223a528eb4e43aa0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 17:57:33 -0700 Subject: [PATCH 08/11] fix: subagent bubble boundary --- core/frontend/src/components/ChatPanel.tsx | 9 +- .../src/components/ParallelSubagentBubble.tsx | 748 +++++++++--------- 2 files changed, 364 insertions(+), 393 deletions(-) diff --git a/core/frontend/src/components/ChatPanel.tsx b/core/frontend/src/components/ChatPanel.tsx index 619b1ef1..59aead74 100644 --- a/core/frontend/src/components/ChatPanel.tsx +++ b/core/frontend/src/components/ChatPanel.tsx @@ -314,8 +314,13 @@ export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting // Hard boundary — stop the run if (m.type === "user" || m.type === "run_divider") break; - // Soft interruption (queen output, system, tool_status) — - // render it normally but keep the subagent run going + // Worker message from a non-subagent node means the graph has + // moved on to the next stage. Close the bubble even if some + // subagents are still streaming in the background. + if (m.role === "worker" && m.nodeId && !m.nodeId.includes(":subagent:")) break; + + // Soft interruption (queen output, system, tool_status without + // nodeId) — render it normally but keep the subagent run going interleaved.push({ idx: items.length + interleaved.length, msg: m }); i++; } diff --git a/core/frontend/src/components/ParallelSubagentBubble.tsx b/core/frontend/src/components/ParallelSubagentBubble.tsx index e79e4adc..96907d84 100644 --- a/core/frontend/src/components/ParallelSubagentBubble.tsx +++ b/core/frontend/src/components/ParallelSubagentBubble.tsx @@ -1,447 +1,413 @@ -import { memo, useState, useRef, useEffect, useCallback } from "react"; +import { memo, useState, useRef, useEffect } from "react"; import { ChevronDown, ChevronUp, Cpu } from "lucide-react"; import type { ChatMessage, ContextUsageEntry } from "@/components/ChatPanel"; import MarkdownContent from "@/components/MarkdownContent"; // --------------------------------------------------------------------------- -// Matrix rain canvas for subagent squares +// Shared helpers // --------------------------------------------------------------------------- -/** Render a matrix-rain effect inside a canvas. Drops only fall within the - * filled region (bottom `fillPct`% of the canvas). */ -function MatrixRainCanvas({ - color, - fillPct, - width, - height, -}: { - color: string; - fillPct: number; - width: number; - height: number; -}) { - const canvasRef = useRef(null); - const stateRef = useRef<{ - columns: number[]; - frameId: number; - lastTick: number; - } | null>(null); - - const draw = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const dpr = window.devicePixelRatio || 1; - const w = width * dpr; - const h = height * dpr; - - if (canvas.width !== w || canvas.height !== h) { - canvas.width = w; - canvas.height = h; - } - - const fontSize = 7 * dpr; - const cols = Math.floor(w / fontSize); - const fillH = (Math.min(fillPct, 100) / 100) * h; - const topOfFill = h - fillH; - - // Init columns - if (!stateRef.current || stateRef.current.columns.length !== cols) { - stateRef.current = { - columns: Array.from({ length: cols }, () => - topOfFill + Math.random() * fillH - ), - frameId: 0, - lastTick: 0, - }; - } - - const state = stateRef.current; - const now = performance.now(); - // Tick at ~8 fps for a slow, ambient feel - if (now - state.lastTick < 125) { - state.frameId = requestAnimationFrame(draw); - return; - } - state.lastTick = now; - - // Fade previous frame - ctx.fillStyle = "rgba(0,0,0,0.25)"; - ctx.fillRect(0, 0, w, h); - - ctx.font = `${fontSize}px monospace`; - ctx.fillStyle = color; - ctx.globalAlpha = 0.8; - - for (let i = 0; i < cols; i++) { - // Random character (digits, symbols, katakana-ish) - const chars = "01.:+*#>|~"; - const char = chars[Math.floor(Math.random() * chars.length)]; - const x = i * fontSize; - const y = state.columns[i]; - - if (y >= topOfFill && y <= h) { - ctx.fillText(char, x, y); - } - - // Advance drop; reset when past bottom - state.columns[i] += fontSize; - if (state.columns[i] > h || Math.random() > 0.96) { - state.columns[i] = topOfFill; - } - } - - ctx.globalAlpha = 1.0; - state.frameId = requestAnimationFrame(draw); - }, [color, fillPct, width, height]); - - useEffect(() => { - const frameId = requestAnimationFrame(draw); - return () => { - cancelAnimationFrame(frameId); - if (stateRef.current) cancelAnimationFrame(stateRef.current.frameId); - }; - }, [draw]); - - return ( - - ); -} - const workerColor = "hsl(220,60%,55%)"; -/** Palette for distinguishing individual subagent squares */ const SUBAGENT_COLORS = [ - "hsl(220,60%,55%)", // blue - "hsl(260,50%,55%)", // purple - "hsl(180,50%,45%)", // teal - "hsl(30,70%,50%)", // orange - "hsl(340,55%,50%)", // rose - "hsl(150,45%,45%)", // green - "hsl(45,80%,50%)", // amber - "hsl(290,45%,55%)", // violet + "hsl(220,60%,55%)", + "hsl(260,50%,55%)", + "hsl(180,50%,45%)", + "hsl(30,70%,50%)", + "hsl(340,55%,50%)", + "hsl(150,45%,45%)", + "hsl(45,80%,50%)", + "hsl(290,45%,55%)", ]; function colorForIndex(i: number): string { return SUBAGENT_COLORS[i % SUBAGENT_COLORS.length]; } -/** Extract a short display label from a subagent node_id like "parentNode:subagent:myAgent" */ function subagentLabel(nodeId: string): string { const parts = nodeId.split(":subagent:"); - if (parts.length >= 2) { - // Title-case the agent ID portion - return parts[1] - .replace(/[_-]/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()) - .trim(); - } - return nodeId + const raw = parts.length >= 2 ? parts[1] : nodeId; + return raw + .replace(/:\d+$/, "") // strip instance suffix like ":3" .replace(/[_-]/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()) .trim(); } -export interface SubagentGroup { - /** Unique node_id for this subagent (includes instance suffix for duplicates) */ - nodeId: string; - /** All chat messages from this subagent (stream snapshots, tool pills, etc.) */ - messages: ChatMessage[]; - /** Context window usage for this subagent's event loop */ - contextUsage?: ContextUsageEntry; -} - -interface ParallelSubagentBubbleProps { - /** Grouped subagent data — one entry per parallel subagent */ - groups: SubagentGroup[]; - /** ID for the overall group — used for expand/collapse persistence */ - groupId: string; -} - -/** A single subagent square in the folded view */ -function SubagentSquare({ - group, - index, - isLatest, - label, -}: { - group: SubagentGroup; - index: number; - isLatest: boolean; - /** Display label — may include instance number for duplicates */ - label: string; -}) { - const color = colorForIndex(index); - const fillPct = group.contextUsage?.usagePct ?? 0; - const msgCount = group.messages.filter( - (m) => m.type !== "tool_status" && m.role === "worker" - ).length; - - return ( -
- {/* Message count badge */} - - {msgCount} - - - {/* Pixel-rain square — matrix rain within the filled region */} -
- {/* Dim base fill so there's something visible even without animation */} -
- {/* Matrix rain canvas */} - - {/* Percentage overlay */} - - {fillPct}% - -
- - {/* Subagent label */} - - {label} - -
- ); -} - -/** Return the last element of an array (compat with ES2021 targets). */ function last(arr: T[]): T | undefined { return arr[arr.length - 1]; } -/** Stable unique key for a group. */ -function groupKey(g: SubagentGroup): string { - return g.nodeId; +export interface SubagentGroup { + nodeId: string; + messages: ChatMessage[]; + contextUsage?: ContextUsageEntry; } -const ParallelSubagentBubble = memo(function ParallelSubagentBubble({ - groups, -}: ParallelSubagentBubbleProps) { - const [expanded, setExpanded] = useState(false); - const previewRef = useRef(null); +interface ParallelSubagentBubbleProps { + groups: SubagentGroup[]; + groupId: string; +} - // Compute display labels — append instance number when multiple groups - // share the same nodeId (e.g. 3× browser-researcher → #1, #2, #3). - const labels: string[] = (() => { - const countByNode = new Map(); - for (const g of groups) { - countByNode.set(g.nodeId, (countByNode.get(g.nodeId) ?? 0) + 1); +// --------------------------------------------------------------------------- +// Thermometer — vertical context gauge on right edge of each pane +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Tool overlay — shown when a tool_status message is active (not all done) +// --------------------------------------------------------------------------- + +function ToolOverlay({ + toolName, + color, + visible, +}: { + toolName: string; + color: string; + visible: boolean; +}) { + return ( +
+
+
+ {toolName} +
+
+ {visible ? "..." : "\u2713"} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Single tmux pane +// --------------------------------------------------------------------------- + +function MuxPane({ + group, + index, + label, + isFocused, + isZoomed, + onClickTitle, +}: { + group: SubagentGroup; + index: number; + label: string; + isFocused: boolean; + isZoomed: boolean; + onClickTitle: () => void; +}) { + const bodyRef = useRef(null); + const stickRef = useRef(true); + const color = colorForIndex(index); + const pct = group.contextUsage?.usagePct ?? 0; + + const streamMsgs = group.messages.filter((m) => m.type !== "tool_status"); + const latestContent = last(streamMsgs)?.content ?? ""; + const msgCount = streamMsgs.length; + + // Detect active tool and finished state from latest tool_status + const latestTool = last( + group.messages.filter((m) => m.type === "tool_status") + ); + let activeToolName = ""; + let toolRunning = false; + let isFinished = false; + if (latestTool) { + try { + const parsed = JSON.parse(latestTool.content); + const tools: { name: string; done: boolean }[] = parsed.tools || []; + const allDone = parsed.allDone as boolean | undefined; + const running = tools.find((t) => !t.done); + if (running) { + activeToolName = running.name; + toolRunning = true; + } + // Finished when all tools are done and one of them is set_output + // or report_to_parent (terminal tool calls) + if (allDone && tools.length > 0) { + const hasTerminal = tools.some( + (t) => + t.done && + (t.name === "set_output" || t.name === "report_to_parent") + ); + if (hasTerminal) isFinished = true; + } + } catch { + /* ignore */ } - const indexByNode = new Map(); - return groups.map((g) => { - const base = subagentLabel(g.nodeId); - if ((countByNode.get(g.nodeId) ?? 1) <= 1) return base; - const idx = (indexByNode.get(g.nodeId) ?? 0) + 1; - indexByNode.set(g.nodeId, idx); - return `${base} #${idx}`; - }); - })(); + } - // Find the subagent that most recently received a stream update - const latestIdx = groups.reduce((bestIdx, g, i) => { - const filtered = g.messages.filter((m) => m.type !== "tool_status"); - const lastMsg = last(filtered); - if (!lastMsg) return bestIdx; - if (bestIdx < 0) return i; - const bestFiltered = groups[bestIdx].messages.filter((m) => m.type !== "tool_status"); - const bestLast = last(bestFiltered); - if (!bestLast) return i; - return (lastMsg.createdAt ?? 0) >= (bestLast.createdAt ?? 0) ? i : bestIdx; - }, -1); - - const latestGroup = latestIdx >= 0 ? groups[latestIdx] : null; - - const latestContent = latestGroup - ? last(latestGroup.messages.filter((m) => m.type !== "tool_status"))?.content ?? "" - : ""; - - const latestLabel = latestIdx >= 0 ? labels[latestIdx] : ""; - - // Auto-scroll the preview window to bottom when content changes + // Auto-scroll useEffect(() => { - if (previewRef.current) { - previewRef.current.scrollTop = previewRef.current.scrollHeight; + if (stickRef.current && bodyRef.current) { + bodyRef.current.scrollTop = bodyRef.current.scrollHeight; } }, [latestContent]); - if (groups.length === 0) return null; + const handleScroll = () => { + const el = bodyRef.current; + if (!el) return; + stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 30; + }; return ( -
- {/* Left icon */} +
+ {/* Title bar */}
- + {isFinished ? ( + + ) : ( +
+ )} + + {label} + + + + {msgCount} + +
+
= 80 ? "hsl(0,65%,55%)" : pct >= 50 ? "hsl(35,90%,55%)" : color, + }} + /> +
+ + {pct}% +
-
- {/* Header */} -
- - {groups.length === 1 ? "Sub-agent" : "Parallel Agents"} - - - {groups.length} running - - -
- - {expanded ? ( - /* ── Expanded view: show individual subagent messages ── */ -
- {groups.map((group, gi) => { - const color = colorForIndex(gi); - const streamMsgs = group.messages.filter( - (m) => m.type !== "tool_status" - ); - const lastContent = last(streamMsgs)?.content ?? ""; - return ( -
-
- - - {labels[gi]} - - {group.contextUsage && ( - - {group.contextUsage.usagePct}% ctx - - )} -
- {lastContent && ( -
- -
- )} -
- ); - })} + {/* Body */} +
+ {latestContent ? ( +
+
) : ( - /* ── Folded view: preview window + squares ── */ -
- {/* Preview window: latest stream content */} -
- {latestContent ? ( -
-
- = 0 - ? colorForIndex(latestIdx) - : workerColor, - }} - /> - - {latestLabel} - -
-
- -
-
- ) : ( -
- - - -
- )} -
+ waiting... + )} + {/* Blinking cursor — hidden when finished */} + {!isFinished && ( + + )} +
- {/* Subagent squares row */} -
+ {/* Tool overlay */} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +const ParallelSubagentBubble = memo( + function ParallelSubagentBubble({ groups }: ParallelSubagentBubbleProps) { + const [expanded, setExpanded] = useState(false); + const [zoomedIdx, setZoomedIdx] = useState(null); + + // Labels with instance numbers for duplicates + const labels: string[] = (() => { + const countByBase = new Map(); + const bases = groups.map((g) => subagentLabel(g.nodeId)); + for (const b of bases) + countByBase.set(b, (countByBase.get(b) ?? 0) + 1); + const idxByBase = new Map(); + return bases.map((b) => { + if ((countByBase.get(b) ?? 1) <= 1) return b; + const idx = (idxByBase.get(b) ?? 0) + 1; + idxByBase.set(b, idx); + return `${b} #${idx}`; + }); + })(); + + // Latest-active pane + const latestIdx = groups.reduce((best, g, i) => { + const filtered = g.messages.filter((m) => m.type !== "tool_status"); + const lm = last(filtered); + if (!lm) return best; + if (best < 0) return i; + const bm = last( + groups[best].messages.filter((m) => m.type !== "tool_status") + ); + if (!bm) return i; + return (lm.createdAt ?? 0) >= (bm.createdAt ?? 0) ? i : best; + }, -1); + + // Per-group finished detection (same logic as MuxPane) + const finishedFlags = groups.map((g) => { + const lt = last(g.messages.filter((m) => m.type === "tool_status")); + if (!lt) return false; + try { + const p = JSON.parse(lt.content); + const tools: { name: string; done: boolean }[] = p.tools || []; + if (!p.allDone || tools.length === 0) return false; + return tools.some( + (t) => t.done && (t.name === "set_output" || t.name === "report_to_parent") + ); + } catch { return false; } + }); + const activeCount = finishedFlags.filter((f) => !f).length; + + if (groups.length === 0) return null; + + // Grid sizing: 2 columns, auto rows capped at a fixed height + const rows = Math.ceil(groups.length / 2); + const gridHeight = expanded + ? Math.min(rows * 200, 480) + : Math.min(rows * 100, 240); + + return ( +
+ {/* Left icon */} +
+ +
+ +
+ {/* Header */} +
+ + {groups.length === 1 ? "Sub-agent" : "Parallel Agents"} + + + {activeCount > 0 ? `${activeCount} running` : `${groups.length} done`} + + +
+ + {/* Mux frame */} +
+ {/* Grid */} +
{groups.map((group, i) => ( - + setZoomedIdx(zoomedIdx === i ? null : i) + } /> ))}
- )} +
-
- ); -}, -(prev, next) => - prev.groupId === next.groupId && - prev.groups.length === next.groups.length && - prev.groups.every( - (g, i) => - g.nodeId === next.groups[i].nodeId && - g.messages.length === next.groups[i].messages.length && - last(g.messages)?.content === last(next.groups[i].messages)?.content && - g.contextUsage?.usagePct === next.groups[i].contextUsage?.usagePct - )); + ); + }, + (prev, next) => + prev.groupId === next.groupId && + prev.groups.length === next.groups.length && + prev.groups.every( + (g, i) => + g.nodeId === next.groups[i].nodeId && + g.messages.length === next.groups[i].messages.length && + last(g.messages)?.content === last(next.groups[i].messages)?.content && + g.contextUsage?.usagePct === next.groups[i].contextUsage?.usagePct + ) +); export default ParallelSubagentBubble; + +// Injected as a global style (keyframes can't be inline) +if (typeof document !== "undefined") { + const id = "parallel-subagent-keyframes"; + if (!document.getElementById(id)) { + const style = document.createElement("style"); + style.id = id; + style.textContent = ` + @keyframes cursorBlink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } + @keyframes thermoPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + `; + document.head.appendChild(style); + } +} From e92caeef24d08f847641d2c6b60d09bf8c359ede Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 20:06:31 -0700 Subject: [PATCH 09/11] fix: line too long in google_sheets_tool Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tools/google_sheets_tool/google_sheets_tool.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py b/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py index 195ab917..82e50d2f 100644 --- a/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py +++ b/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py @@ -417,7 +417,9 @@ def register_tools( if isinstance(values, str): values = json.loads(values) if not isinstance(values, list): - return {"error": f"values must be a 2D list or JSON string, got {type(values).__name__}"} + return { + "error": f"values must be a 2D list or JSON string, got {type(values).__name__}" + } client = _get_client() if isinstance(client, dict): return client @@ -458,7 +460,9 @@ def register_tools( if isinstance(values, str): values = json.loads(values) if not isinstance(values, list): - return {"error": f"values must be a 2D list or JSON string, got {type(values).__name__}"} + return { + "error": f"values must be a 2D list or JSON string, got {type(values).__name__}" + } client = _get_client() if isinstance(client, dict): return client From 377cd39c2a59386330d683d31a0e7dac494ce13c Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 20:07:42 -0700 Subject: [PATCH 10/11] chore: lint --- .../aden_tools/tools/google_sheets_tool/google_sheets_tool.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py b/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py index 82e50d2f..3d57c67d 100644 --- a/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py +++ b/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py @@ -414,6 +414,7 @@ def register_tools( """ # Accept stringified JSON and deserialize import json + if isinstance(values, str): values = json.loads(values) if not isinstance(values, list): @@ -457,6 +458,7 @@ def register_tools( """ # Accept stringified JSON and deserialize import json + if isinstance(values, str): values = json.loads(values) if not isinstance(values, list): From fd4dc1a69a11c30b62c8f76067eb9d47d80c4082 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 19 Mar 2026 20:13:18 -0700 Subject: [PATCH 11/11] fix: google_sheets JSON parse error before credentials check Move _get_client() before JSON deserialization so missing-credentials errors aren't masked by input validation. Wrap json.loads in try/except for non-JSON string inputs. --- .../google_sheets_tool/google_sheets_tool.py | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py b/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py index 3d57c67d..aa09aea2 100644 --- a/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py +++ b/tools/src/aden_tools/tools/google_sheets_tool/google_sheets_tool.py @@ -412,18 +412,22 @@ def register_tools( Returns: Dict with update result or error """ + # Credentials check first so missing-creds errors aren't masked + client = _get_client() + if isinstance(client, dict): + return client # Accept stringified JSON and deserialize import json if isinstance(values, str): - values = json.loads(values) + try: + values = json.loads(values) + except (json.JSONDecodeError, ValueError): + return {"error": "values is not valid JSON"} if not isinstance(values, list): return { "error": f"values must be a 2D list or JSON string, got {type(values).__name__}" } - client = _get_client() - if isinstance(client, dict): - return client try: return client.update_values(spreadsheet_id, range_name, values, value_input_option) except httpx.TimeoutException: @@ -456,18 +460,22 @@ def register_tools( Returns: Dict with append result or error """ + # Credentials check first so missing-creds errors aren't masked + client = _get_client() + if isinstance(client, dict): + return client # Accept stringified JSON and deserialize import json if isinstance(values, str): - values = json.loads(values) + try: + values = json.loads(values) + except (json.JSONDecodeError, ValueError): + return {"error": "values is not valid JSON"} if not isinstance(values, list): return { "error": f"values must be a 2D list or JSON string, got {type(values).__name__}" } - client = _get_client() - if isinstance(client, dict): - return client try: return client.append_values(spreadsheet_id, range_name, values, value_input_option) except httpx.TimeoutException: