@blockrun/franklin 3.22.0 → 3.23.1

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.
@@ -242,7 +242,7 @@ You run on the BlockRun AI Gateway. When the user asks you to "test the BlockRun
242
242
  - \`GET /.well-known/x402\` — x402 resource list with prices
243
243
 
244
244
  **LLM (POST, x402-paid)**
245
- - \`POST /v1/chat/completions\` — OpenAI-compatible. Body: \`{ model, messages, stream?, tools?, max_tokens?, temperature? }\`. \`model\` MUST come from \`GET /v1/models\` (real frontier examples on the gateway as of 2026-05: \`anthropic/claude-sonnet-4.6\`, \`anthropic/claude-opus-4.7\`, \`deepseek/deepseek-v4-pro\`, \`zai/glm-5.1\`, \`nvidia/qwen3-coder-480b\`, \`openai/gpt-5-nano\`). Do NOT invent versions like \`openai/gpt-5.1\` or \`xai/grok-5\` — those don't exist; the gateway 400s with the valid list in the error body, so when in doubt fetch \`GET /v1/models\` first.
245
+ - \`POST /v1/chat/completions\` — OpenAI-compatible. Body: \`{ model, messages, stream?, tools?, max_tokens?, temperature? }\`. \`model\` MUST come from \`GET /v1/models\` (real frontier examples on the gateway as of 2026-05: \`anthropic/claude-sonnet-4.6\`, \`anthropic/claude-opus-4.8\`, \`deepseek/deepseek-v4-pro\`, \`zai/glm-5.1\`, \`nvidia/qwen3-coder-480b\`, \`openai/gpt-5-nano\`). Do NOT invent versions like \`openai/gpt-5.1\` or \`xai/grok-5\` — those don't exist; the gateway 400s with the valid list in the error body, so when in doubt fetch \`GET /v1/models\` first.
246
246
  - \`POST /v1/messages\` — Anthropic-compatible. Body: \`{ model, messages, max_tokens, system?, tools? }\`.
247
247
 
248
248
  **Media (POST, x402-paid; GET to poll async jobs)**
@@ -30,6 +30,17 @@ export function classifyAgentError(message) {
30
30
  // `Exa /v1/exa/search failed (402): {"error":"Payment verification failed",...}`.
31
31
  // Classify BEFORE the generic 'payment' branch below since the body
32
32
  // contains both 'payment' and 'verification failed'.
33
+ //
34
+ // Treated as transient with a small retry budget: real-world telemetry
35
+ // (2026-05-28 audit) shows the gateway intermittently rejects valid
36
+ // signed payments under burst load — identical prompts succeed 5s
37
+ // later. Most plausible root cause is a nonce-cache race in the
38
+ // gateway's replay protection. Retrying re-signs with a fresh nonce on
39
+ // each attempt (llm.ts derives a new nonce per request), so a retry
40
+ // is NOT a replay. Three attempts is enough to ride out the blip
41
+ // without burning tokens on a model whose wallet is genuinely
42
+ // misconfigured (clock skew, wrong chain) — those failure modes are
43
+ // deterministic and will exhaust the budget quickly.
33
44
  if (includesAny(err, [
34
45
  'verification failed',
35
46
  'payment verification',
@@ -40,8 +51,8 @@ export function classifyAgentError(message) {
40
51
  'replay protection',
41
52
  ])) {
42
53
  return {
43
- category: 'payment_rejected', label: 'PaymentRejected', isTransient: false, maxRetries: 0,
44
- suggestion: 'The gateway rejected your signed payment. Run `franklin balance` to confirm funds + chain. Common causes: clock skew (resync system clock), wrong chain selected (use `/chain` to switch), or stale nonce (the same retry will fail). Switch to a free model with `/model free` to keep working.',
54
+ category: 'payment_rejected', label: 'PaymentRejected', isTransient: true, maxRetries: 3,
55
+ suggestion: 'The gateway rejected your signed payment. If this keeps happening: run `franklin balance` to confirm funds + chain. Common causes: clock skew (resync system clock), wrong chain selected (use `/chain` to switch). Transient blips are auto-retried.',
45
56
  };
46
57
  }
47
58
  if (includesAny(err, [
package/dist/agent/llm.js CHANGED
@@ -9,6 +9,7 @@ import { appendSettlementRow } from '../stats/cost-log.js';
9
9
  import { routeRequest, parseRoutingProfile } from '../router/index.js';
10
10
  import { ThinkTagStripper } from './think-tag-stripper.js';
11
11
  import { isNemotronProseModel, stripNemotronProse } from './nemotron-prose-stripper.js';
12
+ import { repairAndParseArgs } from './repair/index.js';
12
13
  // Reasoning-tier models the gateway routes to that reject `tool_choice`
13
14
  // outright. Pattern: OpenAI o1/o3 family + DeepSeek's reasoner variant.
14
15
  // Add new entries as their 400 errors appear in real sessions; this is
@@ -195,6 +196,8 @@ export function modelHasExtendedThinking(model) {
195
196
  const m = model.toLowerCase();
196
197
  // Excluded: Opus 4.7+ uses adaptive thinking; sending `thinking: enabled`
197
198
  // causes the API to 400.
199
+ if (m.includes('opus-4.8') || m.includes('opus-4-8'))
200
+ return false;
198
201
  if (m.includes('opus-4.7') || m.includes('opus-4-7'))
199
202
  return false;
200
203
  return (m.includes('opus-4.6') || m.includes('opus-4-6') ||
@@ -774,17 +777,27 @@ export class ModelClient {
774
777
  if (currentToolId) {
775
778
  let parsedInput = {};
776
779
  let inputParseError = false;
780
+ // First try strict parse; on failure, fall back to the
781
+ // truncation-repair pipeline (closes unbalanced braces,
782
+ // trims trailing commas, fills dangling keys with null).
783
+ // Saves a turn whenever max_tokens cut a tool_use mid-emit.
777
784
  try {
778
785
  parsedInput = JSON.parse(currentToolInput || '{}');
779
786
  }
780
787
  catch (parseErr) {
781
- // Incomplete JSON from stream abort or model error.
782
- // Mark as error so the executor returns an error result
783
- // instead of silently invoking the tool with empty/wrong params.
784
- inputParseError = true;
785
- if (this.debug) {
786
- console.error(`[franklin] Malformed tool input JSON for ${currentToolName}: ${parseErr.message}`);
787
- console.error(`[franklin] Raw input was: ${currentToolInput.slice(0, 200)}`);
788
+ const repaired = repairAndParseArgs(currentToolInput || '{}');
789
+ if (repaired) {
790
+ parsedInput = repaired.input;
791
+ if (this.debug && repaired.repaired) {
792
+ console.error(`[franklin] repaired truncated tool_use JSON for ${currentToolName}: ${repaired.notes.join('; ')}`);
793
+ }
794
+ }
795
+ else {
796
+ inputParseError = true;
797
+ if (this.debug) {
798
+ console.error(`[franklin] Malformed tool input JSON for ${currentToolName}: ${parseErr.message}`);
799
+ console.error(`[franklin] Raw input was: ${currentToolInput.slice(0, 200)}`);
800
+ }
788
801
  }
789
802
  }
790
803
  if (inputParseError) {
@@ -14,6 +14,7 @@ import { StreamingExecutor } from './streaming-executor.js';
14
14
  import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS, getMaxOutputTokens } from './optimize.js';
15
15
  import { classifyAgentError } from './error-classifier.js';
16
16
  import { SessionToolGuard } from './tool-guard.js';
17
+ import { ToolCallRepair } from './repair/index.js';
17
18
  import { resetToolSessionState } from '../tools/index.js';
18
19
  import { CORE_TOOL_NAMES, dynamicToolsEnabled } from '../tools/tool-categories.js';
19
20
  import { createActivateToolCapability } from '../tools/activate.js';
@@ -608,6 +609,11 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
608
609
  // outputs, or paths. Fed into opt-in telemetry at session end.
609
610
  const sessionToolCounts = new Map();
610
611
  const toolGuard = new SessionToolGuard();
612
+ // Recovers tool calls that the model leaked into the text or thinking
613
+ // channels instead of the structured tool_use channel. See
614
+ // src/agent/repair/scavenge.ts for the failure modes — most common on
615
+ // DeepSeek R1 and small Qwen/Llama variants behind the BlockRun gateway.
616
+ const callRepair = new ToolCallRepair({ allowedToolNames: activeTools });
611
617
  const persistSessionMeta = () => {
612
618
  updateSessionMeta(sessionId, {
613
619
  model: config.model,
@@ -1302,6 +1308,29 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1302
1308
  responseParts = result.content;
1303
1309
  usage = result.usage;
1304
1310
  stopReason = result.stopReason;
1311
+ // ── Tool-call scavenge ──
1312
+ // Recover tool calls the model emitted as text/thinking instead of
1313
+ // structured tool_use blocks. Common on DeepSeek R1 (leaks JSON
1314
+ // into reasoning_content) and small Qwen/Llama variants. If the
1315
+ // scavenger finds anything, splice it into responseParts so the
1316
+ // empty-response and stalled-intent checks below see tools.
1317
+ {
1318
+ const declaredCalls = responseParts.filter((p) => p.type === 'tool_use');
1319
+ const reasoningText = responseParts
1320
+ .filter((p) => p.type === 'thinking')
1321
+ .map(p => p.thinking)
1322
+ .join('\n');
1323
+ const contentText = responseParts
1324
+ .filter((p) => p.type === 'text')
1325
+ .map(p => p.text)
1326
+ .join('\n');
1327
+ const repaired = callRepair.process(declaredCalls, reasoningText || null, contentText || null);
1328
+ if (repaired.report.scavenged > 0) {
1329
+ const novelCalls = repaired.calls.slice(declaredCalls.length);
1330
+ responseParts = [...responseParts, ...novelCalls];
1331
+ logger.warn(`[franklin] scavenged ${repaired.report.scavenged} leaked tool call(s) from ${config.model}: ${repaired.report.notes.join('; ')}`);
1332
+ }
1333
+ }
1305
1334
  // ── Empty response recovery ──
1306
1335
  // If the model returns nothing, DON'T just retry the same model with the same input.
1307
1336
  // That's deterministic waste. Instead: switch to a different model — then give up and tell the user.
@@ -1510,13 +1539,21 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1510
1539
  continue;
1511
1540
  }
1512
1541
  // ── Payment failure: auto-fallback to free models ──
1513
- // Track payment-failed models for the entire session — unlike transient errors,
1514
- // 402s will keep failing until the user adds funds. Also handles
1515
- // payment_rejected (signature verified-and-rejected by gateway):
1516
- // same fallback path, but the suggestion text in classifier guides
1517
- // the user toward clock-skew / chain-mismatch fixes rather than
1518
- // "add funds."
1519
- if (classified.category === 'payment' || classified.category === 'payment_rejected') {
1542
+ // 'payment' (insufficient funds / 402): session-permanent blacklist
1543
+ // the wallet won't refill mid-session, so retrying the same model
1544
+ // just wastes a turn. Record to elo so the router learns to avoid it.
1545
+ //
1546
+ // 'payment_rejected' (signed payment rejected by gateway): only
1547
+ // fall back FOR THIS TURN — do NOT add to paymentFailedModels and
1548
+ // do NOT record to elo. The retry budget from the transient path
1549
+ // above (3 attempts) has already been exhausted at this point;
1550
+ // this fallback just lets the user keep working. The next user
1551
+ // turn resets to baseModel (see top of outer loop) so a single
1552
+ // gateway nonce-race blip can't permanently demote the user to
1553
+ // free models for the whole session — that's the bug audited
1554
+ // 2026-05-28 from telemetry showing 28/468 PaymentRejected with
1555
+ // identical prompts succeeding 5s apart.
1556
+ if (classified.category === 'payment') {
1520
1557
  turnFailedModels.add(config.model);
1521
1558
  paymentFailedModels.set(config.model, Date.now());
1522
1559
  // Bound the Map so long sessions don't leak. LRU-evict oldest by timestamp.
@@ -1542,6 +1579,25 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1542
1579
  continue; // Retry with next model
1543
1580
  }
1544
1581
  }
1582
+ if (classified.category === 'payment_rejected') {
1583
+ turnFailedModels.add(config.model);
1584
+ const nextFree = pickFreeFallback(lastRoutedCategory, turnFailedModels);
1585
+ if (nextFree) {
1586
+ const oldModel = config.model;
1587
+ config.model = nextFree;
1588
+ config.onModelChange?.(nextFree, 'system');
1589
+ const reason = `gateway rejected payment [${classified.label}] — will retry ${oldModel} next turn`;
1590
+ // Reset retry counter — the transient path above already burned
1591
+ // this turn's budget on the rejected model; the free fallback
1592
+ // model gets its own (mirrors the rate_limit fallback below).
1593
+ recoveryAttempts = 0;
1594
+ onEvent({
1595
+ kind: 'text_delta',
1596
+ text: `\n*${formatModelSwitch(oldModel, resolvedModel, reason, nextFree)}*\n`,
1597
+ });
1598
+ continue; // Retry with next model
1599
+ }
1600
+ }
1545
1601
  // ── Rate-limit / quota: auto-fallback to a different provider ──
1546
1602
  // Per-day TPM caps (Anthropic) won't clear in this session; per-second
1547
1603
  // limits already had their backoff retry above and still failed. In
@@ -21,10 +21,11 @@ export const CAPPED_MAX_TOKENS = 16_384;
21
21
  export const ESCALATED_MAX_TOKENS = 65_536;
22
22
  /** Per-model max output tokens — prevents requesting more than the model supports */
23
23
  const MODEL_MAX_OUTPUT = {
24
- // Opus 4.7 supports 128k output per the BlockRun gateway model entry
25
- // (anthropic/claude-opus-4.7 maxOutput: 128000). Bumping from 32k to
24
+ // Opus 4.8 / 4.7 support 128k output per the BlockRun gateway model entry
25
+ // (anthropic/claude-opus-4.8 maxOutput: 128000). Bumping from 32k to
26
26
  // 128k unlocks the full headroom — runaway generations are gated
27
27
  // separately by CAPPED_MAX_TOKENS / ESCALATED_MAX_TOKENS budgets.
28
+ 'anthropic/claude-opus-4.8': 128_000,
28
29
  'anthropic/claude-opus-4.7': 128_000,
29
30
  'anthropic/claude-opus-4.6': 32_000,
30
31
  'anthropic/claude-sonnet-4.6': 64_000,
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Schema flatten — ported from reasonix (MIT) and adapted to Franklin's
3
+ * CapabilitySchema shape. Deep / wide schemas get dropped or hallucinated
4
+ * by some models (DeepSeek R1, smaller Llamas, some Qwen variants); present
5
+ * them as dot-paths and re-nest before dispatch.
6
+ *
7
+ * Pure functions; no side effects. Wire into a tool registry via
8
+ * analyzeSchema(spec.input_schema) at registration time, then call
9
+ * flattenSchema() on the spec sent to the model and nestArguments() on
10
+ * the parsed call arguments before invoking the handler.
11
+ */
12
+ import type { CapabilitySchema } from '../types.js';
13
+ /** Loose recursive schema — properties of a CapabilitySchema are typed
14
+ * `unknown`, but in practice they are JSON-Schema-like objects. */
15
+ export interface SchemaNode {
16
+ type?: string | string[];
17
+ properties?: Record<string, SchemaNode>;
18
+ required?: string[];
19
+ items?: SchemaNode;
20
+ [k: string]: unknown;
21
+ }
22
+ export interface FlattenDecision {
23
+ shouldFlatten: boolean;
24
+ leafCount: number;
25
+ maxDepth: number;
26
+ }
27
+ export declare function analyzeSchema(schema: SchemaNode | CapabilitySchema | undefined, opts?: {
28
+ leafLimit?: number;
29
+ depthLimit?: number;
30
+ }): FlattenDecision;
31
+ export declare function flattenSchema(schema: SchemaNode | CapabilitySchema): CapabilitySchema;
32
+ export declare function nestArguments(flatArgs: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,77 @@
1
+ /** Caller defines the trigger thresholds; reasonix's defaults are 10/2. */
2
+ const DEFAULT_LEAF_LIMIT = 10;
3
+ const DEFAULT_DEPTH_LIMIT = 2;
4
+ export function analyzeSchema(schema, opts = {}) {
5
+ if (!schema)
6
+ return { shouldFlatten: false, leafCount: 0, maxDepth: 0 };
7
+ const leafLimit = opts.leafLimit ?? DEFAULT_LEAF_LIMIT;
8
+ const depthLimit = opts.depthLimit ?? DEFAULT_DEPTH_LIMIT;
9
+ let leafCount = 0;
10
+ let maxDepth = 0;
11
+ walk(schema, 0, (depth, isLeaf) => {
12
+ if (isLeaf)
13
+ leafCount++;
14
+ if (depth > maxDepth)
15
+ maxDepth = depth;
16
+ });
17
+ return {
18
+ shouldFlatten: leafCount > leafLimit || maxDepth > depthLimit,
19
+ leafCount,
20
+ maxDepth,
21
+ };
22
+ }
23
+ export function flattenSchema(schema) {
24
+ const flatProps = {};
25
+ const required = [];
26
+ collect('', schema, flatProps, required, true);
27
+ return {
28
+ type: 'object',
29
+ properties: flatProps,
30
+ required,
31
+ };
32
+ }
33
+ export function nestArguments(flatArgs) {
34
+ const out = {};
35
+ for (const [key, value] of Object.entries(flatArgs)) {
36
+ setByPath(out, key.split('.'), value);
37
+ }
38
+ return out;
39
+ }
40
+ function walk(schema, depth, visit) {
41
+ if (schema.type === 'object' && schema.properties) {
42
+ for (const child of Object.values(schema.properties)) {
43
+ walk(child, depth + 1, visit);
44
+ }
45
+ return;
46
+ }
47
+ if (schema.type === 'array' && schema.items) {
48
+ walk(schema.items, depth + 1, visit);
49
+ return;
50
+ }
51
+ visit(depth, true);
52
+ }
53
+ function collect(prefix, schema, out, required, isRootRequired) {
54
+ if (schema.type === 'object' && schema.properties) {
55
+ const requiredSet = new Set(schema.required ?? []);
56
+ for (const [key, child] of Object.entries(schema.properties)) {
57
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
58
+ const childRequired = isRootRequired && requiredSet.has(key);
59
+ collect(nextPrefix, child, out, required, childRequired);
60
+ }
61
+ return;
62
+ }
63
+ out[prefix] = schema;
64
+ if (isRootRequired)
65
+ required.push(prefix);
66
+ }
67
+ function setByPath(target, path, value) {
68
+ let cur = target;
69
+ for (let i = 0; i < path.length - 1; i++) {
70
+ const key = path[i];
71
+ const next = cur[key];
72
+ if (typeof next !== 'object' || next === null)
73
+ cur[key] = {};
74
+ cur = cur[key];
75
+ }
76
+ cur[path[path.length - 1]] = value;
77
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tool-call repair pipeline — ported and adapted from reasonix (MIT).
3
+ *
4
+ * The pipeline has three layers, each used at a different boundary:
5
+ *
6
+ * 1. `repairTruncatedJson(rawArgs)` — call at the LLM-client boundary,
7
+ * right before JSON.parse on the streamed tool_use input. Catches
8
+ * max_tokens-cut-mid-structure and rebalances the braces.
9
+ * 2. `ToolCallRepair.process(calls, reasoning, content)` — call once
10
+ * the assistant turn has finished but before dispatch. Scavenges
11
+ * tool calls the model leaked into text/reasoning channels and
12
+ * merges them in (deduped).
13
+ * 3. `analyzeSchema` + `flattenSchema` + `nestArguments` — apply at
14
+ * tool-registration time for tools whose schemas are deep or wide
15
+ * enough that smaller models drop required params.
16
+ *
17
+ * Storm suppression is intentionally not part of this pipeline.
18
+ * Franklin's `SessionToolGuard` (src/agent/tool-guard.ts) already does
19
+ * per-tool repeat suppression with richer logic — Jaccard search
20
+ * families, mtime-aware Read cache, per-tool circuit breakers.
21
+ */
22
+ import type { CapabilityInvocation } from '../types.js';
23
+ export { analyzeSchema, flattenSchema, nestArguments } from './flatten.js';
24
+ export type { FlattenDecision, SchemaNode } from './flatten.js';
25
+ export { repairTruncatedJson } from './truncation.js';
26
+ export type { TruncationRepairResult } from './truncation.js';
27
+ export { scavengeToolCalls } from './scavenge.js';
28
+ export type { ScavengeOptions, ScavengeResult } from './scavenge.js';
29
+ export interface RepairReport {
30
+ scavenged: number;
31
+ duplicatesDropped: number;
32
+ notes: string[];
33
+ }
34
+ export interface ToolCallRepairOptions {
35
+ allowedToolNames: ReadonlySet<string>;
36
+ maxScavenge?: number;
37
+ }
38
+ /** Boundary-level helper: parse tool-use argument JSON with truncation
39
+ * recovery. Returns `null` if every attempt fails and the caller should
40
+ * reject the call (better than dispatching with `{}`).
41
+ *
42
+ * Usage at the streaming-client boundary:
43
+ * const args = repairAndParseArgs(jsonAccumulator);
44
+ * if (args == null) return reject("invalid JSON in tool_use");
45
+ */
46
+ export declare function repairAndParseArgs(raw: string): {
47
+ input: Record<string, unknown>;
48
+ repaired: boolean;
49
+ notes: string[];
50
+ } | null;
51
+ export declare class ToolCallRepair {
52
+ private readonly opts;
53
+ constructor(opts: ToolCallRepairOptions);
54
+ /**
55
+ * Scavenge leaked tool calls from text/reasoning channels and merge
56
+ * into the declared list, deduped.
57
+ *
58
+ * @param declaredCalls Tool calls the model emitted structurally.
59
+ * @param reasoningText Optional reasoning_content / thinking text.
60
+ * @param contentText Optional plain text-channel content.
61
+ */
62
+ process(declaredCalls: CapabilityInvocation[], reasoningText: string | null, contentText?: string | null): {
63
+ calls: CapabilityInvocation[];
64
+ report: RepairReport;
65
+ };
66
+ }
@@ -0,0 +1,77 @@
1
+ import { scavengeToolCalls } from './scavenge.js';
2
+ import { repairTruncatedJson } from './truncation.js';
3
+ export { analyzeSchema, flattenSchema, nestArguments } from './flatten.js';
4
+ export { repairTruncatedJson } from './truncation.js';
5
+ export { scavengeToolCalls } from './scavenge.js';
6
+ /** Boundary-level helper: parse tool-use argument JSON with truncation
7
+ * recovery. Returns `null` if every attempt fails and the caller should
8
+ * reject the call (better than dispatching with `{}`).
9
+ *
10
+ * Usage at the streaming-client boundary:
11
+ * const args = repairAndParseArgs(jsonAccumulator);
12
+ * if (args == null) return reject("invalid JSON in tool_use");
13
+ */
14
+ export function repairAndParseArgs(raw) {
15
+ const r = repairTruncatedJson(raw);
16
+ if (r.fallback)
17
+ return null;
18
+ try {
19
+ const parsed = JSON.parse(r.repaired);
20
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
21
+ return { input: parsed, repaired: r.changed, notes: r.notes };
22
+ }
23
+ return null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export class ToolCallRepair {
30
+ opts;
31
+ constructor(opts) {
32
+ this.opts = opts;
33
+ }
34
+ /**
35
+ * Scavenge leaked tool calls from text/reasoning channels and merge
36
+ * into the declared list, deduped.
37
+ *
38
+ * @param declaredCalls Tool calls the model emitted structurally.
39
+ * @param reasoningText Optional reasoning_content / thinking text.
40
+ * @param contentText Optional plain text-channel content.
41
+ */
42
+ process(declaredCalls, reasoningText, contentText = null) {
43
+ const report = { scavenged: 0, duplicatesDropped: 0, notes: [] };
44
+ const combined = [reasoningText ?? '', contentText ?? ''].filter(Boolean).join('\n');
45
+ const scavenged = scavengeToolCalls(combined || null, {
46
+ allowedNames: this.opts.allowedToolNames,
47
+ maxCalls: this.opts.maxScavenge ?? 4,
48
+ });
49
+ const seenSignatures = new Set(declaredCalls.map(signature));
50
+ const merged = [...declaredCalls];
51
+ for (const sc of scavenged.calls) {
52
+ const sig = signature(sc);
53
+ if (seenSignatures.has(sig)) {
54
+ report.duplicatesDropped++;
55
+ continue;
56
+ }
57
+ merged.push(sc);
58
+ report.scavenged++;
59
+ seenSignatures.add(sig);
60
+ }
61
+ report.notes.push(...scavenged.notes);
62
+ return { calls: merged, report };
63
+ }
64
+ }
65
+ function signature(call) {
66
+ return `${call.name}::${stableStringify(call.input)}`;
67
+ }
68
+ function stableStringify(value) {
69
+ if (value === null || typeof value !== 'object')
70
+ return JSON.stringify(value);
71
+ if (Array.isArray(value)) {
72
+ return `[${value.map(stableStringify).join(',')}]`;
73
+ }
74
+ const obj = value;
75
+ const keys = Object.keys(obj).sort();
76
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
77
+ }
@@ -0,0 +1,12 @@
1
+ import type { CapabilityInvocation } from '../types.js';
2
+ export interface ScavengeOptions {
3
+ /** Allowlist of tool names the model is permitted to call. */
4
+ allowedNames: ReadonlySet<string>;
5
+ /** Cap on scavenged calls per pass — defence against runaway. */
6
+ maxCalls?: number;
7
+ }
8
+ export interface ScavengeResult {
9
+ calls: CapabilityInvocation[];
10
+ notes: string[];
11
+ }
12
+ export declare function scavengeToolCalls(text: string | null | undefined, opts: ScavengeOptions): ScavengeResult;