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 type { ChatTurn as ChatTurnType, ChatTurnBlock } from "../lib/api";
|
||||
import type { ChatTurn as ChatTurnType, ChatTurnBlockTool } from "../lib/api";
|
||||
|
||||
interface ChatTurnProps {
|
||||
turn: ChatTurnType;
|
||||
onUpdate: (turnId: string, updates: Partial<ChatTurnType>) => void;
|
||||
}
|
||||
|
||||
// Configure marked with breaks enabled
|
||||
marked.setOptions({
|
||||
marked.use({
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
const ChatTurn = memo(function ChatTurn({ turn, onUpdate }: 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 ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) {
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = (ms / 1000).toFixed(1);
|
||||
@ -145,15 +131,22 @@ const ChatTurn = memo(function ChatTurn({ turn, onUpdate }: ChatTurnProps) {
|
||||
);
|
||||
} else if (block.mode === 'tool') {
|
||||
const toolCall = block.content;
|
||||
const subagent = toolCall.subagent;
|
||||
return (
|
||||
<div key={idx} className="mb-3">
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-text-secondary">
|
||||
<span className="text-brand">●</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>
|
||||
)}
|
||||
</div>
|
||||
{subagent && (
|
||||
<SubagentDisplay subagent={subagent} />
|
||||
)}
|
||||
</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;
|
||||
|
||||
@ -92,6 +92,7 @@ input, textarea {
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
/* Prevent the markdown root from stretching to fill the scroll container —
|
||||
display:inline-block shrinks it to content width, so the
|
||||
|
||||
@ -399,6 +399,18 @@ export interface ChatTurnBlockTool {
|
||||
name: string;
|
||||
parameters?: 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;
|
||||
parameters?: string;
|
||||
response?: string;
|
||||
subagent?: ChatTurnBlockTool['content']['subagent'];
|
||||
}>;
|
||||
subagents: any[];
|
||||
stats: ChatTurnStats;
|
||||
|
||||
@ -90,7 +90,19 @@ export interface SocketEvents {
|
||||
tool: string;
|
||||
result: unknown;
|
||||
}) => 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: {
|
||||
level: 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.reconnectAttempts = 0;
|
||||
this.emit("connect");
|
||||
|
||||
@ -25,6 +25,21 @@ interface StreamingState {
|
||||
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() {
|
||||
const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
@ -63,6 +78,7 @@ export default function ChatSessionView() {
|
||||
const updateRafRef = useRef<number | null>(null);
|
||||
const currentTurnIdRef = useRef<string | null>(null);
|
||||
const streamingStateRef = useRef<Map<string, StreamingState>>(new Map());
|
||||
const subagentStateRef = useRef<Map<string, SubagentStreamState>>(new Map());
|
||||
const sessionRef = useRef<ChatSession | null>(null);
|
||||
const projectRef = useRef<Project | null>(null);
|
||||
|
||||
@ -202,6 +218,11 @@ export default function ChatSessionView() {
|
||||
socketClient.on('sessionUpdated', handleSessionUpdated);
|
||||
socketClient.on('log:entry', handleLogEntry);
|
||||
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 = () => {
|
||||
@ -213,6 +234,11 @@ export default function ChatSessionView() {
|
||||
socketClient.off('sessionUpdated', handleSessionUpdated);
|
||||
socketClient.off('log:entry', handleLogEntry);
|
||||
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(() => {
|
||||
@ -241,10 +267,18 @@ export default function ChatSessionView() {
|
||||
const updatedBlocks = [...(oldTurn.blocks || [])];
|
||||
|
||||
for (const updateBlock of turnUpdates.blocks) {
|
||||
let blockIndex = state?.currentBlockIndex ?? null;
|
||||
let blockIndex: number | null = state?.currentBlockIndex ?? null;
|
||||
|
||||
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 (
|
||||
blockIndex === null ||
|
||||
updatedBlocks[blockIndex]?.mode !== updateBlock.mode
|
||||
@ -300,10 +334,26 @@ export default function ChatSessionView() {
|
||||
};
|
||||
|
||||
if (existing?.blocks || updates.blocks) {
|
||||
merged.blocks = [
|
||||
...(existing?.blocks || []),
|
||||
...(updates.blocks || []),
|
||||
];
|
||||
const existingBlocks = [...(existing?.blocks || [])];
|
||||
const updateBlocks = 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) {
|
||||
@ -435,6 +485,18 @@ export default function ChatSessionView() {
|
||||
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
|
||||
mergePendingUpdate(turnId, {
|
||||
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 =>
|
||||
prevTurns.map(turn =>
|
||||
turn._id === turnId
|
||||
@ -494,6 +562,131 @@ export default function ChatSessionView() {
|
||||
appContext?.setStatusMessage(content);
|
||||
}, [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) => {
|
||||
setToast(message);
|
||||
if (toastTimerRef.current) {
|
||||
@ -675,8 +868,7 @@ export default function ChatSessionView() {
|
||||
mode: session?.mode || 'develop',
|
||||
status: 'processing',
|
||||
prompts: { user: promptInput.trim() },
|
||||
thinking: '',
|
||||
response: '',
|
||||
blocks: [],
|
||||
toolCalls: [],
|
||||
subagents: [],
|
||||
stats: {
|
||||
@ -723,14 +915,6 @@ export default function ChatSessionView() {
|
||||
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 promptPlaceholder = isEditingProvider
|
||||
? 'Save or cancel provider changes to continue.'
|
||||
@ -786,7 +970,6 @@ export default function ChatSessionView() {
|
||||
<ChatTurnComponent
|
||||
key={turn._id}
|
||||
turn={turn}
|
||||
onUpdate={handleTurnUpdate}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
ChatTurnStatus,
|
||||
GadgetId,
|
||||
WorkspaceMode,
|
||||
IChatToolCall,
|
||||
IChatTurnBlock,
|
||||
} from "@gadget/api";
|
||||
import {
|
||||
GadgetSocket,
|
||||
@ -62,6 +64,12 @@ export class DroneSession extends SocketSession {
|
||||
this.socket.on("requestTermination", this.onRequestTermination.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(
|
||||
@ -350,6 +358,78 @@ export class DroneSession extends SocketSession {
|
||||
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.
|
||||
*/
|
||||
|
||||
@ -45,6 +45,7 @@ export const ChatToolCallSchema = new Schema<IChatToolCall>({
|
||||
name: { type: String, required: true },
|
||||
parameters: { type: String, required: false },
|
||||
response: { type: String, required: false },
|
||||
subagent: { type: Schema.Types.Mixed, required: false } as any,
|
||||
});
|
||||
|
||||
export const ChatSubagentProcessSchema = new Schema<IChatSubagentProcess>({
|
||||
|
||||
@ -78,6 +78,7 @@ class AgentService extends GadgetService {
|
||||
private toolbox = new AiToolbox(toolboxEnv);
|
||||
private currentWorkOrder: IAgentWorkOrder | null = null;
|
||||
private currentSocket: DroneSocket | null = null;
|
||||
private currentToolCallId: string | null = null;
|
||||
|
||||
get name(): string {
|
||||
return "AgentService";
|
||||
@ -250,18 +251,38 @@ class AgentService extends GadgetService {
|
||||
});
|
||||
|
||||
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(
|
||||
toolCall.function.name,
|
||||
toolCall.function.arguments,
|
||||
);
|
||||
|
||||
socket.emit(
|
||||
"toolCall",
|
||||
toolCall.callId,
|
||||
toolCall.function.name,
|
||||
toolCall.function.arguments,
|
||||
result,
|
||||
);
|
||||
if (toolCall.function.name === "subagent") {
|
||||
let responseText = result;
|
||||
let subagentData: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(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({
|
||||
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
|
||||
* 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 },
|
||||
];
|
||||
|
||||
const agentId = this.currentToolCallId || `sa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const socket = this.currentSocket;
|
||||
const tools = this.getToolsForSubagent(agentType);
|
||||
const streamHandler = this.currentSocket
|
||||
? this.makeStreamHandler(this.currentSocket)
|
||||
const streamHandler = socket
|
||||
? this.makeAgentStreamHandler(socket, agentId)
|
||||
: undefined;
|
||||
|
||||
let fullResponse = "";
|
||||
@ -604,11 +640,20 @@ class AgentService extends GadgetService {
|
||||
|
||||
for (const toolCall of response.toolCalls) {
|
||||
const toolArgsRaw = toolCall.function.arguments;
|
||||
|
||||
if (socket) {
|
||||
socket.emit("agent:tool-call", { agentId, tool: toolCall.function.name, args: toolArgsRaw });
|
||||
}
|
||||
|
||||
const result = await this.executeTool(
|
||||
toolCall.function.name,
|
||||
toolArgsRaw,
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
socket.emit("agent:tool-result", { agentId, tool: toolCall.function.name, result });
|
||||
}
|
||||
|
||||
subagentToolCalls.push({
|
||||
callId: toolCall.callId,
|
||||
name: toolCall.function.name,
|
||||
|
||||
@ -52,13 +52,6 @@ export interface IChatTurnStats {
|
||||
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 {
|
||||
prompt: string;
|
||||
thinking?: string;
|
||||
@ -67,6 +60,14 @@ export interface IChatSubagentProcess {
|
||||
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
|
||||
* stores all data generated by one run of the Agentic Workflow Loop by a Gadget
|
||||
|
||||
@ -66,8 +66,42 @@ export interface ClientToServerEvents {
|
||||
requestCrashRecovery: RequestCrashRecoveryMessage;
|
||||
requestTermination: RequestTerminationMessage;
|
||||
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 {
|
||||
/*
|
||||
* gadget-code:web => gadget-drone
|
||||
@ -93,6 +127,11 @@ export interface ServerToClientEvents {
|
||||
workOrderComplete: WorkOrderCompleteMessage;
|
||||
workspaceModeChanged: WorkspaceModeChangedMessage;
|
||||
sessionUpdated: SessionUpdatedMessage;
|
||||
"agent:thinking": AgentThinkingMessage;
|
||||
"agent:response": AgentResponseMessage;
|
||||
"agent:tool-call": AgentToolCallMessage;
|
||||
"agent:tool-result": AgentToolResultMessage;
|
||||
"agent:complete": AgentCompleteMessage;
|
||||
}
|
||||
|
||||
export interface SocketData {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user