@seanhogg/builderforce-memory 2026.9.30 → 2026.9.32

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.
Files changed (54) hide show
  1. package/dist/bridges/FetchBridge.d.ts +12 -5
  2. package/dist/bridges/FetchBridge.d.ts.map +1 -1
  3. package/dist/bridges/FetchBridge.js +6 -4
  4. package/dist/bridges/FetchBridge.js.map +1 -1
  5. package/dist/bridges/OpenAIBridge.d.ts +17 -4
  6. package/dist/bridges/OpenAIBridge.d.ts.map +1 -1
  7. package/dist/bridges/OpenAIBridge.js +68 -57
  8. package/dist/bridges/OpenAIBridge.js.map +1 -1
  9. package/dist/bridges/TransformerBridge.d.ts +6 -0
  10. package/dist/bridges/TransformerBridge.d.ts.map +1 -1
  11. package/dist/evermind/index.d.ts +6 -0
  12. package/dist/evermind/index.d.ts.map +1 -1
  13. package/dist/evermind/index.js +5 -0
  14. package/dist/evermind/index.js.map +1 -1
  15. package/dist/evermind/runtime.d.ts +158 -0
  16. package/dist/evermind/runtime.d.ts.map +1 -0
  17. package/dist/evermind/runtime.js +236 -0
  18. package/dist/evermind/runtime.js.map +1 -0
  19. package/dist/evermind/textCoherence.d.ts +55 -0
  20. package/dist/evermind/textCoherence.d.ts.map +1 -0
  21. package/dist/evermind/textCoherence.js +218 -0
  22. package/dist/evermind/textCoherence.js.map +1 -0
  23. package/dist/evermind/toolCall.d.ts +175 -0
  24. package/dist/evermind/toolCall.d.ts.map +1 -0
  25. package/dist/evermind/toolCall.js +398 -0
  26. package/dist/evermind/toolCall.js.map +1 -0
  27. package/dist/evermind/wordLexicon.d.ts +103 -0
  28. package/dist/evermind/wordLexicon.d.ts.map +1 -0
  29. package/dist/evermind/wordLexicon.js +361 -0
  30. package/dist/evermind/wordLexicon.js.map +1 -0
  31. package/dist/wire/chatClient.d.ts +100 -0
  32. package/dist/wire/chatClient.d.ts.map +1 -0
  33. package/dist/wire/chatClient.js +123 -0
  34. package/dist/wire/chatClient.js.map +1 -0
  35. package/dist/wire/index.d.ts +3 -1
  36. package/dist/wire/index.d.ts.map +1 -1
  37. package/dist/wire/index.js +2 -1
  38. package/dist/wire/index.js.map +1 -1
  39. package/dist/wire/sse.d.ts +17 -9
  40. package/dist/wire/sse.d.ts.map +1 -1
  41. package/dist/wire/sse.js +45 -22
  42. package/dist/wire/sse.js.map +1 -1
  43. package/package.json +3 -3
  44. package/src/bridges/FetchBridge.ts +15 -6
  45. package/src/bridges/OpenAIBridge.ts +82 -72
  46. package/src/bridges/TransformerBridge.ts +6 -0
  47. package/src/evermind/index.ts +48 -0
  48. package/src/evermind/runtime.ts +364 -0
  49. package/src/evermind/textCoherence.ts +248 -0
  50. package/src/evermind/toolCall.ts +523 -0
  51. package/src/evermind/wordLexicon.ts +395 -0
  52. package/src/wire/chatClient.ts +196 -0
  53. package/src/wire/index.ts +3 -1
  54. package/src/wire/sse.ts +43 -21
@@ -0,0 +1,523 @@
1
+ /**
2
+ * toolCall — function calling for Evermind, a project's OWN SSM.
3
+ *
4
+ * Evermind used to REFUSE every tool-bearing request, on the reasoning that a head
5
+ * which generates raw text cannot emit `tool_calls` and that narrating a call is
6
+ * worse than an error. The refusal was right about the failure mode and wrong about
7
+ * the remedy: "can the architecture emit a JSON tool call?" is not a property of the
8
+ * weights at all, it is a property of the DECODING STRATEGY. A tiny under-trained
9
+ * head asked to free-generate `{"name":…,"arguments":{…}}` will indeed produce
10
+ * malformed junk — so this module never asks it to.
11
+ *
12
+ * The approach is schema-driven CONSTRAINED DECODING (the jsonformer/outlines shape):
13
+ * the JSON is ASSEMBLED HERE as a real JS object from the tool's JSON Schema, and the
14
+ * model is only ever consulted for the leaves it is qualified to fill:
15
+ *
16
+ * • a finite choice (which tool, an `enum`, a boolean) → every candidate is SCORED
17
+ * by likelihood and the argmax wins, so the answer is always a legal value;
18
+ * • a free value (a string, a number) → generated, then parsed/escaped by us.
19
+ *
20
+ * Structural validity is therefore guaranteed BY CONSTRUCTION — the braces, quotes,
21
+ * commas and key names are never sampled, so there is no such thing as a malformed
22
+ * Evermind tool call. `JSON.stringify` of a real object cannot be invalid JSON.
23
+ *
24
+ * What that does NOT buy is a good CHOICE, and this is where the original file's
25
+ * instinct is kept rather than discarded. A head that cannot tell `read_file` from
26
+ * `open_pull_request` will still pick one — structurally perfect, semantically a coin
27
+ * flip — and an agent loop acting on coin flips writes files for no reason. So the
28
+ * planner reports the MARGIN between its best and runner-up candidate, and the vendor
29
+ * refuses (400 → cascade) when the head is measurably guessing. Fitness to tool-call
30
+ * is now measured, the same way {@link ./textCoherence} measures fitness to answer in
31
+ * prose, instead of being assumed absent.
32
+ *
33
+ * Pure + port-based (no engine, no R2, no db imports) so the whole planner is unit
34
+ * testable against a fake decoder; the model-backed adapter lives in
35
+ * {@link ./runtime}.
36
+ */
37
+
38
+ /** A tool the model may call, normalized out of the OpenAI wire shape. */
39
+ export interface NormalizedTool {
40
+ name: string;
41
+ description: string;
42
+ /** JSON Schema for the arguments object (`{ type:'object', properties, required }`). */
43
+ parameters: JsonSchema;
44
+ }
45
+
46
+ /** The slice of JSON Schema this decoder understands. Anything unrecognised is
47
+ * filled as a string, which is always representable. */
48
+ export interface JsonSchema {
49
+ type?: string | string[];
50
+ description?: string;
51
+ enum?: unknown[];
52
+ properties?: Record<string, JsonSchema>;
53
+ required?: string[];
54
+ items?: JsonSchema;
55
+ default?: unknown;
56
+ minimum?: number;
57
+ maximum?: number;
58
+ maxLength?: number;
59
+ minItems?: number;
60
+ maxItems?: number;
61
+ }
62
+
63
+ /**
64
+ * What the caller's `tool_choice` resolved to. Mirrors the OpenAI contract:
65
+ * `none` never calls, `auto` lets the model decide, `required` must call something,
66
+ * and a pinned function forces exactly one.
67
+ */
68
+ export type ToolChoicePlan =
69
+ | { mode: 'none' }
70
+ | { mode: 'auto' }
71
+ | { mode: 'required' }
72
+ | { mode: 'forced'; name: string };
73
+
74
+ /**
75
+ * The two capabilities the planner needs from a language model. Kept this narrow so
76
+ * the planner has no engine dependency — {@link ./runtime} supplies the
77
+ * EvermindLM-backed implementation and tests supply a deterministic fake.
78
+ */
79
+ export interface EvermindToolDecoder {
80
+ /**
81
+ * Length-normalized mean log-probability of `continuation` following `prompt`
82
+ * (higher = more likely). Length normalization is what makes candidates of
83
+ * different token counts comparable — without it the shortest tool name wins
84
+ * every vote purely for being short.
85
+ */
86
+ score(prompt: string, continuation: string): number;
87
+ /** Free-text continuation of `prompt`, at most `maxTokens` new tokens. */
88
+ generate(prompt: string, maxTokens: number): string;
89
+ }
90
+
91
+ /**
92
+ * Minimum separation (in mean log-prob) between the winning candidate and the
93
+ * runner-up for a choice to count as a DECISION rather than a coin flip.
94
+ *
95
+ * This is the tool-calling analogue of the coherence bar: it is what stops a
96
+ * structurally-perfect-but-meaningless call from driving an agent loop. Tuned low
97
+ * on purpose — the job is to catch a head with no preference at all (margins at
98
+ * ~1e-3), not to demand confidence a small model never shows.
99
+ */
100
+ export const TOOL_CHOICE_MIN_MARGIN = 0.02;
101
+
102
+ /**
103
+ * The margin bar in force for a raw override (the host reads it from its own config —
104
+ * the gateway's `EVERMIND_TOOL_CHOICE_MIN_MARGIN`). ONE resolver, shared by the
105
+ * serve-time gate and the bench, so the bench can never report a bar the gateway is
106
+ * not applying.
107
+ *
108
+ * The default is an analytic placeholder and documented as one; the override is what
109
+ * lets it be CALIBRATED from logged margins without a deploy per candidate value.
110
+ */
111
+ export function resolveToolChoiceMinMargin(override?: string | null): number {
112
+ const text = override?.trim();
113
+ // Empty is ABSENT, checked before the numeric parse: `Number('')` is 0, not NaN, so
114
+ // a declared-but-unset variable — the normal shape of a secret nobody filled in —
115
+ // would otherwise set the bar to zero and silently turn the confidence gate off.
116
+ if (!text) return TOOL_CHOICE_MIN_MARGIN;
117
+ const raw = Number(text);
118
+ // A non-numeric or negative override is ignored rather than obeyed: a bar of NaN
119
+ // compares false against everything, which is the same silent disabling.
120
+ return Number.isFinite(raw) && raw >= 0 ? raw : TOOL_CHOICE_MIN_MARGIN;
121
+ }
122
+
123
+ /** Ceiling on calls emitted in ONE turn. Each additional call costs a full tool vote
124
+ * plus an argument fill, so this bounds the CPU an agent loop can spend on a single
125
+ * planning turn — and a head that wants more than this is not planning, it is
126
+ * looping. */
127
+ const MAX_PARALLEL_TOOL_CALLS = 4;
128
+
129
+ /** Hard caps so a hostile or recursive schema cannot spin the decoder. */
130
+ const MAX_SCHEMA_DEPTH = 4;
131
+ const MAX_ARRAY_ITEMS = 4;
132
+ const MAX_STRING_CHARS = 400;
133
+ const STRING_VALUE_TOKENS = 48;
134
+ const NUMBER_VALUE_TOKENS = 8;
135
+
136
+ /** The sentinel candidate representing "don't call a tool, just answer". Scored
137
+ * alongside the real tools in `auto` mode so one primitive makes the decision. */
138
+ const ANSWER_DIRECTLY = 'answer the user directly without calling a tool';
139
+
140
+ // ── Wire-shape normalization ─────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Normalize the OpenAI `tools` array into {@link NormalizedTool}s, accepting both the
144
+ * nested chat-completions shape (`{ type:'function', function:{…} }`) and the already
145
+ * flattened Responses shape (`{ type:'function', name, … }`) — the gateway carries
146
+ * both, and a vendor that understood only one would silently see zero tools.
147
+ * Entries without a usable name are dropped rather than half-registered.
148
+ */
149
+ export function normalizeEvermindTools(tools: unknown[] | undefined): NormalizedTool[] {
150
+ if (!Array.isArray(tools)) return [];
151
+ const out: NormalizedTool[] = [];
152
+ for (const raw of tools) {
153
+ // A caller's `tools` array is untrusted wire data — a null/scalar entry must be
154
+ // skipped, not dereferenced.
155
+ if (!raw || typeof raw !== 'object') continue;
156
+ const tool = raw as { type?: string; name?: unknown; description?: unknown; parameters?: unknown; function?: Record<string, unknown> };
157
+ const fn = (tool.function ?? tool) as { name?: unknown; description?: unknown; parameters?: unknown };
158
+ const name = typeof fn.name === 'string' ? fn.name.trim() : '';
159
+ if (!name) continue;
160
+ out.push({
161
+ name,
162
+ description: typeof fn.description === 'string' ? fn.description : '',
163
+ parameters: (fn.parameters && typeof fn.parameters === 'object' ? fn.parameters : { type: 'object' }) as JsonSchema,
164
+ });
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * Resolve the caller's `tool_choice` against the tools actually on offer. A pinned
171
+ * function that isn't in the list degrades to `required` rather than forcing a name
172
+ * the caller never declared (which would produce a call no agent loop can dispatch).
173
+ */
174
+ export function resolveEvermindToolChoice(toolChoice: unknown, tools: NormalizedTool[]): ToolChoicePlan {
175
+ if (tools.length === 0) return { mode: 'none' };
176
+ if (toolChoice === 'none') return { mode: 'none' };
177
+ if (toolChoice === 'required') return { mode: 'required' };
178
+ if (toolChoice === 'auto' || toolChoice == null) return { mode: 'auto' };
179
+ const pinned = toolChoice as { type?: string; name?: unknown; function?: { name?: unknown } };
180
+ if (pinned && typeof pinned === 'object' && pinned.type === 'function') {
181
+ // Accept both the nested and flattened spellings, exactly as `normalizeEvermindTools` does.
182
+ const name = typeof pinned.function?.name === 'string' ? pinned.function.name
183
+ : typeof pinned.name === 'string' ? pinned.name
184
+ : '';
185
+ if (name && tools.some((t) => t.name === name)) return { mode: 'forced', name };
186
+ return { mode: 'required' };
187
+ }
188
+ return { mode: 'auto' };
189
+ }
190
+
191
+ // ── Prompt rendering ─────────────────────────────────────────────────────────
192
+
193
+ /** One line per tool: the signature the model is choosing between. */
194
+ function renderToolSignature(tool: NormalizedTool): string {
195
+ const props = tool.parameters?.properties ?? {};
196
+ const required = new Set(tool.parameters?.required ?? []);
197
+ const args = Object.entries(props)
198
+ .map(([key, schema]) => `${key}${required.has(key) ? '' : '?'}: ${schemaTypeName(schema)}`)
199
+ .join(', ');
200
+ return `- ${tool.name}(${args})${tool.description ? ` — ${tool.description}` : ''}`;
201
+ }
202
+
203
+ /**
204
+ * The tool catalogue, rendered into the prompt so the head's choice is INFORMED by
205
+ * the descriptions rather than made from the tool names alone. Shared by the decision
206
+ * vote and every argument fill so the model sees one consistent context.
207
+ */
208
+ export function renderToolsPreamble(tools: NormalizedTool[]): string {
209
+ return `Available tools:\n${tools.map(renderToolSignature).join('\n')}`;
210
+ }
211
+
212
+ /** Human-readable type for a schema node, used in the rendered signatures. */
213
+ function schemaTypeName(schema: JsonSchema | undefined): string {
214
+ if (!schema) return 'string';
215
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum.map((v) => JSON.stringify(v)).join('|');
216
+ const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
217
+ if (type === 'array') return `${schemaTypeName(schema.items)}[]`;
218
+ return type ?? 'string';
219
+ }
220
+
221
+ // ── Scoring primitives ───────────────────────────────────────────────────────
222
+
223
+ /** A resolved finite choice: what won, and by how much over the runner-up. */
224
+ interface Decision<T> {
225
+ value: T;
226
+ /** Winner's mean log-prob minus the runner-up's. `Infinity` when unopposed. */
227
+ margin: number;
228
+ }
229
+
230
+ /**
231
+ * Pick the most likely candidate and report the separation. This is the ONE place a
232
+ * finite choice is made — the tool vote, `enum` values and booleans all route through
233
+ * it, so "how does Evermind choose?" has a single answer and a single margin
234
+ * definition. A single candidate is unopposed (`Infinity`): there is nothing to
235
+ * second-guess, so it must not be judged as a low-confidence guess.
236
+ */
237
+ function decide<T>(decoder: EvermindToolDecoder, prompt: string, candidates: Array<{ value: T; text: string }>): Decision<T> {
238
+ if (candidates.length === 0) throw new Error('decide() requires at least one candidate');
239
+ const first = candidates[0]!;
240
+ if (candidates.length === 1) return { value: first.value, margin: Infinity };
241
+ let best = first;
242
+ let bestScore = -Infinity;
243
+ let runnerUp = -Infinity;
244
+ for (const candidate of candidates) {
245
+ const score = decoder.score(prompt, candidate.text);
246
+ if (score > bestScore) {
247
+ runnerUp = bestScore;
248
+ bestScore = score;
249
+ best = candidate;
250
+ } else if (score > runnerUp) {
251
+ runnerUp = score;
252
+ }
253
+ }
254
+ return { value: best.value, margin: bestScore - runnerUp };
255
+ }
256
+
257
+ // ── Argument filling ─────────────────────────────────────────────────────────
258
+
259
+ /** The prompt a leaf value is generated/scored against: conversation + catalogue +
260
+ * exactly which argument of which tool is being filled. */
261
+ function valuePrompt(base: string, tool: NormalizedTool, path: string, schema: JsonSchema | undefined): string {
262
+ const described = schema?.description ? ` (${schema.description})` : '';
263
+ return `${base}\nCalling tool: ${tool.name}\nValue for argument "${path}"${described}, as ${schemaTypeName(schema)}:`;
264
+ }
265
+
266
+ /** First line of a generated value, unquoted and bounded. Free text is never parsed
267
+ * as JSON — it is escaped by `JSON.stringify` at assembly time, so any bytes the
268
+ * head emits are representable. */
269
+ function cleanStringValue(raw: string, schema: JsonSchema | undefined): string {
270
+ const firstLine = (raw ?? '').split('\n').find((line) => line.trim().length > 0) ?? '';
271
+ const unquoted = firstLine.trim().replace(/^["'`]+/, '').replace(/["'`,]+$/, '').trim();
272
+ const cap = Math.min(schema?.maxLength ?? MAX_STRING_CHARS, MAX_STRING_CHARS);
273
+ return unquoted.slice(0, cap);
274
+ }
275
+
276
+ /** First numeric literal in a generated value, clamped to the schema's bounds.
277
+ * Falls back to `default` → `minimum` → 0 when the head emitted no number at all. */
278
+ function cleanNumberValue(raw: string, schema: JsonSchema | undefined, integer: boolean): number {
279
+ const match = (raw ?? '').match(/-?\d+(\.\d+)?/);
280
+ const fallback = typeof schema?.default === 'number' ? schema.default : schema?.minimum ?? 0;
281
+ let value = match ? Number(match[0]) : fallback;
282
+ if (!Number.isFinite(value)) value = fallback;
283
+ if (typeof schema?.minimum === 'number') value = Math.max(value, schema.minimum);
284
+ if (typeof schema?.maximum === 'number') value = Math.min(value, schema.maximum);
285
+ return integer ? Math.round(value) : value;
286
+ }
287
+
288
+ /**
289
+ * Fill ONE schema node by dispatching on its type — the recursive core of the
290
+ * constrained decoder. Every branch returns a value that is legal for the node, so
291
+ * the assembled object always validates against the shape the tool declared.
292
+ */
293
+ function fillValue(
294
+ decoder: EvermindToolDecoder,
295
+ base: string,
296
+ tool: NormalizedTool,
297
+ path: string,
298
+ schema: JsonSchema | undefined,
299
+ depth: number,
300
+ margins: number[],
301
+ ): unknown {
302
+ // Depth guard: a self-referential schema would otherwise recurse forever. A string
303
+ // is the universally representable leaf, so that is where we bottom out.
304
+ if (depth > MAX_SCHEMA_DEPTH) {
305
+ return cleanStringValue(decoder.generate(valuePrompt(base, tool, path, schema), STRING_VALUE_TOKENS), schema);
306
+ }
307
+
308
+ // An `enum` is a finite choice regardless of the declared type — vote, never generate.
309
+ if (Array.isArray(schema?.enum) && schema.enum.length > 0) {
310
+ const decision = decide(decoder, valuePrompt(base, tool, path, schema), schema.enum.map((value) => ({ value, text: String(value) })));
311
+ margins.push(decision.margin);
312
+ return decision.value;
313
+ }
314
+
315
+ const type = Array.isArray(schema?.type) ? schema.type[0] : schema?.type;
316
+
317
+ switch (type) {
318
+ case 'boolean': {
319
+ const decision = decide(decoder, valuePrompt(base, tool, path, schema), [
320
+ { value: true, text: 'true' },
321
+ { value: false, text: 'false' },
322
+ ]);
323
+ margins.push(decision.margin);
324
+ return decision.value;
325
+ }
326
+ case 'integer':
327
+ return cleanNumberValue(decoder.generate(valuePrompt(base, tool, path, schema), NUMBER_VALUE_TOKENS), schema, true);
328
+ case 'number':
329
+ return cleanNumberValue(decoder.generate(valuePrompt(base, tool, path, schema), NUMBER_VALUE_TOKENS), schema, false);
330
+ case 'object':
331
+ return fillObject(decoder, base, tool, path, schema, depth, margins);
332
+ case 'array': {
333
+ const items: unknown[] = [];
334
+ const min = Math.max(0, schema?.minItems ?? 0);
335
+ const max = Math.min(schema?.maxItems ?? MAX_ARRAY_ITEMS, MAX_ARRAY_ITEMS);
336
+ for (let i = 0; i < max; i++) {
337
+ if (i >= min) {
338
+ // Length is itself a decision: keep going only while the head prefers it.
339
+ const more = decide(decoder, `${valuePrompt(base, tool, `${path}[${i}]`, schema?.items)}\nIs there another item?`, [
340
+ { value: true, text: 'yes' },
341
+ { value: false, text: 'no' },
342
+ ]);
343
+ if (!more.value) break;
344
+ }
345
+ items.push(fillValue(decoder, base, tool, `${path}[${i}]`, schema?.items, depth + 1, margins));
346
+ }
347
+ return items;
348
+ }
349
+ case 'null':
350
+ return null;
351
+ default:
352
+ // `string` and anything unrecognised. Generating text is always safe: it is
353
+ // escaped at assembly, so an unknown schema keyword degrades to a filled
354
+ // string rather than to invalid JSON.
355
+ return cleanStringValue(decoder.generate(valuePrompt(base, tool, path, schema), STRING_VALUE_TOKENS), schema);
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Fill an object node. Required properties are always present (that is what
361
+ * `required` means); each optional property is a yes/no decision, so the head can
362
+ * produce a minimal call instead of being forced to invent every argument.
363
+ */
364
+ function fillObject(
365
+ decoder: EvermindToolDecoder,
366
+ base: string,
367
+ tool: NormalizedTool,
368
+ path: string,
369
+ schema: JsonSchema | undefined,
370
+ depth: number,
371
+ margins: number[],
372
+ ): Record<string, unknown> {
373
+ const properties = schema?.properties ?? {};
374
+ const required = new Set(schema?.required ?? []);
375
+ const out: Record<string, unknown> = {};
376
+ for (const [key, propSchema] of Object.entries(properties)) {
377
+ const childPath = path ? `${path}.${key}` : key;
378
+ if (!required.has(key)) {
379
+ const include = decide(decoder, `${valuePrompt(base, tool, childPath, propSchema)}\nShould this optional argument be provided?`, [
380
+ { value: true, text: 'yes' },
381
+ { value: false, text: 'no' },
382
+ ]);
383
+ if (!include.value) continue;
384
+ }
385
+ out[key] = fillValue(decoder, base, tool, childPath, propSchema, depth + 1, margins);
386
+ }
387
+ return out;
388
+ }
389
+
390
+ // ── The planner ──────────────────────────────────────────────────────────────
391
+
392
+ /** One planned call. */
393
+ export interface EvermindPlannedCall {
394
+ name: string;
395
+ arguments: Record<string, unknown>;
396
+ }
397
+
398
+ /** A planned call plus the evidence for how confidently it was chosen. */
399
+ export interface EvermindToolPlan {
400
+ /** The FIRST chosen call, or null when the head elected to answer in prose
401
+ * (`auto` only). Kept as the single-call view every existing reader uses;
402
+ * {@link EvermindToolPlan.calls} is the full list. */
403
+ call: EvermindPlannedCall | null;
404
+ /**
405
+ * Every call this turn emits, in order.
406
+ *
407
+ * The OpenAI shape has always been an ARRAY and frontier models emit parallel
408
+ * calls; Evermind used to plan exactly one, so a caller that relies on parallel
409
+ * calls silently got serialized behaviour. After the first call the head is asked
410
+ * "another call?" — the same continue-vote the array filler already uses for list
411
+ * length — so a model with nothing more to do still emits exactly one.
412
+ * Empty when `call` is null.
413
+ */
414
+ calls: EvermindPlannedCall[];
415
+ /**
416
+ * Separation between the winning tool and the runner-up. The vendor gates on this:
417
+ * a head with no preference is guessing, and a guessed tool call is exactly the
418
+ * "silently doing the wrong thing" this backend refuses to do.
419
+ * `Infinity` when the choice was forced or unopposed (nothing was guessed).
420
+ */
421
+ margin: number;
422
+ }
423
+
424
+ /**
425
+ * Choose a tool and fill its arguments under the caller's `tool_choice` contract.
426
+ *
427
+ * The decision and every argument come from the model; the JSON structure comes from
428
+ * the schema. Returns `{ call: null }` only in `auto` mode, and only when answering
429
+ * directly out-scored every tool — `required`/`forced` always produce a call.
430
+ */
431
+ export function planEvermindToolCall(
432
+ decoder: EvermindToolDecoder,
433
+ basePrompt: string,
434
+ tools: NormalizedTool[],
435
+ choice: ToolChoicePlan,
436
+ ): EvermindToolPlan {
437
+ if (choice.mode === 'none' || tools.length === 0) return { call: null, calls: [], margin: Infinity };
438
+
439
+ const base = `${basePrompt}\n\n${renderToolsPreamble(tools)}`;
440
+ const margins: number[] = [];
441
+
442
+ // 1) Which tool (or none)? `forced` skips the vote entirely — the caller already decided.
443
+ let tool: NormalizedTool | undefined;
444
+ let choiceMargin = Infinity;
445
+ if (choice.mode === 'forced') {
446
+ tool = tools.find((t) => t.name === choice.name);
447
+ } else {
448
+ // In `auto`, "answer directly" competes as a peer candidate, so one vote settles
449
+ // both questions (call vs. answer, and which call) with one comparable margin.
450
+ const candidates: Array<{ value: NormalizedTool | null; text: string }> = tools.map((t) => ({
451
+ value: t,
452
+ text: `${t.name} — ${t.description || t.name}`,
453
+ }));
454
+ if (choice.mode === 'auto') candidates.push({ value: null, text: ANSWER_DIRECTLY });
455
+ const decision = decide(decoder, `${base}\nThe best next action is: `, candidates);
456
+ choiceMargin = decision.margin;
457
+ if (!decision.value) return { call: null, calls: [], margin: choiceMargin };
458
+ tool = decision.value;
459
+ }
460
+ // A `forced` name is validated by `resolveEvermindToolChoice`, so this is a
461
+ // belt-and-braces fallback rather than a reachable branch.
462
+ if (!tool) return { call: null, calls: [], margin: choiceMargin };
463
+
464
+ // 2) Fill the arguments object against the tool's own schema.
465
+ const calls: EvermindPlannedCall[] = [
466
+ { name: tool.name, arguments: fillObject(decoder, base, tool, '', tool.parameters, 0, margins) },
467
+ ];
468
+
469
+ // 3) Additional PARALLEL calls. `forced` names exactly one tool, so it stops here —
470
+ // asking for more would emit calls the caller never requested. Otherwise the head is
471
+ // asked whether it wants another, exactly as the array filler asks whether a list has
472
+ // another item, so a model with nothing more to do still emits exactly one call.
473
+ if (choice.mode !== 'forced') {
474
+ while (calls.length < MAX_PARALLEL_TOOL_CALLS) {
475
+ const rendered = calls.map((c) => `${c.name}(${JSON.stringify(c.arguments)})`).join('\n');
476
+ const more = decide(decoder, `${base}\nAlready calling:\n${rendered}\nIs there another tool call to make?`, [
477
+ { value: true, text: 'yes' },
478
+ { value: false, text: 'no' },
479
+ ]);
480
+ // A TIE means no. `decide` breaks ties by candidate order, so requiring a
481
+ // positive margin is what keeps "one call" the default: an indifferent head
482
+ // must not have a second call read into its silence.
483
+ if (!more.value || !(more.margin > 0)) break;
484
+ // Counted ONLY when the vote said yes. A head that is indifferent about making
485
+ // a SECOND call has guessed nothing — folding that indifference into the plan
486
+ // margin would let an optional extra drag a confidently-chosen first call below
487
+ // the confidence gate and get the whole turn refused. A coin-flip "yes",
488
+ // though, does put a guessed call in the plan, and counts.
489
+ margins.push(more.margin);
490
+
491
+ const next = decide(
492
+ decoder,
493
+ `${base}\nAlready calling:\n${rendered}\nThe next tool to call is: `,
494
+ tools.map((t) => ({ value: t, text: `${t.name} — ${t.description || t.name}` })),
495
+ );
496
+ margins.push(next.margin);
497
+ if (!next.value) break;
498
+ const nextCall: EvermindPlannedCall = {
499
+ name: next.value.name,
500
+ arguments: fillObject(decoder, base, next.value, '', next.value.parameters, 0, margins),
501
+ };
502
+ // An identical repeat is the head LOOPING, not planning parallel work. Stop
503
+ // rather than emit a duplicate an agent loop would faithfully execute twice.
504
+ const duplicate = calls.some(
505
+ (c) => c.name === nextCall.name && JSON.stringify(c.arguments) === JSON.stringify(nextCall.arguments),
506
+ );
507
+ if (duplicate) break;
508
+ calls.push(nextCall);
509
+ }
510
+ }
511
+
512
+
513
+ // The reported margin is the WEAKEST link in the whole plan: a confident tool
514
+ // choice whose enum argument was a coin flip is still a coin flip overall.
515
+ const weakest = Math.min(choiceMargin, ...margins);
516
+ return { call: calls[0]!, calls, margin: Number.isFinite(weakest) ? weakest : Infinity };
517
+ }
518
+
519
+ /** Wrap a planned call in the OpenAI `tool_calls` wire shape. `id` is supplied by the
520
+ * caller so the completion builder controls id generation (and tests stay stable). */
521
+ export function toOpenAIToolCall(call: { name: string; arguments: Record<string, unknown> }, id: string): Record<string, unknown> {
522
+ return { id, type: 'function', function: { name: call.name, arguments: JSON.stringify(call.arguments) } };
523
+ }