subagent processing updates and fixes
This commit is contained in:
parent
d7479d54e1
commit
c5add0fc7d
@ -1,31 +1,17 @@
|
|||||||
import { useState, memo, useCallback, useEffect, useRef } from "react";
|
import { memo } from "react";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import type { ChatTurn as ChatTurnType, ChatTurnBlock } from "../lib/api";
|
import type { ChatTurn as ChatTurnType, ChatTurnBlockTool } from "../lib/api";
|
||||||
|
|
||||||
interface ChatTurnProps {
|
interface ChatTurnProps {
|
||||||
turn: ChatTurnType;
|
turn: ChatTurnType;
|
||||||
onUpdate: (turnId: string, updates: Partial<ChatTurnType>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure marked with breaks enabled
|
// Configure marked with breaks enabled
|
||||||
marked.setOptions({
|
marked.use({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ChatTurn = memo(function ChatTurn({ turn, onUpdate }: ChatTurnProps) {
|
const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) {
|
||||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
|
||||||
const [toolCallsExpanded, setToolCallsExpanded] = useState(true);
|
|
||||||
const [renderedBlocks, setRenderedBlocks] = useState<Map<number, string>>(new Map());
|
|
||||||
const blocksEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleThinkingToggle = useCallback(() => {
|
|
||||||
setThinkingExpanded((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToolCallsToggle = useCallback(() => {
|
|
||||||
setToolCallsExpanded((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatDuration = (ms: number): string => {
|
const formatDuration = (ms: number): string => {
|
||||||
if (ms < 1000) return `${ms}ms`;
|
if (ms < 1000) return `${ms}ms`;
|
||||||
const seconds = (ms / 1000).toFixed(1);
|
const seconds = (ms / 1000).toFixed(1);
|
||||||
@ -145,15 +131,22 @@ const ChatTurn = memo(function ChatTurn({ turn, onUpdate }: ChatTurnProps) {
|
|||||||
);
|
);
|
||||||
} else if (block.mode === 'tool') {
|
} else if (block.mode === 'tool') {
|
||||||
const toolCall = block.content;
|
const toolCall = block.content;
|
||||||
|
const subagent = toolCall.subagent;
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="mb-3">
|
<div key={idx} className="mb-3">
|
||||||
<div className="flex items-center gap-2 text-xs font-mono text-text-secondary">
|
<div className="flex items-center gap-2 text-xs font-mono text-text-secondary">
|
||||||
<span className="text-brand">●</span>
|
<span className="text-brand">●</span>
|
||||||
<span>{toolCall.name}</span>
|
<span>{toolCall.name}</span>
|
||||||
{toolCall.response && (
|
{toolCall.response && !subagent && (
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
)}
|
||||||
|
{subagent && subagent.stats && (
|
||||||
<span className="text-green-500">✓</span>
|
<span className="text-green-500">✓</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{subagent && (
|
||||||
|
<SubagentDisplay subagent={subagent} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -170,4 +163,97 @@ const ChatTurn = memo(function ChatTurn({ turn, onUpdate }: ChatTurnProps) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function SubagentDisplay({ subagent }: { subagent: NonNullable<ChatTurnBlockTool['content']['subagent']> }) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const toggle = useCallback(() => setExpanded((p) => !p), []);
|
||||||
|
|
||||||
|
const formatDuration = (ms: number): string => {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const seconds = (ms / 1000).toFixed(1);
|
||||||
|
return `${seconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTokenCount = (count: number): string => {
|
||||||
|
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
||||||
|
return count.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 ml-4 border-l-2 border-brand/30 pl-3">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-mono text-text-muted hover:text-text-primary transition-colors w-full text-left"
|
||||||
|
>
|
||||||
|
<span className="text-brand">▾</span>
|
||||||
|
<span>Subagent</span>
|
||||||
|
{subagent.stats && (
|
||||||
|
<span className="text-text-muted">
|
||||||
|
({subagent.toolCalls.length} calls · {formatDuration(subagent.stats.durationMs)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{/* Thinking */}
|
||||||
|
{subagent.thinking && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-text-muted font-mono mb-1">Thinking</div>
|
||||||
|
<div
|
||||||
|
className="p-2 bg-bg-secondary rounded text-xs text-text-muted whitespace-pre-wrap font-mono"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: marked.parse(subagent.thinking) as string,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subagent Tool Calls */}
|
||||||
|
{subagent.toolCalls.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-text-muted font-mono mb-1">
|
||||||
|
Tool Calls ({subagent.toolCalls.length})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{subagent.toolCalls.map((tc, i) => (
|
||||||
|
<div key={tc.callId || i} className="flex items-center gap-2 text-xs font-mono text-text-secondary">
|
||||||
|
<span className="text-yellow-500">◆</span>
|
||||||
|
<span>{tc.name}</span>
|
||||||
|
{tc.response && <span className="text-green-500">✓</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Response */}
|
||||||
|
{subagent.response && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-text-muted font-mono mb-1">Response</div>
|
||||||
|
<div
|
||||||
|
className="text-xs text-text-primary gadget-markdown"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: marked.parse(subagent.response) as string,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{subagent.stats && (
|
||||||
|
<div className="flex gap-3 text-xs text-text-muted font-mono pt-1 border-t border-border-subtle">
|
||||||
|
<span>{formatDuration(subagent.stats.durationMs)}</span>
|
||||||
|
<span>{formatTokenCount(subagent.stats.inputTokens)} in</span>
|
||||||
|
<span>{formatTokenCount(subagent.stats.responseTokens)} out</span>
|
||||||
|
{subagent.stats.thinkingTokenCount > 0 && (
|
||||||
|
<span>{formatTokenCount(subagent.stats.thinkingTokenCount)} thinking</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default ChatTurn;
|
export default ChatTurn;
|
||||||
|
|||||||
@ -92,6 +92,7 @@ input, textarea {
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
/* Prevent the markdown root from stretching to fill the scroll container —
|
/* Prevent the markdown root from stretching to fill the scroll container —
|
||||||
display:inline-block shrinks it to content width, so the
|
display:inline-block shrinks it to content width, so the
|
||||||
|
|||||||
@ -399,6 +399,18 @@ export interface ChatTurnBlockTool {
|
|||||||
name: string;
|
name: string;
|
||||||
parameters?: string;
|
parameters?: string;
|
||||||
response?: string;
|
response?: string;
|
||||||
|
subagent?: {
|
||||||
|
agentId: string;
|
||||||
|
thinking: string;
|
||||||
|
response: string;
|
||||||
|
toolCalls: Array<{
|
||||||
|
callId: string;
|
||||||
|
name: string;
|
||||||
|
parameters?: string;
|
||||||
|
response?: string;
|
||||||
|
}>;
|
||||||
|
stats: ChatTurnStats;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -422,6 +434,7 @@ export interface ChatTurn {
|
|||||||
name: string;
|
name: string;
|
||||||
parameters?: string;
|
parameters?: string;
|
||||||
response?: string;
|
response?: string;
|
||||||
|
subagent?: ChatTurnBlockTool['content']['subagent'];
|
||||||
}>;
|
}>;
|
||||||
subagents: any[];
|
subagents: any[];
|
||||||
stats: ChatTurnStats;
|
stats: ChatTurnStats;
|
||||||
|
|||||||
@ -90,7 +90,19 @@ export interface SocketEvents {
|
|||||||
tool: string;
|
tool: string;
|
||||||
result: unknown;
|
result: unknown;
|
||||||
}) => void;
|
}) => void;
|
||||||
"agent:complete": (data: { agentId: string }) => void;
|
"agent:complete": (data: {
|
||||||
|
agentId: string;
|
||||||
|
response?: string;
|
||||||
|
subagent?: Record<string, unknown>;
|
||||||
|
stats?: {
|
||||||
|
toolCallCount: number;
|
||||||
|
inputTokens: number;
|
||||||
|
thinkingTokenCount: number;
|
||||||
|
responseTokens: number;
|
||||||
|
durationMs: number;
|
||||||
|
durationLabel: string;
|
||||||
|
};
|
||||||
|
}) => void;
|
||||||
"log:entry": (data: {
|
"log:entry": (data: {
|
||||||
level: string;
|
level: string;
|
||||||
component: string;
|
component: string;
|
||||||
@ -216,6 +228,26 @@ class SocketClient {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.socket.on("agent:thinking", (data: unknown) => {
|
||||||
|
this.emit("agent:thinking", data as { agentId: string; thinking: string });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("agent:response", (data: unknown) => {
|
||||||
|
this.emit("agent:response", data as { agentId: string; chunk: string });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("agent:tool-call", (data: unknown) => {
|
||||||
|
this.emit("agent:tool-call", data as { agentId: string; tool: string; args: unknown });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("agent:tool-result", (data: unknown) => {
|
||||||
|
this.emit("agent:tool-result", data as { agentId: string; tool: string; result: unknown });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("agent:complete", (data: unknown) => {
|
||||||
|
this.emit("agent:complete", data as { agentId: string; response?: string; subagent?: Record<string, unknown>; stats?: Record<string, unknown> });
|
||||||
|
});
|
||||||
|
|
||||||
this._socket.on("connect", () => {
|
this._socket.on("connect", () => {
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.emit("connect");
|
this.emit("connect");
|
||||||
|
|||||||
@ -25,6 +25,21 @@ interface StreamingState {
|
|||||||
currentBlockIndex: number | null;
|
currentBlockIndex: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SubagentStreamState {
|
||||||
|
agentId: string;
|
||||||
|
name: string;
|
||||||
|
parameters: string;
|
||||||
|
thinking: string;
|
||||||
|
response: string;
|
||||||
|
toolCalls: Array<{
|
||||||
|
callId: string;
|
||||||
|
name: string;
|
||||||
|
parameters?: string;
|
||||||
|
response?: string;
|
||||||
|
}>;
|
||||||
|
stats?: ChatTurnStats;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChatSessionView() {
|
export default function ChatSessionView() {
|
||||||
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
|
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -63,6 +78,7 @@ export default function ChatSessionView() {
|
|||||||
const updateRafRef = useRef<number | null>(null);
|
const updateRafRef = useRef<number | null>(null);
|
||||||
const currentTurnIdRef = useRef<string | null>(null);
|
const currentTurnIdRef = useRef<string | null>(null);
|
||||||
const streamingStateRef = useRef<Map<string, StreamingState>>(new Map());
|
const streamingStateRef = useRef<Map<string, StreamingState>>(new Map());
|
||||||
|
const subagentStateRef = useRef<Map<string, SubagentStreamState>>(new Map());
|
||||||
const sessionRef = useRef<ChatSession | null>(null);
|
const sessionRef = useRef<ChatSession | null>(null);
|
||||||
const projectRef = useRef<Project | null>(null);
|
const projectRef = useRef<Project | null>(null);
|
||||||
|
|
||||||
@ -202,6 +218,11 @@ export default function ChatSessionView() {
|
|||||||
socketClient.on('sessionUpdated', handleSessionUpdated);
|
socketClient.on('sessionUpdated', handleSessionUpdated);
|
||||||
socketClient.on('log:entry', handleLogEntry);
|
socketClient.on('log:entry', handleLogEntry);
|
||||||
socketClient.on('status', handleStatus);
|
socketClient.on('status', handleStatus);
|
||||||
|
socketClient.on('agent:thinking', handleAgentThinking);
|
||||||
|
socketClient.on('agent:response', handleAgentResponse);
|
||||||
|
socketClient.on('agent:tool-call', handleAgentToolCall);
|
||||||
|
socketClient.on('agent:tool-result', handleAgentToolResult);
|
||||||
|
socketClient.on('agent:complete', handleAgentComplete);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanupSocketListeners = () => {
|
const cleanupSocketListeners = () => {
|
||||||
@ -213,6 +234,11 @@ export default function ChatSessionView() {
|
|||||||
socketClient.off('sessionUpdated', handleSessionUpdated);
|
socketClient.off('sessionUpdated', handleSessionUpdated);
|
||||||
socketClient.off('log:entry', handleLogEntry);
|
socketClient.off('log:entry', handleLogEntry);
|
||||||
socketClient.off('status', handleStatus);
|
socketClient.off('status', handleStatus);
|
||||||
|
socketClient.off('agent:thinking', handleAgentThinking);
|
||||||
|
socketClient.off('agent:response', handleAgentResponse);
|
||||||
|
socketClient.off('agent:tool-call', handleAgentToolCall);
|
||||||
|
socketClient.off('agent:tool-result', handleAgentToolResult);
|
||||||
|
socketClient.off('agent:complete', handleAgentComplete);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleUpdate = useCallback(() => {
|
const scheduleUpdate = useCallback(() => {
|
||||||
@ -241,10 +267,18 @@ export default function ChatSessionView() {
|
|||||||
const updatedBlocks = [...(oldTurn.blocks || [])];
|
const updatedBlocks = [...(oldTurn.blocks || [])];
|
||||||
|
|
||||||
for (const updateBlock of turnUpdates.blocks) {
|
for (const updateBlock of turnUpdates.blocks) {
|
||||||
let blockIndex = state?.currentBlockIndex ?? null;
|
let blockIndex: number | null = state?.currentBlockIndex ?? null;
|
||||||
|
|
||||||
if (updateBlock.mode === 'tool') {
|
if (updateBlock.mode === 'tool') {
|
||||||
blockIndex = null;
|
// For tool blocks with callId, find and replace existing
|
||||||
|
if (updateBlock.content?.callId) {
|
||||||
|
const existingIndex = updatedBlocks.findIndex(
|
||||||
|
b => b.mode === 'tool' && b.content?.callId === updateBlock.content?.callId
|
||||||
|
);
|
||||||
|
blockIndex = existingIndex !== -1 ? existingIndex : null;
|
||||||
|
} else {
|
||||||
|
blockIndex = null;
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
blockIndex === null ||
|
blockIndex === null ||
|
||||||
updatedBlocks[blockIndex]?.mode !== updateBlock.mode
|
updatedBlocks[blockIndex]?.mode !== updateBlock.mode
|
||||||
@ -300,10 +334,26 @@ export default function ChatSessionView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (existing?.blocks || updates.blocks) {
|
if (existing?.blocks || updates.blocks) {
|
||||||
merged.blocks = [
|
const existingBlocks = [...(existing?.blocks || [])];
|
||||||
...(existing?.blocks || []),
|
const updateBlocks = updates.blocks || [];
|
||||||
...(updates.blocks || []),
|
|
||||||
];
|
// Deduplicate tool blocks by callId: replace existing tool blocks
|
||||||
|
// that have the same callId as an incoming update block
|
||||||
|
merged.blocks = [...existingBlocks];
|
||||||
|
for (const block of updateBlocks) {
|
||||||
|
if (block.mode === 'tool' && block.content?.callId) {
|
||||||
|
const existingIdx = merged.blocks.findIndex(
|
||||||
|
b => b.mode === 'tool' && b.content?.callId === block.content?.callId
|
||||||
|
);
|
||||||
|
if (existingIdx !== -1) {
|
||||||
|
merged.blocks[existingIdx] = block;
|
||||||
|
} else {
|
||||||
|
merged.blocks.push(block);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
merged.blocks.push(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing?.toolCalls || updates.toolCalls) {
|
if (existing?.toolCalls || updates.toolCalls) {
|
||||||
@ -435,6 +485,18 @@ export default function ChatSessionView() {
|
|||||||
state.currentBlockIndex = null;
|
state.currentBlockIndex = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize subagent state for subagent tool calls
|
||||||
|
if (name === 'subagent') {
|
||||||
|
subagentStateRef.current.set(callId, {
|
||||||
|
agentId: callId,
|
||||||
|
name,
|
||||||
|
parameters: params,
|
||||||
|
thinking: '',
|
||||||
|
response: '',
|
||||||
|
toolCalls: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add tool block
|
// Add tool block
|
||||||
mergePendingUpdate(turnId, {
|
mergePendingUpdate(turnId, {
|
||||||
blocks: [{
|
blocks: [{
|
||||||
@ -458,6 +520,12 @@ export default function ChatSessionView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up subagent state for this turn
|
||||||
|
const subagentEntries = Array.from(subagentStateRef.current.entries());
|
||||||
|
for (const [agentId] of subagentEntries) {
|
||||||
|
subagentStateRef.current.delete(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
setTurns(prevTurns =>
|
setTurns(prevTurns =>
|
||||||
prevTurns.map(turn =>
|
prevTurns.map(turn =>
|
||||||
turn._id === turnId
|
turn._id === turnId
|
||||||
@ -494,6 +562,131 @@ export default function ChatSessionView() {
|
|||||||
appContext?.setStatusMessage(content);
|
appContext?.setStatusMessage(content);
|
||||||
}, [appContext]);
|
}, [appContext]);
|
||||||
|
|
||||||
|
const defaultSubagentStats: ChatTurnStats = {
|
||||||
|
toolCallCount: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
thinkingTokenCount: 0,
|
||||||
|
responseTokens: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
durationLabel: '0ms',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSubagentBlock = useCallback((subState: SubagentStreamState): ChatTurnBlock => ({
|
||||||
|
mode: 'tool' as const,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
content: {
|
||||||
|
callId: subState.agentId,
|
||||||
|
name: subState.name,
|
||||||
|
parameters: subState.parameters,
|
||||||
|
response: subState.response,
|
||||||
|
subagent: {
|
||||||
|
agentId: subState.agentId,
|
||||||
|
thinking: subState.thinking,
|
||||||
|
response: subState.response,
|
||||||
|
toolCalls: subState.toolCalls,
|
||||||
|
stats: subState.stats || defaultSubagentStats,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const updateSubagentBlock = useCallback((turnId: string, agentId: string) => {
|
||||||
|
const subState = subagentStateRef.current.get(agentId);
|
||||||
|
if (!subState) return;
|
||||||
|
mergePendingUpdate(turnId, {
|
||||||
|
blocks: [buildSubagentBlock(subState)],
|
||||||
|
});
|
||||||
|
scheduleUpdate();
|
||||||
|
}, [mergePendingUpdate, scheduleUpdate, buildSubagentBlock]);
|
||||||
|
|
||||||
|
const handleAgentThinking = useCallback((data: { agentId: string; thinking: string }) => {
|
||||||
|
const turnId = currentTurnIdRef.current;
|
||||||
|
if (!turnId) return;
|
||||||
|
|
||||||
|
let subState = subagentStateRef.current.get(data.agentId);
|
||||||
|
if (!subState) {
|
||||||
|
subState = { agentId: data.agentId, name: '', parameters: '', thinking: '', response: '', toolCalls: [] };
|
||||||
|
subagentStateRef.current.set(data.agentId, subState);
|
||||||
|
}
|
||||||
|
subState.thinking += data.thinking;
|
||||||
|
updateSubagentBlock(turnId, data.agentId);
|
||||||
|
}, [updateSubagentBlock]);
|
||||||
|
|
||||||
|
const handleAgentResponse = useCallback((data: { agentId: string; chunk: string }) => {
|
||||||
|
const turnId = currentTurnIdRef.current;
|
||||||
|
if (!turnId) return;
|
||||||
|
|
||||||
|
let subState = subagentStateRef.current.get(data.agentId);
|
||||||
|
if (!subState) {
|
||||||
|
subState = { agentId: data.agentId, name: '', parameters: '', thinking: '', response: '', toolCalls: [] };
|
||||||
|
subagentStateRef.current.set(data.agentId, subState);
|
||||||
|
}
|
||||||
|
subState.response += data.chunk;
|
||||||
|
updateSubagentBlock(turnId, data.agentId);
|
||||||
|
}, [updateSubagentBlock]);
|
||||||
|
|
||||||
|
const handleAgentToolCall = useCallback((data: { agentId: string; tool: string; args: unknown }) => {
|
||||||
|
const turnId = currentTurnIdRef.current;
|
||||||
|
if (!turnId) return;
|
||||||
|
|
||||||
|
let subState = subagentStateRef.current.get(data.agentId);
|
||||||
|
if (!subState) {
|
||||||
|
subState = { agentId: data.agentId, name: '', parameters: '', thinking: '', response: '', toolCalls: [] };
|
||||||
|
subagentStateRef.current.set(data.agentId, subState);
|
||||||
|
}
|
||||||
|
const argsStr = typeof data.args === 'string' ? data.args : JSON.stringify(data.args);
|
||||||
|
subState.toolCalls.push({ callId: `tc_${Date.now()}_${subState.toolCalls.length}`, name: data.tool, parameters: argsStr });
|
||||||
|
updateSubagentBlock(turnId, data.agentId);
|
||||||
|
}, [updateSubagentBlock]);
|
||||||
|
|
||||||
|
const handleAgentToolResult = useCallback((data: { agentId: string; tool: string; result: unknown }) => {
|
||||||
|
const turnId = currentTurnIdRef.current;
|
||||||
|
if (!turnId) return;
|
||||||
|
|
||||||
|
const subState = subagentStateRef.current.get(data.agentId);
|
||||||
|
if (!subState) return;
|
||||||
|
|
||||||
|
const lastToolCall = subState.toolCalls[subState.toolCalls.length - 1];
|
||||||
|
if (lastToolCall && lastToolCall.name === data.tool) {
|
||||||
|
lastToolCall.response = typeof data.result === 'string' ? data.result : JSON.stringify(data.result);
|
||||||
|
}
|
||||||
|
updateSubagentBlock(turnId, data.agentId);
|
||||||
|
}, [updateSubagentBlock]);
|
||||||
|
|
||||||
|
const handleAgentComplete = useCallback((data: { agentId: string; response?: string; subagent?: Record<string, unknown>; stats?: ChatTurnStats }) => {
|
||||||
|
const turnId = currentTurnIdRef.current;
|
||||||
|
if (!turnId) return;
|
||||||
|
|
||||||
|
const subState = subagentStateRef.current.get(data.agentId);
|
||||||
|
if (!subState) return;
|
||||||
|
|
||||||
|
// Apply final response if provided
|
||||||
|
if (data.response) {
|
||||||
|
subState.response = data.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply stats from agent:complete
|
||||||
|
if (data.stats) {
|
||||||
|
subState.stats = data.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If subagent data was passed directly (e.g., from agent:complete with full payload),
|
||||||
|
// apply the thinking and toolCalls from it
|
||||||
|
if (data.subagent) {
|
||||||
|
const sa = data.subagent as Record<string, unknown>;
|
||||||
|
if (typeof sa.thinking === 'string') subState.thinking = sa.thinking;
|
||||||
|
if (typeof sa.response === 'string') subState.response = sa.response;
|
||||||
|
if (Array.isArray(sa.toolCalls)) subState.toolCalls = sa.toolCalls as SubagentStreamState['toolCalls'];
|
||||||
|
if (sa.stats) subState.stats = { ...defaultSubagentStats, ...(sa.stats as Record<string, unknown>) } as ChatTurnStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSubagentBlock(turnId, data.agentId);
|
||||||
|
|
||||||
|
// Clean up subagent state after final update
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
subagentStateRef.current.delete(data.agentId);
|
||||||
|
});
|
||||||
|
}, [updateSubagentBlock]);
|
||||||
|
|
||||||
const showToast = useCallback((message: string) => {
|
const showToast = useCallback((message: string) => {
|
||||||
setToast(message);
|
setToast(message);
|
||||||
if (toastTimerRef.current) {
|
if (toastTimerRef.current) {
|
||||||
@ -675,8 +868,7 @@ export default function ChatSessionView() {
|
|||||||
mode: session?.mode || 'develop',
|
mode: session?.mode || 'develop',
|
||||||
status: 'processing',
|
status: 'processing',
|
||||||
prompts: { user: promptInput.trim() },
|
prompts: { user: promptInput.trim() },
|
||||||
thinking: '',
|
blocks: [],
|
||||||
response: '',
|
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
subagents: [],
|
subagents: [],
|
||||||
stats: {
|
stats: {
|
||||||
@ -723,14 +915,6 @@ export default function ChatSessionView() {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTurnUpdate = useCallback((turnId: string, updates: Partial<ChatTurn>) => {
|
|
||||||
setTurns(prevTurns =>
|
|
||||||
prevTurns.map(turn =>
|
|
||||||
turn._id === turnId ? { ...turn, ...updates } : turn
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider;
|
const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider;
|
||||||
const promptPlaceholder = isEditingProvider
|
const promptPlaceholder = isEditingProvider
|
||||||
? 'Save or cancel provider changes to continue.'
|
? 'Save or cancel provider changes to continue.'
|
||||||
@ -786,7 +970,6 @@ export default function ChatSessionView() {
|
|||||||
<ChatTurnComponent
|
<ChatTurnComponent
|
||||||
key={turn._id}
|
key={turn._id}
|
||||||
turn={turn}
|
turn={turn}
|
||||||
onUpdate={handleTurnUpdate}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
ChatTurnStatus,
|
ChatTurnStatus,
|
||||||
GadgetId,
|
GadgetId,
|
||||||
WorkspaceMode,
|
WorkspaceMode,
|
||||||
|
IChatToolCall,
|
||||||
|
IChatTurnBlock,
|
||||||
} from "@gadget/api";
|
} from "@gadget/api";
|
||||||
import {
|
import {
|
||||||
GadgetSocket,
|
GadgetSocket,
|
||||||
@ -62,6 +64,12 @@ export class DroneSession extends SocketSession {
|
|||||||
this.socket.on("requestTermination", this.onRequestTermination.bind(this));
|
this.socket.on("requestTermination", this.onRequestTermination.bind(this));
|
||||||
|
|
||||||
this.socket.on("log", this.onLog.bind(this));
|
this.socket.on("log", this.onLog.bind(this));
|
||||||
|
|
||||||
|
this.socket.on("agent:thinking", this.onAgentThinking.bind(this));
|
||||||
|
this.socket.on("agent:response", this.onAgentResponse.bind(this));
|
||||||
|
this.socket.on("agent:tool-call", this.onAgentToolCall.bind(this));
|
||||||
|
this.socket.on("agent:tool-result", this.onAgentToolResult.bind(this));
|
||||||
|
this.socket.on("agent:complete", this.onAgentComplete.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async onLog(
|
async onLog(
|
||||||
@ -350,6 +358,78 @@ export class DroneSession extends SocketSession {
|
|||||||
codeSession.onWorkspaceModeChanged(mode);
|
codeSession.onWorkspaceModeChanged(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onAgentThinking(data: { agentId: string; thinking: string }): Promise<void> {
|
||||||
|
if (!this.chatSessionId) return;
|
||||||
|
try {
|
||||||
|
const codeSession = SocketService.getCodeSessionByChatSessionId(this.chatSessionId);
|
||||||
|
codeSession.socket.emit("agent:thinking", data);
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to route agent:thinking", { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAgentResponse(data: { agentId: string; chunk: string }): Promise<void> {
|
||||||
|
if (!this.chatSessionId) return;
|
||||||
|
try {
|
||||||
|
const codeSession = SocketService.getCodeSessionByChatSessionId(this.chatSessionId);
|
||||||
|
codeSession.socket.emit("agent:response", data);
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to route agent:response", { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAgentToolCall(data: { agentId: string; tool: string; args: unknown }): Promise<void> {
|
||||||
|
if (!this.chatSessionId) return;
|
||||||
|
try {
|
||||||
|
const codeSession = SocketService.getCodeSessionByChatSessionId(this.chatSessionId);
|
||||||
|
codeSession.socket.emit("agent:tool-call", data);
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to route agent:tool-call", { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAgentToolResult(data: { agentId: string; tool: string; result: unknown }): Promise<void> {
|
||||||
|
if (!this.chatSessionId) return;
|
||||||
|
try {
|
||||||
|
const codeSession = SocketService.getCodeSessionByChatSessionId(this.chatSessionId);
|
||||||
|
codeSession.socket.emit("agent:tool-result", data);
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to route agent:tool-result", { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAgentComplete(data: { agentId: string; response?: string; subagent?: Record<string, unknown>; stats?: Record<string, unknown> }): Promise<void> {
|
||||||
|
if (!this.chatSessionId) return;
|
||||||
|
try {
|
||||||
|
const codeSession = SocketService.getCodeSessionByChatSessionId(this.chatSessionId);
|
||||||
|
codeSession.socket.emit("agent:complete", data);
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to route agent:complete to frontend", { error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the persisted tool call with the final response and subagent data
|
||||||
|
if (this.currentTurnId && data.agentId) {
|
||||||
|
try {
|
||||||
|
const turn = await ChatTurn.findById(this.currentTurnId);
|
||||||
|
if (turn) {
|
||||||
|
const toolCall = turn.toolCalls.find((tc: IChatToolCall) => tc.callId === data.agentId);
|
||||||
|
if (toolCall) {
|
||||||
|
if (data.response) toolCall.response = data.response;
|
||||||
|
if (data.subagent) (toolCall as any).subagent = data.subagent;
|
||||||
|
}
|
||||||
|
const block = turn.blocks.find((b: IChatTurnBlock) => b.mode === 'tool' && (b.content as IChatToolCall).callId === data.agentId);
|
||||||
|
if (block && block.mode === 'tool') {
|
||||||
|
if (data.response) (block.content as IChatToolCall).response = data.response;
|
||||||
|
if (data.subagent) (block.content as any).subagent = data.subagent;
|
||||||
|
}
|
||||||
|
await turn.save();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to update subagent tool call in DB", { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the drone requests crash recovery for an incomplete work order.
|
* Called when the drone requests crash recovery for an incomplete work order.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export const ChatToolCallSchema = new Schema<IChatToolCall>({
|
|||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
parameters: { type: String, required: false },
|
parameters: { type: String, required: false },
|
||||||
response: { type: String, required: false },
|
response: { type: String, required: false },
|
||||||
|
subagent: { type: Schema.Types.Mixed, required: false } as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ChatSubagentProcessSchema = new Schema<IChatSubagentProcess>({
|
export const ChatSubagentProcessSchema = new Schema<IChatSubagentProcess>({
|
||||||
|
|||||||
@ -78,6 +78,7 @@ class AgentService extends GadgetService {
|
|||||||
private toolbox = new AiToolbox(toolboxEnv);
|
private toolbox = new AiToolbox(toolboxEnv);
|
||||||
private currentWorkOrder: IAgentWorkOrder | null = null;
|
private currentWorkOrder: IAgentWorkOrder | null = null;
|
||||||
private currentSocket: DroneSocket | null = null;
|
private currentSocket: DroneSocket | null = null;
|
||||||
|
private currentToolCallId: string | null = null;
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "AgentService";
|
return "AgentService";
|
||||||
@ -250,18 +251,38 @@ class AgentService extends GadgetService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const toolCall of response.toolCalls) {
|
for (const toolCall of response.toolCalls) {
|
||||||
|
this.currentToolCallId = toolCall.callId;
|
||||||
|
|
||||||
|
// Emit tool call start immediately so frontend shows it
|
||||||
|
if (toolCall.function.name === "subagent") {
|
||||||
|
socket.emit("toolCall", toolCall.callId, toolCall.function.name, toolCall.function.arguments, "");
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.executeTool(
|
const result = await this.executeTool(
|
||||||
toolCall.function.name,
|
toolCall.function.name,
|
||||||
toolCall.function.arguments,
|
toolCall.function.arguments,
|
||||||
);
|
);
|
||||||
|
|
||||||
socket.emit(
|
if (toolCall.function.name === "subagent") {
|
||||||
"toolCall",
|
let responseText = result;
|
||||||
toolCall.callId,
|
let subagentData: Record<string, unknown> | undefined;
|
||||||
toolCall.function.name,
|
try {
|
||||||
toolCall.function.arguments,
|
const parsed = JSON.parse(result);
|
||||||
result,
|
if (parsed.success && parsed.data) {
|
||||||
);
|
subagentData = parsed.data;
|
||||||
|
responseText = parsed.data.response || result;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
socket.emit("agent:complete", {
|
||||||
|
agentId: toolCall.callId,
|
||||||
|
response: responseText,
|
||||||
|
subagent: subagentData,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.emit("toolCall", toolCall.callId, toolCall.function.name, toolCall.function.arguments, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentToolCallId = null;
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
createdAt: turn.createdAt,
|
createdAt: turn.createdAt,
|
||||||
@ -412,6 +433,19 @@ class AgentService extends GadgetService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private makeAgentStreamHandler(socket: DroneSocket, agentId: string): IAiResponseStreamFn {
|
||||||
|
return async (chunk) => {
|
||||||
|
switch (chunk.type) {
|
||||||
|
case "thinking":
|
||||||
|
socket.emit("agent:thinking", { agentId, thinking: chunk.data });
|
||||||
|
break;
|
||||||
|
case "response":
|
||||||
|
socket.emit("agent:response", { agentId, chunk: chunk.data });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds an IDroneModelConfig by looking up the model's stored settings
|
* Builds an IDroneModelConfig by looking up the model's stored settings
|
||||||
* from the provider's cached models array. Falls back to safe defaults
|
* from the provider's cached models array. Falls back to safe defaults
|
||||||
@ -539,9 +573,11 @@ class AgentService extends GadgetService {
|
|||||||
{ createdAt: new Date(), role: "user", content: prompt },
|
{ createdAt: new Date(), role: "user", content: prompt },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const agentId = this.currentToolCallId || `sa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const socket = this.currentSocket;
|
||||||
const tools = this.getToolsForSubagent(agentType);
|
const tools = this.getToolsForSubagent(agentType);
|
||||||
const streamHandler = this.currentSocket
|
const streamHandler = socket
|
||||||
? this.makeStreamHandler(this.currentSocket)
|
? this.makeAgentStreamHandler(socket, agentId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
let fullResponse = "";
|
let fullResponse = "";
|
||||||
@ -604,11 +640,20 @@ class AgentService extends GadgetService {
|
|||||||
|
|
||||||
for (const toolCall of response.toolCalls) {
|
for (const toolCall of response.toolCalls) {
|
||||||
const toolArgsRaw = toolCall.function.arguments;
|
const toolArgsRaw = toolCall.function.arguments;
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
socket.emit("agent:tool-call", { agentId, tool: toolCall.function.name, args: toolArgsRaw });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.executeTool(
|
const result = await this.executeTool(
|
||||||
toolCall.function.name,
|
toolCall.function.name,
|
||||||
toolArgsRaw,
|
toolArgsRaw,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
socket.emit("agent:tool-result", { agentId, tool: toolCall.function.name, result });
|
||||||
|
}
|
||||||
|
|
||||||
subagentToolCalls.push({
|
subagentToolCalls.push({
|
||||||
callId: toolCall.callId,
|
callId: toolCall.callId,
|
||||||
name: toolCall.function.name,
|
name: toolCall.function.name,
|
||||||
|
|||||||
@ -52,13 +52,6 @@ export interface IChatTurnStats {
|
|||||||
durationLabel: string; // total turn runtime as hh:mm:ss
|
durationLabel: string; // total turn runtime as hh:mm:ss
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IChatToolCall {
|
|
||||||
callId: string; // ID of the call so the agent can match response to call
|
|
||||||
name: string; // tool function name being called
|
|
||||||
parameters: string; // JSON.stringify of input parameters
|
|
||||||
response: string; // the tool's response
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IChatSubagentProcess {
|
export interface IChatSubagentProcess {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
@ -67,6 +60,14 @@ export interface IChatSubagentProcess {
|
|||||||
stats: IChatTurnStats;
|
stats: IChatTurnStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IChatToolCall {
|
||||||
|
callId: string; // ID of the call so the agent can match response to call
|
||||||
|
name: string; // tool function name being called
|
||||||
|
parameters: string; // JSON.stringify of input parameters
|
||||||
|
response: string; // the tool's response
|
||||||
|
subagent?: IChatSubagentProcess; // subagent execution details, only present for subagent tool calls
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A chat turn is a single prompt/response pair with tool call accounting. It
|
* A chat turn is a single prompt/response pair with tool call accounting. It
|
||||||
* stores all data generated by one run of the Agentic Workflow Loop by a Gadget
|
* stores all data generated by one run of the Agentic Workflow Loop by a Gadget
|
||||||
|
|||||||
@ -66,8 +66,42 @@ export interface ClientToServerEvents {
|
|||||||
requestCrashRecovery: RequestCrashRecoveryMessage;
|
requestCrashRecovery: RequestCrashRecoveryMessage;
|
||||||
requestTermination: RequestTerminationMessage;
|
requestTermination: RequestTerminationMessage;
|
||||||
workspaceModeChanged: WorkspaceModeChangedMessage;
|
workspaceModeChanged: WorkspaceModeChangedMessage;
|
||||||
|
"agent:thinking": AgentThinkingMessage;
|
||||||
|
"agent:response": AgentResponseMessage;
|
||||||
|
"agent:tool-call": AgentToolCallMessage;
|
||||||
|
"agent:tool-result": AgentToolResultMessage;
|
||||||
|
"agent:complete": AgentCompleteMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AgentThinkingMessage = (data: {
|
||||||
|
agentId: string;
|
||||||
|
thinking: string;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type AgentResponseMessage = (data: {
|
||||||
|
agentId: string;
|
||||||
|
chunk: string;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type AgentToolCallMessage = (data: {
|
||||||
|
agentId: string;
|
||||||
|
tool: string;
|
||||||
|
args: unknown;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type AgentToolResultMessage = (data: {
|
||||||
|
agentId: string;
|
||||||
|
tool: string;
|
||||||
|
result: unknown;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type AgentCompleteMessage = (data: {
|
||||||
|
agentId: string;
|
||||||
|
response?: string;
|
||||||
|
subagent?: Record<string, unknown>;
|
||||||
|
stats?: Record<string, unknown>;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
export interface ServerToClientEvents {
|
export interface ServerToClientEvents {
|
||||||
/*
|
/*
|
||||||
* gadget-code:web => gadget-drone
|
* gadget-code:web => gadget-drone
|
||||||
@ -93,6 +127,11 @@ export interface ServerToClientEvents {
|
|||||||
workOrderComplete: WorkOrderCompleteMessage;
|
workOrderComplete: WorkOrderCompleteMessage;
|
||||||
workspaceModeChanged: WorkspaceModeChangedMessage;
|
workspaceModeChanged: WorkspaceModeChangedMessage;
|
||||||
sessionUpdated: SessionUpdatedMessage;
|
sessionUpdated: SessionUpdatedMessage;
|
||||||
|
"agent:thinking": AgentThinkingMessage;
|
||||||
|
"agent:response": AgentResponseMessage;
|
||||||
|
"agent:tool-call": AgentToolCallMessage;
|
||||||
|
"agent:tool-result": AgentToolResultMessage;
|
||||||
|
"agent:complete": AgentCompleteMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SocketData {
|
export interface SocketData {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user