@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +30 -4
- package/dist/frontend/components/AiChatButton.js +3 -2
- package/dist/frontend/components/AiChatButton.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +364 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -7
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +182 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js +316 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +8 -7
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +43 -20
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/settings/route.js +4 -3
- package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/usage/daily/route.js +111 -0
- package/dist/modules/ai_assistant/api/usage/daily/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js +108 -0
- package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/usage/sessions/route.js +153 -0
- package/dist/modules/ai_assistant/api/usage/sessions/route.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +335 -38
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -7
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +44 -35
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js +282 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js +25 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/cli.js +12 -0
- package/dist/modules/ai_assistant/cli.js.map +2 -2
- package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +1 -1
- package/dist/modules/ai_assistant/data/entities.js +177 -1
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +104 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js +168 -0
- package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js.map +7 -0
- package/dist/modules/ai_assistant/events.js +8 -0
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +74 -1
- package/dist/modules/ai_assistant/i18n/en.json +74 -1
- package/dist/modules/ai_assistant/i18n/es.json +75 -2
- package/dist/modules/ai_assistant/i18n/pl.json +74 -1
- package/dist/modules/ai_assistant/lib/agent-policy.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-runtime.js +588 -23
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
- package/dist/modules/ai_assistant/lib/agent-tools.js +6 -1
- package/dist/modules/ai_assistant/lib/agent-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-factory.js +63 -22
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/token-usage-recorder.js +78 -0
- package/dist/modules/ai_assistant/lib/token-usage-recorder.js.map +7 -0
- package/dist/modules/ai_assistant/lib/usage-serialization.js +33 -0
- package/dist/modules/ai_assistant/lib/usage-serialization.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js +25 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js +88 -0
- package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js.map +7 -0
- package/dist/modules/ai_assistant/setup.js +34 -0
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js +114 -0
- package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +7 -0
- package/generated/entities/ai_token_usage_daily/index.ts +16 -0
- package/generated/entities/ai_token_usage_event/index.ts +19 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +47 -1
- package/package.json +15 -7
- package/src/frontend/components/AiChatButton.tsx +3 -2
- package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +521 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -8
- package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +231 -0
- package/src/modules/ai_assistant/__tests__/events.test.ts +4 -3
- package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +5 -5
- package/src/modules/ai_assistant/__tests__/token-usage-recorder.test.ts +109 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.ts +388 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +5 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +8 -7
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +102 -5
- package/src/modules/ai_assistant/api/ai/chat/route.ts +55 -18
- package/src/modules/ai_assistant/api/settings/route.ts +5 -3
- package/src/modules/ai_assistant/api/usage/daily/__tests__/route.test.ts +159 -0
- package/src/modules/ai_assistant/api/usage/daily/route.ts +126 -0
- package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/__tests__/route.test.ts +143 -0
- package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/route.ts +130 -0
- package/src/modules/ai_assistant/api/usage/sessions/__tests__/route.test.ts +123 -0
- package/src/modules/ai_assistant/api/usage/sessions/route.ts +184 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +372 -16
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +1 -4
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +26 -9
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.tsx +469 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.ts +23 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.tsx +12 -0
- package/src/modules/ai_assistant/cli.ts +18 -0
- package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +1 -1
- package/src/modules/ai_assistant/data/entities.ts +237 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +135 -3
- package/src/modules/ai_assistant/data/repositories/AiTokenUsageRepository.ts +213 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +223 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiTokenUsageRepository.test.ts +58 -0
- package/src/modules/ai_assistant/events.ts +8 -0
- package/src/modules/ai_assistant/i18n/de.json +74 -1
- package/src/modules/ai_assistant/i18n/en.json +74 -1
- package/src/modules/ai_assistant/i18n/es.json +75 -2
- package/src/modules/ai_assistant/i18n/pl.json +74 -1
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase0.test.ts +439 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase1.test.ts +243 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase2.test.ts +388 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase3.test.ts +359 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +2 -2
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +2 -1
- package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +12 -13
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +77 -14
- package/src/modules/ai_assistant/lib/agent-policy.ts +9 -0
- package/src/modules/ai_assistant/lib/agent-runtime.ts +1148 -43
- package/src/modules/ai_assistant/lib/agent-tools.ts +5 -1
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +289 -2
- package/src/modules/ai_assistant/lib/model-factory.ts +128 -43
- package/src/modules/ai_assistant/lib/token-usage-recorder.ts +122 -0
- package/src/modules/ai_assistant/lib/usage-serialization.ts +29 -0
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +791 -0
- package/src/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.ts +25 -0
- package/src/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.ts +89 -0
- package/src/modules/ai_assistant/setup.ts +49 -0
- package/src/modules/ai_assistant/workers/__tests__/ai-token-usage-prune.test.ts +144 -0
- 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 {
|
|
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 {
|
|
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.
|
|
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
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* omitted
|
|
112
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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
|
-
|
|
666
|
-
|
|
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.
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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 {
|