jeopi-ai 16.4.4 → 16.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.5] - 2026-08-03
6
+
7
+ ### Added
8
+
9
+ - Added `salvageLeakedToolCalls` (`jeopi-ai/dialect`), which re-materializes a native-tool-calling turn whose tool call was emitted as visible `<invoke …>` text into real `toolCall` content blocks. It only fires on unambiguous leaks: the turn must have stopped normally, carry no native tool call, keep every `<invoke` outside fenced code blocks, and name only tools available that turn — so quoted documentation is never executed.
10
+
5
11
  ## [16.4.4] - 2026-08-03
6
12
 
7
13
  ### Fixed
@@ -7,5 +7,6 @@ export * from "./history";
7
7
  export * from "./inventory";
8
8
  export * from "./owned-stream";
9
9
  export { renderDelimitedThinking } from "./rendering";
10
+ export * from "./salvage";
10
11
  export * from "./thinking";
11
12
  export * from "./types";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Recovery for tool calls a native-tool-calling model emitted as plain text.
3
+ *
4
+ * Symptom: the provider returns a normal `end_turn` whose visible text carries
5
+ * a fully-formed Anthropic-style `<invoke name="…">…</invoke>` block instead of
6
+ * native `tool_use` content. The turn then holds zero runnable tool calls, so
7
+ * the agent loop stops with the markup rendered verbatim to the user and the
8
+ * announced work never runs.
9
+ *
10
+ * This module re-materializes such a turn into real `toolCall` blocks using the
11
+ * same in-band scanner the owned-dialect path uses, so the loop dispatches them
12
+ * unchanged. It is deliberately conservative: a turn is salvaged ONLY when the
13
+ * markup is unambiguously a call the model meant to make (see
14
+ * {@link salvageLeakedToolCalls}), never when it is quoted documentation.
15
+ */
16
+ import type { AssistantMessage, ToolCall } from "../types";
17
+ import type { InbandTool } from "./types";
18
+ export interface SalvagedToolCalls {
19
+ /** The rewritten turn: markup removed from text, real `toolCall` blocks appended. */
20
+ readonly message: AssistantMessage;
21
+ /** The calls recovered from the leaked markup, in emission order. */
22
+ readonly calls: readonly ToolCall[];
23
+ }
24
+ /**
25
+ * Recover tool calls a model wrote as visible text instead of native tool_use.
26
+ *
27
+ * Returns `undefined` unless EVERY guard holds — each one exists to keep a turn
28
+ * that merely *talks about* tool syntax from being executed:
29
+ *
30
+ * - The turn ended normally (`stop`). `length` may have truncated the markup
31
+ * mid-argument, and `error`/`aborted` turns are already handled upstream.
32
+ * - The turn carries no native `toolCall` block. A model that called tools
33
+ * natively AND quoted markup is documenting, not leaking.
34
+ * - At least one `<invoke` appears outside a fenced code block, and none appear
35
+ * inside one. Fenced markup is quoted by construction; mixing the two is
36
+ * ambiguous, so the whole turn is left alone.
37
+ * - The scanner recovers at least one call, and every recovered call names a
38
+ * tool that is actually available this turn (matching `name` or
39
+ * `customWireName`, mirroring the loop's own dispatch lookup).
40
+ *
41
+ * The caller decides what to do with the result; this function has no side
42
+ * effects and never mutates `message`.
43
+ */
44
+ export declare function salvageLeakedToolCalls(message: AssistantMessage, tools: readonly InbandTool[] | undefined): SalvagedToolCalls | undefined;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Fenced-code-block range computation shared by text scanners that must not
3
+ * trip on markup quoted inside ``` / ~~~ blocks (harmony-leak detection,
4
+ * leaked tool-call salvage).
5
+ */
6
+ /** A `[start, end)` byte span covered by a fenced code block. */
7
+ export type FenceRange = readonly [number, number];
8
+ /**
9
+ * Precompute fenced-code-block ranges once per text. Each range is a
10
+ * [start, end) span of bytes inside any ```/~~~ fence. O(n) once instead of
11
+ * O(n) per detected match.
12
+ */
13
+ export declare function computeFenceRanges(text: string): FenceRange[];
14
+ export declare function isInsideFence(ranges: readonly FenceRange[], position: number): boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-ai",
4
- "version": "16.4.4",
4
+ "version": "16.4.5",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "jeopi-catalog": "16.4.4",
42
- "jeopi-utils": "16.4.4",
43
- "jeopi-wire": "16.4.4",
41
+ "jeopi-catalog": "16.4.5",
42
+ "jeopi-utils": "16.4.5",
43
+ "jeopi-wire": "16.4.5",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -11,5 +11,6 @@ export * from "./owned-stream";
11
11
  // consumer needs (the legacy markdown `/dump` reuses its `<thinking>` envelope
12
12
  // unwrap), so re-export only that symbol rather than `export *`-ing the rest.
13
13
  export { renderDelimitedThinking } from "./rendering";
14
+ export * from "./salvage";
14
15
  export * from "./thinking";
15
16
  export * from "./types";
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Recovery for tool calls a native-tool-calling model emitted as plain text.
3
+ *
4
+ * Symptom: the provider returns a normal `end_turn` whose visible text carries
5
+ * a fully-formed Anthropic-style `<invoke name="…">…</invoke>` block instead of
6
+ * native `tool_use` content. The turn then holds zero runnable tool calls, so
7
+ * the agent loop stops with the markup rendered verbatim to the user and the
8
+ * announced work never runs.
9
+ *
10
+ * This module re-materializes such a turn into real `toolCall` blocks using the
11
+ * same in-band scanner the owned-dialect path uses, so the loop dispatches them
12
+ * unchanged. It is deliberately conservative: a turn is salvaged ONLY when the
13
+ * markup is unambiguously a call the model meant to make (see
14
+ * {@link salvageLeakedToolCalls}), never when it is quoted documentation.
15
+ */
16
+ import type { AssistantMessage, ToolCall } from "../types";
17
+ import { computeFenceRanges, isInsideFence } from "../utils/fences";
18
+ import { parseInbandToolMessage } from "./owned-stream";
19
+ import type { InbandTool } from "./types";
20
+
21
+ /** Matches an opening invoke tag, with or without a dialect prefix (`x:invoke`). */
22
+ const INVOKE_OPEN_RE = /<\s*(?:[A-Za-z_][\w.-]*:)?invoke\b/g;
23
+
24
+ export interface SalvagedToolCalls {
25
+ /** The rewritten turn: markup removed from text, real `toolCall` blocks appended. */
26
+ readonly message: AssistantMessage;
27
+ /** The calls recovered from the leaked markup, in emission order. */
28
+ readonly calls: readonly ToolCall[];
29
+ }
30
+
31
+ /**
32
+ * Recover tool calls a model wrote as visible text instead of native tool_use.
33
+ *
34
+ * Returns `undefined` unless EVERY guard holds — each one exists to keep a turn
35
+ * that merely *talks about* tool syntax from being executed:
36
+ *
37
+ * - The turn ended normally (`stop`). `length` may have truncated the markup
38
+ * mid-argument, and `error`/`aborted` turns are already handled upstream.
39
+ * - The turn carries no native `toolCall` block. A model that called tools
40
+ * natively AND quoted markup is documenting, not leaking.
41
+ * - At least one `<invoke` appears outside a fenced code block, and none appear
42
+ * inside one. Fenced markup is quoted by construction; mixing the two is
43
+ * ambiguous, so the whole turn is left alone.
44
+ * - The scanner recovers at least one call, and every recovered call names a
45
+ * tool that is actually available this turn (matching `name` or
46
+ * `customWireName`, mirroring the loop's own dispatch lookup).
47
+ *
48
+ * The caller decides what to do with the result; this function has no side
49
+ * effects and never mutates `message`.
50
+ */
51
+ export function salvageLeakedToolCalls(
52
+ message: AssistantMessage,
53
+ tools: readonly InbandTool[] | undefined,
54
+ ): SalvagedToolCalls | undefined {
55
+ if (message.stopReason !== "stop") return undefined;
56
+ if (!tools || tools.length === 0) return undefined;
57
+ if (message.content.some(block => block.type === "toolCall")) return undefined;
58
+
59
+ let text = "";
60
+ for (const block of message.content) {
61
+ if (block.type === "text") text += block.text;
62
+ }
63
+ if (text.length === 0) return undefined;
64
+
65
+ const fences = computeFenceRanges(text);
66
+ let bare = 0;
67
+ INVOKE_OPEN_RE.lastIndex = 0;
68
+ for (const match of text.matchAll(INVOKE_OPEN_RE)) {
69
+ if (isInsideFence(fences, match.index ?? 0)) return undefined;
70
+ bare++;
71
+ }
72
+ if (bare === 0) return undefined;
73
+
74
+ const parsed = parseInbandToolMessage(message, "anthropic", tools);
75
+ const calls = parsed.content.filter((block): block is ToolCall => block.type === "toolCall");
76
+ if (calls.length === 0) return undefined;
77
+
78
+ const available = new Set<string>();
79
+ for (const tool of tools) {
80
+ available.add(tool.name);
81
+ if (tool.customWireName !== undefined) available.add(tool.customWireName);
82
+ }
83
+ if (!calls.every(call => available.has(call.name))) return undefined;
84
+
85
+ // `stopReason` is restated rather than inherited: the projector happens to
86
+ // derive `toolUse` today, but this function's contract is "the loop must see
87
+ // a runnable tool turn, not a finished answer" — that must not silently
88
+ // depend on an internal of the owned-stream projector.
89
+ return { message: { ...parsed, stopReason: "toolUse" }, calls };
90
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Fenced-code-block range computation shared by text scanners that must not
3
+ * trip on markup quoted inside ``` / ~~~ blocks (harmony-leak detection,
4
+ * leaked tool-call salvage).
5
+ */
6
+
7
+ const FENCE_RE = /^\s*(?:```+|~~~+)/;
8
+
9
+ /** A `[start, end)` byte span covered by a fenced code block. */
10
+ export type FenceRange = readonly [number, number];
11
+
12
+ /**
13
+ * Precompute fenced-code-block ranges once per text. Each range is a
14
+ * [start, end) span of bytes inside any ```/~~~ fence. O(n) once instead of
15
+ * O(n) per detected match.
16
+ */
17
+ export function computeFenceRanges(text: string): FenceRange[] {
18
+ const ranges: FenceRange[] = [];
19
+ let inFence = false;
20
+ let fenceStart = 0;
21
+ let lineStart = 0;
22
+ while (lineStart <= text.length) {
23
+ const newline = text.indexOf("\n", lineStart);
24
+ const lineEnd = newline === -1 ? text.length : newline;
25
+ const line = text.slice(lineStart, lineEnd);
26
+ if (FENCE_RE.test(line)) {
27
+ if (inFence) {
28
+ ranges.push([fenceStart, lineEnd]);
29
+ inFence = false;
30
+ } else {
31
+ fenceStart = lineStart;
32
+ inFence = true;
33
+ }
34
+ }
35
+ if (newline === -1) break;
36
+ lineStart = newline + 1;
37
+ }
38
+ if (inFence) ranges.push([fenceStart, text.length]);
39
+ return ranges;
40
+ }
41
+
42
+ export function isInsideFence(ranges: readonly FenceRange[], position: number): boolean {
43
+ for (const [start, end] of ranges) {
44
+ if (position >= start && position < end) return true;
45
+ if (start > position) break;
46
+ }
47
+ return false;
48
+ }
@@ -8,6 +8,7 @@
8
8
  * abort-and-retry handled by the agent loop.
9
9
  */
10
10
  import type { AssistantMessage, Model, ToolCall } from "../types";
11
+ import { computeFenceRanges, isInsideFence } from "./fences";
11
12
 
12
13
  // Single source of truth for the marker pattern. `M` in the errata.
13
14
  // Use a fresh non-global instance for `.test()` to avoid lastIndex pitfalls.
@@ -29,8 +30,6 @@ const BODY_CASCADE_RE = /to=functions\.\w+\s+code\b[\s\S]{0,200}?to=functions\./
29
30
  // Fake-result framing (`R`): marker followed within 80 chars by Cell N: framing.
30
31
  const FAKE_RESULT_RE = /to=functions\.\w+[\s\S]{0,80}?code_output\s*\nCell\s+\d+:/;
31
32
 
32
- const FENCE_RE = /^\s*(?:```+|~~~+)/;
33
-
34
33
  // Non-Latin scripts seen in the corpus: CJK + ext, Cyrillic, Thai, Georgian,
35
34
  // Armenian, Kannada, Telugu, Devanagari, Arabic, Malayalam.
36
35
  const SCRIPT_CLASS =
@@ -342,44 +341,6 @@ function makeSignal(classes: HarmonySignalClass[], start: number, end: number, t
342
341
  return { classes: sorted, start, end, text };
343
342
  }
344
343
 
345
- /**
346
- * Precompute fenced-code-block ranges once per text. Each range is a
347
- * [start, end) span of bytes inside any ```/~~~ fence. O(n) once instead of
348
- * O(n) per detected match.
349
- */
350
- function computeFenceRanges(text: string): Array<[number, number]> {
351
- const ranges: Array<[number, number]> = [];
352
- let inFence = false;
353
- let fenceStart = 0;
354
- let lineStart = 0;
355
- while (lineStart <= text.length) {
356
- const newline = text.indexOf("\n", lineStart);
357
- const lineEnd = newline === -1 ? text.length : newline;
358
- const line = text.slice(lineStart, lineEnd);
359
- if (FENCE_RE.test(line)) {
360
- if (inFence) {
361
- ranges.push([fenceStart, lineEnd]);
362
- inFence = false;
363
- } else {
364
- fenceStart = lineStart;
365
- inFence = true;
366
- }
367
- }
368
- if (newline === -1) break;
369
- lineStart = newline + 1;
370
- }
371
- if (inFence) ranges.push([fenceStart, text.length]);
372
- return ranges;
373
- }
374
-
375
- function isInsideFence(ranges: Array<[number, number]>, position: number): boolean {
376
- for (const [start, end] of ranges) {
377
- if (position >= start && position < end) return true;
378
- if (start > position) break;
379
- }
380
- return false;
381
- }
382
-
383
344
  function hasScriptMismatchNear(text: string, start: number, end: number): boolean {
384
345
  const near = text.slice(Math.max(0, start - 32), Math.min(text.length, end + 32));
385
346
  if (!SCRIPT_RUN_RE.test(near)) return false;