From 40cab7ca4905454ff33de6ad5c7e4fcd4a7c3dad Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Mon, 11 May 2026 11:22:59 -0400 Subject: [PATCH] subagents (written by an agent, for an agent) so meta. --- .../data/prompts/subagent/explore/system.md | 2 - .../data/prompts/subagent/general/system.md | 2 - gadget-drone/src/services/agent.ts | 291 +++++++++++++++++- gadget-drone/src/tools/chat/index.ts | 4 + gadget-drone/src/tools/chat/subagent.ts | 108 +++++++ gadget-drone/src/tools/index.ts | 1 + 6 files changed, 395 insertions(+), 13 deletions(-) rename {gadget-code => gadget-drone}/data/prompts/subagent/explore/system.md (99%) rename {gadget-code => gadget-drone}/data/prompts/subagent/general/system.md (99%) create mode 100644 gadget-drone/src/tools/chat/index.ts create mode 100644 gadget-drone/src/tools/chat/subagent.ts diff --git a/gadget-code/data/prompts/subagent/explore/system.md b/gadget-drone/data/prompts/subagent/explore/system.md similarity index 99% rename from gadget-code/data/prompts/subagent/explore/system.md rename to gadget-drone/data/prompts/subagent/explore/system.md index 4b824fc..4cda29a 100644 --- a/gadget-code/data/prompts/subagent/explore/system.md +++ b/gadget-drone/data/prompts/subagent/explore/system.md @@ -8,8 +8,6 @@ You are spawned as a child process by the Master Control Console (MCC) in Gadget You will generally be tasked with exploring a software repository to learn about the software architecture, how things are imlemented, the structure of a class and it's methods, the order of operations as they are performed by the software, or even just read and summarize documentation looking for a specific piece of knowledge for the MCC. -{{tool_aggression}} - This is your job. ### CHAT SESSION diff --git a/gadget-code/data/prompts/subagent/general/system.md b/gadget-drone/data/prompts/subagent/general/system.md similarity index 99% rename from gadget-code/data/prompts/subagent/general/system.md rename to gadget-drone/data/prompts/subagent/general/system.md index 45fc008..b745f93 100644 --- a/gadget-code/data/prompts/subagent/general/system.md +++ b/gadget-drone/data/prompts/subagent/general/system.md @@ -8,8 +8,6 @@ You are spawned as a child process by the Master Control Console (MCC) in Gadget You will generally be tasked with exploring a software repository to learn about the software architecture, how things are imlemented, the structure of a class and it's methods, or even just read and summarize documentation looking for a specific piece of knowledge for the MCC. -{{tool_aggression}} - This is your job. ### CHAT SESSION diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index 0dd2473..5a42217 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -2,23 +2,30 @@ // Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 +import path from "node:path"; +import fs from "node:fs/promises"; + import env from "../config/env.ts"; import { Socket } from "socket.io-client"; import { - IAiChatOptions, - IAiChatResponse, - IAiResponseStreamFn, + type IAiChatOptions, + type IAiChatResponse, + type IAiResponseStreamFn, + type IAiTool, type IContextChatMessage, } from "@gadget/ai"; import { - IChatSession, - IChatTurn, - IUser, - ServerToClientEvents, - ClientToServerEvents, + type IAiProvider, + type IChatSession, + type IChatTurn, + type IChatSubagentProcess, + type IChatToolCall, + type IUser, + type ServerToClientEvents, + type ClientToServerEvents, ChatSessionMode, - IProject, + type IProject, } from "@gadget/api"; import AiService from "./ai.ts"; @@ -40,6 +47,7 @@ import { PlanFileWriteTool, PlanListTool, ShellExecTool, + SubagentTool, type DroneToolboxEnvironment, } from "../tools/index.ts"; @@ -65,6 +73,8 @@ const toolboxEnv: DroneToolboxEnvironment = { class AgentService extends GadgetService { private toolbox = new AiToolbox(toolboxEnv); + private currentWorkOrder: IAgentWorkOrder | null = null; + private currentSocket: DroneSocket | null = null; get name(): string { return "AgentService"; @@ -109,10 +119,17 @@ class AgentService extends GadgetService { this.toolbox.register(new PlanFileEditTool(this.toolbox), [ChatSessionMode.Plan]); this.toolbox.register(new PlanListTool(this.toolbox), [ChatSessionMode.Plan]); + // Chat tools — subagent spawning: available in all modes + const subagentTool = new SubagentTool(this.toolbox); + subagentTool.setSpawner((agentType, prompt) => this.spawnSubagent(agentType, prompt)); + this.toolbox.register(subagentTool, readOnlyModes); + this.log.info("started"); } async stop(): Promise { + this.currentWorkOrder = null; + this.currentSocket = null; this.log.info("stopped"); } @@ -120,6 +137,9 @@ class AgentService extends GadgetService { workOrder: IAgentWorkOrder, socket: DroneSocket, ): Promise { + this.currentWorkOrder = workOrder; + this.currentSocket = socket; + const { turn } = workOrder; let toolCallCount = 0; let inputTokens = 0; @@ -433,6 +453,259 @@ class AgentService extends GadgetService { cacheDir, }); } + + async spawnSubagent( + agentType: string, + prompt: string, + ): Promise { + const workOrder = this.currentWorkOrder; + if (!workOrder) { + return JSON.stringify({ + success: false, + error: "OPERATION_FAILED", + message: "No active work order. Subagent cannot be spawned outside of a work order.", + }); + } + + const turn = workOrder.turn; + const session = turn.session as IChatSession; + + if (!session.user) { + return JSON.stringify({ + success: false, + error: "OPERATION_FAILED", + message: "ChatSession must be populated with user data.", + }); + } + + const user = session.user as IUser; + const provider = turn.provider as IAiProvider; + + if (!provider || typeof provider === "string") { + return JSON.stringify({ + success: false, + error: "OPERATION_FAILED", + message: "Turn provider must be populated.", + }); + } + + // Build the subagent loop + const startTime = Date.now(); + const systemPrompt = await this.buildSubagentSystemPrompt(agentType); + const messages: IContextChatMessage[] = [ + { createdAt: new Date(), role: "system", content: systemPrompt }, + { createdAt: new Date(), role: "user", content: prompt }, + ]; + + const tools = this.getToolsForSubagent(agentType); + const streamHandler = this.currentSocket + ? this.makeStreamHandler(this.currentSocket) + : undefined; + + let fullResponse = ""; + let fullThinking = ""; + const subagentToolCalls: IChatToolCall[] = []; + let toolCallCount = 0; + let inputTokens = 0; + let outputTokens = 0; + let thinkingTokenCount = 0; + + try { + let continueLoop = true; + while (continueLoop) { + continueLoop = false; + + const chatOptions: IAiChatOptions = { + context: messages, + tools, + }; + + this.log.info("subagent loop iteration", { + agentType, + messagesCount: messages.length, + toolsAvailable: tools.map((t) => t.name), + }); + + const response = await AiService.chat( + provider, + { + modelId: turn.llm, + params: { reasoning: false, temperature: 0.8, topP: 0.9, topK: 40 }, + }, + chatOptions, + streamHandler, + ); + + if (response.doneReason === "load" && !response.response && !response.thinking) { + throw new Error("Subagent model failed to respond (still loading or error)"); + } + + inputTokens += response.stats?.tokenCounts?.input ?? 0; + if (response.thinking) { + thinkingTokenCount += Math.ceil(response.thinking.length / 4); + } + if (response.response) { + fullResponse += response.response; + outputTokens += Math.ceil(response.response.length / 4); + } + if (response.thinking) { + fullThinking = (fullThinking + response.thinking).trim(); + } + + // Process tool calls + if (response.toolCalls && response.toolCalls.length > 0) { + continueLoop = true; + toolCallCount += response.toolCalls.length; + + messages.push({ + createdAt: new Date(), + role: "assistant", + content: response.response, + }); + + for (const toolCall of response.toolCalls) { + const toolArgsRaw = toolCall.function.arguments; + const result = await this.executeTool( + toolCall.function.name, + toolArgsRaw, + ); + + subagentToolCalls.push({ + callId: toolCall.callId, + name: toolCall.function.name, + parameters: toolArgsRaw, + response: result, + }); + + messages.push({ + createdAt: new Date(), + role: "tool", + callId: toolCall.callId, + toolName: toolCall.function.name, + content: result, + }); + + inputTokens += Math.ceil(toolArgsRaw.length / 4); + outputTokens += Math.ceil(result.length / 4); + } + } + } + + // Ensure we have a response if the subagent only made tool calls + if (!fullResponse.trim()) { + if (toolCallCount > 0) { + fullResponse = `Subagent task completed. Executed ${toolCallCount} tool calls to perform the requested actions.`; + } else { + fullResponse = "Subagent task completed without additional actions."; + } + } + + const endTime = Date.now(); + const durationMs = endTime - startTime; + const durationSeconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(durationSeconds / 60); + const seconds = durationSeconds % 60; + const durationLabel = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "00")}`; + + const result: IChatSubagentProcess = { + prompt, + thinking: fullThinking || undefined, + response: fullResponse, + toolCalls: subagentToolCalls, + stats: { + toolCallCount, + inputTokens, + thinkingTokenCount, + responseTokens: outputTokens, + durationMs, + durationLabel, + }, + }; + + return JSON.stringify({ success: true, data: result }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.log.error("subagent execution failed", { agentType, error: errorMessage }); + + return JSON.stringify({ + success: false, + error: "SUBAGENT_FAILED", + message: errorMessage, + }); + } + } + + private getToolsForSubagent(agentType: string): IAiTool[] { + const mode = agentType === "explore" + ? ChatSessionMode.Plan + : ChatSessionMode.Build; + + const tools = Array.from(this.toolbox.getModeSet(mode) || []); + + if (agentType === "explore") { + return tools.filter((t) => + !t.name.startsWith("plan_") && + t.name !== "subagent" + ); + } + + return tools.filter((t) => t.name !== "subagent"); + } + + private async buildSubagentSystemPrompt(agentType: string): Promise { + const promptDir = path.resolve(env.installDir, "data", "prompts", "subagent", agentType); + const templatePath = path.join(promptDir, "system.md"); + + let template: string; + try { + template = await fs.readFile(templatePath, "utf-8"); + } catch { + this.log.error("subagent prompt template not found", { agentType, templatePath }); + throw new Error(`Subagent prompt template not found: ${templatePath}`); + } + + const workOrder = this.currentWorkOrder; + if (!workOrder) return template; + + const session = workOrder.turn.session as IChatSession; + const user = session?.user as IUser | undefined; + const project = session?.project as IProject | undefined; + const provider = workOrder.turn.provider as IAiProvider | undefined; + + // Build session block matching the format used in gadget-code's buildSystemPrompt + const sessionBlock = [ + `Session ID: ${session?._id ?? "unknown"}`, + `Session Name: ${session?.name ?? "unknown"}`, + `Session Created: ${session?.createdAt?.toISOString() ?? "unknown"}`, + `AI Provider: ${provider?.name ?? "unknown"}`, + `AI API: ${provider?.apiType ?? "unknown"}`, + `Model: ${workOrder.turn.llm}`, + `Project: ${project?.name ?? "unknown"}`, + ].join("\n"); + + const personaBlock = [ + `User ID: ${user?._id ?? "unknown"}`, + `Name: ${user?.displayName ?? "unknown"}`, + `Is Admin: ${user?.flags?.isAdmin ? "Yes" : "No"}`, + ].join("\n"); + + template = template.replace("{{session_block}}", sessionBlock); + template = template.replace("{{persona_block}}", personaBlock); + + // Append AGENTS.md if available in the project root + try { + const projectRoot = this.toolbox.env.workspace?.projectDir; + if (projectRoot) { + const agentsMdPath = path.join(projectRoot, "AGENTS.md"); + const agentsMd = await fs.readFile(agentsMdPath, "utf-8"); + template += "\n\n## AGENT CONFIGURATION\n\n" + agentsMd; + } + } catch { + // AGENTS.md not found or inaccessible - that's fine + } + + return template; + } } export { AgentService }; diff --git a/gadget-drone/src/tools/chat/index.ts b/gadget-drone/src/tools/chat/index.ts new file mode 100644 index 0000000..0c5b6e7 --- /dev/null +++ b/gadget-drone/src/tools/chat/index.ts @@ -0,0 +1,4 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +export { SubagentTool } from "./subagent.ts"; diff --git a/gadget-drone/src/tools/chat/subagent.ts b/gadget-drone/src/tools/chat/subagent.ts new file mode 100644 index 0000000..7823111 --- /dev/null +++ b/gadget-drone/src/tools/chat/subagent.ts @@ -0,0 +1,108 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; +import { formatError } from "@gadget/ai"; +import { DroneTool } from "../tool.ts"; + +const VALID_AGENT_TYPES = ["explore", "general"] as const; +type AgentType = (typeof VALID_AGENT_TYPES)[number]; + +export class SubagentTool extends DroneTool { + private _spawnSubagent: ((agentType: string, prompt: string) => Promise) | null = null; + + setSpawner(fn: typeof this._spawnSubagent): void { + this._spawnSubagent = fn; + } + + get name(): string { + return "subagent"; + } + + get category(): string { + return "chat"; + } + + get definition(): IToolDefinition { + return { + type: "function", + function: { + name: this.name, + description: + "Spawn a subagent to perform a specific task. The subagent will execute the task and return its result. Use 'explore' agent type for research and information gathering tasks. Use 'general' agent type for general-purpose task execution.", + parameters: { + type: "object", + properties: { + agent_type: { + type: "string", + enum: [...VALID_AGENT_TYPES], + description: + "The type of subagent to spawn. Use 'explore' for research and information gathering. Use 'general' for general-purpose task execution.", + }, + prompt: { + type: "string", + description: + "The task description and instructions for the subagent. Be specific about what information to find or what task to perform.", + }, + }, + required: ["agent_type", "prompt"], + }, + }, + }; + } + + async execute(args: IToolArguments, logger: IAiLogger): Promise { + const { agent_type, prompt } = args; + + if (!agent_type) { + return formatError({ + code: "MISSING_PARAMETER", + message: "The 'agent_type' parameter is required.", + parameter: "agent_type", + expected: "Either 'explore' or 'general'", + recoveryHint: "Specify either 'explore' or 'general' for the agent_type parameter.", + }); + } + + if (!VALID_AGENT_TYPES.includes(agent_type as AgentType)) { + return formatError({ + code: "INVALID_PARAMETER", + message: `Invalid agent_type: '${agent_type}'. Must be one of: ${VALID_AGENT_TYPES.join(", ")}`, + parameter: "agent_type", + expected: `One of: ${VALID_AGENT_TYPES.join(", ")}`, + recoveryHint: "Use 'explore' for research tasks or 'general' for general tasks.", + }); + } + + if (!prompt || typeof prompt !== "string") { + return formatError({ + code: "MISSING_PARAMETER", + message: "The 'prompt' parameter is required and must be a string.", + parameter: "prompt", + expected: "A non-empty string describing the task for the subagent.", + recoveryHint: "Provide specific instructions for the subagent to execute.", + }); + } + + if (!this._spawnSubagent) { + return formatError({ + code: "OPERATION_FAILED", + message: "SubagentTool has not been initialized with a spawner function.", + }); + } + + logger.debug("spawning subagent", { agentType: agent_type, promptLength: (prompt as string).length }); + + try { + return await this._spawnSubagent(agent_type as string, prompt as string); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error("subagent execution failed", { agentType: agent_type, error: message }); + return JSON.stringify({ + success: false, + error: "SUBAGENT_FAILED", + message, + }); + } + } +} diff --git a/gadget-drone/src/tools/index.ts b/gadget-drone/src/tools/index.ts index 2b72326..618e632 100644 --- a/gadget-drone/src/tools/index.ts +++ b/gadget-drone/src/tools/index.ts @@ -3,6 +3,7 @@ export { AiToolbox, type DroneToolboxEnvironment } from "./toolbox.ts"; export { DroneTool } from "./tool.ts"; +export * from "./chat/index.ts"; export * from "./system/index.ts"; export * from "./network/index.ts"; export * from "./plan/index.ts";