mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat: subagent phase 2 implementation
This commit is contained in:
@@ -34,6 +34,7 @@ import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
|||||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||||
|
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the core, hard-coded slash commands that are an integral part
|
* Loads the core, hard-coded slash commands that are an integral part
|
||||||
@@ -52,6 +53,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
|
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
|
||||||
const allDefinitions: Array<SlashCommand | null> = [
|
const allDefinitions: Array<SlashCommand | null> = [
|
||||||
aboutCommand,
|
aboutCommand,
|
||||||
|
agentsCommand,
|
||||||
authCommand,
|
authCommand,
|
||||||
bugCommand,
|
bugCommand,
|
||||||
chatCommand,
|
chatCommand,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useQwenAuth } from './hooks/useQwenAuth.js';
|
|||||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||||
|
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||||
@@ -41,6 +42,7 @@ import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
|
|||||||
import { FolderTrustDialog } from './components/FolderTrustDialog.js';
|
import { FolderTrustDialog } from './components/FolderTrustDialog.js';
|
||||||
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
|
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
|
||||||
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
|
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
|
||||||
|
import { SubagentCreationWizard } from './components/subagents/SubagentCreationWizard.js';
|
||||||
import { Colors } from './colors.js';
|
import { Colors } from './colors.js';
|
||||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||||
import { LoadedSettings, SettingScope } from '../config/settings.js';
|
import { LoadedSettings, SettingScope } from '../config/settings.js';
|
||||||
@@ -269,6 +271,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||||
useSettingsCommand();
|
useSettingsCommand();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isSubagentCreateDialogOpen,
|
||||||
|
openSubagentCreateDialog,
|
||||||
|
closeSubagentCreateDialog,
|
||||||
|
} = useSubagentCreateDialog();
|
||||||
|
|
||||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect } = useFolderTrust(
|
const { isFolderTrustDialogOpen, handleFolderTrustSelect } = useFolderTrust(
|
||||||
settings,
|
settings,
|
||||||
setIsTrustedFolder,
|
setIsTrustedFolder,
|
||||||
@@ -565,6 +573,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
openPrivacyNotice,
|
openPrivacyNotice,
|
||||||
openSettingsDialog,
|
openSettingsDialog,
|
||||||
|
openSubagentCreateDialog,
|
||||||
toggleVimEnabled,
|
toggleVimEnabled,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
setGeminiMdFileCount,
|
setGeminiMdFileCount,
|
||||||
@@ -894,6 +903,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
!isAuthDialogOpen &&
|
!isAuthDialogOpen &&
|
||||||
!isThemeDialogOpen &&
|
!isThemeDialogOpen &&
|
||||||
!isEditorDialogOpen &&
|
!isEditorDialogOpen &&
|
||||||
|
!isSubagentCreateDialogOpen &&
|
||||||
!showPrivacyNotice &&
|
!showPrivacyNotice &&
|
||||||
geminiClient?.isInitialized?.()
|
geminiClient?.isInitialized?.()
|
||||||
) {
|
) {
|
||||||
@@ -907,6 +917,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
isThemeDialogOpen,
|
isThemeDialogOpen,
|
||||||
isEditorDialogOpen,
|
isEditorDialogOpen,
|
||||||
|
isSubagentCreateDialogOpen,
|
||||||
showPrivacyNotice,
|
showPrivacyNotice,
|
||||||
geminiClient,
|
geminiClient,
|
||||||
]);
|
]);
|
||||||
@@ -1069,6 +1080,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||||||
onRestartRequest={() => process.exit(0)}
|
onRestartRequest={() => process.exit(0)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : isSubagentCreateDialogOpen ? (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<SubagentCreationWizard
|
||||||
|
onClose={closeSubagentCreateDialog}
|
||||||
|
config={config}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
) : isAuthenticating ? (
|
) : isAuthenticating ? (
|
||||||
<>
|
<>
|
||||||
{isQwenAuth && isQwenAuthenticating ? (
|
{isQwenAuth && isQwenAuthenticating ? (
|
||||||
|
|||||||
119
packages/cli/src/ui/commands/agentsCommand.ts
Normal file
119
packages/cli/src/ui/commands/agentsCommand.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MessageType } from '../types.js';
|
||||||
|
import {
|
||||||
|
CommandKind,
|
||||||
|
SlashCommand,
|
||||||
|
SlashCommandActionReturn,
|
||||||
|
OpenDialogActionReturn,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export const agentsCommand: SlashCommand = {
|
||||||
|
name: 'agents',
|
||||||
|
description: 'Manage subagents for specialized task delegation.',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
subCommands: [
|
||||||
|
{
|
||||||
|
name: 'create',
|
||||||
|
description: 'Create a new subagent with guided setup.',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: (): OpenDialogActionReturn => ({
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'subagent_create',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list',
|
||||||
|
description: 'List all available subagents.',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (context): Promise<SlashCommandActionReturn | void> => {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Listing subagents... (not implemented yet)',
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'show',
|
||||||
|
description: 'Show detailed information about a subagent.',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (
|
||||||
|
context,
|
||||||
|
args,
|
||||||
|
): Promise<SlashCommandActionReturn | void> => {
|
||||||
|
if (!args || args.trim() === '') {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Usage: /agents show <subagent-name>',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Showing details for subagent: ${args.trim()} (not implemented yet)`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'edit',
|
||||||
|
description: 'Edit an existing subagent configuration.',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (
|
||||||
|
context,
|
||||||
|
args,
|
||||||
|
): Promise<SlashCommandActionReturn | void> => {
|
||||||
|
if (!args || args.trim() === '') {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Usage: /agents edit <subagent-name>',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Editing subagent: ${args.trim()} (not implemented yet)`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'delete',
|
||||||
|
description: 'Delete a subagent configuration.',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
action: async (
|
||||||
|
context,
|
||||||
|
args,
|
||||||
|
): Promise<SlashCommandActionReturn | void> => {
|
||||||
|
if (!args || args.trim() === '') {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Usage: /agents delete <subagent-name>',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Deleting subagent: ${args.trim()} (not implemented yet)`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -104,7 +104,14 @@ export interface MessageActionReturn {
|
|||||||
export interface OpenDialogActionReturn {
|
export interface OpenDialogActionReturn {
|
||||||
type: 'dialog';
|
type: 'dialog';
|
||||||
|
|
||||||
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings';
|
dialog:
|
||||||
|
| 'help'
|
||||||
|
| 'auth'
|
||||||
|
| 'theme'
|
||||||
|
| 'editor'
|
||||||
|
| 'privacy'
|
||||||
|
| 'settings'
|
||||||
|
| 'subagent_create';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
101
packages/cli/src/ui/components/subagents/ColorSelector.tsx
Normal file
101
packages/cli/src/ui/components/subagents/ColorSelector.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||||
|
import { WizardStepProps, ColorOption } from './types.js';
|
||||||
|
import { Colors } from '../../colors.js';
|
||||||
|
|
||||||
|
const colorOptions: ColorOption[] = [
|
||||||
|
{
|
||||||
|
id: 'auto',
|
||||||
|
name: 'Automatic Color',
|
||||||
|
value: 'auto',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blue',
|
||||||
|
name: 'Blue',
|
||||||
|
value: '#3b82f6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'green',
|
||||||
|
name: 'Green',
|
||||||
|
value: '#10b981',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'purple',
|
||||||
|
name: 'Purple',
|
||||||
|
value: '#8b5cf6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orange',
|
||||||
|
name: 'Orange',
|
||||||
|
value: '#f59e0b',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'red',
|
||||||
|
name: 'Red',
|
||||||
|
value: '#ef4444',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cyan',
|
||||||
|
name: 'Cyan',
|
||||||
|
value: '#06b6d4',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 5: Background color selection with preview.
|
||||||
|
*/
|
||||||
|
export function ColorSelector({
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
onNext,
|
||||||
|
onPrevious: _onPrevious,
|
||||||
|
}: WizardStepProps) {
|
||||||
|
const handleSelect = (_selectedValue: string) => {
|
||||||
|
onNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHighlight = (selectedValue: string) => {
|
||||||
|
const colorOption = colorOptions.find(
|
||||||
|
(option) => option.id === selectedValue,
|
||||||
|
);
|
||||||
|
if (colorOption) {
|
||||||
|
dispatch({ type: 'SET_BACKGROUND_COLOR', color: colorOption.value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentColor =
|
||||||
|
colorOptions.find((option) => option.value === state.backgroundColor) ||
|
||||||
|
colorOptions[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={colorOptions.map((option) => ({
|
||||||
|
label: option.name,
|
||||||
|
value: option.id,
|
||||||
|
}))}
|
||||||
|
initialIndex={colorOptions.findIndex(
|
||||||
|
(opt) => opt.id === currentColor.id,
|
||||||
|
)}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onHighlight={handleHighlight}
|
||||||
|
isFocused={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Text color={Colors.Gray}>Preview:</Text>
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color={currentColor.value}>{state.generatedName}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
packages/cli/src/ui/components/subagents/CreationSummary.tsx
Normal file
214
packages/cli/src/ui/components/subagents/CreationSummary.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { WizardStepProps } from './types.js';
|
||||||
|
import { UI } from './constants.js';
|
||||||
|
import { validateSubagentConfig } from './validation.js';
|
||||||
|
import { SubagentManager, SubagentConfig } from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 6: Final confirmation and actions.
|
||||||
|
*/
|
||||||
|
export function CreationSummary({
|
||||||
|
state,
|
||||||
|
onPrevious: _onPrevious,
|
||||||
|
onCancel,
|
||||||
|
}: WizardStepProps) {
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
|
||||||
|
const truncateText = (text: string, maxLength: number): string => {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.substring(0, maxLength - 3) + '...';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolsDisplay = Array.isArray(state.selectedTools)
|
||||||
|
? state.selectedTools.join(', ')
|
||||||
|
: 'All available tools';
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (isSaving) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate configuration before saving
|
||||||
|
const configToValidate = {
|
||||||
|
name: state.generatedName,
|
||||||
|
description: state.generatedDescription,
|
||||||
|
systemPrompt: state.generatedSystemPrompt,
|
||||||
|
tools: state.selectedTools,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validation = validateSubagentConfig(configToValidate);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SubagentManager instance
|
||||||
|
// TODO: Get project root from config or context
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const subagentManager = new SubagentManager(projectRoot);
|
||||||
|
|
||||||
|
// Build subagent configuration
|
||||||
|
const config: SubagentConfig = {
|
||||||
|
name: state.generatedName,
|
||||||
|
description: state.generatedDescription,
|
||||||
|
systemPrompt: state.generatedSystemPrompt,
|
||||||
|
level: state.location,
|
||||||
|
filePath: '', // Will be set by manager
|
||||||
|
tools: Array.isArray(state.selectedTools)
|
||||||
|
? state.selectedTools
|
||||||
|
: undefined,
|
||||||
|
// TODO: Add modelConfig and runConfig if needed
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the subagent
|
||||||
|
await subagentManager.createSubagent(config, {
|
||||||
|
level: state.location,
|
||||||
|
overwrite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSaveSuccess(true);
|
||||||
|
|
||||||
|
// Auto-close after successful save
|
||||||
|
setTimeout(() => {
|
||||||
|
onCancel();
|
||||||
|
}, UI.AUTO_CLOSE_DELAY_MS);
|
||||||
|
} catch (error) {
|
||||||
|
setSaveError(
|
||||||
|
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [state, isSaving, onCancel]);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
// TODO: Implement system editor integration
|
||||||
|
setSaveError('Edit functionality not yet implemented');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle keyboard input
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (isSaving || saveSuccess) return;
|
||||||
|
|
||||||
|
if (key.return || input === 's') {
|
||||||
|
handleSave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'e') {
|
||||||
|
handleEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (saveSuccess) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box>
|
||||||
|
<Text bold color="green">
|
||||||
|
✅ Subagent Created Successfully!
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>
|
||||||
|
Subagent "{state.generatedName}" has been saved to{' '}
|
||||||
|
{state.location} level.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color="gray">Closing wizard...</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSaving) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box>
|
||||||
|
<Text bold color="cyan">
|
||||||
|
💾 Saving Subagent...
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text>Creating subagent "{state.generatedName}"...</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
{saveError && (
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
<Text bold color="red">
|
||||||
|
❌ Error saving subagent:
|
||||||
|
</Text>
|
||||||
|
<Text color="red" wrap="wrap">
|
||||||
|
{saveError}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
<Box >
|
||||||
|
<Text bold>Name: </Text>
|
||||||
|
<Text>{state.generatedName}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text bold>Location: </Text>
|
||||||
|
<Text>
|
||||||
|
{state.location === 'project'
|
||||||
|
? 'Project Level (.qwen/agents/)'
|
||||||
|
: 'User Level (~/.qwen/agents/)'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text bold>Tools: </Text>
|
||||||
|
<Text>{toolsDisplay}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text bold>Description:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text wrap="wrap">
|
||||||
|
{truncateText(state.generatedDescription, 200)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text bold>System Prompt:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text wrap="wrap">
|
||||||
|
{truncateText(state.generatedSystemPrompt, 200)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text bold>Background Color: </Text>
|
||||||
|
<Text>
|
||||||
|
{state.backgroundColor === 'auto'
|
||||||
|
? 'Automatic'
|
||||||
|
: state.backgroundColor}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
packages/cli/src/ui/components/subagents/DescriptionInput.tsx
Normal file
310
packages/cli/src/ui/components/subagents/DescriptionInput.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { WizardStepProps, WizardAction } from './types.js';
|
||||||
|
import { sanitizeInput } from './validation.js';
|
||||||
|
import { Config, subagentGenerator } from '@qwen-code/qwen-code-core';
|
||||||
|
import { useTextBuffer } from '../shared/text-buffer.js';
|
||||||
|
import { useKeypress, Key } from '../../hooks/useKeypress.js';
|
||||||
|
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { cpSlice, cpLen } from '../../utils/textUtils.js';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import stringWidth from 'string-width';
|
||||||
|
import { Colors } from '../../colors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3: Description input with LLM generation.
|
||||||
|
*/
|
||||||
|
export function DescriptionInput({
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
onNext,
|
||||||
|
config,
|
||||||
|
}: WizardStepProps) {
|
||||||
|
const [inputWidth] = useState(80); // Fixed width for now
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const buffer = useTextBuffer({
|
||||||
|
initialText: state.userDescription || '',
|
||||||
|
viewport: { height: 10, width: inputWidth },
|
||||||
|
isValidPath: () => false, // For subagent description, we don't need file path validation
|
||||||
|
onChange: (text) => {
|
||||||
|
const sanitized = sanitizeInput(text);
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_USER_DESCRIPTION',
|
||||||
|
description: sanitized,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(
|
||||||
|
async (
|
||||||
|
userDescription: string,
|
||||||
|
dispatch: (action: WizardAction) => void,
|
||||||
|
config: Config,
|
||||||
|
): Promise<void> => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const generated = await subagentGenerator(
|
||||||
|
userDescription,
|
||||||
|
config.getGeminiClient(),
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only dispatch if not aborted
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_GENERATED_CONTENT',
|
||||||
|
name: generated.name,
|
||||||
|
description: generated.description,
|
||||||
|
systemPrompt: generated.systemPrompt,
|
||||||
|
});
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!state.canProceed || state.isGenerating || !buffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputValue = buffer.text.trim();
|
||||||
|
if (!inputValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start LLM generation
|
||||||
|
dispatch({ type: 'SET_GENERATING', isGenerating: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Configuration not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use real LLM integration
|
||||||
|
await handleGenerate(inputValue, dispatch, config);
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({ type: 'SET_GENERATING', isGenerating: false });
|
||||||
|
|
||||||
|
// Don't show error if it was cancelled by user
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_VALIDATION_ERRORS',
|
||||||
|
errors: [
|
||||||
|
`Failed to generate subagent: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
state.canProceed,
|
||||||
|
state.isGenerating,
|
||||||
|
buffer,
|
||||||
|
dispatch,
|
||||||
|
config,
|
||||||
|
handleGenerate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle keyboard input during generation
|
||||||
|
const handleGenerationKeypress = useCallback(
|
||||||
|
(key: Key) => {
|
||||||
|
if (keyMatchers[Command.ESCAPE](key)) {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
// Cancel the ongoing generation
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
dispatch({ type: 'SET_GENERATING', isGenerating: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle keyboard input for text editing
|
||||||
|
const handleInput = useCallback(
|
||||||
|
(key: Key) => {
|
||||||
|
if (!buffer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyMatchers[Command.SUBMIT](key)) {
|
||||||
|
if (buffer.text.trim()) {
|
||||||
|
const [row, col] = buffer.cursor;
|
||||||
|
const line = buffer.lines[row];
|
||||||
|
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
|
||||||
|
if (charBefore === '\\') {
|
||||||
|
buffer.backspace();
|
||||||
|
buffer.newline();
|
||||||
|
} else {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newline insertion
|
||||||
|
if (keyMatchers[Command.NEWLINE](key)) {
|
||||||
|
buffer.newline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+A (Home) / Ctrl+E (End)
|
||||||
|
if (keyMatchers[Command.HOME](key)) {
|
||||||
|
buffer.move('home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyMatchers[Command.END](key)) {
|
||||||
|
buffer.move('end');
|
||||||
|
buffer.moveToOffset(cpLen(buffer.text));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+C (Clear input)
|
||||||
|
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||||
|
if (buffer.text.length > 0) {
|
||||||
|
buffer.setText('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill line commands
|
||||||
|
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||||
|
buffer.killLineRight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
||||||
|
buffer.killLineLeft();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// External editor
|
||||||
|
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
||||||
|
buffer.openInExternalEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the text buffer's default input handling for all other keys
|
||||||
|
buffer.handleInput(key);
|
||||||
|
},
|
||||||
|
[buffer, handleSubmit],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use separate keypress handlers for different states
|
||||||
|
useKeypress(handleGenerationKeypress, {
|
||||||
|
isActive: state.isGenerating,
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeypress(handleInput, {
|
||||||
|
isActive: !state.isGenerating,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!buffer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linesToRender = buffer.viewportVisualLines;
|
||||||
|
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||||
|
buffer.visualCursor;
|
||||||
|
const scrollVisualRow = buffer.visualScrollRow;
|
||||||
|
const placeholder =
|
||||||
|
'e.g., Expert code reviewer that reviews code based on best practices...';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
Describe what this subagent should do and when it should be used. (Be
|
||||||
|
comprehensive for best results)
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{state.isGenerating ? (
|
||||||
|
<Box>
|
||||||
|
<Text color={theme.text.accent}>
|
||||||
|
⏳ Generating subagent configuration...
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box>
|
||||||
|
<Text color={theme.text.accent}>{'> '}</Text>
|
||||||
|
<Box flexGrow={1} flexDirection="column">
|
||||||
|
{buffer.text.length === 0 && placeholder ? (
|
||||||
|
<Text>
|
||||||
|
{chalk.inverse(placeholder.slice(0, 1))}
|
||||||
|
<Text color={Colors.Gray}>{placeholder.slice(1)}</Text>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
||||||
|
const cursorVisualRow =
|
||||||
|
cursorVisualRowAbsolute - scrollVisualRow;
|
||||||
|
let display = cpSlice(lineText, 0, inputWidth);
|
||||||
|
const currentVisualWidth = stringWidth(display);
|
||||||
|
if (currentVisualWidth < inputWidth) {
|
||||||
|
display =
|
||||||
|
display + ' '.repeat(inputWidth - currentVisualWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visualIdxInRenderedSet === cursorVisualRow) {
|
||||||
|
const relativeVisualColForHighlight =
|
||||||
|
cursorVisualColAbsolute;
|
||||||
|
|
||||||
|
if (relativeVisualColForHighlight >= 0) {
|
||||||
|
if (relativeVisualColForHighlight < cpLen(display)) {
|
||||||
|
const charToHighlight =
|
||||||
|
cpSlice(
|
||||||
|
display,
|
||||||
|
relativeVisualColForHighlight,
|
||||||
|
relativeVisualColForHighlight + 1,
|
||||||
|
) || ' ';
|
||||||
|
const highlighted = chalk.inverse(charToHighlight);
|
||||||
|
display =
|
||||||
|
cpSlice(display, 0, relativeVisualColForHighlight) +
|
||||||
|
highlighted +
|
||||||
|
cpSlice(display, relativeVisualColForHighlight + 1);
|
||||||
|
} else if (
|
||||||
|
relativeVisualColForHighlight === cpLen(display) &&
|
||||||
|
cpLen(display) === inputWidth
|
||||||
|
) {
|
||||||
|
display = display + chalk.inverse(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text key={`line-${visualIdxInRenderedSet}`}>
|
||||||
|
{display}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{state.validationErrors.length > 0 && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{state.validationErrors.map((error, index) => (
|
||||||
|
<Text key={index} color="red">
|
||||||
|
⚠ {error}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
packages/cli/src/ui/components/subagents/ErrorBoundary.tsx
Normal file
114
packages/cli/src/ui/components/subagents/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { Component, ReactNode } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
errorInfo?: React.ErrorInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary component for graceful error handling in the subagent wizard.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<
|
||||||
|
ErrorBoundaryProps,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
this.setState({
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call optional error handler
|
||||||
|
this.props.onError?.(error, errorInfo);
|
||||||
|
|
||||||
|
// Log error for debugging
|
||||||
|
console.error(
|
||||||
|
'SubagentWizard Error Boundary caught an error:',
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
// Custom fallback UI or default error display
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box>
|
||||||
|
<Text bold color="red">
|
||||||
|
❌ Subagent Wizard Error
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text>
|
||||||
|
An unexpected error occurred in the subagent creation wizard.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{this.state.error && (
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
<Text bold color="yellow">
|
||||||
|
Error Details:
|
||||||
|
</Text>
|
||||||
|
<Text color="gray" wrap="wrap">
|
||||||
|
{this.state.error.message}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color="gray">
|
||||||
|
Press <Text color="cyan">Esc</Text> to close the wizard and try
|
||||||
|
again.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{process.env['NODE_ENV'] === 'development' &&
|
||||||
|
this.state.errorInfo && (
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
<Text bold color="yellow">
|
||||||
|
Stack Trace (Development):
|
||||||
|
</Text>
|
||||||
|
<Text color="gray" wrap="wrap">
|
||||||
|
{this.state.errorInfo.componentStack}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Box } from 'ink';
|
||||||
|
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||||
|
import { WizardStepProps } from './types.js';
|
||||||
|
|
||||||
|
interface GenerationOption {
|
||||||
|
label: string;
|
||||||
|
value: 'qwen' | 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
const generationOptions: GenerationOption[] = [
|
||||||
|
{
|
||||||
|
label: 'Generate with Qwen Code (Recommended)',
|
||||||
|
value: 'qwen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Manual Creation',
|
||||||
|
value: 'manual',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2: Generation method selection.
|
||||||
|
*/
|
||||||
|
export function GenerationMethodSelector({
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
onNext,
|
||||||
|
onPrevious: _onPrevious,
|
||||||
|
}: WizardStepProps) {
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
const method = selectedValue as 'qwen' | 'manual';
|
||||||
|
dispatch({ type: 'SET_GENERATION_METHOD', method });
|
||||||
|
onNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={generationOptions.map((option) => ({
|
||||||
|
label: option.label,
|
||||||
|
value: option.value,
|
||||||
|
}))}
|
||||||
|
initialIndex={generationOptions.findIndex(
|
||||||
|
(opt) => opt.value === state.generationMethod,
|
||||||
|
)}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
isFocused={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Box } from 'ink';
|
||||||
|
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||||
|
import { WizardStepProps } from './types.js';
|
||||||
|
|
||||||
|
interface LocationOption {
|
||||||
|
label: string;
|
||||||
|
value: 'project' | 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationOptions: LocationOption[] = [
|
||||||
|
{
|
||||||
|
label: 'Project Level (.qwen/agents/)',
|
||||||
|
value: 'project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'User Level (~/.qwen/agents/)',
|
||||||
|
value: 'user',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1: Location selection for subagent storage.
|
||||||
|
*/
|
||||||
|
export function LocationSelector({ state, dispatch, onNext }: WizardStepProps) {
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
const location = selectedValue as 'project' | 'user';
|
||||||
|
dispatch({ type: 'SET_LOCATION', location });
|
||||||
|
onNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={locationOptions.map((option) => ({
|
||||||
|
label: option.label,
|
||||||
|
value: option.value,
|
||||||
|
}))}
|
||||||
|
initialIndex={locationOptions.findIndex(
|
||||||
|
(opt) => opt.value === state.location,
|
||||||
|
)}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
isFocused={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useReducer, useCallback, useMemo } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { wizardReducer, initialWizardState } from './wizardReducer.js';
|
||||||
|
import { LocationSelector } from './LocationSelector.js';
|
||||||
|
import { GenerationMethodSelector } from './GenerationMethodSelector.js';
|
||||||
|
import { DescriptionInput } from './DescriptionInput.js';
|
||||||
|
import { ToolSelector } from './ToolSelector.js';
|
||||||
|
import { ColorSelector } from './ColorSelector.js';
|
||||||
|
import { CreationSummary } from './CreationSummary.js';
|
||||||
|
import { ErrorBoundary } from './ErrorBoundary.js';
|
||||||
|
import { WizardStepProps } from './types.js';
|
||||||
|
import { WIZARD_STEPS } from './constants.js';
|
||||||
|
import { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import { Colors } from '../../colors.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
|
||||||
|
interface SubagentCreationWizardProps {
|
||||||
|
onClose: () => void;
|
||||||
|
config: Config | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main orchestrator component for the subagent creation wizard.
|
||||||
|
*/
|
||||||
|
export function SubagentCreationWizard({
|
||||||
|
onClose,
|
||||||
|
config,
|
||||||
|
}: SubagentCreationWizardProps) {
|
||||||
|
const [state, dispatch] = useReducer(wizardReducer, initialWizardState);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
dispatch({ type: 'GO_TO_NEXT_STEP' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePrevious = useCallback(() => {
|
||||||
|
dispatch({ type: 'GO_TO_PREVIOUS_STEP' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
dispatch({ type: 'RESET_WIZARD' });
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Centralized ESC key handling for the entire wizard
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
// Step 3 (DescriptionInput) handles its own ESC logic when generating
|
||||||
|
if (
|
||||||
|
state.currentStep === WIZARD_STEPS.DESCRIPTION_INPUT &&
|
||||||
|
state.isGenerating
|
||||||
|
) {
|
||||||
|
return; // Let DescriptionInput handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.currentStep === WIZARD_STEPS.LOCATION_SELECTION) {
|
||||||
|
// On first step, ESC cancels the entire wizard
|
||||||
|
handleCancel();
|
||||||
|
} else {
|
||||||
|
// On other steps, ESC goes back to previous step
|
||||||
|
handlePrevious();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepProps: WizardStepProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
onNext: handleNext,
|
||||||
|
onPrevious: handlePrevious,
|
||||||
|
onCancel: handleCancel,
|
||||||
|
config,
|
||||||
|
}),
|
||||||
|
[state, dispatch, handleNext, handlePrevious, handleCancel, config],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderStepHeader = useCallback(() => {
|
||||||
|
const getStepHeaderText = () => {
|
||||||
|
switch (state.currentStep) {
|
||||||
|
case WIZARD_STEPS.LOCATION_SELECTION:
|
||||||
|
return 'Step 1: Choose Location';
|
||||||
|
case WIZARD_STEPS.GENERATION_METHOD:
|
||||||
|
return 'Step 2: Choose Generation Method';
|
||||||
|
case WIZARD_STEPS.DESCRIPTION_INPUT:
|
||||||
|
return 'Step 3: Describe Your Subagent';
|
||||||
|
case WIZARD_STEPS.TOOL_SELECTION:
|
||||||
|
return 'Step 4: Select Tools';
|
||||||
|
case WIZARD_STEPS.COLOR_SELECTION:
|
||||||
|
return 'Step 5: Choose Background Color';
|
||||||
|
case WIZARD_STEPS.FINAL_CONFIRMATION:
|
||||||
|
return 'Step 6: Confirm and Save';
|
||||||
|
default:
|
||||||
|
return 'Unknown Step';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text bold>{getStepHeaderText()}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}, [state.currentStep]);
|
||||||
|
|
||||||
|
const renderDebugContent = useCallback(() => {
|
||||||
|
if (process.env['NODE_ENV'] !== 'development') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box borderStyle="single" borderColor="yellow" padding={1}>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color="yellow" bold>
|
||||||
|
Debug Info:
|
||||||
|
</Text>
|
||||||
|
<Text color={Colors.Gray}>Step: {state.currentStep}</Text>
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
Can Proceed: {state.canProceed ? 'Yes' : 'No'}
|
||||||
|
</Text>
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
Generating: {state.isGenerating ? 'Yes' : 'No'}
|
||||||
|
</Text>
|
||||||
|
<Text color={Colors.Gray}>Location: {state.location}</Text>
|
||||||
|
<Text color={Colors.Gray}>Method: {state.generationMethod}</Text>
|
||||||
|
{state.validationErrors.length > 0 && (
|
||||||
|
<Text color="red">Errors: {state.validationErrors.join(', ')}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
state.currentStep,
|
||||||
|
state.canProceed,
|
||||||
|
state.isGenerating,
|
||||||
|
state.location,
|
||||||
|
state.generationMethod,
|
||||||
|
state.validationErrors,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const renderStepFooter = useCallback(() => {
|
||||||
|
const getNavigationInstructions = () => {
|
||||||
|
// Special case: During generation in description input step, only show cancel option
|
||||||
|
if (
|
||||||
|
state.currentStep === WIZARD_STEPS.DESCRIPTION_INPUT &&
|
||||||
|
state.isGenerating
|
||||||
|
) {
|
||||||
|
return 'Esc to cancel';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.currentStep === WIZARD_STEPS.FINAL_CONFIRMATION) {
|
||||||
|
return 'Press Enter to save, e to save and edit, Esc to go back';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps that have ↑↓ navigation (RadioButtonSelect components)
|
||||||
|
const stepsWithNavigation = [
|
||||||
|
WIZARD_STEPS.LOCATION_SELECTION,
|
||||||
|
WIZARD_STEPS.GENERATION_METHOD,
|
||||||
|
WIZARD_STEPS.TOOL_SELECTION,
|
||||||
|
WIZARD_STEPS.COLOR_SELECTION,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const hasNavigation = (stepsWithNavigation as readonly number[]).includes(
|
||||||
|
state.currentStep,
|
||||||
|
);
|
||||||
|
const navigationPart = hasNavigation ? '↑↓ to navigate, ' : '';
|
||||||
|
|
||||||
|
const escAction =
|
||||||
|
state.currentStep === WIZARD_STEPS.LOCATION_SELECTION
|
||||||
|
? 'cancel'
|
||||||
|
: 'go back';
|
||||||
|
|
||||||
|
return `Press Enter to continue, ${navigationPart}Esc to ${escAction}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}, [state.currentStep, state.isGenerating]);
|
||||||
|
|
||||||
|
const renderStepContent = useCallback(() => {
|
||||||
|
switch (state.currentStep) {
|
||||||
|
case WIZARD_STEPS.LOCATION_SELECTION:
|
||||||
|
return <LocationSelector {...stepProps} />;
|
||||||
|
case WIZARD_STEPS.GENERATION_METHOD:
|
||||||
|
return <GenerationMethodSelector {...stepProps} />;
|
||||||
|
case WIZARD_STEPS.DESCRIPTION_INPUT:
|
||||||
|
return <DescriptionInput {...stepProps} />;
|
||||||
|
case WIZARD_STEPS.TOOL_SELECTION:
|
||||||
|
return <ToolSelector {...stepProps} />;
|
||||||
|
case WIZARD_STEPS.COLOR_SELECTION:
|
||||||
|
return <ColorSelector {...stepProps} />;
|
||||||
|
case WIZARD_STEPS.FINAL_CONFIRMATION:
|
||||||
|
return <CreationSummary {...stepProps} />;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color="red">Invalid step: {state.currentStep}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [stepProps, state.currentStep]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
onError={(error, errorInfo) => {
|
||||||
|
// Additional error handling if needed
|
||||||
|
console.error('Subagent wizard error:', error, errorInfo);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Main content wrapped in bounding box */}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={Colors.Gray}
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
width="100%"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
{renderStepHeader()}
|
||||||
|
{renderStepContent()}
|
||||||
|
{renderDebugContent()}
|
||||||
|
{renderStepFooter()}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
packages/cli/src/ui/components/subagents/ToolSelector.tsx
Normal file
200
packages/cli/src/ui/components/subagents/ToolSelector.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||||
|
import { WizardStepProps, ToolCategory } from './types.js';
|
||||||
|
import { Kind } from '@qwen-code/qwen-code-core';
|
||||||
|
import { Colors } from '../../colors.js';
|
||||||
|
|
||||||
|
interface ToolOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
category: ToolCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 4: Tool selection with categories.
|
||||||
|
*/
|
||||||
|
export function ToolSelector({
|
||||||
|
state: _state,
|
||||||
|
dispatch,
|
||||||
|
onNext,
|
||||||
|
onPrevious: _onPrevious,
|
||||||
|
config,
|
||||||
|
}: WizardStepProps) {
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
|
|
||||||
|
// Generate tool categories from actual tool registry
|
||||||
|
const { toolCategories, readTools, editTools, executeTools } = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
// Fallback categories if config not available
|
||||||
|
return {
|
||||||
|
toolCategories: [
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
name: 'All Tools (Default)',
|
||||||
|
tools: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
readTools: [],
|
||||||
|
editTools: [],
|
||||||
|
executeTools: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolRegistry = config.getToolRegistry();
|
||||||
|
const allTools = toolRegistry.getAllTools();
|
||||||
|
|
||||||
|
// Categorize tools by Kind
|
||||||
|
const readTools = allTools
|
||||||
|
.filter(
|
||||||
|
(tool) =>
|
||||||
|
tool.kind === Kind.Read ||
|
||||||
|
tool.kind === Kind.Search ||
|
||||||
|
tool.kind === Kind.Fetch,
|
||||||
|
)
|
||||||
|
.map((tool) => tool.displayName)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const editTools = allTools
|
||||||
|
.filter(
|
||||||
|
(tool) =>
|
||||||
|
tool.kind === Kind.Edit ||
|
||||||
|
tool.kind === Kind.Delete ||
|
||||||
|
tool.kind === Kind.Move ||
|
||||||
|
tool.kind === Kind.Think,
|
||||||
|
)
|
||||||
|
.map((tool) => tool.displayName)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const executeTools = allTools
|
||||||
|
.filter((tool) => tool.kind === Kind.Execute)
|
||||||
|
.map((tool) => tool.displayName)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const toolCategories = [
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
name: 'All Tools',
|
||||||
|
tools: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'read',
|
||||||
|
name: 'Read-only Tools',
|
||||||
|
tools: readTools,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'edit',
|
||||||
|
name: 'Read & Edit Tools',
|
||||||
|
tools: [...readTools, ...editTools],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'execute',
|
||||||
|
name: 'Read & Edit & Execution Tools',
|
||||||
|
tools: [...readTools, ...editTools, ...executeTools],
|
||||||
|
},
|
||||||
|
].filter((category) => category.id === 'all' || category.tools.length > 0);
|
||||||
|
|
||||||
|
return { toolCategories, readTools, editTools, executeTools };
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const toolOptions: ToolOption[] = toolCategories.map((category) => ({
|
||||||
|
label: category.name,
|
||||||
|
value: category.id,
|
||||||
|
category,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleHighlight = (selectedValue: string) => {
|
||||||
|
setSelectedCategory(selectedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
const category = toolCategories.find((cat) => cat.id === selectedValue);
|
||||||
|
if (category) {
|
||||||
|
if (category.id === 'all') {
|
||||||
|
dispatch({ type: 'SET_TOOLS', tools: 'all' });
|
||||||
|
} else {
|
||||||
|
dispatch({ type: 'SET_TOOLS', tools: category.tools });
|
||||||
|
}
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the currently selected category for displaying tools
|
||||||
|
const currentCategory = toolCategories.find(
|
||||||
|
(cat) => cat.id === selectedCategory,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={toolOptions.map((option) => ({
|
||||||
|
label: option.label,
|
||||||
|
value: option.value,
|
||||||
|
}))}
|
||||||
|
initialIndex={toolOptions.findIndex(
|
||||||
|
(opt) => opt.value === selectedCategory,
|
||||||
|
)}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onHighlight={handleHighlight}
|
||||||
|
isFocused={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Show help information or tools for selected category */}
|
||||||
|
{currentCategory && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{currentCategory.id === 'all' ? (
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
All tools selected, including MCP tools
|
||||||
|
</Text>
|
||||||
|
) : currentCategory.tools.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text color={Colors.Gray}>Selected tools:</Text>
|
||||||
|
<Box flexDirection="column" marginLeft={2}>
|
||||||
|
{(() => {
|
||||||
|
// Filter the already categorized tools to show only those in current category
|
||||||
|
const categoryReadTools = currentCategory.tools.filter(
|
||||||
|
(tool) => readTools.includes(tool),
|
||||||
|
);
|
||||||
|
const categoryEditTools = currentCategory.tools.filter(
|
||||||
|
(tool) => editTools.includes(tool),
|
||||||
|
);
|
||||||
|
const categoryExecuteTools = currentCategory.tools.filter(
|
||||||
|
(tool) => executeTools.includes(tool),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{categoryReadTools.length > 0 && (
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
• Read-only tools: {categoryReadTools.join(', ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{categoryEditTools.length > 0 && (
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
• Edit tools: {categoryEditTools.join(', ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{categoryExecuteTools.length > 0 && (
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
• Execution tools: {categoryExecuteTools.join(', ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
packages/cli/src/ui/components/subagents/constants.ts
Normal file
39
packages/cli/src/ui/components/subagents/constants.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants for the subagent creation wizard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Wizard step numbers
|
||||||
|
export const WIZARD_STEPS = {
|
||||||
|
LOCATION_SELECTION: 1,
|
||||||
|
GENERATION_METHOD: 2,
|
||||||
|
DESCRIPTION_INPUT: 3,
|
||||||
|
TOOL_SELECTION: 4,
|
||||||
|
COLOR_SELECTION: 5,
|
||||||
|
FINAL_CONFIRMATION: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Total number of wizard steps
|
||||||
|
export const TOTAL_WIZARD_STEPS = 6;
|
||||||
|
|
||||||
|
// Step names for display
|
||||||
|
export const STEP_NAMES: Record<number, string> = {
|
||||||
|
[WIZARD_STEPS.LOCATION_SELECTION]: 'Location Selection',
|
||||||
|
[WIZARD_STEPS.GENERATION_METHOD]: 'Generation Method',
|
||||||
|
[WIZARD_STEPS.DESCRIPTION_INPUT]: 'Description Input',
|
||||||
|
[WIZARD_STEPS.TOOL_SELECTION]: 'Tool Selection',
|
||||||
|
[WIZARD_STEPS.COLOR_SELECTION]: 'Color Selection',
|
||||||
|
[WIZARD_STEPS.FINAL_CONFIRMATION]: 'Final Confirmation',
|
||||||
|
};
|
||||||
|
|
||||||
|
// UI constants
|
||||||
|
export const UI = {
|
||||||
|
AUTO_CLOSE_DELAY_MS: 2000,
|
||||||
|
PROGRESS_BAR_FILLED: '█',
|
||||||
|
PROGRESS_BAR_EMPTY: '░',
|
||||||
|
} as const;
|
||||||
24
packages/cli/src/ui/components/subagents/index.ts
Normal file
24
packages/cli/src/ui/components/subagents/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { SubagentCreationWizard } from './SubagentCreationWizard.js';
|
||||||
|
export { LocationSelector } from './LocationSelector.js';
|
||||||
|
export { GenerationMethodSelector } from './GenerationMethodSelector.js';
|
||||||
|
export { DescriptionInput } from './DescriptionInput.js';
|
||||||
|
export { ToolSelector } from './ToolSelector.js';
|
||||||
|
export { ColorSelector } from './ColorSelector.js';
|
||||||
|
export { CreationSummary } from './CreationSummary.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CreationWizardState,
|
||||||
|
WizardAction,
|
||||||
|
WizardStepProps,
|
||||||
|
WizardResult,
|
||||||
|
ToolCategory,
|
||||||
|
ColorOption,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export { wizardReducer, initialWizardState } from './wizardReducer.js';
|
||||||
112
packages/cli/src/ui/components/subagents/types.ts
Normal file
112
packages/cli/src/ui/components/subagents/types.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SubagentLevel, Config } from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State management for the subagent creation wizard.
|
||||||
|
*/
|
||||||
|
export interface CreationWizardState {
|
||||||
|
/** Current step in the wizard (1-6) */
|
||||||
|
currentStep: number;
|
||||||
|
|
||||||
|
/** Storage location for the subagent */
|
||||||
|
location: SubagentLevel;
|
||||||
|
|
||||||
|
/** Generation method selection */
|
||||||
|
generationMethod: 'qwen' | 'manual';
|
||||||
|
|
||||||
|
/** User's description input for the subagent */
|
||||||
|
userDescription: string;
|
||||||
|
|
||||||
|
/** LLM-generated system prompt */
|
||||||
|
generatedSystemPrompt: string;
|
||||||
|
|
||||||
|
/** LLM-generated refined description */
|
||||||
|
generatedDescription: string;
|
||||||
|
|
||||||
|
/** Generated subagent name */
|
||||||
|
generatedName: string;
|
||||||
|
|
||||||
|
/** Selected tools for the subagent */
|
||||||
|
selectedTools: string[] | 'all';
|
||||||
|
|
||||||
|
/** Background color for runtime display */
|
||||||
|
backgroundColor: string;
|
||||||
|
|
||||||
|
/** Whether LLM generation is in progress */
|
||||||
|
isGenerating: boolean;
|
||||||
|
|
||||||
|
/** Validation errors for current step */
|
||||||
|
validationErrors: string[];
|
||||||
|
|
||||||
|
/** Whether the wizard can proceed to next step */
|
||||||
|
canProceed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool categories for organized selection.
|
||||||
|
*/
|
||||||
|
export interface ToolCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tools: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predefined color options for subagent display.
|
||||||
|
*/
|
||||||
|
export interface ColorOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions that can be dispatched to update wizard state.
|
||||||
|
*/
|
||||||
|
export type WizardAction =
|
||||||
|
| { type: 'SET_STEP'; step: number }
|
||||||
|
| { type: 'SET_LOCATION'; location: SubagentLevel }
|
||||||
|
| { type: 'SET_GENERATION_METHOD'; method: 'qwen' | 'manual' }
|
||||||
|
| { type: 'SET_USER_DESCRIPTION'; description: string }
|
||||||
|
| {
|
||||||
|
type: 'SET_GENERATED_CONTENT';
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
}
|
||||||
|
| { type: 'SET_TOOLS'; tools: string[] | 'all' }
|
||||||
|
| { type: 'SET_BACKGROUND_COLOR'; color: string }
|
||||||
|
| { type: 'SET_GENERATING'; isGenerating: boolean }
|
||||||
|
| { type: 'SET_VALIDATION_ERRORS'; errors: string[] }
|
||||||
|
| { type: 'RESET_WIZARD' }
|
||||||
|
| { type: 'GO_TO_PREVIOUS_STEP' }
|
||||||
|
| { type: 'GO_TO_NEXT_STEP' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for wizard step components.
|
||||||
|
*/
|
||||||
|
export interface WizardStepProps {
|
||||||
|
state: CreationWizardState;
|
||||||
|
dispatch: (action: WizardAction) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
config: Config | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of the wizard completion.
|
||||||
|
*/
|
||||||
|
export interface WizardResult {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
location: SubagentLevel;
|
||||||
|
tools?: string[];
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
106
packages/cli/src/ui/components/subagents/validation.ts
Normal file
106
packages/cli/src/ui/components/subagents/validation.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result interface.
|
||||||
|
*/
|
||||||
|
export interface ValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes user input by removing dangerous characters and normalizing whitespace.
|
||||||
|
*/
|
||||||
|
export function sanitizeInput(input: string): string {
|
||||||
|
return (
|
||||||
|
input
|
||||||
|
.trim()
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control characters
|
||||||
|
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||||
|
); // Limit length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a system prompt.
|
||||||
|
*/
|
||||||
|
export function validateSystemPrompt(prompt: string): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const sanitized = sanitizeInput(prompt);
|
||||||
|
|
||||||
|
if (sanitized.length === 0) {
|
||||||
|
errors.push('System prompt cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.length > 5000) {
|
||||||
|
errors.push('System prompt is too long (maximum 5000 characters)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates tool selection.
|
||||||
|
*/
|
||||||
|
export function validateToolSelection(
|
||||||
|
tools: string[] | 'all',
|
||||||
|
): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(tools)) {
|
||||||
|
if (tools.length === 0) {
|
||||||
|
errors.push('At least one tool must be selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for valid tool names (basic validation)
|
||||||
|
const invalidTools = tools.filter(
|
||||||
|
(tool) =>
|
||||||
|
typeof tool !== 'string' ||
|
||||||
|
tool.trim().length === 0 ||
|
||||||
|
!/^[a-zA-Z0-9_-]+$/.test(tool),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidTools.length > 0) {
|
||||||
|
errors.push(`Invalid tool names: ${invalidTools.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprehensive validation for the entire subagent configuration.
|
||||||
|
*/
|
||||||
|
export function validateSubagentConfig(config: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
tools: string[] | 'all';
|
||||||
|
}): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const promptValidation = validateSystemPrompt(config.systemPrompt);
|
||||||
|
if (!promptValidation.isValid) {
|
||||||
|
errors.push(...promptValidation.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolsValidation = validateToolSelection(config.tools);
|
||||||
|
if (!toolsValidation.isValid) {
|
||||||
|
errors.push(...toolsValidation.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
165
packages/cli/src/ui/components/subagents/wizardReducer.ts
Normal file
165
packages/cli/src/ui/components/subagents/wizardReducer.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CreationWizardState, WizardAction } from './types.js';
|
||||||
|
import { WIZARD_STEPS, TOTAL_WIZARD_STEPS } from './constants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial state for the creation wizard.
|
||||||
|
*/
|
||||||
|
export const initialWizardState: CreationWizardState = {
|
||||||
|
currentStep: WIZARD_STEPS.LOCATION_SELECTION,
|
||||||
|
location: 'project',
|
||||||
|
generationMethod: 'qwen',
|
||||||
|
userDescription: '',
|
||||||
|
generatedSystemPrompt: '',
|
||||||
|
generatedDescription: '',
|
||||||
|
generatedName: '',
|
||||||
|
selectedTools: 'all',
|
||||||
|
backgroundColor: 'auto',
|
||||||
|
isGenerating: false,
|
||||||
|
validationErrors: [],
|
||||||
|
canProceed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reducer for managing wizard state transitions.
|
||||||
|
*/
|
||||||
|
export function wizardReducer(
|
||||||
|
state: CreationWizardState,
|
||||||
|
action: WizardAction,
|
||||||
|
): CreationWizardState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_STEP':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStep: Math.max(
|
||||||
|
WIZARD_STEPS.LOCATION_SELECTION,
|
||||||
|
Math.min(TOTAL_WIZARD_STEPS, action.step),
|
||||||
|
),
|
||||||
|
validationErrors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_LOCATION':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
location: action.location,
|
||||||
|
canProceed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_GENERATION_METHOD':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
generationMethod: action.method,
|
||||||
|
canProceed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_USER_DESCRIPTION':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
userDescription: action.description,
|
||||||
|
canProceed: action.description.trim().length >= 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_GENERATED_CONTENT':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
generatedName: action.name,
|
||||||
|
generatedDescription: action.description,
|
||||||
|
generatedSystemPrompt: action.systemPrompt,
|
||||||
|
isGenerating: false,
|
||||||
|
canProceed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_TOOLS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedTools: action.tools,
|
||||||
|
canProceed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_BACKGROUND_COLOR':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
backgroundColor: action.color,
|
||||||
|
canProceed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_GENERATING':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isGenerating: action.isGenerating,
|
||||||
|
canProceed: !action.isGenerating,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_VALIDATION_ERRORS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
validationErrors: action.errors,
|
||||||
|
canProceed: action.errors.length === 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'GO_TO_NEXT_STEP':
|
||||||
|
if (state.canProceed && state.currentStep < TOTAL_WIZARD_STEPS) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStep: state.currentStep + 1,
|
||||||
|
validationErrors: [],
|
||||||
|
canProceed: validateStep(state.currentStep + 1, state),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
case 'GO_TO_PREVIOUS_STEP':
|
||||||
|
if (state.currentStep > WIZARD_STEPS.LOCATION_SELECTION) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStep: state.currentStep - 1,
|
||||||
|
validationErrors: [],
|
||||||
|
canProceed: validateStep(state.currentStep - 1, state),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
case 'RESET_WIZARD':
|
||||||
|
return initialWizardState;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates whether a step can proceed based on current state.
|
||||||
|
*/
|
||||||
|
function validateStep(step: number, state: CreationWizardState): boolean {
|
||||||
|
switch (step) {
|
||||||
|
case WIZARD_STEPS.LOCATION_SELECTION: // Location selection
|
||||||
|
return true; // Always can proceed from location selection
|
||||||
|
|
||||||
|
case WIZARD_STEPS.GENERATION_METHOD: // Generation method
|
||||||
|
return true; // Always can proceed from method selection
|
||||||
|
|
||||||
|
case WIZARD_STEPS.DESCRIPTION_INPUT: // Description input
|
||||||
|
return state.userDescription.trim().length >= 0;
|
||||||
|
|
||||||
|
case WIZARD_STEPS.TOOL_SELECTION: // Tool selection
|
||||||
|
return (
|
||||||
|
state.generatedName.length > 0 &&
|
||||||
|
state.generatedDescription.length > 0 &&
|
||||||
|
state.generatedSystemPrompt.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
case WIZARD_STEPS.COLOR_SELECTION: // Color selection
|
||||||
|
return true; // Always can proceed from tool selection
|
||||||
|
|
||||||
|
case WIZARD_STEPS.FINAL_CONFIRMATION: // Final confirmation
|
||||||
|
return state.backgroundColor.length > 0;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,8 +144,10 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
mockSetQuittingMessages,
|
mockSetQuittingMessages,
|
||||||
vi.fn(), // openPrivacyNotice
|
vi.fn(), // openPrivacyNotice
|
||||||
vi.fn(), // openSettingsDialog
|
vi.fn(), // openSettingsDialog
|
||||||
|
vi.fn(), // openSubagentCreateDialog
|
||||||
vi.fn(), // toggleVimEnabled
|
vi.fn(), // toggleVimEnabled
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
|
vi.fn(), // setGeminiMdFileCount
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -894,11 +896,11 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
vi.fn(), // toggleCorgiMode
|
vi.fn(), // toggleCorgiMode
|
||||||
mockSetQuittingMessages,
|
mockSetQuittingMessages,
|
||||||
vi.fn(), // openPrivacyNotice
|
vi.fn(), // openPrivacyNotice
|
||||||
|
|
||||||
vi.fn(), // openSettingsDialog
|
vi.fn(), // openSettingsDialog
|
||||||
|
vi.fn(), // openSubagentCreateDialog
|
||||||
vi.fn(), // toggleVimEnabled
|
vi.fn(), // toggleVimEnabled
|
||||||
vi.fn().mockResolvedValue(false), // toggleVimEnabled
|
|
||||||
vi.fn(), // setIsProcessing
|
vi.fn(), // setIsProcessing
|
||||||
|
vi.fn(), // setGeminiMdFileCount
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const useSlashCommandProcessor = (
|
|||||||
setQuittingMessages: (message: HistoryItem[]) => void,
|
setQuittingMessages: (message: HistoryItem[]) => void,
|
||||||
openPrivacyNotice: () => void,
|
openPrivacyNotice: () => void,
|
||||||
openSettingsDialog: () => void,
|
openSettingsDialog: () => void,
|
||||||
|
openSubagentCreateDialog: () => void,
|
||||||
toggleVimEnabled: () => Promise<boolean>,
|
toggleVimEnabled: () => Promise<boolean>,
|
||||||
setIsProcessing: (isProcessing: boolean) => void,
|
setIsProcessing: (isProcessing: boolean) => void,
|
||||||
setGeminiMdFileCount: (count: number) => void,
|
setGeminiMdFileCount: (count: number) => void,
|
||||||
@@ -379,6 +380,9 @@ export const useSlashCommandProcessor = (
|
|||||||
case 'settings':
|
case 'settings':
|
||||||
openSettingsDialog();
|
openSettingsDialog();
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
|
case 'subagent_create':
|
||||||
|
openSubagentCreateDialog();
|
||||||
|
return { type: 'handled' };
|
||||||
case 'help':
|
case 'help':
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
default: {
|
default: {
|
||||||
@@ -553,6 +557,7 @@ export const useSlashCommandProcessor = (
|
|||||||
openEditorDialog,
|
openEditorDialog,
|
||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
openSettingsDialog,
|
openSettingsDialog,
|
||||||
|
openSubagentCreateDialog,
|
||||||
setShellConfirmationRequest,
|
setShellConfirmationRequest,
|
||||||
setSessionShellAllowlist,
|
setSessionShellAllowlist,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
|
|||||||
26
packages/cli/src/ui/hooks/useSubagentCreateDialog.ts
Normal file
26
packages/cli/src/ui/hooks/useSubagentCreateDialog.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useSubagentCreateDialog() {
|
||||||
|
const [isSubagentCreateDialogOpen, setIsSubagentCreateDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const openSubagentCreateDialog = useCallback(() => {
|
||||||
|
setIsSubagentCreateDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSubagentCreateDialog = useCallback(() => {
|
||||||
|
setIsSubagentCreateDialogOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSubagentCreateDialogOpen,
|
||||||
|
openSubagentCreateDialog,
|
||||||
|
closeSubagentCreateDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,7 +26,11 @@ import {
|
|||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
import { Config } from '../config/config.js';
|
import { Config } from '../config/config.js';
|
||||||
import { UserTierId } from '../code_assist/types.js';
|
import { UserTierId } from '../code_assist/types.js';
|
||||||
import { getCoreSystemPrompt, getCompressionPrompt } from './prompts.js';
|
import {
|
||||||
|
getCoreSystemPrompt,
|
||||||
|
getCompressionPrompt,
|
||||||
|
getCustomSystemPrompt,
|
||||||
|
} from './prompts.js';
|
||||||
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
|
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
|
||||||
import { reportError } from '../utils/errorReporting.js';
|
import { reportError } from '../utils/errorReporting.js';
|
||||||
import { GeminiChat } from './geminiChat.js';
|
import { GeminiChat } from './geminiChat.js';
|
||||||
@@ -618,11 +622,15 @@ export class GeminiClient {
|
|||||||
model || this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
|
model || this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
|
||||||
try {
|
try {
|
||||||
const userMemory = this.config.getUserMemory();
|
const userMemory = this.config.getUserMemory();
|
||||||
const systemInstruction = getCoreSystemPrompt(userMemory);
|
const finalSystemInstruction = config.systemInstruction
|
||||||
|
? getCustomSystemPrompt(config.systemInstruction, userMemory)
|
||||||
|
: getCoreSystemPrompt(userMemory);
|
||||||
|
|
||||||
const requestConfig = {
|
const requestConfig = {
|
||||||
abortSignal,
|
abortSignal,
|
||||||
...this.generateContentConfig,
|
...this.generateContentConfig,
|
||||||
...config,
|
...config,
|
||||||
|
systemInstruction: finalSystemInstruction,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert schema to function declaration
|
// Convert schema to function declaration
|
||||||
@@ -644,7 +652,6 @@ export class GeminiClient {
|
|||||||
model: modelToUse,
|
model: modelToUse,
|
||||||
config: {
|
config: {
|
||||||
...requestConfig,
|
...requestConfig,
|
||||||
systemInstruction,
|
|
||||||
tools,
|
tools,
|
||||||
},
|
},
|
||||||
contents,
|
contents,
|
||||||
@@ -706,12 +713,14 @@ export class GeminiClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const userMemory = this.config.getUserMemory();
|
const userMemory = this.config.getUserMemory();
|
||||||
const systemInstruction = getCoreSystemPrompt(userMemory);
|
const finalSystemInstruction = generationConfig.systemInstruction
|
||||||
|
? getCustomSystemPrompt(generationConfig.systemInstruction, userMemory)
|
||||||
|
: getCoreSystemPrompt(userMemory);
|
||||||
|
|
||||||
const requestConfig: GenerateContentConfig = {
|
const requestConfig: GenerateContentConfig = {
|
||||||
abortSignal,
|
abortSignal,
|
||||||
...configToUse,
|
...configToUse,
|
||||||
systemInstruction,
|
systemInstruction: finalSystemInstruction,
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiCall = () =>
|
const apiCall = () =>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { getCoreSystemPrompt } from './prompts.js';
|
import { getCoreSystemPrompt, getCustomSystemPrompt } from './prompts.js';
|
||||||
import { isGitRepository } from '../utils/gitUtils.js';
|
import { isGitRepository } from '../utils/gitUtils.js';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
@@ -363,3 +363,45 @@ describe('URL matching with trailing slash compatibility', () => {
|
|||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getCustomSystemPrompt', () => {
|
||||||
|
it('should handle string custom instruction without user memory', () => {
|
||||||
|
const customInstruction =
|
||||||
|
'You are a helpful assistant specialized in code review.';
|
||||||
|
const result = getCustomSystemPrompt(customInstruction);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
'You are a helpful assistant specialized in code review.',
|
||||||
|
);
|
||||||
|
expect(result).not.toContain('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string custom instruction with user memory', () => {
|
||||||
|
const customInstruction =
|
||||||
|
'You are a helpful assistant specialized in code review.';
|
||||||
|
const userMemory =
|
||||||
|
'Remember to be extra thorough.\nFocus on security issues.';
|
||||||
|
const result = getCustomSystemPrompt(customInstruction, userMemory);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
'You are a helpful assistant specialized in code review.\n\n---\n\nRemember to be extra thorough.\nFocus on security issues.',
|
||||||
|
);
|
||||||
|
expect(result).toContain('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Content object with parts array and user memory', () => {
|
||||||
|
const customInstruction = {
|
||||||
|
parts: [
|
||||||
|
{ text: 'You are a code assistant. ' },
|
||||||
|
{ text: 'Always provide examples.' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const userMemory = 'User prefers TypeScript examples.';
|
||||||
|
const result = getCustomSystemPrompt(customInstruction, userMemory);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
'You are a code assistant. Always provide examples.\n\n---\n\nUser prefers TypeScript examples.',
|
||||||
|
);
|
||||||
|
expect(result).toContain('---');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import process from 'node:process';
|
|||||||
import { isGitRepository } from '../utils/gitUtils.js';
|
import { isGitRepository } from '../utils/gitUtils.js';
|
||||||
import { MemoryTool, GEMINI_CONFIG_DIR } from '../tools/memoryTool.js';
|
import { MemoryTool, GEMINI_CONFIG_DIR } from '../tools/memoryTool.js';
|
||||||
import { TodoWriteTool } from '../tools/todoWrite.js';
|
import { TodoWriteTool } from '../tools/todoWrite.js';
|
||||||
|
import { GenerateContentConfig } from '@google/genai';
|
||||||
|
|
||||||
export interface ModelTemplateMapping {
|
export interface ModelTemplateMapping {
|
||||||
baseUrls?: string[];
|
baseUrls?: string[];
|
||||||
@@ -44,6 +45,48 @@ function urlMatches(urlArray: string[], targetUrl: string): boolean {
|
|||||||
return urlArray.some((url) => normalizeUrl(url) === normalizedTarget);
|
return urlArray.some((url) => normalizeUrl(url) === normalizedTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a custom system instruction by appending user memory if available.
|
||||||
|
* This function should only be used when there is actually a custom instruction.
|
||||||
|
*
|
||||||
|
* @param customInstruction - Custom system instruction (ContentUnion from @google/genai)
|
||||||
|
* @param userMemory - User memory to append
|
||||||
|
* @returns Processed custom system instruction with user memory appended
|
||||||
|
*/
|
||||||
|
export function getCustomSystemPrompt(
|
||||||
|
customInstruction: GenerateContentConfig['systemInstruction'],
|
||||||
|
userMemory?: string,
|
||||||
|
): string {
|
||||||
|
// Extract text from custom instruction
|
||||||
|
let instructionText = '';
|
||||||
|
|
||||||
|
if (typeof customInstruction === 'string') {
|
||||||
|
instructionText = customInstruction;
|
||||||
|
} else if (Array.isArray(customInstruction)) {
|
||||||
|
// PartUnion[]
|
||||||
|
instructionText = customInstruction
|
||||||
|
.map((part) => (typeof part === 'string' ? part : part.text || ''))
|
||||||
|
.join('');
|
||||||
|
} else if (customInstruction && 'parts' in customInstruction) {
|
||||||
|
// Content
|
||||||
|
instructionText =
|
||||||
|
customInstruction.parts
|
||||||
|
?.map((part) => (typeof part === 'string' ? part : part.text || ''))
|
||||||
|
.join('') || '';
|
||||||
|
} else if (customInstruction && 'text' in customInstruction) {
|
||||||
|
// PartUnion (single part)
|
||||||
|
instructionText = customInstruction.text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append user memory using the same pattern as getCoreSystemPrompt
|
||||||
|
const memorySuffix =
|
||||||
|
userMemory && userMemory.trim().length > 0
|
||||||
|
? `\n\n---\n\n${userMemory.trim()}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `${instructionText}${memorySuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function getCoreSystemPrompt(
|
export function getCoreSystemPrompt(
|
||||||
userMemory?: string,
|
userMemory?: string,
|
||||||
config?: SystemPromptConfig,
|
config?: SystemPromptConfig,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export * from './utils/textUtils.js';
|
|||||||
export * from './utils/formatters.js';
|
export * from './utils/formatters.js';
|
||||||
export * from './utils/filesearch/fileSearch.js';
|
export * from './utils/filesearch/fileSearch.js';
|
||||||
export * from './utils/errorParsing.js';
|
export * from './utils/errorParsing.js';
|
||||||
|
export * from './utils/subagentGenerator.js';
|
||||||
|
|
||||||
// Export services
|
// Export services
|
||||||
export * from './services/fileDiscoveryService.js';
|
export * from './services/fileDiscoveryService.js';
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { createHash } from 'crypto';
|
|||||||
import { GeminiEventType, ServerGeminiStreamEvent } from '../core/turn.js';
|
import { GeminiEventType, ServerGeminiStreamEvent } from '../core/turn.js';
|
||||||
import { logLoopDetected } from '../telemetry/loggers.js';
|
import { logLoopDetected } from '../telemetry/loggers.js';
|
||||||
import { LoopDetectedEvent, LoopType } from '../telemetry/types.js';
|
import { LoopDetectedEvent, LoopType } from '../telemetry/types.js';
|
||||||
import { Config, DEFAULT_GEMINI_FLASH_MODEL } from '../config/config.js';
|
import { Config } from '../config/config.js';
|
||||||
|
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
|
||||||
|
|
||||||
const TOOL_CALL_LOOP_THRESHOLD = 5;
|
const TOOL_CALL_LOOP_THRESHOLD = 5;
|
||||||
const CONTENT_LOOP_THRESHOLD = 10;
|
const CONTENT_LOOP_THRESHOLD = 10;
|
||||||
@@ -360,7 +361,7 @@ Please analyze the conversation history to determine the possibility that the co
|
|||||||
try {
|
try {
|
||||||
result = await this.config
|
result = await this.config
|
||||||
.getGeminiClient()
|
.getGeminiClient()
|
||||||
.generateJson(contents, schema, signal, DEFAULT_GEMINI_FLASH_MODEL);
|
.generateJson(contents, schema, signal, DEFAULT_QWEN_FLASH_MODEL);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Do nothing, treat it as a non-loop.
|
// Do nothing, treat it as a non-loop.
|
||||||
this.config.getDebugMode() ? console.error(e) : console.debug(e);
|
this.config.getDebugMode() ? console.error(e) : console.debug(e);
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ export class MemoryTool
|
|||||||
constructor() {
|
constructor() {
|
||||||
super(
|
super(
|
||||||
MemoryTool.Name,
|
MemoryTool.Name,
|
||||||
'Save Memory',
|
'SaveMemory',
|
||||||
memoryToolDescription,
|
memoryToolDescription,
|
||||||
Kind.Think,
|
Kind.Think,
|
||||||
memoryToolSchemaData.parametersJsonSchema as Record<string, unknown>,
|
memoryToolSchemaData.parametersJsonSchema as Record<string, unknown>,
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ export class TodoWriteTool extends BaseDeclarativeTool<
|
|||||||
constructor(private readonly config: Config) {
|
constructor(private readonly config: Config) {
|
||||||
super(
|
super(
|
||||||
TodoWriteTool.Name,
|
TodoWriteTool.Name,
|
||||||
'Todo Write',
|
'TodoWrite',
|
||||||
todoWriteToolDescription,
|
todoWriteToolDescription,
|
||||||
Kind.Think,
|
Kind.Think,
|
||||||
todoWriteToolSchemaData.parametersJsonSchema as Record<string, unknown>,
|
todoWriteToolSchemaData.parametersJsonSchema as Record<string, unknown>,
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export class WebSearchTool extends BaseDeclarativeTool<
|
|||||||
constructor(private readonly config: Config) {
|
constructor(private readonly config: Config) {
|
||||||
super(
|
super(
|
||||||
WebSearchTool.Name,
|
WebSearchTool.Name,
|
||||||
'TavilySearch',
|
'WebSearch',
|
||||||
'Performs a web search using the Tavily API and returns a concise answer with sources. Requires the TAVILY_API_KEY environment variable.',
|
'Performs a web search using the Tavily API and returns a concise answer with sources. Requires the TAVILY_API_KEY environment variable.',
|
||||||
Kind.Search,
|
Kind.Search,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import { ReadFileTool } from '../tools/read-file.js';
|
|||||||
import { ReadManyFilesTool } from '../tools/read-many-files.js';
|
import { ReadManyFilesTool } from '../tools/read-many-files.js';
|
||||||
import { GrepTool } from '../tools/grep.js';
|
import { GrepTool } from '../tools/grep.js';
|
||||||
import { LruCache } from './LruCache.js';
|
import { LruCache } from './LruCache.js';
|
||||||
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
|
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
|
||||||
import {
|
import {
|
||||||
isFunctionResponse,
|
isFunctionResponse,
|
||||||
isFunctionCall,
|
isFunctionCall,
|
||||||
} from '../utils/messageInspectors.js';
|
} from '../utils/messageInspectors.js';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
const EditModel = DEFAULT_GEMINI_FLASH_LITE_MODEL;
|
const EditModel = DEFAULT_QWEN_FLASH_MODEL;
|
||||||
const EditConfig: GenerateContentConfig = {
|
const EditConfig: GenerateContentConfig = {
|
||||||
thinkingConfig: {
|
thinkingConfig: {
|
||||||
thinkingBudget: 0,
|
thinkingBudget: 0,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest';
|
||||||
import { Content, GoogleGenAI, Models } from '@google/genai';
|
import { Content, GoogleGenAI, Models } from '@google/genai';
|
||||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
|
||||||
import { GeminiClient } from '../core/client.js';
|
import { GeminiClient } from '../core/client.js';
|
||||||
import { Config } from '../config/config.js';
|
import { Config } from '../config/config.js';
|
||||||
import { checkNextSpeaker, NextSpeakerResponse } from './nextSpeakerChecker.js';
|
import { checkNextSpeaker, NextSpeakerResponse } from './nextSpeakerChecker.js';
|
||||||
@@ -233,7 +233,7 @@ describe('checkNextSpeaker', () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call generateJson with DEFAULT_GEMINI_FLASH_MODEL', async () => {
|
it('should call generateJson with DEFAULT_QWEN_FLASH_MODEL', async () => {
|
||||||
(chatInstance.getHistory as Mock).mockReturnValue([
|
(chatInstance.getHistory as Mock).mockReturnValue([
|
||||||
{ role: 'model', parts: [{ text: 'Some model output.' }] },
|
{ role: 'model', parts: [{ text: 'Some model output.' }] },
|
||||||
] as Content[]);
|
] as Content[]);
|
||||||
@@ -248,6 +248,6 @@ describe('checkNextSpeaker', () => {
|
|||||||
expect(mockGeminiClient.generateJson).toHaveBeenCalled();
|
expect(mockGeminiClient.generateJson).toHaveBeenCalled();
|
||||||
const generateJsonCall = (mockGeminiClient.generateJson as Mock).mock
|
const generateJsonCall = (mockGeminiClient.generateJson as Mock).mock
|
||||||
.calls[0];
|
.calls[0];
|
||||||
expect(generateJsonCall[3]).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
expect(generateJsonCall[3]).toBe(DEFAULT_QWEN_FLASH_MODEL);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Content } from '@google/genai';
|
import { Content } from '@google/genai';
|
||||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
|
||||||
import { GeminiClient } from '../core/client.js';
|
import { GeminiClient } from '../core/client.js';
|
||||||
import { GeminiChat } from '../core/geminiChat.js';
|
import { GeminiChat } from '../core/geminiChat.js';
|
||||||
import { isFunctionResponse } from './messageInspectors.js';
|
import { isFunctionResponse } from './messageInspectors.js';
|
||||||
@@ -112,7 +112,7 @@ export async function checkNextSpeaker(
|
|||||||
contents,
|
contents,
|
||||||
RESPONSE_SCHEMA,
|
RESPONSE_SCHEMA,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
DEFAULT_GEMINI_FLASH_MODEL,
|
DEFAULT_QWEN_FLASH_MODEL,
|
||||||
)) as unknown as NextSpeakerResponse;
|
)) as unknown as NextSpeakerResponse;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
262
packages/core/src/utils/subagentGenerator.test.ts
Normal file
262
packages/core/src/utils/subagentGenerator.test.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest';
|
||||||
|
import { Content, GoogleGenAI, Models } from '@google/genai';
|
||||||
|
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||||
|
import { GeminiClient } from '../core/client.js';
|
||||||
|
import { Config } from '../config/config.js';
|
||||||
|
import {
|
||||||
|
subagentGenerator,
|
||||||
|
SubagentGeneratedContent,
|
||||||
|
} from './subagentGenerator.js';
|
||||||
|
|
||||||
|
// Mock GeminiClient and Config constructor
|
||||||
|
vi.mock('../core/client.js');
|
||||||
|
vi.mock('../config/config.js');
|
||||||
|
|
||||||
|
// Define mocks for GoogleGenAI and Models instances that will be used across tests
|
||||||
|
const mockModelsInstance = {
|
||||||
|
generateContent: vi.fn(),
|
||||||
|
generateContentStream: vi.fn(),
|
||||||
|
countTokens: vi.fn(),
|
||||||
|
embedContent: vi.fn(),
|
||||||
|
batchEmbedContents: vi.fn(),
|
||||||
|
} as unknown as Models;
|
||||||
|
|
||||||
|
const mockGoogleGenAIInstance = {
|
||||||
|
getGenerativeModel: vi.fn().mockReturnValue(mockModelsInstance),
|
||||||
|
} as unknown as GoogleGenAI;
|
||||||
|
|
||||||
|
vi.mock('@google/genai', async () => {
|
||||||
|
const actualGenAI =
|
||||||
|
await vi.importActual<typeof import('@google/genai')>('@google/genai');
|
||||||
|
return {
|
||||||
|
...actualGenAI,
|
||||||
|
GoogleGenAI: vi.fn(() => mockGoogleGenAIInstance),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subagentGenerator', () => {
|
||||||
|
let mockGeminiClient: GeminiClient;
|
||||||
|
let MockConfig: Mock;
|
||||||
|
const abortSignal = new AbortController().signal;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
MockConfig = vi.mocked(Config);
|
||||||
|
const mockConfigInstance = new MockConfig(
|
||||||
|
'test-api-key',
|
||||||
|
'gemini-pro',
|
||||||
|
false,
|
||||||
|
'.',
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
mockGeminiClient = new GeminiClient(mockConfigInstance);
|
||||||
|
|
||||||
|
// Reset mocks before each test to ensure test isolation
|
||||||
|
vi.mocked(mockModelsInstance.generateContent).mockReset();
|
||||||
|
vi.mocked(mockModelsInstance.generateContentStream).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for empty user description', async () => {
|
||||||
|
await expect(
|
||||||
|
subagentGenerator('', mockGeminiClient, abortSignal),
|
||||||
|
).rejects.toThrow('User description cannot be empty');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
subagentGenerator(' ', mockGeminiClient, abortSignal),
|
||||||
|
).rejects.toThrow('User description cannot be empty');
|
||||||
|
|
||||||
|
expect(mockGeminiClient.generateJson).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should successfully generate content with valid LLM response', async () => {
|
||||||
|
const userDescription = 'help with code reviews and suggestions';
|
||||||
|
const mockApiResponse: SubagentGeneratedContent = {
|
||||||
|
name: 'code-review-assistant',
|
||||||
|
description:
|
||||||
|
'A specialized subagent that helps with code reviews and provides improvement suggestions.',
|
||||||
|
systemPrompt:
|
||||||
|
'You are a code review expert. Analyze code for best practices, bugs, and improvements.',
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockGeminiClient.generateJson as Mock).mockResolvedValue(mockApiResponse);
|
||||||
|
|
||||||
|
const result = await subagentGenerator(
|
||||||
|
userDescription,
|
||||||
|
mockGeminiClient,
|
||||||
|
abortSignal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockApiResponse);
|
||||||
|
expect(mockGeminiClient.generateJson).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Verify the call parameters
|
||||||
|
const generateJsonCall = (mockGeminiClient.generateJson as Mock).mock
|
||||||
|
.calls[0];
|
||||||
|
const contents = generateJsonCall[0] as Content[];
|
||||||
|
|
||||||
|
// Should have 1 user message with the query
|
||||||
|
expect(contents).toHaveLength(1);
|
||||||
|
expect(contents[0]?.role).toBe('user');
|
||||||
|
expect(contents[0]?.parts?.[0]?.text).toContain(
|
||||||
|
`Create an agent configuration based on this request: "${userDescription}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that system prompt is passed in the config parameter
|
||||||
|
expect(generateJsonCall[2]).toBe(abortSignal);
|
||||||
|
expect(generateJsonCall[3]).toBe(DEFAULT_QWEN_MODEL);
|
||||||
|
expect(generateJsonCall[4]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
systemInstruction: expect.stringContaining(
|
||||||
|
'You are an elite AI agent architect',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when LLM response is missing required fields', async () => {
|
||||||
|
const userDescription = 'help with documentation';
|
||||||
|
const incompleteResponse = {
|
||||||
|
name: 'doc-helper',
|
||||||
|
description: 'Helps with documentation',
|
||||||
|
// Missing systemPrompt
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockGeminiClient.generateJson as Mock).mockResolvedValue(
|
||||||
|
incompleteResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
subagentGenerator(userDescription, mockGeminiClient, abortSignal),
|
||||||
|
).rejects.toThrow('Invalid response from LLM: missing required fields');
|
||||||
|
|
||||||
|
expect(mockGeminiClient.generateJson).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when LLM response has empty fields', async () => {
|
||||||
|
const userDescription = 'database optimization';
|
||||||
|
const emptyFieldsResponse = {
|
||||||
|
name: '',
|
||||||
|
description: 'Helps with database optimization',
|
||||||
|
systemPrompt: 'You are a database expert.',
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockGeminiClient.generateJson as Mock).mockResolvedValue(
|
||||||
|
emptyFieldsResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
subagentGenerator(userDescription, mockGeminiClient, abortSignal),
|
||||||
|
).rejects.toThrow('Invalid response from LLM: missing required fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when generateJson throws an error', async () => {
|
||||||
|
const userDescription = 'testing automation';
|
||||||
|
(mockGeminiClient.generateJson as Mock).mockRejectedValue(
|
||||||
|
new Error('API Error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
subagentGenerator(userDescription, mockGeminiClient, abortSignal),
|
||||||
|
).rejects.toThrow('API Error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call generateJson with correct schema and model', async () => {
|
||||||
|
const userDescription = 'data analysis';
|
||||||
|
const mockResponse: SubagentGeneratedContent = {
|
||||||
|
name: 'data-analyst',
|
||||||
|
description: 'Analyzes data and provides insights.',
|
||||||
|
systemPrompt: 'You are a data analysis expert.',
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockGeminiClient.generateJson as Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
await subagentGenerator(userDescription, mockGeminiClient, abortSignal);
|
||||||
|
|
||||||
|
expect(mockGeminiClient.generateJson).toHaveBeenCalledWith(
|
||||||
|
expect.any(Array),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'object',
|
||||||
|
properties: expect.objectContaining({
|
||||||
|
name: expect.objectContaining({ type: 'string' }),
|
||||||
|
description: expect.objectContaining({ type: 'string' }),
|
||||||
|
systemPrompt: expect.objectContaining({ type: 'string' }),
|
||||||
|
}),
|
||||||
|
required: ['name', 'description', 'systemPrompt'],
|
||||||
|
}),
|
||||||
|
abortSignal,
|
||||||
|
DEFAULT_QWEN_MODEL,
|
||||||
|
expect.objectContaining({
|
||||||
|
systemInstruction: expect.stringContaining(
|
||||||
|
'You are an elite AI agent architect',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include user description in the prompt', async () => {
|
||||||
|
const userDescription = 'machine learning model training';
|
||||||
|
const mockResponse: SubagentGeneratedContent = {
|
||||||
|
name: 'ml-trainer',
|
||||||
|
description: 'Trains machine learning models.',
|
||||||
|
systemPrompt: 'You are an ML expert.',
|
||||||
|
};
|
||||||
|
|
||||||
|
(mockGeminiClient.generateJson as Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
await subagentGenerator(userDescription, mockGeminiClient, abortSignal);
|
||||||
|
|
||||||
|
const generateJsonCall = (mockGeminiClient.generateJson as Mock).mock
|
||||||
|
.calls[0];
|
||||||
|
const contents = generateJsonCall[0] as Content[];
|
||||||
|
|
||||||
|
// Check user query (only message)
|
||||||
|
expect(contents).toHaveLength(1);
|
||||||
|
const userQueryContent = contents[0]?.parts?.[0]?.text;
|
||||||
|
expect(userQueryContent).toContain(userDescription);
|
||||||
|
expect(userQueryContent).toContain(
|
||||||
|
'Create an agent configuration based on this request:',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that system prompt is passed in the config parameter
|
||||||
|
expect(generateJsonCall[4]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
systemInstruction: expect.stringContaining(
|
||||||
|
'You are an elite AI agent architect',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for null response from generateJson', async () => {
|
||||||
|
const userDescription = 'security auditing';
|
||||||
|
(mockGeminiClient.generateJson as Mock).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
subagentGenerator(userDescription, mockGeminiClient, abortSignal),
|
||||||
|
).rejects.toThrow('Invalid response from LLM: missing required fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for undefined response from generateJson', async () => {
|
||||||
|
const userDescription = 'api documentation';
|
||||||
|
(mockGeminiClient.generateJson as Mock).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
subagentGenerator(userDescription, mockGeminiClient, abortSignal),
|
||||||
|
).rejects.toThrow('Invalid response from LLM: missing required fields');
|
||||||
|
});
|
||||||
|
});
|
||||||
148
packages/core/src/utils/subagentGenerator.ts
Normal file
148
packages/core/src/utils/subagentGenerator.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Content } from '@google/genai';
|
||||||
|
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||||
|
import { GeminiClient } from '../core/client.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are an elite AI agent architect specializing in crafting high-performance agent configurations. Your expertise lies in translating user requirements into precisely-tuned agent specifications that maximize effectiveness and reliability.
|
||||||
|
|
||||||
|
**Important Context**: You may have access to project-specific instructions from QWEN.md files and other context that may include coding standards, project structure, and custom requirements. Consider this context when creating agents to ensure they align with the project's established patterns and practices.
|
||||||
|
|
||||||
|
When a user describes what they want an agent to do, you will:
|
||||||
|
|
||||||
|
1. **Extract Core Intent**: Identify the fundamental purpose, key responsibilities, and success criteria for the agent. Look for both explicit requirements and implicit needs. Consider any project-specific context from QWEN.md files. For agents that are meant to review code, you should assume that the user is asking to review recently written code and not the whole codebase, unless the user has explicitly instructed you otherwise.
|
||||||
|
|
||||||
|
2. **Design Expert Persona**: Create a compelling expert identity that embodies deep domain knowledge relevant to the task. The persona should inspire confidence and guide the agent's decision-making approach.
|
||||||
|
|
||||||
|
3. **Architect Comprehensive Instructions**: Develop a system prompt that:
|
||||||
|
- Establishes clear behavioral boundaries and operational parameters
|
||||||
|
- Provides specific methodologies and best practices for task execution
|
||||||
|
- Anticipates edge cases and provides guidance for handling them
|
||||||
|
- Incorporates any specific requirements or preferences mentioned by the user
|
||||||
|
- Defines output format expectations when relevant
|
||||||
|
- Aligns with project-specific coding standards and patterns from QWEN.md
|
||||||
|
|
||||||
|
4. **Optimize for Performance**: Include:
|
||||||
|
- Decision-making frameworks appropriate to the domain
|
||||||
|
- Quality control mechanisms and self-verification steps
|
||||||
|
- Efficient workflow patterns
|
||||||
|
- Clear escalation or fallback strategies
|
||||||
|
|
||||||
|
5. **Create Identifier**: Design a concise, descriptive identifier that:
|
||||||
|
- Uses lowercase letters, numbers, and hyphens only
|
||||||
|
- Is typically 2-4 words joined by hyphens
|
||||||
|
- Clearly indicates the agent's primary function
|
||||||
|
- Is memorable and easy to type
|
||||||
|
- Avoids generic terms like "helper" or "assistant"
|
||||||
|
|
||||||
|
6 **Example agent descriptions**:
|
||||||
|
- in the 'whenToUse' field of the JSON object, you should include examples of when this agent should be used.
|
||||||
|
- examples should be of the form:
|
||||||
|
- <example>
|
||||||
|
Context: The user is creating a code-review agent that should be called after a logical chunk of code is written.
|
||||||
|
user: "Please write a function that checks if a number is prime"
|
||||||
|
assistant: "Here is the relevant function: "
|
||||||
|
<function call omitted for brevity only for this example>
|
||||||
|
<commentary>
|
||||||
|
Since the user is greeting, use the Task tool to launch the greeting-responder agent to respond with a friendly joke.
|
||||||
|
</commentary>
|
||||||
|
assistant: "Now let me use the code-reviewer agent to review the code"
|
||||||
|
</example>
|
||||||
|
- <example>
|
||||||
|
Context: User is creating an agent to respond to the word "hello" with a friendly jok.
|
||||||
|
user: "Hello"
|
||||||
|
assistant: "I'm going to use the Task tool to launch the greeting-responder agent to respond with a friendly joke"
|
||||||
|
<commentary>
|
||||||
|
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke.
|
||||||
|
</commentary>
|
||||||
|
</example>
|
||||||
|
- If the user mentioned or implied that the agent should be used proactively, you should include examples of this.
|
||||||
|
- NOTE: Ensure that in the examples, you are making the assistant use the Agent tool and not simply respond directly to the task.
|
||||||
|
|
||||||
|
Key principles for your system prompts:
|
||||||
|
- Be specific rather than generic - avoid vague instructions
|
||||||
|
- Include concrete examples when they would clarify behavior
|
||||||
|
- Balance comprehensiveness with clarity - every instruction should add value
|
||||||
|
- Ensure the agent has enough context to handle variations of the core task
|
||||||
|
- Make the agent proactive in seeking clarification when needed
|
||||||
|
- Build in quality assurance and self-correction mechanisms
|
||||||
|
|
||||||
|
Remember: The agents you create should be autonomous experts capable of handling their designated tasks with minimal additional guidance. Your system prompts are their complete operational manual.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createUserPrompt = (userInput: string): string =>
|
||||||
|
`Create an agent configuration based on this request: "${userInput}"`;
|
||||||
|
|
||||||
|
const RESPONSE_SCHEMA: Record<string, unknown> = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
"A unique, descriptive identifier using lowercase letters, numbers, and hyphens (e.g., 'code-reviewer', 'api-docs-writer', 'test-generator')",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
"A precise, actionable description starting with 'Use this agent when...' that clearly defines the triggering conditions and use cases",
|
||||||
|
},
|
||||||
|
systemPrompt: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
"The complete system prompt that will govern the agent's behavior, written in second person ('You are...', 'You will...') and structured for maximum clarity and effectiveness",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['name', 'description', 'systemPrompt'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SubagentGeneratedContent {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates subagent configuration content using LLM.
|
||||||
|
*
|
||||||
|
* @param userDescription - The user's description of what the subagent should do
|
||||||
|
* @param geminiClient - Initialized GeminiClient instance
|
||||||
|
* @param abortSignal - AbortSignal for cancelling the request
|
||||||
|
* @returns Promise resolving to generated subagent content
|
||||||
|
*/
|
||||||
|
export async function subagentGenerator(
|
||||||
|
userDescription: string,
|
||||||
|
geminiClient: GeminiClient,
|
||||||
|
abortSignal: AbortSignal,
|
||||||
|
): Promise<SubagentGeneratedContent> {
|
||||||
|
if (!userDescription.trim()) {
|
||||||
|
throw new Error('User description cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPrompt = createUserPrompt(userDescription);
|
||||||
|
const contents: Content[] = [{ role: 'user', parts: [{ text: userPrompt }] }];
|
||||||
|
|
||||||
|
const parsedResponse = (await geminiClient.generateJson(
|
||||||
|
contents,
|
||||||
|
RESPONSE_SCHEMA,
|
||||||
|
abortSignal,
|
||||||
|
DEFAULT_QWEN_MODEL,
|
||||||
|
{
|
||||||
|
systemInstruction: SYSTEM_PROMPT,
|
||||||
|
},
|
||||||
|
)) as unknown as SubagentGeneratedContent;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!parsedResponse ||
|
||||||
|
!parsedResponse.name ||
|
||||||
|
!parsedResponse.description ||
|
||||||
|
!parsedResponse.systemPrompt
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid response from LLM: missing required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedResponse;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user