subagents (written by an agent, for an agent)

so meta.
This commit is contained in:
Rob Colbert 2026-05-11 11:22:59 -04:00
parent 09445cb565
commit 40cab7ca49
6 changed files with 395 additions and 13 deletions

View File

@ -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. 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. This is your job.
### CHAT SESSION ### CHAT SESSION

View File

@ -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. 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. This is your job.
### CHAT SESSION ### CHAT SESSION

View File

@ -2,23 +2,30 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us> // Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0 // 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 env from "../config/env.ts";
import { Socket } from "socket.io-client"; import { Socket } from "socket.io-client";
import { import {
IAiChatOptions, type IAiChatOptions,
IAiChatResponse, type IAiChatResponse,
IAiResponseStreamFn, type IAiResponseStreamFn,
type IAiTool,
type IContextChatMessage, type IContextChatMessage,
} from "@gadget/ai"; } from "@gadget/ai";
import { import {
IChatSession, type IAiProvider,
IChatTurn, type IChatSession,
IUser, type IChatTurn,
ServerToClientEvents, type IChatSubagentProcess,
ClientToServerEvents, type IChatToolCall,
type IUser,
type ServerToClientEvents,
type ClientToServerEvents,
ChatSessionMode, ChatSessionMode,
IProject, type IProject,
} from "@gadget/api"; } from "@gadget/api";
import AiService from "./ai.ts"; import AiService from "./ai.ts";
@ -40,6 +47,7 @@ import {
PlanFileWriteTool, PlanFileWriteTool,
PlanListTool, PlanListTool,
ShellExecTool, ShellExecTool,
SubagentTool,
type DroneToolboxEnvironment, type DroneToolboxEnvironment,
} from "../tools/index.ts"; } from "../tools/index.ts";
@ -65,6 +73,8 @@ const toolboxEnv: DroneToolboxEnvironment = {
class AgentService extends GadgetService { class AgentService extends GadgetService {
private toolbox = new AiToolbox(toolboxEnv); private toolbox = new AiToolbox(toolboxEnv);
private currentWorkOrder: IAgentWorkOrder | null = null;
private currentSocket: DroneSocket | null = null;
get name(): string { get name(): string {
return "AgentService"; return "AgentService";
@ -109,10 +119,17 @@ class AgentService extends GadgetService {
this.toolbox.register(new PlanFileEditTool(this.toolbox), [ChatSessionMode.Plan]); this.toolbox.register(new PlanFileEditTool(this.toolbox), [ChatSessionMode.Plan]);
this.toolbox.register(new PlanListTool(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"); this.log.info("started");
} }
async stop(): Promise<void> { async stop(): Promise<void> {
this.currentWorkOrder = null;
this.currentSocket = null;
this.log.info("stopped"); this.log.info("stopped");
} }
@ -120,6 +137,9 @@ class AgentService extends GadgetService {
workOrder: IAgentWorkOrder, workOrder: IAgentWorkOrder,
socket: DroneSocket, socket: DroneSocket,
): Promise<void> { ): Promise<void> {
this.currentWorkOrder = workOrder;
this.currentSocket = socket;
const { turn } = workOrder; const { turn } = workOrder;
let toolCallCount = 0; let toolCallCount = 0;
let inputTokens = 0; let inputTokens = 0;
@ -433,6 +453,259 @@ class AgentService extends GadgetService {
cacheDir, cacheDir,
}); });
} }
async spawnSubagent(
agentType: string,
prompt: string,
): Promise<string> {
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<string> {
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 }; export { AgentService };

View File

@ -0,0 +1,4 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
export { SubagentTool } from "./subagent.ts";

View File

@ -0,0 +1,108 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// 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<string>) | 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<string> {
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,
});
}
}
}

View File

@ -3,6 +3,7 @@
export { AiToolbox, type DroneToolboxEnvironment } from "./toolbox.ts"; export { AiToolbox, type DroneToolboxEnvironment } from "./toolbox.ts";
export { DroneTool } from "./tool.ts"; export { DroneTool } from "./tool.ts";
export * from "./chat/index.ts";
export * from "./system/index.ts"; export * from "./system/index.ts";
export * from "./network/index.ts"; export * from "./network/index.ts";
export * from "./plan/index.ts"; export * from "./plan/index.ts";