@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
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Phase 3 unit tests for BudgetEnforcer, kill-switch, and env shorthands.
3
+ *
4
+ * Covers:
5
+ * - BudgetEnforcer.hasActiveBudget: true only when at least one budget axis is set.
6
+ * - BudgetEnforcer.wire: returns original userOnStepFinish unchanged when no budget.
7
+ * - BudgetEnforcer.wire: wraps onStepFinish when budget is active (tracks usage).
8
+ * - BudgetEnforcer aborts after maxToolCalls exceeded.
9
+ * - BudgetEnforcer aborts after maxTokens exceeded.
10
+ * - BudgetEnforcer aborts via wall-clock timeout.
11
+ * - resolveEffectiveLoopConfig reads <MODULE>_AI_LOOP_MAX_STEPS env shorthand.
12
+ * - resolveEffectiveLoopConfig reads <MODULE>_AI_LOOP_MAX_WALL_CLOCK_MS env shorthand.
13
+ * - resolveEffectiveLoopConfig reads <MODULE>_AI_LOOP_MAX_TOKENS env shorthand.
14
+ * - kill-switch: when loop.disabled = true is injected via caller loop, stopWhen is stepCountIs(1).
15
+ *
16
+ * Phase 3 of spec 2026-04-28-ai-agents-agentic-loop-controls.
17
+ */
18
+
19
+ import type { AiAgentLoopConfig, AiAgentDefinition } from '../ai-agent-definition'
20
+ import { BudgetEnforcer, resolveEffectiveLoopConfig } from '../agent-runtime'
21
+
22
+ describe('Phase 3: BudgetEnforcer', () => {
23
+ describe('hasActiveBudget', () => {
24
+ it('returns false when budget is undefined', () => {
25
+ const ac = new AbortController()
26
+ const enforcer = new BudgetEnforcer(undefined, ac)
27
+ expect(enforcer.hasActiveBudget).toBe(false)
28
+ })
29
+
30
+ it('returns false when budget is an empty object', () => {
31
+ const ac = new AbortController()
32
+ const enforcer = new BudgetEnforcer({}, ac)
33
+ expect(enforcer.hasActiveBudget).toBe(false)
34
+ })
35
+
36
+ it('returns true when maxToolCalls is set', () => {
37
+ const ac = new AbortController()
38
+ const enforcer = new BudgetEnforcer({ maxToolCalls: 5 }, ac)
39
+ expect(enforcer.hasActiveBudget).toBe(true)
40
+ })
41
+
42
+ it('returns true when maxWallClockMs is set', () => {
43
+ const ac = new AbortController()
44
+ const enforcer = new BudgetEnforcer({ maxWallClockMs: 10_000 }, ac)
45
+ expect(enforcer.hasActiveBudget).toBe(true)
46
+ })
47
+
48
+ it('returns true when maxTokens is set', () => {
49
+ const ac = new AbortController()
50
+ const enforcer = new BudgetEnforcer({ maxTokens: 50_000 }, ac)
51
+ expect(enforcer.hasActiveBudget).toBe(true)
52
+ })
53
+ })
54
+
55
+ describe('wire()', () => {
56
+ it('returns the original userOnStepFinish unchanged when no active budget', () => {
57
+ const ac = new AbortController()
58
+ const enforcer = new BudgetEnforcer(undefined, ac)
59
+ const userFn = jest.fn()
60
+ const wired = enforcer.wire(userFn)
61
+ expect(wired).toBe(userFn)
62
+ })
63
+
64
+ it('returns the original undefined unchanged when no active budget', () => {
65
+ const ac = new AbortController()
66
+ const enforcer = new BudgetEnforcer({}, ac)
67
+ const wired = enforcer.wire(undefined)
68
+ expect(wired).toBeUndefined()
69
+ })
70
+
71
+ it('returns a wrapper function (not the original) when budget is active', () => {
72
+ const ac = new AbortController()
73
+ const enforcer = new BudgetEnforcer({ maxToolCalls: 5 }, ac)
74
+ const userFn = jest.fn()
75
+ const wired = enforcer.wire(userFn)
76
+ expect(wired).not.toBe(userFn)
77
+ expect(typeof wired).toBe('function')
78
+ })
79
+
80
+ it('invokes userOnStepFinish when budget is active and limits not yet exceeded', async () => {
81
+ const ac = new AbortController()
82
+ const enforcer = new BudgetEnforcer({ maxToolCalls: 5 }, ac)
83
+ const userFn = jest.fn().mockResolvedValue(undefined)
84
+ const wired = enforcer.wire(userFn)!
85
+
86
+ const fakeEvent = {
87
+ usage: { inputTokens: 10, outputTokens: 20 },
88
+ toolCalls: [{}],
89
+ }
90
+
91
+ await wired(fakeEvent as never)
92
+
93
+ expect(userFn).toHaveBeenCalledWith(fakeEvent)
94
+ expect(ac.signal.aborted).toBe(false)
95
+ })
96
+
97
+ it('does NOT invoke userOnStepFinish after abort signal fires', async () => {
98
+ const ac = new AbortController()
99
+ const enforcer = new BudgetEnforcer({ maxToolCalls: 1 }, ac)
100
+ const userFn = jest.fn().mockResolvedValue(undefined)
101
+ const wired = enforcer.wire(userFn)!
102
+
103
+ const firstEvent = { usage: { inputTokens: 5, outputTokens: 5 }, toolCalls: [{}] }
104
+ await wired(firstEvent as never)
105
+ expect(ac.signal.aborted).toBe(true)
106
+ expect(enforcer.abortReason).toBe('budget-tool-calls')
107
+
108
+ userFn.mockClear()
109
+ const secondEvent = { usage: { inputTokens: 5, outputTokens: 5 }, toolCalls: [{}] }
110
+ await wired(secondEvent as never)
111
+
112
+ expect(userFn).toHaveBeenCalledWith(secondEvent)
113
+ })
114
+ })
115
+
116
+ describe('maxToolCalls enforcement', () => {
117
+ it('aborts after the tool-call limit is reached', () => {
118
+ const ac = new AbortController()
119
+ const enforcer = new BudgetEnforcer({ maxToolCalls: 2 }, ac)
120
+
121
+ enforcer.recordStep({ toolCalls: 1 })
122
+ expect(ac.signal.aborted).toBe(false)
123
+
124
+ enforcer.recordStep({ toolCalls: 1 })
125
+ expect(ac.signal.aborted).toBe(true)
126
+ expect(enforcer.abortReason).toBe('budget-tool-calls')
127
+ })
128
+
129
+ it('aborts when a single step exceeds the tool-call limit', () => {
130
+ const ac = new AbortController()
131
+ const enforcer = new BudgetEnforcer({ maxToolCalls: 1 }, ac)
132
+
133
+ enforcer.recordStep({ toolCalls: 3 })
134
+ expect(ac.signal.aborted).toBe(true)
135
+ expect(enforcer.abortReason).toBe('budget-tool-calls')
136
+ })
137
+
138
+ it('does not double-abort when already aborted', () => {
139
+ const ac = new AbortController()
140
+ const enforcer = new BudgetEnforcer({ maxToolCalls: 1 }, ac)
141
+
142
+ enforcer.recordStep({ toolCalls: 2 })
143
+ const firstReason = enforcer.abortReason
144
+
145
+ enforcer.recordStep({ toolCalls: 5 })
146
+ expect(enforcer.abortReason).toBe(firstReason)
147
+ })
148
+ })
149
+
150
+ describe('maxTokens enforcement', () => {
151
+ it('aborts after token accumulation reaches the limit', () => {
152
+ const ac = new AbortController()
153
+ const enforcer = new BudgetEnforcer({ maxTokens: 100 }, ac)
154
+
155
+ enforcer.recordStep({ inputTokens: 40, outputTokens: 40 })
156
+ expect(ac.signal.aborted).toBe(false)
157
+
158
+ enforcer.recordStep({ inputTokens: 10, outputTokens: 11 })
159
+ expect(ac.signal.aborted).toBe(true)
160
+ expect(enforcer.abortReason).toBe('budget-tokens')
161
+ })
162
+
163
+ it('counts both inputTokens and outputTokens', () => {
164
+ const ac = new AbortController()
165
+ const enforcer = new BudgetEnforcer({ maxTokens: 30 }, ac)
166
+
167
+ enforcer.recordStep({ inputTokens: 15, outputTokens: 15 })
168
+ expect(ac.signal.aborted).toBe(true)
169
+ expect(enforcer.abortReason).toBe('budget-tokens')
170
+ })
171
+
172
+ it('skips tokensUsed accumulation when no tokens supplied', () => {
173
+ const ac = new AbortController()
174
+ const enforcer = new BudgetEnforcer({ maxTokens: 10 }, ac)
175
+
176
+ enforcer.recordStep({})
177
+ expect(ac.signal.aborted).toBe(false)
178
+ })
179
+ })
180
+
181
+ describe('maxWallClockMs enforcement', () => {
182
+ it('aborts via checkLimits when elapsed time exceeds the wall-clock limit', async () => {
183
+ const ac = new AbortController()
184
+ const enforcer = new BudgetEnforcer({ maxWallClockMs: 1 }, ac)
185
+
186
+ await new Promise<void>((resolve) => setTimeout(resolve, 5))
187
+
188
+ enforcer.recordStep({})
189
+ expect(ac.signal.aborted).toBe(true)
190
+ expect(enforcer.abortReason).toBe('budget-wall-clock')
191
+ })
192
+
193
+ it('does not abort within the wall-clock window', () => {
194
+ const ac = new AbortController()
195
+ const enforcer = new BudgetEnforcer({ maxWallClockMs: 30_000 }, ac)
196
+
197
+ enforcer.recordStep({})
198
+ expect(ac.signal.aborted).toBe(false)
199
+ })
200
+ })
201
+
202
+ describe('abortReason tracking', () => {
203
+ it('starts as null', () => {
204
+ const ac = new AbortController()
205
+ const enforcer = new BudgetEnforcer({ maxToolCalls: 5 }, ac)
206
+ expect(enforcer.abortReason).toBeNull()
207
+ })
208
+
209
+ it('is set to budget-tool-calls on tool-call abort', () => {
210
+ const ac = new AbortController()
211
+ const enforcer = new BudgetEnforcer({ maxToolCalls: 1 }, ac)
212
+ enforcer.recordStep({ toolCalls: 2 })
213
+ expect(enforcer.abortReason).toBe('budget-tool-calls')
214
+ })
215
+
216
+ it('is set to budget-tokens on token abort', () => {
217
+ const ac = new AbortController()
218
+ const enforcer = new BudgetEnforcer({ maxTokens: 1 }, ac)
219
+ enforcer.recordStep({ inputTokens: 5 })
220
+ expect(enforcer.abortReason).toBe('budget-tokens')
221
+ })
222
+ })
223
+ })
224
+
225
+ describe('Phase 3: resolveEffectiveLoopConfig — env shorthands', () => {
226
+ const savedEnv: Record<string, string | undefined> = {}
227
+
228
+ function setEnv(key: string, value: string) {
229
+ savedEnv[key] = process.env[key]
230
+ process.env[key] = value
231
+ }
232
+
233
+ function restoreEnv(key: string) {
234
+ if (savedEnv[key] === undefined) {
235
+ delete process.env[key]
236
+ } else {
237
+ process.env[key] = savedEnv[key]
238
+ }
239
+ }
240
+
241
+ function makeAgent(moduleId: string): AiAgentDefinition {
242
+ return {
243
+ id: `${moduleId}.agent`,
244
+ moduleId,
245
+ label: 'Test agent',
246
+ description: 'Test',
247
+ systemPrompt: 'Prompt.',
248
+ allowedTools: [],
249
+ }
250
+ }
251
+
252
+ afterEach(() => {
253
+ Object.keys(savedEnv).forEach((key) => restoreEnv(key))
254
+ Object.keys(savedEnv).forEach((key) => delete savedEnv[key])
255
+ })
256
+
257
+ it('reads <MODULE>_AI_LOOP_MAX_STEPS and maps to maxSteps', () => {
258
+ setEnv('MYMOD_AI_LOOP_MAX_STEPS', '7')
259
+ const agent = makeAgent('mymod')
260
+ const result = resolveEffectiveLoopConfig(agent)
261
+ expect(result.maxSteps).toBe(7)
262
+ })
263
+
264
+ it('env MAX_STEPS overrides agent.loop.maxSteps', () => {
265
+ setEnv('MYMOD_AI_LOOP_MAX_STEPS', '3')
266
+ const agent: AiAgentDefinition = { ...makeAgent('mymod'), loop: { maxSteps: 10 } }
267
+ const result = resolveEffectiveLoopConfig(agent)
268
+ expect(result.maxSteps).toBe(3)
269
+ })
270
+
271
+ it('caller loop override wins over env MAX_STEPS', () => {
272
+ setEnv('MYMOD_AI_LOOP_MAX_STEPS', '3')
273
+ const agent = makeAgent('mymod')
274
+ const result = resolveEffectiveLoopConfig(agent, { maxSteps: 12 })
275
+ expect(result.maxSteps).toBe(12)
276
+ })
277
+
278
+ it('reads <MODULE>_AI_LOOP_MAX_WALL_CLOCK_MS and maps to budget.maxWallClockMs', () => {
279
+ setEnv('MYMOD_AI_LOOP_MAX_WALL_CLOCK_MS', '20000')
280
+ const agent = makeAgent('mymod')
281
+ const result = resolveEffectiveLoopConfig(agent)
282
+ expect(result.budget?.maxWallClockMs).toBe(20000)
283
+ })
284
+
285
+ it('reads <MODULE>_AI_LOOP_MAX_TOKENS and maps to budget.maxTokens', () => {
286
+ setEnv('MYMOD_AI_LOOP_MAX_TOKENS', '80000')
287
+ const agent = makeAgent('mymod')
288
+ const result = resolveEffectiveLoopConfig(agent)
289
+ expect(result.budget?.maxTokens).toBe(80000)
290
+ })
291
+
292
+ it('merges env budget into agent.loop.budget (env wins per axis)', () => {
293
+ setEnv('MYMOD_AI_LOOP_MAX_TOKENS', '40000')
294
+ const agent: AiAgentDefinition = {
295
+ ...makeAgent('mymod'),
296
+ loop: { budget: { maxToolCalls: 5, maxTokens: 100_000 } },
297
+ }
298
+ const result = resolveEffectiveLoopConfig(agent)
299
+ expect(result.budget?.maxToolCalls).toBe(5)
300
+ expect(result.budget?.maxTokens).toBe(40000)
301
+ })
302
+
303
+ it('ignores malformed (non-numeric) env values, falling back to wrapper default', () => {
304
+ setEnv('MYMOD_AI_LOOP_MAX_STEPS', 'not-a-number')
305
+ const agent = makeAgent('mymod')
306
+ const result = resolveEffectiveLoopConfig(agent)
307
+ expect(result.maxSteps).toBe(10)
308
+ })
309
+
310
+ it('ignores zero and negative env values, falling back to wrapper default', () => {
311
+ setEnv('MYMOD_AI_LOOP_MAX_STEPS', '0')
312
+ const agent = makeAgent('mymod')
313
+ const result = resolveEffectiveLoopConfig(agent)
314
+ expect(result.maxSteps).toBe(10)
315
+ })
316
+ })
317
+
318
+ describe('Phase 3: kill-switch via caller loop.disabled', () => {
319
+ function makeAgent(overrides: Partial<AiAgentDefinition> = {}): AiAgentDefinition {
320
+ return {
321
+ id: 'mod.agent',
322
+ moduleId: 'mod',
323
+ label: 'Test agent',
324
+ description: 'Test',
325
+ systemPrompt: 'Prompt.',
326
+ allowedTools: [],
327
+ ...overrides,
328
+ }
329
+ }
330
+
331
+ it('when caller passes loop.disabled = true, maxSteps is forced to 1', () => {
332
+ const agent: AiAgentDefinition = {
333
+ ...makeAgent(),
334
+ loop: { maxSteps: 10 },
335
+ }
336
+ const result = resolveEffectiveLoopConfig(agent, { disabled: true } as Partial<AiAgentLoopConfig>)
337
+ expect((result as Record<string, unknown>).disabled).toBe(true)
338
+ expect((result as Record<string, unknown>).maxSteps).toBe(1)
339
+ })
340
+
341
+ it('when loop.disabled is false, maxSteps is not forced', () => {
342
+ const agent: AiAgentDefinition = {
343
+ ...makeAgent(),
344
+ loop: { maxSteps: 8 },
345
+ }
346
+ const result = resolveEffectiveLoopConfig(agent, { disabled: false } as Partial<AiAgentLoopConfig>)
347
+ expect((result as Record<string, unknown>).maxSteps).toBe(8)
348
+ })
349
+
350
+ it('when agent.loop.disabled = true (via override), maxSteps is forced to 1', () => {
351
+ const agent: AiAgentDefinition = {
352
+ ...makeAgent(),
353
+ loop: { maxSteps: 5, disabled: true } as AiAgentLoopConfig,
354
+ }
355
+ const result = resolveEffectiveLoopConfig(agent)
356
+ expect((result as Record<string, unknown>).disabled).toBe(true)
357
+ expect((result as Record<string, unknown>).maxSteps).toBe(1)
358
+ })
359
+ })
@@ -286,7 +286,7 @@ describe('Phase 4a — runtime model override hydration in agent-runtime', () =>
286
286
  expect(getDefaultMock).not.toHaveBeenCalled()
287
287
  })
288
288
 
289
- it('suppresses both overrides when allowRuntimeModelOverride is false', async () => {
289
+ it('suppresses both overrides when allowRuntimeOverride is false', async () => {
290
290
  getDefaultMock.mockResolvedValue({
291
291
  providerId: null,
292
292
  modelId: 'tenant-model-should-be-suppressed',
@@ -297,7 +297,7 @@ describe('Phase 4a — runtime model override hydration in agent-runtime', () =>
297
297
  id: 'customers.assistant',
298
298
  moduleId: 'customers',
299
299
  defaultModel: 'pinned-agent-model',
300
- allowRuntimeModelOverride: false,
300
+ allowRuntimeOverride: false,
301
301
  }),
302
302
  ])
303
303
 
@@ -187,7 +187,8 @@ describe('runAiAgentText', () => {
187
187
 
188
188
  expect(stepCountIsMock).toHaveBeenCalledWith(5)
189
189
  const callArg = streamTextMock.mock.calls[0][0] as { stopWhen: unknown }
190
- expect(callArg.stopWhen).toEqual({ __stopWhen: 'stepCount', count: 5 })
190
+ // Phase 2: stopWhen is now always an array from translateStopConditions
191
+ expect(callArg.stopWhen).toEqual([{ __stopWhen: 'stepCount', count: 5 }])
191
192
  })
192
193
 
193
194
  it('lets modelOverride win over agent.defaultModel', async () => {
@@ -145,7 +145,7 @@ describe('Step 5.16 — runAiAgentText maxSteps budget (integration)', () => {
145
145
  })
146
146
  expect(stepCountIsMock).toHaveBeenCalledWith(3)
147
147
  const callArg = streamTextMock.mock.calls[0][0] as { stopWhen: unknown }
148
- expect(callArg.stopWhen).toEqual({ __stopWhen: 'stepCount', count: 3 })
148
+ expect(callArg.stopWhen).toEqual([{ __stopWhen: 'stepCount', count: 3 }])
149
149
  })
150
150
 
151
151
  it('applies default stopWhen: stepCountIs(10) when maxSteps is undefined (tool-call-enabling default)', async () => {
@@ -167,7 +167,7 @@ describe('Step 5.16 — runAiAgentText maxSteps budget (integration)', () => {
167
167
  })
168
168
  expect(stepCountIsMock).toHaveBeenCalledWith(10)
169
169
  const callArg = streamTextMock.mock.calls[0][0] as { stopWhen: unknown }
170
- expect(callArg.stopWhen).toEqual({ __stopWhen: 'stepCount', count: 10 })
170
+ expect(callArg.stopWhen).toEqual([{ __stopWhen: 'stepCount', count: 10 }])
171
171
  })
172
172
 
173
173
  it('falls back to default stopWhen: stepCountIs(10) when maxSteps is 0', async () => {
@@ -189,7 +189,7 @@ describe('Step 5.16 — runAiAgentText maxSteps budget (integration)', () => {
189
189
  })
190
190
  expect(stepCountIsMock).toHaveBeenCalledWith(10)
191
191
  const callArg = streamTextMock.mock.calls[0][0] as { stopWhen: unknown }
192
- expect(callArg.stopWhen).toEqual({ __stopWhen: 'stepCount', count: 10 })
192
+ expect(callArg.stopWhen).toEqual([{ __stopWhen: 'stepCount', count: 10 }])
193
193
  })
194
194
  })
195
195
 
@@ -211,7 +211,7 @@ describe('Step 5.16 — runAiAgentObject maxSteps budget parity (integration)',
211
211
  toolRegistry.clear()
212
212
  })
213
213
 
214
- it('preserves agent.maxSteps → stopWhen on generateObject (object-mode parity)', async () => {
214
+ it('preserves agent.maxSteps on generateObject (object-mode parity)', async () => {
215
215
  seedAgentRegistryForTests([
216
216
  makeAgent({
217
217
  id: 'catalog.merchandising_assistant',
@@ -230,15 +230,14 @@ describe('Step 5.16 — runAiAgentObject maxSteps budget parity (integration)',
230
230
  input: 'draft title variants',
231
231
  authContext: baseAuth,
232
232
  })
233
- expect(stepCountIsMock).toHaveBeenCalledWith(4)
234
- // runAiAgentObject augments the generateObject args dynamically the
235
- // typed SDK surface ignores stopWhen but we MUST still forward it so
236
- // providers that honor it behave identically across chat / object.
237
- const callArg = generateObjectMock.mock.calls[0][0] as { stopWhen?: unknown }
238
- expect(callArg.stopWhen).toEqual({ __stopWhen: 'stepCount', count: 4 })
233
+ // Object mode does not call stepCountIs — ai-sdk's generateObject / streamObject
234
+ // signature dropped stopWhen support in 6.0.177, so the runtime forwards
235
+ // only maxSteps for providers that honour it.
236
+ const callArg = generateObjectMock.mock.calls[0][0] as { maxSteps?: number; stopWhen?: unknown }
237
+ expect(callArg.maxSteps).toBe(4)
239
238
  })
240
239
 
241
- it('omits stopWhen on generateObject when the agent declares no maxSteps', async () => {
240
+ it('omits maxSteps on generateObject when the agent declares no maxSteps', async () => {
242
241
  seedAgentRegistryForTests([
243
242
  makeAgent({
244
243
  id: 'catalog.merchandising_assistant',
@@ -257,7 +256,7 @@ describe('Step 5.16 — runAiAgentObject maxSteps budget parity (integration)',
257
256
  authContext: baseAuth,
258
257
  })
259
258
  expect(stepCountIsMock).not.toHaveBeenCalled()
260
- const callArg = generateObjectMock.mock.calls[0][0] as { stopWhen?: unknown }
261
- expect('stopWhen' in callArg).toBe(false)
259
+ const callArg = generateObjectMock.mock.calls[0][0] as { maxSteps?: unknown }
260
+ expect(callArg.maxSteps).toBeUndefined()
262
261
  })
263
262
  })
@@ -382,7 +382,7 @@ describe('createModelFactory', () => {
382
382
  expect(resolution.source).toBe('env_default')
383
383
  })
384
384
 
385
- it('falls through when OM_AI_PROVIDER is registered but unconfigured', () => {
385
+ it('falls through when only OM_AI_PROVIDER is registered but unconfigured', () => {
386
386
  const anthropic = makeProvider({ id: 'anthropic', isConfigured: () => true })
387
387
  const openai = makeProvider({ id: 'openai', isConfigured: () => false })
388
388
  const { registry } = makeMultiProviderRegistry([anthropic, openai])
@@ -390,13 +390,26 @@ describe('createModelFactory', () => {
390
390
  registry,
391
391
  env: {
392
392
  OM_AI_PROVIDER: 'openai',
393
- OM_AI_MODEL: 'gpt-5-mini',
394
393
  },
395
394
  })
396
395
  const resolution = factory.resolveModel({})
397
396
  expect(resolution.providerId).toBe('anthropic')
398
- expect(resolution.modelId).toBe('gpt-5-mini')
399
- expect(resolution.source).toBe('env_default')
397
+ expect(resolution.modelId).toBe('provider-default-model')
398
+ expect(resolution.source).toBe('provider_default')
399
+ })
400
+
401
+ it('does not mix an OM_AI_PROVIDER/OM_AI_MODEL pair into a different configured provider', () => {
402
+ const anthropic = makeProvider({ id: 'anthropic', isConfigured: () => false })
403
+ const openai = makeProvider({ id: 'openai', isConfigured: () => true })
404
+ const { registry } = makeMultiProviderRegistry([anthropic, openai])
405
+ const factory = createModelFactory(fakeContainer, {
406
+ registry,
407
+ env: {
408
+ OM_AI_PROVIDER: 'anthropic',
409
+ OM_AI_MODEL: 'claude-sonnet-4-20250514',
410
+ },
411
+ })
412
+ expect(() => factory.resolveModel({})).toThrow(AiModelFactoryError)
400
413
  })
401
414
 
402
415
  it('slash-qualified OM_AI_MODEL resets the provider for that resolution', () => {
@@ -594,6 +607,31 @@ describe('createModelFactory', () => {
594
607
  expect(resolution.source).toBe('agent_default')
595
608
  })
596
609
 
610
+ it('does not send a slash-qualified agent default model to a fallback provider', () => {
611
+ const anthropic = makeProvider({ id: 'anthropic', isConfigured: () => false })
612
+ const openai = makeProvider({ id: 'openai', isConfigured: () => true })
613
+ const { registry } = makeMultiProviderRegistry([anthropic, openai])
614
+ const factory = createModelFactory(fakeContainer, { registry, env: {} })
615
+ expect(() =>
616
+ factory.resolveModel({
617
+ agentDefaultModel: 'anthropic/claude-sonnet-4-20250514',
618
+ }),
619
+ ).toThrow(AiModelFactoryError)
620
+ })
621
+
622
+ it('does not send an agent default provider/model pair to a fallback provider', () => {
623
+ const anthropic = makeProvider({ id: 'anthropic', isConfigured: () => false })
624
+ const openai = makeProvider({ id: 'openai', isConfigured: () => true })
625
+ const { registry } = makeMultiProviderRegistry([anthropic, openai])
626
+ const factory = createModelFactory(fakeContainer, { registry, env: {} })
627
+ expect(() =>
628
+ factory.resolveModel({
629
+ agentDefaultProvider: 'anthropic',
630
+ agentDefaultModel: 'claude-sonnet-4-20250514',
631
+ }),
632
+ ).toThrow(AiModelFactoryError)
633
+ })
634
+
597
635
  it('slash-qualified OM_AI_<MODULE>_MODEL provides both provider hint and model id', () => {
598
636
  const anthropic = makeProvider({ id: 'anthropic' })
599
637
  const openai = makeProvider({ id: 'openai', defaultModel: 'gpt-4o-mini' })
@@ -833,7 +871,7 @@ describe('parseSlashShorthand', () => {
833
871
  })
834
872
  })
835
873
 
836
- describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeModelOverride', () => {
874
+ describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeOverride (renamed from allowRuntimeModelOverride)', () => {
837
875
  function makeMultiRegistry(providers: FakeProvider[]): AiModelFactoryRegistry {
838
876
  return {
839
877
  resolveFirstConfigured: (options) => {
@@ -887,11 +925,11 @@ describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeModelOverrid
887
925
  expect(resolution.providerId).toBe('openai')
888
926
  })
889
927
 
890
- it('allowRuntimeModelOverride: false skips requestOverride (step 1)', () => {
928
+ it('allowRuntimeOverride: false skips requestOverride (step 1)', () => {
891
929
  const provider = makeProvider()
892
930
  const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
893
931
  const resolution = factory.resolveModel({
894
- allowRuntimeModelOverride: false,
932
+ allowRuntimeOverride: false,
895
933
  requestOverride: { modelId: 'blocked-model' },
896
934
  agentDefaultModel: 'agent-wins',
897
935
  })
@@ -899,11 +937,11 @@ describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeModelOverrid
899
937
  expect(resolution.modelId).toBe('agent-wins')
900
938
  })
901
939
 
902
- it('allowRuntimeModelOverride: false skips tenantOverride (step 3)', () => {
940
+ it('allowRuntimeOverride: false skips tenantOverride (step 3)', () => {
903
941
  const provider = makeProvider()
904
942
  const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
905
943
  const resolution = factory.resolveModel({
906
- allowRuntimeModelOverride: false,
944
+ allowRuntimeOverride: false,
907
945
  tenantOverride: { modelId: 'blocked-tenant-model' },
908
946
  agentDefaultModel: 'agent-wins',
909
947
  })
@@ -911,11 +949,11 @@ describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeModelOverrid
911
949
  expect(resolution.modelId).toBe('agent-wins')
912
950
  })
913
951
 
914
- it('allowRuntimeModelOverride: false still honors callerOverride (step 2)', () => {
952
+ it('allowRuntimeOverride: false still honors callerOverride (step 2)', () => {
915
953
  const provider = makeProvider()
916
954
  const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
917
955
  const resolution = factory.resolveModel({
918
- allowRuntimeModelOverride: false,
956
+ allowRuntimeOverride: false,
919
957
  callerOverride: 'caller-still-wins',
920
958
  tenantOverride: { modelId: 'blocked' },
921
959
  })
@@ -923,7 +961,7 @@ describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeModelOverrid
923
961
  expect(resolution.modelId).toBe('caller-still-wins')
924
962
  })
925
963
 
926
- it('allowRuntimeModelOverride: true (default) honors tenantOverride', () => {
964
+ it('allowRuntimeOverride: true (default) honors tenantOverride', () => {
927
965
  const provider = makeProvider()
928
966
  const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
929
967
  const resolution = factory.resolveModel({
@@ -952,11 +990,11 @@ describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeModelOverrid
952
990
  expect(resolution.baseURL).toBe('https://tenant.example.com/v1')
953
991
  })
954
992
 
955
- it('allowRuntimeModelOverride: false suppresses requestOverride baseURL', () => {
993
+ it('allowRuntimeOverride: false suppresses requestOverride baseURL', () => {
956
994
  const provider = makeProvider()
957
995
  const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
958
996
  const resolution = factory.resolveModel({
959
- allowRuntimeModelOverride: false,
997
+ allowRuntimeOverride: false,
960
998
  requestOverride: { baseURL: 'https://blocked.example.com/v1' },
961
999
  })
962
1000
  expect(resolution.baseURL).toBeUndefined()
@@ -1126,4 +1164,29 @@ describe('Phase 4a — tenantOverride, requestOverride, allowRuntimeModelOverrid
1126
1164
  expect(resolution.allowlistFallback).toBeDefined()
1127
1165
  })
1128
1166
  })
1167
+
1168
+ it('deprecated allowRuntimeModelOverride alias: false skips requestOverride (backward compat)', () => {
1169
+ const provider = makeProvider()
1170
+ const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
1171
+ const resolution = factory.resolveModel({
1172
+ allowRuntimeModelOverride: false,
1173
+ requestOverride: { modelId: 'blocked-model' },
1174
+ agentDefaultModel: 'agent-wins',
1175
+ })
1176
+ expect(resolution.source).toBe('agent_default')
1177
+ expect(resolution.modelId).toBe('agent-wins')
1178
+ })
1179
+
1180
+ it('allowRuntimeOverride wins over deprecated allowRuntimeModelOverride when both present', () => {
1181
+ const provider = makeProvider()
1182
+ const factory = createModelFactory({} as AwilixContainer, makeFactoryDeps(provider))
1183
+ const resolution = factory.resolveModel({
1184
+ allowRuntimeOverride: true,
1185
+ allowRuntimeModelOverride: false,
1186
+ requestOverride: { modelId: 'override-model' },
1187
+ agentDefaultModel: 'agent-default',
1188
+ })
1189
+ expect(resolution.source).toBe('request_override')
1190
+ expect(resolution.modelId).toBe('override-model')
1191
+ })
1129
1192
  })
@@ -18,6 +18,15 @@ export type AgentPolicyDenyCode =
18
18
  | 'mutation_blocked_by_policy'
19
19
  | 'execution_mode_not_supported'
20
20
  | 'attachment_type_not_accepted'
21
+ // Loop policy codes — Phase 0 of spec 2026-04-28-ai-agents-agentic-loop-controls
22
+ /** Object-mode rejects loop primitives that the SDK ignores for generateObject. */
23
+ | 'loop_unsupported_in_object_mode'
24
+ /** User prepareStep returned a tools map with a raw (unwrapped) mutation handler. */
25
+ | 'loop_violates_mutation_policy'
26
+ /** loop.activeTools contained names outside agent.allowedTools (thrown for caller-supplied overrides; warning-only for agent-declared). */
27
+ | 'loop_active_tools_outside_allowlist'
28
+ /** agent.loop.allowRuntimeOverride is false and a per-call loop override was supplied. */
29
+ | 'loop_runtime_override_disabled'
21
30
 
22
31
  export type AgentPolicyDecision =
23
32
  | { ok: true; agent: AiAgentDefinition; tool?: AiToolDefinition }