@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,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
|
-
'`
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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',
|
|
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
|
})
|