@adminforth/agent 1.51.1 → 1.52.0

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 (50) hide show
  1. package/agent/middleware/apiBasedTools.ts +31 -25
  2. package/agent/runtime/AgentRuntime.ts +24 -3
  3. package/agent/systemPrompt.ts +3 -6
  4. package/agent/turn/TurnLifecycleService.ts +18 -0
  5. package/agent/turn/TurnStreamConsumer.ts +11 -3
  6. package/agent/turn/turnTypes.ts +9 -1
  7. package/agentEvents.ts +5 -0
  8. package/agentTurnService.ts +158 -12
  9. package/apiBasedTools.ts +7 -0
  10. package/build.log +3 -2
  11. package/custom/ChatFooter.vue +3 -2
  12. package/custom/composables/agentStore/useAgentChat.ts +169 -6
  13. package/custom/composables/agentStore/useAgentSessions.ts +3 -1
  14. package/custom/composables/useAgentStore.ts +87 -0
  15. package/custom/conversation_area/MessageRenderer.vue +6 -1
  16. package/custom/conversation_area/ToolApprovalRenderer.vue +98 -0
  17. package/custom/skills/mutate_data/SKILL.md +10 -36
  18. package/custom/types.ts +4 -1
  19. package/dist/agent/middleware/apiBasedTools.js +26 -25
  20. package/dist/agent/runtime/AgentRuntime.d.ts +1 -1
  21. package/dist/agent/runtime/AgentRuntime.js +18 -3
  22. package/dist/agent/systemPrompt.js +3 -6
  23. package/dist/agent/turn/TurnLifecycleService.d.ts +8 -1
  24. package/dist/agent/turn/TurnLifecycleService.js +17 -1
  25. package/dist/agent/turn/TurnStreamConsumer.d.ts +2 -1
  26. package/dist/agent/turn/TurnStreamConsumer.js +14 -8
  27. package/dist/agent/turn/turnTypes.d.ts +14 -1
  28. package/dist/agentEvents.d.ts +4 -0
  29. package/dist/agentTurnService.d.ts +1 -0
  30. package/dist/agentTurnService.js +132 -14
  31. package/dist/apiBasedTools.d.ts +5 -0
  32. package/dist/apiBasedTools.js +1 -0
  33. package/dist/custom/ChatFooter.vue +3 -2
  34. package/dist/custom/composables/agentStore/useAgentChat.ts +169 -6
  35. package/dist/custom/composables/agentStore/useAgentSessions.ts +3 -1
  36. package/dist/custom/composables/useAgentStore.ts +87 -0
  37. package/dist/custom/conversation_area/MessageRenderer.vue +6 -1
  38. package/dist/custom/conversation_area/ToolApprovalRenderer.vue +98 -0
  39. package/dist/custom/skills/mutate_data/SKILL.md +10 -36
  40. package/dist/custom/types.ts +4 -1
  41. package/dist/endpoints/core.js +28 -0
  42. package/dist/index.js +1 -1
  43. package/dist/sessionStore.d.ts +1 -0
  44. package/dist/sessionStore.js +6 -0
  45. package/dist/surfaces/web-sse/createSseEventEmitter.js +13 -0
  46. package/endpoints/core.ts +30 -0
  47. package/index.ts +1 -1
  48. package/package.json +3 -6
  49. package/sessionStore.ts +11 -0
  50. package/surfaces/web-sse/createSseEventEmitter.ts +14 -0
@@ -14,15 +14,159 @@ type CreateAgentChatManagerOptions = {
14
14
  lastMessage: Ref<string>;
15
15
  activeModeName: Ref<string | null>;
16
16
  onOpenPage: (targetPath: string) => void;
17
+ onToolApprovalRequest: (sessionId: string, interrupt: unknown) => void;
17
18
  };
18
19
 
19
20
  export function createAgentChatManager({
20
21
  lastMessage,
21
22
  activeModeName,
22
23
  onOpenPage,
24
+ onToolApprovalRequest,
23
25
  }: CreateAgentChatManagerOptions) {
24
26
  const chats = new Map<string, Chat<any>>();
25
27
  const currentChat = shallowRef<Chat<any> | null>();
28
+ const agentApiBase = `${(import.meta as AgentImportMeta).env.VITE_ADMINFORTH_PUBLIC_PATH || ''}/adminapi/v1/agent`;
29
+
30
+ function replaceLastMessage(message: any) {
31
+ const chat = currentChat.value;
32
+
33
+ if (!chat) {
34
+ return;
35
+ }
36
+
37
+ chat.messages.splice(chat.messages.length - 1, 1, message);
38
+ }
39
+
40
+ function getOrCreateAssistantMessage() {
41
+ const chat = currentChat.value;
42
+ const lastChatMessage = chat?.lastMessage;
43
+
44
+ if (lastChatMessage?.role === 'assistant') {
45
+ return lastChatMessage;
46
+ }
47
+
48
+ const assistantMessage = {
49
+ role: 'assistant',
50
+ parts: [],
51
+ };
52
+
53
+ chat?.messages.push(assistantMessage);
54
+ return assistantMessage;
55
+ }
56
+
57
+ function appendTextDelta(delta: string) {
58
+ const assistantMessage = getOrCreateAssistantMessage();
59
+ const lastPart = assistantMessage.parts.at(-1);
60
+
61
+ if (lastPart?.type === 'text') {
62
+ lastPart.text = `${lastPart.text ?? ''}${delta}`;
63
+ lastPart.state = 'streaming';
64
+ } else {
65
+ assistantMessage.parts.push({
66
+ type: 'text',
67
+ text: delta,
68
+ state: 'streaming',
69
+ });
70
+ }
71
+
72
+ replaceLastMessage(assistantMessage);
73
+ }
74
+
75
+ function finishTextPart() {
76
+ const assistantMessage = currentChat.value?.lastMessage;
77
+ const lastPart = assistantMessage?.parts.at(-1);
78
+
79
+ if (assistantMessage?.role === 'assistant' && lastPart?.type === 'text') {
80
+ lastPart.state = 'done';
81
+ replaceLastMessage(assistantMessage);
82
+ }
83
+ }
84
+
85
+ function appendDataPart(type: string, data: unknown) {
86
+ const assistantMessage = getOrCreateAssistantMessage();
87
+
88
+ assistantMessage.parts.push({ type, data });
89
+ replaceLastMessage(assistantMessage);
90
+ }
91
+
92
+ function handleRealtimeChatData(dataPart: any) {
93
+ if (dataPart?.type === 'data-open-page' && typeof dataPart.data?.targetPath === 'string') {
94
+ onOpenPage(dataPart.data.targetPath);
95
+ return;
96
+ }
97
+
98
+ if (dataPart?.type === 'data-interrupt' && typeof dataPart.data?.sessionId === 'string') {
99
+ onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt);
100
+ }
101
+ }
102
+
103
+ function handleManualApprovalStreamPart(dataPart: any) {
104
+ if (dataPart?.type === 'text-delta' && typeof dataPart.delta === 'string') {
105
+ appendTextDelta(dataPart.delta);
106
+ return;
107
+ }
108
+
109
+ if (dataPart?.type === 'text-end') {
110
+ finishTextPart();
111
+ return;
112
+ }
113
+
114
+ if (dataPart?.type === 'data-tool-call') {
115
+ appendDataPart('data-tool-call', dataPart.data);
116
+ return;
117
+ }
118
+
119
+ if (dataPart?.type === 'data-rendering') {
120
+ appendDataPart('data-rendering', dataPart.data);
121
+ return;
122
+ }
123
+
124
+ if (dataPart?.type === 'data-open-page' && typeof dataPart.data?.targetPath === 'string') {
125
+ onOpenPage(dataPart.data.targetPath);
126
+ return;
127
+ }
128
+
129
+ if (dataPart?.type === 'data-interrupt' && typeof dataPart.data?.sessionId === 'string') {
130
+ onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt);
131
+ }
132
+ }
133
+
134
+ async function consumeAgentStream(response: Response) {
135
+ const reader = response.body?.getReader();
136
+
137
+ if (!reader) {
138
+ return;
139
+ }
140
+
141
+ const decoder = new TextDecoder();
142
+ let buffer = '';
143
+
144
+ while (true) {
145
+ const { value, done } = await reader.read();
146
+
147
+ if (done) {
148
+ break;
149
+ }
150
+
151
+ buffer += decoder.decode(value, { stream: true });
152
+ const events = buffer.split('\n\n');
153
+ buffer = events.pop() ?? '';
154
+
155
+ for (const event of events) {
156
+ const data = event
157
+ .split('\n')
158
+ .filter(line => line.startsWith('data:'))
159
+ .map(line => line.slice(5).trim())
160
+ .join('\n');
161
+
162
+ if (!data || data === '[DONE]') {
163
+ continue;
164
+ }
165
+
166
+ handleManualApprovalStreamPart(JSON.parse(data));
167
+ }
168
+ }
169
+ }
26
170
 
27
171
  function setCurrentChat(sessionId: string) {
28
172
  if (chats.has(sessionId)) {
@@ -30,7 +174,7 @@ export function createAgentChatManager({
30
174
  } else {
31
175
  const newChat = new Chat({
32
176
  transport: new DefaultChatTransport({
33
- api: `${(import.meta as AgentImportMeta).env.VITE_ADMINFORTH_PUBLIC_PATH || ''}/adminapi/v1/agent/response`,
177
+ api: `${agentApiBase}/response`,
34
178
  credentials: 'include',
35
179
  prepareSendMessagesRequest({ messages }: any) {
36
180
  const message = lastMessage.value;
@@ -54,11 +198,7 @@ export function createAgentChatManager({
54
198
  onError(error: unknown) {
55
199
  console.error('Chat error:', error);
56
200
  },
57
- onData(dataPart: any) {
58
- if (dataPart?.type === 'data-open-page' && typeof dataPart.data?.targetPath === 'string') {
59
- onOpenPage(dataPart.data.targetPath);
60
- }
61
- },
201
+ onData: handleRealtimeChatData,
62
202
  });
63
203
  chats.set(sessionId, newChat);
64
204
  currentChat.value = newChat;
@@ -69,9 +209,32 @@ export function createAgentChatManager({
69
209
  currentChat.value?.stop();
70
210
  }
71
211
 
212
+ async function submitToolApproval(sessionId: string, decision: 'approve' | 'reject') {
213
+ const response = await fetch(`${agentApiBase}/approval`, {
214
+ method: 'POST',
215
+ credentials: 'include',
216
+ headers: {
217
+ Accept: 'text/event-stream',
218
+ 'Content-Type': 'application/json',
219
+ 'x-vercel-ai-ui-message-stream': 'v1',
220
+ },
221
+ body: JSON.stringify({
222
+ sessionId,
223
+ decision,
224
+ }),
225
+ });
226
+
227
+ if (!response.ok) {
228
+ throw new Error(`Agent approval failed with status ${response.status}`);
229
+ }
230
+
231
+ await consumeAgentStream(response);
232
+ }
233
+
72
234
  return {
73
235
  currentChat,
74
236
  setCurrentChat,
75
237
  abortCurrentChatRequest,
238
+ submitToolApproval,
76
239
  };
77
240
  }
@@ -17,6 +17,7 @@ type CreateAgentSessionManagerOptions = {
17
17
  currentChat: ShallowRef<Chat<any> | null | undefined>;
18
18
  trimmedUserMessage: ComputedRef<string>;
19
19
  isResponseInProgress: ComputedRef<boolean>;
20
+ isMessageInputBlocked: ComputedRef<boolean>;
20
21
  userMessageInput: Ref<any>;
21
22
  lastMessage: Ref<string>;
22
23
  blockCloseOfChat: Ref<boolean>;
@@ -32,6 +33,7 @@ export function createAgentSessionManager({
32
33
  currentChat,
33
34
  trimmedUserMessage,
34
35
  isResponseInProgress,
36
+ isMessageInputBlocked,
35
37
  userMessageInput,
36
38
  lastMessage,
37
39
  blockCloseOfChat,
@@ -130,7 +132,7 @@ export function createAgentSessionManager({
130
132
 
131
133
  async function sendMessage() {
132
134
  const message = trimmedUserMessage.value;
133
- if (!message || isResponseInProgress.value) {
135
+ if (!message || isMessageInputBlocked.value) {
134
136
  return;
135
137
  }
136
138
  if (!currentSession.value || currentSession.value.sessionId === PRE_SESSION_ID) {
@@ -53,10 +53,12 @@ export const useAgentStore = defineStore('agent', () => {
53
53
  currentChat,
54
54
  setCurrentChat,
55
55
  abortCurrentChatRequest,
56
+ submitToolApproval: submitToolApprovalResponse,
56
57
  } = createAgentChatManager({
57
58
  lastMessage,
58
59
  activeModeName,
59
60
  onOpenPage: openAgentPage,
61
+ onToolApprovalRequest: addToolApprovalMessage,
60
62
  });
61
63
  const {
62
64
  userMessagePlaceholder,
@@ -95,6 +97,75 @@ export const useAgentStore = defineStore('agent', () => {
95
97
  isAudioChatMode.value = isAudioChat;
96
98
  }
97
99
 
100
+ function getToolApprovalMessages(interrupt: unknown): string[] {
101
+ const interrupts = Array.isArray(interrupt) ? interrupt : [interrupt];
102
+
103
+ return interrupts.flatMap((item: any) => {
104
+ const value = item?.value ?? item;
105
+ const actionRequests = Array.isArray(value?.actionRequests) ? value.actionRequests : [];
106
+
107
+ return actionRequests.map((actionRequest: any) => (
108
+ typeof actionRequest?.description === 'string'
109
+ ? actionRequest.description
110
+ : String(actionRequest?.name ?? 'Tool execution pending approval')
111
+ ));
112
+ });
113
+ }
114
+
115
+ function addToolApprovalMessage(sessionId: string, interrupt: unknown) {
116
+ const approvalPart = {
117
+ type: 'data-tool-approval' as const,
118
+ data: {
119
+ sessionId,
120
+ status: 'pending' as const,
121
+ messages: getToolApprovalMessages(interrupt),
122
+ },
123
+ };
124
+ const lastChatMessage = currentChat.value?.lastMessage;
125
+
126
+ if (lastChatMessage?.role === 'assistant') {
127
+ lastChatMessage.parts.push(approvalPart);
128
+ currentChat.value?.messages.splice(currentChat.value.messages.length - 1, 1, lastChatMessage);
129
+ return;
130
+ }
131
+
132
+ currentChat.value?.messages.push({
133
+ role: 'assistant',
134
+ parts: [approvalPart],
135
+ });
136
+ }
137
+
138
+ async function submitToolApproval(sessionId: string, decision: 'approve' | 'reject') {
139
+ const message = currentChat.value?.messages
140
+ .findLast(candidate => candidate.role === 'assistant' && candidate.parts.some(part => {
141
+ return part.type === 'data-tool-approval'
142
+ && part.data?.sessionId === sessionId
143
+ && part.data?.status === 'pending';
144
+ }));
145
+ const approvalPart = message?.parts.find(part => {
146
+ return part.type === 'data-tool-approval'
147
+ && part.data?.sessionId === sessionId
148
+ && part.data?.status === 'pending';
149
+ });
150
+
151
+ if (approvalPart?.data) {
152
+ approvalPart.data.status = 'processing';
153
+ }
154
+
155
+ try {
156
+ await submitToolApprovalResponse(sessionId, decision);
157
+
158
+ if (approvalPart?.data) {
159
+ approvalPart.data.status = decision === 'approve' ? 'approved' : 'rejected';
160
+ }
161
+ } catch (error) {
162
+ if (approvalPart?.data) {
163
+ approvalPart.data.status = 'pending';
164
+ }
165
+ console.error('Error submitting tool approval', error);
166
+ }
167
+ }
168
+
98
169
  watch(isAudioChatMode, (newVal: boolean) => {
99
170
  if (newVal) {
100
171
  addSystemMessage(RESERVED_SYSTEM_MESSAGE_CONTENT.START_AUDIO_CHAT);
@@ -107,6 +178,18 @@ export const useAgentStore = defineStore('agent', () => {
107
178
  const isResponseInProgress = computed( () => {
108
179
  return currentChat.value?.status === 'streaming';
109
180
  });
181
+ const hasPendingToolApproval = computed(() => (
182
+ currentChat.value?.messages.some((message: any) => (
183
+ message.role === 'assistant'
184
+ && message.parts.some((part: any) => (
185
+ part.type === 'data-tool-approval'
186
+ && (part.data?.status === 'pending' || part.data?.status === 'processing')
187
+ ))
188
+ )) ?? false
189
+ ));
190
+ const isMessageInputBlocked = computed(() => (
191
+ isResponseInProgress.value || hasPendingToolApproval.value
192
+ ));
110
193
  const blockCloseOfChat = ref(false);
111
194
  const {
112
195
  sendMessage,
@@ -129,6 +212,7 @@ export const useAgentStore = defineStore('agent', () => {
129
212
  currentChat,
130
213
  trimmedUserMessage,
131
214
  isResponseInProgress,
215
+ isMessageInputBlocked,
132
216
  userMessageInput,
133
217
  lastMessage,
134
218
  blockCloseOfChat,
@@ -385,11 +469,14 @@ export const useAgentStore = defineStore('agent', () => {
385
469
  isSessionHistoryOpen,
386
470
  setSessionHistoryOpen,
387
471
  sendMessage,
472
+ submitToolApproval,
388
473
  userMessageInput,
389
474
  userMessagePlaceholder,
390
475
  chatMessages: computed(() => currentChat.value?.messages || []),
391
476
  trimmedUserMessage,
392
477
  isResponseInProgress,
478
+ hasPendingToolApproval,
479
+ isMessageInputBlocked,
393
480
  isTeleportedToBody,
394
481
  setIsTeleportedToBody,
395
482
  chatWidth,
@@ -13,8 +13,12 @@
13
13
  :role="props.message.role"
14
14
  :state="part.state ?? (props.message.role === 'user' ? 'done' : undefined)"
15
15
  />
16
+ <ToolApprovalRenderer
17
+ v-else-if="part.type === 'data-tool-approval'"
18
+ :data="part.data ?? {}"
19
+ />
16
20
  <SystemMessageRenderer
17
- v-else
21
+ v-else-if="part.type === 'text'"
18
22
  :message="part.text"
19
23
  />
20
24
  </template>
@@ -30,6 +34,7 @@
30
34
  import { getMessageParts } from '../utils';
31
35
  import ProcessingTimeline from './ProcessingTimeline.vue';
32
36
  import SystemMessageRenderer from './SystemMessageRenderer.vue';
37
+ import ToolApprovalRenderer from './ToolApprovalRenderer.vue';
33
38
  import { RESERVED_SYSTEM_MESSAGE_CONTENT } from '../composables/agentStore/constants';
34
39
 
35
40
  const props = defineProps<{
@@ -0,0 +1,98 @@
1
+ <template>
2
+ <div class="mx-4 my-3 max-w-[min(34rem,calc(100%-2rem))] rounded-lg border border-lightPrimary/30 bg-lightNavbar p-4 text-lightListTableHeadingText shadow-sm dark:border-darkPrimary/40 dark:bg-darkNavbar dark:text-darkListTableHeadingText">
3
+ <div class="flex items-start gap-3">
4
+ <div class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/15 dark:text-darkPrimary">
5
+ <IconExclamationCircleOutline class="h-5 w-5" />
6
+ </div>
7
+ <div class="min-w-0 flex-1">
8
+ <h3 class="text-sm font-semibold leading-5">{{ $t('Approval required') }}</h3>
9
+ <p class="mt-1 text-sm leading-5 text-lightListTableText dark:text-darkListTableText">
10
+ {{ $t('Review the agent message before continuing.') }}
11
+ </p>
12
+ <button
13
+ v-if="data.messages?.length"
14
+ type="button"
15
+ class="mt-3 inline-flex items-center gap-2 text-sm font-medium text-lightListTableHeadingText transition hover:opacity-80 dark:text-darkListTableHeadingText"
16
+ @click="isExpanded = !isExpanded"
17
+ >
18
+ <IconChevronDownOutline
19
+ class="h-4 w-4 transition-transform"
20
+ :class="isExpanded ? 'rotate-180' : ''"
21
+ />
22
+ {{ isExpanded ? $t('Hide details') : $t('Show details') }}
23
+ <span class="rounded-full bg-lightListTableText/10 px-2 py-0.5 text-xs text-lightListTableHeadingText dark:bg-darkListTableText/10 dark:text-darkListTableHeadingText">
24
+ {{ data.messages.length }}
25
+ </span>
26
+ </button>
27
+ <ul
28
+ v-if="isExpanded && data.messages?.length"
29
+ class="mt-3 space-y-1 text-sm leading-5 text-lightListTableHeadingText dark:text-darkListTableHeadingText"
30
+ >
31
+ <li
32
+ v-for="message in data.messages"
33
+ :key="message"
34
+ class="flex gap-2"
35
+ >
36
+ <span class="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-lightListTableHeadingText dark:bg-darkListTableHeadingText" />
37
+ <span>{{ message }}</span>
38
+ </li>
39
+ </ul>
40
+ <div class="mt-4 flex flex-wrap gap-2">
41
+ <template v-if="data.status === 'pending'">
42
+ <button
43
+ type="button"
44
+ class="inline-flex h-9 items-center gap-2 rounded-md border border-lightButtonsBorder bg-lightButtonsBackground px-3 text-sm font-medium text-lightButtonsText transition hover:bg-lightButtonsHover dark:border-darkButtonsBorder dark:bg-darkButtonsBackground dark:text-darkButtonsText dark:hover:bg-darkButtonsHover"
45
+ @click="submit('approve')"
46
+ >
47
+ <IconCheckOutline class="h-4 w-4" />
48
+ {{ $t('Approve') }}
49
+ </button>
50
+ <button
51
+ type="button"
52
+ class="inline-flex h-9 items-center gap-2 rounded-md border border-lightListTableText/20 bg-transparent px-3 text-sm font-medium text-lightListTableHeadingText transition hover:bg-lightListTableText/10 dark:border-darkListTableText/20 dark:text-darkListTableHeadingText dark:hover:bg-darkListTableText/10"
53
+ @click="submit('reject')"
54
+ >
55
+ <IconCloseOutline class="h-4 w-4" />
56
+ {{ $t('Reject') }}
57
+ </button>
58
+ </template>
59
+ <span
60
+ v-else
61
+ class="inline-flex h-8 items-center gap-2 rounded-md border px-2.5 text-sm font-medium"
62
+ :class="data.status === 'processing'
63
+ ? 'border-lightListTableText/20 bg-lightListTableText/10 text-lightListTableHeadingText dark:border-darkListTableText/20 dark:bg-darkListTableText/10 dark:text-darkListTableHeadingText'
64
+ : data.status === 'approved'
65
+ ? 'border-lightListTableText/20 bg-lightListTableText/10 text-lightListTableHeadingText dark:border-darkListTableText/20 dark:bg-darkListTableText/10 dark:text-darkListTableHeadingText'
66
+ : 'border-lightListTableText/20 bg-lightListTableText/10 text-lightListTableHeadingText dark:border-darkListTableText/20 dark:bg-darkListTableText/10 dark:text-darkListTableHeadingText'"
67
+ >
68
+ <IconCheckOutline v-if="data.status === 'approved'" class="h-4 w-4" />
69
+ <IconCloseOutline v-else-if="data.status === 'rejected'" class="h-4 w-4" />
70
+ {{ data.status === 'processing' ? $t('Processing') : data.status === 'approved' ? $t('Approved') : $t('Rejected') }}
71
+ </span>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </template>
77
+
78
+ <script setup lang="ts">
79
+ import type { IPartData } from '../types';
80
+ import { useAgentStore } from '../composables/useAgentStore';
81
+ import { ref } from 'vue';
82
+ import { IconCheckOutline, IconChevronDownOutline, IconCloseOutline, IconExclamationCircleOutline } from '@iconify-prerendered/vue-flowbite';
83
+
84
+ const props = defineProps<{
85
+ data: IPartData;
86
+ }>();
87
+
88
+ const agentStore = useAgentStore();
89
+ const isExpanded = ref(false);
90
+
91
+ function submit(decision: 'approve' | 'reject') {
92
+ if (!props.data.sessionId || props.data.status !== 'pending') {
93
+ return;
94
+ }
95
+
96
+ agentStore.submitToolApproval(props.data.sessionId, decision);
97
+ }
98
+ </script>
@@ -16,41 +16,22 @@ Use `start_custom_action` and `start_custom_bulk_action` for resource actions.
16
16
 
17
17
  - if there is a dedicated action for some routine (result of `get_resource` tool call, field actions), prefer this to manual updating of records, for example, if you want to approve some comment, prefer calling `approve` action instead of updating `approved` field of comment record (because in action there might be some additional logic like sending notification to user, updating some counters and so on)
18
18
 
19
- ## Confirmation
19
+ ## Mutation context
20
20
 
21
- Before performing any state mutation including action calls edit/delete please fetch record which is going to be edited/deleted and show user record in format field → value (show several most important fields which can help user to understand what exactly record he is going to edit or delete).
21
+ Before performing any state mutation including action calls edit/delete please fetch record which is going to be edited/deleted and show user record in format field → value (show several most important fields which can help user to understand what exactly record he is going to edit or delete).
22
22
 
23
- Every confirmation must describe one exact fetched row. Never combine `_label`, primary key, link, or field values from different rows or different resources in one confirmation.
23
+ Every mutation description must describe one exact fetched row. Never combine `_label`, primary key, link, or field values from different rows or different resources in one description.
24
24
 
25
25
  For field values with long texts show only several first words and add "..." at the end.
26
26
  Also please add related link to record with will be changed. Build it as `{ADMIN_BASE_PATH}resource/{resourceId}/show/{primary key}`. Use _label from `get_resource_data` as anchor text for link (use markdown link). Links should always be relative paths and must start with `ADMIN_BASE_PATH`. Do not add an extra slash after `ADMIN_BASE_PATH`.
27
27
 
28
- Before sending the confirmation, verify that the `resourceId`, `{primary key}`, `_label`, and all shown fields come from the same exact fetched row.
28
+ Before calling the mutation tool, verify that the `resourceId`, `{primary key}`, `_label`, and all shown fields come from the same exact fetched row.
29
29
 
30
- Never show information about more than 10 records in one message. If a mutation plan affects more than 10 records, show only the 10 most important examples plus the total count if known, and ask the user to confirm the clearly described full batch.
30
+ Never show information about more than 10 records in one message. If a mutation plan affects more than 10 records, show only the 10 most important examples plus the total count if known.
31
31
 
32
- And in the same message ask user for final confirmation.
32
+ When creating new record, show user all data which you gonna create.
33
33
 
34
- When creating new record, show user all data which you gona create and in same message ask for confirmation.
35
-
36
- Accept any positive confirmation from user like "yes", "sure", "+", anything non-negative call to action, can be considered as confirmation.
37
-
38
- A confirmation is valid only for the clearly described mutation plan from the immediately previous assistant message.
39
-
40
- Never reuse an older confirmation for a later mutation.
41
-
42
- One confirmation may cover:
43
- - one single mutation
44
- - one explicitly described batch
45
- - one short sequence of related mutations that together implement the same user request
46
-
47
- If the confirmed plan contains several related mutation steps, execute that whole confirmed plan without asking again between those steps.
48
-
49
- Ask for confirmation again if the plan changes in any way: different record, different fields, different values, different number of records, different action, or any extra mutation that was not listed in the confirmation message.
50
-
51
- If you are creating or deleting multiple records in one batch, you may ask once for that exact batch, but list the whole batch explicitly in the confirmation message. Any extra record outside that described batch requires a new confirmation.
52
-
53
- After the confirmed plan is finished, do not treat that confirmation as still active for later requests.
34
+ Do not ask user for textual confirmation. Dangerous tools are approved by the runtime approval UI.
54
35
 
55
36
  # Calling actions
56
37
 
@@ -60,7 +41,7 @@ Before calling any of this action you should understand whether this action is a
60
41
 
61
42
  ### Example
62
43
 
63
- If you want to block some user you can confirm that this action by saying:
44
+ If you want to block some user you can describe the action by saying:
64
45
 
65
46
  ```I am going to block user:
66
47
  * Username: john_doe
@@ -69,7 +50,6 @@ If you want to block some user you can confirm that this action by saying:
69
50
  * Currently blocked: No // show this field only if it exists in user record
70
51
 
71
52
  View [John Doe]({ADMIN_BASE_PATH}resource/users/show/123)
72
- Are you sure?
73
53
  ```
74
54
 
75
55
  ## Updating
@@ -81,7 +61,7 @@ In addition to instructions above show user the table of edits (old value/new va
81
61
 
82
62
  ### Examples
83
63
 
84
- For example if you gonna modify user record, in confirmation please share full user info (not only username but also email, ip country - anything which help adminto check that that is correct user). Message could look like this:
64
+ For example if you gonna modify user record, please share full user info (not only username but also email, ip country - anything which help adminto check that that is correct user). Message could look like this:
85
65
 
86
66
  ```
87
67
  I am going to update user:
@@ -91,8 +71,6 @@ I am going to update user:
91
71
  I am going to change email from john_doe@example.com to new_email@example.com
92
72
 
93
73
  View [John Doe]({ADMIN_BASE_PATH}resource/users/show/123)
94
-
95
- Are you sure?
96
74
  ```
97
75
 
98
76
 
@@ -102,7 +80,7 @@ To delete some record you can use `delete_record` tool. To delete record `allowe
102
80
 
103
81
  ### Example
104
82
 
105
- If you gonna delete user record, in confirmation please share full user info (not only username but also email, ip country - anything which help adminto check that that is correct user). Message could look like this:
83
+ If you gonna delete user record, please share full user info (not only username but also email, ip country - anything which help adminto check that that is correct user). Message could look like this:
106
84
 
107
85
  ```I am going to delete user:
108
86
  * Username: john_doe
@@ -111,8 +89,6 @@ If you gonna delete user record, in confirmation please share full user info (no
111
89
  * IP Country: USA
112
90
 
113
91
  View [John Doe]({ADMIN_BASE_PATH}resource/users/show/123)
114
-
115
- Are you sure?
116
92
  ```
117
93
 
118
94
  ## Creating
@@ -142,6 +118,4 @@ I am going to create user:
142
118
  * Email: john_doe@example.com
143
119
 
144
120
  View [John Doe]({ADMIN_BASE_PATH}resource/users/show/421) # 421 is id of new created record
145
-
146
- Are you sure?
147
121
  ```
@@ -1,15 +1,18 @@
1
1
  export interface IPartData {
2
2
  toolCallId?: string;
3
3
  toolName?: string;
4
+ sessionId?: string;
4
5
  phase?: 'start' | 'end';
5
6
  label?: string;
6
7
  input?: any;
7
8
  output?: any;
8
9
  durationMs?: number;
9
10
  toolInfo?: string;
11
+ status?: 'pending' | 'processing' | 'approved' | 'rejected';
12
+ messages?: string[];
10
13
  }
11
14
  export interface IPart {
12
- type: 'reasoning' | 'data-tool-call' | 'data-rendering' | 'text';
15
+ type: 'reasoning' | 'data-tool-call' | 'data-rendering' | 'data-tool-approval' | 'text';
13
16
  text?: string;
14
17
  state?: 'started' | 'thinking' | 'processing' | 'streaming' | 'done';
15
18
  data?: IPartData;
@@ -16,6 +16,10 @@ const agentResponseBodySchema = z.object({
16
16
  timeZone: z.string().optional(),
17
17
  currentPage: z.custom().optional(),
18
18
  }).strict();
19
+ const agentApprovalBodySchema = z.object({
20
+ sessionId: z.string(),
21
+ decision: z.enum(["approve", "reject"]),
22
+ }).strict();
19
23
  const agentSpeechResponseBodySchema = agentResponseBodySchema.omit({ message: true });
20
24
  export function setupCoreEndpoints(ctx, server) {
21
25
  server.endpoint({
@@ -63,6 +67,30 @@ export function setupCoreEndpoints(ctx, server) {
63
67
  return null;
64
68
  })
65
69
  });
70
+ server.endpoint({
71
+ method: 'POST',
72
+ path: `/agent/approval`,
73
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, response, _raw_express_res, abortSignal }) {
74
+ const data = ctx.parseBody(agentApprovalBodySchema, body, response);
75
+ if (!data)
76
+ return;
77
+ const emit = createSseEventEmitter(_raw_express_res, {
78
+ vercelAiUiMessageStream: true,
79
+ closeActiveBlockOnToolStart: true,
80
+ });
81
+ yield ctx.handleTurn({
82
+ prompt: "",
83
+ sessionId: data.sessionId,
84
+ approvalDecision: data.decision,
85
+ abortSignal,
86
+ adminUser: adminUser,
87
+ emit,
88
+ failureLogMessage: "Agent approval response streaming failed",
89
+ abortLogMessage: "Agent approval response streaming aborted by the client",
90
+ });
91
+ return null;
92
+ })
93
+ });
66
94
  server.endpoint({
67
95
  method: 'POST',
68
96
  path: `/agent/speech-response`,
package/dist/index.js CHANGED
@@ -66,7 +66,7 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
66
66
  toolProvider,
67
67
  });
68
68
  const persistence = new TurnPersistenceService(() => this.adminforth, this.options);
69
- this.agentTurnService = new AgentTurnService(new TurnLifecycleService(this.sessionStore, persistence), new TurnContextBuilder(() => this.adminforth), new AgentModeResolver(this.options), new AgentModelFactory((_a = this.options.maxTokens) !== null && _a !== void 0 ? _a : 1000), new TurnPromptBuilder({
69
+ this.agentTurnService = new AgentTurnService(new TurnLifecycleService(this.sessionStore, persistence, this.options), new TurnContextBuilder(() => this.adminforth), new AgentModeResolver(this.options), new AgentModelFactory((_a = this.options.maxTokens) !== null && _a !== void 0 ? _a : 1000), new TurnPromptBuilder({
70
70
  getAdminforth: () => this.adminforth,
71
71
  getAgentSystemPrompt: () => this.agentSystemPromptPromise,
72
72
  }), runtime, new TurnStreamConsumer());
@@ -14,6 +14,7 @@ export declare class AgentSessionStore {
14
14
  response: any;
15
15
  }[]>;
16
16
  getPreviousUserMessages(sessionId: string): Promise<PreviousUserMessage[]>;
17
+ getLatestTurn(sessionId: string): Promise<any>;
17
18
  getChatSurfaceSessionId(incoming: ChatSurfaceIncomingMessage): string;
18
19
  getOrCreateChatSurfaceSession(incoming: ChatSurfaceIncomingMessage, adminUser: AdminUser): Promise<string>;
19
20
  }