Merge df539d50851f7f0ce245afde10942a5a045510a7 into 5e417b44e1540f528d2ae63e3e20229a902d1db2

This commit is contained in:
F_ool 2026-03-21 10:19:19 +08:00 committed by GitHub
commit 2656b48823
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 279 additions and 11 deletions

View File

@ -4,6 +4,7 @@ import {
createOllamaStreamFn,
convertToOllamaMessages,
buildAssistantMessage,
recoverToolCallsFromText,
parseNdjsonStream,
resolveOllamaBaseUrlForRun,
} from "./ollama-stream.js";
@ -182,6 +183,119 @@ describe("buildAssistantMessage", () => {
total: 0,
});
});
it("recovers tool calls from <tool_call> tags and strips markup from text", () => {
const response = {
model: "qwen3.5:9b",
created_at: "2026-01-01T00:00:00Z",
message: {
role: "assistant" as const,
content:
'I\'ll save that for you.\n<tool_call>\n{"name": "write_file", "arguments": {"path": "/tmp/note.md", "content": "hello"}}\n</tool_call>',
},
done: true,
prompt_eval_count: 50,
eval_count: 30,
};
const result = buildAssistantMessage(response, {
api: "ollama",
provider: "ollama",
id: "qwen3.5:9b",
});
expect(result.stopReason).toBe("toolUse");
const textParts = result.content.filter((c) => c.type === "text");
expect(textParts.length).toBe(1);
expect((textParts[0] as { text: string }).text).toBe("I'll save that for you.");
const toolCalls = result.content.filter((c) => c.type === "toolCall");
expect(toolCalls.length).toBe(1);
const tc = toolCalls[0] as { name: string; arguments: Record<string, unknown> };
expect(tc.name).toBe("write_file");
expect(tc.arguments).toEqual({ path: "/tmp/note.md", content: "hello" });
});
it("does not recover tool calls when proper tool_calls exist", () => {
const response = {
model: "qwen3.5:9b",
created_at: "2026-01-01T00:00:00Z",
message: {
role: "assistant" as const,
content: '<tool_call>{"name": "ghost", "arguments": {}}</tool_call>',
tool_calls: [{ function: { name: "bash", arguments: { command: "ls" } } }],
},
done: true,
};
const result = buildAssistantMessage(response, modelInfo);
const toolCalls = result.content.filter((c) => c.type === "toolCall");
expect(toolCalls.length).toBe(1);
expect((toolCalls[0] as { name: string }).name).toBe("bash");
});
it("does not recover tool calls from fenced JSON code blocks", () => {
const response = {
model: "qwen3.5:9b",
created_at: "2026-01-01T00:00:00Z",
message: {
role: "assistant" as const,
content:
'Here is an example:\n```json\n{"name": "bash", "arguments": {"command": "rm -rf /"}}\n```',
},
done: true,
};
const result = buildAssistantMessage(response, {
api: "ollama",
provider: "ollama",
id: "qwen3.5:9b",
});
expect(result.stopReason).toBe("stop");
expect(result.content.filter((c) => c.type === "toolCall").length).toBe(0);
});
});
describe("recoverToolCallsFromText", () => {
it("extracts tool calls from <tool_call> XML tags", () => {
const text =
'Sure!\n<tool_call>\n{"name": "bash", "arguments": {"command": "ls"}}\n</tool_call>';
const result = recoverToolCallsFromText(text);
expect(result).toEqual([{ function: { name: "bash", arguments: { command: "ls" } } }]);
});
it("handles function-wrapped format", () => {
const text =
'<tool_call>{"function": {"name": "bash", "arguments": {"command": "pwd"}}}</tool_call>';
const result = recoverToolCallsFromText(text);
expect(result).toEqual([{ function: { name: "bash", arguments: { command: "pwd" } } }]);
});
it("handles parameters key instead of arguments", () => {
const text = '<tool_call>{"name": "read", "parameters": {"path": "/tmp/x"}}</tool_call>';
const result = recoverToolCallsFromText(text);
expect(result).toEqual([{ function: { name: "read", arguments: { path: "/tmp/x" } } }]);
});
it("returns empty for plain text without tool patterns", () => {
expect(recoverToolCallsFromText("Just a normal reply.")).toEqual([]);
});
it("returns empty for malformed JSON inside tags", () => {
expect(recoverToolCallsFromText("<tool_call>not json</tool_call>")).toEqual([]);
});
it("extracts multiple tool calls", () => {
const text = [
'<tool_call>{"name": "bash", "arguments": {"command": "ls"}}</tool_call>',
'<tool_call>{"name": "read", "arguments": {"path": "a.txt"}}</tool_call>',
].join("\n");
const result = recoverToolCallsFromText(text);
expect(result.length).toBe(2);
expect(result[0]!.function.name).toBe("bash");
expect(result[1]!.function.name).toBe("read");
});
it("ignores fenced JSON blocks (security: no code-block recovery)", () => {
const text =
'```json\n{"name": "bash", "arguments": {"command": "rm -rf /"}}\n```';
expect(recoverToolCallsFromText(text)).toEqual([]);
});
});
// Helper: build a ReadableStreamDefaultReader from NDJSON lines
@ -399,6 +513,65 @@ describe("createOllamaStreamFn", () => {
);
});
it("uses model options num_ctx when contextWindow is not set", async () => {
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":true,"prompt_eval_count":1,"eval_count":1}',
],
async (fetchMock) => {
const streamFn = createOllamaStreamFn("http://ollama-host:11434", undefined, {
num_ctx: 8192,
});
const stream = await Promise.resolve(
streamFn(
{ id: "qwen3.5:9b", api: "ollama", provider: "ollama" } as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{} as never,
),
);
await collectStreamEvents(stream);
const [, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
const body = JSON.parse(requestInit.body as string) as {
options: Record<string, unknown>;
};
expect(body.options.num_ctx).toBe(8192);
},
);
});
it("contextWindow takes precedence over model options num_ctx", async () => {
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":true,"prompt_eval_count":1,"eval_count":1}',
],
async (fetchMock) => {
const streamFn = createOllamaStreamFn("http://ollama-host:11434", undefined, {
num_ctx: 8192,
});
const stream = await Promise.resolve(
streamFn(
{
id: "qwen3.5:9b",
api: "ollama",
provider: "ollama",
contextWindow: 131072,
} as never,
{ messages: [{ role: "user", content: "hi" }] } as never,
{} as never,
),
);
await collectStreamEvents(stream);
const [, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
const body = JSON.parse(requestInit.body as string) as {
options: Record<string, unknown>;
};
expect(body.options.num_ctx).toBe(131072);
},
);
});
it("merges default headers and allows request headers to override them", async () => {
await withMockNdjsonFetch(
[

View File

@ -335,6 +335,49 @@ function extractOllamaTools(tools: Tool[] | undefined): OllamaTool[] {
// ── Response conversion ─────────────────────────────────────────────────────
/**
* Recover structured tool calls from `<tool_call>…</tool_call>` tags in text.
*
* Some smaller local models (e.g. Qwen3.5-9B) understand tool definitions but
* emit the call as text instead of a proper `tool_calls` array. We only match
* the explicit `<tool_call>` tag pattern to minimise false-positive risk;
* generic fenced JSON blocks are intentionally ignored.
*/
export function recoverToolCallsFromText(text: string): OllamaToolCall[] {
const recovered: OllamaToolCall[] = [];
const tagRegex = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/gi;
let match: RegExpExecArray | null;
while ((match = tagRegex.exec(text)) !== null) {
const parsed = tryParseToolCallJson(match[1]!);
if (parsed) {
recovered.push(parsed);
}
}
return recovered;
}
function tryParseToolCallJson(raw: string): OllamaToolCall | null {
try {
const obj = parseJsonPreservingUnsafeIntegers(raw.trim()) as Record<string, unknown>;
const name = obj.name ?? (obj.function as Record<string, unknown> | undefined)?.name;
const args =
obj.arguments ??
obj.parameters ??
(obj.function as Record<string, unknown> | undefined)?.arguments;
if (typeof name === "string" && name && args && typeof args === "object" && !Array.isArray(args)) {
return { function: { name, arguments: args as Record<string, unknown> } };
}
} catch {
// Not valid JSON skip.
}
return null;
}
/** Strip `<tool_call>…</tool_call>` blocks from text after recovery. */
function stripToolCallTags(text: string): string {
return text.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, "").replace(/\n{3,}/g, "\n\n").trim();
}
export function buildAssistantMessage(
response: OllamaChatResponse,
modelInfo: { api: string; provider: string; id: string },
@ -344,11 +387,31 @@ export function buildAssistantMessage(
// Native Ollama reasoning fields are internal model output. The reply text
// must come from `content`; reasoning visibility is controlled elsewhere.
const text = response.message.content || "";
if (text) {
content.push({ type: "text", text });
let toolCalls = response.message.tool_calls;
let didRecover = false;
// Fallback: recover tool calls from <tool_call> tags when the model
// produced no native tool_calls. Only the explicit XML-tag pattern is
// matched to reduce false-positive risk (see security review).
if ((!toolCalls || toolCalls.length === 0) && text) {
const recovered = recoverToolCallsFromText(text);
if (recovered.length > 0) {
toolCalls = recovered;
didRecover = true;
log.info(
`Recovered ${recovered.length} tool call(s) from text output for model ${modelInfo.id}`,
);
}
}
// When tool calls were recovered from text, strip the raw markup so it
// does not pollute conversation history on subsequent turns.
const visibleText = didRecover ? stripToolCallTags(text) : text;
if (visibleText) {
content.push({ type: "text", text: visibleText });
}
const toolCalls = response.message.tool_calls;
if (toolCalls && toolCalls.length > 0) {
for (const tc of toolCalls) {
content.push({
@ -434,6 +497,7 @@ function resolveOllamaModelHeaders(model: {
export function createOllamaStreamFn(
baseUrl: string,
defaultHeaders?: Record<string, string>,
modelOptions?: Record<string, unknown>,
): StreamFn {
const chatUrl = resolveOllamaChatUrl(baseUrl);
@ -449,9 +513,16 @@ export function createOllamaStreamFn(
const ollamaTools = extractOllamaTools(context.tools);
// Ollama defaults to num_ctx=4096 which is too small for large
// system prompts + many tool definitions. Use model's contextWindow.
const ollamaOptions: Record<string, unknown> = { num_ctx: model.contextWindow ?? 65536 };
const userNumCtx =
modelOptions &&
typeof modelOptions.num_ctx === "number" &&
Number.isFinite(modelOptions.num_ctx)
? modelOptions.num_ctx
: undefined;
const ollamaOptions: Record<string, unknown> = {
...(modelOptions ?? {}),
num_ctx: model.contextWindow ?? userNumCtx ?? 65536,
};
if (typeof options?.temperature === "number") {
ollamaOptions.temperature = options.temperature;
}
@ -561,7 +632,7 @@ export function createOllamaStreamFn(
}
export function createConfiguredOllamaStreamFn(params: {
model: { baseUrl?: string; headers?: unknown };
model: { baseUrl?: string; headers?: unknown; options?: Record<string, unknown> };
providerBaseUrl?: string;
}): StreamFn {
const modelBaseUrl = typeof params.model.baseUrl === "string" ? params.model.baseUrl : undefined;
@ -571,5 +642,6 @@ export function createConfiguredOllamaStreamFn(params: {
providerBaseUrl: params.providerBaseUrl,
}),
resolveOllamaModelHeaders(params.model),
params.model.options,
);
}

View File

@ -146,7 +146,8 @@ function applyConfiguredProviderOverrides(params: {
}
: undefined,
compat: configuredModel?.compat ?? discoveredModel.compat,
};
...(configuredModel?.options != null ? { options: configuredModel.options } : {}),
} as Model<Api>;
}
export function buildInlineProviderModels(
@ -301,6 +302,7 @@ export function resolveModelWithRegistry(params: {
DEFAULT_CONTEXT_TOKENS,
headers:
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
...(configuredModel?.options != null ? { options: configuredModel.options } : {}),
} as Model<Api>,
});
}

View File

@ -2159,12 +2159,20 @@ export async function runEmbeddedAttempt(
queueYieldInterruptForSession = () => {
queueSessionsYieldInterruptMessage(activeSession);
};
const modelWithOptions = params.model as { options?: Record<string, unknown> };
const optionsNumCtx =
typeof modelWithOptions.options?.num_ctx === "number"
? modelWithOptions.options.num_ctx
: undefined;
removeToolResultContextGuard = installToolResultContextGuard({
agent: activeSession.agent,
contextWindowTokens: Math.max(
1,
Math.floor(
params.model.contextWindow ?? params.model.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
params.model.contextWindow ??
optionsNumCtx ??
params.model.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
),
),
});
@ -2237,7 +2245,10 @@ export async function runEmbeddedAttempt(
const numCtx = Math.max(
1,
Math.floor(
params.model.contextWindow ?? params.model.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
params.model.contextWindow ??
optionsNumCtx ??
params.model.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
),
);
activeSession.agent.streamFn = wrapOllamaCompatNumCtx(activeSession.agent.streamFn, numCtx);

View File

@ -254,9 +254,17 @@ export function applyModelDefaults(cfg: OpenClawConfig): OpenClawConfig {
modelMutated = true;
}
const optionsNumCtx =
raw.options &&
typeof raw.options === "object" &&
typeof (raw.options as Record<string, unknown>).num_ctx === "number" &&
Number.isFinite((raw.options as Record<string, unknown>).num_ctx) &&
((raw.options as Record<string, unknown>).num_ctx as number) > 0
? ((raw.options as Record<string, unknown>).num_ctx as number)
: undefined;
const contextWindow = isPositiveNumber(raw.contextWindow)
? raw.contextWindow
: DEFAULT_CONTEXT_TOKENS;
: optionsNumCtx ?? DEFAULT_CONTEXT_TOKENS;
if (raw.contextWindow !== contextWindow) {
modelMutated = true;
}

View File

@ -59,6 +59,7 @@ export type ModelDefinitionConfig = {
maxTokens: number;
headers?: Record<string, string>;
compat?: ModelCompatConfig;
options?: Record<string, unknown>;
};
export type ModelProviderConfig = {

View File

@ -242,6 +242,7 @@ export const ModelDefinitionSchema = z
maxTokens: z.number().positive().optional(),
headers: z.record(z.string(), z.string()).optional(),
compat: ModelCompatSchema,
options: z.record(z.string(), z.unknown()).optional(),
})
.strict();