@theoremjs/agents 0.3.1 → 0.3.3

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 (46) hide show
  1. package/README.md +7 -2
  2. package/esm/mod.d.ts +3 -1
  3. package/esm/mod.js +2 -1
  4. package/esm/src/guardrails/error.d.ts +1 -1
  5. package/esm/src/guardrails/error.js +3 -1
  6. package/esm/src/guardrails/labels.d.ts +2 -2
  7. package/esm/src/guardrails/labels.js +8 -1
  8. package/esm/src/guardrails/mod.d.ts +1 -1
  9. package/esm/src/guardrails/mod.js +1 -1
  10. package/esm/src/guardrails/quota.d.ts +3 -1
  11. package/esm/src/guardrails/quota.js +6 -1
  12. package/esm/src/guardrails/theorem-error.d.ts +1 -1
  13. package/esm/src/guardrails/theorem-error.js +3 -1
  14. package/esm/src/host/reply.js +1 -0
  15. package/esm/src/kernel/engine/decision.js +1 -0
  16. package/esm/src/kernel/engine/runner/gates.js +5 -0
  17. package/esm/src/kernel/engine/runner/stages.d.ts +14 -2
  18. package/esm/src/kernel/engine/runner/stages.js +80 -3
  19. package/esm/src/kernel/engine/runner/state.d.ts +13 -0
  20. package/esm/src/kernel/engine/runner/state.js +2 -0
  21. package/esm/src/kernel/engine/runner/steps.d.ts +3 -1
  22. package/esm/src/kernel/engine/runner/steps.js +49 -11
  23. package/esm/src/kernel/interaction-parts.d.ts +13 -1
  24. package/esm/src/kernel/interaction-parts.js +14 -1
  25. package/esm/src/kernel/mod.d.ts +2 -0
  26. package/esm/src/kernel/mod.js +1 -0
  27. package/esm/src/kernel/protocols/runtime.js +2 -2
  28. package/esm/src/kernel/provider-runtime.js +18 -16
  29. package/esm/src/kernel/schema.d.ts +1 -1
  30. package/esm/src/kernel/schema.js +2 -1
  31. package/esm/src/kernel/stages.d.ts +14 -3
  32. package/esm/src/kernel/stages.js +24 -7
  33. package/esm/src/kernel/tools/memory-execution.d.ts +11 -0
  34. package/esm/src/kernel/tools/memory-execution.js +59 -0
  35. package/esm/src/kernel/tools/operation.d.ts +2 -5
  36. package/esm/src/kernel/tools/operation.js +5 -1
  37. package/esm/src/kernel/turn-events.d.ts +9 -2
  38. package/esm/src/kernel/turn-events.js +29 -2
  39. package/esm/src/observability/trace-catalog.js +2 -1
  40. package/esm/src/providers/gemini/interactions-protocol.js +2 -5
  41. package/esm/src/providers/google/api-error.d.ts +2 -0
  42. package/esm/src/providers/google/api-error.js +26 -18
  43. package/esm/src/providers/google/live/stream.js +1 -1
  44. package/esm/src/providers/shared/retry.d.ts +1 -0
  45. package/esm/src/providers/shared/retry.js +6 -7
  46. package/package.json +1 -1
@@ -7,7 +7,7 @@ import { resolveGuardrailPolicy } from '../../../guardrails/policy.js';
7
7
  import { replyIsJudged } from '../../../guardrails/progressive-yield.js';
8
8
  import { recordTaint } from '../../../guardrails/tool-result.js';
9
9
  import { waitDefault } from '../../../providers/shared/retry.js';
10
- import { historyToolCalls } from '../../interaction-parts.js';
10
+ import { stepReplyMessages } from '../../interaction-parts.js';
11
11
  import { profileTurnOutputs } from '../../registry/profile-outputs.js';
12
12
  import { providerCompleteRequest } from '../../registry/provider-request.js';
13
13
  import { injectWouldExceedMaxSteps } from '../../stages.js';
@@ -19,7 +19,7 @@ import { profileToolsParallel } from '../../tools/resolve.js';
19
19
  import { findLast } from '../../util/find-last.js';
20
20
  import { startToolTrace } from '../tool-trace.js';
21
21
  import { startCallTrace } from '../turn-trace.js';
22
- import { applyStageInjects } from './stages.js';
22
+ import { applyStageInjects, recordReply } from './stages.js';
23
23
  import { recordStepEvent } from './state.js';
24
24
  import { inStep, runSideBySide, stepOrder } from './step-order.js';
25
25
  import { isWithheldOnBlock, StructuredDrafts, yieldProviderEvents, } from './stream.js';
@@ -112,10 +112,28 @@ function tallyCallEvent(event, tally, call) {
112
112
  call.guardrail(event.guardrail);
113
113
  if (event.type === 'structured')
114
114
  tally.latestStructured = event.structured;
115
+ if (event.type === 'text')
116
+ tally.text += event.text;
115
117
  if (REPLY_EVENTS.has(event.type))
116
118
  tally.replied = true;
117
119
  return false;
118
120
  }
121
+ /** What sets one model call's text apart from the reply text before it: a blank line. */
122
+ const CALL_BREAK = '\n\n';
123
+ /**
124
+ * The call's first text, set apart from the attempt's reply before it.
125
+ *
126
+ * why: Each model call writes as if it starts the message, so a reply that goes on after tools or
127
+ * an inject would otherwise run its first word into the last one ("…full picture.Here's").
128
+ */
129
+ function withCallBreak(event, tally, state, generation) {
130
+ if (tally.text)
131
+ return event;
132
+ const before = state.replyText;
133
+ const breaks = before && generation.structured === null && !/\s$/.test(before) && !/^\s/.test(event.text);
134
+ tally.replyAt = before.length + (breaks ? CALL_BREAK.length : 0);
135
+ return breaks ? { ...event, text: `${CALL_BREAK}${event.text}` } : event;
136
+ }
119
137
  /** Records an event the call gave. True when the host gets it now. */
120
138
  function releasesNow(event, control, state, buffer) {
121
139
  recordStepEvent(event, state);
@@ -125,8 +143,10 @@ function releasesNow(event, control, state, buffer) {
125
143
  state.withheldVisible = true;
126
144
  return false;
127
145
  }
128
- if (event.type === 'text')
146
+ if (event.type === 'text') {
129
147
  state.released += event.text;
148
+ state.replyText += event.text;
149
+ }
130
150
  // why: Text and media stream via progressive-yield under egress, thoughts
131
151
  // stream unguarded; holdLate only buffers non-visible events (e.g.
132
152
  // structured) for validation.
@@ -135,6 +155,7 @@ function releasesNow(event, control, state, buffer) {
135
155
  }
136
156
  // why: Every other kind is the provider's answer to the request itself (it refused a part, a key, a setting); the same call sent again gets the same answer.
137
157
  const PASSING_ERROR_KINDS = new Set([
158
+ 'busy',
138
159
  'unavailable',
139
160
  'bad_response',
140
161
  'network',
@@ -174,7 +195,7 @@ async function* executeAutonomousStep(args, state, buffer = {
174
195
  }) {
175
196
  const { profile, generation, system, provider, signal } = args;
176
197
  const { usage, genForStep, request, call } = startProviderCall(generation, system, state, isAnswerOnlyStep(profile, generation, state.stepCount));
177
- const tally = { calls: [], reasoning: [], failures: [], replied: false };
198
+ const tally = { calls: [], reasoning: [], text: '', failures: [], replied: false };
178
199
  const control = {
179
200
  withholdVisible: false,
180
201
  ...(state.canaryCarry ? { canaryCarry: state.canaryCarry } : {}),
@@ -219,11 +240,13 @@ async function* executeAutonomousStep(args, state, buffer = {
219
240
  yield event;
220
241
  continue;
221
242
  }
243
+ // why: The model's history keeps what it wrote; only the reply the host gets is set apart.
244
+ const next = event.type === 'text' ? withCallBreak(event, tally, state, genForStep) : event;
222
245
  if (tallyCallEvent(event, tally, call))
223
246
  continue;
224
- if (!releasesNow(event, control, state, buffer))
247
+ if (!releasesNow(next, control, state, buffer))
225
248
  continue;
226
- yield* shown.show(event);
249
+ yield* shown.show(next);
227
250
  }
228
251
  }
229
252
  catch (err) {
@@ -249,7 +272,9 @@ async function* executeAutonomousStep(args, state, buffer = {
249
272
  return {
250
273
  calls: tally.calls,
251
274
  reasoning: tally.reasoning,
275
+ text: tally.text,
252
276
  latestStructured: tally.latestStructured,
277
+ ...(tally.replyAt === undefined ? {} : { replyAt: tally.replyAt }),
253
278
  ...(resume ? { resume } : {}),
254
279
  };
255
280
  }
@@ -356,6 +381,14 @@ function* applyToolSettlement(settlement, state, call) {
356
381
  }
357
382
  return 'continue';
358
383
  }
384
+ /** A step's reply as the model reads it back: its text, then its calls with the reasoning before them. */
385
+ function stepReply(outcome) {
386
+ return stepReplyMessages({
387
+ text: outcome.text,
388
+ structured: outcome.latestStructured,
389
+ calls: outcome.calls.map((call, index) => index === 0 ? { ...call, reasoning: outcome.reasoning } : call),
390
+ });
391
+ }
359
392
  /**
360
393
  * Run the model's calls. Each reaches the host as its raw call first, all of them before
361
394
  * any runs; their phases then arrive as each tool gives them. With `tools.parallel` they
@@ -370,10 +403,10 @@ function* applyToolSettlement(settlement, state, call) {
370
403
  * After the step, any gate stops the turn. A call a stage cancelled stops the turn too,
371
404
  * once every result that did come back is recorded.
372
405
  */
373
- async function* handleModelCalls({ calls: upstreamCalls, reasoning }, generation, profile, state, system, { onStage, signal, credentials, resolveHost } = {}) {
374
- if (upstreamCalls.length > 0) {
375
- state.currentHistory.push(historyToolCalls(upstreamCalls.map((call, index) => (index === 0 ? { ...call, reasoning } : call))));
376
- }
406
+ async function* handleModelCalls(outcome, generation, profile, state, system, { onStage, signal, credentials, resolveHost } = {}) {
407
+ const { calls: upstreamCalls, reasoning } = outcome;
408
+ if (upstreamCalls.length > 0)
409
+ recordReply(state, stepReply(outcome), outcome.replyAt);
377
410
  const stepId = crypto.randomUUID();
378
411
  const leakScope = {
379
412
  canary: generation.canary,
@@ -485,6 +518,9 @@ async function* handleModelCalls({ calls: upstreamCalls, reasoning }, generation
485
518
  async function* executeAttempt(args) {
486
519
  const { profile, generation, system, provider, state } = args;
487
520
  let latestStructured;
521
+ // why: The answer that ends the attempt is not in turn history; a `before_end` inject puts it there first.
522
+ let reply = [];
523
+ let replyAt;
488
524
  // why: Text streams via progressive-yield under egress, thoughts unguarded; validation and egress
489
525
  // both hold non-visible events (structured) until the attempt gate, so a policy
490
526
  // sees the structured payload before any of it reaches the host.
@@ -505,6 +541,8 @@ async function* executeAttempt(args) {
505
541
  if (stepResult.latestStructured !== undefined) {
506
542
  latestStructured = stepResult.latestStructured;
507
543
  }
544
+ reply = stepResult.calls.length > 0 ? [] : stepReply(stepResult);
545
+ replyAt = stepResult.replyAt;
508
546
  const executed = yield* handleModelCalls(stepResult, generation, profile, state, system, args.safe);
509
547
  if (!executed) {
510
548
  break;
@@ -514,6 +552,6 @@ async function* executeAttempt(args) {
514
552
  state.lastStop = { kind: 'step_limit' };
515
553
  }
516
554
  }
517
- return { latestStructured };
555
+ return { latestStructured, reply, ...(replyAt === undefined ? {} : { replyAt }) };
518
556
  }
519
557
  export { executeAttempt };
@@ -9,5 +9,17 @@ export declare function isMediaRefPart(part: InteractionPart): part is Interacti
9
9
  * Neither field replaces the other.
10
10
  */
11
11
  export declare function historyMessageParts(msg: TurnHistoryMessage): InteractionPart[];
12
+ /**
13
+ * One model call's reply as the model reads it back later in its turn: what it
14
+ * wrote (its structured reply, when it gave one), then the calls it made. Turn
15
+ * history and a provider checkpoint both build it here, so the checkpoint
16
+ * covers exactly the history the turn's next call sends.
17
+ */
18
+ export declare function stepReplyMessages(reply: {
19
+ text: string;
20
+ structured?: unknown;
21
+ calls: Parameters<typeof historyToolCalls>[0];
22
+ }): TurnHistoryMessage[];
12
23
  /** One step's calls as the assistant message that made them, with the reasoning written before them. */
13
- export declare function historyToolCalls(calls: readonly Pick<import('./turn-events.js').ToolCallRequest, 'callId' | 'name' | 'arguments' | 'reasoning'>[]): TurnHistoryMessage;
24
+ declare function historyToolCalls(calls: readonly Pick<import('./turn-events.js').ToolCallRequest, 'callId' | 'name' | 'arguments' | 'reasoning'>[]): TurnHistoryMessage;
25
+ export {};
@@ -21,8 +21,21 @@ export function historyMessageParts(msg) {
21
21
  const parts = msg.parts ?? [];
22
22
  return msg.content ? [{ type: 'text', text: msg.content }, ...parts] : [...parts];
23
23
  }
24
+ /**
25
+ * One model call's reply as the model reads it back later in its turn: what it
26
+ * wrote (its structured reply, when it gave one), then the calls it made. Turn
27
+ * history and a provider checkpoint both build it here, so the checkpoint
28
+ * covers exactly the history the turn's next call sends.
29
+ */
30
+ export function stepReplyMessages(reply) {
31
+ const content = reply.structured !== undefined ? JSON.stringify(reply.structured) : reply.text;
32
+ return [
33
+ ...(content ? [{ role: 'assistant', content }] : []),
34
+ ...(reply.calls.length ? [historyToolCalls(reply.calls)] : []),
35
+ ];
36
+ }
24
37
  /** One step's calls as the assistant message that made them, with the reasoning written before them. */
25
- export function historyToolCalls(calls) {
38
+ function historyToolCalls(calls) {
26
39
  const reasoning = calls.flatMap((call) => call.reasoning ?? []);
27
40
  const tool_calls = [];
28
41
  for (const call of calls) {
@@ -60,6 +60,8 @@ export { systemPromptProblem } from './system-parts.js';
60
60
  export { activityLabelProblem } from './tools/activity-label.js';
61
61
  export { agentToolInputFor, agentToolOutput } from './tools/agent.js';
62
62
  export { type AnsweredGate, answerGatedCall, GATE_DECISIONS, type GateAnswerRequest, type GateDecision, gateExpired, type HeldGatedCall, resolveGateTtlMs, sessionPermissionsAfterApproval, type ToolGateAuth, } from './tools/gate-answer.js';
63
+ export type { MemoryToolExecutionOptions } from './tools/memory-execution.js';
64
+ export { createMemoryToolExecution } from './tools/memory-execution.js';
63
65
  export type { CredentialQuery, McpProtocolVersion, McpRpcResponse, ToolRegistry, } from './tools/mod.js';
64
66
  export { askUserTool, authorizeToolReplay, coerceToolResultParts, createToolRegistry, credentialPlacement, executeHttpTool, executeMcpTool, executeRegisteredTool, formatToolResult, isUnsupportedMcpProtocolError, leanToolResultData, MCP_PROTOCOL_VERSIONS, parseMcpRpcResponse, prepareTurnToolSnapshot, projectForModel, registerHarnessTools, resolveToolAuth, withCredentialQuery, } from './tools/mod.js';
65
67
  export type { BeginToolOperationResult, PageToolDispatch, PageToolExecution, SavedToolInvocation, TerminalToolEvent, ToolExecutionCoordinator, ToolOperationClaim, ToolOperationIdentity, ToolOperationOutcome, ToolOperationUncertainty, } from './tools/operation.js';
@@ -36,6 +36,7 @@ export { systemPromptProblem } from './system-parts.js';
36
36
  export { activityLabelProblem } from './tools/activity-label.js';
37
37
  export { agentToolInputFor, agentToolOutput } from './tools/agent.js';
38
38
  export { answerGatedCall, GATE_DECISIONS, gateExpired, resolveGateTtlMs, sessionPermissionsAfterApproval, } from './tools/gate-answer.js';
39
+ export { createMemoryToolExecution } from './tools/memory-execution.js';
39
40
  export { askUserTool, authorizeToolReplay, coerceToolResultParts, createToolRegistry, credentialPlacement, executeHttpTool, executeMcpTool, executeRegisteredTool, formatToolResult, isUnsupportedMcpProtocolError, leanToolResultData, MCP_PROTOCOL_VERSIONS, parseMcpRpcResponse, prepareTurnToolSnapshot, projectForModel, registerHarnessTools, resolveToolAuth, withCredentialQuery, } from './tools/mod.js';
40
41
  export { savedToolInvocationSchema, toolInvocationHash, toolOperationOutcomeSchema, } from './tools/operation.js';
41
42
  export { jsonSchemaFromZod } from './tools/schema.js';
@@ -309,7 +309,7 @@ function isKind(error, kind) {
309
309
  }
310
310
  /**
311
311
  * Runs one call with the kernel's retries, all before the first event: once on the fallback slot
312
- * when the first key is out of quota, and once after an `auth` refusal with the minted token
312
+ * when the first key is out of quota or its model is busy, and once after an `auth` refusal with the minted token
313
313
  * dropped. A fallback starts clean: it may be another account, so it never sends the first key's
314
314
  * state.
315
315
  */
@@ -330,7 +330,7 @@ async function* withKeys(setup, run) {
330
330
  catch (error) {
331
331
  if (started)
332
332
  throw error;
333
- if (isKind(error, 'rate_limit') &&
333
+ if ((isKind(error, 'rate_limit') || isKind(error, 'busy')) &&
334
334
  fallback &&
335
335
  slot !== fallback &&
336
336
  holdsKey(setup, fallback)) {
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { isAbortError, TheoremError, throwIfAborted, } from '../guardrails/error.js';
3
3
  import { labelText } from '../guardrails/labels.js';
4
4
  import { historyHash, providerCheckpointSchema, } from './checkpoint.js';
5
- import { historyToolCalls } from './interaction-parts.js';
5
+ import { stepReplyMessages } from './interaction-parts.js';
6
6
  import { jsonObjectSchema, jsonValueSchema } from './json.js';
7
7
  import { keySlotsOf, protocolOf, } from './protocols/binding.js';
8
8
  import { resolveProviderLimits } from './protocols/limits.js';
@@ -409,12 +409,7 @@ async function* consumeModelStep(events, signal) {
409
409
  const pending = [];
410
410
  const reasoning = [];
411
411
  let text = '';
412
- const outputs = [];
413
- const flushText = () => {
414
- if (text)
415
- outputs.push({ role: 'assistant', content: text });
416
- text = '';
417
- };
412
+ let structured;
418
413
  const iterator = events[Symbol.asyncIterator]();
419
414
  let exhausted = false;
420
415
  try {
@@ -435,8 +430,7 @@ async function* consumeModelStep(events, signal) {
435
430
  text += event.text;
436
431
  break;
437
432
  case 'structured':
438
- flushText();
439
- outputs.push({ role: 'assistant', content: JSON.stringify(event.structured) });
433
+ structured = event.structured;
440
434
  break;
441
435
  case 'reasoning': {
442
436
  const { type: _type, ...part } = event;
@@ -446,7 +440,6 @@ async function* consumeModelStep(events, signal) {
446
440
  case 'tool_call':
447
441
  if (pending.some((call) => call.callId === event.call.callId))
448
442
  throw new TheoremError('bad_response', labelText('provider.duplicate_call'));
449
- flushText();
450
443
  if (event.call.wireCallId)
451
444
  wireCallIds[event.call.callId] = event.call.wireCallId;
452
445
  pending.push(kernelCall(event.call));
@@ -467,8 +460,15 @@ async function* consumeModelStep(events, signal) {
467
460
  if (!exhausted && iterator.return)
468
461
  void Promise.resolve(iterator.return()).catch(() => undefined);
469
462
  }
470
- flushText();
471
- return { terminal, failed, pending, reasoning, outputs, wireCallIds };
463
+ return {
464
+ terminal,
465
+ failed,
466
+ pending,
467
+ reasoning,
468
+ text,
469
+ ...(structured !== undefined ? { structured } : {}),
470
+ wireCallIds,
471
+ };
472
472
  }
473
473
  /**
474
474
  * A profile's model as the engine calls it: each step one guarded protocol call, with the
@@ -489,7 +489,7 @@ export function registeredTurnModel(provider, binding, profileType, host, initia
489
489
  yield { type: 'provider_warning', warning: restored.warning };
490
490
  }
491
491
  const call = guardedCall(provider, binding, profileType, host, req);
492
- const { terminal, failed, pending, reasoning, outputs, wireCallIds } = yield* consumeModelStep(call.complete(protocolRequest(req, protocol, restored)), req.signal);
492
+ const { terminal, failed, pending, reasoning, text, structured, wireCallIds } = yield* consumeModelStep(call.complete(protocolRequest(req, protocol, restored)), req.signal);
493
493
  throwIfAborted(req.signal);
494
494
  if (!terminal) {
495
495
  yield { type: 'done', stop: { kind: failed ? 'provider_error' : 'stream_incomplete' } };
@@ -501,8 +501,6 @@ export function registeredTurnModel(provider, binding, profileType, host, initia
501
501
  yield { type: 'done', stop: terminal.stop };
502
502
  return;
503
503
  }
504
- if (pending.length)
505
- outputs.push(historyToolCalls(pending.map((entry, index) => (index === 0 ? { ...entry, reasoning } : entry))));
506
504
  const history = [...(req.history ?? [])];
507
505
  if (req.input.length)
508
506
  history.push(req.input.every((part) => part.type === 'text')
@@ -513,7 +511,11 @@ export function registeredTurnModel(provider, binding, profileType, host, initia
513
511
  : { role: 'user', parts: req.input });
514
512
  const checkpoint = await checkpointProviderState(provider, binding, call.keySlot(), terminal.state, [
515
513
  ...history,
516
- ...(pending.length ? outputs.filter((message) => message.tool_calls) : outputs),
514
+ ...stepReplyMessages({
515
+ text,
516
+ structured,
517
+ calls: pending.map((entry, index) => (index === 0 ? { ...entry, reasoning } : entry)),
518
+ }),
517
519
  ], { ...restored.wireCallIds, ...wireCallIds });
518
520
  active = checkpoint;
519
521
  for (const entry of pending)
@@ -126,7 +126,7 @@ export declare const AWAITING_USER_INPUT_KINDS: readonly ["confirm", "choice", "
126
126
  /** One of {@linkcode AWAITING_USER_INPUT_KINDS}. */
127
127
  export type AwaitingUserInputKind = (typeof AWAITING_USER_INPUT_KINDS)[number];
128
128
  /** Why a stage handler's result was partly or wholly ignored. */
129
- export declare const STAGE_APPLY_WARNING_CODES: readonly ["affordance_not_allowed", "inject_not_allowed", "inject_rejected_max_steps", "inject_invalid_messages", "inject_id_invalid", "deny_invalid", "confirm_invalid", "mutate_invalid", "label_failed_invalid", "abort_invalid", "wait_for_invalid", "unknown_field", "result_invalid"];
129
+ export declare const STAGE_APPLY_WARNING_CODES: readonly ["affordance_not_allowed", "inject_not_allowed", "inject_rejected_max_steps", "inject_invalid_messages", "inject_id_invalid", "inject_released_invalid", "deny_invalid", "confirm_invalid", "mutate_invalid", "label_failed_invalid", "abort_invalid", "wait_for_invalid", "unknown_field", "result_invalid"];
130
130
  /** One of {@linkcode STAGE_APPLY_WARNING_CODES}. */
131
131
  export type StageApplyWarningCode = (typeof STAGE_APPLY_WARNING_CODES)[number];
132
132
  /** The `status` of a tool output that asks the user a question. */
@@ -140,6 +140,7 @@ export const STAGE_APPLY_WARNING_CODES = [
140
140
  'inject_rejected_max_steps',
141
141
  'inject_invalid_messages',
142
142
  'inject_id_invalid',
143
+ 'inject_released_invalid',
143
144
  'deny_invalid',
144
145
  'confirm_invalid',
145
146
  'mutate_invalid',
@@ -513,7 +514,7 @@ export const PROFILE_FIELDS = withScopeAndPresence({
513
514
  'models.*.protocol': field('string', 'The API shape this model is spoken to in.'),
514
515
  'models.*.provider': field('string', 'The service that runs the model or routes requests to one, such as Gemini or OpenRouter. Its registration supplies the endpoint and authentication.'),
515
516
  'models.*.keySlot': field('KeySlot', "The key slot this model's calls are signed with, instead of the provider's. Use it to bill one model to a different key. Absent: the provider's primary slot."),
516
- 'models.*.fallbackKeySlot': field('KeySlot', "The key slot tried once after a rate limit on the primary slot, instead of the provider's fallback. Must differ from the primary slot. Absent: the provider's fallback slot, if it has one."),
517
+ 'models.*.fallbackKeySlot': field('KeySlot', "The key slot tried once after a rate limit or a busy model on the primary slot, instead of the provider's fallback. Must differ from the primary slot. Absent: the provider's fallback slot, if it has one."),
517
518
  'models.*.options': field('JsonObject', "Settings only this model's protocol has, such as a task type or vector size. Each is checked when the profile loads. Any left out keep the provider's default."),
518
519
  providerContinuation: field('ProviderContinuationPolicy', 'Who holds the conversation a saved provider state continues.'),
519
520
  'providerContinuation.history': field("'host' | 'stateless' | 'provider'", 'Who holds the conversation a checkpoint continues: the host passes history back, the provider keeps it and the checkpoint travels alone, or the profile is stateless and every request stands alone.', ['host', 'stateless', 'provider']),
@@ -55,6 +55,13 @@ export interface StageResult {
55
55
  * non-string id refuses the inject whole.
56
56
  */
57
57
  injectId?: string;
58
+ /**
59
+ * With `inject`, at `post_tool` or `before_end`: how many characters of the attempt's reply text,
60
+ * as the turn gave it, the user already has. The rest is a draft they never got: it leaves the
61
+ * turn's history and its reply when the inject lands, so the model answers everything afresh
62
+ * after the part they have. Left out, they have all of it.
63
+ */
64
+ released?: number;
58
65
  abort?: boolean | {
59
66
  reason?: string;
60
67
  };
@@ -105,6 +112,8 @@ export interface StageApplyInput {
105
112
  export interface InjectUnit {
106
113
  id?: string;
107
114
  messages: TurnHistoryMessage[];
115
+ /** {@linkcode StageResult.released}: the reply text the user has when this inject lands. */
116
+ released?: number;
108
117
  }
109
118
  /** A stage handler's result after validation: the parts the turn will act on. */
110
119
  export interface StageApplyOutput {
@@ -139,16 +148,18 @@ export type StageEventExtra = {
139
148
  injected?: {
140
149
  id: string;
141
150
  }[];
151
+ released?: number;
142
152
  };
143
153
  /** Build a stream `stage` event. Unknown extra keys are not copied. */
144
154
  export declare function stageEventFields(stage: TurnStage, extra?: Partial<StageEventExtra>): TurnEventOf<'stage'>;
145
155
  export declare function injectMessages(units: readonly InjectUnit[]): TurnHistoryMessage[];
146
156
  /**
147
157
  * The `stage` event recording that `units` landed, naming the ones the host
148
- * named; `undefined` when none was named. Emit it where the messages land,
149
- * never before: an inject that never lands is never reported.
158
+ * named, and the reply text the user has when they dropped a draft (`released`);
159
+ * `undefined` when there is neither. Emit it where the messages land, never
160
+ * before: an inject that never lands is never reported.
150
161
  */
151
- export declare function injectedStageEvent(stage: TurnStage, units: readonly InjectUnit[], extra?: Pick<StageEventExtra, 'callId' | 'toolName'>): TurnEventOf<'stage'> | undefined;
162
+ export declare function injectedStageEvent(stage: TurnStage, units: readonly InjectUnit[], extra?: Pick<StageEventExtra, 'callId' | 'toolName' | 'released'>): TurnEventOf<'stage'> | undefined;
152
163
  export declare function injectWouldExceedMaxSteps(stepCount: number, maxSteps: number | undefined): boolean;
153
164
  export interface RunStageArgs extends StageCallBag {
154
165
  stage: TurnStage;
@@ -26,7 +26,7 @@ export const STAGE_AFFORDANCE_MATRIX = Object.freeze({
26
26
  before_end: Object.freeze(['inject', 'abort']),
27
27
  post_turn: Object.freeze([]),
28
28
  });
29
- const STAGE_RESULT_KEYS = new Set([...STAGE_AFFORDANCES, 'injectId']);
29
+ const STAGE_RESULT_KEYS = new Set([...STAGE_AFFORDANCES, 'injectId', 'released']);
30
30
  /** True when the affordance is physically allowed at `stage` (ignores inject gate). */
31
31
  export function stageAllowsAffordance(stage, affordance) {
32
32
  return STAGE_AFFORDANCE_MATRIX[stage].includes(affordance);
@@ -102,15 +102,26 @@ function coerceHistoryMessages(raw, warnings) {
102
102
  }
103
103
  return out.length > 0 ? out : undefined;
104
104
  }
105
+ /** `released` as a count of characters; anything else is a warning, and the user has the whole reply. */
106
+ function injectReleased(raw, warnings) {
107
+ if (raw === undefined)
108
+ return undefined;
109
+ if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 0)
110
+ return raw;
111
+ warn(warnings, 'inject_released_invalid', 'released', 'released must be a whole number of characters, 0 or more; ignored');
112
+ return undefined;
113
+ }
105
114
  /** The inject's messages under its id; an unusable id refuses the inject whole. */
106
115
  function injectUnit(result, warnings) {
107
116
  const messages = coerceHistoryMessages(result.inject, warnings);
108
117
  if (!messages)
109
118
  return undefined;
119
+ const released = injectReleased(result.released, warnings);
120
+ const unit = { messages, ...(released === undefined ? {} : { released }) };
110
121
  if (result.injectId === undefined)
111
- return { messages };
122
+ return unit;
112
123
  if (typeof result.injectId === 'string' && result.injectId.trim()) {
113
- return { id: result.injectId, messages };
124
+ return { id: result.injectId, ...unit };
114
125
  }
115
126
  warn(warnings, 'inject_id_invalid', 'injectId', 'injectId must be a non-empty string; inject refused');
116
127
  return undefined;
@@ -120,6 +131,9 @@ function applyInjectField(result, input, out) {
120
131
  if (result.injectId !== undefined) {
121
132
  warn(out.warnings, 'inject_id_invalid', 'injectId', 'injectId without inject ignored'); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
122
133
  }
134
+ if (result.released !== undefined) {
135
+ warn(out.warnings, 'inject_released_invalid', 'released', 'released without inject ignored'); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
136
+ }
123
137
  return;
124
138
  }
125
139
  const { stage, injectAllowed, injectWouldExceedMaxSteps } = input;
@@ -305,6 +319,8 @@ export function stageEventFields(stage, extra) {
305
319
  event.stop = extra.stop;
306
320
  if (extra.injected !== undefined)
307
321
  event.injected = extra.injected;
322
+ if (extra.released !== undefined)
323
+ event.released = extra.released;
308
324
  return event;
309
325
  }
310
326
  export function injectMessages(units) {
@@ -312,14 +328,15 @@ export function injectMessages(units) {
312
328
  }
313
329
  /**
314
330
  * The `stage` event recording that `units` landed, naming the ones the host
315
- * named; `undefined` when none was named. Emit it where the messages land,
316
- * never before: an inject that never lands is never reported.
331
+ * named, and the reply text the user has when they dropped a draft (`released`);
332
+ * `undefined` when there is neither. Emit it where the messages land, never
333
+ * before: an inject that never lands is never reported.
317
334
  */
318
335
  export function injectedStageEvent(stage, units, extra) {
319
336
  const injected = units.flatMap((unit) => (unit.id === undefined ? [] : [{ id: unit.id }]));
320
- if (injected.length === 0)
337
+ if (injected.length === 0 && extra?.released === undefined)
321
338
  return undefined;
322
- return stageEventFields(stage, { ...extra, injected });
339
+ return stageEventFields(stage, { ...extra, ...(injected.length > 0 ? { injected } : {}) });
323
340
  }
324
341
  export function injectWouldExceedMaxSteps(stepCount, maxSteps) {
325
342
  if (maxSteps === undefined || maxSteps <= 0)
@@ -0,0 +1,11 @@
1
+ import { type ToolExecutionCoordinator } from './operation.js';
2
+ export interface MemoryToolExecutionOptions {
3
+ /** The most operations it holds. A full coordinator refuses dispatch and keeps what it has. */
4
+ maxOperations?: number;
5
+ }
6
+ /**
7
+ * A coordinator that keeps claims and outcomes in process memory.
8
+ * Use one per turn or per process when nothing needs to resume after a restart.
9
+ * A host that resumes across requests or workers supplies its own `ToolExecutionCoordinator`.
10
+ */
11
+ export declare function createMemoryToolExecution(options?: MemoryToolExecutionOptions): ToolExecutionCoordinator;
@@ -0,0 +1,59 @@
1
+ import { operationUnavailable, } from './operation.js';
2
+ /** How many operations a memory coordinator tracks before it refuses new ones. */
3
+ const DEFAULT_MEMORY_EXECUTION_LIMIT = 256;
4
+ /**
5
+ * A coordinator that keeps claims and outcomes in process memory.
6
+ * Use one per turn or per process when nothing needs to resume after a restart.
7
+ * A host that resumes across requests or workers supplies its own `ToolExecutionCoordinator`.
8
+ */
9
+ export function createMemoryToolExecution(options = {}) {
10
+ const limit = options.maxOperations ?? DEFAULT_MEMORY_EXECUTION_LIMIT;
11
+ const records = new Map();
12
+ // invariant: callId and operationId never contain a NUL byte, so the joined key is unambiguous.
13
+ const keyOf = (operation) => `${operation.callId}\0${operation.operationId}`;
14
+ const known = (operation) => {
15
+ const record = records.get(keyOf(operation));
16
+ if (!record)
17
+ return undefined;
18
+ if (record.state === 'settled')
19
+ return { kind: 'settled', outcome: record.outcome };
20
+ return { kind: record.state };
21
+ };
22
+ const holds = (claim) => {
23
+ const record = records.get(keyOf(claim));
24
+ return record?.state === 'running' && record.claimToken === claim.claimToken;
25
+ };
26
+ return {
27
+ lookup: (operation) => Promise.resolve(known(operation)),
28
+ begin: (operation) => {
29
+ const earlier = known(operation);
30
+ if (earlier)
31
+ return Promise.resolve(earlier);
32
+ if (records.size >= limit)
33
+ return Promise.reject(operationUnavailable());
34
+ const claimToken = crypto.randomUUID();
35
+ records.set(keyOf(operation), { state: 'running', claimToken });
36
+ return Promise.resolve({ kind: 'acquired', claimToken });
37
+ },
38
+ settle: (claim, outcome) => {
39
+ if (!holds(claim))
40
+ return Promise.reject(operationUnavailable());
41
+ records.set(keyOf(claim), { state: 'settled', outcome });
42
+ return Promise.resolve();
43
+ },
44
+ settleNotStarted: (operation, outcome) => {
45
+ if (records.has(keyOf(operation)))
46
+ return Promise.reject(operationUnavailable());
47
+ if (records.size >= limit)
48
+ return Promise.reject(operationUnavailable());
49
+ records.set(keyOf(operation), { state: 'settled', outcome });
50
+ return Promise.resolve();
51
+ },
52
+ markUncertain: (claim) => {
53
+ if (!holds(claim))
54
+ return Promise.reject(operationUnavailable());
55
+ records.set(keyOf(claim), { state: 'uncertain' });
56
+ return Promise.resolve();
57
+ },
58
+ };
59
+ }
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { TheoremError } from '../../guardrails/error.js';
3
3
  import { type JsonObject, type JsonValue } from '../json.js';
4
+ import type { InjectUnit } from '../stages.js';
4
5
  import type { GateReference, TurnEventOf } from '../turn-events.js';
5
- import type { TurnHistoryMessage } from '../types.js';
6
6
  import type { ModelToolResult, PageAnswer, ToolFailure } from './types.js';
7
7
  export type TerminalToolEvent = TurnEventOf<'tool'> & {
8
8
  tool: Extract<TurnEventOf<'tool'>['tool'], {
@@ -43,10 +43,7 @@ export interface ToolOperationOutcome {
43
43
  aborted?: boolean | {
44
44
  reason?: string;
45
45
  };
46
- pendingInject?: {
47
- id?: string;
48
- messages: TurnHistoryMessage[];
49
- }[];
46
+ pendingInject?: InjectUnit[];
50
47
  };
51
48
  }
52
49
  export type ToolOperationUncertainty = 'execution_interrupted' | 'settlement_write_failed' | 'worker_lost';
@@ -64,7 +64,11 @@ export const toolOperationOutcomeSchema = z.strictObject({
64
64
  callNotStarted: z.boolean().optional(),
65
65
  aborted: z.union([z.boolean(), z.strictObject({ reason: z.string().optional() })]).optional(),
66
66
  pendingInject: z
67
- .array(z.strictObject({ id: z.string().optional(), messages: z.array(turnHistoryMessageSchema) }))
67
+ .array(z.strictObject({
68
+ id: z.string().optional(),
69
+ messages: z.array(turnHistoryMessageSchema),
70
+ released: z.number().int().nonnegative().optional(),
71
+ }))
68
72
  .optional(),
69
73
  }),
70
74
  });
@@ -690,6 +690,12 @@ export type TurnEvent = {
690
690
  injected?: {
691
691
  id: string;
692
692
  }[];
693
+ /**
694
+ * An inject at this stage dropped a draft the user never got: of the reply text since the
695
+ * turn began or its last `attempt`, only the first `released` characters stay the reply.
696
+ * {@linkcode keptReply} drops the rest.
697
+ */
698
+ released?: number;
693
699
  }
694
700
  /**
695
701
  * A tool call the model is still writing: the next piece of its arguments' JSON text, in order,
@@ -770,7 +776,8 @@ export declare const TURN_EVENT_SCHEMAS: {
770
776
  };
771
777
  /**
772
778
  * `events` without the replies a retry threw away: each `attempt` drops the text, media and
773
- * structured output before it, back to the turn's start or its last `done`. Tool calls, thoughts
774
- * and everything else stay, since they happened.
779
+ * structured output before it, back to the turn's start or its last `done`. A `stage` event with
780
+ * `released` keeps only that much of the reply's text, and drops what followed it. Tool calls,
781
+ * thoughts and everything else stay, since they happened.
775
782
  */
776
783
  export declare function keptReply(events: readonly TurnEvent[]): TurnEvent[];