@open-mercato/ai-assistant 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1

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 (135) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +30 -4
  3. package/dist/frontend/components/AiChatButton.js +3 -2
  4. package/dist/frontend/components/AiChatButton.js.map +2 -2
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +364 -0
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +7 -0
  7. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -7
  8. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
  9. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +182 -0
  10. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +7 -0
  11. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js +316 -0
  12. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js.map +7 -0
  13. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +8 -7
  14. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +2 -2
  15. package/dist/modules/ai_assistant/api/ai/chat/route.js +43 -20
  16. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  17. package/dist/modules/ai_assistant/api/settings/route.js +4 -3
  18. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  19. package/dist/modules/ai_assistant/api/usage/daily/route.js +111 -0
  20. package/dist/modules/ai_assistant/api/usage/daily/route.js.map +7 -0
  21. package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js +108 -0
  22. package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js.map +7 -0
  23. package/dist/modules/ai_assistant/api/usage/sessions/route.js +153 -0
  24. package/dist/modules/ai_assistant/api/usage/sessions/route.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +335 -38
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -7
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +44 -35
  30. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  31. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js +282 -0
  32. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js.map +7 -0
  33. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js +10 -0
  34. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js.map +7 -0
  35. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js +25 -0
  36. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js.map +7 -0
  37. package/dist/modules/ai_assistant/cli.js +12 -0
  38. package/dist/modules/ai_assistant/cli.js.map +2 -2
  39. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +1 -1
  40. package/dist/modules/ai_assistant/data/entities.js +177 -1
  41. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  42. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +104 -2
  43. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  44. package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js +168 -0
  45. package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js.map +7 -0
  46. package/dist/modules/ai_assistant/events.js +8 -0
  47. package/dist/modules/ai_assistant/events.js.map +2 -2
  48. package/dist/modules/ai_assistant/i18n/de.json +74 -1
  49. package/dist/modules/ai_assistant/i18n/en.json +74 -1
  50. package/dist/modules/ai_assistant/i18n/es.json +75 -2
  51. package/dist/modules/ai_assistant/i18n/pl.json +74 -1
  52. package/dist/modules/ai_assistant/lib/agent-policy.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/agent-runtime.js +588 -23
  54. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
  55. package/dist/modules/ai_assistant/lib/agent-tools.js +6 -1
  56. package/dist/modules/ai_assistant/lib/agent-tools.js.map +2 -2
  57. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  58. package/dist/modules/ai_assistant/lib/model-factory.js +63 -22
  59. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  60. package/dist/modules/ai_assistant/lib/token-usage-recorder.js +78 -0
  61. package/dist/modules/ai_assistant/lib/token-usage-recorder.js.map +7 -0
  62. package/dist/modules/ai_assistant/lib/usage-serialization.js +33 -0
  63. package/dist/modules/ai_assistant/lib/usage-serialization.js.map +7 -0
  64. package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js +25 -0
  65. package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js.map +7 -0
  66. package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js +88 -0
  67. package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js.map +7 -0
  68. package/dist/modules/ai_assistant/setup.js +34 -0
  69. package/dist/modules/ai_assistant/setup.js.map +2 -2
  70. package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js +114 -0
  71. package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js.map +7 -0
  72. package/generated/entities/ai_agent_runtime_override/index.ts +7 -0
  73. package/generated/entities/ai_token_usage_daily/index.ts +16 -0
  74. package/generated/entities/ai_token_usage_event/index.ts +19 -0
  75. package/generated/entities.ids.generated.ts +2 -0
  76. package/generated/entity-fields-registry.ts +47 -1
  77. package/package.json +15 -7
  78. package/src/frontend/components/AiChatButton.tsx +3 -2
  79. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +521 -0
  80. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -8
  81. package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +231 -0
  82. package/src/modules/ai_assistant/__tests__/events.test.ts +4 -3
  83. package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +5 -5
  84. package/src/modules/ai_assistant/__tests__/token-usage-recorder.test.ts +109 -0
  85. package/src/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.ts +388 -0
  86. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +5 -0
  87. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +8 -7
  88. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +102 -5
  89. package/src/modules/ai_assistant/api/ai/chat/route.ts +55 -18
  90. package/src/modules/ai_assistant/api/settings/route.ts +5 -3
  91. package/src/modules/ai_assistant/api/usage/daily/__tests__/route.test.ts +159 -0
  92. package/src/modules/ai_assistant/api/usage/daily/route.ts +126 -0
  93. package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/__tests__/route.test.ts +143 -0
  94. package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/route.ts +130 -0
  95. package/src/modules/ai_assistant/api/usage/sessions/__tests__/route.test.ts +123 -0
  96. package/src/modules/ai_assistant/api/usage/sessions/route.ts +184 -0
  97. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +372 -16
  98. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +1 -4
  99. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +26 -9
  100. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.tsx +469 -0
  101. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.ts +23 -0
  102. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.tsx +12 -0
  103. package/src/modules/ai_assistant/cli.ts +18 -0
  104. package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +1 -1
  105. package/src/modules/ai_assistant/data/entities.ts +237 -0
  106. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +135 -3
  107. package/src/modules/ai_assistant/data/repositories/AiTokenUsageRepository.ts +213 -0
  108. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +223 -0
  109. package/src/modules/ai_assistant/data/repositories/__tests__/AiTokenUsageRepository.test.ts +58 -0
  110. package/src/modules/ai_assistant/events.ts +8 -0
  111. package/src/modules/ai_assistant/i18n/de.json +74 -1
  112. package/src/modules/ai_assistant/i18n/en.json +74 -1
  113. package/src/modules/ai_assistant/i18n/es.json +75 -2
  114. package/src/modules/ai_assistant/i18n/pl.json +74 -1
  115. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase0.test.ts +439 -0
  116. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase1.test.ts +243 -0
  117. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase2.test.ts +388 -0
  118. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase3.test.ts +359 -0
  119. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +2 -2
  120. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +2 -1
  121. package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +12 -13
  122. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +77 -14
  123. package/src/modules/ai_assistant/lib/agent-policy.ts +9 -0
  124. package/src/modules/ai_assistant/lib/agent-runtime.ts +1148 -43
  125. package/src/modules/ai_assistant/lib/agent-tools.ts +5 -1
  126. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +289 -2
  127. package/src/modules/ai_assistant/lib/model-factory.ts +128 -43
  128. package/src/modules/ai_assistant/lib/token-usage-recorder.ts +122 -0
  129. package/src/modules/ai_assistant/lib/usage-serialization.ts +29 -0
  130. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +791 -0
  131. package/src/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.ts +25 -0
  132. package/src/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.ts +89 -0
  133. package/src/modules/ai_assistant/setup.ts +49 -0
  134. package/src/modules/ai_assistant/workers/__tests__/ai-token-usage-prune.test.ts +144 -0
  135. package/src/modules/ai_assistant/workers/ai-token-usage-prune.ts +188 -0
@@ -1,26 +1,49 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { createContainer } from 'awilix'
2
3
  import type { AwilixContainer } from 'awilix'
3
4
  import type { EntityManager } from '@mikro-orm/postgresql'
4
- import type { LanguageModel, UIMessage } from 'ai'
5
+ import type {
6
+ GenerateObjectResult,
7
+ GenerateTextResult,
8
+ LanguageModel,
9
+ PrepareStepFunction,
10
+ PrepareStepResult,
11
+ StreamObjectResult,
12
+ StreamTextResult,
13
+ ToolSet,
14
+ UIMessage,
15
+ ToolLoopAgentSettings,
16
+ } from 'ai'
5
17
  import {
6
18
  convertToModelMessages,
7
19
  generateObject,
20
+ hasToolCall,
8
21
  stepCountIs,
9
22
  streamObject,
10
23
  streamText,
24
+ Experimental_Agent as ToolLoopAgent,
11
25
  } from 'ai'
26
+ import type { StopCondition } from 'ai'
12
27
  import type { ZodTypeAny } from 'zod'
13
- import { createModelFactory } from './model-factory'
28
+ import { createModelFactory, resolveAllowRuntimeOverride } from './model-factory'
14
29
  import type {
15
30
  AiAgentDefinition,
31
+ AiAgentLoopConfig,
16
32
  AiAgentPageContextInput,
17
33
  AiAgentStructuredOutput,
34
+ LoopStepRecord,
35
+ LoopTrace,
18
36
  } from './ai-agent-definition'
19
37
  import type {
20
38
  AiChatRequestContext,
21
39
  AiResolvedAttachmentPart,
22
40
  } from './attachment-bridge-types'
23
- import { resolveAiAgentTools, AgentPolicyError } from './agent-tools'
41
+ import {
42
+ resolveAiAgentTools,
43
+ AgentPolicyError,
44
+ desanitizeToolNameForDisplay,
45
+ sanitizeToolNameForModel,
46
+ } from './agent-tools'
24
47
  import { resolveEffectiveMutationPolicy } from './agent-policy'
25
48
  import { toolRegistry } from './tool-registry'
26
49
  import {
@@ -36,6 +59,7 @@ import type { TenantAllowlistSnapshot } from './model-allowlist'
36
59
  import { composeSystemPromptWithOverride } from './prompt-override-merge'
37
60
  import { isKnownMutationPolicy } from './agent-policy'
38
61
  import type { AiAgentMutationPolicy } from './ai-agent-definition'
62
+ import { recordTokenUsage } from './token-usage-recorder'
39
63
 
40
64
  // Ensure built-in LLM providers are registered. Side-effect import; identical to
41
65
  // what `./ai-sdk.ts` consumers already rely on.
@@ -89,7 +113,7 @@ export interface RunAiAgentTextInput {
89
113
  * Per-request HTTP dispatcher override (query params `?provider=`, `?model=`,
90
114
  * `?baseUrl=`). Validated by the dispatcher route before being forwarded
91
115
  * here. Wins over tenantOverride and all lower-priority sources when
92
- * `agent.allowRuntimeModelOverride !== false`.
116
+ * `agent.allowRuntimeOverride !== false`.
93
117
  *
94
118
  * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
95
119
  */
@@ -105,13 +129,838 @@ export interface RunAiAgentTextInput {
105
129
  */
106
130
  container?: AwilixContainer
107
131
  /**
108
- * Optional stable chat-turn conversation id forwarded from `<AiChat>`.
109
- * Bridged into the Step 5.6 `prepareMutation` idempotency hash so repeated
110
- * turns within the same chat collapse onto the same pending action. When
111
- * omitted, the idempotency hash falls back to `null` which still preserves
112
- * per-tenant/org uniqueness within the TTL window.
132
+ * Stable per-conversation id that ties every turn of a chat together for
133
+ * token-usage correlation and pending-action idempotency (Phase 6.2).
134
+ *
135
+ * When omitted the server generates one and echoes it on the SSE `done`
136
+ * event so the client can persist it for subsequent turns. Callers that
137
+ * supply a value from a previous turn will have their usage rows grouped
138
+ * under the same `session_id` in the token-usage tables.
139
+ *
140
+ * Phase 6.2 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
141
+ */
142
+ sessionId?: string | null
143
+ /**
144
+ * @deprecated Use `sessionId` instead. This alias was the original name of
145
+ * the same field; both names are accepted for one minor release to let
146
+ * callers migrate without a hard cut. `sessionId` takes precedence when both
147
+ * are provided.
113
148
  */
114
149
  conversationId?: string | null
150
+ /**
151
+ * Optional per-call loop config override. Fields set here win over the
152
+ * agent's `loop` declaration and the tenant DB override. The override is
153
+ * gated by `agent.loop?.allowRuntimeOverride ?? true` — agents that pin
154
+ * a loop policy for correctness reasons can set `allowRuntimeOverride: false`
155
+ * to reject any per-call override with `AgentPolicyError` code
156
+ * `loop_runtime_override_disabled`.
157
+ *
158
+ * Phase 1 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
159
+ */
160
+ loop?: Partial<AiAgentLoopConfig>
161
+ /**
162
+ * Optional escape-hatch callback that receives the fully prepared AI SDK
163
+ * options bag and must return the SDK result (either from `streamText` or
164
+ * `generateText`). When supplied, the wrapper still enforces every policy
165
+ * guardrail (features, tool allowlist, mutation approval, model factory,
166
+ * prompt composition, attachment bridging) and then hands control to this
167
+ * callback instead of calling `streamText` directly.
168
+ *
169
+ * The callback MUST pass `stopWhen` and `prepareStep` through to the AI SDK
170
+ * call — dropping either one disables the agent's loop policy or mutation
171
+ * approval guards respectively. See `agents.mdx` §"Option B" for the full
172
+ * contract and what you lose when fields are omitted.
173
+ *
174
+ * Phase 2 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
175
+ */
176
+ generateText?: (
177
+ options: PreparedAiSdkOptions,
178
+ ) => Promise<GenerateTextResult<ToolSet, never> | StreamTextResult<ToolSet, never>>
179
+ /**
180
+ * When `true`, the runtime appends a `loop-finish` SSE event to the
181
+ * response stream after the AI SDK stream closes. The event payload is the
182
+ * serialized `LoopTrace` for the turn (agent id, turn id, per-step records,
183
+ * stop reason, total duration, total usage).
184
+ *
185
+ * Consumed by `useAiChat` to populate `lastLoopTrace` and by the playground
186
+ * debug panel to render the per-turn trace via `LoopTracePanel`.
187
+ *
188
+ * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
189
+ */
190
+ emitLoopTrace?: boolean
191
+ }
192
+
193
+ /**
194
+ * The wrapper default loop config used when neither the caller, tenant, agent,
195
+ * nor legacy maxSteps supplies any config. Chat mode defaults to `{ maxSteps: 10 }`
196
+ * to ensure tool-using agents can loop; object mode defaults to an empty config
197
+ * (single structured-output call, no explicit step cap).
198
+ */
199
+ const WRAPPER_DEFAULT_LOOP_CHAT: AiAgentLoopConfig = { maxSteps: 10 }
200
+ const WRAPPER_DEFAULT_LOOP_OBJECT: AiAgentLoopConfig = {}
201
+
202
+ /**
203
+ * Named loop-budget preset values for `?loopBudget=<preset>` query param.
204
+ *
205
+ * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
206
+ */
207
+ export type AiAgentLoopBudgetPreset = 'tight' | 'default' | 'loose'
208
+
209
+ /**
210
+ * Maps a `loopBudget` preset name to the corresponding `AiAgentLoopBudget`
211
+ * triple. `'default'` returns `undefined` (no override — agent default applies).
212
+ * Values are pinned per spec §"loopBudget preset values (Phase 4)".
213
+ */
214
+ export function resolveLoopBudgetPreset(
215
+ preset: AiAgentLoopBudgetPreset,
216
+ ): Partial<AiAgentLoopConfig> | undefined {
217
+ switch (preset) {
218
+ case 'tight':
219
+ return { maxSteps: 3, budget: { maxToolCalls: 3, maxWallClockMs: 10_000, maxTokens: 50_000 } }
220
+ case 'loose':
221
+ return { maxSteps: 20, budget: { maxToolCalls: 20, maxWallClockMs: 120_000, maxTokens: 500_000 } }
222
+ case 'default':
223
+ return undefined
224
+ }
225
+ }
226
+
227
+ const SSE_ENCODER = new TextEncoder()
228
+
229
+ /**
230
+ * Wraps a streaming `Response` to append a typed `loop-finish` SSE event
231
+ * after the AI SDK stream closes. The event carries the serialized `LoopTrace`
232
+ * for the turn so the `useAiChat` hook can render it in the debug panel.
233
+ *
234
+ * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
235
+ */
236
+ function appendLoopFinishToStream(
237
+ baseResponse: Response,
238
+ finalizeLoopTrace: () => LoopTrace,
239
+ ): Response {
240
+ const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
241
+ const writer = writable.getWriter()
242
+
243
+ async function pump(): Promise<void> {
244
+ if (!baseResponse.body) {
245
+ await writer.close()
246
+ return
247
+ }
248
+ const reader = baseResponse.body.getReader()
249
+ try {
250
+ for (;;) {
251
+ const { value, done } = await reader.read()
252
+ if (done) break
253
+ await writer.write(value)
254
+ }
255
+ const trace = finalizeLoopTrace()
256
+ const eventLine = `data: ${JSON.stringify({ type: 'loop-finish', trace })}\n\n`
257
+ await writer.write(SSE_ENCODER.encode(eventLine))
258
+ } catch {
259
+ // Pass through — the reader abort is surfaced by the upstream consumer.
260
+ } finally {
261
+ reader.releaseLock()
262
+ await writer.close().catch(() => undefined)
263
+ }
264
+ }
265
+
266
+ void pump()
267
+ return new Response(readable, {
268
+ status: baseResponse.status,
269
+ headers: baseResponse.headers,
270
+ })
271
+ }
272
+
273
+ /**
274
+ * The fully prepared options bag handed to the `runAiAgentText({ generateText })`
275
+ * escape-hatch callback. Callers receive a complete set of wrapper-composed
276
+ * loop primitives so they can forward them to `streamText` / `generateText`.
277
+ *
278
+ * SECURITY CONTRACT: callers MUST forward `prepareStep` to the AI SDK call.
279
+ * Dropping it removes the per-step tool-allowlist re-check and the mutation-
280
+ * approval wrapping. Dropping `stopWhen` removes the agent's loop policy and
281
+ * the R3 step-count fallback.
282
+ *
283
+ * Phase 2 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
284
+ */
285
+ export interface PreparedAiSdkOptions {
286
+ model: LanguageModel
287
+ tools: ToolSet
288
+ system: string
289
+ messages: Awaited<ReturnType<typeof convertToModelMessages>>
290
+ /** Alias kept for SDK compat — equals `stopWhen` array's effective maxSteps. */
291
+ maxSteps: number
292
+ /**
293
+ * Wrapper-composed stop conditions (R3 mitigated: always ends with
294
+ * `stepCountIs(maxSteps)`). MUST be forwarded to the SDK call.
295
+ */
296
+ stopWhen: StopCondition<ToolSet>[]
297
+ /**
298
+ * Wrapper-owned `PrepareStepFunction` that re-asserts the tool allowlist and
299
+ * mutation-approval wrapping per step. SECURITY-CRITICAL: callers MUST
300
+ * forward this to the SDK call or they lose mutation-approval guarantees.
301
+ */
302
+ prepareStep: PrepareStepFunction<ToolSet>
303
+ /** Wrapper trace aggregator chained with the agent's `onStepFinish` hook. */
304
+ onStepFinish: AiAgentLoopConfig['onStepFinish']
305
+ onStepStart: AiAgentLoopConfig['onStepStart']
306
+ onToolCallStart: AiAgentLoopConfig['onToolCallStart']
307
+ onToolCallFinish: AiAgentLoopConfig['onToolCallFinish']
308
+ experimental_repairToolCall: AiAgentLoopConfig['repairToolCall']
309
+ activeTools: AiAgentLoopConfig['activeTools']
310
+ toolChoice: AiAgentLoopConfig['toolChoice']
311
+ /**
312
+ * Pre-wired to the per-turn `AbortController` used by budget enforcement
313
+ * (Phase 3). Forward to the SDK call so budget limits can abort in-flight
314
+ * requests. May be `undefined` when budget enforcement is not yet active
315
+ * (Phases 0–2); the SDK treats `undefined` the same as no signal.
316
+ */
317
+ abortSignal: AbortSignal | undefined
318
+ /**
319
+ * Finalizes the per-turn `LoopTrace` and returns it. Callers that use the
320
+ * `generateText` escape-hatch SHOULD call this after the SDK call resolves so
321
+ * the trace is available for logging or SSE emission.
322
+ *
323
+ * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
324
+ */
325
+ finalizeLoopTrace: () => LoopTrace
326
+ /**
327
+ * Present only when `agent.executionEngine === 'tool-loop-agent'`. Callers
328
+ * can invoke `agent.generate(...)` / `agent.stream(...)` directly with their
329
+ * own `providerOptions` as an escape-hatch — the `ToolLoopAgent` instance is
330
+ * already wired with the wrapper-owned `prepareStep`, `stopWhen`, and
331
+ * `onStepFinish` at construction.
332
+ *
333
+ * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
334
+ */
335
+ toolLoopAgent?: ToolLoopAgent<never, ToolSet>
336
+ }
337
+
338
+ /**
339
+ * The fully prepared options bag handed to the `runAiAgentObject({ generateObject })`
340
+ * escape-hatch callback. Object-mode subset — chat-only fields are absent.
341
+ *
342
+ * Phase 2 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
343
+ */
344
+ export interface PreparedAiSdkObjectOptions {
345
+ model: LanguageModel
346
+ system: string
347
+ messages: Awaited<ReturnType<typeof convertToModelMessages>>
348
+ schemaName: string
349
+ schema: unknown
350
+ maxSteps: number | undefined
351
+ onStepFinish: AiAgentLoopConfig['onStepFinish']
352
+ onStepStart: AiAgentLoopConfig['onStepStart']
353
+ abortSignal: AbortSignal | undefined
354
+ }
355
+
356
+ /**
357
+ * Guards the per-call loop override against agents that have opted out of
358
+ * runtime overrides by setting `loop.allowRuntimeOverride: false`.
359
+ *
360
+ * Throws `AgentPolicyError` with code `loop_runtime_override_disabled` when
361
+ * the agent has opted out and a caller override was supplied.
362
+ *
363
+ * Phase 1 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
364
+ */
365
+ function assertLoopRuntimeOverrideAllowed(
366
+ agent: AiAgentDefinition,
367
+ callerLoop: Partial<AiAgentLoopConfig> | undefined,
368
+ ): void {
369
+ if (!callerLoop) return
370
+ const allowed = agent.loop?.allowRuntimeOverride ?? true
371
+ if (!allowed) {
372
+ throw new AgentPolicyError(
373
+ 'loop_runtime_override_disabled',
374
+ `Agent "${agent.id}" has disabled per-call loop overrides (loop.allowRuntimeOverride: false). Remove the loop override to proceed.`,
375
+ )
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Reads `<MODULE>_AI_LOOP_*` env shorthands for the given module id.
381
+ * Returns a partial `AiAgentLoopConfig` containing only the axes that are
382
+ * explicitly set in the environment. Missing or malformed values are silently
383
+ * ignored (fail-open — env vars are a best-effort static deployment mechanism).
384
+ *
385
+ * Supported variables (Phase 3 of spec
386
+ * `2026-04-28-ai-agents-agentic-loop-controls`):
387
+ *
388
+ * - `<MODULE>_AI_LOOP_MAX_STEPS` — maps to `loop.maxSteps`
389
+ * - `<MODULE>_AI_LOOP_MAX_WALL_CLOCK_MS` — maps to `loop.budget.maxWallClockMs`
390
+ * - `<MODULE>_AI_LOOP_MAX_TOKENS` — maps to `loop.budget.maxTokens`
391
+ */
392
+ function readModuleLoopEnv(moduleId: string): Partial<AiAgentLoopConfig> {
393
+ const prefix = moduleId.toUpperCase()
394
+ const partial: Partial<AiAgentLoopConfig> = {}
395
+
396
+ const maxStepsRaw = process.env[`${prefix}_AI_LOOP_MAX_STEPS`]
397
+ if (maxStepsRaw) {
398
+ const parsed = parseInt(maxStepsRaw.trim(), 10)
399
+ if (!isNaN(parsed) && parsed > 0) partial.maxSteps = parsed
400
+ }
401
+
402
+ const maxWallClockRaw = process.env[`${prefix}_AI_LOOP_MAX_WALL_CLOCK_MS`]
403
+ const maxTokensRaw = process.env[`${prefix}_AI_LOOP_MAX_TOKENS`]
404
+
405
+ if (maxWallClockRaw || maxTokensRaw) {
406
+ const budgetPartial: AiAgentLoopConfig['budget'] = {}
407
+ if (maxWallClockRaw) {
408
+ const parsed = parseInt(maxWallClockRaw.trim(), 10)
409
+ if (!isNaN(parsed) && parsed > 0) budgetPartial.maxWallClockMs = parsed
410
+ }
411
+ if (maxTokensRaw) {
412
+ const parsed = parseInt(maxTokensRaw.trim(), 10)
413
+ if (!isNaN(parsed) && parsed > 0) budgetPartial.maxTokens = parsed
414
+ }
415
+ if (Object.keys(budgetPartial).length > 0) partial.budget = budgetPartial
416
+ }
417
+
418
+ return partial
419
+ }
420
+
421
+ /**
422
+ * Resolves the effective loop config for a turn by walking the precedence
423
+ * chain (highest first):
424
+ *
425
+ * 1. `callerLoop` — per-call `runAiAgentText({ loop })` override (Phase 1).
426
+ * 2. Tenant override row — NOT yet implemented in DB; always `undefined` here.
427
+ * // TODO(Phase 1782-3): hydrate loop columns from ai_agent_runtime_overrides
428
+ * 3. `<MODULE>_AI_LOOP_*` env shorthands (Phase 3) — only MAX_STEPS,
429
+ * MAX_WALL_CLOCK_MS, MAX_TOKENS. Lower precedence than DB override but higher
430
+ * than the agent's code-declared defaults.
431
+ * 4. `agent.loop` — agent's declarative loop config.
432
+ * 5. `agent.maxSteps` (deprecated alias) — mapped to `{ maxSteps: agent.maxSteps }`.
433
+ * 6. `wrapperDefault` — the wrapper's hardcoded fallback.
434
+ *
435
+ * Each source contributes only the fields it sets explicitly; fields absent at
436
+ * a higher-priority source fall through to a lower-priority one. The merge is
437
+ * performed left-to-right with higher-priority sources winning field-by-field.
438
+ *
439
+ * Throws `AgentPolicyError` code `loop_runtime_override_disabled` when the
440
+ * agent opts out of per-call overrides and a caller loop was supplied.
441
+ *
442
+ * Phase 0 + Phase 1 + Phase 3 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
443
+ */
444
+ export function resolveEffectiveLoopConfig(
445
+ agent: AiAgentDefinition,
446
+ callerLoop?: Partial<AiAgentLoopConfig> | undefined,
447
+ wrapperDefault?: AiAgentLoopConfig,
448
+ ): AiAgentLoopConfig {
449
+ assertLoopRuntimeOverrideAllowed(agent, callerLoop)
450
+
451
+ const effectiveDefault = wrapperDefault ?? WRAPPER_DEFAULT_LOOP_CHAT
452
+
453
+ // Build base from lowest-priority: wrapper default → legacy maxSteps → agent.loop
454
+ const legacyMaxSteps: AiAgentLoopConfig | undefined =
455
+ typeof agent.maxSteps === 'number' && agent.maxSteps > 0 && !agent.loop
456
+ ? { maxSteps: agent.maxSteps }
457
+ : undefined
458
+
459
+ const base: AiAgentLoopConfig = {
460
+ ...effectiveDefault,
461
+ ...(legacyMaxSteps ?? {}),
462
+ ...(agent.loop ?? {}),
463
+ }
464
+
465
+ // Phase 3 — env shorthands at priority 3 (above agent.loop, below DB override).
466
+ // TODO(Phase 1782-3): hydrate loop columns from ai_agent_runtime_overrides
467
+ // and merge tenantOverride here at priority #2 (above envOverride).
468
+ const envOverride = readModuleLoopEnv(agent.moduleId)
469
+ const withEnv: AiAgentLoopConfig = {
470
+ ...base,
471
+ ...envOverride,
472
+ ...(envOverride.budget != null
473
+ ? { budget: { ...(base.budget ?? {}), ...envOverride.budget } }
474
+ : {}),
475
+ }
476
+
477
+ const withCaller: AiAgentLoopConfig = callerLoop
478
+ ? { ...withEnv, ...callerLoop }
479
+ : withEnv
480
+
481
+ // Phase 3 — kill switch: when disabled is set to true, force maxSteps: 1 so the
482
+ // agent executes as a single model call with no tool looping. All other loop config
483
+ // is preserved (budget, etc.) but the step cap wins.
484
+ if (withCaller.disabled === true) {
485
+ return { ...withCaller, maxSteps: 1 }
486
+ }
487
+
488
+ return withCaller
489
+ }
490
+
491
+ /**
492
+ * The reason a budget limit was hit, exposed on `LoopAbortReason` (Phase 3).
493
+ */
494
+ export type LoopBudgetAbortReason =
495
+ | 'budget-tool-calls'
496
+ | 'budget-wall-clock'
497
+ | 'budget-tokens'
498
+
499
+ /**
500
+ * Tracks per-turn budget usage and aborts the run when any limit is exceeded.
501
+ *
502
+ * Usage:
503
+ * 1. Construct with the loop budget and the turn's `AbortController`.
504
+ * 2. Call `wire(onStepFinish)` to get a composed `onStepFinish` that feeds
505
+ * usage data into the enforcer on every completed step.
506
+ * 3. The enforcer calls `abortController.abort()` with a typed
507
+ * `LoopBudgetAbortReason` when a limit is hit.
508
+ *
509
+ * Phase 3 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
510
+ */
511
+ export class BudgetEnforcer {
512
+ private toolCallsUsed = 0
513
+ private tokensUsed = 0
514
+ readonly turnStartMs: number
515
+ abortReason: LoopBudgetAbortReason | null = null
516
+
517
+ constructor(
518
+ private readonly budget: AiAgentLoopConfig['budget'],
519
+ private readonly abortController: AbortController,
520
+ ) {
521
+ this.turnStartMs = Date.now()
522
+ }
523
+
524
+ get hasActiveBudget(): boolean {
525
+ const b = this.budget
526
+ return (
527
+ b !== undefined &&
528
+ (b.maxToolCalls !== undefined || b.maxWallClockMs !== undefined || b.maxTokens !== undefined)
529
+ )
530
+ }
531
+
532
+ recordStep(usage: { inputTokens?: number; outputTokens?: number; toolCalls?: number }): void {
533
+ if (!this.budget) return
534
+ this.toolCallsUsed += usage.toolCalls ?? 0
535
+ this.tokensUsed += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
536
+ this.checkLimits()
537
+ }
538
+
539
+ private checkLimits(): void {
540
+ const b = this.budget
541
+ if (!b) return
542
+
543
+ if (b.maxToolCalls !== undefined && this.toolCallsUsed >= b.maxToolCalls) {
544
+ this.abort('budget-tool-calls')
545
+ return
546
+ }
547
+
548
+ const elapsedMs = Date.now() - this.turnStartMs
549
+ if (b.maxWallClockMs !== undefined && elapsedMs >= b.maxWallClockMs) {
550
+ this.abort('budget-wall-clock')
551
+ return
552
+ }
553
+
554
+ if (b.maxTokens !== undefined && this.tokensUsed >= b.maxTokens) {
555
+ this.abort('budget-tokens')
556
+ }
557
+ }
558
+
559
+ private abort(reason: LoopBudgetAbortReason): void {
560
+ if (this.abortReason !== null) return
561
+ this.abortReason = reason
562
+ console.info(
563
+ `[AI Agents] Budget exceeded — aborting turn. Reason: ${reason}. ` +
564
+ `toolCalls=${this.toolCallsUsed}, tokens=${this.tokensUsed}, ` +
565
+ `elapsedMs=${Date.now() - this.turnStartMs}.`,
566
+ )
567
+ this.abortController.abort(reason)
568
+ }
569
+
570
+ wire(
571
+ userOnStepFinish: AiAgentLoopConfig['onStepFinish'],
572
+ ): AiAgentLoopConfig['onStepFinish'] {
573
+ if (!this.hasActiveBudget) return userOnStepFinish
574
+ return async (event) => {
575
+ this.recordStep({
576
+ inputTokens: event.usage?.inputTokens,
577
+ outputTokens: event.usage?.outputTokens,
578
+ toolCalls: event.toolCalls?.length,
579
+ })
580
+ if (userOnStepFinish) {
581
+ try {
582
+ await userOnStepFinish(event)
583
+ } catch (err) {
584
+ console.error('[AI Agents] User onStepFinish threw; ignoring:', err)
585
+ }
586
+ }
587
+ }
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Builds a wrapper-owned `onStepFinish` collector that aggregates per-step
593
+ * usage and tool-call data into a `LoopTrace` object. The collector chains
594
+ * the user's `onStepFinish` after it aggregates (exceptions from the user's
595
+ * hook are caught and logged but do not abort the turn).
596
+ *
597
+ * Returns both the wired `onStepFinish` hook and a `finalize()` function that
598
+ * resolves the `LoopTrace` once the turn is complete. The `budgetEnforcer`
599
+ * is already wired into `onStepFinish` at a lower layer — this collector sits
600
+ * above it.
601
+ *
602
+ * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
603
+ */
604
+ export function buildLoopTraceCollector(
605
+ agentId: string,
606
+ sessionId: string,
607
+ turnId: string,
608
+ userOnStepFinish: AiAgentLoopConfig['onStepFinish'],
609
+ ): {
610
+ onStepFinish: AiAgentLoopConfig['onStepFinish']
611
+ finalize: (abortReason: LoopBudgetAbortReason | null) => LoopTrace
612
+ } {
613
+ const turnStartMs = Date.now()
614
+ const steps: LoopStepRecord[] = []
615
+
616
+ const onStepFinish: AiAgentLoopConfig['onStepFinish'] = async (event) => {
617
+ const stepIndex = steps.length
618
+ const toolCalls = (event.toolCalls ?? []).map((tc) => {
619
+ const raw = tc as unknown as {
620
+ toolName?: string
621
+ args?: unknown
622
+ result?: unknown
623
+ experimental_toToolResultError?: { code?: string; message?: string }
624
+ repairAttempted?: boolean
625
+ startTime?: number
626
+ endTime?: number
627
+ }
628
+ return {
629
+ toolName: raw.toolName
630
+ ? desanitizeToolNameForDisplay(raw.toolName)
631
+ : 'unknown',
632
+ args: raw.args ?? {},
633
+ result: raw.result,
634
+ error: raw.experimental_toToolResultError
635
+ ? {
636
+ code: String(raw.experimental_toToolResultError?.code ?? 'unknown'),
637
+ message: String(raw.experimental_toToolResultError?.message ?? ''),
638
+ }
639
+ : undefined,
640
+ repairAttempted: raw.repairAttempted === true,
641
+ durationMs:
642
+ typeof raw.startTime === 'number' && typeof raw.endTime === 'number'
643
+ ? raw.endTime - raw.startTime
644
+ : 0,
645
+ }
646
+ })
647
+
648
+ const textDelta =
649
+ (event as unknown as { text?: string }).text ?? ''
650
+
651
+ const finishReason = (
652
+ (event as unknown as { finishReason?: string }).finishReason ?? 'stop'
653
+ ) as LoopStepRecord['finishReason']
654
+
655
+ const modelId =
656
+ (event as unknown as { response?: { modelId?: string } }).response?.modelId ?? 'unknown'
657
+
658
+ steps.push({
659
+ stepIndex,
660
+ modelId,
661
+ toolCalls,
662
+ textDelta,
663
+ usage: {
664
+ inputTokens: event.usage?.inputTokens ?? 0,
665
+ outputTokens: event.usage?.outputTokens ?? 0,
666
+ },
667
+ finishReason,
668
+ })
669
+
670
+ if (userOnStepFinish) {
671
+ try {
672
+ await userOnStepFinish(event)
673
+ } catch (err) {
674
+ console.error('[AI Agents] User onStepFinish in LoopTrace collector threw; ignoring:', err)
675
+ }
676
+ }
677
+ }
678
+
679
+ function finalize(abortReason: LoopBudgetAbortReason | null): LoopTrace {
680
+ const totalDurationMs = Date.now() - turnStartMs
681
+ const totalUsage = steps.reduce(
682
+ (acc, step) => ({
683
+ inputTokens: acc.inputTokens + step.usage.inputTokens,
684
+ outputTokens: acc.outputTokens + step.usage.outputTokens,
685
+ }),
686
+ { inputTokens: 0, outputTokens: 0 },
687
+ )
688
+
689
+ let stopReason: LoopTrace['stopReason'] = 'finish-reason'
690
+ if (abortReason === 'budget-tool-calls') stopReason = 'budget-tool-calls'
691
+ else if (abortReason === 'budget-wall-clock') stopReason = 'budget-wall-clock'
692
+ else if (abortReason === 'budget-tokens') stopReason = 'budget-tokens'
693
+
694
+ return {
695
+ agentId,
696
+ sessionId,
697
+ turnId,
698
+ steps,
699
+ stopReason,
700
+ totalDurationMs,
701
+ totalUsage,
702
+ }
703
+ }
704
+
705
+ return { onStepFinish, finalize }
706
+ }
707
+
708
+ /**
709
+ * Translates serializable `AiAgentLoopStopCondition` items into the Vercel AI
710
+ * SDK `StopCondition` array ready to pass to `streamText` / `generateText`.
711
+ *
712
+ * The wrapper ALWAYS appends `stepCountIs(maxSteps ?? 10)` as the final item
713
+ * in the returned array (R3 mitigation). This guarantees that a misconfigured
714
+ * `hasToolCall` for a non-existent tool can never cause an infinite loop
715
+ * because the SDK treats `stopWhen` arrays with OR semantics — the step-count
716
+ * fallback will always trip eventually.
717
+ *
718
+ * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
719
+ */
720
+ export function translateStopConditions(
721
+ loopConfig: AiAgentLoopConfig,
722
+ mapToolName: (toolName: string) => string = (toolName) => toolName,
723
+ ): StopCondition<ToolSet>[] {
724
+ const effectiveMaxSteps = loopConfig.maxSteps ?? 10
725
+ const userConditions: StopCondition<ToolSet>[] = []
726
+
727
+ const rawStopWhen = loopConfig.stopWhen
728
+ if (rawStopWhen) {
729
+ const items = Array.isArray(rawStopWhen) ? rawStopWhen : [rawStopWhen]
730
+ for (const item of items) {
731
+ if (item.kind === 'stepCount') {
732
+ userConditions.push(stepCountIs(item.count))
733
+ } else if (item.kind === 'hasToolCall') {
734
+ userConditions.push(hasToolCall(mapToolName(item.toolName)))
735
+ } else if (item.kind === 'custom') {
736
+ userConditions.push(item.stop as StopCondition<ToolSet>)
737
+ }
738
+ }
739
+ }
740
+
741
+ // Always append the hard step-count fallback (R3 mitigation).
742
+ return [...userConditions, stepCountIs(effectiveMaxSteps)]
743
+ }
744
+
745
+ /**
746
+ * Security-critical merge of the wrapper-owned step override with the user's
747
+ * `prepareStep` return value.
748
+ *
749
+ * Guarantees (R1 mitigation — preserving the mutation-approval contract):
750
+ * 1. Any `tools` map returned by the user is intersected with `toolRegistry`
751
+ * (the policy-gated, mutation-approval-wrapped map). If the user returned
752
+ * a raw mutation handler, the merged map points at the wrapped one.
753
+ * 2. Any `activeTools` returned by the user is intersected with
754
+ * `agent.allowedTools`. Out-of-set names are dropped with a single
755
+ * `loop:active_tools_filtered` warning.
756
+ * 3. A user-returned `tools` map that contains a mutation tool pointing at the
757
+ * raw handler (not the wrapped one) is rejected with
758
+ * `AgentPolicyError` code `loop_violates_mutation_policy`.
759
+ * 4. Non-policy fields (`model`, `toolChoice`, `system`, `messages`) from the
760
+ * user override are honored as-is.
761
+ *
762
+ * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
763
+ *
764
+ * ai-sdk 6.0.177 dropped the `tools` field from `PrepareStepResult`, so the
765
+ * map is no longer consumed by `streamText` / `generateText`. We still inspect
766
+ * any `tools` map that a caller passes via a type-asserted result — even
767
+ * though the SDK ignores it — to keep the defense-in-depth guard and the
768
+ * existing test contract (R1 mitigation #3) intact.
769
+ */
770
+ type StepOverrideWithTools = NonNullable<PrepareStepResult<ToolSet>> & {
771
+ tools?: Record<string, unknown>
772
+ }
773
+
774
+ function normalizeAllowedToolNameForAgent(
775
+ toolName: string,
776
+ agent: AiAgentDefinition,
777
+ ): string | null {
778
+ if (agent.allowedTools.includes(toolName)) return toolName
779
+ const dottedName = desanitizeToolNameForDisplay(toolName)
780
+ return agent.allowedTools.includes(dottedName) ? dottedName : null
781
+ }
782
+
783
+ export function mergeStepOverrides(
784
+ wrapperOverride: PrepareStepResult<ToolSet>,
785
+ userOverride: PrepareStepResult<ToolSet> | undefined | null,
786
+ agent: AiAgentDefinition,
787
+ wrappedToolRegistry: Record<string, unknown>,
788
+ ): PrepareStepResult<ToolSet> {
789
+ if (!userOverride) return wrapperOverride
790
+
791
+ const merged: StepOverrideWithTools = { ...(wrapperOverride as StepOverrideWithTools) }
792
+ const userWithTools = userOverride as StepOverrideWithTools
793
+
794
+ if (userOverride.model !== undefined) {
795
+ merged.model = userOverride.model
796
+ }
797
+ if (userOverride.toolChoice !== undefined) {
798
+ merged.toolChoice = userOverride.toolChoice
799
+ }
800
+
801
+ if (userOverride.activeTools !== undefined) {
802
+ const filtered = userOverride.activeTools.flatMap((name) => {
803
+ const normalized = normalizeAllowedToolNameForAgent(name, agent)
804
+ const allowed = normalized !== null
805
+ if (!allowed) {
806
+ console.warn(
807
+ `[AI Agents] loop:active_tools_filtered — tool "${name}" is not in agent "${agent.id}" allowedTools; dropping from activeTools.`,
808
+ )
809
+ }
810
+ return normalized ? [normalized] : []
811
+ })
812
+ merged.activeTools = filtered
813
+ }
814
+
815
+ if (userWithTools.tools !== undefined) {
816
+ const userTools = userWithTools.tools
817
+ const mergedTools: Record<string, unknown> = {}
818
+
819
+ for (const [toolKey, userHandler] of Object.entries(userTools)) {
820
+ const wrappedHandler = wrappedToolRegistry[toolKey]
821
+ if (!wrappedHandler) {
822
+ console.warn(
823
+ `[AI Agents] mergeStepOverrides — tool "${toolKey}" from user prepareStep is not in the wrapper tool registry; dropping.`,
824
+ )
825
+ continue
826
+ }
827
+ if (userHandler !== wrappedHandler) {
828
+ const toolDef = toolRegistry.getTool(
829
+ toolKey.replace(/__/g, '.'),
830
+ ) as { isMutation?: boolean } | undefined
831
+ if (toolDef?.isMutation === true) {
832
+ throw new AgentPolicyError(
833
+ 'loop_violates_mutation_policy',
834
+ `User prepareStep returned a tools map with raw (unwrapped) mutation handler for "${toolKey}". This bypasses the mutation-approval gate and is rejected.`,
835
+ )
836
+ }
837
+ }
838
+ mergedTools[toolKey] = wrappedHandler
839
+ }
840
+ merged.tools = mergedTools
841
+ }
842
+
843
+ return merged
844
+ }
845
+
846
+ function mapPrepareStepResultForModel(
847
+ result: PrepareStepResult<ToolSet>,
848
+ wrappedTools: Record<string, unknown>,
849
+ ): PrepareStepResult<ToolSet> {
850
+ if (!result?.activeTools) return result
851
+ const activeTools = result.activeTools
852
+ .map((toolName) => sanitizeToolNameForModel(toolName))
853
+ .filter((toolName) => wrappedTools[toolName] !== undefined)
854
+ return { ...result, activeTools }
855
+ }
856
+
857
+ function mapToolChoiceForModel(
858
+ toolChoice: AiAgentLoopConfig['toolChoice'],
859
+ ): AiAgentLoopConfig['toolChoice'] {
860
+ if (!toolChoice || typeof toolChoice !== 'object' || toolChoice.type !== 'tool') {
861
+ return toolChoice
862
+ }
863
+ return {
864
+ ...toolChoice,
865
+ toolName: sanitizeToolNameForModel(toolChoice.toolName),
866
+ } as AiAgentLoopConfig['toolChoice']
867
+ }
868
+
869
+ function mapActiveToolsForModel(
870
+ activeTools: string[] | undefined,
871
+ wrappedTools: Record<string, unknown>,
872
+ ): string[] | undefined {
873
+ if (!activeTools) return undefined
874
+ return activeTools
875
+ .map((toolName) => sanitizeToolNameForModel(toolName))
876
+ .filter((toolName) => wrappedTools[toolName] !== undefined)
877
+ }
878
+
879
+ /**
880
+ * Builds the wrapper-owned `PrepareStepFunction` that enforces the tool
881
+ * allowlist and mutation-approval contract on every step, then composes
882
+ * the user's `prepareStep` on top via `mergeStepOverrides`.
883
+ *
884
+ * This is the SECURITY-CRITICAL function for Phase 0. The wrapper `prepareStep`
885
+ * ensures:
886
+ * - Tool active-set is always a subset of `effectiveLoop.activeTools ?? agent.allowedTools`.
887
+ * - Mutation tools always point at the prepareMutation-wrapped handlers.
888
+ * - User's `prepareStep` return value cannot smuggle raw mutation handlers.
889
+ *
890
+ * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
891
+ */
892
+ export function buildWrapperPrepareStep(
893
+ agent: AiAgentDefinition,
894
+ effectiveLoop: AiAgentLoopConfig,
895
+ wrappedTools: Record<string, unknown>,
896
+ ): PrepareStepFunction<ToolSet> {
897
+ return async (state) => {
898
+ const wrapperOverride: PrepareStepResult<ToolSet> = {}
899
+
900
+ if (effectiveLoop.activeTools && effectiveLoop.activeTools.length > 0) {
901
+ wrapperOverride.activeTools = effectiveLoop.activeTools.flatMap((name) => {
902
+ const normalized = normalizeAllowedToolNameForAgent(name, agent)
903
+ const allowed = normalized !== null
904
+ if (!allowed) {
905
+ console.warn(
906
+ `[AI Agents] loop:active_tools_filtered — tool "${name}" is not in agent "${agent.id}" allowedTools; dropping from activeTools.`,
907
+ )
908
+ }
909
+ return normalized ? [normalized] : []
910
+ })
911
+ }
912
+
913
+ if (effectiveLoop.prepareStep) {
914
+ let userOverride: PrepareStepResult<ToolSet> | undefined | null
915
+ try {
916
+ userOverride = await effectiveLoop.prepareStep(state)
917
+ } catch (error) {
918
+ console.error(
919
+ `[AI Agents] User prepareStep threw for agent "${agent.id}"; ignoring user override:`,
920
+ error,
921
+ )
922
+ return mapPrepareStepResultForModel(wrapperOverride, wrappedTools)
923
+ }
924
+ return mapPrepareStepResultForModel(
925
+ mergeStepOverrides(wrapperOverride, userOverride, agent, wrappedTools),
926
+ wrappedTools,
927
+ )
928
+ }
929
+
930
+ return mapPrepareStepResultForModel(wrapperOverride, wrappedTools)
931
+ }
932
+ }
933
+
934
+ /**
935
+ * Validates that a loop config does not set any primitives that are
936
+ * unsupported by the object-mode SDK path (`generateObject` / `streamObject`).
937
+ *
938
+ * Object mode accepts ONLY: `maxSteps`, `budget`, `onStepFinish`,
939
+ * `onStepStart`, `allowRuntimeOverride`. The remaining fields
940
+ * (`prepareStep`, `repairToolCall`, `stopWhen`, `activeTools`,
941
+ * `toolChoice`) are chat-only and will never reach `generateObject`.
942
+ *
943
+ * Throws `AgentPolicyError` code `loop_unsupported_in_object_mode` if any
944
+ * unsupported field is set. This provides an explicit, actionable error
945
+ * rather than a silent no-op.
946
+ *
947
+ * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
948
+ */
949
+ export function assertLoopObjectModeCompatible(loopConfig: Partial<AiAgentLoopConfig>): void {
950
+ const unsupportedFields: string[] = []
951
+
952
+ if (loopConfig.prepareStep !== undefined) unsupportedFields.push('prepareStep')
953
+ if (loopConfig.repairToolCall !== undefined) unsupportedFields.push('repairToolCall')
954
+ if (loopConfig.stopWhen !== undefined) unsupportedFields.push('stopWhen')
955
+ if (loopConfig.activeTools !== undefined) unsupportedFields.push('activeTools')
956
+ if (loopConfig.toolChoice !== undefined) unsupportedFields.push('toolChoice')
957
+
958
+ if (unsupportedFields.length > 0) {
959
+ throw new AgentPolicyError(
960
+ 'loop_unsupported_in_object_mode',
961
+ `Object-mode agents do not support these loop primitives: ${unsupportedFields.join(', ')}. Use runAiAgentText for agents that require these loop controls.`,
962
+ )
963
+ }
115
964
  }
116
965
 
117
966
  interface ResolvedAgentModel {
@@ -131,7 +980,6 @@ function resolveAgentModel(
131
980
  tenantAllowlist?: TenantAllowlistSnapshot | null,
132
981
  ): ResolvedAgentModel {
133
982
  const effectiveContainer = container ?? createContainer()
134
- const allowRuntimeModelOverride = agent.allowRuntimeModelOverride !== false
135
983
  const resolution = createModelFactory(effectiveContainer).resolveModel({
136
984
  moduleId: agent.moduleId,
137
985
  agentDefaultModel: agent.defaultModel,
@@ -140,7 +988,7 @@ function resolveAgentModel(
140
988
  callerOverride: modelOverride,
141
989
  providerOverride,
142
990
  baseUrlOverride,
143
- allowRuntimeModelOverride,
991
+ allowRuntimeOverride: resolveAllowRuntimeOverride(agent),
144
992
  tenantOverride: tenantOverride ?? undefined,
145
993
  requestOverride: requestOverride ?? undefined,
146
994
  tenantAllowlist: tenantAllowlist ?? null,
@@ -603,6 +1451,12 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
603
1451
  input.authContext.organizationId,
604
1452
  ),
605
1453
  ])
1454
+ // Phase 6.2 — resolve the effective session id. `sessionId` takes precedence
1455
+ // over the deprecated `conversationId` alias. When neither is supplied, a
1456
+ // fresh UUID is generated server-side so token-usage rows and pending-action
1457
+ // idempotency hashes are always correlated within the same session.
1458
+ const effectiveSessionId = (input.sessionId ?? input.conversationId) || randomUUID()
1459
+
606
1460
  const { agent, tools } = await resolveAiAgentTools({
607
1461
  agentId: input.agentId,
608
1462
  authContext: input.authContext,
@@ -610,7 +1464,7 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
610
1464
  attachmentIds: input.attachmentIds,
611
1465
  mutationPolicyOverride,
612
1466
  container: input.container,
613
- conversationId: input.conversationId ?? null,
1467
+ conversationId: effectiveSessionId,
614
1468
  })
615
1469
 
616
1470
  const resolvedAttachments = await resolveAttachmentPartsForAgent({
@@ -633,7 +1487,7 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
633
1487
  mutationPolicyOverride,
634
1488
  )
635
1489
 
636
- const { model } = resolveAgentModel(
1490
+ const resolvedModel = resolveAgentModel(
637
1491
  agent,
638
1492
  input.modelOverride,
639
1493
  input.providerOverride,
@@ -643,33 +1497,201 @@ export async function runAiAgentText(input: RunAiAgentTextInput): Promise<Respon
643
1497
  input.requestOverride,
644
1498
  tenantAllowlistSnapshot,
645
1499
  )
1500
+ const { model } = resolvedModel
646
1501
  const normalizedMessages = ensureUiMessageShape(input.messages)
647
1502
  const hydratedMessages = attachAttachmentsToMessages(normalizedMessages, resolvedAttachments)
648
1503
  const modelMessages = await convertToModelMessages(hydratedMessages)
649
- // Default to 10 agentic steps when the agent does not declare maxSteps.
650
- // Without stopWhen the AI SDK runs a single model call and never executes
651
- // tool calls, which makes every tool-using query return an empty stream.
652
- const effectiveMaxSteps = typeof agent.maxSteps === 'number' && agent.maxSteps > 0
653
- ? agent.maxSteps
654
- : 10
655
- const stopWhen = stepCountIs(effectiveMaxSteps)
656
-
657
- const streamArgs: Parameters<typeof streamText>[0] = {
1504
+
1505
+ const effectiveLoop = resolveEffectiveLoopConfig(agent, input.loop, WRAPPER_DEFAULT_LOOP_CHAT)
1506
+ const stopConditions = translateStopConditions(effectiveLoop, sanitizeToolNameForModel)
1507
+ const wrapperPrepareStep = buildWrapperPrepareStep(agent, effectiveLoop, tools)
1508
+ const sdkActiveTools = mapActiveToolsForModel(effectiveLoop.activeTools, tools)
1509
+ const sdkToolChoice = mapToolChoiceForModel(effectiveLoop.toolChoice)
1510
+
1511
+ // Phase 3 + Phase 4 — budget enforcement + LoopTrace collection.
1512
+ // Layer order (outer inner):
1513
+ // budgetEnforcer.wire(traceOnStepFinish) → traceOnStepFinish calls userOnStepFinish
1514
+ // The trace collector builds the per-turn LoopTrace; the budget enforcer
1515
+ // aborts via AbortController when any limit is exceeded.
1516
+ // Phase 6.2 — generate a per-call turnId as a UUID.
1517
+ const turnId = randomUUID()
1518
+ const loopTraceCollector = buildLoopTraceCollector(agent.id, effectiveSessionId, turnId, effectiveLoop.onStepFinish)
1519
+ const abortController = new AbortController()
1520
+ const budgetEnforcer = new BudgetEnforcer(effectiveLoop.budget, abortController)
1521
+ const tracedOnStepFinish = budgetEnforcer.wire(loopTraceCollector.onStepFinish)
1522
+
1523
+ // Phase 6.3 — wire the token-usage recorder into the wrapper-owned onStepFinish.
1524
+ // The recorder fires AFTER the trace collector so modelId is already in the trace.
1525
+ // It is invoked as void (detached) and MUST NEVER throw per R12.
1526
+ // resolvedModel is already computed above (model, modelId, providerId).
1527
+
1528
+ let currentStepIndex = 0
1529
+ const wiredOnStepFinish: AiAgentLoopConfig['onStepFinish'] = async (event) => {
1530
+ const capturedStepIndex = currentStepIndex
1531
+ currentStepIndex += 1
1532
+
1533
+ if (tracedOnStepFinish) {
1534
+ await tracedOnStepFinish(event)
1535
+ }
1536
+ if (input.container) {
1537
+ const rawEvent = event as unknown as {
1538
+ usage?: { inputTokens?: number; outputTokens?: number; cachedInputTokens?: number; reasoningTokens?: number }
1539
+ finishReason?: string
1540
+ }
1541
+ void recordTokenUsage(
1542
+ {
1543
+ authContext: input.authContext,
1544
+ agentId: agent.id,
1545
+ moduleId: agent.moduleId,
1546
+ sessionId: effectiveSessionId,
1547
+ turnId,
1548
+ stepIndex: capturedStepIndex,
1549
+ providerId: resolvedModel.providerId,
1550
+ modelId: resolvedModel.modelId,
1551
+ usage: {
1552
+ inputTokens: rawEvent.usage?.inputTokens,
1553
+ outputTokens: rawEvent.usage?.outputTokens,
1554
+ cachedInputTokens: rawEvent.usage?.cachedInputTokens,
1555
+ reasoningTokens: rawEvent.usage?.reasoningTokens,
1556
+ },
1557
+ finishReason: rawEvent.finishReason,
1558
+ loopAbortReason: budgetEnforcer.abortReason ?? undefined,
1559
+ },
1560
+ input.container,
1561
+ )
1562
+ }
1563
+ }
1564
+
1565
+ let wallClockTimer: ReturnType<typeof setTimeout> | undefined
1566
+ if (effectiveLoop.budget?.maxWallClockMs) {
1567
+ wallClockTimer = setTimeout(() => {
1568
+ budgetEnforcer.recordStep({ toolCalls: 0 })
1569
+ }, effectiveLoop.budget.maxWallClockMs)
1570
+ }
1571
+
1572
+ // Phase 5 — construct ToolLoopAgent when executionEngine === 'tool-loop-agent'.
1573
+ // The agent is built ONCE per turn (not pooled) with:
1574
+ // - model + tools from the wrapper-resolved registry
1575
+ // - stopWhen wired at construction (ToolLoopAgentSettings field)
1576
+ // - prepareStep wired at construction (NOT via prepareCall — it is not in
1577
+ // prepareCall's Pick list per spec §Phase 5 correction)
1578
+ // - onStepFinish wired at construction (budget + trace collector)
1579
+ // - prepareCall used only for per-turn narrowing of model/tools/stopWhen/
1580
+ // activeTools/providerOptions (per spec §Phase 5 correction)
1581
+ let builtToolLoopAgent: ToolLoopAgent<never, ToolSet> | undefined
1582
+ if (agent.executionEngine === 'tool-loop-agent') {
1583
+ const agentSettings: ToolLoopAgentSettings<never, ToolSet> = {
1584
+ model,
1585
+ tools: tools as ToolSet,
1586
+ stopWhen: stopConditions,
1587
+ prepareStep: wrapperPrepareStep,
1588
+ onStepFinish: wiredOnStepFinish,
1589
+ ...(effectiveLoop.repairToolCall !== undefined
1590
+ ? { experimental_repairToolCall: effectiveLoop.repairToolCall }
1591
+ : {}),
1592
+ ...(sdkActiveTools !== undefined ? { activeTools: sdkActiveTools } : {}),
1593
+ ...(sdkToolChoice !== undefined ? { toolChoice: sdkToolChoice } : {}),
1594
+ }
1595
+ builtToolLoopAgent = new ToolLoopAgent(agentSettings)
1596
+ }
1597
+
1598
+ const preparedOptions: PreparedAiSdkOptions = {
658
1599
  model,
1600
+ tools,
659
1601
  system: systemPrompt,
660
1602
  messages: modelMessages,
661
- tools,
662
- stopWhen,
1603
+ maxSteps: effectiveLoop.maxSteps ?? 10,
1604
+ stopWhen: stopConditions,
1605
+ prepareStep: wrapperPrepareStep,
1606
+ onStepFinish: wiredOnStepFinish,
1607
+ onStepStart: effectiveLoop.onStepStart,
1608
+ onToolCallStart: effectiveLoop.onToolCallStart,
1609
+ onToolCallFinish: effectiveLoop.onToolCallFinish,
1610
+ experimental_repairToolCall: effectiveLoop.repairToolCall,
1611
+ activeTools: sdkActiveTools,
1612
+ toolChoice: sdkToolChoice,
1613
+ abortSignal: abortController.signal,
1614
+ finalizeLoopTrace: () => loopTraceCollector.finalize(budgetEnforcer.abortReason),
1615
+ ...(builtToolLoopAgent !== undefined ? { toolLoopAgent: builtToolLoopAgent } : {}),
1616
+ }
1617
+
1618
+ if (input.generateText) {
1619
+ try {
1620
+ const callbackResult = await input.generateText(preparedOptions)
1621
+ const baseResponse = (callbackResult as StreamTextResult<ToolSet, never>).toUIMessageStreamResponse({
1622
+ sendReasoning: true,
1623
+ headers: {
1624
+ 'Cache-Control': 'no-cache, no-transform',
1625
+ Connection: 'keep-alive',
1626
+ },
1627
+ })
1628
+ if (input.emitLoopTrace) {
1629
+ return appendLoopFinishToStream(baseResponse, preparedOptions.finalizeLoopTrace)
1630
+ }
1631
+ return baseResponse
1632
+ } finally {
1633
+ if (wallClockTimer !== undefined) clearTimeout(wallClockTimer)
1634
+ }
1635
+ }
1636
+
1637
+ // Phase 5 — engine dispatch: tool-loop-agent path vs default stream-text path.
1638
+ if (builtToolLoopAgent !== undefined) {
1639
+ // `ToolLoopAgent.stream` dispatches via the agent's own prepareCall/prepareStep
1640
+ // pipeline. prepareStep is already wired at construction (security-critical).
1641
+ const agentStreamResult = await builtToolLoopAgent.stream({
1642
+ messages: modelMessages,
1643
+ abortSignal: abortController.signal,
1644
+ onStepFinish: wiredOnStepFinish,
1645
+ })
1646
+ if (wallClockTimer !== undefined) {
1647
+ const clearTimer = () => clearTimeout(wallClockTimer!)
1648
+ Promise.resolve(agentStreamResult.consumeStream()).then(clearTimer, clearTimer)
1649
+ }
1650
+ const baseResponse = agentStreamResult.toUIMessageStreamResponse({
1651
+ sendReasoning: true,
1652
+ headers: {
1653
+ 'Cache-Control': 'no-cache, no-transform',
1654
+ Connection: 'keep-alive',
1655
+ },
1656
+ })
1657
+ if (input.emitLoopTrace) {
1658
+ return appendLoopFinishToStream(baseResponse, preparedOptions.finalizeLoopTrace)
1659
+ }
1660
+ return baseResponse
663
1661
  }
664
1662
 
665
- const result = streamText(streamArgs)
666
- return result.toUIMessageStreamResponse({
1663
+ // Default stream-text path (executionEngine === 'stream-text' or unset).
1664
+ const result = streamText({
1665
+ model,
1666
+ system: systemPrompt,
1667
+ messages: modelMessages,
1668
+ tools,
1669
+ stopWhen: stopConditions as never,
1670
+ prepareStep: wrapperPrepareStep as never,
1671
+ onStepFinish: wiredOnStepFinish as never,
1672
+ experimental_onStepStart: effectiveLoop.onStepStart as never,
1673
+ experimental_onToolCallStart: effectiveLoop.onToolCallStart as never,
1674
+ experimental_onToolCallFinish: effectiveLoop.onToolCallFinish as never,
1675
+ experimental_repairToolCall: effectiveLoop.repairToolCall as never,
1676
+ ...(sdkActiveTools !== undefined ? { activeTools: sdkActiveTools } : {}),
1677
+ ...(sdkToolChoice !== undefined ? { toolChoice: sdkToolChoice } : {}),
1678
+ abortSignal: abortController.signal,
1679
+ })
1680
+ if (wallClockTimer !== undefined) {
1681
+ const clearTimer = () => clearTimeout(wallClockTimer!)
1682
+ Promise.resolve(result.consumeStream()).then(clearTimer, clearTimer)
1683
+ }
1684
+ const baseResponse = result.toUIMessageStreamResponse({
667
1685
  sendReasoning: true,
668
1686
  headers: {
669
1687
  'Cache-Control': 'no-cache, no-transform',
670
1688
  Connection: 'keep-alive',
671
1689
  },
672
1690
  })
1691
+ if (input.emitLoopTrace) {
1692
+ return appendLoopFinishToStream(baseResponse, preparedOptions.finalizeLoopTrace)
1693
+ }
1694
+ return baseResponse
673
1695
  }
674
1696
 
675
1697
  /**
@@ -725,7 +1747,7 @@ export interface RunAiAgentObjectInput<TSchema = ZodTypeAny> {
725
1747
  * Per-request HTTP dispatcher override (query params `?provider=`, `?model=`,
726
1748
  * `?baseUrl=`). Validated by the dispatcher route before being forwarded
727
1749
  * here. Wins over tenantOverride and all lower-priority sources when
728
- * `agent.allowRuntimeModelOverride !== false`.
1750
+ * `agent.allowRuntimeOverride !== false`.
729
1751
  *
730
1752
  * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
731
1753
  */
@@ -737,6 +1759,36 @@ export interface RunAiAgentObjectInput<TSchema = ZodTypeAny> {
737
1759
  output?: RunAiAgentObjectOutputOverride<TSchema>
738
1760
  debug?: boolean
739
1761
  container?: AwilixContainer
1762
+ /**
1763
+ * Optional stable per-run session id for token-usage correlation.
1764
+ * Object-mode runs are single-turn by definition but the session id lets
1765
+ * callers group multiple object runs together for reporting.
1766
+ *
1767
+ * Phase 6.2 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
1768
+ */
1769
+ sessionId?: string | null
1770
+ /**
1771
+ * Optional per-call loop config override for object mode. Only the
1772
+ * object-safe subset is accepted: `maxSteps`, `budget`, `onStepFinish`,
1773
+ * `onStepStart`, and `allowRuntimeOverride`. Providing any chat-only
1774
+ * field (`prepareStep`, `repairToolCall`, `stopWhen`, `activeTools`,
1775
+ * `toolChoice`) throws `AgentPolicyError` code
1776
+ * `loop_unsupported_in_object_mode`.
1777
+ *
1778
+ * Phase 1 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
1779
+ */
1780
+ loop?: Pick<AiAgentLoopConfig, 'maxSteps' | 'budget' | 'onStepFinish' | 'onStepStart' | 'allowRuntimeOverride'>
1781
+ /**
1782
+ * Optional escape-hatch callback receiving the fully prepared object-mode
1783
+ * options bag. When supplied the wrapper still enforces all policy guardrails
1784
+ * and then delegates the actual SDK call to this function. The callback MUST
1785
+ * return a value compatible with `GenerateObjectResult` or `StreamObjectResult`.
1786
+ *
1787
+ * Phase 2 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
1788
+ */
1789
+ generateObject?: (
1790
+ options: PreparedAiSdkObjectOptions,
1791
+ ) => Promise<GenerateObjectResult<unknown> | StreamObjectResult<unknown, unknown, unknown>>
740
1792
  }
741
1793
 
742
1794
  export type RunAiAgentObjectGenerateResult<TSchema> = {
@@ -883,18 +1935,76 @@ export async function runAiAgentObject<TSchema = unknown>(
883
1935
  resolvedAttachments,
884
1936
  )
885
1937
  const modelMessages = await convertToModelMessages(hydratedMessages)
886
- const stopWhen = typeof agent.maxSteps === 'number' && agent.maxSteps > 0
887
- ? stepCountIs(agent.maxSteps)
888
- : undefined
1938
+ void tools
1939
+
1940
+ // Phase 6.2 — resolve session id and generate per-call turn id for
1941
+ // token-usage correlation in object mode.
1942
+ const effectiveObjectSessionId = input.sessionId ?? randomUUID()
1943
+ const objectTurnId = randomUUID()
1944
+ // Expose for token recorder via closure (used by token-usage-recorder in Phase 6.3).
1945
+ void effectiveObjectSessionId
1946
+ void objectTurnId
1947
+
1948
+ if (input.loop) {
1949
+ assertLoopObjectModeCompatible(input.loop)
1950
+ }
1951
+ const effectiveLoop = resolveEffectiveLoopConfig(agent, input.loop, WRAPPER_DEFAULT_LOOP_OBJECT)
1952
+
1953
+ const abortController = new AbortController()
1954
+
1955
+ const preparedObjectOptions: PreparedAiSdkObjectOptions = {
1956
+ model,
1957
+ system: systemPrompt,
1958
+ messages: modelMessages,
1959
+ schemaName: resolvedOutput.schemaName,
1960
+ schema: resolvedOutput.schema,
1961
+ maxSteps: effectiveLoop.maxSteps,
1962
+ onStepFinish: effectiveLoop.onStepFinish,
1963
+ onStepStart: effectiveLoop.onStepStart,
1964
+ abortSignal: abortController.signal,
1965
+ }
1966
+
1967
+ if (input.generateObject) {
1968
+ const callbackResult = await input.generateObject(preparedObjectOptions)
1969
+ const typedResult = callbackResult as unknown as Record<string, unknown>
1970
+ if ('partialObjectStream' in typedResult) {
1971
+ const streamResult = typedResult as {
1972
+ object: Promise<TSchema>
1973
+ partialObjectStream: AsyncIterable<Partial<TSchema>>
1974
+ textStream: AsyncIterable<string>
1975
+ finishReason?: Promise<string | undefined>
1976
+ usage?: Promise<{ inputTokens?: number; outputTokens?: number } | undefined>
1977
+ }
1978
+ return {
1979
+ mode: 'stream',
1980
+ object: streamResult.object,
1981
+ partialObjectStream: streamResult.partialObjectStream,
1982
+ textStream: streamResult.textStream,
1983
+ finishReason: streamResult.finishReason,
1984
+ usage: streamResult.usage,
1985
+ }
1986
+ }
1987
+ const genResult = typedResult as { object: unknown; finishReason?: string; usage?: { inputTokens?: number; outputTokens?: number } }
1988
+ return {
1989
+ mode: 'generate',
1990
+ object: genResult.object as TSchema,
1991
+ finishReason: genResult.finishReason,
1992
+ usage: genResult.usage,
1993
+ }
1994
+ }
889
1995
 
890
1996
  if (resolvedOutput.mode === 'stream') {
891
- const streamArgs: Parameters<typeof streamObject>[0] = {
1997
+ const streamArgs = {
892
1998
  model,
893
1999
  system: systemPrompt,
894
2000
  messages: modelMessages,
895
2001
  schema: resolvedOutput.schema as never,
896
2002
  schemaName: resolvedOutput.schemaName,
897
- }
2003
+ ...(effectiveLoop.maxSteps !== undefined ? { maxSteps: effectiveLoop.maxSteps } : {}),
2004
+ onStepFinish: effectiveLoop.onStepFinish,
2005
+ onStepStart: effectiveLoop.onStepStart,
2006
+ abortSignal: abortController.signal,
2007
+ } as Parameters<typeof streamObject>[0]
898
2008
  const result = streamObject(streamArgs) as unknown as {
899
2009
  object: Promise<TSchema>
900
2010
  partialObjectStream: AsyncIterable<Partial<TSchema>>
@@ -912,22 +2022,17 @@ export async function runAiAgentObject<TSchema = unknown>(
912
2022
  }
913
2023
  }
914
2024
 
915
- const generateArgs: Parameters<typeof generateObject>[0] = {
2025
+ const generateArgs = {
916
2026
  model,
917
2027
  system: systemPrompt,
918
2028
  messages: modelMessages,
919
2029
  schema: resolvedOutput.schema as never,
920
2030
  schemaName: resolvedOutput.schemaName,
921
- }
922
- if (stopWhen) {
923
- // generateObject shares `CallSettings` with generateText; stopWhen is ignored
924
- // by the typed surface but harmless for providers that respect it. Tools
925
- // flow through the system prompt only in object mode today — the whitelist
926
- // has already been resolved via `resolveAiAgentTools` above, even if we
927
- // don't hand it to generateObject.
928
- ;(generateArgs as Record<string, unknown>).stopWhen = stopWhen
929
- }
930
- void tools
2031
+ ...(effectiveLoop.maxSteps !== undefined ? { maxSteps: effectiveLoop.maxSteps } : {}),
2032
+ onStepFinish: effectiveLoop.onStepFinish,
2033
+ onStepStart: effectiveLoop.onStepStart,
2034
+ abortSignal: abortController.signal,
2035
+ } as Parameters<typeof generateObject>[0]
931
2036
 
932
2037
  const result = await generateObject(generateArgs)
933
2038
  return {