@adminforth/agent 2.7.4 → 2.8.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/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
@@ -3,7 +3,7 @@ import type { AgentSessionStore } from "../persistence/sessionStore.js";
3
3
  import type { SteerBuffer } from "../domain/steerBuffer.js";
4
4
  import type { PluginOptions } from "../types.js";
5
5
  import type { LlmPort } from "./ports.js";
6
- import type { DebugSink, HandleTurnInput, HandleEditTurnInput, PendingInterrupt, RunAndPersistAgentResponseInput, RunAndPersistAgentResponseResult } from "../domain/turnTypes.js";
6
+ import type { BaseAgentTurnInput, DebugSink, HandleTurnInput, HandleEditTurnInput, PendingInterrupt, RunAndPersistAgentResponseInput, RunAndPersistAgentResponseResult } from "../domain/turnTypes.js";
7
7
  /**
8
8
  * Build the provider-agnostic resume payload for a human-in-the-loop turn.
9
9
  * Exported for unit testing; the LLM port wraps it into a LangGraph Command.
@@ -72,7 +72,7 @@ export declare class RunTurnUseCase {
72
72
  private resolvePendingInterrupts;
73
73
  private requiresSessionOwnership;
74
74
  /** Verify the session exists and belongs to the requesting admin user. */
75
- private assertSessionOwnership;
75
+ assertSessionOwnership(sessionId: string, adminUser: BaseAgentTurnInput["adminUser"]): Promise<void>;
76
76
  /**
77
77
  * Prepare an edit/branch turn: resolve the fork checkpoint from the previous turn,
78
78
  * truncate the conversation after the edited turn, and reuse the edited turn's id
@@ -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")
@@ -272,7 +273,7 @@ export class RunTurnUseCase {
272
273
  buildMessages(prepared) {
273
274
  return [{ role: "user", content: prepared.prompt }];
274
275
  }
275
- handleInterrupt(prepared, interrupt, descriptors) {
276
+ handleInterrupt(prepared, interrupt, descriptors, requests) {
276
277
  return __awaiter(this, void 0, void 0, function* () {
277
278
  var _a, _b, _c;
278
279
  if (!this.deps.hasPersistentCheckpointer) {
@@ -283,16 +284,19 @@ export class RunTurnUseCase {
283
284
  }
284
285
  this.pendingInterrupts.set(prepared.sessionId, [...merged.entries()].map(([id, count]) => ({ id, count })));
285
286
  }
287
+ const adminforth = this.deps.getAdminforth();
286
288
  yield ((_c = (_b = prepared.observability).emit) === null || _c === void 0 ? void 0 : _c.call(_b, {
287
289
  type: "interrupt",
288
290
  sessionId: prepared.sessionId,
289
291
  interrupt,
292
+ approvals: requests.map((request) => formatApprovalRequest(adminforth, request)),
290
293
  }));
291
294
  });
292
295
  }
293
296
  runAgentTurn(prepared) {
294
297
  return __awaiter(this, void 0, void 0, function* () {
295
298
  var _a, e_1, _b, _c;
299
+ var _d;
296
300
  const userLanguage = yield this.resolveUserLanguage(prepared);
297
301
  const streamInput = prepared.resume
298
302
  ? {
@@ -318,16 +322,16 @@ export class RunTurnUseCase {
318
322
  let interrupted = false;
319
323
  try {
320
324
  try {
321
- for (var _d = true, stream_1 = __asyncValues(stream), stream_1_1; stream_1_1 = yield stream_1.next(), _a = stream_1_1.done, !_a; _d = true) {
325
+ for (var _e = true, stream_1 = __asyncValues(stream), stream_1_1; stream_1_1 = yield stream_1.next(), _a = stream_1_1.done, !_a; _e = true) {
322
326
  _c = stream_1_1.value;
323
- _d = false;
327
+ _e = false;
324
328
  const chunk = _c;
325
329
  if (abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) {
326
330
  throw new DOMException("This operation was aborted", "AbortError");
327
331
  }
328
332
  if (chunk.kind === "interrupt") {
329
333
  interrupted = true;
330
- yield this.handleInterrupt(prepared, chunk.interrupt, chunk.descriptors);
334
+ yield this.handleInterrupt(prepared, chunk.interrupt, chunk.descriptors, (_d = chunk.requests) !== null && _d !== void 0 ? _d : []);
331
335
  continue;
332
336
  }
333
337
  if (chunk.kind === "reasoning") {
@@ -345,7 +349,7 @@ export class RunTurnUseCase {
345
349
  catch (e_1_1) { e_1 = { error: e_1_1 }; }
346
350
  finally {
347
351
  try {
348
- if (!_d && !_a && (_b = stream_1.return)) yield _b.call(stream_1);
352
+ if (!_e && !_a && (_b = stream_1.return)) yield _b.call(stream_1);
349
353
  }
350
354
  finally { if (e_1) throw e_1.error; }
351
355
  }
@@ -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
 
@@ -123,13 +123,18 @@ export const useAgentStore = defineStore('agent', () => {
123
123
  });
124
124
  }
125
125
 
126
- function addToolApprovalMessage(sessionId: string, interrupt: unknown) {
126
+ function addToolApprovalMessage(sessionId: string, interrupt: unknown, approvals?: unknown) {
127
+ // The backend describes each pending tool call in plain language; its raw
128
+ // descriptions are only the fallback when it could not.
129
+ const formatted = Array.isArray(approvals)
130
+ ? approvals.filter((line): line is string => typeof line === 'string' && Boolean(line))
131
+ : [];
127
132
  const approvalPart = {
128
133
  type: 'data-tool-approval' as const,
129
134
  data: {
130
135
  sessionId,
131
136
  status: 'pending' as const,
132
- messages: getToolApprovalMessages(interrupt),
137
+ messages: formatted.length ? formatted : getToolApprovalMessages(interrupt),
133
138
  },
134
139
  };
135
140
  const lastChatMessage = currentChat.value?.lastMessage;
@@ -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') {
@@ -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;
@@ -97,4 +98,6 @@ export type AgentStreamChunk = {
97
98
  kind: "interrupt";
98
99
  interrupt: unknown;
99
100
  descriptors: PendingInterrupt[];
101
+ /** The pending tool calls, normalized so they can be described to the user. */
102
+ requests: ApprovalRequest[];
100
103
  };
package/dist/index.js CHANGED
@@ -154,10 +154,11 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
154
154
  handleTurn: this.runTurnUseCase.handleTurn.bind(this.runTurnUseCase),
155
155
  handleEditTurn: this.runTurnUseCase.handleEditTurn.bind(this.runTurnUseCase),
156
156
  handleSpeechTurn: this.speechTurnService.handle.bind(this.speechTurnService),
157
- steer: ({ sessionId, message }) => {
157
+ steer: (_a) => __awaiter(this, [_a], void 0, function* ({ sessionId, message, adminUser }) {
158
+ yield this.runTurnUseCase.assertSessionOwnership(sessionId, adminUser);
158
159
  const { id } = this.steerBuffer.add(sessionId, message);
159
160
  return { id, queued: this.steerBuffer.size(sessionId) };
160
- },
161
+ }),
161
162
  runAndPersistAgentResponse: this.runTurnUseCase.runAndPersistAgentResponse.bind(this.runTurnUseCase),
162
163
  getSessionTurns: this.sessionStore.getSessionTurns.bind(this.sessionStore),
163
164
  createNewTurn: this.sessionStore.createNewTurn.bind(this.sessionStore),
@@ -1,9 +1,16 @@
1
+ import type { ApprovalRequest } from "../tools/approvalFormatter.js";
1
2
  import type { AgentStreamChunk, PendingInterrupt } from "../domain/turnTypes.js";
2
3
  /**
3
4
  * Normalize raw LangGraph interrupt object(s) into provider-agnostic descriptors.
4
5
  * Kept in the llm layer so the LangGraph interrupt shape never reaches the app.
5
6
  */
6
7
  export declare function normalizeInterrupts(interrupt: unknown): PendingInterrupt[];
8
+ /**
9
+ * Pull the tool calls out of the raw interrupt so the application layer can describe
10
+ * them to the user. Like {@link normalizeInterrupts}, this keeps the LangGraph shape
11
+ * from leaking past the llm layer.
12
+ */
13
+ export declare function extractApprovalRequests(interrupt: unknown): ApprovalRequest[];
7
14
  type RawStreamEntry = ["messages", [any, any]] | ["updates", Record<string, any>];
8
15
  /**
9
16
  * Convert one raw LangGraph stream entry into zero or more normalized chunks.
@@ -44,6 +44,31 @@ export function normalizeInterrupts(interrupt) {
44
44
  : [];
45
45
  });
46
46
  }
47
+ /**
48
+ * Pull the tool calls out of the raw interrupt so the application layer can describe
49
+ * them to the user. Like {@link normalizeInterrupts}, this keeps the LangGraph shape
50
+ * from leaking past the llm layer.
51
+ */
52
+ export function extractApprovalRequests(interrupt) {
53
+ return getInterruptItems(interrupt).flatMap((item) => {
54
+ const value = item && typeof item === "object" && "value" in item
55
+ ? item.value
56
+ : item;
57
+ const actionRequests = value && typeof value === "object"
58
+ ? value.actionRequests
59
+ : undefined;
60
+ if (!Array.isArray(actionRequests)) {
61
+ return [];
62
+ }
63
+ return actionRequests.flatMap((actionRequest) => {
64
+ var _a, _b;
65
+ 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;
66
+ return typeof toolName === "string" && toolName
67
+ ? [{ toolName, args: ((_b = actionRequest === null || actionRequest === void 0 ? void 0 : actionRequest.args) !== null && _b !== void 0 ? _b : {}) }]
68
+ : [];
69
+ });
70
+ });
71
+ }
47
72
  function extractBlocks(token) {
48
73
  if (Array.isArray(token === null || token === void 0 ? void 0 : token.contentBlocks)) {
49
74
  return token.contentBlocks;
@@ -62,7 +87,12 @@ export function parseRawStreamChunk(entry) {
62
87
  if (mode === "updates") {
63
88
  if (chunk && typeof chunk === "object" && "__interrupt__" in chunk) {
64
89
  const interrupt = chunk.__interrupt__;
65
- return [{ kind: "interrupt", interrupt, descriptors: normalizeInterrupts(interrupt) }];
90
+ return [{
91
+ kind: "interrupt",
92
+ interrupt,
93
+ descriptors: normalizeInterrupts(interrupt),
94
+ requests: extractApprovalRequests(interrupt),
95
+ }];
66
96
  }
67
97
  return [];
68
98
  }
@@ -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
+ }
@@ -15,10 +15,11 @@ export type AgentEndpointsContext = {
15
15
  steer(input: {
16
16
  sessionId: string;
17
17
  message: string;
18
- }): {
18
+ adminUser: any;
19
+ }): Promise<{
19
20
  id: string;
20
21
  queued: number;
21
- };
22
+ }>;
22
23
  runAndPersistAgentResponse(input: RunAndPersistAgentResponseInput): Promise<RunAndPersistAgentResponseResult>;
23
24
  getSessionTurns(sessionId: string): Promise<SessionTurn[]>;
24
25
  createNewTurn(sessionId: string, prompt: string, response?: string): Promise<string>;
@@ -167,14 +167,15 @@ export function setupCoreEndpoints(ctx, server) {
167
167
  method: 'POST',
168
168
  path: `/agent/steer`,
169
169
  request_schema: agentSteerBodySchema,
170
- handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body }) {
170
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser }) {
171
171
  const data = body;
172
172
  // Fire-and-forget: the steer is buffered and folded into the running turn by the
173
173
  // beforeModel steer middleware, which signals `data-steer-applied` on that turn's
174
174
  // SSE stream once consumed. This endpoint only confirms it was received/queued.
175
- const { id, queued } = ctx.steer({
175
+ const { id, queued } = yield ctx.steer({
176
176
  sessionId: data.sessionId,
177
177
  message: data.message,
178
+ adminUser: adminUser
178
179
  });
179
180
  return { ok: true, id, queued };
180
181
  })
@@ -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.4",
3
+ "version": "2.8.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",