Skip to content

Commit edb6564

Browse files
fix: Ollama provider tool result handling and premature context condensing (#848)
* ollama fixes * fix(ollama): isolate failing model fetches and address PR #848 review - getOllamaModels: append per-request .catch() so a single failing /api/show no longer rejects the whole Promise.all and wipes out all healthy models (issue #851) - native-ollama: keep tool results text-only; move images extracted from tool results onto the adjacent user message and reset the per-result image accumulator to prevent leakage between tool results - native-ollama: recursively strip additionalProperties from tool schemas (top-level and nested properties/items) instead of only the top level - tests: update ollama fetcher maxTokens expectations to the inherited default (4096), add regression test for individual model fetch failure, and add coverage for text-only tool results, image relocation, image leakage prevention, and nested additionalProperties stripping
1 parent e2cdd3c commit edb6564

4 files changed

Lines changed: 545 additions & 23 deletions

File tree

src/api/providers/__tests__/native-ollama.spec.ts

Lines changed: 364 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,19 +117,31 @@ describe("NativeOllamaHandler", () => {
117117
// consume stream
118118
}
119119

120-
// Text blocks are joined with "\n"; the image emits a placeholder and is
121-
// flushed separately via the `images` field rather than inlined.
120+
// Text blocks are joined with "\n"; the image emits a placeholder.
121+
// Tool results are text-only in Ollama, so the image is flushed onto a
122+
// separate adjacent user message via the `images` field rather than
123+
// inlined into the tool result.
122124
expect(mockChat).toHaveBeenCalledWith(
123125
expect.objectContaining({
124126
messages: expect.arrayContaining([
125127
expect.objectContaining({
126128
role: "user",
127129
content: "line one\n(see following user message for image)\nline two",
130+
}),
131+
expect.objectContaining({
132+
role: "user",
128133
images: ["imgdata"],
129134
}),
130135
]),
131136
}),
132137
)
138+
139+
// The tool result message itself must not carry an images field.
140+
const callArgs = mockChat.mock.calls[0][0] as any
141+
const toolResultMessage = callArgs.messages.find(
142+
(m: any) => typeof m.content === "string" && m.content.includes("line one"),
143+
)
144+
expect(toolResultMessage.images).toBeUndefined()
133145
})
134146

135147
it("should drop unknown block types in tool_result content (empty string contribution)", async () => {
@@ -1238,5 +1250,355 @@ describe("NativeOllamaHandler", () => {
12381250
const firstEndIndex = results.findIndex((r) => r.type === "tool_call_end")
12391251
expect(firstEndIndex).toBeGreaterThan(lastPartialIndex)
12401252
})
1253+
1254+
it("should send tool results with role 'tool' and tool_name when preceded by a tool_use", async () => {
1255+
mockChat.mockImplementation(async function* () {
1256+
yield { message: { content: "ok" } }
1257+
})
1258+
1259+
const messages: Anthropic.Messages.MessageParam[] = [
1260+
{
1261+
role: "assistant",
1262+
content: [
1263+
{
1264+
type: "tool_use",
1265+
id: "tool-abc",
1266+
name: "apply_diff",
1267+
input: {
1268+
path: "foo.ts",
1269+
diff: "SEARCH_REPLACE_DIFF_CONTENT",
1270+
},
1271+
},
1272+
],
1273+
},
1274+
{
1275+
role: "user",
1276+
content: [
1277+
{
1278+
type: "tool_result",
1279+
tool_use_id: "tool-abc",
1280+
content: "Diff applied successfully",
1281+
},
1282+
],
1283+
},
1284+
]
1285+
1286+
const stream = handler.createMessage("System", messages)
1287+
for await (const _ of stream) {
1288+
// consume stream
1289+
}
1290+
1291+
// The tool result should use Ollama's native "tool" role with tool_name
1292+
expect(mockChat).toHaveBeenCalledWith(
1293+
expect.objectContaining({
1294+
messages: expect.arrayContaining([
1295+
expect.objectContaining({
1296+
role: "tool",
1297+
tool_name: "apply_diff",
1298+
content: "Diff applied successfully",
1299+
}),
1300+
]),
1301+
}),
1302+
)
1303+
})
1304+
1305+
it("should fall back to role 'user' for tool results when no matching tool_use is found", async () => {
1306+
mockChat.mockImplementation(async function* () {
1307+
yield { message: { content: "ok" } }
1308+
})
1309+
1310+
const messages: Anthropic.Messages.MessageParam[] = [
1311+
{
1312+
role: "user",
1313+
content: [
1314+
{
1315+
type: "tool_result",
1316+
tool_use_id: "unknown-id",
1317+
content: "orphan result",
1318+
},
1319+
],
1320+
},
1321+
]
1322+
1323+
const stream = handler.createMessage("System", messages)
1324+
for await (const _ of stream) {
1325+
// consume stream
1326+
}
1327+
1328+
// No preceding tool_use -> fall back to "user" role
1329+
expect(mockChat).toHaveBeenCalledWith(
1330+
expect.objectContaining({
1331+
messages: expect.arrayContaining([
1332+
expect.objectContaining({
1333+
role: "user",
1334+
content: "orphan result",
1335+
}),
1336+
]),
1337+
}),
1338+
)
1339+
})
1340+
1341+
it("should strip additionalProperties from tool schema parameters", async () => {
1342+
mockGetOllamaModels.mockResolvedValue({
1343+
"llama3.2": {
1344+
contextWindow: 128000,
1345+
maxTokens: 4096,
1346+
supportsImages: true,
1347+
supportsPromptCache: false,
1348+
},
1349+
})
1350+
1351+
const options: ApiHandlerOptions = {
1352+
apiModelId: "llama3.2",
1353+
ollamaModelId: "llama3.2",
1354+
ollamaBaseUrl: "http://localhost:11434",
1355+
}
1356+
1357+
handler = new NativeOllamaHandler(options)
1358+
1359+
mockChat.mockImplementation(async function* () {
1360+
yield { message: { content: "ok" } }
1361+
})
1362+
1363+
const tools = [
1364+
{
1365+
type: "function" as const,
1366+
function: {
1367+
name: "apply_diff",
1368+
description: "Apply a diff",
1369+
parameters: {
1370+
type: "object",
1371+
properties: {
1372+
path: { type: "string", description: "File path" },
1373+
diff: { type: "string", description: "Diff content" },
1374+
},
1375+
required: ["path", "diff"],
1376+
additionalProperties: false,
1377+
},
1378+
},
1379+
},
1380+
]
1381+
1382+
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Edit the file" }], {
1383+
taskId: "test",
1384+
tools,
1385+
})
1386+
1387+
for await (const _ of stream) {
1388+
// consume stream
1389+
}
1390+
1391+
// additionalProperties should be stripped from the parameters
1392+
expect(mockChat).toHaveBeenCalledWith(
1393+
expect.objectContaining({
1394+
tools: [
1395+
{
1396+
type: "function",
1397+
function: {
1398+
name: "apply_diff",
1399+
description: "Apply a diff",
1400+
parameters: {
1401+
type: "object",
1402+
properties: {
1403+
path: { type: "string", description: "File path" },
1404+
diff: { type: "string", description: "Diff content" },
1405+
},
1406+
required: ["path", "diff"],
1407+
},
1408+
},
1409+
},
1410+
],
1411+
}),
1412+
)
1413+
1414+
// Explicitly verify additionalProperties is not present
1415+
const callArgs = mockChat.mock.calls[0][0] as any
1416+
expect(callArgs.tools[0].function.parameters).not.toHaveProperty("additionalProperties")
1417+
})
1418+
1419+
it("should recursively strip additionalProperties from nested tool schema parameters", async () => {
1420+
mockGetOllamaModels.mockResolvedValue({
1421+
"llama3.2": {
1422+
contextWindow: 128000,
1423+
maxTokens: 4096,
1424+
supportsImages: true,
1425+
supportsPromptCache: false,
1426+
},
1427+
})
1428+
1429+
const options: ApiHandlerOptions = {
1430+
apiModelId: "llama3.2",
1431+
ollamaModelId: "llama3.2",
1432+
ollamaBaseUrl: "http://localhost:11434",
1433+
}
1434+
1435+
handler = new NativeOllamaHandler(options)
1436+
1437+
mockChat.mockImplementation(async function* () {
1438+
yield { message: { content: "ok" } }
1439+
})
1440+
1441+
const tools = [
1442+
{
1443+
type: "function" as const,
1444+
function: {
1445+
name: "apply_diff",
1446+
description: "Apply a diff",
1447+
parameters: {
1448+
type: "object",
1449+
properties: {
1450+
path: { type: "string", description: "File path" },
1451+
options: {
1452+
type: "object",
1453+
properties: {
1454+
dry_run: { type: "boolean" },
1455+
backup: { type: "boolean" },
1456+
},
1457+
required: ["dry_run"],
1458+
additionalProperties: false,
1459+
},
1460+
},
1461+
required: ["path", "options"],
1462+
additionalProperties: false,
1463+
},
1464+
},
1465+
},
1466+
]
1467+
1468+
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Edit the file" }], {
1469+
taskId: "test",
1470+
tools,
1471+
})
1472+
1473+
for await (const _ of stream) {
1474+
// consume stream
1475+
}
1476+
1477+
const callArgs = mockChat.mock.calls[0][0] as any
1478+
const params = callArgs.tools[0].function.parameters
1479+
1480+
// Top-level additionalProperties stripped
1481+
expect(params).not.toHaveProperty("additionalProperties")
1482+
1483+
// Nested additionalProperties also stripped
1484+
expect(params.properties.options).not.toHaveProperty("additionalProperties")
1485+
expect(params.properties.options.properties.dry_run).toEqual({ type: "boolean" })
1486+
})
1487+
1488+
it("should keep tool results text-only and move images onto the adjacent user message", async () => {
1489+
mockChat.mockImplementation(async function* () {
1490+
yield { message: { content: "ok" } }
1491+
})
1492+
1493+
const messages: Anthropic.Messages.MessageParam[] = [
1494+
{
1495+
role: "assistant",
1496+
content: [
1497+
{
1498+
type: "tool_use",
1499+
id: "tool-img",
1500+
name: "read_file",
1501+
input: { path: "foo.ts" },
1502+
},
1503+
],
1504+
},
1505+
{
1506+
role: "user",
1507+
content: [
1508+
{
1509+
type: "tool_result",
1510+
tool_use_id: "tool-img",
1511+
content: [
1512+
{ type: "text", text: "screenshot" },
1513+
{
1514+
type: "image",
1515+
source: {
1516+
type: "base64",
1517+
media_type: "image/png",
1518+
data: "imgdata",
1519+
},
1520+
},
1521+
],
1522+
},
1523+
{ type: "text", text: "please continue" },
1524+
],
1525+
},
1526+
]
1527+
1528+
const stream = handler.createMessage("System", messages)
1529+
for await (const _ of stream) {
1530+
// consume stream
1531+
}
1532+
1533+
const callArgs = mockChat.mock.calls[0][0] as any
1534+
1535+
// The tool result uses the native "tool" role and is text-only.
1536+
const toolMessage = callArgs.messages.find((m: any) => m.role === "tool")
1537+
expect(toolMessage).toBeDefined()
1538+
expect(toolMessage.tool_name).toBe("read_file")
1539+
expect(toolMessage.images).toBeUndefined()
1540+
1541+
// The image is carried by the adjacent user message.
1542+
const userMessage = callArgs.messages.find(
1543+
(m: any) => m.role === "user" && Array.isArray(m.images) && m.images.includes("imgdata"),
1544+
)
1545+
expect(userMessage).toBeDefined()
1546+
})
1547+
1548+
it("should not leak images from one tool result into another", async () => {
1549+
mockChat.mockImplementation(async function* () {
1550+
yield { message: { content: "ok" } }
1551+
})
1552+
1553+
const messages: Anthropic.Messages.MessageParam[] = [
1554+
{
1555+
role: "assistant",
1556+
content: [
1557+
{ type: "tool_use", id: "tool-a", name: "read_file", input: { path: "a.ts" } },
1558+
{ type: "tool_use", id: "tool-b", name: "read_file", input: { path: "b.ts" } },
1559+
],
1560+
},
1561+
{
1562+
role: "user",
1563+
content: [
1564+
{
1565+
type: "tool_result",
1566+
tool_use_id: "tool-a",
1567+
content: [
1568+
{ type: "text", text: "a" },
1569+
{
1570+
type: "image",
1571+
source: { type: "base64", media_type: "image/png", data: "img-a" },
1572+
},
1573+
],
1574+
},
1575+
{
1576+
type: "tool_result",
1577+
tool_use_id: "tool-b",
1578+
content: [{ type: "text", text: "b" }],
1579+
},
1580+
],
1581+
},
1582+
]
1583+
1584+
const stream = handler.createMessage("System", messages)
1585+
for await (const _ of stream) {
1586+
// consume stream
1587+
}
1588+
1589+
const callArgs = mockChat.mock.calls[0][0] as any
1590+
const toolMessages = callArgs.messages.filter((m: any) => m.role === "tool")
1591+
1592+
// Neither tool result should carry an images field.
1593+
expect(toolMessages).toHaveLength(2)
1594+
for (const m of toolMessages) {
1595+
expect(m.images).toBeUndefined()
1596+
}
1597+
1598+
// The single image is delivered once via the adjacent user message.
1599+
const userImageMessages = callArgs.messages.filter((m: any) => m.role === "user" && Array.isArray(m.images))
1600+
expect(userImageMessages).toHaveLength(1)
1601+
expect(userImageMessages[0].images).toEqual(["img-a"])
1602+
})
12411603
})
12421604
})

0 commit comments

Comments
 (0)