@theoremjs/agents 0.3.2 → 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.
package/README.md CHANGED
@@ -386,6 +386,10 @@ The React server handler creates this service from its atomic session store.
386
386
  Use `toolInvocationHash` to compute the same canonical JSON digest as the coordinator.
387
387
  Use this evidence to check current resource access before you disclose a cached result.
388
388
  For browser effects, `PageToolExecution` issues a dispatch grant only after the claim.
389
+ A page-answered tool needs a claim, so pass `execution` together with `pageExecution`.
390
+ Use `createMemoryToolExecution` when one process serves the whole turn and nothing resumes after a restart.
391
+ It keeps claims and outcomes in memory. Its `MemoryToolExecutionOptions` set `maxOperations` (256 by default), after which it refuses new operations.
392
+ Write your own `ToolExecutionCoordinator` when a turn can resume on another worker.
389
393
  `PageToolDispatch` contains the grant ID and a pending `PageAnswer`. Host code supplies result delivery.
390
394
  The browser receives the final prepared arguments, then returns a result against that grant.
391
395
  Neither profile JSON nor a provider event can supply this service.
@@ -403,7 +407,7 @@ Import from the narrowest entrypoint that has what you need. Each row gives the
403
407
 
404
408
  | Entrypoint | Purpose |
405
409
  | :--- | :--- |
406
- | `jsr:@theoremjs/agents` / `@theoremjs/agents` | Main kernel API: profiles, schemas, runner, core types, provider constructors, declarative HTTP/MCP tool execution. |
410
+ | `jsr:@theoremjs/agents` / `@theoremjs/agents` | Main kernel API: profiles, schemas, runner, core types, provider constructors, declarative HTTP/MCP tool execution, `createMemoryToolExecution`. |
407
411
  | `jsr:@theoremjs/agents/kernel` / `@theoremjs/agents/kernel` | Profile/turn types, tool catalog, `requireModelBinding`, thinking clamps over host model maps, OAuth 2.1 PKCE helpers (`createOAuthPkceFlow`, `exchangeOAuthPkce`, `refreshOAuthToken`). |
408
412
  | `jsr:@theoremjs/agents/providers` / `@theoremjs/agents/providers` | Protocols, first-party providers, provider definitions, sign-in helpers, and vault contracts. |
409
413
  | `jsr:@theoremjs/agents/guardrails` / `@theoremjs/agents/guardrails` | Sanitization, canary/egress gates, public error mapping, inbound injection/sensitive-data primitives. |
package/esm/mod.d.ts CHANGED
@@ -65,6 +65,8 @@ export type { MediaTurnBehaviourSpec, ProfileTurnBehaviourSpec, ProfileTurnResum
65
65
  export { AUTO_CONTINUE_DELAY_MS, DEFAULT_ALLOW_CONTINUE, DEFAULT_AUTO_CONTINUE, GenerationStopError, isContinueStopKind, isGenerationStopError, isResumeableStop, isUserCancelledStop, profileAllowsInject, profileAllowsSteering, profileTurnResumption, shouldAutoContinue, turnStopFromClientStreamEnd, turnStopFromInteractionStatus, turnStopFromOpenAiFinishReason, } from './src/kernel/stop.js';
66
66
  export { authorizeToolReplay } from './src/kernel/tools/execute.js';
67
67
  export { GATE_DECISIONS, type GateDecision } from './src/kernel/tools/gate-answer.js';
68
+ export type { MemoryToolExecutionOptions } from './src/kernel/tools/memory-execution.js';
69
+ export { createMemoryToolExecution } from './src/kernel/tools/memory-execution.js';
68
70
  export type { CredentialQuery, McpProtocolVersion, McpRpcResponse, ToolRegistry, } from './src/kernel/tools/mod.js';
69
71
  export { askUserTool, buildHttpToolTarget, createToolRegistry, credentialPlacement, executeHttpTool, executeMcpTool, formatToolResult, isUnsupportedMcpProtocolError, MCP_PROTOCOL_VERSIONS, parseMcpRpcResponse, prepareTurnToolSnapshot, registerHarnessTools, resolveToolAuth, UncheckedOutput, uncheckedOutput, withCredentialQuery, } from './src/kernel/tools/mod.js';
70
72
  export type { BeginToolOperationResult, PageToolDispatch, PageToolExecution, SavedToolInvocation, TerminalToolEvent, ToolExecutionCoordinator, ToolOperationClaim, ToolOperationIdentity, ToolOperationOutcome, ToolOperationUncertainty, } from './src/kernel/tools/operation.js';
package/esm/mod.js CHANGED
@@ -41,6 +41,7 @@ export { applyStageResult, STAGE_AFFORDANCE_MATRIX, STAGE_AFFORDANCES, stageAllo
41
41
  export { AUTO_CONTINUE_DELAY_MS, DEFAULT_ALLOW_CONTINUE, DEFAULT_AUTO_CONTINUE, GenerationStopError, isContinueStopKind, isGenerationStopError, isResumeableStop, isUserCancelledStop, profileAllowsInject, profileAllowsSteering, profileTurnResumption, shouldAutoContinue, turnStopFromClientStreamEnd, turnStopFromInteractionStatus, turnStopFromOpenAiFinishReason, } from './src/kernel/stop.js';
42
42
  export { authorizeToolReplay } from './src/kernel/tools/execute.js';
43
43
  export { GATE_DECISIONS } from './src/kernel/tools/gate-answer.js';
44
+ export { createMemoryToolExecution } from './src/kernel/tools/memory-execution.js';
44
45
  export { askUserTool, buildHttpToolTarget, createToolRegistry, credentialPlacement, executeHttpTool, executeMcpTool, formatToolResult, isUnsupportedMcpProtocolError, MCP_PROTOCOL_VERSIONS, parseMcpRpcResponse, prepareTurnToolSnapshot, registerHarnessTools, resolveToolAuth, UncheckedOutput, uncheckedOutput, withCredentialQuery, } from './src/kernel/tools/mod.js';
45
46
  export { savedToolInvocationSchema, toolInvocationHash, toolOperationOutcomeSchema, } from './src/kernel/tools/operation.js';
46
47
  export { awaitingUserInputSchema, gateReferenceSchema, isAwaitingUserInput, keptReply, reasoningPartSchema, TURN_EVENT_SCHEMAS, toolGateSchema, turnDoneOf, turnEventSchema, turnHistoryMessageSchema, turnToolSnapshotSchema, } from './src/kernel/turn-events.js';
@@ -409,6 +409,9 @@ async function* executeSingleAttemptCycle(args) {
409
409
  // invariant: Fresh maxSteps budget per validation/egress attempt. before_end inject
410
410
  // re-entry inside this cycle still accumulates stepCount (do not reset there).
411
411
  state.stepCount = 0;
412
+ // invariant: The attempt's reply spans its before_end re-entries; only a new attempt starts it again.
413
+ state.replyText = '';
414
+ state.replyMessages = [];
412
415
  let latestStructured;
413
416
  for (;;) {
414
417
  state.attemptEvents = [];
@@ -439,6 +442,8 @@ async function* executeSingleAttemptCycle(args) {
439
442
  state,
440
443
  stage: 'before_end',
441
444
  step: Math.max(state.stepCount, 1),
445
+ reply: attempt.reply,
446
+ ...(attempt.replyAt === undefined ? {} : { replyAt: attempt.replyAt }),
442
447
  onStage: flow.currentReq.onStage,
443
448
  signal: flow.currentReq.signal,
444
449
  host: flow.currentGen.host,
@@ -1,5 +1,5 @@
1
1
  import { type InjectUnit, type StageApplyWarning, type StageCallBag, type StageContext, type StageHandler } from '../../stages.js';
2
- import type { Profile, ResolvedGeneration, TurnEvent, TurnStage } from '../../types.js';
2
+ import type { Profile, ResolvedGeneration, TurnEvent, TurnHistoryMessage, TurnStage } from '../../types.js';
3
3
  import type { StepExecutionState } from './state.js';
4
4
  export interface ApplyTurnStageArgs extends StageCallBag {
5
5
  profile: Profile;
@@ -12,7 +12,16 @@ export interface ApplyTurnStageArgs extends StageCallBag {
12
12
  /** Another provider step would exceed maxSteps — reject inject. */
13
13
  injectWouldExceedMaxSteps?: boolean;
14
14
  host?: unknown;
15
+ /** `before_end`: the answer the model just wrote, which goes into turn history ahead of an inject. */
16
+ reply?: readonly TurnHistoryMessage[];
17
+ /** Where that answer's text begins in the attempt's reply. */
18
+ replyAt?: number;
15
19
  }
20
+ /**
21
+ * A model call's reply joins turn history. Its text message, whose text begins at `at` in the
22
+ * attempt's reply, is the one an inject's drop cuts.
23
+ */
24
+ export declare function recordReply(state: StepExecutionState, reply: readonly TurnHistoryMessage[], at: number | undefined): void;
16
25
  export interface ApplyTurnStageResult {
17
26
  abort?: boolean | {
18
27
  reason?: string;
@@ -20,7 +29,10 @@ export interface ApplyTurnStageResult {
20
29
  injectCount: number;
21
30
  warnings: StageApplyWarning[];
22
31
  }
23
- /** Sanitized stage injects extend portable history. */
32
+ /**
33
+ * Sanitized stage injects extend portable history. An inject whose host says the user has only
34
+ * part of the attempt's reply drops the rest first (`StageResult.released`).
35
+ */
24
36
  export declare function applyStageInjects(state: StepExecutionState, stage: TurnStage, units: readonly InjectUnit[], extra?: {
25
37
  callId?: string;
26
38
  toolName?: string;
@@ -1,13 +1,87 @@
1
1
  import { throwIfAborted } from '../../../guardrails/error.js';
2
2
  import { injectedStageEvent, injectMessages, runStage, } from '../../stages.js';
3
3
  import { profileAllowsInject } from '../../stop.js';
4
- /** Sanitized stage injects extend portable history. */
4
+ /**
5
+ * A model call's reply joins turn history. Its text message, whose text begins at `at` in the
6
+ * attempt's reply, is the one an inject's drop cuts.
7
+ */
8
+ export function recordReply(state, reply, at) {
9
+ state.currentHistory.push(...reply);
10
+ if (at === undefined)
11
+ return;
12
+ for (const message of reply) {
13
+ if (typeof message.content === 'string')
14
+ state.replyMessages.push({ message, at });
15
+ }
16
+ }
17
+ /** The least of the reply text any of `units` says the user has; `undefined` when none says. */
18
+ function releasedOf(units) {
19
+ const counts = units.flatMap((unit) => (unit.released === undefined ? [] : [unit.released]));
20
+ return counts.length > 0 ? Math.min(...counts) : undefined;
21
+ }
22
+ /** `events` less their last `count` characters of reply text, cut from the end. */
23
+ function withoutTextTail(events, count) {
24
+ const out = [...events];
25
+ let left = count;
26
+ for (let index = out.length - 1; index >= 0 && left > 0; index -= 1) {
27
+ const event = out[index];
28
+ if (event?.type !== 'text')
29
+ continue;
30
+ const keep = Math.max(0, event.text.length - left);
31
+ left -= event.text.length - keep;
32
+ if (keep === 0)
33
+ out.splice(index, 1);
34
+ else
35
+ out[index] = { ...event, text: event.text.slice(0, keep) };
36
+ }
37
+ return out;
38
+ }
39
+ /**
40
+ * Drops the draft past the `released` characters of the attempt's reply the user has: from its
41
+ * history messages, from what the attempt gate reads, and from the reply. Returns `released` when
42
+ * a draft was dropped.
43
+ *
44
+ * invariant: A message that keeps no text leaves history; a call it made stays on its own message.
45
+ */
46
+ function dropDraft(state, released) {
47
+ if (released === undefined || released >= state.replyText.length)
48
+ return undefined;
49
+ const dropped = state.replyText.length - released;
50
+ state.replyText = state.replyText.slice(0, released);
51
+ state.released = state.released.slice(0, Math.max(0, state.released.length - dropped));
52
+ state.attemptEvents = withoutTextTail(state.attemptEvents, dropped);
53
+ const kept = [];
54
+ for (const { message, at } of state.replyMessages) {
55
+ const text = typeof message.content === 'string' ? message.content : '';
56
+ const keep = text.slice(0, Math.max(0, released - at));
57
+ const index = state.currentHistory.indexOf(message);
58
+ if (index < 0)
59
+ continue;
60
+ if (keep) {
61
+ const cut = keep === text ? message : { ...message, content: keep };
62
+ state.currentHistory[index] = cut;
63
+ kept.push({ message: cut, at });
64
+ }
65
+ else
66
+ state.currentHistory.splice(index, 1);
67
+ }
68
+ state.replyMessages = kept;
69
+ return released;
70
+ }
71
+ /**
72
+ * Sanitized stage injects extend portable history. An inject whose host says the user has only
73
+ * part of the attempt's reply drops the rest first (`StageResult.released`).
74
+ */
5
75
  export function* applyStageInjects(state, stage, units, extra) {
6
76
  const inject = injectMessages(units);
7
77
  if (inject.length === 0)
8
78
  return 0;
79
+ const released = dropDraft(state, releasedOf(units));
9
80
  state.currentHistory.push(...inject);
10
- const landed = injectedStageEvent(stage, units, extra);
81
+ const landed = injectedStageEvent(stage, units, {
82
+ ...extra,
83
+ ...(released === undefined ? {} : { released }),
84
+ });
11
85
  if (landed) {
12
86
  state.allEmittedEvents.push(landed);
13
87
  yield landed;
@@ -15,7 +89,7 @@ export function* applyStageInjects(state, stage, units, extra) {
15
89
  return inject.length;
16
90
  }
17
91
  export async function* applyTurnStage(args) {
18
- const { profile, generation, state, onStage, host, ...call } = args;
92
+ const { profile, generation, state, onStage, host, reply, replyAt, ...call } = args;
19
93
  throwIfAborted(call.signal);
20
94
  const run = runStage({
21
95
  ...call,
@@ -33,6 +107,9 @@ export async function* applyTurnStage(args) {
33
107
  next = await run.next();
34
108
  }
35
109
  const out = next.value;
110
+ // why: The model goes on after the inject, so it reads the answer it wrote before it.
111
+ if (reply && injectMessages(out.inject).length > 0)
112
+ recordReply(state, reply, replyAt);
36
113
  return {
37
114
  abort: out.abort,
38
115
  injectCount: yield* applyStageInjects(state, call.stage, out.inject),
@@ -35,6 +35,19 @@ interface StepExecutionState {
35
35
  withheldVisible?: boolean;
36
36
  /** The reply text the stream's gate released this attempt, as it released it. */
37
37
  released: string;
38
+ /**
39
+ * The attempt's reply text as the host has it, across `before_end` re-entries, less any draft an
40
+ * inject dropped (`InjectUnit.released`).
41
+ */
42
+ replyText: string;
43
+ /**
44
+ * The messages in `currentHistory` that hold the model's text of `replyText`, each with where its
45
+ * text begins there: what an inject's drop cuts.
46
+ */
47
+ replyMessages: {
48
+ message: TurnHistoryMessage;
49
+ at: number;
50
+ }[];
38
51
  /** The canary opening the last provider call ended on, read in front of the next call's reply. */
39
52
  canaryCarry?: string;
40
53
  /** What the last provider call's thoughts ended on, read in front of the next call's thoughts. */
@@ -34,6 +34,8 @@ function openTurnState(args) {
34
34
  allEmittedEvents: args.allEmittedEvents ?? [],
35
35
  attemptEvents: [],
36
36
  released: '',
37
+ replyText: '',
38
+ replyMessages: [],
37
39
  givenUrls: givenUrlSets(),
38
40
  destinations: turnDestinations(),
39
41
  canaryGiven: false,
@@ -1,5 +1,5 @@
1
1
  import type { BoundSystem } from '../../system-parts.js';
2
- import type { Profile, ResolvedGeneration, TurnEvent, TurnModel, TurnRequest } from '../../types.js';
2
+ import type { Profile, ResolvedGeneration, TurnEvent, TurnHistoryMessage, TurnModel, TurnRequest } from '../../types.js';
3
3
  import { type StepExecutionState } from './state.js';
4
4
  declare function executeAttempt(args: {
5
5
  safe: TurnRequest;
@@ -10,5 +10,7 @@ declare function executeAttempt(args: {
10
10
  state: StepExecutionState;
11
11
  }): AsyncGenerator<TurnEvent, {
12
12
  latestStructured?: unknown;
13
+ reply: TurnHistoryMessage[];
14
+ replyAt?: number;
13
15
  }>;
14
16
  export { executeAttempt };
@@ -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.
@@ -175,7 +195,7 @@ async function* executeAutonomousStep(args, state, buffer = {
175
195
  }) {
176
196
  const { profile, generation, system, provider, signal } = args;
177
197
  const { usage, genForStep, request, call } = startProviderCall(generation, system, state, isAnswerOnlyStep(profile, generation, state.stepCount));
178
- const tally = { calls: [], reasoning: [], failures: [], replied: false };
198
+ const tally = { calls: [], reasoning: [], text: '', failures: [], replied: false };
179
199
  const control = {
180
200
  withholdVisible: false,
181
201
  ...(state.canaryCarry ? { canaryCarry: state.canaryCarry } : {}),
@@ -220,11 +240,13 @@ async function* executeAutonomousStep(args, state, buffer = {
220
240
  yield event;
221
241
  continue;
222
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;
223
245
  if (tallyCallEvent(event, tally, call))
224
246
  continue;
225
- if (!releasesNow(event, control, state, buffer))
247
+ if (!releasesNow(next, control, state, buffer))
226
248
  continue;
227
- yield* shown.show(event);
249
+ yield* shown.show(next);
228
250
  }
229
251
  }
230
252
  catch (err) {
@@ -250,7 +272,9 @@ async function* executeAutonomousStep(args, state, buffer = {
250
272
  return {
251
273
  calls: tally.calls,
252
274
  reasoning: tally.reasoning,
275
+ text: tally.text,
253
276
  latestStructured: tally.latestStructured,
277
+ ...(tally.replyAt === undefined ? {} : { replyAt: tally.replyAt }),
254
278
  ...(resume ? { resume } : {}),
255
279
  };
256
280
  }
@@ -357,6 +381,14 @@ function* applyToolSettlement(settlement, state, call) {
357
381
  }
358
382
  return 'continue';
359
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
+ }
360
392
  /**
361
393
  * Run the model's calls. Each reaches the host as its raw call first, all of them before
362
394
  * any runs; their phases then arrive as each tool gives them. With `tools.parallel` they
@@ -371,10 +403,10 @@ function* applyToolSettlement(settlement, state, call) {
371
403
  * After the step, any gate stops the turn. A call a stage cancelled stops the turn too,
372
404
  * once every result that did come back is recorded.
373
405
  */
374
- async function* handleModelCalls({ calls: upstreamCalls, reasoning }, generation, profile, state, system, { onStage, signal, credentials, resolveHost } = {}) {
375
- if (upstreamCalls.length > 0) {
376
- state.currentHistory.push(historyToolCalls(upstreamCalls.map((call, index) => (index === 0 ? { ...call, reasoning } : call))));
377
- }
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);
378
410
  const stepId = crypto.randomUUID();
379
411
  const leakScope = {
380
412
  canary: generation.canary,
@@ -486,6 +518,9 @@ async function* handleModelCalls({ calls: upstreamCalls, reasoning }, generation
486
518
  async function* executeAttempt(args) {
487
519
  const { profile, generation, system, provider, state } = args;
488
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;
489
524
  // why: Text streams via progressive-yield under egress, thoughts unguarded; validation and egress
490
525
  // both hold non-visible events (structured) until the attempt gate, so a policy
491
526
  // sees the structured payload before any of it reaches the host.
@@ -506,6 +541,8 @@ async function* executeAttempt(args) {
506
541
  if (stepResult.latestStructured !== undefined) {
507
542
  latestStructured = stepResult.latestStructured;
508
543
  }
544
+ reply = stepResult.calls.length > 0 ? [] : stepReply(stepResult);
545
+ replyAt = stepResult.replyAt;
509
546
  const executed = yield* handleModelCalls(stepResult, generation, profile, state, system, args.safe);
510
547
  if (!executed) {
511
548
  break;
@@ -515,6 +552,6 @@ async function* executeAttempt(args) {
515
552
  state.lastStop = { kind: 'step_limit' };
516
553
  }
517
554
  }
518
- return { latestStructured };
555
+ return { latestStructured, reply, ...(replyAt === undefined ? {} : { replyAt }) };
519
556
  }
520
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';
@@ -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',
@@ -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[];
@@ -464,6 +464,7 @@ const TURN_EVENTS = {
464
464
  .array(z.object({ id: z.string().regex(/\S/) }))
465
465
  .min(1)
466
466
  .optional(),
467
+ released: z.number().int().nonnegative().optional(),
467
468
  }),
468
469
  tool_draft: z.object({
469
470
  type: z.literal('tool_draft'),
@@ -522,10 +523,33 @@ function isReply(event) {
522
523
  event.type === 'structured' ||
523
524
  event.type === 'structured_draft');
524
525
  }
526
+ /**
527
+ * `reply` with only its first `released` characters of text: the text past them, and every
528
+ * media and structured output that came after, are a draft the user never got.
529
+ */
530
+ function cutReply(reply, released) {
531
+ const out = [];
532
+ let left = released;
533
+ for (const event of reply) {
534
+ if (!isReply(event))
535
+ out.push(event);
536
+ else if (event.type !== 'text') {
537
+ if (left > 0)
538
+ out.push(event);
539
+ }
540
+ else if (left > 0) {
541
+ const text = event.text.slice(0, left);
542
+ left -= text.length;
543
+ out.push(text === event.text ? event : { ...event, text });
544
+ }
545
+ }
546
+ return out;
547
+ }
525
548
  /**
526
549
  * `events` without the replies a retry threw away: each `attempt` drops the text, media and
527
- * structured output before it, back to the turn's start or its last `done`. Tool calls, thoughts
528
- * and everything else stay, since they happened.
550
+ * structured output before it, back to the turn's start or its last `done`. A `stage` event with
551
+ * `released` keeps only that much of the reply's text, and drops what followed it. Tool calls,
552
+ * thoughts and everything else stay, since they happened.
529
553
  */
530
554
  export function keptReply(events) {
531
555
  const kept = [];
@@ -535,6 +559,9 @@ export function keptReply(events) {
535
559
  const before = kept.splice(turnStart);
536
560
  kept.push(...before.filter((each) => !isReply(each)));
537
561
  }
562
+ if (event.type === 'stage' && event.released !== undefined) {
563
+ kept.push(...cutReply(kept.splice(turnStart), event.released));
564
+ }
538
565
  kept.push(event);
539
566
  if (event.type === 'done')
540
567
  turnStart = kept.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theoremjs/agents",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "A flat TypeScript agent kernel for typed profiles, deterministic turn execution, registered tools, provider adapters, guardrails, and host-injected traces.",
5
5
  "keywords": [
6
6
  "agent",