@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
@@ -127,10 +127,14 @@ function toPolicyAuthContext(ctx: AiChatRequestContext): {
127
127
  * `^[a-zA-Z0-9_-]+$`; dots are replaced with double underscores (`__`).
128
128
  * Anthropic and Google accept both formats, so this is safe across providers.
129
129
  */
130
- function sanitizeToolNameForModel(name: string): string {
130
+ export function sanitizeToolNameForModel(name: string): string {
131
131
  return name.replace(/\./g, '__')
132
132
  }
133
133
 
134
+ export function desanitizeToolNameForDisplay(name: string): string {
135
+ return name.replace(/__/g, '.')
136
+ }
137
+
134
138
  function formatToolResult(result: unknown): string {
135
139
  if (result === null || result === undefined) return 'No result returned'
136
140
  if (typeof result === 'string') return result
@@ -1,8 +1,246 @@
1
1
  import type { AwilixContainer } from 'awilix'
2
2
  import type { ZodTypeAny } from 'zod'
3
+ import type {
4
+ PrepareStepFunction,
5
+ GenerateTextOnStepFinishCallback,
6
+ GenerateTextOnStepStartCallback,
7
+ GenerateTextOnToolCallStartCallback,
8
+ GenerateTextOnToolCallFinishCallback,
9
+ ToolCallRepairFunction,
10
+ StopCondition,
11
+ ToolChoice,
12
+ ToolSet,
13
+ } from 'ai'
3
14
 
4
15
  export type AiAgentExecutionMode = 'chat' | 'object'
5
16
 
17
+ /**
18
+ * Selects the underlying Vercel AI SDK dispatch strategy for this agent.
19
+ *
20
+ * - `'stream-text'` (default): the runtime calls `streamText(...)` directly on
21
+ * every turn. All loop primitives are supported: `prepareStep`, `stopWhen`,
22
+ * `repairToolCall`, `activeTools`, `toolChoice`.
23
+ *
24
+ * - `'tool-loop-agent'`: the runtime constructs a `ToolLoopAgent`
25
+ * (`Experimental_Agent`) once and dispatches via `agent.generate(...)` /
26
+ * `agent.stream(...)` per turn. The wrapper-owned `prepareStep` (security-
27
+ * critical for mutation-approval) is supplied at construction via
28
+ * `settings.prepareStep`. `stopWhen` is similarly wired at construction.
29
+ * The `prepareCall` hook is used for per-turn narrowing of `model`, `tools`,
30
+ * `stopWhen`, `activeTools`, and `providerOptions`; `prepareStep` is NOT in
31
+ * its `Pick` list and MUST NOT be threaded through it.
32
+ *
33
+ * Note: the current SDK version ships `experimental_repairToolCall` on
34
+ * `ToolLoopAgentSettings`, so `repairToolCall` is technically reachable via
35
+ * this engine. The `loop.repairToolCall` JSDoc retains a caveat reflecting
36
+ * the spec's documented limitation, which was written against an earlier SDK
37
+ * snapshot where the setting was absent — use with awareness that SDK
38
+ * behaviour may differ across versions.
39
+ *
40
+ * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
41
+ */
42
+ export type AiAgentExecutionEngine = 'stream-text' | 'tool-loop-agent'
43
+
44
+ /**
45
+ * A serializable stop condition for the agentic loop. The `kind` field
46
+ * determines which Vercel AI SDK helper is used at runtime:
47
+ * - `stepCount` → `stepCountIs(count)` — the loop stops after N steps.
48
+ * - `hasToolCall` → `hasToolCall(toolName)` — the loop stops immediately
49
+ * after the model emits a tool call for the named tool.
50
+ * - `custom` — a raw `StopCondition<ToolSet>` predicate supplied in code.
51
+ * NOT valid from JSON-only override sources (tenant DB overrides); only
52
+ * accepted when declared directly in `agent.loop` or a `runAiAgentText`
53
+ * caller override.
54
+ *
55
+ * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
56
+ */
57
+ export type AiAgentLoopStopCondition =
58
+ | { kind: 'stepCount'; count: number }
59
+ | { kind: 'hasToolCall'; toolName: string }
60
+ | { kind: 'custom'; stop: StopCondition<ToolSet> }
61
+
62
+ /**
63
+ * Budget limits for the agentic loop turn. When any limit is exceeded the
64
+ * wrapper's `prepareStep`/`onStepFinish` aborts the turn via the per-turn
65
+ * `AbortController` and the loop terminates with a `loop_budget_exceeded`
66
+ * finish condition.
67
+ *
68
+ * Budget enforcement is implemented in Phase 1782-3; for Phases 0–2 the
69
+ * fields are accepted and forwarded to the prepared-options bag but are not
70
+ * actively enforced.
71
+ *
72
+ * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
73
+ */
74
+ export interface AiAgentLoopBudget {
75
+ /** Hard cap on tool calls across all steps in this turn. */
76
+ maxToolCalls?: number
77
+ /** Wall-clock cap (ms) per turn; runtime aborts via AbortController. */
78
+ maxWallClockMs?: number
79
+ /** Input+output token cap; aggregated from step `usage` fields. */
80
+ maxTokens?: number
81
+ }
82
+
83
+ /**
84
+ * First-class loop configuration for an AI agent. Supersedes the flat
85
+ * `maxSteps` alias on `AiAgentDefinition`.
86
+ *
87
+ * All fields are optional; the runtime falls back to the wrapper default
88
+ * (`{ maxSteps: 10 }` for chat, `{ maxSteps: undefined }` for object) when
89
+ * neither the agent nor the caller supplies any loop config.
90
+ *
91
+ * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
92
+ */
93
+ export interface AiAgentLoopConfig {
94
+ /** Maximum number of agentic steps before the loop is forced to stop. */
95
+ maxSteps?: number
96
+ /**
97
+ * Additional stop conditions. The wrapper ALWAYS composes these with
98
+ * `stepCountIs(maxSteps ?? 10)` so a misconfigured `hasToolCall` for a
99
+ * non-existent tool can never cause an infinite loop (R3 mitigation).
100
+ */
101
+ stopWhen?: AiAgentLoopStopCondition | AiAgentLoopStopCondition[]
102
+ /**
103
+ * Per-step preparation hook. The wrapper composes this with its own
104
+ * security-critical `prepareStep` that re-asserts the tool allowlist and
105
+ * mutation-approval wrapping per step.
106
+ *
107
+ * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`
108
+ * for object-mode agents.
109
+ */
110
+ prepareStep?: PrepareStepFunction<ToolSet>
111
+ /**
112
+ * Callback fired when a step finishes. The wrapper chains its own
113
+ * aggregation callback (LoopTrace builder) before invoking this one.
114
+ * Exceptions thrown by this callback are caught and logged but do not
115
+ * abort the turn (matching the SDK's own contract).
116
+ */
117
+ onStepFinish?: GenerateTextOnStepFinishCallback<ToolSet>
118
+ /**
119
+ * Callback fired when a step starts. Forwarded to the AI SDK as
120
+ * `experimental_onStepStart`.
121
+ */
122
+ onStepStart?: GenerateTextOnStepStartCallback<ToolSet>
123
+ /**
124
+ * Callback fired when a tool call starts. Forwarded to the AI SDK as
125
+ * `experimental_onToolCallStart`.
126
+ */
127
+ onToolCallStart?: GenerateTextOnToolCallStartCallback<ToolSet>
128
+ /**
129
+ * Callback fired when a tool call finishes. Forwarded to the AI SDK as
130
+ * `experimental_onToolCallFinish`.
131
+ */
132
+ onToolCallFinish?: GenerateTextOnToolCallFinishCallback<ToolSet>
133
+ /**
134
+ * Tool-call repair function. Forwarded to the AI SDK as
135
+ * `experimental_repairToolCall`.
136
+ *
137
+ * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`
138
+ * for object-mode agents.
139
+ *
140
+ * **Engine note**: this primitive is honored under `executionEngine: 'stream-text'`
141
+ * (default). Agents on `'tool-loop-agent'` may not reliably support
142
+ * `repairToolCall` across all SDK versions — if you require it, use the
143
+ * default `stream-text` engine until support is confirmed stable on the
144
+ * `ToolLoopAgent` class.
145
+ *
146
+ * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
147
+ */
148
+ repairToolCall?: ToolCallRepairFunction<ToolSet>
149
+ /**
150
+ * Narrow the active tool surface for each step. Names must be a subset of
151
+ * `agent.allowedTools`; any names outside the allowlist are filtered out
152
+ * with a `loop:active_tools_filtered` warning.
153
+ *
154
+ * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`
155
+ * for object-mode agents.
156
+ */
157
+ activeTools?: string[]
158
+ /**
159
+ * Tool choice strategy forwarded to the AI SDK on each step.
160
+ *
161
+ * Only valid for chat agents. Rejected with `loop_unsupported_in_object_mode`
162
+ * for object-mode agents.
163
+ */
164
+ toolChoice?: ToolChoice<ToolSet>
165
+ /** Budget caps for this loop turn. */
166
+ budget?: AiAgentLoopBudget
167
+ /**
168
+ * When `false`, per-call `runAiAgentText({ loop })` / HTTP query-param
169
+ * overrides are rejected with `AgentPolicyError` code
170
+ * `loop_runtime_override_disabled`. Default is `true` (permissive).
171
+ *
172
+ * Agents that pin a loop policy for correctness reasons (e.g. a
173
+ * `stopWhen: hasToolCall(...)` that must not be bypassed by callers)
174
+ * should set this to `false`.
175
+ */
176
+ allowRuntimeOverride?: boolean
177
+ /**
178
+ * Kill switch — when `true`, the runtime forces `stopWhen: stepCountIs(1)` and
179
+ * ignores all other loop config. Used by the per-tenant operator override to
180
+ * collapse an agent to a single model call (no tool execution) without
181
+ * disabling the agent entirely.
182
+ *
183
+ * Phase 3 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
184
+ */
185
+ disabled?: boolean
186
+ }
187
+
188
+ /**
189
+ * Per-step record aggregated by the wrapper-owned `onStepFinish` hook into
190
+ * `LoopTrace`. Each completed agentic step produces one record.
191
+ *
192
+ * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
193
+ */
194
+ export interface LoopStepRecord {
195
+ stepIndex: number
196
+ /** Model id resolved for this step (relevant when prepareStep swaps models). */
197
+ modelId: string
198
+ toolCalls: Array<{
199
+ toolName: string
200
+ args: unknown
201
+ result?: unknown
202
+ error?: { code: string; message: string }
203
+ repairAttempted: boolean
204
+ durationMs: number
205
+ }>
206
+ /** Raw assistant text emitted in this step. */
207
+ textDelta: string
208
+ usage: { inputTokens: number; outputTokens: number }
209
+ finishReason: 'stop' | 'tool-calls' | 'length' | 'content-filter' | 'error'
210
+ }
211
+
212
+ /**
213
+ * Per-turn trace aggregated by the wrapper-owned `buildLoopTraceCollector`.
214
+ * Not persisted — in-memory only; surfaced via the dispatcher SSE stream and
215
+ * the playground/`<AiChat>` debug panel.
216
+ *
217
+ * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
218
+ */
219
+ export interface LoopTrace {
220
+ agentId: string
221
+ /**
222
+ * Stable per-conversation id that ties every turn together. Echoed back on
223
+ * the SSE `loop-finish` event so clients can persist it for subsequent turns.
224
+ *
225
+ * Phase 6.2 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
226
+ */
227
+ sessionId: string
228
+ turnId: string
229
+ steps: LoopStepRecord[]
230
+ stopReason:
231
+ | 'step-count'
232
+ | 'has-tool-call'
233
+ | 'custom-stop'
234
+ | 'budget-tokens'
235
+ | 'budget-tool-calls'
236
+ | 'budget-wall-clock'
237
+ | 'tenant-disabled'
238
+ | 'finish-reason'
239
+ | 'abort'
240
+ totalDurationMs: number
241
+ totalUsage: { inputTokens: number; outputTokens: number }
242
+ }
243
+
6
244
  export type AiAgentMutationPolicy =
7
245
  | 'read-only'
8
246
  | 'confirm-required'
@@ -46,6 +284,28 @@ export interface AiAgentDefinition {
46
284
  allowedTools: string[]
47
285
  suggestions?: AiAgentSuggestion[]
48
286
  executionMode?: AiAgentExecutionMode
287
+ /**
288
+ * Selects the underlying Vercel AI SDK dispatch strategy for this agent.
289
+ * Defaults to `'stream-text'` — the existing behavior and the only engine
290
+ * with unconditional full primitive coverage (`repairToolCall`, all loop
291
+ * controls).
292
+ *
293
+ * Set to `'tool-loop-agent'` to use the `ToolLoopAgent` (`Experimental_Agent`)
294
+ * class, which is closer to a semantic agent abstraction and receives upcoming
295
+ * SDK features (multi-agent handoff, streaming approval responses) first.
296
+ *
297
+ * **Note on `repairToolCall`**: the current SDK version ships
298
+ * `experimental_repairToolCall` on `ToolLoopAgentSettings`, so the primitive
299
+ * is technically available. However, SDK behaviour is not guaranteed to be
300
+ * identical across versions — prefer `'stream-text'` when `repairToolCall`
301
+ * correctness is critical.
302
+ *
303
+ * This field is opt-in: omitting it leaves the existing `stream-text` path
304
+ * completely unchanged.
305
+ *
306
+ * Phase 5 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
307
+ */
308
+ executionEngine?: AiAgentExecutionEngine
49
309
  /**
50
310
  * Optional provider id this agent prefers (e.g. `'openai'`, `'anthropic'`).
51
311
  * Must match a registered `LlmProvider.id`. When the named provider is
@@ -84,15 +344,26 @@ export interface AiAgentDefinition {
84
344
  defaultBaseUrl?: string
85
345
  /**
86
346
  * When false, per-request HTTP overrides (query params `provider`, `model`,
87
- * `baseUrl`) and the per-tenant settings override stored in
347
+ * `baseUrl`, `loopBudget`) and the per-tenant settings override stored in
88
348
  * `ai_agent_runtime_overrides` are both suppressed. Steps 1 and 3 of the
89
- * model-factory resolution chain are skipped for this agent.
349
+ * model-factory resolution chain are skipped for this agent, and the
350
+ * `loopBudget` query parameter is ignored by the chat dispatcher.
90
351
  *
91
352
  * Default is `true` (permissive). Agents that pin a specific model for
92
353
  * correctness reasons (e.g. a structured-output agent whose JSON-mode schema
93
354
  * only works with one provider) should set this to `false`.
94
355
  *
95
356
  * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
357
+ * Renamed from `allowRuntimeModelOverride` in Phase 4 of spec
358
+ * `2026-04-28-ai-agents-agentic-loop-controls`.
359
+ */
360
+ allowRuntimeOverride?: boolean
361
+ /**
362
+ * @deprecated Use `allowRuntimeOverride` instead. This alias is kept for
363
+ * one minor release and will be removed in a future version. The runtime
364
+ * checks `allowRuntimeOverride` first; if absent it falls back to this field.
365
+ *
366
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
96
367
  */
97
368
  allowRuntimeModelOverride?: boolean
98
369
  acceptedMediaTypes?: AiAgentAcceptedMediaType[]
@@ -100,7 +371,23 @@ export interface AiAgentDefinition {
100
371
  uiParts?: string[]
101
372
  readOnly?: boolean
102
373
  mutationPolicy?: AiAgentMutationPolicy
374
+ /**
375
+ * @deprecated Use `loop.maxSteps` instead. Honored as alias when `loop` is
376
+ * omitted. When both `maxSteps` and `loop.maxSteps` are specified, `loop.maxSteps`
377
+ * wins. This field will be removed in a future minor release.
378
+ *
379
+ * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
380
+ */
103
381
  maxSteps?: number
382
+ /**
383
+ * First-class agentic loop configuration. Supersedes the flat `maxSteps`
384
+ * alias. The runtime walks a precedence chain (per-call override → tenant
385
+ * DB override → this block → legacy `maxSteps` alias → wrapper default)
386
+ * to resolve the effective loop config for each turn.
387
+ *
388
+ * Phase 0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
389
+ */
390
+ loop?: AiAgentLoopConfig
104
391
  output?: AiAgentStructuredOutput
105
392
  resolvePageContext?: (ctx: AiAgentPageContextInput) => Promise<string | null>
106
393
  keywords?: string[]
@@ -23,15 +23,19 @@
23
23
  * 4. Global env `OM_AI_MODEL` (canonical) with `OPENCODE_MODEL` kept as
24
24
  * a backward-compatibility fallback. Accepts either a plain model id
25
25
  * (`gpt-5-mini`) or a slash-qualified id (`openai/gpt-5-mini`).
26
- * Slash qualifiers consume the provider axis at the same step a
27
- * higher-priority provider source still wins, but a lower-priority
28
- * one cannot overwrite a slash-qualified model.
26
+ * Slash qualifiers consume the provider axis at the same step. When the
27
+ * selected model source carries a provider hint, the provider/model pair
28
+ * is atomic: an unconfigured hinted provider fails instead of sending
29
+ * the model id to a different configured provider.
29
30
  * 5. The configured provider's own default model id
30
31
  * (`provider.defaultModel`).
31
32
  *
32
33
  * Every model-axis source is parsed through {@link parseSlashShorthand}.
33
34
  * Resolution walks the chain top-down and takes the first non-null hint as
34
- * the registry-walk seed:
35
+ * the registry-walk seed. If the winning model source also supplies the
36
+ * winning provider hint — either through `<provider>/<model>` or through the
37
+ * same-source provider field/env var — that pair is resolved exactly and
38
+ * never mixed with a fallback provider.
35
39
  *
36
40
  * Provider-axis seed order (highest priority first):
37
41
  * 1. Slash-prefix from `callerOverride` (Phase 1).
@@ -104,8 +108,10 @@ export interface AiModelFactoryInput {
104
108
  agentDefaultModel?: string
105
109
  /**
106
110
  * Agent-level default provider, typically `AiAgentDefinition.defaultProvider`.
107
- * Named provider id; falls through transparently when the named provider is
108
- * registered-but-unconfigured. Sits between `OM_AI_<MODULE>_PROVIDER`
111
+ * Named provider id. When paired with an agent default model, the pair is
112
+ * resolved exactly and fails if the provider is unconfigured. When used as
113
+ * a provider preference without an agent default model, it can fall through
114
+ * to the next configured provider. Sits between `OM_AI_<MODULE>_PROVIDER`
109
115
  * and the global `OM_AI_PROVIDER` in the provider-axis seed list above.
110
116
  *
111
117
  * Phase 1 of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
@@ -149,9 +155,9 @@ export interface AiModelFactoryInput {
149
155
  * between the caller/request override (step 1–2) and the module-env axis
150
156
  * (step 4).
151
157
  *
152
- * Honored ONLY when `allowRuntimeModelOverride !== false` on the agent
153
- * definition. The agent runtime is responsible for hydration — the factory
154
- * does NOT load the row itself.
158
+ * Honored ONLY when `allowRuntimeOverride !== false` on the agent definition
159
+ * (checked via `resolveAllowRuntimeOverride`). The agent runtime is
160
+ * responsible for hydration — the factory does NOT load the row itself.
155
161
  *
156
162
  * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
157
163
  */
@@ -165,8 +171,9 @@ export interface AiModelFactoryInput {
165
171
  * (`?provider=`, `?model=`, `?baseUrl=`). Sits at step 1 of the resolution
166
172
  * chain — wins over everything else for that turn.
167
173
  *
168
- * Honored ONLY when `allowRuntimeModelOverride !== false` on the agent.
169
- * The dispatcher validates all three values before setting this input.
174
+ * Honored ONLY when `allowRuntimeOverride !== false` on the agent (checked
175
+ * via `resolveAllowRuntimeOverride`). The dispatcher validates all three
176
+ * values before setting this input.
170
177
  *
171
178
  * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
172
179
  */
@@ -178,8 +185,19 @@ export interface AiModelFactoryInput {
178
185
  /**
179
186
  * When false, steps 1 (requestOverride) and 3 (tenantOverride) of the
180
187
  * resolution chain are skipped. Agents that pin a specific model for
181
- * correctness reasons set `AiAgentDefinition.allowRuntimeModelOverride =
182
- * false`. Default behavior (omitted) is permissive (= true).
188
+ * correctness reasons set `AiAgentDefinition.allowRuntimeOverride = false`.
189
+ * Default behavior (omitted) is permissive (= true).
190
+ *
191
+ * Canonical field (renamed from `allowRuntimeModelOverride` in Phase 4 of
192
+ * spec `2026-04-28-ai-agents-agentic-loop-controls`). The deprecated alias
193
+ * `allowRuntimeModelOverride` is still accepted via the resolution helper
194
+ * {@link resolveAllowRuntimeOverride}.
195
+ *
196
+ * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
197
+ */
198
+ allowRuntimeOverride?: boolean
199
+ /**
200
+ * @deprecated Use `allowRuntimeOverride` instead.
183
201
  *
184
202
  * Phase 4a of spec `2026-04-27-ai-agents-provider-model-baseurl-overrides`.
185
203
  */
@@ -410,6 +428,28 @@ function normalizeProviderHint(
410
428
  return providerIdAliases(providerId)[0] ?? providerId
411
429
  }
412
430
 
431
+ function resolveRequiredProvider(
432
+ providerId: string,
433
+ registry: AiModelFactoryRegistry,
434
+ env: EnvLookup,
435
+ ): LlmProvider | null {
436
+ const resolved = registry.resolveFirstConfigured({ env, order: [providerId] })
437
+ if (resolved?.id === providerId) return resolved
438
+
439
+ const direct = registry.get?.(providerId) ?? null
440
+ if (direct) return direct.isConfigured(env) ? direct : null
441
+ return null
442
+ }
443
+
444
+ function requiredProviderMessage(providerId: string, registry: AiModelFactoryRegistry, env: EnvLookup): string {
445
+ const provider = registry.get?.(providerId) ?? null
446
+ const envKey = provider?.getConfiguredEnvKey?.(env)
447
+ const credentialHint = envKey
448
+ ? ` Set ${envKey} to use this provider.`
449
+ : ' Configure the matching provider API key to use this provider.'
450
+ return `The resolved model is pinned to provider "${providerId}", but that provider is not configured.${credentialHint} The runtime refuses to send provider-specific model ids to a different provider.`
451
+ }
452
+
413
453
  function moduleBaseUrlEnvVarName(moduleId: string): string {
414
454
  return `${moduleId.toUpperCase()}_AI_BASE_URL`
415
455
  }
@@ -443,6 +483,23 @@ export function parseSlashShorthand(
443
483
  return { providerHint: before, modelId: after }
444
484
  }
445
485
 
486
+ /**
487
+ * Resolves the effective `allowRuntimeOverride` flag from an input that may
488
+ * carry either the new canonical name (`allowRuntimeOverride`) or the
489
+ * deprecated alias (`allowRuntimeModelOverride`). The canonical name wins
490
+ * when both are present. Returns `true` (permissive) when neither is set.
491
+ *
492
+ * Exported for test coverage.
493
+ */
494
+ export function resolveAllowRuntimeOverride(input: {
495
+ allowRuntimeOverride?: boolean
496
+ allowRuntimeModelOverride?: boolean
497
+ }): boolean {
498
+ if (input.allowRuntimeOverride !== undefined) return input.allowRuntimeOverride !== false
499
+ if (input.allowRuntimeModelOverride !== undefined) return input.allowRuntimeModelOverride !== false
500
+ return true
501
+ }
502
+
446
503
  /**
447
504
  * Creates an {@link AiModelFactory} bound to the DI container. The container
448
505
  * reference is accepted for API symmetry with other runtime helpers (and so
@@ -460,9 +517,9 @@ export function createModelFactory(
460
517
  return {
461
518
  resolveModel(input: AiModelFactoryInput): AiModelResolution {
462
519
  const hasModule = typeof input.moduleId === 'string' && input.moduleId.length > 0
463
- // When allowRuntimeModelOverride is explicitly false, skip steps 1
464
- // (requestOverride) and 3 (tenantOverride) — the agent pins a model.
465
- const runtimeOverridesAllowed = input.allowRuntimeModelOverride !== false
520
+ // When allowRuntimeOverride (or its deprecated alias allowRuntimeModelOverride)
521
+ // is explicitly false, skip steps 1 (requestOverride) and 3 (tenantOverride).
522
+ const runtimeOverridesAllowed = resolveAllowRuntimeOverride(input)
466
523
 
467
524
  // --- Step 1: requestOverride (HTTP query params) — gated by flag ---
468
525
  const requestModelRaw = runtimeOverridesAllowed
@@ -506,10 +563,6 @@ export function createModelFactory(
506
563
  const agentModelParsed = agentModelRaw ? parseSlashShorthand(agentModelRaw, registry) : null
507
564
  const globalModelParsed = globalModelRaw ? parseSlashShorthand(globalModelRaw, registry) : null
508
565
 
509
- // --- Provider-axis: walk from highest to lowest priority for the seed.
510
- // A slash-qualified hint from a model source wins over a plain provider
511
- // source at the same priority step. We walk top-down and take the first
512
- // non-null hint.
513
566
  const providerOverrideRaw = normalizeOverride(input.providerOverride)
514
567
  const moduleProviderRaw = hasModule
515
568
  ? readModuleProviderEnvOverride(env, input.moduleId!)
@@ -519,70 +572,102 @@ export function createModelFactory(
519
572
  // a backward-compatibility fallback through readGlobalProviderFromEnv.
520
573
  const globalProviderRaw = readGlobalProviderFromEnv(env, registry)
521
574
 
575
+ const requestProviderHint = normalizeProviderHint(requestProviderRaw, registry)
576
+ const providerOverrideHint = normalizeProviderHint(providerOverrideRaw, registry)
577
+ const tenantProviderHint = normalizeProviderHint(tenantProviderRaw, registry)
578
+ const moduleProviderHint = normalizeProviderHint(moduleProviderRaw, registry)
579
+ const agentDefaultProviderHint = normalizeProviderHint(agentDefaultProviderRaw, registry)
580
+
522
581
  // Walk the provider-axis seed list: slash hint beats plain provider at
523
582
  // the same step. We keep only the first (highest-priority) non-null hint.
524
583
  const providerHintCandidates: Array<string | null> = [
525
584
  requestModelParsed?.providerHint ?? null,
526
- normalizeProviderHint(requestProviderRaw, registry),
585
+ requestProviderHint,
527
586
  callerParsed?.providerHint ?? null,
528
- normalizeProviderHint(providerOverrideRaw, registry),
587
+ providerOverrideHint,
529
588
  tenantModelParsed?.providerHint ?? null,
530
- normalizeProviderHint(tenantProviderRaw, registry),
589
+ tenantProviderHint,
531
590
  moduleModelParsed?.providerHint ?? null,
532
- normalizeProviderHint(moduleProviderRaw, registry),
591
+ moduleProviderHint,
533
592
  agentModelParsed?.providerHint ?? null,
534
- normalizeProviderHint(agentDefaultProviderRaw, registry),
593
+ agentDefaultProviderHint,
535
594
  globalModelParsed?.providerHint ?? null,
536
595
  globalProviderRaw,
537
596
  ]
538
597
  const orderHint = providerHintCandidates.find((hint) => hint !== null) ?? null
539
598
  const order = orderHint ? [orderHint] : undefined
540
599
 
541
- const provider = registry.resolveFirstConfigured({ env, order })
542
- if (!provider) {
543
- throw new AiModelFactoryError(
544
- 'no_provider_configured',
545
- 'No LLM provider is configured. Set OM_AI_PROVIDER (or the legacy OPENCODE_PROVIDER) plus a matching API key such as OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY, then restart the app. See https://docs.openmercato.com/framework/ai-assistant/overview.',
546
- )
547
- }
548
- const apiKey = provider.resolveApiKey(env)
549
- if (!apiKey) {
550
- throw new AiModelFactoryError(
551
- 'api_key_missing',
552
- `LLM provider "${provider.id}" is advertised as configured but resolveApiKey() returned empty.`,
553
- )
554
- }
600
+ const pairPlainProviderIfWinning = (providerHint: string | null): string | null =>
601
+ providerHint && providerHint === orderHint ? providerHint : null
555
602
 
556
- // --- Model-axis: use the post-parse model id from the winning source.
557
603
  let modelId: string
558
604
  let source: AiModelResolution['source']
605
+ let pairedProviderHint: string | null = null
559
606
  if (requestModelParsed) {
560
607
  modelId = requestModelParsed.modelId
561
608
  source = 'request_override'
609
+ pairedProviderHint = requestModelParsed.providerHint ?? pairPlainProviderIfWinning(requestProviderHint)
562
610
  } else if (callerParsed) {
563
611
  modelId = callerParsed.modelId
564
612
  source = 'caller_override'
613
+ pairedProviderHint = callerParsed.providerHint ?? pairPlainProviderIfWinning(providerOverrideHint)
565
614
  } else if (tenantModelParsed) {
566
615
  modelId = tenantModelParsed.modelId
567
616
  source = 'tenant_override'
617
+ pairedProviderHint = tenantModelParsed.providerHint ?? pairPlainProviderIfWinning(tenantProviderHint)
568
618
  } else if (moduleModelParsed) {
569
619
  modelId = moduleModelParsed.modelId
570
620
  source = 'module_env'
621
+ pairedProviderHint = moduleModelParsed.providerHint ?? pairPlainProviderIfWinning(moduleProviderHint)
571
622
  } else if (agentModelParsed) {
572
623
  modelId = agentModelParsed.modelId
573
624
  source = 'agent_default'
625
+ pairedProviderHint = agentModelParsed.providerHint ?? pairPlainProviderIfWinning(agentDefaultProviderHint)
574
626
  } else if (globalModelParsed) {
575
627
  modelId = globalModelParsed.modelId
576
628
  source = 'env_default'
629
+ pairedProviderHint = globalModelParsed.providerHint ?? pairPlainProviderIfWinning(globalProviderRaw)
577
630
  } else {
578
- modelId = provider.defaultModel
631
+ modelId = ''
579
632
  source = 'provider_default'
580
633
  }
581
634
 
635
+ // --- Provider-axis: walk from highest to lowest priority for the seed.
636
+ // A slash-qualified hint from a model source wins over a plain provider
637
+ // source at the same priority step. We walk top-down and take the first
638
+ // non-null hint.
639
+ const provider = pairedProviderHint
640
+ ? resolveRequiredProvider(pairedProviderHint, registry, env)
641
+ : registry.resolveFirstConfigured({ env, order })
642
+ if (!provider) {
643
+ if (pairedProviderHint) {
644
+ throw new AiModelFactoryError(
645
+ 'no_provider_configured',
646
+ requiredProviderMessage(pairedProviderHint, registry, env),
647
+ )
648
+ }
649
+ throw new AiModelFactoryError(
650
+ 'no_provider_configured',
651
+ 'No LLM provider is configured. Set OM_AI_PROVIDER (or the legacy OPENCODE_PROVIDER) plus a matching API key such as OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY, then restart the app. See https://docs.openmercato.com/framework/ai-assistant/overview.',
652
+ )
653
+ }
654
+ const apiKey = provider.resolveApiKey(env)
655
+ if (!apiKey) {
656
+ throw new AiModelFactoryError(
657
+ 'api_key_missing',
658
+ `LLM provider "${provider.id}" is advertised as configured but resolveApiKey() returned empty.`,
659
+ )
660
+ }
661
+
662
+ // --- Model-axis: use the post-parse model id from the winning source.
663
+ if (source === 'provider_default') {
664
+ modelId = provider.defaultModel
665
+ }
666
+
582
667
  // --- BaseURL-axis resolution (highest to lowest priority) ---
583
- // 1. requestOverride.baseURL (HTTP dispatcher) — gated by allowRuntimeModelOverride
668
+ // 1. requestOverride.baseURL (HTTP dispatcher) — gated by allowRuntimeOverride
584
669
  // 2. baseUrlOverride (programmatic caller)
585
- // 3. tenantOverride.baseURL (DB row) — gated by allowRuntimeModelOverride
670
+ // 3. tenantOverride.baseURL (DB row) — gated by allowRuntimeOverride
586
671
  // 4. <MODULE>_AI_BASE_URL env
587
672
  // 5. agentDefaultBaseUrl
588
673
  // Steps 6-7 (preset env + preset default) are handled inside the adapter's