gadget/gadget-drone/src/services/agent.ts
2026-05-11 11:22:59 -04:00

713 lines
21 KiB
TypeScript

// src/services/agent.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// 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<ServerToClientEvents, ClientToServerEvents>;
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<void> {
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<void> {
this.currentWorkOrder = null;
this.currentSocket = null;
this.log.info("stopped");
}
async process(
workOrder: IAgentWorkOrder,
socket: DroneSocket,
): Promise<void> {
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 += `<thinking>${block.content}</thinking>`;
} 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<string> {
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<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 default new AgentService();