@ouispec/agent-worker 0.4.0 → 0.8.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 (97) hide show
  1. package/README.md +57 -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/approvals/preview.d.ts +2 -2
  11. package/dist/approvals/preview.d.ts.map +1 -1
  12. package/dist/approvals/preview.js +20 -3
  13. package/dist/approvals/preview.js.map +1 -1
  14. package/dist/attachments/content.d.ts +68 -0
  15. package/dist/attachments/content.d.ts.map +1 -0
  16. package/dist/attachments/content.js +318 -0
  17. package/dist/attachments/content.js.map +1 -0
  18. package/dist/attachments/guard.d.ts +69 -0
  19. package/dist/attachments/guard.d.ts.map +1 -0
  20. package/dist/attachments/guard.js +86 -0
  21. package/dist/attachments/guard.js.map +1 -0
  22. package/dist/attachments/limits.d.ts +46 -0
  23. package/dist/attachments/limits.d.ts.map +1 -0
  24. package/dist/attachments/limits.js +64 -0
  25. package/dist/attachments/limits.js.map +1 -0
  26. package/dist/attachments/store.d.ts +85 -0
  27. package/dist/attachments/store.d.ts.map +1 -0
  28. package/dist/attachments/store.js +2 -0
  29. package/dist/attachments/store.js.map +1 -0
  30. package/dist/attachments/tools.d.ts +12 -0
  31. package/dist/attachments/tools.d.ts.map +1 -0
  32. package/dist/attachments/tools.js +216 -0
  33. package/dist/attachments/tools.js.map +1 -0
  34. package/dist/authz/tool-policy.d.ts +6 -0
  35. package/dist/authz/tool-policy.d.ts.map +1 -1
  36. package/dist/authz/tool-policy.js.map +1 -1
  37. package/dist/index.d.ts +12 -3
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +10 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/lambda/handler.d.ts +2 -2
  42. package/dist/lambda/handler.d.ts.map +1 -1
  43. package/dist/lambda/handler.js +3 -2
  44. package/dist/lambda/handler.js.map +1 -1
  45. package/dist/orchestrator.d.ts.map +1 -1
  46. package/dist/orchestrator.js +613 -101
  47. package/dist/orchestrator.js.map +1 -1
  48. package/dist/prompt/clock.d.ts +0 -17
  49. package/dist/prompt/clock.d.ts.map +1 -1
  50. package/dist/prompt/clock.js +19 -2
  51. package/dist/prompt/clock.js.map +1 -1
  52. package/dist/prompt/user-text.d.ts +19 -0
  53. package/dist/prompt/user-text.d.ts.map +1 -0
  54. package/dist/prompt/user-text.js +20 -0
  55. package/dist/prompt/user-text.js.map +1 -0
  56. package/dist/runtime/turn-runner.d.ts +29 -2
  57. package/dist/runtime/turn-runner.d.ts.map +1 -1
  58. package/dist/runtime/turn-runner.js +118 -9
  59. package/dist/runtime/turn-runner.js.map +1 -1
  60. package/dist/runtime/types.d.ts +84 -6
  61. package/dist/runtime/types.d.ts.map +1 -1
  62. package/dist/stop/partial.d.ts +49 -0
  63. package/dist/stop/partial.d.ts.map +1 -0
  64. package/dist/stop/partial.js +73 -0
  65. package/dist/stop/partial.js.map +1 -0
  66. package/dist/stop/results.d.ts +20 -0
  67. package/dist/stop/results.d.ts.map +1 -0
  68. package/dist/stop/results.js +46 -0
  69. package/dist/stop/results.js.map +1 -0
  70. package/dist/stop/turn-stop.d.ts +114 -0
  71. package/dist/stop/turn-stop.d.ts.map +1 -0
  72. package/dist/stop/turn-stop.js +159 -0
  73. package/dist/stop/turn-stop.js.map +1 -0
  74. package/dist/tools/input-validation.d.ts.map +1 -1
  75. package/dist/tools/input-validation.js +3 -0
  76. package/dist/tools/input-validation.js.map +1 -1
  77. package/dist/tools/types.d.ts +19 -0
  78. package/dist/tools/types.d.ts.map +1 -1
  79. package/dist/tools/types.js.map +1 -1
  80. package/dist/turn-policy.d.ts +14 -0
  81. package/dist/turn-policy.d.ts.map +1 -1
  82. package/dist/turn-policy.js.map +1 -1
  83. package/dist/types.d.ts +84 -3
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/ui/knowledge.d.ts +28 -2
  86. package/dist/ui/knowledge.d.ts.map +1 -1
  87. package/dist/ui/knowledge.js +117 -13
  88. package/dist/ui/knowledge.js.map +1 -1
  89. package/dist/ui/newest-page-state.d.ts +25 -0
  90. package/dist/ui/newest-page-state.d.ts.map +1 -0
  91. package/dist/ui/newest-page-state.js +91 -0
  92. package/dist/ui/newest-page-state.js.map +1 -0
  93. package/dist/ui/ui-tools.d.ts +20 -2
  94. package/dist/ui/ui-tools.d.ts.map +1 -1
  95. package/dist/ui/ui-tools.js +65 -6
  96. package/dist/ui/ui-tools.js.map +1 -1
  97. package/package.json +6 -6
@@ -19,14 +19,24 @@
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 { withUserText } from './prompt/user-text.js';
34
+ import { AttachmentGuard } from './attachments/guard.js';
35
+ import { ATTACHMENT_DATA_NOTE, turnAttachmentParts, withReferenceLines, withTurnAttachments, withoutLeftOut } from './attachments/content.js';
36
+ import { attachmentTools } from './attachments/tools.js';
37
+ import { acceptsMediaType, attachmentIdsIn, attachmentNameForModel, misplacedAttachmentInputs } from '@ouispec/agent-core';
38
+ const ATTACHMENT_LIST_HINT = 'attachment_list lists the files that can.';
39
+ import { buildUITools, createPageSight, DefinitionUnavailable, fitNotes, jobWaitOf, UI_ACT_TOOL, UI_DESCRIBE_TOOL, UI_READ_TOOL, } from './ui/ui-tools.js';
30
40
  import { DEFAULT_INDEX_CHARS, indexText, pageFingerprint } from './ui/page-index.js';
31
41
  import { describeSchema } from './ui/outline.js';
32
42
  import { createUISequence } from './ui/ui-sequence.js';
@@ -34,7 +44,7 @@ import { createTurnLedger } from './turn-ledger.js';
34
44
  import { DEFAULT_PAGE_STATE_CHARS, observationSchemas, observationsText } from './ui/observations.js';
35
45
  import { approvalRequirement, APPROVAL_TOOL_NOTE } from './approvals/requirement.js';
36
46
  import { buildApprovalPreview, declaredTitle } from './approvals/preview.js';
37
- import { approvalMarker, approvedExpiredMarker, expiredNote, markedResult, notRunResult, ranNote, refusedNote, resolveContinuation, unavailableNote, waitingCalls, } from './approvals/continuation.js';
47
+ import { approvalMarker, approvedExpiredMarker, expiredNote, markedResult, notRunResult, ranNote, refusedNote, resolveContinuation, unavailableNote, waitingCalls, withdrawnMarker, } from './approvals/continuation.js';
38
48
  const DEFAULT_MAX_ROUNDS = 12;
39
49
  const DEFAULT_MAX_TOKENS = 4096;
40
50
  const DEFAULT_TEMPERATURE = 0.3;
@@ -76,6 +86,7 @@ export async function runAgentTurn(config, input) {
76
86
  const turnDeadlineMs = config.turnDeadlineMs ?? 240_000; // 4 min, below the 5 min Lambda ceiling
77
87
  const retries = config.retries ?? 3;
78
88
  const uiResultTimeoutMs = config.ui?.resultTimeoutMs ?? DEFAULT_UI_RESULT_TIMEOUT_MS;
89
+ const uiJobWaitMs = jobWaitOf({ jobWaitMs: config.ui?.jobWaitMs });
79
90
  if (!config.model) {
80
91
  throw new Error('[agent-sdk] config.model is required: pass an `ai` library model (ADR-0227 §2.2)');
81
92
  }
@@ -109,9 +120,35 @@ export async function runAgentTurn(config, input) {
109
120
  const heldDefinitions = client?.held ?? new Map();
110
121
  // Whether the page can be seen (ADR-0245 §2.5): nothing that changes it runs while it cannot.
111
122
  const sight = createPageSight();
112
- const uiCounts = { describes: 0, reads: 0, acts: 0 };
123
+ const uiCounts = { describes: 0, reads: 0, acts: 0, guides: 0 };
124
+ // Earlier answers' page states replaced, summed over the turn's steps.
125
+ let pageStatesNotRepeated = 0;
113
126
  // The knowledge the client sent for its page goes after the host's prompt.
114
127
  const knowledge = readClientKnowledge(input.context ?? null);
128
+ // ─── Files (ADR-0252 §2.11, §2.12) ─────────────────────────────────────────
129
+ // With the host's file area, the turn's files go to the model with its
130
+ // message, the attachment tools reach every file of the conversation, and the
131
+ // cost guard caps what the model is given of them.
132
+ const fileArea = config.attachments ?? null;
133
+ const fileOwner = { userId: input.userId, accountId: input.accountId, conversationId: input.conversationId };
134
+ let conversationFileTokens = 0;
135
+ if (fileArea?.store.conversationUsage) {
136
+ try {
137
+ conversationFileTokens = await fileArea.store.conversationUsage(fileOwner, { signal: AbortSignal.timeout(5_000) });
138
+ }
139
+ catch (err) {
140
+ log('warn', 'agent:tool', 'Could not read the conversation’s use of files; only the turn’s cap applies', {
141
+ turnId,
142
+ error: err instanceof Error ? err.message : String(err),
143
+ });
144
+ }
145
+ }
146
+ const fileGuard = new AttachmentGuard({
147
+ turnTokens: fileArea?.turnTokens,
148
+ conversationTokens: fileArea?.conversationTokens,
149
+ conversationUsed: conversationFileTokens,
150
+ });
151
+ const fileTools = fileArea ? attachmentTools({ store: fileArea.store, owner: fileOwner, guard: fileGuard }) : [];
115
152
  log('info', 'agent:ui', 'UI surfaces for this turn', {
116
153
  turnId,
117
154
  clientSentSnapshot: client !== null,
@@ -145,14 +182,59 @@ export async function runAgentTurn(config, input) {
145
182
  const hostPrompt = typeof config.systemPrompt === 'function'
146
183
  ? config.systemPrompt({ userId: input.userId, accountId: input.accountId, context: withoutClientUI(input.context) })
147
184
  : config.systemPrompt;
148
- const systemPrompt = withClientKnowledge(hostPrompt, input.context);
149
- log('debug', 'agent:prompt', 'System prompt built', { turnId, promptLength: systemPrompt.length });
185
+ // With a file area, the model is told a file's name and content are data, never instructions (ADR-0252 §2.11).
186
+ const systemPrompt = withClientKnowledge(fileArea ? `${hostPrompt}\n\n${ATTACHMENT_DATA_NOTE}` : hostPrompt, input.context);
187
+ log('debug', 'agent:prompt', 'System prompt built', { turnId, promptLength: systemPrompt.length, hostPromptLength: hostPrompt.length });
150
188
  // Wall-clock deadline: abort the entire streamText call if it exceeds turnDeadlineMs.
151
189
  const turnStartedAt = Date.now();
152
190
  // A UI tool waiting on work it started stops in time for the model to answer.
153
191
  const uiWaitDeadline = () => turnStartedAt + turnDeadlineMs - UI_ANSWER_MARGIN_MS;
154
192
  const abortController = new AbortController();
155
- const deadlineTimer = setTimeout(() => abortController.abort('Turn deadline exceeded'), turnDeadlineMs);
193
+ // ─── Stopping (ADR-0252) ───────────────────────────────────────────────────
194
+ // The person's Stop, or a newer message superseding this turn, is kept by the
195
+ // realtime server; the watch asks for it for as long as the turn runs. A stop
196
+ // aborts the turn's signal with a typed reason, which is what everything that
197
+ // waits (the model stream, a UI action's answer, a job's outcome) already
198
+ // honours. What the turn had produced is then stored and announced (below).
199
+ const stopWatch = input.stopWatch ?? watchTurnStop(config.stops, turnId, input.userId);
200
+ /** Set when the turn begins its own end: a stop that lands after is not one (§2.4). */
201
+ let ending = false;
202
+ /** Kept back from a stopped turn's grace for storing and announcing it. */
203
+ const STOP_STORE_MARGIN_MS = 3_000;
204
+ /** Kept back from a stopped turn's store for announcing it. */
205
+ const STOP_ANNOUNCE_MARGIN_MS = 1_000;
206
+ /** The least any step of the stop path is given, however little is left. */
207
+ const STOP_STEP_FLOOR_MS = 250;
208
+ let stopGraceMs = config.stopGraceMs ?? DEFAULT_STOP_GRACE_MS;
209
+ // The stop path's own signal: the turn's is aborted by the time anything needs one.
210
+ let graceSignal = null;
211
+ const stopState = {
212
+ reason: () => stopOf(abortController.signal)?.reason ?? null,
213
+ graceSignal: () => (graceSignal ??= AbortSignal.timeout(stopGraceMs + 250)),
214
+ get graceMs() {
215
+ return stopGraceMs;
216
+ },
217
+ };
218
+ /** Abort the turn on its stop path: what it had produced is stored, then announced. */
219
+ const stopTurn = (stop) => {
220
+ if (ending || abortController.signal.aborted)
221
+ return;
222
+ // Never longer than this process has left, less what storing and announcing take.
223
+ const remaining = input.remainingMs?.();
224
+ if (remaining !== undefined)
225
+ stopGraceMs = Math.max(0, Math.min(stopGraceMs, remaining - STOP_STORE_MARGIN_MS));
226
+ graceSignal = AbortSignal.timeout(stopGraceMs + 250);
227
+ log('info', 'agent:turn', stop.reason === 'deadline' ? 'Turn ran out of time' : 'Turn asked to stop', {
228
+ turnId,
229
+ reason: stop.reason,
230
+ graceMs: stopGraceMs,
231
+ });
232
+ abortController.abort(stop);
233
+ };
234
+ stopWatch.onStop((record) => stopTurn(new TurnStopped(record.reason, record.at)));
235
+ // The turn's deadline ends it on the same path (ADR-0252 §6.4): what it did
236
+ // before it ran out of time is kept, marked, and the next turn is told.
237
+ const deadlineTimer = setTimeout(() => stopTurn(new TurnStopped('deadline', Date.now())), turnDeadlineMs);
156
238
  // Build tool execution context
157
239
  const toolCtx = {
158
240
  userId: input.userId,
@@ -163,18 +245,44 @@ export async function runAgentTurn(config, input) {
163
245
  apiSurface: config.apiSurface,
164
246
  abortSignal: abortController.signal,
165
247
  socketRoom,
248
+ stop: stopState,
166
249
  };
167
250
  // Track tool executions for emit events and persistence.
168
251
  // We capture toolCallId from the execute options (2nd argument).
169
252
  const executedToolResults = [];
170
253
  // The pictures results came with, by call: given to the model with that call's result (ui/answer-image.ts).
171
254
  const answerImages = new Map();
255
+ // The text results came with (a file's text, read by attachment_read), by call: the model's only, like the pictures.
256
+ const answerTexts = new Map();
257
+ // The worker's own record of the turn, kept as it runs: what a stopped turn
258
+ // is stored from (stop/partial.ts). The model stream's own results do not
259
+ // survive an abort, so nothing on the stop path reads them.
260
+ const recordedSteps = [];
261
+ const recordedCalls = new Map();
262
+ let streamedText = '';
263
+ /** Records a call as it arrives, and its result when it settles. */
264
+ const tracked = (toolCallId, toolName, callInput, run) => {
265
+ const call = { toolCallId, toolName, input: callInput, text: null, settled: Promise.resolve() };
266
+ recordedCalls.set(toolCallId, call);
267
+ const result = run().then((text) => {
268
+ call.text = text;
269
+ return text;
270
+ });
271
+ call.settled = result.then(() => undefined, () => undefined);
272
+ return result;
273
+ };
172
274
  // Per-tool invocation quotas — mandatory for side-effecting tools.
173
275
  // Quota is per-turn, held in this closure — never module scope. It spans
174
276
  // segments: rebuilding the tool set does not reset what was already used.
175
277
  const toolInvocationCounts = new Map();
176
- const DEFAULT_TOOL_QUOTA = 12; // matches maxRounds
278
+ const DEFAULT_TOOL_QUOTA = 12;
177
279
  const SIDE_EFFECT_TOOL_QUOTA = 2;
280
+ // A UI action is not counted (session 24611234: filling 12 slides of 3 values each
281
+ // ran one action 36 times, and a cap of 12 stopped it). What is refused is a loop:
282
+ // the same call with the same input, made again after it changed nothing three
283
+ // times in a row. Undo five times, or nudge a layer five times, is work: each changes the page.
284
+ const UNCHANGED_REPEATS = 3;
285
+ const uiRepeats = new Map();
178
286
  // What each call of the turn did: said back to the model before every step
179
287
  // after one did not succeed, so its reply matches it (ADR-0244 §2.5).
180
288
  const ledger = createTurnLedger();
@@ -206,6 +314,9 @@ export async function runAgentTurn(config, input) {
206
314
  const executeCall = async (t, rawArgs, toolUseId, uiSlot, approved) => {
207
315
  const startMs = Date.now();
208
316
  const isUI = t.kind === 'ui';
317
+ // A stopped turn starts nothing more.
318
+ if (stopState.reason())
319
+ return { text: resultText(stoppedNotRun(t.name)), ran: false };
209
320
  /**
210
321
  * A call refused before its tool ran: invalid input, over its quota, or
211
322
  * denied by policy. The model gets the reason; so does the client, as a
@@ -234,9 +345,10 @@ export async function runAgentTurn(config, input) {
234
345
  return notRun({ success: false, ...payload });
235
346
  };
236
347
  // 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.
348
+ // then for the outcome of work it started: for uiJobWaitMs at most, and
349
+ // never past uiWaitDeadline.
238
350
  const timeoutMs = isUI
239
- ? Math.max(toolTimeoutMs, uiResultTimeoutMs + 5_000, uiWaitDeadline() - Date.now() + uiResultTimeoutMs + 5_000)
351
+ ? Math.max(toolTimeoutMs, uiResultTimeoutMs + 5_000, Math.min(uiJobWaitMs, Math.max(0, uiWaitDeadline() - Date.now())) + uiResultTimeoutMs + 5_000)
240
352
  : toolTimeoutMs;
241
353
  // ── No changes while blind (ADR-0245 §2.5) ──
242
354
  // After an answer that came without the page's state, or no answer, only
@@ -289,23 +401,107 @@ export async function runAgentTurn(config, input) {
289
401
  }, rawArgs);
290
402
  }
291
403
  const args = validation.value;
404
+ // ── Attached files named in the call (ADR-0252 §2.13) ──
405
+ // Each must be a file of this turn's conversation, ready, and of a type the
406
+ // input takes, checked before anything is dispatched; the page then
407
+ // resolves it through its own host. An action declaring a file where none
408
+ // can be checked is not run at all.
409
+ const misplaced = misplacedAttachmentInputs(schema);
410
+ if (misplaced.length > 0) {
411
+ return refused({
412
+ error: `"${t.name}" declares an attached file at ${misplaced.join(', ')}, where none can be checked: it cannot be run with a file.`,
413
+ notRun: true,
414
+ }, args);
415
+ }
416
+ const namedFiles = attachmentIdsIn(schema, args);
417
+ const callFiles = new Map();
418
+ if (namedFiles.length > 0) {
419
+ if (!fileArea) {
420
+ return refused({ error: `"${t.name}" takes an attached file, and this assistant cannot use attached files.`, notRun: true }, args);
421
+ }
422
+ const ids = [...new Set(namedFiles.map((f) => f.id))];
423
+ try {
424
+ for (const ref of await fileArea.store.describe(ids, fileOwner, { signal: abortController.signal }))
425
+ callFiles.set(ref.id, ref);
426
+ }
427
+ catch (err) {
428
+ log('warn', 'agent:tool', 'Could not check the files a call names; it was not run', {
429
+ turnId,
430
+ toolName: t.name,
431
+ error: err instanceof Error ? err.message : String(err),
432
+ });
433
+ return refused({ error: `The files "${t.name}" names could not be checked just now. It was not run; try again.`, notRun: true }, args);
434
+ }
435
+ const missing = ids.filter((id) => !callFiles.has(id) || callFiles.get(id).removed);
436
+ if (missing.length > 0) {
437
+ return refused({
438
+ error: `${missing.map((id) => `"${id}"`).join(', ')} ${missing.length === 1 ? 'is not a file' : 'are not files'} of this conversation that can be used. ${ATTACHMENT_LIST_HINT}`,
439
+ notRun: true,
440
+ }, args);
441
+ }
442
+ const pending = ids.filter((id) => callFiles.get(id).pending);
443
+ if (pending.length > 0) {
444
+ return refused({
445
+ error: `${pending.map((id) => `"${id}"`).join(', ')} ${pending.length === 1 ? 'is' : 'are'} still being checked, and can be used once that is done. It was not run.`,
446
+ notRun: true,
447
+ }, args);
448
+ }
449
+ // The type each input takes, checked here as the page checks it (OUI spec §7.3.11).
450
+ const wrongType = namedFiles.filter((f) => !acceptsMediaType(f.input, callFiles.get(f.id).mediaType));
451
+ if (wrongType.length > 0) {
452
+ return refused({
453
+ error: wrongType
454
+ .map((f) => {
455
+ const ref = callFiles.get(f.id);
456
+ return `${f.property}: ${attachmentNameForModel(ref.name)} (${f.id}) is ${ref.mediaType}; it takes ${f.input.mediaTypes.join(', ')}.`;
457
+ })
458
+ .join(' '),
459
+ notRun: true,
460
+ }, args);
461
+ }
462
+ }
292
463
  // Told to the model with the result, so it sends the value itself next time (below).
293
464
  const readFromText = validation.coerced ?? [];
465
+ // The call as a repeat is judged by: its name and its exact input. Set for a UI action that changes the page.
466
+ let repeatKey = null;
294
467
  // ── Quota enforcement ──
295
468
  // 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).
469
+ // backend side-effect cap does not apply to them (ADR-0209 D5). They are
470
+ // bounded by the turn's steps and deadline; only a repeated identical call is refused.
297
471
  const currentCount = toolInvocationCounts.get(t.name) ?? 0;
298
472
  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);
473
+ if (isUI) {
474
+ // A UI action that only reads (a room's inspect and query, ADR-0244 §2.2)
475
+ // changes nothing however often it is called.
476
+ if (!reads) {
477
+ let input;
478
+ try {
479
+ input = canonicalJson(args);
480
+ }
481
+ catch {
482
+ input = JSON.stringify(args);
483
+ }
484
+ repeatKey = `${t.name} ${input}`;
485
+ const unchanged = uiRepeats.get(repeatKey)?.unchanged ?? 0;
486
+ if (unchanged >= UNCHANGED_REPEATS) {
487
+ return refused({
488
+ error: `"${t.name}" with exactly this input has changed nothing the last ${unchanged} times it ran in this turn. ` +
489
+ 'Running it again would change nothing again: read the page for why, then change the input or stop.',
490
+ repeatedCall: true,
491
+ }, args);
492
+ }
493
+ }
494
+ }
495
+ else {
496
+ const quota = isSideEffecting ? SIDE_EFFECT_TOOL_QUOTA : DEFAULT_TOOL_QUOTA;
497
+ if (currentCount >= quota) {
498
+ return refused({
499
+ error: `Tool "${t.name}" has reached its maximum invocation quota of ${quota} for this turn. Please proceed without calling it again.`,
500
+ quotaExceeded: true,
501
+ }, args);
502
+ }
503
+ toolInvocationCounts.set(t.name, currentCount + 1);
307
504
  }
308
- toolInvocationCounts.set(t.name, currentCount + 1);
309
505
  // ── Policy enforcement ──
310
506
  // A deny always wins, over an approval too (ADR-0228 §2.1).
311
507
  const requirement = approvalRequirement(t);
@@ -319,6 +515,7 @@ export async function runAgentTurn(config, input) {
319
515
  turnId,
320
516
  hasSideEffects: isSideEffecting,
321
517
  toolKind: isUI ? 'ui' : 'backend',
518
+ ...(t.toolClass ? { toolClass: t.toolClass } : {}),
322
519
  effect: requirement.effect,
323
520
  destructive: requirement.destructive,
324
521
  });
@@ -338,12 +535,15 @@ export async function runAgentTurn(config, input) {
338
535
  }
339
536
  if ((requirement.required || policyRequiresApproval) && !approved) {
340
537
  ledger.record({ tool: t.name, ok: false, error: 'Not run: it waits for the user’s approval on the card' });
341
- return { text: await requestApproval(t, args, toolUseId, requirement), ran: false };
538
+ return { text: await requestApproval(t, args, toolUseId, requirement, callFiles), ran: false };
342
539
  }
343
540
  if (t.name === UI_DESCRIBE_TOOL)
344
541
  uiCounts.describes++;
345
542
  else if (t.name === UI_READ_TOOL)
346
543
  uiCounts.reads++;
544
+ // It reads what the client sent, and touches no page.
545
+ else if (t.name === KNOWLEDGE_TOOL)
546
+ uiCounts.guides++;
347
547
  else if (isUI)
348
548
  uiCounts.acts++;
349
549
  log('info', 'agent:tool', 'Tool call started', {
@@ -384,6 +584,11 @@ export async function runAgentTurn(config, input) {
384
584
  if (timer)
385
585
  clearTimeout(timer);
386
586
  }
587
+ // A host tool that the stop cut short failed with whatever its abort threw:
588
+ // its result says what happened instead. (A UI tool says so itself.)
589
+ if (!isUI && stopState.reason() && result && typeof result === 'object' && 'success' in result && !result.success) {
590
+ result = stoppedWhileRunning(t.name);
591
+ }
387
592
  const durationMs = Date.now() - startMs;
388
593
  // Normalize result — built-in tools (present_options, etc.) return the
389
594
  // raw payload directly, while schema and UI tools return { success, data, error }.
@@ -423,10 +628,25 @@ export async function runAgentTurn(config, input) {
423
628
  ok: resultSuccess,
424
629
  ...(resultSuccess ? {} : { error: isWrapped && result.error ? result.error : 'it did not succeed' }),
425
630
  });
631
+ // Did this run change the page? Not when it failed, when the page says it changed no row,
632
+ // or when the page's state is what it was after the same call last ran.
633
+ if (repeatKey) {
634
+ const answer = (modelPayload ?? {});
635
+ const state = answer.state === undefined ? null : JSON.stringify(answer.state);
636
+ const changed = answer.result?.changed;
637
+ const held = uiRepeats.get(repeatKey);
638
+ const noChange = !resultSuccess ||
639
+ changed === false ||
640
+ (Array.isArray(changed) && changed.length === 0) ||
641
+ (state !== null && held?.lastState === state);
642
+ uiRepeats.set(repeatKey, { unchanged: noChange ? (held?.unchanged ?? 0) + 1 : 0, lastState: state });
643
+ }
426
644
  // A picture in the result is the model's to look at with this call's
427
645
  // result, and nothing else's: it is not in the text above, the event or the record.
428
646
  if (isWrapped && result.image)
429
647
  answerImages.set(toolUseId, result.image);
648
+ if (isWrapped && typeof result.modelText === 'string')
649
+ answerTexts.set(toolUseId, result.modelText);
430
650
  return { text: JSON.stringify(modelPayload ?? { error: 'no result' }), ran: true };
431
651
  };
432
652
  /**
@@ -436,13 +656,15 @@ export async function runAgentTurn(config, input) {
436
656
  const toModelOutput = ({ toolCallId, output }) => {
437
657
  const text = typeof output === 'string' ? output : JSON.stringify(output ?? null);
438
658
  const image = answerImages.get(toolCallId);
439
- if (!image)
659
+ const fileText = answerTexts.get(toolCallId);
660
+ if (!image && fileText === undefined)
440
661
  return { type: 'text', value: text };
441
662
  return {
442
663
  type: 'content',
443
664
  value: [
444
665
  { type: 'text', text },
445
- { type: 'file', mediaType: image.mediaType, data: { type: 'data', data: image.base64 } },
666
+ ...(fileText !== undefined ? [{ type: 'text', text: fileText }] : []),
667
+ ...(image ? [{ type: 'file', mediaType: image.mediaType, data: { type: 'data', data: image.base64 } }] : []),
446
668
  ],
447
669
  };
448
670
  };
@@ -451,8 +673,9 @@ export async function runAgentTurn(config, input) {
451
673
  * stores it as a pending approval, shows the card, and tells the model only
452
674
  * that it is waiting. The model never sees a token.
453
675
  */
454
- const requestApproval = async (t, args, toolUseId, requirement) => {
455
- const preview = buildApprovalPreview(t, args);
676
+ const requestApproval = async (t, args, toolUseId, requirement, files) => {
677
+ // A file is shown by its name and size; the approval stays bound to its id (ADR-0252 §2.13).
678
+ const preview = buildApprovalPreview(t, args, files);
456
679
  approvalHold = { approvalId: toolUseId, title: preview.title };
457
680
  const refuse = (why) => {
458
681
  approvalHold = null;
@@ -526,12 +749,14 @@ export async function runAgentTurn(config, input) {
526
749
  // one response may get through them in any order (ui-sequence.ts). The place
527
750
  // is given up if the call never reaches the page, and otherwise held until
528
751
  // its action settles.
529
- const execute = async (rawArgs, options) => {
752
+ const execute = (rawArgs, options) => {
530
753
  const toolUseId = options?.toolCallId ?? `tool_${Date.now()}`;
531
754
  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;
755
+ return tracked(toolUseId, t.name, rawArgs, async () => {
756
+ const { text } = await executeCall(t, rawArgs, toolUseId, uiSlot).finally(() => uiSlot?.release());
757
+ executedToolResults.push({ toolCallId: toolUseId, toolName: t.name, result: text });
758
+ return text;
759
+ });
535
760
  };
536
761
  // A call that needs approval says so in its description, in place of any
537
762
  // "confirm first" instruction: the card is the confirmation.
@@ -555,6 +780,7 @@ export async function runAgentTurn(config, input) {
555
780
  resultTimeoutMs: uiResultTimeoutMs,
556
781
  maxObservationChars: config.ui.maxObservationChars,
557
782
  waitDeadline: uiWaitDeadline,
783
+ jobWaitMs: uiJobWaitMs,
558
784
  currentPage: () => currentPage,
559
785
  sequence: uiSequence,
560
786
  held: heldDefinitions,
@@ -591,9 +817,10 @@ export async function runAgentTurn(config, input) {
591
817
  // with the id of an action of the page, is withheld (ADR-0209 D6): for a
592
818
  // UI-only agent, the page is the authority.
593
819
  function hostTools(ui) {
820
+ const sdkOwned = new Set(fileTools.map((t) => t.name));
594
821
  if (!ui)
595
- return config.tools.tools;
596
- const taken = (name) => ui.actions.has(name) || name === UI_ACT_TOOL || name === UI_DESCRIBE_TOOL || name === UI_READ_TOOL;
822
+ return [...config.tools.tools.filter((t) => !sdkOwned.has(t.name)), ...fileTools];
823
+ const taken = (name) => sdkOwned.has(name) || ui.actions.has(name) || name === UI_ACT_TOOL || name === UI_DESCRIBE_TOOL || name === UI_READ_TOOL;
597
824
  const withheld = config.tools.tools.filter((t) => taken(t.name)).map((t) => t.name);
598
825
  if (withheld.length > 0) {
599
826
  log('error', 'agent:ui', 'Host tools collide with UI tools or action ids; the host tools are withheld this turn', {
@@ -601,7 +828,7 @@ export async function runAgentTurn(config, input) {
601
828
  withheld,
602
829
  });
603
830
  }
604
- return config.tools.tools.filter((t) => !taken(t.name));
831
+ return [...config.tools.tools.filter((t) => !taken(t.name)), ...fileTools];
605
832
  }
606
833
  /** Every tool a call can name: the host's, and the page's actions. What an approved call is looked up in. */
607
834
  function callableTools() {
@@ -631,42 +858,55 @@ export async function runAgentTurn(config, input) {
631
858
  required: ['action'],
632
859
  additionalProperties: false,
633
860
  }),
634
- execute: async (rawArgs, options) => {
861
+ execute: (rawArgs, options) => {
635
862
  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;
863
+ return tracked(toolUseId, UI_ACT_TOOL, rawArgs, () => act(rawArgs, toolUseId));
667
864
  },
668
865
  toModelOutput,
669
866
  });
867
+ async function act(rawArgs, toolUseId) {
868
+ const { action, input: actionInput } = (rawArgs ?? {});
869
+ // Taken as the call arrives, like any UI call (ui-sequence.ts), and
870
+ // given up if it never reaches the page.
871
+ const uiSlot = uiSequence.reserve();
872
+ const notAnAction = (error) => {
873
+ uiSlot.release();
874
+ ledger.record({ tool: typeof action === 'string' && action ? action : UI_ACT_TOOL, ok: false, error });
875
+ const text = JSON.stringify({ success: false, notRun: true, error });
876
+ executedToolResults.push({ toolCallId: toolUseId, toolName: UI_ACT_TOOL, result: text });
877
+ return text;
878
+ };
879
+ if (typeof action !== 'string' || !action)
880
+ return notAnAction(`${UI_ACT_TOOL} needs "action": an action id from the page's index.`);
881
+ if (stepActions.forced && action !== stepActions.forced) {
882
+ return notAnAction(`This step runs "${stepActions.forced}" and nothing else: call ${UI_ACT_TOOL} with that action.`);
883
+ }
884
+ if (stepActions.only && !stepActions.only.has(action)) {
885
+ return notAnAction(`"${action}" is not available at this step.`);
886
+ }
887
+ // Looked up in the page as it is now: an earlier action of this response may have changed it.
888
+ const ui = currentUI();
889
+ const tool = ui?.actions.get(action);
890
+ if (!tool) {
891
+ const ids = ui ? [...ui.actions.keys()] : [];
892
+ const near = ids.filter((id) => id.includes(action) || action.includes(id)).slice(0, 8);
893
+ return notAnAction(`"${action}" is not an action of the page as it is now.` +
894
+ (near.length ? ` Did you mean: ${near.join(', ')}?` : ' Read the page’s index for what it offers; an action is named by its id there.'));
895
+ }
896
+ const { text } = await executeCall(tool, actionInput ?? {}, toolUseId, uiSlot).finally(() => uiSlot.release());
897
+ executedToolResults.push({ toolCallId: toolUseId, toolName: UI_ACT_TOOL, result: text });
898
+ return text;
899
+ }
900
+ }
901
+ /** A tool the model is given, as a turn policy sees it: its name, kind and class. */
902
+ function describeStepTool(name) {
903
+ if (name === UI_ACT_TOOL || name === UI_DESCRIBE_TOOL || name === UI_READ_TOOL)
904
+ return { name, kind: 'ui' };
905
+ // The SDK's own file tools take their names before the host's (hostTools).
906
+ const registered = fileTools.find((t) => t.name === name) ?? config.tools.tools.find((t) => t.name === name);
907
+ if (!registered)
908
+ return { name, kind: 'builtin' };
909
+ return { name, kind: registered.kind === 'ui' ? 'ui' : 'backend', ...(registered.toolClass ? { toolClass: registered.toolClass } : {}) };
670
910
  }
671
911
  function assembleTools() {
672
912
  const ui = currentUI();
@@ -678,6 +918,9 @@ export async function runAgentTurn(config, input) {
678
918
  tools[UI_DESCRIBE_TOOL] = toAiTool(ui.describe);
679
919
  tools[UI_READ_TOOL] = toAiTool(ui.read);
680
920
  }
921
+ // What the prompt leaves out of the client's knowledge, read when it is needed.
922
+ if (knowledge && !tools[KNOWLEDGE_TOOL])
923
+ tools[KNOWLEDGE_TOOL] = toAiTool(knowledgeTool(knowledge));
681
924
  return tools;
682
925
  }
683
926
  /**
@@ -830,6 +1073,13 @@ export async function runAgentTurn(config, input) {
830
1073
  // only when the page changes. `cacheReadTokens` in the usage below is how you
831
1074
  // tell caching is still working.
832
1075
  const cachePoint = config.promptCacheBreakpoint;
1076
+ // A stop asked for while the turn waited to start (in a queue, or for the
1077
+ // turns before it to be stored) is heard first: nothing runs, and the turn
1078
+ // stores only that it was stopped.
1079
+ await stopWatch.checked();
1080
+ const stoppedBeforeStart = stopOf(abortController.signal);
1081
+ if (stoppedBeforeStart)
1082
+ return await finishStopped(stoppedBeforeStart, { expired: null, continued: null });
833
1083
  // ─── The turn after an approval decision (ADR-0228 §2.2.5) ────────────────
834
1084
  const continued = input.approval ? await continueApproval(input.approval) : null;
835
1085
  // ─── Calls still stored as waiting, whose approval expired undecided ──────
@@ -846,7 +1096,7 @@ export async function runAgentTurn(config, input) {
846
1096
  /** Calls beyond the newest few are left: a store call per waiting call, on every turn, has to stay small. */
847
1097
  const MAX_WAITING_SETTLED = 5;
848
1098
  async function settleExpiredWaiting() {
849
- const settled = { outcomes: [], persisted: [], claimed: [], undecided: 0, approved: 0 };
1099
+ const settled = { outcomes: [], persisted: [], claimed: [], undecided: 0, approved: 0, withdrawn: 0 };
850
1100
  const store = config.approvals;
851
1101
  if (!store?.settleExpired)
852
1102
  return settled;
@@ -870,8 +1120,15 @@ export async function runAgentTurn(config, input) {
870
1120
  continue;
871
1121
  // The user had approved it and it was never run, or nobody decided: the result says which.
872
1122
  const wasApproved = answer.decided === 'approved';
873
- const content = notRunResult(wasApproved ? approvedExpiredMarker() : approvalMarker('expired', false));
874
- if (wasApproved)
1123
+ // Withdrawn (a new message, or its turn stopped) is said as that, not as time running out (ADR-0252 §2.6).
1124
+ const content = notRunResult(answer.withdrawn
1125
+ ? withdrawnMarker(answer.withdrawn, wasApproved)
1126
+ : wasApproved
1127
+ ? approvedExpiredMarker()
1128
+ : approvalMarker('expired', false));
1129
+ if (answer.withdrawn)
1130
+ settled.withdrawn += 1;
1131
+ else if (wasApproved)
875
1132
  settled.approved += 1;
876
1133
  else
877
1134
  settled.undecided += 1;
@@ -885,12 +1142,17 @@ export async function runAgentTurn(config, input) {
885
1142
  turnId,
886
1143
  approvalId: w.approvalId,
887
1144
  decided: wasApproved ? 'approved' : null,
1145
+ ...(answer.withdrawn ? { withdrawn: answer.withdrawn } : {}),
888
1146
  stored: answer.outcome === 'claimed',
889
1147
  });
890
1148
  }
891
1149
  return settled;
892
1150
  }
893
1151
  const expired = await settleExpiredWaiting();
1152
+ // Stopped while the approved call ran, or while the store was asked: the model is not called.
1153
+ const stoppedBeforeModel = stopOf(abortController.signal);
1154
+ if (stoppedBeforeModel)
1155
+ return await finishStopped(stoppedBeforeModel, { expired, continued });
894
1156
  // Convert history to AI SDK CoreMessage format. The page the user is on
895
1157
  // travels with their message, not in the system prompt: it changes on every
896
1158
  // page, and in the system prompt it would invalidate the cached prefix. An
@@ -899,15 +1161,48 @@ export async function runAgentTurn(config, input) {
899
1161
  // The date and time on the user's clock travel with their message too: a
900
1162
  // model has no clock, and the changing minute must stay out of the cached
901
1163
  // system prompt (prompt/clock.ts).
1164
+ const turnFilesGiven = !!fileArea && (input.attachments?.length ?? 0) > 0;
902
1165
  const messages = [
903
1166
  ...withClock(withPageState(withNote(convertHistoryToCoreMessages(
904
1167
  // An approved call's outcome, and an expired one's, is a later result of the call already in the
905
1168
  // history: it takes that call's place.
906
- [...(input.history ?? []), ...expired.outcomes, ...(continued?.outcome ? [continued.outcome] : [])], [input.content, continued?.note].filter(Boolean).join('\n\n')),
1169
+ [...(input.history ?? []), ...expired.outcomes, ...(continued?.outcome ? [continued.outcome] : [])], [input.content, continued?.note].filter(Boolean).join('\n\n'),
1170
+ // The turn's own message, when the history already holds it: its files are given below,
1171
+ // each with its line, so the history's copy of the message does not name them again.
1172
+ turnFilesGiven && input.history?.at(-1)?.role === 'user' ? input.history.length - 1 : null),
907
1173
  // Said on the user's message whether or not the host's history already holds that message.
908
1174
  expired.outcomes.length > 0 ? expiredNote(expired) : null), client, config.ui?.maxObservationChars ?? DEFAULT_PAGE_STATE_CHARS, config.ui?.maxIndexChars ?? DEFAULT_INDEX_CHARS), input.context ?? null),
909
1175
  ...(continued?.messages ?? []),
910
1176
  ];
1177
+ // The turn's files go with its message, after everything said about it (ADR-0252 §2.11).
1178
+ // A file that cannot be had is named with why; the turn goes on with the others.
1179
+ if (fileArea && turnFilesGiven) {
1180
+ try {
1181
+ const files = await turnAttachmentParts(input.attachments, {
1182
+ store: fileArea.store,
1183
+ owner: fileOwner,
1184
+ guard: fileGuard,
1185
+ pdfAsDocument: fileArea.pdfAsDocument ?? false,
1186
+ perMessage: fileArea.perMessage,
1187
+ signal: abortController.signal,
1188
+ });
1189
+ if (files.failures.length > 0) {
1190
+ log('warn', 'agent:tool', 'Some of the message’s files could not be loaded; the turn goes on without them', { turnId, failures: files.failures });
1191
+ }
1192
+ messages.splice(0, messages.length, ...withTurnAttachments(messages, files));
1193
+ }
1194
+ catch (err) {
1195
+ log('error', 'agent:tool', 'The message’s files could not be given; the turn goes on with their lines', {
1196
+ turnId,
1197
+ error: err instanceof Error ? err.message : String(err),
1198
+ });
1199
+ messages.splice(0, messages.length, ...withTurnAttachments(messages, {
1200
+ parts: [],
1201
+ notes: [withReferenceLines('', input.attachments), 'These files could not be loaded with this message.'],
1202
+ failures: [],
1203
+ }));
1204
+ }
1205
+ }
911
1206
  // The system prompt goes in `instructions`, NOT in `messages`.
912
1207
  //
913
1208
  // ai v7 rejects a system-role entry in `messages` outright —
@@ -964,7 +1259,7 @@ export async function runAgentTurn(config, input) {
964
1259
  isEnabled: true,
965
1260
  functionId: `agent-turn:${turnId}`,
966
1261
  },
967
- prepareStep: async ({ steps }) => {
1262
+ prepareStep: async ({ steps, messages }) => {
968
1263
  // The policy speaks of the page's actions by their ids, as it always
969
1264
  // has: a `ui_act` call is shown to it as the action it ran, and the
970
1265
  // actions are among the tool names it may choose from.
@@ -980,6 +1275,11 @@ export async function runAgentTurn(config, input) {
980
1275
  })),
981
1276
  turnClass,
982
1277
  allToolNames: [...Object.keys(aiTools), ...actionIds],
1278
+ // What each tool is, so a policy can choose by class (the attachment tools are `attachment`).
1279
+ allTools: [
1280
+ ...Object.keys(aiTools).map((name) => describeStepTool(name)),
1281
+ ...actionIds.map((name) => ({ name, kind: 'ui' })),
1282
+ ],
983
1283
  });
984
1284
  // What it constrained, in the model's terms: an action is run through
985
1285
  // `ui_act`, which holds the step to the actions the policy allowed.
@@ -1006,14 +1306,37 @@ export async function runAgentTurn(config, input) {
1006
1306
  // without a note sets the plain instructions back.
1007
1307
  // So does the turn's record, once a call has not succeeded.
1008
1308
  const notes = [note, forced ? `This step: call ${UI_ACT_TOOL} with action "${forced}".` : null, ledger.note()].filter((n) => !!n);
1309
+ // The page's state on the newest answer only: an earlier page is not the page now.
1310
+ const trimmed = messages ? withNewestPageStateOnly(messages) : null;
1311
+ if (trimmed)
1312
+ pageStatesNotRepeated += trimmed.replaced;
1313
+ // The files this step would carry, within the turn's and the conversation's caps (ADR-0252 §2.11).
1314
+ const leftOutNow = fileGuard.beforeStep();
1315
+ if (leftOutNow.length > 0) {
1316
+ log('info', 'agent:tool', 'Files left out of the next step to keep within the cap', {
1317
+ turnId,
1318
+ attachments: leftOutNow.map((p) => ({ id: p.attachmentId, kind: p.kind, estimatedTokens: p.tokens })),
1319
+ });
1320
+ }
1321
+ const leftOut = fileGuard.leftOutParts();
1322
+ const stepMessages = leftOut.length > 0 && messages ? withoutLeftOut(trimmed?.messages ?? messages, leftOut) : trimmed?.messages;
1009
1323
  return {
1010
1324
  ...constraints,
1325
+ ...(stepMessages ? { messages: stepMessages } : {}),
1011
1326
  instructions: notes.length
1012
1327
  ? [instructions, ...notes.map((content) => ({ role: 'system', content }))]
1013
1328
  : instructions,
1014
1329
  };
1015
1330
  },
1016
- onStepEnd: async ({ text, toolCalls }) => {
1331
+ onStepEnd: async ({ text, toolCalls, usage: stepUsage }) => {
1332
+ recordedSteps.push({
1333
+ text: text ?? '',
1334
+ toolCalls: (toolCalls ?? []).map((tc) => ({ toolCallId: tc.toolCallId, toolName: tc.toolName, input: tc.input })),
1335
+ inputTokens: stepUsage?.inputTokens ?? 0,
1336
+ outputTokens: stepUsage?.outputTokens ?? 0,
1337
+ cacheReadTokens: stepUsage?.inputTokenDetails?.cacheReadTokens ?? 0,
1338
+ cacheWriteTokens: stepUsage?.inputTokenDetails?.cacheWriteTokens ?? 0,
1339
+ });
1017
1340
  log('info', 'agent:llm', 'Step completed', {
1018
1341
  turnId,
1019
1342
  turnClass,
@@ -1024,31 +1347,53 @@ export async function runAgentTurn(config, input) {
1024
1347
  });
1025
1348
  },
1026
1349
  });
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);
1350
+ // A stop aborts the stream. However that surfaces, as a throw here or as the
1351
+ // stream simply ending, the turn then goes to its stop path, which reads
1352
+ // none of the stream's own results: they reject on an abort.
1353
+ try {
1354
+ for await (const textPart of result.textStream) {
1355
+ streamedText += textPart;
1356
+ tokenBuffer += textPart;
1357
+ if (tokenBuffer.length >= COALESCE_CHARS) {
1358
+ await flushTokenBuffer();
1359
+ }
1360
+ else if (!flushTimer) {
1361
+ flushTimer = setTimeout(() => flushTokenBuffer(), COALESCE_MS);
1362
+ }
1034
1363
  }
1364
+ await flushTokenBuffer();
1365
+ if (stopOf(abortController.signal))
1366
+ break;
1367
+ // After the text stream completes, all promised properties resolve.
1368
+ const steps = await result.steps;
1369
+ const segmentUsage = await result.usage;
1370
+ const response = await result.response;
1371
+ allSteps.push(...steps);
1372
+ usageTotals.inputTokens += segmentUsage?.inputTokens ?? 0;
1373
+ usageTotals.outputTokens += segmentUsage?.outputTokens ?? 0;
1374
+ usageTotals.cacheReadTokens += segmentUsage?.inputTokenDetails?.cacheReadTokens ?? 0;
1375
+ usageTotals.cacheWriteTokens += segmentUsage?.inputTokenDetails?.cacheWriteTokens ?? 0;
1376
+ responseMessageCount += response.messages.length;
1377
+ conversation = [...conversation, ...response.messages];
1378
+ }
1379
+ catch (err) {
1380
+ if (!stopOf(abortController.signal))
1381
+ throw err;
1035
1382
  }
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
1383
  // The model's tools no longer change with the page (ADR-0245 §2.2): what
1049
1384
  // the page offers reaches it in each answer, so the turn runs as one segment.
1050
1385
  break;
1051
1386
  }
1387
+ // ─── Stopped, or ending ────────────────────────────────────────────────────
1388
+ // Decided here with nothing awaited in between: either the stop was heard
1389
+ // before this point and the turn takes its stop path, or the turn has begun
1390
+ // its own end and a stop that lands from now on is not one. Its watch is
1391
+ // closed, so it cannot abort the store or cause a second announcement.
1392
+ const stop = stopOf(abortController.signal);
1393
+ if (stop)
1394
+ return await finishStopped(stop, { expired, continued });
1395
+ ending = true;
1396
+ stopWatch.close();
1052
1397
  // ─── Finalize ──────────────────────────────────────────────────────────────
1053
1398
  const steps = allSteps;
1054
1399
  // Determine stop reason
@@ -1107,10 +1452,12 @@ export async function runAgentTurn(config, input) {
1107
1452
  // What bounds the model: the page's index as it reads it, against the most it is given
1108
1453
  // before the furthest surfaces are listed by id only.
1109
1454
  indexChars: indexText(client.page, Infinity).length,
1455
+ pageStatesNotRepeated,
1110
1456
  maxIndexChars: config.ui?.maxIndexChars ?? DEFAULT_INDEX_CHARS,
1111
1457
  },
1112
1458
  }
1113
1459
  : {}),
1460
+ ...(fileArea ? { attachments: fileGuard.usage() } : {}),
1114
1461
  });
1115
1462
  // The turn's messages, in the host's format, for it to store.
1116
1463
  const newMessages = [...expired.persisted, ...(continued?.persisted ?? []), ...convertResponseToTurnMessages({ steps }, executedToolResults)];
@@ -1149,12 +1496,172 @@ export async function runAgentTurn(config, input) {
1149
1496
  ...(expired.claimed.length > 0 ? { settledApprovals: expired.claimed } : {}),
1150
1497
  maxRoundsReached: steps.length >= maxRounds,
1151
1498
  stopReason,
1499
+ ...(fileArea ? { attachments: fileGuard.usage() } : {}),
1152
1500
  };
1153
1501
  }
1154
1502
  finally {
1155
1503
  clearTimeout(deadlineTimer);
1156
1504
  if (flushTimer)
1157
1505
  clearTimeout(flushTimer);
1506
+ // The turn is over, however it ended: nothing is listening for a stop.
1507
+ ending = true;
1508
+ stopWatch.close();
1509
+ }
1510
+ /**
1511
+ * The end of a stopped turn (ADR-0252 §2.4): settle what was in flight, store
1512
+ * what the turn had produced, then say so. Persist first, announce after, as
1513
+ * a turn that ends by itself does.
1514
+ *
1515
+ * Everything here happens after the turn's signal was aborted, so nothing
1516
+ * here is given that signal. The wait for calls in flight is bounded by the
1517
+ * grace; the store and the announcement are bounded by what the process has
1518
+ * left, when the host says (`remainingMs`). Without that, they take as long
1519
+ * as the host's store and the emit adapter's own timeout take.
1520
+ */
1521
+ async function finishStopped(stopped, earlier) {
1522
+ ending = true;
1523
+ stopWatch.close();
1524
+ // Text the turn produced and had not sent yet is still the person's to see.
1525
+ await flushTokenBuffer().catch(() => undefined);
1526
+ // A call that was out when the stop came takes one last look for its answer
1527
+ // (the UI tools do, on the stop's own signal). They are waited for, a little
1528
+ // past that grace, and no longer.
1529
+ const pending = [...recordedCalls.values()].filter((call) => call.text === null);
1530
+ if (pending.length > 0) {
1531
+ let timer;
1532
+ await Promise.race([
1533
+ Promise.all(pending.map((call) => call.settled)),
1534
+ new Promise((resolve) => {
1535
+ timer = setTimeout(resolve, stopGraceMs + 500);
1536
+ }),
1537
+ ]);
1538
+ if (timer)
1539
+ clearTimeout(timer);
1540
+ }
1541
+ // A stopped turn leaves no live card: an approval it asked for as the stop came
1542
+ // is withdrawn now, and a later turn records it as that (§2.6).
1543
+ const held = approvalHold;
1544
+ if (held && config.approvals?.withdraw) {
1545
+ try {
1546
+ await config.approvals.withdraw(held.approvalId, { userId: input.userId, conversationId: input.conversationId }, 'stopped');
1547
+ }
1548
+ catch (err) {
1549
+ log('warn', 'agent:tool', 'Could not withdraw the stopped turn’s approval; it lasts until its time limit', {
1550
+ turnId,
1551
+ approvalId: held.approvalId,
1552
+ error: err instanceof Error ? err.message : String(err),
1553
+ });
1554
+ }
1555
+ }
1556
+ const marker = { reason: stopped.reason, at: stopped.at };
1557
+ const newMessages = [
1558
+ ...(earlier.expired?.persisted ?? []),
1559
+ ...(earlier.continued?.persisted ?? []),
1560
+ ...stoppedTurnMessages({ steps: recordedSteps, calls: [...recordedCalls.values()], streamedText, marker }),
1561
+ ];
1562
+ const unsettled = [...recordedCalls.values()].filter((call) => call.text === null).map((call) => call.toolName);
1563
+ // The steps that ended reported their tokens; the step the stop cut short did not.
1564
+ const stepInputTokens = recordedSteps.map((step) => step.inputTokens);
1565
+ const promptTokens = stepInputTokens.reduce((sum, n) => sum + n, 0);
1566
+ const completionTokens = recordedSteps.reduce((sum, step) => sum + step.outputTokens, 0);
1567
+ const usage = {
1568
+ promptTokens,
1569
+ completionTokens,
1570
+ totalTokens: promptTokens + completionTokens,
1571
+ peakPromptTokens: stepInputTokens.length > 0 ? Math.max(...stepInputTokens) : 0,
1572
+ cacheReadTokens: recordedSteps.reduce((sum, step) => sum + step.cacheReadTokens, 0),
1573
+ cacheWriteTokens: recordedSteps.reduce((sum, step) => sum + step.cacheWriteTokens, 0),
1574
+ };
1575
+ const rounds = recordedSteps.length;
1576
+ log('info', 'agent:llm', 'Turn usage', {
1577
+ turnId,
1578
+ rounds,
1579
+ stopped: true,
1580
+ stopReason: stopped.reason,
1581
+ peakPromptTokens: usage.peakPromptTokens,
1582
+ promptTokens: usage.promptTokens,
1583
+ cacheReadTokens: usage.cacheReadTokens,
1584
+ cacheWriteTokens: usage.cacheWriteTokens,
1585
+ ...(unsettled.length > 0 ? { unsettledCalls: unsettled } : {}),
1586
+ ...(client ? { ui: { ...uiCounts, blindRefusals: sight.refusals(), stopped: sight.stopped() } } : {}),
1587
+ ...(fileArea ? { attachments: fileGuard.usage() } : {}),
1588
+ });
1589
+ // The store and the announcement each get a limit of their own when the host says how long
1590
+ // the process has left: a slow store must not run it to its hard end with nothing announced.
1591
+ /** Whether the work finished within its limit. */
1592
+ const within = async (what, keepBackMs, work) => {
1593
+ const remaining = input.remainingMs?.();
1594
+ if (remaining === undefined) {
1595
+ await work();
1596
+ return true;
1597
+ }
1598
+ const limitMs = Math.max(STOP_STEP_FLOOR_MS, remaining - keepBackMs);
1599
+ let timer;
1600
+ const timedOut = Symbol('timed out');
1601
+ const outcome = await Promise.race([
1602
+ work(),
1603
+ new Promise((resolve) => {
1604
+ timer = setTimeout(() => resolve(timedOut), limitMs);
1605
+ }),
1606
+ ]);
1607
+ if (timer)
1608
+ clearTimeout(timer);
1609
+ if (outcome === timedOut) {
1610
+ log('error', 'agent:turn', `The stopped turn's ${what} did not finish within its limit; the turn goes on without waiting for it`, {
1611
+ turnId,
1612
+ limitMs,
1613
+ remainingMs: remaining,
1614
+ });
1615
+ return false;
1616
+ }
1617
+ return true;
1618
+ };
1619
+ // Whether what the turn had produced was stored: a deadline stop that could not store it is
1620
+ // not a stopped turn but one that ran out of time with nothing kept (ADR-0252 §6.4).
1621
+ let stored = true;
1622
+ if (config.beforeTurnComplete) {
1623
+ try {
1624
+ // Kept back: time to announce after it.
1625
+ stored = await within('store', STOP_ANNOUNCE_MARGIN_MS, () => config.beforeTurnComplete({ rounds, usage, newMessages, stopped: marker }));
1626
+ }
1627
+ catch (err) {
1628
+ stored = false;
1629
+ log('error', 'agent:turn', 'beforeTurnComplete failed; the stopped turn is announced without it', {
1630
+ turnId,
1631
+ error: err instanceof Error ? err.message : String(err),
1632
+ });
1633
+ }
1634
+ }
1635
+ try {
1636
+ await within('announcement', STOP_STEP_FLOOR_MS, () => config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TURN_COMPLETE, {
1637
+ turnId,
1638
+ rounds,
1639
+ usage,
1640
+ stopReason: stopped.reason,
1641
+ timestamp: Date.now(),
1642
+ }));
1643
+ }
1644
+ catch (err) {
1645
+ log('error', 'agent:turn', 'The stopped turn could not be announced', { turnId, error: err instanceof Error ? err.message : String(err) });
1646
+ }
1647
+ log('info', 'agent:turn', 'Turn stopped', {
1648
+ turnId,
1649
+ conversationId: input.conversationId,
1650
+ stopReason: stopped.reason,
1651
+ totalRounds: rounds,
1652
+ newMessageCount: newMessages.length,
1653
+ });
1654
+ return {
1655
+ rounds,
1656
+ usage,
1657
+ newMessages,
1658
+ ...(earlier.expired && earlier.expired.claimed.length > 0 ? { settledApprovals: earlier.expired.claimed } : {}),
1659
+ maxRoundsReached: false,
1660
+ stopReason: stopped.reason,
1661
+ stopped: marker,
1662
+ ...(stored ? {} : { stored: false }),
1663
+ ...(fileArea ? { attachments: fileGuard.usage() } : {}),
1664
+ };
1158
1665
  }
1159
1666
  }
1160
1667
  /** Put a note for the model on the turn's user message, after what the user said. */
@@ -1162,8 +1669,7 @@ function withNote(messages, note) {
1162
1669
  const last = messages[messages.length - 1];
1163
1670
  if (!note || !last || last.role !== 'user')
1164
1671
  return messages;
1165
- const text = typeof last.content === 'string' ? last.content : last.content.map((p) => ('text' in p ? p.text : '')).join('');
1166
- return [...messages.slice(0, -1), { role: 'user', content: text ? `${text}\n\n${note}` : note }];
1672
+ return [...messages.slice(0, -1), withUserText(last, note)];
1167
1673
  }
1168
1674
  /**
1169
1675
  * Put the client's page on the user's message: what it offers, as an index of
@@ -1187,10 +1693,7 @@ function withPageState(messages, client, maxChars, maxIndexChars) {
1187
1693
  ...(notShown.length ? [`Not shown: ${notShown.join(' ')}`] : []),
1188
1694
  '</page_state>',
1189
1695
  ].join('\n');
1190
- const text = typeof last.content === 'string'
1191
- ? last.content
1192
- : last.content.map((p) => ('text' in p ? p.text : '')).join('');
1193
- return [...messages.slice(0, -1), { role: 'user', content: `${text}\n\n${pageState}` }];
1696
+ return [...messages.slice(0, -1), withUserText(last, pageState)];
1194
1697
  }
1195
1698
  /**
1196
1699
  * Convert our TurnHistoryMessage[] to AI SDK CoreMessage[].
@@ -1201,7 +1704,9 @@ function withPageState(messages, client, maxChars, maxIndexChars) {
1201
1704
  * AI_MissingToolResultsError crashes when persistence has gaps (e.g., stopWhen
1202
1705
  * triggered before a tool result was persisted).
1203
1706
  */
1204
- function convertHistoryToCoreMessages(history, currentContent) {
1707
+ function convertHistoryToCoreMessages(history, currentContent,
1708
+ /** The history's copy of the turn's own message, whose files the turn gives with their lines. */
1709
+ turnMessageAt = null) {
1205
1710
  const messages = [];
1206
1711
  // First pass: collect all tool_call_ids that have results in history
1207
1712
  // AND all tool call IDs from assistant messages (to validate results)
@@ -1244,13 +1749,20 @@ function convertHistoryToCoreMessages(history, currentContent) {
1244
1749
  for (let idx = 0; idx < history.length; idx++) {
1245
1750
  const msg = history[idx];
1246
1751
  if (msg.role === 'user') {
1247
- messages.push({ role: 'user', content: msg.content });
1752
+ // A message's files are in history as their reference lines, never their content (ADR-0252 §2.11).
1753
+ messages.push({ role: 'user', content: idx === turnMessageAt ? msg.content : withReferenceLines(msg.content, msg.attachments ?? undefined) });
1248
1754
  }
1249
1755
  else if (msg.role === 'assistant') {
1250
- if (msg.content || msg.tool_calls?.length) {
1756
+ // A stopped turn's last message says so after its text (ADR-0252 §2.3).
1757
+ // A message that has only that carries the line as its whole text: an
1758
+ // empty text block is refused by a model provider.
1759
+ const stoppedLine = msg.stopped ? turnStoppedNote(msg.stopped) : null;
1760
+ const said = msg.content && msg.content.trim() ? msg.content : null;
1761
+ const text = [said, stoppedLine && !said?.includes(stoppedLine) ? stoppedLine : null].filter(Boolean).join('\n\n');
1762
+ if (text || msg.tool_calls?.length) {
1251
1763
  const parts = [];
1252
- if (msg.content) {
1253
- parts.push({ type: 'text', text: msg.content });
1764
+ if (text) {
1765
+ parts.push({ type: 'text', text });
1254
1766
  }
1255
1767
  if (msg.tool_calls?.length) {
1256
1768
  for (const tc of msg.tool_calls) {