@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.
- package/README.md +57 -2
- package/dist/approvals/client.d.ts +8 -1
- package/dist/approvals/client.d.ts.map +1 -1
- package/dist/approvals/client.js +10 -0
- package/dist/approvals/client.js.map +1 -1
- package/dist/approvals/continuation.d.ts +13 -1
- package/dist/approvals/continuation.d.ts.map +1 -1
- package/dist/approvals/continuation.js +35 -0
- package/dist/approvals/continuation.js.map +1 -1
- package/dist/approvals/preview.d.ts +2 -2
- package/dist/approvals/preview.d.ts.map +1 -1
- package/dist/approvals/preview.js +20 -3
- package/dist/approvals/preview.js.map +1 -1
- package/dist/attachments/content.d.ts +68 -0
- package/dist/attachments/content.d.ts.map +1 -0
- package/dist/attachments/content.js +318 -0
- package/dist/attachments/content.js.map +1 -0
- package/dist/attachments/guard.d.ts +69 -0
- package/dist/attachments/guard.d.ts.map +1 -0
- package/dist/attachments/guard.js +86 -0
- package/dist/attachments/guard.js.map +1 -0
- package/dist/attachments/limits.d.ts +46 -0
- package/dist/attachments/limits.d.ts.map +1 -0
- package/dist/attachments/limits.js +64 -0
- package/dist/attachments/limits.js.map +1 -0
- package/dist/attachments/store.d.ts +85 -0
- package/dist/attachments/store.d.ts.map +1 -0
- package/dist/attachments/store.js +2 -0
- package/dist/attachments/store.js.map +1 -0
- package/dist/attachments/tools.d.ts +12 -0
- package/dist/attachments/tools.d.ts.map +1 -0
- package/dist/attachments/tools.js +216 -0
- package/dist/attachments/tools.js.map +1 -0
- package/dist/authz/tool-policy.d.ts +6 -0
- package/dist/authz/tool-policy.d.ts.map +1 -1
- package/dist/authz/tool-policy.js.map +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/lambda/handler.d.ts +2 -2
- package/dist/lambda/handler.d.ts.map +1 -1
- package/dist/lambda/handler.js +3 -2
- package/dist/lambda/handler.js.map +1 -1
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +613 -101
- package/dist/orchestrator.js.map +1 -1
- package/dist/prompt/clock.d.ts +0 -17
- package/dist/prompt/clock.d.ts.map +1 -1
- package/dist/prompt/clock.js +19 -2
- package/dist/prompt/clock.js.map +1 -1
- package/dist/prompt/user-text.d.ts +19 -0
- package/dist/prompt/user-text.d.ts.map +1 -0
- package/dist/prompt/user-text.js +20 -0
- package/dist/prompt/user-text.js.map +1 -0
- package/dist/runtime/turn-runner.d.ts +29 -2
- package/dist/runtime/turn-runner.d.ts.map +1 -1
- package/dist/runtime/turn-runner.js +118 -9
- package/dist/runtime/turn-runner.js.map +1 -1
- package/dist/runtime/types.d.ts +84 -6
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/stop/partial.d.ts +49 -0
- package/dist/stop/partial.d.ts.map +1 -0
- package/dist/stop/partial.js +73 -0
- package/dist/stop/partial.js.map +1 -0
- package/dist/stop/results.d.ts +20 -0
- package/dist/stop/results.d.ts.map +1 -0
- package/dist/stop/results.js +46 -0
- package/dist/stop/results.js.map +1 -0
- package/dist/stop/turn-stop.d.ts +114 -0
- package/dist/stop/turn-stop.d.ts.map +1 -0
- package/dist/stop/turn-stop.js +159 -0
- package/dist/stop/turn-stop.js.map +1 -0
- package/dist/tools/input-validation.d.ts.map +1 -1
- package/dist/tools/input-validation.js +3 -0
- package/dist/tools/input-validation.js.map +1 -1
- package/dist/tools/types.d.ts +19 -0
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/tools/types.js.map +1 -1
- package/dist/turn-policy.d.ts +14 -0
- package/dist/turn-policy.d.ts.map +1 -1
- package/dist/turn-policy.js.map +1 -1
- package/dist/types.d.ts +84 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/knowledge.d.ts +28 -2
- package/dist/ui/knowledge.d.ts.map +1 -1
- package/dist/ui/knowledge.js +117 -13
- package/dist/ui/knowledge.js.map +1 -1
- package/dist/ui/newest-page-state.d.ts +25 -0
- package/dist/ui/newest-page-state.d.ts.map +1 -0
- package/dist/ui/newest-page-state.js +91 -0
- package/dist/ui/newest-page-state.js.map +1 -0
- package/dist/ui/ui-tools.d.ts +20 -2
- package/dist/ui/ui-tools.d.ts.map +1 -1
- package/dist/ui/ui-tools.js +65 -6
- package/dist/ui/ui-tools.js.map +1 -1
- package/package.json +6 -6
package/dist/orchestrator.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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;
|
|
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,
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
752
|
+
const execute = (rawArgs, options) => {
|
|
530
753
|
const toolUseId = options?.toolCallId ?? `tool_${Date.now()}`;
|
|
531
754
|
const uiSlot = isUI ? uiSequence.reserve() : undefined;
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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:
|
|
861
|
+
execute: (rawArgs, options) => {
|
|
635
862
|
const toolUseId = options?.toolCallId ?? `tool_${Date.now()}`;
|
|
636
|
-
|
|
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
|
-
|
|
874
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1253
|
-
parts.push({ type: 'text', text
|
|
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) {
|