@adminforth/agent 2.2.4 → 2.3.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.
@@ -35,7 +35,21 @@ export function createAgentSteerQueue({
35
35
  queue.value = queue.value.filter((item: QueuedMessage) => item.id !== id);
36
36
  }
37
37
 
38
- function injectSteerMessage(text: string) {
38
+ // Break the assistant's live stream at the steer point: freeze whatever streamed so far
39
+ // (e.g. the current reasoning block), drop the steer message in, and let the rest stream
40
+ // into a fresh assistant message after it — so the conversation reads
41
+ // "reasoning block → steer → new reasoning block" instead of the steer landing next to
42
+ // the user's original prompt.
43
+ //
44
+ // This reaches into the AI SDK's in-flight streaming state (`chat.activeResponse`):
45
+ // - reasoning/text parts are looked up by id reference in `active*Parts`, so pointing
46
+ // those entries at fresh (empty) parts redirects subsequent deltas past the steer;
47
+ // - the stream writer pushes a *new* message whenever `state.message.id` no longer
48
+ // matches the last displayed message, so swapping in a new `message` splits the bubble
49
+ // without duplicating the frozen block.
50
+ // If that internal shape isn't present (not streaming, or a future SDK change), we fall
51
+ // back to inserting the steer just above the streaming assistant.
52
+ async function injectSteerMessage(text: string) {
39
53
  const chat = currentChat.value;
40
54
  if (!chat) {
41
55
  return;
@@ -46,6 +60,11 @@ export function createAgentSteerQueue({
46
60
  parts: [{ type: 'text', text, state: 'done' }],
47
61
  };
48
62
  const messages = chat.messages;
63
+
64
+ if (breakStreamWithSteer(chat, messages, steerMessage)) {
65
+ return;
66
+ }
67
+
49
68
  const lastMessage = messages[messages.length - 1];
50
69
  if (lastMessage?.role === 'assistant') {
51
70
  messages.splice(messages.length - 1, 0, steerMessage);
@@ -54,6 +73,57 @@ export function createAgentSteerQueue({
54
73
  }
55
74
  }
56
75
 
76
+ function breakStreamWithSteer(chat: Chat<any>, messages: any[], steerMessage: IMessage): boolean {
77
+ try {
78
+ const streamingState = (chat as any).activeResponse?.state;
79
+ const streamingMessage = streamingState?.message;
80
+ const lastIndex = messages.length - 1;
81
+ const lastMessage = messages[lastIndex];
82
+ const isStreamingIntoLastAssistant =
83
+ streamingMessage?.role === 'assistant'
84
+ && Array.isArray(streamingMessage.parts)
85
+ && streamingMessage.parts.length > 0
86
+ && lastMessage?.role === 'assistant';
87
+
88
+ if (!isStreamingIntoLastAssistant) {
89
+ return false;
90
+ }
91
+
92
+ // 1) Freeze block 1 as a fresh, decoupled assistant message. Copying the parts (and
93
+ // reassigning the array slot) detaches it from the SDK's live parts and forces a
94
+ // re-render so the block stops showing as "streaming".
95
+ const frozenParts = streamingMessage.parts.map((part: any) => ({
96
+ ...part,
97
+ state: part?.state === 'streaming' ? 'done' : part?.state,
98
+ }));
99
+ messages[lastIndex] = { ...streamingMessage, parts: frozenParts };
100
+ // 2) Redirect the SDK's in-flight reasoning/text parts into a fresh continuation
101
+ // message so subsequent deltas (resolved by id reference) land after the steer.
102
+ const continuation: any = { id: crypto.randomUUID(), role: 'assistant', parts: [] };
103
+ const redirectActiveParts = (activeParts: Record<string, any> | undefined) => {
104
+ if (!activeParts) {
105
+ return;
106
+ }
107
+ for (const id of Object.keys(activeParts)) {
108
+ const continued = { ...activeParts[id], text: '', state: 'streaming' };
109
+ activeParts[id] = continued;
110
+ continuation.parts.push(continued);
111
+ }
112
+ };
113
+ redirectActiveParts(streamingState.activeReasoningParts);
114
+ redirectActiveParts(streamingState.activeTextParts);
115
+
116
+ // 3) Hand the SDK the continuation as its streaming message; its next write() pushes
117
+ // it as a new assistant bubble. Slot the steer between the frozen block and it.
118
+ streamingState.message = continuation;
119
+ messages.push(steerMessage);
120
+ return true;
121
+ } catch (error) {
122
+ console.error('Steer stream-break failed, inserting steer inline instead', error);
123
+ return false;
124
+ }
125
+ }
126
+
57
127
  // Persist the steer by appending it onto the running turn's prompt (display only — the
58
128
  // LLM already has it via the running turn + checkpointer, so this never re-enters the
59
129
  // model context). The backend adds the marker; the reload path splits it back out.
@@ -104,11 +174,16 @@ export function createAgentSteerQueue({
104
174
  void sendMessage(next.text);
105
175
  }
106
176
 
177
+ function clear() {
178
+ queue.value = [];
179
+ }
180
+
107
181
  return {
108
182
  queue,
109
183
  enqueue,
110
184
  removeQueuedMessage,
111
185
  steerQueuedMessage,
112
186
  flushNext,
187
+ clear,
113
188
  };
114
189
  }
@@ -34,6 +34,14 @@ export const useAgentStore = defineStore('agent', () => {
34
34
  const textInput = ref<HTMLTextAreaElement | null>(null);
35
35
  const userMessageInput = ref();
36
36
  const trimmedUserMessage = computed(() => userMessageInput.value ? userMessageInput.value.trim() : '');
37
+ // Message-edit state. `editingEnabled` mirrors the server capability (checkpointer +
38
+ // turn checkpoints). While a message is being edited its id/turnId are held here and
39
+ // the user's in-progress input is stashed in `savedUserInput` so it can be restored.
40
+ const editingEnabled = ref(false);
41
+ const editingMessageId = ref<string | null>(null);
42
+ const editingMessageTurnId = ref<string | null>(null);
43
+ const savedUserInput = ref('');
44
+ const isEditingMessage = computed(() => editingMessageId.value !== null);
37
45
  const lastMessage = ref('');
38
46
  const isTeleportedToBody = ref(false);
39
47
  const setIsTeleportedToBody = (isTeleported: boolean) => {
@@ -55,6 +63,7 @@ export const useAgentStore = defineStore('agent', () => {
55
63
  setCurrentChat,
56
64
  abortCurrentChatRequest,
57
65
  submitToolApproval: submitToolApprovalResponse,
66
+ sendEditMessage,
58
67
  } = createAgentChatManager({
59
68
  lastMessage,
60
69
  activeModeName,
@@ -275,6 +284,10 @@ export const useAgentStore = defineStore('agent', () => {
275
284
  }
276
285
  })
277
286
  watch(activeSessionId, (newVal: string | null) => {
287
+ // The edited message lives in the outgoing session's chat, so drop edit mode.
288
+ if (editingMessageId.value !== null) {
289
+ exitEditMode();
290
+ }
278
291
  if (newVal) {
279
292
  setLocalStorageItem('lastSessionId', newVal);
280
293
  }
@@ -448,6 +461,67 @@ export const useAgentStore = defineStore('agent', () => {
448
461
  addSystemMessage(RESERVED_SYSTEM_MESSAGE_CONTENT.AGENT_RESPONSE_ABORTED);
449
462
  }
450
463
 
464
+ function setEditingEnabled(enabled: boolean) {
465
+ editingEnabled.value = enabled;
466
+ }
467
+
468
+ // Enter edit mode for a user message: stash the current input, then load the
469
+ // message's text into the textarea for the user to amend. Allowed mid-generation —
470
+ // committing the edit stops the running turn (see submitEditMessage).
471
+ function startEditMessage(message: any) {
472
+ if (!editingEnabled.value) {
473
+ return;
474
+ }
475
+ const turnId = message?.metadata?.turnId;
476
+ const messageId = message?.id;
477
+ if (!turnId || !messageId) {
478
+ return;
479
+ }
480
+ if (editingMessageId.value === null) {
481
+ savedUserInput.value = userMessageInput.value ?? '';
482
+ }
483
+ const text = (message.parts ?? [])
484
+ .filter((part: any) => part.type === 'text')
485
+ .map((part: any) => part.text ?? '')
486
+ .join('');
487
+ editingMessageId.value = messageId;
488
+ editingMessageTurnId.value = turnId;
489
+ userMessageInput.value = text;
490
+ nextTick(() => focusTextInput());
491
+ }
492
+
493
+ // Leave edit mode and restore whatever the user had been typing before.
494
+ function exitEditMode() {
495
+ userMessageInput.value = savedUserInput.value;
496
+ savedUserInput.value = '';
497
+ editingMessageId.value = null;
498
+ editingMessageTurnId.value = null;
499
+ }
500
+
501
+ function cancelEditMessage() {
502
+ if (editingMessageId.value === null) {
503
+ return;
504
+ }
505
+ exitEditMode();
506
+ nextTick(() => focusTextInput());
507
+ }
508
+
509
+ async function submitEditMessage() {
510
+ if (editingMessageId.value === null) {
511
+ return;
512
+ }
513
+ const text = trimmedUserMessage.value;
514
+ if (!text) {
515
+ return;
516
+ }
517
+ const messageId = editingMessageId.value;
518
+ const turnId = editingMessageTurnId.value!;
519
+ steerBuffer.clear();
520
+ // Restore the stashed input and leave edit mode before the regeneration streams in.
521
+ exitEditMode();
522
+ await sendEditMessage({ messageId, turnId, text });
523
+ }
524
+
451
525
  function resolveInternalRoute(href: string): string | null {
452
526
  if (href.startsWith('#')) {
453
527
  return `${window.location.pathname}${window.location.search}${href}`;
@@ -533,6 +607,15 @@ export const useAgentStore = defineStore('agent', () => {
533
607
  getLocalStorageItem,
534
608
  addDebugMessage,
535
609
  abortCurrentChatRequestAndAddSystemMessage,
610
+ //_________-Message editing-_____________
611
+ editingEnabled,
612
+ setEditingEnabled,
613
+ isEditingMessage,
614
+ editingMessageId,
615
+ startEditMessage,
616
+ cancelEditMessage,
617
+ submitEditMessage,
618
+ //_______________________________________
536
619
  addSystemMessage,
537
620
  isAudioChatMode,
538
621
  setIsAudioChatMode,
@@ -1,33 +1,77 @@
1
1
  <template>
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"
2
+ <div
3
+ class="relative flex flex-col w-full group/msg"
5
4
  >
6
- {{ $t('Steering instruction') }}
7
- </div>
8
- <ProcessingTimeline
9
- :message="message"
10
- :isLastMessageInChat="isLastMessageInChat"
11
- />
12
- <template
13
- v-for="(part, index) in getMessageParts(message)"
14
- :key="part.type"
15
- >
16
- <TextRenderer
17
- v-if="part.type === 'text' && !checkIfMessageSystemMessage(part.text ?? '')"
18
- :message="part.text"
19
- :role="props.message.role"
20
- :state="part.state ?? (props.message.role === 'user' ? 'done' : undefined)"
21
- />
22
- <ToolApprovalRenderer
23
- v-else-if="part.type === 'data-tool-approval'"
24
- :data="part.data ?? {}"
5
+ <div
6
+ v-if="canEdit"
7
+ class="absolute w-full h-[calc(100%+1.5rem)] pb-8"
8
+ @mouseenter="showEditButtonOnHover"
9
+ @mouseleave="hideEditButtonOnHover"
10
+ />
11
+ <Transition
12
+ enter-active-class="transition ease-out duration-200"
13
+ enter-from-class="opacity-0 scale-95"
14
+ enter-to-class="opacity-100 scale-100"
15
+ leave-active-class="transition ease-in duration-150"
16
+ leave-from-class="opacity-100 scale-100"
17
+ leave-to-class="opacity-0 scale-95"
18
+ >
19
+ <div
20
+ v-if="canEdit && showEditButton"
21
+ class="absolute flex -bottom-6 right-0 self-end p-1 mr-4 mt-0.5 z-20"
22
+ @mouseenter="showEditButtonOnHover"
23
+ @mouseleave="hideEditButtonOnHover"
24
+ >
25
+ <button
26
+ type="button"
27
+ class="flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-lightPrimary dark:hover:text-darkPrimary opacity-60 group-hover/msg:opacity-100 transition-opacity duration-150 hover:scale-110"
28
+ :title="$t('Copy message')"
29
+ @click="copyMessageToClipboard(message)"
30
+ >
31
+ <IconFileCopySolid class="w-5 h-5" />
32
+ </button>
33
+ <button
34
+ type="button"
35
+ class="flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-lightPrimary dark:hover:text-darkPrimary opacity-60 group-hover/msg:opacity-100 transition-opacity duration-150 hover:scale-110"
36
+ :title="$t('Edit message')"
37
+ @click="agentStore.startEditMessage(message)"
38
+ >
39
+ <IconPenSolid class="w-5 h-5" />
40
+ </button>
41
+ </div>
42
+ </Transition>
43
+ <div
44
+ v-if="message.role === 'user' && message.metadata?.steer"
45
+ class="self-end mr-4 mb-0.5 text-xs italic text-gray-400 dark:text-gray-500"
46
+ >
47
+ {{ $t('Steering instruction') }}
48
+ </div>
49
+ <ProcessingTimeline
50
+ :message="message"
51
+ :isLastMessageInChat="isLastMessageInChat"
25
52
  />
26
- <SystemMessageRenderer
27
- v-else-if="part.type === 'text'"
28
- :message="part.text"
29
- />
30
- </template>
53
+ <template
54
+ v-for="(part, index) in getMessageParts(message)"
55
+ :key="part.type"
56
+ >
57
+ <TextRenderer
58
+ v-if="part.type === 'text' && !checkIfMessageSystemMessage(part.text ?? '')"
59
+ :message="part.text"
60
+ :role="props.message.role"
61
+ :highlight="isBeingEdited"
62
+ :state="part.state ?? (props.message.role === 'user' ? 'done' : undefined)"
63
+ />
64
+ <ToolApprovalRenderer
65
+ v-else-if="part.type === 'data-tool-approval'"
66
+ :data="part.data ?? {}"
67
+ />
68
+ <SystemMessageRenderer
69
+ v-else-if="part.type === 'text'"
70
+ :message="part.text"
71
+ />
72
+ </template>
73
+
74
+ </div>
31
75
 
32
76
  </template>
33
77
 
@@ -35,6 +79,8 @@
35
79
 
36
80
 
37
81
  <script setup lang="ts">
82
+ import { computed, ref } from 'vue';
83
+ import { IconPenSolid, IconFileCopySolid } from '@iconify-prerendered/vue-flowbite';
38
84
  import TextRenderer from './TextRenderer.vue';
39
85
  import type { IMessage } from '../types';
40
86
  import { getMessageParts } from '../utils';
@@ -42,14 +88,53 @@
42
88
  import SystemMessageRenderer from './SystemMessageRenderer.vue';
43
89
  import ToolApprovalRenderer from './ToolApprovalRenderer.vue';
44
90
  import { RESERVED_SYSTEM_MESSAGE_CONTENT } from '../composables/agentStore/constants';
91
+ import { useAgentStore } from '../composables/useAgentStore';
92
+ import { useAdminforth } from '@/adminforth';
45
93
 
46
- const props = defineProps<{
47
- message: IMessage
94
+ const props = defineProps<{
95
+ message: IMessage
48
96
  isLastMessageInChat: boolean
49
97
  }>();
50
98
 
99
+ const agentStore = useAgentStore();
100
+ const { alert } = useAdminforth();
101
+ const showEditButton = ref(false);
102
+ // A user message is editable once it carries a turnId (loaded from history or stamped
103
+ // after its turn finished). Steer sub-messages and other roles are never editable, and
104
+ // we hide the affordance entirely while another edit or a live turn is in flight.
105
+ const canEdit = computed(() =>
106
+ agentStore.editingEnabled
107
+ && props.message.role === 'user'
108
+ && !props.message.metadata?.steer
109
+ && Boolean(props.message.metadata?.turnId)
110
+ && !agentStore.isEditingMessage
111
+ );
112
+
113
+ const isBeingEdited = computed(() =>
114
+ Boolean(props.message.id) && props.message.id === agentStore.editingMessageId
115
+ );
116
+
51
117
  function checkIfMessageSystemMessage(message: IMessage): boolean {
52
118
  const isReserved = Object.values(RESERVED_SYSTEM_MESSAGE_CONTENT).includes(message as RESERVED_SYSTEM_MESSAGE_CONTENT);
53
119
  return isReserved;
54
120
  }
121
+
122
+ function showEditButtonOnHover() {
123
+ showEditButton.value = true;
124
+ }
125
+
126
+ function hideEditButtonOnHover() {
127
+ showEditButton.value = false;
128
+ }
129
+
130
+ function copyMessageToClipboard(message: IMessage) {
131
+ navigator.clipboard.writeText(message.parts[0].text ?? '')
132
+ .then(() => {
133
+ alert({ message: 'Message copied to clipboard', variant: 'success' });
134
+ })
135
+ .catch((error) => {
136
+ console.error('Failed to copy message to clipboard:', error);
137
+ alert({ message: 'Failed to copy message to clipboard', variant: 'danger' });
138
+ });
139
+ }
55
140
  </script>
@@ -1,18 +1,20 @@
1
1
  <template>
2
2
  <div
3
- class="flex items-center justify-center rounded-xl border px-6 border-gray-200 dark:border-gray-700 min-w-0"
3
+ class="flex items-center justify-center rounded-xl border px-6 border-gray-200 dark:border-gray-700 min-w-0 transition-shadow duration-150"
4
4
  @click="handleMarkdownLinkClick"
5
5
  :class="[
6
- hasVegaLite ? 'w-full my-2' : '',
6
+ hasVegaLite ? 'w-full my-2' : 'm-2',
7
7
  props.role === 'user' ? 'bg-lightListTableHeading dark:bg-darkListTableHeading self-end max-w-[80%] mr-4'
8
- : 'border-none self-start max-w-full'
8
+ : 'border-none self-start max-w-full',
9
+ props.highlight ? 'ring-2 ring-lightPrimary dark:ring-darkPrimary w-full max-w-[calc(100%-2rem)]' : ''
9
10
  ]"
10
11
  >
11
12
  <IncremarkContent
12
13
  class="text-wrap break-words w-full max-w-full"
13
14
  v-if="content"
14
- :content="content"
15
- :is-finished="isFinished"
15
+ :key="renderNonce"
16
+ :content="content"
17
+ :is-finished="isFinished"
16
18
  :components="incremarkComponents"
17
19
  :incremark-options="incremarkOptions"
18
20
  />
@@ -33,7 +35,8 @@
33
35
  const props = defineProps<{
34
36
  message: string | undefined,
35
37
  state: string | undefined,
36
- role: IMessage['role']
38
+ role: IMessage['role'],
39
+ highlight?: boolean
37
40
  }>();
38
41
 
39
42
  const emit = defineEmits(['toggle-thoughts']);
@@ -49,6 +52,13 @@
49
52
 
50
53
  const content = computed(() => props.message)
51
54
  const isFinished = computed(() => props.state === 'done')
55
+
56
+ const renderNonce = ref(0);
57
+ watch(content, (newContent, oldContent) => {
58
+ if (isFinished.value && newContent !== oldContent) {
59
+ renderNonce.value++;
60
+ }
61
+ });
52
62
  const hasVegaLite = computed(() => props.message?.includes('```vega-lite'))
53
63
  const isStateStreaming = computed(() => props.state === 'streaming')
54
64
 
@@ -18,7 +18,7 @@
18
18
  "@shikijs/langs": "^4.0.2",
19
19
  "@shikijs/themes": "^4.0.2",
20
20
  "@vueuse/core": "^14.2.1",
21
- "ai": "^6.0.168",
21
+ "ai": "6.0.168",
22
22
  "custom-vue-scrollbar": "^0.0.8",
23
23
  "dompurify": "^3.3.3",
24
24
  "katex": "^0.16.45",
@@ -30,11 +30,18 @@ export interface IToolGroup {
30
30
  }
31
31
 
32
32
  export interface IMessage {
33
+ id?: string;
33
34
  role: 'user' | 'assistant' | 'system';
34
35
  metadata?: any,
35
36
  parts: IPart[];
36
37
  }
37
38
 
39
+ export interface IStoredMessage {
40
+ role: 'user' | 'assistant' | 'system';
41
+ text: string;
42
+ turnId?: string;
43
+ }
44
+
38
45
  export interface IAgentSession {
39
46
  sessionId: string;
40
47
  title: string;
@@ -30,6 +30,10 @@ export type AgentEvent = {
30
30
  type: "transcript";
31
31
  text: string;
32
32
  language?: string;
33
+ } | {
34
+ type: "turn-persisted";
35
+ sessionId: string;
36
+ turnId: string;
33
37
  } | {
34
38
  type: "response";
35
39
  text: string;
@@ -63,6 +63,7 @@ export type RunAndPersistAgentResponseInput = BaseAgentTurnInput & {
63
63
  approvalDecision?: "approve" | "reject";
64
64
  failureLogMessage: string;
65
65
  abortLogMessage: string;
66
+ editTurnId?: string;
66
67
  };
67
68
  export type RunAndPersistAgentResponseResult = {
68
69
  text: string;
@@ -72,6 +73,9 @@ export type RunAndPersistAgentResponseResult = {
72
73
  };
73
74
  export type HandleTurnInput = TextAgentTurnInput;
74
75
  export type HandleSpeechTurnInput = SpeechAgentTurnInput;
76
+ export type HandleEditTurnInput = TextAgentTurnInput & {
77
+ turnId: string;
78
+ };
75
79
  /**
76
80
  * Provider-agnostic message passed from the application layer to the LLM port.
77
81
  * The infrastructure adapter maps it onto concrete LangChain messages.
package/dist/index.js CHANGED
@@ -79,6 +79,8 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
79
79
  getAdminforth: () => this.adminforth,
80
80
  getAgentSystemPrompt: this.getAgentSystemPrompt.bind(this),
81
81
  hasPersistentCheckpointer: Boolean(this.options.checkpointResource),
82
+ turnCheckpointsEnabled: Boolean(this.options.turnResource.checkpointIdField),
83
+ sessionResource: this.options.sessionResource,
82
84
  createDebugSink: createSequenceDebugCollector,
83
85
  });
84
86
  this.speechTurnService = new SpeechTurnService(this.runTurnUseCase.runAndPersistAgentResponse.bind(this.runTurnUseCase));
@@ -107,6 +109,7 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
107
109
  defaultModeName: this.options.modes[0].name,
108
110
  stickByDefault: (_b = this.options.stickByDefault) !== null && _b !== void 0 ? _b : false,
109
111
  hasAudioAdapter: Boolean(this.options.audioAdapter),
112
+ editingEnabled: Boolean(this.options.checkpointResource && this.options.turnResource.checkpointIdField),
110
113
  }
111
114
  });
112
115
  if (!this.adminforth.config.customization.customHeadItems) {
@@ -149,6 +152,7 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
149
152
  adminforth: this.adminforth,
150
153
  options: this.options,
151
154
  handleTurn: this.runTurnUseCase.handleTurn.bind(this.runTurnUseCase),
155
+ handleEditTurn: this.runTurnUseCase.handleEditTurn.bind(this.runTurnUseCase),
152
156
  handleSpeechTurn: this.speechTurnService.handle.bind(this.speechTurnService),
153
157
  steer: ({ sessionId, message }) => {
154
158
  const { id } = this.steerBuffer.add(sessionId, message);
@@ -21,6 +21,7 @@ export type AgentRuntimeRunInput = {
21
21
  } | Command;
22
22
  context: AgentTurnContext;
23
23
  observability: AgentTurnObservability;
24
+ branchFromCheckpointId?: string;
24
25
  };
25
26
  type AgentChatModelSpec = {
26
27
  model: AgentChatModel;
@@ -24,4 +24,18 @@ export declare class AgentRuntime {
24
24
  models: AgentTurnModels;
25
25
  sessionId: string;
26
26
  }): Promise<unknown[]>;
27
+ /**
28
+ * Return the thread's latest (tip) checkpoint id, or null when the thread has no
29
+ * persisted state. Builds a minimal agent and reads its state — no model call is
30
+ * made. Called after a turn completes to record the turn's fork point for editing.
31
+ */
32
+ getLatestCheckpointId(input: {
33
+ models: AgentTurnModels;
34
+ sessionId: string;
35
+ }): Promise<string | null>;
36
+ /**
37
+ * Drop the entire LangGraph checkpoint thread for a session. Used when editing the
38
+ * first message, where there is no earlier checkpoint to fork from.
39
+ */
40
+ resetThreadCheckpoints(sessionId: string): Promise<void>;
27
41
  }
@@ -68,9 +68,9 @@ export class AgentRuntime {
68
68
  recursionLimit: 100,
69
69
  callbacks: [createAgentLlmMetricsLogger()],
70
70
  signal: input.context.abortSignal,
71
- configurable: {
72
- thread_id: input.context.sessionId,
73
- },
71
+ configurable: Object.assign({ thread_id: input.context.sessionId }, (input.branchFromCheckpointId
72
+ ? { checkpoint_id: input.branchFromCheckpointId }
73
+ : {})),
74
74
  context: toLangchainAgentContext(Object.assign(Object.assign({}, input.context), { adminBaseUrl: adminforth.config.baseUrlSlashed, emit: input.observability.emit, sequenceDebugSink })),
75
75
  });
76
76
  });
@@ -104,4 +104,43 @@ export class AgentRuntime {
104
104
  return tasks.flatMap((task) => (Array.isArray(task === null || task === void 0 ? void 0 : task.interrupts) ? task.interrupts : []));
105
105
  });
106
106
  }
107
+ /**
108
+ * Return the thread's latest (tip) checkpoint id, or null when the thread has no
109
+ * persisted state. Builds a minimal agent and reads its state — no model call is
110
+ * made. Called after a turn completes to record the turn's fork point for editing.
111
+ */
112
+ getLatestCheckpointId(input) {
113
+ return __awaiter(this, void 0, void 0, function* () {
114
+ var _a, _b;
115
+ const apiBasedTools = this.options.toolProvider.getApiBasedTools();
116
+ const tools = yield this.options.toolProvider.getTools(apiBasedTools);
117
+ const agent = createAgent({
118
+ name: this.options.name,
119
+ model: input.models.model,
120
+ checkpointer: this.options.getCheckpointer(),
121
+ tools,
122
+ contextSchema,
123
+ middleware: [
124
+ humanInTheLoopMiddleware({
125
+ interruptOn: createHumanInTheLoopInterrupts(apiBasedTools),
126
+ descriptionPrefix: "Tool execution pending approval",
127
+ }),
128
+ ],
129
+ });
130
+ const state = yield agent.getState({
131
+ configurable: { thread_id: input.sessionId },
132
+ });
133
+ const checkpointId = (_b = (_a = state === null || state === void 0 ? void 0 : state.config) === null || _a === void 0 ? void 0 : _a.configurable) === null || _b === void 0 ? void 0 : _b.checkpoint_id;
134
+ return checkpointId ? String(checkpointId) : null;
135
+ });
136
+ }
137
+ /**
138
+ * Drop the entire LangGraph checkpoint thread for a session. Used when editing the
139
+ * first message, where there is no earlier checkpoint to fork from.
140
+ */
141
+ resetThreadCheckpoints(sessionId) {
142
+ return __awaiter(this, void 0, void 0, function* () {
143
+ yield this.options.getCheckpointer().deleteThread(sessionId);
144
+ });
145
+ }
107
146
  }
@@ -22,4 +22,12 @@ export declare class LangGraphLlm implements LlmPort {
22
22
  completionAdapter: AgentModeCompletionAdapter;
23
23
  sessionId: string;
24
24
  }): Promise<PendingInterrupt[]>;
25
+ getLatestCheckpointId(input: {
26
+ completionAdapter: AgentModeCompletionAdapter;
27
+ sessionId: string;
28
+ }): Promise<string | null>;
29
+ resetThreadCheckpoints(input: {
30
+ completionAdapter: AgentModeCompletionAdapter;
31
+ sessionId: string;
32
+ }): Promise<void>;
25
33
  }
@@ -37,6 +37,7 @@ export class LangGraphLlm {
37
37
  input: runtimeInput,
38
38
  context: input.context,
39
39
  observability: input.observability,
40
+ branchFromCheckpointId: input.branchFromCheckpointId,
40
41
  });
41
42
  return adaptRawStream(rawStream);
42
43
  });
@@ -51,4 +52,15 @@ export class LangGraphLlm {
51
52
  return normalizeInterrupts(raw);
52
53
  });
53
54
  }
55
+ getLatestCheckpointId(input) {
56
+ return __awaiter(this, void 0, void 0, function* () {
57
+ const models = yield this.modelFactory.create(input.completionAdapter);
58
+ return this.runtime.getLatestCheckpointId({ models, sessionId: input.sessionId });
59
+ });
60
+ }
61
+ resetThreadCheckpoints(input) {
62
+ return __awaiter(this, void 0, void 0, function* () {
63
+ yield this.runtime.resetThreadCheckpoints(input.sessionId);
64
+ });
65
+ }
54
66
  }