@ouispec/agent-worker 0.3.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 +20 -1
  3. package/dist/approvals/client.d.ts.map +1 -1
  4. package/dist/approvals/client.js +25 -0
  5. package/dist/approvals/client.js.map +1 -1
  6. package/dist/approvals/continuation.d.ts +47 -1
  7. package/dist/approvals/continuation.d.ts.map +1 -1
  8. package/dist/approvals/continuation.js +97 -1
  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 +504 -84
  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 +106 -12
  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 +79 -2
  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, markedResult, notRunResult, ranNote, refusedNote, resolveContinuation, unavailableNote, } 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,8 +956,86 @@ 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;
968
+ // ─── Calls still stored as waiting, whose approval expired undecided ──────
969
+ //
970
+ // A card that expires with nobody clicking it sends nothing, so no turn ever
971
+ // stored how the call ended: every later turn read "waiting for approval…
972
+ // has not run" (dev, 2026-10-04). At the start of a turn the store is asked
973
+ // about each call the history still shows as waiting. Only its explicit
974
+ // answer settles one: `claimed` stores the expired result under the call's
975
+ // own id; `already` (another turn holds the claim) tells the model for this
976
+ // turn and stores nothing; `open` and `unknown` leave the call as it is. A
977
+ // call whose "waiting" row is not in the history yet is simply not found.
978
+ // Expiry is the store's clock's to decide, never this process's.
979
+ /** Calls beyond the newest few are left: a store call per waiting call, on every turn, has to stay small. */
980
+ const MAX_WAITING_SETTLED = 5;
981
+ async function settleExpiredWaiting() {
982
+ const settled = { outcomes: [], persisted: [], claimed: [], undecided: 0, approved: 0, withdrawn: 0 };
983
+ const store = config.approvals;
984
+ if (!store?.settleExpired)
985
+ return settled;
986
+ const owner = { userId: input.userId, conversationId: input.conversationId };
987
+ const waiting = waitingCalls(input.history ?? [])
988
+ // The call this very turn carries the decision for is the continuation's.
989
+ .filter((w) => w.approvalId !== input.approval?.approvalId)
990
+ .slice(0, MAX_WAITING_SETTLED);
991
+ const answers = await Promise.all(waiting.map(async (w) => {
992
+ try {
993
+ return { w, answer: await store.settleExpired(w.approvalId, owner) };
994
+ }
995
+ catch (err) {
996
+ // A store that cannot be reached settles nothing: the call keeps reading "waiting".
997
+ log('warn', 'agent:tool', 'Could not ask the approval store about a waiting call', { turnId, approvalId: w.approvalId, error: err instanceof Error ? err.message : String(err) });
998
+ return { w, answer: null };
999
+ }
1000
+ }));
1001
+ for (const { w, answer } of answers) {
1002
+ if (answer?.outcome !== 'claimed' && answer?.outcome !== 'already')
1003
+ continue;
1004
+ // The user had approved it and it was never run, or nobody decided: the result says which.
1005
+ const wasApproved = answer.decided === 'approved';
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)
1015
+ settled.approved += 1;
1016
+ else
1017
+ settled.undecided += 1;
1018
+ const name = w.name ?? 'unknown';
1019
+ settled.outcomes.push({ role: 'tool', content, tool_call_id: w.approvalId, name });
1020
+ if (answer.outcome === 'claimed') {
1021
+ settled.persisted.push({ role: 'tool', content, toolCallId: w.approvalId, name });
1022
+ settled.claimed.push(w.approvalId);
1023
+ }
1024
+ log('info', 'agent:tool', 'A waiting call’s approval expired without the call running', {
1025
+ turnId,
1026
+ approvalId: w.approvalId,
1027
+ decided: wasApproved ? 'approved' : null,
1028
+ ...(answer.withdrawn ? { withdrawn: answer.withdrawn } : {}),
1029
+ stored: answer.outcome === 'claimed',
1030
+ });
1031
+ }
1032
+ return settled;
1033
+ }
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 });
835
1039
  // Convert history to AI SDK CoreMessage format. The page the user is on
836
1040
  // travels with their message, not in the system prompt: it changes on every
837
1041
  // page, and in the system prompt it would invalidate the cached prefix. An
@@ -841,9 +1045,12 @@ export async function runAgentTurn(config, input) {
841
1045
  // model has no clock, and the changing minute must stay out of the cached
842
1046
  // system prompt (prompt/clock.ts).
843
1047
  const messages = [
844
- ...withClock(withPageState(convertHistoryToCoreMessages(
845
- // An approved call's outcome is a later result of the call already in the history: it takes that call's place.
846
- continued?.outcome ? [...(input.history ?? []), continued.outcome] : (input.history ?? []), [input.content, continued?.note].filter(Boolean).join('\n\n')), client, config.ui?.maxObservationChars ?? DEFAULT_PAGE_STATE_CHARS, config.ui?.maxIndexChars ?? DEFAULT_INDEX_CHARS), input.context ?? null),
1048
+ ...withClock(withPageState(withNote(convertHistoryToCoreMessages(
1049
+ // An approved call's outcome, and an expired one's, is a later result of the call already in the
1050
+ // history: it takes that call's place.
1051
+ [...(input.history ?? []), ...expired.outcomes, ...(continued?.outcome ? [continued.outcome] : [])], [input.content, continued?.note].filter(Boolean).join('\n\n')),
1052
+ // Said on the user's message whether or not the host's history already holds that message.
1053
+ expired.outcomes.length > 0 ? expiredNote(expired) : null), client, config.ui?.maxObservationChars ?? DEFAULT_PAGE_STATE_CHARS, config.ui?.maxIndexChars ?? DEFAULT_INDEX_CHARS), input.context ?? null),
847
1054
  ...(continued?.messages ?? []),
848
1055
  ];
849
1056
  // The system prompt goes in `instructions`, NOT in `messages`.
@@ -902,7 +1109,7 @@ export async function runAgentTurn(config, input) {
902
1109
  isEnabled: true,
903
1110
  functionId: `agent-turn:${turnId}`,
904
1111
  },
905
- prepareStep: async ({ steps }) => {
1112
+ prepareStep: async ({ steps, messages }) => {
906
1113
  // The policy speaks of the page's actions by their ids, as it always
907
1114
  // has: a `ui_act` call is shown to it as the action it ran, and the
908
1115
  // actions are among the tool names it may choose from.
@@ -944,14 +1151,27 @@ export async function runAgentTurn(config, input) {
944
1151
  // without a note sets the plain instructions back.
945
1152
  // So does the turn's record, once a call has not succeeded.
946
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;
947
1158
  return {
948
1159
  ...constraints,
1160
+ ...(trimmed ? { messages: trimmed.messages } : {}),
949
1161
  instructions: notes.length
950
1162
  ? [instructions, ...notes.map((content) => ({ role: 'system', content }))]
951
1163
  : instructions,
952
1164
  };
953
1165
  },
954
- 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
+ });
955
1175
  log('info', 'agent:llm', 'Step completed', {
956
1176
  turnId,
957
1177
  turnClass,
@@ -962,31 +1182,53 @@ export async function runAgentTurn(config, input) {
962
1182
  });
963
1183
  },
964
1184
  });
965
- for await (const textPart of result.textStream) {
966
- tokenBuffer += textPart;
967
- if (tokenBuffer.length >= COALESCE_CHARS) {
968
- await flushTokenBuffer();
969
- }
970
- else if (!flushTimer) {
971
- 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
+ }
972
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;
973
1217
  }
974
- await flushTokenBuffer();
975
- // After the text stream completes, all promised properties resolve.
976
- const steps = await result.steps;
977
- const segmentUsage = await result.usage;
978
- const response = await result.response;
979
- allSteps.push(...steps);
980
- usageTotals.inputTokens += segmentUsage?.inputTokens ?? 0;
981
- usageTotals.outputTokens += segmentUsage?.outputTokens ?? 0;
982
- usageTotals.cacheReadTokens += segmentUsage?.inputTokenDetails?.cacheReadTokens ?? 0;
983
- usageTotals.cacheWriteTokens += segmentUsage?.inputTokenDetails?.cacheWriteTokens ?? 0;
984
- responseMessageCount += response.messages.length;
985
- conversation = [...conversation, ...response.messages];
986
1218
  // The model's tools no longer change with the page (ADR-0245 §2.2): what
987
1219
  // the page offers reaches it in each answer, so the turn runs as one segment.
988
1220
  break;
989
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();
990
1232
  // ─── Finalize ──────────────────────────────────────────────────────────────
991
1233
  const steps = allSteps;
992
1234
  // Determine stop reason
@@ -1045,11 +1287,27 @@ export async function runAgentTurn(config, input) {
1045
1287
  // What bounds the model: the page's index as it reads it, against the most it is given
1046
1288
  // before the furthest surfaces are listed by id only.
1047
1289
  indexChars: indexText(client.page, Infinity).length,
1290
+ pageStatesNotRepeated,
1048
1291
  maxIndexChars: config.ui?.maxIndexChars ?? DEFAULT_INDEX_CHARS,
1049
1292
  },
1050
1293
  }
1051
1294
  : {}),
1052
1295
  });
1296
+ // The turn's messages, in the host's format, for it to store.
1297
+ const newMessages = [...expired.persisted, ...(continued?.persisted ?? []), ...convertResponseToTurnMessages({ steps }, executedToolResults)];
1298
+ // The host stores the turn before the client hears it is complete: a message sent the moment
1299
+ // it completes must be answered from a history that holds this turn.
1300
+ if (config.beforeTurnComplete) {
1301
+ try {
1302
+ await config.beforeTurnComplete({ rounds: steps.length, usage, newMessages });
1303
+ }
1304
+ catch (err) {
1305
+ log('error', 'agent:turn', 'beforeTurnComplete failed; the turn completes without it', {
1306
+ turnId,
1307
+ error: err instanceof Error ? err.message : String(err),
1308
+ });
1309
+ }
1310
+ }
1053
1311
  await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TURN_COMPLETE, {
1054
1312
  turnId,
1055
1313
  rounds: steps.length,
@@ -1065,12 +1323,11 @@ export async function runAgentTurn(config, input) {
1065
1323
  usage,
1066
1324
  newMessageCount: responseMessageCount,
1067
1325
  });
1068
- // Convert AI SDK response messages to our TurnMessage format for persistence
1069
- const newMessages = [...(continued?.persisted ?? []), ...convertResponseToTurnMessages({ steps }, executedToolResults)];
1070
1326
  return {
1071
1327
  rounds: steps.length,
1072
1328
  usage,
1073
1329
  newMessages,
1330
+ ...(expired.claimed.length > 0 ? { settledApprovals: expired.claimed } : {}),
1074
1331
  maxRoundsReached: steps.length >= maxRounds,
1075
1332
  stopReason,
1076
1333
  };
@@ -1079,7 +1336,164 @@ export async function runAgentTurn(config, input) {
1079
1336
  clearTimeout(deadlineTimer);
1080
1337
  if (flushTimer)
1081
1338
  clearTimeout(flushTimer);
1339
+ // The turn is over, however it ended: nothing is listening for a stop.
1340
+ ending = true;
1341
+ stopWatch.close();
1082
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
+ };
1488
+ }
1489
+ }
1490
+ /** Put a note for the model on the turn's user message, after what the user said. */
1491
+ function withNote(messages, note) {
1492
+ const last = messages[messages.length - 1];
1493
+ if (!note || !last || last.role !== 'user')
1494
+ return messages;
1495
+ const text = typeof last.content === 'string' ? last.content : last.content.map((p) => ('text' in p ? p.text : '')).join('');
1496
+ return [...messages.slice(0, -1), { role: 'user', content: text ? `${text}\n\n${note}` : note }];
1083
1497
  }
1084
1498
  /**
1085
1499
  * Put the client's page on the user's message: what it offers, as an index of
@@ -1163,10 +1577,16 @@ function convertHistoryToCoreMessages(history, currentContent) {
1163
1577
  messages.push({ role: 'user', content: msg.content });
1164
1578
  }
1165
1579
  else if (msg.role === 'assistant') {
1166
- 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) {
1167
1587
  const parts = [];
1168
- if (msg.content) {
1169
- parts.push({ type: 'text', text: msg.content });
1588
+ if (text) {
1589
+ parts.push({ type: 'text', text });
1170
1590
  }
1171
1591
  if (msg.tool_calls?.length) {
1172
1592
  for (const tc of msg.tool_calls) {