feat: subagent runtime & CLI display - done

This commit is contained in:
tanzhenxin
2025-09-09 15:53:10 +08:00
parent 4985bfc000
commit 35e996d46c
23 changed files with 767 additions and 684 deletions

View File

@@ -196,7 +196,7 @@ describe('<ToolMessage />', () => {
it('shows subagent execution display for task tool with proper result display', () => {
const subagentResultDisplay = {
type: 'subagent_execution' as const,
type: 'task_execution' as const,
subagentName: 'file-search',
taskDescription: 'Search for files matching pattern',
status: 'running' as const,

View File

@@ -34,7 +34,7 @@ type DisplayRendererResult =
| { type: 'todo'; data: TodoResultDisplay }
| { type: 'string'; data: string }
| { type: 'diff'; data: { fileDiff: string; fileName: string } }
| { type: 'subagent_execution'; data: TaskResultDisplay };
| { type: 'task'; data: TaskResultDisplay };
/**
* Custom hook to determine the type of result display and return appropriate rendering info
@@ -65,10 +65,10 @@ const useResultDisplayRenderer = (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'type' in resultDisplay &&
resultDisplay.type === 'subagent_execution'
resultDisplay.type === 'task_execution'
) {
return {
type: 'subagent_execution',
type: 'task',
data: resultDisplay as TaskResultDisplay,
};
}
@@ -216,7 +216,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
{displayRenderer.type === 'todo' && (
<TodoResultRenderer data={displayRenderer.data} />
)}
{displayRenderer.type === 'subagent_execution' && (
{displayRenderer.type === 'task' && (
<SubagentExecutionRenderer
data={displayRenderer.data}
availableHeight={availableHeight}

View File

@@ -48,12 +48,12 @@ export const AgentViewerStep = ({ selectedAgent }: AgentViewerStepProps) => {
<Text>{toolsDisplay}</Text>
</Box>
{shouldShowColor(agent.backgroundColor) && (
{shouldShowColor(agent.color) && (
<Box>
<Text bold>Color: </Text>
<Box backgroundColor={getColorForDisplay(agent.backgroundColor)}>
<Text color="black">{` ${agent.name} `}</Text>
</Box>
<Text
color={getColorForDisplay(agent.color)}
>{` ${agent.name} `}</Text>
</Box>
)}

View File

@@ -248,7 +248,7 @@ export function AgentsManagerDialog({
return (
<Box flexDirection="column" gap={1}>
<ColorSelector
backgroundColor={selectedAgent?.backgroundColor || 'auto'}
color={selectedAgent?.color || 'auto'}
agentName={selectedAgent?.name || 'Agent'}
onSelect={async (color) => {
// Save changes and reload agents
@@ -258,7 +258,7 @@ export function AgentsManagerDialog({
const subagentManager = config.getSubagentManager();
await subagentManager.updateSubagent(
selectedAgent.name,
{ backgroundColor: color },
{ color },
selectedAgent.level,
);
// Reload agents to get updated state

View File

@@ -14,7 +14,7 @@ import { COLOR_OPTIONS } from './constants.js';
const colorOptions: ColorOption[] = COLOR_OPTIONS;
interface ColorSelectorProps {
backgroundColor?: string;
color?: string;
agentName?: string;
onSelect: (color: string) => void;
}
@@ -23,16 +23,16 @@ interface ColorSelectorProps {
* Color selection with preview.
*/
export function ColorSelector({
backgroundColor = 'auto',
color = 'auto',
agentName = 'Agent',
onSelect,
}: ColorSelectorProps) {
const [selectedColor, setSelectedColor] = useState<string>(backgroundColor);
const [selectedColor, setSelectedColor] = useState<string>(color);
// Update selected color when backgroundColor prop changes
// Update selected color when color prop changes
useEffect(() => {
setSelectedColor(backgroundColor);
}, [backgroundColor]);
setSelectedColor(color);
}, [color]);
const handleSelect = (selectedValue: string) => {
const colorOption = colorOptions.find(
@@ -75,8 +75,8 @@ export function ColorSelector({
<Box flexDirection="row">
<Text color={Colors.Gray}>Preview:</Text>
<Box marginLeft={2} backgroundColor={currentColor.value}>
<Text color="black">{` ${agentName} `}</Text>
<Box marginLeft={2}>
<Text color={currentColor.value}>{` ${agentName} `}</Text>
</Box>
</Box>
</Box>

View File

@@ -122,7 +122,7 @@ export function CreationSummary({
tools: Array.isArray(state.selectedTools)
? state.selectedTools
: undefined,
backgroundColor: state.backgroundColor,
color: state.color,
};
// Create the subagent
@@ -243,12 +243,12 @@ export function CreationSummary({
<Text>{toolsDisplay}</Text>
</Box>
{shouldShowColor(state.backgroundColor) && (
{shouldShowColor(state.color) && (
<Box>
<Text bold>Color: </Text>
<Box backgroundColor={getColorForDisplay(state.backgroundColor)}>
<Text color="black">{` ${state.generatedName} `}</Text>
</Box>
<Text
color={getColorForDisplay(state.color)}
>{` ${state.generatedName} `}</Text>
</Box>
)}

View File

@@ -207,7 +207,7 @@ export function SubagentCreationWizard({
case WIZARD_STEPS.COLOR_SELECTION:
return (
<ColorSelector
backgroundColor={state.backgroundColor}
color={state.color}
agentName={state.generatedName}
onSelect={(color) => {
dispatch({ type: 'SET_BACKGROUND_COLOR', color });
@@ -230,7 +230,7 @@ export function SubagentCreationWizard({
stepProps,
state.currentStep,
state.selectedTools,
state.backgroundColor,
state.color,
state.generatedName,
config,
handleNext,

View File

@@ -4,15 +4,54 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import { TaskResultDisplay } from '@qwen-code/qwen-code-core';
import {
TaskResultDisplay,
SubagentStatsSummary,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { COLOR_OPTIONS } from './constants.js';
import { fmtDuration } from './utils.js';
export type DisplayMode = 'compact' | 'default' | 'verbose';
export interface SubagentExecutionDisplayProps {
data: TaskResultDisplay;
}
const getStatusColor = (
status: TaskResultDisplay['status'] | 'executing' | 'success',
) => {
switch (status) {
case 'running':
case 'executing':
return theme.status.warning;
case 'completed':
case 'success':
return theme.status.success;
case 'failed':
return theme.status.error;
default:
return Colors.Gray;
}
};
const getStatusText = (status: TaskResultDisplay['status']) => {
switch (status) {
case 'running':
return 'Running';
case 'completed':
return 'Completed';
case 'failed':
return 'Failed';
default:
return 'Unknown';
}
};
/**
* Component to display subagent execution progress and results.
* This is now a pure component that renders the provided SubagentExecutionResultDisplay data.
@@ -20,61 +59,133 @@ export interface SubagentExecutionDisplayProps {
*/
export const SubagentExecutionDisplay: React.FC<
SubagentExecutionDisplayProps
> = ({ data }) => (
<Box flexDirection="column" paddingX={1}>
{/* Header with subagent name and status */}
<Box flexDirection="row" marginBottom={1}>
<StatusDot status={data.status} />
<Text bold color={Colors.AccentBlue}>
{data.subagentName}
</Text>
<Text color={Colors.Gray}> </Text>
<StatusIndicator status={data.status} />
> = ({ data }) => {
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('default');
const agentColor = useMemo(() => {
const colorOption = COLOR_OPTIONS.find(
(option) => option.name === data.subagentColor,
);
return colorOption?.value || theme.text.accent;
}, [data.subagentColor]);
const footerText = React.useMemo(() => {
// This component only listens to keyboard shortcut events when the subagent is running
if (data.status !== 'running') return '';
if (displayMode === 'verbose') return 'Press ctrl+r to show less.';
if (displayMode === 'default') {
const hasMoreLines = data.taskPrompt.split('\n').length > 10;
const hasMoreToolCalls = data.toolCalls && data.toolCalls.length > 5;
if (hasMoreToolCalls || hasMoreLines) {
return 'Press ctrl+s to show more.';
}
return '';
}
return '';
}, [displayMode, data.toolCalls, data.taskPrompt, data.status]);
// Handle ctrl+s and ctrl+r keypresses to control display mode
useKeypress(
(key) => {
if (key.ctrl && key.name === 's') {
setDisplayMode((current) =>
current === 'default' ? 'verbose' : 'verbose',
);
} else if (key.ctrl && key.name === 'r') {
setDisplayMode((current) =>
current === 'verbose' ? 'default' : 'default',
);
}
},
{ isActive: true },
);
return (
<Box flexDirection="column" paddingX={1} gap={1}>
{/* Header with subagent name and status */}
<Box flexDirection="row">
<Text bold color={agentColor}>
{data.subagentName}
</Text>
<StatusDot status={data.status} />
<StatusIndicator status={data.status} />
</Box>
{/* Task description */}
<TaskPromptSection
taskPrompt={data.taskPrompt}
displayMode={displayMode}
/>
{/* Progress section for running tasks */}
{data.status === 'running' &&
data.toolCalls &&
data.toolCalls.length > 0 && (
<Box flexDirection="column">
<ToolCallsList
toolCalls={data.toolCalls}
displayMode={displayMode}
/>
</Box>
)}
{/* Results section for completed/failed tasks */}
{(data.status === 'completed' || data.status === 'failed') && (
<ResultsSection data={data} displayMode={displayMode} />
)}
{/* Footer with keyboard shortcuts */}
{footerText && (
<Box flexDirection="row">
<Text color={Colors.Gray}>{footerText}</Text>
</Box>
)}
</Box>
);
};
{/* Task description */}
<Box flexDirection="row" marginBottom={1}>
<Text color={Colors.Gray}>Task: </Text>
<Text wrap="wrap">{data.taskDescription}</Text>
/**
* Task prompt section with truncation support
*/
const TaskPromptSection: React.FC<{
taskPrompt: string;
displayMode: DisplayMode;
}> = ({ taskPrompt, displayMode }) => {
const lines = taskPrompt.split('\n');
const shouldTruncate = lines.length > 10;
const showFull = displayMode === 'verbose';
const displayLines = showFull ? lines : lines.slice(0, 10);
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="row">
<Text color={theme.text.primary}>Task Detail: </Text>
{shouldTruncate && displayMode === 'default' && (
<Text color={Colors.Gray}> Showing the first 10 lines.</Text>
)}
</Box>
<Box paddingLeft={1}>
<Text wrap="wrap">
{displayLines.join('\n') + (shouldTruncate && !showFull ? '...' : '')}
</Text>
</Box>
</Box>
{/* Progress section for running tasks */}
{data.status === 'running' && (
<ProgressSection progress={data.progress || { toolCalls: [] }} />
)}
{/* Results section for completed/failed tasks */}
{(data.status === 'completed' || data.status === 'failed') && (
<ResultsSection data={data} />
)}
</Box>
);
);
};
/**
* Status dot component with similar height as text
*/
const StatusDot: React.FC<{
status: TaskResultDisplay['status'];
}> = ({ status }) => {
const color = React.useMemo(() => {
switch (status) {
case 'running':
return Colors.AccentYellow;
case 'completed':
return Colors.AccentGreen;
case 'failed':
return Colors.AccentRed;
default:
return Colors.Gray;
}
}, [status]);
return (
<Box marginRight={1}>
<Text color={color}></Text>
</Box>
);
};
}> = ({ status }) => (
<Box marginLeft={1} marginRight={1}>
<Text color={getStatusColor(status)}></Text>
</Box>
);
/**
* Status indicator component
@@ -82,109 +193,95 @@ const StatusDot: React.FC<{
const StatusIndicator: React.FC<{
status: TaskResultDisplay['status'];
}> = ({ status }) => {
switch (status) {
case 'running':
return <Text color={Colors.AccentYellow}>Running</Text>;
case 'completed':
return <Text color={Colors.AccentGreen}>Completed</Text>;
case 'failed':
return <Text color={Colors.AccentRed}>Failed</Text>;
default:
return <Text color={Colors.Gray}>Unknown</Text>;
}
const color = getStatusColor(status);
const text = getStatusText(status);
return <Text color={color}>{text}</Text>;
};
/**
* Progress section for running executions
* Tool calls list - format consistent with ToolInfo in ToolMessage.tsx
*/
const ProgressSection: React.FC<{
progress: {
toolCalls?: Array<{
name: string;
status: 'executing' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
returnDisplay?: string;
}>;
};
}> = ({ progress }) => (
<Box flexDirection="column" marginBottom={1}>
{progress.toolCalls && progress.toolCalls.length > 0 && (
<CleanToolCallsList toolCalls={progress.toolCalls} />
)}
</Box>
);
const ToolCallsList: React.FC<{
toolCalls: TaskResultDisplay['toolCalls'];
displayMode: DisplayMode;
}> = ({ toolCalls, displayMode }) => {
const calls = toolCalls || [];
const shouldTruncate = calls.length > 5;
const showAll = displayMode === 'verbose';
const displayCalls = showAll ? calls : calls.slice(-5); // Show last 5
/**
* Clean tool calls list - format consistent with ToolInfo in ToolMessage.tsx
*/
const CleanToolCallsList: React.FC<{
toolCalls: Array<{
name: string;
status: 'executing' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
returnDisplay?: string;
}>;
}> = ({ toolCalls }) => (
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="row" marginBottom={1}>
<Text bold>Tools:</Text>
// Reverse the order to show most recent first
const reversedDisplayCalls = [...displayCalls].reverse();
return (
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Tools:</Text>
{shouldTruncate && displayMode === 'default' && (
<Text color={Colors.Gray}>
{' '}
Showing the last 5 of {calls.length} tools.
</Text>
)}
</Box>
{reversedDisplayCalls.map((toolCall, index) => (
<ToolCallItem key={`${toolCall.name}-${index}`} toolCall={toolCall} />
))}
</Box>
{toolCalls.map((toolCall, index) => (
<CleanToolCallItem
key={`${toolCall.name}-${index}`}
toolCall={toolCall}
/>
))}
</Box>
);
);
};
/**
* Individual tool call item - consistent with ToolInfo format
*/
const CleanToolCallItem: React.FC<{
const ToolCallItem: React.FC<{
toolCall: {
name: string;
status: 'executing' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
returnDisplay?: string;
resultDisplay?: string;
description?: string;
};
}> = ({ toolCall }) => {
const STATUS_INDICATOR_WIDTH = 3;
// Map subagent status to ToolCallStatus-like display
const statusIcon = React.useMemo(() => {
const color = getStatusColor(toolCall.status);
switch (toolCall.status) {
case 'executing':
return <Text color={Colors.AccentYellow}></Text>; // Using same as ToolMessage
return <Text color={color}></Text>; // Using same as ToolMessage
case 'success':
return <Text color={Colors.AccentGreen}></Text>;
return <Text color={color}></Text>;
case 'failed':
return (
<Text color={Colors.AccentRed} bold>
<Text color={color} bold>
x
</Text>
);
default:
return <Text color={Colors.Gray}>o</Text>;
return <Text color={color}>o</Text>;
}
}, [toolCall.status]);
const description = getToolDescription(toolCall);
// Get first line of returnDisplay for truncated output
const truncatedOutput = React.useMemo(() => {
if (!toolCall.returnDisplay) return '';
const firstLine = toolCall.returnDisplay.split('\n')[0];
const description = React.useMemo(() => {
if (!toolCall.description) return '';
const firstLine = toolCall.description.split('\n')[0];
return firstLine.length > 80
? firstLine.substring(0, 80) + '...'
: firstLine;
}, [toolCall.returnDisplay]);
}, [toolCall.description]);
// Get first line of resultDisplay for truncated output
const truncatedOutput = React.useMemo(() => {
if (!toolCall.resultDisplay) return '';
const firstLine = toolCall.resultDisplay.split('\n')[0];
return firstLine.length > 80
? firstLine.substring(0, 80) + '...'
: firstLine;
}, [toolCall.resultDisplay]);
return (
<Box flexDirection="column" paddingLeft={1} marginBottom={0}>
@@ -197,7 +294,7 @@ const CleanToolCallItem: React.FC<{
</Text>{' '}
<Text color={Colors.Gray}>{description}</Text>
{toolCall.error && (
<Text color={Colors.AccentRed}> - {toolCall.error}</Text>
<Text color={theme.status.error}> - {toolCall.error}</Text>
)}
</Text>
</Box>
@@ -212,59 +309,16 @@ const CleanToolCallItem: React.FC<{
);
};
/**
* Helper function to get tool description from args
*/
const getToolDescription = (toolCall: {
name: string;
args?: Record<string, unknown>;
}): string => {
if (!toolCall.args) return '';
// Handle common tool patterns
if (toolCall.name === 'Glob' && toolCall.args['glob_pattern']) {
return `"${toolCall.args['glob_pattern']}"`;
}
if (toolCall.name === 'ReadFile' && toolCall.args['target_file']) {
const path = toolCall.args['target_file'] as string;
return path.split('/').pop() || path;
}
if (toolCall.name === 'SearchFileContent' && toolCall.args['pattern']) {
return `"${toolCall.args['pattern']}"`;
}
// Generic fallback
const firstArg = Object.values(toolCall.args)[0];
if (typeof firstArg === 'string' && firstArg.length < 50) {
return firstArg;
}
return '';
};
/**
* Execution summary details component
*/
const ExecutionSummaryDetails: React.FC<{
data: TaskResultDisplay;
}> = ({ data }) => {
// Parse execution summary for structured data
const summaryData = React.useMemo(() => {
if (!data.executionSummary) return null;
displayMode: DisplayMode;
}> = ({ data, displayMode: _displayMode }) => {
const stats = data.executionSummary;
// Try to extract structured data from execution summary
const durationMatch = data.executionSummary.match(/Duration:\s*([^\n]+)/i);
const roundsMatch = data.executionSummary.match(/Rounds:\s*(\d+)/i);
const tokensMatch = data.executionSummary.match(/Tokens:\s*([\d,]+)/i);
return {
duration: durationMatch?.[1] || 'N/A',
rounds: roundsMatch?.[1] || 'N/A',
tokens: tokensMatch?.[1] || 'N/A',
};
}, [data.executionSummary]);
if (!summaryData) {
if (!stats) {
return (
<Box flexDirection="column" paddingLeft={1}>
<Text color={Colors.Gray}> No summary available</Text>
@@ -275,13 +329,13 @@ const ExecutionSummaryDetails: React.FC<{
return (
<Box flexDirection="column" paddingLeft={1}>
<Text>
<Text bold>Duration:</Text> {summaryData.duration}
<Text>Duration: {fmtDuration(stats.totalDurationMs)}</Text>
</Text>
<Text>
<Text bold>Rounds:</Text> {summaryData.rounds}
<Text>Rounds: {stats.rounds}</Text>
</Text>
<Text>
<Text bold>Tokens:</Text> {summaryData.tokens}
<Text>Tokens: {stats.totalTokens.toLocaleString()}</Text>
</Text>
</Box>
);
@@ -291,37 +345,35 @@ const ExecutionSummaryDetails: React.FC<{
* Tool usage statistics component
*/
const ToolUsageStats: React.FC<{
toolCalls: Array<{
name: string;
status: 'executing' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
returnDisplay?: string;
}>;
}> = ({ toolCalls }) => {
const stats = React.useMemo(() => {
const total = toolCalls.length;
const successful = toolCalls.filter(
(call) => call.status === 'success',
).length;
const failed = toolCalls.filter((call) => call.status === 'failed').length;
const successRate =
total > 0 ? ((successful / total) * 100).toFixed(1) : '0.0';
return { total, successful, failed, successRate };
}, [toolCalls]);
executionSummary?: SubagentStatsSummary;
}> = ({ executionSummary }) => {
if (!executionSummary) {
return (
<Box flexDirection="column" paddingLeft={1}>
<Text color={Colors.Gray}> No tool usage data available</Text>
</Box>
);
}
return (
<Box flexDirection="column" paddingLeft={1}>
<Text>
<Text bold>Total Calls:</Text> {stats.total}
<Text bold>Total Calls:</Text> {executionSummary.totalToolCalls}
</Text>
<Text>
<Text bold>Success Rate:</Text>{' '}
<Text color={Colors.AccentGreen}>{stats.successRate}%</Text> (
<Text color={Colors.AccentGreen}>{stats.successful} success</Text>,{' '}
<Text color={Colors.AccentRed}>{stats.failed} failed</Text>)
<Text color={Colors.AccentGreen}>
{executionSummary.successRate.toFixed(1)}%
</Text>{' '}
(
<Text color={Colors.AccentGreen}>
{executionSummary.successfulToolCalls} success
</Text>
,{' '}
<Text color={Colors.AccentRed}>
{executionSummary.failedToolCalls} failed
</Text>
)
</Text>
</Box>
);
@@ -332,47 +384,39 @@ const ToolUsageStats: React.FC<{
*/
const ResultsSection: React.FC<{
data: TaskResultDisplay;
}> = ({ data }) => (
<Box flexDirection="column">
displayMode: DisplayMode;
}> = ({ data, displayMode }) => (
<Box flexDirection="column" gap={1}>
{/* Tool calls section - clean list format */}
{data.progress?.toolCalls && data.progress.toolCalls.length > 0 && (
<CleanToolCallsList toolCalls={data.progress.toolCalls} />
{data.toolCalls && data.toolCalls.length > 0 && (
<ToolCallsList toolCalls={data.toolCalls} displayMode={displayMode} />
)}
{/* Task Completed section */}
<Box flexDirection="row" marginTop={1} marginBottom={1}>
<Text>📄 </Text>
<Text bold>Task Completed: </Text>
<Text>{data.taskDescription}</Text>
</Box>
{/* Execution Summary section */}
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text>📊 </Text>
<Text bold color={Colors.AccentBlue}>
<Text color={theme.text.primary}>
Execution Summary:
</Text>
</Box>
<ExecutionSummaryDetails data={data} />
<ExecutionSummaryDetails data={data} displayMode={displayMode} />
</Box>
{/* Tool Usage section */}
{data.progress?.toolCalls && data.progress.toolCalls.length > 0 && (
{data.executionSummary && (
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text>🔧 </Text>
<Text bold color={Colors.AccentBlue}>
<Text color={theme.text.primary}>
Tool Usage:
</Text>
</Box>
<ToolUsageStats toolCalls={data.progress.toolCalls} />
<ToolUsageStats executionSummary={data.executionSummary} />
</Box>
)}
{/* Error reason for failed tasks */}
{data.status === 'failed' && data.terminateReason && (
<Box flexDirection="row" marginTop={1}>
<Box flexDirection="row">
<Text color={Colors.AccentRed}> Failed: </Text>
<Text color={Colors.Gray}>{data.terminateReason}</Text>
</Box>

View File

@@ -19,7 +19,7 @@ export const initialWizardState: CreationWizardState = {
generatedDescription: '',
generatedName: '',
selectedTools: [],
backgroundColor: 'auto',
color: 'auto',
isGenerating: false,
validationErrors: [],
canProceed: false,
@@ -84,7 +84,7 @@ export function wizardReducer(
case 'SET_BACKGROUND_COLOR':
return {
...state,
backgroundColor: action.color,
color: action.color,
canProceed: true,
};
@@ -157,7 +157,7 @@ function validateStep(step: number, state: CreationWizardState): boolean {
return true; // Always can proceed from tool selection
case WIZARD_STEPS.FINAL_CONFIRMATION: // Final confirmation
return state.backgroundColor.length > 0;
return state.color.length > 0;
default:
return false;

View File

@@ -34,8 +34,8 @@ export interface CreationWizardState {
/** Selected tools for the subagent */
selectedTools: string[];
/** Background color for runtime display */
backgroundColor: string;
/** Color for runtime display */
color: string;
/** Whether LLM generation is in progress */
isGenerating: boolean;
@@ -108,7 +108,7 @@ export interface WizardResult {
systemPrompt: string;
location: SubagentLevel;
tools?: string[];
backgroundColor: string;
color: string;
}
export const MANAGEMENT_STEPS = {

View File

@@ -1,7 +1,7 @@
import { COLOR_OPTIONS } from './constants.js';
export const shouldShowColor = (backgroundColor?: string): boolean =>
backgroundColor !== undefined && backgroundColor !== 'auto';
export const shouldShowColor = (color?: string): boolean =>
color !== undefined && color !== 'auto';
export const getColorForDisplay = (colorName?: string): string | undefined =>
!colorName || colorName === 'auto'
@@ -20,3 +20,16 @@ export function sanitizeInput(input: string): string {
.replace(/\s+/g, ' ') // Normalize whitespace
); // Limit length
}
export function fmtDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) {
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
return `${m}m ${s}s`;
}
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
return `${h}h ${m}m`;
}

View File

@@ -47,7 +47,7 @@ export type {
ToolConfig,
SubagentTerminateMode,
OutputObject,
} from './subagent.js';
} from './types.js';
export { SubAgentScope } from './subagent.js';
@@ -55,11 +55,19 @@ export { SubAgentScope } from './subagent.js';
export type {
SubAgentEvent,
SubAgentStartEvent,
SubAgentFinishEvent,
SubAgentRoundEvent,
SubAgentStreamTextEvent,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentModelTextEvent,
SubAgentFinishEvent,
SubAgentErrorEvent,
} from './subagent-events.js';
export { SubAgentEventEmitter } from './subagent-events.js';
// Statistics and formatting
export type {
SubagentStatsSummary,
ToolUsageStats,
} from './subagent-statistics.js';
export { formatCompact, formatDetailed } from './subagent-result-format.js';

View File

@@ -10,17 +10,21 @@ export type SubAgentEvent =
| 'start'
| 'round_start'
| 'round_end'
| 'model_text'
| 'stream_text'
| 'tool_call'
| 'tool_result'
| 'finish'
| 'error';
export interface SubAgentModelTextEvent {
subagentId: string;
round: number;
text: string;
timestamp: number;
export enum SubAgentEventType {
START = 'start',
ROUND_START = 'round_start',
ROUND_END = 'round_end',
STREAM_TEXT = 'stream_text',
TOOL_CALL = 'tool_call',
TOOL_RESULT = 'tool_result',
FINISH = 'finish',
ERROR = 'error',
}
export interface SubAgentStartEvent {
@@ -38,12 +42,20 @@ export interface SubAgentRoundEvent {
timestamp: number;
}
export interface SubAgentStreamTextEvent {
subagentId: string;
round: number;
text: string;
timestamp: number;
}
export interface SubAgentToolCallEvent {
subagentId: string;
round: number;
callId: string;
name: string;
args: Record<string, unknown>;
description: string;
timestamp: number;
}
@@ -54,6 +66,7 @@ export interface SubAgentToolResultEvent {
name: string;
success: boolean;
error?: string;
resultDisplay?: string;
durationMs?: number;
timestamp: number;
}
@@ -72,6 +85,12 @@ export interface SubAgentFinishEvent {
totalTokens?: number;
}
export interface SubAgentErrorEvent {
subagentId: string;
error: string;
timestamp: number;
}
export class SubAgentEventEmitter {
private ee = new EventEmitter();

View File

@@ -21,15 +21,13 @@ import {
CreateSubagentOptions,
SubagentError,
SubagentErrorCode,
} from './types.js';
import { SubagentValidator } from './validation.js';
import {
SubAgentScope,
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from './subagent.js';
} from './types.js';
import { SubagentValidator } from './validation.js';
import { SubAgentScope } from './subagent.js';
import { Config } from '../config/config.js';
const QWEN_CONFIG_DIR = '.qwen';
@@ -369,9 +367,7 @@ export class SubagentManager {
const runConfig = frontmatter['runConfig'] as
| Record<string, unknown>
| undefined;
const backgroundColor = frontmatter['backgroundColor'] as
| string
| undefined;
const color = frontmatter['color'] as string | undefined;
// Determine level from file path
// Project level paths contain the project root, user level paths are in home directory
@@ -387,11 +383,9 @@ export class SubagentManager {
systemPrompt: systemPrompt.trim(),
level,
filePath,
modelConfig: modelConfig as Partial<
import('./subagent.js').ModelConfig
>,
runConfig: runConfig as Partial<import('./subagent.js').RunConfig>,
backgroundColor,
modelConfig: modelConfig as Partial<ModelConfig>,
runConfig: runConfig as Partial<RunConfig>,
color,
};
// Validate the parsed configuration
@@ -436,8 +430,8 @@ export class SubagentManager {
frontmatter['runConfig'] = config.runConfig;
}
if (config.backgroundColor && config.backgroundColor !== 'auto') {
frontmatter['backgroundColor'] = config.backgroundColor;
if (config.color && config.color !== 'auto') {
frontmatter['color'] = config.color;
}
// Serialize to YAML

View File

@@ -4,17 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
export interface SubAgentBasicStats {
rounds: number;
totalDurationMs: number;
totalToolCalls: number;
successfulToolCalls: number;
failedToolCalls: number;
successRate?: number;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
}
import { SubagentStatsSummary } from './subagent-statistics.js';
function fmtDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
@@ -30,7 +20,7 @@ function fmtDuration(ms: number): string {
}
export function formatCompact(
stats: SubAgentBasicStats,
stats: SubagentStatsSummary,
taskDesc: string,
): string {
const sr =
@@ -52,16 +42,7 @@ export function formatCompact(
}
export function formatDetailed(
stats: SubAgentBasicStats & {
toolUsage?: Array<{
name: string;
count: number;
success: number;
failure: number;
lastError?: string;
averageDurationMs?: number;
}>;
},
stats: SubagentStatsSummary,
taskDesc: string,
): string {
const sr =
@@ -118,18 +99,7 @@ export function formatDetailed(
return lines.join('\n');
}
export function generatePerformanceTips(
stats: SubAgentBasicStats & {
toolUsage?: Array<{
name: string;
count: number;
success: number;
failure: number;
lastError?: string;
averageDurationMs?: number;
}>;
},
): string[] {
export function generatePerformanceTips(stats: SubagentStatsSummary): string[] {
const tips: string[] = [];
const totalCalls = stats.totalToolCalls;
const sr =

View File

@@ -14,7 +14,7 @@ export interface ToolUsageStats {
averageDurationMs: number;
}
export interface SubagentSummary {
export interface SubagentStatsSummary {
rounds: number;
totalDurationMs: number;
totalToolCalls: number;
@@ -79,7 +79,7 @@ export class SubagentStatistics {
this.outputTokens += Math.max(0, output || 0);
}
getSummary(now = Date.now()): SubagentSummary {
getSummary(now = Date.now()): SubagentStatsSummary {
const totalDurationMs = this.startTimeMs ? now - this.startTimeMs : 0;
const totalToolCalls = this.totalToolCalls;
const successRate =

View File

@@ -5,15 +5,14 @@
*/
import { vi, describe, it, expect, beforeEach, Mock, afterEach } from 'vitest';
import { ContextState, SubAgentScope } from './subagent.js';
import {
ContextState,
SubAgentScope,
SubagentTerminateMode,
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from './subagent.js';
} from './types.js';
import { Config, ConfigParameters } from '../config/config.js';
import { GeminiChat } from '../core/geminiChat.js';
import { createContentGenerator } from '../core/contentGenerator.js';

View File

@@ -19,9 +19,30 @@ import {
GenerateContentResponseUsageMetadata,
} from '@google/genai';
import { GeminiChat } from '../core/geminiChat.js';
import { SubAgentEventEmitter } from './subagent-events.js';
import { formatCompact, formatDetailed } from './subagent-result-format.js';
import { SubagentStatistics } from './subagent-statistics.js';
import {
OutputObject,
SubagentTerminateMode,
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from './types.js';
import {
SubAgentEventEmitter,
SubAgentEventType,
SubAgentFinishEvent,
SubAgentRoundEvent,
SubAgentStartEvent,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentStreamTextEvent,
SubAgentErrorEvent,
} from './subagent-events.js';
import { formatCompact } from './subagent-result-format.js';
import {
SubagentStatistics,
SubagentStatsSummary,
} from './subagent-statistics.js';
import { SubagentHooks } from './subagent-hooks.js';
import { logSubagentExecution } from '../telemetry/loggers.js';
import { SubagentExecutionEvent } from '../telemetry/types.js';
@@ -46,114 +67,6 @@ interface ExecutionStats {
estimatedCost?: number;
}
/**
* Describes the possible termination modes for a subagent.
* This enum provides a clear indication of why a subagent's execution might have ended.
*/
export enum SubagentTerminateMode {
/**
* Indicates that the subagent's execution terminated due to an unrecoverable error.
*/
ERROR = 'ERROR',
/**
* Indicates that the subagent's execution terminated because it exceeded the maximum allowed working time.
*/
TIMEOUT = 'TIMEOUT',
/**
* Indicates that the subagent's execution successfully completed all its defined goals.
*/
GOAL = 'GOAL',
/**
* Indicates that the subagent's execution terminated because it exceeded the maximum number of turns.
*/
MAX_TURNS = 'MAX_TURNS',
}
/**
* Represents the output structure of a subagent's execution.
* This interface defines the data that a subagent will return upon completion,
* including the final result and the reason for its termination.
*/
export interface OutputObject {
/**
* The final result text returned by the subagent upon completion.
* This contains the direct output from the model's final response.
*/
result: string;
/**
* The reason for the subagent's termination, indicating whether it completed
* successfully, timed out, or encountered an error.
*/
terminate_reason: SubagentTerminateMode;
}
/**
* Configures the initial prompt for the subagent.
*/
export interface PromptConfig {
/**
* A single system prompt string that defines the subagent's persona and instructions.
* Note: You should use either `systemPrompt` or `initialMessages`, but not both.
*/
systemPrompt?: string;
/**
* An array of user/model content pairs to seed the chat history for few-shot prompting.
* Note: You should use either `systemPrompt` or `initialMessages`, but not both.
*/
initialMessages?: Content[];
}
/**
* Configures the tools available to the subagent during its execution.
*/
export interface ToolConfig {
/**
* A list of tool names (from the tool registry) or full function declarations
* that the subagent is permitted to use.
*/
tools: Array<string | FunctionDeclaration>;
}
/**
* Configures the generative model parameters for the subagent.
* This interface specifies the model to be used and its associated generation settings,
* such as temperature and top-p values, which influence the creativity and diversity of the model's output.
*/
export interface ModelConfig {
/**
* The name or identifier of the model to be used (e.g., 'gemini-2.5-pro').
*
* TODO: In the future, this needs to support 'auto' or some other string to support routing use cases.
*/
model?: string;
/**
* The temperature for the model's sampling process.
*/
temp?: number;
/**
* The top-p value for nucleus sampling.
*/
top_p?: number;
}
/**
* Configures the execution environment and constraints for the subagent.
* This interface defines parameters that control the subagent's runtime behavior,
* such as maximum execution time, to prevent infinite loops or excessive resource consumption.
*
* TODO: Consider adding max_tokens as a form of budgeting.
*/
export interface RunConfig {
/** The maximum execution time for the subagent in minutes. */
max_time_minutes?: number;
/**
* The maximum number of conversational turns (a user message + model response)
* before the execution is terminated. Helps prevent infinite loops.
*/
max_turns?: number;
}
/**
* Manages the runtime context state for the subagent.
* This class provides a mechanism to store and retrieve key-value pairs
@@ -450,7 +363,7 @@ export class SubAgentScope {
let turnCounter = 0;
try {
// Emit start event
this.eventEmitter?.emit('start', {
this.eventEmitter?.emit(SubAgentEventType.START, {
subagentId: this.subagentId,
name: this.name,
model: this.modelConfig.model,
@@ -458,7 +371,7 @@ export class SubAgentScope {
typeof t === 'string' ? t : t.name,
),
timestamp: Date.now(),
});
} as SubAgentStartEvent);
// Log telemetry for subagent start
const startEvent = new SubagentExecutionEvent(this.name, 'started');
@@ -494,12 +407,12 @@ export class SubAgentScope {
messageParams,
promptId,
);
this.eventEmitter?.emit('round_start', {
this.eventEmitter?.emit(SubAgentEventType.ROUND_START, {
subagentId: this.subagentId,
round: turnCounter,
promptId,
timestamp: Date.now(),
});
} as SubAgentRoundEvent);
const functionCalls: FunctionCall[] = [];
let roundText = '';
@@ -514,12 +427,12 @@ export class SubAgentScope {
const txt = (p as Part & { text?: string }).text;
if (txt) roundText += txt;
if (txt)
this.eventEmitter?.emit('model_text', {
this.eventEmitter?.emit(SubAgentEventType.STREAM_TEXT, {
subagentId: this.subagentId,
round: turnCounter,
text: txt,
timestamp: Date.now(),
});
} as SubAgentStreamTextEvent);
}
if (resp.usageMetadata) lastUsage = resp.usageMetadata;
}
@@ -565,6 +478,7 @@ export class SubAgentScope {
functionCalls,
abortController,
promptId,
turnCounter,
);
} else {
// No tool calls — treat this as the model's final answer.
@@ -586,21 +500,21 @@ export class SubAgentScope {
},
];
}
this.eventEmitter?.emit('round_end', {
this.eventEmitter?.emit(SubAgentEventType.ROUND_END, {
subagentId: this.subagentId,
round: turnCounter,
promptId,
timestamp: Date.now(),
});
} as SubAgentRoundEvent);
}
} catch (error) {
console.error('Error during subagent execution:', error);
this.output.terminate_reason = SubagentTerminateMode.ERROR;
this.eventEmitter?.emit('error', {
this.eventEmitter?.emit(SubAgentEventType.ERROR, {
subagentId: this.subagentId,
error: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
});
} as SubAgentErrorEvent);
// Log telemetry for subagent error
const errorEvent = new SubagentExecutionEvent(this.name, 'failed', {
@@ -614,7 +528,7 @@ export class SubAgentScope {
if (externalSignal) externalSignal.removeEventListener('abort', onAbort);
this.executionStats.totalDurationMs = Date.now() - startTime;
const summary = this.stats.getSummary(Date.now());
this.eventEmitter?.emit('finish', {
this.eventEmitter?.emit(SubAgentEventType.FINISH, {
subagentId: this.subagentId,
terminate_reason: this.output.terminate_reason,
timestamp: Date.now(),
@@ -626,7 +540,7 @@ export class SubAgentScope {
inputTokens: summary.inputTokens,
outputTokens: summary.outputTokens,
totalTokens: summary.totalTokens,
});
} as SubAgentFinishEvent);
// Log telemetry for subagent completion
const completionEvent = new SubagentExecutionEvent(
@@ -637,7 +551,8 @@ export class SubAgentScope {
{
terminate_reason: this.output.terminate_reason,
result: this.finalText,
execution_summary: this.formatCompactResult(
execution_summary: formatCompact(
summary,
'Subagent execution completed',
),
},
@@ -669,6 +584,7 @@ export class SubAgentScope {
functionCalls: FunctionCall[],
abortController: AbortController,
promptId: string,
currentRound: number,
): Promise<Content[]> {
const toolResponseParts: Part[] = [];
@@ -683,6 +599,20 @@ export class SubAgentScope {
prompt_id: promptId,
};
// Get tool description before execution
const description = this.getToolDescription(toolName, requestInfo.args);
// Emit tool call event BEFORE execution
this.eventEmitter?.emit(SubAgentEventType.TOOL_CALL, {
subagentId: this.subagentId,
round: currentRound,
callId,
name: toolName,
args: requestInfo.args,
description,
timestamp: Date.now(),
} as SubAgentToolCallEvent);
// Execute tools with timing and hooks
const start = Date.now();
await this.hooks?.preToolUse?.({
@@ -717,13 +647,7 @@ export class SubAgentScope {
tu.count += 1;
if (toolResponse?.error) {
tu.failure += 1;
const disp =
typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: toolResponse.resultDisplay
? JSON.stringify(toolResponse.resultDisplay)
: undefined;
tu.lastError = disp || toolResponse.error?.message || 'Unknown error';
tu.lastError = toolResponse.error?.message || 'Unknown error';
} else {
tu.success += 1;
}
@@ -737,31 +661,22 @@ export class SubAgentScope {
}
this.toolUsage.set(toolName, tu);
// Emit tool call/result events
this.eventEmitter?.emit('tool_call', {
// Emit tool result event
this.eventEmitter?.emit(SubAgentEventType.TOOL_RESULT, {
subagentId: this.subagentId,
round: this.executionStats.rounds,
callId,
name: toolName,
args: requestInfo.args,
timestamp: Date.now(),
});
this.eventEmitter?.emit('tool_result', {
subagentId: this.subagentId,
round: this.executionStats.rounds,
round: currentRound,
callId,
name: toolName,
success: !toolResponse?.error,
error: toolResponse?.error
error: toolResponse?.error?.message,
resultDisplay: toolResponse?.resultDisplay
? typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: toolResponse.resultDisplay
? JSON.stringify(toolResponse.resultDisplay)
: toolResponse.error.message
: JSON.stringify(toolResponse.resultDisplay)
: undefined,
durationMs: duration,
timestamp: Date.now(),
});
} as SubAgentToolResultEvent);
// Update statistics service
this.stats.recordToolCall(
@@ -779,19 +694,13 @@ export class SubAgentScope {
args: requestInfo.args,
success: !toolResponse?.error,
durationMs: duration,
errorMessage: toolResponse?.error
? typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: toolResponse.resultDisplay
? JSON.stringify(toolResponse.resultDisplay)
: toolResponse.error.message
: undefined,
errorMessage: toolResponse?.error?.message,
timestamp: Date.now(),
});
if (toolResponse.error) {
console.error(
`Error executing tool ${functionCall.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
`Error executing tool ${functionCall.name}: ${toolResponse.error.message}`,
);
}
@@ -836,47 +745,14 @@ export class SubAgentScope {
};
}
formatCompactResult(taskDesc: string, _useColors = false) {
const stats = this.getStatistics();
return formatCompact(
{
rounds: stats.rounds,
totalDurationMs: stats.totalDurationMs,
totalToolCalls: stats.totalToolCalls,
successfulToolCalls: stats.successfulToolCalls,
failedToolCalls: stats.failedToolCalls,
successRate: stats.successRate,
inputTokens: this.executionStats.inputTokens,
outputTokens: this.executionStats.outputTokens,
totalTokens: this.executionStats.totalTokens,
},
taskDesc,
);
getExecutionSummary(): SubagentStatsSummary {
return this.stats.getSummary();
}
getFinalText(): string {
return this.finalText;
}
formatDetailedResult(taskDesc: string) {
const stats = this.getStatistics();
return formatDetailed(
{
rounds: stats.rounds,
totalDurationMs: stats.totalDurationMs,
totalToolCalls: stats.totalToolCalls,
successfulToolCalls: stats.successfulToolCalls,
failedToolCalls: stats.failedToolCalls,
successRate: stats.successRate,
inputTokens: this.executionStats.inputTokens,
outputTokens: this.executionStats.outputTokens,
totalTokens: this.executionStats.totalTokens,
toolUsage: stats.toolUsage,
},
taskDesc,
);
}
private async createChatObject(context: ContextState) {
if (!this.promptConfig.systemPrompt && !this.promptConfig.initialMessages) {
throw new Error(
@@ -944,6 +820,33 @@ export class SubAgentScope {
}
}
/**
* Safely retrieves the description of a tool by attempting to build it.
* Returns an empty string if any error occurs during the process.
*
* @param toolName The name of the tool to get description for.
* @param args The arguments that would be passed to the tool.
* @returns The tool description or empty string if error occurs.
*/
private getToolDescription(
toolName: string,
args: Record<string, unknown>,
): string {
try {
const toolRegistry = this.runtimeContext.getToolRegistry();
const tool = toolRegistry.getTool(toolName);
if (!tool) {
return '';
}
const toolInstance = tool.build(args);
return toolInstance.getDescription() || '';
} catch {
// Safely ignore all runtime errors and return empty string
return '';
}
}
private buildChatSystemPrompt(context: ContextState): string {
if (!this.promptConfig.systemPrompt) {
// This should ideally be caught in createChatObject, but serves as a safeguard.

View File

@@ -4,12 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from './subagent.js';
import { Content, FunctionDeclaration } from '@google/genai';
/**
* Represents the storage level for a subagent configuration.
@@ -61,10 +56,10 @@ export interface SubagentConfig {
runConfig?: Partial<RunConfig>;
/**
* Optional background color for runtime display.
* Optional color for runtime display.
* If 'auto' or omitted, uses automatic color assignment.
*/
backgroundColor?: string;
color?: string;
}
/**
@@ -159,3 +154,111 @@ export const SubagentErrorCode = {
export type SubagentErrorCode =
(typeof SubagentErrorCode)[keyof typeof SubagentErrorCode];
/**
* Describes the possible termination modes for a subagent.
* This enum provides a clear indication of why a subagent's execution might have ended.
*/
export enum SubagentTerminateMode {
/**
* Indicates that the subagent's execution terminated due to an unrecoverable error.
*/
ERROR = 'ERROR',
/**
* Indicates that the subagent's execution terminated because it exceeded the maximum allowed working time.
*/
TIMEOUT = 'TIMEOUT',
/**
* Indicates that the subagent's execution successfully completed all its defined goals.
*/
GOAL = 'GOAL',
/**
* Indicates that the subagent's execution terminated because it exceeded the maximum number of turns.
*/
MAX_TURNS = 'MAX_TURNS',
}
/**
* Represents the output structure of a subagent's execution.
* This interface defines the data that a subagent will return upon completion,
* including the final result and the reason for its termination.
*/
export interface OutputObject {
/**
* The final result text returned by the subagent upon completion.
* This contains the direct output from the model's final response.
*/
result: string;
/**
* The reason for the subagent's termination, indicating whether it completed
* successfully, timed out, or encountered an error.
*/
terminate_reason: SubagentTerminateMode;
}
/**
* Configures the initial prompt for the subagent.
*/
export interface PromptConfig {
/**
* A single system prompt string that defines the subagent's persona and instructions.
* Note: You should use either `systemPrompt` or `initialMessages`, but not both.
*/
systemPrompt?: string;
/**
* An array of user/model content pairs to seed the chat history for few-shot prompting.
* Note: You should use either `systemPrompt` or `initialMessages`, but not both.
*/
initialMessages?: Content[];
}
/**
* Configures the tools available to the subagent during its execution.
*/
export interface ToolConfig {
/**
* A list of tool names (from the tool registry) or full function declarations
* that the subagent is permitted to use.
*/
tools: Array<string | FunctionDeclaration>;
}
/**
* Configures the generative model parameters for the subagent.
* This interface specifies the model to be used and its associated generation settings,
* such as temperature and top-p values, which influence the creativity and diversity of the model's output.
*/
export interface ModelConfig {
/**
* The name or identifier of the model to be used (e.g., 'gemini-2.5-pro').
*
* TODO: In the future, this needs to support 'auto' or some other string to support routing use cases.
*/
model?: string;
/**
* The temperature for the model's sampling process.
*/
temp?: number;
/**
* The top-p value for nucleus sampling.
*/
top_p?: number;
}
/**
* Configures the execution environment and constraints for the subagent.
* This interface defines parameters that control the subagent's runtime behavior,
* such as maximum execution time, to prevent infinite loops or excessive resource consumption.
*
* TODO: Consider adding max_tokens as a form of budgeting.
*/
export interface RunConfig {
/** The maximum execution time for the subagent in minutes. */
max_time_minutes?: number;
/**
* The maximum number of conversational turns (a user message + model response)
* before the execution is terminated. Helps prevent infinite loops.
*/
max_turns?: number;
}

View File

@@ -9,6 +9,8 @@ import {
ValidationResult,
SubagentError,
SubagentErrorCode,
ModelConfig,
RunConfig,
} from './types.js';
/**
@@ -250,9 +252,7 @@ export class SubagentValidator {
* @param modelConfig - Partial model configuration to validate
* @returns ValidationResult
*/
validateModelConfig(
modelConfig: Partial<import('./subagent.js').ModelConfig>,
): ValidationResult {
validateModelConfig(modelConfig: ModelConfig): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
@@ -298,9 +298,7 @@ export class SubagentValidator {
* @param runConfig - Partial run configuration to validate
* @returns ValidationResult
*/
validateRunConfig(
runConfig: Partial<import('./subagent.js').RunConfig>,
): ValidationResult {
validateRunConfig(runConfig: RunConfig): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];

View File

@@ -6,14 +6,12 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TaskTool, TaskParams } from './task.js';
import type { PartListUnion } from '@google/genai';
import type { ToolResultDisplay, TaskResultDisplay } from './tools.js';
import { Config } from '../config/config.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import { SubagentConfig } from '../subagents/types.js';
import {
SubAgentScope,
ContextState,
SubagentTerminateMode,
} from '../subagents/subagent.js';
import { SubagentConfig, SubagentTerminateMode } from '../subagents/types.js';
import { SubAgentScope, ContextState } from '../subagents/subagent.js';
import { partToString } from '../utils/partUtils.js';
// Type for accessing protected methods in tests
@@ -23,8 +21,8 @@ type TaskToolWithProtectedMethods = TaskTool & {
signal?: AbortSignal,
liveOutputCallback?: (chunk: string) => void,
) => Promise<{
llmContent: string;
returnDisplay: unknown;
llmContent: PartListUnion;
returnDisplay: ToolResultDisplay;
}>;
getDescription: () => string;
shouldConfirmExecute: () => Promise<boolean>;
@@ -270,6 +268,36 @@ describe('TaskTool', () => {
.mockReturnValue(
'✅ Success: Search files completed with GOAL termination',
),
getExecutionSummary: vi.fn().mockReturnValue({
rounds: 2,
totalDurationMs: 1500,
totalToolCalls: 3,
successfulToolCalls: 3,
failedToolCalls: 0,
successRate: 100,
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
estimatedCost: 0.045,
toolUsage: [
{
name: 'grep',
count: 2,
success: 2,
failure: 0,
totalDurationMs: 800,
averageDurationMs: 400,
},
{
name: 'read_file',
count: 1,
success: 1,
failure: 0,
totalDurationMs: 200,
averageDurationMs: 200,
},
],
}),
getStatistics: vi.fn().mockReturnValue({
rounds: 2,
totalDurationMs: 1500,
@@ -319,13 +347,11 @@ describe('TaskTool', () => {
);
const llmText = partToString(result.llmContent);
const parsedResult = JSON.parse(llmText) as {
success: boolean;
subagent_name?: string;
error?: string;
};
expect(parsedResult.success).toBe(true);
expect(parsedResult.subagent_name).toBe('file-search');
expect(llmText).toBe('Task completed successfully');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.type).toBe('task_execution');
expect(display.status).toBe('completed');
expect(display.subagentName).toBe('file-search');
});
it('should handle subagent not found error', async () => {
@@ -343,13 +369,10 @@ describe('TaskTool', () => {
const result = await invocation.execute();
const llmText = partToString(result.llmContent);
const parsedResult = JSON.parse(llmText) as {
success: boolean;
subagent_name?: string;
error?: string;
};
expect(parsedResult.success).toBe(false);
expect(parsedResult.error).toContain('Subagent "non-existent" not found');
expect(llmText).toContain('Subagent "non-existent" not found');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('failed');
expect(display.subagentName).toBe('non-existent');
});
it('should handle subagent execution failure', async () => {
@@ -366,16 +389,9 @@ describe('TaskTool', () => {
).createInvocation(params);
const result = await invocation.execute();
const llmText = partToString(result.llmContent);
const parsedResult = JSON.parse(llmText) as {
success: boolean;
subagent_name?: string;
error?: string;
};
expect(parsedResult.success).toBe(false);
expect(parsedResult.error).toContain(
'Task did not complete successfully',
);
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('failed');
expect(display.terminateReason).toBe('ERROR');
});
it('should handle execution errors gracefully', async () => {
@@ -395,13 +411,13 @@ describe('TaskTool', () => {
const result = await invocation.execute();
const llmText = partToString(result.llmContent);
const parsedResult = JSON.parse(llmText) as {
success: boolean;
subagent_name?: string;
error?: string;
};
expect(parsedResult.success).toBe(false);
expect(parsedResult.error).toContain('Failed to start subagent');
expect(llmText).toContain('Failed to run subagent: Creation failed');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('failed');
expect(display.result ?? '').toContain(
'Failed to run subagent: Creation failed',
);
});
it('should execute subagent without live output callback', async () => {
@@ -421,12 +437,11 @@ describe('TaskTool', () => {
expect(result.returnDisplay).toBeDefined();
// Verify the result has the expected structure
const llmContent = Array.isArray(result.llmContent)
? result.llmContent
: [result.llmContent];
const parsedResult = JSON.parse((llmContent[0] as { text: string }).text);
expect(parsedResult.success).toBe(true);
expect(parsedResult.subagent_name).toBe('file-search');
const text = partToString(result.llmContent);
expect(text).toBe('Task completed successfully');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('completed');
expect(display.subagentName).toBe('file-search');
});
it('should set context variables correctly', async () => {
@@ -460,7 +475,7 @@ describe('TaskTool', () => {
const result = await invocation.execute();
expect(typeof result.returnDisplay).toBe('object');
expect(result.returnDisplay).toHaveProperty('type', 'subagent_execution');
expect(result.returnDisplay).toHaveProperty('type', 'task_execution');
expect(result.returnDisplay).toHaveProperty(
'subagentName',
'file-search',
@@ -499,9 +514,7 @@ describe('TaskTool', () => {
).createInvocation(params);
const description = invocation.getDescription();
expect(description).toBe(
'file-search subagent: "Search files"',
);
expect(description).toBe('file-search subagent: "Search files"');
});
});
});

View File

@@ -21,6 +21,8 @@ import {
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentFinishEvent,
SubAgentEventType,
SubAgentErrorEvent,
} from '../subagents/subagent-events.js';
import { ChatRecordingService } from '../services/chatRecordingService.js';
@@ -30,14 +32,6 @@ export interface TaskParams {
subagent_type: string;
}
export interface TaskResult {
success: boolean;
output?: string;
error?: string;
subagent_name?: string;
execution_summary?: string;
}
/**
* Task tool that enables primary agents to delegate tasks to specialized subagents.
* The tool dynamically loads available subagents and includes them in its description
@@ -107,21 +101,6 @@ export class TaskTool extends BaseDeclarativeTool<TaskParams, ToolResult> {
* Updates the tool's description and schema based on available subagents.
*/
private updateDescriptionAndSchema(): void {
// Generate dynamic description
const baseDescription = `Delegate tasks to specialized subagents. This tool allows you to offload specific tasks to agents optimized for particular domains, reducing context usage and improving task completion.
## When to Use This Tool
Use this tool proactively when:
- The task matches a specialized agent's description
- You want to reduce context usage for file searches or analysis
- The task requires domain-specific expertise
- You need to perform focused work that doesn't require the full conversation context
## Available Subagents
`;
let subagentDescriptions = '';
if (this.availableSubagents.length === 0) {
subagentDescriptions =
@@ -132,6 +111,63 @@ Use this tool proactively when:
.join('\n');
}
const baseDescription = `Launch a new agent to handle complex, multi-step tasks autonomously.
Available agent types and the tools they have access to:
${subagentDescriptions}
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
When NOT to use the Agent tool:
- If you want to read a specific file path, use the Read or Glob tool instead of the Agent tool, to find the match more quickly
- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly
- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Agent tool, to find the match more quickly
- Other tasks that are not related to the agent descriptions above
Usage notes:
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
Example usage:
<example_agent_descriptions>
"code-reviewer": use this agent after you are done writing a signficant piece of code
"greeting-responder": use this agent when to respond to user greetings with a friendly joke
</example_agent_description>
<example>
user: "Please write a function that checks if a number is prime"
assistant: Sure let me write a function that checks if a number is prime
assistant: First let me use the Write tool to write a function that checks if a number is prime
assistant: I'm going to use the Write tool to write the following code:
<code>
function isPrime(n) {
if (n <= 1) return false
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false
}
return true
}
</code>
<commentary>
Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code
</commentary>
assistant: Now let me use the code-reviewer agent to review the code
assistant: Uses the Task tool to launch the with the code-reviewer agent
</example>
<example>
user: "Hello"
<commentary>
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
</commentary>
assistant: "I'm going to use the Task tool to launch the with the greeting-responder agent"
</example>
`;
// Update description using object property assignment since it's readonly
(this as { description: string }).description =
baseDescription + subagentDescriptions;
@@ -211,14 +247,7 @@ Use this tool proactively when:
class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
private readonly _eventEmitter: SubAgentEventEmitter;
private currentDisplay: TaskResultDisplay | null = null;
private currentToolCalls: Array<{
name: string;
status: 'executing' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
returnDisplay?: string;
}> = [];
private currentToolCalls: TaskResultDisplay['toolCalls'] = [];
constructor(
private readonly config: Config,
@@ -258,72 +287,75 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
private setupEventListeners(
updateOutput?: (output: ToolResultDisplay) => void,
): void {
this.eventEmitter.on('start', () => {
this.eventEmitter.on(SubAgentEventType.START, () => {
this.updateDisplay({ status: 'running' }, updateOutput);
});
this.eventEmitter.on('model_text', (..._args: unknown[]) => {
// Model text events are no longer displayed as currentStep
// Keep the listener for potential future use
});
this.eventEmitter.on('tool_call', (...args: unknown[]) => {
this.eventEmitter.on(SubAgentEventType.TOOL_CALL, (...args: unknown[]) => {
const event = args[0] as SubAgentToolCallEvent;
const newToolCall = {
callId: event.callId,
name: event.name,
status: 'executing' as const,
args: event.args,
description: event.description,
};
this.currentToolCalls.push(newToolCall);
this.currentToolCalls!.push(newToolCall);
this.updateDisplay(
{
progress: {
toolCalls: [...this.currentToolCalls],
},
toolCalls: [...this.currentToolCalls!],
},
updateOutput,
);
});
this.eventEmitter.on('tool_result', (...args: unknown[]) => {
const event = args[0] as SubAgentToolResultEvent;
const toolCallIndex = this.currentToolCalls.findIndex(
(call) => call.name === event.name,
);
if (toolCallIndex >= 0) {
this.currentToolCalls[toolCallIndex] = {
...this.currentToolCalls[toolCallIndex],
status: event.success ? 'success' : 'failed',
error: event.error,
// Note: result would need to be added to SubAgentToolResultEvent to be captured
};
this.updateDisplay(
{
progress: {
toolCalls: [...this.currentToolCalls],
},
},
updateOutput,
this.eventEmitter.on(
SubAgentEventType.TOOL_RESULT,
(...args: unknown[]) => {
const event = args[0] as SubAgentToolResultEvent;
const toolCallIndex = this.currentToolCalls!.findIndex(
(call) => call.callId === event.callId,
);
}
});
if (toolCallIndex >= 0) {
this.currentToolCalls![toolCallIndex] = {
...this.currentToolCalls![toolCallIndex],
status: event.success ? 'success' : 'failed',
error: event.error,
resultDisplay: event.resultDisplay,
};
this.eventEmitter.on('finish', (...args: unknown[]) => {
this.updateDisplay(
{
toolCalls: [...this.currentToolCalls!],
},
updateOutput,
);
}
},
);
this.eventEmitter.on(SubAgentEventType.FINISH, (...args: unknown[]) => {
const event = args[0] as SubAgentFinishEvent;
this.updateDisplay(
{
status: event.terminate_reason === 'GOAL' ? 'completed' : 'failed',
terminateReason: event.terminate_reason,
// Keep progress data including tool calls for final display
// Keep toolCalls data for final display
},
updateOutput,
);
});
this.eventEmitter.on('error', () => {
this.updateDisplay({ status: 'failed' }, updateOutput);
this.eventEmitter.on(SubAgentEventType.ERROR, (...args: unknown[]) => {
const event = args[0] as SubAgentErrorEvent;
this.updateDisplay(
{
status: 'failed',
terminateReason: event.error,
},
updateOutput,
);
});
}
@@ -348,38 +380,50 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
if (!subagentConfig) {
const errorDisplay = {
type: 'subagent_execution' as const,
type: 'task_execution' as const,
subagentName: this.params.subagent_type,
taskDescription: this.params.description,
taskPrompt: this.params.prompt,
status: 'failed' as const,
terminateReason: 'ERROR',
result: `Subagent "${this.params.subagent_type}" not found`,
subagentColor: undefined,
};
return {
llmContent: [
{
text: JSON.stringify({
success: false,
error: `Subagent "${this.params.subagent_type}" not found`,
}),
},
],
llmContent: `Subagent "${this.params.subagent_type}" not found`,
returnDisplay: errorDisplay,
};
}
// Initialize the current display state
this.currentDisplay = {
type: 'subagent_execution' as const,
type: 'task_execution' as const,
subagentName: subagentConfig.name,
taskDescription: this.params.description,
taskPrompt: this.params.prompt,
status: 'running' as const,
subagentColor: subagentConfig.color,
};
// Set up event listeners for real-time updates
this.setupEventListeners(updateOutput);
if (signal) {
signal.addEventListener('abort', () => {
if (this.currentDisplay) {
this.updateDisplay(
{
status: 'failed',
terminateReason: 'CANCELLED',
result: 'Task was cancelled by user',
},
updateOutput,
);
}
});
}
// Send initial display
if (updateOutput) {
updateOutput(this.currentDisplay);
@@ -432,25 +476,7 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
const finalText = subagentScope.getFinalText();
const terminateReason = subagentScope.output.terminate_reason;
const success = terminateReason === 'GOAL';
// Format the results based on description (iflow-like switch)
const wantDetailed = /\b(stats|statistics|detailed)\b/i.test(
this.params.description,
);
const executionSummary = wantDetailed
? subagentScope.formatDetailedResult(this.params.description)
: subagentScope.formatCompactResult(this.params.description);
const result: TaskResult = {
success,
output: finalText,
subagent_name: subagentConfig.name,
execution_summary: executionSummary,
};
if (!success) {
result.error = `Task did not complete successfully. Termination reason: ${terminateReason}`;
}
const executionSummary = subagentScope.getExecutionSummary();
// Update the final display state
this.updateDisplay(
@@ -459,38 +485,28 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
terminateReason,
result: finalText,
executionSummary,
// Keep progress data including tool calls for final display
},
updateOutput,
);
return {
llmContent: [{ text: JSON.stringify(result) }],
llmContent: [{ text: finalText }],
returnDisplay: this.currentDisplay!,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[TaskTool] Error starting subagent: ${errorMessage}`);
console.error(`[TaskTool] Error running subagent: ${errorMessage}`);
const errorDisplay = {
type: 'subagent_execution' as const,
subagentName: this.params.subagent_type,
taskDescription: this.params.description,
const errorDisplay: TaskResultDisplay = {
...this.currentDisplay!,
status: 'failed' as const,
terminateReason: 'ERROR',
result: `Failed to start subagent: ${errorMessage}`,
result: `Failed to run subagent: ${errorMessage}`,
};
return {
llmContent: [
{
text: JSON.stringify({
success: false,
error: `Failed to start subagent: ${errorMessage}`,
}),
},
],
llmContent: `Failed to run subagent: ${errorMessage}`,
returnDisplay: errorDisplay,
};
}

View File

@@ -8,6 +8,7 @@ import { FunctionDeclaration, PartListUnion } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
import { DiffUpdateResult } from '../ide/ideContext.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { SubagentStatsSummary } from '../subagents/subagent-statistics.js';
/**
* Represents a validated and ready-to-execute tool call.
@@ -422,23 +423,25 @@ export function hasCycleInSchema(schema: object): boolean {
}
export interface TaskResultDisplay {
type: 'subagent_execution';
type: 'task_execution';
subagentName: string;
subagentColor?: string;
taskDescription: string;
taskPrompt: string;
status: 'running' | 'completed' | 'failed';
terminateReason?: string;
result?: string;
executionSummary?: string;
progress?: {
toolCalls?: Array<{
name: string;
status: 'executing' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
returnDisplay?: string;
}>;
};
executionSummary?: SubagentStatsSummary;
toolCalls?: Array<{
callId: string;
name: string;
status: 'executing' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
resultDisplay?: string;
description?: string;
}>;
}
export type ToolResultDisplay =