@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
@@ -264,6 +264,13 @@ export class AiAgentRuntimeOverride {
264
264
  | 'allowedOverrideModelsByProvider'
265
265
  | 'updatedByUserId'
266
266
  | 'deletedAt'
267
+ | 'loopDisabled'
268
+ | 'loopMaxSteps'
269
+ | 'loopMaxToolCalls'
270
+ | 'loopMaxWallClockMs'
271
+ | 'loopMaxTokens'
272
+ | 'loopStopWhenJson'
273
+ | 'loopActiveToolsJson'
267
274
 
268
275
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
269
276
  id!: string
@@ -303,6 +310,236 @@ export class AiAgentRuntimeOverride {
303
310
 
304
311
  @Property({ name: 'deleted_at', type: Date, nullable: true })
305
312
  deletedAt?: Date | null
313
+
314
+ /**
315
+ * Kill switch — when `true`, runtime forces `stopWhen: stepCountIs(1)` and
316
+ * ignores all other loop config. Phase 3 of spec
317
+ * `2026-04-28-ai-agents-agentic-loop-controls`.
318
+ */
319
+ @Property({ name: 'loop_disabled', type: 'boolean', nullable: true })
320
+ loopDisabled?: boolean | null
321
+
322
+ /**
323
+ * Override `loop.maxSteps`. Phase 3 of spec
324
+ * `2026-04-28-ai-agents-agentic-loop-controls`.
325
+ */
326
+ @Property({ name: 'loop_max_steps', type: 'int', nullable: true })
327
+ loopMaxSteps?: number | null
328
+
329
+ /**
330
+ * Override `loop.budget.maxToolCalls`. Phase 3 of spec
331
+ * `2026-04-28-ai-agents-agentic-loop-controls`.
332
+ */
333
+ @Property({ name: 'loop_max_tool_calls', type: 'int', nullable: true })
334
+ loopMaxToolCalls?: number | null
335
+
336
+ /**
337
+ * Override `loop.budget.maxWallClockMs`. Phase 3 of spec
338
+ * `2026-04-28-ai-agents-agentic-loop-controls`.
339
+ */
340
+ @Property({ name: 'loop_max_wall_clock_ms', type: 'int', nullable: true })
341
+ loopMaxWallClockMs?: number | null
342
+
343
+ /**
344
+ * Override `loop.budget.maxTokens`. Phase 3 of spec
345
+ * `2026-04-28-ai-agents-agentic-loop-controls`.
346
+ */
347
+ @Property({ name: 'loop_max_tokens', type: 'int', nullable: true })
348
+ loopMaxTokens?: number | null
349
+
350
+ /**
351
+ * Override `loop.stopWhen`. JSON-safe variants only (`stepCount`,
352
+ * `hasToolCall`); validator rejects `kind: 'custom'`. Phase 3 of spec
353
+ * `2026-04-28-ai-agents-agentic-loop-controls`.
354
+ */
355
+ @Property({ name: 'loop_stop_when_json', type: 'jsonb', nullable: true })
356
+ loopStopWhenJson?: unknown | null
357
+
358
+ /**
359
+ * Override `loop.activeTools` (must be subset of `agent.allowedTools`).
360
+ * Phase 3 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
361
+ */
362
+ @Property({ name: 'loop_active_tools_json', type: 'jsonb', nullable: true })
363
+ loopActiveToolsJson?: unknown | null
364
+ }
365
+
366
+ /**
367
+ * Append-only event log for token usage per step (chat) or per turn (object).
368
+ *
369
+ * One row is created by `recordTokenUsage` (Phase 6.3) for every completed
370
+ * AI SDK step. Indexed for the three read patterns: daily rollup, per-agent
371
+ * report, and session drill-down.
372
+ *
373
+ * Retention: rows older than `AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS` (default
374
+ * 90) are swept by the `ai-token-usage-prune` worker (Phase 6.4).
375
+ *
376
+ * Phase 6.0 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
377
+ */
378
+ @Entity({ tableName: 'ai_token_usage_events' })
379
+ @Index({
380
+ name: 'ai_token_usage_events_tenant_created_idx',
381
+ properties: ['tenantId', 'createdAt'],
382
+ })
383
+ @Index({
384
+ name: 'ai_token_usage_events_tenant_agent_created_idx',
385
+ properties: ['tenantId', 'agentId', 'createdAt'],
386
+ })
387
+ @Index({
388
+ name: 'ai_token_usage_events_tenant_model_created_idx',
389
+ properties: ['tenantId', 'modelId', 'createdAt'],
390
+ })
391
+ @Index({
392
+ name: 'ai_token_usage_events_tenant_session_turn_step_idx',
393
+ properties: ['tenantId', 'sessionId', 'turnId', 'stepIndex'],
394
+ })
395
+ export class AiTokenUsageEvent {
396
+ [OptionalProps]?:
397
+ | 'createdAt'
398
+ | 'updatedAt'
399
+ | 'organizationId'
400
+ | 'cachedInputTokens'
401
+ | 'reasoningTokens'
402
+ | 'finishReason'
403
+ | 'loopAbortReason'
404
+
405
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
406
+ id!: string
407
+
408
+ @Property({ name: 'tenant_id', type: 'uuid' })
409
+ tenantId!: string
410
+
411
+ @Property({ name: 'organization_id', type: 'uuid', nullable: true })
412
+ organizationId?: string | null
413
+
414
+ @Property({ name: 'user_id', type: 'uuid' })
415
+ userId!: string
416
+
417
+ @Property({ name: 'agent_id', type: 'text' })
418
+ agentId!: string
419
+
420
+ @Property({ name: 'module_id', type: 'text' })
421
+ moduleId!: string
422
+
423
+ @Property({ name: 'session_id', type: 'uuid' })
424
+ sessionId!: string
425
+
426
+ @Property({ name: 'turn_id', type: 'uuid' })
427
+ turnId!: string
428
+
429
+ @Property({ name: 'step_index', type: 'int' })
430
+ stepIndex!: number
431
+
432
+ @Property({ name: 'provider_id', type: 'text' })
433
+ providerId!: string
434
+
435
+ @Property({ name: 'model_id', type: 'text' })
436
+ modelId!: string
437
+
438
+ @Property({ name: 'input_tokens', type: 'int' })
439
+ inputTokens!: number
440
+
441
+ @Property({ name: 'output_tokens', type: 'int' })
442
+ outputTokens!: number
443
+
444
+ @Property({ name: 'cached_input_tokens', type: 'int', nullable: true })
445
+ cachedInputTokens?: number | null
446
+
447
+ @Property({ name: 'reasoning_tokens', type: 'int', nullable: true })
448
+ reasoningTokens?: number | null
449
+
450
+ @Property({ name: 'finish_reason', type: 'text', nullable: true })
451
+ finishReason?: string | null
452
+
453
+ @Property({ name: 'loop_abort_reason', type: 'text', nullable: true })
454
+ loopAbortReason?: string | null
455
+
456
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
457
+ createdAt: Date = new Date()
458
+
459
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
460
+ updatedAt: Date = new Date()
461
+ }
462
+
463
+ /**
464
+ * Materialized daily rollup of token usage per `(tenant, day, agent, model)`.
465
+ *
466
+ * Updated incrementally by UPSERT on every `recordTokenUsage` call so the
467
+ * rollup is always current even when the prune worker is behind. A daily
468
+ * reconciliation worker (Phase 6.4) recomputes `session_count` from the events
469
+ * table to correct any drift caused by event delivery delays or outages.
470
+ *
471
+ * `session_count` is maintained via a per-row LATERAL exists check at write
472
+ * time (first event in a `(tenant, day, agent, model, session)` window
473
+ * increments the counter). This counter may drift if events arrive out of
474
+ * order; the daily worker corrects it.
475
+ *
476
+ * Phase 6.1 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
477
+ */
478
+ @Entity({ tableName: 'ai_token_usage_daily' })
479
+ @Index({
480
+ name: 'ai_token_usage_daily_tenant_day_agent_model_org_uq',
481
+ expression:
482
+ 'create unique index "ai_token_usage_daily_tenant_day_agent_model_org_uq" on "ai_token_usage_daily" ("tenant_id", "day", "agent_id", "model_id", "organization_id") where "organization_id" is not null',
483
+ })
484
+ @Index({
485
+ name: 'ai_token_usage_daily_tenant_day_agent_model_null_org_uq',
486
+ expression:
487
+ 'create unique index "ai_token_usage_daily_tenant_day_agent_model_null_org_uq" on "ai_token_usage_daily" ("tenant_id", "day", "agent_id", "model_id") where "organization_id" is null',
488
+ })
489
+ @Index({
490
+ name: 'ai_token_usage_daily_tenant_day_idx',
491
+ properties: ['tenantId', 'day'],
492
+ })
493
+ export class AiTokenUsageDaily {
494
+ [OptionalProps]?: 'createdAt' | 'updatedAt' | 'organizationId'
495
+
496
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
497
+ id!: string
498
+
499
+ @Property({ name: 'tenant_id', type: 'uuid' })
500
+ tenantId!: string
501
+
502
+ @Property({ name: 'organization_id', type: 'uuid', nullable: true })
503
+ organizationId?: string | null
504
+
505
+ @Property({ name: 'day', type: 'string', columnType: 'date' })
506
+ day!: string
507
+
508
+ @Property({ name: 'agent_id', type: 'text' })
509
+ agentId!: string
510
+
511
+ @Property({ name: 'model_id', type: 'text' })
512
+ modelId!: string
513
+
514
+ @Property({ name: 'provider_id', type: 'text' })
515
+ providerId!: string
516
+
517
+ @Property({ name: 'input_tokens', type: 'string', columnType: 'bigint' })
518
+ inputTokens!: string
519
+
520
+ @Property({ name: 'output_tokens', type: 'string', columnType: 'bigint' })
521
+ outputTokens!: string
522
+
523
+ @Property({ name: 'cached_input_tokens', type: 'string', columnType: 'bigint' })
524
+ cachedInputTokens!: string
525
+
526
+ @Property({ name: 'reasoning_tokens', type: 'string', columnType: 'bigint' })
527
+ reasoningTokens!: string
528
+
529
+ @Property({ name: 'step_count', type: 'string', columnType: 'bigint' })
530
+ stepCount!: string
531
+
532
+ @Property({ name: 'turn_count', type: 'string', columnType: 'bigint' })
533
+ turnCount!: string
534
+
535
+ @Property({ name: 'session_count', type: 'string', columnType: 'bigint' })
536
+ sessionCount!: string
537
+
538
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
539
+ createdAt: Date = new Date()
540
+
541
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
542
+ updatedAt: Date = new Date()
306
543
  }
307
544
 
308
545
  /**
@@ -2,6 +2,7 @@ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
2
  import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
3
3
  import { canonicalProviderId } from '../../lib/model-allowlist'
4
4
  import { AiAgentRuntimeOverride } from '../entities'
5
+ import type { AiAgentLoopStopCondition } from '../../lib/ai-agent-definition'
5
6
 
6
7
  export interface AiAgentRuntimeOverrideContext {
7
8
  tenantId: string
@@ -9,7 +10,24 @@ export interface AiAgentRuntimeOverrideContext {
9
10
  userId?: string | null
10
11
  }
11
12
 
12
- export interface AiAgentRuntimeOverrideInput {
13
+ export interface AiAgentRuntimeOverrideLoopInput {
14
+ /** Kill switch — when true, runtime forces stepCountIs(1). */
15
+ loopDisabled?: boolean | null
16
+ /** Override loop.maxSteps. */
17
+ loopMaxSteps?: number | null
18
+ /** Override loop.budget.maxToolCalls. */
19
+ loopMaxToolCalls?: number | null
20
+ /** Override loop.budget.maxWallClockMs. */
21
+ loopMaxWallClockMs?: number | null
22
+ /** Override loop.budget.maxTokens. */
23
+ loopMaxTokens?: number | null
24
+ /** Override loop.stopWhen — JSON-safe variants only (stepCount, hasToolCall). */
25
+ loopStopWhenJson?: AiAgentLoopStopCondition[] | null
26
+ /** Override loop.activeTools — must be a subset of agent.allowedTools. */
27
+ loopActiveToolsJson?: string[] | null
28
+ }
29
+
30
+ export interface AiAgentRuntimeOverrideInput extends AiAgentRuntimeOverrideLoopInput {
13
31
  /** null means tenant-wide default (no agent pinning). */
14
32
  agentId?: string | null
15
33
  providerId?: string | null
@@ -17,6 +35,12 @@ export interface AiAgentRuntimeOverrideInput {
17
35
  baseURL?: string | null
18
36
  allowedOverrideProviders?: string[] | null
19
37
  allowedOverrideModelsByProvider?: Record<string, string[]>
38
+ /**
39
+ * Optional: the agent's declared allowedTools. When provided, loopActiveToolsJson
40
+ * is validated to be a subset. When omitted, allowlist validation is skipped
41
+ * (write-time defense only; the runtime re-validates at read time).
42
+ */
43
+ agentAllowedTools?: string[]
20
44
  }
21
45
 
22
46
  /**
@@ -90,6 +114,89 @@ export class AiAgentRuntimeOverrideRepository {
90
114
  return row ?? null
91
115
  }
92
116
 
117
+ /**
118
+ * Validates and normalizes the loop override fields from an input object.
119
+ * Throws `AiAgentRuntimeOverrideValidationError` with code
120
+ * `invalid_loop_override` for any validation failure.
121
+ *
122
+ * Validation rules (Phase 3 — R5 mitigation):
123
+ * - `loopStopWhenJson`: all items must have kind `stepCount` or `hasToolCall`.
124
+ * Items with kind `custom` are rejected — they cannot be stored as JSON.
125
+ * - `loopActiveToolsJson`: when `agentAllowedTools` is provided, every entry
126
+ * must be in that allowlist.
127
+ */
128
+ private validateLoopInput(input: AiAgentRuntimeOverrideInput): void {
129
+ if (input.loopStopWhenJson != null) {
130
+ if (!Array.isArray(input.loopStopWhenJson)) {
131
+ throw new AiAgentRuntimeOverrideValidationError(
132
+ 'loopStopWhenJson must be an array of stop condition objects.',
133
+ 'invalid_loop_override',
134
+ )
135
+ }
136
+ for (const item of input.loopStopWhenJson) {
137
+ if (!item || typeof item !== 'object' || !('kind' in item)) {
138
+ throw new AiAgentRuntimeOverrideValidationError(
139
+ 'loopStopWhenJson items must have a "kind" field.',
140
+ 'invalid_loop_override',
141
+ )
142
+ }
143
+ const kind = (item as AiAgentLoopStopCondition).kind
144
+ if (kind === 'custom') {
145
+ throw new AiAgentRuntimeOverrideValidationError(
146
+ 'loopStopWhenJson does not support kind "custom" — only "stepCount" and "hasToolCall" are JSON-safe and storable.',
147
+ 'invalid_loop_override',
148
+ )
149
+ }
150
+ if (kind !== 'stepCount' && kind !== 'hasToolCall') {
151
+ throw new AiAgentRuntimeOverrideValidationError(
152
+ `loopStopWhenJson contains unknown kind "${String(kind)}". Allowed: "stepCount", "hasToolCall".`,
153
+ 'invalid_loop_override',
154
+ )
155
+ }
156
+ if (kind === 'stepCount' && typeof (item as { count?: unknown }).count !== 'number') {
157
+ throw new AiAgentRuntimeOverrideValidationError(
158
+ 'loopStopWhenJson stepCount item must have a numeric "count" field.',
159
+ 'invalid_loop_override',
160
+ )
161
+ }
162
+ if (kind === 'hasToolCall' && typeof (item as { toolName?: unknown }).toolName !== 'string') {
163
+ throw new AiAgentRuntimeOverrideValidationError(
164
+ 'loopStopWhenJson hasToolCall item must have a string "toolName" field.',
165
+ 'invalid_loop_override',
166
+ )
167
+ }
168
+ }
169
+ }
170
+
171
+ if (input.loopActiveToolsJson != null) {
172
+ if (!Array.isArray(input.loopActiveToolsJson)) {
173
+ throw new AiAgentRuntimeOverrideValidationError(
174
+ 'loopActiveToolsJson must be an array of tool name strings.',
175
+ 'invalid_loop_override',
176
+ )
177
+ }
178
+ for (const name of input.loopActiveToolsJson) {
179
+ if (typeof name !== 'string' || name.length === 0) {
180
+ throw new AiAgentRuntimeOverrideValidationError(
181
+ 'loopActiveToolsJson entries must be non-empty strings.',
182
+ 'invalid_loop_override',
183
+ )
184
+ }
185
+ }
186
+ if (input.agentAllowedTools && input.agentAllowedTools.length > 0) {
187
+ const outsideAllowlist = input.loopActiveToolsJson.filter(
188
+ (name) => !input.agentAllowedTools!.includes(name),
189
+ )
190
+ if (outsideAllowlist.length > 0) {
191
+ throw new AiAgentRuntimeOverrideValidationError(
192
+ `loopActiveToolsJson contains tools outside the agent's allowedTools: ${outsideAllowlist.join(', ')}.`,
193
+ 'invalid_loop_override',
194
+ )
195
+ }
196
+ }
197
+ }
198
+ }
199
+
93
200
  /**
94
201
  * Inserts or updates the runtime override for the given context.
95
202
  *
@@ -97,6 +204,11 @@ export class AiAgentRuntimeOverrideRepository {
97
204
  * cannot save a typo (Phase 1.4 contract re-applied per spec §Data Models).
98
205
  * An unknown provider id throws a typed error.
99
206
  *
207
+ * Also validates loop override fields (R5 mitigation — Phase 3):
208
+ * - `loopStopWhenJson` items must use only JSON-safe kinds.
209
+ * - `loopActiveToolsJson` items must be a subset of `agentAllowedTools`
210
+ * when that is provided.
211
+ *
100
212
  * The R6 base-URL allowlist check is intentionally NOT performed here —
101
213
  * that enforcement lives at the HTTP layer (PUT settings route). The
102
214
  * repository trusts that callers have already validated the value.
@@ -121,6 +233,8 @@ export class AiAgentRuntimeOverrideRepository {
121
233
  }
122
234
  }
123
235
 
236
+ this.validateLoopInput(input)
237
+
124
238
  const orgFilter = ctx.organizationId ?? null
125
239
  const agentIdFilter = input.agentId ?? null
126
240
  const hasProviderId = Object.prototype.hasOwnProperty.call(input, 'providerId')
@@ -149,6 +263,13 @@ export class AiAgentRuntimeOverrideRepository {
149
263
  }
150
264
  existing.updatedByUserId = ctx.userId ?? null
151
265
  existing.updatedAt = new Date()
266
+ if ('loopDisabled' in input) existing.loopDisabled = input.loopDisabled ?? null
267
+ if ('loopMaxSteps' in input) existing.loopMaxSteps = input.loopMaxSteps ?? null
268
+ if ('loopMaxToolCalls' in input) existing.loopMaxToolCalls = input.loopMaxToolCalls ?? null
269
+ if ('loopMaxWallClockMs' in input) existing.loopMaxWallClockMs = input.loopMaxWallClockMs ?? null
270
+ if ('loopMaxTokens' in input) existing.loopMaxTokens = input.loopMaxTokens ?? null
271
+ if ('loopStopWhenJson' in input) existing.loopStopWhenJson = input.loopStopWhenJson ?? null
272
+ if ('loopActiveToolsJson' in input) existing.loopActiveToolsJson = input.loopActiveToolsJson ?? null
152
273
  await tx.persist(existing).flush()
153
274
  return existing
154
275
  }
@@ -167,6 +288,13 @@ export class AiAgentRuntimeOverrideRepository {
167
288
  ? (input.allowedOverrideModelsByProvider ?? {})
168
289
  : {},
169
290
  updatedByUserId: ctx.userId ?? null,
291
+ loopDisabled: input.loopDisabled ?? null,
292
+ loopMaxSteps: input.loopMaxSteps ?? null,
293
+ loopMaxToolCalls: input.loopMaxToolCalls ?? null,
294
+ loopMaxWallClockMs: input.loopMaxWallClockMs ?? null,
295
+ loopMaxTokens: input.loopMaxTokens ?? null,
296
+ loopStopWhenJson: input.loopStopWhenJson ?? null,
297
+ loopActiveToolsJson: input.loopActiveToolsJson ?? null,
170
298
  } as unknown as AiAgentRuntimeOverride)
171
299
  await tx.persist(row).flush()
172
300
  return row
@@ -215,12 +343,16 @@ export class AiAgentRuntimeOverrideRepository {
215
343
  }
216
344
 
217
345
  /**
218
- * Thrown by `upsertDefault` when an unknown provider id is submitted.
346
+ * Thrown by `upsertDefault` when validation fails (unknown provider id,
347
+ * invalid loop override JSON).
219
348
  */
220
349
  export class AiAgentRuntimeOverrideValidationError extends Error {
221
- constructor(message: string) {
350
+ readonly code: string
351
+
352
+ constructor(message: string, code = 'invalid_override') {
222
353
  super(message)
223
354
  this.name = 'AiAgentRuntimeOverrideValidationError'
355
+ this.code = code
224
356
  }
225
357
  }
226
358
 
@@ -0,0 +1,213 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { AiTokenUsageEvent, AiTokenUsageDaily } from '../entities'
3
+
4
+ export interface CreateTokenUsageEventInput {
5
+ tenantId: string
6
+ organizationId?: string | null
7
+ userId: string
8
+ agentId: string
9
+ moduleId: string
10
+ sessionId: string
11
+ turnId: string
12
+ stepIndex: number
13
+ providerId: string
14
+ modelId: string
15
+ inputTokens: number
16
+ outputTokens: number
17
+ cachedInputTokens?: number | null
18
+ reasoningTokens?: number | null
19
+ finishReason?: string | null
20
+ loopAbortReason?: string | null
21
+ }
22
+
23
+ export interface UpsertTokenUsageDailyInput {
24
+ tenantId: string
25
+ organizationId?: string | null
26
+ day: string
27
+ agentId: string
28
+ modelId: string
29
+ providerId: string
30
+ sessionId: string
31
+ inputTokens: number
32
+ outputTokens: number
33
+ cachedInputTokens: number
34
+ reasoningTokens: number
35
+ }
36
+
37
+ /**
38
+ * Repository for the Phase 6 token-usage event log and daily rollup tables.
39
+ *
40
+ * `upsertDaily` uses raw SQL to perform the CONFLICT-based incremental update
41
+ * because MikroORM does not expose `INSERT ... ON CONFLICT DO UPDATE` for
42
+ * arbitrary expressions. The LATERAL session-count check guards against
43
+ * double-counting a session within the same `(tenant, day, agent, model)` tuple.
44
+ *
45
+ * All writes are fail-open — callers MUST wrap invocations in try/catch and
46
+ * log at `warn` rather than rethrowing (R12: recorder must never break a turn).
47
+ *
48
+ * Phase 6.1 + 6.3 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
49
+ */
50
+ export class AiTokenUsageRepository {
51
+ constructor(private readonly em: EntityManager) {}
52
+
53
+ async createEvent(input: CreateTokenUsageEventInput): Promise<AiTokenUsageEvent> {
54
+ const event = this.em.create(AiTokenUsageEvent, {
55
+ tenantId: input.tenantId,
56
+ organizationId: input.organizationId ?? null,
57
+ userId: input.userId,
58
+ agentId: input.agentId,
59
+ moduleId: input.moduleId,
60
+ sessionId: input.sessionId,
61
+ turnId: input.turnId,
62
+ stepIndex: input.stepIndex,
63
+ providerId: input.providerId,
64
+ modelId: input.modelId,
65
+ inputTokens: input.inputTokens,
66
+ outputTokens: input.outputTokens,
67
+ cachedInputTokens: input.cachedInputTokens ?? null,
68
+ reasoningTokens: input.reasoningTokens ?? null,
69
+ finishReason: input.finishReason ?? null,
70
+ loopAbortReason: input.loopAbortReason ?? null,
71
+ })
72
+ this.em.persist(event)
73
+ await this.em.flush()
74
+ return event
75
+ }
76
+
77
+ /**
78
+ * Upserts the daily rollup row, incrementing counters atomically via
79
+ * `INSERT ... ON CONFLICT DO UPDATE`. The `session_count` column is
80
+ * incremented only when this is the first event observed for the
81
+ * `(tenant_id, session_id, day, agent_id, model_id)` tuple — a LATERAL
82
+ * NOT EXISTS check prevents double-counting.
83
+ *
84
+ * The query handles the two partial unique indexes (org IS NOT NULL vs
85
+ * IS NULL) by encoding `organization_id` in the EXCLUDED row and relying
86
+ * on the appropriate partial index the planner selects.
87
+ */
88
+ async upsertDaily(input: UpsertTokenUsageDailyInput): Promise<void> {
89
+ const connection = this.em.getConnection()
90
+ const now = new Date()
91
+ const orgValue = input.organizationId ?? null
92
+
93
+ // Determine if this is the first event for this session in the window
94
+ // (used to guard the session_count increment).
95
+ const sessionCheckSql = `
96
+ select exists (
97
+ select 1 from ai_token_usage_events
98
+ where tenant_id = ?
99
+ and session_id = ?::uuid
100
+ and agent_id = ?
101
+ and model_id = ?
102
+ and date_trunc('day', created_at) = ?::date
103
+ ${orgValue !== null ? 'and organization_id = ?' : 'and organization_id is null'}
104
+ ) as already_seen
105
+ `
106
+ const sessionCheckParams: unknown[] = [
107
+ input.tenantId,
108
+ input.sessionId,
109
+ input.agentId,
110
+ input.modelId,
111
+ input.day,
112
+ ]
113
+ if (orgValue !== null) sessionCheckParams.push(orgValue)
114
+
115
+ const sessionRows = await connection.execute(sessionCheckSql, sessionCheckParams, 'all')
116
+ const alreadySeen =
117
+ Array.isArray(sessionRows) &&
118
+ sessionRows.length > 0 &&
119
+ (sessionRows[0] as Record<string, unknown>).already_seen === true
120
+
121
+ const sessionDelta = alreadySeen ? 0 : 1
122
+
123
+ if (orgValue !== null) {
124
+ await connection.execute(
125
+ `
126
+ insert into ai_token_usage_daily (
127
+ id, tenant_id, organization_id, day, agent_id, model_id, provider_id,
128
+ input_tokens, output_tokens, cached_input_tokens, reasoning_tokens,
129
+ step_count, turn_count, session_count, created_at, updated_at
130
+ ) values (
131
+ gen_random_uuid(), ?, ?, ?::date, ?, ?, ?,
132
+ ?, ?, ?, ?,
133
+ 1, 1, ?, ?, ?
134
+ )
135
+ on conflict (tenant_id, day, agent_id, model_id, organization_id)
136
+ where organization_id is not null
137
+ do update set
138
+ input_tokens = ai_token_usage_daily.input_tokens + excluded.input_tokens,
139
+ output_tokens = ai_token_usage_daily.output_tokens + excluded.output_tokens,
140
+ cached_input_tokens = ai_token_usage_daily.cached_input_tokens + excluded.cached_input_tokens,
141
+ reasoning_tokens = ai_token_usage_daily.reasoning_tokens + excluded.reasoning_tokens,
142
+ step_count = ai_token_usage_daily.step_count + 1,
143
+ turn_count = ai_token_usage_daily.turn_count + 1,
144
+ session_count = ai_token_usage_daily.session_count + excluded.session_count,
145
+ updated_at = excluded.updated_at
146
+ `,
147
+ [
148
+ input.tenantId, orgValue, input.day, input.agentId, input.modelId, input.providerId,
149
+ input.inputTokens, input.outputTokens, input.cachedInputTokens, input.reasoningTokens,
150
+ sessionDelta, now, now,
151
+ ],
152
+ 'run',
153
+ )
154
+ } else {
155
+ await connection.execute(
156
+ `
157
+ insert into ai_token_usage_daily (
158
+ id, tenant_id, organization_id, day, agent_id, model_id, provider_id,
159
+ input_tokens, output_tokens, cached_input_tokens, reasoning_tokens,
160
+ step_count, turn_count, session_count, created_at, updated_at
161
+ ) values (
162
+ gen_random_uuid(), ?, null, ?::date, ?, ?, ?,
163
+ ?, ?, ?, ?,
164
+ 1, 1, ?, ?, ?
165
+ )
166
+ on conflict (tenant_id, day, agent_id, model_id)
167
+ where organization_id is null
168
+ do update set
169
+ input_tokens = ai_token_usage_daily.input_tokens + excluded.input_tokens,
170
+ output_tokens = ai_token_usage_daily.output_tokens + excluded.output_tokens,
171
+ cached_input_tokens = ai_token_usage_daily.cached_input_tokens + excluded.cached_input_tokens,
172
+ reasoning_tokens = ai_token_usage_daily.reasoning_tokens + excluded.reasoning_tokens,
173
+ step_count = ai_token_usage_daily.step_count + 1,
174
+ turn_count = ai_token_usage_daily.turn_count + 1,
175
+ session_count = ai_token_usage_daily.session_count + excluded.session_count,
176
+ updated_at = excluded.updated_at
177
+ `,
178
+ [
179
+ input.tenantId, input.day, input.agentId, input.modelId, input.providerId,
180
+ input.inputTokens, input.outputTokens, input.cachedInputTokens, input.reasoningTokens,
181
+ sessionDelta, now, now,
182
+ ],
183
+ 'run',
184
+ )
185
+ }
186
+ }
187
+
188
+ async listEventsForSession(
189
+ tenantId: string,
190
+ sessionId: string,
191
+ limit = 200,
192
+ ): Promise<AiTokenUsageEvent[]> {
193
+ return this.em.find(
194
+ AiTokenUsageEvent,
195
+ { tenantId, sessionId },
196
+ { orderBy: { createdAt: 'ASC', stepIndex: 'ASC' }, limit },
197
+ )
198
+ }
199
+
200
+ async listDailyRollup(
201
+ tenantId: string,
202
+ from: string,
203
+ to: string,
204
+ filters: { agentId?: string; modelId?: string } = {},
205
+ ): Promise<AiTokenUsageDaily[]> {
206
+ const where: Record<string, unknown> = { tenantId, day: { $gte: from, $lte: to } }
207
+ if (filters.agentId) where.agentId = filters.agentId
208
+ if (filters.modelId) where.modelId = filters.modelId
209
+ return this.em.find(AiTokenUsageDaily, where, {
210
+ orderBy: { day: 'ASC', agentId: 'ASC', modelId: 'ASC' },
211
+ })
212
+ }
213
+ }