@adminforth/agent 2.1.0 → 2.2.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 (32) hide show
  1. package/dist/application/runTurnUseCase.d.ts +3 -0
  2. package/dist/application/runTurnUseCase.js +6 -0
  3. package/dist/custom/ChatFooter.vue +58 -30
  4. package/dist/custom/CustomAutoScrollContainer.vue +5 -2
  5. package/dist/custom/SteerDebugPanel.vue +45 -0
  6. package/dist/custom/SteerQueue.vue +54 -0
  7. package/dist/custom/composables/agentStore/constants.ts +1 -0
  8. package/dist/custom/composables/agentStore/useAgentSessions.ts +35 -16
  9. package/dist/custom/composables/agentStore/useAgentSteerQueue.ts +114 -0
  10. package/dist/custom/composables/useAgentStore.ts +46 -1
  11. package/dist/custom/conversation_area/ConversationArea.vue +42 -16
  12. package/dist/custom/conversation_area/MessageRenderer.vue +7 -1
  13. package/dist/custom/conversation_area/ThreeDotsAnimation.vue +3 -3
  14. package/dist/custom/speech_recognition_frontend/AudioLines.vue +1 -1
  15. package/dist/custom/speech_recognition_frontend/MicrophoneButon.vue +3 -3
  16. package/dist/domain/agentEvents.d.ts +4 -0
  17. package/dist/domain/steerBuffer.d.ts +33 -0
  18. package/dist/domain/steerBuffer.js +51 -0
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.js +9 -0
  21. package/dist/llm/agentRuntime.d.ts +3 -0
  22. package/dist/llm/agentRuntime.js +3 -0
  23. package/dist/llm/middleware/steerMiddleware.d.ts +21 -0
  24. package/dist/llm/middleware/steerMiddleware.js +50 -0
  25. package/dist/persistence/sessionStore.d.ts +7 -0
  26. package/dist/persistence/sessionStore.js +20 -0
  27. package/dist/transport/http/context.d.ts +10 -2
  28. package/dist/transport/http/coreEndpoints.js +20 -0
  29. package/dist/transport/http/sessionEndpoints.js +29 -0
  30. package/dist/transport/sse/sseWriter.d.ts +3 -3
  31. package/dist/transport/sse/sseWriter.js +15 -3
  32. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { type IAdminForth } from "adminforth";
2
2
  import type { AgentSessionStore } from "../persistence/sessionStore.js";
3
+ import type { SteerBuffer } from "../domain/steerBuffer.js";
3
4
  import type { PluginOptions } from "../types.js";
4
5
  import type { LlmPort } from "./ports.js";
5
6
  import type { DebugSink, HandleTurnInput, PendingInterrupt, RunAndPersistAgentResponseInput, RunAndPersistAgentResponseResult } from "../domain/turnTypes.js";
@@ -33,6 +34,8 @@ export declare function buildResumeValue(input: {
33
34
  export type RunTurnUseCaseDeps = {
34
35
  llm: LlmPort;
35
36
  sessions: AgentSessionStore;
37
+ /** Per-session buffer of mid-turn steering instructions; cleared when a turn ends unconsumed. */
38
+ steerBuffer: SteerBuffer;
36
39
  modes: PluginOptions["modes"];
37
40
  getAdminforth: () => IAdminForth;
38
41
  getAgentSystemPrompt: () => Promise<string>;
@@ -271,6 +271,12 @@ export class RunTurnUseCase {
271
271
  return { text: fullResponse };
272
272
  }
273
273
  finally {
274
+ // When the turn is interrupted for HITL approval it will resume, so any steers
275
+ // buffered meanwhile must survive to be applied on resume. Otherwise the turn is
276
+ // done and leftover (unconsumed) steers must not leak into the next turn.
277
+ if (!interrupted) {
278
+ this.deps.steerBuffer.clear(prepared.sessionId);
279
+ }
274
280
  if (!this.deps.hasPersistentCheckpointer && !interrupted) {
275
281
  this.pendingInterrupts.delete(prepared.sessionId);
276
282
  }
@@ -7,27 +7,30 @@
7
7
  transition: `transform ${agentTransitions.TRANSITION_DURATION}ms ease-in-out`
8
8
  }"
9
9
  >
10
- <div
11
- class="w-full border rounded-lg pb-8"
12
- :class="agentStore.isAudioChatMode ? 'border-none mt-8' : 'border dark:bg-gray-700'"
13
- >
10
+ <SteerQueue />
11
+ <!-- <SteerDebugPanel /> -->
12
+ <div
13
+ class="w-full border rounded-lg flex flex-col"
14
+ :class="agentStore.isAudioChatMode ? 'border-none mt-8' : 'border dark:bg-gray-700'"
15
+ >
14
16
  <textarea
15
17
  v-if="!agentStore.isAudioChatMode"
16
18
  v-model="agentStore.userMessageInput"
17
19
  ref="textInput"
20
+ rows="1"
18
21
  @input="autoResize"
19
22
  :class="[
20
- 'min-h-12 px-4 pt-4 rounded-xl w-full resize-none overflow-hidden text-lightInputText dark:text-darkInputText rounded-md bg-transparent text-sm bg-gray-50 dark:bg-gray-700 dark:border-gray-600 focus:outline-none',
23
+ 'h-8 px-4 py-3 border-b rounded-xl rounded-b-none w-full resize-none overflow-hidden text-lightInputText dark:text-darkInputText rounded-md bg-transparent text-sm bg-gray-50 dark:bg-gray-700 dark:border-gray-600 focus:outline-none',
21
24
  { '!text-base': coreStore.isIos }
22
25
  ]"
23
- :placeholder="agentStore.hasPendingToolApproval ? 'Approve or reject the pending action to continue' : agentStore.userMessagePlaceholder"
24
- :disabled="agentStore.isMessageInputBlocked"
26
+ :placeholder="placeholderText"
25
27
  @keydown.enter.exact.prevent="sendMessage"
26
28
  />
29
+ <div class="flex items-center justify-between px-2 py-1">
27
30
  <div
28
31
  v-if="agentStore.availableModes.length > 1"
29
32
  ref="modeMenu"
30
- class="absolute bottom-2 left-4"
33
+ class="relative"
31
34
  >
32
35
  <button
33
36
  aria-label="Select mode"
@@ -46,22 +49,30 @@
46
49
  :class="isModeMenuOpen ? '!rotate-180' : '!rotate-0'"
47
50
  />
48
51
  </button>
49
-
50
- <div
51
- v-if="isModeMenuOpen"
52
- class="absolute bottom-full left-0 mb-2 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
53
- >
54
- <button
55
- v-for="mode in agentStore.availableModes"
56
- :key="mode.name"
57
- class="block w-full px-3 py-2 text-left text-sm text-lightInputText transition-colors duration-150 hover:bg-gray-100 dark:text-darkInputText dark:hover:bg-gray-700"
58
- :class="mode.name === agentStore.activeModeName ? 'bg-gray-100 dark:bg-gray-700' : ''"
59
- type="button"
60
- @click="selectMode(mode.name)"
52
+ <Transition
53
+ enter-active-class="transition ease-out duration-200"
54
+ enter-from-class="opacity-0 scale-95"
55
+ enter-to-class="opacity-100 scale-100"
56
+ leave-active-class="transition ease-in duration-150"
57
+ leave-from-class="opacity-100 scale-100"
58
+ leave-to-class="opacity-0 scale-95"
59
+ >
60
+ <div
61
+ v-if="isModeMenuOpen"
62
+ class="absolute bottom-full left-0 mb-2 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800"
61
63
  >
62
- {{ mode.name }}
63
- </button>
64
- </div>
64
+ <button
65
+ v-for="mode in agentStore.availableModes"
66
+ :key="mode.name"
67
+ class="block w-full px-3 py-2 text-left text-sm text-lightInputText transition-colors duration-150 hover:bg-gray-100 dark:text-darkInputText dark:hover:bg-gray-700"
68
+ :class="mode.name === agentStore.activeModeName ? 'bg-gray-100 dark:bg-gray-700' : ''"
69
+ type="button"
70
+ @click="selectMode(mode.name)"
71
+ >
72
+ {{ mode.name }}
73
+ </button>
74
+ </div>
75
+ </Transition>
65
76
  </div>
66
77
  <MicrophoneButton
67
78
  v-if="props.meta.hasAudioAdapter"
@@ -69,32 +80,33 @@
69
80
  <template v-if="!agentStore.isAudioChatMode">
70
81
  <Button
71
82
  v-if="!agentStore.isResponseInProgress"
72
- class="absolute right-4 bottom-2 !p-0 h-9 w-9 transition-opacity duration-200"
83
+ class=" !p-0 h-7 w-7 transition-opacity duration-200"
73
84
  @click="sendMessage"
74
- :disabled="!agentStore.trimmedUserMessage || agentStore.isMessageInputBlocked"
85
+ :disabled="!agentStore.trimmedUserMessage"
75
86
  >
76
87
  <IconArrowUpOutline
77
- class="w-8 h-8 p-1
88
+ class="w-6 h-6
78
89
  text-white"
79
90
  />
80
91
  </Button>
81
92
  <Button
82
93
  v-else
83
- class="absolute right-4 bottom-2 !p-0 h-9 w-9"
94
+ class="right-4 bottom-2 !p-0 h-7 w-7"
84
95
  @click="stopCurrentRequest"
85
96
  >
86
97
  <div
87
- class="w-3 h-3 bg-white rounded-sm"
98
+ class="w-2.5 h-2.5 bg-white rounded-sm"
88
99
  />
89
100
  </Button>
90
101
  </template>
102
+ </div>
91
103
  </div>
92
104
  </div>
93
105
  </template>
94
106
 
95
107
  <script setup lang="ts">
96
108
  import { IconArrowUpOutline, IconAngleDownOutline } from '@iconify-prerendered/vue-flowbite';
97
- import { useTemplateRef, onMounted, ref, onUnmounted } from 'vue';
109
+ import { useTemplateRef, onMounted, ref, onUnmounted, watch, computed } from 'vue';
98
110
  import { onClickOutside } from '@vueuse/core'
99
111
  import { useAgentStore } from './composables/useAgentStore';
100
112
  import { useAgentTransitions } from './composables/useAgentTransitions';
@@ -102,6 +114,8 @@ import { Button } from '@/afcl';
102
114
  import { useCoreStore } from '@/stores/core';
103
115
  import { remToPx } from './utils';
104
116
  import MicrophoneButton from './speech_recognition_frontend/MicrophoneButon.vue';
117
+ import SteerQueue from './SteerQueue.vue';
118
+ import SteerDebugPanel from './SteerDebugPanel.vue';
105
119
 
106
120
  const props = defineProps<{
107
121
  meta: {
@@ -124,6 +138,13 @@ const agentTransitions = useAgentTransitions();
124
138
  const coreStore = useCoreStore();
125
139
  const isModeMenuOpen = ref(false);
126
140
 
141
+ const placeholderText = computed(() => {
142
+ if (agentStore.isTurnActive) {
143
+ return 'Queue a follow-up — send after, or steer it…';
144
+ }
145
+ return agentStore.userMessagePlaceholder;
146
+ });
147
+
127
148
  onClickOutside(modeMenu as any, () => { isModeMenuOpen.value = false; });
128
149
 
129
150
  onMounted(async () => {
@@ -131,6 +152,11 @@ onMounted(async () => {
131
152
  agentStore.setCurrentGenerationModeFromLocalStorage();
132
153
  agentStore.regisrerTextInput(textInput.value);
133
154
  textInput.value?.focus();
155
+ autoResize();
156
+ });
157
+
158
+ watch(() => textInput.value?.value, () => {
159
+ autoResize();
134
160
  });
135
161
 
136
162
  function autoResize() {
@@ -161,7 +187,9 @@ function selectMode(modeName: string) {
161
187
  async function sendMessage() {
162
188
  if (agentStore.isAudioChatMode) return;
163
189
  isModeMenuOpen.value = false;
164
- await agentStore.sendMessage();
190
+ // Routes to a steer (folded into the running turn) while generating, or a normal
191
+ // new-turn send when idle.
192
+ await agentStore.submitUserMessage();
165
193
  autoResize();
166
194
  props.conversationAreaRef?.handleSendMessage();
167
195
  }
@@ -24,11 +24,13 @@ const props = withDefaults(defineProps<{
24
24
  wrapperStyle?: Record<string, string>
25
25
  contentStyle?: Record<string, string>
26
26
  scrollBarAutoHide?: boolean
27
+ scrollToBottomOnMount?: boolean
27
28
  }>(), {
28
29
  enabled: true,
29
30
  threshold: 50,
30
31
  behavior: 'instant',
31
- scrollBarAutoHide: true
32
+ scrollBarAutoHide: true,
33
+ scrollToBottomOnMount: true
32
34
  })
33
35
 
34
36
  const containerRef = ref<HTMLDivElement | null>(null)
@@ -72,7 +74,7 @@ onMounted(() => {
72
74
  scrollParams.value.scrollTop = containerRef.value.scrollEl.scrollTop
73
75
  scrollParams.value.scrollHeight = containerRef.value.scrollEl.scrollHeight
74
76
  scrollParams.value.clientHeight = containerRef.value.scrollEl.clientHeight
75
- if (props.enabled && !isUserScrolledUp.value) {
77
+ if (!isUserScrolledUp.value && props.scrollToBottomOnMount) {
76
78
  scrollToBottom()
77
79
  }
78
80
  })
@@ -102,6 +104,7 @@ function isNearBottom(customThreshold?: number): boolean {
102
104
  }
103
105
 
104
106
  function scrollToBottom(force = false): void {
107
+ console.log('scrollToBottom called with force:', force)
105
108
  const container = containerRef.value?.scrollEl
106
109
  if (!container) return
107
110
 
@@ -0,0 +1,45 @@
1
+ <!--
2
+ TEMPORARY debug helper: enqueues a test steer message into the buffer WITHOUT sending
3
+ or steering it, so the SteerQueue enter/leave animation can be exercised without a live
4
+ generating turn. Remove before shipping.
5
+ -->
6
+ <template>
7
+ <div
8
+ class="fixed bottom-[400px] left-4 z-[9999] flex items-center gap-2 rounded-lg border border-dashed border-amber-400 bg-amber-50/95 px-2.5 py-2 shadow-lg backdrop-blur dark:border-amber-500 dark:bg-amber-950/95"
9
+ >
10
+ <span class="text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400">
11
+ Steer&nbsp;debug
12
+ </span>
13
+ <input
14
+ v-model="debugText"
15
+ type="text"
16
+ placeholder="Test steer text…"
17
+ class="w-44 rounded-md border border-amber-300 bg-white px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-amber-400 dark:border-amber-600 dark:bg-gray-800 dark:text-gray-100"
18
+ @keydown.enter.prevent="addTestSteer"
19
+ />
20
+ <button
21
+ type="button"
22
+ class="rounded-md bg-amber-500 px-2.5 py-1 text-xs font-semibold text-white transition-colors hover:bg-amber-600 active:bg-amber-700"
23
+ @click="addTestSteer"
24
+ >
25
+ Add steer
26
+ </button>
27
+ </div>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import { ref } from 'vue';
32
+ import { useAgentStore } from './composables/useAgentStore';
33
+
34
+ const agentStore = useAgentStore();
35
+ const debugText = ref('');
36
+ let counter = 0;
37
+
38
+ function addTestSteer() {
39
+ counter += 1;
40
+ const text = debugText.value.trim() || `Test steer #${counter}`;
41
+ // enqueue() only pushes onto the client-side buffer — no /agent/steer, no send.
42
+ agentStore.enqueueSteerMessage(text);
43
+ debugText.value = '';
44
+ }
45
+ </script>
@@ -0,0 +1,54 @@
1
+ <template>
2
+ <div
3
+ v-if="agentStore.steerQueue.length"
4
+ class="absolute bottom-full left-0 w-full mb-2 flex flex-col gap-1.5 px-2 z-20"
5
+ >
6
+ <TransitionGroup
7
+ appear
8
+ enter-active-class="transition ease-out duration-150"
9
+ enter-from-class="opacity-0 translate-y-1"
10
+ enter-to-class="opacity-100 translate-y-0"
11
+ leave-active-class="transition ease-in duration-100"
12
+ leave-from-class="opacity-100 translate-y-0"
13
+ leave-to-class="opacity-0 translate-y-1"
14
+ >
15
+ <div
16
+ v-for="item in agentStore.steerQueue"
17
+ :key="item.id"
18
+ class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5 shadow-sm dark:border-gray-600 dark:bg-gray-800"
19
+ >
20
+ <span
21
+ class="flex-1 min-w-0 truncate text-sm text-lightInputText dark:text-darkInputText"
22
+ :title="item.text"
23
+ >
24
+ {{ item.text }}
25
+ </span>
26
+ <span class="shrink-0 text-xs text-gray-400 dark:text-gray-500">
27
+ {{ $t('queued') }}
28
+ </span>
29
+ <button
30
+ type="button"
31
+ class="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-lightPrimary transition-colors hover:bg-lightPrimary/10 dark:text-darkPrimary dark:hover:bg-darkPrimary/10"
32
+ :title="$t('Steer into the current response')"
33
+ @click="agentStore.steerQueuedMessage(item.id)"
34
+ >
35
+ {{ $t('Steer') }}
36
+ </button>
37
+ <button
38
+ type="button"
39
+ class="shrink-0 flex h-6 w-6 items-center justify-center rounded-md text-lg leading-none text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
40
+ :title="$t('Remove from queue')"
41
+ @click="agentStore.removeQueuedMessage(item.id)"
42
+ >
43
+ &times;
44
+ </button>
45
+ </div>
46
+ </TransitionGroup>
47
+ </div>
48
+ </template>
49
+
50
+ <script setup lang="ts">
51
+ import { useAgentStore } from './composables/useAgentStore';
52
+
53
+ const agentStore = useAgentStore();
54
+ </script>
@@ -11,6 +11,7 @@ export const PLACEHOLDER_TYPING_DELAY_MS = 60;
11
11
  export const PLACEHOLDER_DELETING_DELAY_MS = 35;
12
12
  export const PLACEHOLDER_HOLD_DELAY_MS = 3000;
13
13
  export const PRE_SESSION_ID = 'pre-session';
14
+ export const STEER_PERSIST_PREFIX = '__adminforth_steer__:';
14
15
 
15
16
  export enum RESERVED_SYSTEM_MESSAGE_CONTENT {
16
17
  START_AUDIO_CHAT = 'START_AUDIO_CHAT',
@@ -2,7 +2,7 @@ import type { ComputedRef, Ref, ShallowRef } from 'vue';
2
2
  import { callAdminForthApi } from '@/utils';
3
3
  import type { Chat } from '../../chat';
4
4
  import type { IAgentSession, ISessionsListItem, IPart } from '../../types';
5
- import { PRE_SESSION_ID } from './constants';
5
+ import { PRE_SESSION_ID, STEER_PERSIST_PREFIX } from './constants';
6
6
  import { i18nInstance } from '@/i18n';
7
7
 
8
8
  type AdminforthLike = {
@@ -50,14 +50,36 @@ export function createAgentSessionManager({
50
50
 
51
51
  function saveCurrentSessionInCache() {
52
52
  if (currentSession.value) {
53
- currentSession.value.messages = currentChat.value?.messages.map((m: any) => ({
54
- role: m.role,
55
- text: m.parts.map((p: IPart) => p.type === 'text' ? p.text : '').join(''),
56
- })) || [];
53
+ currentSession.value.messages = currentChat.value?.messages.map((m: any) => {
54
+ const text = m.parts.map((p: IPart) => p.type === 'text' ? p.text : '').join('');
55
+ if (m.role === 'user' && m.metadata?.steer) {
56
+ return { role: 'user', text: `${STEER_PERSIST_PREFIX}${text}` };
57
+ }
58
+ return { role: m.role, text };
59
+ }) || [];
57
60
  sessions.value[currentSession.value.sessionId] = currentSession.value;
58
61
  }
59
62
  }
60
63
 
64
+ function mapStoredMessage(m: any): any[] {
65
+ if (m.role === 'user' && typeof m.text === 'string' && m.text.includes(STEER_PERSIST_PREFIX)) {
66
+ const [prompt, ...steers] = m.text.split(STEER_PERSIST_PREFIX);
67
+ const mapped: any[] = [];
68
+ if (prompt) {
69
+ mapped.push({ role: 'user', parts: [{ type: 'text', text: prompt, state: 'done' }] });
70
+ }
71
+ for (const steer of steers) {
72
+ mapped.push({
73
+ role: 'user',
74
+ metadata: { steer: true },
75
+ parts: [{ type: 'text', text: steer, state: 'done' }],
76
+ });
77
+ }
78
+ return mapped;
79
+ }
80
+ return [{ role: m.role, parts: [{ type: 'text', text: m.text, state: 'done' }] }];
81
+ }
82
+
61
83
  async function fetchSession(sessionId: string) {
62
84
  try {
63
85
  const res = await callAdminForthApi({
@@ -85,14 +107,7 @@ export function createAgentSessionManager({
85
107
  currentSession.value = sessions.value[sessionId];
86
108
  setCurrentChat(sessionId);
87
109
  if (currentChat.value.messages.length === 0) {
88
- currentChat.value.messages = currentSession.value?.messages.map((m: any) => ({
89
- role: m.role,
90
- parts:[{
91
- type: 'text',
92
- text: m.text,
93
- state: 'done',
94
- }]
95
- }));
110
+ currentChat.value.messages = currentSession.value?.messages.flatMap(mapStoredMessage);
96
111
  }
97
112
  }
98
113
 
@@ -130,8 +145,10 @@ export function createAgentSessionManager({
130
145
  }
131
146
  }
132
147
 
133
- async function sendMessage() {
134
- const message = trimmedUserMessage.value;
148
+ async function sendMessage(explicitMessage?: string) {
149
+ // `explicitMessage` is used to drain a buffered (queued) message; without it we send
150
+ // whatever is currently typed in the input.
151
+ const message = (explicitMessage ?? trimmedUserMessage.value).trim();
135
152
  if (!message || isMessageInputBlocked.value) {
136
153
  return;
137
154
  }
@@ -147,7 +164,9 @@ export function createAgentSessionManager({
147
164
  currentChat.value?.sendMessage({
148
165
  text: message,
149
166
  });
150
- userMessageInput.value = '';
167
+ if (explicitMessage === undefined) {
168
+ userMessageInput.value = '';
169
+ }
151
170
  }
152
171
 
153
172
  async function createPreSession() {
@@ -0,0 +1,114 @@
1
+ import type { Ref, ShallowRef } from 'vue';
2
+ import { ref } from 'vue';
3
+ import { callAdminForthApi } from '@/utils';
4
+ import type { Chat } from '../../chat';
5
+ import type { IMessage } from '../../types';
6
+ import { PRE_SESSION_ID } from './constants';
7
+
8
+ export type QueuedMessage = {
9
+ id: string;
10
+ text: string;
11
+ };
12
+
13
+ type CreateAgentSteerQueueOptions = {
14
+ activeSessionId: Ref<string | null>;
15
+ currentChat: ShallowRef<Chat<any> | null | undefined>;
16
+ sendMessage: (text: string) => void | Promise<void>;
17
+ };
18
+
19
+ export function createAgentSteerQueue({
20
+ activeSessionId,
21
+ currentChat,
22
+ sendMessage,
23
+ }: CreateAgentSteerQueueOptions) {
24
+ const queue = ref<QueuedMessage[]>([]);
25
+
26
+ function enqueue(text: string) {
27
+ const message = text.trim();
28
+ if (!message) {
29
+ return;
30
+ }
31
+ queue.value.push({ id: crypto.randomUUID(), text: message });
32
+ }
33
+
34
+ function removeQueuedMessage(id: string) {
35
+ queue.value = queue.value.filter((item: QueuedMessage) => item.id !== id);
36
+ }
37
+
38
+ function injectSteerMessage(text: string) {
39
+ const chat = currentChat.value;
40
+ if (!chat) {
41
+ return;
42
+ }
43
+ const steerMessage: IMessage = {
44
+ role: 'user',
45
+ metadata: { steer: true },
46
+ parts: [{ type: 'text', text, state: 'done' }],
47
+ };
48
+ const messages = chat.messages;
49
+ const lastMessage = messages[messages.length - 1];
50
+ if (lastMessage?.role === 'assistant') {
51
+ messages.splice(messages.length - 1, 0, steerMessage);
52
+ } else {
53
+ messages.push(steerMessage);
54
+ }
55
+ }
56
+
57
+ // Persist the steer by appending it onto the running turn's prompt (display only — the
58
+ // LLM already has it via the running turn + checkpointer, so this never re-enters the
59
+ // model context). The backend adds the marker; the reload path splits it back out.
60
+ async function persistSteerMessage(sessionId: string, text: string) {
61
+ try {
62
+ const res = await callAdminForthApi({
63
+ method: 'POST',
64
+ path: '/agent/append-steer-to-turn',
65
+ body: { sessionId, message: text },
66
+ });
67
+ if (res?.error) {
68
+ console.error('Error persisting steer message:', res.error);
69
+ }
70
+ } catch (error) {
71
+ console.error('Error persisting steer message', error);
72
+ }
73
+ }
74
+
75
+ async function steerQueuedMessage(id: string) {
76
+ const item = queue.value.find((candidate: QueuedMessage) => candidate.id === id);
77
+ const sessionId = activeSessionId.value;
78
+ if (!item || !sessionId || sessionId === PRE_SESSION_ID) {
79
+ return;
80
+ }
81
+ removeQueuedMessage(id);
82
+ injectSteerMessage(item.text);
83
+ void persistSteerMessage(sessionId, item.text);
84
+
85
+ try {
86
+ const res = await callAdminForthApi({
87
+ method: 'POST',
88
+ path: '/agent/steer',
89
+ body: { sessionId, message: item.text },
90
+ });
91
+ if (res?.error) {
92
+ console.error('Error steering agent:', res.error);
93
+ }
94
+ } catch (error) {
95
+ console.error('Error steering agent', error);
96
+ }
97
+ }
98
+
99
+ function flushNext() {
100
+ const next = queue.value.shift();
101
+ if (!next) {
102
+ return;
103
+ }
104
+ void sendMessage(next.text);
105
+ }
106
+
107
+ return {
108
+ queue,
109
+ enqueue,
110
+ removeQueuedMessage,
111
+ steerQueuedMessage,
112
+ flushNext,
113
+ };
114
+ }
@@ -18,6 +18,7 @@ import {
18
18
  import { createAgentChatManager } from './agentStore/useAgentChat';
19
19
  import { createAgentPlaceholderController } from './agentStore/useAgentPlaceholder';
20
20
  import { createAgentSessionManager } from './agentStore/useAgentSessions';
21
+ import { createAgentSteerQueue } from './agentStore/useAgentSteerQueue';
21
22
 
22
23
  export const useAgentStore = defineStore('agent', () => {
23
24
  const agentTransitions = useAgentTransitions();
@@ -220,6 +221,42 @@ export const useAgentStore = defineStore('agent', () => {
220
221
  setCurrentChat,
221
222
  });
222
223
 
224
+ // "Turn active" = a request is in flight (submitted or streaming) or paused for HITL
225
+ // approval. Messages submitted during this window are buffered rather than sent.
226
+ const isTurnActive = computed(() => {
227
+ const status = (currentChat.value as any)?.status;
228
+ return status === 'submitted' || status === 'streaming' || hasPendingToolApproval.value;
229
+ });
230
+
231
+ const steerBuffer = createAgentSteerQueue({
232
+ activeSessionId,
233
+ currentChat,
234
+ sendMessage,
235
+ });
236
+
237
+ // Submitting while a turn is active buffers the message (it shows up in the steer
238
+ // queue, where the user can steer it or let it send); otherwise it sends right away.
239
+ function submitUserMessage() {
240
+ const message = trimmedUserMessage.value;
241
+ if (!message) {
242
+ return;
243
+ }
244
+ if (isTurnActive.value) {
245
+ steerBuffer.enqueue(message);
246
+ userMessageInput.value = '';
247
+ return;
248
+ }
249
+ return sendMessage();
250
+ }
251
+
252
+ // When the active turn finishes, drain the next buffered message as a normal send
253
+ // (one per turn, FIFO). Messages the user explicitly steered were already removed.
254
+ watch(isTurnActive, (active: boolean, wasActive: boolean) => {
255
+ if (wasActive && !active) {
256
+ steerBuffer.flushNext();
257
+ }
258
+ });
259
+
223
260
  watch(() => viewportWidth.value, (newWidth) => {
224
261
  if (isFullScreen.value) {
225
262
  setChatWidth(newWidth, false);
@@ -505,6 +542,14 @@ export const useAgentStore = defineStore('agent', () => {
505
542
  addDataToolCallMessage,
506
543
  openAgentPage,
507
544
  setCurrentChatStatus,
508
- updateLastAgentMessage
545
+ updateLastAgentMessage,
546
+ //_________-Steer queue-_____________
547
+ submitUserMessage,
548
+ isTurnActive,
549
+ steerQueue: steerBuffer.queue,
550
+ steerQueuedMessage: steerBuffer.steerQueuedMessage,
551
+ removeQueuedMessage: steerBuffer.removeQueuedMessage,
552
+ enqueueSteerMessage: steerBuffer.enqueue,
553
+ //___________________________________
509
554
  }
510
555
  })
@@ -11,7 +11,7 @@
11
11
  <div ref="chatContainerRef" class="relative flex-1 min-h-0 overflow-hidden" @click="recalculateScroll()">
12
12
  <CustomAutoScrollContainer
13
13
  v-if="showScrollContainer"
14
- :enabled="!showScrollToBottomButton"
14
+ :enabled="false"
15
15
  class="relative h-full flex flex-col overflow-y-auto translate-x-[-50%] left-1/2"
16
16
  ref="scrollContainer"
17
17
  :threshold="THRESHOLD_TO_SHOW_BUTTON"
@@ -53,27 +53,49 @@
53
53
  </div>
54
54
  <div v-if="showBottomSpacer" class="w-full" :style="{ height: spacerHeight + 'px' }"></div>
55
55
  </CustomAutoScrollContainer>
56
- <button @click="scrollContainer.scrollToBottom();">
57
- <IconArrowDownOutline
58
- class="absolute z-10 bottom-8 left-1/2 -translate-x-1/2 bg-lightPrimary/20 dark:bg-darkPrimary/10
59
- border border-lightPrimary dark:border-darkPrimary text-lightPrimary dark:text-darkPrimary p-2 w-10 h-10
60
- rounded-full transition-opacity duration-100 ease-in backdrop-blur-sm"
61
- :class="showScrollToBottomButton ? 'opacity-100' : 'opacity-0 pointer-events-none'"
62
- :disabled="!showScrollToBottomButton"
63
- />
64
- </button>
56
+ <Transition
57
+ enter-active-class="transition-all duration-250 ease-out"
58
+ enter-from-class="opacity-0 translate-y-3 scale-90"
59
+ enter-to-class="opacity-100 translate-y-0 scale-100"
60
+ leave-active-class="transition-all duration-200 ease-in"
61
+ leave-from-class="opacity-100 translate-y-0 scale-100"
62
+ leave-to-class="opacity-0 translate-y-3 scale-90"
63
+ >
64
+ <button
65
+ v-if="showScrollToBottomButton"
66
+ @click="scrollContainer.scrollToBottom();"
67
+ class="absolute z-10 bottom-8 left-1/2 -translate-x-1/2
68
+ bg-lightPrimary/20 dark:bg-darkPrimary/10
69
+ border border-lightPrimary dark:border-darkPrimary
70
+ text-lightPrimary dark:text-darkPrimary
71
+ backdrop-blur-sm rounded-full
72
+ w-10 h-10 flex items-center justify-center gap-0.5"
73
+ :class="{ 'w-12 h-8': showAnimationInScrollToBottomButton }"
74
+ >
75
+ <IconArrowDownOutline
76
+ v-if="!showAnimationInScrollToBottomButton"
77
+ class="p-2 w-10 h-10 transition-opacity duration-100 ease-in "
78
+ :disabled="!showScrollToBottomButton"
79
+ />
80
+ <ThreeDotsAnimation
81
+ v-else
82
+ :disabled="!showScrollToBottomButton"
83
+ />
84
+ </button>
85
+ </Transition>
65
86
  </div>
66
87
  </template>
67
88
 
68
89
 
69
90
  <script setup lang="ts">
70
91
  import type { IMessage } from '../types';
71
- import { useTemplateRef, ref, onMounted, onUnmounted, watch, nextTick, defineAsyncComponent } from 'vue';
92
+ import { useTemplateRef, ref, onMounted, onUnmounted, watch, nextTick, defineAsyncComponent, computed } from 'vue';
72
93
  import { IconArrowDownOutline } from '@iconify-prerendered/vue-flowbite';
73
94
  import SessionsHistory from '../SessionsHistory.vue';
74
95
  import { useAgentStore } from '../composables/useAgentStore';
75
96
  import { useAgentTransitions } from '../composables/useAgentTransitions';
76
97
  import MessageRenderer from './MessageRenderer.vue';
98
+ import ThreeDotsAnimation from './ThreeDotsAnimation.vue';
77
99
 
78
100
  const CustomAutoScrollContainer = defineAsyncComponent(() => import('../CustomAutoScrollContainer.vue'));
79
101
 
@@ -114,6 +136,10 @@ let updateSpacerFrameId: number | null = null;
114
136
  let pendingSpacerUpdate: Promise<void> | null = null;
115
137
  let spacerUpdateQueued = false;
116
138
 
139
+ const showAnimationInScrollToBottomButton = computed(() => {
140
+ return agentStore.isResponseInProgress && showScrollToBottomButton.value;
141
+ });
142
+
117
143
  onMounted(async () => {
118
144
  messageResizeObserver = new ResizeObserver(() => {
119
145
  scheduleSpacerHeightUpdate();
@@ -225,11 +251,11 @@ function scheduleSpacerHeightUpdate() {
225
251
  pendingSpacerUpdate = null;
226
252
 
227
253
  // Auto-scroll to bottom if response generation is in progress
228
- if (agentStore.isResponseInProgress) {
229
- nextTick(() => {
230
- scrollContainer.value?.scrollToBottom();
231
- });
232
- }
254
+ // if (agentStore.isResponseInProgress) {
255
+ // nextTick(() => {
256
+ // scrollContainer.value?.scrollToBottom();
257
+ // });
258
+ // }
233
259
 
234
260
  if (spacerUpdateQueued) {
235
261
  scheduleSpacerHeightUpdate();
@@ -1,5 +1,11 @@
1
1
  <template>
2
- <ProcessingTimeline
2
+ <div
3
+ v-if="message.role === 'user' && message.metadata?.steer"
4
+ class="self-end mr-4 mb-0.5 text-xs italic text-gray-400 dark:text-gray-500"
5
+ >
6
+ {{ $t('Steering instruction') }}
7
+ </div>
8
+ <ProcessingTimeline
3
9
  :message="message"
4
10
  :isLastMessageInChat="isLastMessageInChat"
5
11
  />
@@ -12,18 +12,18 @@
12
12
 
13
13
  .bounce-dot2 {
14
14
  animation: bounce 1.5s infinite;
15
- animation-delay: 0.1s;
15
+ animation-delay: 0.2s;
16
16
  }
17
17
 
18
18
  .bounce-dot3 {
19
19
  animation: bounce 1.5s infinite;
20
- animation-delay: 0.2s;
20
+ animation-delay: 0.4s;
21
21
  }
22
22
 
23
23
  @keyframes bounce {
24
24
  0%, 100% {
25
25
  transform: translateY(20%);
26
- opacity: 0.3;
26
+ opacity: 0.4;
27
27
  animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
28
28
  }
29
29
  50% {
@@ -11,7 +11,7 @@
11
11
  <script setup lang="ts">
12
12
  import { computed } from 'vue';
13
13
 
14
- const IDLE_LINE_HEIGHTS = [0.5, 1, 0.75, 0.5];
14
+ const IDLE_LINE_HEIGHTS = [0.4, 0.8, 0.55, 0.4];
15
15
  const RECORDING_LINE_WEIGHTS = [0.45, 1, 0.75, 0.9, 0.55];
16
16
  const MIN_RECORDING_HEIGHT = 0.25;
17
17
  const MAX_RECORDING_DELTA = 0.9;
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div
3
3
  v-show="!agentStore.isResponseInProgress || isAudioChatMode"
4
- class="absolute bottom-2 flex items-center justify-center z-10 gap-4"
4
+ class="absolute bottom-1 flex items-center justify-center z-10 gap-4"
5
5
  :class="[!agentStore.isAudioChatMode ? 'right-16': agentStore.isMobile ? 'right-1/2 translate-x-1/2' : 'right-1/2 translate-x-1/2']"
6
6
  >
7
7
  <button
@@ -14,10 +14,10 @@
14
14
  </button>
15
15
  <button
16
16
  v-else
17
- class="h-9 bg-lightPrimary dark:bg-darkPrimary
17
+ class="h-7 bg-lightPrimary dark:bg-darkPrimary
18
18
  hover:opacity-90 rounded-full flex items-center justify-center
19
19
  transition-all duration-300 ease-in-out overflow-hidden"
20
- :class="[isAudioChatMode ? 'w-32 px-2': 'w-9']"
20
+ :class="[isAudioChatMode ? 'w-32 px-2': 'w-7']"
21
21
  @click="toggleChatMode"
22
22
  >
23
23
  <div class="w-5 h-5 flex items-center justify-center">
@@ -19,6 +19,10 @@ export type AgentEvent = {
19
19
  type: "interrupt";
20
20
  sessionId: string;
21
21
  interrupt: unknown;
22
+ } | {
23
+ type: "steer-applied";
24
+ count: number;
25
+ ids: string[];
22
26
  } | {
23
27
  type: "open-page";
24
28
  targetPath: string;
@@ -0,0 +1,33 @@
1
+ /** A single steering instruction the user sent while a turn was already running. */
2
+ export type SteerMessage = {
3
+ id: string;
4
+ text: string;
5
+ /** Epoch ms the steer was enqueued; useful for ordering/debugging. */
6
+ at: number;
7
+ };
8
+ /**
9
+ * In-process, per-session buffer of steering instructions.
10
+ *
11
+ * While an agent turn is streaming (thinking / calling tools) the user may send
12
+ * one or more extra instructions ("steers"). Each is enqueued here by the
13
+ * `/agent/steer` endpoint and drained by the `beforeModel` steer middleware just
14
+ * before the next model call in the same running loop, so the model course-corrects
15
+ * mid-turn instead of the user having to abort and restart.
16
+ *
17
+ * The buffer is deliberately in-process: a steer only makes sense for a turn that
18
+ * is *currently running*, and that turn's SSE stream is held open on exactly one
19
+ * instance/process. In a multi-instance deployment the `/agent/steer` request must
20
+ * therefore reach the same instance running the turn (same constraint the in-memory
21
+ * HITL cache / MemorySaver already carry when no persistent checkpointer is set).
22
+ */
23
+ export declare class SteerBuffer {
24
+ private readonly buffers;
25
+ /** Enqueue a steering instruction for a session. Returns the created entry. */
26
+ add(sessionId: string, text: string): SteerMessage;
27
+ /** Number of steers currently buffered for a session. */
28
+ size(sessionId: string): number;
29
+ /** Return every buffered steer for a session and remove them from the buffer. */
30
+ drain(sessionId: string): SteerMessage[];
31
+ /** Discard any buffered steers for a session (e.g. when its turn ends unconsumed). */
32
+ clear(sessionId: string): void;
33
+ }
@@ -0,0 +1,51 @@
1
+ import { randomUUID } from "crypto";
2
+ /**
3
+ * In-process, per-session buffer of steering instructions.
4
+ *
5
+ * While an agent turn is streaming (thinking / calling tools) the user may send
6
+ * one or more extra instructions ("steers"). Each is enqueued here by the
7
+ * `/agent/steer` endpoint and drained by the `beforeModel` steer middleware just
8
+ * before the next model call in the same running loop, so the model course-corrects
9
+ * mid-turn instead of the user having to abort and restart.
10
+ *
11
+ * The buffer is deliberately in-process: a steer only makes sense for a turn that
12
+ * is *currently running*, and that turn's SSE stream is held open on exactly one
13
+ * instance/process. In a multi-instance deployment the `/agent/steer` request must
14
+ * therefore reach the same instance running the turn (same constraint the in-memory
15
+ * HITL cache / MemorySaver already carry when no persistent checkpointer is set).
16
+ */
17
+ export class SteerBuffer {
18
+ constructor() {
19
+ this.buffers = new Map();
20
+ }
21
+ /** Enqueue a steering instruction for a session. Returns the created entry. */
22
+ add(sessionId, text) {
23
+ const message = { id: randomUUID(), text, at: Date.now() };
24
+ const existing = this.buffers.get(sessionId);
25
+ if (existing) {
26
+ existing.push(message);
27
+ }
28
+ else {
29
+ this.buffers.set(sessionId, [message]);
30
+ }
31
+ return message;
32
+ }
33
+ /** Number of steers currently buffered for a session. */
34
+ size(sessionId) {
35
+ var _a, _b;
36
+ return (_b = (_a = this.buffers.get(sessionId)) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
37
+ }
38
+ /** Return every buffered steer for a session and remove them from the buffer. */
39
+ drain(sessionId) {
40
+ const messages = this.buffers.get(sessionId);
41
+ if (!messages || messages.length === 0) {
42
+ return [];
43
+ }
44
+ this.buffers.delete(sessionId);
45
+ return messages;
46
+ }
47
+ /** Discard any buffered steers for a session (e.g. when its turn ends unconsumed). */
48
+ clear(sessionId) {
49
+ this.buffers.delete(sessionId);
50
+ }
51
+ }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
9
9
  private agentSystemPrompt;
10
10
  private checkpointer;
11
11
  private sessionStore;
12
+ private steerBuffer;
12
13
  private runTurnUseCase;
13
14
  private speechTurnService;
14
15
  private chatSurfaceService;
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ import { AgentModelFactory } from "./llm/modelFactory.js";
23
23
  import { AgentRuntime } from "./llm/agentRuntime.js";
24
24
  import { SpeechTurnService } from "./transport/surfaces/speechTurnService.js";
25
25
  import { AgentToolProvider } from "./tools/agentToolProvider.js";
26
+ import { SteerBuffer } from "./domain/steerBuffer.js";
26
27
  export default class AdminForthAgentPlugin extends AdminForthPlugin {
27
28
  getCheckpointer() {
28
29
  if (this.checkpointer)
@@ -60,17 +61,20 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
60
61
  this.checkpointer = null;
61
62
  this.options = options;
62
63
  this.sessionStore = new AgentSessionStore(() => this.adminforth, this.options);
64
+ this.steerBuffer = new SteerBuffer();
63
65
  const toolProvider = new AgentToolProvider(() => this.adminforth, this.getInternalAgentResourceIds.bind(this));
64
66
  const runtime = new AgentRuntime({
65
67
  name: `adminforth-agent-${this.pluginInstanceId}`,
66
68
  getAdminforth: () => this.adminforth,
67
69
  getCheckpointer: this.getCheckpointer.bind(this),
68
70
  toolProvider,
71
+ steerBuffer: this.steerBuffer,
69
72
  });
70
73
  const llm = new LangGraphLlm(runtime, new AgentModelFactory((_a = this.options.maxTokens) !== null && _a !== void 0 ? _a : 1000));
71
74
  this.runTurnUseCase = new RunTurnUseCase({
72
75
  llm,
73
76
  sessions: this.sessionStore,
77
+ steerBuffer: this.steerBuffer,
74
78
  modes: this.options.modes,
75
79
  getAdminforth: () => this.adminforth,
76
80
  getAgentSystemPrompt: this.getAgentSystemPrompt.bind(this),
@@ -146,10 +150,15 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
146
150
  options: this.options,
147
151
  handleTurn: this.runTurnUseCase.handleTurn.bind(this.runTurnUseCase),
148
152
  handleSpeechTurn: this.speechTurnService.handle.bind(this.speechTurnService),
153
+ steer: ({ sessionId, message }) => {
154
+ const { id } = this.steerBuffer.add(sessionId, message);
155
+ return { id, queued: this.steerBuffer.size(sessionId) };
156
+ },
149
157
  runAndPersistAgentResponse: this.runTurnUseCase.runAndPersistAgentResponse.bind(this.runTurnUseCase),
150
158
  getSessionTurns: this.sessionStore.getSessionTurns.bind(this.sessionStore),
151
159
  createNewTurn: this.sessionStore.createNewTurn.bind(this.sessionStore),
152
160
  createSystemTurn: this.sessionStore.createSystemTurn.bind(this.sessionStore),
161
+ appendSteerToCurrentTurn: this.sessionStore.appendSteerToCurrentTurn.bind(this.sessionStore),
153
162
  handleChatSurfaceMessage: this.chatSurfaceService.handleMessage.bind(this.chatSurfaceService),
154
163
  };
155
164
  setupCoreEndpoints(endpointContext, server);
@@ -1,5 +1,6 @@
1
1
  import type { IAdminForth } from "adminforth";
2
2
  import type { BaseCheckpointSaver } from "@langchain/langgraph";
3
+ import type { SteerBuffer } from "../domain/steerBuffer.js";
3
4
  import type { AgentToolProvider } from "../tools/agentToolProvider.js";
4
5
  import type { AgentRuntimeRunInput, AgentTurnModels } from "./agentModels.js";
5
6
  export type AgentRuntimeOptions = {
@@ -7,6 +8,8 @@ export type AgentRuntimeOptions = {
7
8
  getAdminforth: () => IAdminForth;
8
9
  getCheckpointer: () => BaseCheckpointSaver;
9
10
  toolProvider: AgentToolProvider;
11
+ /** Per-session buffer of mid-turn steering instructions drained by the steer middleware. */
12
+ steerBuffer: SteerBuffer;
10
13
  };
11
14
  export declare class AgentRuntime {
12
15
  private readonly options;
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  import { createAgent, summarizationMiddleware, humanInTheLoopMiddleware } from "langchain";
11
11
  import { createApiBasedToolsMiddleware } from "./middleware/apiToolsMiddleware.js";
12
12
  import { createSequenceDebugMiddleware } from "./middleware/sequenceDebug.js";
13
+ import { createSteerMiddleware } from "./middleware/steerMiddleware.js";
13
14
  import { createAgentLlmMetricsLogger } from "./agentModels.js";
14
15
  import { contextSchema, toLangchainAgentContext } from "./agentContext.js";
15
16
  function createHumanInTheLoopInterrupts(apiBasedTools) {
@@ -41,7 +42,9 @@ export class AgentRuntime {
41
42
  interruptOn: createHumanInTheLoopInterrupts(apiBasedTools),
42
43
  descriptionPrefix: "Tool execution pending approval",
43
44
  });
45
+ const steerMiddleware = createSteerMiddleware(this.options.steerBuffer);
44
46
  const middleware = [
47
+ steerMiddleware,
45
48
  apiBasedToolsMiddleware,
46
49
  hitlMiddleware,
47
50
  ...((_a = input.models.modelMiddleware) !== null && _a !== void 0 ? _a : []),
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import type { AgentEventEmitter } from "../../domain/agentEvents.js";
3
+ import type { SteerBuffer } from "../../domain/steerBuffer.js";
4
+ /**
5
+ * `beforeModel` middleware that folds user "steering" instructions into a running
6
+ * turn. Before every model call in the agent loop it drains the per-session
7
+ * {@link SteerBuffer}; any buffered steers are appended to the conversation as
8
+ * user messages (so the model sees them on the very next call) and a
9
+ * `steer-applied` event is emitted on the turn's already-open SSE stream so the
10
+ * frontend knows the steer was actually consumed (not just received/queued).
11
+ *
12
+ * Placed first in the middleware array so the injected messages are present for
13
+ * every downstream `beforeModel` hook (e.g. summarization) and the model itself.
14
+ */
15
+ export declare function createSteerMiddleware(steerBuffer: SteerBuffer): import("langchain").AgentMiddleware<undefined, z.ZodObject<{
16
+ sessionId: z.ZodString;
17
+ emit: z.ZodOptional<z.ZodCustom<AgentEventEmitter, AgentEventEmitter>>;
18
+ }, z.core.$strip>, {
19
+ sessionId: string;
20
+ emit?: AgentEventEmitter | undefined;
21
+ }, readonly (import("@langchain/core/tools").ClientTool | import("@langchain/core/tools").ServerTool)[]>;
@@ -0,0 +1,50 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { HumanMessage } from "langchain";
11
+ import { createMiddleware } from "langchain";
12
+ import { z } from "zod";
13
+ const steerContextSchema = z.object({
14
+ sessionId: z.string(),
15
+ emit: z.custom().optional(),
16
+ });
17
+ /**
18
+ * `beforeModel` middleware that folds user "steering" instructions into a running
19
+ * turn. Before every model call in the agent loop it drains the per-session
20
+ * {@link SteerBuffer}; any buffered steers are appended to the conversation as
21
+ * user messages (so the model sees them on the very next call) and a
22
+ * `steer-applied` event is emitted on the turn's already-open SSE stream so the
23
+ * frontend knows the steer was actually consumed (not just received/queued).
24
+ *
25
+ * Placed first in the middleware array so the injected messages are present for
26
+ * every downstream `beforeModel` hook (e.g. summarization) and the model itself.
27
+ */
28
+ export function createSteerMiddleware(steerBuffer) {
29
+ return createMiddleware({
30
+ name: "SteerMiddleware",
31
+ contextSchema: steerContextSchema,
32
+ beforeModel(_state, runtime) {
33
+ return __awaiter(this, void 0, void 0, function* () {
34
+ const { sessionId, emit } = runtime.context;
35
+ const steers = steerBuffer.drain(sessionId);
36
+ if (steers.length === 0) {
37
+ return;
38
+ }
39
+ void (emit === null || emit === void 0 ? void 0 : emit({
40
+ type: "steer-applied",
41
+ count: steers.length,
42
+ ids: steers.map((steer) => steer.id),
43
+ }));
44
+ return {
45
+ messages: steers.map((steer) => new HumanMessage(`[User steering — new instruction sent mid-turn, take it into account from now on]\n${steer.text}`)),
46
+ };
47
+ });
48
+ },
49
+ });
50
+ }
@@ -3,12 +3,19 @@ import type { ChatSurfaceIncomingMessage } from "adminforth";
3
3
  import type { PreviousUserMessage } from "../domain/languageDetect.js";
4
4
  import type { PluginOptions } from "../types.js";
5
5
  export declare const AGENT_SYSTEM_TURN_PROMPT = "__adminforth_system_message__";
6
+ export declare const STEER_PROMPT_MARKER = "__adminforth_steer__:";
6
7
  export declare class AgentSessionStore {
7
8
  private getAdminforth;
8
9
  private options;
9
10
  constructor(getAdminforth: () => IAdminForth, options: PluginOptions);
10
11
  createNewTurn(sessionId: string, prompt: string, response?: string): Promise<any>;
11
12
  createSystemTurn(sessionId: string, systemMessage: string): Promise<any>;
13
+ /**
14
+ * Append a mid-turn steer to the current (running) turn's prompt instead of creating a
15
+ * new record. Targets the latest non-system turn so a steer attaches to the real
16
+ * conversation turn, never an audio/system note.
17
+ */
18
+ appendSteerToCurrentTurn(sessionId: string, steerText: string): Promise<void>;
12
19
  getSessionTurns(sessionId: string): Promise<{
13
20
  prompt: any;
14
21
  response: any;
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  import { Filters, Sorts } from "adminforth";
11
11
  import { randomUUID } from "crypto";
12
12
  export const AGENT_SYSTEM_TURN_PROMPT = "__adminforth_system_message__";
13
+ export const STEER_PROMPT_MARKER = "__adminforth_steer__:";
13
14
  export class AgentSessionStore {
14
15
  constructor(getAdminforth, options) {
15
16
  this.getAdminforth = getAdminforth;
@@ -41,6 +42,25 @@ export class AgentSessionStore {
41
42
  return newTurn.createdRecord[this.options.turnResource.idField];
42
43
  });
43
44
  }
45
+ /**
46
+ * Append a mid-turn steer to the current (running) turn's prompt instead of creating a
47
+ * new record. Targets the latest non-system turn so a steer attaches to the real
48
+ * conversation turn, never an audio/system note.
49
+ */
50
+ appendSteerToCurrentTurn(sessionId, steerText) {
51
+ return __awaiter(this, void 0, void 0, function* () {
52
+ var _a;
53
+ const turns = yield this.getAdminforth().resource(this.options.turnResource.resourceId).list([Filters.EQ(this.options.turnResource.sessionIdField, sessionId)], undefined, undefined, [Sorts.DESC(this.options.turnResource.createdAtField)]);
54
+ const turn = turns.find((candidate) => candidate[this.options.turnResource.promptField] !== AGENT_SYSTEM_TURN_PROMPT);
55
+ if (!turn) {
56
+ return;
57
+ }
58
+ const currentPrompt = (_a = turn[this.options.turnResource.promptField]) !== null && _a !== void 0 ? _a : "";
59
+ yield this.getAdminforth().resource(this.options.turnResource.resourceId).update(turn[this.options.turnResource.idField], {
60
+ [this.options.turnResource.promptField]: `${currentPrompt}${STEER_PROMPT_MARKER}${steerText}`,
61
+ });
62
+ });
63
+ }
44
64
  getSessionTurns(sessionId) {
45
65
  return __awaiter(this, void 0, void 0, function* () {
46
66
  const turns = yield this.getAdminforth().resource(this.options.turnResource.resourceId).list([Filters.EQ(this.options.turnResource.sessionIdField, sessionId)], undefined, undefined, [Sorts.ASC(this.options.turnResource.createdAtField)]);
@@ -10,12 +10,20 @@ export type AgentEndpointsContext = {
10
10
  options: PluginOptions;
11
11
  handleTurn(input: HandleTurnInput): Promise<RunAndPersistAgentResponseResult>;
12
12
  handleSpeechTurn(input: HandleSpeechTurnInput): Promise<RunAndPersistAgentResponseResult | null>;
13
+ steer(input: {
14
+ sessionId: string;
15
+ message: string;
16
+ }): {
17
+ id: string;
18
+ queued: number;
19
+ };
13
20
  runAndPersistAgentResponse(input: RunAndPersistAgentResponseInput): Promise<RunAndPersistAgentResponseResult>;
14
21
  getSessionTurns(sessionId: string): Promise<SessionTurn[]>;
15
22
  createNewTurn(sessionId: string, prompt: string, response?: string): Promise<string>;
16
23
  createSystemTurn(sessionId: string, systemMessage: string): Promise<string>;
24
+ appendSteerToCurrentTurn(sessionId: string, steerText: string): Promise<void>;
17
25
  handleChatSurfaceMessage(adapter: ChatSurfaceAdapter, incoming: ChatSurfaceIncomingMessage, sink: ChatSurfaceEventSink): Promise<void>;
18
26
  };
19
- export type CoreEndpointsContext = Pick<AgentEndpointsContext, "options" | "handleTurn" | "handleSpeechTurn">;
20
- export type SessionEndpointsContext = Pick<AgentEndpointsContext, "adminforth" | "options" | "getSessionTurns" | "createNewTurn" | "createSystemTurn">;
27
+ export type CoreEndpointsContext = Pick<AgentEndpointsContext, "options" | "handleTurn" | "handleSpeechTurn" | "steer">;
28
+ export type SessionEndpointsContext = Pick<AgentEndpointsContext, "adminforth" | "options" | "getSessionTurns" | "createNewTurn" | "createSystemTurn" | "appendSteerToCurrentTurn">;
21
29
  export type ChatSurfaceEndpointsContext = Pick<AgentEndpointsContext, "adminforth" | "options" | "handleChatSurfaceMessage">;
@@ -26,6 +26,10 @@ const agentApprovalBodySchema = z.object({
26
26
  sessionId: z.string(),
27
27
  decision: z.enum(["approve", "reject"]),
28
28
  }).strict();
29
+ const agentSteerBodySchema = z.object({
30
+ sessionId: z.string(),
31
+ message: z.string().min(1),
32
+ }).strict();
29
33
  // Sent as multipart/form-data (via multer), so every field arrives as a plain string on the wire.
30
34
  // `currentPage` is JSON.stringify'd by the client into a form field and must be parsed back into
31
35
  // an object. Zod -> JSON-Schema conversion cannot represent `.transform()` at all (it throws), so
@@ -122,6 +126,22 @@ export function setupCoreEndpoints(ctx, server) {
122
126
  return null;
123
127
  })
124
128
  });
129
+ server.endpoint({
130
+ method: 'POST',
131
+ path: `/agent/steer`,
132
+ request_schema: agentSteerBodySchema,
133
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body }) {
134
+ const data = body;
135
+ // Fire-and-forget: the steer is buffered and folded into the running turn by the
136
+ // beforeModel steer middleware, which signals `data-steer-applied` on that turn's
137
+ // SSE stream once consumed. This endpoint only confirms it was received/queued.
138
+ const { id, queued } = ctx.steer({
139
+ sessionId: data.sessionId,
140
+ message: data.message,
141
+ });
142
+ return { ok: true, id, queued };
143
+ })
144
+ });
125
145
  server.endpoint({
126
146
  method: 'POST',
127
147
  path: `/agent/speech-response`,
@@ -15,6 +15,10 @@ const addSystemMessageBodySchema = z.object({
15
15
  sessionId: z.string(),
16
16
  systemMessage: z.string(),
17
17
  }).strict();
18
+ const appendSteerBodySchema = z.object({
19
+ sessionId: z.string(),
20
+ message: z.string().min(1),
21
+ }).strict();
18
22
  const getSessionsBodySchema = z.object({
19
23
  limit: z.number().int().min(1).max(100).optional(),
20
24
  }).strict();
@@ -175,4 +179,29 @@ export function setupSessionEndpoints(ctx, server) {
175
179
  };
176
180
  })
177
181
  });
182
+ server.endpoint({
183
+ method: 'POST',
184
+ path: `/agent/append-steer-to-turn`,
185
+ request_schema: appendSteerBodySchema,
186
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, response }) {
187
+ const data = body;
188
+ const session = yield ctx.adminforth.resource(ctx.options.sessionResource.resourceId).get([Filters.EQ(ctx.options.sessionResource.idField, data.sessionId)]);
189
+ if (!session) {
190
+ response.setStatus(404, 'Session not found');
191
+ return {
192
+ error: 'Session not found'
193
+ };
194
+ }
195
+ if (session[ctx.options.sessionResource.askerIdField] !== adminUser.pk) {
196
+ response.setStatus(403, 'Unauthorized');
197
+ return {
198
+ error: 'Unauthorized'
199
+ };
200
+ }
201
+ yield ctx.appendSteerToCurrentTurn(data.sessionId, data.message);
202
+ return {
203
+ ok: true
204
+ };
205
+ })
206
+ });
178
207
  }
@@ -17,9 +17,9 @@ type AgentEventStreamOptions = {
17
17
  *
18
18
  * - `vercelAiUiMessageStream: true` (used by `/agent/response` and `/agent/approval`):
19
19
  * the Vercel AI SDK message-stream v1 format (`start`, `text-start`/`text-delta`/
20
- * `text-end`, `reasoning-*`, `data-*` parts, `error` with `errorText`, `finish`,
21
- * `[DONE]`). Consumed by `DefaultChatTransport` and the manual approval parser in
22
- * `custom/composables/agentStore/useAgentChat.ts`.
20
+ * `text-end`, `reasoning-*`, `data-*` parts including `data-steer-applied`,
21
+ * `error` with `errorText`, `finish`, `[DONE]`). Consumed by `DefaultChatTransport`
22
+ * and the manual approval parser in `custom/composables/agentStore/useAgentChat.ts`.
23
23
  * - default (used by `/agent/speech-response`): bare event names
24
24
  * (`transcript`, `speech-response`, `audio-start`/`audio-delta`/`audio-done`,
25
25
  * `open-page`, `error`) plus the Vercel-shaped `data-tool-call`. Consumed by
@@ -98,6 +98,15 @@ function createAgentEventStream(res, options = {}) {
98
98
  },
99
99
  });
100
100
  },
101
+ steerApplied(count, ids) {
102
+ stream.send({
103
+ type: isAiUiMessageStream ? "data-steer-applied" : "steer-applied",
104
+ data: {
105
+ count,
106
+ ids,
107
+ },
108
+ });
109
+ },
101
110
  openPage(targetPath) {
102
111
  stream.send({
103
112
  type: isAiUiMessageStream ? "data-open-page" : "open-page",
@@ -192,9 +201,9 @@ function createAgentEventStream(res, options = {}) {
192
201
  *
193
202
  * - `vercelAiUiMessageStream: true` (used by `/agent/response` and `/agent/approval`):
194
203
  * the Vercel AI SDK message-stream v1 format (`start`, `text-start`/`text-delta`/
195
- * `text-end`, `reasoning-*`, `data-*` parts, `error` with `errorText`, `finish`,
196
- * `[DONE]`). Consumed by `DefaultChatTransport` and the manual approval parser in
197
- * `custom/composables/agentStore/useAgentChat.ts`.
204
+ * `text-end`, `reasoning-*`, `data-*` parts including `data-steer-applied`,
205
+ * `error` with `errorText`, `finish`, `[DONE]`). Consumed by `DefaultChatTransport`
206
+ * and the manual approval parser in `custom/composables/agentStore/useAgentChat.ts`.
198
207
  * - default (used by `/agent/speech-response`): bare event names
199
208
  * (`transcript`, `speech-response`, `audio-start`/`audio-delta`/`audio-done`,
200
209
  * `open-page`, `error`) plus the Vercel-shaped `data-tool-call`. Consumed by
@@ -222,6 +231,9 @@ export function createSseEventEmitter(res, options = {}) {
222
231
  case "interrupt":
223
232
  stream.interrupt(event.sessionId, event.interrupt);
224
233
  break;
234
+ case "steer-applied":
235
+ stream.steerApplied(event.count, event.ids);
236
+ break;
225
237
  case "open-page":
226
238
  stream.openPage(event.targetPath);
227
239
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/agent",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",