@adminforth/agent 2.1.0 → 2.2.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.
- package/dist/application/runTurnUseCase.d.ts +3 -0
- package/dist/application/runTurnUseCase.js +6 -0
- package/dist/custom/ChatFooter.vue +58 -30
- package/dist/custom/SteerDebugPanel.vue +45 -0
- package/dist/custom/SteerQueue.vue +54 -0
- package/dist/custom/composables/agentStore/constants.ts +1 -0
- package/dist/custom/composables/agentStore/useAgentSessions.ts +35 -16
- package/dist/custom/composables/agentStore/useAgentSteerQueue.ts +114 -0
- package/dist/custom/composables/useAgentStore.ts +46 -1
- package/dist/custom/conversation_area/MessageRenderer.vue +7 -1
- package/dist/custom/speech_recognition_frontend/AudioLines.vue +1 -1
- package/dist/custom/speech_recognition_frontend/MicrophoneButon.vue +3 -3
- package/dist/domain/agentEvents.d.ts +4 -0
- package/dist/domain/steerBuffer.d.ts +33 -0
- package/dist/domain/steerBuffer.js +51 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -0
- package/dist/llm/agentRuntime.d.ts +3 -0
- package/dist/llm/agentRuntime.js +3 -0
- package/dist/llm/middleware/steerMiddleware.d.ts +21 -0
- package/dist/llm/middleware/steerMiddleware.js +50 -0
- package/dist/persistence/sessionStore.d.ts +7 -0
- package/dist/persistence/sessionStore.js +20 -0
- package/dist/transport/http/context.d.ts +10 -2
- package/dist/transport/http/coreEndpoints.js +20 -0
- package/dist/transport/http/sessionEndpoints.js +29 -0
- package/dist/transport/sse/sseWriter.d.ts +3 -3
- package/dist/transport/sse/sseWriter.js +15 -3
- 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
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
'
|
|
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="
|
|
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="
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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="
|
|
83
|
+
class=" !p-0 h-7 w-7 transition-opacity duration-200"
|
|
73
84
|
@click="sendMessage"
|
|
74
|
-
:disabled="!agentStore.trimmedUserMessage
|
|
85
|
+
:disabled="!agentStore.trimmedUserMessage"
|
|
75
86
|
>
|
|
76
87
|
<IconArrowUpOutline
|
|
77
|
-
class="w-
|
|
88
|
+
class="w-6 h-6
|
|
78
89
|
text-white"
|
|
79
90
|
/>
|
|
80
91
|
</Button>
|
|
81
92
|
<Button
|
|
82
93
|
v-else
|
|
83
|
-
class="
|
|
94
|
+
class="right-4 bottom-2 !p-0 h-7 w-7"
|
|
84
95
|
@click="stopCurrentRequest"
|
|
85
96
|
>
|
|
86
97
|
<div
|
|
87
|
-
class="w-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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 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
|
+
×
|
|
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
|
-
|
|
55
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
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
|
/>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<script setup lang="ts">
|
|
12
12
|
import { computed } from 'vue';
|
|
13
13
|
|
|
14
|
-
const IDLE_LINE_HEIGHTS = [0.
|
|
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-
|
|
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-
|
|
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-
|
|
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">
|
|
@@ -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
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;
|
package/dist/llm/agentRuntime.js
CHANGED
|
@@ -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
|
|
21
|
-
* `[DONE]`). Consumed by `DefaultChatTransport`
|
|
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
|
|
196
|
-
* `[DONE]`). Consumed by `DefaultChatTransport`
|
|
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;
|