@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 +156 -11
- package/dist/application/ports.d.ts +4 -3
- package/dist/application/runTurnUseCase.d.ts +9 -0
- package/dist/application/runTurnUseCase.js +45 -9
- package/dist/custom/composables/agentStore/useAgentChat.ts +3 -3
- package/dist/custom/composables/agentStore/useAgentSessions.ts +8 -0
- package/dist/custom/composables/useAgentStore.ts +16 -2
- package/dist/custom/conversation_area/ToolApprovalRenderer.vue +9 -29
- package/dist/custom/types.ts +2 -0
- package/dist/domain/agentEvents.d.ts +2 -0
- package/dist/domain/turnTypes.d.ts +4 -0
- package/dist/index.js +1 -0
- package/dist/llm/streamAdapter.d.ts +3 -2
- package/dist/llm/streamAdapter.js +26 -4
- package/dist/tools/approvalFormatter.d.ts +12 -0
- package/dist/tools/approvalFormatter.js +104 -0
- package/dist/transport/http/context.d.ts +2 -1
- package/dist/transport/http/sessionEndpoints.js +7 -1
- 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
|
|
@@ -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
|
|
61
|
-
* interrupt shape never leaves the llm
|
|
62
|
-
* failure (the caller must not treat that as
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
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.
|
|
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
|
|
315
|
+
const merged = new Map(existing.map((item) => [item.id, item]));
|
|
281
316
|
for (const item of descriptors) {
|
|
282
|
-
merged.set(item.id, item
|
|
317
|
+
merged.set(item.id, item);
|
|
283
318
|
}
|
|
284
|
-
this.pendingInterrupts.set(prepared.sessionId, [...merged.
|
|
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
|
|
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
|
|
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
|
|
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') {
|
package/dist/custom/types.ts
CHANGED
|
@@ -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 {
|
|
@@ -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
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
-
? [{
|
|
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 [{
|
|
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
|
|
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);
|