import type { CoreMessage } from "ai"; import { streamText } from "ai"; import { dirname, isAbsolute, relative, resolve } from "node:path"; import { realpathSync } from "node:fs"; import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js"; import { createToolRegistry } from "../tools/registry.js"; import { analyzeCommand } from "../tools/shell-analyze.js"; import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js"; import type { AgentConfig, AgentEvent, AgentStatus, ChatMessage, ToolCall, ToolResult, } from "../types/index.js"; function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMessage[] { const result: CoreMessage[] = []; for (const msg of messages) { if (msg.role === "user") { result.push({ role: "user", content: msg.content }); } else if (msg.role === "assistant") { const parts: Array<{ type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; args: Record }> = [{ type: "text", text: msg.content }]; for (const tc of msg.toolCalls ?? []) { const toolName = isAnthropic ? prefixToolName(tc.name) : tc.name; parts.push({ type: "tool-call", toolCallId: tc.id, toolName, args: tc.arguments }); } result.push({ role: "assistant", content: parts }); for (const tr of msg.toolResults ?? []) { const toolName = isAnthropic ? prefixToolName(tr.toolName) : tr.toolName; result.push({ role: "tool", content: [{ type: "tool-result", toolCallId: tr.toolCallId, toolName, result: tr.result }] }); } } } return result; } function formatError(err: unknown, config: AgentConfig): string { const context = `[model=${config.model}, baseURL=${config.baseURL}]`; if (err instanceof Error) { const cause = err.cause ? ` | cause: ${JSON.stringify(err.cause)}` : ""; // AI SDK errors often have statusCode, responseBody, or url properties const extras: string[] = []; const errRecord = err as unknown as Record; if ("statusCode" in errRecord) extras.push(`status=${errRecord.statusCode}`); if ("url" in errRecord) extras.push(`url=${errRecord.url}`); if ("responseBody" in errRecord) extras.push(`body=${JSON.stringify(errRecord.responseBody)}`); if ("responseHeaders" in errRecord) extras.push(`headers=${JSON.stringify(errRecord.responseHeaders)}`); const detail = extras.length > 0 ? ` (${extras.join(", ")})` : ""; return `${err.message}${detail}${cause} ${context}`; } return `${String(err)} ${context}`; } const MAX_STEPS = 10; export class Agent { status: AgentStatus = "idle"; messages: ChatMessage[] = []; private config: AgentConfig; constructor(config: AgentConfig) { this.config = config; } private async executeToolWithStreaming( tc: ToolCall, shellOutputQueue: Array<{ data: string; stream: "stdout" | "stderr" }>, ): Promise { const registry = createToolRegistry(this.config.tools); const tool = registry.getTool(tc.name); if (!tool) { return { toolCallId: tc.id, toolName: tc.name, result: `Unknown tool: ${tc.name}`, isError: true }; } // Permission check for shell commands — only prompt for external directory access. // Commands that stay within the working directory are auto-allowed. if (tc.name === "run_shell" && this.config.permissionChecker) { const command = typeof tc.arguments.command === "string" ? tc.arguments.command : ""; const workingDirectory = this.config.workingDirectory; const analysis = await analyzeCommand(command, workingDirectory); const ruleset = this.config.ruleset ?? []; // Check for external directory access from shell command if (analysis.dirs.length > 0) { const dirRequest = { permission: "external_directory", patterns: analysis.dirs.map((d) => `${d}/*`), always: analysis.dirs.map((d) => `${d}/*`), description: `Shell command accesses external directory: ${analysis.dirs.join(", ")}`, metadata: { command, dirs: analysis.dirs }, }; try { const dirReply = await this.config.permissionChecker.ask(dirRequest, [ruleset]); if (dirReply === "reject") { return { toolCallId: tc.id, toolName: tc.name, result: `Permission denied: access to external directories rejected`, isError: true, }; } } catch { return { toolCallId: tc.id, toolName: tc.name, result: `Permission denied: external directory access not allowed`, isError: true, }; } } } // Permission check for file tools accessing paths outside workspace if ( this.config.permissionChecker && (tc.name === "read_file" || tc.name === "write_file" || tc.name === "list_files") ) { const pathArg = typeof tc.arguments.path === "string" ? tc.arguments.path : "."; let resolvedPath: string; try { resolvedPath = realpathSync(resolve(this.config.workingDirectory, pathArg)); } catch { // Path doesn't exist yet (e.g. write_file creating a new file) — fall back resolvedPath = resolve(this.config.workingDirectory, pathArg); } // Check if outside workspace const rel = relative(this.config.workingDirectory, resolvedPath); const isOutside = rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel); if (isOutside) { const permissionType = tc.name === "read_file" ? "read" : tc.name === "write_file" ? "edit" : "list"; const parentDir = dirname(resolvedPath); const request = { permission: "external_directory", patterns: [`${parentDir}/*`], always: [`${parentDir}/*`], description: `${permissionType} file outside workspace: ${resolvedPath}`, metadata: { filepath: resolvedPath, parentDir, operation: permissionType }, }; const ruleset = this.config.ruleset ?? []; try { const reply = await this.config.permissionChecker.ask(request, [ruleset]); if (reply === "reject") { return { toolCallId: tc.id, toolName: tc.name, result: `Permission denied: ${permissionType} access to ${resolvedPath} rejected`, isError: true, }; } } catch { return { toolCallId: tc.id, toolName: tc.name, result: `Permission denied: ${permissionType} access to ${resolvedPath} not allowed`, isError: true, }; } } } try { const execPromise = tool.execute(tc.arguments, { onOutput: (data: string, stream: "stdout" | "stderr") => { shellOutputQueue.push({ data, stream }); }, }); const rawResult = await execPromise; return { toolCallId: tc.id, toolName: tc.name, result: typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult), isError: false, }; } catch (err) { return { toolCallId: tc.id, toolName: tc.name, result: err instanceof Error ? err.message : String(err), isError: true, }; } } async *run(userMessage: string, options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" }): AsyncGenerator { this.status = "running"; yield { type: "status", status: "running" }; this.messages.push({ role: "user", content: userMessage }); const registry = createToolRegistry(this.config.tools); const isAnthropic = this.config.provider === "anthropic"; const providerFactory = createProvider({ apiKey: this.config.apiKey, baseURL: this.config.baseURL, provider: this.config.provider, claudeCredentials: this.config.claudeCredentials, }); // For Anthropic provider, prefix tool names and build full system prompt const aiTools = registry.getAISDKTools(); const tools = isAnthropic ? Object.fromEntries( Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]), ) : aiTools; // Build system prompt let systemPrompt = this.config.systemPrompt; if (isAnthropic) { const billingHeader = buildBillingHeaderValue(this.messages); systemPrompt = `${billingHeader}\n${SYSTEM_IDENTITY}\n\n${systemPrompt}`; } try { // Track the final assistant message across all steps let finalText = ""; const allToolCalls: ToolCall[] = []; const allToolResults: ToolResult[] = []; // We build up a local message list for multi-turn within one run() call // that includes tool results fed back to the LLM const stepMessages: ChatMessage[] = [...this.messages]; for (let step = 0; step < MAX_STEPS; step++) { const effort = options?.reasoningEffort ?? this.config.reasoningEffort ?? "max"; // Build stream text options const streamOptions: Parameters[0] = { model: providerFactory(this.config.model), system: systemPrompt, messages: toCoreMessages(stepMessages, isAnthropic), tools, }; if (isAnthropic && effort !== "none") { const budgetTokens = effort === "max" ? 16000 : effort === "high" ? 10000 : effort === "medium" ? 5000 : effort === "low" ? 2000 : 0; streamOptions.providerOptions = { anthropic: { thinking: { type: "enabled" as const, budgetTokens } } }; streamOptions.maxTokens = budgetTokens + 8000; } else if (!isAnthropic && effort !== "none") { streamOptions.providerOptions = { openaiCompatible: { reasoningEffort: effort } }; } const result = streamText(streamOptions); let stepText = ""; const stepToolCalls: ToolCall[] = []; for await (const event of result.fullStream) { if (event.type === "text-delta") { stepText += event.textDelta; finalText += event.textDelta; yield { type: "text-delta", delta: event.textDelta }; } else if (event.type === "reasoning") { yield { type: "reasoning-delta", delta: event.textDelta }; } else if (event.type === "tool-call") { const rawName = event.toolName; const toolName = isAnthropic ? unprefixToolName(rawName) : rawName; const toolCall: ToolCall = { id: event.toolCallId, name: toolName, arguments: event.args as Record, }; stepToolCalls.push(toolCall); allToolCalls.push(toolCall); yield { type: "tool-call", toolCall }; } else if (event.type === "error") { const errorMsg = formatError(event.error, this.config); yield { type: "error", error: errorMsg }; this.status = "error"; yield { type: "status", status: "error" }; return; } } // No tool calls means the agent is done if (stepToolCalls.length === 0) { // Add the final assistant message to step messages (for history) stepMessages.push({ role: "assistant", content: stepText, }); break; } // Add assistant message with tool calls to step messages stepMessages.push({ role: "assistant", content: stepText, toolCalls: stepToolCalls, }); // Execute tool calls manually const stepToolResults: ToolResult[] = []; for (const tc of stepToolCalls) { const shellOutputQueue: Array<{ data: string; stream: "stdout" | "stderr" }> = []; const execPromise = this.executeToolWithStreaming(tc, shellOutputQueue); // Poll for shell output while the tool is running, using Promise.race // so we can yield shell-output events as they arrive rather than buffering // them all until tool completion. let toolResult: ToolResult | undefined; while (toolResult === undefined) { if (shellOutputQueue.length > 0) { const item = shellOutputQueue.shift()!; yield { type: "shell-output", data: item.data, stream: item.stream }; continue; } const raceResult = await Promise.race([ execPromise.then((r) => ({ done: true as const, value: r })), new Promise<{ done: false }>((resolve) => setImmediate(() => resolve({ done: false }))), ]); if (raceResult.done) { toolResult = raceResult.value; } } // Drain any remaining shell output emitted before we read the result while (shellOutputQueue.length > 0) { const item = shellOutputQueue.shift()!; yield { type: "shell-output", data: item.data, stream: item.stream }; } stepToolResults.push(toolResult); allToolResults.push(toolResult); yield { type: "tool-result", toolResult }; } // Add tool results back to step messages so LLM can see them // We append them to the last assistant message's toolResults const lastMsg = stepMessages[stepMessages.length - 1]; if (lastMsg) { lastMsg.toolResults = stepToolResults; } } // If we exhausted MAX_STEPS and there were pending tool calls, surface an error if (stepMessages.length > 0) { const lastMsg = stepMessages[stepMessages.length - 1]; if (lastMsg?.toolCalls && lastMsg.toolCalls.length > 0 && !lastMsg.toolResults) { yield { type: "error", error: `Agent reached MAX_STEPS (${MAX_STEPS}) with unresolved tool calls`, }; } } const assistantMessage: ChatMessage = { role: "assistant", content: finalText, toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined, toolResults: allToolResults.length > 0 ? allToolResults : undefined, }; this.messages.push(assistantMessage); yield { type: "done", message: assistantMessage }; } catch (err) { const errorMsg = formatError(err, this.config); yield { type: "error", error: errorMsg }; this.status = "error"; yield { type: "status", status: "error" }; return; } this.status = "idle"; yield { type: "status", status: "idle" }; } }