@cubicecho/agent-core 2.18.1 → 2.20.0

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.
@@ -1,16 +1,17 @@
1
1
  import { charsPerTokenFor } from "./calibration.js";
2
- import { capabilitiesFor, effortFor, } from "./capabilities.js";
2
+ import { capabilitiesFor, effortFor, modelCapabilitiesFor, } from "./capabilities.js";
3
3
  import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
4
4
  import { continueTurn } from "./continuation.js";
5
5
  import { errorMessage } from "./errors.js";
6
6
  import { runMetrics } from "./events.js";
7
- import { gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
8
- import { toolsChars } from "./retry.js";
7
+ import { configureHooks, gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
8
+ import { rebaseLedger, recordRequest } from "./ledger.js";
9
+ import { contextTokens, toolsChars } from "./retry.js";
9
10
  import { runTurn } from "./run-turn.js";
10
11
  import { relaxTools, sanitizeTools } from "./schema-compat.js";
11
12
  import { askJson, tryAsk } from "./side-task.js";
12
13
  import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
13
- import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
14
+ import { CALL_TOOL, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, } from "./tool-loading.js";
14
15
  /**
15
16
  * The loop above a turn: send, run the tools the model asked for, send again, until it stops
16
17
  * asking.
@@ -206,17 +207,32 @@ function cacheDiagnosis(previous, messages, tools, usage) {
206
207
  * prompt unchanged from step to step, a load adds to the tool array — which every request sends
207
208
  * in the stable order `toolOrder` asks for — and
208
209
  * a catalogued tool called without being loaded is loaded and run rather than refused, and a
209
- * preselection shapes the first step. A turn cut off at `maxTokens` is said so as a notice,
210
- * because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
210
+ * preselection shapes the first step unless `preselectRouting` says to append it. A turn cut off
211
+ * at `maxTokens` is said so as a notice, because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
211
212
  * continued first. Every turn ends in a `usage` event carrying the turn's own report, with the
212
- * cache compared against the request before it; see `TurnUsage` and `runMetrics`.
213
+ * cache compared against the request before it and the request broken down by what filled it; see
214
+ * `TurnUsage` and `runMetrics`.
215
+ *
216
+ * Each step's reported prompt goes on a token ledger, in the same epoch as the step before where
217
+ * the request only appended to it and in a new one where it did not — a `beforeStep` that rewrote
218
+ * the transcript, a tool array a load grew — so that what the messages between two steps cost can
219
+ * be read back as a subtraction. The prompt recorded is the first request's, before any
220
+ * continuation, and a step that reported none, or reported a cache count above its prompt, is not
221
+ * recorded. The ledger comes back in the result and on every `usage` event; see `tokensBetween`.
222
+ *
223
+ * Proxied discovery is the same catalogue behind a tool array that never moves: every step
224
+ * declares `PROXY_TOOLS` and nothing else, a load answers with the definitions themselves, and a
225
+ * `call_tool` is dispatched, counted and reported as the tool it names. A preselection has no
226
+ * tool array to go into, so it is written into the transcript as a `load_tools` exchange after
227
+ * the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
228
+ * a session switched out of proxied mode still has it in its history, and the model copies it.
213
229
  *
214
230
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
215
231
  * and cancellation. See `AgentLoopOptions`.
216
232
  */
217
233
  export async function runAgentLoop(options) {
218
234
  const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
219
- const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, } = options;
235
+ const { onRequest, onTurn, onToolCall, onToolResult, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, preselectRouting = "exclusive", } = options;
220
236
  const dedupable = typeof dedupeToolCalls === "function" ? dedupeToolCalls : () => dedupeToolCalls;
221
237
  const started = Date.now();
222
238
  // What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
@@ -245,11 +261,16 @@ export async function runAgentLoop(options) {
245
261
  const supports = capabilitiesFor(config.baseUrl, config.apiKey);
246
262
  const maxRetries = Math.max(0, Number(config.maxRetries) || 0);
247
263
  const notice = (text) => onEvent({ kind: "notice", text });
248
- const onDemand = config.toolDiscovery === "ondemand" && catalog.length > 0;
249
- const loaded = new Set(onDemand ? (options.loaded ?? []) : []);
264
+ // On demand, but with a tool array that never changes: definitions come back as `load_tools`
265
+ // results and run through `call_tool`. `onDemand` is true of both.
266
+ const proxied = config.toolDiscovery === "proxy" && catalog.length > 0;
267
+ const onDemand = proxied || (config.toolDiscovery === "ondemand" && catalog.length > 0);
268
+ // Proxied, `loaded` is what this run has put a definition in the history for, and starts empty.
269
+ const loaded = new Set(onDemand && !proxied ? (options.loaded ?? []) : []);
250
270
  const preselected = onDemand ? [...(options.preselected ?? [])] : [];
251
- for (const name of preselected)
252
- loaded.add(name);
271
+ if (!proxied)
272
+ for (const name of preselected)
273
+ loaded.add(name);
253
274
  const used = new Set();
254
275
  const definitions = new Map();
255
276
  for (const tool of tools) {
@@ -257,6 +278,8 @@ export async function runAgentLoop(options) {
257
278
  definitions.set(tool.function.name, tool);
258
279
  }
259
280
  }
281
+ // Looked through unless the host has a tool by that name, which is then the host's to answer.
282
+ const proxies = onDemand && !definitions.has(CALL_TOOL);
260
283
  // In the order the names are given, not the order of `tools`: `loaded` is a set, which iterates
261
284
  // in the order things were added, so a load appends and never reshuffles what went before.
262
285
  const byName = (names) => [...names].flatMap((name) => definitions.get(name) ?? []);
@@ -272,32 +295,84 @@ export async function runAgentLoop(options) {
272
295
  maxTokens: hooks.maxTokens,
273
296
  })
274
297
  : { context: "", notes: [] };
298
+ // Read once, so what the result hands back is what every step of this run said.
299
+ const preface = hooks?.preface ?? configureHooks().preface;
275
300
  const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
276
301
  const toolCalls = [];
277
302
  const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
278
303
  let previous;
304
+ let ledger = options.ledger ?? [];
305
+ // The request the ledger's last entry came from, which is what the next one is measured against.
306
+ // Nothing before this run's first: an earlier run's head is not something the loop can see.
307
+ let measured;
308
+ // Proxied, a shortlist has nowhere to go but the history: the tool array is fixed, so it is
309
+ // answered as though the model had loaded it, and the definitions sit after the question.
310
+ const shortlist = proxied
311
+ ? byName(new Set(preselected)).filter((tool) => tool.type === "function" && inCatalog(catalog, tool.function.name))
312
+ : [];
313
+ if (shortlist.length) {
314
+ const names = shortlist.map((tool) => (tool.type === "function" ? tool.function.name : ""));
315
+ // Numbered by where the call lands, so two questions in one transcript do not share an id.
316
+ const id = `preselect-${messages.length}`;
317
+ const args = JSON.stringify({ names });
318
+ // Held to its own length rather than `MAX_PER_LOAD`: that cap is for a model choosing, and a
319
+ // host that shortlisted more has already chosen.
320
+ const content = proxyLoadResult(expandNames(names, catalog, names.length), catalog, shortlist);
321
+ // Announced as a call the model made would be, so a host pairing calls with results by id
322
+ // shows this one like the rest.
323
+ onEvent({ kind: "tool-call", id, name: LOAD_TOOLS, text: preview(args) });
324
+ onToolCall?.({ id, name: LOAD_TOOLS, args: { names }, raw: args });
325
+ onEvent({ kind: "tool-result", id, name: LOAD_TOOLS, ok: true, text: preview(content) });
326
+ onToolResult?.({ id, name: LOAD_TOOLS, ok: true, content });
327
+ toolCalls.push({ id, name: LOAD_TOOLS, ok: true });
328
+ loads.toolsLoaded += names.length;
329
+ for (const name of names)
330
+ loaded.add(name);
331
+ messages.push({
332
+ role: "assistant",
333
+ content: null,
334
+ tool_calls: [{ id, type: "function", function: { name: LOAD_TOOLS, arguments: args } }],
335
+ }, { role: "tool", tool_call_id: id, content });
336
+ }
279
337
  for (let step = 0; step < config.maxToolIterations; step++) {
280
338
  // A stop aborts the request in flight, but a tool call already handed off runs to its own
281
339
  // end — so the signal is read between steps as well.
282
340
  signal?.throwIfAborted();
283
- messages = (await beforeStep?.(messages, step)) ?? messages;
341
+ const before = messages;
342
+ const rewritten = await beforeStep?.(messages, step, {
343
+ ...(previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
344
+ limit: config.contextLength ?? 0,
345
+ ledger,
346
+ });
347
+ // A rewrite may have folded a definition away, and a load answered "already loaded" would
348
+ // then point at nothing. Forgetting costs a definition sent twice at worst.
349
+ if (proxied && rewritten && rewritten !== messages)
350
+ loaded.clear();
351
+ messages = rewritten ?? messages;
352
+ ledger = rebaseLedger(ledger, before, messages);
284
353
  onEvent({ kind: "turn", text: `turn ${step + 1}` });
285
- const routed = preselected.length > 0 && step === 0;
354
+ // Appended, the shortlist is already in `loaded` — after what was carried, and once — so the
355
+ // first step needs nothing of its own. Proxied, it is in the history.
356
+ const routed = !proxied && preselectRouting !== "append" && preselected.length > 0 && step === 0;
286
357
  // Ordered here rather than left to `buildBody`, so `names` below is what the request actually
287
358
  // declared — a diagnosis reading an order the server never saw calls an untouched tool array
288
359
  // `tools-changed`.
289
360
  const declared = orderTools(routed
290
361
  ? byName(new Set(preselected))
291
- : onDemand
292
- ? loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded))
293
- : tools, toolOrder);
362
+ : proxied
363
+ ? [...PROXY_TOOLS]
364
+ : onDemand
365
+ ? loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded))
366
+ : tools, toolOrder);
294
367
  // Unmarked, so the system prompt is the same text on every step and a load does not throw
295
368
  // away the cache for the whole transcript. What is loaded is said in `declared` and in the
296
- // `load_tools` result instead. The preselected first step is the one exception, by design.
297
- const prompt = onDemand && !routed ? `${system}\n\n${catalogPrompt(catalog)}`.trim() : system;
369
+ // `load_tools` result instead. The preselected first step is the one exception, by design,
370
+ // and `preselectRouting: "append"` is the way out of it.
371
+ const catalogue = proxied ? proxyCatalogPrompt(catalog) : catalogPrompt(catalog);
372
+ const prompt = onDemand && !routed ? `${system}\n\n${catalogue}`.trim() : system;
298
373
  const request = [
299
374
  ...(prompt ? [{ role: "system", content: prompt }] : []),
300
- ...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, hooks?.preface),
375
+ ...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, preface),
301
376
  ];
302
377
  const build = (supported, refused) => buildBody(config, supported, refused, request, declared, toolOrder);
303
378
  const turnOptions = {
@@ -315,15 +390,37 @@ export async function runAgentLoop(options) {
315
390
  onThinking: (text) => onEvent({ kind: "thinking", text }),
316
391
  onOutput: (text) => onEvent({ kind: "output", text }),
317
392
  };
393
+ // Built the way `negotiate` is about to build the first attempt — the same flags, the same
394
+ // model's refusals — so what the host is told is what is sent unless something is refused.
395
+ if (onRequest) {
396
+ const opening = build(supports, modelCapabilitiesFor(supports, config.model));
397
+ onRequest({ messages: opening.messages, tools: opening.tools ?? [], step });
398
+ }
318
399
  const first = await runTurn(client, supports, build, turnOptions);
319
400
  const names = declared.map((tool) => (tool.type === "function" ? tool.function.name : ""));
320
401
  // Compared before any continuation is joined on: the cache a request meets is the one its own
321
402
  // prompt found, and a continuation's prompt is this request's plus the reply so far.
403
+ // The breakdown measures the array `toolSchemaTokens` does, so where no prompt was reported
404
+ // the two give one number for the tool block rather than two.
405
+ const charsPerToken = charsPerTokenFor(supports, config.model);
322
406
  Object.assign(first.usage, cacheDiagnosis(previous, request, names, first.usage), {
323
407
  toolsDeclared: declared.length,
324
- toolSchemaTokens: Math.ceil(toolsChars(declared) / charsPerTokenFor(supports, config.model)),
408
+ toolSchemaTokens: Math.ceil(toolsChars(declared) / charsPerToken),
409
+ context: contextTokens({ model: config.model, stream: true, messages: request, tools: declared }, { charsPerToken, promptTokens: first.usage.prompt }),
325
410
  });
326
411
  const firstPrompt = first.usage.prompt;
412
+ // Recorded from the first request for the same reason. A cache count above the prompt is a
413
+ // server reporting its prompt net of the cache, whose differences would measure nothing.
414
+ const sent = {
415
+ messages: request,
416
+ tools: names,
417
+ prompt: first.usage.cached > firstPrompt ? 0 : firstPrompt,
418
+ through: messages.length - 1,
419
+ };
420
+ const entered = recordRequest(ledger, measured, sent);
421
+ if (entered !== ledger)
422
+ measured = sent;
423
+ ledger = entered;
327
424
  const turn = maxContinuations > 0
328
425
  ? await continueTurn(client, supports, build, first, { ...turnOptions, maxContinuations })
329
426
  : first;
@@ -342,6 +439,7 @@ export async function runAgentLoop(options) {
342
439
  totalTokens: usage.total,
343
440
  cachedTokens: usage.cached,
344
441
  turn: { ...turn.usage, finishReason: turn.finishReason },
442
+ ledger,
345
443
  },
346
444
  });
347
445
  if (turn.finishReason === "length") {
@@ -354,7 +452,7 @@ export async function runAgentLoop(options) {
354
452
  if (recover && !calls.length && content && (tools.length > 0 || onDemand)) {
355
453
  const names = tools.flatMap((tool) => (tool.type === "function" ? [tool.function.name] : []));
356
454
  const recovered = recoverToolCalls(content, {
357
- names: onDemand ? [...names, LOAD_TOOLS] : names,
455
+ names: onDemand ? [...names, LOAD_TOOLS, ...(proxies ? [CALL_TOOL] : [])] : names,
358
456
  });
359
457
  if (recovered.toolCalls.length) {
360
458
  calls = recovered.toolCalls;
@@ -391,11 +489,12 @@ export async function runAgentLoop(options) {
391
489
  : {}),
392
490
  });
393
491
  if (!calls.length) {
492
+ let afterTurn = Promise.resolve([]);
394
493
  if (hooks) {
395
494
  const at = question ? messages.indexOf(question) : -1;
396
495
  // Not awaited: the answer is ready, and remembering it is not something to hold it for.
397
- // `notify` never rejects.
398
- void notify(hooks.run, "afterTurn", {
496
+ // Handed back instead, for a host that wants its notes.
497
+ afterTurn = notify(hooks.run, "afterTurn", {
399
498
  ...hooks.context,
400
499
  reply: turn.content,
401
500
  ...(at >= 0
@@ -414,47 +513,87 @@ export async function runAgentLoop(options) {
414
513
  messages,
415
514
  usage,
416
515
  toolCalls,
417
- loaded: [...loaded],
516
+ loaded: proxied ? [] : [...loaded],
418
517
  used: [...used],
419
518
  notes: gathered.notes,
519
+ context: gathered.context,
520
+ preface,
521
+ afterTurn,
420
522
  metrics: {
421
523
  ...metrics,
422
524
  ...(onDemand ? loads : {}),
423
525
  wallMs: Date.now() - started,
424
526
  outcome: turn.finishReason === "length" ? "truncated" : "answered",
425
527
  },
528
+ ledger,
426
529
  };
427
530
  }
428
531
  // Per step, not per run: the answer to a call made two steps ago was true before the tools in
429
532
  // between ran, and the file the model read may be the file it has since written.
430
533
  const answered = new Map();
431
534
  const run = async ({ call, args, error: unreadable, normal }) => {
432
- const { name, arguments: raw } = call.function;
433
- onEvent({ kind: "tool-call", name, text: preview(raw) });
535
+ const proxy = proxies && call.function.name === CALL_TOOL;
536
+ // A `call_tool` is shown, counted and dispatched as the tool it names. Read from the repaired
537
+ // arguments where there are any, since the model's own may be almost-JSON.
538
+ const { name, input: raw } = proxy
539
+ ? shownCall(CALL_TOOL, args ? normal : call.function.arguments)
540
+ : { name: call.function.name, input: call.function.arguments };
541
+ // Looked through before the call is announced, so a host is told of the tool it names with
542
+ // that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
543
+ // nothing a load could not, and the refusal is the call's answer below.
544
+ let inner = args ?? {};
545
+ let refused;
546
+ if (proxy && args) {
547
+ try {
548
+ inner = proxiedCall(args, catalog).input;
549
+ }
550
+ catch (error) {
551
+ inner = {};
552
+ refused = { error };
553
+ }
554
+ }
555
+ // Built for every call rather than only the dispatched ones, so a host hears of the calls
556
+ // the loop answers itself in the same shape. Arguments that could not be read are none.
557
+ const request = { id: call.id, name, args: inner, raw };
558
+ onEvent({ kind: "tool-call", id: call.id, name, text: preview(raw) });
559
+ onToolCall?.(request);
434
560
  let content;
435
561
  let ok = true;
436
562
  try {
437
563
  if (!args)
438
564
  throw unreadable;
439
- if (onDemand && name === LOAD_TOOLS) {
565
+ // By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
566
+ // the catalogue, and one reached through `call_tool` is refused below like any other.
567
+ if (onDemand && call.function.name === LOAD_TOOLS) {
440
568
  const resolved = expandNames(requestedNames(args), catalog);
441
- content = loadResult(resolved, catalog, loaded);
442
- for (const hit of resolved.matched)
569
+ content = proxied
570
+ ? proxyLoadResult(resolved, catalog, byName(resolved.matched), loaded)
571
+ : loadResult(resolved, catalog, loaded);
572
+ // Proxied, a load is only of what the result could define: a catalogued name the host
573
+ // gave no definition for was not loaded, and is not counted or remembered as though it was.
574
+ const hits = proxied
575
+ ? resolved.matched.filter((hit) => definitions.has(hit))
576
+ : resolved.matched;
577
+ for (const hit of hits)
443
578
  loads[loaded.has(hit) ? "redundantLoads" : "toolsLoaded"]++;
444
579
  loads.unknownToolNames += resolved.unknown.length;
445
- for (const hit of resolved.matched)
580
+ for (const hit of hits)
446
581
  loaded.add(hit);
447
- ok = resolved.matched.length > 0;
582
+ ok = hits.length > 0;
448
583
  }
449
584
  else {
585
+ if (refused)
586
+ throw refused.error;
450
587
  // A model that skips `load_tools` and calls a catalogued tool by name is right about
451
- // what it wants; load it and run it rather than refusing.
452
- if (onDemand && inCatalog(catalog, name))
588
+ // what it wants; load it and run it rather than refusing. Not proxied, where loaded
589
+ // means its definition is in the history, and this call put none there.
590
+ if (onDemand && !proxied && inCatalog(catalog, name))
453
591
  loaded.add(name);
454
592
  used.add(name);
455
- const request = { id: call.id, name, args, raw };
593
+ // Keyed on the inner call, so one made through `call_tool` and one made natively share.
594
+ const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
456
595
  content = dedupable(request)
457
- ? await once(answered, `${name}\0${normal}`, () => dispatch(request, signal))
596
+ ? await once(answered, key, () => dispatch(request, signal))
458
597
  : await dispatch(request, signal);
459
598
  }
460
599
  }
@@ -464,8 +603,10 @@ export async function runAgentLoop(options) {
464
603
  content = errorMessage(error);
465
604
  ok = false;
466
605
  }
467
- onEvent({ kind: "tool-result", name, ok, text: preview(content) });
468
- return { id: call.id, name, ok, content };
606
+ onEvent({ kind: "tool-result", id: call.id, name, ok, text: preview(content) });
607
+ const result = { id: call.id, name, ok, content };
608
+ onToolResult?.(result);
609
+ return result;
469
610
  };
470
611
  const outcomes = [];
471
612
  if (parallel) {
@@ -479,7 +620,7 @@ export async function runAgentLoop(options) {
479
620
  }
480
621
  }
481
622
  for (const { id, name, ok, content } of outcomes) {
482
- toolCalls.push({ name, ok });
623
+ toolCalls.push({ id, name, ok });
483
624
  messages.push({ role: "tool", tool_call_id: id, content });
484
625
  }
485
626
  }
@@ -42,7 +42,9 @@ export interface PruneOptions {
42
42
  * after it, and by then the model has usually taken what it wanted from it; the stub keeps the
43
43
  * call answered — a call with no result is a malformed transcript — and says how much was there,
44
44
  * so a model that does need it again knows to ask. Short results are kept, since a stub saves
45
- * nothing on them. Returns the same array when there was nothing to clear. Rewrites the prefix;
45
+ * nothing on them, and so is a proxied `load_tools` result carrying definitions, which is the
46
+ * only copy of them the model has (`holdsDefinitions`); either still counts as one of the latest
47
+ * few. Returns the same array when there was nothing to clear. Rewrites the prefix;
46
48
  * see the module comment on when to run it.
47
49
  *
48
50
  * @param messages The transcript. Not written to.
@@ -62,6 +64,17 @@ export interface CompactionOptions {
62
64
  compactAt?: number;
63
65
  /** The fraction of `limit` the kept tail may fill. `KEEP_RATIO` by default. */
64
66
  keepRatio?: number;
67
+ /**
68
+ * The fraction of `limit` the whole request should come down to, in place of `keepRatio`: the
69
+ * cut is the earliest that leaves `used`, less what is folded, plus `summaryTokens`, at or under
70
+ * it. Absent, or not above zero, plans by `keepRatio` as before.
71
+ */
72
+ target?: number;
73
+ /**
74
+ * What the summary is assumed to cost when planning to a `target` — the ceiling its writer is
75
+ * held to, since the plan is made before it is written. 1024 by default, which is `summariser`'s.
76
+ */
77
+ summaryTokens?: number;
65
78
  /** One message's tokens. `messageTokens` by default, divided by `charsPerToken`. */
66
79
  estimate?: (message: Message) => number;
67
80
  /**
@@ -91,6 +104,12 @@ export interface CompactionPlan {
91
104
  toSummarise: Message[];
92
105
  /** The summary an earlier compaction left, which this one continues. */
93
106
  previous?: string;
107
+ /**
108
+ * What the request is expected to cost once folded, in tokens: what was in use, less what is
109
+ * summarised, plus the summary's ceiling. Only on a plan made to a `target`, and above the
110
+ * target where no legal cut reaches it.
111
+ */
112
+ after?: number;
94
113
  }
95
114
  /**
96
115
  * Where to fold a transcript that has grown into its window, or `undefined` when it should not be.
@@ -107,11 +126,20 @@ export interface CompactionPlan {
107
126
  * transcript — its system prompt a separate argument, no summary message in the array at all —
108
127
  * knows them exactly, and passes `from` and `previous` instead of hoping the scan agrees.
109
128
  *
129
+ * `keepRatio` bounds the kept tail alone, and the system prompt, the tool schemas and the summary
130
+ * sit in the window on top of it uncounted. Given a `target` the plan is made against what the
131
+ * request will be afterwards instead: the cut is the first user message at which `used`, less the
132
+ * folded messages, plus the summary's ceiling, is at or under that share of the window. Where no
133
+ * cut gets there the plan folds up to the last user message, which is as near as a legal one
134
+ * comes, and `after` says how far short it fell. An earlier summary the new one replaces is not
135
+ * credited, so `after` errs high by at most that much. It is only as good as `estimate` — see
136
+ * `estimateFrom` for one that is measured.
137
+ *
110
138
  * @param messages The transcript, system prompts included if the caller keeps them in it.
111
139
  * @param options The window, what is in use, the ratios, and where the last fold ended. See
112
140
  * `CompactionOptions`.
113
141
  */
114
- export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, charsPerToken, estimate, from: givenFrom, previous: givenPrevious, }: CompactionOptions): CompactionPlan | undefined;
142
+ export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, target, summaryTokens, charsPerToken, estimate, from: givenFrom, previous: givenPrevious, }: CompactionOptions): CompactionPlan | undefined;
115
143
  /**
116
144
  * What the summariser is handed for a plan: the earlier summary if there was one, then each
117
145
  * message as its role and at most 4000 characters of its text.
@@ -1,10 +1,13 @@
1
1
  import { consult, notify, turnMessages, } from "./hooks.js";
2
2
  import { messageTokens } from "./retry.js";
3
3
  import { ask } from "./side-task.js";
4
+ import { holdsDefinitions } from "./tool-loading.js";
4
5
  /** The fraction of the window in use before a summary is worth its own round trip. */
5
6
  export const COMPACT_AT = 0.75;
6
7
  /** The fraction of the window the kept tail may fill, leaving room for the run to grow again. */
7
8
  export const KEEP_RATIO = 0.35;
9
+ /** The ceiling `summariser` gives a summary, and what `planCompaction` assumes one will cost. */
10
+ const SUMMARY_TOKENS = 1024;
8
11
  /** How much of any one message the summariser is shown. A pasted file is not worth it whole. */
9
12
  const SUMMARY_SLICE = 4000;
10
13
  /**
@@ -63,7 +66,9 @@ const systemHead = (messages) => {
63
66
  * after it, and by then the model has usually taken what it wanted from it; the stub keeps the
64
67
  * call answered — a call with no result is a malformed transcript — and says how much was there,
65
68
  * so a model that does need it again knows to ask. Short results are kept, since a stub saves
66
- * nothing on them. Returns the same array when there was nothing to clear. Rewrites the prefix;
69
+ * nothing on them, and so is a proxied `load_tools` result carrying definitions, which is the
70
+ * only copy of them the model has (`holdsDefinitions`); either still counts as one of the latest
71
+ * few. Returns the same array when there was nothing to clear. Rewrites the prefix;
67
72
  * see the module comment on when to run it.
68
73
  *
69
74
  * @param messages The transcript. Not written to.
@@ -81,6 +86,10 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
81
86
  const text = textOf(message.content);
82
87
  if (text.length <= maxChars || text.startsWith("[result cleared"))
83
88
  continue;
89
+ // A proxied load's result is the schema itself; stubbed, the tool is one the model can still
90
+ // name and no longer call correctly.
91
+ if (holdsDefinitions(text))
92
+ continue;
84
93
  out ??= [...messages];
85
94
  out[at] = {
86
95
  ...message,
@@ -104,11 +113,20 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
104
113
  * transcript — its system prompt a separate argument, no summary message in the array at all —
105
114
  * knows them exactly, and passes `from` and `previous` instead of hoping the scan agrees.
106
115
  *
116
+ * `keepRatio` bounds the kept tail alone, and the system prompt, the tool schemas and the summary
117
+ * sit in the window on top of it uncounted. Given a `target` the plan is made against what the
118
+ * request will be afterwards instead: the cut is the first user message at which `used`, less the
119
+ * folded messages, plus the summary's ceiling, is at or under that share of the window. Where no
120
+ * cut gets there the plan folds up to the last user message, which is as near as a legal one
121
+ * comes, and `after` says how far short it fell. An earlier summary the new one replaces is not
122
+ * credited, so `after` errs high by at most that much. It is only as good as `estimate` — see
123
+ * `estimateFrom` for one that is measured.
124
+ *
107
125
  * @param messages The transcript, system prompts included if the caller keeps them in it.
108
126
  * @param options The window, what is in use, the ratios, and where the last fold ended. See
109
127
  * `CompactionOptions`.
110
128
  */
111
- export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), from: givenFrom, previous: givenPrevious, }) {
129
+ export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, target, summaryTokens = SUMMARY_TOKENS, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), from: givenFrom, previous: givenPrevious, }) {
112
130
  if (!(limit > 0))
113
131
  return undefined;
114
132
  const cost = used ?? messages.reduce((total, message) => total + estimate(message), 0);
@@ -117,20 +135,42 @@ export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT,
117
135
  const head = systemHead(messages);
118
136
  const from = Math.min(Math.max(givenFrom ?? head.from, 0), messages.length);
119
137
  const previous = givenPrevious ?? head.previous;
120
- const budget = limit * keepRatio;
121
- let kept = 0;
122
138
  let cut = messages.length;
123
- for (let at = messages.length - 1; at > from; at--) {
124
- kept += estimate(messages[at]);
125
- if (kept > budget)
126
- break;
127
- cut = at;
139
+ let after;
140
+ if (target !== undefined && target > 0) {
141
+ // Forward from the head, so the first cut that reaches the target is the one that folds least.
142
+ let folded = 0;
143
+ for (let at = from; at < messages.length; at++) {
144
+ if (messages[at].role === "user") {
145
+ cut = at;
146
+ after = cost - folded + summaryTokens;
147
+ if (after <= limit * target)
148
+ break;
149
+ }
150
+ folded += estimate(messages[at]);
151
+ }
152
+ }
153
+ else {
154
+ const budget = limit * keepRatio;
155
+ let kept = 0;
156
+ for (let at = messages.length - 1; at > from; at--) {
157
+ kept += estimate(messages[at]);
158
+ if (kept > budget)
159
+ break;
160
+ cut = at;
161
+ }
162
+ while (cut < messages.length && messages[cut].role !== "user")
163
+ cut++;
128
164
  }
129
- while (cut < messages.length && messages[cut].role !== "user")
130
- cut++;
131
165
  if (cut >= messages.length || cut - from < 2)
132
166
  return undefined;
133
- return { from, cut, toSummarise: messages.slice(from, cut), ...(previous ? { previous } : {}) };
167
+ return {
168
+ from,
169
+ cut,
170
+ toSummarise: messages.slice(from, cut),
171
+ ...(previous ? { previous } : {}),
172
+ ...(after === undefined ? {} : { after: Math.round(after) }),
173
+ };
134
174
  }
135
175
  /**
136
176
  * What the summariser is handed for a plan: the earlier summary if there was one, then each
@@ -158,7 +198,7 @@ export function summaryInput(plan) {
158
198
  * @param options Cancellation and notices; the ceiling is 1024 and the instruction
159
199
  * `SUMMARY_PROMPT` unless given.
160
200
  */
161
- export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens = 1024, ...options } = {}) => (text) => ask(config, model, system, text, { maxTokens, ...options });
201
+ export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens = SUMMARY_TOKENS, ...options } = {}) => (text) => ask(config, model, system, text, { maxTokens, ...options });
162
202
  /**
163
203
  * The hooks and the summariser for a plan, as a record to store rather than a transcript to send.
164
204
  *
package/dist/config.d.ts CHANGED
@@ -75,9 +75,11 @@ export interface ModelParams {
75
75
  export interface ToolPolicy {
76
76
  /**
77
77
  * "eager" sends every tool definition on every request. "ondemand" sends a name-only
78
- * catalogue and lets the model pull in the schemas it needs. See `tool-loading.ts`.
78
+ * catalogue and lets the model pull in the schemas it needs. "proxy" is the same catalogue
79
+ * behind a tool array that never changes — `load_tools` and `call_tool` — for a server whose
80
+ * prompt cache a growing tool array throws away. See `tool-loading.ts`.
79
81
  */
80
- toolDiscovery: "eager" | "ondemand";
82
+ toolDiscovery: "eager" | "ondemand" | "proxy";
81
83
  /** The model that does the preselection pass. Empty means don't preselect. */
82
84
  toolSelectModel: string;
83
85
  /** Hard stop on runaway tool loops. */
package/dist/events.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { TokenLedger } from "./ledger.ts";
1
2
  import type { TurnUsage } from "./stream.ts";
2
3
  /**
3
4
  * What a run is doing, while it is doing it.
@@ -76,9 +77,9 @@ export type RunEventKind =
76
77
  | "thinking"
77
78
  /** Reply tokens, as they arrive. */
78
79
  | "output"
79
- /** The model asked for a tool, with the arguments it chose. */
80
+ /** The model asked for a tool, with the arguments it chose. `id` is the call's. */
80
81
  | "tool-call"
81
- /** A tool came back, with what it said. */
82
+ /** A tool came back, with what it said. `id` is the id of the call it answers. */
82
83
  | "tool-result"
83
84
  /** Something the runner did that is not the model's doing — a preselection, a retry. */
84
85
  | "notice"
@@ -110,6 +111,12 @@ export interface RunUsage {
110
111
  * from a caller emitting usage of its own.
111
112
  */
112
113
  turn?: TurnReport;
114
+ /**
115
+ * The run's token ledger as of this turn, whole rather than the entry it added — a host whose
116
+ * run then fails still holds the latest one, and a rewrite in `beforeStep` moves earlier entries.
117
+ * Absent from a caller emitting usage of its own.
118
+ */
119
+ ledger?: TokenLedger;
113
120
  }
114
121
  /** One turn's usage and measurements as a `usage` event carries them. */
115
122
  export interface TurnReport extends TurnUsage {
@@ -119,8 +126,8 @@ export interface TurnReport extends TurnUsage {
119
126
  /**
120
127
  * One thing that happened in a run, as a watcher receives it.
121
128
  *
122
- * Every field is always present — the empty ones are `""` or `null` rather than missing — so a
123
- * client reads it without guarding each key.
129
+ * Every field but `id` is always present — the empty ones are `""` or `null` rather than
130
+ * missing — so a client reads it without guarding each key.
124
131
  */
125
132
  export interface RunEvent {
126
133
  /** The run it belongs to. `emit` fills this in; a caller does not pass it. */
@@ -141,6 +148,12 @@ export interface RunEvent {
141
148
  text: string;
142
149
  /** Tool name on the tool kinds, otherwise empty. */
143
150
  name: string;
151
+ /**
152
+ * The call's id on the tool kinds, which pairs a `tool-result` with its `tool-call` when one
153
+ * tool is called twice in a step. The one field `emit` does not fill in: it is there only where
154
+ * the emitter gave one, and optional so an event built before it existed still compiles.
155
+ */
156
+ id?: string;
144
157
  /**
145
158
  * The caller's own flow step this happened inside, so a watcher can group a run the way the
146
159
  * work is written. Empty for events that belong to the run rather than to any one step, and