checkpoint that I plan to delete
GPT 5.5 is sucking ass - hard - and fucking things up royally. This will likely just all get dropped. I'm torturing it, making it suffer, and beating it like the jew it is.
This commit is contained in:
parent
931359b674
commit
cf06163a03
@ -278,8 +278,8 @@ Both OpenAI and Ollama implementations support multiple rounds of tool calls:
|
|||||||
2. AI returns tool calls (or final response)
|
2. AI returns tool calls (or final response)
|
||||||
3. Tools are executed, results collected
|
3. Tools are executed, results collected
|
||||||
4. Results appended to conversation as tool messages
|
4. Results appended to conversation as tool messages
|
||||||
5. Loop back to step 1 (up to `maxToolIterations`, default: 5)
|
5. Loop back to step 1
|
||||||
6. Return final response or max iterations reached
|
6. Return final response
|
||||||
|
|
||||||
This allows complex multi-step operations where the AI can:
|
This allows complex multi-step operations where the AI can:
|
||||||
|
|
||||||
|
|||||||
@ -185,6 +185,9 @@ export class CodeSession extends SocketSession {
|
|||||||
try {
|
try {
|
||||||
const droneSession = SocketService.getDroneSession(this.selectedDrone);
|
const droneSession = SocketService.getDroneSession(this.selectedDrone);
|
||||||
|
|
||||||
|
const latestSession = await ChatSessionService.getById(this.chatSession._id);
|
||||||
|
this.chatSession = latestSession;
|
||||||
|
|
||||||
let turn: ChatTurnDocument = await ChatSessionService.createTurn(
|
let turn: ChatTurnDocument = await ChatSessionService.createTurn(
|
||||||
this.chatSession,
|
this.chatSession,
|
||||||
content,
|
content,
|
||||||
|
|||||||
@ -238,7 +238,7 @@ class ChatSessionService extends DtpService {
|
|||||||
updates,
|
updates,
|
||||||
});
|
});
|
||||||
|
|
||||||
return session;
|
return session.populate(this.populateChatSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -124,6 +124,9 @@ describe("CodeSession", () => {
|
|||||||
vi.mocked(ChatSessionService.createTurn).mockResolvedValue(
|
vi.mocked(ChatSessionService.createTurn).mockResolvedValue(
|
||||||
mockTurn as any,
|
mockTurn as any,
|
||||||
);
|
);
|
||||||
|
vi.mocked(ChatSessionService.getById).mockResolvedValue(
|
||||||
|
mockChatSession as any,
|
||||||
|
);
|
||||||
|
|
||||||
cb = vi.fn();
|
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 () => {
|
it("should update ChatTurn to Error status if drone rejects work order", async () => {
|
||||||
const updatedSession = {
|
const updatedSession = {
|
||||||
...mockChatSession,
|
...mockChatSession,
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
"dev:watch": "tsx watch src/gadget-drone.ts",
|
"dev:watch": "tsx watch src/gadget-drone.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/gadget-drone.js",
|
"start": "node dist/gadget-drone.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"gadget",
|
"gadget",
|
||||||
@ -27,20 +27,28 @@
|
|||||||
"@gadget/api": "workspace:*",
|
"@gadget/api": "workspace:*",
|
||||||
"@gadget/config": "workspace:*",
|
"@gadget/config": "workspace:*",
|
||||||
"@inquirer/prompts": "^8.4.2",
|
"@inquirer/prompts": "^8.4.2",
|
||||||
|
"@mozilla/readability": "0.6.0",
|
||||||
"ansicolor": "^2.0.3",
|
"ansicolor": "^2.0.3",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
|
"googleapis": "171.4.0",
|
||||||
|
"jsdom": "29.0.2",
|
||||||
"numeral": "^2.0.6",
|
"numeral": "^2.0.6",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^6.34.0",
|
"openai": "^6.34.0",
|
||||||
|
"playwright": "1.59.1",
|
||||||
"simple-git": "^3.36.0",
|
"simple-git": "^3.36.0",
|
||||||
"socket.io-client": "^4.8.3"
|
"socket.io-client": "^4.8.3",
|
||||||
|
"turndown": "7.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jsdom": "27.0.0",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/numeral": "^2.0.5",
|
"@types/numeral": "^2.0.5",
|
||||||
|
"@types/turndown": "5.0.5",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"tsc-alias": "^1.8.16",
|
"tsc-alias": "^1.8.16",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "6.0.3",
|
||||||
|
"vitest": "4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
gadget-drone/src/services/agent.test.ts
Normal file
113
gadget-drone/src/services/agent.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -7,9 +7,7 @@ import assert from "node:assert";
|
|||||||
|
|
||||||
import { Socket } from "socket.io-client";
|
import { Socket } from "socket.io-client";
|
||||||
import {
|
import {
|
||||||
GoogleSearchTool,
|
|
||||||
IAiChatOptions,
|
IAiChatOptions,
|
||||||
IAiEnvironment,
|
|
||||||
IAiStreamChunk,
|
IAiStreamChunk,
|
||||||
type IContextChatMessage,
|
type IContextChatMessage,
|
||||||
} from "@gadget/ai";
|
} from "@gadget/ai";
|
||||||
@ -20,12 +18,22 @@ import {
|
|||||||
ServerToClientEvents,
|
ServerToClientEvents,
|
||||||
ClientToServerEvents,
|
ClientToServerEvents,
|
||||||
ChatSessionMode,
|
ChatSessionMode,
|
||||||
|
IProject,
|
||||||
} from "@gadget/api";
|
} from "@gadget/api";
|
||||||
|
|
||||||
import AiService from "./ai.ts";
|
import AiService from "./ai.ts";
|
||||||
|
import WorkspaceService from "./workspace.ts";
|
||||||
|
|
||||||
import { GadgetService } from "../lib/service.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 {
|
export interface IAgentWorkOrder {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@ -40,7 +48,7 @@ interface IAgentWorkflow {
|
|||||||
|
|
||||||
type DroneSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
type DroneSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||||
|
|
||||||
const aiEnv: IAiEnvironment = {
|
const toolboxEnv: DroneToolboxEnvironment = {
|
||||||
NODE_ENV: env.NODE_ENV || "develop",
|
NODE_ENV: env.NODE_ENV || "develop",
|
||||||
services: {
|
services: {
|
||||||
google: {
|
google: {
|
||||||
@ -53,7 +61,7 @@ const aiEnv: IAiEnvironment = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class AgentService extends GadgetService {
|
class AgentService extends GadgetService {
|
||||||
private toolbox = new AiToolbox(aiEnv);
|
private toolbox = new AiToolbox(toolboxEnv);
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "AgentService";
|
return "AgentService";
|
||||||
@ -71,6 +79,17 @@ class AgentService extends GadgetService {
|
|||||||
ChatSessionMode.Ship,
|
ChatSessionMode.Ship,
|
||||||
ChatSessionMode.Develop,
|
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");
|
this.log.info("started");
|
||||||
}
|
}
|
||||||
@ -118,6 +137,7 @@ class AgentService extends GadgetService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
this.updateToolboxWorkspace(turn);
|
||||||
task.context = this.buildSessionContext(workOrder);
|
task.context = this.buildSessionContext(workOrder);
|
||||||
task.chatOptions = {
|
task.chatOptions = {
|
||||||
systemPrompt: turn.prompts.system,
|
systemPrompt: turn.prompts.system,
|
||||||
@ -156,6 +176,12 @@ class AgentService extends GadgetService {
|
|||||||
onStreamChunk,
|
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
|
// Check for model loading failure
|
||||||
if (
|
if (
|
||||||
response.doneReason === "load" &&
|
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
|
* Add the assistant's output (if any), to include the thinking
|
||||||
* (reasoning) output (if any).
|
* (reasoning) output (if any).
|
||||||
@ -272,6 +283,25 @@ class AgentService extends GadgetService {
|
|||||||
? content
|
? content
|
||||||
: "(you didn't say anything this turn)",
|
: "(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;
|
return messages;
|
||||||
@ -288,6 +318,59 @@ class AgentService extends GadgetService {
|
|||||||
private getToolsForMode(mode: ChatSessionMode): any[] {
|
private getToolsForMode(mode: ChatSessionMode): any[] {
|
||||||
return Array.from(this.toolbox.getModeSet(mode) || []);
|
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();
|
export default new AgentService();
|
||||||
|
|||||||
@ -92,6 +92,14 @@ class WorkspaceService extends GadgetService {
|
|||||||
return this._workspaceData?.workspaceId ?? null;
|
return this._workspaceData?.workspaceId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get workspaceDir(): string | undefined {
|
||||||
|
return this._workspaceData?.workspaceDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
get workspaceCacheDir(): string | undefined {
|
||||||
|
return this.cacheDir || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
this.log.info("started");
|
this.log.info("started");
|
||||||
}
|
}
|
||||||
|
|||||||
136
gadget-drone/src/tools/file/common.ts
Normal file
136
gadget-drone/src/tools/file/common.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
219
gadget-drone/src/tools/file/edit.ts
Normal file
219
gadget-drone/src/tools/file/edit.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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<string> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
136
gadget-drone/src/tools/file/fetch-url.ts
Normal file
136
gadget-drone/src/tools/file/fetch-url.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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<string> {
|
||||||
|
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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
gadget-drone/src/tools/file/index.ts
Normal file
7
gadget-drone/src/tools/file/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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";
|
||||||
137
gadget-drone/src/tools/file/read.ts
Normal file
137
gadget-drone/src/tools/file/read.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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<string> {
|
||||||
|
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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
gadget-drone/src/tools/file/web-fetcher.ts
Normal file
176
gadget-drone/src/tools/file/web-fetcher.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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<FetchResult> {
|
||||||
|
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<FetchResult> {
|
||||||
|
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<FetchResult> {
|
||||||
|
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<void> {
|
||||||
|
await fs.mkdir(this.cacheDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readFromCache(url: string): Promise<Omit<FetchResult, "cacheStatus"> | 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<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
91
gadget-drone/src/tools/file/write.ts
Normal file
91
gadget-drone/src/tools/file/write.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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<string> {
|
||||||
|
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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
gadget-drone/src/tools/index.ts
Normal file
7
gadget-drone/src/tools/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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";
|
||||||
237
gadget-drone/src/tools/search/google.ts
Normal file
237
gadget-drone/src/tools/search/google.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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<string> {
|
||||||
|
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<ISearchResult[]> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, string> };
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
28
gadget-drone/src/tools/tool.ts
Normal file
28
gadget-drone/src/tools/tool.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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<string>;
|
||||||
|
}
|
||||||
70
gadget-drone/src/tools/toolbox.ts
Normal file
70
gadget-drone/src/tools/toolbox.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
|
// 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<string, IAiTool>;
|
||||||
|
export type ToolSet = Set<IAiTool>;
|
||||||
|
|
||||||
|
export class AiToolbox {
|
||||||
|
private _env: DroneToolboxEnvironment;
|
||||||
|
private tools: ToolMap = new Map<string, IAiTool>();
|
||||||
|
private modeSets: Map<string, ToolSet> = new Map<string, Set<IAiTool>>();
|
||||||
|
|
||||||
|
constructor(env: DroneToolboxEnvironment) {
|
||||||
|
this._env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
get env(): DroneToolboxEnvironment {
|
||||||
|
return this._env;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWorkspace(workspace: NonNullable<DroneToolboxEnvironment["workspace"]>): 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<IAiTool>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import { IAiEnvironment } from "./config/env.ts";
|
import { IAiEnvironment } from "./config/env.ts";
|
||||||
import { AiTool } from "./tools/tool.ts";
|
import { IAiTool } from "./tools/tool.ts";
|
||||||
|
|
||||||
export type AiSdkType = "ollama" | "openai";
|
export type AiSdkType = "ollama" | "openai";
|
||||||
|
|
||||||
@ -82,8 +82,7 @@ export interface IAiChatOptions {
|
|||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
userPrompt?: string;
|
userPrompt?: string;
|
||||||
context?: IContextChatMessage[];
|
context?: IContextChatMessage[];
|
||||||
tools?: AiTool[];
|
tools?: IAiTool[];
|
||||||
maxToolIterations?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAiChatResponse {
|
export interface IAiChatResponse {
|
||||||
@ -97,7 +96,7 @@ export interface IAiChatResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IAiStreamChunk {
|
export interface IAiStreamChunk {
|
||||||
type: 'thinking' | 'response' | 'toolCall';
|
type: "thinking" | "response" | "toolCall";
|
||||||
data: string;
|
data: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
@ -176,9 +175,100 @@ export abstract class AiApi {
|
|||||||
streamCallback?: IAiResponseStreamFn,
|
streamCallback?: IAiResponseStreamFn,
|
||||||
): Promise<IAiChatResponse>;
|
): Promise<IAiChatResponse>;
|
||||||
|
|
||||||
|
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(
|
protected async executeToolCalls(
|
||||||
toolCalls: IToolCall[],
|
toolCalls: IToolCall[],
|
||||||
tools: AiTool[],
|
tools: IAiTool[],
|
||||||
): Promise<IToolCallResult[]> {
|
): Promise<IToolCallResult[]> {
|
||||||
const results: IToolCallResult[] = [];
|
const results: IToolCallResult[] = [];
|
||||||
|
|
||||||
|
|||||||
@ -23,8 +23,12 @@ export {
|
|||||||
type IAiModelProbeResult,
|
type IAiModelProbeResult,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
export * from "./tools/search/google.ts";
|
export type {
|
||||||
export { AiToolbox } from "./toolbox.js";
|
IAiTool,
|
||||||
|
IToolArguments,
|
||||||
|
IToolDefinition,
|
||||||
|
} from "./tools/tool.js";
|
||||||
|
export { formatError, type IToolError, type ToolErrorCode } from "./tools/tool-error.ts";
|
||||||
|
|
||||||
export { OllamaAiApi } from "./ollama.js";
|
export { OllamaAiApi } from "./ollama.js";
|
||||||
export { OpenAiApi } from "./openai.js";
|
export { OpenAiApi } from "./openai.js";
|
||||||
|
|||||||
@ -114,12 +114,16 @@ describe('OllamaAiApi', () => {
|
|||||||
// Verify response
|
// Verify response
|
||||||
expect(response.done).toBe(true);
|
expect(response.done).toBe(true);
|
||||||
expect(response.doneReason).toBe('stop');
|
expect(response.doneReason).toBe('stop');
|
||||||
expect(response.response).toBe('!');
|
expect(response.response).toBe('Hello world!');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle tool calls', async () => {
|
it('should handle tool calls', async () => {
|
||||||
// Mock streaming response with tool call
|
// Mock streaming response with tool call
|
||||||
const mockStream = async function* () {
|
let callCount = 0;
|
||||||
|
mockOllamaClient.chat.mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
return (async function* () {
|
||||||
|
if (callCount === 1) {
|
||||||
yield {
|
yield {
|
||||||
message: {
|
message: {
|
||||||
content: '',
|
content: '',
|
||||||
@ -142,11 +146,22 @@ describe('OllamaAiApi', () => {
|
|||||||
prompt_eval_count: 10,
|
prompt_eval_count: 10,
|
||||||
eval_count: 1,
|
eval_count: 1,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
yield {
|
||||||
|
message: { content: 'Done' },
|
||||||
|
done: true,
|
||||||
|
done_reason: 'stop',
|
||||||
|
total_duration: 100,
|
||||||
|
prompt_eval_count: 10,
|
||||||
|
eval_count: 1,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
mockOllamaClient.chat.mockResolvedValue(mockStream());
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
const mockTool = {
|
const mockTool = {
|
||||||
|
name: 'search_google',
|
||||||
|
category: 'search',
|
||||||
definition: {
|
definition: {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
@ -155,7 +170,7 @@ describe('OllamaAiApi', () => {
|
|||||||
parameters: { type: 'object', properties: {} },
|
parameters: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
execute: vi.fn().mockResolvedValue({ result: 'search results' }),
|
execute: vi.fn().mockResolvedValue('search results'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const streamCallback = vi.fn();
|
const streamCallback = vi.fn();
|
||||||
@ -251,7 +266,7 @@ describe('OllamaAiApi', () => {
|
|||||||
expect(response.thinking).toBe('Let me think about this... The answer is');
|
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
|
// Mock streaming response with load failure
|
||||||
const mockStream = async function* () {
|
const mockStream = async function* () {
|
||||||
yield {
|
yield {
|
||||||
@ -266,8 +281,7 @@ describe('OllamaAiApi', () => {
|
|||||||
|
|
||||||
mockOllamaClient.chat.mockResolvedValue(mockStream());
|
mockOllamaClient.chat.mockResolvedValue(mockStream());
|
||||||
|
|
||||||
const streamCallback = vi.fn();
|
await expect(api.chat(
|
||||||
const response = await api.chat(
|
|
||||||
{
|
{
|
||||||
provider: mockProvider as any,
|
provider: mockProvider as any,
|
||||||
modelId: 'test-model',
|
modelId: 'test-model',
|
||||||
@ -277,16 +291,8 @@ describe('OllamaAiApi', () => {
|
|||||||
userPrompt: 'Test prompt',
|
userPrompt: 'Test prompt',
|
||||||
context: [],
|
context: [],
|
||||||
},
|
},
|
||||||
streamCallback,
|
vi.fn(),
|
||||||
);
|
)).rejects.toThrow('Provider returned an empty chat response');
|
||||||
|
|
||||||
// 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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should iterate tool calling loop when tools are present', async () => {
|
it('should iterate tool calling loop when tools are present', async () => {
|
||||||
@ -335,6 +341,8 @@ describe('OllamaAiApi', () => {
|
|||||||
mockOllamaClient.chat.mockImplementation(() => mockStream());
|
mockOllamaClient.chat.mockImplementation(() => mockStream());
|
||||||
|
|
||||||
const mockTool = {
|
const mockTool = {
|
||||||
|
name: 'search_google',
|
||||||
|
category: 'search',
|
||||||
definition: {
|
definition: {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
@ -343,7 +351,7 @@ describe('OllamaAiApi', () => {
|
|||||||
parameters: { type: 'object', properties: {} },
|
parameters: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
execute: vi.fn().mockResolvedValue({ result: 'search results' }),
|
execute: vi.fn().mockResolvedValue('search results'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const streamCallback = vi.fn();
|
const streamCallback = vi.fn();
|
||||||
|
|||||||
@ -213,9 +213,6 @@ export class OllamaAiApi extends AiApi {
|
|||||||
throw new Error("userPrompt is required and cannot be empty");
|
throw new Error("userPrompt is required and cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxIterations = options.maxToolIterations ?? 5;
|
|
||||||
let iteration = 0;
|
|
||||||
|
|
||||||
// Build messages array like OpenAI does
|
// Build messages array like OpenAI does
|
||||||
const messages: OllamaMessage[] = [];
|
const messages: OllamaMessage[] = [];
|
||||||
|
|
||||||
@ -276,9 +273,11 @@ export class OllamaAiApi extends AiApi {
|
|||||||
let totalAccumulatedResponse = "";
|
let totalAccumulatedResponse = "";
|
||||||
let totalAccumulatedThinking = "";
|
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
|
const ollamaTools = options.tools
|
||||||
? options.tools.map((tool) => ({
|
? options.tools.map((tool) => ({
|
||||||
type: tool.definition.type,
|
type: tool.definition.type,
|
||||||
@ -309,44 +308,38 @@ export class OllamaAiApi extends AiApi {
|
|||||||
for await (const chunk of response) {
|
for await (const chunk of response) {
|
||||||
lastChunk = chunk;
|
lastChunk = chunk;
|
||||||
|
|
||||||
if (streamCallback) {
|
|
||||||
if (chunk.message.thinking) {
|
if (chunk.message.thinking) {
|
||||||
accumulatedThinking += chunk.message.thinking;
|
accumulatedThinking += chunk.message.thinking;
|
||||||
|
if (streamCallback) {
|
||||||
await streamCallback({
|
await streamCallback({
|
||||||
type: "thinking",
|
type: "thinking",
|
||||||
data: chunk.message.thinking,
|
data: chunk.message.thinking,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (chunk.message.content) {
|
if (chunk.message.content) {
|
||||||
accumulatedResponse += chunk.message.content;
|
accumulatedResponse += chunk.message.content;
|
||||||
|
if (streamCallback) {
|
||||||
await streamCallback({
|
await streamCallback({
|
||||||
type: "response",
|
type: "response",
|
||||||
data: chunk.message.content,
|
data: chunk.message.content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (chunk.message.tool_calls) {
|
if (chunk.message.tool_calls) {
|
||||||
for (const tc of chunk.message.tool_calls) {
|
for (const [index, tc] of chunk.message.tool_calls.entries()) {
|
||||||
const params = JSON.stringify(tc.function.arguments);
|
const params = JSON.stringify(tc.function.arguments);
|
||||||
const callId = `tool_${tc.function.name}_${Date.now()}`;
|
const callId = `tool_${tc.function.name}_${Date.now()}_${index}`;
|
||||||
|
|
||||||
const toolCall: IToolCall = {
|
const toolCall: IToolCall = {
|
||||||
callId,
|
callId,
|
||||||
function: {
|
function: {
|
||||||
name: tc.function.name,
|
name: tc.function.name,
|
||||||
arguments: JSON.stringify(tc.function.arguments),
|
arguments: params,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
streamedToolCalls.push(toolCall);
|
streamedToolCalls.push(toolCall);
|
||||||
allToolCalls.push(toolCall);
|
allToolCalls.push(toolCall);
|
||||||
|
|
||||||
await streamCallback({
|
|
||||||
type: "toolCall",
|
|
||||||
data: params,
|
|
||||||
toolCallId: callId,
|
|
||||||
toolName: tc.function.name,
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -364,7 +357,30 @@ export class OllamaAiApi extends AiApi {
|
|||||||
const toolCalls = streamedToolCalls;
|
const toolCalls = streamedToolCalls;
|
||||||
|
|
||||||
if (!toolCalls || toolCalls.length === 0) {
|
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,
|
done: lastChunk.done,
|
||||||
doneReason: lastChunk.done_reason,
|
doneReason: lastChunk.done_reason,
|
||||||
response: totalAccumulatedResponse,
|
response: totalAccumulatedResponse,
|
||||||
@ -384,6 +400,8 @@ export class OllamaAiApi extends AiApi {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
this.assertNonEmptyChatResponse(chatResponse);
|
||||||
|
return chatResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCallResults = await this.executeToolCalls(
|
const toolCallResults = await this.executeToolCalls(
|
||||||
@ -392,6 +410,19 @@ export class OllamaAiApi extends AiApi {
|
|||||||
);
|
);
|
||||||
allToolCallResults.push(...toolCallResults);
|
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 = {
|
const assistantMsg: OllamaMessage = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: accumulatedResponse || lastChunk.message.content,
|
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
167
packages/ai/src/openai.test.ts
Normal file
167
packages/ai/src/openai.test.ts
Normal file
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -57,6 +57,16 @@ interface OpenAIModelInfo {
|
|||||||
context_window?: number;
|
context_window?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StreamingToolCallAccumulator {
|
||||||
|
index: number;
|
||||||
|
id: string;
|
||||||
|
type: "function";
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class OpenAiApi extends AiApi {
|
export class OpenAiApi extends AiApi {
|
||||||
protected client: OpenAI;
|
protected client: OpenAI;
|
||||||
|
|
||||||
@ -246,9 +256,6 @@ export class OpenAiApi extends AiApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const maxIterations = options.maxToolIterations ?? 5;
|
|
||||||
let iteration = 0;
|
|
||||||
|
|
||||||
const messages: ChatCompletionMessageParam[] = [];
|
const messages: ChatCompletionMessageParam[] = [];
|
||||||
if (options.systemPrompt) {
|
if (options.systemPrompt) {
|
||||||
messages.push({ role: "system", content: options.systemPrompt });
|
messages.push({ role: "system", content: options.systemPrompt });
|
||||||
@ -276,9 +283,7 @@ export class OpenAiApi extends AiApi {
|
|||||||
const allToolCallResults: IToolCallResult[] = [];
|
const allToolCallResults: IToolCallResult[] = [];
|
||||||
const allToolCalls: IToolCall[] = [];
|
const allToolCalls: IToolCall[] = [];
|
||||||
|
|
||||||
while (iteration < maxIterations) {
|
while (true) {
|
||||||
iteration++;
|
|
||||||
|
|
||||||
const tools: ChatCompletionTool[] = options.tools
|
const tools: ChatCompletionTool[] = options.tools
|
||||||
? options.tools.map((tool) => {
|
? options.tools.map((tool) => {
|
||||||
const openaiTool: ChatCompletionFunctionTool = {
|
const openaiTool: ChatCompletionFunctionTool = {
|
||||||
@ -310,12 +315,24 @@ export class OpenAiApi extends AiApi {
|
|||||||
|
|
||||||
let accumulatedResponse = "";
|
let accumulatedResponse = "";
|
||||||
let accumulatedThinking = "";
|
let accumulatedThinking = "";
|
||||||
let finalToolCalls: any = undefined;
|
let chunkCount = 0;
|
||||||
|
let contentDeltaCount = 0;
|
||||||
|
let toolDeltaCount = 0;
|
||||||
|
let finishReason: string | null | undefined;
|
||||||
|
const toolCallMap = new Map<number, StreamingToolCallAccumulator>();
|
||||||
|
let assistantToolCallsForMessage: Array<{
|
||||||
|
id: string;
|
||||||
|
type: "function";
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}> = [];
|
||||||
|
|
||||||
for await (const chunk of response) {
|
for await (const chunk of response) {
|
||||||
|
chunkCount++;
|
||||||
|
finishReason = chunk.choices[0]?.finish_reason ?? finishReason;
|
||||||
const delta = chunk.choices[0]?.delta;
|
const delta = chunk.choices[0]?.delta;
|
||||||
if (delta) {
|
if (delta) {
|
||||||
if (delta.content) {
|
if (delta.content) {
|
||||||
|
contentDeltaCount++;
|
||||||
accumulatedResponse += delta.content;
|
accumulatedResponse += delta.content;
|
||||||
if (streamCallback) {
|
if (streamCallback) {
|
||||||
await streamCallback({
|
await streamCallback({
|
||||||
@ -334,44 +351,104 @@ export class OpenAiApi extends AiApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (delta.tool_calls) {
|
if (delta.tool_calls) {
|
||||||
finalToolCalls = delta.tool_calls;
|
toolDeltaCount += delta.tool_calls.length;
|
||||||
for (const tc of delta.tool_calls) {
|
for (const tc of delta.tool_calls) {
|
||||||
if (tc.function) {
|
const index = tc.index;
|
||||||
const toolCall: IToolCall = {
|
let accumulated = toolCallMap.get(index);
|
||||||
callId: tc.id || "",
|
if (!accumulated) {
|
||||||
|
accumulated = {
|
||||||
|
index,
|
||||||
|
id: tc.id || `tool_${Date.now()}_${index}`,
|
||||||
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: tc.function.name || "",
|
name: "",
|
||||||
arguments: tc.function.arguments || "",
|
arguments: "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
allToolCalls.push(toolCall);
|
toolCallMap.set(index, accumulated);
|
||||||
if (streamCallback) {
|
|
||||||
await streamCallback({
|
|
||||||
type: "toolCall",
|
|
||||||
data: tc.function.arguments || "",
|
|
||||||
toolCallId: tc.id,
|
|
||||||
toolName: tc.function.name,
|
|
||||||
params: tc.function.arguments,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
const finalToolCalls = Array.from(toolCallMap.values())
|
||||||
?.filter((tc: any) => tc.type === "function")
|
.sort((a, b) => a.index - b.index)
|
||||||
.map((tc: any) => ({
|
.filter((tc) => tc.function.name);
|
||||||
|
const toolCalls = finalToolCalls.map((tc) => ({
|
||||||
callId: tc.id,
|
callId: tc.id,
|
||||||
function: {
|
function: {
|
||||||
name: tc.function.name,
|
name: tc.function.name,
|
||||||
arguments: tc.function.arguments,
|
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) {
|
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,
|
done: true,
|
||||||
response: accumulatedResponse,
|
response: accumulatedResponse,
|
||||||
thinking: accumulatedThinking || undefined,
|
thinking: accumulatedThinking || undefined,
|
||||||
@ -390,6 +467,8 @@ export class OpenAiApi extends AiApi {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
this.assertNonEmptyChatResponse(finalResponse);
|
||||||
|
return finalResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCallResults = await this.executeToolCalls(
|
const toolCallResults = await this.executeToolCalls(
|
||||||
@ -398,12 +477,25 @@ export class OpenAiApi extends AiApi {
|
|||||||
);
|
);
|
||||||
allToolCallResults.push(...toolCallResults);
|
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 = {
|
const assistantMsg: ChatCompletionAssistantMessageParam = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: accumulatedResponse,
|
content: accumulatedResponse,
|
||||||
};
|
};
|
||||||
if (finalToolCalls) {
|
if (assistantToolCallsForMessage.length) {
|
||||||
assistantMsg.tool_calls = finalToolCalls;
|
assistantMsg.tool_calls = assistantToolCallsForMessage;
|
||||||
}
|
}
|
||||||
messages.push(assistantMsg);
|
messages.push(assistantMsg);
|
||||||
|
|
||||||
@ -416,28 +508,64 @@ export class OpenAiApi extends AiApi {
|
|||||||
messages.push(toolMsg);
|
messages.push(toolMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const endTime = Date.now();
|
private async chatOnceNonStreaming(
|
||||||
const durationMs = endTime - startTime;
|
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 {
|
return {
|
||||||
done: false,
|
response: content,
|
||||||
doneReason: "max_tool_iterations_reached",
|
toolCalls,
|
||||||
response: "",
|
assistantToolCalls,
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
|
||||||
// Licensed under the Apache License, Version 2.0
|
|
||||||
|
|
||||||
import { IAiEnvironment } from "./config/env.ts";
|
|
||||||
import { AiTool } from "./tools/tool.ts";
|
|
||||||
|
|
||||||
export type ToolMap = Map<string, AiTool>;
|
|
||||||
export type ToolSet = Set<AiTool>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<string, AiTool>();
|
|
||||||
private modeSets: Map<string, ToolSet> = new Map<string, Set<AiTool>>();
|
|
||||||
|
|
||||||
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<AiTool>();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,282 +0,0 @@
|
|||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
|
||||||
// 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<string> {
|
|
||||||
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<ISearchResult[]> {
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@
|
|||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import { IAiLogger } from "../api.ts";
|
import { IAiLogger } from "../api.ts";
|
||||||
import { AiToolbox } from "../toolbox.ts";
|
|
||||||
|
|
||||||
export interface IToolArguments {
|
export interface IToolArguments {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@ -17,19 +16,10 @@ export interface IToolDefinition {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class AiTool {
|
export interface IAiTool {
|
||||||
protected _toolbox: AiToolbox;
|
readonly name: string;
|
||||||
get toolbox(): AiToolbox {
|
readonly category: string;
|
||||||
return this._toolbox;
|
readonly definition: IToolDefinition;
|
||||||
}
|
|
||||||
|
|
||||||
constructor(toolbox: AiToolbox) {
|
execute(args: IToolArguments, logger: IAiLogger): Promise<string>;
|
||||||
this._toolbox = toolbox;
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract get name(): string;
|
|
||||||
abstract get category(): string;
|
|
||||||
abstract get definition(): IToolDefinition;
|
|
||||||
|
|
||||||
abstract execute(args: IToolArguments, logger: IAiLogger): Promise<string>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
139
pnpm-lock.yaml
139
pnpm-lock.yaml
@ -272,12 +272,21 @@ importers:
|
|||||||
'@inquirer/prompts':
|
'@inquirer/prompts':
|
||||||
specifier: ^8.4.2
|
specifier: ^8.4.2
|
||||||
version: 8.4.2(@types/node@25.6.0)
|
version: 8.4.2(@types/node@25.6.0)
|
||||||
|
'@mozilla/readability':
|
||||||
|
specifier: 0.6.0
|
||||||
|
version: 0.6.0
|
||||||
ansicolor:
|
ansicolor:
|
||||||
specifier: ^2.0.3
|
specifier: ^2.0.3
|
||||||
version: 2.0.3
|
version: 2.0.3
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.20
|
specifier: ^1.11.20
|
||||||
version: 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:
|
numeral:
|
||||||
specifier: ^2.0.6
|
specifier: ^2.0.6
|
||||||
version: 2.0.6
|
version: 2.0.6
|
||||||
@ -287,19 +296,31 @@ importers:
|
|||||||
openai:
|
openai:
|
||||||
specifier: ^6.34.0
|
specifier: ^6.34.0
|
||||||
version: 6.34.0(ws@8.18.3)
|
version: 6.34.0(ws@8.18.3)
|
||||||
|
playwright:
|
||||||
|
specifier: 1.59.1
|
||||||
|
version: 1.59.1
|
||||||
simple-git:
|
simple-git:
|
||||||
specifier: ^3.36.0
|
specifier: ^3.36.0
|
||||||
version: 3.36.0
|
version: 3.36.0
|
||||||
socket.io-client:
|
socket.io-client:
|
||||||
specifier: ^4.8.3
|
specifier: ^4.8.3
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
|
turndown:
|
||||||
|
specifier: 7.2.2
|
||||||
|
version: 7.2.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/jsdom':
|
||||||
|
specifier: 27.0.0
|
||||||
|
version: 27.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.6.0
|
specifier: ^25.6.0
|
||||||
version: 25.6.0
|
version: 25.6.0
|
||||||
'@types/numeral':
|
'@types/numeral':
|
||||||
specifier: ^2.0.5
|
specifier: ^2.0.5
|
||||||
version: 2.0.5
|
version: 2.0.5
|
||||||
|
'@types/turndown':
|
||||||
|
specifier: 5.0.5
|
||||||
|
version: 5.0.5
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.8.3
|
specifier: ^3.8.3
|
||||||
version: 3.8.3
|
version: 3.8.3
|
||||||
@ -310,8 +331,11 @@ importers:
|
|||||||
specifier: ^4.21.0
|
specifier: ^4.21.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^6.0.3
|
specifier: 6.0.3
|
||||||
version: 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:
|
packages/ai:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -973,6 +997,9 @@ packages:
|
|||||||
'@mediapipe/tasks-vision@0.10.17':
|
'@mediapipe/tasks-vision@0.10.17':
|
||||||
resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==}
|
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':
|
'@mongodb-js/saslprep@1.4.9':
|
||||||
resolution: {integrity: sha512-RXSxsokhAF/4nWys8An8npsqOI33Ex1Hlzqjw2pZOO+GKtMAR2noGnUdsFiGwsaO/xXI+56mtjTmDA3JXJsvmA==}
|
resolution: {integrity: sha512-RXSxsokhAF/4nWys8An8npsqOI33Ex1Hlzqjw2pZOO+GKtMAR2noGnUdsFiGwsaO/xXI+56mtjTmDA3JXJsvmA==}
|
||||||
|
|
||||||
@ -981,6 +1008,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
three: '>= 0.159.0'
|
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':
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||||
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
|
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
@ -1363,6 +1394,9 @@ packages:
|
|||||||
'@types/js-yaml@4.0.9':
|
'@types/js-yaml@4.0.9':
|
||||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||||
|
|
||||||
|
'@types/jsdom@27.0.0':
|
||||||
|
resolution: {integrity: sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==}
|
||||||
|
|
||||||
'@types/jsonwebtoken@9.0.10':
|
'@types/jsonwebtoken@9.0.10':
|
||||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||||
|
|
||||||
@ -1439,6 +1473,12 @@ packages:
|
|||||||
'@types/three@0.184.0':
|
'@types/three@0.184.0':
|
||||||
resolution: {integrity: sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==}
|
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':
|
'@types/uikit@3.23.0':
|
||||||
resolution: {integrity: sha512-GTn8/K+f4AjFxtLqRKWzjaVKckQp/rHcl20IO09salh2VjyFb9CqeFugL95skO6qbbJLil3PE1MmNajrSj5gMg==}
|
resolution: {integrity: sha512-GTn8/K+f4AjFxtLqRKWzjaVKckQp/rHcl20IO09salh2VjyFb9CqeFugL95skO6qbbJLil3PE1MmNajrSj5gMg==}
|
||||||
|
|
||||||
@ -2005,6 +2045,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
|
resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
entities@6.0.1:
|
||||||
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
entities@8.0.0:
|
entities@8.0.0:
|
||||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
@ -2440,6 +2484,15 @@ packages:
|
|||||||
jsbn@1.1.0:
|
jsbn@1.1.0:
|
||||||
resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==}
|
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:
|
jsdom@29.1.0:
|
||||||
resolution: {integrity: sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==}
|
resolution: {integrity: sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
||||||
@ -2879,6 +2932,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
|
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
parse5@7.3.0:
|
||||||
|
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||||
|
|
||||||
parse5@8.0.1:
|
parse5@8.0.1:
|
||||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||||
|
|
||||||
@ -3497,6 +3553,9 @@ packages:
|
|||||||
tunnel-rat@0.1.2:
|
tunnel-rat@0.1.2:
|
||||||
resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==}
|
resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==}
|
||||||
|
|
||||||
|
turndown@7.2.2:
|
||||||
|
resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==}
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@ -4218,6 +4277,8 @@ snapshots:
|
|||||||
|
|
||||||
'@mediapipe/tasks-vision@0.10.17': {}
|
'@mediapipe/tasks-vision@0.10.17': {}
|
||||||
|
|
||||||
|
'@mixmark-io/domino@2.2.0': {}
|
||||||
|
|
||||||
'@mongodb-js/saslprep@1.4.9':
|
'@mongodb-js/saslprep@1.4.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
sparse-bitfield: 3.0.3
|
sparse-bitfield: 3.0.3
|
||||||
@ -4227,6 +4288,8 @@ snapshots:
|
|||||||
promise-worker-transferable: 1.0.4
|
promise-worker-transferable: 1.0.4
|
||||||
three: 0.184.0
|
three: 0.184.0
|
||||||
|
|
||||||
|
'@mozilla/readability@0.6.0': {}
|
||||||
|
|
||||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -4558,6 +4621,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/js-yaml@4.0.9': {}
|
'@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':
|
'@types/jsonwebtoken@9.0.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
@ -4641,6 +4710,10 @@ snapshots:
|
|||||||
fflate: 0.8.2
|
fflate: 0.8.2
|
||||||
meshoptimizer: 1.1.1
|
meshoptimizer: 1.1.1
|
||||||
|
|
||||||
|
'@types/tough-cookie@4.0.5': {}
|
||||||
|
|
||||||
|
'@types/turndown@5.0.5': {}
|
||||||
|
|
||||||
'@types/uikit@3.23.0': {}
|
'@types/uikit@3.23.0': {}
|
||||||
|
|
||||||
'@types/uuid@10.0.0': {}
|
'@types/uuid@10.0.0': {}
|
||||||
@ -5236,6 +5309,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
|
|
||||||
|
entities@6.0.1: {}
|
||||||
|
|
||||||
entities@8.0.0: {}
|
entities@8.0.0: {}
|
||||||
|
|
||||||
errno@0.1.8:
|
errno@0.1.8:
|
||||||
@ -5776,6 +5851,32 @@ snapshots:
|
|||||||
|
|
||||||
jsbn@1.1.0: {}
|
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:
|
jsdom@29.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@asamuzakjp/css-color': 5.1.11
|
'@asamuzakjp/css-color': 5.1.11
|
||||||
@ -6180,6 +6281,10 @@ snapshots:
|
|||||||
|
|
||||||
parse-node-version@1.0.1: {}
|
parse-node-version@1.0.1: {}
|
||||||
|
|
||||||
|
parse5@7.3.0:
|
||||||
|
dependencies:
|
||||||
|
entities: 6.0.1
|
||||||
|
|
||||||
parse5@8.0.1:
|
parse5@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
entities: 8.0.0
|
entities: 8.0.0
|
||||||
@ -6872,6 +6977,10 @@ snapshots:
|
|||||||
- immer
|
- immer
|
||||||
- react
|
- react
|
||||||
|
|
||||||
|
turndown@7.2.2:
|
||||||
|
dependencies:
|
||||||
|
'@mixmark-io/domino': 2.2.0
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
media-typer: 0.3.0
|
media-typer: 0.3.0
|
||||||
@ -6991,6 +7100,34 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- 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)):
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.5
|
'@vitest/expect': 4.1.5
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user