diff --git a/core/config/load.ts b/core/config/load.ts index 35d12d421fc..4a88fbcce30 100644 --- a/core/config/load.ts +++ b/core/config/load.ts @@ -514,6 +514,7 @@ async function intermediateToFinalConfig({ autocomplete: [...tabAutocompleteModels], embed: newEmbedder ? [newEmbedder] : [], rerank: newReranker ? [newReranker] : [], + subagent: [], }, selectedModelByRole: { chat: null, // Not implemented (uses GUI defaultModel) @@ -523,6 +524,7 @@ async function intermediateToFinalConfig({ autocomplete: null, rerank: newReranker ?? null, summarize: null, // Not implemented + subagent: null, }, rules: [], }; diff --git a/core/config/usesFreeTrialApiKey.vitest.ts b/core/config/usesFreeTrialApiKey.vitest.ts index 6d8318830d5..79dfca35a2c 100644 --- a/core/config/usesFreeTrialApiKey.vitest.ts +++ b/core/config/usesFreeTrialApiKey.vitest.ts @@ -58,6 +58,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: [], rerank: [], embed: [], + subagent: [], }, selectedModelByRole: { chat: null, @@ -67,6 +68,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: null, rerank: null, embed: null, + subagent: null, }, contextProviders: [], slashCommands: [], @@ -106,6 +108,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: [], rerank: [], embed: [], + subagent: [], }, selectedModelByRole: { chat: null, @@ -115,6 +118,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: null, rerank: null, embed: null, + subagent: null, }, contextProviders: [], slashCommands: [], @@ -166,6 +170,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: [], rerank: [], embed: [], + subagent: [], }, selectedModelByRole: { chat: null, @@ -175,6 +180,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: null, rerank: null, embed: null, + subagent: null, }, contextProviders: [], slashCommands: [], @@ -227,6 +233,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: [], rerank: [], embed: [], + subagent: [], }, selectedModelByRole: { chat: null, @@ -236,6 +243,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: null, rerank: null, embed: null, + subagent: null, }, contextProviders: [], slashCommands: [], @@ -281,6 +289,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: [], rerank: [], embed: [], + subagent: [], }, selectedModelByRole: { chat: null, @@ -290,6 +299,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: null, rerank: null, embed: null, + subagent: null, }, contextProviders: [], slashCommands: [], @@ -323,6 +333,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: [], rerank: [], embed: [], + subagent: [], }, selectedModelByRole: { chat: null, @@ -332,6 +343,7 @@ describe("usesFreeTrialApiKey", () => { autocomplete: null, rerank: null, embed: null, + subagent: null, }, contextProviders: [], slashCommands: [], diff --git a/core/config/yaml/loadYaml.ts b/core/config/yaml/loadYaml.ts index 40821bb04ca..3c0f2d2201d 100644 --- a/core/config/yaml/loadYaml.ts +++ b/core/config/yaml/loadYaml.ts @@ -187,6 +187,7 @@ export async function configYamlToContinueConfig(options: { autocomplete: [], rerank: [], summarize: [], + subagent: [], }, selectedModelByRole: { chat: null, @@ -196,6 +197,7 @@ export async function configYamlToContinueConfig(options: { autocomplete: null, rerank: null, summarize: null, + subagent: null, }, rules: [], requestOptions: { ...config.requestOptions }, @@ -333,6 +335,10 @@ export async function configYamlToContinueConfig(options: { if (model.roles?.includes("rerank")) { continueConfig.modelsByRole.rerank.push(...llms); } + + if (model.roles?.includes("subagent")) { + continueConfig.modelsByRole.subagent.push(...llms); + } } catch (e) { localErrors.push({ fatal: false, diff --git a/core/config/yaml/models.vitest.ts b/core/config/yaml/models.vitest.ts index aa2bc46919d..0e99448d51d 100644 --- a/core/config/yaml/models.vitest.ts +++ b/core/config/yaml/models.vitest.ts @@ -67,6 +67,7 @@ describe("llmsFromModelConfig requestOptions merging", () => { embed: null, rerank: null, summarize: null, + subagent: null, }, modelsByRole: { apply: [], @@ -76,6 +77,7 @@ describe("llmsFromModelConfig requestOptions merging", () => { embed: [], rerank: [], summarize: [], + subagent: [], }, slashCommands: [], tools: [], diff --git a/extensions/cli/src/commands/BaseCommandOptions.ts b/extensions/cli/src/commands/BaseCommandOptions.ts index 40db1aa7a7c..d9987403e39 100644 --- a/extensions/cli/src/commands/BaseCommandOptions.ts +++ b/extensions/cli/src/commands/BaseCommandOptions.ts @@ -25,6 +25,8 @@ export interface BaseCommandOptions { agent?: string; /** Enable beta UploadArtifact tool */ betaUploadArtifactTool?: boolean; + /** Enable beta Subagent tool */ + betaSubagentTool?: boolean; } /** diff --git a/extensions/cli/src/index.ts b/extensions/cli/src/index.ts index 68a6b58cd00..37dfd2cba24 100644 --- a/extensions/cli/src/index.ts +++ b/extensions/cli/src/index.ts @@ -198,6 +198,10 @@ 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/ModelService.ts b/extensions/cli/src/services/ModelService.ts index 2e43062e409..9e542205707 100644 --- a/extensions/cli/src/services/ModelService.ts +++ b/extensions/cli/src/services/ModelService.ts @@ -305,4 +305,25 @@ export class ModelService return nameMatches; }); } + + static getSubagentModels(modelState: ModelServiceState) { + if (!modelState.assistant) { + return []; + } + const subagentModels = modelState.assistant.models + ?.filter((model) => !!model) + .filter((model) => !!model.name) // filter out models without a name + .filter((model) => model.roles?.includes("subagent")) // filter with role subagent + .filter((model) => !!model.chatOptions?.baseSystemMessage); // filter those with a system message + + if (!subagentModels) { + return []; + } + return subagentModels?.map((model) => ({ + llmApi: createLlmApi(model, modelState.authConfig), + model, + assistant: modelState.assistant, + authConfig: modelState.authConfig, + })); + } } diff --git a/extensions/cli/src/services/index.ts b/extensions/cli/src/services/index.ts index f53a53c8471..56a5d4e938a 100644 --- a/extensions/cli/src/services/index.ts +++ b/extensions/cli/src/services/index.ts @@ -1,6 +1,9 @@ import { loadAuthConfig } from "../auth/workos.js"; import { initializeWithOnboarding } from "../onboarding.js"; -import { setBetaUploadArtifactToolEnabled } from "../tools/toolsConfig.js"; +import { + setBetaSubagentToolEnabled, + setBetaUploadArtifactToolEnabled, +} from "../tools/toolsConfig.js"; import { logger } from "../util/logger.js"; import { AgentFileService } from "./AgentFileService.js"; @@ -62,6 +65,9 @@ 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(); @@ -383,6 +389,8 @@ export const services = { gitAiIntegration: gitAiIntegrationService, } as const; +export type ServicesType = typeof services; + // Export the service container for advanced usage export { serviceContainer }; diff --git a/extensions/cli/src/stream/streamChatResponse.ts b/extensions/cli/src/stream/streamChatResponse.ts index b63541db14a..7a67054ea1c 100644 --- a/extensions/cli/src/stream/streamChatResponse.ts +++ b/extensions/cli/src/stream/streamChatResponse.ts @@ -457,8 +457,6 @@ export async function streamChatResponse( chatHistory = refreshChatHistoryFromService(chatHistory, isCompacting); logger.debug("Starting conversation iteration"); - logger.debug("debug1 streamChatResponse history", { chatHistory }); - // Get system message once per iteration (can change based on tool permissions mode) const systemMessage = await services.systemMessage.getSystemMessage( services.toolPermissions.getState().currentMode, diff --git a/extensions/cli/src/subagent/executor.ts b/extensions/cli/src/subagent/executor.ts new file mode 100644 index 00000000000..1580e7f7f9d --- /dev/null +++ b/extensions/cli/src/subagent/executor.ts @@ -0,0 +1,213 @@ +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 new file mode 100644 index 00000000000..9abdc2d3adc --- /dev/null +++ b/extensions/cli/src/subagent/get-agents.ts @@ -0,0 +1,40 @@ +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/index.ts b/extensions/cli/src/subagent/index.ts new file mode 100644 index 00000000000..29f89fadc83 --- /dev/null +++ b/extensions/cli/src/subagent/index.ts @@ -0,0 +1,28 @@ +import { Tool } from "../tools/types.js"; + +export const SUBAGENT_TOOL_META: Tool = { + name: "Subagent", + displayName: "Subagent", + description: "Use a subagent to handle a specialized task.", + readonly: false, + isBuiltIn: true, + parameters: { + type: "object", + required: ["description", "prompt", "subagent_name"], + properties: { + description: { + type: "string", + description: "A short description of the task", + }, + prompt: { + type: "string", + description: "The task for the agent to perform", + }, + subagent_name: { + type: "string", + description: "The type of specialized agent to use for this task.", + }, + }, + }, + run: async () => "", +}; diff --git a/extensions/cli/src/tools/allBuiltIns.ts b/extensions/cli/src/tools/allBuiltIns.ts index 18a7e8eeecd..4e2f4a6d92c 100644 --- a/extensions/cli/src/tools/allBuiltIns.ts +++ b/extensions/cli/src/tools/allBuiltIns.ts @@ -1,3 +1,5 @@ +import { SUBAGENT_TOOL_META } from "../subagent/index.js"; + import { editTool } from "./edit.js"; import { exitTool } from "./exit.js"; import { fetchTool } from "./fetch.js"; @@ -7,12 +9,13 @@ import { readFileTool } from "./readFile.js"; import { reportFailureTool } from "./reportFailure.js"; import { runTerminalCommandTool } from "./runTerminalCommand.js"; import { searchCodeTool } from "./searchCode.js"; +import type { Tool } from "./types.js"; import { uploadArtifactTool } from "./uploadArtifact.js"; import { writeChecklistTool } from "./writeChecklist.js"; import { writeFileTool } from "./writeFile.js"; // putting in here for circular import issue -export const ALL_BUILT_IN_TOOLS = [ +export const ALL_BUILT_IN_TOOLS: Tool[] = [ readFileTool, editTool, multiEditTool, @@ -22,6 +25,7 @@ export const ALL_BUILT_IN_TOOLS = [ runTerminalCommandTool, fetchTool, writeChecklistTool, + SUBAGENT_TOOL_META, exitTool, reportFailureTool, uploadArtifactTool, diff --git a/extensions/cli/src/tools/gitAiIntegration.test.ts b/extensions/cli/src/tools/gitAiIntegration.test.ts index cc5c5f52a9e..d9d2c85e213 100644 --- a/extensions/cli/src/tools/gitAiIntegration.test.ts +++ b/extensions/cli/src/tools/gitAiIntegration.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PreprocessedToolCall } from "./types.js"; @@ -69,6 +69,7 @@ describe("Git AI Integration - executeToolCall", () => { oldContent: "old", newContent: "new", }, + context: { toolCallId: "test-edit-id" }, }, }; @@ -94,11 +95,16 @@ describe("Git AI Integration - executeToolCall", () => { ); // Verify tool.run was called - expect(mockTool.run).toHaveBeenCalledWith({ - resolvedPath: "/test/file.ts", - oldContent: "old", - newContent: "new", - }); + expect(mockTool.run).toHaveBeenCalledWith( + { + resolvedPath: "/test/file.ts", + oldContent: "old", + newContent: "new", + }, + { + toolCallId: "test-edit-id", + }, + ); }); it("should call git-ai before and after MultiEdit tool execution", async () => { diff --git a/extensions/cli/src/tools/index.tsx b/extensions/cli/src/tools/index.tsx index efc7a1e5f79..8ec0aca3574 100644 --- a/extensions/cli/src/tools/index.tsx +++ b/extensions/cli/src/tools/index.tsx @@ -28,7 +28,11 @@ import { readFileTool } from "./readFile.js"; import { reportFailureTool } from "./reportFailure.js"; import { runTerminalCommandTool } from "./runTerminalCommand.js"; import { checkIfRipgrepIsInstalled, searchCodeTool } from "./searchCode.js"; -import { isBetaUploadArtifactToolEnabled } from "./toolsConfig.js"; +import { subagentTool } from "./subagent.js"; +import { + isBetaSubagentToolEnabled, + isBetaUploadArtifactToolEnabled, +} from "./toolsConfig.js"; import { type Tool, type ToolCall, @@ -119,6 +123,10 @@ export async function getAllAvailableTools( tools.push(exitTool); } + if (isBetaSubagentToolEnabled()) { + tools.push(await subagentTool()); + } + const mcpState = await serviceContainer.get( SERVICE_NAMES.MCP, ); @@ -214,6 +222,7 @@ export async function executeToolCall( // Preprocessed arg names may be different const result = await toolCall.tool.run( toolCall.preprocessResult?.args ?? toolCall.arguments, + { toolCallId: toolCall.id }, ); const duration = Date.now() - startTime; diff --git a/extensions/cli/src/tools/subagent.test.ts b/extensions/cli/src/tools/subagent.test.ts new file mode 100644 index 00000000000..e4f2d61eaa8 --- /dev/null +++ b/extensions/cli/src/tools/subagent.test.ts @@ -0,0 +1,121 @@ +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 { subagentTool } from "./subagent.js"; + +vi.mock("../subagent/get-agents.js"); +vi.mock("../subagent/executor.js"); +vi.mock("../services/ServiceContainer.js", () => ({ + serviceContainer: { + get: vi.fn(), + }, +})); +vi.mock("../services/index.js", () => ({ + services: { + chatHistory: { + getSessionId: vi.fn().mockReturnValue("parent-session-id"), + addToolResult: vi.fn(), + }, + }, +})); + +describe("subagentTool", () => { + const modelServiceState = {} as any; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(serviceContainer.get).mockResolvedValue(modelServiceState); + }); + + it("preprocess throws when agent is not found", async () => { + vi.mocked(getAgentNames).mockReturnValue(["code-agent"]); + + const tool = await subagentTool(); + + const args = { + description: "Test task", + prompt: "Do something", + subagent_name: "unknown-agent", + }; + + await expect(tool.preprocess!(args)).rejects.toThrow( + "Unknown agent type: unknown-agent", + ); + expect(vi.mocked(getSubagent)).toHaveBeenCalledWith( + modelServiceState, + "unknown-agent", + ); + }); + + it("preprocess includes agent model name when agent exists", async () => { + vi.mocked(getAgentNames).mockReturnValue(["code-agent"]); + + const tool = await subagentTool(); + + vi.mocked(getSubagent).mockReturnValue({ + model: { name: "test-model" }, + } as any); + + const args = { + description: "Handle specialized task", + prompt: "Do it", + subagent_name: "code-agent", + }; + + const result = await tool.preprocess!(args); + + expect(result.preview).toEqual([ + { + type: "text", + content: "Spawning test-model to: Handle specialized task", + }, + ]); + expect(vi.mocked(getSubagent)).toHaveBeenCalledWith( + modelServiceState, + "code-agent", + ); + }); + + it("run executes subagent and returns formatted output", async () => { + vi.mocked(getAgentNames).mockReturnValue(["code-agent"]); + vi.mocked(getSubagent).mockReturnValue({ + model: { name: "test-model" }, + } as any); + vi.mocked(executeSubAgent).mockResolvedValue({ + success: true, + response: "subagent-output", + } as any); + + const tool = await subagentTool(); + + const result = await tool.run( + { + prompt: "Subagent prompt", + subagent_name: "code-agent", + }, + { toolCallId: "tool-call-id" }, + ); + + expect(vi.mocked(executeSubAgent)).toHaveBeenCalledTimes(1); + const [options] = vi.mocked(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 new file mode 100644 index 00000000000..43d2d29eb4f --- /dev/null +++ b/extensions/cli/src/tools/subagent.ts @@ -0,0 +1,115 @@ +import { services } from "../services/index.js"; +import { serviceContainer } from "../services/ServiceContainer.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"; +import { logger } from "../util/logger.js"; + +import { Tool } from "./types.js"; + +export const subagentTool = async (): Promise => { + const modelServiceState = await serviceContainer.get( + SERVICE_NAMES.MODEL, + ); + + return { + ...SUBAGENT_TOOL_META, + + description: generateSubagentToolDescription(modelServiceState), + + parameters: { + ...SUBAGENT_TOOL_META.parameters, + properties: { + ...SUBAGENT_TOOL_META.parameters.properties, + subagent_name: { + type: "string", + description: `The type of specialized agent to use for this task. Available agents: ${ + modelServiceState + ? getSubagentNames(modelServiceState).join(", ") + : "" + }`, + }, + }, + }, + + preprocess: async (args: any) => { + const { description, subagent_name } = args; + + const agent = getSubagent(modelServiceState, subagent_name); + if (!agent) { + throw new Error( + `Unknown agent type: ${subagent_name}. Available agents: ${getSubagentNames( + modelServiceState, + ).join(", ")}`, + ); + } + + return { + args, + preview: [ + { + type: "text", + content: `Spawning ${agent.model.name} to: ${description}`, + }, + ], + }; + }, + + run: async (args: any, context?: { toolCallId: string }) => { + const { prompt, subagent_name } = args; + + logger.debug("subagent args", { args, context }); + + // get agent configuration + const agent = getSubagent(modelServiceState, subagent_name); + if (!agent) { + throw new Error(`Unknown agent type: ${subagent_name}`); + } + + const chatHistoryService = services.chatHistory; + const parentSessionId = chatHistoryService.getSessionId(); + if (!parentSessionId) { + throw new Error("No active session found"); + } + + // Execute subagent with output streaming + const result = await 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 }); + + const output = [ + result.response, + "", + `status: ${result.success ? "completed" : "failed"}`, + "", + ] + .filter(Boolean) + .join("\n"); + + return output; + }, + }; +}; diff --git a/extensions/cli/src/tools/toolsConfig.ts b/extensions/cli/src/tools/toolsConfig.ts index b1703a545bc..e9a2e02330b 100644 --- a/extensions/cli/src/tools/toolsConfig.ts +++ b/extensions/cli/src/tools/toolsConfig.ts @@ -4,6 +4,7 @@ */ let betaUploadArtifactToolEnabled = false; +let betaSubagentToolEnabled = false; export function setBetaUploadArtifactToolEnabled(enabled: boolean): void { betaUploadArtifactToolEnabled = enabled; @@ -12,3 +13,11 @@ 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/tools/types.ts b/extensions/cli/src/tools/types.ts index 5adb45a92f8..544c6d9706a 100644 --- a/extensions/cli/src/tools/types.ts +++ b/extensions/cli/src/tools/types.ts @@ -29,6 +29,7 @@ export interface ToolCallPreview { export interface PreprocessToolCallResult { preview?: ToolCallPreview[]; args: Record; + context?: { toolCallId: string }; } export interface Tool { @@ -37,7 +38,7 @@ export interface Tool { description: string; parameters: ToolParametersSchema; preprocess?: (args: any) => Promise; - run: (args: any) => Promise; + run: (args: any, context?: { toolCallId: string }) => Promise; readonly?: boolean; // Indicates if the tool is readonly isBuiltIn: boolean; evaluateToolCallPolicy?: ( diff --git a/extensions/cli/src/ui/ToolResultSummary.tsx b/extensions/cli/src/ui/ToolResultSummary.tsx index 9e2de082435..97e1794279a 100644 --- a/extensions/cli/src/ui/ToolResultSummary.tsx +++ b/extensions/cli/src/ui/ToolResultSummary.tsx @@ -115,6 +115,57 @@ 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/UserInput.tsx b/extensions/cli/src/ui/UserInput.tsx index 47068812036..205d48b2ebd 100644 --- a/extensions/cli/src/ui/UserInput.tsx +++ b/extensions/cli/src/ui/UserInput.tsx @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ + import { type AssistantConfig } from "@continuedev/sdk"; import { Box, Text, useApp, useInput } from "ink"; import React, { useCallback, useRef, useState } from "react"; @@ -13,6 +14,7 @@ import { services, } from "../services/index.js"; import { messageQueue } from "../stream/messageQueue.js"; +import { escapeEvents } from "../util/cli.js"; import { InputHistory } from "../util/inputHistory.js"; import { FileSearchUI } from "./FileSearchUI.js"; @@ -616,6 +618,8 @@ const UserInput: React.FC = ({ const handleEscapeKey = (key: any): boolean => { if (!key.escape) return false; + escapeEvents.emit("user-escape"); + // If only "!" is present, clear shell mode if (inputMode && showBashMode && inputText.trim() === "!") { textBuffer.clear(); diff --git a/extensions/cli/src/util/cli.ts b/extensions/cli/src/util/cli.ts index 5132fd4c108..b8e359a1d7f 100644 --- a/extensions/cli/src/util/cli.ts +++ b/extensions/cli/src/util/cli.ts @@ -2,6 +2,8 @@ * Utility functions for CLI operations */ +import EventEmitter from "events"; + /** * Check if running in headless mode (-p/--print flags) */ @@ -90,3 +92,5 @@ export function hasSuppliedPrompt(): boolean { return false; } + +export const escapeEvents = new EventEmitter(); diff --git a/gui/src/redux/slices/configSlice.ts b/gui/src/redux/slices/configSlice.ts index 67835465530..202abbfbbc1 100644 --- a/gui/src/redux/slices/configSlice.ts +++ b/gui/src/redux/slices/configSlice.ts @@ -23,6 +23,7 @@ export const EMPTY_CONFIG: BrowserSerializedContinueConfig = { autocomplete: [], rerank: [], embed: [], + subagent: [], }, selectedModelByRole: { chat: null, @@ -32,6 +33,7 @@ export const EMPTY_CONFIG: BrowserSerializedContinueConfig = { autocomplete: null, rerank: null, embed: null, + subagent: null, }, rules: [], }; diff --git a/packages/config-yaml/src/schemas/models.ts b/packages/config-yaml/src/schemas/models.ts index 9f5a9e96e94..cefbd93fa95 100644 --- a/packages/config-yaml/src/schemas/models.ts +++ b/packages/config-yaml/src/schemas/models.ts @@ -28,6 +28,7 @@ export const modelRolesSchema = z.enum([ "edit", "apply", "summarize", + "subagent", ]); export type ModelRole = z.infer;