diff --git a/docs/agent-toolbox.md b/docs/agent-toolbox.md index 158db8d..4f71757 100644 --- a/docs/agent-toolbox.md +++ b/docs/agent-toolbox.md @@ -278,8 +278,8 @@ Both OpenAI and Ollama implementations support multiple rounds of tool calls: 2. AI returns tool calls (or final response) 3. Tools are executed, results collected 4. Results appended to conversation as tool messages -5. Loop back to step 1 (up to `maxToolIterations`, default: 5) -6. Return final response or max iterations reached +5. Loop back to step 1 +6. Return final response This allows complex multi-step operations where the AI can: diff --git a/gadget-code/src/lib/code-session.ts b/gadget-code/src/lib/code-session.ts index c942a1a..9439ca6 100644 --- a/gadget-code/src/lib/code-session.ts +++ b/gadget-code/src/lib/code-session.ts @@ -185,6 +185,9 @@ export class CodeSession extends SocketSession { try { const droneSession = SocketService.getDroneSession(this.selectedDrone); + const latestSession = await ChatSessionService.getById(this.chatSession._id); + this.chatSession = latestSession; + let turn: ChatTurnDocument = await ChatSessionService.createTurn( this.chatSession, content, diff --git a/gadget-code/src/services/chat-session.ts b/gadget-code/src/services/chat-session.ts index d46f3f1..2848d9f 100644 --- a/gadget-code/src/services/chat-session.ts +++ b/gadget-code/src/services/chat-session.ts @@ -238,7 +238,7 @@ class ChatSessionService extends DtpService { updates, }); - return session; + return session.populate(this.populateChatSession); } /** diff --git a/gadget-code/tests/code-session.test.ts b/gadget-code/tests/code-session.test.ts index ca90ff9..c9e0932 100644 --- a/gadget-code/tests/code-session.test.ts +++ b/gadget-code/tests/code-session.test.ts @@ -124,6 +124,9 @@ describe("CodeSession", () => { vi.mocked(ChatSessionService.createTurn).mockResolvedValue( mockTurn as any, ); + vi.mocked(ChatSessionService.getById).mockResolvedValue( + mockChatSession as any, + ); cb = vi.fn(); @@ -189,6 +192,37 @@ describe("CodeSession", () => { ); }); + it("should reload the latest chat session before creating a turn", async () => { + const latestSession = { + ...mockChatSession, + provider: { + _id: nanoid(), + name: "Gab AI", + apiType: "openai", + baseUrl: "https://api.gabai.chat/v1", + apiKey: "test-key", + }, + selectedModel: "minimax-m2-7", + reasoningEffort: "medium", + } as any; + const updatedSession = { + ...latestSession, + stats: { ...mockChatSession.stats, turnCount: 1 }, + }; + vi.mocked(ChatSessionService.getById).mockResolvedValue(latestSession); + vi.mocked(ChatSession.findOneAndUpdate).mockResolvedValue( + updatedSession as any, + ); + + await codeSession.onSubmitPrompt("test prompt", cb); + + expect(ChatSessionService.getById).toHaveBeenCalledWith(mockChatSession._id); + expect(ChatSessionService.createTurn).toHaveBeenCalledWith( + latestSession, + "test prompt", + ); + }); + it("should update ChatTurn to Error status if drone rejects work order", async () => { const updatedSession = { ...mockChatSession, diff --git a/gadget-drone/package.json b/gadget-drone/package.json index 224e35a..dae8357 100644 --- a/gadget-drone/package.json +++ b/gadget-drone/package.json @@ -12,7 +12,7 @@ "dev:watch": "tsx watch src/gadget-drone.ts", "build": "tsc", "start": "node dist/gadget-drone.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest run" }, "keywords": [ "gadget", @@ -27,20 +27,28 @@ "@gadget/api": "workspace:*", "@gadget/config": "workspace:*", "@inquirer/prompts": "^8.4.2", + "@mozilla/readability": "0.6.0", "ansicolor": "^2.0.3", "dayjs": "^1.11.20", + "googleapis": "171.4.0", + "jsdom": "29.0.2", "numeral": "^2.0.6", "ollama": "^0.6.3", "openai": "^6.34.0", + "playwright": "1.59.1", "simple-git": "^3.36.0", - "socket.io-client": "^4.8.3" + "socket.io-client": "^4.8.3", + "turndown": "7.2.2" }, "devDependencies": { + "@types/jsdom": "27.0.0", "@types/node": "^25.6.0", "@types/numeral": "^2.0.5", + "@types/turndown": "5.0.5", "prettier": "^3.8.3", "tsc-alias": "^1.8.16", "tsx": "^4.21.0", - "typescript": "^6.0.3" + "typescript": "6.0.3", + "vitest": "4.1.5" } } diff --git a/gadget-drone/src/services/agent.test.ts b/gadget-drone/src/services/agent.test.ts new file mode 100644 index 0000000..bdaac66 --- /dev/null +++ b/gadget-drone/src/services/agent.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { ChatSessionMode, ChatTurnStatus } from "@gadget/api"; +import { AgentService, type IAgentWorkOrder } from "./agent.ts"; + +describe("AgentService", () => { + it("replays historical tool results as assistant-readable context, not raw tool-role messages", () => { + const service = new AgentService(); + const user = { + _id: "user-1", + email: "user@example.com", + email_lc: "user@example.com", + displayName: "User", + flags: { + isEmailVerified: true, + isAdmin: false, + isTest: true, + isBanned: false, + }, + }; + const session = { + _id: "session-1", + createdAt: new Date(), + user, + project: "project-1", + name: "Test Session", + mode: ChatSessionMode.Build, + provider: "provider-1", + selectedModel: "model", + stats: { + turnCount: 0, + toolCallCount: 0, + inputTokens: 0, + outputTokens: 0, + }, + pins: [], + }; + const workOrder: IAgentWorkOrder = { + createdAt: new Date(), + turn: { + _id: "turn-current", + createdAt: new Date(), + user, + project: "project-1", + session, + provider: "provider-1", + llm: "model", + mode: ChatSessionMode.Build, + status: ChatTurnStatus.Processing, + prompts: { user: "continue" }, + blocks: [], + toolCalls: [], + subagents: [], + stats: { + toolCallCount: 0, + inputTokens: 0, + thinkingTokenCount: 0, + responseTokens: 0, + durationMs: 0, + durationLabel: "pending", + }, + }, + context: [ + { + _id: "turn-1", + createdAt: new Date(), + user, + project: "project-1", + session, + provider: "provider-1", + llm: "model", + mode: ChatSessionMode.Build, + status: ChatTurnStatus.Finished, + prompts: { user: "Read index.html" }, + blocks: [ + { + mode: "responding", + createdAt: new Date(), + content: "I read the file.", + }, + ], + toolCalls: [ + { + callId: "call-1", + name: "file_read", + parameters: '{"path":"index.html"}', + response: "PATH: index.html\n---\ncontent", + }, + ], + subagents: [], + stats: { + toolCallCount: 1, + inputTokens: 0, + thinkingTokenCount: 0, + responseTokens: 0, + durationMs: 0, + durationLabel: "done", + }, + }, + ], + }; + + const messages = service.buildSessionContext(workOrder); + + expect(messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "assistant", + ]); + expect(messages[2]?.content).toContain("Historical tool result: file_read"); + expect(messages[2]?.content).toContain("PATH: index.html"); + }); +}); diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index d1ba605..796ed6c 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -7,9 +7,7 @@ import assert from "node:assert"; import { Socket } from "socket.io-client"; import { - GoogleSearchTool, IAiChatOptions, - IAiEnvironment, IAiStreamChunk, type IContextChatMessage, } from "@gadget/ai"; @@ -20,12 +18,22 @@ import { ServerToClientEvents, ClientToServerEvents, ChatSessionMode, + IProject, } from "@gadget/api"; import AiService from "./ai.ts"; +import WorkspaceService from "./workspace.ts"; import { GadgetService } from "../lib/service.ts"; -import { AiToolbox } from "@gadget/ai"; +import { + AiToolbox, + FetchUrlTool, + FileEditTool, + FileReadTool, + FileWriteTool, + GoogleSearchTool, + type DroneToolboxEnvironment, +} from "../tools/index.ts"; export interface IAgentWorkOrder { createdAt: Date; @@ -40,7 +48,7 @@ interface IAgentWorkflow { type DroneSocket = Socket; -const aiEnv: IAiEnvironment = { +const toolboxEnv: DroneToolboxEnvironment = { NODE_ENV: env.NODE_ENV || "develop", services: { google: { @@ -53,7 +61,7 @@ const aiEnv: IAiEnvironment = { }; class AgentService extends GadgetService { - private toolbox = new AiToolbox(aiEnv); + private toolbox = new AiToolbox(toolboxEnv); get name(): string { return "AgentService"; @@ -71,6 +79,17 @@ class AgentService extends GadgetService { ChatSessionMode.Ship, ChatSessionMode.Develop, ]); + const modes = [ + ChatSessionMode.Plan, + ChatSessionMode.Build, + ChatSessionMode.Test, + ChatSessionMode.Ship, + ChatSessionMode.Develop, + ]; + this.toolbox.register(new FileReadTool(this.toolbox), modes); + this.toolbox.register(new FileWriteTool(this.toolbox), modes); + this.toolbox.register(new FileEditTool(this.toolbox), modes); + this.toolbox.register(new FetchUrlTool(this.toolbox), modes); this.log.info("started"); } @@ -118,6 +137,7 @@ class AgentService extends GadgetService { }; try { + this.updateToolboxWorkspace(turn); task.context = this.buildSessionContext(workOrder); task.chatOptions = { systemPrompt: turn.prompts.system, @@ -156,6 +176,12 @@ class AgentService extends GadgetService { onStreamChunk, ); + if (this.isEmptyAgentResponse(response)) { + throw new Error( + "AI provider returned an empty response: no thinking, response, tool calls, or tool results.", + ); + } + // Check for model loading failure if ( response.doneReason === "load" && @@ -228,21 +254,6 @@ class AgentService extends GadgetService { }, }); - /* - * Add the agent's responses (thinking, respone text, tool calls) - */ - if (turn.toolCalls?.length > 0) { - for (const toolCall of turn.toolCalls) { - messages.push({ - createdAt: turn.createdAt, - role: "tool", - callId: toolCall.callId, - toolName: toolCall.name, - content: toolCall.response, - }); - } - } - /* * Add the assistant's output (if any), to include the thinking * (reasoning) output (if any). @@ -272,6 +283,25 @@ class AgentService extends GadgetService { ? 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; @@ -288,6 +318,59 @@ class AgentService extends GadgetService { private getToolsForMode(mode: ChatSessionMode): any[] { return Array.from(this.toolbox.getModeSet(mode) || []); } + + private formatHistoricalToolResult(toolCall: { + name: string; + parameters?: string; + response?: string; + }): string { + const response = toolCall.response || ""; + const maxLength = 8000; + const trimmedResponse = + response.length > maxLength + ? `${response.slice(0, maxLength)}\n\n[Tool result truncated from ${response.length} characters.]` + : response; + return [ + `Historical tool result: ${toolCall.name}`, + `Parameters: ${toolCall.parameters || "{}"}`, + "---", + trimmedResponse, + ].join("\n"); + } + + private isEmptyAgentResponse(response: { + response?: string; + thinking?: string; + toolCalls?: unknown[]; + toolCallResults?: unknown[]; + }): boolean { + return ( + !(response.response && response.response.trim()) && + !(response.thinking && response.thinking.trim()) && + !(response.toolCalls && response.toolCalls.length) && + !(response.toolCallResults && response.toolCallResults.length) + ); + } + + 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, + }); + } } +export { AgentService }; export default new AgentService(); diff --git a/gadget-drone/src/services/workspace.ts b/gadget-drone/src/services/workspace.ts index 2760487..3f04816 100644 --- a/gadget-drone/src/services/workspace.ts +++ b/gadget-drone/src/services/workspace.ts @@ -92,6 +92,14 @@ class WorkspaceService extends GadgetService { return this._workspaceData?.workspaceId ?? null; } + get workspaceDir(): string | undefined { + return this._workspaceData?.workspaceDir; + } + + get workspaceCacheDir(): string | undefined { + return this.cacheDir || undefined; + } + async start(): Promise { this.log.info("started"); } diff --git a/gadget-drone/src/tools/file/common.ts b/gadget-drone/src/tools/file/common.ts new file mode 100644 index 0000000..b5d7413 --- /dev/null +++ b/gadget-drone/src/tools/file/common.ts @@ -0,0 +1,136 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import fs from "node:fs/promises"; +import path from "node:path"; + +import { formatError, type IToolError } from "@gadget/ai"; +import type { AiToolbox } from "../toolbox.ts"; + +export const BINARY_EXTENSIONS = new Set([ + ".o", + ".obj", + ".a", + ".lib", + ".so", + ".dll", + ".exe", + ".bin", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".ico", + ".webp", + ".pdf", + ".zip", + ".tar", + ".gz", + ".bz2", + ".7z", + ".wasm", + ".pyc", + ".class", +]); + +export interface ResolvedProjectPath { + inputPath: string; + absolutePath: string; + displayPath: string; +} + +export function toolError(error: IToolError): string { + return formatError(error); +} + +export function getProjectRoot(toolbox: AiToolbox): string | undefined { + return toolbox.env.workspace?.projectDir; +} + +export function getCacheDir(toolbox: AiToolbox): string | undefined { + return toolbox.env.workspace?.cacheDir; +} + +export function resolveProjectPath( + toolbox: AiToolbox, + inputPath: string, +): ResolvedProjectPath | string { + const projectRoot = getProjectRoot(toolbox); + if (!projectRoot) { + return toolError({ + code: "OPERATION_NOT_ALLOWED", + message: "No active project workspace is configured for file tools.", + recoveryHint: "Run this tool during an active Agent work order.", + }); + } + + const trimmedPath = inputPath.trim(); + if (!trimmedPath) { + return toolError({ + code: "MISSING_PARAMETER", + message: "File path must not be empty.", + parameter: "path", + recoveryHint: "Provide a valid file path within the project directory.", + }); + } + + const root = path.resolve(projectRoot); + const absolutePath = path.isAbsolute(trimmedPath) + ? path.resolve(trimmedPath) + : path.resolve(root, trimmedPath); + const relative = path.relative(root, absolutePath); + + if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) { + return { + inputPath: trimmedPath, + absolutePath, + displayPath: relative || ".", + }; + } + + return toolError({ + code: "SECURITY_VIOLATION", + message: `Path is outside the active project directory: ${trimmedPath}`, + parameter: "path", + recoveryHint: "Use a relative path inside the project workspace.", + }); +} + +export function isBinaryBuffer(buffer: Buffer): boolean { + const sampleSize = Math.min(buffer.length, 8192); + for (let i = 0; i < sampleSize; i++) { + const byte = buffer[i]; + if (byte === undefined) continue; + if (byte === 0) return true; + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) return true; + } + return false; +} + +export function isBinaryPath(filePath: string): boolean { + return BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase()); +} + +export function asPositiveInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return Math.floor(value); +} + +export function formatNumberedLines( + lines: string[], + startIndex: number, +): string { + return lines.map((line, index) => `${startIndex + index + 1}: ${line}`).join("\n"); +} + +export async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} diff --git a/gadget-drone/src/tools/file/edit.ts b/gadget-drone/src/tools/file/edit.ts new file mode 100644 index 0000000..2479ce1 --- /dev/null +++ b/gadget-drone/src/tools/file/edit.ts @@ -0,0 +1,219 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import fs from "node:fs/promises"; + +import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; +import { DroneTool } from "../tool.ts"; +import { resolveProjectPath, toolError } from "./common.ts"; + +export class FileEditTool extends DroneTool { + get name(): string { + return "file_edit"; + } + + get category(): string { + return "file"; + } + + public definition: IToolDefinition = { + type: "function", + function: { + name: this.name, + description: "Perform an exact search-and-replace edit on an existing project file. Replaces the first occurrence only and returns changed-line context.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file to edit, relative to the project root." }, + search: { type: "string", description: "Exact text to search for. It must match whitespace and line endings exactly." }, + replace: { type: "string", description: "Replacement text. Empty string is allowed to delete the match." }, + }, + required: ["path", "search", "replace"], + }, + }, + }; + + public async execute(args: IToolArguments, logger: IAiLogger): Promise { + const filePath = args.path; + const search = args.search; + const replace = args.replace; + + if (typeof filePath !== "string" || filePath.trim().length === 0) { + return toolError({ + code: "MISSING_PARAMETER", + message: "File path must not be empty.", + parameter: "path", + recoveryHint: "Provide a valid file path within the project directory.", + }); + } + if (typeof search !== "string" || search.length === 0) { + return toolError({ + code: "MISSING_PARAMETER", + message: "Search string must not be empty.", + parameter: "search", + recoveryHint: "Provide the exact text to search for.", + }); + } + if (typeof replace !== "string") { + return toolError({ + code: "MISSING_PARAMETER", + message: "Replace string must be a string and must not be undefined.", + parameter: "replace", + recoveryHint: "Provide the replacement text. Use an empty string to delete the match.", + }); + } + + const resolved = resolveProjectPath(this.toolbox, filePath); + if (typeof resolved === "string") return resolved; + + try { + const content = await fs.readFile(resolved.absolutePath, "utf-8"); + const searchIdx = content.indexOf(search); + if (searchIdx === -1) { + const contextInfo = this.buildNotFoundContext(content, search); + return toolError({ + code: "NOT_FOUND", + message: `Search string not found in ${resolved.displayPath}.${contextInfo}`, + parameter: "search", + recoveryHint: "Verify your search string matches exactly, including whitespace and line endings.", + }); + } + + const newContent = content.replace(search, replace); + await fs.writeFile(resolved.absolutePath, newContent, "utf-8"); + const diffContext = this.buildDiffContext(content, searchIdx, search, newContent); + + return [ + `PATH: ${resolved.displayPath}`, + "FILE OPERATION: edit", + "SEARCH FOUND: true", + "---", + `File edited: ${resolved.displayPath}`, + "", + diffContext, + ].join("\n"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("ENOENT")) { + return toolError({ + code: "NOT_FOUND", + message: `File not found: ${resolved.displayPath}`, + parameter: "path", + recoveryHint: "Check the file path and ensure the file exists. Use file_write to create it.", + }); + } + logger.error("failed to edit file", { path: resolved.displayPath, error: errorMessage }); + return toolError({ + code: "OPERATION_FAILED", + message: `Failed to edit file: ${errorMessage}`, + }); + } + } + + private buildNotFoundContext(content: string, search: string): string { + const lines = content.split("\n"); + const searchWords = search.toLowerCase().split(/\s+/).filter((word) => word.length > 2); + let bestMatchLine = -1; + let bestMatchScore = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + const lineLower = line.toLowerCase(); + let score = 0; + for (const word of searchWords) { + if (lineLower.includes(word)) score++; + } + if (score > bestMatchScore) { + bestMatchScore = score; + bestMatchLine = i; + } + } + + if (bestMatchLine === -1) { + const preview = lines.slice(0, 5).map((line, index) => ` ${index + 1}: ${line}`).join("\n"); + return `\n\nFile content (first 5 lines):\n${preview}`; + } + + const contextStart = Math.max(0, bestMatchLine - 2); + const contextEnd = Math.min(lines.length, bestMatchLine + 3); + const context = lines + .slice(contextStart, contextEnd) + .map((line, index) => ` ${contextStart + index + 1}: ${line}`) + .join("\n"); + + return `\n\nFile content around line ${bestMatchLine + 1} (possible match location):\n${context}`; + } + + private buildDiffContext( + original: string, + matchStart: number, + search: string, + newContent: string, + ): string { + const contextLines = 2; + const oldLines = original.split("\n"); + const newLines = newContent.split("\n"); + let charOffset = 0; + let matchStartLine = 0; + + for (let i = 0; i < oldLines.length; i++) { + const line = oldLines[i]; + if (line === undefined) break; + const lineLen = line.length + 1; + if (charOffset + lineLen > matchStart) { + matchStartLine = i; + break; + } + charOffset += lineLen; + } + + const searchLines = search.split("\n"); + const matchEndLine = matchStartLine + searchLines.length - 1; + const affectedStartLine = Math.max(0, matchStartLine - contextLines); + const diffLines: string[] = []; + const numChangedLines = matchEndLine - matchStartLine + 1; + + if (numChangedLines === 1) { + const lineNum = matchStartLine + 1; + const oldLineText = oldLines[matchStartLine] ?? ""; + const newLineText = newLines[matchStartLine] ?? ""; + diffLines.push(`Changed line ${lineNum}:`); + diffLines.push(` Removed (${oldLineText.length} chars): ${oldLineText}`); + diffLines.push(` Added (${newLineText.length} chars): ${newLineText}`); + } else { + diffLines.push(`Changed lines ${matchStartLine + 1}-${matchEndLine + 1}:`); + diffLines.push(` Search spanned ${numChangedLines} lines`); + diffLines.push(" --- Old:"); + for (let i = matchStartLine; i <= matchEndLine; i++) { + const oldLine = oldLines[i]; + if (oldLine !== undefined) diffLines.push(` ${i + 1}: ${oldLine}`); + } + diffLines.push(" --- New:"); + for (let i = 0; i < searchLines.length; i++) { + const newLineIdx = matchStartLine + i; + const newLine = newLines[newLineIdx]; + if (newLine !== undefined) diffLines.push(` ${newLineIdx + 1}: ${newLine}`); + } + } + + if (matchStartLine > affectedStartLine) { + diffLines.push("", "Context before:"); + for (let i = affectedStartLine; i < matchStartLine; i++) { + const ctxLine = oldLines[i]; + if (ctxLine !== undefined) diffLines.push(` ${i + 1}: ${ctxLine}`); + } + } + + const actualEndLine = Math.min(newLines.length, matchEndLine + contextLines + 1); + if (matchEndLine + 1 < actualEndLine) { + diffLines.push("", "Context after:"); + for (let i = matchEndLine + 1; i < actualEndLine; i++) { + const ctxLine = newLines[i]; + if (ctxLine !== undefined) diffLines.push(` ${i + 1}: ${ctxLine}`); + } + } + + return diffLines.join("\n"); + } +} diff --git a/gadget-drone/src/tools/file/fetch-url.ts b/gadget-drone/src/tools/file/fetch-url.ts new file mode 100644 index 0000000..71192db --- /dev/null +++ b/gadget-drone/src/tools/file/fetch-url.ts @@ -0,0 +1,136 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; +import { DroneTool } from "../tool.ts"; +import { asPositiveInteger, getCacheDir, toolError } from "./common.ts"; +import { WebFetcher } from "./web-fetcher.ts"; + +export class FetchUrlTool extends DroneTool { + get name(): string { + return "fetch_url"; + } + + get category(): string { + return "file"; + } + + public definition: IToolDefinition = { + type: "function", + function: { + name: this.name, + description: "Fetch a URL, convert readable page content to line-numbered Markdown, cache it in the drone .gadget/cache directory, and return an optional line range like file_read.", + parameters: { + type: "object", + properties: { + url: { type: "string", description: "The URL to fetch. Must start with http:// or https://." }, + startLine: { type: "number", description: "Starting line number (1-indexed). Defaults to 1." }, + endLine: { type: "number", description: "Ending line number (inclusive). Defaults to end of content." }, + useCache: { type: "boolean", description: "Use cached Markdown if available. Defaults to true." }, + }, + required: ["url"], + }, + }, + }; + + public async execute(args: IToolArguments, logger: IAiLogger): Promise { + const url = args.url; + const startLine = asPositiveInteger(args.startLine) ?? 1; + const endLine = args.endLine === undefined ? undefined : asPositiveInteger(args.endLine); + const useCache = typeof args.useCache === "boolean" ? args.useCache : true; + + if (typeof url !== "string" || url.trim().length === 0) { + return toolError({ + code: "MISSING_PARAMETER", + message: "URL must not be empty.", + parameter: "url", + recoveryHint: "Provide a valid URL starting with http:// or https://.", + }); + } + if (!/^https?:\/\/.+/i.test(url)) { + return toolError({ + code: "INVALID_PARAMETER", + message: "URL must start with http:// or https://.", + parameter: "url", + expected: "A valid URL starting with http:// or https://.", + example: "https://example.com", + }); + } + if (startLine < 1) { + return toolError({ + code: "INVALID_PARAMETER", + message: "startLine must be >= 1.", + parameter: "startLine", + expected: "A positive integer >= 1", + }); + } + if (endLine !== undefined && endLine < startLine) { + return toolError({ + code: "INVALID_PARAMETER", + message: "endLine must be >= startLine.", + parameter: "endLine", + expected: "An integer >= startLine", + }); + } + + const cacheDir = getCacheDir(this.toolbox); + if (!cacheDir) { + return toolError({ + code: "OPERATION_NOT_ALLOWED", + message: "No drone cache directory is configured for fetch_url.", + recoveryHint: "Run this tool during an active Agent work order after workspace initialization.", + }); + } + + try { + logger.info("fetching URL", { url, startLine, endLine, useCache }); + const result = await new WebFetcher(cacheDir).fetchUrlWithRange( + url, + startLine, + endLine, + useCache, + ); + + return [ + `URL: ${result.url}`, + `TITLE: ${result.title}`, + `TOTAL LINES: ${result.totalLineCount}`, + `LINES SHOWN: ${result.shownLineCount}`, + "FETCH OPERATION: fetch_url", + `CACHE: ${result.cacheStatus}`, + "---", + result.markdown, + ].join("\n"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error("failed to fetch URL", { url, error: errorMessage }); + + if (errorMessage.toLowerCase().includes("timeout")) { + return toolError({ + code: "TIMEOUT", + message: `Request timed out while fetching: ${url}`, + recoveryHint: "The page may be slow to load or unreachable.", + }); + } + if (errorMessage.includes("ENOTFOUND") || errorMessage.includes("ERR_NAME_NOT_RESOLVED")) { + return toolError({ + code: "OPERATION_FAILED", + message: `Failed to resolve hostname: ${url}`, + recoveryHint: "Check the URL and ensure the domain is accessible.", + }); + } + if (errorMessage.includes("ERR_ABORTED") || errorMessage.includes("404")) { + return toolError({ + code: "NOT_FOUND", + message: `Page not found: ${url}`, + recoveryHint: "The URL may be incorrect or the page may have been removed.", + }); + } + + return toolError({ + code: "OPERATION_FAILED", + message: `Failed to fetch URL: ${errorMessage}`, + }); + } + } +} diff --git a/gadget-drone/src/tools/file/index.ts b/gadget-drone/src/tools/file/index.ts new file mode 100644 index 0000000..2d7c618 --- /dev/null +++ b/gadget-drone/src/tools/file/index.ts @@ -0,0 +1,7 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +export { FileReadTool } from "./read.ts"; +export { FileWriteTool } from "./write.ts"; +export { FileEditTool } from "./edit.ts"; +export { FetchUrlTool } from "./fetch-url.ts"; diff --git a/gadget-drone/src/tools/file/read.ts b/gadget-drone/src/tools/file/read.ts new file mode 100644 index 0000000..b2a7f6a --- /dev/null +++ b/gadget-drone/src/tools/file/read.ts @@ -0,0 +1,137 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import fs from "node:fs/promises"; + +import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; +import { DroneTool } from "../tool.ts"; +import { + asPositiveInteger, + formatNumberedLines, + isBinaryBuffer, + resolveProjectPath, + toolError, +} from "./common.ts"; + +export class FileReadTool extends DroneTool { + get name(): string { + return "file_read"; + } + + get category(): string { + return "file"; + } + + public definition: IToolDefinition = { + type: "function", + function: { + name: this.name, + description: "Read a project file with line numbers. Supports startLine and endLine ranges. Binary files cannot be displayed.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file to read, relative to the project root." }, + startLine: { type: "number", description: "Starting line number (1-indexed). Defaults to 1." }, + endLine: { type: "number", description: "Ending line number (inclusive). Defaults to end of file." }, + }, + required: ["path"], + }, + }, + }; + + public async execute(args: IToolArguments, logger: IAiLogger): Promise { + const filePath = args.path; + if (typeof filePath !== "string" || filePath.trim().length === 0) { + return toolError({ + code: "MISSING_PARAMETER", + message: "File path must not be empty.", + parameter: "path", + recoveryHint: "Provide a valid file path within the project directory.", + }); + } + + const startLine = asPositiveInteger(args.startLine) ?? 1; + const endLine = args.endLine === undefined ? undefined : asPositiveInteger(args.endLine); + + if (startLine < 1) { + return toolError({ + code: "INVALID_PARAMETER", + message: "startLine must be >= 1.", + parameter: "startLine", + expected: "A positive integer >= 1", + }); + } + if (endLine !== undefined && endLine < startLine) { + return toolError({ + code: "INVALID_PARAMETER", + message: "endLine must be >= startLine.", + parameter: "endLine", + expected: "An integer >= startLine", + }); + } + + const resolved = resolveProjectPath(this.toolbox, filePath); + if (typeof resolved === "string") return resolved; + + try { + const stat = await fs.stat(resolved.absolutePath); + if (!stat.isFile()) { + return toolError({ + code: "INVALID_PARAMETER", + message: `"${resolved.displayPath}" is not a file.`, + parameter: "path", + recoveryHint: "Provide a path to a regular file, not a directory.", + }); + } + + const raw = await fs.readFile(resolved.absolutePath); + if (isBinaryBuffer(raw)) { + return [ + `PATH: ${resolved.displayPath}`, + "TOTAL LINES: 0", + "LINES SHOWN: 0", + "FILE OPERATION: read", + "---", + `Binary file, cannot display: ${resolved.displayPath}`, + ].join("\n"); + } + + const content = raw.toString("utf-8"); + const lines = content.split("\n"); + const startIdx = Math.max(0, startLine - 1); + const endIdx = endLine !== undefined ? Math.min(endLine, lines.length) : lines.length; + const selectedLines = lines.slice(startIdx, endIdx); + const numberedLines = formatNumberedLines(selectedLines, startIdx); + const totalLines = lines.length; + const rangeLabel = endLine !== undefined || startLine > 1 + ? `lines ${startIdx + 1}-${endIdx} of ${totalLines}` + : `${totalLines} lines`; + + return [ + `PATH: ${resolved.displayPath}`, + `TOTAL LINES: ${totalLines}`, + `LINES SHOWN: ${selectedLines.length}`, + "FILE OPERATION: read", + "---", + `File: ${resolved.displayPath} (${rangeLabel})`, + "", + numberedLines, + ].join("\n"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("ENOENT")) { + return toolError({ + code: "NOT_FOUND", + message: `File not found: ${resolved.displayPath}`, + parameter: "path", + recoveryHint: "Check the file path and ensure the file exists.", + }); + } + logger.error("failed to read file", { path: resolved.displayPath, error: errorMessage }); + return toolError({ + code: "OPERATION_FAILED", + message: `Failed to read file: ${errorMessage}`, + }); + } + } +} diff --git a/gadget-drone/src/tools/file/web-fetcher.ts b/gadget-drone/src/tools/file/web-fetcher.ts new file mode 100644 index 0000000..ccb3a80 --- /dev/null +++ b/gadget-drone/src/tools/file/web-fetcher.ts @@ -0,0 +1,176 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { Readability } from "@mozilla/readability"; +import { JSDOM } from "jsdom"; +import { chromium, type Browser, type Page } from "playwright"; +import TurndownService from "turndown"; + +export interface FetchResult { + url: string; + title: string; + markdown: string; + totalLineCount: number; + shownLineCount: number; + cacheStatus: "hit" | "miss" | "disabled"; +} + +export class WebFetcher { + private turndown: TurndownService; + + constructor(private cacheDir: string) { + this.turndown = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + hr: "---", + }); + this.turndown.remove([ + "script", + "style", + "noscript", + "nav", + "footer", + "header", + "button", + "input", + "form", + ]); + } + + async fetchUrlWithRange( + url: string, + startLine: number = 1, + endLine?: number, + useCache: boolean = true, + ): Promise { + const result = await this.fetchUrl(url, useCache); + + if (startLine === 1 && endLine === undefined) { + return result; + } + + const lines = result.markdown.split("\n"); + const startIdx = Math.max(0, startLine - 1); + const endIdx = endLine !== undefined ? Math.min(endLine, lines.length) : lines.length; + const selectedLines = lines.slice(startIdx, endIdx); + + return { + ...result, + markdown: selectedLines.join("\n"), + shownLineCount: selectedLines.length, + }; + } + + private async fetchUrl(url: string, useCache: boolean): Promise { + if (useCache) { + const cached = await this.readFromCache(url); + if (cached) { + return { ...cached, cacheStatus: "hit" }; + } + } + + const fetched = await this.fetchFresh(url, useCache ? "miss" : "disabled"); + if (useCache) { + await this.writeToCache(url, fetched.title, fetched.markdown); + } + return fetched; + } + + private async fetchFresh( + url: string, + cacheStatus: FetchResult["cacheStatus"], + ): Promise { + const browser: Browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page: Page = await context.newPage(); + + try { + await page.goto(url, { waitUntil: "networkidle", timeout: 30000 }); + const title = await page.title(); + const rawHtml = await page.content(); + const doc = new JSDOM(rawHtml, { url }); + const article = new Readability(doc.window.document).parse(); + + let htmlContent: string; + let extractedTitle = title; + + if (article) { + htmlContent = article.content || ""; + extractedTitle = article.title || title; + } else { + htmlContent = await page.evaluate(() => { + const main = document.querySelector("main") || document.body; + return main.innerHTML; + }); + } + + const markdown = this.addLineNumbers(this.turndown.turndown(htmlContent)); + const lineCount = markdown.split("\n").length; + + return { + url, + title: extractedTitle, + markdown, + totalLineCount: lineCount, + shownLineCount: lineCount, + cacheStatus, + }; + } finally { + await browser.close(); + } + } + + private getCacheFilePath(url: string): string { + const hash = crypto.createHash("sha256").update(url).digest("hex"); + return path.join(this.cacheDir, `fetch-url-${hash}.md`); + } + + private async ensureCacheDir(): Promise { + await fs.mkdir(this.cacheDir, { recursive: true }); + } + + private async readFromCache(url: string): Promise | null> { + try { + const content = await fs.readFile(this.getCacheFilePath(url), "utf-8"); + const lines = content.split("\n"); + const titleLine = lines.find((line) => line.startsWith("## Title:")); + const fetchedLineIndex = lines.findIndex((line) => line.startsWith("## Fetched:")); + const markdownStartIndex = fetchedLineIndex === -1 ? 0 : fetchedLineIndex + 2; + const markdown = lines.slice(markdownStartIndex).join("\n"); + const lineCount = markdown.split("\n").length; + + return { + url, + title: titleLine?.replace("## Title:", "").trim() || "Unknown", + markdown, + totalLineCount: lineCount, + shownLineCount: lineCount, + }; + } catch { + return null; + } + } + + private async writeToCache(url: string, title: string, markdown: string): Promise { + await this.ensureCacheDir(); + const content = [ + `## URL: ${url}`, + `## Title: ${title}`, + `## Fetched: ${new Date().toISOString()}`, + "", + markdown, + ].join("\n"); + await fs.writeFile(this.getCacheFilePath(url), content, "utf-8"); + } + + private addLineNumbers(text: string): string { + return text + .split("\n") + .map((line, index) => `${index + 1}: ${line}`) + .join("\n"); + } +} diff --git a/gadget-drone/src/tools/file/write.ts b/gadget-drone/src/tools/file/write.ts new file mode 100644 index 0000000..e295f9d --- /dev/null +++ b/gadget-drone/src/tools/file/write.ts @@ -0,0 +1,91 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; +import { DroneTool } from "../tool.ts"; +import { resolveProjectPath, toolError } from "./common.ts"; + +export class FileWriteTool extends DroneTool { + get name(): string { + return "file_write"; + } + + get category(): string { + return "file"; + } + + public definition: IToolDefinition = { + type: "function", + function: { + name: this.name, + description: "Create a new project file or overwrite an existing file with the provided UTF-8 content. Parent directories are created automatically.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to create or overwrite, relative to the project root." }, + content: { type: "string", description: "The content to write to the file. Empty string is allowed." }, + }, + required: ["path", "content"], + }, + }, + }; + + public async execute(args: IToolArguments, logger: IAiLogger): Promise { + const filePath = args.path; + const content = args.content; + + if (typeof filePath !== "string" || filePath.trim().length === 0) { + return toolError({ + code: "MISSING_PARAMETER", + message: "File path must not be empty.", + parameter: "path", + recoveryHint: "Provide a valid file path within the project directory.", + }); + } + if (content === undefined || typeof content !== "string") { + return toolError({ + code: "MISSING_PARAMETER", + message: "Content must be a string and must not be undefined.", + parameter: "content", + recoveryHint: "Provide the content to write to the file. Use an empty string to create an empty file.", + }); + } + + const resolved = resolveProjectPath(this.toolbox, filePath); + if (typeof resolved === "string") return resolved; + + try { + await fs.mkdir(path.dirname(resolved.absolutePath), { recursive: true }); + + let created = true; + try { + const stat = await fs.stat(resolved.absolutePath); + created = !stat.isFile(); + } catch { + created = true; + } + + await fs.writeFile(resolved.absolutePath, content, "utf-8"); + const byteCount = Buffer.byteLength(content, "utf-8"); + + return [ + `PATH: ${resolved.displayPath}`, + "FILE OPERATION: write", + `CREATED: ${created ? "true" : "false"}`, + `BYTES WRITTEN: ${byteCount}`, + "---", + `File written: ${resolved.displayPath} (${byteCount} bytes)`, + ].join("\n"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error("failed to write file", { path: resolved.displayPath, error: errorMessage }); + return toolError({ + code: "OPERATION_FAILED", + message: `Failed to write file: ${errorMessage}`, + }); + } + } +} diff --git a/gadget-drone/src/tools/index.ts b/gadget-drone/src/tools/index.ts new file mode 100644 index 0000000..5438577 --- /dev/null +++ b/gadget-drone/src/tools/index.ts @@ -0,0 +1,7 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +export { AiToolbox, type DroneToolboxEnvironment } from "./toolbox.ts"; +export { DroneTool } from "./tool.ts"; +export { GoogleSearchTool } from "./search/google.ts"; +export * from "./file/index.ts"; diff --git a/gadget-drone/src/tools/search/google.ts b/gadget-drone/src/tools/search/google.ts new file mode 100644 index 0000000..5ee7c93 --- /dev/null +++ b/gadget-drone/src/tools/search/google.ts @@ -0,0 +1,237 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import { google } from "googleapis"; + +import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; +import { formatError } from "@gadget/ai"; +import { DroneTool } from "../tool.ts"; + +export interface ISearchResult { + title: string; + link: string; + snippet: string; + image?: string; + position?: number; + displayLink?: string; +} + +export interface ISearchOptions { + num?: number; + siteSearch?: string; + dateRestrict?: string; + fileType?: string; + safe?: "active" | "off"; + sort?: "relevance" | "date"; + start?: number; +} + +export class GoogleSearchTool extends DroneTool { + get name(): string { + return "search_google"; + } + + get category(): string { + return "search"; + } + + public definition: IToolDefinition = { + type: "function", + function: { + name: this.name, + description: "Perform a Google search for relevant information on the web.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "The search query string.", + }, + num_results: { + type: "number", + description: "Number of search results to return (default: 10, max: 10).", + }, + siteSearch: { + type: "string", + description: "Optional site to restrict the search to (e.g. github.com).", + }, + dateRestrict: { + type: "string", + description: "Restricts results to a date range. Examples: d1, d7, d30, d365.", + }, + fileType: { + type: "string", + description: "Restricts results to files of a specified extension. Examples: pdf, doc, xls, ppt.", + }, + sort: { + type: "string", + description: "Sort order for results. Values: relevance or date.", + enum: ["relevance", "date"], + }, + start: { + type: "number", + description: "The index of the first result to return. Default: 1.", + }, + }, + required: ["query"], + }, + }, + }; + + public async execute(args: IToolArguments, logger: IAiLogger): Promise { + const { query } = args; + + if (!query || typeof query !== "string" || query.trim().length === 0) { + return formatError({ + code: "MISSING_PARAMETER", + message: "The 'query' parameter is required.", + parameter: "query", + expected: "A non-empty string containing the search query.", + example: 'search_google(query: "latest AI news")', + recoveryHint: "Provide a 'query' parameter with your search terms and try again.", + }); + } + + logger.debug("performing Google search for agent", { args }); + + try { + const { + num_results = 10, + siteSearch, + dateRestrict, + fileType, + sort, + start, + } = args; + + const results = await this.search(query, { + num: Math.min(num_results as number, 10), + siteSearch: siteSearch as string | undefined, + dateRestrict: dateRestrict as string | undefined, + fileType: fileType as string | undefined, + safe: "active", + sort: sort as "relevance" | "date" | undefined, + start: start as number | undefined, + }); + + if (!results.length) { + return "No relevant search results found."; + } + + const output: string[] = ["Here are some relevant search results I found:", ""]; + for (const result of results) { + output.push(`Title: ${result.title || ""}`); + output.push(`Link: ${result.link || ""}`); + if (result.displayLink) { + output.push(`Source: ${result.displayLink}`); + } + output.push(`Snippet: ${result.snippet || ""}`); + output.push(""); + } + + return output.join("\n"); + } catch (error) { + return this.parseCseError(error); + } + } + + async search(query: string, options: ISearchOptions): Promise { + const apiKey = this.toolbox.env.services?.google?.cse?.apiKey; + const engineId = this.toolbox.env.services?.google?.cse?.engineId; + + if (!apiKey || !engineId) { + throw new Error("Google CSE credentials not configured in drone environment"); + } + + const customSearch = google.customsearch({ + version: "v1", + auth: apiKey, + }); + + const params: Record = { + q: query, + cx: engineId, + num: options.num || 10, + }; + + if (options.siteSearch) params.siteSearch = options.siteSearch; + if (options.dateRestrict) params.dateRestrict = options.dateRestrict; + if (options.fileType) params.fileType = options.fileType; + if (options.safe) params.safe = options.safe; + if (options.sort) params.sort = options.sort; + if (options.start) params.start = options.start; + + const response = await customSearch.cse.list(params); + const results: ISearchResult[] = []; + + if (response.data.items) { + response.data.items.forEach((item, index) => { + const result: ISearchResult = { + title: item.title || "", + link: item.link || "", + snippet: item.snippet || "", + position: index + 1, + displayLink: item.displayLink || "", + }; + + const thumbnail = item.pagemap?.["cse_thumbnail"]?.[0]?.src; + if (typeof thumbnail === "string") { + result.image = thumbnail; + } + + results.push(result); + }); + } + + return results; + } + + private parseCseError(error: unknown): string { + const err = error as { + response?: { data?: { error?: { message?: string } }; status?: number; headers?: Record }; + code?: string; + message?: string; + }; + + if (err.response?.data?.error) { + const apiError = err.response.data.error; + const statusCode = err.response.status; + + switch (statusCode) { + case 401: + return formatError({ + code: "UNAUTHORIZED", + message: `401 Unauthorized - Invalid API key. ${apiError.message || ""}`, + }); + case 403: + return formatError({ + code: "FORBIDDEN", + message: `403 Forbidden - ${apiError.message || "Access denied"}. Check your Engine ID and API key permissions.`, + }); + case 429: + return formatError({ + code: "RATE_LIMIT_EXCEEDED", + message: `429 Too Many Requests - rate limit exceeded. ${apiError.message || ""}`, + }); + default: + return formatError({ + code: "TOOL_EXECUTION_FAILED", + message: `HTTP ${statusCode ?? "unknown"} - ${apiError.message || "Unknown error"}`, + }); + } + } + + if (err.code === "ENOTFOUND") { + return formatError({ + code: "NETWORK_ERROR", + message: "Network error - unable to reach Google API.", + }); + } + + return formatError({ + code: "OPERATION_FAILED", + message: `Failed to perform search: ${err.message || String(error)}`, + recoveryHint: "Please try again or check your search query.", + }); + } +} diff --git a/gadget-drone/src/tools/tool.ts b/gadget-drone/src/tools/tool.ts new file mode 100644 index 0000000..fb8bf2e --- /dev/null +++ b/gadget-drone/src/tools/tool.ts @@ -0,0 +1,28 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import type { + IAiLogger, + IAiTool, + IToolArguments, + IToolDefinition, +} from "@gadget/ai"; +import type { AiToolbox } from "./toolbox.ts"; + +export abstract class DroneTool implements IAiTool { + protected _toolbox: AiToolbox; + + constructor(toolbox: AiToolbox) { + this._toolbox = toolbox; + } + + get toolbox(): AiToolbox { + return this._toolbox; + } + + abstract get name(): string; + abstract get category(): string; + abstract get definition(): IToolDefinition; + + abstract execute(args: IToolArguments, logger: IAiLogger): Promise; +} diff --git a/gadget-drone/src/tools/toolbox.ts b/gadget-drone/src/tools/toolbox.ts new file mode 100644 index 0000000..5905947 --- /dev/null +++ b/gadget-drone/src/tools/toolbox.ts @@ -0,0 +1,70 @@ +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import type { IAiTool } from "@gadget/ai"; + +export interface DroneToolboxEnvironment { + NODE_ENV: string; + services?: { + google?: { + cse?: { + apiKey?: string; + engineId?: string; + }; + }; + }; + workspace?: { + workspaceDir?: string; + projectDir?: string; + cacheDir?: string; + }; +} + +export type ToolMap = Map; +export type ToolSet = Set; + +export class AiToolbox { + private _env: DroneToolboxEnvironment; + private tools: ToolMap = new Map(); + private modeSets: Map = new Map>(); + + constructor(env: DroneToolboxEnvironment) { + this._env = env; + } + + get env(): DroneToolboxEnvironment { + return this._env; + } + + updateWorkspace(workspace: NonNullable): void { + this._env.workspace = workspace; + } + + register(tool: IAiTool, modes?: string[]): void { + if (this.tools.has(tool.name)) { + throw new Error(`tool already registered: ${tool.name}`); + } + this.tools.set(tool.name, tool); + + if (!modes) { + return; + } + + for (const mode of modes) { + let set = this.modeSets.get(mode); + if (!set) { + set = new Set(); + this.modeSets.set(mode, set); + } + set.add(tool); + } + } + + getTool(name: string): IAiTool | undefined { + return this.tools.get(name); + } + + getModeSet(mode: string): ToolSet | undefined { + return this.modeSets.get(mode); + } +} diff --git a/packages/ai/src/api.ts b/packages/ai/src/api.ts index ab8b9ef..c221ef5 100644 --- a/packages/ai/src/api.ts +++ b/packages/ai/src/api.ts @@ -2,7 +2,7 @@ // Licensed under the Apache License, Version 2.0 import { IAiEnvironment } from "./config/env.ts"; -import { AiTool } from "./tools/tool.ts"; +import { IAiTool } from "./tools/tool.ts"; export type AiSdkType = "ollama" | "openai"; @@ -82,8 +82,7 @@ export interface IAiChatOptions { systemPrompt?: string; userPrompt?: string; context?: IContextChatMessage[]; - tools?: AiTool[]; - maxToolIterations?: number; + tools?: IAiTool[]; } export interface IAiChatResponse { @@ -97,7 +96,7 @@ export interface IAiChatResponse { } export interface IAiStreamChunk { - type: 'thinking' | 'response' | 'toolCall'; + type: "thinking" | "response" | "toolCall"; data: string; toolCallId?: string; toolName?: string; @@ -176,9 +175,100 @@ export abstract class AiApi { streamCallback?: IAiResponseStreamFn, ): Promise; + protected shouldContinueAfterNonToolResponse(response: string): boolean { + const normalized = response.trim().toLowerCase(); + if (!normalized) { + return false; + } + + const futureIntentPatterns = [ + /\bi\s*(?:will|'ll|am going to)\b/, + /\blet me\b/, + /\bi need to\b/, + /\bi should\b/, + /\bi(?:'m| am) going to\b/, + /\bnext,? i\b/, + /\bi(?:'ll| will) inspect\b/, + /\bi(?:'ll| will) read\b/, + /\bi(?:'ll| will) open\b/, + /\bi(?:'ll| will) check\b/, + /\bi(?:'ll| will) update\b/, + /\bi(?:'ll| will) modify\b/, + /\bi(?:'ll| will) fix\b/, + ]; + + return futureIntentPatterns.some((pattern) => pattern.test(normalized)); + } + + protected assertNonEmptyChatResponse(response: IAiChatResponse): void { + const hasResponse = response.response.trim().length > 0; + const hasThinking = !!response.thinking?.trim(); + const hasToolCalls = !!response.toolCalls?.length; + const hasToolResults = !!response.toolCallResults?.length; + + if (!hasResponse && !hasThinking && !hasToolCalls && !hasToolResults) { + throw new Error( + "Provider returned an empty chat response: no text, thinking, tool calls, or tool results.", + ); + } + } + + protected buildContinuationPrompt(): string { + return [ + "You stopped after describing future work instead of performing it.", + "Do not explain what you are about to do.", + "Call the appropriate tool now, or provide a final answer only if no tool use is needed.", + ].join(" "); + } + + protected shouldContinueForUserWorkRequest( + userPrompt: string | undefined, + response: string, + hasExecutedToolsThisTurn: boolean, + ): boolean { + if (hasExecutedToolsThisTurn) { + return false; + } + + const prompt = (userPrompt || "").toLowerCase(); + const answer = response.trim().toLowerCase(); + if (!prompt || !answer) { + return false; + } + + const workRequestPatterns = [ + /\bread\b/, + /\bfix\b/, + /\bchange\b/, + /\bupdate\b/, + /\bmodify\b/, + /\bedit\b/, + /\bwrite\b/, + /\bcreate\b/, + /\bimplement\b/, + /\bdebug\b/, + /\binspect\b/, + /\bopen\b/, + ]; + const directAnswerPatterns = [ + /\bcompleted\b/, + /\bfixed\b/, + /\bupdated\b/, + /\bchanged\b/, + /\bimplemented\b/, + /\bno tool use (?:is|was) needed\b/, + /\bdoes not require tool use\b/, + ]; + + return ( + workRequestPatterns.some((pattern) => pattern.test(prompt)) && + !directAnswerPatterns.some((pattern) => pattern.test(answer)) + ); + } + protected async executeToolCalls( toolCalls: IToolCall[], - tools: AiTool[], + tools: IAiTool[], ): Promise { const results: IToolCallResult[] = []; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 18c260c..1c6c57d 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -23,8 +23,12 @@ export { type IAiModelProbeResult, } from "./api.js"; -export * from "./tools/search/google.ts"; -export { AiToolbox } from "./toolbox.js"; +export type { + IAiTool, + IToolArguments, + IToolDefinition, +} from "./tools/tool.js"; +export { formatError, type IToolError, type ToolErrorCode } from "./tools/tool-error.ts"; export { OllamaAiApi } from "./ollama.js"; export { OpenAiApi } from "./openai.js"; diff --git a/packages/ai/src/ollama.test.ts b/packages/ai/src/ollama.test.ts index c11bc4b..cafcdca 100644 --- a/packages/ai/src/ollama.test.ts +++ b/packages/ai/src/ollama.test.ts @@ -114,39 +114,54 @@ describe('OllamaAiApi', () => { // Verify response expect(response.done).toBe(true); expect(response.doneReason).toBe('stop'); - expect(response.response).toBe('!'); + expect(response.response).toBe('Hello world!'); }); it('should handle tool calls', async () => { // Mock streaming response with tool call - const mockStream = async function* () { - yield { - message: { - content: '', - tool_calls: [ - { - function: { - name: 'search_google', - arguments: { query: 'test query' }, - }, + let callCount = 0; + mockOllamaClient.chat.mockImplementation(() => { + callCount++; + return (async function* () { + if (callCount === 1) { + yield { + message: { + content: '', + tool_calls: [ + { + function: { + name: 'search_google', + arguments: { query: 'test query' }, + }, + }, + ], }, - ], - }, - done: false, - }; - yield { - message: { content: '' }, - done: true, - done_reason: 'stop', - total_duration: 100, - prompt_eval_count: 10, - eval_count: 1, - }; - }; - - mockOllamaClient.chat.mockResolvedValue(mockStream()); + done: false, + }; + yield { + message: { content: '' }, + done: true, + done_reason: 'stop', + total_duration: 100, + prompt_eval_count: 10, + eval_count: 1, + }; + } else { + yield { + message: { content: 'Done' }, + done: true, + done_reason: 'stop', + total_duration: 100, + prompt_eval_count: 10, + eval_count: 1, + }; + } + })(); + }); const mockTool = { + name: 'search_google', + category: 'search', definition: { type: 'function', function: { @@ -155,7 +170,7 @@ describe('OllamaAiApi', () => { parameters: { type: 'object', properties: {} }, }, }, - execute: vi.fn().mockResolvedValue({ result: 'search results' }), + execute: vi.fn().mockResolvedValue('search results'), }; const streamCallback = vi.fn(); @@ -251,7 +266,7 @@ describe('OllamaAiApi', () => { expect(response.thinking).toBe('Let me think about this... The answer is'); }); - it('should handle empty response on load failure', async () => { + it('should reject empty response on load failure', async () => { // Mock streaming response with load failure const mockStream = async function* () { yield { @@ -266,8 +281,7 @@ describe('OllamaAiApi', () => { mockOllamaClient.chat.mockResolvedValue(mockStream()); - const streamCallback = vi.fn(); - const response = await api.chat( + await expect(api.chat( { provider: mockProvider as any, modelId: 'test-model', @@ -277,16 +291,8 @@ describe('OllamaAiApi', () => { userPrompt: 'Test prompt', context: [], }, - streamCallback, - ); - - // Verify response indicates load failure - expect(response.done).toBe(true); - expect(response.doneReason).toBe('load'); - expect(response.response).toBe(''); - - // Verify no stream callbacks for empty content - expect(streamCallback).not.toHaveBeenCalled(); + vi.fn(), + )).rejects.toThrow('Provider returned an empty chat response'); }); it('should iterate tool calling loop when tools are present', async () => { @@ -335,6 +341,8 @@ describe('OllamaAiApi', () => { mockOllamaClient.chat.mockImplementation(() => mockStream()); const mockTool = { + name: 'search_google', + category: 'search', definition: { type: 'function', function: { @@ -343,7 +351,7 @@ describe('OllamaAiApi', () => { parameters: { type: 'object', properties: {} }, }, }, - execute: vi.fn().mockResolvedValue({ result: 'search results' }), + execute: vi.fn().mockResolvedValue('search results'), }; const streamCallback = vi.fn(); diff --git a/packages/ai/src/ollama.ts b/packages/ai/src/ollama.ts index 3201117..6a4962a 100644 --- a/packages/ai/src/ollama.ts +++ b/packages/ai/src/ollama.ts @@ -213,9 +213,6 @@ export class OllamaAiApi extends AiApi { throw new Error("userPrompt is required and cannot be empty"); } - const maxIterations = options.maxToolIterations ?? 5; - let iteration = 0; - // Build messages array like OpenAI does const messages: OllamaMessage[] = []; @@ -276,9 +273,11 @@ export class OllamaAiApi extends AiApi { let totalAccumulatedResponse = ""; let totalAccumulatedThinking = ""; - while (iteration < maxIterations) { - iteration++; - + /* + * Our agents do not have iteration count limits. We have seen an agent + * issue 100+ legitimate calls in a single turn. + */ + while (true) { const ollamaTools = options.tools ? options.tools.map((tool) => ({ type: tool.definition.type, @@ -309,44 +308,38 @@ export class OllamaAiApi extends AiApi { for await (const chunk of response) { lastChunk = chunk; - if (streamCallback) { - if (chunk.message.thinking) { - accumulatedThinking += chunk.message.thinking; + if (chunk.message.thinking) { + accumulatedThinking += chunk.message.thinking; + if (streamCallback) { await streamCallback({ type: "thinking", data: chunk.message.thinking, }); } - if (chunk.message.content) { - accumulatedResponse += chunk.message.content; + } + if (chunk.message.content) { + accumulatedResponse += chunk.message.content; + if (streamCallback) { await streamCallback({ type: "response", data: chunk.message.content, }); } - if (chunk.message.tool_calls) { - for (const tc of chunk.message.tool_calls) { - const params = JSON.stringify(tc.function.arguments); - const callId = `tool_${tc.function.name}_${Date.now()}`; + } + if (chunk.message.tool_calls) { + for (const [index, tc] of chunk.message.tool_calls.entries()) { + const params = JSON.stringify(tc.function.arguments); + const callId = `tool_${tc.function.name}_${Date.now()}_${index}`; - const toolCall: IToolCall = { - callId, - function: { - name: tc.function.name, - arguments: JSON.stringify(tc.function.arguments), - }, - }; - streamedToolCalls.push(toolCall); - allToolCalls.push(toolCall); - - await streamCallback({ - type: "toolCall", - data: params, - toolCallId: callId, - toolName: tc.function.name, - params, - }); - } + const toolCall: IToolCall = { + callId, + function: { + name: tc.function.name, + arguments: params, + }, + }; + streamedToolCalls.push(toolCall); + allToolCalls.push(toolCall); } } } @@ -364,7 +357,30 @@ export class OllamaAiApi extends AiApi { const toolCalls = streamedToolCalls; if (!toolCalls || toolCalls.length === 0) { - return { + if ( + options.tools?.length && + (this.shouldContinueAfterNonToolResponse(finalResponse || "") || + this.shouldContinueForUserWorkRequest( + options.userPrompt, + finalResponse || "", + allToolCallResults.length > 0, + )) + ) { + await this.log.warn("model produced future-intent text without tool calls; continuing AWL", { + responseLength: (finalResponse || "").length, + }); + messages.push({ + role: "assistant", + content: finalResponse || "", + }); + messages.push({ + role: "user", + content: this.buildContinuationPrompt(), + }); + continue; + } + + const chatResponse: IAiChatResponse = { done: lastChunk.done, doneReason: lastChunk.done_reason, response: totalAccumulatedResponse, @@ -384,6 +400,8 @@ export class OllamaAiApi extends AiApi { }, }, }; + this.assertNonEmptyChatResponse(chatResponse); + return chatResponse; } const toolCallResults = await this.executeToolCalls( @@ -392,6 +410,19 @@ export class OllamaAiApi extends AiApi { ); allToolCallResults.push(...toolCallResults); + if (streamCallback) { + for (const result of toolCallResults) { + const toolCall = toolCalls.find((tc) => tc.callId === result.callId); + await streamCallback({ + type: "toolCall", + data: result.error || result.result, + toolCallId: result.callId, + toolName: result.functionName, + params: toolCall?.function.arguments || "{}", + }); + } + } + const assistantMsg: OllamaMessage = { role: "assistant", content: accumulatedResponse || lastChunk.message.content, @@ -425,25 +456,5 @@ export class OllamaAiApi extends AiApi { }); } } - - return { - done: false, - doneReason: "max_tool_iterations_reached", - response: "", - thinking: undefined, - toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined, - toolCallResults: allToolCallResults, - stats: { - duration: { - seconds: 0, - text: "00:00:00", - }, - tokenCounts: { - input: 0, - response: 0, - thinking: 0, - }, - }, - }; } } diff --git a/packages/ai/src/openai.test.ts b/packages/ai/src/openai.test.ts new file mode 100644 index 0000000..310f090 --- /dev/null +++ b/packages/ai/src/openai.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockCreate = vi.fn(); +const mockList = vi.fn(); +const mockRetrieve = vi.fn(); + +vi.mock("openai", () => { + return { + default: class MockOpenAI { + chat = { + completions: { + create: mockCreate, + }, + }; + models = { + list: mockList, + retrieve: mockRetrieve, + }; + }, + }; +}); + +import { OpenAiApi } from "./openai"; + +const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +const mockEnv = { NODE_ENV: "test", services: {} }; +const mockProvider = { + _id: "provider-openai", + name: "OpenAI Compatible", + sdk: "openai" as const, + baseUrl: "https://example.test/v1", + apiKey: "test-key", +}; + +async function* streamChunks(chunks: unknown[]) { + for (const chunk of chunks) { + yield chunk; + } +} + +describe("OpenAiApi", () => { + let api: OpenAiApi; + + beforeEach(() => { + vi.clearAllMocks(); + api = new OpenAiApi(mockEnv as any, mockProvider as any, mockLogger as any); + }); + + it("rejects an empty streaming and fallback response", async () => { + mockCreate + .mockResolvedValueOnce(streamChunks([ + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ])) + .mockResolvedValueOnce({ + choices: [{ message: { content: "", tool_calls: [] }, finish_reason: "stop" }], + }); + + await expect(api.chat( + { + provider: mockProvider as any, + modelId: "test-model", + params: { reasoning: false, temperature: 0.8, topP: 0.9, topK: 40 }, + }, + { userPrompt: "Hello", context: [], tools: [] }, + vi.fn(), + )).rejects.toThrow("Provider returned an empty chat response"); + }); + + it("assembles streamed tool-call argument fragments before executing", async () => { + mockCreate + .mockResolvedValueOnce(streamChunks([ + { + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: "call_1", + type: "function", + function: { name: "file_read", arguments: '{"path"' }, + }], + }, + finish_reason: null, + }], + }, + { + choices: [{ + delta: { + tool_calls: [{ + index: 0, + function: { arguments: ':"index.html"}' }, + }], + }, + finish_reason: "tool_calls", + }], + }, + ])) + .mockResolvedValueOnce(streamChunks([ + { choices: [{ delta: { content: "Done" }, finish_reason: "stop" }] }, + ])); + + const tool = { + name: "file_read", + category: "file", + definition: { + type: "function" as const, + function: { + name: "file_read", + description: "Read file", + parameters: { type: "object", properties: {} }, + }, + }, + execute: vi.fn().mockResolvedValue("PATH: index.html\n---\ncontent"), + }; + const streamCallback = vi.fn(); + + const response = await api.chat( + { + provider: mockProvider as any, + modelId: "test-model", + params: { reasoning: false, temperature: 0.8, topP: 0.9, topK: 40 }, + }, + { userPrompt: "Read index.html", context: [], tools: [tool] }, + streamCallback, + ); + + expect(tool.execute).toHaveBeenCalledWith({ path: "index.html" }, mockLogger); + expect(streamCallback).toHaveBeenCalledWith(expect.objectContaining({ + type: "toolCall", + toolCallId: "call_1", + toolName: "file_read", + data: "PATH: index.html\n---\ncontent", + params: '{"path":"index.html"}', + })); + expect(response.response).toBe("Done"); + expect(mockCreate).toHaveBeenCalledTimes(2); + }); + + it("falls back to non-streaming response when stream has no deltas", async () => { + mockCreate + .mockResolvedValueOnce(streamChunks([ + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ])) + .mockResolvedValueOnce({ + choices: [{ message: { content: "Fallback answer", tool_calls: [] }, finish_reason: "stop" }], + }); + const streamCallback = vi.fn(); + + const response = await api.chat( + { + provider: mockProvider as any, + modelId: "test-model", + params: { reasoning: false, temperature: 0.8, topP: 0.9, topK: 40 }, + }, + { userPrompt: "Hello", context: [], tools: [] }, + streamCallback, + ); + + expect(response.response).toBe("Fallback answer"); + expect(streamCallback).toHaveBeenCalledWith({ type: "response", data: "Fallback answer" }); + }); +}); diff --git a/packages/ai/src/openai.ts b/packages/ai/src/openai.ts index da164b9..83ae059 100644 --- a/packages/ai/src/openai.ts +++ b/packages/ai/src/openai.ts @@ -57,6 +57,16 @@ interface OpenAIModelInfo { context_window?: number; } +interface StreamingToolCallAccumulator { + index: number; + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + export class OpenAiApi extends AiApi { protected client: OpenAI; @@ -246,9 +256,6 @@ export class OpenAiApi extends AiApi { }); const startTime = Date.now(); - const maxIterations = options.maxToolIterations ?? 5; - let iteration = 0; - const messages: ChatCompletionMessageParam[] = []; if (options.systemPrompt) { messages.push({ role: "system", content: options.systemPrompt }); @@ -276,9 +283,7 @@ export class OpenAiApi extends AiApi { const allToolCallResults: IToolCallResult[] = []; const allToolCalls: IToolCall[] = []; - while (iteration < maxIterations) { - iteration++; - + while (true) { const tools: ChatCompletionTool[] = options.tools ? options.tools.map((tool) => { const openaiTool: ChatCompletionFunctionTool = { @@ -310,12 +315,24 @@ export class OpenAiApi extends AiApi { let accumulatedResponse = ""; let accumulatedThinking = ""; - let finalToolCalls: any = undefined; + let chunkCount = 0; + let contentDeltaCount = 0; + let toolDeltaCount = 0; + let finishReason: string | null | undefined; + const toolCallMap = new Map(); + let assistantToolCallsForMessage: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + }> = []; for await (const chunk of response) { + chunkCount++; + finishReason = chunk.choices[0]?.finish_reason ?? finishReason; const delta = chunk.choices[0]?.delta; if (delta) { if (delta.content) { + contentDeltaCount++; accumulatedResponse += delta.content; if (streamCallback) { await streamCallback({ @@ -334,44 +351,104 @@ export class OpenAiApi extends AiApi { } } if (delta.tool_calls) { - finalToolCalls = delta.tool_calls; + toolDeltaCount += delta.tool_calls.length; for (const tc of delta.tool_calls) { - if (tc.function) { - const toolCall: IToolCall = { - callId: tc.id || "", + const index = tc.index; + let accumulated = toolCallMap.get(index); + if (!accumulated) { + accumulated = { + index, + id: tc.id || `tool_${Date.now()}_${index}`, + type: "function", function: { - name: tc.function.name || "", - arguments: tc.function.arguments || "", + name: "", + arguments: "", }, }; - allToolCalls.push(toolCall); - if (streamCallback) { - await streamCallback({ - type: "toolCall", - data: tc.function.arguments || "", - toolCallId: tc.id, - toolName: tc.function.name, - params: tc.function.arguments, - }); - } + toolCallMap.set(index, accumulated); + } + + if (tc.id) { + accumulated.id = tc.id; + } + if (tc.function?.name) { + accumulated.function.name += tc.function.name; + } + if (tc.function?.arguments) { + accumulated.function.arguments += tc.function.arguments; } } } } } - const toolCalls = finalToolCalls - ?.filter((tc: any) => tc.type === "function") - .map((tc: any) => ({ + const finalToolCalls = Array.from(toolCallMap.values()) + .sort((a, b) => a.index - b.index) + .filter((tc) => tc.function.name); + const toolCalls = finalToolCalls.map((tc) => ({ callId: tc.id, function: { name: tc.function.name, arguments: tc.function.arguments, }, })); + assistantToolCallsForMessage = finalToolCalls.map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + })); + allToolCalls.push(...toolCalls); + + await this.log.debug("OpenAI chat stream iteration finished", { + chunkCount, + contentDeltaCount, + toolDeltaCount, + responseLength: accumulatedResponse.length, + thinkingLength: accumulatedThinking.length, + toolCallCount: toolCalls.length, + finishReason, + }); + + if (chunkCount > 0 && !accumulatedResponse && !accumulatedThinking && toolCalls.length === 0) { + const fallback = await this.chatOnceNonStreaming(model, messages, tools); + accumulatedResponse = fallback.response; + accumulatedThinking = fallback.thinking || ""; + toolCalls.push(...fallback.toolCalls); + allToolCalls.push(...fallback.toolCalls); + assistantToolCallsForMessage = fallback.assistantToolCalls; + if (streamCallback && fallback.response) { + await streamCallback({ type: "response", data: fallback.response }); + } + await this.log.warn("OpenAI stream was empty; used non-streaming fallback", { + responseLength: accumulatedResponse.length, + thinkingLength: accumulatedThinking.length, + toolCallCount: fallback.toolCalls.length, + finishReason, + }); + } if (!toolCalls || toolCalls.length === 0) { - return { + if ( + options.tools?.length && + (this.shouldContinueAfterNonToolResponse(accumulatedResponse) || + this.shouldContinueForUserWorkRequest( + options.userPrompt, + accumulatedResponse, + allToolCallResults.length > 0, + )) + ) { + await this.log.warn("model produced future-intent text without tool calls; continuing AWL", { + responseLength: accumulatedResponse.length, + }); + messages.push({ role: "assistant", content: accumulatedResponse }); + messages.push({ role: "user", content: this.buildContinuationPrompt() }); + continue; + } + + const finalResponse: IAiChatResponse = { done: true, response: accumulatedResponse, thinking: accumulatedThinking || undefined, @@ -390,6 +467,8 @@ export class OpenAiApi extends AiApi { }, }, }; + this.assertNonEmptyChatResponse(finalResponse); + return finalResponse; } const toolCallResults = await this.executeToolCalls( @@ -398,12 +477,25 @@ export class OpenAiApi extends AiApi { ); allToolCallResults.push(...toolCallResults); + if (streamCallback) { + for (const result of toolCallResults) { + const toolCall = toolCalls.find((tc) => tc.callId === result.callId); + await streamCallback({ + type: "toolCall", + data: result.error || result.result, + toolCallId: result.callId, + toolName: result.functionName, + params: toolCall?.function.arguments || "{}", + }); + } + } + const assistantMsg: ChatCompletionAssistantMessageParam = { role: "assistant", content: accumulatedResponse, }; - if (finalToolCalls) { - assistantMsg.tool_calls = finalToolCalls; + if (assistantToolCallsForMessage.length) { + assistantMsg.tool_calls = assistantToolCallsForMessage; } messages.push(assistantMsg); @@ -416,28 +508,64 @@ export class OpenAiApi extends AiApi { messages.push(toolMsg); } } + } - const endTime = Date.now(); - const durationMs = endTime - startTime; + private async chatOnceNonStreaming( + model: IAiModelConfig, + messages: ChatCompletionMessageParam[], + tools: ChatCompletionTool[], + ): Promise<{ + response: string; + thinking?: string; + toolCalls: IToolCall[]; + assistantToolCalls: Array<{ + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; + }>; + }> { + const response = await this.client.chat.completions.create({ + model: model.modelId, + messages, + tools, + stream: false, + ...(typeof model.params.reasoning === "string" + ? { + reasoning_effort: model.params.reasoning as + | "low" + | "medium" + | "high", + } + : {}), + }); + const choice = response.choices[0]; + const message = choice?.message; + const content = typeof message?.content === "string" ? message.content : ""; + const assistantToolCalls = (message?.tool_calls || []) + .filter((tc) => tc.type === "function") + .map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + })); + const toolCalls: IToolCall[] = assistantToolCalls.map((tc) => ({ + callId: tc.id, + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + })); return { - done: false, - doneReason: "max_tool_iterations_reached", - response: "", - thinking: undefined, - toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined, - toolCallResults: allToolCallResults, - stats: { - duration: { - seconds: durationMs / 1000, - text: numeral(durationMs / 1000).format("hh:mm:ss"), - }, - tokenCounts: { - input: 0, - response: 0, - thinking: 0, - }, - }, + response: content, + toolCalls, + assistantToolCalls, }; } } diff --git a/packages/ai/src/toolbox.ts b/packages/ai/src/toolbox.ts deleted file mode 100644 index 98e09f4..0000000 --- a/packages/ai/src/toolbox.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (C) 2026 Rob Colbert -// Licensed under the Apache License, Version 2.0 - -import { IAiEnvironment } from "./config/env.ts"; -import { AiTool } from "./tools/tool.ts"; - -export type ToolMap = Map; -export type ToolSet = Set; - -/** - * No. I don't want to create an "MCP" server. I just want an in-process - * toolbox that the agents can use in their daily work. That's not too much to - * ask, dammit. - */ -export class AiToolbox { - private _env: IAiEnvironment; - get env(): IAiEnvironment { - return this._env; - } - - private tools: ToolMap = new Map(); - private modeSets: Map = new Map>(); - - constructor(env: IAiEnvironment) { - this._env = env; - } - - /** - * Registers an AiTool instance for use by the platform. If no ChatSessionMode - * modes are specified, you are registering a system tool - such as the chat - * session auto-naming tool - which are not called by agents (they are called) - * by the platform itself, deterministically. - * @param tool the tool being registered for use by the platform - * @param modes the optional name(s) of the mode for which the tool is being - * registered - */ - register(tool: AiTool, modes?: string[]): void { - if (this.tools.has(tool.name)) { - throw new Error(`tool already registered: ${tool.name}`); - } - this.tools.set(tool.name, tool); - - if (!modes) { - return; // system tools aren't listed in the modes for agent use - } - - for (const mode of modes) { - let set = this.modeSets.get(mode); - if (!set) { - set = new Set(); - this.modeSets.set(mode, set); - } - set.add(tool); - } - } - - /** - * Retrieve a tool instance from the toolbox by name, ignoring mode(s). This - * is how the system fetches system tools for use. - * @param name the name of the tool to be retrieved - * @returns the tool, or undefined if the tool is not registered - */ - getTool(name: string): AiTool | undefined { - return this.tools.get(name); - } - - /** - * Retrieves the set of tools registered for use in a given ChatSessionMode. - * @param mode the ChatSessionMode for which a set of tools is being requested - * @returns the set of tools, or undefined if there is not set for the mode - * @todo the mode parameter should be the ChatSessionMode enum - */ - getModeSet(mode: string): ToolSet | undefined { - return this.modeSets.get(mode); - } -} diff --git a/packages/ai/src/tools/search/google.ts b/packages/ai/src/tools/search/google.ts deleted file mode 100644 index 6c11894..0000000 --- a/packages/ai/src/tools/search/google.ts +++ /dev/null @@ -1,282 +0,0 @@ -// Copyright (C) 2026 Rob Colbert -// Licensed under the Apache License, Version 2.0 - -import { IAiLogger } from "../../api.ts"; -import { formatError } from "../tool-error.ts"; -import { AiTool, IToolArguments, IToolDefinition } from "../tool.ts"; - -import { google } from "googleapis"; -// import SearchService, { SearchServiceError } from "../../services/search.js"; - -export interface ISearchResult { - title: string; - link: string; - snippet: string; - image?: string; - position?: number; - displayLink?: string; -} - -export interface ISearchOptions { - num?: number; - siteSearch?: string; - dateRestrict?: string; - fileType?: string; - safe?: "active" | "off"; - sort?: "relevance" | "date"; - start?: number; -} - -export class GoogleSearchTool extends AiTool { - get name(): string { - return "search_google"; - } - - get category(): string { - return "search"; - } - - public definition: IToolDefinition = { - type: "function", - function: { - name: this.name, - description: - "Perform a Google search for relevant information on the web.", - parameters: { - type: "object", - properties: { - query: { - type: "string", - description: "The search query string.", - }, - num_results: { - type: "number", - description: - "Number of search results to return (default: 10, max: 10).", - }, - siteSearch: { - type: "string", - description: - "Optional site to restrict the search to (e.g. github.com).", - }, - dateRestrict: { - type: "string", - description: - "Restricts results to documents based on a date range. Examples: d1 (last day), d7 (last week), d30 (last month), d365 (last year).", - }, - fileType: { - type: "string", - description: - "Restricts results to files of a specified extension. Examples: pdf, doc, xls, ppt.", - }, - sort: { - type: "string", - description: - "Sort order for results. Values: 'relevance' (default) or 'date'.", - enum: ["relevance", "date"], - }, - start: { - type: "number", - description: - "The index of the first result to return (for pagination). Default: 1.", - }, - }, - required: ["query"], - }, - }, - }; - - public async execute( - args: IToolArguments, - logger: IAiLogger, - ): Promise { - const { query } = args; - - if (!query || typeof query !== "string" || query.trim().length === 0) { - return formatError({ - code: "MISSING_PARAMETER", - message: "The 'query' parameter is required.", - parameter: "query", - expected: "A non-empty string containing the search query.", - example: 'search_google(query: "latest AI news")', - recoveryHint: - "Provide a 'query' parameter with your search terms and try again.", - }); - } - - logger.debug("performing Google search for user", { args }); - - try { - const { - num_results = 10, - siteSearch, - dateRestrict, - fileType, - sort, - start, - } = args; - - const results = await this.search(query, { - num: Math.min(num_results as number, 10), - siteSearch: siteSearch as string | undefined, - dateRestrict: dateRestrict as string | undefined, - fileType: fileType as string | undefined, - safe: "active", - sort: sort as "relevance" | "date" | undefined, - start: start as number | undefined, - }); - - logger.debug("Google search results", { results }); - - let content = ""; - if (results && results.length) { - content += `Here are some relevant search results I found:\n\n`; - for (const result of results) { - const title = JSON.stringify(result.title || "").slice(1, -1); - const link = JSON.stringify(result.link || "").slice(1, -1); - const snippet = JSON.stringify(result.snippet || "").slice(1, -1); - const displayLink = result.displayLink - ? JSON.stringify(result.displayLink).slice(1, -1) - : ""; - - content += `Title: ${title}\n`; - content += `Link: ${link}\n`; - if (displayLink) { - content += `Source: ${displayLink}\n`; - } - content += `Snippet: ${snippet}\n\n`; - } - } else { - content += "No relevant search results found."; - } - - return content; - } catch (error: any) { - // Generic error handling - return formatError({ - code: "OPERATION_FAILED", - message: `Failed to perform search: ${error.message}`, - recoveryHint: "Please try again or check your search query.", - }); - } - } - - async search( - query: string, - options: ISearchOptions, - ): Promise { - const apiKey = this.toolbox.env.services?.google?.cse?.apiKey; - const engineId = this.toolbox.env.services?.google?.cse?.engineId; - - if (!apiKey || !engineId) { - throw new Error( - "Google CSE credentials not configured in environment", - ); - } - - const customSearch = google.customsearch({ - version: "v1", - auth: apiKey, - }); - - const params: any = { - q: query, - cx: engineId, - num: options.num || 10, - }; - - if (options.siteSearch) { - params.siteSearch = options.siteSearch; - } - if (options.dateRestrict) { - params.dateRestrict = options.dateRestrict; - } - if (options.fileType) { - params.fileType = options.fileType; - } - if (options.safe) { - params.safe = options.safe; - } - if (options.sort) { - params.sort = options.sort; - } - if (options.start) { - params.start = options.start; - } - - const response = await customSearch.cse.list(params); - const results: ISearchResult[] = []; - - if (response.data.items) { - response.data.items.forEach((item: any, index: number) => { - const result: ISearchResult = { - title: item.title || "", - link: item.link || "", - snippet: item.snippet || "", - position: item.position || index + 1, - displayLink: item.displayLink || "", - }; - - // Extract thumbnail if available - if (item.pagemap?.cse_thumbnail?.[0]?.src) { - result.image = item.pagemap.cse_thumbnail[0].src; - } - - results.push(result); - }); - } - - return results; - } - - /** - * Parse Google CSE API errors and provide meaningful messages - */ - private parseCseError(error: any): string { - // Handle Google API error structure - if (error.response?.data?.error) { - const apiError = error.response.data.error; - const statusCode = error.response.status; - - switch (statusCode) { - case 401: - return formatError({ - code: "UNAUTHORIZED", - message: `401 Unauthorized - Invalid API key. ${apiError.message || ""}`, - }); - case 403: - return formatError({ - code: "FORBIDDEN", - message: `403 Forbidden - ${apiError.message || "Access denied"}. Check your Engine ID and API key permissions.`, - }); - case 429: - const retryAfter = error.response.headers?.["retry-after"]; - return formatError({ - code: "RATE_LIMIT_EXCEEDED", - message: `429 Too Many Requests - Rate limit exceeded. ${retryAfter ? `Retry after: ${retryAfter} seconds.` : ""} ${apiError.message || ""}`, - }); - default: - break; - } - - return formatError({ - code: "TOOL_EXECUTION_FAILED", - message: `HTTP ${statusCode} - ${apiError.message || "Unknown error"}`, - }); - } - - // Handle network errors - if (error.code === "ENOTFOUND") { - return formatError({ - code: "NETWORK_ERROR", - message: `Network error (code: ${error.code}) - Unable to reach Google API`, - }); - } - - // Generic error - return formatError({ - code: error.code || "UNKNOWN_ERROR", - message: error.message || "An unknown error occurred", - }); - } -} diff --git a/packages/ai/src/tools/tool.ts b/packages/ai/src/tools/tool.ts index 118641f..8877dcc 100644 --- a/packages/ai/src/tools/tool.ts +++ b/packages/ai/src/tools/tool.ts @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0 import { IAiLogger } from "../api.ts"; -import { AiToolbox } from "../toolbox.ts"; export interface IToolArguments { [key: string]: unknown; @@ -17,19 +16,10 @@ export interface IToolDefinition { }; } -export abstract class AiTool { - protected _toolbox: AiToolbox; - get toolbox(): AiToolbox { - return this._toolbox; - } +export interface IAiTool { + readonly name: string; + readonly category: string; + readonly definition: IToolDefinition; - constructor(toolbox: AiToolbox) { - this._toolbox = toolbox; - } - - abstract get name(): string; - abstract get category(): string; - abstract get definition(): IToolDefinition; - - abstract execute(args: IToolArguments, logger: IAiLogger): Promise; + execute(args: IToolArguments, logger: IAiLogger): Promise; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1def50c..c3e580d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,12 +272,21 @@ importers: '@inquirer/prompts': specifier: ^8.4.2 version: 8.4.2(@types/node@25.6.0) + '@mozilla/readability': + specifier: 0.6.0 + version: 0.6.0 ansicolor: specifier: ^2.0.3 version: 2.0.3 dayjs: specifier: ^1.11.20 version: 1.11.20 + googleapis: + specifier: 171.4.0 + version: 171.4.0 + jsdom: + specifier: 29.0.2 + version: 29.0.2 numeral: specifier: ^2.0.6 version: 2.0.6 @@ -287,19 +296,31 @@ importers: openai: specifier: ^6.34.0 version: 6.34.0(ws@8.18.3) + playwright: + specifier: 1.59.1 + version: 1.59.1 simple-git: specifier: ^3.36.0 version: 3.36.0 socket.io-client: specifier: ^4.8.3 version: 4.8.3 + turndown: + specifier: 7.2.2 + version: 7.2.2 devDependencies: + '@types/jsdom': + specifier: 27.0.0 + version: 27.0.0 '@types/node': specifier: ^25.6.0 version: 25.6.0 '@types/numeral': specifier: ^2.0.5 version: 2.0.5 + '@types/turndown': + specifier: 5.0.5 + version: 5.0.5 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -310,8 +331,11 @@ importers: specifier: ^4.21.0 version: 4.21.0 typescript: - specifier: ^6.0.3 + specifier: 6.0.3 version: 6.0.3 + vitest: + specifier: 4.1.5 + version: 4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(tsx@4.21.0)) packages/ai: dependencies: @@ -973,6 +997,9 @@ packages: '@mediapipe/tasks-vision@0.10.17': resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==} + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@mongodb-js/saslprep@1.4.9': resolution: {integrity: sha512-RXSxsokhAF/4nWys8An8npsqOI33Ex1Hlzqjw2pZOO+GKtMAR2noGnUdsFiGwsaO/xXI+56mtjTmDA3JXJsvmA==} @@ -981,6 +1008,10 @@ packages: peerDependencies: three: '>= 0.159.0' + '@mozilla/readability@0.6.0': + resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} + engines: {node: '>=14.0.0'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} cpu: [arm64] @@ -1363,6 +1394,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsdom@27.0.0': + resolution: {integrity: sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==} + '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} @@ -1439,6 +1473,12 @@ packages: '@types/three@0.184.0': resolution: {integrity: sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/turndown@5.0.5': + resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==} + '@types/uikit@3.23.0': resolution: {integrity: sha512-GTn8/K+f4AjFxtLqRKWzjaVKckQp/rHcl20IO09salh2VjyFb9CqeFugL95skO6qbbJLil3PE1MmNajrSj5gMg==} @@ -2005,6 +2045,10 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -2440,6 +2484,15 @@ packages: jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + jsdom@29.0.2: + resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsdom@29.1.0: resolution: {integrity: sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -2879,6 +2932,9 @@ packages: resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} engines: {node: '>= 0.10'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -3497,6 +3553,9 @@ packages: tunnel-rat@0.1.2: resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + turndown@7.2.2: + resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -4218,6 +4277,8 @@ snapshots: '@mediapipe/tasks-vision@0.10.17': {} + '@mixmark-io/domino@2.2.0': {} + '@mongodb-js/saslprep@1.4.9': dependencies: sparse-bitfield: 3.0.3 @@ -4227,6 +4288,8 @@ snapshots: promise-worker-transferable: 1.0.4 three: 0.184.0 + '@mozilla/readability@0.6.0': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': optional: true @@ -4558,6 +4621,12 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/jsdom@27.0.0': + dependencies: + '@types/node': 25.6.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 @@ -4641,6 +4710,10 @@ snapshots: fflate: 0.8.2 meshoptimizer: 1.1.1 + '@types/tough-cookie@4.0.5': {} + + '@types/turndown@5.0.5': {} + '@types/uikit@3.23.0': {} '@types/uuid@10.0.0': {} @@ -5236,6 +5309,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@6.0.1: {} + entities@8.0.0: {} errno@0.1.8: @@ -5776,6 +5851,32 @@ snapshots: jsbn@1.1.0: {} + jsdom@29.0.2: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.5 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsdom@29.1.0: dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -6180,6 +6281,10 @@ snapshots: parse-node-version@1.0.1: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -6872,6 +6977,10 @@ snapshots: - immer - react + turndown@7.2.2: + dependencies: + '@mixmark-io/domino': 2.2.0 + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -6991,6 +7100,34 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.5(@types/node@25.6.0)(jsdom@29.0.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 + jsdom: 29.0.2 + transitivePeerDependencies: + - msw + vitest@4.1.5(@types/node@25.6.0)(jsdom@29.1.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.5