@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
@@ -3,10 +3,8 @@
3
3
  import * as React from 'react'
4
4
  import { useQuery, useQueryClient } from '@tanstack/react-query'
5
5
  import {
6
- AlertCircle,
7
6
  Bot,
8
7
  BookOpen,
9
- CheckCircle2,
10
8
  History,
11
9
  Image as ImageIcon,
12
10
  FileText,
@@ -16,6 +14,7 @@ import {
16
14
  RefreshCcw,
17
15
  Save,
18
16
  ShieldAlert,
17
+ ShieldOff,
19
18
  Trash2,
20
19
  Wand2,
21
20
  Wrench,
@@ -26,6 +25,7 @@ import { Badge } from '@open-mercato/ui/primitives/badge'
26
25
  import { Button } from '@open-mercato/ui/primitives/button'
27
26
  import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
28
27
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
28
+ import { Input } from '@open-mercato/ui/primitives/input'
29
29
  import { Label } from '@open-mercato/ui/primitives/label'
30
30
  import { Radio, RadioGroup } from '@open-mercato/ui/primitives/radio'
31
31
  import {
@@ -704,8 +704,11 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
704
704
  </div>
705
705
  </div>
706
706
 
707
- <Alert variant="info" data-ai-agent-mutation-policy-notice>
708
- <ShieldAlert className="size-4" aria-hidden />
707
+ <Alert
708
+ variant="info"
709
+ icon={<ShieldAlert aria-hidden="true" />}
710
+ data-ai-agent-mutation-policy-notice
711
+ >
709
712
  <AlertTitle>
710
713
  {t(
711
714
  'ai_assistant.agents.mutation_policy.noticeTitle',
@@ -729,7 +732,6 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
729
732
  />
730
733
  ) : query.isError ? (
731
734
  <Alert variant="destructive" data-ai-agent-mutation-policy-load-error>
732
- <AlertCircle className="size-4" aria-hidden />
733
735
  <AlertTitle>
734
736
  {t(
735
737
  'ai_assistant.agents.mutation_policy.loadErrorTitle',
@@ -818,7 +820,6 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
818
820
 
819
821
  {state.kind === 'success' ? (
820
822
  <Alert variant="success" data-ai-agent-mutation-policy-state="success">
821
- <CheckCircle2 className="size-4" aria-hidden />
822
823
  <AlertTitle>
823
824
  {t('ai_assistant.agents.mutation_policy.savedTitle', 'Mutation policy updated')}
824
825
  </AlertTitle>
@@ -827,7 +828,6 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
827
828
  ) : null}
828
829
  {state.kind === 'error' ? (
829
830
  <Alert variant="destructive" data-ai-agent-mutation-policy-state="error">
830
- <AlertCircle className="size-4" aria-hidden />
831
831
  <AlertTitle>
832
832
  {t(
833
833
  'ai_assistant.agents.mutation_policy.errorTitle',
@@ -1190,7 +1190,6 @@ function AgentModelOverrideSection({ agent }: { agent: AgentSettings }) {
1190
1190
  />
1191
1191
  ) : settingsQuery.isError ? (
1192
1192
  <Alert variant="destructive" data-ai-agent-model-override-load-error>
1193
- <AlertCircle className="size-4" aria-hidden />
1194
1193
  <AlertTitle>
1195
1194
  {t(
1196
1195
  'ai_assistant.agents.model_override.loadErrorTitle',
@@ -1401,13 +1400,11 @@ function AgentModelOverrideSection({ agent }: { agent: AgentSettings }) {
1401
1400
 
1402
1401
  {state.kind === 'success' ? (
1403
1402
  <Alert variant="success" data-ai-agent-model-override-state="success">
1404
- <CheckCircle2 className="size-4" aria-hidden />
1405
1403
  <AlertDescription>{state.message}</AlertDescription>
1406
1404
  </Alert>
1407
1405
  ) : null}
1408
1406
  {state.kind === 'error' ? (
1409
1407
  <Alert variant="destructive" data-ai-agent-model-override-state="error">
1410
- <AlertCircle className="size-4" aria-hidden />
1411
1408
  <AlertDescription>{state.message}</AlertDescription>
1412
1409
  </Alert>
1413
1410
  ) : null}
@@ -1448,6 +1445,364 @@ function AgentModelOverrideSection({ agent }: { agent: AgentSettings }) {
1448
1445
  )
1449
1446
  }
1450
1447
 
1448
+ type LoopOverrideRow = {
1449
+ id: string
1450
+ agentId: string | null
1451
+ loopDisabled: boolean | null
1452
+ loopMaxSteps: number | null
1453
+ loopMaxToolCalls: number | null
1454
+ loopMaxWallClockMs: number | null
1455
+ loopMaxTokens: number | null
1456
+ loopStopWhenJson: unknown[] | null
1457
+ loopActiveToolsJson: string[] | null
1458
+ updatedAt: string
1459
+ }
1460
+
1461
+ type LoopOverrideResponse = {
1462
+ agentId: string
1463
+ override: LoopOverrideRow | null
1464
+ }
1465
+
1466
+ async function fetchLoopOverride(agentId: string): Promise<LoopOverrideResponse> {
1467
+ const { result, status } = await apiCallOrThrow<LoopOverrideResponse>(
1468
+ `/api/ai_assistant/ai/agents/${encodeURIComponent(agentId)}/loop-override`,
1469
+ { method: 'GET', credentials: 'include' },
1470
+ { errorMessage: 'Failed to load loop override' },
1471
+ )
1472
+ if (!result) throw new Error(`Failed to load loop override (${status})`)
1473
+ return result
1474
+ }
1475
+
1476
+ function LoopPolicySection({ agent }: { agent: AgentSettings }) {
1477
+ const t = useT()
1478
+ const queryClient = useQueryClient()
1479
+
1480
+ const query = useQuery<LoopOverrideResponse>({
1481
+ queryKey: ['ai_assistant', 'agent_settings', 'loop_override', agent.id],
1482
+ queryFn: () => fetchLoopOverride(agent.id),
1483
+ retry: false,
1484
+ })
1485
+
1486
+ const currentOverride = query.data?.override ?? null
1487
+
1488
+ const [loopDisabled, setLoopDisabled] = React.useState<boolean>(false)
1489
+ const [maxSteps, setMaxSteps] = React.useState<string>('')
1490
+ const [maxToolCalls, setMaxToolCalls] = React.useState<string>('')
1491
+ const [maxWallClockMs, setMaxWallClockMs] = React.useState<string>('')
1492
+ const [maxTokens, setMaxTokens] = React.useState<string>('')
1493
+ const [isSaving, setIsSaving] = React.useState(false)
1494
+ const [isClearing, setIsClearing] = React.useState(false)
1495
+ const [state, setState] = React.useState<
1496
+ | { kind: 'idle' }
1497
+ | { kind: 'success'; message: string }
1498
+ | { kind: 'error'; message: string }
1499
+ >({ kind: 'idle' })
1500
+
1501
+ React.useEffect(() => {
1502
+ setLoopDisabled(currentOverride?.loopDisabled ?? false)
1503
+ setMaxSteps(currentOverride?.loopMaxSteps != null ? String(currentOverride.loopMaxSteps) : '')
1504
+ setMaxToolCalls(
1505
+ currentOverride?.loopMaxToolCalls != null ? String(currentOverride.loopMaxToolCalls) : '',
1506
+ )
1507
+ setMaxWallClockMs(
1508
+ currentOverride?.loopMaxWallClockMs != null
1509
+ ? String(currentOverride.loopMaxWallClockMs)
1510
+ : '',
1511
+ )
1512
+ setMaxTokens(
1513
+ currentOverride?.loopMaxTokens != null ? String(currentOverride.loopMaxTokens) : '',
1514
+ )
1515
+ setState({ kind: 'idle' })
1516
+ }, [
1517
+ agent.id,
1518
+ currentOverride?.loopDisabled,
1519
+ currentOverride?.loopMaxSteps,
1520
+ currentOverride?.loopMaxToolCalls,
1521
+ currentOverride?.loopMaxWallClockMs,
1522
+ currentOverride?.loopMaxTokens,
1523
+ ])
1524
+
1525
+ const toNullableInt = (value: string): number | null => {
1526
+ const trimmed = value.trim()
1527
+ if (trimmed === '') return null
1528
+ const parsed = parseInt(trimmed, 10)
1529
+ return isNaN(parsed) ? null : parsed
1530
+ }
1531
+
1532
+ const save = React.useCallback(async () => {
1533
+ if (isSaving) return
1534
+ setIsSaving(true)
1535
+ setState({ kind: 'idle' })
1536
+ try {
1537
+ const { ok, status, result } = await apiCall<{
1538
+ ok?: boolean
1539
+ error?: string
1540
+ code?: string
1541
+ }>(
1542
+ `/api/ai_assistant/ai/agents/${encodeURIComponent(agent.id)}/loop-override`,
1543
+ {
1544
+ method: 'PUT',
1545
+ headers: { 'content-type': 'application/json' },
1546
+ credentials: 'include',
1547
+ body: JSON.stringify({
1548
+ loopDisabled: loopDisabled || null,
1549
+ loopMaxSteps: toNullableInt(maxSteps),
1550
+ loopMaxToolCalls: toNullableInt(maxToolCalls),
1551
+ loopMaxWallClockMs: toNullableInt(maxWallClockMs),
1552
+ loopMaxTokens: toNullableInt(maxTokens),
1553
+ }),
1554
+ },
1555
+ )
1556
+ const payload = result ?? {}
1557
+ if (!ok) {
1558
+ setState({
1559
+ kind: 'error',
1560
+ message: payload.error ?? `Failed to save loop policy (${status}).`,
1561
+ })
1562
+ return
1563
+ }
1564
+ setState({
1565
+ kind: 'success',
1566
+ message: t('ai_assistant.agents.loop_policy.savedMessage', 'Loop policy override saved.'),
1567
+ })
1568
+ await queryClient.invalidateQueries({
1569
+ queryKey: ['ai_assistant', 'agent_settings', 'loop_override', agent.id],
1570
+ })
1571
+ } catch (err) {
1572
+ setState({
1573
+ kind: 'error',
1574
+ message: err instanceof Error ? err.message : String(err),
1575
+ })
1576
+ } finally {
1577
+ setIsSaving(false)
1578
+ }
1579
+ }, [agent.id, isSaving, loopDisabled, maxSteps, maxToolCalls, maxWallClockMs, maxTokens, queryClient, t])
1580
+
1581
+ const clear = React.useCallback(async () => {
1582
+ if (isClearing) return
1583
+ setIsClearing(true)
1584
+ setState({ kind: 'idle' })
1585
+ try {
1586
+ const { ok, status, result } = await apiCall<{ ok?: boolean; error?: string }>(
1587
+ `/api/ai_assistant/ai/agents/${encodeURIComponent(agent.id)}/loop-override`,
1588
+ { method: 'DELETE', credentials: 'include' },
1589
+ )
1590
+ const payload = result ?? {}
1591
+ if (!ok) {
1592
+ setState({
1593
+ kind: 'error',
1594
+ message: payload.error ?? `Failed to clear loop override (${status}).`,
1595
+ })
1596
+ return
1597
+ }
1598
+ setState({
1599
+ kind: 'success',
1600
+ message: t(
1601
+ 'ai_assistant.agents.loop_policy.clearedMessage',
1602
+ 'Loop policy override cleared; agent is using its declared defaults.',
1603
+ ),
1604
+ })
1605
+ await queryClient.invalidateQueries({
1606
+ queryKey: ['ai_assistant', 'agent_settings', 'loop_override', agent.id],
1607
+ })
1608
+ } catch (err) {
1609
+ setState({
1610
+ kind: 'error',
1611
+ message: err instanceof Error ? err.message : String(err),
1612
+ })
1613
+ } finally {
1614
+ setIsClearing(false)
1615
+ }
1616
+ }, [agent.id, isClearing, queryClient, t])
1617
+
1618
+ return (
1619
+ <section
1620
+ className="rounded-lg border border-border bg-background p-4"
1621
+ data-ai-agent-loop-policy={agent.id}
1622
+ >
1623
+ <header className="flex items-center justify-between gap-3 border-b border-border pb-3">
1624
+ <div className="flex items-center gap-2">
1625
+ <ShieldOff className="size-4 text-muted-foreground" aria-hidden />
1626
+ <div>
1627
+ <h3 className="text-sm font-semibold">
1628
+ {t('ai_assistant.agents.loop_policy.title', 'Loop policy')}
1629
+ </h3>
1630
+ <p className="text-xs text-muted-foreground">
1631
+ {t(
1632
+ 'ai_assistant.agents.loop_policy.subtitle',
1633
+ 'Set per-tenant budget limits or disable the agentic loop for this agent.',
1634
+ )}
1635
+ </p>
1636
+ </div>
1637
+ </div>
1638
+ {currentOverride?.loopDisabled ? (
1639
+ <Badge variant="destructive" data-ai-agent-loop-disabled-badge>
1640
+ {t('ai_assistant.agents.loop_policy.disabledBadge', 'Loop disabled')}
1641
+ </Badge>
1642
+ ) : null}
1643
+ </header>
1644
+
1645
+ <div className="mt-3 flex flex-col gap-4">
1646
+ {query.isLoading ? (
1647
+ <SettingsLoading
1648
+ message={t('ai_assistant.agents.loop_policy.loading', 'Loading loop policy...')}
1649
+ />
1650
+ ) : query.isError ? (
1651
+ <Alert variant="destructive" data-ai-agent-loop-policy-load-error>
1652
+ <AlertTitle>
1653
+ {t('ai_assistant.agents.loop_policy.loadErrorTitle', 'Failed to load loop policy')}
1654
+ </AlertTitle>
1655
+ <AlertDescription>
1656
+ {query.error instanceof Error ? query.error.message : String(query.error)}
1657
+ </AlertDescription>
1658
+ </Alert>
1659
+ ) : (
1660
+ <>
1661
+ <div className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2">
1662
+ <div>
1663
+ <span className="text-sm font-medium">
1664
+ {t('ai_assistant.agents.loop_policy.killSwitchLabel', 'Kill switch')}
1665
+ </span>
1666
+ <p className="text-xs text-muted-foreground">
1667
+ {t(
1668
+ 'ai_assistant.agents.loop_policy.killSwitchDescription',
1669
+ 'When enabled, the agent runs as a single model call with no tool loop.',
1670
+ )}
1671
+ </p>
1672
+ </div>
1673
+ <Switch
1674
+ checked={loopDisabled}
1675
+ onCheckedChange={(next: boolean) => setLoopDisabled(next)}
1676
+ aria-label={t('ai_assistant.agents.loop_policy.killSwitchLabel', 'Kill switch')}
1677
+ data-ai-agent-loop-kill-switch
1678
+ />
1679
+ </div>
1680
+
1681
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
1682
+ <div className="flex flex-col gap-1">
1683
+ <Label htmlFor={`loop-max-steps-${agent.id}`} className="text-xs">
1684
+ {t('ai_assistant.agents.loop_policy.maxStepsLabel', 'Max steps')}
1685
+ </Label>
1686
+ <Input
1687
+ id={`loop-max-steps-${agent.id}`}
1688
+ type="number"
1689
+ min={1}
1690
+ max={1000}
1691
+ value={maxSteps}
1692
+ onChange={(event) => setMaxSteps(event.target.value)}
1693
+ placeholder={t('ai_assistant.agents.loop_policy.noOverridePlaceholder', 'No override')}
1694
+ className="h-8 text-sm"
1695
+ data-ai-agent-loop-max-steps
1696
+ />
1697
+ </div>
1698
+ <div className="flex flex-col gap-1">
1699
+ <Label htmlFor={`loop-max-tool-calls-${agent.id}`} className="text-xs">
1700
+ {t('ai_assistant.agents.loop_policy.maxToolCallsLabel', 'Max tool calls')}
1701
+ </Label>
1702
+ <Input
1703
+ id={`loop-max-tool-calls-${agent.id}`}
1704
+ type="number"
1705
+ min={1}
1706
+ max={10000}
1707
+ value={maxToolCalls}
1708
+ onChange={(event) => setMaxToolCalls(event.target.value)}
1709
+ placeholder={t('ai_assistant.agents.loop_policy.noOverridePlaceholder', 'No override')}
1710
+ className="h-8 text-sm"
1711
+ data-ai-agent-loop-max-tool-calls
1712
+ />
1713
+ </div>
1714
+ <div className="flex flex-col gap-1">
1715
+ <Label htmlFor={`loop-max-wall-clock-${agent.id}`} className="text-xs">
1716
+ {t('ai_assistant.agents.loop_policy.maxWallClockMsLabel', 'Max wall-clock (ms)')}
1717
+ </Label>
1718
+ <Input
1719
+ id={`loop-max-wall-clock-${agent.id}`}
1720
+ type="number"
1721
+ min={100}
1722
+ max={3600000}
1723
+ value={maxWallClockMs}
1724
+ onChange={(event) => setMaxWallClockMs(event.target.value)}
1725
+ placeholder={t('ai_assistant.agents.loop_policy.noOverridePlaceholder', 'No override')}
1726
+ className="h-8 text-sm"
1727
+ data-ai-agent-loop-max-wall-clock-ms
1728
+ />
1729
+ </div>
1730
+ <div className="flex flex-col gap-1">
1731
+ <Label htmlFor={`loop-max-tokens-${agent.id}`} className="text-xs">
1732
+ {t('ai_assistant.agents.loop_policy.maxTokensLabel', 'Max tokens')}
1733
+ </Label>
1734
+ <Input
1735
+ id={`loop-max-tokens-${agent.id}`}
1736
+ type="number"
1737
+ min={1}
1738
+ max={10000000}
1739
+ value={maxTokens}
1740
+ onChange={(event) => setMaxTokens(event.target.value)}
1741
+ placeholder={t('ai_assistant.agents.loop_policy.noOverridePlaceholder', 'No override')}
1742
+ className="h-8 text-sm"
1743
+ data-ai-agent-loop-max-tokens
1744
+ />
1745
+ </div>
1746
+ </div>
1747
+
1748
+ {state.kind === 'success' ? (
1749
+ <Alert variant="success" data-ai-agent-loop-policy-state="success">
1750
+ <AlertTitle>
1751
+ {t('ai_assistant.agents.loop_policy.savedTitle', 'Loop policy updated')}
1752
+ </AlertTitle>
1753
+ <AlertDescription>{state.message}</AlertDescription>
1754
+ </Alert>
1755
+ ) : null}
1756
+ {state.kind === 'error' ? (
1757
+ <Alert variant="destructive" data-ai-agent-loop-policy-state="error">
1758
+ <AlertTitle>
1759
+ {t(
1760
+ 'ai_assistant.agents.loop_policy.errorTitle',
1761
+ 'Failed to update loop policy',
1762
+ )}
1763
+ </AlertTitle>
1764
+ <AlertDescription>{state.message}</AlertDescription>
1765
+ </Alert>
1766
+ ) : null}
1767
+
1768
+ <div className="flex items-center justify-end gap-2">
1769
+ <Button
1770
+ type="button"
1771
+ size="sm"
1772
+ variant="outline"
1773
+ onClick={() => void clear()}
1774
+ disabled={isClearing || isSaving || !currentOverride}
1775
+ data-ai-agent-loop-policy-clear
1776
+ >
1777
+ {isClearing ? (
1778
+ <Loader2 className="size-4 animate-spin" aria-hidden />
1779
+ ) : (
1780
+ <Trash2 className="size-4" aria-hidden />
1781
+ )}
1782
+ <span>{t('ai_assistant.agents.loop_policy.clear', 'Clear override')}</span>
1783
+ </Button>
1784
+ <Button
1785
+ type="button"
1786
+ size="sm"
1787
+ onClick={() => void save()}
1788
+ disabled={isSaving || isClearing}
1789
+ data-ai-agent-loop-policy-save
1790
+ >
1791
+ {isSaving ? (
1792
+ <Loader2 className="size-4 animate-spin" aria-hidden />
1793
+ ) : (
1794
+ <Save className="size-4" aria-hidden />
1795
+ )}
1796
+ <span>{t('ai_assistant.agents.loop_policy.save', 'Save override')}</span>
1797
+ </Button>
1798
+ </div>
1799
+ </>
1800
+ )}
1801
+ </div>
1802
+ </section>
1803
+ )
1804
+ }
1805
+
1451
1806
  function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1452
1807
  const t = useT()
1453
1808
  const queryClient = useQueryClient()
@@ -1709,6 +2064,8 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1709
2064
 
1710
2065
  <MutationPolicySection agent={agent} />
1711
2066
 
2067
+ <LoopPolicySection agent={agent} />
2068
+
1712
2069
  <section
1713
2070
  className="rounded-lg border border-border bg-background p-4"
1714
2071
  data-ai-agent-prompt-editor={agent.id}
@@ -1741,8 +2098,11 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1741
2098
  </Button>
1742
2099
  </header>
1743
2100
  <div className="mt-3">
1744
- <Alert variant="info" data-ai-agent-prompt-notice>
1745
- <Wand2 className="size-4" aria-hidden />
2101
+ <Alert
2102
+ variant="info"
2103
+ icon={<Wand2 aria-hidden="true" />}
2104
+ data-ai-agent-prompt-notice
2105
+ >
1746
2106
  <AlertTitle>
1747
2107
  {t('ai_assistant.agents.override.noticeTitle', 'Prompt overrides are additive')}
1748
2108
  </AlertTitle>
@@ -1756,7 +2116,6 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1756
2116
  </div>
1757
2117
  {saveState.kind === 'success' ? (
1758
2118
  <Alert variant="success" className="mt-3" data-ai-agent-prompt-state="success">
1759
- <CheckCircle2 className="size-4" aria-hidden />
1760
2119
  <AlertTitle>
1761
2120
  {saveState.version > 0
1762
2121
  ? t('ai_assistant.agents.override.savedTitle', 'Prompt override saved')
@@ -1771,7 +2130,6 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1771
2130
  ) : null}
1772
2131
  {saveState.kind === 'error' ? (
1773
2132
  <Alert variant="destructive" className="mt-3" data-ai-agent-prompt-state="error">
1774
- <AlertCircle className="size-4" aria-hidden />
1775
2133
  <AlertTitle>
1776
2134
  {t('ai_assistant.agents.override.errorTitle', 'Failed to save prompt override')}
1777
2135
  </AlertTitle>
@@ -1892,7 +2250,6 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
1892
2250
  />
1893
2251
  ) : overrideQuery.isError ? (
1894
2252
  <Alert variant="destructive" data-ai-agent-override-history-error>
1895
- <AlertCircle className="size-4" aria-hidden />
1896
2253
  <AlertTitle>
1897
2254
  {t(
1898
2255
  'ai_assistant.agents.override.history.errorTitle',
@@ -2002,7 +2359,6 @@ export function AiAgentSettingsPageClient() {
2002
2359
  if (isError) {
2003
2360
  return (
2004
2361
  <Alert variant="destructive" data-ai-agent-settings-error>
2005
- <AlertCircle className="size-4" aria-hidden />
2006
2362
  <AlertTitle>
2007
2363
  {t('ai_assistant.agents.loadErrorTitle', 'Failed to load AI agents')}
2008
2364
  </AlertTitle>
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { useQuery, useQueryClient } from '@tanstack/react-query'
5
- import { Loader2, Save, Shield, Trash2, Info, AlertCircle, ShieldCheck } from 'lucide-react'
5
+ import { Loader2, Save, Shield, Trash2, ShieldCheck } from 'lucide-react'
6
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
7
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
8
8
  import { Badge } from '@open-mercato/ui/primitives/badge'
@@ -130,7 +130,6 @@ export function AiTenantAllowlistPageClient(): React.JSX.Element {
130
130
  <div className="flex max-w-3xl flex-col gap-4">
131
131
  {pageHeader}
132
132
  <Alert variant="destructive">
133
- <AlertCircle className="size-4" />
134
133
  <AlertTitle>{t('ai_assistant.allowlist.loadError.title', 'Failed to load allowlist')}</AlertTitle>
135
134
  <AlertDescription>
136
135
  {settingsQuery.error instanceof Error
@@ -286,7 +285,6 @@ export function AiTenantAllowlistPageClient(): React.JSX.Element {
286
285
  ? null
287
286
  : (
288
287
  <Alert>
289
- <Info className="h-4 w-4" />
290
288
  <AlertTitle>{t('ai_assistant.allowlist.envBanner.title', 'Env allowlist is in effect')}</AlertTitle>
291
289
  <AlertDescription className="space-y-1">
292
290
  {envAllowedProviders ? (
@@ -314,7 +312,6 @@ export function AiTenantAllowlistPageClient(): React.JSX.Element {
314
312
 
315
313
  {feedback ? (
316
314
  <Alert variant={feedback.kind === 'error' ? 'destructive' : undefined}>
317
- {feedback.kind === 'error' ? <AlertCircle className="h-4 w-4" /> : <Info className="h-4 w-4" />}
318
315
  <AlertDescription>{feedback.text}</AlertDescription>
319
316
  </Alert>
320
317
  ) : null}
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { useQuery } from '@tanstack/react-query'
5
- import { AlertCircle, Bot, BookOpen, Loader2, Play, RefreshCcw } from 'lucide-react'
5
+ import { Bot, BookOpen, Loader2, Play, RefreshCcw } from 'lucide-react'
6
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
7
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
8
8
  import { Button } from '@open-mercato/ui/primitives/button'
@@ -13,7 +13,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@open-mercato/ui/primi
13
13
  import { Textarea } from '@open-mercato/ui/primitives/textarea'
14
14
  import { EmptyState } from '@open-mercato/ui/backend/EmptyState'
15
15
  import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
16
- import { AiChat, createAiUiPartRegistry, useAiShortcuts } from '@open-mercato/ui/ai'
16
+ import { AiChat, createAiUiPartRegistry, LoopDisabledBanner, useAiShortcuts } from '@open-mercato/ui/ai'
17
17
  import type { AiChatDebugPromptSection, AiChatDebugTool } from '@open-mercato/ui/ai'
18
18
 
19
19
  type PlaygroundAgentTool = {
@@ -110,10 +110,7 @@ function PlaygroundNoAgents() {
110
110
  function AgentDetails({ agent }: { agent: PlaygroundAgent }) {
111
111
  const t = useT()
112
112
  return (
113
- <div
114
- className="rounded-md border border-border bg-muted/30 p-3 text-sm"
115
- data-ai-playground-agent={agent.id}
116
- >
113
+ <div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
117
114
  <div className="font-semibold">{agent.label}</div>
118
115
  <p className="mt-1 text-xs text-muted-foreground">{agent.description}</p>
119
116
  <dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
@@ -196,6 +193,27 @@ async function fetchAgentResolutions(): Promise<SettingsAgentResolutionResponse>
196
193
  return { agents: result.result.agents ?? [] }
197
194
  }
198
195
 
196
+ async function fetchLoopOverrideForAgent(
197
+ agentId: string,
198
+ ): Promise<{ agentId: string; override: { loopDisabled?: boolean | null } | null }> {
199
+ const result = await apiCall<{ agentId: string; override: { loopDisabled?: boolean | null } | null }>(
200
+ `/api/ai_assistant/ai/agents/${encodeURIComponent(agentId)}/loop-override`,
201
+ { method: 'GET', credentials: 'include' },
202
+ )
203
+ if (!result.ok || !result.result) return { agentId, override: null }
204
+ return result.result
205
+ }
206
+
207
+ function LoopDisabledPlaygroundBanner({ agentId }: { agentId: string }) {
208
+ const { data } = useQuery({
209
+ queryKey: ['ai_assistant', 'loop_override', agentId],
210
+ queryFn: () => fetchLoopOverrideForAgent(agentId),
211
+ staleTime: 30000,
212
+ })
213
+ if (!data?.override?.loopDisabled) return null
214
+ return <LoopDisabledBanner agentId={agentId} />
215
+ }
216
+
199
217
  function ModelResolutionPanel({ agentId }: { agentId: string }) {
200
218
  const t = useT()
201
219
  const { data } = useQuery({
@@ -211,7 +229,6 @@ function ModelResolutionPanel({ agentId }: { agentId: string }) {
211
229
  <dl
212
230
  className="grid grid-cols-2 gap-x-4 gap-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs sm:grid-cols-4"
213
231
  data-ai-playground-model-resolution={agentId}
214
- data-ai-playground-resolution-panel={agentId}
215
232
  >
216
233
  <div>
217
234
  <dt className="font-medium text-muted-foreground">
@@ -317,7 +334,7 @@ function ChatLane({ agent, debug }: { agent: PlaygroundAgent; debug: boolean })
317
334
  }
318
335
 
319
336
  return (
320
- <div data-ai-playground-chat={agent.id}>
337
+ <div className="flex flex-col gap-2" data-ai-playground-chat={agent.id}>
321
338
  <AiChat
322
339
  key={agent.id}
323
340
  agent={agent.id}
@@ -562,7 +579,6 @@ export function AiPlaygroundPageClient() {
562
579
  if (isError) {
563
580
  return (
564
581
  <Alert variant="destructive" data-ai-playground-error>
565
- <AlertCircle className="size-4" aria-hidden />
566
582
  <AlertTitle>
567
583
  {t('ai_assistant.playground.loadErrorTitle', 'Failed to load AI agents')}
568
584
  </AlertTitle>
@@ -651,6 +667,7 @@ export function AiPlaygroundPageClient() {
651
667
  </div>
652
668
  {selectedAgent ? <AgentDetails agent={selectedAgent} /> : null}
653
669
  {selectedAgent ? <ModelResolutionPanel agentId={selectedAgent.id} /> : null}
670
+ {selectedAgent ? <LoopDisabledPlaygroundBanner agentId={selectedAgent.id} /> : null}
654
671
  </section>
655
672
 
656
673
  {selectedAgent ? (