@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,388 @@
1
+ import { NextResponse, type NextRequest } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
+ import type { EntityManager } from '@mikro-orm/postgresql'
8
+ import { getAgent, loadAgentRegistry } from '../../../../../lib/agent-registry'
9
+ import { hasRequiredFeatures } from '../../../../../lib/auth'
10
+ import {
11
+ AiAgentRuntimeOverrideRepository,
12
+ AiAgentRuntimeOverrideValidationError,
13
+ } from '../../../../../data/repositories/AiAgentRuntimeOverrideRepository'
14
+ import type { AiAgentRuntimeOverride } from '../../../../../data/entities'
15
+
16
+ const agentIdPattern = /^[a-z0-9_]+\.[a-z0-9_]+$/
17
+
18
+ const agentIdParamSchema = z.object({
19
+ agentId: z
20
+ .string()
21
+ .regex(agentIdPattern, 'agentId must match "<module>.<agent>" (lowercase, digits, underscores only)'),
22
+ })
23
+
24
+ const loopOverrideRequestSchema = z.object({
25
+ loopDisabled: z.boolean().nullable().optional(),
26
+ loopMaxSteps: z.number().int().min(1).max(1000).nullable().optional(),
27
+ loopMaxToolCalls: z.number().int().min(1).max(10000).nullable().optional(),
28
+ loopMaxWallClockMs: z.number().int().min(100).max(3_600_000).nullable().optional(),
29
+ loopMaxTokens: z.number().int().min(1).max(10_000_000).nullable().optional(),
30
+ loopStopWhenJson: z
31
+ .array(
32
+ z.discriminatedUnion('kind', [
33
+ z.object({ kind: z.literal('stepCount'), count: z.number().int().min(1) }),
34
+ z.object({ kind: z.literal('hasToolCall'), toolName: z.string().min(1) }),
35
+ ]),
36
+ )
37
+ .nullable()
38
+ .optional(),
39
+ loopActiveToolsJson: z.array(z.string().min(1)).nullable().optional(),
40
+ })
41
+
42
+ const VIEW_FEATURE = 'ai_assistant.view'
43
+ const MANAGE_FEATURE = 'ai_assistant.settings.manage'
44
+
45
+ export const openApi: OpenApiRouteDoc = {
46
+ tag: 'AI Assistant',
47
+ summary: 'Tenant-scoped loop-policy override for an AI agent',
48
+ methods: {
49
+ GET: {
50
+ operationId: 'aiAssistantGetLoopOverride',
51
+ summary:
52
+ 'Read the current loop-policy override for this agent, if any.',
53
+ description:
54
+ 'Returns `{ agentId, override }` where `override` is the agent-scoped loop-policy ' +
55
+ 'row from `ai_agent_runtime_overrides` (or `null`). Requires `ai_assistant.view`.',
56
+ responses: [
57
+ {
58
+ status: 200,
59
+ description: 'Loop override payload.',
60
+ mediaType: 'application/json',
61
+ },
62
+ ],
63
+ errors: [
64
+ { status: 400, description: 'Invalid agent id.' },
65
+ { status: 401, description: 'Unauthenticated caller.' },
66
+ { status: 403, description: 'Caller lacks `ai_assistant.view`.' },
67
+ { status: 404, description: 'Unknown agent id.' },
68
+ ],
69
+ },
70
+ PUT: {
71
+ operationId: 'aiAssistantSaveLoopOverride',
72
+ summary: 'Set (or replace) the tenant-scoped loop-policy override for this agent.',
73
+ description:
74
+ 'Body: loop columns. All fields are nullable/optional; `null` explicitly clears ' +
75
+ 'that axis. Validates `loopStopWhenJson` items and `loopActiveToolsJson` membership. ' +
76
+ 'Requires `ai_assistant.settings.manage`.',
77
+ requestBody: {
78
+ contentType: 'application/json',
79
+ description: 'Loop override payload.',
80
+ schema: loopOverrideRequestSchema,
81
+ },
82
+ responses: [
83
+ {
84
+ status: 200,
85
+ description: 'Override persisted.',
86
+ mediaType: 'application/json',
87
+ },
88
+ ],
89
+ errors: [
90
+ { status: 400, description: 'Invalid agent id or validation error.' },
91
+ { status: 401, description: 'Unauthenticated caller.' },
92
+ { status: 403, description: 'Caller lacks `ai_assistant.settings.manage`.' },
93
+ { status: 404, description: 'Unknown agent id.' },
94
+ ],
95
+ },
96
+ DELETE: {
97
+ operationId: 'aiAssistantClearLoopOverride',
98
+ summary: 'Remove the loop-policy columns from the agent-scoped runtime override row.',
99
+ description:
100
+ 'Nulls out all seven loop columns on the agent-scoped `ai_agent_runtime_overrides` row. ' +
101
+ 'Idempotent — returns 200 even when no override exists. ' +
102
+ 'Requires `ai_assistant.settings.manage`.',
103
+ responses: [
104
+ {
105
+ status: 200,
106
+ description: 'Loop override cleared (or already absent).',
107
+ mediaType: 'application/json',
108
+ },
109
+ ],
110
+ errors: [
111
+ { status: 400, description: 'Invalid agent id.' },
112
+ { status: 401, description: 'Unauthenticated caller.' },
113
+ { status: 403, description: 'Caller lacks `ai_assistant.settings.manage`.' },
114
+ { status: 404, description: 'Unknown agent id.' },
115
+ ],
116
+ },
117
+ },
118
+ }
119
+
120
+ export const metadata = {
121
+ GET: { requireAuth: true, requireFeatures: [VIEW_FEATURE] },
122
+ PUT: { requireAuth: true, requireFeatures: [MANAGE_FEATURE] },
123
+ DELETE: { requireAuth: true, requireFeatures: [MANAGE_FEATURE] },
124
+ }
125
+
126
+ interface RouteContext {
127
+ params: Promise<{ agentId: string }>
128
+ }
129
+
130
+ function jsonError(
131
+ status: number,
132
+ message: string,
133
+ code: string,
134
+ extra?: Record<string, unknown>,
135
+ ): NextResponse {
136
+ return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
137
+ }
138
+
139
+ interface ResolvedAuth {
140
+ tenantId: string | null
141
+ organizationId: string | null
142
+ userId: string
143
+ isSuperAdmin: boolean
144
+ features: string[]
145
+ container: Awaited<ReturnType<typeof createRequestContainer>>
146
+ }
147
+
148
+ async function resolveAuthOrRespond(
149
+ req: NextRequest,
150
+ requiredFeature: string,
151
+ ): Promise<ResolvedAuth | NextResponse> {
152
+ const auth = await getAuthFromRequest(req)
153
+ if (!auth) {
154
+ return jsonError(401, 'Unauthorized', 'unauthenticated')
155
+ }
156
+ const container = await createRequestContainer()
157
+ const rbacService = container.resolve<RbacService>('rbacService')
158
+ const acl = await rbacService.loadAcl(auth.sub, {
159
+ tenantId: auth.tenantId,
160
+ organizationId: auth.orgId,
161
+ })
162
+ if (!hasRequiredFeatures([requiredFeature], acl.features, acl.isSuperAdmin, rbacService)) {
163
+ return jsonError(403, `Caller lacks required feature "${requiredFeature}".`, 'forbidden')
164
+ }
165
+ return {
166
+ tenantId: auth.tenantId ?? null,
167
+ organizationId: auth.orgId ?? null,
168
+ userId: auth.sub,
169
+ isSuperAdmin: acl.isSuperAdmin,
170
+ features: acl.features,
171
+ container,
172
+ }
173
+ }
174
+
175
+ function serializeLoopOverride(row: AiAgentRuntimeOverride) {
176
+ return {
177
+ id: row.id,
178
+ agentId: row.agentId ?? null,
179
+ loopDisabled: row.loopDisabled ?? null,
180
+ loopMaxSteps: row.loopMaxSteps ?? null,
181
+ loopMaxToolCalls: row.loopMaxToolCalls ?? null,
182
+ loopMaxWallClockMs: row.loopMaxWallClockMs ?? null,
183
+ loopMaxTokens: row.loopMaxTokens ?? null,
184
+ loopStopWhenJson: row.loopStopWhenJson ?? null,
185
+ loopActiveToolsJson: row.loopActiveToolsJson ?? null,
186
+ updatedAt: row.updatedAt?.toISOString?.() ?? new Date().toISOString(),
187
+ }
188
+ }
189
+
190
+ export async function GET(req: NextRequest, context: RouteContext): Promise<Response> {
191
+ const authResult = await resolveAuthOrRespond(req, VIEW_FEATURE)
192
+ if (authResult instanceof NextResponse) return authResult
193
+
194
+ const rawParams = await context.params
195
+ const paramResult = agentIdParamSchema.safeParse(rawParams)
196
+ if (!paramResult.success) {
197
+ return jsonError(400, 'Invalid agent id.', 'validation_error', {
198
+ issues: paramResult.error.issues,
199
+ })
200
+ }
201
+
202
+ try {
203
+ await loadAgentRegistry()
204
+ const agent = getAgent(paramResult.data.agentId)
205
+ if (!agent) {
206
+ return jsonError(404, `Unknown agent "${paramResult.data.agentId}".`, 'agent_unknown')
207
+ }
208
+
209
+ if (!authResult.tenantId) {
210
+ return NextResponse.json({ agentId: agent.id, override: null })
211
+ }
212
+
213
+ const em = authResult.container.resolve<EntityManager>('em')
214
+ const repo = new AiAgentRuntimeOverrideRepository(em)
215
+ const row = await repo.getDefault({
216
+ tenantId: authResult.tenantId,
217
+ organizationId: authResult.organizationId,
218
+ agentId: agent.id,
219
+ })
220
+
221
+ const hasLoopData =
222
+ row !== null &&
223
+ (row.loopDisabled !== null ||
224
+ row.loopMaxSteps !== null ||
225
+ row.loopMaxToolCalls !== null ||
226
+ row.loopMaxWallClockMs !== null ||
227
+ row.loopMaxTokens !== null ||
228
+ row.loopStopWhenJson !== null ||
229
+ row.loopActiveToolsJson !== null)
230
+
231
+ return NextResponse.json({
232
+ agentId: agent.id,
233
+ override: hasLoopData ? serializeLoopOverride(row!) : null,
234
+ })
235
+ } catch (error) {
236
+ console.error('[AI Loop Override GET] Failure:', error)
237
+ return jsonError(
238
+ 500,
239
+ error instanceof Error ? error.message : 'Failed to load loop override.',
240
+ 'internal_error',
241
+ )
242
+ }
243
+ }
244
+
245
+ export async function PUT(req: NextRequest, context: RouteContext): Promise<Response> {
246
+ const authResult = await resolveAuthOrRespond(req, MANAGE_FEATURE)
247
+ if (authResult instanceof NextResponse) return authResult
248
+
249
+ const rawParams = await context.params
250
+ const paramResult = agentIdParamSchema.safeParse(rawParams)
251
+ if (!paramResult.success) {
252
+ return jsonError(400, 'Invalid agent id.', 'validation_error', {
253
+ issues: paramResult.error.issues,
254
+ })
255
+ }
256
+
257
+ let parsedBody: unknown
258
+ try {
259
+ parsedBody = await req.json()
260
+ } catch {
261
+ return jsonError(400, 'Request body must be valid JSON.', 'validation_error')
262
+ }
263
+
264
+ const bodyResult = loopOverrideRequestSchema.safeParse(parsedBody)
265
+ if (!bodyResult.success) {
266
+ return jsonError(400, 'Invalid request body.', 'validation_error', {
267
+ issues: bodyResult.error.issues,
268
+ })
269
+ }
270
+
271
+ try {
272
+ await loadAgentRegistry()
273
+ const agent = getAgent(paramResult.data.agentId)
274
+ if (!agent) {
275
+ return jsonError(404, `Unknown agent "${paramResult.data.agentId}".`, 'agent_unknown')
276
+ }
277
+
278
+ if (!authResult.tenantId) {
279
+ return jsonError(
280
+ 400,
281
+ 'Caller has no tenant context; cannot persist tenant-scoped loop override.',
282
+ 'tenant_required',
283
+ )
284
+ }
285
+
286
+ const em = authResult.container.resolve<EntityManager>('em')
287
+ const repo = new AiAgentRuntimeOverrideRepository(em)
288
+ const row = await repo.upsertDefault(
289
+ {
290
+ agentId: agent.id,
291
+ agentAllowedTools: agent.allowedTools,
292
+ loopDisabled: bodyResult.data.loopDisabled ?? null,
293
+ loopMaxSteps: bodyResult.data.loopMaxSteps ?? null,
294
+ loopMaxToolCalls: bodyResult.data.loopMaxToolCalls ?? null,
295
+ loopMaxWallClockMs: bodyResult.data.loopMaxWallClockMs ?? null,
296
+ loopMaxTokens: bodyResult.data.loopMaxTokens ?? null,
297
+ loopStopWhenJson: bodyResult.data.loopStopWhenJson ?? null,
298
+ loopActiveToolsJson: bodyResult.data.loopActiveToolsJson ?? null,
299
+ },
300
+ {
301
+ tenantId: authResult.tenantId,
302
+ organizationId: authResult.organizationId,
303
+ userId: authResult.userId,
304
+ },
305
+ )
306
+
307
+ return NextResponse.json({
308
+ ok: true,
309
+ agentId: agent.id,
310
+ override: serializeLoopOverride(row),
311
+ })
312
+ } catch (error) {
313
+ if (error instanceof AiAgentRuntimeOverrideValidationError) {
314
+ return jsonError(400, error.message, error.code)
315
+ }
316
+ console.error('[AI Loop Override PUT] Failure:', error)
317
+ return jsonError(
318
+ 500,
319
+ error instanceof Error ? error.message : 'Failed to save loop override.',
320
+ 'internal_error',
321
+ )
322
+ }
323
+ }
324
+
325
+ export async function DELETE(req: NextRequest, context: RouteContext): Promise<Response> {
326
+ const authResult = await resolveAuthOrRespond(req, MANAGE_FEATURE)
327
+ if (authResult instanceof NextResponse) return authResult
328
+
329
+ const rawParams = await context.params
330
+ const paramResult = agentIdParamSchema.safeParse(rawParams)
331
+ if (!paramResult.success) {
332
+ return jsonError(400, 'Invalid agent id.', 'validation_error', {
333
+ issues: paramResult.error.issues,
334
+ })
335
+ }
336
+
337
+ try {
338
+ await loadAgentRegistry()
339
+ const agent = getAgent(paramResult.data.agentId)
340
+ if (!agent) {
341
+ return jsonError(404, `Unknown agent "${paramResult.data.agentId}".`, 'agent_unknown')
342
+ }
343
+
344
+ if (!authResult.tenantId) {
345
+ return NextResponse.json({ ok: true, agentId: agent.id, cleared: false })
346
+ }
347
+
348
+ const em = authResult.container.resolve<EntityManager>('em')
349
+ const repo = new AiAgentRuntimeOverrideRepository(em)
350
+
351
+ const existing = await repo.getDefault({
352
+ tenantId: authResult.tenantId,
353
+ organizationId: authResult.organizationId,
354
+ agentId: agent.id,
355
+ })
356
+
357
+ if (!existing) {
358
+ return NextResponse.json({ ok: true, agentId: agent.id, cleared: false })
359
+ }
360
+
361
+ await repo.upsertDefault(
362
+ {
363
+ agentId: agent.id,
364
+ loopDisabled: null,
365
+ loopMaxSteps: null,
366
+ loopMaxToolCalls: null,
367
+ loopMaxWallClockMs: null,
368
+ loopMaxTokens: null,
369
+ loopStopWhenJson: null,
370
+ loopActiveToolsJson: null,
371
+ },
372
+ {
373
+ tenantId: authResult.tenantId,
374
+ organizationId: authResult.organizationId,
375
+ userId: authResult.userId,
376
+ },
377
+ )
378
+
379
+ return NextResponse.json({ ok: true, agentId: agent.id, cleared: true })
380
+ } catch (error) {
381
+ console.error('[AI Loop Override DELETE] Failure:', error)
382
+ return jsonError(
383
+ 500,
384
+ error instanceof Error ? error.message : 'Failed to clear loop override.',
385
+ 'internal_error',
386
+ )
387
+ }
388
+ }
@@ -62,6 +62,11 @@ jest.mock('../../../../../../lib/model-factory', () => ({
62
62
  createModelFactory: jest.fn(() => ({
63
63
  resolveModel: resolveModelMock,
64
64
  })),
65
+ resolveAllowRuntimeOverride: (input: { allowRuntimeOverride?: boolean; allowRuntimeModelOverride?: boolean }) => {
66
+ if (input.allowRuntimeOverride !== undefined) return input.allowRuntimeOverride !== false
67
+ if (input.allowRuntimeModelOverride !== undefined) return input.allowRuntimeModelOverride !== false
68
+ return true
69
+ },
65
70
  }))
66
71
 
67
72
  import { GET } from '../route'
@@ -8,7 +8,7 @@ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacS
8
8
  import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
9
9
  import { getAgent, loadAgentRegistry } from '../../../../../lib/agent-registry'
10
10
  import { hasRequiredFeatures } from '../../../../../lib/auth'
11
- import { createModelFactory } from '../../../../../lib/model-factory'
11
+ import { createModelFactory, resolveAllowRuntimeOverride } from '../../../../../lib/model-factory'
12
12
  import {
13
13
  hasAllowlistSnapshotRestrictions,
14
14
  intersectEffectiveAllowlistWithSnapshot,
@@ -47,7 +47,7 @@ export const openApi: OpenApiRouteDoc = {
47
47
  description:
48
48
  'Returns all configured providers with their curated model catalogs, filtered to providers ' +
49
49
  'that have an API key configured in the current environment. When the agent declares ' +
50
- '`allowRuntimeModelOverride: false`, the response reflects that constraint so the ' +
50
+ '`allowRuntimeOverride: false`, the response reflects that constraint so the ' +
51
51
  'UI picker can hide itself. Includes the agent\'s resolved default provider/model so ' +
52
52
  'the picker can render a "(default)" badge next to the right entry. ' +
53
53
  'RBAC: requires the same features as the agent itself (typically `ai_assistant.view`).',
@@ -56,7 +56,7 @@ export const openApi: OpenApiRouteDoc = {
56
56
  status: 200,
57
57
  description:
58
58
  'Providers and curated models available for the agent picker. ' +
59
- 'Empty `providers` array when `allowRuntimeModelOverride` is false.',
59
+ 'Empty `providers` array when `allowRuntimeOverride` is false.',
60
60
  },
61
61
  ],
62
62
  errors: [
@@ -120,7 +120,7 @@ export async function GET(
120
120
  }
121
121
  }
122
122
 
123
- const allowRuntimeModelOverride = agent.allowRuntimeModelOverride !== false
123
+ const allowRuntimeOverride = resolveAllowRuntimeOverride(agent)
124
124
 
125
125
  // Load the per-tenant allowlist snapshot so the picker reflects both env
126
126
  // and admin-edited tenant constraints (Phase 1780-6).
@@ -181,7 +181,7 @@ export async function GET(
181
181
  agentDefaultModel: agent.defaultModel,
182
182
  agentDefaultProvider: agent.defaultProvider,
183
183
  agentDefaultBaseUrl: agent.defaultBaseUrl,
184
- allowRuntimeModelOverride,
184
+ allowRuntimeOverride,
185
185
  tenantOverride: tenantRuntimeOverride ?? undefined,
186
186
  tenantAllowlist: tenantAllowlistSnapshot,
187
187
  })
@@ -208,7 +208,7 @@ export async function GET(
208
208
  knownProviderIds,
209
209
  agentRuntimeOverrideAllowlist,
210
210
  )
211
- const providers = allowRuntimeModelOverride
211
+ const providers = allowRuntimeOverride
212
212
  ? llmProviderRegistry.list()
213
213
  .filter((provider) => provider.isConfigured())
214
214
  .filter((provider) => isProviderAllowedInEffective(effectiveAllowlist, provider.id))
@@ -234,7 +234,8 @@ export async function GET(
234
234
 
235
235
  return NextResponse.json({
236
236
  agentId,
237
- allowRuntimeModelOverride,
237
+ allowRuntimeOverride,
238
+ allowRuntimeModelOverride: allowRuntimeOverride,
238
239
  defaultProviderId,
239
240
  defaultModelId,
240
241
  defaultProviderName: llmProviderRegistry.get(defaultProviderId)?.name ?? defaultProviderId,
@@ -20,9 +20,13 @@ jest.mock('@open-mercato/shared/lib/di/container', () => ({
20
20
  createRequestContainer: (...args: unknown[]) => createRequestContainerMock(...args),
21
21
  }))
22
22
 
23
- jest.mock('../../../../lib/agent-runtime', () => ({
24
- runAiAgentText: (...args: unknown[]) => runAiAgentTextMock(...args),
25
- }))
23
+ jest.mock('../../../../lib/agent-runtime', () => {
24
+ const actual = jest.requireActual<typeof import('../../../../lib/agent-runtime')>('../../../../lib/agent-runtime')
25
+ return {
26
+ ...actual,
27
+ runAiAgentText: (...args: unknown[]) => runAiAgentTextMock(...args),
28
+ }
29
+ })
26
30
 
27
31
  const getMock = jest.fn()
28
32
  const listMock = jest.fn()
@@ -339,9 +343,9 @@ describe('POST /api/ai/chat', () => {
339
343
  })
340
344
  }
341
345
 
342
- it('returns 400 with code runtime_override_disabled when agent has allowRuntimeModelOverride: false', async () => {
346
+ it('returns 400 with code runtime_override_disabled when agent has allowRuntimeOverride: false', async () => {
343
347
  seedAgentRegistryForTests([
344
- makeAgent({ id: 'customers.assistant', moduleId: 'customers', allowRuntimeModelOverride: false }),
348
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers', allowRuntimeOverride: false }),
345
349
  ])
346
350
 
347
351
  const response = await POST(buildRequestWithOverrides({ provider: 'openai' }) as any)
@@ -552,4 +556,97 @@ describe('POST /api/ai/chat', () => {
552
556
  expect(json.error).toContain('env ∩ tenant')
553
557
  })
554
558
  })
559
+
560
+ describe('Phase 4 (1782) — loopBudget query-param (TC-AI-AGENT-LOOP-002)', () => {
561
+ function buildRequestWithLoopBudget(loopBudget: string): Request {
562
+ const url = new URL('http://localhost/api/ai/chat')
563
+ url.searchParams.set('agent', 'customers.assistant')
564
+ url.searchParams.set('loopBudget', loopBudget)
565
+ return new Request(url, {
566
+ method: 'POST',
567
+ body: JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }),
568
+ headers: { 'content-type': 'application/json' },
569
+ })
570
+ }
571
+
572
+ it('forwards tight preset budget to runAiAgentText', async () => {
573
+ seedAgentRegistryForTests([
574
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
575
+ ])
576
+
577
+ await POST(buildRequestWithLoopBudget('tight') as any)
578
+
579
+ expect(runAiAgentTextMock).toHaveBeenCalledTimes(1)
580
+ const callArg = runAiAgentTextMock.mock.calls[0][0] as {
581
+ loop?: {
582
+ maxSteps?: number
583
+ budget?: { maxToolCalls?: number; maxWallClockMs?: number; maxTokens?: number }
584
+ }
585
+ }
586
+ expect(callArg.loop?.maxSteps).toBe(3)
587
+ expect(callArg.loop?.budget).toEqual({
588
+ maxToolCalls: 3,
589
+ maxWallClockMs: 10_000,
590
+ maxTokens: 50_000,
591
+ })
592
+ })
593
+
594
+ it('forwards loose preset budget to runAiAgentText', async () => {
595
+ seedAgentRegistryForTests([
596
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
597
+ ])
598
+
599
+ await POST(buildRequestWithLoopBudget('loose') as any)
600
+
601
+ expect(runAiAgentTextMock).toHaveBeenCalledTimes(1)
602
+ const callArg = runAiAgentTextMock.mock.calls[0][0] as {
603
+ loop?: {
604
+ maxSteps?: number
605
+ budget?: { maxToolCalls?: number; maxWallClockMs?: number; maxTokens?: number }
606
+ }
607
+ }
608
+ expect(callArg.loop?.maxSteps).toBe(20)
609
+ expect(callArg.loop?.budget).toEqual({
610
+ maxToolCalls: 20,
611
+ maxWallClockMs: 120_000,
612
+ maxTokens: 500_000,
613
+ })
614
+ })
615
+
616
+ it('sends no loop override when loopBudget=default', async () => {
617
+ seedAgentRegistryForTests([
618
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
619
+ ])
620
+
621
+ await POST(buildRequestWithLoopBudget('default') as any)
622
+
623
+ expect(runAiAgentTextMock).toHaveBeenCalledTimes(1)
624
+ const callArg = runAiAgentTextMock.mock.calls[0][0] as {
625
+ loop?: unknown
626
+ }
627
+ expect(callArg.loop).toBeUndefined()
628
+ })
629
+
630
+ it('returns 400 runtime_override_disabled when loopBudget=tight and allowRuntimeOverride: false', async () => {
631
+ seedAgentRegistryForTests([
632
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers', allowRuntimeOverride: false }),
633
+ ])
634
+
635
+ const response = await POST(buildRequestWithLoopBudget('tight') as any)
636
+
637
+ expect(response.status).toBe(400)
638
+ const json = await response.json()
639
+ expect(json.code).toBe('runtime_override_disabled')
640
+ })
641
+
642
+ it('accepts loopBudget=tight when loop.allowRuntimeOverride is true (default)', async () => {
643
+ seedAgentRegistryForTests([
644
+ makeAgent({ id: 'customers.assistant', moduleId: 'customers' }),
645
+ ])
646
+
647
+ const response = await POST(buildRequestWithLoopBudget('tight') as any)
648
+
649
+ expect(response.status).toBe(200)
650
+ })
651
+ })
555
652
  })