@ouispec/agent-worker 0.4.0 → 0.7.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.
Files changed (56) hide show
  1. package/README.md +27 -2
  2. package/dist/approvals/client.d.ts +8 -1
  3. package/dist/approvals/client.d.ts.map +1 -1
  4. package/dist/approvals/client.js +10 -0
  5. package/dist/approvals/client.js.map +1 -1
  6. package/dist/approvals/continuation.d.ts +13 -1
  7. package/dist/approvals/continuation.d.ts.map +1 -1
  8. package/dist/approvals/continuation.js +35 -0
  9. package/dist/approvals/continuation.js.map +1 -1
  10. package/dist/index.d.ts +5 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/lambda/handler.d.ts +2 -2
  15. package/dist/lambda/handler.d.ts.map +1 -1
  16. package/dist/lambda/handler.js +3 -2
  17. package/dist/lambda/handler.js.map +1 -1
  18. package/dist/orchestrator.d.ts.map +1 -1
  19. package/dist/orchestrator.js +418 -82
  20. package/dist/orchestrator.js.map +1 -1
  21. package/dist/runtime/turn-runner.d.ts +21 -2
  22. package/dist/runtime/turn-runner.d.ts.map +1 -1
  23. package/dist/runtime/turn-runner.js +77 -8
  24. package/dist/runtime/turn-runner.js.map +1 -1
  25. package/dist/runtime/types.d.ts +63 -6
  26. package/dist/runtime/types.d.ts.map +1 -1
  27. package/dist/stop/partial.d.ts +49 -0
  28. package/dist/stop/partial.d.ts.map +1 -0
  29. package/dist/stop/partial.js +73 -0
  30. package/dist/stop/partial.js.map +1 -0
  31. package/dist/stop/results.d.ts +20 -0
  32. package/dist/stop/results.d.ts.map +1 -0
  33. package/dist/stop/results.js +46 -0
  34. package/dist/stop/results.js.map +1 -0
  35. package/dist/stop/turn-stop.d.ts +114 -0
  36. package/dist/stop/turn-stop.d.ts.map +1 -0
  37. package/dist/stop/turn-stop.js +155 -0
  38. package/dist/stop/turn-stop.js.map +1 -0
  39. package/dist/tools/types.d.ts +7 -0
  40. package/dist/tools/types.d.ts.map +1 -1
  41. package/dist/tools/types.js.map +1 -1
  42. package/dist/types.d.ts +60 -3
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/ui/knowledge.d.ts +28 -2
  45. package/dist/ui/knowledge.d.ts.map +1 -1
  46. package/dist/ui/knowledge.js +117 -13
  47. package/dist/ui/knowledge.js.map +1 -1
  48. package/dist/ui/newest-page-state.d.ts +25 -0
  49. package/dist/ui/newest-page-state.d.ts.map +1 -0
  50. package/dist/ui/newest-page-state.js +91 -0
  51. package/dist/ui/newest-page-state.js.map +1 -0
  52. package/dist/ui/ui-tools.d.ts +20 -2
  53. package/dist/ui/ui-tools.d.ts.map +1 -1
  54. package/dist/ui/ui-tools.js +65 -6
  55. package/dist/ui/ui-tools.js.map +1 -1
  56. package/package.json +6 -6
@@ -19,14 +19,18 @@
19
19
  * - Configurable TurnPolicy for product-specific step control
20
20
  */
21
21
  import { streamText, dynamicTool, jsonSchema, isStepCount, hasToolCall } from 'ai';
22
- import { AGENT_SOCKET_EVENTS, argsHash } from '@ouispec/agent-core';
22
+ import { AGENT_SOCKET_EVENTS, argsHash, canonicalJson, turnStoppedNote, } from '@ouispec/agent-core';
23
+ import { DEFAULT_STOP_GRACE_MS, TurnStopped, stopOf, watchTurnStop } from './stop/turn-stop.js';
24
+ import { stoppedTurnMessages } from './stop/partial.js';
25
+ import { resultText, stoppedNotRun, stoppedWhileRunning } from './stop/results.js';
23
26
  import { defaultTurnPolicy } from './turn-policy.js';
24
27
  import { evaluateToolPolicySafe } from './authz/tool-policy.js';
25
28
  import { createToolInputValidator } from './tools/input-validation.js';
26
29
  import { readClientPage, withoutClientUI } from './ui/snapshot.js';
27
- import { readClientKnowledge, withClientKnowledge } from './ui/knowledge.js';
30
+ import { KNOWLEDGE_TOOL, knowledgeTool, readClientKnowledge, withClientKnowledge } from './ui/knowledge.js';
31
+ import { withNewestPageStateOnly } from './ui/newest-page-state.js';
28
32
  import { withClock } from './prompt/clock.js';
29
- import { buildUITools, createPageSight, DefinitionUnavailable, fitNotes, UI_ACT_TOOL, UI_DESCRIBE_TOOL, UI_READ_TOOL, } from './ui/ui-tools.js';
33
+ import { buildUITools, createPageSight, DefinitionUnavailable, fitNotes, jobWaitOf, UI_ACT_TOOL, UI_DESCRIBE_TOOL, UI_READ_TOOL, } from './ui/ui-tools.js';
30
34
  import { DEFAULT_INDEX_CHARS, indexText, pageFingerprint } from './ui/page-index.js';
31
35
  import { describeSchema } from './ui/outline.js';
32
36
  import { createUISequence } from './ui/ui-sequence.js';
@@ -34,7 +38,7 @@ import { createTurnLedger } from './turn-ledger.js';
34
38
  import { DEFAULT_PAGE_STATE_CHARS, observationSchemas, observationsText } from './ui/observations.js';
35
39
  import { approvalRequirement, APPROVAL_TOOL_NOTE } from './approvals/requirement.js';
36
40
  import { buildApprovalPreview, declaredTitle } from './approvals/preview.js';
37
- import { approvalMarker, approvedExpiredMarker, expiredNote, markedResult, notRunResult, ranNote, refusedNote, resolveContinuation, unavailableNote, waitingCalls, } from './approvals/continuation.js';
41
+ import { approvalMarker, approvedExpiredMarker, expiredNote, markedResult, notRunResult, ranNote, refusedNote, resolveContinuation, unavailableNote, waitingCalls, withdrawnMarker, } from './approvals/continuation.js';
38
42
  const DEFAULT_MAX_ROUNDS = 12;
39
43
  const DEFAULT_MAX_TOKENS = 4096;
40
44
  const DEFAULT_TEMPERATURE = 0.3;
@@ -76,6 +80,7 @@ export async function runAgentTurn(config, input) {
76
80
  const turnDeadlineMs = config.turnDeadlineMs ?? 240_000; // 4 min, below the 5 min Lambda ceiling
77
81
  const retries = config.retries ?? 3;
78
82
  const uiResultTimeoutMs = config.ui?.resultTimeoutMs ?? DEFAULT_UI_RESULT_TIMEOUT_MS;
83
+ const uiJobWaitMs = jobWaitOf({ jobWaitMs: config.ui?.jobWaitMs });
79
84
  if (!config.model) {
80
85
  throw new Error('[agent-sdk] config.model is required: pass an `ai` library model (ADR-0227 §2.2)');
81
86
  }
@@ -109,7 +114,9 @@ export async function runAgentTurn(config, input) {
109
114
  const heldDefinitions = client?.held ?? new Map();
110
115
  // Whether the page can be seen (ADR-0245 §2.5): nothing that changes it runs while it cannot.
111
116
  const sight = createPageSight();
112
- const uiCounts = { describes: 0, reads: 0, acts: 0 };
117
+ const uiCounts = { describes: 0, reads: 0, acts: 0, guides: 0 };
118
+ // Earlier answers' page states replaced, summed over the turn's steps.
119
+ let pageStatesNotRepeated = 0;
113
120
  // The knowledge the client sent for its page goes after the host's prompt.
114
121
  const knowledge = readClientKnowledge(input.context ?? null);
115
122
  log('info', 'agent:ui', 'UI surfaces for this turn', {
@@ -146,13 +153,49 @@ export async function runAgentTurn(config, input) {
146
153
  ? config.systemPrompt({ userId: input.userId, accountId: input.accountId, context: withoutClientUI(input.context) })
147
154
  : config.systemPrompt;
148
155
  const systemPrompt = withClientKnowledge(hostPrompt, input.context);
149
- log('debug', 'agent:prompt', 'System prompt built', { turnId, promptLength: systemPrompt.length });
156
+ log('debug', 'agent:prompt', 'System prompt built', { turnId, promptLength: systemPrompt.length, hostPromptLength: hostPrompt.length });
150
157
  // Wall-clock deadline: abort the entire streamText call if it exceeds turnDeadlineMs.
151
158
  const turnStartedAt = Date.now();
152
159
  // A UI tool waiting on work it started stops in time for the model to answer.
153
160
  const uiWaitDeadline = () => turnStartedAt + turnDeadlineMs - UI_ANSWER_MARGIN_MS;
154
161
  const abortController = new AbortController();
155
162
  const deadlineTimer = setTimeout(() => abortController.abort('Turn deadline exceeded'), turnDeadlineMs);
163
+ // ─── Stopping (ADR-0252) ───────────────────────────────────────────────────
164
+ // The person's Stop, or a newer message superseding this turn, is kept by the
165
+ // realtime server; the watch asks for it for as long as the turn runs. A stop
166
+ // aborts the turn's signal with a typed reason, which is what everything that
167
+ // waits (the model stream, a UI action's answer, a job's outcome) already
168
+ // honours. What the turn had produced is then stored and announced (below).
169
+ const stopWatch = input.stopWatch ?? watchTurnStop(config.stops, turnId, input.userId);
170
+ /** Set when the turn begins its own end: a stop that lands after is not one (§2.4). */
171
+ let ending = false;
172
+ /** Kept back from a stopped turn's grace for storing and announcing it. */
173
+ const STOP_STORE_MARGIN_MS = 3_000;
174
+ /** Kept back from a stopped turn's store for announcing it. */
175
+ const STOP_ANNOUNCE_MARGIN_MS = 1_000;
176
+ /** The least any step of the stop path is given, however little is left. */
177
+ const STOP_STEP_FLOOR_MS = 250;
178
+ let stopGraceMs = config.stopGraceMs ?? DEFAULT_STOP_GRACE_MS;
179
+ // The stop path's own signal: the turn's is aborted by the time anything needs one.
180
+ let graceSignal = null;
181
+ const stopState = {
182
+ reason: () => stopOf(abortController.signal)?.reason ?? null,
183
+ graceSignal: () => (graceSignal ??= AbortSignal.timeout(stopGraceMs + 250)),
184
+ get graceMs() {
185
+ return stopGraceMs;
186
+ },
187
+ };
188
+ stopWatch.onStop((record) => {
189
+ if (ending || abortController.signal.aborted)
190
+ return;
191
+ // Never longer than this process has left, less what storing and announcing take.
192
+ const remaining = input.remainingMs?.();
193
+ if (remaining !== undefined)
194
+ stopGraceMs = Math.max(0, Math.min(stopGraceMs, remaining - STOP_STORE_MARGIN_MS));
195
+ graceSignal = AbortSignal.timeout(stopGraceMs + 250);
196
+ log('info', 'agent:turn', 'Turn asked to stop', { turnId, reason: record.reason, graceMs: stopGraceMs });
197
+ abortController.abort(new TurnStopped(record.reason, record.at));
198
+ });
156
199
  // Build tool execution context
157
200
  const toolCtx = {
158
201
  userId: input.userId,
@@ -163,18 +206,42 @@ export async function runAgentTurn(config, input) {
163
206
  apiSurface: config.apiSurface,
164
207
  abortSignal: abortController.signal,
165
208
  socketRoom,
209
+ stop: stopState,
166
210
  };
167
211
  // Track tool executions for emit events and persistence.
168
212
  // We capture toolCallId from the execute options (2nd argument).
169
213
  const executedToolResults = [];
170
214
  // The pictures results came with, by call: given to the model with that call's result (ui/answer-image.ts).
171
215
  const answerImages = new Map();
216
+ // The worker's own record of the turn, kept as it runs: what a stopped turn
217
+ // is stored from (stop/partial.ts). The model stream's own results do not
218
+ // survive an abort, so nothing on the stop path reads them.
219
+ const recordedSteps = [];
220
+ const recordedCalls = new Map();
221
+ let streamedText = '';
222
+ /** Records a call as it arrives, and its result when it settles. */
223
+ const tracked = (toolCallId, toolName, callInput, run) => {
224
+ const call = { toolCallId, toolName, input: callInput, text: null, settled: Promise.resolve() };
225
+ recordedCalls.set(toolCallId, call);
226
+ const result = run().then((text) => {
227
+ call.text = text;
228
+ return text;
229
+ });
230
+ call.settled = result.then(() => undefined, () => undefined);
231
+ return result;
232
+ };
172
233
  // Per-tool invocation quotas — mandatory for side-effecting tools.
173
234
  // Quota is per-turn, held in this closure — never module scope. It spans
174
235
  // segments: rebuilding the tool set does not reset what was already used.
175
236
  const toolInvocationCounts = new Map();
176
- const DEFAULT_TOOL_QUOTA = 12; // matches maxRounds
237
+ const DEFAULT_TOOL_QUOTA = 12;
177
238
  const SIDE_EFFECT_TOOL_QUOTA = 2;
239
+ // A UI action is not counted (session 24611234: filling 12 slides of 3 values each
240
+ // ran one action 36 times, and a cap of 12 stopped it). What is refused is a loop:
241
+ // the same call with the same input, made again after it changed nothing three
242
+ // times in a row. Undo five times, or nudge a layer five times, is work: each changes the page.
243
+ const UNCHANGED_REPEATS = 3;
244
+ const uiRepeats = new Map();
178
245
  // What each call of the turn did: said back to the model before every step
179
246
  // after one did not succeed, so its reply matches it (ADR-0244 §2.5).
180
247
  const ledger = createTurnLedger();
@@ -206,6 +273,9 @@ export async function runAgentTurn(config, input) {
206
273
  const executeCall = async (t, rawArgs, toolUseId, uiSlot, approved) => {
207
274
  const startMs = Date.now();
208
275
  const isUI = t.kind === 'ui';
276
+ // A stopped turn starts nothing more.
277
+ if (stopState.reason())
278
+ return { text: resultText(stoppedNotRun(t.name)), ran: false };
209
279
  /**
210
280
  * A call refused before its tool ran: invalid input, over its quota, or
211
281
  * denied by policy. The model gets the reason; so does the client, as a
@@ -234,9 +304,10 @@ export async function runAgentTurn(config, input) {
234
304
  return notRun({ success: false, ...payload });
235
305
  };
236
306
  // A UI tool waits for the client's answer, which has its own deadline, and
237
- // then for the outcome of work it started, until uiWaitDeadline.
307
+ // then for the outcome of work it started: for uiJobWaitMs at most, and
308
+ // never past uiWaitDeadline.
238
309
  const timeoutMs = isUI
239
- ? Math.max(toolTimeoutMs, uiResultTimeoutMs + 5_000, uiWaitDeadline() - Date.now() + uiResultTimeoutMs + 5_000)
310
+ ? Math.max(toolTimeoutMs, uiResultTimeoutMs + 5_000, Math.min(uiJobWaitMs, Math.max(0, uiWaitDeadline() - Date.now())) + uiResultTimeoutMs + 5_000)
240
311
  : toolTimeoutMs;
241
312
  // ── No changes while blind (ADR-0245 §2.5) ──
242
313
  // After an answer that came without the page's state, or no answer, only
@@ -291,21 +362,46 @@ export async function runAgentTurn(config, input) {
291
362
  const args = validation.value;
292
363
  // Told to the model with the result, so it sends the value itself next time (below).
293
364
  const readFromText = validation.coerced ?? [];
365
+ // The call as a repeat is judged by: its name and its exact input. Set for a UI action that changes the page.
366
+ let repeatKey = null;
294
367
  // ── Quota enforcement ──
295
368
  // UI actions run as the user in their own session (ADR-0182 §3), so the
296
- // backend side-effect cap does not apply to them (ADR-0209 D5).
369
+ // backend side-effect cap does not apply to them (ADR-0209 D5). They are
370
+ // bounded by the turn's steps and deadline; only a repeated identical call is refused.
297
371
  const currentCount = toolInvocationCounts.get(t.name) ?? 0;
298
372
  const isSideEffecting = schema.sideEffects !== false; // fail-closed: undefined = side-effecting
299
- // A UI action that only reads (a room's inspect and query, ADR-0244 §2.2)
300
- // changes nothing however often it is called: the turn's step limit bounds it.
301
- const quota = reads ? Infinity : !isUI && isSideEffecting ? SIDE_EFFECT_TOOL_QUOTA : DEFAULT_TOOL_QUOTA;
302
- if (currentCount >= quota) {
303
- return refused({
304
- error: `Tool "${t.name}" has reached its maximum invocation quota of ${quota} for this turn. Please proceed without calling it again.`,
305
- quotaExceeded: true,
306
- }, args);
373
+ if (isUI) {
374
+ // A UI action that only reads (a room's inspect and query, ADR-0244 §2.2)
375
+ // changes nothing however often it is called.
376
+ if (!reads) {
377
+ let input;
378
+ try {
379
+ input = canonicalJson(args);
380
+ }
381
+ catch {
382
+ input = JSON.stringify(args);
383
+ }
384
+ repeatKey = `${t.name} ${input}`;
385
+ const unchanged = uiRepeats.get(repeatKey)?.unchanged ?? 0;
386
+ if (unchanged >= UNCHANGED_REPEATS) {
387
+ return refused({
388
+ error: `"${t.name}" with exactly this input has changed nothing the last ${unchanged} times it ran in this turn. ` +
389
+ 'Running it again would change nothing again: read the page for why, then change the input or stop.',
390
+ repeatedCall: true,
391
+ }, args);
392
+ }
393
+ }
394
+ }
395
+ else {
396
+ const quota = isSideEffecting ? SIDE_EFFECT_TOOL_QUOTA : DEFAULT_TOOL_QUOTA;
397
+ if (currentCount >= quota) {
398
+ return refused({
399
+ error: `Tool "${t.name}" has reached its maximum invocation quota of ${quota} for this turn. Please proceed without calling it again.`,
400
+ quotaExceeded: true,
401
+ }, args);
402
+ }
403
+ toolInvocationCounts.set(t.name, currentCount + 1);
307
404
  }
308
- toolInvocationCounts.set(t.name, currentCount + 1);
309
405
  // ── Policy enforcement ──
310
406
  // A deny always wins, over an approval too (ADR-0228 §2.1).
311
407
  const requirement = approvalRequirement(t);
@@ -344,6 +440,9 @@ export async function runAgentTurn(config, input) {
344
440
  uiCounts.describes++;
345
441
  else if (t.name === UI_READ_TOOL)
346
442
  uiCounts.reads++;
443
+ // It reads what the client sent, and touches no page.
444
+ else if (t.name === KNOWLEDGE_TOOL)
445
+ uiCounts.guides++;
347
446
  else if (isUI)
348
447
  uiCounts.acts++;
349
448
  log('info', 'agent:tool', 'Tool call started', {
@@ -384,6 +483,11 @@ export async function runAgentTurn(config, input) {
384
483
  if (timer)
385
484
  clearTimeout(timer);
386
485
  }
486
+ // A host tool that the stop cut short failed with whatever its abort threw:
487
+ // its result says what happened instead. (A UI tool says so itself.)
488
+ if (!isUI && stopState.reason() && result && typeof result === 'object' && 'success' in result && !result.success) {
489
+ result = stoppedWhileRunning(t.name);
490
+ }
387
491
  const durationMs = Date.now() - startMs;
388
492
  // Normalize result — built-in tools (present_options, etc.) return the
389
493
  // raw payload directly, while schema and UI tools return { success, data, error }.
@@ -423,6 +527,19 @@ export async function runAgentTurn(config, input) {
423
527
  ok: resultSuccess,
424
528
  ...(resultSuccess ? {} : { error: isWrapped && result.error ? result.error : 'it did not succeed' }),
425
529
  });
530
+ // Did this run change the page? Not when it failed, when the page says it changed no row,
531
+ // or when the page's state is what it was after the same call last ran.
532
+ if (repeatKey) {
533
+ const answer = (modelPayload ?? {});
534
+ const state = answer.state === undefined ? null : JSON.stringify(answer.state);
535
+ const changed = answer.result?.changed;
536
+ const held = uiRepeats.get(repeatKey);
537
+ const noChange = !resultSuccess ||
538
+ changed === false ||
539
+ (Array.isArray(changed) && changed.length === 0) ||
540
+ (state !== null && held?.lastState === state);
541
+ uiRepeats.set(repeatKey, { unchanged: noChange ? (held?.unchanged ?? 0) + 1 : 0, lastState: state });
542
+ }
426
543
  // A picture in the result is the model's to look at with this call's
427
544
  // result, and nothing else's: it is not in the text above, the event or the record.
428
545
  if (isWrapped && result.image)
@@ -526,12 +643,14 @@ export async function runAgentTurn(config, input) {
526
643
  // one response may get through them in any order (ui-sequence.ts). The place
527
644
  // is given up if the call never reaches the page, and otherwise held until
528
645
  // its action settles.
529
- const execute = async (rawArgs, options) => {
646
+ const execute = (rawArgs, options) => {
530
647
  const toolUseId = options?.toolCallId ?? `tool_${Date.now()}`;
531
648
  const uiSlot = isUI ? uiSequence.reserve() : undefined;
532
- const { text } = await executeCall(t, rawArgs, toolUseId, uiSlot).finally(() => uiSlot?.release());
533
- executedToolResults.push({ toolCallId: toolUseId, toolName: t.name, result: text });
534
- return text;
649
+ return tracked(toolUseId, t.name, rawArgs, async () => {
650
+ const { text } = await executeCall(t, rawArgs, toolUseId, uiSlot).finally(() => uiSlot?.release());
651
+ executedToolResults.push({ toolCallId: toolUseId, toolName: t.name, result: text });
652
+ return text;
653
+ });
535
654
  };
536
655
  // A call that needs approval says so in its description, in place of any
537
656
  // "confirm first" instruction: the card is the confirmation.
@@ -555,6 +674,7 @@ export async function runAgentTurn(config, input) {
555
674
  resultTimeoutMs: uiResultTimeoutMs,
556
675
  maxObservationChars: config.ui.maxObservationChars,
557
676
  waitDeadline: uiWaitDeadline,
677
+ jobWaitMs: uiJobWaitMs,
558
678
  currentPage: () => currentPage,
559
679
  sequence: uiSequence,
560
680
  held: heldDefinitions,
@@ -631,42 +751,45 @@ export async function runAgentTurn(config, input) {
631
751
  required: ['action'],
632
752
  additionalProperties: false,
633
753
  }),
634
- execute: async (rawArgs, options) => {
754
+ execute: (rawArgs, options) => {
635
755
  const toolUseId = options?.toolCallId ?? `tool_${Date.now()}`;
636
- const { action, input: actionInput } = (rawArgs ?? {});
637
- // Taken as the call arrives, like any UI call (ui-sequence.ts), and
638
- // given up if it never reaches the page.
639
- const uiSlot = uiSequence.reserve();
640
- const notAnAction = (error) => {
641
- uiSlot.release();
642
- ledger.record({ tool: typeof action === 'string' && action ? action : UI_ACT_TOOL, ok: false, error });
643
- const text = JSON.stringify({ success: false, notRun: true, error });
644
- executedToolResults.push({ toolCallId: toolUseId, toolName: UI_ACT_TOOL, result: text });
645
- return text;
646
- };
647
- if (typeof action !== 'string' || !action)
648
- return notAnAction(`${UI_ACT_TOOL} needs "action": an action id from the page's index.`);
649
- if (stepActions.forced && action !== stepActions.forced) {
650
- return notAnAction(`This step runs "${stepActions.forced}" and nothing else: call ${UI_ACT_TOOL} with that action.`);
651
- }
652
- if (stepActions.only && !stepActions.only.has(action)) {
653
- return notAnAction(`"${action}" is not available at this step.`);
654
- }
655
- // Looked up in the page as it is now: an earlier action of this response may have changed it.
656
- const ui = currentUI();
657
- const tool = ui?.actions.get(action);
658
- if (!tool) {
659
- const ids = ui ? [...ui.actions.keys()] : [];
660
- const near = ids.filter((id) => id.includes(action) || action.includes(id)).slice(0, 8);
661
- return notAnAction(`"${action}" is not an action of the page as it is now.` +
662
- (near.length ? ` Did you mean: ${near.join(', ')}?` : ' Read the page’s index for what it offers; an action is named by its id there.'));
663
- }
664
- const { text } = await executeCall(tool, actionInput ?? {}, toolUseId, uiSlot).finally(() => uiSlot.release());
665
- executedToolResults.push({ toolCallId: toolUseId, toolName: UI_ACT_TOOL, result: text });
666
- return text;
756
+ return tracked(toolUseId, UI_ACT_TOOL, rawArgs, () => act(rawArgs, toolUseId));
667
757
  },
668
758
  toModelOutput,
669
759
  });
760
+ async function act(rawArgs, toolUseId) {
761
+ const { action, input: actionInput } = (rawArgs ?? {});
762
+ // Taken as the call arrives, like any UI call (ui-sequence.ts), and
763
+ // given up if it never reaches the page.
764
+ const uiSlot = uiSequence.reserve();
765
+ const notAnAction = (error) => {
766
+ uiSlot.release();
767
+ ledger.record({ tool: typeof action === 'string' && action ? action : UI_ACT_TOOL, ok: false, error });
768
+ const text = JSON.stringify({ success: false, notRun: true, error });
769
+ executedToolResults.push({ toolCallId: toolUseId, toolName: UI_ACT_TOOL, result: text });
770
+ return text;
771
+ };
772
+ if (typeof action !== 'string' || !action)
773
+ return notAnAction(`${UI_ACT_TOOL} needs "action": an action id from the page's index.`);
774
+ if (stepActions.forced && action !== stepActions.forced) {
775
+ return notAnAction(`This step runs "${stepActions.forced}" and nothing else: call ${UI_ACT_TOOL} with that action.`);
776
+ }
777
+ if (stepActions.only && !stepActions.only.has(action)) {
778
+ return notAnAction(`"${action}" is not available at this step.`);
779
+ }
780
+ // Looked up in the page as it is now: an earlier action of this response may have changed it.
781
+ const ui = currentUI();
782
+ const tool = ui?.actions.get(action);
783
+ if (!tool) {
784
+ const ids = ui ? [...ui.actions.keys()] : [];
785
+ const near = ids.filter((id) => id.includes(action) || action.includes(id)).slice(0, 8);
786
+ return notAnAction(`"${action}" is not an action of the page as it is now.` +
787
+ (near.length ? ` Did you mean: ${near.join(', ')}?` : ' Read the page’s index for what it offers; an action is named by its id there.'));
788
+ }
789
+ const { text } = await executeCall(tool, actionInput ?? {}, toolUseId, uiSlot).finally(() => uiSlot.release());
790
+ executedToolResults.push({ toolCallId: toolUseId, toolName: UI_ACT_TOOL, result: text });
791
+ return text;
792
+ }
670
793
  }
671
794
  function assembleTools() {
672
795
  const ui = currentUI();
@@ -678,6 +801,9 @@ export async function runAgentTurn(config, input) {
678
801
  tools[UI_DESCRIBE_TOOL] = toAiTool(ui.describe);
679
802
  tools[UI_READ_TOOL] = toAiTool(ui.read);
680
803
  }
804
+ // What the prompt leaves out of the client's knowledge, read when it is needed.
805
+ if (knowledge && !tools[KNOWLEDGE_TOOL])
806
+ tools[KNOWLEDGE_TOOL] = toAiTool(knowledgeTool(knowledge));
681
807
  return tools;
682
808
  }
683
809
  /**
@@ -830,6 +956,13 @@ export async function runAgentTurn(config, input) {
830
956
  // only when the page changes. `cacheReadTokens` in the usage below is how you
831
957
  // tell caching is still working.
832
958
  const cachePoint = config.promptCacheBreakpoint;
959
+ // A stop asked for while the turn waited to start (in a queue, or for the
960
+ // turns before it to be stored) is heard first: nothing runs, and the turn
961
+ // stores only that it was stopped.
962
+ await stopWatch.checked();
963
+ const stoppedBeforeStart = stopOf(abortController.signal);
964
+ if (stoppedBeforeStart)
965
+ return await finishStopped(stoppedBeforeStart, { expired: null, continued: null });
833
966
  // ─── The turn after an approval decision (ADR-0228 §2.2.5) ────────────────
834
967
  const continued = input.approval ? await continueApproval(input.approval) : null;
835
968
  // ─── Calls still stored as waiting, whose approval expired undecided ──────
@@ -846,7 +979,7 @@ export async function runAgentTurn(config, input) {
846
979
  /** Calls beyond the newest few are left: a store call per waiting call, on every turn, has to stay small. */
847
980
  const MAX_WAITING_SETTLED = 5;
848
981
  async function settleExpiredWaiting() {
849
- const settled = { outcomes: [], persisted: [], claimed: [], undecided: 0, approved: 0 };
982
+ const settled = { outcomes: [], persisted: [], claimed: [], undecided: 0, approved: 0, withdrawn: 0 };
850
983
  const store = config.approvals;
851
984
  if (!store?.settleExpired)
852
985
  return settled;
@@ -870,8 +1003,15 @@ export async function runAgentTurn(config, input) {
870
1003
  continue;
871
1004
  // The user had approved it and it was never run, or nobody decided: the result says which.
872
1005
  const wasApproved = answer.decided === 'approved';
873
- const content = notRunResult(wasApproved ? approvedExpiredMarker() : approvalMarker('expired', false));
874
- if (wasApproved)
1006
+ // Withdrawn (a new message, or its turn stopped) is said as that, not as time running out (ADR-0252 §2.6).
1007
+ const content = notRunResult(answer.withdrawn
1008
+ ? withdrawnMarker(answer.withdrawn, wasApproved)
1009
+ : wasApproved
1010
+ ? approvedExpiredMarker()
1011
+ : approvalMarker('expired', false));
1012
+ if (answer.withdrawn)
1013
+ settled.withdrawn += 1;
1014
+ else if (wasApproved)
875
1015
  settled.approved += 1;
876
1016
  else
877
1017
  settled.undecided += 1;
@@ -885,12 +1025,17 @@ export async function runAgentTurn(config, input) {
885
1025
  turnId,
886
1026
  approvalId: w.approvalId,
887
1027
  decided: wasApproved ? 'approved' : null,
1028
+ ...(answer.withdrawn ? { withdrawn: answer.withdrawn } : {}),
888
1029
  stored: answer.outcome === 'claimed',
889
1030
  });
890
1031
  }
891
1032
  return settled;
892
1033
  }
893
1034
  const expired = await settleExpiredWaiting();
1035
+ // Stopped while the approved call ran, or while the store was asked: the model is not called.
1036
+ const stoppedBeforeModel = stopOf(abortController.signal);
1037
+ if (stoppedBeforeModel)
1038
+ return await finishStopped(stoppedBeforeModel, { expired, continued });
894
1039
  // Convert history to AI SDK CoreMessage format. The page the user is on
895
1040
  // travels with their message, not in the system prompt: it changes on every
896
1041
  // page, and in the system prompt it would invalidate the cached prefix. An
@@ -964,7 +1109,7 @@ export async function runAgentTurn(config, input) {
964
1109
  isEnabled: true,
965
1110
  functionId: `agent-turn:${turnId}`,
966
1111
  },
967
- prepareStep: async ({ steps }) => {
1112
+ prepareStep: async ({ steps, messages }) => {
968
1113
  // The policy speaks of the page's actions by their ids, as it always
969
1114
  // has: a `ui_act` call is shown to it as the action it ran, and the
970
1115
  // actions are among the tool names it may choose from.
@@ -1006,14 +1151,27 @@ export async function runAgentTurn(config, input) {
1006
1151
  // without a note sets the plain instructions back.
1007
1152
  // So does the turn's record, once a call has not succeeded.
1008
1153
  const notes = [note, forced ? `This step: call ${UI_ACT_TOOL} with action "${forced}".` : null, ledger.note()].filter((n) => !!n);
1154
+ // The page's state on the newest answer only: an earlier page is not the page now.
1155
+ const trimmed = messages ? withNewestPageStateOnly(messages) : null;
1156
+ if (trimmed)
1157
+ pageStatesNotRepeated += trimmed.replaced;
1009
1158
  return {
1010
1159
  ...constraints,
1160
+ ...(trimmed ? { messages: trimmed.messages } : {}),
1011
1161
  instructions: notes.length
1012
1162
  ? [instructions, ...notes.map((content) => ({ role: 'system', content }))]
1013
1163
  : instructions,
1014
1164
  };
1015
1165
  },
1016
- onStepEnd: async ({ text, toolCalls }) => {
1166
+ onStepEnd: async ({ text, toolCalls, usage: stepUsage }) => {
1167
+ recordedSteps.push({
1168
+ text: text ?? '',
1169
+ toolCalls: (toolCalls ?? []).map((tc) => ({ toolCallId: tc.toolCallId, toolName: tc.toolName, input: tc.input })),
1170
+ inputTokens: stepUsage?.inputTokens ?? 0,
1171
+ outputTokens: stepUsage?.outputTokens ?? 0,
1172
+ cacheReadTokens: stepUsage?.inputTokenDetails?.cacheReadTokens ?? 0,
1173
+ cacheWriteTokens: stepUsage?.inputTokenDetails?.cacheWriteTokens ?? 0,
1174
+ });
1017
1175
  log('info', 'agent:llm', 'Step completed', {
1018
1176
  turnId,
1019
1177
  turnClass,
@@ -1024,31 +1182,53 @@ export async function runAgentTurn(config, input) {
1024
1182
  });
1025
1183
  },
1026
1184
  });
1027
- for await (const textPart of result.textStream) {
1028
- tokenBuffer += textPart;
1029
- if (tokenBuffer.length >= COALESCE_CHARS) {
1030
- await flushTokenBuffer();
1031
- }
1032
- else if (!flushTimer) {
1033
- flushTimer = setTimeout(() => flushTokenBuffer(), COALESCE_MS);
1185
+ // A stop aborts the stream. However that surfaces, as a throw here or as the
1186
+ // stream simply ending, the turn then goes to its stop path, which reads
1187
+ // none of the stream's own results: they reject on an abort.
1188
+ try {
1189
+ for await (const textPart of result.textStream) {
1190
+ streamedText += textPart;
1191
+ tokenBuffer += textPart;
1192
+ if (tokenBuffer.length >= COALESCE_CHARS) {
1193
+ await flushTokenBuffer();
1194
+ }
1195
+ else if (!flushTimer) {
1196
+ flushTimer = setTimeout(() => flushTokenBuffer(), COALESCE_MS);
1197
+ }
1034
1198
  }
1199
+ await flushTokenBuffer();
1200
+ if (stopOf(abortController.signal))
1201
+ break;
1202
+ // After the text stream completes, all promised properties resolve.
1203
+ const steps = await result.steps;
1204
+ const segmentUsage = await result.usage;
1205
+ const response = await result.response;
1206
+ allSteps.push(...steps);
1207
+ usageTotals.inputTokens += segmentUsage?.inputTokens ?? 0;
1208
+ usageTotals.outputTokens += segmentUsage?.outputTokens ?? 0;
1209
+ usageTotals.cacheReadTokens += segmentUsage?.inputTokenDetails?.cacheReadTokens ?? 0;
1210
+ usageTotals.cacheWriteTokens += segmentUsage?.inputTokenDetails?.cacheWriteTokens ?? 0;
1211
+ responseMessageCount += response.messages.length;
1212
+ conversation = [...conversation, ...response.messages];
1213
+ }
1214
+ catch (err) {
1215
+ if (!stopOf(abortController.signal))
1216
+ throw err;
1035
1217
  }
1036
- await flushTokenBuffer();
1037
- // After the text stream completes, all promised properties resolve.
1038
- const steps = await result.steps;
1039
- const segmentUsage = await result.usage;
1040
- const response = await result.response;
1041
- allSteps.push(...steps);
1042
- usageTotals.inputTokens += segmentUsage?.inputTokens ?? 0;
1043
- usageTotals.outputTokens += segmentUsage?.outputTokens ?? 0;
1044
- usageTotals.cacheReadTokens += segmentUsage?.inputTokenDetails?.cacheReadTokens ?? 0;
1045
- usageTotals.cacheWriteTokens += segmentUsage?.inputTokenDetails?.cacheWriteTokens ?? 0;
1046
- responseMessageCount += response.messages.length;
1047
- conversation = [...conversation, ...response.messages];
1048
1218
  // The model's tools no longer change with the page (ADR-0245 §2.2): what
1049
1219
  // the page offers reaches it in each answer, so the turn runs as one segment.
1050
1220
  break;
1051
1221
  }
1222
+ // ─── Stopped, or ending ────────────────────────────────────────────────────
1223
+ // Decided here with nothing awaited in between: either the stop was heard
1224
+ // before this point and the turn takes its stop path, or the turn has begun
1225
+ // its own end and a stop that lands from now on is not one. Its watch is
1226
+ // closed, so it cannot abort the store or cause a second announcement.
1227
+ const stop = stopOf(abortController.signal);
1228
+ if (stop)
1229
+ return await finishStopped(stop, { expired, continued });
1230
+ ending = true;
1231
+ stopWatch.close();
1052
1232
  // ─── Finalize ──────────────────────────────────────────────────────────────
1053
1233
  const steps = allSteps;
1054
1234
  // Determine stop reason
@@ -1107,6 +1287,7 @@ export async function runAgentTurn(config, input) {
1107
1287
  // What bounds the model: the page's index as it reads it, against the most it is given
1108
1288
  // before the furthest surfaces are listed by id only.
1109
1289
  indexChars: indexText(client.page, Infinity).length,
1290
+ pageStatesNotRepeated,
1110
1291
  maxIndexChars: config.ui?.maxIndexChars ?? DEFAULT_INDEX_CHARS,
1111
1292
  },
1112
1293
  }
@@ -1155,6 +1336,155 @@ export async function runAgentTurn(config, input) {
1155
1336
  clearTimeout(deadlineTimer);
1156
1337
  if (flushTimer)
1157
1338
  clearTimeout(flushTimer);
1339
+ // The turn is over, however it ended: nothing is listening for a stop.
1340
+ ending = true;
1341
+ stopWatch.close();
1342
+ }
1343
+ /**
1344
+ * The end of a stopped turn (ADR-0252 §2.4): settle what was in flight, store
1345
+ * what the turn had produced, then say so. Persist first, announce after, as
1346
+ * a turn that ends by itself does.
1347
+ *
1348
+ * Everything here happens after the turn's signal was aborted, so nothing
1349
+ * here is given that signal. The wait for calls in flight is bounded by the
1350
+ * grace; the store and the announcement are bounded by what the process has
1351
+ * left, when the host says (`remainingMs`). Without that, they take as long
1352
+ * as the host's store and the emit adapter's own timeout take.
1353
+ */
1354
+ async function finishStopped(stopped, earlier) {
1355
+ ending = true;
1356
+ stopWatch.close();
1357
+ // Text the turn produced and had not sent yet is still the person's to see.
1358
+ await flushTokenBuffer().catch(() => undefined);
1359
+ // A call that was out when the stop came takes one last look for its answer
1360
+ // (the UI tools do, on the stop's own signal). They are waited for, a little
1361
+ // past that grace, and no longer.
1362
+ const pending = [...recordedCalls.values()].filter((call) => call.text === null);
1363
+ if (pending.length > 0) {
1364
+ let timer;
1365
+ await Promise.race([
1366
+ Promise.all(pending.map((call) => call.settled)),
1367
+ new Promise((resolve) => {
1368
+ timer = setTimeout(resolve, stopGraceMs + 500);
1369
+ }),
1370
+ ]);
1371
+ if (timer)
1372
+ clearTimeout(timer);
1373
+ }
1374
+ // A stopped turn leaves no live card: an approval it asked for as the stop came
1375
+ // is withdrawn now, and a later turn records it as that (§2.6).
1376
+ const held = approvalHold;
1377
+ if (held && config.approvals?.withdraw) {
1378
+ try {
1379
+ await config.approvals.withdraw(held.approvalId, { userId: input.userId, conversationId: input.conversationId }, 'stopped');
1380
+ }
1381
+ catch (err) {
1382
+ log('warn', 'agent:tool', 'Could not withdraw the stopped turn’s approval; it lasts until its time limit', {
1383
+ turnId,
1384
+ approvalId: held.approvalId,
1385
+ error: err instanceof Error ? err.message : String(err),
1386
+ });
1387
+ }
1388
+ }
1389
+ const marker = { reason: stopped.reason, at: stopped.at };
1390
+ const newMessages = [
1391
+ ...(earlier.expired?.persisted ?? []),
1392
+ ...(earlier.continued?.persisted ?? []),
1393
+ ...stoppedTurnMessages({ steps: recordedSteps, calls: [...recordedCalls.values()], streamedText, marker }),
1394
+ ];
1395
+ const unsettled = [...recordedCalls.values()].filter((call) => call.text === null).map((call) => call.toolName);
1396
+ // The steps that ended reported their tokens; the step the stop cut short did not.
1397
+ const stepInputTokens = recordedSteps.map((step) => step.inputTokens);
1398
+ const promptTokens = stepInputTokens.reduce((sum, n) => sum + n, 0);
1399
+ const completionTokens = recordedSteps.reduce((sum, step) => sum + step.outputTokens, 0);
1400
+ const usage = {
1401
+ promptTokens,
1402
+ completionTokens,
1403
+ totalTokens: promptTokens + completionTokens,
1404
+ peakPromptTokens: stepInputTokens.length > 0 ? Math.max(...stepInputTokens) : 0,
1405
+ cacheReadTokens: recordedSteps.reduce((sum, step) => sum + step.cacheReadTokens, 0),
1406
+ cacheWriteTokens: recordedSteps.reduce((sum, step) => sum + step.cacheWriteTokens, 0),
1407
+ };
1408
+ const rounds = recordedSteps.length;
1409
+ log('info', 'agent:llm', 'Turn usage', {
1410
+ turnId,
1411
+ rounds,
1412
+ stopped: true,
1413
+ stopReason: stopped.reason,
1414
+ peakPromptTokens: usage.peakPromptTokens,
1415
+ promptTokens: usage.promptTokens,
1416
+ cacheReadTokens: usage.cacheReadTokens,
1417
+ cacheWriteTokens: usage.cacheWriteTokens,
1418
+ ...(unsettled.length > 0 ? { unsettledCalls: unsettled } : {}),
1419
+ ...(client ? { ui: { ...uiCounts, blindRefusals: sight.refusals(), stopped: sight.stopped() } } : {}),
1420
+ });
1421
+ // The store and the announcement each get a limit of their own when the host says how long
1422
+ // the process has left: a slow store must not run it to its hard end with nothing announced.
1423
+ const within = async (what, keepBackMs, work) => {
1424
+ const remaining = input.remainingMs?.();
1425
+ if (remaining === undefined) {
1426
+ await work();
1427
+ return;
1428
+ }
1429
+ const limitMs = Math.max(STOP_STEP_FLOOR_MS, remaining - keepBackMs);
1430
+ let timer;
1431
+ const timedOut = Symbol('timed out');
1432
+ const outcome = await Promise.race([
1433
+ work(),
1434
+ new Promise((resolve) => {
1435
+ timer = setTimeout(() => resolve(timedOut), limitMs);
1436
+ }),
1437
+ ]);
1438
+ if (timer)
1439
+ clearTimeout(timer);
1440
+ if (outcome === timedOut) {
1441
+ log('error', 'agent:turn', `The stopped turn's ${what} did not finish within its limit; the turn goes on without waiting for it`, {
1442
+ turnId,
1443
+ limitMs,
1444
+ remainingMs: remaining,
1445
+ });
1446
+ }
1447
+ };
1448
+ if (config.beforeTurnComplete) {
1449
+ try {
1450
+ // Kept back: time to announce after it.
1451
+ await within('store', STOP_ANNOUNCE_MARGIN_MS, () => config.beforeTurnComplete({ rounds, usage, newMessages, stopped: marker }));
1452
+ }
1453
+ catch (err) {
1454
+ log('error', 'agent:turn', 'beforeTurnComplete failed; the stopped turn is announced without it', {
1455
+ turnId,
1456
+ error: err instanceof Error ? err.message : String(err),
1457
+ });
1458
+ }
1459
+ }
1460
+ try {
1461
+ await within('announcement', STOP_STEP_FLOOR_MS, () => config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TURN_COMPLETE, {
1462
+ turnId,
1463
+ rounds,
1464
+ usage,
1465
+ stopReason: stopped.reason,
1466
+ timestamp: Date.now(),
1467
+ }));
1468
+ }
1469
+ catch (err) {
1470
+ log('error', 'agent:turn', 'The stopped turn could not be announced', { turnId, error: err instanceof Error ? err.message : String(err) });
1471
+ }
1472
+ log('info', 'agent:turn', 'Turn stopped', {
1473
+ turnId,
1474
+ conversationId: input.conversationId,
1475
+ stopReason: stopped.reason,
1476
+ totalRounds: rounds,
1477
+ newMessageCount: newMessages.length,
1478
+ });
1479
+ return {
1480
+ rounds,
1481
+ usage,
1482
+ newMessages,
1483
+ ...(earlier.expired && earlier.expired.claimed.length > 0 ? { settledApprovals: earlier.expired.claimed } : {}),
1484
+ maxRoundsReached: false,
1485
+ stopReason: stopped.reason,
1486
+ stopped: marker,
1487
+ };
1158
1488
  }
1159
1489
  }
1160
1490
  /** Put a note for the model on the turn's user message, after what the user said. */
@@ -1247,10 +1577,16 @@ function convertHistoryToCoreMessages(history, currentContent) {
1247
1577
  messages.push({ role: 'user', content: msg.content });
1248
1578
  }
1249
1579
  else if (msg.role === 'assistant') {
1250
- if (msg.content || msg.tool_calls?.length) {
1580
+ // A stopped turn's last message says so after its text (ADR-0252 §2.3).
1581
+ // A message that has only that carries the line as its whole text: an
1582
+ // empty text block is refused by a model provider.
1583
+ const stoppedLine = msg.stopped ? turnStoppedNote(msg.stopped) : null;
1584
+ const said = msg.content && msg.content.trim() ? msg.content : null;
1585
+ const text = [said, stoppedLine && !said?.includes(stoppedLine) ? stoppedLine : null].filter(Boolean).join('\n\n');
1586
+ if (text || msg.tool_calls?.length) {
1251
1587
  const parts = [];
1252
- if (msg.content) {
1253
- parts.push({ type: 'text', text: msg.content });
1588
+ if (text) {
1589
+ parts.push({ type: 'text', text });
1254
1590
  }
1255
1591
  if (msg.tool_calls?.length) {
1256
1592
  for (const tc of msg.tool_calls) {