Three upgrades targeting the most common local-model failure modes where a tool call is intended but never executes: #2 Partial/truncated-JSON recovery (native streaming): - src/toolcalling/partialJson.ts: cheap structural repair for tool-call arguments that fail JSON.parse — close unterminated strings, balance open braces/brackets (max_tokens truncation), strip trailing commas and stray trailing tokens. Never invents keys/values; a repaired call still goes through schema validation. - agent/loop.ts: on a parse failure in accumulated native tool calls, try repair before falling back to a full non-streaming regeneration (which is expensive on a local backend and fails identically when the cause was max_tokens). Repaired args replace the broken ones; only unrepairable calls trigger the retry. - partialJson.test.ts: 10 cases (truncation, trailing comma nesting, braces inside strings, escaped quotes, stray trailing content). #3 Fallback tool-call parser robustness: - src/toolcalling/fallbackParser.ts: now accepts ```tool_call blocks (multiline-anchored so an inner ```json fence isn't read as the terminator), ```json blocks, AND bare unfenced tool-call objects in prose. Inner ```json fences are stripped. Every candidate runs through partial-JSON repair, so a truncated fence (no closing ```) still recovers. Only accepts bare braces that contain "name"+"arguments" keys. - fallbackParser.test.ts: 11 cases. #6 Empty-response retry: - agent/loop.ts: a bare empty `stop` (no text, no tool calls — common from small/quantized local models) now retries once with a nudge instead of returning "" or hard-erroring. After the retry budget is exhausted, a genuine empty response throws "Empty response from model." - loop.test.ts: retry-then-succeed and retry-exhausted-throws cases. Verified: typecheck clean, build 251.48 KB, 242 tests pass (+22).
64 lines
2.7 KiB
TypeScript
64 lines
2.7 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { repairPartialJson } from "./partialJson.js";
|
|
|
|
describe("repairPartialJson", () => {
|
|
it("parses already-valid JSON unchanged", () => {
|
|
expect(repairPartialJson('{"path":"src/a.ts"}')).toEqual({ path: "src/a.ts" });
|
|
expect(repairPartialJson("[]")).toEqual([]);
|
|
expect(repairPartialJson(" 42 ")).toBe(42);
|
|
expect(repairPartialJson('{"a":1}\n')).toEqual({ a: 1 });
|
|
});
|
|
|
|
it("returns null for empty / whitespace input", () => {
|
|
expect(repairPartialJson("")).toBeNull();
|
|
expect(repairPartialJson(" ")).toBeNull();
|
|
});
|
|
|
|
it("strips a trailing comma before a closing bracket", () => {
|
|
expect(repairPartialJson('{"a":1,}')).toEqual({ a: 1 });
|
|
expect(repairPartialJson('[1,2,]')).toEqual([1, 2]);
|
|
expect(repairPartialJson('{"a":{"b":2,},}')).toEqual({ a: { b: 2 } });
|
|
});
|
|
|
|
it("strips stray trailing content after a complete value", () => {
|
|
expect(repairPartialJson('{"a":1}\n```')).toEqual({ a: 1 });
|
|
expect(repairPartialJson('{"a":1}garbage')).toEqual({ a: 1 });
|
|
expect(repairPartialJson('[1,2] }')).toEqual([1, 2]);
|
|
});
|
|
|
|
it("closes a truncated string value", () => {
|
|
// max_tokens clipped mid-value: {"path":"src/lo → needs closing quote + brace
|
|
expect(repairPartialJson('{"path":"src/lo')).toEqual({ path: "src/lo" });
|
|
expect(repairPartialJson('{"a":"hello wor')).toEqual({ a: "hello wor" });
|
|
});
|
|
|
|
it("balances unclosed braces and brackets from truncation", () => {
|
|
expect(repairPartialJson('{"a":1')).toEqual({ a: 1 });
|
|
expect(repairPartialJson('{"a":{"b":2')).toEqual({ a: { b: 2 } });
|
|
expect(repairPartialJson("[1,2")).toEqual([1, 2]);
|
|
expect(repairPartialJson('{"items":[1,2')).toEqual({ items: [1, 2] });
|
|
});
|
|
|
|
it("does not count braces inside string literals", () => {
|
|
// The braces/brackets inside the string are content, not nesting.
|
|
expect(repairPartialJson('{"code":"func() { return [1] "')).toEqual({
|
|
code: "func() { return [1] ",
|
|
});
|
|
expect(repairPartialJson('{"s":"\\\"escaped\\\""}')).toEqual({ s: '"escaped"' });
|
|
});
|
|
|
|
it("handles escaped quotes inside strings during truncation repair", () => {
|
|
// Unterminated string with an escaped quote inside: {"s":"a\"b
|
|
expect(repairPartialJson('{"s":"a\\"b')).toEqual({ s: 'a"b' });
|
|
});
|
|
|
|
it("combined: trailing comma exposed after balancing", () => {
|
|
// {"a":1,"b":2, (truncated with trailing comma) → close brace, then strip comma
|
|
expect(repairPartialJson('{"a":1,"b":2,')).toEqual({ a: 1, b: 2 });
|
|
});
|
|
|
|
it("returns null when input is not salvageable as object/array/scalar", () => {
|
|
expect(repairPartialJson("just prose with no json")).toBeNull();
|
|
expect(repairPartialJson("{:}")).toBeNull();
|
|
});
|
|
}); |