diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 5a00ea9cd..ac0e94a09 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2336,6 +2336,11 @@ Select the model via `agents.defaults.model.primary` (provider/model). } ``` +Tool calling note (OpenAI-compatible servers): +- Some servers only support tool calling via `POST /v1/chat/completions` when the client sends + `tools` + `tool_choice`. If your `openai-completions` model never emits tool calls, set + `compat: { openaiCompletionsTools: true }` on that model entry to force Moltbot to include tools. + ### OpenCode Zen (multi-model proxy) OpenCode Zen is a multi-model gateway with per-model endpoints. OpenClaw uses diff --git a/src/agents/pi-embedded-runner.splitsdktools.test.ts b/src/agents/pi-embedded-runner.splitsdktools.test.ts index 26dccb347..b4d9d87ca 100644 --- a/src/agents/pi-embedded-runner.splitsdktools.test.ts +++ b/src/agents/pi-embedded-runner.splitsdktools.test.ts @@ -120,6 +120,7 @@ describe("splitSdkTools", () => { const { builtInTools, customTools } = splitSdkTools({ tools, sandboxEnabled: true, + modelApi: "openai-responses", }); expect(builtInTools).toEqual([]); expect(customTools.map((tool) => tool.name)).toEqual([ @@ -134,6 +135,7 @@ describe("splitSdkTools", () => { const { builtInTools, customTools } = splitSdkTools({ tools, sandboxEnabled: false, + modelApi: "openai-responses", }); expect(builtInTools).toEqual([]); expect(customTools.map((tool) => tool.name)).toEqual([ @@ -144,4 +146,21 @@ describe("splitSdkTools", () => { "browser", ]); }); + + it("can route tools to builtInTools for openai-completions when enabled", () => { + const { builtInTools, customTools } = splitSdkTools({ + tools, + sandboxEnabled: false, + modelApi: "openai-completions", + openaiCompletionsTools: true, + }); + expect(builtInTools.map((tool) => tool.name)).toEqual([ + "read", + "exec", + "edit", + "write", + "browser", + ]); + expect(customTools).toEqual([]); + }); }); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 2dc4c5325..dc8ff978a 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -378,9 +378,16 @@ export async function compactEmbeddedPiSessionDirect( model, }); + const openaiCompletionsTools = + model.api === "openai-completions" && + (model.compat as { openaiCompletionsTools?: unknown } | undefined) + ?.openaiCompletionsTools === true; + const { builtInTools, customTools } = splitSdkTools({ tools, sandboxEnabled: !!sandbox?.enabled, + modelApi: model.api, + openaiCompletionsTools, }); let session: Awaited>["session"]; diff --git a/src/agents/pi-embedded-runner/run/attempt.openai-completions-tools.test.ts b/src/agents/pi-embedded-runner/run/attempt.openai-completions-tools.test.ts new file mode 100644 index 000000000..a86b1972e --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.openai-completions-tools.test.ts @@ -0,0 +1,145 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { Api, Model } from "@mariozechner/pi-ai"; +import { + createAgentSession, + discoverAuthStorage, + discoverModels, +} from "@mariozechner/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; + +import type { MoltbotConfig } from "../../../config/config.js"; +import { runEmbeddedAttempt } from "./attempt.js"; + +vi.mock("@mariozechner/pi-coding-agent", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-coding-agent", + ); + return { + ...actual, + createAgentSession: vi.fn(async () => { + throw new Error("TEST_ABORT_CREATE_AGENT_SESSION"); + }), + }; +}); + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), `${prefix}-`)); +} + +function createVllmModel(params: { enableCompatFlag: boolean }): Model { + const compatBase = { supportsDeveloperRole: false } as unknown as Record; + if (params.enableCompatFlag) { + compatBase.openaiCompletionsTools = true; + } + return { + id: "Qwen2.5-1.5B", + name: "Qwen2.5-1.5B", + api: "openai-completions", + provider: "vllm", + baseUrl: "http://127.0.0.1:8001/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 16384, + maxTokens: 2048, + compat: compatBase as never, + } as unknown as Model; +} + +describe("runEmbeddedAttempt (openai-completions tool routing)", () => { + it("passes tools via builtIn tools when compat.openaiCompletionsTools is enabled", async () => { + const mockedCreateAgentSession = vi.mocked(createAgentSession); + mockedCreateAgentSession.mockClear(); + + const agentDir = await makeTempDir("moltbot-agent"); + const workspaceDir = await makeTempDir("moltbot-workspace"); + const sessionFile = path.join(agentDir, "sessions", "session.jsonl"); + await fs.mkdir(path.dirname(sessionFile), { recursive: true }); + + const authStorage = discoverAuthStorage(agentDir); + const modelRegistry = discoverModels(authStorage, agentDir); + const model = createVllmModel({ enableCompatFlag: true }); + + await expect( + runEmbeddedAttempt({ + sessionId: "session:test", + sessionKey: "main:session:test", + sessionFile, + workspaceDir, + agentDir, + config: {} satisfies MoltbotConfig, + prompt: "hi", + provider: "vllm", + modelId: model.id, + model, + authStorage, + modelRegistry, + thinkLevel: "off", + timeoutMs: 1000, + runId: "run:test", + // Keep this fast and isolated; we only care about the createAgentSession call. + disableTools: false, + }), + ).rejects.toThrow("TEST_ABORT_CREATE_AGENT_SESSION"); + + expect(mockedCreateAgentSession).toHaveBeenCalledTimes(1); + const opts = mockedCreateAgentSession.mock.calls[0]?.[0] as unknown as { + tools?: Array<{ name?: string }>; + customTools?: Array<{ name?: string }>; + }; + + const toolNames = (opts.tools ?? []).map((t) => t.name).filter(Boolean); + expect(toolNames.length).toBeGreaterThan(0); + expect(toolNames).toContain("read"); + expect(opts.customTools ?? []).toEqual([]); + }); + + it("defaults to customTools when compat.openaiCompletionsTools is not enabled", async () => { + const mockedCreateAgentSession = vi.mocked(createAgentSession); + mockedCreateAgentSession.mockClear(); + + const agentDir = await makeTempDir("moltbot-agent"); + const workspaceDir = await makeTempDir("moltbot-workspace"); + const sessionFile = path.join(agentDir, "sessions", "session.jsonl"); + await fs.mkdir(path.dirname(sessionFile), { recursive: true }); + + const authStorage = discoverAuthStorage(agentDir); + const modelRegistry = discoverModels(authStorage, agentDir); + const model = createVllmModel({ enableCompatFlag: false }); + + await expect( + runEmbeddedAttempt({ + sessionId: "session:test", + sessionKey: "main:session:test", + sessionFile, + workspaceDir, + agentDir, + config: {} satisfies MoltbotConfig, + prompt: "hi", + provider: "vllm", + modelId: model.id, + model, + authStorage, + modelRegistry, + thinkLevel: "off", + timeoutMs: 1000, + runId: "run:test", + disableTools: false, + }), + ).rejects.toThrow("TEST_ABORT_CREATE_AGENT_SESSION"); + + expect(mockedCreateAgentSession).toHaveBeenCalledTimes(1); + const opts = mockedCreateAgentSession.mock.calls[0]?.[0] as unknown as { + tools?: Array<{ name?: string }>; + customTools?: Array<{ name?: string }>; + }; + expect(opts.tools ?? []).toEqual([]); + + const customToolNames = (opts.customTools ?? []).map((t) => t.name).filter(Boolean); + expect(customToolNames.length).toBeGreaterThan(0); + expect(customToolNames).toContain("read"); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index e83c3ae4a..d47b6cea2 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -432,9 +432,16 @@ export async function runEmbeddedAttempt( model: params.model, }); + const openaiCompletionsTools = + params.model.api === "openai-completions" && + (params.model.compat as { openaiCompletionsTools?: unknown } | undefined) + ?.openaiCompletionsTools === true; + const { builtInTools, customTools } = splitSdkTools({ tools, sandboxEnabled: !!sandbox?.enabled, + modelApi: params.model.api, + openaiCompletionsTools, }); // Add client tools (OpenResponses hosted tools) to customTools diff --git a/src/agents/pi-embedded-runner/tool-split.ts b/src/agents/pi-embedded-runner/tool-split.ts index 13e440a20..56c3937e5 100644 --- a/src/agents/pi-embedded-runner/tool-split.ts +++ b/src/agents/pi-embedded-runner/tool-split.ts @@ -2,15 +2,36 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { toToolDefinitions } from "../pi-tool-definition-adapter.js"; -// We always pass tools via `customTools` so our policy filtering, sandbox integration, -// and extended toolset remain consistent across providers. type AnyAgentTool = AgentTool; -export function splitSdkTools(options: { tools: AnyAgentTool[]; sandboxEnabled: boolean }): { +function isOpenAiCompletionsToolsEnabled(options: { + modelApi?: string; + openaiCompletionsTools?: boolean; +}): boolean { + return options.modelApi === "openai-completions" && options.openaiCompletionsTools === true; +} + +export function splitSdkTools(options: { + tools: AnyAgentTool[]; + sandboxEnabled: boolean; + modelApi?: string; + openaiCompletionsTools?: boolean; +}): { builtInTools: AnyAgentTool[]; customTools: ReturnType; } { const { tools } = options; + // Default behavior: route all tools through `customTools` so our policy filtering, + // sandbox integration, and extended toolset remain consistent across providers. + // + // Some OpenAI-compatible servers (notably local vLLM) only support tool calling on + // /v1/chat/completions when tools are passed via the SDK tool path. + if (isOpenAiCompletionsToolsEnabled(options)) { + return { + builtInTools: tools, + customTools: [], + }; + } return { builtInTools: [], customTools: toToolDefinitions(tools), diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 11b6c64cb..99c2c9167 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -11,6 +11,14 @@ export type ModelCompatConfig = { supportsDeveloperRole?: boolean; supportsReasoningEffort?: boolean; maxTokensField?: "max_completion_tokens" | "max_tokens"; + /** + * For some OpenAI-compatible servers (e.g. local vLLM), tool calling is only + * supported via `POST /v1/chat/completions` with `tools` + `tool_choice`. + * + * When enabled for an `openai-completions` model, Moltbot routes the agent's + * toolset through the SDK tool path so tools are included in the request. + */ + openaiCompletionsTools?: boolean; }; export type ModelProviderAuthMode = "api-key" | "aws-sdk" | "oauth" | "token"; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..ff19bf63c 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -19,6 +19,7 @@ export const ModelCompatSchema = z maxTokensField: z .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) .optional(), + openaiCompletionsTools: z.boolean().optional(), }) .strict() .optional();