Merge remote-tracking branch 'origin/farm/8b26bc9b/force-command-not-working'
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Ollama named tool forcing to send only the requested tool when the caller passes a named `toolChoice`, preserving `tool_choice: "required"` while preventing local models from selecting a different tool. ([#1236](https://github.com/can1357/oh-my-pi/issues/1236))
|
||||
- Fixed `/btw` (and IRC background replies) returning a `BedrockException` 400 (`The toolConfig field must be defined when using toolUse and toolResult content blocks.`) on LiteLLM → Bedrock once the session has tool-call history. Two source fixes in `buildParams`: (1) `if (context.tools)` → `if (context.tools?.length)` so an explicit `context.tools = []` (the /btw opt-out) never routes through `convertTools` and never emits an empty `"tools"` array; (2) `else if (hasToolHistory(...))` → `else if (context.tools === undefined && hasToolHistory(...))` so the Anthropic-proxy sentinel that injects `tools: []` for tool-history turns is suppressed when the caller explicitly opted out, preventing it from re-introducing the empty array. As defence-in-depth, `tool_choice: "none"` is also dropped when the resolved tools list is missing or empty. ([#1227](https://github.com/can1357/oh-my-pi/issues/1227))
|
||||
|
||||
## [15.1.8] - 2026-05-20
|
||||
|
||||
@@ -116,6 +116,29 @@ function mapToolChoice(toolChoice: ToolChoice | undefined): "auto" | "none" | "r
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNamedToolChoiceName(toolChoice: ToolChoice | undefined): string | undefined {
|
||||
if (!toolChoice || typeof toolChoice === "string") {
|
||||
return undefined;
|
||||
}
|
||||
if ("function" in toolChoice) {
|
||||
return toolChoice.function.name;
|
||||
}
|
||||
return toolChoice.name;
|
||||
}
|
||||
|
||||
function selectToolsForToolChoice(tools: Tool[] | undefined, toolChoice: ToolChoice | undefined): Tool[] | undefined {
|
||||
const toolName = getNamedToolChoiceName(toolChoice);
|
||||
if (!toolName || !tools) {
|
||||
return tools;
|
||||
}
|
||||
for (const tool of tools) {
|
||||
if (tool.name === toolName) {
|
||||
return [tool];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toPlainContent(content: string | Array<{ type: "text" | "image"; text?: string; data?: string }>): {
|
||||
content: string;
|
||||
images?: string[];
|
||||
@@ -231,10 +254,12 @@ function convertTools(tools: Tool[] | undefined): OllamaFunctionTool[] | undefin
|
||||
function createChatBody(model: Model<"ollama-chat">, context: Context, options: OllamaChatOptions | undefined) {
|
||||
const think = mapReasoning(options?.reasoning);
|
||||
const toolChoice = mapToolChoice(options?.toolChoice);
|
||||
const selectedTools = selectToolsForToolChoice(context.tools, options?.toolChoice);
|
||||
const tools = convertTools(selectedTools);
|
||||
return {
|
||||
model: model.id,
|
||||
messages: convertMessages(model, context),
|
||||
...(convertTools(context.tools) ? { tools: convertTools(context.tools) } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
...(think !== undefined ? { think } : {}),
|
||||
...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}),
|
||||
...(options?.maxTokens !== undefined ? { options: { num_predict: options.maxTokens } } : {}),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, describe, expect, test, vi } from "bun:test";
|
||||
import { Effort } from "../src/model-thinking";
|
||||
import { ollamaModelManagerOptions } from "../src/provider-models/openai-compat";
|
||||
import { streamOllama } from "../src/providers/ollama";
|
||||
import type { Context, Model, Tool } from "../src/types";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
@@ -9,6 +11,11 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
interface OllamaRequestBody {
|
||||
tools?: Array<{ function: { name: string } }>;
|
||||
tool_choice?: string;
|
||||
}
|
||||
|
||||
describe("ollama local provider discovery", () => {
|
||||
test("applies /api/show context and thinking capabilities to OpenAI-compatible local models", async () => {
|
||||
global.fetch = vi.fn(async (input, init) => {
|
||||
@@ -47,3 +54,55 @@ describe("ollama local provider discovery", () => {
|
||||
expect(model?.input).toEqual(["text", "image"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ollama tool forcing", () => {
|
||||
test("limits named forced tool requests to the selected tool", async () => {
|
||||
let requestBody: OllamaRequestBody | undefined;
|
||||
global.fetch = vi.fn(async (_input, init) => {
|
||||
requestBody = JSON.parse(String(init?.body ?? "{}")) as OllamaRequestBody;
|
||||
return new Response(`${JSON.stringify({ done: true })}\n`, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const model = {
|
||||
id: "ggml-org/gemma-3-1b-it/GGUF",
|
||||
name: "Gemma 3 1B",
|
||||
api: "ollama-chat",
|
||||
provider: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32_768,
|
||||
maxTokens: 8_192,
|
||||
} satisfies Model<"ollama-chat">;
|
||||
const readTool = {
|
||||
name: "read",
|
||||
description: "Read a file",
|
||||
parameters: { type: "object", properties: {}, additionalProperties: false },
|
||||
} satisfies Tool;
|
||||
const writeTool = {
|
||||
name: "write",
|
||||
description: "Write a file",
|
||||
parameters: { type: "object", properties: {}, additionalProperties: false },
|
||||
} satisfies Tool;
|
||||
const context = {
|
||||
messages: [{ role: "user", content: "Create README.md", timestamp: Date.now() }],
|
||||
tools: [readTool, writeTool],
|
||||
} satisfies Context;
|
||||
|
||||
const eventTypes: string[] = [];
|
||||
for await (const event of streamOllama(model, context, {
|
||||
apiKey: "test-key",
|
||||
toolChoice: { type: "function", name: "write" },
|
||||
})) {
|
||||
eventTypes.push(event.type);
|
||||
}
|
||||
|
||||
expect(eventTypes).toContain("done");
|
||||
expect(requestBody?.tool_choice).toBe("required");
|
||||
expect(requestBody?.tools?.map(tool => tool.function.name)).toEqual(["write"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
|
||||
- Fixed binary `omp update` rollbacks so a downloaded replacement that fails post-install version verification no longer remains installed over the previous working binary. ([#1240](https://github.com/can1357/oh-my-pi/issues/1240))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `/force <tool>` rejecting Ollama/local models before the requested tool could run; Ollama now receives a named forced choice that the provider transport narrows to the selected tool. ([#1236](https://github.com/can1357/oh-my-pi/issues/1236))
|
||||
|
||||
## [15.1.8] - 2026-05-20
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { Api, Model, ToolChoice } from "@oh-my-pi/pi-ai";
|
||||
|
||||
/**
|
||||
* Build a provider-aware tool choice that targets one specific tool when supported.
|
||||
* Some providers only support "any tool" forcing, not a named tool.
|
||||
* Providers that only expose required/any forcing may still honor named choices by
|
||||
* narrowing their request tool list before transport.
|
||||
*/
|
||||
export function buildNamedToolChoice(toolName: string, model?: Model<Api>): ToolChoice | undefined {
|
||||
if (!model) return undefined;
|
||||
@@ -20,12 +21,11 @@ export function buildNamedToolChoice(toolName: string, model?: Model<Api>): Tool
|
||||
return { type: "function", name: toolName };
|
||||
}
|
||||
|
||||
if (
|
||||
model.api === "google-generative-ai" ||
|
||||
model.api === "google-gemini-cli" ||
|
||||
model.api === "google-vertex" ||
|
||||
model.api === "ollama-chat"
|
||||
) {
|
||||
if (model.api === "ollama-chat") {
|
||||
return { type: "function", name: toolName };
|
||||
}
|
||||
|
||||
if (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") {
|
||||
return "required";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it, vi } from "bun:test";
|
||||
import type { Model } from "@oh-my-pi/pi-ai";
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry";
|
||||
import { buildNamedToolChoice } from "@oh-my-pi/pi-coding-agent/utils/tool-choice";
|
||||
|
||||
function createRuntimeHarness(overrides?: { setForcedToolChoice?: (toolName: string) => void }) {
|
||||
const setForcedToolChoice = vi.fn(overrides?.setForcedToolChoice ?? ((_toolName: string) => {}));
|
||||
@@ -95,4 +97,21 @@ describe("/force slash command", () => {
|
||||
expect(harness.showStatus).not.toHaveBeenCalled();
|
||||
expect(harness.setText).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("builds a named Ollama choice for local forced tools", () => {
|
||||
const model = {
|
||||
id: "ggml-org/gemma-3-1b-it/GGUF",
|
||||
name: "Gemma 3 1B",
|
||||
api: "ollama-chat",
|
||||
provider: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32_768,
|
||||
maxTokens: 8_192,
|
||||
} satisfies Model<"ollama-chat">;
|
||||
|
||||
expect(buildNamedToolChoice("write", model)).toEqual({ type: "function", name: "write" });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user