@ouispec/agent-worker 0.1.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 (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -0
  3. package/dist/approvals/client.d.ts +29 -0
  4. package/dist/approvals/client.d.ts.map +1 -0
  5. package/dist/approvals/client.js +50 -0
  6. package/dist/approvals/client.js.map +1 -0
  7. package/dist/approvals/continuation.d.ts +28 -0
  8. package/dist/approvals/continuation.d.ts.map +1 -0
  9. package/dist/approvals/continuation.js +43 -0
  10. package/dist/approvals/continuation.js.map +1 -0
  11. package/dist/approvals/preview.d.ts +15 -0
  12. package/dist/approvals/preview.d.ts.map +1 -0
  13. package/dist/approvals/preview.js +33 -0
  14. package/dist/approvals/preview.js.map +1 -0
  15. package/dist/approvals/requirement.d.ts +15 -0
  16. package/dist/approvals/requirement.d.ts.map +1 -0
  17. package/dist/approvals/requirement.js +26 -0
  18. package/dist/approvals/requirement.js.map +1 -0
  19. package/dist/authz/tool-policy.d.ts +63 -0
  20. package/dist/authz/tool-policy.d.ts.map +1 -0
  21. package/dist/authz/tool-policy.js +23 -0
  22. package/dist/authz/tool-policy.js.map +1 -0
  23. package/dist/container/server.d.ts +24 -0
  24. package/dist/container/server.d.ts.map +1 -0
  25. package/dist/container/server.js +119 -0
  26. package/dist/container/server.js.map +1 -0
  27. package/dist/emit/http-adapter.d.ts +15 -0
  28. package/dist/emit/http-adapter.d.ts.map +1 -0
  29. package/dist/emit/http-adapter.js +35 -0
  30. package/dist/emit/http-adapter.js.map +1 -0
  31. package/dist/emit/types.d.ts +9 -0
  32. package/dist/emit/types.d.ts.map +1 -0
  33. package/dist/emit/types.js +7 -0
  34. package/dist/emit/types.js.map +1 -0
  35. package/dist/events/http-waiter.d.ts +19 -0
  36. package/dist/events/http-waiter.d.ts.map +1 -0
  37. package/dist/events/http-waiter.js +20 -0
  38. package/dist/events/http-waiter.js.map +1 -0
  39. package/dist/index.d.ts +60 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +45 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/lambda/handler.d.ts +38 -0
  44. package/dist/lambda/handler.d.ts.map +1 -0
  45. package/dist/lambda/handler.js +26 -0
  46. package/dist/lambda/handler.js.map +1 -0
  47. package/dist/model.d.ts +33 -0
  48. package/dist/model.d.ts.map +1 -0
  49. package/dist/model.js +15 -0
  50. package/dist/model.js.map +1 -0
  51. package/dist/openapi/document.d.ts +87 -0
  52. package/dist/openapi/document.d.ts.map +1 -0
  53. package/dist/openapi/document.js +117 -0
  54. package/dist/openapi/document.js.map +1 -0
  55. package/dist/openapi/executor.d.ts +51 -0
  56. package/dist/openapi/executor.d.ts.map +1 -0
  57. package/dist/openapi/executor.js +140 -0
  58. package/dist/openapi/executor.js.map +1 -0
  59. package/dist/openapi/index.d.ts +14 -0
  60. package/dist/openapi/index.d.ts.map +1 -0
  61. package/dist/openapi/index.js +9 -0
  62. package/dist/openapi/index.js.map +1 -0
  63. package/dist/openapi/loader.d.ts +81 -0
  64. package/dist/openapi/loader.d.ts.map +1 -0
  65. package/dist/openapi/loader.js +361 -0
  66. package/dist/openapi/loader.js.map +1 -0
  67. package/dist/openapi/x-agent.d.ts +34 -0
  68. package/dist/openapi/x-agent.d.ts.map +1 -0
  69. package/dist/openapi/x-agent.js +102 -0
  70. package/dist/openapi/x-agent.js.map +1 -0
  71. package/dist/orchestrator.d.ts +12 -0
  72. package/dist/orchestrator.d.ts.map +1 -0
  73. package/dist/orchestrator.js +1019 -0
  74. package/dist/orchestrator.js.map +1 -0
  75. package/dist/prompt/builder.d.ts +29 -0
  76. package/dist/prompt/builder.d.ts.map +1 -0
  77. package/dist/prompt/builder.js +129 -0
  78. package/dist/prompt/builder.js.map +1 -0
  79. package/dist/prompt/index.d.ts +15 -0
  80. package/dist/prompt/index.d.ts.map +1 -0
  81. package/dist/prompt/index.js +13 -0
  82. package/dist/prompt/index.js.map +1 -0
  83. package/dist/prompt/rules/base.d.ts +6 -0
  84. package/dist/prompt/rules/base.d.ts.map +1 -0
  85. package/dist/prompt/rules/base.js +38 -0
  86. package/dist/prompt/rules/base.js.map +1 -0
  87. package/dist/prompt/rules/communication.d.ts +7 -0
  88. package/dist/prompt/rules/communication.d.ts.map +1 -0
  89. package/dist/prompt/rules/communication.js +53 -0
  90. package/dist/prompt/rules/communication.js.map +1 -0
  91. package/dist/prompt/rules/tool-strategy.d.ts +6 -0
  92. package/dist/prompt/rules/tool-strategy.d.ts.map +1 -0
  93. package/dist/prompt/rules/tool-strategy.js +42 -0
  94. package/dist/prompt/rules/tool-strategy.js.map +1 -0
  95. package/dist/prompt/rules/ui-control.d.ts +10 -0
  96. package/dist/prompt/rules/ui-control.d.ts.map +1 -0
  97. package/dist/prompt/rules/ui-control.js +44 -0
  98. package/dist/prompt/rules/ui-control.js.map +1 -0
  99. package/dist/prompt/types.d.ts +85 -0
  100. package/dist/prompt/types.d.ts.map +1 -0
  101. package/dist/prompt/types.js +10 -0
  102. package/dist/prompt/types.js.map +1 -0
  103. package/dist/realtime/long-poll.d.ts +21 -0
  104. package/dist/realtime/long-poll.d.ts.map +1 -0
  105. package/dist/realtime/long-poll.js +36 -0
  106. package/dist/realtime/long-poll.js.map +1 -0
  107. package/dist/runtime/config.d.ts +4 -0
  108. package/dist/runtime/config.d.ts.map +1 -0
  109. package/dist/runtime/config.js +29 -0
  110. package/dist/runtime/config.js.map +1 -0
  111. package/dist/runtime/turn-runner.d.ts +32 -0
  112. package/dist/runtime/turn-runner.d.ts.map +1 -0
  113. package/dist/runtime/turn-runner.js +216 -0
  114. package/dist/runtime/turn-runner.js.map +1 -0
  115. package/dist/runtime/types.d.ts +244 -0
  116. package/dist/runtime/types.d.ts.map +1 -0
  117. package/dist/runtime/types.js +2 -0
  118. package/dist/runtime/types.js.map +1 -0
  119. package/dist/testing/desk-events.d.ts +12 -0
  120. package/dist/testing/desk-events.d.ts.map +1 -0
  121. package/dist/testing/desk-events.js +62 -0
  122. package/dist/testing/desk-events.js.map +1 -0
  123. package/dist/testing/index.d.ts +8 -0
  124. package/dist/testing/index.d.ts.map +1 -0
  125. package/dist/testing/index.js +7 -0
  126. package/dist/testing/index.js.map +1 -0
  127. package/dist/testing/openapi-fixture.d.ts +47 -0
  128. package/dist/testing/openapi-fixture.d.ts.map +1 -0
  129. package/dist/testing/openapi-fixture.js +370 -0
  130. package/dist/testing/openapi-fixture.js.map +1 -0
  131. package/dist/tools/async-binding.d.ts +71 -0
  132. package/dist/tools/async-binding.d.ts.map +1 -0
  133. package/dist/tools/async-binding.js +104 -0
  134. package/dist/tools/async-binding.js.map +1 -0
  135. package/dist/tools/builtin-tools.d.ts +20 -0
  136. package/dist/tools/builtin-tools.d.ts.map +1 -0
  137. package/dist/tools/builtin-tools.js +108 -0
  138. package/dist/tools/builtin-tools.js.map +1 -0
  139. package/dist/tools/entity-tools.d.ts +6 -0
  140. package/dist/tools/entity-tools.d.ts.map +1 -0
  141. package/dist/tools/entity-tools.js +227 -0
  142. package/dist/tools/entity-tools.js.map +1 -0
  143. package/dist/tools/input-validation.d.ts +16 -0
  144. package/dist/tools/input-validation.d.ts.map +1 -0
  145. package/dist/tools/input-validation.js +75 -0
  146. package/dist/tools/input-validation.js.map +1 -0
  147. package/dist/tools/load-all-tools.d.ts +12 -0
  148. package/dist/tools/load-all-tools.d.ts.map +1 -0
  149. package/dist/tools/load-all-tools.js +42 -0
  150. package/dist/tools/load-all-tools.js.map +1 -0
  151. package/dist/tools/schema-loader.d.ts +24 -0
  152. package/dist/tools/schema-loader.d.ts.map +1 -0
  153. package/dist/tools/schema-loader.js +267 -0
  154. package/dist/tools/schema-loader.js.map +1 -0
  155. package/dist/tools/types.d.ts +85 -0
  156. package/dist/tools/types.d.ts.map +1 -0
  157. package/dist/tools/types.js +22 -0
  158. package/dist/tools/types.js.map +1 -0
  159. package/dist/turn-policy.d.ts +68 -0
  160. package/dist/turn-policy.d.ts.map +1 -0
  161. package/dist/turn-policy.js +21 -0
  162. package/dist/turn-policy.js.map +1 -0
  163. package/dist/types.d.ts +151 -0
  164. package/dist/types.d.ts.map +1 -0
  165. package/dist/types.js +2 -0
  166. package/dist/types.js.map +1 -0
  167. package/dist/ui/channel.d.ts +64 -0
  168. package/dist/ui/channel.d.ts.map +1 -0
  169. package/dist/ui/channel.js +81 -0
  170. package/dist/ui/channel.js.map +1 -0
  171. package/dist/ui/knowledge.d.ts +30 -0
  172. package/dist/ui/knowledge.d.ts.map +1 -0
  173. package/dist/ui/knowledge.js +66 -0
  174. package/dist/ui/knowledge.js.map +1 -0
  175. package/dist/ui/observations.d.ts +41 -0
  176. package/dist/ui/observations.d.ts.map +1 -0
  177. package/dist/ui/observations.js +236 -0
  178. package/dist/ui/observations.js.map +1 -0
  179. package/dist/ui/snapshot.d.ts +33 -0
  180. package/dist/ui/snapshot.d.ts.map +1 -0
  181. package/dist/ui/snapshot.js +75 -0
  182. package/dist/ui/snapshot.js.map +1 -0
  183. package/dist/ui/ui-sequence.d.ts +75 -0
  184. package/dist/ui/ui-sequence.d.ts.map +1 -0
  185. package/dist/ui/ui-sequence.js +73 -0
  186. package/dist/ui/ui-sequence.js.map +1 -0
  187. package/dist/ui/ui-tools.d.ts +59 -0
  188. package/dist/ui/ui-tools.d.ts.map +1 -0
  189. package/dist/ui/ui-tools.js +244 -0
  190. package/dist/ui/ui-tools.js.map +1 -0
  191. package/package.json +78 -0
@@ -0,0 +1,1019 @@
1
+ /**
2
+ * Agent Orchestrator — powered by Vercel AI SDK.
3
+ *
4
+ * The model is the host's: any `ai` library model, passed in configuration
5
+ * (ADR-0227 §2.2, "Model"). The core names no provider. The AI SDK handles:
6
+ * - each provider's wire format (internal, never exposed)
7
+ * - tool_use / tool_result pairing (automatic, no manual repair needed)
8
+ * - Multi-round tool loops (via stopWhen)
9
+ * - True token streaming with coalescing
10
+ * - Step callbacks for logging
11
+ * - Error categorization and retries
12
+ *
13
+ * Our value-add stays:
14
+ * - UI tools from the client's OUI surfaces, answered by the client (ADR-0209)
15
+ * - Schema-based tool loading
16
+ * - Persona/prompt compilation
17
+ * - Emit adapter (realtime socket streaming)
18
+ * - ApiSurface binding for tool execution
19
+ * - Configurable TurnPolicy for product-specific step control
20
+ */
21
+ import { streamText, dynamicTool, jsonSchema, isStepCount, hasToolCall } from 'ai';
22
+ import { AGENT_SOCKET_EVENTS, argsHash } from '@ouispec/agent-core';
23
+ import { defaultTurnPolicy } from './turn-policy.js';
24
+ import { evaluateToolPolicySafe } from './authz/tool-policy.js';
25
+ import { createToolInputValidator } from './tools/input-validation.js';
26
+ import { readClientSnapshot, capabilityFingerprint, withoutClientUI } from './ui/snapshot.js';
27
+ import { readClientKnowledge, withClientKnowledge } from './ui/knowledge.js';
28
+ import { buildUITools } from './ui/ui-tools.js';
29
+ import { createUISequence } from './ui/ui-sequence.js';
30
+ import { observationsText } from './ui/observations.js';
31
+ import { approvalRequirement, APPROVAL_TOOL_NOTE } from './approvals/requirement.js';
32
+ import { buildApprovalPreview, declaredTitle } from './approvals/preview.js';
33
+ import { approvalNote, ranNote, resolveContinuation, unavailableNote } from './approvals/continuation.js';
34
+ const DEFAULT_MAX_ROUNDS = 12;
35
+ const DEFAULT_MAX_TOKENS = 4096;
36
+ const DEFAULT_TEMPERATURE = 0.3;
37
+ const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
38
+ const DEFAULT_UI_RESULT_TIMEOUT_MS = 20_000;
39
+ /** Time left, before the turn's deadline, for the model to answer after waiting on a UI action's work. */
40
+ const UI_ANSWER_MARGIN_MS = 45_000;
41
+ const DEFAULT_PAGE_STATE_CHARS = 6_000;
42
+ /**
43
+ * Tools that hand the turn back to the user. The turn stops after any of them:
44
+ * the model must not carry on as if the user had already answered.
45
+ */
46
+ const USER_INPUT_TOOLS = ['present_options', 'confirm_action', 'request_user_decision'];
47
+ function log(level, ns, message, data) {
48
+ const entry = { level, ns, message, timestamp: new Date().toISOString(), ...data };
49
+ if (level === 'error')
50
+ console.error(JSON.stringify(entry));
51
+ else if (level === 'warn')
52
+ console.warn(JSON.stringify(entry));
53
+ else
54
+ console.log(JSON.stringify(entry));
55
+ }
56
+ /** A call refused before its tool ran. */
57
+ const notRun = (payload) => ({ text: JSON.stringify(payload), ran: false });
58
+ /**
59
+ * Run a complete agent turn using the Vercel AI SDK.
60
+ *
61
+ * A turn runs in one or more segments. Each segment is one `streamText` call
62
+ * with a fixed tool set. When a UI action changes what the client's page
63
+ * offers (navigating is the common case), the segment ends and the turn
64
+ * continues in a new one whose tools are the new page's (ADR-0209 D3). The
65
+ * round cap counts across segments.
66
+ */
67
+ export async function runAgentTurn(config, input) {
68
+ const maxRounds = config.maxToolRounds ?? DEFAULT_MAX_ROUNDS;
69
+ const maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
70
+ const temperature = config.temperature ?? DEFAULT_TEMPERATURE;
71
+ const toolTimeoutMs = config.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
72
+ const turnDeadlineMs = config.turnDeadlineMs ?? 240_000; // 4 min, below the 5 min Lambda ceiling
73
+ const retries = config.retries ?? 3;
74
+ const uiResultTimeoutMs = config.ui?.resultTimeoutMs ?? DEFAULT_UI_RESULT_TIMEOUT_MS;
75
+ if (!config.model) {
76
+ throw new Error('[agent-sdk] config.model is required: pass an `ai` library model (ADR-0227 §2.2)');
77
+ }
78
+ const { turnId, socketRoom } = input;
79
+ log('info', 'agent:turn', 'Turn started', {
80
+ turnId,
81
+ conversationId: input.conversationId,
82
+ userId: input.userId,
83
+ historyLength: input.history?.length ?? 0,
84
+ contentLength: input.content.length,
85
+ maxRounds,
86
+ maxTokens,
87
+ temperature,
88
+ });
89
+ // ─── The client's UI (ADR-0209 D1) ─────────────────────────────────────────
90
+ // The turn carries the surfaces of the client that sent it. They are the only
91
+ // source of UI tools, so the tools always describe the page the user is on.
92
+ const snapshot = readClientSnapshot(input.context ?? null);
93
+ if (snapshot && !config.ui) {
94
+ throw new Error('[agent-sdk] The turn carries UI surfaces but the worker has no UI action channel (config.ui): ' +
95
+ 'its UI actions could be sent but never answered.');
96
+ }
97
+ let currentSurfaces = snapshot?.surfaces ?? [];
98
+ // The turn's UI actions run one at a time, in the order the model called them,
99
+ // across every rebuild of the UI tools (ui-sequence.ts).
100
+ const uiSequence = createUISequence();
101
+ // The page's surfaces as the turn's snapshot gave them, under the client's hash for them.
102
+ if (snapshot)
103
+ uiSequence.record(snapshot.surfaces, snapshot.surfacesHash);
104
+ // Set when an answer shows the page now offers different actions; the
105
+ // current segment then ends and the next is built from these.
106
+ let pendingSurfaces = null;
107
+ // The knowledge the client sent for its page goes after the host's prompt.
108
+ const knowledge = readClientKnowledge(input.context ?? null);
109
+ log('info', 'agent:ui', 'UI surfaces for this turn', {
110
+ turnId,
111
+ clientSentSnapshot: snapshot !== null,
112
+ surfaceIds: currentSurfaces.map((s) => s.id),
113
+ actionCount: currentSurfaces.reduce((n, s) => n + s.actions.length, 0),
114
+ knowledgeEntries: knowledge?.entries.length ?? 0,
115
+ knowledgeWorkflows: knowledge?.workflows.length ?? 0,
116
+ });
117
+ // ─── Turn classification via policy ────────────────────────────────────────
118
+ const turnPolicy = config.turnPolicy ?? defaultTurnPolicy;
119
+ const historyForClassify = input.history?.map((m) => ({
120
+ role: m.role,
121
+ content: 'content' in m ? (m.content ?? '') : '',
122
+ })) ?? [];
123
+ const turnClass = turnPolicy.classifyTurn(input.content, historyForClassify);
124
+ // Let the policy perform side effects (e.g. deterministic navigation emit)
125
+ if (turnPolicy.onTurnClassified) {
126
+ await turnPolicy.onTurnClassified({
127
+ turnClass,
128
+ content: input.content,
129
+ emit: config.emit,
130
+ socketRoom,
131
+ });
132
+ }
133
+ log('info', 'agent:turn', 'Turn classified', { turnId, turnClass });
134
+ // Build system prompt: the host's, which never sees what the client sent for
135
+ // the worker itself, then the client's knowledge.
136
+ const hostPrompt = typeof config.systemPrompt === 'function'
137
+ ? config.systemPrompt({ userId: input.userId, accountId: input.accountId, context: withoutClientUI(input.context) })
138
+ : config.systemPrompt;
139
+ const systemPrompt = withClientKnowledge(hostPrompt, input.context);
140
+ log('debug', 'agent:prompt', 'System prompt built', { turnId, promptLength: systemPrompt.length });
141
+ // Wall-clock deadline: abort the entire streamText call if it exceeds turnDeadlineMs.
142
+ const turnStartedAt = Date.now();
143
+ // A UI tool waiting on work it started stops in time for the model to answer.
144
+ const uiWaitDeadline = () => turnStartedAt + turnDeadlineMs - UI_ANSWER_MARGIN_MS;
145
+ const abortController = new AbortController();
146
+ const deadlineTimer = setTimeout(() => abortController.abort('Turn deadline exceeded'), turnDeadlineMs);
147
+ // Build tool execution context
148
+ const toolCtx = {
149
+ userId: input.userId,
150
+ accountId: input.accountId,
151
+ turnId,
152
+ conversationId: input.conversationId,
153
+ userToken: input.userToken,
154
+ apiSurface: config.apiSurface,
155
+ abortSignal: abortController.signal,
156
+ socketRoom,
157
+ };
158
+ // Track tool executions for emit events and persistence.
159
+ // We capture toolCallId from the execute options (2nd argument).
160
+ const executedToolResults = [];
161
+ // Per-tool invocation quotas — mandatory for side-effecting tools.
162
+ // Quota is per-turn, held in this closure — never module scope. It spans
163
+ // segments: rebuilding the tool set does not reset what was already used.
164
+ const toolInvocationCounts = new Map();
165
+ const DEFAULT_TOOL_QUOTA = 12; // matches maxRounds
166
+ const SIDE_EFFECT_TOOL_QUOTA = 2;
167
+ // ─── Approvals (ADR-0228) ──────────────────────────────────────────────────
168
+ // The first call in a turn that needs the user's approval stops the turn:
169
+ // it is stored as a pending approval, the card is shown, and nothing else
170
+ // runs until they decide. Each call is approved separately.
171
+ let approvalHold = null;
172
+ // jsonSchema() carries no validator, so a call's arguments are whatever the
173
+ // model produced. Each tool's declared schema is compiled once per turn.
174
+ const validators = new WeakMap();
175
+ const validatorFor = (t) => {
176
+ let v = validators.get(t);
177
+ if (!v) {
178
+ // The schema the tool DECLARED, not the model-facing stand-in: a strict
179
+ // empty schema only gives the model a valid schema for a tool that
180
+ // declared no properties, and enforcing it would reject input such a
181
+ // tool has always received.
182
+ v = createToolInputValidator(t.inputSchema);
183
+ validators.set(t, v);
184
+ }
185
+ return v;
186
+ };
187
+ /**
188
+ * Runs one call of a tool: validation, quota, policy, approval, then the
189
+ * tool. `approved` is set only for the call a redeemed approval token names,
190
+ * in the turn after the user's decision.
191
+ */
192
+ const executeCall = async (t, rawArgs, toolUseId, uiSlot, approved) => {
193
+ const startMs = Date.now();
194
+ const isUI = t.kind === 'ui';
195
+ // A UI tool waits for the client's answer, which has its own deadline, and
196
+ // then for the outcome of work it started, until uiWaitDeadline.
197
+ const timeoutMs = isUI
198
+ ? Math.max(toolTimeoutMs, uiResultTimeoutMs + 5_000, uiWaitDeadline() - Date.now() + uiResultTimeoutMs + 5_000)
199
+ : toolTimeoutMs;
200
+ // ── Input validation ──
201
+ // Before quota and policy: an invalid call consumes no quota and never
202
+ // reaches the host. The model gets the errors so it can correct the call.
203
+ const validation = validatorFor(t)(rawArgs);
204
+ if (!validation.ok) {
205
+ log('warn', 'agent:tool', 'Tool call rejected: invalid input', {
206
+ turnId,
207
+ toolName: t.name,
208
+ errors: validation.errors,
209
+ });
210
+ return notRun({
211
+ success: false,
212
+ error: `Invalid input for "${t.name}": ${validation.errors.join('; ')}`,
213
+ invalidInput: true,
214
+ });
215
+ }
216
+ const args = validation.value;
217
+ // ── Quota enforcement ──
218
+ // UI actions run as the user in their own session (ADR-0182 §3), so the
219
+ // backend side-effect cap does not apply to them (ADR-0209 D5).
220
+ const currentCount = toolInvocationCounts.get(t.name) ?? 0;
221
+ const isSideEffecting = t.inputSchema?.sideEffects !== false; // fail-closed: undefined = side-effecting
222
+ const quota = !isUI && isSideEffecting ? SIDE_EFFECT_TOOL_QUOTA : DEFAULT_TOOL_QUOTA;
223
+ if (currentCount >= quota) {
224
+ return notRun({
225
+ success: false,
226
+ error: `Tool "${t.name}" has reached its maximum invocation quota of ${quota} for this turn. Please proceed without calling it again.`,
227
+ quotaExceeded: true,
228
+ });
229
+ }
230
+ toolInvocationCounts.set(t.name, currentCount + 1);
231
+ // ── Policy enforcement ──
232
+ // A deny always wins, over an approval too (ADR-0228 §2.1).
233
+ const requirement = approvalRequirement(t);
234
+ let policyRequiresApproval = false;
235
+ if (config.toolPolicy) {
236
+ const decision = await evaluateToolPolicySafe(config.toolPolicy, {
237
+ userId: input.userId,
238
+ accountId: input.accountId,
239
+ toolName: t.name,
240
+ args,
241
+ turnId,
242
+ hasSideEffects: isSideEffecting,
243
+ toolKind: isUI ? 'ui' : 'backend',
244
+ effect: requirement.effect,
245
+ destructive: requirement.destructive,
246
+ });
247
+ if (decision.action === 'deny') {
248
+ return notRun({
249
+ success: false,
250
+ error: decision.reason,
251
+ policyDenied: true,
252
+ });
253
+ }
254
+ policyRequiresApproval = decision.action === 'require_approval';
255
+ }
256
+ // ── Approval (ADR-0228) ──
257
+ // Checked and set with no await in between, so of the calls in one
258
+ // response exactly the first that needs approval stops the turn.
259
+ if (approvalHold && !approved) {
260
+ return notRun({
261
+ success: false,
262
+ notRun: true,
263
+ error: `Not run: this turn is waiting for the user's approval of "${approvalHold.title}". ` +
264
+ 'Nothing else runs until they decide; ask again afterwards if it is still needed.',
265
+ });
266
+ }
267
+ if ((requirement.required || policyRequiresApproval) && !approved) {
268
+ return { text: await requestApproval(t, args, toolUseId, requirement), ran: false };
269
+ }
270
+ log('info', 'agent:tool', 'Tool call started', {
271
+ turnId,
272
+ toolName: t.name,
273
+ kind: t.kind ?? 'backend',
274
+ input: args,
275
+ ...(approved ? { approvalId: approved.approvalId } : {}),
276
+ });
277
+ // Emit tool call started BEFORE execution
278
+ await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TOOL_CALL_STARTED, {
279
+ turnId,
280
+ toolUseId,
281
+ name: t.name,
282
+ input: args,
283
+ timestamp: startMs,
284
+ });
285
+ // Execute with timeout
286
+ let result;
287
+ let timer;
288
+ try {
289
+ result = await Promise.race([
290
+ t.execute(args, {
291
+ ...toolCtx,
292
+ toolCallId: toolUseId,
293
+ ...(uiSlot ? { uiSlot } : {}),
294
+ ...(approved ? { approval: approved } : {}),
295
+ }),
296
+ new Promise((_, reject) => {
297
+ timer = setTimeout(() => reject(new Error(`Tool ${t.name} timed out after ${timeoutMs}ms`)), timeoutMs);
298
+ }),
299
+ ]);
300
+ }
301
+ catch (err) {
302
+ result = { success: false, error: err instanceof Error ? err.message : String(err) };
303
+ }
304
+ finally {
305
+ if (timer)
306
+ clearTimeout(timer);
307
+ }
308
+ const durationMs = Date.now() - startMs;
309
+ // Normalize result — built-in tools (present_options, etc.) return the
310
+ // raw payload directly, while schema and UI tools return { success, data, error }.
311
+ const isWrapped = result && typeof result === 'object' && 'success' in result;
312
+ const resultData = isWrapped ? (result.data ?? result.error ?? null) : result;
313
+ const resultSuccess = isWrapped ? result.success : true;
314
+ // A failed call that still carries data (a UI action's page state) must
315
+ // not lose its error message on the way to the model.
316
+ const modelPayload = isWrapped && !result.success && result.data !== undefined && result.error
317
+ ? { error: result.error, ...result.data }
318
+ : resultData;
319
+ log('info', 'agent:tool', 'Tool call completed', {
320
+ turnId,
321
+ toolName: t.name,
322
+ success: resultSuccess,
323
+ durationMs,
324
+ ...(isUI && !resultSuccess ? { error: isWrapped ? result.error : undefined } : {}),
325
+ });
326
+ await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TOOL_CALL_COMPLETE, {
327
+ turnId,
328
+ toolUseId,
329
+ name: t.name,
330
+ result: modelPayload,
331
+ success: resultSuccess,
332
+ durationMs,
333
+ timestamp: Date.now(),
334
+ });
335
+ return { text: JSON.stringify(modelPayload ?? { error: 'no result' }), ran: true };
336
+ };
337
+ /**
338
+ * Stops the turn at a call that needs the user's approval (ADR-0228 §2.2.1):
339
+ * stores it as a pending approval, shows the card, and tells the model only
340
+ * that it is waiting. The model never sees a token.
341
+ */
342
+ const requestApproval = async (t, args, toolUseId, requirement) => {
343
+ const preview = buildApprovalPreview(t, args);
344
+ approvalHold = { approvalId: toolUseId, title: preview.title };
345
+ const refuse = (why) => {
346
+ approvalHold = null;
347
+ log('error', 'agent:tool', 'Approval could not be requested; the call was not run', { turnId, toolName: t.name, why });
348
+ return JSON.stringify({
349
+ success: false,
350
+ notRun: true,
351
+ error: `"${preview.title}" needs the user's approval, which could not be asked for (${why}). It was not run.`,
352
+ });
353
+ };
354
+ if (!config.approvals)
355
+ return refuse('this assistant has no approval store');
356
+ const expiresAt = Date.now() + requirement.ttlMs;
357
+ try {
358
+ const hash = await argsHash(args);
359
+ await config.approvals.create({
360
+ approvalId: toolUseId,
361
+ toolCallId: toolUseId,
362
+ conversationId: input.conversationId,
363
+ turnId,
364
+ userId: input.userId,
365
+ tool: t.name,
366
+ args,
367
+ argsHash: hash,
368
+ effect: requirement.effectName,
369
+ destructive: requirement.destructive,
370
+ argsSensitive: t.argsSensitive !== false,
371
+ expiresAt,
372
+ preview,
373
+ });
374
+ }
375
+ catch (err) {
376
+ return refuse(err instanceof Error ? err.message : String(err));
377
+ }
378
+ await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.APPROVAL_REQUIRED, {
379
+ turnId,
380
+ conversationId: input.conversationId,
381
+ approvalId: toolUseId,
382
+ tool: t.name,
383
+ effect: requirement.effectName,
384
+ destructive: requirement.destructive,
385
+ preview,
386
+ expiresAt,
387
+ timestamp: Date.now(),
388
+ });
389
+ log('info', 'agent:tool', 'Turn stopped for the user’s approval', {
390
+ turnId,
391
+ toolName: t.name,
392
+ approvalId: toolUseId,
393
+ effect: requirement.effectName,
394
+ destructive: requirement.destructive,
395
+ });
396
+ return JSON.stringify({
397
+ success: false,
398
+ awaitingApproval: true,
399
+ approvalId: toolUseId,
400
+ message: `Waiting for the user to approve "${preview.title}" on the approval card. It has not run. ` +
401
+ 'The turn ends here: it runs only if they approve it there, and the next turn tells you the outcome.',
402
+ });
403
+ };
404
+ // Convert a RegisteredTool to an AI SDK tool. Our tools have runtime-defined
405
+ // JSON Schemas — dynamicTool accepts raw JSON Schema via jsonSchema(), which
406
+ // is exactly our use case.
407
+ function toAiTool(t) {
408
+ const rawSchema = t.inputSchema && Object.keys(t.inputSchema.properties ?? {}).length > 0
409
+ ? t.inputSchema
410
+ : { type: 'object', properties: {}, additionalProperties: false };
411
+ const isUI = t.kind === 'ui';
412
+ // A UI call takes its place in the turn's UI order synchronously, as the
413
+ // model's call arrives: the checks and the emit below await, and calls from
414
+ // one response may get through them in any order (ui-sequence.ts). The place
415
+ // is given up if the call never reaches the page, and otherwise held until
416
+ // its action settles.
417
+ const execute = async (rawArgs, options) => {
418
+ const toolUseId = options?.toolCallId ?? `tool_${Date.now()}`;
419
+ const uiSlot = isUI ? uiSequence.reserve() : undefined;
420
+ const { text } = await executeCall(t, rawArgs, toolUseId, uiSlot).finally(() => uiSlot?.release());
421
+ executedToolResults.push({ toolCallId: toolUseId, toolName: t.name, result: text });
422
+ return text;
423
+ };
424
+ // A call that needs approval says so in its description, in place of any
425
+ // "confirm first" instruction: the card is the confirmation.
426
+ const description = approvalRequirement(t).required ? `${t.description}\n\n${APPROVAL_TOOL_NOTE}` : t.description;
427
+ return dynamicTool({
428
+ description,
429
+ inputSchema: jsonSchema(rawSchema),
430
+ execute,
431
+ });
432
+ }
433
+ // The tools for the current surfaces: the host's tools plus one UI tool per
434
+ // mounted action. A UI action keeps its name over a host tool of the same
435
+ // name (ADR-0209 D6): for a UI-only agent, the page is the authority.
436
+ function currentTools() {
437
+ const hostTools = config.tools.tools;
438
+ let uiTools = [];
439
+ if (config.ui && currentSurfaces.length > 0) {
440
+ const built = buildUITools(currentSurfaces, {
441
+ channel: config.ui.channel,
442
+ resultTimeoutMs: uiResultTimeoutMs,
443
+ maxObservationChars: config.ui.maxObservationChars,
444
+ waitDeadline: uiWaitDeadline,
445
+ currentSurfaces: () => currentSurfaces,
446
+ sequence: uiSequence,
447
+ onResult: (answer) => {
448
+ if (!answer.surfaces)
449
+ return;
450
+ if (capabilityFingerprint(answer.surfaces) !== capabilityFingerprint(currentSurfaces)) {
451
+ pendingSurfaces = answer.surfaces;
452
+ }
453
+ },
454
+ });
455
+ uiTools = built.tools;
456
+ for (const c of built.collisions) {
457
+ log('error', 'agent:ui', 'Two mounted surfaces declare the same action id; the first keeps it', {
458
+ turnId,
459
+ ...c,
460
+ });
461
+ }
462
+ }
463
+ const uiNames = new Set(uiTools.map((t) => t.name));
464
+ const withheld = hostTools.filter((t) => uiNames.has(t.name)).map((t) => t.name);
465
+ if (withheld.length > 0) {
466
+ log('error', 'agent:ui', 'UI action ids collide with host tools; the host tools are withheld this turn', {
467
+ turnId,
468
+ withheld,
469
+ });
470
+ }
471
+ return [...hostTools.filter((t) => !uiNames.has(t.name)), ...uiTools];
472
+ }
473
+ function assembleTools() {
474
+ const tools = {};
475
+ for (const t of currentTools())
476
+ tools[t.name] = toAiTool(t);
477
+ return tools;
478
+ }
479
+ /**
480
+ * The turn after the user's decision on an approval card (ADR-0228 §2.2.5):
481
+ * redeem the token and run exactly the stored call, or learn that they
482
+ * declined. What happened goes to the model as an `<approval>` note, and a
483
+ * call that was run as a tool call and its result, which are persisted too.
484
+ */
485
+ async function continueApproval(continuation) {
486
+ const outcome = await resolveContinuation(config.approvals, continuation, {
487
+ userId: input.userId,
488
+ conversationId: input.conversationId,
489
+ });
490
+ if (outcome.kind === 'note') {
491
+ log('info', 'agent:tool', 'Approval continuation ran nothing', { turnId, approvalId: continuation.approvalId, decision: continuation.decision });
492
+ return { note: outcome.note, messages: [], persisted: [] };
493
+ }
494
+ const { call } = outcome;
495
+ const tool = currentTools().find((t) => t.name === call.tool);
496
+ if (!tool) {
497
+ log('warn', 'agent:tool', 'Approved call is not available this turn; it was not run', { turnId, approvalId: call.approvalId, toolName: call.tool });
498
+ return { note: unavailableNote(call.tool), messages: [], persisted: [] };
499
+ }
500
+ const uiSlot = tool.kind === 'ui' ? uiSequence.reserve() : undefined;
501
+ const { text, ran } = await executeCall(tool, call.args, call.approvalId, uiSlot, {
502
+ approvalId: call.approvalId,
503
+ argsHash: call.argsHash,
504
+ }).finally(() => uiSlot?.release());
505
+ // An approved action that changed the page: the turn starts on the new one.
506
+ if (pendingSurfaces) {
507
+ currentSurfaces = pendingSurfaces;
508
+ pendingSurfaces = null;
509
+ }
510
+ const { title } = declaredTitle(tool);
511
+ const callId = `${call.approvalId}-approved`;
512
+ const refusal = ran ? null : JSON.parse(text).error ?? 'it was refused';
513
+ return {
514
+ note: ran ? ranNote(title) : approvalNote(`The user approved "${title}" on the approval card, but it did not run: ${refusal}`),
515
+ messages: [
516
+ { role: 'assistant', content: [{ type: 'tool-call', toolCallId: callId, toolName: tool.name, input: call.args }] },
517
+ { role: 'tool', content: [{ type: 'tool-result', toolCallId: callId, toolName: tool.name, output: { type: 'text', value: text } }] },
518
+ ],
519
+ persisted: [
520
+ { role: 'assistant', content: null, toolCalls: [{ id: callId, name: tool.name, arguments: call.args }] },
521
+ { role: 'tool', content: text, toolCallId: callId, name: tool.name },
522
+ ],
523
+ };
524
+ }
525
+ // Track cumulative text length emitted this turn.
526
+ let totalTextEmitted = 0;
527
+ // ─── Coalesce token deltas ─────────────────────────────────────────────────
528
+ // Buffer for 50ms or 32 chars, whichever comes first, to avoid one HTTP POST
529
+ // per token delta to the realtime server.
530
+ const COALESCE_MS = 50;
531
+ const COALESCE_CHARS = 32;
532
+ let tokenBuffer = '';
533
+ let flushTimer = null;
534
+ const flushTokenBuffer = async () => {
535
+ if (flushTimer) {
536
+ clearTimeout(flushTimer);
537
+ flushTimer = null;
538
+ }
539
+ if (tokenBuffer.length > 0) {
540
+ const text = tokenBuffer;
541
+ tokenBuffer = '';
542
+ totalTextEmitted += text.length;
543
+ await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TOKEN, {
544
+ turnId,
545
+ text,
546
+ timestamp: Date.now(),
547
+ });
548
+ }
549
+ };
550
+ // Run the multi-round generation
551
+ try {
552
+ // ─── Prompt cache breakpoints ──────────────────────────────────────────────
553
+ // Every round of every turn re-sent the same ~25k-token prefix at full price:
554
+ // the system prompt plus ~87 tool schemas, unchanged from the first call to
555
+ // the last. A five-round turn paid for it five times.
556
+ //
557
+ // Providers that cache by breakpoint (Bedrock, Anthropic) render the request
558
+ // as tools -> system -> messages, and a breakpoint caches the whole prefix up
559
+ // to itself. So a breakpoint at the end of the system block covers the tool
560
+ // definitions too, even where the provider offers no way to mark tools.
561
+ //
562
+ // What marks a breakpoint is provider-specific, so it is the host's to say
563
+ // (`promptCacheBreakpoint`, e.g. PROMPT_CACHE_BREAKPOINTS.bedrock). Without
564
+ // one, nothing is marked: a provider that caches on its own still does.
565
+ //
566
+ // The system prompt has to travel as a message rather than the top-level
567
+ // `system` field, because only a message carries `providerOptions`.
568
+ //
569
+ // Two breakpoints, both on content that is append-only:
570
+ // 1. system (+ tools) — identical for every round of a segment, and for
571
+ // every turn on the same page.
572
+ // 2. the end of the incoming history — stable across the rounds that follow
573
+ // it, since a round appends rather than rewrites.
574
+ //
575
+ // Caching is a PREFIX match: one byte earlier in the prefix invalidates
576
+ // everything after it. Nothing volatile may move ahead of these points — no
577
+ // timestamp or request id in the system prompt, and a stable tool order.
578
+ // A new segment changes the tool set, so it writes a new prefix; that happens
579
+ // only when the page changes. `cacheReadTokens` in the usage below is how you
580
+ // tell caching is still working.
581
+ const cachePoint = config.promptCacheBreakpoint;
582
+ // ─── The turn after an approval decision (ADR-0228 §2.2.5) ────────────────
583
+ const continued = input.approval ? await continueApproval(input.approval) : null;
584
+ // Convert history to AI SDK CoreMessage format. The page the user is on
585
+ // travels with their message, not in the system prompt: it changes on every
586
+ // page, and in the system prompt it would invalidate the cached prefix. An
587
+ // approved call that ran follows the user's message, as the call it was.
588
+ const messages = [
589
+ ...withPageState(convertHistoryToCoreMessages(input.history ?? [], [input.content, continued?.note].filter(Boolean).join('\n\n')), snapshot, config.ui?.maxObservationChars ?? DEFAULT_PAGE_STATE_CHARS),
590
+ ...(continued?.messages ?? []),
591
+ ];
592
+ // The system prompt goes in `instructions`, NOT in `messages`.
593
+ //
594
+ // ai v7 rejects a system-role entry in `messages` outright —
595
+ // AI_InvalidPromptError, "System messages are not allowed in the prompt or
596
+ // messages fields" — because `allowSystemInMessages` defaults to false. An
597
+ // earlier attempt at these breakpoints put it there and broke every turn.
598
+ //
599
+ // `instructions` takes `string | SystemModelMessage | SystemModelMessage[]`,
600
+ // and the message form carries `providerOptions`, so the breakpoint travels
601
+ // with it. (`system` is the deprecated alias for the same type.)
602
+ const instructions = {
603
+ role: 'system',
604
+ content: systemPrompt,
605
+ ...(cachePoint ? { providerOptions: cachePoint } : {}),
606
+ };
607
+ const withCachePoint = (msgs) => !cachePoint ? msgs : [
608
+ ...msgs.slice(0, -1),
609
+ ...msgs.slice(-1).map((m) => ({
610
+ ...m,
611
+ providerOptions: { ...(m.providerOptions ?? {}), ...cachePoint },
612
+ })),
613
+ ];
614
+ const allSteps = [];
615
+ let conversation = messages;
616
+ let segments = 0;
617
+ let responseMessageCount = 0;
618
+ const usageTotals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
619
+ for (;;) {
620
+ segments++;
621
+ const aiTools = assembleTools();
622
+ const previousSteps = [...allSteps];
623
+ log('info', 'agent:llm', 'Messages prepared', {
624
+ turnId,
625
+ segment: segments,
626
+ messageCount: conversation.length,
627
+ toolCount: Object.keys(aiTools).length,
628
+ });
629
+ const result = streamText({
630
+ model: config.model,
631
+ instructions,
632
+ messages: withCachePoint(conversation),
633
+ tools: aiTools,
634
+ maxOutputTokens: maxTokens,
635
+ maxRetries: retries,
636
+ abortSignal: abortController.signal,
637
+ stopWhen: [
638
+ isStepCount(maxRounds - allSteps.length),
639
+ ...USER_INPUT_TOOLS.map((name) => hasToolCall(name)),
640
+ // A call is waiting for the user's approval: the turn ends here (ADR-0228).
641
+ () => approvalHold !== null,
642
+ // The page now offers different actions: end this segment so the next
643
+ // one is built from them (ADR-0209 D3).
644
+ () => pendingSurfaces !== null,
645
+ ],
646
+ temperature,
647
+ telemetry: {
648
+ isEnabled: true,
649
+ functionId: `agent-turn:${turnId}`,
650
+ },
651
+ prepareStep: async ({ steps }) => {
652
+ const { note, ...constraints } = await turnPolicy.prepareStep({
653
+ // The policy counts steps across the whole turn, not per segment.
654
+ steps: [...previousSteps, ...steps],
655
+ turnClass,
656
+ allToolNames: Object.keys(aiTools),
657
+ });
658
+ // The policy's note goes after the system prompt, for this step only.
659
+ // ai carries an instructions override forward to later steps, so a step
660
+ // without a note sets the plain instructions back.
661
+ return {
662
+ ...constraints,
663
+ instructions: note ? [instructions, { role: 'system', content: note }] : instructions,
664
+ };
665
+ },
666
+ onStepEnd: async ({ text, toolCalls }) => {
667
+ log('info', 'agent:llm', 'Step completed', {
668
+ turnId,
669
+ turnClass,
670
+ segment: segments,
671
+ textLength: text?.length ?? 0,
672
+ toolCallCount: toolCalls?.length ?? 0,
673
+ totalTextEmitted,
674
+ });
675
+ },
676
+ });
677
+ for await (const textPart of result.textStream) {
678
+ tokenBuffer += textPart;
679
+ if (tokenBuffer.length >= COALESCE_CHARS) {
680
+ await flushTokenBuffer();
681
+ }
682
+ else if (!flushTimer) {
683
+ flushTimer = setTimeout(() => flushTokenBuffer(), COALESCE_MS);
684
+ }
685
+ }
686
+ await flushTokenBuffer();
687
+ // After the text stream completes, all promised properties resolve.
688
+ const steps = await result.steps;
689
+ const segmentUsage = await result.usage;
690
+ const response = await result.response;
691
+ allSteps.push(...steps);
692
+ usageTotals.inputTokens += segmentUsage?.inputTokens ?? 0;
693
+ usageTotals.outputTokens += segmentUsage?.outputTokens ?? 0;
694
+ usageTotals.cacheReadTokens += segmentUsage?.inputTokenDetails?.cacheReadTokens ?? 0;
695
+ usageTotals.cacheWriteTokens += segmentUsage?.inputTokenDetails?.cacheWriteTokens ?? 0;
696
+ responseMessageCount += response.messages.length;
697
+ conversation = [...conversation, ...response.messages];
698
+ const askedUser = steps.some((s) => s.toolCalls?.some((tc) => USER_INPUT_TOOLS.includes(tc.toolName)));
699
+ const next = pendingSurfaces;
700
+ if (next && !askedUser && !approvalHold && allSteps.length < maxRounds) {
701
+ log('info', 'agent:ui', 'The page changed; continuing the turn with its actions', {
702
+ turnId,
703
+ from: currentSurfaces.map((s) => s.id),
704
+ to: next.map((s) => s.id),
705
+ });
706
+ currentSurfaces = next;
707
+ pendingSurfaces = null;
708
+ continue;
709
+ }
710
+ if (next) {
711
+ // The turn ends here anyway; the next turn starts from the client's new snapshot.
712
+ currentSurfaces = next;
713
+ pendingSurfaces = null;
714
+ }
715
+ break;
716
+ }
717
+ // ─── Finalize ──────────────────────────────────────────────────────────────
718
+ const steps = allSteps;
719
+ // Determine stop reason
720
+ let stopReason = 'complete';
721
+ if (approvalHold) {
722
+ stopReason = 'awaiting_approval';
723
+ }
724
+ else if (steps.length >= maxRounds) {
725
+ stopReason = 'step_count';
726
+ }
727
+ else if (steps.some((s) => s.toolCalls?.some(tc => USER_INPUT_TOOLS.includes(tc.toolName)))) {
728
+ stopReason = 'present_options';
729
+ }
730
+ // deadline and token_budget are caught by the try/catch — they produce errors, not normal returns
731
+ // ─── Usage ─────────────────────────────────────────────────────────────────
732
+ // `promptTokens` is the SUM over rounds, and it was the only number reported.
733
+ // Read as a context measurement it is wrong by a factor of the round count: a
734
+ // five-round turn showed 128,808 tokens when no single call exceeded ~26,000,
735
+ // so the context ceiling fired on turns that were nowhere near the window and
736
+ // said nothing about the ones that were.
737
+ //
738
+ // Both numbers matter, and they answer different questions:
739
+ // peakPromptTokens — the largest single call. What the context window sees,
740
+ // and what the ceiling must be compared against.
741
+ // promptTokens — the sum. What the turn costs.
742
+ //
743
+ // cacheReadTokens/cacheWriteTokens are the caching gauge: a read of roughly
744
+ // the prefix size on every round after the first means the breakpoints above
745
+ // are holding. Reads at zero across a multi-round turn mean something
746
+ // volatile has moved into the prefix and the cache is being missed silently.
747
+ const stepInputTokens = steps.map((s) => s.usage?.inputTokens ?? 0);
748
+ const usage = {
749
+ promptTokens: usageTotals.inputTokens,
750
+ completionTokens: usageTotals.outputTokens,
751
+ totalTokens: usageTotals.inputTokens + usageTotals.outputTokens,
752
+ peakPromptTokens: stepInputTokens.length > 0 ? Math.max(...stepInputTokens) : 0,
753
+ cacheReadTokens: usageTotals.cacheReadTokens,
754
+ cacheWriteTokens: usageTotals.cacheWriteTokens,
755
+ };
756
+ log('info', 'agent:llm', 'Turn usage', {
757
+ turnId,
758
+ rounds: steps.length,
759
+ segments,
760
+ peakPromptTokens: usage.peakPromptTokens,
761
+ promptTokens: usage.promptTokens,
762
+ cacheReadTokens: usage.cacheReadTokens,
763
+ cacheWriteTokens: usage.cacheWriteTokens,
764
+ });
765
+ await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TURN_COMPLETE, {
766
+ turnId,
767
+ rounds: steps.length,
768
+ usage,
769
+ timestamp: Date.now(),
770
+ });
771
+ log('info', 'agent:turn', 'Turn completed', {
772
+ turnId,
773
+ conversationId: input.conversationId,
774
+ totalRounds: steps.length,
775
+ segments,
776
+ maxRoundsReached: steps.length >= maxRounds,
777
+ usage,
778
+ newMessageCount: responseMessageCount,
779
+ });
780
+ // Convert AI SDK response messages to our TurnMessage format for persistence
781
+ const newMessages = [...(continued?.persisted ?? []), ...convertResponseToTurnMessages({ steps }, executedToolResults)];
782
+ return {
783
+ rounds: steps.length,
784
+ usage,
785
+ newMessages,
786
+ maxRoundsReached: steps.length >= maxRounds,
787
+ stopReason,
788
+ };
789
+ }
790
+ finally {
791
+ clearTimeout(deadlineTimer);
792
+ if (flushTimer)
793
+ clearTimeout(flushTimer);
794
+ }
795
+ }
796
+ /**
797
+ * Put the client's page state on the user's message: which surfaces are
798
+ * mounted and their latest observations. The model reads it as part of what
799
+ * the user said, where it is true for this message only.
800
+ */
801
+ function withPageState(messages, snapshot, maxChars) {
802
+ if (!snapshot || snapshot.surfaces.length === 0)
803
+ return messages;
804
+ const last = messages[messages.length - 1];
805
+ if (!last || last.role !== 'user')
806
+ return messages;
807
+ const observations = observationsText(snapshot.observations, maxChars);
808
+ const pageState = [
809
+ '<page_state>',
810
+ `The user's screen offers: ${snapshot.surfaces.map((s) => `${s.name} (${s.id})`).join(', ')}.`,
811
+ `Current values: ${observations}`,
812
+ '</page_state>',
813
+ ].join('\n');
814
+ const text = typeof last.content === 'string'
815
+ ? last.content
816
+ : last.content.map((p) => ('text' in p ? p.text : '')).join('');
817
+ return [...messages.slice(0, -1), { role: 'user', content: `${text}\n\n${pageState}` }];
818
+ }
819
+ /**
820
+ * Convert our TurnHistoryMessage[] to AI SDK CoreMessage[].
821
+ * The AI SDK handles each provider's message format internally.
822
+ *
823
+ * DEFENSIVE: If an assistant message has tool_calls but a matching tool result
824
+ * is missing from history, we synthesize a placeholder result. This prevents
825
+ * AI_MissingToolResultsError crashes when persistence has gaps (e.g., stopWhen
826
+ * triggered before a tool result was persisted).
827
+ */
828
+ function convertHistoryToCoreMessages(history, currentContent) {
829
+ const messages = [];
830
+ // First pass: collect all tool_call_ids that have results in history
831
+ // AND all tool call IDs from assistant messages (to validate results)
832
+ const answeredToolCallIds = new Set();
833
+ const knownToolCallIds = new Set();
834
+ for (const msg of history) {
835
+ if (msg.role === 'tool' && msg.tool_call_id) {
836
+ answeredToolCallIds.add(msg.tool_call_id);
837
+ }
838
+ if (msg.role === 'assistant' && msg.tool_calls?.length) {
839
+ for (const tc of msg.tool_calls) {
840
+ knownToolCallIds.add(tc.id);
841
+ }
842
+ }
843
+ }
844
+ log('info', 'agent:llm', 'History conversion diagnostics', {
845
+ sdkVersion: '2.4.5-diag',
846
+ historyLength: history.length,
847
+ answeredToolCallIds: Array.from(answeredToolCallIds),
848
+ knownToolCallIds: Array.from(knownToolCallIds),
849
+ toolMessages: history.filter(m => m.role === 'tool').map(m => ({
850
+ tool_call_id: m.tool_call_id,
851
+ name: m.name,
852
+ inKnown: knownToolCallIds.has(m.tool_call_id ?? ''),
853
+ })),
854
+ });
855
+ for (let idx = 0; idx < history.length; idx++) {
856
+ const msg = history[idx];
857
+ if (msg.role === 'user') {
858
+ messages.push({ role: 'user', content: msg.content });
859
+ }
860
+ else if (msg.role === 'assistant') {
861
+ if (msg.content || msg.tool_calls?.length) {
862
+ const parts = [];
863
+ if (msg.content) {
864
+ parts.push({ type: 'text', text: msg.content });
865
+ }
866
+ if (msg.tool_calls?.length) {
867
+ for (const tc of msg.tool_calls) {
868
+ let toolName;
869
+ let args;
870
+ if (tc.function && typeof tc.function === 'object') {
871
+ toolName = tc.function.name;
872
+ try {
873
+ args = typeof tc.function.arguments === 'string'
874
+ ? JSON.parse(tc.function.arguments)
875
+ : tc.function.arguments;
876
+ }
877
+ catch {
878
+ args = {};
879
+ }
880
+ }
881
+ else {
882
+ const flat = tc;
883
+ toolName = flat.name;
884
+ try {
885
+ args = typeof flat.arguments === 'string'
886
+ ? JSON.parse(flat.arguments)
887
+ : flat.arguments ?? {};
888
+ }
889
+ catch {
890
+ args = {};
891
+ }
892
+ }
893
+ parts.push({ type: 'tool-call', toolCallId: tc.id, toolName, input: args });
894
+ }
895
+ }
896
+ messages.push({ role: 'assistant', content: parts });
897
+ // DEFENSIVE: synthesize missing tool results for any tool_calls
898
+ // that don't have a matching tool result in history.
899
+ if (msg.tool_calls?.length) {
900
+ for (const tc of msg.tool_calls) {
901
+ if (!answeredToolCallIds.has(tc.id)) {
902
+ const toolName = tc.function?.name ?? tc.name ?? 'unknown';
903
+ log('info', 'agent:llm', 'SYNTHESIZING tool result (missing from history)', {
904
+ toolCallId: tc.id,
905
+ toolName,
906
+ answeredIds: Array.from(answeredToolCallIds),
907
+ });
908
+ messages.push({
909
+ role: 'tool',
910
+ content: [{
911
+ type: 'tool-result',
912
+ toolCallId: tc.id,
913
+ toolName,
914
+ output: { type: 'text', value: JSON.stringify({ acknowledged: true }) },
915
+ }],
916
+ });
917
+ // Mark as answered so we don't double-synthesize
918
+ answeredToolCallIds.add(tc.id);
919
+ }
920
+ }
921
+ }
922
+ }
923
+ }
924
+ else if (msg.role === 'tool') {
925
+ // Skip tool results that are corrupted or orphaned:
926
+ // 1. Empty tool_call_id (persistence bug: toolCallId was undefined)
927
+ // 2. tool_call_id that doesn't match ANY known tool call in the conversation
928
+ // (persistence bug: fallback ID 'tool_${timestamp}' instead of real ID)
929
+ // The defensive synthesis above creates proper results for the real tool calls.
930
+ if (!msg.tool_call_id || !knownToolCallIds.has(msg.tool_call_id)) {
931
+ log('info', 'agent:llm', 'SKIPPING orphaned tool result', {
932
+ tool_call_id: msg.tool_call_id,
933
+ name: msg.name,
934
+ inKnown: msg.tool_call_id ? knownToolCallIds.has(msg.tool_call_id) : false,
935
+ });
936
+ continue;
937
+ }
938
+ log('info', 'agent:llm', 'KEEPING valid tool result', {
939
+ tool_call_id: msg.tool_call_id,
940
+ name: msg.name,
941
+ });
942
+ // Tool result output must use discriminated format: { type: 'text', value: string }
943
+ const outputValue = msg.content || 'acknowledged';
944
+ messages.push({
945
+ role: 'tool',
946
+ content: [{
947
+ type: 'tool-result',
948
+ toolCallId: msg.tool_call_id,
949
+ toolName: msg.name ?? 'unknown',
950
+ output: { type: 'text', value: outputValue },
951
+ }],
952
+ });
953
+ }
954
+ }
955
+ // Add current user message if not already at the end
956
+ const lastMsg = messages[messages.length - 1];
957
+ if (!lastMsg || lastMsg.role !== 'user') {
958
+ messages.push({ role: 'user', content: currentContent });
959
+ }
960
+ // Final diagnostics: summary of converted messages
961
+ log('info', 'agent:llm', 'Converted messages summary', {
962
+ totalMessages: messages.length,
963
+ byRole: messages.reduce((acc, m) => {
964
+ acc[m.role] = (acc[m.role] || 0) + 1;
965
+ return acc;
966
+ }, {}),
967
+ toolResults: messages
968
+ .filter((m) => m.role === 'tool')
969
+ .map((m) => {
970
+ const first = m.content[0];
971
+ return {
972
+ toolCallId: first && 'toolCallId' in first ? first.toolCallId : undefined,
973
+ toolName: first && 'toolName' in first ? first.toolName : undefined,
974
+ };
975
+ }),
976
+ assistantToolCalls: messages
977
+ .filter((m) => m.role === 'assistant')
978
+ .map((m) => ({
979
+ hasToolCalls: Array.isArray(m.content) && m.content.some((p) => p.type === 'tool-call'),
980
+ toolCallIds: Array.isArray(m.content)
981
+ ? m.content.filter((p) => p.type === 'tool-call').map((p) => p.toolCallId)
982
+ : [],
983
+ })),
984
+ });
985
+ return messages;
986
+ }
987
+ function convertResponseToTurnMessages(result, executedToolResults) {
988
+ const turnMessages = [];
989
+ let resultIdx = 0;
990
+ for (const step of result.steps) {
991
+ // Assistant message with text and/or tool calls
992
+ if (step.text || step.toolCalls.length > 0) {
993
+ const msg = {
994
+ role: 'assistant',
995
+ content: step.text || null,
996
+ };
997
+ if (step.toolCalls.length > 0) {
998
+ msg.toolCalls = step.toolCalls.map((tc) => ({
999
+ id: tc.toolCallId,
1000
+ name: tc.toolName,
1001
+ arguments: (tc.args ?? tc.input ?? {}),
1002
+ }));
1003
+ }
1004
+ turnMessages.push(msg);
1005
+ // Add tool results for the tool calls in this step
1006
+ for (let i = 0; i < step.toolCalls.length && resultIdx < executedToolResults.length; i++) {
1007
+ const tracked = executedToolResults[resultIdx++];
1008
+ turnMessages.push({
1009
+ role: 'tool',
1010
+ content: tracked.result,
1011
+ toolCallId: tracked.toolCallId,
1012
+ name: tracked.toolName,
1013
+ });
1014
+ }
1015
+ }
1016
+ }
1017
+ return turnMessages;
1018
+ }
1019
+ //# sourceMappingURL=orchestrator.js.map