@adminforth/agent 2.7.5 → 2.8.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.
package/README.md CHANGED
@@ -19,6 +19,10 @@ API-based tools, streams its answers token-by-token, and keeps persistent chat s
19
19
  by Markdown *skills* you can extend.
20
20
  - **Human-in-the-loop approvals** — tools you mark as dangerous pause for explicit
21
21
  approve/reject from the user before running.
22
+ - **Mid-turn steering** — send another instruction while the agent is still working; it is
23
+ folded into the *running* turn before its next model call instead of waiting in line.
24
+ - **Message editing & branching** — edit an earlier message to fork the conversation from
25
+ that turn's checkpoint: later turns are dropped and the answer is regenerated.
22
26
  - **Multiple modes** — expose several models (e.g. *Fast*, *Balanced*, *Smart Thinking*)
23
27
  and let users switch between them.
24
28
  - **Persistent sessions** — conversations are stored in your database; an optional
@@ -36,6 +40,124 @@ list of your resources and available skills), streams the model's output back ov
36
40
  persists the prompt/response to your turn resource. Tools are executed through AdminForth's
37
41
  API endpoints; conversation memory is kept per session (`thread_id = sessionId`).
38
42
 
43
+ ## Architecture
44
+
45
+ The plugin is layered so that the provider-specific parts (LangChain/LangGraph, the
46
+ completion adapter, the checkpointer) sit behind ports and never leak into the turn logic:
47
+
48
+ | Layer | Directory | Depends on |
49
+ | --- | --- | --- |
50
+ | Domain — prompt building, event vocabulary, buffers | [domain/](domain/) | adminforth types only |
51
+ | Application — the turn use case + ports | [application/](application/) | domain |
52
+ | LLM runtime — LangChain/LangGraph behind `LlmPort` | [llm/](llm/) | application, domain, tools |
53
+ | Tools & skills — API tools, progressive disclosure | [tools/](tools/) | AdminForth API layer |
54
+ | Persistence — sessions, turns, checkpoints | [persistence/](persistence/) | AdminForth resources |
55
+ | Transport — HTTP endpoints, SSE, external surfaces | [transport/](transport/) | application |
56
+ | Frontend — Vue 3 + Pinia chat surface | [custom/](custom/) | HTTP + SSE contract |
57
+
58
+ ```mermaid
59
+ flowchart TB
60
+ UI["Admin UI - custom/<br/>Vue 3 + Pinia chat surface"]
61
+ TR["transport/<br/>HTTP endpoints, SSE, external surfaces"]
62
+ APP["application/<br/>RunTurnUseCase + LlmPort"]
63
+ DOM["domain/<br/>prompt building, steer buffer,<br/>events, language detection"]
64
+ LLMS["llm/<br/>LangGraph agent, middleware chain, models"]
65
+ TLS["tools/<br/>API tools + skills,<br/>progressive disclosure"]
66
+ PRS["persistence/<br/>sessions, turns, checkpoints"]
67
+
68
+ AF[("AdminForth core<br/>resources, API layer, auth")]
69
+ MODEL(["Completion adapter<br/>OpenAI / Anthropic / Gemini"])
70
+ ADPT(["Audio and chat surface adapters<br/>STT / TTS, Telegram"])
71
+
72
+ UI <-->|"POST + SSE"| TR
73
+ TR <--> ADPT
74
+ TR --> APP
75
+ TR -->|"session CRUD"| PRS
76
+ APP --> DOM
77
+ APP --> PRS
78
+ APP -->|"LlmPort"| LLMS
79
+ LLMS --> MODEL
80
+ LLMS --> TLS
81
+ LLMS -->|"checkpointer"| PRS
82
+ TLS --> AF
83
+ PRS --> AF
84
+ ```
85
+
86
+ Every arrow into `AdminForth core` goes through its ordinary resource/API layer, so your
87
+ access rules, hooks and validation apply to the agent exactly as they do to a human admin.
88
+
89
+ ### One turn, end to end
90
+
91
+ ```mermaid
92
+ sequenceDiagram
93
+ autonumber
94
+ actor U as User
95
+ participant UI as Chat UI (useAgentChat)
96
+ participant EP as POST /agent/response
97
+ participant UC as RunTurnUseCase
98
+ participant DB as sessions and turns
99
+ participant LLM as LangGraphLlm + AgentRuntime
100
+ participant M as Model (completion adapter)
101
+ participant T as API tool (AdminForth API layer)
102
+
103
+ U->>UI: type a message
104
+ UI->>EP: message, sessionId, mode, timeZone, currentPage
105
+ EP->>UC: handleTurn
106
+ UC->>DB: assert session ownership
107
+ UC->>DB: create turn, response = not_finished
108
+ UC-->>UI: data-turn-persisted with turnId
109
+ UC->>M: detect user language (small side call)
110
+ UC->>LLM: streamTurn with system prompt, messages, context
111
+
112
+ loop agent loop, recursionLimit 100
113
+ LLM->>LLM: steer middleware drains SteerBuffer
114
+ LLM->>M: model call with currently enabled tools
115
+ M-->>LLM: reasoning and text deltas
116
+ LLM-->>UI: reasoning-delta and text-delta frames
117
+ M-->>LLM: tool call
118
+ alt tool has agent.isDangerous
119
+ LLM-->>UI: data-interrupt, generation pauses
120
+ U->>UI: approve or reject
121
+ UI->>EP: POST /agent/approval
122
+ EP->>UC: resume the interrupted graph
123
+ else safe tool
124
+ LLM->>T: execute through the API layer
125
+ T-->>LLM: YAML result plus durationMs
126
+ LLM-->>UI: data-tool-call start and end
127
+ end
128
+ end
129
+
130
+ LLM-->>UC: stream ends
131
+ UC->>LLM: getLatestCheckpointId, the fork point for edits
132
+ UC->>DB: save response, debug trace, checkpointId
133
+ UC-->>UI: data-response then finish
134
+ ```
135
+
136
+ Two side channels run on top of that flow:
137
+
138
+ - **Steering** — `POST /agent/steer` buffers a message in the in-process `SteerBuffer`;
139
+ the `beforeModel` steer middleware folds it in as a user message before the next model
140
+ call and emits `data-steer-applied` on the turn's already-open stream.
141
+ - **Editing / branching** — `POST /agent/edit` forks from the previous turn's stored
142
+ checkpoint id, truncates the later turns, and regenerates. Requires both
143
+ `checkpointResource` and `turnResource.checkpointIdField`.
144
+
145
+ ### Progressive tool & skill disclosure
146
+
147
+ ```mermaid
148
+ flowchart LR
149
+ A["Turn starts"] --> B["Exposed tools:<br/>get_resource, get_user_location,<br/>navigate_user, fetch_skill, fetch_tool_schema"]
150
+ B --> C{"Needs more than<br/>reading schema?"}
151
+ C -- no --> D["Answer with the base tools"]
152
+ C -- yes --> E["fetch_skill returns the SKILL.md<br/>for the matching skill"]
153
+ E --> F["fetch_tool_schema loads one<br/>API tool schema by name"]
154
+ F --> G["ApiBasedToolsMiddleware sees the<br/>status 200 tool message and adds that<br/>tool to the next model call"]
155
+ G --> H{"agent.isDangerous?"}
156
+ H -- yes --> I["humanInTheLoopMiddleware interrupt,<br/>approve or reject in the UI"]
157
+ H -- no --> J["Execute through the AdminForth API layer"]
158
+ I -- approved --> J
159
+ ```
160
+
39
161
  ## Requirements
40
162
 
41
163
  - An AdminForth app (`adminforth >= 3.8.2`, declared as a peer dependency).
@@ -78,7 +200,6 @@ const sessionsResource: AdminForthResourceInput = {
78
200
  columns: [
79
201
  { name: 'id', primaryKey: true, type: AdminForthDataTypes.STRING, fillOnCreate: () => randomUUID() },
80
202
  { name: 'title', type: AdminForthDataTypes.STRING },
81
- { name: 'turns', type: AdminForthDataTypes.INTEGER },
82
203
  { name: 'asker_id', type: AdminForthDataTypes.STRING },
83
204
  { name: 'created_at', type: AdminForthDataTypes.DATETIME, fillOnCreate: () => new Date().toISOString() },
84
205
  ],
@@ -106,6 +227,9 @@ const turnsResource: AdminForthResourceInput = {
106
227
  { name: 'created_at', type: AdminForthDataTypes.DATETIME, fillOnCreate: () => new Date().toISOString() },
107
228
  { name: 'prompt', type: AdminForthDataTypes.TEXT },
108
229
  { name: 'response', type: AdminForthDataTypes.TEXT },
230
+ // Optional: map via turnResource.checkpointIdField. Stores each turn's tip
231
+ // checkpoint id — required for message editing / branching.
232
+ { name: 'checkpoint_id', type: AdminForthDataTypes.STRING },
109
233
  // Optional: add a `debug` TEXT column and map it via turnResource.debugField
110
234
  // to store per-turn debug traces.
111
235
  ],
@@ -180,7 +304,6 @@ export const globalPlugins = [
180
304
  resourceId: 'sessions',
181
305
  idField: 'id',
182
306
  titleField: 'title',
183
- turnsField: 'turns',
184
307
  askerIdField: 'asker_id',
185
308
  createdAtField: 'created_at',
186
309
  },
@@ -191,6 +314,8 @@ export const globalPlugins = [
191
314
  createdAtField: 'created_at',
192
315
  promptField: 'prompt',
193
316
  responseField: 'response',
317
+ // Enables message editing / branching (together with checkpointResource below).
318
+ checkpointIdField: 'checkpoint_id',
194
319
  },
195
320
 
196
321
  // Optional but recommended in production:
@@ -254,12 +379,21 @@ That's it — a chat panel now appears in the admin UI.
254
379
 
255
380
  ### `sessionResource` fields
256
381
 
257
- `resourceId`, `idField`, `titleField`, `turnsField`, `askerIdField`, `createdAtField`.
382
+ `resourceId`, `idField`, `titleField`, `askerIdField`, `createdAtField`.
383
+
384
+ > `turnsField` is **deprecated** and ignored — session turns are looked up through
385
+ > `turnResource.sessionIdField`. It still type-checks for backward compatibility and will be
386
+ > removed in a future major version.
258
387
 
259
388
  ### `turnResource` fields
260
389
 
261
390
  `resourceId`, `idField`, `sessionIdField`, `createdAtField`, `promptField`, `responseField`,
262
- and optional `debugField` (when set, per-turn debug traces are written to it).
391
+ plus two optional ones:
392
+
393
+ | Field | Effect |
394
+ | --- | --- |
395
+ | `debugField` | Per-turn debug traces are written to this column. |
396
+ | `checkpointIdField` | Each successful turn's tip checkpoint id is stored, which is what **message editing / branching** forks from. Editing needs this *and* `checkpointResource`; without both, the chat UI hides the edit action and `POST /agent/edit` rejects the request. |
263
397
 
264
398
  > **Reasoning effort** is set on the completion adapter (e.g.
265
399
  > `extraRequestBodyParameters: { reasoning: { effort } }`), not on the plugin.
@@ -273,7 +407,8 @@ permissions and validation are enforced.
273
407
 
274
408
  To keep the model focused, tools are disclosed progressively:
275
409
 
276
- - `get_resource` is always available (to inspect resource structure).
410
+ - Always available: `get_resource` (inspect resource structure), `get_user_location`,
411
+ `navigate_user`, and the two discovery tools `fetch_skill` / `fetch_tool_schema`.
277
412
  - The agent reads a **skill** (a `SKILL.md` file) to learn which tools a task needs, then
278
413
  loads those tool schemas on demand.
279
414
 
@@ -287,7 +422,11 @@ directories are also discovered.
287
422
  Tools whose definition marks them dangerous (`agent.isDangerous === true`) trigger an
288
423
  approval interrupt: generation pauses and the UI shows an approve/reject prompt. The client
289
424
  resolves it via `POST /agent/approval`, and the run resumes (or, on reject, the model is
290
- told the action was declined). Approval state is currently held per process instance.
425
+ told the action was declined).
426
+
427
+ Where the pending approval lives depends on your setup: with `checkpointResource` configured
428
+ the LangGraph checkpoint is authoritative, so a resume survives a restart and works across
429
+ instances; with the in-memory fallback the state is held per process instance.
291
430
 
292
431
  ## Voice
293
432
 
@@ -311,7 +450,9 @@ All routes are registered under your AdminForth API base path.
311
450
  | Method & path | Purpose |
312
451
  | --- | --- |
313
452
  | `POST /agent/response` | Send a message; streams the answer over SSE. |
453
+ | `POST /agent/edit` | Edit a previous message: forks from its turn's checkpoint, truncates later turns, regenerates. |
314
454
  | `POST /agent/approval` | Approve/reject a pending human-in-the-loop tool call. |
455
+ | `POST /agent/steer` | Buffer a mid-turn instruction; folded into the running turn before the next model call. |
315
456
  | `POST /agent/speech-response` | Multipart audio upload; streams transcript + answer (+ audio). |
316
457
  | `POST /agent/get-placeholder-messages` | Placeholder prompts for the chat textarea. |
317
458
  | `POST /agent/get-sessions` | List chat sessions. |
@@ -319,23 +460,27 @@ All routes are registered under your AdminForth API base path.
319
460
  | `POST /agent/create-session` | Create a new session. |
320
461
  | `POST /agent/delete-session` | Delete a session and its turns. |
321
462
  | `POST /agent/add-system-message-to-turns` | Append a system message turn. |
463
+ | `POST /agent/append-steer-to-turn` | Persist a steer into the running turn's stored prompt. |
322
464
  | `POST /agent/surface/<name>/webhook` | Inbound webhook for an external chat surface. |
323
465
 
324
- The `/agent/response` and `/agent/approval` streams use the Vercel AI UI message stream
325
- format (`x-vercel-ai-ui-message-stream: v1`); the frontend consumes them directly.
466
+ The `/agent/response`, `/agent/edit` and `/agent/approval` streams use the Vercel AI UI
467
+ message stream format (`x-vercel-ai-ui-message-stream: v1`); the frontend consumes them
468
+ directly. `/agent/speech-response` uses the plugin's own bare event names instead.
326
469
 
327
470
  ## Contributing / tests
328
471
 
329
472
  This package is developed inside the [AdminForth monorepo](https://github.com/devforth/adminforth).
330
- Unit and characterization tests for the plugin live in `tests/jest_tests/`
331
- (`adminforth_agent_*.test.ts`) and run with:
473
+ The plugin carries its own self-contained Jest suite in [tests/](tests/) — run it from the
474
+ plugin root:
332
475
 
333
476
  ```bash
334
- cd tests/jest_tests
335
477
  pnpm install
336
478
  pnpm test
337
479
  ```
338
480
 
481
+ A couple of integration-level agent tests (`adminforth_agent_*.test.ts`) still live in the
482
+ monorepo's `tests/jest_tests/` and run from that directory.
483
+
339
484
  ## About AdminForth
340
485
 
341
486
  AdminForth is an open-source, agent-first admin framework for building robust admin panels
@@ -57,9 +57,10 @@ export interface LlmPort {
57
57
  /**
58
58
  * Rebuild the pending human-in-the-loop interrupts for a session from persisted
59
59
  * checkpoint state (used when the in-process cache is empty — after a restart or
60
- * on another instance). Returns already-normalized descriptors; the LangGraph
61
- * interrupt shape never leaves the llm layer. Throws on a checkpoint/runtime
62
- * failure (the caller must not treat that as "no pending interrupt").
60
+ * on another instance). Returns already-normalized descriptors, each carrying the
61
+ * tool calls it is waiting on; the LangGraph interrupt shape never leaves the llm
62
+ * layer. Throws on a checkpoint/runtime failure (the caller must not treat that as
63
+ * "no pending interrupt").
63
64
  */
64
65
  getPendingInterrupts(input: {
65
66
  completionAdapter: AgentModeCompletionAdapter;
@@ -70,6 +70,15 @@ export declare class RunTurnUseCase {
70
70
  * MemorySaver the in-process cache is authoritative (single process, cannot be stale).
71
71
  */
72
72
  private resolvePendingInterrupts;
73
+ /** Describe pending tool calls in plain language an admin (not a developer) can act on. */
74
+ private describeApprovals;
75
+ /**
76
+ * Describe what a session is still waiting for approval on, so a client that (re)loads
77
+ * the conversation can rebuild the approval prompt — it lives only in the browser, so a
78
+ * page reload would otherwise leave the user with no way to answer it. Empty when
79
+ * nothing is pending.
80
+ */
81
+ getPendingApprovals(sessionId: string, modeName?: string | null): Promise<string[]>;
73
82
  private requiresSessionOwnership;
74
83
  /** Verify the session exists and belongs to the requesting admin user. */
75
84
  assertSessionOwnership(sessionId: string, adminUser: BaseAgentTurnInput["adminUser"]): Promise<void>;
@@ -18,6 +18,7 @@ import { logger, Filters } from "adminforth";
18
18
  import { randomUUID } from "crypto";
19
19
  import { VegaLiteStreamBuffer } from "../domain/vegaLiteStreamBuffer.js";
20
20
  import { getErrorMessage, isAbortError } from "../shared/errors.js";
21
+ import { formatApprovalRequest } from "../tools/approvalFormatter.js";
21
22
  function getApprovalDecision(input) {
22
23
  return "approvalDecision" in input
23
24
  && (input.approvalDecision === "approve" || input.approvalDecision === "reject")
@@ -121,6 +122,29 @@ export class RunTurnUseCase {
121
122
  return (_a = this.pendingInterrupts.get(sessionId)) !== null && _a !== void 0 ? _a : [];
122
123
  });
123
124
  }
125
+ /** Describe pending tool calls in plain language an admin (not a developer) can act on. */
126
+ describeApprovals(interrupts) {
127
+ const adminforth = this.deps.getAdminforth();
128
+ return interrupts
129
+ .flatMap((interrupt) => interrupt.requests)
130
+ .map((request) => formatApprovalRequest(adminforth, request));
131
+ }
132
+ /**
133
+ * Describe what a session is still waiting for approval on, so a client that (re)loads
134
+ * the conversation can rebuild the approval prompt — it lives only in the browser, so a
135
+ * page reload would otherwise leave the user with no way to answer it. Empty when
136
+ * nothing is pending.
137
+ */
138
+ getPendingApprovals(sessionId, modeName) {
139
+ return __awaiter(this, void 0, void 0, function* () {
140
+ // Loading a conversation must not fail because the checkpoint store is unhappy
141
+ // (already logged by resolvePendingInterrupts). Without the prompt the user simply
142
+ // falls back to the implicit reject in `prepareTurn`.
143
+ const interrupts = yield this.resolvePendingInterrupts(sessionId, this.resolveMode(modeName))
144
+ .catch(() => []);
145
+ return this.describeApprovals(interrupts);
146
+ });
147
+ }
124
148
  requiresSessionOwnership(input) {
125
149
  return !input.chatSurface;
126
150
  }
@@ -193,6 +217,7 @@ export class RunTurnUseCase {
193
217
  mode,
194
218
  context: this.buildContext(input, editTurnId),
195
219
  observability: { emit: input.emit, sequenceDebugSink },
220
+ freshPrompt: true,
196
221
  branchFromCheckpointId,
197
222
  };
198
223
  });
@@ -212,20 +237,31 @@ export class RunTurnUseCase {
212
237
  let turnId;
213
238
  let previousUserMessages = [];
214
239
  let initialResponse;
215
- let resumeInterrupts;
240
+ let resume;
216
241
  if (shouldResume) {
217
- resumeInterrupts = yield this.resolvePendingInterrupts(input.sessionId, mode);
242
+ const resumeInterrupts = yield this.resolvePendingInterrupts(input.sessionId, mode);
218
243
  if (resumeInterrupts.length === 0) {
219
244
  throw new Error(`No pending approval interrupt found for session "${input.sessionId}".`);
220
245
  }
221
246
  const resumeState = yield this.deps.sessions.getResumeState(input.sessionId);
222
247
  turnId = resumeState.turnId;
223
248
  initialResponse = resumeState.initialResponse;
249
+ resume = { decision: approvalDecision, interrupts: resumeInterrupts };
224
250
  }
225
251
  else {
252
+ // A pending approval leaves the provider thread with a tool call that has no
253
+ // result, so a plain new message would be rejected wholesale ("No tool output
254
+ // found for function call ..."). Sending one means the user moved on, so the
255
+ // pending call is rejected as part of this turn and the prompt rides along in
256
+ // the rejection. Reached whenever the approval UI is gone — after a page
257
+ // reload, from a second tab, or from a chat surface.
258
+ const pending = yield this.resolvePendingInterrupts(input.sessionId, mode);
226
259
  previousUserMessages = yield this.deps.sessions.getPreviousUserMessages(input.sessionId);
227
260
  turnId = yield this.deps.sessions.createNewTurn(input.sessionId, input.prompt);
228
261
  yield this.deps.sessions.touchSession(input.sessionId);
262
+ if (pending.length > 0) {
263
+ resume = { decision: "reject", interrupts: pending };
264
+ }
229
265
  }
230
266
  return {
231
267
  prompt: input.prompt,
@@ -238,9 +274,8 @@ export class RunTurnUseCase {
238
274
  emit: input.emit,
239
275
  sequenceDebugSink,
240
276
  },
241
- resume: shouldResume
242
- ? { decision: approvalDecision, interrupts: resumeInterrupts }
243
- : undefined,
277
+ freshPrompt: !shouldResume,
278
+ resume,
244
279
  initialResponse,
245
280
  };
246
281
  });
@@ -248,7 +283,7 @@ export class RunTurnUseCase {
248
283
  resolveUserLanguage(prepared) {
249
284
  return __awaiter(this, void 0, void 0, function* () {
250
285
  var _a;
251
- if (prepared.resume) {
286
+ if (!prepared.freshPrompt) {
252
287
  return (_a = this.lastDetectedLanguage.get(prepared.sessionId)) !== null && _a !== void 0 ? _a : null;
253
288
  }
254
289
  const detected = yield this.deps.llm
@@ -277,16 +312,17 @@ export class RunTurnUseCase {
277
312
  var _a, _b, _c;
278
313
  if (!this.deps.hasPersistentCheckpointer) {
279
314
  const existing = (_a = this.pendingInterrupts.get(prepared.sessionId)) !== null && _a !== void 0 ? _a : [];
280
- const merged = new Map(existing.map((item) => [item.id, item.count]));
315
+ const merged = new Map(existing.map((item) => [item.id, item]));
281
316
  for (const item of descriptors) {
282
- merged.set(item.id, item.count);
317
+ merged.set(item.id, item);
283
318
  }
284
- this.pendingInterrupts.set(prepared.sessionId, [...merged.entries()].map(([id, count]) => ({ id, count })));
319
+ this.pendingInterrupts.set(prepared.sessionId, [...merged.values()]);
285
320
  }
286
321
  yield ((_c = (_b = prepared.observability).emit) === null || _c === void 0 ? void 0 : _c.call(_b, {
287
322
  type: "interrupt",
288
323
  sessionId: prepared.sessionId,
289
324
  interrupt,
325
+ approvals: this.describeApprovals(descriptors),
290
326
  }));
291
327
  });
292
328
  }
@@ -14,7 +14,7 @@ type CreateAgentChatManagerOptions = {
14
14
  lastMessage: Ref<string>;
15
15
  activeModeName: Ref<string | null>;
16
16
  onOpenPage: (targetPath: string) => void;
17
- onToolApprovalRequest: (sessionId: string, interrupt: unknown) => void;
17
+ onToolApprovalRequest: (sessionId: string, interrupt: unknown, approvals?: unknown) => void;
18
18
  };
19
19
 
20
20
  export function createAgentChatManager({
@@ -119,7 +119,7 @@ export function createAgentChatManager({
119
119
  }
120
120
 
121
121
  if (dataPart?.type === 'data-interrupt' && typeof dataPart.data?.sessionId === 'string') {
122
- onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt);
122
+ onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt, dataPart.data.approvals);
123
123
  return;
124
124
  }
125
125
 
@@ -165,7 +165,7 @@ export function createAgentChatManager({
165
165
  }
166
166
 
167
167
  if (dataPart?.type === 'data-interrupt' && typeof dataPart.data?.sessionId === 'string') {
168
- onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt);
168
+ onToolApprovalRequest(dataPart.data.sessionId, dataPart.data.interrupt, dataPart.data.approvals);
169
169
  return;
170
170
  }
171
171
 
@@ -23,6 +23,7 @@ type CreateAgentSessionManagerOptions = {
23
23
  blockCloseOfChat: Ref<boolean>;
24
24
  adminforth: AdminforthLike;
25
25
  setCurrentChat: (sessionId: string) => void;
26
+ restorePendingToolApproval: (sessionId: string, messages: string[]) => void;
26
27
  };
27
28
 
28
29
  export function createAgentSessionManager({
@@ -39,6 +40,7 @@ export function createAgentSessionManager({
39
40
  blockCloseOfChat,
40
41
  adminforth,
41
42
  setCurrentChat,
43
+ restorePendingToolApproval,
42
44
  }: CreateAgentSessionManagerOptions) {
43
45
  function sortSessionsListByTimestamp(sessionsListToSort: ISessionsListItem[]) {
44
46
  return [...sessionsListToSort].sort((a: ISessionsListItem, b: ISessionsListItem) => b.timestamp.localeCompare(a.timestamp));
@@ -113,6 +115,12 @@ export function createAgentSessionManager({
113
115
  // The session fetch can bail out (deleted/expired session), leaving no stored
114
116
  // messages — keep `messages` an array so consumers can iterate it safely.
115
117
  currentChat.value.messages = (currentSession.value?.messages ?? []).flatMap(mapStoredMessage);
118
+ // Only the turns are stored; an approval the agent is still waiting on is replayed
119
+ // from the backend so the user can answer it after a reload instead of being stuck.
120
+ const pendingApprovals = currentSession.value?.pendingApprovals ?? [];
121
+ if (pendingApprovals.length) {
122
+ restorePendingToolApproval(sessionId, pendingApprovals);
123
+ }
116
124
  }
117
125
  }
118
126
 
@@ -123,13 +123,13 @@ export const useAgentStore = defineStore('agent', () => {
123
123
  });
124
124
  }
125
125
 
126
- function addToolApprovalMessage(sessionId: string, interrupt: unknown) {
126
+ function addToolApprovalPart(sessionId: string, messages: string[]) {
127
127
  const approvalPart = {
128
128
  type: 'data-tool-approval' as const,
129
129
  data: {
130
130
  sessionId,
131
131
  status: 'pending' as const,
132
- messages: getToolApprovalMessages(interrupt),
132
+ messages,
133
133
  },
134
134
  };
135
135
  const lastChatMessage = currentChat.value?.lastMessage;
@@ -146,6 +146,16 @@ export const useAgentStore = defineStore('agent', () => {
146
146
  });
147
147
  }
148
148
 
149
+ function addToolApprovalMessage(sessionId: string, interrupt: unknown, approvals?: unknown) {
150
+ // The backend describes each pending tool call in plain language; its raw
151
+ // descriptions are only the fallback when it could not.
152
+ const formatted = Array.isArray(approvals)
153
+ ? approvals.filter((line): line is string => typeof line === 'string' && Boolean(line))
154
+ : [];
155
+
156
+ addToolApprovalPart(sessionId, formatted.length ? formatted : getToolApprovalMessages(interrupt));
157
+ }
158
+
149
159
  async function submitToolApproval(sessionId: string, decision: 'approve' | 'reject') {
150
160
  const message = (currentChat.value?.messages ?? [])
151
161
  .findLast(candidate => candidate.role === 'assistant' && (candidate.parts ?? []).some((part: any) => {
@@ -167,6 +177,9 @@ export const useAgentStore = defineStore('agent', () => {
167
177
  try {
168
178
  await submitToolApprovalResponse(sessionId, decision);
169
179
 
180
+ if (currentSession.value?.sessionId === sessionId) {
181
+ currentSession.value.pendingApprovals = [];
182
+ }
170
183
  if (approvalPart?.data) {
171
184
  approvalPart.data.status = decision === 'approve' ? 'approved' : 'rejected';
172
185
  }
@@ -231,6 +244,7 @@ export const useAgentStore = defineStore('agent', () => {
231
244
  blockCloseOfChat,
232
245
  adminforth,
233
246
  setCurrentChat,
247
+ restorePendingToolApproval: addToolApprovalPart,
234
248
  });
235
249
 
236
250
  // "Turn active" = a request is in flight (submitted or streaming) or paused for HITL
@@ -7,25 +7,10 @@
7
7
  <div class="min-w-0 flex-1">
8
8
  <h3 class="text-sm font-semibold leading-5">{{ $t('Approval required') }}</h3>
9
9
  <p class="mt-1 text-sm leading-5 text-lightListTableText dark:text-darkListTableText">
10
- {{ $t('Review the agent message before continuing.') }}
10
+ {{ $t('Review what the agent is about to do before continuing.') }}
11
11
  </p>
12
- <button
13
- v-if="data.messages?.length"
14
- type="button"
15
- class="mt-3 inline-flex items-center gap-2 text-sm font-medium text-lightListTableHeadingText transition hover:opacity-80 dark:text-darkListTableHeadingText"
16
- @click="isExpanded = !isExpanded"
17
- >
18
- <IconChevronDownOutline
19
- class="h-4 w-4 transition-transform"
20
- :class="isExpanded ? 'rotate-180' : ''"
21
- />
22
- {{ isExpanded ? $t('Hide details') : $t('Show details') }}
23
- <span class="rounded-full bg-lightListTableText/10 px-2 py-0.5 text-xs text-lightListTableHeadingText dark:bg-darkListTableText/10 dark:text-darkListTableHeadingText">
24
- {{ data.messages.length }}
25
- </span>
26
- </button>
27
12
  <ul
28
- v-if="isExpanded && data.messages?.length"
13
+ v-if="data.messages?.length"
29
14
  class="mt-3 space-y-1 text-sm leading-5 text-lightListTableHeadingText dark:text-darkListTableHeadingText"
30
15
  >
31
16
  <li
@@ -34,7 +19,7 @@
34
19
  class="flex gap-2"
35
20
  >
36
21
  <span class="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-lightListTableHeadingText dark:bg-darkListTableHeadingText" />
37
- <span>{{ message }}</span>
22
+ <span class="min-w-0 break-words">{{ message }}</span>
38
23
  </li>
39
24
  </ul>
40
25
  <div class="mt-4 flex flex-wrap gap-2">
@@ -58,15 +43,11 @@
58
43
  </template>
59
44
  <span
60
45
  v-else
61
- class="inline-flex h-8 items-center gap-2 rounded-md border px-2.5 text-sm font-medium"
62
- :class="data.status === 'processing'
63
- ? 'border-lightListTableText/20 bg-lightListTableText/10 text-lightListTableHeadingText dark:border-darkListTableText/20 dark:bg-darkListTableText/10 dark:text-darkListTableHeadingText'
64
- : data.status === 'approved'
65
- ? 'border-lightListTableText/20 bg-lightListTableText/10 text-lightListTableHeadingText dark:border-darkListTableText/20 dark:bg-darkListTableText/10 dark:text-darkListTableHeadingText'
66
- : 'border-lightListTableText/20 bg-lightListTableText/10 text-lightListTableHeadingText dark:border-darkListTableText/20 dark:bg-darkListTableText/10 dark:text-darkListTableHeadingText'"
46
+ class="inline-flex h-8 items-center gap-2 rounded-md border border-lightListTableText/20 bg-lightListTableText/10 px-2.5 text-sm font-medium text-lightListTableHeadingText dark:border-darkListTableText/20 dark:bg-darkListTableText/10 dark:text-darkListTableHeadingText"
67
47
  >
68
- <IconCheckOutline v-if="data.status === 'approved'" class="h-4 w-4" />
69
- <IconCloseOutline v-else-if="data.status === 'rejected'" class="h-4 w-4" />
48
+ <Spinner v-if="data.status === 'processing'" class="h-4 w-4" />
49
+ <IconCheckOutline v-else-if="data.status === 'approved'" class="h-4 w-4" />
50
+ <IconCloseOutline v-else class="h-4 w-4" />
70
51
  {{ data.status === 'processing' ? $t('Processing') : data.status === 'approved' ? $t('Approved') : $t('Rejected') }}
71
52
  </span>
72
53
  </div>
@@ -84,15 +65,14 @@
84
65
  <script setup lang="ts">
85
66
  import type { IPartData } from '../types';
86
67
  import { useAgentStore } from '../composables/useAgentStore';
87
- import { ref } from 'vue';
88
- import { IconCheckOutline, IconChevronDownOutline, IconCloseOutline, IconExclamationCircleOutline } from '@iconify-prerendered/vue-flowbite';
68
+ import { Spinner } from '@/afcl';
69
+ import { IconCheckOutline, IconCloseOutline, IconExclamationCircleOutline } from '@iconify-prerendered/vue-flowbite';
89
70
 
90
71
  const props = defineProps<{
91
72
  data: IPartData;
92
73
  }>();
93
74
 
94
75
  const agentStore = useAgentStore();
95
- const isExpanded = ref(false);
96
76
 
97
77
  function submit(decision: 'approve' | 'reject') {
98
78
  if (!props.data.sessionId || props.data.status !== 'pending') {
@@ -47,6 +47,8 @@ export interface IAgentSession {
47
47
  title: string;
48
48
  timestamp: string;
49
49
  messages: IMessage[];
50
+ /** Tool calls this session is still waiting for the user to approve, in plain language. */
51
+ pendingApprovals?: string[];
50
52
  }
51
53
 
52
54
  export interface ISessionsListItem {
@@ -19,6 +19,8 @@ export type AgentEvent = {
19
19
  type: "interrupt";
20
20
  sessionId: string;
21
21
  interrupt: unknown;
22
+ /** One plain-language line per pending tool call, for the approval card. */
23
+ approvals: string[];
22
24
  } | {
23
25
  type: "steer-applied";
24
26
  count: number;
@@ -2,6 +2,7 @@ import type { AdminUser, AudioAdapter } from "adminforth";
2
2
  import type { DetectedLanguage } from "./languageDetect.js";
3
3
  import type { CurrentPageContext } from "../tools/getUserLocation.js";
4
4
  import type { AgentEventEmitter } from "./agentEvents.js";
5
+ import type { ApprovalRequest } from "../tools/approvalFormatter.js";
5
6
  /**
6
7
  * Minimal sink the application layer uses to persist per-turn debug traces. The
7
8
  * concrete implementation (the sequence-debug collector) lives in the llm layer;
@@ -15,7 +16,10 @@ export type DebugSink = {
15
16
  /** A pending human-in-the-loop approval, normalized (provider-agnostic). */
16
17
  export type PendingInterrupt = {
17
18
  id: string;
19
+ /** Number of decisions the provider expects on resume (one per pending tool call). */
18
20
  count: number;
21
+ /** The pending tool calls, normalized so they can be described to the user. */
22
+ requests: ApprovalRequest[];
19
23
  };
20
24
  export type BaseAgentTurnInput = {
21
25
  prompt: string;
package/dist/index.js CHANGED
@@ -161,6 +161,7 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
161
161
  }),
162
162
  runAndPersistAgentResponse: this.runTurnUseCase.runAndPersistAgentResponse.bind(this.runTurnUseCase),
163
163
  getSessionTurns: this.sessionStore.getSessionTurns.bind(this.sessionStore),
164
+ getPendingApprovals: (sessionId) => this.runTurnUseCase.getPendingApprovals(sessionId),
164
165
  createNewTurn: this.sessionStore.createNewTurn.bind(this.sessionStore),
165
166
  createSystemTurn: this.sessionStore.createSystemTurn.bind(this.sessionStore),
166
167
  appendSteerToCurrentTurn: this.sessionStore.appendSteerToCurrentTurn.bind(this.sessionStore),
@@ -1,7 +1,8 @@
1
1
  import type { AgentStreamChunk, PendingInterrupt } from "../domain/turnTypes.js";
2
2
  /**
3
- * Normalize raw LangGraph interrupt object(s) into provider-agnostic descriptors.
4
- * Kept in the llm layer so the LangGraph interrupt shape never reaches the app.
3
+ * Normalize raw LangGraph interrupt object(s) into provider-agnostic descriptors, each
4
+ * carrying the tool calls it is waiting on. Kept in the llm layer so the LangGraph
5
+ * interrupt shape never reaches the app.
5
6
  */
6
7
  export declare function normalizeInterrupts(interrupt: unknown): PendingInterrupt[];
7
8
  type RawStreamEntry = ["messages", [any, any]] | ["updates", Record<string, any>];
@@ -25,8 +25,22 @@ function getInterruptItems(interrupt) {
25
25
  return Array.isArray(interrupt) ? interrupt : [interrupt];
26
26
  }
27
27
  /**
28
- * Normalize raw LangGraph interrupt object(s) into provider-agnostic descriptors.
29
- * Kept in the llm layer so the LangGraph interrupt shape never reaches the app.
28
+ * Pull the tool calls out of one interrupt's action requests so they can be described
29
+ * to the user. Drops entries the provider did not name.
30
+ */
31
+ function toApprovalRequests(actionRequests) {
32
+ return actionRequests.flatMap((actionRequest) => {
33
+ var _a, _b;
34
+ const toolName = (_a = actionRequest === null || actionRequest === void 0 ? void 0 : actionRequest.name) !== null && _a !== void 0 ? _a : actionRequest === null || actionRequest === void 0 ? void 0 : actionRequest.action;
35
+ return typeof toolName === "string" && toolName
36
+ ? [{ toolName, args: ((_b = actionRequest === null || actionRequest === void 0 ? void 0 : actionRequest.args) !== null && _b !== void 0 ? _b : {}) }]
37
+ : [];
38
+ });
39
+ }
40
+ /**
41
+ * Normalize raw LangGraph interrupt object(s) into provider-agnostic descriptors, each
42
+ * carrying the tool calls it is waiting on. Kept in the llm layer so the LangGraph
43
+ * interrupt shape never reaches the app.
30
44
  */
31
45
  export function normalizeInterrupts(interrupt) {
32
46
  return getInterruptItems(interrupt).flatMap((item) => {
@@ -40,7 +54,11 @@ export function normalizeInterrupts(interrupt) {
40
54
  ? item.id
41
55
  : undefined;
42
56
  return typeof interruptId === "string" && Array.isArray(actionRequests)
43
- ? [{ id: interruptId, count: actionRequests.length }]
57
+ ? [{
58
+ id: interruptId,
59
+ count: actionRequests.length,
60
+ requests: toApprovalRequests(actionRequests),
61
+ }]
44
62
  : [];
45
63
  });
46
64
  }
@@ -62,7 +80,11 @@ export function parseRawStreamChunk(entry) {
62
80
  if (mode === "updates") {
63
81
  if (chunk && typeof chunk === "object" && "__interrupt__" in chunk) {
64
82
  const interrupt = chunk.__interrupt__;
65
- return [{ kind: "interrupt", interrupt, descriptors: normalizeInterrupts(interrupt) }];
83
+ return [{
84
+ kind: "interrupt",
85
+ interrupt,
86
+ descriptors: normalizeInterrupts(interrupt),
87
+ }];
66
88
  }
67
89
  return [];
68
90
  }
@@ -0,0 +1,12 @@
1
+ import type { IAdminForth } from "adminforth";
2
+ /** One tool call awaiting approval, normalized out of the provider interrupt payload. */
3
+ export type ApprovalRequest = {
4
+ toolName: string;
5
+ args: Record<string, unknown>;
6
+ };
7
+ /**
8
+ * Describe a tool call awaiting approval in a sentence an admin (not a developer) can
9
+ * act on — resource and column labels instead of ids, values instead of raw JSON.
10
+ * Falls back to a key/value dump for tool calls it does not recognize.
11
+ */
12
+ export declare function formatApprovalRequest(adminforth: IAdminForth, request: ApprovalRequest): string;
@@ -0,0 +1,104 @@
1
+ /** Argument keys that address the call itself rather than the data being changed. */
2
+ const NON_FIELD_KEYS = new Set(["resourceId", "recordId", "primaryKey", "requiredColumnsToSkip", "meta", "extra"]);
3
+ const MAX_VALUE_LENGTH = 80;
4
+ const MAX_FIELDS = 12;
5
+ function truncate(text, limit = MAX_VALUE_LENGTH) {
6
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
7
+ }
8
+ function findResource(adminforth, resourceId) {
9
+ var _a, _b;
10
+ return typeof resourceId === "string"
11
+ ? ((_b = (_a = adminforth.config) === null || _a === void 0 ? void 0 : _a.resources) !== null && _b !== void 0 ? _b : []).find((resource) => resource.resourceId === resourceId)
12
+ : undefined;
13
+ }
14
+ function resourceLabel(resource, args) {
15
+ var _a;
16
+ return (_a = resource === null || resource === void 0 ? void 0 : resource.label) !== null && _a !== void 0 ? _a : (typeof args.resourceId === "string" ? args.resourceId : "resource");
17
+ }
18
+ /** `first_name` / `firstName` -> `First name`, for keys the resource does not declare. */
19
+ function humanize(name) {
20
+ const spaced = name.replace(/[_-]+/g, " ").replace(/([a-z\d])([A-Z])/g, "$1 $2").trim();
21
+ return spaced ? `${spaced[0].toUpperCase()}${spaced.slice(1).toLowerCase()}` : name;
22
+ }
23
+ function formatValue(value, column) {
24
+ var _a;
25
+ if (value === undefined || value === null || value === "") {
26
+ return "empty";
27
+ }
28
+ if ((column === null || column === void 0 ? void 0 : column.masked) || (column === null || column === void 0 ? void 0 : column.backendOnly)) {
29
+ return "••••••";
30
+ }
31
+ if (typeof value === "boolean") {
32
+ return value ? "Yes" : "No";
33
+ }
34
+ const enumLabel = Array.isArray(column === null || column === void 0 ? void 0 : column.enum)
35
+ ? (_a = column.enum.find((item) => item.value === value)) === null || _a === void 0 ? void 0 : _a.label
36
+ : undefined;
37
+ if (enumLabel) {
38
+ return truncate(String(enumLabel));
39
+ }
40
+ if (Array.isArray(value)) {
41
+ return truncate(value.map((item) => formatValue(item)).join(", "));
42
+ }
43
+ if (typeof value === "object") {
44
+ return truncate(JSON.stringify(value));
45
+ }
46
+ return truncate(String(value));
47
+ }
48
+ /** `Brand: Tesla, Price: 45000` — the record payload in the resource's own wording. */
49
+ function formatRecord(record, resource) {
50
+ var _a;
51
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
52
+ return "";
53
+ }
54
+ const columnsByName = new Map(((_a = resource === null || resource === void 0 ? void 0 : resource.columns) !== null && _a !== void 0 ? _a : []).map((column) => [column.name, column]));
55
+ const entries = Object.entries(record)
56
+ .filter(([key]) => !NON_FIELD_KEYS.has(key));
57
+ const shown = entries.slice(0, MAX_FIELDS).map(([name, value]) => {
58
+ const column = columnsByName.get(name);
59
+ return `${(column === null || column === void 0 ? void 0 : column.label) || humanize(name)}: ${formatValue(value, column)}`;
60
+ });
61
+ const hidden = entries.length - shown.length;
62
+ return hidden > 0 ? `${shown.join(", ")} and ${hidden} more` : shown.join(", ");
63
+ }
64
+ function actionLabel(resource, actionId) {
65
+ var _a, _b;
66
+ if (typeof actionId !== "string" || !actionId) {
67
+ return "action";
68
+ }
69
+ const action = ((_b = (_a = resource === null || resource === void 0 ? void 0 : resource.options) === null || _a === void 0 ? void 0 : _a.actions) !== null && _b !== void 0 ? _b : []).find((candidate) => candidate.id === actionId);
70
+ return `"${(action === null || action === void 0 ? void 0 : action.name) || humanize(actionId)}"`;
71
+ }
72
+ /**
73
+ * Describe a tool call awaiting approval in a sentence an admin (not a developer) can
74
+ * act on — resource and column labels instead of ids, values instead of raw JSON.
75
+ * Falls back to a key/value dump for tool calls it does not recognize.
76
+ */
77
+ export function formatApprovalRequest(adminforth, request) {
78
+ const { args, toolName } = request;
79
+ const resource = findResource(adminforth, args.resourceId);
80
+ const label = resourceLabel(resource, args);
81
+ switch (toolName) {
82
+ case "create_record": {
83
+ const fields = formatRecord(args.record, resource);
84
+ return `Create a new record in ${label}${fields ? ` — ${fields}` : ""}`;
85
+ }
86
+ case "update_record": {
87
+ const fields = formatRecord(args.record, resource);
88
+ return `Update record with id:${formatValue(args.recordId)} in ${label}${fields ? ` — set ${fields}` : ""}`;
89
+ }
90
+ case "delete_record":
91
+ return `Delete record with id:${formatValue(args.primaryKey)} from ${label} — this cannot be undone`;
92
+ case "start_custom_action":
93
+ return `Run ${actionLabel(resource, args.actionId)} on record ${formatValue(args.recordId)} in ${label}`;
94
+ case "start_custom_bulk_action":
95
+ case "start_bulk_action": {
96
+ const count = Array.isArray(args.recordIds) ? args.recordIds.length : 0;
97
+ return `Run ${actionLabel(resource, args.actionId)} on ${count} record(s) in ${label}`;
98
+ }
99
+ default: {
100
+ const fields = formatRecord(args, resource);
101
+ return `${humanize(toolName)}${fields ? ` — ${fields}` : ""}`;
102
+ }
103
+ }
104
+ }
@@ -22,11 +22,12 @@ export type AgentEndpointsContext = {
22
22
  }>;
23
23
  runAndPersistAgentResponse(input: RunAndPersistAgentResponseInput): Promise<RunAndPersistAgentResponseResult>;
24
24
  getSessionTurns(sessionId: string): Promise<SessionTurn[]>;
25
+ getPendingApprovals(sessionId: string): Promise<string[]>;
25
26
  createNewTurn(sessionId: string, prompt: string, response?: string): Promise<string>;
26
27
  createSystemTurn(sessionId: string, systemMessage: string): Promise<string>;
27
28
  appendSteerToCurrentTurn(sessionId: string, steerText: string): Promise<void>;
28
29
  handleChatSurfaceMessage(adapter: ChatSurfaceAdapter, incoming: ChatSurfaceIncomingMessage, sink: ChatSurfaceEventSink, request: IAdminForthEndpointHandlerInput): Promise<void>;
29
30
  };
30
31
  export type CoreEndpointsContext = Pick<AgentEndpointsContext, "options" | "handleTurn" | "handleEditTurn" | "handleSpeechTurn" | "steer">;
31
- export type SessionEndpointsContext = Pick<AgentEndpointsContext, "adminforth" | "options" | "getSessionTurns" | "createNewTurn" | "createSystemTurn" | "appendSteerToCurrentTurn">;
32
+ export type SessionEndpointsContext = Pick<AgentEndpointsContext, "adminforth" | "options" | "getSessionTurns" | "getPendingApprovals" | "createNewTurn" | "createSystemTurn" | "appendSteerToCurrentTurn">;
32
33
  export type ChatSurfaceEndpointsContext = Pick<AgentEndpointsContext, "adminforth" | "options" | "handleChatSurfaceMessage">;
@@ -69,12 +69,18 @@ export function setupSessionEndpoints(ctx, server) {
69
69
  error: 'Unauthorized'
70
70
  };
71
71
  }
72
- const turns = yield ctx.getSessionTurns(sessionId);
72
+ const [turns, pendingApprovals] = yield Promise.all([
73
+ ctx.getSessionTurns(sessionId),
74
+ // The approval prompt lives only in the browser, so a reload has to rebuild it
75
+ // from the agent's persisted state — otherwise the user cannot answer it.
76
+ ctx.getPendingApprovals(sessionId),
77
+ ]);
73
78
  return {
74
79
  session: {
75
80
  sessionId,
76
81
  title: session[ctx.options.sessionResource.titleField],
77
82
  timestamp: session[ctx.options.sessionResource.createdAtField],
83
+ pendingApprovals,
78
84
  messages: turns.flatMap(turn => {
79
85
  const messages = [];
80
86
  if (turn.prompt === AGENT_SYSTEM_TURN_PROMPT) {
@@ -88,13 +88,14 @@ function createAgentEventStream(res, options = {}) {
88
88
  },
89
89
  });
90
90
  },
91
- interrupt(sessionId, interrupt) {
91
+ interrupt(sessionId, interrupt, approvals) {
92
92
  stream.endActiveBlock();
93
93
  stream.send({
94
94
  type: isAiUiMessageStream ? "data-interrupt" : "interrupt",
95
95
  data: {
96
96
  sessionId,
97
97
  interrupt,
98
+ approvals,
98
99
  },
99
100
  });
100
101
  },
@@ -238,7 +239,7 @@ export function createSseEventEmitter(res, options = {}) {
238
239
  stream.rendering(event.phase, event.label);
239
240
  break;
240
241
  case "interrupt":
241
- stream.interrupt(event.sessionId, event.interrupt);
242
+ stream.interrupt(event.sessionId, event.interrupt, event.approvals);
242
243
  break;
243
244
  case "steer-applied":
244
245
  stream.steerApplied(event.count, event.ids);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/agent",
3
- "version": "2.7.5",
3
+ "version": "2.8.1",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",