@adminforth/agent 2.7.5 → 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 +156 -11
- package/dist/application/runTurnUseCase.js +9 -5
- package/dist/custom/composables/agentStore/useAgentChat.ts +3 -3
- package/dist/custom/composables/useAgentStore.ts +7 -2
- package/dist/custom/conversation_area/ToolApprovalRenderer.vue +9 -29
- package/dist/domain/agentEvents.d.ts +2 -0
- package/dist/domain/turnTypes.d.ts +3 -0
- package/dist/llm/streamAdapter.d.ts +7 -0
- package/dist/llm/streamAdapter.js +31 -1
- package/dist/tools/approvalFormatter.d.ts +12 -0
- package/dist/tools/approvalFormatter.js +104 -0
- package/dist/transport/sse/sseWriter.js +3 -2
- package/package.json +1 -1
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`, `
|
|
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
|
-
|
|
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`
|
|
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).
|
|
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
|
|
325
|
-
format (`x-vercel-ai-ui-message-stream: v1`); the frontend consumes them
|
|
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
|
-
|
|
331
|
-
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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="
|
|
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
|
-
<
|
|
69
|
-
<
|
|
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 {
|
|
88
|
-
import { IconCheckOutline,
|
|
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') {
|
|
@@ -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
|
};
|
|
@@ -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 [{
|
|
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
|
+
}
|
|
@@ -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);
|