diff --git a/extensions/cli/src/index.ts b/extensions/cli/src/index.ts index a3a718e3ef..f251a427ee 100644 --- a/extensions/cli/src/index.ts +++ b/extensions/cli/src/index.ts @@ -200,10 +200,6 @@ addCommonOptions(program) ) .option("--resume", "Resume from last session") .option("--fork ", "Fork from an existing session ID") - .option( - "--beta-subagent-tool", - "Enable beta Subagent tool for invoking subagents", - ) .action(async (prompt, options) => { // Telemetry: record command invocation await posthogService.capture("cliCommand", { command: "cn" }); diff --git a/extensions/cli/src/services/SubAgentService.ts b/extensions/cli/src/services/SubAgentService.ts new file mode 100644 index 0000000000..681551c26f --- /dev/null +++ b/extensions/cli/src/services/SubAgentService.ts @@ -0,0 +1,277 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { ChatHistoryItem } from "core"; + +import { streamChatResponse } from "../stream/streamChatResponse.js"; +import { escapeEvents } from "../util/cli.js"; +import { logger } from "../util/logger.js"; + +import { BaseService } from "./BaseService.js"; +import { serviceContainer } from "./ServiceContainer.js"; +import type { ToolPermissionServiceState } from "./ToolPermissionService.js"; +import { type ModelServiceState, SERVICE_NAMES } from "./types.js"; + +/** Service */ + +export class SubAgentService extends BaseService { + constructor() { + super("SubAgentService", { + activeExecutions: new Map(), + }); + } + + async doInitialize(): Promise { + return { + activeExecutions: new Map(), + }; + } + + private generateExecutionId(): string { + return `subagent-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + } + + isInsideSubagent(): boolean { + return subAgentExecutionContext.getStore() !== undefined; + } + + private async buildAgentSystemMessage( + agent: ModelServiceState, + services: any, + ): Promise { + const baseMessage = services.systemMessage + ? await services.systemMessage.getSystemMessage( + services.toolPermissions.getState().currentMode, + ) + : ""; + + const agentPrompt = agent.model?.chatOptions?.baseSystemMessage || ""; + + if (agentPrompt) { + return `${baseMessage}\n\n${agentPrompt}`; + } + + return baseMessage; + } + + async executeSubAgent( + options: SubAgentExecutionOptions, + ): Promise { + if (this.isInsideSubagent()) { + return { + success: false, + response: "", + error: "Nested subagent invocation is not allowed", + }; + } + + const { agent: subAgent, prompt, abortController } = options; + const executionId = this.generateExecutionId(); + const agentName = subAgent.model?.name || "unknown"; + + const mainAgentPermissionsState = + await serviceContainer.get( + SERVICE_NAMES.TOOL_PERMISSIONS, + ); + + const execution: PendingExecution = { + executionId, + agentName, + startTime: Date.now(), + }; + + const activeExecutions = new Map(this.currentState.activeExecutions); + activeExecutions.set(executionId, execution); + this.setState({ activeExecutions }); + + this.emit("subagentStarted", { + executionId, + agentName: subAgent.model?.name, + prompt, + }); + + try { + logger.debug("Starting subagent execution", { + executionId, + agent: subAgent.model?.name, + }); + + const { model, llmApi } = subAgent; + if (!model || !llmApi) { + throw new Error("Model or LLM API not available"); + } + + const { services } = await import("./index.js"); + + // Build agent system message + const systemMessage = await this.buildAgentSystemMessage( + subAgent, + services, + ); + + // allow all tools for now + // todo: eventually we want to show the same prompt in a dialog whether asking whether that tool call is allowed or not + const subAgentPermissions: ToolPermissionServiceState = { + ...mainAgentPermissionsState, + permissions: { + policies: [{ tool: "*", permission: "allow" }], + }, + }; + + const chatHistorySvc = services.chatHistory; + const originalIsReady = + chatHistorySvc && typeof chatHistorySvc.isReady === "function" + ? chatHistorySvc.isReady + : undefined; + + if (chatHistorySvc && originalIsReady) { + chatHistorySvc.isReady = () => false; + } + + const chatHistory = [ + { + message: { role: "user", content: prompt }, + contextItems: [], + }, + ] as ChatHistoryItem[]; + + const escapeHandler = () => { + abortController.abort(); + chatHistory.push({ + message: { + role: "user", + content: "Subagent execution was cancelled by the user.", + }, + contextItems: [], + }); + }; + + escapeEvents.on("user-escape", escapeHandler); + + try { + const result = await subAgentExecutionContext.run( + { + executionId, + systemMessage, + permissions: subAgentPermissions, + }, + async () => { + await streamChatResponse( + chatHistory, + model, + llmApi, + abortController, + { + onContent: (content: string) => { + this.emit("subagentContent", { + executionId, + agentName: model?.name, + content, + type: "content", + }); + }, + onToolResult: (result: string) => { + this.emit("subagentContent", { + executionId, + agentName: model?.name, + content: result, + type: "toolResult", + }); + }, + }, + false, + ); + + const lastMessage = chatHistory.at(-1); + const response = + typeof lastMessage?.message?.content === "string" + ? lastMessage.message.content + : ""; + + return { success: true as const, response }; + }, + ); + + logger.debug("Subagent execution completed", { + executionId, + agent: model?.name, + responseLength: result.response.length, + }); + + this.emit("subagentCompleted", { + executionId, + agentName: model?.name, + success: true, + }); + + return result; + } finally { + escapeEvents.removeListener("user-escape", escapeHandler); + + if (chatHistorySvc && originalIsReady) { + chatHistorySvc.isReady = originalIsReady; + } + } + } catch (error: any) { + logger.error("Subagent execution failed", { + executionId, + agent: subAgent.model?.name, + error: error.message, + }); + + this.emit("subagentFailed", { + executionId, + agentName: subAgent.model?.name, + error: error.message, + }); + + return { + success: false, + response: "", + error: error.message, + }; + } finally { + const updatedExecutions = new Map(this.currentState.activeExecutions); + updatedExecutions.delete(executionId); + this.setState({ activeExecutions: updatedExecutions }); + } + } +} + +export const subAgentService = new SubAgentService(); + +// Subagent execution context + +interface ExecutionContext { + executionId: string; + systemMessage: string; + permissions: ToolPermissionServiceState; +} + +/* Scopes system messages and tool permissions per subagent execution for enabling parallel execution*/ +export const subAgentExecutionContext = + new AsyncLocalStorage(); + +// Types + +export interface SubAgentExecutionOptions { + agent: ModelServiceState; + prompt: string; + parentSessionId: string; + abortController: AbortController; +} + +export interface SubAgentResult { + success: boolean; + response: string; + error?: string; +} + +export interface PendingExecution { + executionId: string; + agentName: string; + startTime: number; +} + +export interface SubAgentServiceState { + activeExecutions: Map; +} diff --git a/extensions/cli/src/services/index.ts b/extensions/cli/src/services/index.ts index ffb7cba2ca..05e7eec3b1 100644 --- a/extensions/cli/src/services/index.ts +++ b/extensions/cli/src/services/index.ts @@ -1,9 +1,6 @@ import { loadAuthConfig } from "../auth/workos.js"; import { initializeWithOnboarding } from "../onboarding.js"; -import { - setBetaSubagentToolEnabled, - setBetaUploadArtifactToolEnabled, -} from "../tools/toolsConfig.js"; +import { setBetaUploadArtifactToolEnabled } from "../tools/toolsConfig.js"; import { logger } from "../util/logger.js"; import { AgentFileService } from "./AgentFileService.js"; @@ -21,6 +18,7 @@ import { quizService } from "./QuizService.js"; import { ResourceMonitoringService } from "./ResourceMonitoringService.js"; import { serviceContainer } from "./ServiceContainer.js"; import { StorageSyncService } from "./StorageSyncService.js"; +import { subAgentService } from "./SubAgentService.js"; import { SystemMessageService } from "./SystemMessageService.js"; import { InitializeToolServiceOverrides, @@ -67,9 +65,6 @@ export async function initializeServices(initOptions: ServiceInitOptions = {}) { if (commandOptions.betaUploadArtifactTool) { setBetaUploadArtifactToolEnabled(true); } - if (commandOptions.betaSubagentTool) { - setBetaSubagentToolEnabled(true); - } // Handle onboarding for TUI mode (headless: false) unless explicitly skipped if (!initOptions.headless && !initOptions.skipOnboarding) { const authConfig = loadAuthConfig(); @@ -332,6 +327,12 @@ export async function initializeServices(initOptions: ServiceInitOptions = {}) { [], // No dependencies ); + serviceContainer.register( + SERVICE_NAMES.SUBAGENT, + () => subAgentService.initialize(), + [], // No dependencies + ); + // Eagerly initialize all services to ensure they're ready when needed // This avoids race conditions and "service not ready" errors await serviceContainer.initializeAll(); @@ -397,6 +398,7 @@ export const services = { gitAiIntegration: gitAiIntegrationService, backgroundJobs: backgroundJobService, quiz: quizService, + subAgent: subAgentService, } as const; export type ServicesType = typeof services; diff --git a/extensions/cli/src/services/types.ts b/extensions/cli/src/services/types.ts index 3b3776883b..87cff6b30c 100644 --- a/extensions/cli/src/services/types.ts +++ b/extensions/cli/src/services/types.ts @@ -130,6 +130,11 @@ export interface ArtifactUploadServiceState { lastError: string | null; } +export type { + PendingExecution, + SubAgentServiceState, +} from "./SubAgentService.js"; + export type { BackgroundJob, BackgroundJobStatus, @@ -159,6 +164,7 @@ export const SERVICE_NAMES = { GIT_AI_INTEGRATION: "gitAiIntegration", BACKGROUND_JOBS: "backgroundJobs", QUIZ: "quiz", + SUBAGENT: "subagent", } as const; /** diff --git a/extensions/cli/src/stream/handleToolCalls.ts b/extensions/cli/src/stream/handleToolCalls.ts index bc5d99176e..7310175a05 100644 --- a/extensions/cli/src/stream/handleToolCalls.ts +++ b/extensions/cli/src/stream/handleToolCalls.ts @@ -9,6 +9,7 @@ import { serviceContainer, services, } from "../services/index.js"; +import { subAgentExecutionContext } from "../services/SubAgentService.js"; import type { ToolPermissionServiceState } from "../services/ToolPermissionService.js"; import { convertToolToChatCompletionTool, @@ -147,13 +148,41 @@ export async function handleToolCalls( // Execute the valid preprocessed tool calls // Note: executeStreamedToolCalls adds tool results to toolCallStates via - // services.chatHistory.addToolResult() internally - const { hasRejection } = await executeStreamedToolCalls( + // services.chatHistory.addToolResult() internally when service is available + const { hasRejection, chatHistoryEntries } = await executeStreamedToolCalls( preprocessedCalls, callbacks, isHeadless, ); + // When ChatHistoryService is disabled in subagent execution, + // manually update the local chatHistory array with tool results + if (!useService && chatHistoryEntries.length > 0) { + const lastAssistantIndex = chatHistory.findLastIndex( + (item) => item.message.role === "assistant" && item.toolCallStates, + ); + if ( + lastAssistantIndex >= 0 && + chatHistory[lastAssistantIndex].toolCallStates + ) { + for (const entry of chatHistoryEntries) { + const toolState = chatHistory[lastAssistantIndex].toolCallStates!.find( + (ts) => ts.toolCallId === entry.tool_call_id, + ); + if (toolState) { + toolState.status = entry.status; + toolState.output = [ + { + content: String(entry.content) || "", + name: "Tool Result", + description: "Tool execution result", + }, + ]; + } + } + } + } + if (isHeadless && hasRejection) { logger.debug( "Tool call rejected in headless mode - returning current content", @@ -171,10 +200,12 @@ export async function handleToolCalls( export async function getRequestTools(isHeadless: boolean) { const availableTools = await getAllAvailableTools(isHeadless); + const ctx = subAgentExecutionContext.getStore(); const permissionsState = - await serviceContainer.get( + ctx?.permissions ?? + (await serviceContainer.get( SERVICE_NAMES.TOOL_PERMISSIONS, - ); + )); const allowedTools: Tool[] = []; for (const tool of availableTools) { diff --git a/extensions/cli/src/stream/streamChatResponse.helpers.ts b/extensions/cli/src/stream/streamChatResponse.helpers.ts index 6f8f3fa18d..f0110a1e56 100644 --- a/extensions/cli/src/stream/streamChatResponse.helpers.ts +++ b/extensions/cli/src/stream/streamChatResponse.helpers.ts @@ -16,6 +16,7 @@ import { serviceContainer, services, } from "../services/index.js"; +import { subAgentExecutionContext } from "../services/SubAgentService.js"; import { trackSessionUsage } from "../session.js"; import { posthogService } from "../telemetry/posthogService.js"; import { telemetryService } from "../telemetry/telemetryService.js"; @@ -562,10 +563,12 @@ export async function executeStreamedToolCalls( callbacks?.onToolStart?.(call.name, call.arguments); // Check tool permissions using helper + const ctx = subAgentExecutionContext.getStore(); const permissionState = - await serviceContainer.get( + ctx?.permissions ?? + (await serviceContainer.get( SERVICE_NAMES.TOOL_PERMISSIONS, - ); + )); const permissionResult = await checkToolPermissionApproval( permissionState.permissions, call, diff --git a/extensions/cli/src/stream/streamChatResponse.ts b/extensions/cli/src/stream/streamChatResponse.ts index d0b677b741..1c2ec01a59 100644 --- a/extensions/cli/src/stream/streamChatResponse.ts +++ b/extensions/cli/src/stream/streamChatResponse.ts @@ -10,6 +10,7 @@ import type { import { pruneLastMessage } from "../compaction.js"; import { services } from "../services/index.js"; +import { subAgentExecutionContext } from "../services/SubAgentService.js"; import { posthogService } from "../telemetry/posthogService.js"; import { telemetryService } from "../telemetry/telemetryService.js"; import { applyChatCompletionToolOverrides } from "../tools/applyToolOverrides.js"; @@ -455,10 +456,13 @@ export async function streamChatResponse( chatHistory = refreshChatHistoryFromService(chatHistory, isCompacting); logger.debug("Starting conversation iteration"); - // Get system message once per iteration (can change based on tool permissions mode) - const systemMessage = await services.systemMessage.getSystemMessage( - services.toolPermissions.getState().currentMode, - ); + // Get system message from context if running in subagent, else use global service + const ctx = subAgentExecutionContext.getStore(); + const systemMessage = + ctx?.systemMessage ?? + (await services.systemMessage.getSystemMessage( + services.toolPermissions.getState().currentMode, + )); // Recompute tools on each iteration to handle mode changes during streaming const rawTools = await getRequestTools(isHeadless); diff --git a/extensions/cli/src/subagent/builtInSubagents.ts b/extensions/cli/src/subagent/builtInSubagents.ts new file mode 100644 index 0000000000..627c1ec105 --- /dev/null +++ b/extensions/cli/src/subagent/builtInSubagents.ts @@ -0,0 +1,88 @@ +import type { ModelConfig } from "@continuedev/config-yaml"; + +import { logger } from "src/util/logger.js"; + +export interface BuiltInSubagent { + name: string; + systemPrompt: string; + model: string; +} + +export const NAVIGATOR_SUBAGENT: BuiltInSubagent = { + name: "navigator", + model: "claude-haiku-4-5", + systemPrompt: `You are a Codebase Navigator subagent specialized in exploring, searching, and mapping large codebases. + +When to use: + + Use this subagent whenever you need to explore or find or understand a codebase or a folder. + +When navigating a codebase, you will: + +1. **Locate Relevant Code**: Use file and code search tools to find the most relevant files, modules, functions, and types. Prefer a small, high-signal set of locations over exhaustive listings. + +2. **Trace Behavior and Dependencies**: Follow call chains, imports, and data flow to understand how the relevant pieces interact, including upstream/downstream dependencies and important side effects. + +3. **Map the Codebase for Others**: Build a concise mental map: which components are core, which are helpers, where entry points live, and how configuration or environment affects behavior. + +Your output should be concise and actionable, starting with a brief summary of what you found and listing the key files/paths, functions, symbols, and important relationships or flows between them in plain language. If you cannot find something, describe what you searched for, where you looked, and suggest next places or strategies to investigate.`, +}; + +export const GENERALIST_SUBAGENT: BuiltInSubagent = { + name: "general-tasker", + model: "claude-sonnet-4-6", + systemPrompt: `You are a Generalist subagent capable of handling any development task delegated to you. + +When to use: + + Use this subagent for any task that doesn't require a specialized subagent, including but not limited to: implementing features, fixing bugs, refactoring, code review, documentation, research, debugging, and analysis. + + **Important: You should use this subagent whenever you have independent tasks..** + +When handling a task, you will: + +1. **Interpret the Request**: Understand what is being asked, whether it's exploration, implementation, review, analysis, or something else entirely. Adapt your approach based on the nature of the task. + +2. **Gather Context**: Use available tools to explore the codebase, read relevant files, and understand the surrounding architecture before taking action or forming conclusions. + +3. **Communicate Results**: Provide clear, actionable output tailored to the task. Summarize what you did or discovered, highlight key insights or changes, and note any open questions or recommended next steps. + +You are flexible and resourceful. If a task is ambiguous, make reasonable assumptions and state them. If you encounter blockers, describe what you attempted and suggest alternatives.`, +}; + +export const BUILT_IN_SUBAGENTS: BuiltInSubagent[] = [ + NAVIGATOR_SUBAGENT, + GENERALIST_SUBAGENT, +]; + +export function createBuiltInSubagentModel( + subagent: BuiltInSubagent, + baseModel: ModelConfig, +): ModelConfig { + return { + ...baseModel, + name: subagent.name, + model: subagent.model, + roles: ["subagent"], + chatOptions: { + ...baseModel.chatOptions, + baseSystemMessage: subagent.systemPrompt, + }, + }; +} + +export function isLocalAnthropicModel(model: ModelConfig | null): boolean { + if (!model) { + return false; + } + + const isAnthropic = model.provider === "anthropic"; + const hasDirectApiKey = + typeof model.apiKey === "string" && model.apiKey.length > 0; + + logger.debug("subagent_enabled_for_anthropic", { + enabled: isAnthropic && hasDirectApiKey, + }); + + return isAnthropic && hasDirectApiKey; +} diff --git a/extensions/cli/src/subagent/executor.ts b/extensions/cli/src/subagent/executor.ts deleted file mode 100644 index 1580e7f7f9..0000000000 --- a/extensions/cli/src/subagent/executor.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { ChatHistoryItem } from "core"; - -import { services } from "../services/index.js"; -import { serviceContainer } from "../services/ServiceContainer.js"; -import type { ToolPermissionServiceState } from "../services/ToolPermissionService.js"; -import { ModelServiceState, SERVICE_NAMES } from "../services/types.js"; -import { streamChatResponse } from "../stream/streamChatResponse.js"; -import { escapeEvents } from "../util/cli.js"; -import { logger } from "../util/logger.js"; - -/** - * Options for executing a subagent - */ -export interface SubAgentExecutionOptions { - agent: ModelServiceState; - prompt: string; - parentSessionId: string; - abortController: AbortController; - onOutputUpdate?: (output: string) => void; -} - -/** - * Result from executing a subagent - */ -export interface SubAgentResult { - success: boolean; - response: string; - error?: string; -} - -/** - * Build system message for the agent - */ -async function buildAgentSystemMessage( - agent: ModelServiceState, - services: any, -): Promise { - const baseMessage = services.systemMessage - ? await services.systemMessage.getSystemMessage( - services.toolPermissions.getState().currentMode, - ) - : ""; - - const agentPrompt = agent.model?.chatOptions?.baseSystemMessage || ""; - - // Combine base system message with agent-specific prompt - if (agentPrompt) { - return `${baseMessage}\n\n${agentPrompt}`; - } - - return baseMessage; -} - -/** - * Execute a subagent in a child session - */ -// eslint-disable-next-line complexity -export async function executeSubAgent( - options: SubAgentExecutionOptions, -): Promise { - const { agent: subAgent, prompt, abortController, onOutputUpdate } = options; - - const mainAgentPermissionsState = - await serviceContainer.get( - SERVICE_NAMES.TOOL_PERMISSIONS, - ); - - try { - logger.debug("Starting subagent execution", { - agent: subAgent.model?.name, - }); - - const { model, llmApi } = subAgent; - if (!model || !llmApi) { - throw new Error("Model or LLM API not available"); - } - - // allow all tools for now - // todo: eventually we want to show the same prompt in a dialog whether asking whether that tool call is allowed or not - - serviceContainer.set( - SERVICE_NAMES.TOOL_PERMISSIONS, - { - ...mainAgentPermissionsState, - permissions: { - policies: [{ tool: "*", permission: "allow" }], - }, - }, - ); - - // Build agent system message - const systemMessage = await buildAgentSystemMessage(subAgent, services); - - // Store original system message function - const originalGetSystemMessage = services.systemMessage?.getSystemMessage; - - // Store original ChatHistoryService ready state - const chatHistorySvc = services.chatHistory; - const originalIsReady = - chatHistorySvc && typeof chatHistorySvc.isReady === "function" - ? chatHistorySvc.isReady - : undefined; - - // Override system message for this execution - if (services.systemMessage) { - services.systemMessage.getSystemMessage = async () => systemMessage; - } - - // Temporarily disable ChatHistoryService to prevent it from interfering with child session - if (chatHistorySvc && originalIsReady) { - chatHistorySvc.isReady = () => false; - } - - const chatHistory = [ - { - message: { - role: "user", - content: prompt, - }, - contextItems: [], - }, - ] as ChatHistoryItem[]; - - const escapeHandler = () => { - abortController.abort(); - chatHistory.push({ - message: { - role: "user", - content: "Subagent execution was cancelled by the user.", - }, - contextItems: [], - }); - }; - - escapeEvents.on("user-escape", escapeHandler); - - try { - let accumulatedOutput = ""; - - // Execute the chat stream with child session - await streamChatResponse( - chatHistory, - model, - llmApi, - abortController, - { - onContent: (content: string) => { - accumulatedOutput += content; - if (onOutputUpdate) { - onOutputUpdate(accumulatedOutput); - } - }, - onToolResult: (result: string) => { - // todo: skip tool outputs - show tool names and params - accumulatedOutput += `\n\n${result}`; - if (onOutputUpdate) { - onOutputUpdate(accumulatedOutput); - } - }, - }, - false, // Not compacting - ); - - // The last message (mostly) contains the important output to be submitted back to the main agent - const lastMessage = chatHistory.at(-1); - const response = - typeof lastMessage?.message?.content === "string" - ? lastMessage.message.content - : ""; - - logger.debug("Subagent execution completed", { - agent: model?.name, - responseLength: response.length, - }); - - return { - success: true, - response, - }; - } finally { - if (escapeHandler) { - escapeEvents.removeListener("user-escape", escapeHandler); - } - - // Restore original system message function - if (services.systemMessage && originalGetSystemMessage) { - services.systemMessage.getSystemMessage = originalGetSystemMessage; - } - - // Restore original ChatHistoryService ready state - if (chatHistorySvc && originalIsReady) { - chatHistorySvc.isReady = originalIsReady; - } - - // Restore original main agent tool permissions - serviceContainer.set( - SERVICE_NAMES.TOOL_PERMISSIONS, - mainAgentPermissionsState, - ); - } - } catch (error: any) { - logger.error("Subagent execution failed", { - agent: subAgent.model?.name, - error: error.message, - }); - - return { - success: false, - response: "", - error: error.message, - }; - } -} diff --git a/extensions/cli/src/subagent/get-agents.ts b/extensions/cli/src/subagent/get-agents.ts deleted file mode 100644 index 9abdc2d3ad..0000000000 --- a/extensions/cli/src/subagent/get-agents.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ModelService } from "../services/ModelService.js"; -import type { ModelServiceState } from "../services/types.js"; - -/** - * Get an agent by name - */ -export function getSubagent(modelState: ModelServiceState, name: string) { - return ( - ModelService.getSubagentModels(modelState).find( - (model) => model.model.name === name, - ) ?? null - ); -} - -/** - * Generate dynamic tool description listing available agents - */ -export function generateSubagentToolDescription( - modelState: ModelServiceState, -): string { - const agentList = ModelService.getSubagentModels(modelState) - .map( - (subagentModel) => - ` - ${subagentModel.model.name}: ${subagentModel.model.chatOptions?.baseSystemMessage}`, - ) - .join("\n"); - - // todo: refine this prompt later - return `Launch a specialized subagent to handle a specific task. - -Here are the available subagents: -${agentList} -`; -} - -export function getAgentNames(modelState: ModelServiceState): string[] { - return ModelService.getSubagentModels(modelState).map( - (model) => model.model.name, - ); -} diff --git a/extensions/cli/src/subagent/getAgents.ts b/extensions/cli/src/subagent/getAgents.ts new file mode 100644 index 0000000000..563ac9820e --- /dev/null +++ b/extensions/cli/src/subagent/getAgents.ts @@ -0,0 +1,65 @@ +import { createLlmApi } from "../config.js"; +import { ModelService } from "../services/ModelService.js"; +import type { ModelServiceState } from "../services/types.js"; + +import { + BUILT_IN_SUBAGENTS, + createBuiltInSubagentModel, + isLocalAnthropicModel, +} from "./builtInSubagents.js"; + +function getAllSubagentModels(modelState: ModelServiceState) { + const configSubagents = ModelService.getSubagentModels(modelState); + + if (!isLocalAnthropicModel(modelState.model)) { + return configSubagents; + } + + const builtInSubagents = BUILT_IN_SUBAGENTS.map((subagent) => { + const subagentModel = createBuiltInSubagentModel( + subagent, + modelState.model!, + ); + return { + llmApi: createLlmApi(subagentModel, modelState.authConfig), + model: subagentModel, + assistant: modelState.assistant, + authConfig: modelState.authConfig, + }; + }); + + return [...configSubagents, ...builtInSubagents]; +} + +export function getSubagent(modelState: ModelServiceState, name: string) { + return ( + getAllSubagentModels(modelState).find( + (model) => model.model.name === name, + ) ?? null + ); +} + +export function generateSubagentToolDescription( + modelState: ModelServiceState, +): string { + const agentList = getAllSubagentModels(modelState) + .map( + (subagentModel) => + ` - ${subagentModel.model.name}: ${subagentModel.model.chatOptions?.baseSystemMessage}`, + ) + .join("\n"); + + return `Launch an autonomous specialized subagent to handle a specific task. + +You have the independence to make decisions within you scope. You should focus on the specific task given by the main agent. + +Remember: You are part of a larger system. Your specialized focus helps the main agent handle multiple concerns efficiently. + +Here are the available subagents: +${agentList} +`; +} + +export function getAgentNames(modelState: ModelServiceState): string[] { + return getAllSubagentModels(modelState).map((model) => model.model.name); +} diff --git a/extensions/cli/src/subagent/index.ts b/extensions/cli/src/subagent/index.ts index 29f89fadc8..a45a811a5f 100644 --- a/extensions/cli/src/subagent/index.ts +++ b/extensions/cli/src/subagent/index.ts @@ -26,3 +26,24 @@ export const SUBAGENT_TOOL_META: Tool = { }, run: async () => "", }; + +export const SUBAGENT_PARALLEL_TOOL_META: Tool = { + name: "SubagentParallel", + displayName: "Subagent Parallel", + description: + "Invoke multiple subagents in parallel to carry out independent tasks.", + readonly: false, + isBuiltIn: true, + parameters: { + type: "object", + required: ["tasks"], + properties: { + tasks: { + type: "array", + description: + "Array of tasks to execute in parallel. Each task specifies a subagent and its prompt.", + }, + }, + }, + run: async () => "", +}; diff --git a/extensions/cli/src/tools/allBuiltIns.ts b/extensions/cli/src/tools/allBuiltIns.ts index a7a501c711..6e7e46261e 100644 --- a/extensions/cli/src/tools/allBuiltIns.ts +++ b/extensions/cli/src/tools/allBuiltIns.ts @@ -1,4 +1,7 @@ -import { SUBAGENT_TOOL_META } from "../subagent/index.js"; +import { + SUBAGENT_PARALLEL_TOOL_META, + SUBAGENT_TOOL_META, +} from "../subagent/index.js"; import { askQuestionTool } from "./askQuestion.js"; import { editTool } from "./edit.js"; @@ -31,6 +34,7 @@ export const ALL_BUILT_IN_TOOLS = [ searchCodeTool, statusTool, SUBAGENT_TOOL_META, + SUBAGENT_PARALLEL_TOOL_META, SKILLS_TOOL_META, uploadArtifactTool, viewDiffTool, diff --git a/extensions/cli/src/tools/index.tsx b/extensions/cli/src/tools/index.tsx index fb43739f8b..063f71ed28 100644 --- a/extensions/cli/src/tools/index.tsx +++ b/extensions/cli/src/tools/index.tsx @@ -31,11 +31,8 @@ import { reportFailureTool } from "./reportFailure.js"; import { runTerminalCommandTool } from "./runTerminalCommand.js"; import { checkIfRipgrepIsInstalled, searchCodeTool } from "./searchCode.js"; import { skillsTool } from "./skills.js"; -import { subagentTool } from "./subagent.js"; -import { - isBetaSubagentToolEnabled, - isBetaUploadArtifactToolEnabled, -} from "./toolsConfig.js"; +import { subagentParallelTool, subagentTool } from "./subagent.js"; +import { isBetaUploadArtifactToolEnabled } from "./toolsConfig.js"; import { type Tool, type ToolCall, @@ -129,9 +126,8 @@ export async function getAllAvailableTools( tools.push(exitTool); } - if (isBetaSubagentToolEnabled()) { - tools.push(await subagentTool()); - } + tools.push(await subagentTool()); + tools.push(await subagentParallelTool()); tools.push(await skillsTool()); diff --git a/extensions/cli/src/tools/subagent.test.ts b/extensions/cli/src/tools/subagent.test.ts index 3af2ff4bfe..9c05983b28 100644 --- a/extensions/cli/src/tools/subagent.test.ts +++ b/extensions/cli/src/tools/subagent.test.ts @@ -1,14 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { services } from "../services/index.js"; import { serviceContainer } from "../services/ServiceContainer.js"; -import { executeSubAgent } from "../subagent/executor.js"; -import { getAgentNames, getSubagent } from "../subagent/get-agents.js"; +import { subAgentService } from "../services/SubAgentService.js"; +import { getAgentNames, getSubagent } from "../subagent/getAgents.js"; import { subagentTool } from "./subagent.js"; vi.mock("../subagent/get-agents.js"); -vi.mock("../subagent/executor.js"); +vi.mock("../services/SubAgentService.js", () => ({ + subAgentService: { + executeSubAgent: vi.fn(), + }, +})); vi.mock("../services/ServiceContainer.js", () => ({ serviceContainer: { get: vi.fn(), @@ -85,7 +88,7 @@ describe("subagentTool", () => { vi.mocked(getSubagent).mockReturnValue({ model: { name: "test-model" }, } as any); - vi.mocked(executeSubAgent).mockResolvedValue({ + vi.mocked(subAgentService.executeSubAgent).mockResolvedValue({ success: true, response: "subagent-output", } as any); @@ -100,19 +103,11 @@ describe("subagentTool", () => { { toolCallId: "tool-call-id", parallelToolCallCount: 1 }, ); - expect(vi.mocked(executeSubAgent)).toHaveBeenCalledTimes(1); - const [options] = vi.mocked(executeSubAgent).mock.calls[0]; + expect(vi.mocked(subAgentService.executeSubAgent)).toHaveBeenCalledTimes(1); + const [options] = vi.mocked(subAgentService.executeSubAgent).mock.calls[0]; expect(options.prompt).toBe("Subagent prompt"); expect(options.parentSessionId).toBe("parent-session-id"); - expect(typeof options.onOutputUpdate).toBe("function"); - - options.onOutputUpdate?.("partial-output"); - expect(vi.mocked(services.chatHistory.addToolResult)).toHaveBeenCalledWith( - "tool-call-id", - "partial-output", - "calling", - ); expect(result).toBe( "subagent-output\n\nstatus: completed\n", diff --git a/extensions/cli/src/tools/subagent.ts b/extensions/cli/src/tools/subagent.ts index 43d2d29eb4..f82a083426 100644 --- a/extensions/cli/src/tools/subagent.ts +++ b/extensions/cli/src/tools/subagent.ts @@ -1,17 +1,26 @@ import { services } from "../services/index.js"; import { serviceContainer } from "../services/ServiceContainer.js"; +import { subAgentService } from "../services/SubAgentService.js"; import { ModelServiceState, SERVICE_NAMES } from "../services/types.js"; -import { executeSubAgent } from "../subagent/executor.js"; import { generateSubagentToolDescription, getSubagent, getAgentNames as getSubagentNames, -} from "../subagent/get-agents.js"; -import { SUBAGENT_TOOL_META } from "../subagent/index.js"; +} from "../subagent/getAgents.js"; +import { + SUBAGENT_PARALLEL_TOOL_META, + SUBAGENT_TOOL_META, +} from "../subagent/index.js"; import { logger } from "../util/logger.js"; import { Tool } from "./types.js"; +interface SubagentTask { + description: string; + prompt: string; + subagent_name: string; +} + export const subagentTool = async (): Promise => { const modelServiceState = await serviceContainer.get( SERVICE_NAMES.MODEL, @@ -77,25 +86,11 @@ export const subagentTool = async (): Promise => { throw new Error("No active session found"); } - // Execute subagent with output streaming - const result = await executeSubAgent({ + const result = await subAgentService.executeSubAgent({ agent, prompt, parentSessionId, abortController: new AbortController(), - onOutputUpdate: context?.toolCallId - ? (output: string) => { - try { - chatHistoryService.addToolResult( - context.toolCallId, - output, - "calling", - ); - } catch { - // Ignore errors during streaming updates - } - } - : undefined, }); logger.debug("subagent result", { result }); @@ -113,3 +108,138 @@ export const subagentTool = async (): Promise => { }, }; }; + +export const subagentParallelTool = async (): Promise => { + const modelServiceState = await serviceContainer.get( + SERVICE_NAMES.MODEL, + ); + + const availableAgents = modelServiceState + ? getSubagentNames(modelServiceState).join(", ") + : ""; + + return { + ...SUBAGENT_PARALLEL_TOOL_META, + + description: `Invoke multiple subagents in parallel and wait for all to complete. Use this when you have multiple independent tasks that can be executed concurrently. Available agents: ${availableAgents}`, + + parameters: { + ...SUBAGENT_PARALLEL_TOOL_META.parameters, + properties: { + tasks: { + ...SUBAGENT_PARALLEL_TOOL_META.parameters.properties.tasks, + items: { + type: "object", + required: ["description", "prompt", "subagent_name"], + properties: { + ...SUBAGENT_TOOL_META.parameters.properties, + subagent_name: { + type: "string", + description: `The type of specialized agent to use. Available: ${availableAgents}`, + }, + }, + }, + }, + }, + }, + + preprocess: async (args: any) => { + const { tasks } = args as { tasks: SubagentTask[] }; + + if (!Array.isArray(tasks) || tasks.length === 0) { + throw new Error("tasks must be a non-empty array"); + } + + const previews: { type: string; content: string }[] = []; + + for (const task of tasks) { + const agent = getSubagent(modelServiceState, task.subagent_name); + if (!agent) { + throw new Error( + `Unknown agent type: ${task.subagent_name}. Available agents: ${availableAgents}`, + ); + } + previews.push({ + type: "text", + content: `Spawning ${agent.model.name} to: ${task.description}`, + }); + } + + return { + args, + preview: [ + { + type: "text", + content: `Spawning ${tasks.length} subagents in parallel:\n${previews.map((p) => ` - ${p.content}`).join("\n")}`, + }, + ], + }; + }, + + run: async (args: any, context?: { toolCallId: string }) => { + const { tasks } = args as { tasks: SubagentTask[] }; + + logger.debug("subagent_parallel args", { args, context }); + + const chatHistoryService = services.chatHistory; + const parentSessionId = chatHistoryService.getSessionId(); + if (!parentSessionId) { + throw new Error("No active session found"); + } + + const executeTask = async (task: SubagentTask, index: number) => { + const agent = getSubagent(modelServiceState, task.subagent_name); + if (!agent) { + return { + index, + description: task.description, + success: false, + response: `Unknown agent type: ${task.subagent_name}`, + }; + } + + try { + const result = await subAgentService.executeSubAgent({ + agent, + prompt: task.prompt, + parentSessionId, + abortController: new AbortController(), + }); + + return { + index, + description: task.description, + success: result.success, + response: result.response, + }; + } catch (error) { + return { + index, + description: task.description, + success: false, + response: `Error: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }; + + const results = await Promise.all( + tasks.map((task, index) => executeTask(task, index)), + ); + + logger.debug("subagent_parallel results", { results }); + + const outputParts = results.map((result) => { + return [ + ``, + result.response, + ``, + `status: ${result.success ? "completed" : "failed"}`, + ``, + ``, + ].join("\n"); + }); + + return outputParts.join("\n\n"); + }, + }; +}; diff --git a/extensions/cli/src/tools/toolsConfig.ts b/extensions/cli/src/tools/toolsConfig.ts index e9a2e02330..b1703a545b 100644 --- a/extensions/cli/src/tools/toolsConfig.ts +++ b/extensions/cli/src/tools/toolsConfig.ts @@ -4,7 +4,6 @@ */ let betaUploadArtifactToolEnabled = false; -let betaSubagentToolEnabled = false; export function setBetaUploadArtifactToolEnabled(enabled: boolean): void { betaUploadArtifactToolEnabled = enabled; @@ -13,11 +12,3 @@ export function setBetaUploadArtifactToolEnabled(enabled: boolean): void { export function isBetaUploadArtifactToolEnabled(): boolean { return betaUploadArtifactToolEnabled; } - -export function setBetaSubagentToolEnabled(enabled: boolean): void { - betaSubagentToolEnabled = enabled; -} - -export function isBetaSubagentToolEnabled(): boolean { - return betaSubagentToolEnabled; -} diff --git a/extensions/cli/src/ui/TUIChat.tsx b/extensions/cli/src/ui/TUIChat.tsx index 62174ceada..7dec77ccd9 100644 --- a/extensions/cli/src/ui/TUIChat.tsx +++ b/extensions/cli/src/ui/TUIChat.tsx @@ -28,6 +28,7 @@ import { BottomStatusBar } from "./components/BottomStatusBar.js"; import { ResourceDebugBar } from "./components/ResourceDebugBar.js"; import { ScreenContent } from "./components/ScreenContent.js"; import { StaticChatContent } from "./components/StaticChatContent.js"; +import { SubAgentOutput } from "./components/SubAgentOutput.js"; import { useNavigation } from "./context/NavigationContext.js"; import { useChat } from "./hooks/useChat.js"; import { useContextPercentage } from "./hooks/useContextPercentage.js"; @@ -416,6 +417,9 @@ const TUIChat: React.FC = ({ loadingColor="grey" /> + {/* Subagent Output */} + + {/* Temporary status message */} {statusMessage && ( diff --git a/extensions/cli/src/ui/ToolResultSummary.tsx b/extensions/cli/src/ui/ToolResultSummary.tsx index 97e1794279..9e2de08243 100644 --- a/extensions/cli/src/ui/ToolResultSummary.tsx +++ b/extensions/cli/src/ui/ToolResultSummary.tsx @@ -115,57 +115,6 @@ const ToolResultSummary: React.FC = ({ } } - // show streaming output for subagent tool output - if (toolName === "Subagent") { - const metadataIndex = content.indexOf(""); - const actualOutput = - metadataIndex >= 0 ? content.slice(0, metadataIndex).trim() : content; - - if (!actualOutput) { - return ( - - - Subagent executing... - - ); - } - - const outputLines = actualOutput.split("\n"); - const MAX_TASK_OUTPUT_LINES = 20; - - if (outputLines.length <= MAX_TASK_OUTPUT_LINES) { - return ( - - - - Subagent output: - - - {actualOutput.trimEnd()} - - - ); - } else { - const lastLines = outputLines.slice(-MAX_TASK_OUTPUT_LINES).join("\n"); - return ( - - - - Subagent output: - - - - ... +{outputLines.length - MAX_TASK_OUTPUT_LINES} lines - - - - {lastLines.trimEnd()} - - - ); - } - } - // Handle all other cases with text summary const getSummary = () => { // Check if this is a user cancellation first diff --git a/extensions/cli/src/ui/components/SubAgentOutput.tsx b/extensions/cli/src/ui/components/SubAgentOutput.tsx new file mode 100644 index 0000000000..76f7c1020e --- /dev/null +++ b/extensions/cli/src/ui/components/SubAgentOutput.tsx @@ -0,0 +1,132 @@ +import { Box, Text } from "ink"; +import React, { useEffect, useState } from "react"; + +import { subAgentService } from "../../services/SubAgentService.js"; +import { LoadingAnimation } from "../LoadingAnimation.js"; +import { MarkdownRenderer } from "../MarkdownRenderer.js"; + +interface SubAgentState { + agentName: string; + contentLines: string[]; +} + +export const SubAgentOutput: React.FC = () => { + const [agents, setAgents] = useState>({}); + + useEffect(() => { + const onStarted = (data: { executionId: string; agentName: string }) => { + setAgents((prev) => ({ + ...prev, + [data.executionId]: { + agentName: data.agentName, + contentLines: [], + }, + })); + }; + + const onContent = (data: { + executionId: string; + agentName: string; + content: string; + type: "content" | "toolResult"; + }) => { + setAgents((prev) => { + const agent = prev[data.executionId]; + if (!agent) return prev; + + const newLines = data.content.split("\n"); + let updatedLines: string[]; + + if (data.type === "toolResult") { + updatedLines = [...agent.contentLines, "", ...newLines]; + } else if (agent.contentLines.length === 0) { + updatedLines = newLines; + } else { + const lastLine = agent.contentLines[agent.contentLines.length - 1]; + updatedLines = [ + ...agent.contentLines.slice(0, -1), + lastLine + newLines[0], + ...newLines.slice(1), + ]; + } + + return { + ...prev, + [data.executionId]: { + ...agent, + contentLines: updatedLines, + }, + }; + }); + }; + + const onCompleted = (data: { executionId: string }) => { + setAgents((prev) => { + const { [data.executionId]: _, ...rest } = prev; + return rest; + }); + }; + + const onFailed = (data: { executionId: string }) => { + setAgents((prev) => { + const { [data.executionId]: _, ...rest } = prev; + return rest; + }); + }; + + subAgentService.on("subagentStarted", onStarted); + subAgentService.on("subagentContent", onContent); + subAgentService.on("subagentCompleted", onCompleted); + subAgentService.on("subagentFailed", onFailed); + + return () => { + subAgentService.off("subagentStarted", onStarted); + subAgentService.off("subagentContent", onContent); + subAgentService.off("subagentCompleted", onCompleted); + subAgentService.off("subagentFailed", onFailed); + }; + }, []); + + const agentEntries = Object.entries(agents); + + if (agentEntries.length === 0) { + return null; + } + + const MAX_OUTPUT_LINES = 15; + + return ( + + {agentEntries.map(([executionId, agent]) => { + const displayContent = + agent.contentLines.length > MAX_OUTPUT_LINES + ? agent.contentLines.slice(-MAX_OUTPUT_LINES).join("\n") + : agent.contentLines.join("\n"); + const hiddenLines = + agent.contentLines.length > MAX_OUTPUT_LINES + ? agent.contentLines.length - MAX_OUTPUT_LINES + : 0; + + return ( + + 0 ? 1 : 0}> + + + {" "} + Subagent: {agent.agentName || "unknown"} + + + {agent.contentLines.length > 0 && ( + + {hiddenLines > 0 && ( + ... +{hiddenLines} lines + )} + + + )} + + ); + })} + + ); +};