diff --git a/openbot/docs/configuration.md b/openbot/docs/configuration.md index b2c5113d..0294c8eb 100644 --- a/openbot/docs/configuration.md +++ b/openbot/docs/configuration.md @@ -239,6 +239,8 @@ Most gateways publish a model list, which is the way to check a name before conf Two things are worth knowing before pointing a deployment at any gateway. Not every catalogue entry accepts tools, and a Bot without tool calling cannot drive its computer; the model list says which do. And `BOT_RESPONSES_API=true` needs an endpoint that implements the Responses API, not only chat completions. +With `OPENAI_BASE_URL` set, the built-in Bot sends its Responses API requests with `store: false` and each earlier step of a turn in full, because an endpoint other than OpenAI keeps no stored responses to refer back to. A reasoning model's thinking is therefore not carried from one step of a turn to the next, including on a gateway in front of OpenAI itself. + ## Voice dictation Dictation inserts recorded speech into the composer for review before sending. It works with any diff --git a/openbot/server/src/copilot.ts b/openbot/server/src/copilot.ts index ba0c6359..a4defe39 100644 --- a/openbot/server/src/copilot.ts +++ b/openbot/server/src/copilot.ts @@ -469,6 +469,17 @@ export function builtInAgentConfiguration( ...(learnedSkills?.catalog ? [learnedSkills.catalog] : []), ].join("\n\n"), ...(planModel ? {} : { apiKey: apiKey ?? undefined }), + /* + * An OpenAI-compatible endpoint other than OpenAI (OPENAI_BASE_URL) keeps no response store, so + * the Responses API must resend earlier items in full. With `store` on, the AI SDK sends a + * reasoning model's previous thinking back as `item_reference`, and an endpoint such as Ollama + * refuses the whole step after the first tool call ("unknown input item type"). + */ + ...(!planModel && + model.provider === "openai" && + process.env.OPENAI_BASE_URL?.trim() + ? { providerOptions: { openai: { store: false } } } + : {}), /* * A run stops after one step unless told otherwise, which for a Bot with tools means it calls * one and never speaks: the tool executes, the result arrives, and the run ends before the model diff --git a/openbot/server/tests/copilot.test.ts b/openbot/server/tests/copilot.test.ts index 978a0811..6215a30f 100644 --- a/openbot/server/tests/copilot.test.ts +++ b/openbot/server/tests/copilot.test.ts @@ -219,6 +219,90 @@ describe("deployment model selection", () => { }, ); + /* + * A compatible endpoint keeps no response store. With `store` on, the AI SDK sends a step's + * reasoning back as an `item_reference`, which such an endpoint (Ollama, for one) refuses, so a + * reasoning model failed every turn at its first tool call. + */ + test("a compatible endpoint is sent the earlier step in full, not by reference", async () => { + const recorder = new LLMock(); + const originalBase = process.env.OPENAI_BASE_URL; + // The journal keeps a normalised body, so the request is read as sent, on its way through. + const bodies: { store?: unknown; input?: unknown }[] = []; + let proxy: ReturnType | undefined; + try { + const base = await recorder.start(); + proxy = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const body = await request.text(); + bodies.push(JSON.parse(body)); + return fetch(`${base}${new URL(request.url).pathname}`, { + method: "POST", + headers: request.headers, + body, + }); + }, + }); + process.env.OPENAI_BASE_URL = `${proxy.url.origin}/v1`; + recorder.on({ hasToolResult: true }, { content: "The balance is 42." }); + recorder.onMessage(/balance/, { + reasoning: "Read the balance before answering.", + toolCalls: [ + { + id: "balance-call", + name: "read_balance", + arguments: { account: "demo" }, + }, + ], + }); + const agents = await resolveRuntimeAgents( + async () => [ + { + id: "general-assistant", + name: "General Assistant", + type: "built_in" as const, + systemPrompt: "Answer using the granted balance tool.", + }, + ], + { provider: "openai", defaultModel: "selected-local-model" }, + async () => "synthetic-model-key", + undefined, + async () => [ + { + name: "read_balance", + ref: "bank/read_balance", + description: "Read the balance.", + parameters: z.object({ account: z.string() }), + execute: async () => "42", + }, + ], + ); + const agent = agents["general-assistant"]?.clone(); + if (!agent) throw new Error("Expected General Assistant."); + agent.addMessage({ + id: "compatible-store-request", + role: "user", + content: "Read my balance.", + }); + await agent.runAgent(); + + expect(agent.messages.at(-1)).toMatchObject({ + role: "assistant", + content: "The balance is 42.", + }); + expect(bodies).toHaveLength(2); + expect(bodies.map((body) => body.store)).toEqual([false, false]); + expect(JSON.stringify(bodies[1]?.input)).not.toContain("item_reference"); + } finally { + if (originalBase === undefined) delete process.env.OPENAI_BASE_URL; + else process.env.OPENAI_BASE_URL = originalBase; + await proxy?.stop(true); + await recorder.stop(); + } + }); + /* * Switching the provider away from the package's own, without naming a model. The two fallbacks are * not the same source: OpenAI's is the built-in Bot's own (it speaks the Responses API), Anthropic's