// src/services/agent.ts // 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 { type IAiChatOptions, type IAiChatResponse, type IAiResponseStreamFn, type IAiTool, type IContextChatMessage, } from "@gadget/ai"; import { type IAiProvider, type IChatSession, type IChatTurn, type IChatSubagentProcess, type IChatToolCall, type IUser, type ServerToClientEvents, type ClientToServerEvents, ChatSessionMode, type IProject, } from "@gadget/api"; import AiService from "./ai.ts"; import WorkspaceService from "./workspace.ts"; import { GadgetService } from "../lib/service.ts"; import { AiToolbox, FileEditTool, FileReadTool, FileWriteTool, FetchUrlTool, GlobTool, GoogleSearchTool, GrepTool, ListTool, PlanFileEditTool, PlanFileReadTool, PlanFileWriteTool, PlanListTool, ShellExecTool, SubagentTool, type DroneToolboxEnvironment, } from "../tools/index.ts"; export interface IAgentWorkOrder { createdAt: Date; turn: IChatTurn; context: IChatTurn[]; } type DroneSocket = Socket; const toolboxEnv: DroneToolboxEnvironment = { NODE_ENV: env.NODE_ENV || "develop", services: { google: { cse: { apiKey: env.google.cse.apiKey, engineId: env.google.cse.engineId, }, }, }, }; class AgentService extends GadgetService { private toolbox = new AiToolbox(toolboxEnv); private currentWorkOrder: IAgentWorkOrder | null = null; private currentSocket: DroneSocket | null = null; get name(): string { return "AgentService"; } get slug(): string { return "svc:agent"; } async start(): Promise { const readOnlyModes = [ ChatSessionMode.Plan, ChatSessionMode.Build, ChatSessionMode.Test, ChatSessionMode.Ship, ChatSessionMode.Develop, ]; const writeModes = [ ChatSessionMode.Build, ChatSessionMode.Test, ChatSessionMode.Ship, ChatSessionMode.Develop, ]; // Network tools — available in all modes for research this.toolbox.register(new GoogleSearchTool(this.toolbox), readOnlyModes); this.toolbox.register(new FetchUrlTool(this.toolbox), readOnlyModes); // System tools — read-only: available in all modes for exploration this.toolbox.register(new FileReadTool(this.toolbox), readOnlyModes); this.toolbox.register(new ListTool(this.toolbox), readOnlyModes); this.toolbox.register(new GrepTool(this.toolbox), readOnlyModes); this.toolbox.register(new GlobTool(this.toolbox), readOnlyModes); // System tools — write: restricted to build/test/ship/develop this.toolbox.register(new FileWriteTool(this.toolbox), writeModes); this.toolbox.register(new FileEditTool(this.toolbox), writeModes); this.toolbox.register(new ShellExecTool(this.toolbox), writeModes); // Plan tools — Gadget's own .gadget directory: only available in Plan mode this.toolbox.register(new PlanFileReadTool(this.toolbox), [ChatSessionMode.Plan]); this.toolbox.register(new PlanFileWriteTool(this.toolbox), [ChatSessionMode.Plan]); 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"); } async process( workOrder: IAgentWorkOrder, socket: DroneSocket, ): Promise { this.currentWorkOrder = workOrder; this.currentSocket = socket; const { turn } = workOrder; let toolCallCount = 0; let inputTokens = 0; let outputTokens = 0; // Build the full message array that grows with each iteration const messages: IContextChatMessage[] = []; if (turn.prompts.system) { messages.push({ createdAt: turn.createdAt, role: "system", content: turn.prompts.system, }); } messages.push(...this.buildSessionContext(workOrder)); // Current turn's user prompt must be the last message before the AI call messages.push({ createdAt: turn.createdAt, role: "user", content: turn.prompts.user, }); const reasoningEffort = turn.reasoningEffort || "off"; const reasoning: boolean | "low" | "medium" | "high" = reasoningEffort === "off" ? false : reasoningEffort; try { this.updateToolboxWorkspace(turn); } catch (cause) { socket.emit( "workOrderComplete", turn._id, false, `failed to update workspace: ${(cause as Error).message}`, ); throw new Error("failed to update workspace", { cause }); } this.log.info("agent loop starting", { turnId: turn._id, messageCount: messages.length, toolCount: this.toolbox.getToolNamesForMode(turn.mode).length, }); try { let continueLoop = true; while (continueLoop) { continueLoop = false; this.log.info("agent loop iteration", { messagesCount: messages.length, toolsAvailable: this.toolbox.getToolNamesForMode(turn.mode), }); const chatOptions: IAiChatOptions = { context: messages, tools: this.getToolsForMode(turn.mode), }; let response: IAiChatResponse; let currentReasoning: boolean | "low" | "medium" | "high" = reasoning; for (let attempt = 0; ; attempt++) { try { response = await AiService.chat( turn.provider, { modelId: turn.llm, params: { reasoning: currentReasoning, temperature: 0.8, topP: 0.9, topK: 40 }, }, chatOptions, this.makeStreamHandler(socket), ); break; } catch (error) { if ( attempt === 0 && currentReasoning !== false && error instanceof Error && error.message.includes("empty chat response") ) { this.log.warn("model returned empty response with reasoning; retrying without reasoning", { originalError: error.message, }); currentReasoning = false; continue; } throw error; } } if (response.doneReason === "load" && !response.response && !response.thinking) { throw new Error("Model failed to respond (still loading or error)"); } // Process tool calls if present if (response.toolCalls && response.toolCalls.length > 0) { continueLoop = true; toolCallCount += response.toolCalls.length; messages.push({ createdAt: turn.createdAt, role: "assistant", content: response.response, }); for (const toolCall of response.toolCalls) { const result = await this.executeTool( toolCall.function.name, toolCall.function.arguments, ); socket.emit( "toolCall", toolCall.callId, toolCall.function.name, toolCall.function.arguments, result, ); messages.push({ createdAt: turn.createdAt, role: "tool", callId: toolCall.callId, toolName: toolCall.function.name, content: result, }); inputTokens += Math.ceil(toolCall.function.arguments.length / 4); outputTokens += Math.ceil(result.length / 4); } } } socket.emit("workOrderComplete", turn._id, true); } catch (cause) { const msg = cause instanceof Error ? cause.message : String(cause); this.log.error("agent loop failed, sending workOrderComplete(false)", { turnId: turn._id, error: msg, }); socket.emit("workOrderComplete", turn._id, false, msg); throw cause; } } buildSessionContext(workOrder: IAgentWorkOrder): IContextChatMessage[] { const session = workOrder.turn.session as IChatSession; if (!session.user) { throw new Error("ChatSession must be populated with user data"); } const user: IUser = session.user as IUser; const messages: IContextChatMessage[] = []; for (const turn of workOrder.context) { /* * add the User message */ messages.push({ createdAt: turn.createdAt, role: "user", content: turn.prompts.user, user: { _id: user._id, username: user.email, displayName: user.displayName, }, }); /* * Add the assistant's output (if any), to include the thinking * (reasoning) output (if any). */ let content = ""; // Extract thinking and response from blocks for (const block of turn.blocks) { if (block.mode === "thinking" && typeof block.content === "string") { content += `${block.content}`; } else if ( block.mode === "responding" && typeof block.content === "string" ) { if (content && content.length) { content += "\n"; } content += block.content; } } messages.push({ createdAt: turn.createdAt, role: "assistant", content: content && content.length ? content : "(you didn't say anything this turn)", }); /* * Persisted turns do not currently store provider-native assistant * tool-call messages. Replaying these as role=tool creates invalid * OpenAI-compatible history. Keep the information, but make it normal * assistant-readable context. */ if (turn.toolCalls?.length > 0) { for (const toolCall of turn.toolCalls) { const content = this.formatHistoricalToolResult(toolCall); messages.push({ createdAt: turn.createdAt, role: "assistant", callId: toolCall.callId, toolName: toolCall.name, content, }); } } } return messages; } /** * To optimize context, reduce clutter, and help the agent focus, full outputs * of older file reads and edits are summarized once a newer version is available. */ pruneSessionContext(messages: IContextChatMessage[]): void { // TODO } private getToolsForMode(mode: ChatSessionMode): any[] { return Array.from(this.toolbox.getModeSet(mode) || []); } getToolNamesForMode(mode: ChatSessionMode): string[] { return this.toolbox.getToolNamesForMode(mode); } private formatHistoricalToolResult(toolCall: { name: string; parameters?: string; response?: string; }): string { const response = toolCall.response || ""; return [ `Historical tool result: ${toolCall.name}`, `Parameters: ${toolCall.parameters || "{}"}`, "---", response.length > 8000 ? `${response.slice(0, 8000)}\n\n[Tool result truncated from ${response.length} characters.]` : response, ].join("\n"); } private makeStreamHandler(socket: DroneSocket): IAiResponseStreamFn { return async (chunk) => { switch (chunk.type) { case "thinking": socket.emit("thinking", chunk.data); break; case "response": socket.emit("response", chunk.data); break; } }; } private async executeTool(name: string, argsJson: string): Promise { const tool = this.toolbox.getTool(name); if (!tool) { const msg = `Unknown tool: ${name}`; this.log.error("tool not found", { toolName: name }); return JSON.stringify({ success: false, error: msg }); } try { const args = JSON.parse(argsJson); this.log.info("executing tool", { name, params: argsJson }); const result = await tool.execute(args, this.log); this.log.info("tool result", { name, resultLength: result.length, preview: result.length > 100 ? `${result.slice(0, 100)}...` : result, }); return result; } catch (error) { const msg = error instanceof Error ? error.message : String(error); this.log.error("tool execution failed", { toolName: name, args: argsJson, error: msg }); return JSON.stringify({ success: false, error: msg }); } } private updateToolboxWorkspace(turn: IChatTurn): void { const project = turn.project as IProject; if (!project || typeof project === "string") { throw new Error("ChatTurn must be populated with project data"); } const workspaceDir = WorkspaceService.workspaceDir; const cacheDir = WorkspaceService.workspaceCacheDir; if (!workspaceDir || !cacheDir) { throw new Error("Workspace must be initialized before agent tools are used"); } this.toolbox.updateWorkspace({ workspaceDir, projectDir: WorkspaceService.getProjectDirectory(project.slug), 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 }; export default new AgentService();