@artooi/ag-ui-web-component 0.10.0 → 0.12.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/CHANGELOG.md +78 -1
- package/README.md +144 -7
- package/dist/ag-ui-web-component.bundle.js +237 -40
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +29 -0
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +28 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/run_index.d.ts +50 -0
- package/dist/core/run_index.d.ts.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +942 -101
- package/dist/index.js.map +4 -4
- package/dist/ui/approval_card.d.ts +51 -0
- package/dist/ui/approval_card.d.ts.map +1 -0
- package/dist/ui/attachment_chips.d.ts.map +1 -1
- package/dist/ui/attachment_tray.d.ts.map +1 -1
- package/dist/ui/checkpoint_menu.d.ts +32 -0
- package/dist/ui/checkpoint_menu.d.ts.map +1 -0
- package/dist/ui/question_card.d.ts +52 -0
- package/dist/ui/question_card.d.ts.map +1 -0
- package/dist/ui/skills_menu.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thoughts_block.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +26 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/core/ag_ui_chat.ts +263 -4
- package/src/core/agui_client.ts +86 -11
- package/src/core/run_index.ts +91 -0
- package/src/index.ts +16 -0
- package/src/ui/approval_card.ts +119 -0
- package/src/ui/attachment_chips.ts +5 -0
- package/src/ui/attachment_tray.ts +8 -0
- package/src/ui/checkpoint_menu.ts +153 -0
- package/src/ui/question_card.ts +216 -0
- package/src/ui/skills_menu.ts +6 -0
- package/src/ui/styles.ts +197 -0
- package/src/ui/thoughts_block.ts +1 -0
- package/src/ui/thread_drawer.ts +11 -0
- package/src/ui/ui_strings.ts +45 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Context, Message, Tool } from "@ag-ui/core";
|
|
1
|
+
import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
4
4
|
MESSAGE_ROLE,
|
|
@@ -20,10 +20,21 @@ import { createPageMapContext, type PageMap } from "../tools/page_map.js";
|
|
|
20
20
|
import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
|
|
21
21
|
import { createRouteTools, type RouteMap } from "../tools/route_map.js";
|
|
22
22
|
import { createStateHookTools, type StateHook } from "../tools/state_hook.js";
|
|
23
|
+
import {
|
|
24
|
+
type ApprovalRenderer,
|
|
25
|
+
type ApprovalRequest,
|
|
26
|
+
requestApproval,
|
|
27
|
+
} from "../ui/approval_card.js";
|
|
23
28
|
import { renderAttachmentChips } from "../ui/attachment_chips.js";
|
|
24
29
|
import { AttachmentTray } from "../ui/attachment_tray.js";
|
|
30
|
+
import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
|
|
25
31
|
import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
|
|
26
32
|
import { prettifyToolName } from "../ui/prettify_tool_name.js";
|
|
33
|
+
import {
|
|
34
|
+
type QuestionRenderer,
|
|
35
|
+
type QuestionRequest,
|
|
36
|
+
requestQuestion,
|
|
37
|
+
} from "../ui/question_card.js";
|
|
27
38
|
import { renderMarkdown } from "../ui/render_markdown.js";
|
|
28
39
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
29
40
|
import { SkillsMenu } from "../ui/skills_menu.js";
|
|
@@ -37,6 +48,7 @@ import {
|
|
|
37
48
|
AgUiClient,
|
|
38
49
|
type AgUiClientHandlers,
|
|
39
50
|
type AgUiToolCall,
|
|
51
|
+
type InterruptResponse,
|
|
40
52
|
type ToolExecution,
|
|
41
53
|
} from "./agui_client.js";
|
|
42
54
|
import { type AttachmentRef, messageAttachments } from "./attachment.js";
|
|
@@ -47,6 +59,7 @@ import {
|
|
|
47
59
|
} from "./conversation_store.js";
|
|
48
60
|
import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
|
|
49
61
|
import { RemoteConversationStore } from "./remote_conversation_store.js";
|
|
62
|
+
import { RunIndex } from "./run_index.js";
|
|
50
63
|
import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
|
|
51
64
|
import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
|
|
52
65
|
|
|
@@ -101,6 +114,36 @@ export class AgUiChat extends HTMLElement {
|
|
|
101
114
|
/** When true, destructive tools execute without a confirmation modal. */
|
|
102
115
|
autoConfirm = false;
|
|
103
116
|
|
|
117
|
+
/**
|
|
118
|
+
* When true, the built-in `ask_user` frontend tool is offered to the agent:
|
|
119
|
+
* calling it renders an inline question card (radio choices and/or a free-text
|
|
120
|
+
* field) and returns the user's answer. Off by default — like the other
|
|
121
|
+
* built-in tool groups (route / page-action), it is opt-in so it doesn't
|
|
122
|
+
* change the advertised catalog until a host asks for it.
|
|
123
|
+
*/
|
|
124
|
+
askUser = false;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Optional full replacement for the `ask_user` question UI. When set, calling
|
|
128
|
+
* `ask_user` invokes this instead of the built-in inline card: the host
|
|
129
|
+
* renders whatever it likes (a native modal, a framework component, …) and
|
|
130
|
+
* resolves with the answer. Unset (default) uses the built-in
|
|
131
|
+
* {@link requestQuestion} card — style that via the `strings` override and the
|
|
132
|
+
* `question*` CSS `::part()`s. Requires {@link askUser} to be enabled.
|
|
133
|
+
*/
|
|
134
|
+
askUserRenderer: QuestionRenderer | null = null;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Optional full replacement for the server-side-tool approval UI. When set, an
|
|
138
|
+
* approval interrupt invokes this instead of the built-in inline approval
|
|
139
|
+
* card: the host renders whatever it likes and resolves `true` to approve /
|
|
140
|
+
* `false` to deny. Unset (default) uses the built-in {@link requestApproval}
|
|
141
|
+
* card — style that via the `strings` override and the `approval*` CSS
|
|
142
|
+
* `::part()`s. The gate itself is enabled server-side; this only changes how
|
|
143
|
+
* the decision is collected.
|
|
144
|
+
*/
|
|
145
|
+
approvalRenderer: ApprovalRenderer | null = null;
|
|
146
|
+
|
|
104
147
|
/**
|
|
105
148
|
* Optional per-call confirmation predicate. When set, it is authoritative:
|
|
106
149
|
* given a tool name + args it decides whether *this* call needs confirmation
|
|
@@ -254,6 +297,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
254
297
|
readonly #title: HTMLSpanElement;
|
|
255
298
|
readonly #skillsMenu: SkillsMenu;
|
|
256
299
|
readonly #drawer: ThreadDrawer;
|
|
300
|
+
/** Checkpoint panel; rows load only when `data-runs-url` is set. */
|
|
301
|
+
readonly #checkpoints: CheckpointMenu;
|
|
302
|
+
/** Built lazily from `data-runs-url`; `null` when the host didn't opt in. */
|
|
303
|
+
#runIndex: RunIndex | null = null;
|
|
257
304
|
readonly #skillHint: HTMLDivElement;
|
|
258
305
|
/** File-picker button + hidden input + tray slot; the tray mounts on connect. */
|
|
259
306
|
readonly #attachButton: HTMLButtonElement;
|
|
@@ -346,6 +393,73 @@ export class AgUiChat extends HTMLElement {
|
|
|
346
393
|
this.#deleteThread(threadId);
|
|
347
394
|
},
|
|
348
395
|
});
|
|
396
|
+
this.#checkpoints = new CheckpointMenu((runId, verb) => {
|
|
397
|
+
void this.#continueRun(runId, verb);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The run index, built once from `data-runs-url`; `null` when unset. */
|
|
402
|
+
#runs(): RunIndex | null {
|
|
403
|
+
const url = this.getAttribute("data-runs-url");
|
|
404
|
+
if (url === null || url === "") {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
if (this.#runIndex === null) {
|
|
408
|
+
this.#runIndex = new RunIndex(url, () => this.headers);
|
|
409
|
+
}
|
|
410
|
+
return this.#runIndex;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Continue `runId` as a **new** run, seeded server-side from its snapshot.
|
|
415
|
+
*
|
|
416
|
+
* Uses a short-lived agent pointed at the resume / fork endpoint and seeded
|
|
417
|
+
* with **no** history, so the request carries only the turn typed here — the
|
|
418
|
+
* contract those endpoints assume, since the server supplies the prior turns
|
|
419
|
+
* from the snapshot and re-sending them would duplicate. Building a separate
|
|
420
|
+
* agent makes that structural: the main agent keeps its own history, and
|
|
421
|
+
* "only the new turn" can't be got wrong by forgetting to clear it. The
|
|
422
|
+
* fresh `run_id` the endpoints also require comes free — a new agent mints
|
|
423
|
+
* one per run.
|
|
424
|
+
*
|
|
425
|
+
* Handlers are the element's own, so the continuation streams into the same
|
|
426
|
+
* transcript the user is looking at.
|
|
427
|
+
*/
|
|
428
|
+
async #continueRun(runId: string, verb: CheckpointVerb): Promise<void> {
|
|
429
|
+
const index = this.#runs();
|
|
430
|
+
if (index === null) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const content = this.#input.value.trim();
|
|
434
|
+
if (content === "") {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
this.#input.value = "";
|
|
438
|
+
const endpoint = verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId);
|
|
439
|
+
const agent = this.agentFactory({
|
|
440
|
+
endpoint,
|
|
441
|
+
headers: this.headers,
|
|
442
|
+
getHeaders: () => this.headers,
|
|
443
|
+
threadId: this.#threadId,
|
|
444
|
+
// The seed the endpoints assume: nothing. The snapshot is the history.
|
|
445
|
+
initialMessages: [],
|
|
446
|
+
});
|
|
447
|
+
const client = new AgUiClient({
|
|
448
|
+
agent,
|
|
449
|
+
handlers: this.#handlers(),
|
|
450
|
+
getTools: () => this.getTools(),
|
|
451
|
+
getContext: () => this.getContext(),
|
|
452
|
+
executeTool: (call) => this.#executeTool(call),
|
|
453
|
+
resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
|
|
454
|
+
connectionLostMessage: this.#strings.connectionLost,
|
|
455
|
+
});
|
|
456
|
+
await client.send(content);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Load the checkpoint panel with the runs that can actually be continued. */
|
|
460
|
+
async #refreshCheckpoints(): Promise<void> {
|
|
461
|
+
const index = this.#runs();
|
|
462
|
+
this.#checkpoints.setRuns(index === null ? [] : await index.continuable());
|
|
349
463
|
}
|
|
350
464
|
|
|
351
465
|
/** Attributes whose late changes must reflect in already-rendered chrome. */
|
|
@@ -430,9 +544,84 @@ export class AgUiChat extends HTMLElement {
|
|
|
430
544
|
return createPageActionTools(enabled, (target) => this.resolvePageTarget(target));
|
|
431
545
|
}
|
|
432
546
|
|
|
433
|
-
/** All built-in (route + page + page-action) frontend tools. */
|
|
547
|
+
/** All built-in (route + page + page-action + ask_user) frontend tools. */
|
|
434
548
|
#builtinTools(): ClientTool[] {
|
|
435
|
-
return [
|
|
549
|
+
return [
|
|
550
|
+
...this.#routeTools(),
|
|
551
|
+
...this.#pageTools(),
|
|
552
|
+
...this.#pageActionTools(),
|
|
553
|
+
...this.#askUserTool(),
|
|
554
|
+
];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The built-in `ask_user` frontend tool, or `[]` when {@link askUser} is off.
|
|
559
|
+
*
|
|
560
|
+
* A generic "ask the user a typed question" primitive: the agent calls it, the
|
|
561
|
+
* client executes it locally via the normal frontend-tool path (rendering a
|
|
562
|
+
* {@link requestQuestion} card), and the chosen/typed answer flows back as the
|
|
563
|
+
* tool result — no new protocol, reusing the machinery already in place.
|
|
564
|
+
*/
|
|
565
|
+
#askUserTool(): ClientTool[] {
|
|
566
|
+
if (!this.askUser) {
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
return [
|
|
570
|
+
{
|
|
571
|
+
name: "ask_user",
|
|
572
|
+
description:
|
|
573
|
+
"Ask the user a question and wait for their answer. Provide `options` for a " +
|
|
574
|
+
"multiple-choice prompt; set `allow_custom` to also accept a free-text answer.",
|
|
575
|
+
parameters: {
|
|
576
|
+
type: "object",
|
|
577
|
+
properties: {
|
|
578
|
+
question: { type: "string", description: "The question to ask the user." },
|
|
579
|
+
options: {
|
|
580
|
+
type: "array",
|
|
581
|
+
items: { type: "string" },
|
|
582
|
+
description: "Preset choices offered as radio buttons.",
|
|
583
|
+
},
|
|
584
|
+
allow_custom: {
|
|
585
|
+
type: "boolean",
|
|
586
|
+
description: "Allow a free-text answer in addition to any options.",
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
required: ["question"],
|
|
590
|
+
},
|
|
591
|
+
handler: (args) => this.#askUser(args),
|
|
592
|
+
},
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Render the `ask_user` question card and resolve with the user's answer. */
|
|
597
|
+
async #askUser(args: Record<string, unknown>): Promise<string> {
|
|
598
|
+
const question = typeof args["question"] === "string" ? args["question"] : "";
|
|
599
|
+
const request: QuestionRequest = { question };
|
|
600
|
+
const rawOptions = args["options"];
|
|
601
|
+
if (Array.isArray(rawOptions)) {
|
|
602
|
+
request.options = rawOptions.filter((option): option is string => typeof option === "string");
|
|
603
|
+
}
|
|
604
|
+
if (args["allow_custom"] === true) {
|
|
605
|
+
request.allowCustom = true;
|
|
606
|
+
}
|
|
607
|
+
// The run is suspended on the card; a Stop aborts the controller, resolving
|
|
608
|
+
// it with an empty answer (the run is then cancelled).
|
|
609
|
+
this.#confirmAbort = new AbortController();
|
|
610
|
+
const signal = this.#confirmAbort.signal;
|
|
611
|
+
this.#hidePending();
|
|
612
|
+
// A host-supplied renderer takes full control of the UI; otherwise the
|
|
613
|
+
// built-in inline card renders into the current answer group.
|
|
614
|
+
const answer =
|
|
615
|
+
this.askUserRenderer !== null
|
|
616
|
+
? await this.askUserRenderer(request, { signal })
|
|
617
|
+
: await requestQuestion(this.#ensureGroup(), request, {
|
|
618
|
+
signal,
|
|
619
|
+
strings: this.#strings,
|
|
620
|
+
});
|
|
621
|
+
this.#confirmAbort = null;
|
|
622
|
+
this.#updateEmptyState();
|
|
623
|
+
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
624
|
+
return answer;
|
|
436
625
|
}
|
|
437
626
|
|
|
438
627
|
/** Resolve a tool by name: built-in tools first, then the registry. */
|
|
@@ -494,6 +683,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
494
683
|
}
|
|
495
684
|
this.#render();
|
|
496
685
|
this.#drawer.setStrings(this.#strings);
|
|
686
|
+
this.#checkpoints.setStrings(this.#strings);
|
|
497
687
|
if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
|
|
498
688
|
this.setAttribute("collapsed", "");
|
|
499
689
|
}
|
|
@@ -1113,13 +1303,28 @@ export class AgUiChat extends HTMLElement {
|
|
|
1113
1303
|
this.#drawer.open();
|
|
1114
1304
|
});
|
|
1115
1305
|
|
|
1306
|
+
const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "⭯");
|
|
1307
|
+
checkpoints.addEventListener("click", () => {
|
|
1308
|
+
void this.#refreshCheckpoints();
|
|
1309
|
+
this.#checkpoints.open();
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1116
1312
|
const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
|
|
1117
1313
|
newChat.addEventListener("click", () => this.newChat());
|
|
1118
1314
|
|
|
1119
1315
|
const collapse = this.#headerButton("collapse", this.#strings.collapse, "—");
|
|
1120
1316
|
collapse.addEventListener("click", () => this.toggleCollapsed());
|
|
1121
1317
|
|
|
1122
|
-
|
|
1318
|
+
// Only offered when the server actually indexes runs — without
|
|
1319
|
+
// `data-runs-url` there is nothing to continue and the button would open
|
|
1320
|
+
// a permanently empty panel. Asks `#runs()` rather than re-testing the
|
|
1321
|
+
// attribute, so "configured" means one thing everywhere (an empty value
|
|
1322
|
+
// is unset, not a relative URL to the current page).
|
|
1323
|
+
if (this.#runs() !== null) {
|
|
1324
|
+
controls.append(history, checkpoints, newChat);
|
|
1325
|
+
} else {
|
|
1326
|
+
controls.append(history, newChat);
|
|
1327
|
+
}
|
|
1123
1328
|
// Optional built-in theme toggle: off unless the host opts in, so
|
|
1124
1329
|
// it never competes with a host-supplied switch in `slot="header-actions"`.
|
|
1125
1330
|
if (this.getAttribute("data-theme-toggle") !== null) {
|
|
@@ -1180,6 +1385,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1180
1385
|
});
|
|
1181
1386
|
|
|
1182
1387
|
this.#skillHint.className = "skill-hint";
|
|
1388
|
+
this.#skillHint.setAttribute("part", "skill-hint");
|
|
1183
1389
|
this.#skillHint.hidden = true;
|
|
1184
1390
|
|
|
1185
1391
|
// File-upload affordance: a 📎 button (hidden until `data-attachments-url`
|
|
@@ -1222,6 +1428,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1222
1428
|
inputRow,
|
|
1223
1429
|
footer,
|
|
1224
1430
|
this.#drawer.element,
|
|
1431
|
+
this.#checkpoints.element,
|
|
1225
1432
|
);
|
|
1226
1433
|
|
|
1227
1434
|
// The collapsed-sidebar rail: a slim edge strip (the expand affordance),
|
|
@@ -1388,6 +1595,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1388
1595
|
getTools: () => this.getTools(),
|
|
1389
1596
|
getContext: () => this.getContext(),
|
|
1390
1597
|
executeTool: (call) => this.#executeTool(call),
|
|
1598
|
+
resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
|
|
1391
1599
|
onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
|
|
1392
1600
|
connectionLostMessage: this.#strings.connectionLost,
|
|
1393
1601
|
});
|
|
@@ -1478,6 +1686,57 @@ export class AgUiChat extends HTMLElement {
|
|
|
1478
1686
|
}
|
|
1479
1687
|
}
|
|
1480
1688
|
|
|
1689
|
+
/**
|
|
1690
|
+
* Render an approval card per server-side-tool interrupt and collect the
|
|
1691
|
+
* user's decisions (approve → run it, deny → decline it).
|
|
1692
|
+
*
|
|
1693
|
+
* The run is suspended on these cards; a Stop while any is open aborts the
|
|
1694
|
+
* shared {@link #confirmAbort} controller, resolving every still-open card as
|
|
1695
|
+
* denied (and the client loop then sees the cancellation and stops). An
|
|
1696
|
+
* approved tool runs on the follow-up (resume) run and streams its result
|
|
1697
|
+
* back into the same pending card; a denied one is settled here, since no
|
|
1698
|
+
* result will ever arrive for it.
|
|
1699
|
+
*/
|
|
1700
|
+
async #resolveInterrupts(
|
|
1701
|
+
interrupts: readonly Interrupt[],
|
|
1702
|
+
): Promise<Record<string, InterruptResponse>> {
|
|
1703
|
+
const responses: Record<string, InterruptResponse> = {};
|
|
1704
|
+
// One controller covers the whole batch: a single Stop denies all of them.
|
|
1705
|
+
this.#confirmAbort = new AbortController();
|
|
1706
|
+
this.#hidePending();
|
|
1707
|
+
for (const interrupt of interrupts) {
|
|
1708
|
+
const request: ApprovalRequest = {};
|
|
1709
|
+
if (interrupt.message !== undefined) {
|
|
1710
|
+
request.message = interrupt.message;
|
|
1711
|
+
}
|
|
1712
|
+
const card =
|
|
1713
|
+
interrupt.toolCallId !== undefined ? this.#toolCards.get(interrupt.toolCallId) : undefined;
|
|
1714
|
+
const toolName = card?.element.getAttribute("data-tool-name");
|
|
1715
|
+
if (toolName !== null && toolName !== undefined) {
|
|
1716
|
+
request.toolName = toolName;
|
|
1717
|
+
}
|
|
1718
|
+
const signal = this.#confirmAbort.signal;
|
|
1719
|
+
// A host-supplied renderer takes full control of the approval UI;
|
|
1720
|
+
// otherwise the built-in inline card renders into the current answer group.
|
|
1721
|
+
const approved =
|
|
1722
|
+
this.approvalRenderer !== null
|
|
1723
|
+
? await this.approvalRenderer(request, { signal })
|
|
1724
|
+
: await requestApproval(this.#ensureGroup(), request, { signal, strings: this.#strings });
|
|
1725
|
+
this.#updateEmptyState();
|
|
1726
|
+
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
1727
|
+
if (approved) {
|
|
1728
|
+
responses[interrupt.id] = { status: "resolved", payload: { approved: true } };
|
|
1729
|
+
} else {
|
|
1730
|
+
responses[interrupt.id] = { status: "cancelled" };
|
|
1731
|
+
// No TOOL_CALL_RESULT will stream for a denied tool — settle its pending
|
|
1732
|
+
// card now rather than leaving it hanging until the onSettled sweep.
|
|
1733
|
+
card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
this.#confirmAbort = null;
|
|
1737
|
+
return responses;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1481
1740
|
#handlers(): AgUiClientHandlers {
|
|
1482
1741
|
return {
|
|
1483
1742
|
onRunStart: () => {
|
package/src/core/agui_client.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
type AbstractAgent,
|
|
3
|
+
type AgentSubscriber,
|
|
4
|
+
buildResumeArray,
|
|
5
|
+
type RunAgentParameters,
|
|
6
|
+
randomUUID,
|
|
7
|
+
} from "@ag-ui/client";
|
|
8
|
+
import type { Context, Interrupt, Message, ResumeEntry, Tool } from "@ag-ui/core";
|
|
3
9
|
import { MAX_TOOL_ROUNDS } from "../constants.js";
|
|
4
10
|
import type { AttachmentRef } from "./attachment.js";
|
|
5
11
|
|
|
@@ -33,6 +39,27 @@ export interface ToolExecution {
|
|
|
33
39
|
*/
|
|
34
40
|
export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
|
|
35
41
|
|
|
42
|
+
/**
|
|
43
|
+
* One user decision for a server-side-tool approval interrupt. Structurally
|
|
44
|
+
* matches `@ag-ui/client`'s (non-exported) `ResumeResponse`, the payload
|
|
45
|
+
* {@link buildResumeArray} turns into a `ResumeEntry`: `resolved` approves (with
|
|
46
|
+
* an optional `payload`, e.g. `{ approved: true }`), `cancelled` denies.
|
|
47
|
+
*/
|
|
48
|
+
export type InterruptResponse = { status: "resolved"; payload?: unknown } | { status: "cancelled" };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the approval interrupts a run finished on, keyed by interrupt id.
|
|
52
|
+
*
|
|
53
|
+
* When a gated **server-side** tool defers instead of executing, the run
|
|
54
|
+
* finishes on an AG-UI interrupt outcome; the host renders an approval card per
|
|
55
|
+
* interrupt and returns each decision here, and the loop resumes the run with
|
|
56
|
+
* the answers. Omit for agents that never gate server-side tools — an
|
|
57
|
+
* unresolved interrupt then simply ends the loop.
|
|
58
|
+
*/
|
|
59
|
+
export type ResolveInterrupts = (
|
|
60
|
+
interrupts: readonly Interrupt[],
|
|
61
|
+
) => Promise<Record<string, InterruptResponse>>;
|
|
62
|
+
|
|
36
63
|
/**
|
|
37
64
|
* Callbacks the {@link AgUiClient} invokes as a run progresses. The host
|
|
38
65
|
* (the `<ag-ui-chat>` element) implements these to render streaming text and
|
|
@@ -93,6 +120,11 @@ export interface AgUiClientConfig extends AgUiRunInputs {
|
|
|
93
120
|
handlers: AgUiClientHandlers;
|
|
94
121
|
/** Executes frontend tool calls. Omit for server-only tool sets. */
|
|
95
122
|
executeTool?: ExecuteTool;
|
|
123
|
+
/**
|
|
124
|
+
* Resolves server-side-tool approval interrupts. Omit when no server-side
|
|
125
|
+
* tool is gated for approval — an interrupt then ends the loop unanswered.
|
|
126
|
+
*/
|
|
127
|
+
resolveInterrupts?: ResolveInterrupts;
|
|
96
128
|
/**
|
|
97
129
|
* Invoked with the latest history whenever it changes, so the host can
|
|
98
130
|
* persist it for durability across page reloads. Omit to keep the
|
|
@@ -134,6 +166,7 @@ export class AgUiClient {
|
|
|
134
166
|
readonly #getTools: () => Tool[];
|
|
135
167
|
readonly #getContext: () => Context[];
|
|
136
168
|
readonly #executeTool: ExecuteTool | null;
|
|
169
|
+
readonly #resolveInterrupts: ResolveInterrupts | null;
|
|
137
170
|
readonly #onPersist: (messages: readonly Message[]) => void;
|
|
138
171
|
readonly #connectionLostMessage: string;
|
|
139
172
|
// Set by cancel(); reset at the top of each #run(). Checked by the loop so
|
|
@@ -146,6 +179,7 @@ export class AgUiClient {
|
|
|
146
179
|
this.#getTools = config.getTools ?? (() => []);
|
|
147
180
|
this.#getContext = config.getContext ?? (() => []);
|
|
148
181
|
this.#executeTool = config.executeTool ?? null;
|
|
182
|
+
this.#resolveInterrupts = config.resolveInterrupts ?? null;
|
|
149
183
|
this.#onPersist = config.onPersist ?? (() => {});
|
|
150
184
|
this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
|
|
151
185
|
}
|
|
@@ -241,6 +275,11 @@ export class AgUiClient {
|
|
|
241
275
|
}
|
|
242
276
|
|
|
243
277
|
async #runLoop(): Promise<void> {
|
|
278
|
+
// Carries the resolved approval answers into the *next* run when a round
|
|
279
|
+
// finished on a server-side-tool interrupt. Distinct from the public
|
|
280
|
+
// resume() navigation-reload path (which continues an unfinished
|
|
281
|
+
// frontend-tool round after a page load) — this stays inside one #run().
|
|
282
|
+
let resume: ResumeEntry[] | undefined;
|
|
244
283
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
245
284
|
// A cancel during the previous round's frontend-tool execution lands
|
|
246
285
|
// here: the running handler completed, but no further round starts.
|
|
@@ -248,11 +287,16 @@ export class AgUiClient {
|
|
|
248
287
|
return;
|
|
249
288
|
}
|
|
250
289
|
const pending: AgUiToolCall[] = [];
|
|
251
|
-
const runState = { terminal: false, errored: false };
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
this.#
|
|
255
|
-
|
|
290
|
+
const runState: RunState = { terminal: false, errored: false, interrupts: [] };
|
|
291
|
+
const params: RunAgentParameters = {
|
|
292
|
+
tools: this.#getTools(),
|
|
293
|
+
context: this.#getContext(),
|
|
294
|
+
};
|
|
295
|
+
if (resume !== undefined) {
|
|
296
|
+
params.resume = resume;
|
|
297
|
+
}
|
|
298
|
+
await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
|
|
299
|
+
resume = undefined;
|
|
256
300
|
this.#onPersist(this.#agent.messages);
|
|
257
301
|
// Cancelled mid-stream: the user said stop — don't execute the tool
|
|
258
302
|
// calls collected before the abort.
|
|
@@ -272,6 +316,22 @@ export class AgUiClient {
|
|
|
272
316
|
if (runState.errored) {
|
|
273
317
|
return;
|
|
274
318
|
}
|
|
319
|
+
// A gated server-side tool deferred instead of executing: the run finished
|
|
320
|
+
// on an interrupt outcome. Ask the host to resolve each interrupt, then
|
|
321
|
+
// re-enter the loop carrying the answers — the follow-up run runs or denies
|
|
322
|
+
// the tool (its result streams back as TOOL_CALL_RESULT). Takes precedence
|
|
323
|
+
// over the frontend-tool sweep below: a server-side tool isn't ours to run.
|
|
324
|
+
if (runState.interrupts.length > 0) {
|
|
325
|
+
if (this.#resolveInterrupts === null) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const responses = await this.#resolveInterrupts(runState.interrupts);
|
|
329
|
+
if (this.#cancelled) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
resume = buildResumeArray(runState.interrupts, responses);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
275
335
|
if (this.#executeTool === null || pending.length === 0) {
|
|
276
336
|
return;
|
|
277
337
|
}
|
|
@@ -301,10 +361,7 @@ export class AgUiClient {
|
|
|
301
361
|
}
|
|
302
362
|
}
|
|
303
363
|
|
|
304
|
-
#buildSubscriber(
|
|
305
|
-
pending: AgUiToolCall[],
|
|
306
|
-
runState: { terminal: boolean; errored: boolean },
|
|
307
|
-
): AgentSubscriber {
|
|
364
|
+
#buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
|
|
308
365
|
const h = this.#handlers;
|
|
309
366
|
return {
|
|
310
367
|
onRunInitialized() {
|
|
@@ -340,6 +397,16 @@ export class AgUiClient {
|
|
|
340
397
|
onReasoningEndEvent() {
|
|
341
398
|
h.onReasoningEnd();
|
|
342
399
|
},
|
|
400
|
+
onRunFinishedEvent(params) {
|
|
401
|
+
// RUN_FINISHED is terminal for both a normal finish and an interrupt.
|
|
402
|
+
// Capturing the interrupts here (rather than reading the agent's
|
|
403
|
+
// `pendingInterrupts` field afterwards) keeps the loop self-contained
|
|
404
|
+
// and independent of that field's cross-run clearing semantics.
|
|
405
|
+
runState.terminal = true;
|
|
406
|
+
if (params.outcome === "interrupt") {
|
|
407
|
+
runState.interrupts = params.interrupts;
|
|
408
|
+
}
|
|
409
|
+
},
|
|
343
410
|
onRunErrorEvent({ event }) {
|
|
344
411
|
runState.terminal = true;
|
|
345
412
|
runState.errored = true;
|
|
@@ -353,6 +420,14 @@ export class AgUiClient {
|
|
|
353
420
|
}
|
|
354
421
|
}
|
|
355
422
|
|
|
423
|
+
/** Per-run mutable flags the subscriber writes and {@link AgUiClient} reads. */
|
|
424
|
+
interface RunState {
|
|
425
|
+
terminal: boolean;
|
|
426
|
+
errored: boolean;
|
|
427
|
+
/** Approval interrupts a run finished on (empty for a normal finish). */
|
|
428
|
+
interrupts: Interrupt[];
|
|
429
|
+
}
|
|
430
|
+
|
|
356
431
|
/**
|
|
357
432
|
* Whether a rejection came from aborting the run's fetch. Belt-and-suspenders
|
|
358
433
|
* with the `#cancelled` flag: some `@ag-ui/client` versions re-throw the
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** One row of the server run index (django-ag-ui's `RunsView` wire shape). */
|
|
2
|
+
export interface RunRow {
|
|
3
|
+
readonly run_id: string;
|
|
4
|
+
readonly thread_id: string | null;
|
|
5
|
+
readonly parent_run_id: string | null;
|
|
6
|
+
readonly started_at: string | null;
|
|
7
|
+
/** Whether the run has a snapshot to seed from — see {@link RunIndex}. */
|
|
8
|
+
readonly continuable: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Live header source, read per request so rotated tokens / CSRF reach the server. */
|
|
12
|
+
type HeadersProvider = () => Record<string, string>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reads the server's run index and derives the resume / fork URLs beside it.
|
|
16
|
+
*
|
|
17
|
+
* Backed by django-ag-ui's owner-scoped `RunsView` — the URL passed to
|
|
18
|
+
* `<ag-ui-chat>` as `data-runs-url`:
|
|
19
|
+
*
|
|
20
|
+
* - `GET <url>` → the user's runs, newest first.
|
|
21
|
+
*
|
|
22
|
+
* **Only `continuable` rows can be resumed.** The server reports whether a run
|
|
23
|
+
* has a saved snapshot to seed from; a run that never reached a provider-valid
|
|
24
|
+
* boundary has none, so resuming it would start from nothing. Callers should
|
|
25
|
+
* offer the action only for those rows and treat the rest as informational — a
|
|
26
|
+
* crashed run worth showing, not worth continuing. {@link continuable} filters
|
|
27
|
+
* for exactly that.
|
|
28
|
+
*
|
|
29
|
+
* **Resume and fork are siblings of the index**, not separate configuration.
|
|
30
|
+
* django-ag-ui mounts all three under one prefix whenever a step store is
|
|
31
|
+
* configured (`runs/`, `resume/<id>/`, `fork/<id>/`), so one URL locates them
|
|
32
|
+
* all and there is no way to configure a half-working set.
|
|
33
|
+
*/
|
|
34
|
+
export class RunIndex {
|
|
35
|
+
readonly #url: string;
|
|
36
|
+
readonly #headers: HeadersProvider;
|
|
37
|
+
|
|
38
|
+
constructor(url: string, headers: HeadersProvider = () => ({})) {
|
|
39
|
+
this.#url = url.endsWith("/") ? url : `${url}/`;
|
|
40
|
+
this.#headers = headers;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The user's runs, or `[]` when the endpoint is unreachable or answers with
|
|
45
|
+
* an error. A history affordance that cannot load is empty, never broken:
|
|
46
|
+
* the caller renders its empty state rather than surfacing a transport fault
|
|
47
|
+
* the user can do nothing about.
|
|
48
|
+
*/
|
|
49
|
+
async list(): Promise<readonly RunRow[]> {
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(this.#url, {
|
|
52
|
+
method: "GET",
|
|
53
|
+
headers: { Accept: "application/json", ...this.#headers() },
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const body = (await response.json()) as { runs?: readonly RunRow[] };
|
|
59
|
+
return body.runs ?? [];
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The rows a client may actually continue. */
|
|
66
|
+
async continuable(): Promise<readonly RunRow[]> {
|
|
67
|
+
return (await this.list()).filter((run) => run.continuable);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The endpoint that continues `runId` as a new run. */
|
|
71
|
+
resumeUrl(runId: string): string {
|
|
72
|
+
return this.#sibling("resume", runId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The endpoint that branches `runId` into a new run, leaving the source untouched. */
|
|
76
|
+
forkUrl(runId: string): string {
|
|
77
|
+
return this.#sibling("fork", runId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `<mount>/<verb>/<runId>/`, derived from the index URL's own prefix.
|
|
82
|
+
*
|
|
83
|
+
* Built by string surgery on the trailing `runs/` rather than with `new URL`,
|
|
84
|
+
* because the configured value may be root-relative (`/agent/runs/`) — the
|
|
85
|
+
* common case in a Django template — and `new URL` needs an absolute base.
|
|
86
|
+
*/
|
|
87
|
+
#sibling(verb: string, runId: string): string {
|
|
88
|
+
const prefix = this.#url.slice(0, -"runs/".length);
|
|
89
|
+
return `${prefix}${verb}/${encodeURIComponent(runId)}/`;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ export {
|
|
|
27
27
|
type AgUiToolCall,
|
|
28
28
|
ConnectionLostError,
|
|
29
29
|
type ExecuteTool,
|
|
30
|
+
type InterruptResponse,
|
|
31
|
+
type ResolveInterrupts,
|
|
30
32
|
type ToolExecution,
|
|
31
33
|
} from "./core/agui_client.js";
|
|
32
34
|
export { type AttachmentRef, messageAttachments } from "./core/attachment.js";
|
|
@@ -43,6 +45,7 @@ export {
|
|
|
43
45
|
} from "./core/create_http_agent.js";
|
|
44
46
|
export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
|
|
45
47
|
export { RemoteConversationStore } from "./core/remote_conversation_store.js";
|
|
48
|
+
export { RunIndex, type RunRow } from "./core/run_index.js";
|
|
46
49
|
export {
|
|
47
50
|
type TranscribeHandler,
|
|
48
51
|
type TranscribeOptions,
|
|
@@ -98,12 +101,25 @@ export {
|
|
|
98
101
|
type RouteWithParams,
|
|
99
102
|
} from "./tools/route_map.js";
|
|
100
103
|
export { createStateHookTools, type StateHook } from "./tools/state_hook.js";
|
|
104
|
+
export {
|
|
105
|
+
type ApprovalOptions,
|
|
106
|
+
type ApprovalRenderer,
|
|
107
|
+
type ApprovalRequest,
|
|
108
|
+
requestApproval,
|
|
109
|
+
} from "./ui/approval_card.js";
|
|
110
|
+
export { CheckpointMenu, type CheckpointVerb } from "./ui/checkpoint_menu.js";
|
|
101
111
|
export {
|
|
102
112
|
type ConfirmationOptions,
|
|
103
113
|
type ConfirmationRequest,
|
|
104
114
|
requestConfirmation,
|
|
105
115
|
} from "./ui/confirmation_card.js";
|
|
106
116
|
export { prettifyToolName } from "./ui/prettify_tool_name.js";
|
|
117
|
+
export {
|
|
118
|
+
type QuestionOptions,
|
|
119
|
+
type QuestionRenderer,
|
|
120
|
+
type QuestionRequest,
|
|
121
|
+
requestQuestion,
|
|
122
|
+
} from "./ui/question_card.js";
|
|
107
123
|
export { type RenderMarkdownOptions, renderMarkdown } from "./ui/render_markdown.js";
|
|
108
124
|
export {
|
|
109
125
|
type SettledStatus,
|