@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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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 {
|
|
261
|
-
expect(
|
|
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('
|
|
399
|
-
expect(resolution.source).toBe('
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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 }
|