713 lines
21 KiB
TypeScript
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();
|