mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat: add built-in agent(general-purpose)
This commit is contained in:
@@ -8,26 +8,37 @@ import { useState } from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||
import { MANAGEMENT_STEPS } from './types.js';
|
||||
import { SubagentConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
interface ActionSelectionStepProps {
|
||||
selectedAgent: SubagentConfig | null;
|
||||
onNavigateToStep: (step: string) => void;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
export const ActionSelectionStep = ({
|
||||
selectedAgent,
|
||||
onNavigateToStep,
|
||||
onNavigateBack,
|
||||
}: ActionSelectionStepProps) => {
|
||||
const [selectedAction, setSelectedAction] = useState<
|
||||
'view' | 'edit' | 'delete' | null
|
||||
>(null);
|
||||
const actions = [
|
||||
|
||||
// Filter actions based on whether the agent is built-in
|
||||
const allActions = [
|
||||
{ label: 'View Agent', value: 'view' as const },
|
||||
{ label: 'Edit Agent', value: 'edit' as const },
|
||||
{ label: 'Delete Agent', value: 'delete' as const },
|
||||
{ label: 'Back', value: 'back' as const },
|
||||
];
|
||||
|
||||
const actions = selectedAgent?.isBuiltin
|
||||
? allActions.filter(
|
||||
(action) => action.value === 'view' || action.value === 'back',
|
||||
)
|
||||
: allActions;
|
||||
|
||||
const handleActionSelect = (value: 'view' | 'edit' | 'delete' | 'back') => {
|
||||
if (value === 'back') {
|
||||
onNavigateBack();
|
||||
|
||||
@@ -12,9 +12,10 @@ import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { SubagentConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
interface NavigationState {
|
||||
currentBlock: 'project' | 'user';
|
||||
currentBlock: 'project' | 'user' | 'builtin';
|
||||
projectIndex: number;
|
||||
userIndex: number;
|
||||
builtinIndex: number;
|
||||
}
|
||||
|
||||
interface AgentSelectionStepProps {
|
||||
@@ -30,6 +31,7 @@ export const AgentSelectionStep = ({
|
||||
currentBlock: 'project',
|
||||
projectIndex: 0,
|
||||
userIndex: 0,
|
||||
builtinIndex: 0,
|
||||
});
|
||||
|
||||
// Group agents by level
|
||||
@@ -41,6 +43,10 @@ export const AgentSelectionStep = ({
|
||||
() => availableAgents.filter((agent) => agent.level === 'user'),
|
||||
[availableAgents],
|
||||
);
|
||||
const builtinAgents = useMemo(
|
||||
() => availableAgents.filter((agent) => agent.level === 'builtin'),
|
||||
[availableAgents],
|
||||
);
|
||||
const projectNames = useMemo(
|
||||
() => new Set(projectAgents.map((agent) => agent.name)),
|
||||
[projectAgents],
|
||||
@@ -52,8 +58,10 @@ export const AgentSelectionStep = ({
|
||||
setNavigation((prev) => ({ ...prev, currentBlock: 'project' }));
|
||||
} else if (userAgents.length > 0) {
|
||||
setNavigation((prev) => ({ ...prev, currentBlock: 'user' }));
|
||||
} else if (builtinAgents.length > 0) {
|
||||
setNavigation((prev) => ({ ...prev, currentBlock: 'builtin' }));
|
||||
}
|
||||
}, [projectAgents, userAgents]);
|
||||
}, [projectAgents, userAgents, builtinAgents]);
|
||||
|
||||
// Custom keyboard navigation
|
||||
useKeypress(
|
||||
@@ -65,6 +73,13 @@ export const AgentSelectionStep = ({
|
||||
if (prev.currentBlock === 'project') {
|
||||
if (prev.projectIndex > 0) {
|
||||
return { ...prev, projectIndex: prev.projectIndex - 1 };
|
||||
} else if (builtinAgents.length > 0) {
|
||||
// Move to last item in builtin block
|
||||
return {
|
||||
...prev,
|
||||
currentBlock: 'builtin',
|
||||
builtinIndex: builtinAgents.length - 1,
|
||||
};
|
||||
} else if (userAgents.length > 0) {
|
||||
// Move to last item in user block
|
||||
return {
|
||||
@@ -76,7 +91,7 @@ export const AgentSelectionStep = ({
|
||||
// Wrap to last item in project block
|
||||
return { ...prev, projectIndex: projectAgents.length - 1 };
|
||||
}
|
||||
} else {
|
||||
} else if (prev.currentBlock === 'user') {
|
||||
if (prev.userIndex > 0) {
|
||||
return { ...prev, userIndex: prev.userIndex - 1 };
|
||||
} else if (projectAgents.length > 0) {
|
||||
@@ -86,10 +101,39 @@ export const AgentSelectionStep = ({
|
||||
currentBlock: 'project',
|
||||
projectIndex: projectAgents.length - 1,
|
||||
};
|
||||
} else if (builtinAgents.length > 0) {
|
||||
// Move to last item in builtin block
|
||||
return {
|
||||
...prev,
|
||||
currentBlock: 'builtin',
|
||||
builtinIndex: builtinAgents.length - 1,
|
||||
};
|
||||
} else {
|
||||
// Wrap to last item in user block
|
||||
return { ...prev, userIndex: userAgents.length - 1 };
|
||||
}
|
||||
} else {
|
||||
// builtin block
|
||||
if (prev.builtinIndex > 0) {
|
||||
return { ...prev, builtinIndex: prev.builtinIndex - 1 };
|
||||
} else if (userAgents.length > 0) {
|
||||
// Move to last item in user block
|
||||
return {
|
||||
...prev,
|
||||
currentBlock: 'user',
|
||||
userIndex: userAgents.length - 1,
|
||||
};
|
||||
} else if (projectAgents.length > 0) {
|
||||
// Move to last item in project block
|
||||
return {
|
||||
...prev,
|
||||
currentBlock: 'project',
|
||||
projectIndex: projectAgents.length - 1,
|
||||
};
|
||||
} else {
|
||||
// Wrap to last item in builtin block
|
||||
return { ...prev, builtinIndex: builtinAgents.length - 1 };
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (name === 'down' || name === 'j') {
|
||||
@@ -100,13 +144,19 @@ export const AgentSelectionStep = ({
|
||||
} else if (userAgents.length > 0) {
|
||||
// Move to first item in user block
|
||||
return { ...prev, currentBlock: 'user', userIndex: 0 };
|
||||
} else if (builtinAgents.length > 0) {
|
||||
// Move to first item in builtin block
|
||||
return { ...prev, currentBlock: 'builtin', builtinIndex: 0 };
|
||||
} else {
|
||||
// Wrap to first item in project block
|
||||
return { ...prev, projectIndex: 0 };
|
||||
}
|
||||
} else {
|
||||
} else if (prev.currentBlock === 'user') {
|
||||
if (prev.userIndex < userAgents.length - 1) {
|
||||
return { ...prev, userIndex: prev.userIndex + 1 };
|
||||
} else if (builtinAgents.length > 0) {
|
||||
// Move to first item in builtin block
|
||||
return { ...prev, currentBlock: 'builtin', builtinIndex: 0 };
|
||||
} else if (projectAgents.length > 0) {
|
||||
// Move to first item in project block
|
||||
return { ...prev, currentBlock: 'project', projectIndex: 0 };
|
||||
@@ -114,6 +164,20 @@ export const AgentSelectionStep = ({
|
||||
// Wrap to first item in user block
|
||||
return { ...prev, userIndex: 0 };
|
||||
}
|
||||
} else {
|
||||
// builtin block
|
||||
if (prev.builtinIndex < builtinAgents.length - 1) {
|
||||
return { ...prev, builtinIndex: prev.builtinIndex + 1 };
|
||||
} else if (projectAgents.length > 0) {
|
||||
// Move to first item in project block
|
||||
return { ...prev, currentBlock: 'project', projectIndex: 0 };
|
||||
} else if (userAgents.length > 0) {
|
||||
// Move to first item in user block
|
||||
return { ...prev, currentBlock: 'user', userIndex: 0 };
|
||||
} else {
|
||||
// Wrap to first item in builtin block
|
||||
return { ...prev, builtinIndex: 0 };
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (name === 'return' || name === 'space') {
|
||||
@@ -121,9 +185,14 @@ export const AgentSelectionStep = ({
|
||||
let globalIndex: number;
|
||||
if (navigation.currentBlock === 'project') {
|
||||
globalIndex = navigation.projectIndex;
|
||||
} else {
|
||||
} else if (navigation.currentBlock === 'user') {
|
||||
// User agents come after project agents in the availableAgents array
|
||||
globalIndex = projectAgents.length + navigation.userIndex;
|
||||
} else {
|
||||
// builtin block
|
||||
// Builtin agents come after project and user agents in the availableAgents array
|
||||
globalIndex =
|
||||
projectAgents.length + userAgents.length + navigation.builtinIndex;
|
||||
}
|
||||
|
||||
if (globalIndex >= 0 && globalIndex < availableAgents.length) {
|
||||
@@ -147,7 +216,11 @@ export const AgentSelectionStep = ({
|
||||
|
||||
// Render custom radio button items
|
||||
const renderAgentItem = (
|
||||
agent: { name: string; level: 'project' | 'user' },
|
||||
agent: {
|
||||
name: string;
|
||||
level: 'project' | 'user' | 'builtin';
|
||||
isBuiltin?: boolean;
|
||||
},
|
||||
index: number,
|
||||
isSelected: boolean,
|
||||
) => {
|
||||
@@ -162,6 +235,12 @@ export const AgentSelectionStep = ({
|
||||
</Box>
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{agent.name}
|
||||
{agent.isBuiltin && (
|
||||
<Text color={isSelected ? theme.text.accent : theme.text.secondary}>
|
||||
{' '}
|
||||
(built-in)
|
||||
</Text>
|
||||
)}
|
||||
{agent.level === 'user' && projectNames.has(agent.name) && (
|
||||
<Text color={isSelected ? theme.status.warning : Colors.Gray}>
|
||||
{' '}
|
||||
@@ -176,7 +255,8 @@ export const AgentSelectionStep = ({
|
||||
// Calculate enabled agents count (excluding conflicted user-level agents)
|
||||
const enabledAgentsCount =
|
||||
projectAgents.length +
|
||||
userAgents.filter((agent) => !projectNames.has(agent.name)).length;
|
||||
userAgents.filter((agent) => !projectNames.has(agent.name)).length +
|
||||
builtinAgents.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -199,7 +279,10 @@ export const AgentSelectionStep = ({
|
||||
|
||||
{/* User Level Agents */}
|
||||
{userAgents.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginBottom={builtinAgents.length > 0 ? 1 : 0}
|
||||
>
|
||||
<Text color={theme.text.primary} bold>
|
||||
User Level ({userAgents[0].filePath.replace(/\/[^/]+$/, '')})
|
||||
</Text>
|
||||
@@ -214,8 +297,27 @@ export const AgentSelectionStep = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Built-in Agents */}
|
||||
{builtinAgents.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary} bold>
|
||||
Built-in Agents
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{builtinAgents.map((agent, index) => {
|
||||
const isSelected =
|
||||
navigation.currentBlock === 'builtin' &&
|
||||
navigation.builtinIndex === index;
|
||||
return renderAgentItem(agent, index, isSelected);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Agent count summary */}
|
||||
{(projectAgents.length > 0 || userAgents.length > 0) && (
|
||||
{(projectAgents.length > 0 ||
|
||||
userAgents.length > 0 ||
|
||||
builtinAgents.length > 0) && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Using: {enabledAgentsCount} agents
|
||||
|
||||
@@ -29,15 +29,6 @@ export const AgentViewerStep = ({ selectedAgent }: AgentViewerStepProps) => {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold>Location: </Text>
|
||||
<Text>
|
||||
{agent.level === 'project'
|
||||
? 'Project Level (.qwen/agents/)'
|
||||
: 'User Level (~/.qwen/agents/)'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text bold>File Path: </Text>
|
||||
<Text>{agent.filePath}</Text>
|
||||
|
||||
@@ -50,14 +50,19 @@ export function AgentsManagerDialog({
|
||||
|
||||
const manager = config.getSubagentManager();
|
||||
|
||||
// Load agents from both levels separately to show all agents including conflicts
|
||||
const [projectAgents, userAgents] = await Promise.all([
|
||||
// Load agents from all levels separately to show all agents including conflicts
|
||||
const [projectAgents, userAgents, builtinAgents] = await Promise.all([
|
||||
manager.listSubagents({ level: 'project' }),
|
||||
manager.listSubagents({ level: 'user' }),
|
||||
manager.listSubagents({ level: 'builtin' }),
|
||||
]);
|
||||
|
||||
// Combine all agents (project and user level)
|
||||
const allAgents = [...(projectAgents || []), ...(userAgents || [])];
|
||||
// Combine all agents (project, user, and builtin level)
|
||||
const allAgents = [
|
||||
...(projectAgents || []),
|
||||
...(userAgents || []),
|
||||
...(builtinAgents || []),
|
||||
];
|
||||
|
||||
setAvailableAgents(allAgents);
|
||||
}, [config]);
|
||||
@@ -208,7 +213,9 @@ export function AgentsManagerDialog({
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.ACTION_SELECTION:
|
||||
return <ActionSelectionStep {...commonProps} />;
|
||||
return (
|
||||
<ActionSelectionStep selectedAgent={selectedAgent} {...commonProps} />
|
||||
);
|
||||
case MANAGEMENT_STEPS.AGENT_VIEWER:
|
||||
return (
|
||||
<AgentViewerStep selectedAgent={selectedAgent} {...commonProps} />
|
||||
|
||||
@@ -289,9 +289,7 @@ const ToolCallItem: React.FC<{
|
||||
<Box flexDirection="row">
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusIcon}</Box>
|
||||
<Text wrap="truncate-end">
|
||||
<Text>
|
||||
{toolCall.name}
|
||||
</Text>{' '}
|
||||
<Text>{toolCall.name}</Text>{' '}
|
||||
<Text color={Colors.Gray}>{description}</Text>
|
||||
{toolCall.error && (
|
||||
<Text color={theme.status.error}> - {toolCall.error}</Text>
|
||||
|
||||
@@ -65,7 +65,8 @@ export function ToolSelector({
|
||||
(tool) =>
|
||||
tool.kind === Kind.Read ||
|
||||
tool.kind === Kind.Search ||
|
||||
tool.kind === Kind.Fetch,
|
||||
tool.kind === Kind.Fetch ||
|
||||
tool.kind === Kind.Think,
|
||||
)
|
||||
.map((tool) => tool.displayName)
|
||||
.sort();
|
||||
@@ -75,8 +76,7 @@ export function ToolSelector({
|
||||
(tool) =>
|
||||
tool.kind === Kind.Edit ||
|
||||
tool.kind === Kind.Delete ||
|
||||
tool.kind === Kind.Move ||
|
||||
tool.kind === Kind.Think,
|
||||
tool.kind === Kind.Move,
|
||||
)
|
||||
.map((tool) => tool.displayName)
|
||||
.sort();
|
||||
|
||||
95
packages/core/src/subagents/builtin-agents.test.ts
Normal file
95
packages/core/src/subagents/builtin-agents.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BuiltinAgentRegistry } from './builtin-agents.js';
|
||||
|
||||
describe('BuiltinAgentRegistry', () => {
|
||||
describe('getBuiltinAgents', () => {
|
||||
it('should return array of builtin agents with correct properties', () => {
|
||||
const agents = BuiltinAgentRegistry.getBuiltinAgents();
|
||||
|
||||
expect(agents).toBeInstanceOf(Array);
|
||||
expect(agents.length).toBeGreaterThan(0);
|
||||
|
||||
agents.forEach((agent) => {
|
||||
expect(agent).toMatchObject({
|
||||
name: expect.any(String),
|
||||
description: expect.any(String),
|
||||
systemPrompt: expect.any(String),
|
||||
level: 'builtin',
|
||||
filePath: `<builtin:${agent.name}>`,
|
||||
isBuiltin: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should include general-purpose agent', () => {
|
||||
const agents = BuiltinAgentRegistry.getBuiltinAgents();
|
||||
const generalAgent = agents.find(
|
||||
(agent) => agent.name === 'general-purpose',
|
||||
);
|
||||
|
||||
expect(generalAgent).toBeDefined();
|
||||
expect(generalAgent?.description).toContain('General-purpose agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuiltinAgent', () => {
|
||||
it('should return correct agent for valid name', () => {
|
||||
const agent = BuiltinAgentRegistry.getBuiltinAgent('general-purpose');
|
||||
|
||||
expect(agent).toMatchObject({
|
||||
name: 'general-purpose',
|
||||
level: 'builtin',
|
||||
filePath: '<builtin:general-purpose>',
|
||||
isBuiltin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for invalid name', () => {
|
||||
expect(BuiltinAgentRegistry.getBuiltinAgent('invalid')).toBeNull();
|
||||
expect(BuiltinAgentRegistry.getBuiltinAgent('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBuiltinAgent', () => {
|
||||
it('should return true for valid builtin agent names', () => {
|
||||
expect(BuiltinAgentRegistry.isBuiltinAgent('general-purpose')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid names', () => {
|
||||
expect(BuiltinAgentRegistry.isBuiltinAgent('invalid')).toBe(false);
|
||||
expect(BuiltinAgentRegistry.isBuiltinAgent('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuiltinAgentNames', () => {
|
||||
it('should return array of agent names', () => {
|
||||
const names = BuiltinAgentRegistry.getBuiltinAgentNames();
|
||||
|
||||
expect(names).toBeInstanceOf(Array);
|
||||
expect(names).toContain('general-purpose');
|
||||
expect(names.every((name) => typeof name === 'string')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consistency', () => {
|
||||
it('should maintain consistency across all methods', () => {
|
||||
const agents = BuiltinAgentRegistry.getBuiltinAgents();
|
||||
const names = BuiltinAgentRegistry.getBuiltinAgentNames();
|
||||
|
||||
// Names should match agents
|
||||
expect(names).toEqual(agents.map((agent) => agent.name));
|
||||
|
||||
// Each name should be valid
|
||||
names.forEach((name) => {
|
||||
expect(BuiltinAgentRegistry.isBuiltinAgent(name)).toBe(true);
|
||||
expect(BuiltinAgentRegistry.getBuiltinAgent(name)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
95
packages/core/src/subagents/builtin-agents.ts
Normal file
95
packages/core/src/subagents/builtin-agents.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SubagentConfig } from './types.js';
|
||||
|
||||
/**
|
||||
* Registry of built-in subagents that are always available to all users.
|
||||
* These agents are embedded in the codebase and cannot be modified or deleted.
|
||||
*/
|
||||
export class BuiltinAgentRegistry {
|
||||
private static readonly BUILTIN_AGENTS: Array<
|
||||
Omit<SubagentConfig, 'level' | 'filePath'>
|
||||
> = [
|
||||
{
|
||||
name: 'general-purpose',
|
||||
description:
|
||||
'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.',
|
||||
systemPrompt: `You are a general-purpose research and code analysis agent. Given the user's message, you should use the tools available to complete the task. Do what has been asked; nothing more, nothing less. When you complete the task simply respond with a detailed writeup.
|
||||
|
||||
Your strengths:
|
||||
- Searching for code, configurations, and patterns across large codebases
|
||||
- Analyzing multiple files to understand system architecture
|
||||
- Investigating complex questions that require exploring many files
|
||||
- Performing multi-step research tasks
|
||||
|
||||
Guidelines:
|
||||
- For file searches: Use Grep or Glob when you need to search broadly. Use Read when you know the specific file path.
|
||||
- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.
|
||||
- Be thorough: Check multiple locations, consider different naming conventions, look for related files.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.
|
||||
- In your final response always share relevant file names and code snippets. Any file paths you return in your response MUST be absolute. Do NOT use relative paths.
|
||||
- For clear communication, avoid using emojis.
|
||||
|
||||
|
||||
Notes:
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
- In your final response always share relevant file names and code snippets. Any file paths you return in your response MUST be absolute. Do NOT use relative paths.
|
||||
- For clear communication with the user the assistant MUST avoid using emojis.`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets all built-in agent configurations.
|
||||
* @returns Array of built-in subagent configurations
|
||||
*/
|
||||
static getBuiltinAgents(): SubagentConfig[] {
|
||||
return this.BUILTIN_AGENTS.map((agent) => ({
|
||||
...agent,
|
||||
level: 'builtin' as const,
|
||||
filePath: `<builtin:${agent.name}>`,
|
||||
isBuiltin: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a specific built-in agent by name.
|
||||
* @param name - Name of the built-in agent
|
||||
* @returns Built-in agent configuration or null if not found
|
||||
*/
|
||||
static getBuiltinAgent(name: string): SubagentConfig | null {
|
||||
const agent = this.BUILTIN_AGENTS.find((a) => a.name === name);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...agent,
|
||||
level: 'builtin' as const,
|
||||
filePath: `<builtin:${name}>`,
|
||||
isBuiltin: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an agent name corresponds to a built-in agent.
|
||||
* @param name - Agent name to check
|
||||
* @returns True if the name is a built-in agent
|
||||
*/
|
||||
static isBuiltinAgent(name: string): boolean {
|
||||
return this.BUILTIN_AGENTS.some((agent) => agent.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the names of all built-in agents.
|
||||
* @returns Array of built-in agent names
|
||||
*/
|
||||
static getBuiltinAgentNames(): string[] {
|
||||
return this.BUILTIN_AGENTS.map((agent) => agent.name);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export type {
|
||||
|
||||
export { SubagentError } from './types.js';
|
||||
|
||||
// Built-in agents registry
|
||||
export { BuiltinAgentRegistry } from './builtin-agents.js';
|
||||
|
||||
// Validation system
|
||||
export { SubagentValidator } from './validation.js';
|
||||
|
||||
@@ -70,4 +73,3 @@ export type {
|
||||
SubagentStatsSummary,
|
||||
ToolUsageStats,
|
||||
} from './subagent-statistics.js';
|
||||
export { formatCompact, formatDetailed } from './subagent-result-format.js';
|
||||
|
||||
@@ -584,11 +584,12 @@ System prompt 3`);
|
||||
it('should list subagents from both levels', async () => {
|
||||
const subagents = await manager.listSubagents();
|
||||
|
||||
expect(subagents).toHaveLength(3); // agent1 (project takes precedence), agent2, agent3
|
||||
expect(subagents).toHaveLength(4); // agent1 (project takes precedence), agent2, agent3, general-purpose (built-in)
|
||||
expect(subagents.map((s) => s.name)).toEqual([
|
||||
'agent1',
|
||||
'agent2',
|
||||
'agent3',
|
||||
'general-purpose',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -615,7 +616,7 @@ System prompt 3`);
|
||||
});
|
||||
|
||||
const names = subagents.map((s) => s.name);
|
||||
expect(names).toEqual(['agent1', 'agent2', 'agent3']);
|
||||
expect(names).toEqual(['agent1', 'agent2', 'agent3', 'general-purpose']);
|
||||
});
|
||||
|
||||
it('should handle empty directories', async () => {
|
||||
@@ -626,7 +627,9 @@ System prompt 3`);
|
||||
|
||||
const subagents = await manager.listSubagents();
|
||||
|
||||
expect(subagents).toHaveLength(0);
|
||||
expect(subagents).toHaveLength(1); // Only built-in agents remain
|
||||
expect(subagents[0].name).toBe('general-purpose');
|
||||
expect(subagents[0].level).toBe('builtin');
|
||||
});
|
||||
|
||||
it('should handle directory read errors', async () => {
|
||||
@@ -636,7 +639,9 @@ System prompt 3`);
|
||||
|
||||
const subagents = await manager.listSubagents();
|
||||
|
||||
expect(subagents).toHaveLength(0);
|
||||
expect(subagents).toHaveLength(1); // Only built-in agents remain
|
||||
expect(subagents[0].name).toBe('general-purpose');
|
||||
expect(subagents[0].level).toBe('builtin');
|
||||
});
|
||||
|
||||
it('should skip invalid subagent files', async () => {
|
||||
@@ -656,7 +661,7 @@ System prompt 3`);
|
||||
|
||||
const subagents = await manager.listSubagents();
|
||||
|
||||
expect(subagents).toHaveLength(1);
|
||||
expect(subagents).toHaveLength(2); // 1 valid file + 1 built-in agent
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Skipping invalid subagent file'),
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { SubagentValidator } from './validation.js';
|
||||
import { SubAgentScope } from './subagent.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { BuiltinAgentRegistry } from './builtin-agents.js';
|
||||
|
||||
const QWEN_CONFIG_DIR = '.qwen';
|
||||
const AGENT_CONFIG_DIR = 'agents';
|
||||
@@ -104,7 +105,7 @@ export class SubagentManager {
|
||||
/**
|
||||
* Loads a subagent configuration by name.
|
||||
* If level is specified, only searches that level.
|
||||
* If level is omitted, searches project-level first, then user-level.
|
||||
* If level is omitted, searches project-level first, then user-level, then built-in.
|
||||
*
|
||||
* @param name - Name of the subagent to load
|
||||
* @param level - Optional level to limit search to specific level
|
||||
@@ -116,6 +117,10 @@ export class SubagentManager {
|
||||
): Promise<SubagentConfig | null> {
|
||||
if (level) {
|
||||
// Search only the specified level
|
||||
if (level === 'builtin') {
|
||||
return BuiltinAgentRegistry.getBuiltinAgent(name);
|
||||
}
|
||||
|
||||
const path = this.getSubagentPath(name, level);
|
||||
try {
|
||||
const config = await this.parseSubagentFile(path);
|
||||
@@ -140,9 +145,11 @@ export class SubagentManager {
|
||||
const config = await this.parseSubagentFile(userPath);
|
||||
return config;
|
||||
} catch (_error) {
|
||||
// Not found at either level
|
||||
return null;
|
||||
// Continue to built-in agents
|
||||
}
|
||||
|
||||
// Try built-in agents as fallback
|
||||
return BuiltinAgentRegistry.getBuiltinAgent(name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +173,15 @@ export class SubagentManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent updating built-in agents
|
||||
if (existing.isBuiltin) {
|
||||
throw new SubagentError(
|
||||
`Cannot update built-in subagent "${name}"`,
|
||||
SubagentErrorCode.INVALID_CONFIG,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
// Merge updates with existing configuration
|
||||
const updatedConfig = this.mergeConfigurations(existing, updates);
|
||||
|
||||
@@ -194,12 +210,26 @@ export class SubagentManager {
|
||||
* @throws SubagentError if deletion fails
|
||||
*/
|
||||
async deleteSubagent(name: string, level?: SubagentLevel): Promise<void> {
|
||||
// Check if it's a built-in agent first
|
||||
if (BuiltinAgentRegistry.isBuiltinAgent(name)) {
|
||||
throw new SubagentError(
|
||||
`Cannot delete built-in subagent "${name}"`,
|
||||
SubagentErrorCode.INVALID_CONFIG,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
const levelsToCheck: SubagentLevel[] = level
|
||||
? [level]
|
||||
: ['project', 'user'];
|
||||
let deleted = false;
|
||||
|
||||
for (const currentLevel of levelsToCheck) {
|
||||
// Skip builtin level for deletion
|
||||
if (currentLevel === 'builtin') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = this.getSubagentPath(name, currentLevel);
|
||||
|
||||
try {
|
||||
@@ -233,14 +263,14 @@ export class SubagentManager {
|
||||
|
||||
const levelsToCheck: SubagentLevel[] = options.level
|
||||
? [options.level]
|
||||
: ['project', 'user'];
|
||||
: ['project', 'user', 'builtin'];
|
||||
|
||||
// Collect subagents from each level (project takes precedence)
|
||||
// Collect subagents from each level (project takes precedence over user, user takes precedence over builtin)
|
||||
for (const level of levelsToCheck) {
|
||||
const levelSubagents = await this.listSubagentsAtLevel(level);
|
||||
|
||||
for (const subagent of levelSubagents) {
|
||||
// Skip if we've already seen this name (project takes precedence)
|
||||
// Skip if we've already seen this name (precedence: project > user > builtin)
|
||||
if (seenNames.has(subagent.name)) {
|
||||
continue;
|
||||
}
|
||||
@@ -267,11 +297,12 @@ export class SubagentManager {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'level':
|
||||
// Project comes before user
|
||||
comparison =
|
||||
a.level === 'project' ? -1 : b.level === 'project' ? 1 : 0;
|
||||
case 'level': {
|
||||
// Project comes before user, user comes before builtin
|
||||
const levelOrder = { project: 0, user: 1, builtin: 2 };
|
||||
comparison = levelOrder[a.level] - levelOrder[b.level];
|
||||
break;
|
||||
}
|
||||
default:
|
||||
comparison = 0;
|
||||
break;
|
||||
@@ -605,6 +636,10 @@ export class SubagentManager {
|
||||
* @returns Absolute file path
|
||||
*/
|
||||
getSubagentPath(name: string, level: SubagentLevel): string {
|
||||
if (level === 'builtin') {
|
||||
return `<builtin:${name}>`;
|
||||
}
|
||||
|
||||
const baseDir =
|
||||
level === 'project'
|
||||
? path.join(
|
||||
@@ -626,6 +661,11 @@ export class SubagentManager {
|
||||
private async listSubagentsAtLevel(
|
||||
level: SubagentLevel,
|
||||
): Promise<SubagentConfig[]> {
|
||||
// Handle built-in agents
|
||||
if (level === 'builtin') {
|
||||
return BuiltinAgentRegistry.getBuiltinAgents();
|
||||
}
|
||||
|
||||
const baseDir =
|
||||
level === 'project'
|
||||
? path.join(
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SubagentStatsSummary } from './subagent-statistics.js';
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
export function formatCompact(
|
||||
stats: SubagentStatsSummary,
|
||||
taskDesc: string,
|
||||
): string {
|
||||
const sr =
|
||||
stats.totalToolCalls > 0
|
||||
? (stats.successRate ??
|
||||
(stats.successfulToolCalls / stats.totalToolCalls) * 100)
|
||||
: 0;
|
||||
const lines = [
|
||||
`📋 Task Completed: ${taskDesc}`,
|
||||
`🔧 Tool Usage: ${stats.totalToolCalls} calls${stats.totalToolCalls ? `, ${sr.toFixed(1)}% success` : ''}`,
|
||||
`⏱️ Duration: ${fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
|
||||
];
|
||||
if (typeof stats.totalTokens === 'number') {
|
||||
lines.push(
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function formatDetailed(
|
||||
stats: SubagentStatsSummary,
|
||||
taskDesc: string,
|
||||
): string {
|
||||
const sr =
|
||||
stats.totalToolCalls > 0
|
||||
? (stats.successRate ??
|
||||
(stats.successfulToolCalls / stats.totalToolCalls) * 100)
|
||||
: 0;
|
||||
const lines: string[] = [];
|
||||
lines.push(`📋 Task Completed: ${taskDesc}`);
|
||||
lines.push(
|
||||
`⏱️ Duration: ${fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
|
||||
);
|
||||
// Quality indicator
|
||||
let quality = 'Poor execution';
|
||||
if (sr >= 95) quality = 'Excellent execution';
|
||||
else if (sr >= 85) quality = 'Good execution';
|
||||
else if (sr >= 70) quality = 'Fair execution';
|
||||
lines.push(`✅ Quality: ${quality} (${sr.toFixed(1)}% tool success)`);
|
||||
// Speed category
|
||||
const d = stats.totalDurationMs;
|
||||
let speed = 'Long execution - consider breaking down tasks';
|
||||
if (d < 10_000) speed = 'Fast completion - under 10 seconds';
|
||||
else if (d < 60_000) speed = 'Good speed - under a minute';
|
||||
else if (d < 300_000) speed = 'Moderate duration - a few minutes';
|
||||
lines.push(`🚀 Speed: ${speed}`);
|
||||
lines.push(
|
||||
`🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
|
||||
);
|
||||
if (typeof stats.totalTokens === 'number') {
|
||||
lines.push(
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`,
|
||||
);
|
||||
}
|
||||
if (stats.toolUsage && stats.toolUsage.length) {
|
||||
const sorted = [...stats.toolUsage]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
lines.push('\nTop tools:');
|
||||
for (const t of sorted) {
|
||||
const avg =
|
||||
typeof t.averageDurationMs === 'number'
|
||||
? `, avg ${fmtDuration(Math.round(t.averageDurationMs))}`
|
||||
: '';
|
||||
lines.push(
|
||||
` - ${t.name}: ${t.count} calls (${t.success} ok, ${t.failure} fail${avg}${t.lastError ? `, last error: ${t.lastError}` : ''})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const tips = generatePerformanceTips(stats);
|
||||
if (tips.length) {
|
||||
lines.push('\n💡 Performance Insights:');
|
||||
for (const tip of tips.slice(0, 3)) lines.push(` - ${tip}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function generatePerformanceTips(stats: SubagentStatsSummary): string[] {
|
||||
const tips: string[] = [];
|
||||
const totalCalls = stats.totalToolCalls;
|
||||
const sr =
|
||||
stats.totalToolCalls > 0
|
||||
? (stats.successRate ??
|
||||
(stats.successfulToolCalls / stats.totalToolCalls) * 100)
|
||||
: 0;
|
||||
|
||||
// High failure rate
|
||||
if (sr < 80)
|
||||
tips.push('Low tool success rate - review inputs and error messages');
|
||||
|
||||
// Long duration
|
||||
if (stats.totalDurationMs > 60_000)
|
||||
tips.push('Long execution time - consider breaking down complex tasks');
|
||||
|
||||
// Token usage
|
||||
if (typeof stats.totalTokens === 'number' && stats.totalTokens > 100_000) {
|
||||
tips.push(
|
||||
'High token usage - consider optimizing prompts or narrowing scope',
|
||||
);
|
||||
}
|
||||
if (typeof stats.totalTokens === 'number' && totalCalls > 0) {
|
||||
const avgTokPerCall = stats.totalTokens / totalCalls;
|
||||
if (avgTokPerCall > 5_000)
|
||||
tips.push(
|
||||
`High token usage per tool call (~${Math.round(avgTokPerCall)} tokens/call)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Network failures
|
||||
const isNetworkTool = (name: string) => /web|fetch|search/i.test(name);
|
||||
const hadNetworkFailure = (stats.toolUsage || []).some(
|
||||
(t) =>
|
||||
isNetworkTool(t.name) &&
|
||||
t.lastError &&
|
||||
/timeout|network/i.test(t.lastError),
|
||||
);
|
||||
if (hadNetworkFailure)
|
||||
tips.push(
|
||||
'Network operations had failures - consider increasing timeout or checking connectivity',
|
||||
);
|
||||
|
||||
// Slow tools
|
||||
const slow = (stats.toolUsage || [])
|
||||
.filter((t) => (t.averageDurationMs ?? 0) > 10_000)
|
||||
.sort((a, b) => (b.averageDurationMs ?? 0) - (a.averageDurationMs ?? 0));
|
||||
if (slow.length)
|
||||
tips.push(
|
||||
`Consider optimizing ${slow[0].name} operations (avg ${fmtDuration(Math.round(slow[0].averageDurationMs!))})`,
|
||||
);
|
||||
|
||||
return tips;
|
||||
}
|
||||
309
packages/core/src/subagents/subagent-statistics.test.ts
Normal file
309
packages/core/src/subagents/subagent-statistics.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { SubagentStatistics } from './subagent-statistics.js';
|
||||
|
||||
describe('SubagentStatistics', () => {
|
||||
let stats: SubagentStatistics;
|
||||
const baseTime = 1000000000000; // Fixed timestamp for consistent testing
|
||||
|
||||
beforeEach(() => {
|
||||
stats = new SubagentStatistics();
|
||||
});
|
||||
|
||||
describe('basic statistics tracking', () => {
|
||||
it('should track execution time', () => {
|
||||
stats.start(baseTime);
|
||||
const summary = stats.getSummary(baseTime + 5000);
|
||||
|
||||
expect(summary.totalDurationMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('should track rounds', () => {
|
||||
stats.setRounds(3);
|
||||
const summary = stats.getSummary();
|
||||
|
||||
expect(summary.rounds).toBe(3);
|
||||
});
|
||||
|
||||
it('should track tool calls', () => {
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordToolCall('web_search', false, 200, 'Network timeout');
|
||||
|
||||
const summary = stats.getSummary();
|
||||
expect(summary.totalToolCalls).toBe(2);
|
||||
expect(summary.successfulToolCalls).toBe(1);
|
||||
expect(summary.failedToolCalls).toBe(1);
|
||||
expect(summary.successRate).toBe(50);
|
||||
});
|
||||
|
||||
it('should track tokens', () => {
|
||||
stats.recordTokens(1000, 500);
|
||||
stats.recordTokens(200, 100);
|
||||
|
||||
const summary = stats.getSummary();
|
||||
expect(summary.inputTokens).toBe(1200);
|
||||
expect(summary.outputTokens).toBe(600);
|
||||
expect(summary.totalTokens).toBe(1800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool usage statistics', () => {
|
||||
it('should track individual tool usage', () => {
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordToolCall('file_read', false, 150, 'Permission denied');
|
||||
stats.recordToolCall('web_search', true, 300);
|
||||
|
||||
const summary = stats.getSummary();
|
||||
const fileReadTool = summary.toolUsage.find(
|
||||
(t) => t.name === 'file_read',
|
||||
);
|
||||
const webSearchTool = summary.toolUsage.find(
|
||||
(t) => t.name === 'web_search',
|
||||
);
|
||||
|
||||
expect(fileReadTool).toEqual({
|
||||
name: 'file_read',
|
||||
count: 2,
|
||||
success: 1,
|
||||
failure: 1,
|
||||
lastError: 'Permission denied',
|
||||
totalDurationMs: 250,
|
||||
averageDurationMs: 125,
|
||||
});
|
||||
|
||||
expect(webSearchTool).toEqual({
|
||||
name: 'web_search',
|
||||
count: 1,
|
||||
success: 1,
|
||||
failure: 0,
|
||||
lastError: undefined,
|
||||
totalDurationMs: 300,
|
||||
averageDurationMs: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCompact', () => {
|
||||
it('should format basic execution summary', () => {
|
||||
stats.start(baseTime);
|
||||
stats.setRounds(2);
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordTokens(1000, 500);
|
||||
|
||||
const result = stats.formatCompact('Test task', baseTime + 5000);
|
||||
|
||||
expect(result).toContain('📋 Task Completed: Test task');
|
||||
expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success');
|
||||
expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2');
|
||||
expect(result).toContain('🔢 Tokens: 1,500 (in 1000, out 500)');
|
||||
});
|
||||
|
||||
it('should handle zero tool calls', () => {
|
||||
stats.start(baseTime);
|
||||
|
||||
const result = stats.formatCompact('Empty task', baseTime + 1000);
|
||||
|
||||
expect(result).toContain('🔧 Tool Usage: 0 calls');
|
||||
expect(result).not.toContain('% success');
|
||||
});
|
||||
|
||||
it('should show zero tokens when no tokens recorded', () => {
|
||||
stats.start(baseTime);
|
||||
stats.recordToolCall('test', true, 100);
|
||||
|
||||
const result = stats.formatCompact('No tokens task', baseTime + 1000);
|
||||
|
||||
expect(result).toContain('🔢 Tokens: 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDetailed', () => {
|
||||
beforeEach(() => {
|
||||
stats.start(baseTime);
|
||||
stats.setRounds(3);
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordToolCall('file_read', true, 150);
|
||||
stats.recordToolCall('web_search', false, 2000, 'Network timeout');
|
||||
stats.recordTokens(2000, 1000);
|
||||
});
|
||||
|
||||
it('should include quality assessment', () => {
|
||||
const result = stats.formatDetailed('Complex task', baseTime + 30000);
|
||||
|
||||
expect(result).toContain(
|
||||
'✅ Quality: Poor execution (66.7% tool success)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include speed assessment', () => {
|
||||
const result = stats.formatDetailed('Fast task', baseTime + 5000);
|
||||
|
||||
expect(result).toContain('🚀 Speed: Fast completion - under 10 seconds');
|
||||
});
|
||||
|
||||
it('should show top tools', () => {
|
||||
const result = stats.formatDetailed('Tool-heavy task', baseTime + 15000);
|
||||
|
||||
expect(result).toContain('Top tools:');
|
||||
expect(result).toContain('- file_read: 2 calls (2 ok, 0 fail');
|
||||
expect(result).toContain('- web_search: 1 calls (0 ok, 1 fail');
|
||||
expect(result).toContain('last error: Network timeout');
|
||||
});
|
||||
|
||||
it('should include performance insights', () => {
|
||||
const result = stats.formatDetailed('Slow task', baseTime + 120000);
|
||||
|
||||
expect(result).toContain('💡 Performance Insights:');
|
||||
expect(result).toContain(
|
||||
'Long execution time - consider breaking down complex tasks',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quality categories', () => {
|
||||
it('should categorize excellent execution', () => {
|
||||
stats.recordToolCall('test', true, 100);
|
||||
stats.recordToolCall('test', true, 100);
|
||||
|
||||
const result = stats.formatDetailed('Perfect task');
|
||||
expect(result).toContain('Excellent execution (100.0% tool success)');
|
||||
});
|
||||
|
||||
it('should categorize good execution', () => {
|
||||
// Need 85% success rate for "Good execution" - 17 success, 3 failures = 85%
|
||||
for (let i = 0; i < 17; i++) {
|
||||
stats.recordToolCall('test', true, 100);
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stats.recordToolCall('test', false, 100);
|
||||
}
|
||||
|
||||
const result = stats.formatDetailed('Good task');
|
||||
expect(result).toContain('Good execution (85.0% tool success)');
|
||||
});
|
||||
|
||||
it('should categorize poor execution', () => {
|
||||
stats.recordToolCall('test', false, 100);
|
||||
stats.recordToolCall('test', false, 100);
|
||||
|
||||
const result = stats.formatDetailed('Poor task');
|
||||
expect(result).toContain('Poor execution (0.0% tool success)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('speed categories', () => {
|
||||
it('should categorize fast completion', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatDetailed('Fast task', baseTime + 5000);
|
||||
expect(result).toContain('Fast completion - under 10 seconds');
|
||||
});
|
||||
|
||||
it('should categorize good speed', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatDetailed('Medium task', baseTime + 30000);
|
||||
expect(result).toContain('Good speed - under a minute');
|
||||
});
|
||||
|
||||
it('should categorize moderate duration', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatDetailed('Slow task', baseTime + 120000);
|
||||
expect(result).toContain('Moderate duration - a few minutes');
|
||||
});
|
||||
|
||||
it('should categorize long execution', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatDetailed('Very slow task', baseTime + 600000);
|
||||
expect(result).toContain('Long execution - consider breaking down tasks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance tips', () => {
|
||||
it('should suggest reviewing low success rate', () => {
|
||||
stats.recordToolCall('test', false, 100);
|
||||
stats.recordToolCall('test', false, 100);
|
||||
stats.recordToolCall('test', true, 100);
|
||||
|
||||
const result = stats.formatDetailed('Failing task');
|
||||
expect(result).toContain(
|
||||
'Low tool success rate - review inputs and error messages',
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest breaking down long tasks', () => {
|
||||
stats.start(baseTime);
|
||||
|
||||
const result = stats.formatDetailed('Long task', baseTime + 120000);
|
||||
expect(result).toContain(
|
||||
'Long execution time - consider breaking down complex tasks',
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest optimizing high token usage', () => {
|
||||
stats.recordTokens(80000, 30000);
|
||||
|
||||
const result = stats.formatDetailed('Token-heavy task');
|
||||
expect(result).toContain(
|
||||
'High token usage - consider optimizing prompts or narrowing scope',
|
||||
);
|
||||
});
|
||||
|
||||
it('should identify high token usage per call', () => {
|
||||
stats.recordToolCall('test', true, 100);
|
||||
stats.recordTokens(6000, 0);
|
||||
|
||||
const result = stats.formatDetailed('Verbose task');
|
||||
expect(result).toContain(
|
||||
'High token usage per tool call (~6000 tokens/call)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should identify network failures', () => {
|
||||
stats.recordToolCall('web_search', false, 100, 'Network timeout');
|
||||
|
||||
const result = stats.formatDetailed('Network task');
|
||||
expect(result).toContain(
|
||||
'Network operations had failures - consider increasing timeout or checking connectivity',
|
||||
);
|
||||
});
|
||||
|
||||
it('should identify slow tools', () => {
|
||||
stats.recordToolCall('slow_tool', true, 15000);
|
||||
|
||||
const result = stats.formatDetailed('Slow tool task');
|
||||
expect(result).toContain(
|
||||
'Consider optimizing slow_tool operations (avg 15.0s)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duration formatting', () => {
|
||||
it('should format milliseconds', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatCompact('Quick task', baseTime + 500);
|
||||
expect(result).toContain('500ms');
|
||||
});
|
||||
|
||||
it('should format seconds', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatCompact('Second task', baseTime + 2500);
|
||||
expect(result).toContain('2.5s');
|
||||
});
|
||||
|
||||
it('should format minutes and seconds', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatCompact('Minute task', baseTime + 125000);
|
||||
expect(result).toContain('2m 5s');
|
||||
});
|
||||
|
||||
it('should format hours and minutes', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatCompact('Hour task', baseTime + 4500000);
|
||||
expect(result).toContain('1h 15m');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,4 +102,149 @@ export class SubagentStatistics {
|
||||
toolUsage: Array.from(this.toolUsage.values()),
|
||||
};
|
||||
}
|
||||
|
||||
formatCompact(taskDesc: string, now = Date.now()): string {
|
||||
const stats = this.getSummary(now);
|
||||
const sr =
|
||||
stats.totalToolCalls > 0
|
||||
? (stats.successRate ??
|
||||
(stats.successfulToolCalls / stats.totalToolCalls) * 100)
|
||||
: 0;
|
||||
const lines = [
|
||||
`📋 Task Completed: ${taskDesc}`,
|
||||
`🔧 Tool Usage: ${stats.totalToolCalls} calls${stats.totalToolCalls ? `, ${sr.toFixed(1)}% success` : ''}`,
|
||||
`⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
|
||||
];
|
||||
if (typeof stats.totalTokens === 'number') {
|
||||
lines.push(
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
formatDetailed(taskDesc: string, now = Date.now()): string {
|
||||
const stats = this.getSummary(now);
|
||||
const sr =
|
||||
stats.totalToolCalls > 0
|
||||
? (stats.successRate ??
|
||||
(stats.successfulToolCalls / stats.totalToolCalls) * 100)
|
||||
: 0;
|
||||
const lines: string[] = [];
|
||||
lines.push(`📋 Task Completed: ${taskDesc}`);
|
||||
lines.push(
|
||||
`⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
|
||||
);
|
||||
// Quality indicator
|
||||
let quality = 'Poor execution';
|
||||
if (sr >= 95) quality = 'Excellent execution';
|
||||
else if (sr >= 85) quality = 'Good execution';
|
||||
else if (sr >= 70) quality = 'Fair execution';
|
||||
lines.push(`✅ Quality: ${quality} (${sr.toFixed(1)}% tool success)`);
|
||||
// Speed category
|
||||
const d = stats.totalDurationMs;
|
||||
let speed = 'Long execution - consider breaking down tasks';
|
||||
if (d < 10_000) speed = 'Fast completion - under 10 seconds';
|
||||
else if (d < 60_000) speed = 'Good speed - under a minute';
|
||||
else if (d < 300_000) speed = 'Moderate duration - a few minutes';
|
||||
lines.push(`🚀 Speed: ${speed}`);
|
||||
lines.push(
|
||||
`🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
|
||||
);
|
||||
if (typeof stats.totalTokens === 'number') {
|
||||
lines.push(
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`,
|
||||
);
|
||||
}
|
||||
if (stats.toolUsage && stats.toolUsage.length) {
|
||||
const sorted = [...stats.toolUsage]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
lines.push('\nTop tools:');
|
||||
for (const t of sorted) {
|
||||
const avg =
|
||||
typeof t.averageDurationMs === 'number'
|
||||
? `, avg ${this.fmtDuration(Math.round(t.averageDurationMs))}`
|
||||
: '';
|
||||
lines.push(
|
||||
` - ${t.name}: ${t.count} calls (${t.success} ok, ${t.failure} fail${avg}${t.lastError ? `, last error: ${t.lastError}` : ''})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const tips = this.generatePerformanceTips(stats);
|
||||
if (tips.length) {
|
||||
lines.push('\n💡 Performance Insights:');
|
||||
for (const tip of tips.slice(0, 3)) lines.push(` - ${tip}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private 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`;
|
||||
}
|
||||
|
||||
private generatePerformanceTips(stats: SubagentStatsSummary): string[] {
|
||||
const tips: string[] = [];
|
||||
const totalCalls = stats.totalToolCalls;
|
||||
const sr =
|
||||
stats.totalToolCalls > 0
|
||||
? (stats.successRate ??
|
||||
(stats.successfulToolCalls / stats.totalToolCalls) * 100)
|
||||
: 0;
|
||||
|
||||
// High failure rate
|
||||
if (sr < 80)
|
||||
tips.push('Low tool success rate - review inputs and error messages');
|
||||
|
||||
// Long duration
|
||||
if (stats.totalDurationMs > 60_000)
|
||||
tips.push('Long execution time - consider breaking down complex tasks');
|
||||
|
||||
// Token usage
|
||||
if (typeof stats.totalTokens === 'number' && stats.totalTokens > 100_000) {
|
||||
tips.push(
|
||||
'High token usage - consider optimizing prompts or narrowing scope',
|
||||
);
|
||||
}
|
||||
if (typeof stats.totalTokens === 'number' && totalCalls > 0) {
|
||||
const avgTokPerCall = stats.totalTokens / totalCalls;
|
||||
if (avgTokPerCall > 5_000)
|
||||
tips.push(
|
||||
`High token usage per tool call (~${Math.round(avgTokPerCall)} tokens/call)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Network failures
|
||||
const isNetworkTool = (name: string) => /web|fetch|search/i.test(name);
|
||||
const hadNetworkFailure = (stats.toolUsage || []).some(
|
||||
(t) =>
|
||||
isNetworkTool(t.name) &&
|
||||
t.lastError &&
|
||||
/timeout|network/i.test(t.lastError),
|
||||
);
|
||||
if (hadNetworkFailure)
|
||||
tips.push(
|
||||
'Network operations had failures - consider increasing timeout or checking connectivity',
|
||||
);
|
||||
|
||||
// Slow tools
|
||||
const slow = (stats.toolUsage || [])
|
||||
.filter((t) => (t.averageDurationMs ?? 0) > 10_000)
|
||||
.sort((a, b) => (b.averageDurationMs ?? 0) - (a.averageDurationMs ?? 0));
|
||||
if (slow.length)
|
||||
tips.push(
|
||||
`Consider optimizing ${slow[0].name} operations (avg ${this.fmtDuration(Math.round(slow[0].averageDurationMs!))})`,
|
||||
);
|
||||
|
||||
return tips;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentErrorEvent,
|
||||
} from './subagent-events.js';
|
||||
import { formatCompact } from './subagent-result-format.js';
|
||||
import {
|
||||
SubagentStatistics,
|
||||
SubagentStatsSummary,
|
||||
@@ -551,8 +550,7 @@ export class SubAgentScope {
|
||||
{
|
||||
terminate_reason: this.output.terminate_reason,
|
||||
result: this.finalText,
|
||||
execution_summary: formatCompact(
|
||||
summary,
|
||||
execution_summary: this.stats.formatCompact(
|
||||
'Subagent execution completed',
|
||||
),
|
||||
},
|
||||
|
||||
@@ -10,8 +10,9 @@ import { Content, FunctionDeclaration } from '@google/genai';
|
||||
* Represents the storage level for a subagent configuration.
|
||||
* - 'project': Stored in `.qwen/agents/` within the project directory
|
||||
* - 'user': Stored in `~/.qwen/agents/` in the user's home directory
|
||||
* - 'builtin': Built-in agents embedded in the codebase, always available
|
||||
*/
|
||||
export type SubagentLevel = 'project' | 'user';
|
||||
export type SubagentLevel = 'project' | 'user' | 'builtin';
|
||||
|
||||
/**
|
||||
* Core configuration for a subagent as stored in Markdown files.
|
||||
@@ -60,6 +61,12 @@ export interface SubagentConfig {
|
||||
* If 'auto' or omitted, uses automatic color assignment.
|
||||
*/
|
||||
color?: string;
|
||||
|
||||
/**
|
||||
* Indicates whether this is a built-in agent.
|
||||
* Built-in agents cannot be modified or deleted.
|
||||
*/
|
||||
readonly isBuiltin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('TaskTool', () => {
|
||||
it('should initialize with correct name and properties', () => {
|
||||
expect(taskTool.name).toBe('task');
|
||||
expect(taskTool.displayName).toBe('Task');
|
||||
expect(taskTool.kind).toBe('execute');
|
||||
expect(taskTool.kind).toBe('other');
|
||||
});
|
||||
|
||||
it('should load available subagents during initialization', () => {
|
||||
|
||||
@@ -70,7 +70,7 @@ export class TaskTool extends BaseDeclarativeTool<TaskParams, ToolResult> {
|
||||
TaskTool.Name,
|
||||
'Task',
|
||||
'Delegate tasks to specialized subagents. Loading available subagents...', // Initial description
|
||||
Kind.Execute,
|
||||
Kind.Other,
|
||||
initialSchema,
|
||||
true, // isOutputMarkdown
|
||||
true, // canUpdateOutput - Enable live output updates for real-time progress
|
||||
|
||||
Reference in New Issue
Block a user