@artooi/ag-ui-web-component 0.38.0 → 0.40.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 +515 -1
- package/README.md +232 -56
- package/dist/ag-ui-web-component.bundle.js +370 -139
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +17 -14
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/activity_registry.d.ts +46 -0
- package/dist/core/activity_registry.d.ts.map +1 -0
- package/dist/core/ag_ui_chat.d.ts +23 -10
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +28 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/client_seed.d.ts +27 -0
- package/dist/core/client_seed.d.ts.map +1 -0
- package/dist/core/read_max_tool_rounds.d.ts +9 -0
- package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
- package/dist/core/run_handlers.d.ts +80 -0
- package/dist/core/run_handlers.d.ts.map +1 -0
- package/dist/core/storage_scope.d.ts +110 -0
- package/dist/core/storage_scope.d.ts.map +1 -0
- package/dist/core/tool_outcome.d.ts +2 -3
- package/dist/core/tool_outcome.d.ts.map +1 -1
- package/dist/core/utils.d.ts +46 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.js +16268 -14747
- package/dist/index.js.map +4 -4
- package/dist/skills/skill_catalog.d.ts +67 -0
- package/dist/skills/skill_catalog.d.ts.map +1 -0
- package/dist/skills/skill_name_from.d.ts +12 -0
- package/dist/skills/skill_name_from.d.ts.map +1 -0
- package/dist/tools/tool_catalog.d.ts +79 -0
- package/dist/tools/tool_catalog.d.ts.map +1 -0
- package/dist/tools/tool_dispatch.d.ts +104 -0
- package/dist/tools/tool_dispatch.d.ts.map +1 -0
- package/dist/ui/composer/attachment_tray.d.ts.map +1 -1
- package/dist/ui/composer/auto_grow.d.ts +10 -0
- package/dist/ui/composer/auto_grow.d.ts.map +1 -0
- package/dist/ui/composer/composer_attachments.d.ts +57 -0
- package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
- package/dist/ui/composer/composer_voice.d.ts +48 -0
- package/dist/ui/composer/composer_voice.d.ts.map +1 -0
- package/dist/ui/composer/voice_input.d.ts.map +1 -1
- package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
- package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
- package/dist/ui/fill_ui_string.d.ts +32 -0
- package/dist/ui/fill_ui_string.d.ts.map +1 -0
- package/dist/ui/history/conversation_history.d.ts +203 -0
- package/dist/ui/history/conversation_history.d.ts.map +1 -0
- package/dist/ui/history/relative_time.d.ts.map +1 -1
- package/dist/ui/history/thread_drawer.d.ts +0 -14
- package/dist/ui/history/thread_drawer.d.ts.map +1 -1
- package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -1
- package/dist/ui/interrupts/pending_decision.d.ts +23 -0
- package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
- package/dist/ui/placement/is_collapsible_placement.d.ts +18 -0
- package/dist/ui/placement/is_collapsible_placement.d.ts.map +1 -0
- package/dist/ui/placement/is_draggable_placement.d.ts +9 -0
- package/dist/ui/placement/is_draggable_placement.d.ts.map +1 -0
- package/dist/ui/placement/launcher_drag.d.ts +6 -0
- package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
- package/dist/ui/placement/panel_placement.d.ts +119 -0
- package/dist/ui/placement/panel_placement.d.ts.map +1 -0
- package/dist/ui/progress/run_announcer.d.ts +41 -0
- package/dist/ui/progress/run_announcer.d.ts.map +1 -0
- package/dist/ui/progress/subagent_progress.d.ts +58 -0
- package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
- package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
- package/dist/ui/shell/adopt_styles.d.ts +23 -0
- package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
- package/dist/ui/shell/glyph_slot.d.ts +7 -0
- package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
- package/dist/ui/shell/header_button.d.ts +10 -0
- package/dist/ui/shell/header_button.d.ts.map +1 -0
- package/dist/ui/shell/icon_element.d.ts +12 -0
- package/dist/ui/shell/icon_element.d.ts.map +1 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/transcript/answer_actions.d.ts +39 -0
- package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
- package/dist/ui/transcript/answer_stream.d.ts +56 -0
- package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
- package/dist/ui/transcript/render_or_warn.d.ts +16 -0
- package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
- package/dist/ui/transcript/starter_chips.d.ts +21 -0
- package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
- package/dist/ui/transcript/transcript.d.ts +171 -0
- package/dist/ui/transcript/transcript.d.ts.map +1 -0
- package/dist/ui/ui_strings.d.ts +29 -1
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +17 -15
- package/src/core/activity_registry.ts +133 -0
- package/src/core/ag_ui_chat.ts +945 -4419
- package/src/core/agui_client.ts +172 -16
- package/src/core/client_seed.ts +27 -0
- package/src/core/read_max_tool_rounds.ts +13 -0
- package/src/core/remote_conversation_store.ts +1 -1
- package/src/core/run_handlers.ts +501 -0
- package/src/core/storage_scope.ts +279 -0
- package/src/core/tool_outcome.ts +5 -3
- package/src/core/utils.ts +109 -0
- package/src/skills/skill_catalog.ts +166 -0
- package/src/skills/skill_name_from.ts +19 -0
- package/src/tools/tool_catalog.ts +354 -0
- package/src/tools/tool_dispatch.ts +521 -0
- package/src/ui/composer/attachment_tray.ts +6 -5
- package/src/ui/composer/auto_grow.ts +12 -0
- package/src/ui/composer/composer_attachments.ts +324 -0
- package/src/ui/composer/composer_voice.ts +93 -0
- package/src/ui/composer/voice_input.ts +4 -4
- package/src/ui/excerpts/transcript_quote_offer.ts +198 -0
- package/src/ui/fill_ui_string.ts +41 -0
- package/src/ui/history/conversation_history.ts +699 -0
- package/src/ui/history/relative_time.ts +5 -4
- package/src/ui/history/thread_drawer.ts +3 -50
- package/src/ui/interrupts/confirmation_card.ts +4 -2
- package/src/ui/interrupts/pending_decision.ts +34 -0
- package/src/ui/placement/is_collapsible_placement.ts +19 -0
- package/src/ui/placement/is_draggable_placement.ts +23 -0
- package/src/ui/placement/launcher_drag.ts +104 -89
- package/src/ui/placement/panel_placement.ts +1130 -0
- package/src/ui/progress/run_announcer.ts +67 -0
- package/src/ui/progress/subagent_progress.ts +201 -0
- package/src/ui/progress/tool_call_card.ts +2 -0
- package/src/ui/shell/adopt_styles.ts +28 -0
- package/src/ui/shell/glyph_slot.ts +12 -0
- package/src/ui/shell/header_button.ts +21 -0
- package/src/ui/shell/icon_element.ts +33 -0
- package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
- package/src/ui/shell/read_launcher_icon_url.ts +9 -0
- package/src/ui/styles.ts +301 -70
- package/src/ui/transcript/answer_actions.ts +139 -0
- package/src/ui/transcript/answer_stream.ts +123 -0
- package/src/ui/transcript/render_or_warn.ts +22 -0
- package/src/ui/transcript/starter_chips.ts +42 -0
- package/src/ui/transcript/transcript.ts +429 -0
- package/src/ui/ui_strings.ts +38 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import type { Context, Interrupt } from "@ag-ui/core";
|
|
2
|
+
import { READ_PAGE_TOOL, TOOL_CALL_STATUS, TOOL_OUTCOME, X_CONFIRM_KEY } from "../constants.js";
|
|
3
|
+
import type { AgUiToolCall, InterruptResponse, ToolExecution } from "../core/agui_client.js";
|
|
4
|
+
import type { ClientConversationStore } from "../core/conversation_store.js";
|
|
5
|
+
import { skillNameFrom } from "../skills/skill_name_from.js";
|
|
6
|
+
import { fillUiString } from "../ui/fill_ui_string.js";
|
|
7
|
+
import {
|
|
8
|
+
type ApprovalRenderer,
|
|
9
|
+
type ApprovalRequest,
|
|
10
|
+
requestApproval,
|
|
11
|
+
} from "../ui/interrupts/approval_card.js";
|
|
12
|
+
import {
|
|
13
|
+
type ConfirmationRequest,
|
|
14
|
+
requestConfirmation,
|
|
15
|
+
} from "../ui/interrupts/confirmation_card.js";
|
|
16
|
+
import type { PendingDecision } from "../ui/interrupts/pending_decision.js";
|
|
17
|
+
import type { Transcript } from "../ui/transcript/transcript.js";
|
|
18
|
+
import type { UiStrings } from "../ui/ui_strings.js";
|
|
19
|
+
import type { ClientTool } from "./client_tool_registry.js";
|
|
20
|
+
import { isDestructive } from "./is_destructive.js";
|
|
21
|
+
import { isNavigates } from "./is_navigates.js";
|
|
22
|
+
import type { PageMap } from "./page_map.js";
|
|
23
|
+
import type { ToolCatalog } from "./tool_catalog.js";
|
|
24
|
+
|
|
25
|
+
/** What tool dispatch needs from the element that owns it. */
|
|
26
|
+
export interface ToolDispatchHost {
|
|
27
|
+
/** The custom element, which a host's predicate and renderer are called on. */
|
|
28
|
+
readonly element: HTMLElement;
|
|
29
|
+
/** The transcript the cards and prompts are drawn in. */
|
|
30
|
+
readonly transcript: Transcript;
|
|
31
|
+
/** The tools this round advertised, and their implementations. */
|
|
32
|
+
readonly tools: ToolCatalog;
|
|
33
|
+
/** The decision a run is suspended on, which a Stop abandons. */
|
|
34
|
+
readonly decision: PendingDecision;
|
|
35
|
+
/** The resolved string table. */
|
|
36
|
+
readonly strings: () => UiStrings;
|
|
37
|
+
/** Say one short thing to a screen reader. */
|
|
38
|
+
readonly announce: (message: string) => void;
|
|
39
|
+
/** The element's `autoConfirm`. */
|
|
40
|
+
readonly autoConfirm: () => boolean;
|
|
41
|
+
/** The element's `confirmPredicate`. */
|
|
42
|
+
readonly confirmPredicate: () =>
|
|
43
|
+
| ((toolName: string, args: Record<string, unknown>) => boolean | Promise<boolean>)
|
|
44
|
+
| null;
|
|
45
|
+
/** The element's `getPageMap`. */
|
|
46
|
+
readonly getPageMap: () => (() => PageMap) | null;
|
|
47
|
+
/** The element's `navigate`. */
|
|
48
|
+
readonly navigate: () => ((path: string) => void) | null;
|
|
49
|
+
/** The element's `approveWithEdits`. */
|
|
50
|
+
readonly approveWithEdits: () => boolean;
|
|
51
|
+
/** The element's `approvalRenderer`. */
|
|
52
|
+
readonly approvalRenderer: () => ApprovalRenderer | null;
|
|
53
|
+
/** The element's `getContext`, which a host may have replaced. */
|
|
54
|
+
readonly getContext: () => Context[];
|
|
55
|
+
/** The element's `conversationStore`, read per use. */
|
|
56
|
+
readonly conversationStore: () => ClientConversationStore;
|
|
57
|
+
/** The active thread's id. */
|
|
58
|
+
readonly threadId: () => string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Running the frontend tool calls a round produced, and answering the server's
|
|
63
|
+
* interrupts: the page each round's context described, the confirmation rules,
|
|
64
|
+
* and the tools the user waived confirmation for.
|
|
65
|
+
*
|
|
66
|
+
* Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
|
|
67
|
+
* instance.
|
|
68
|
+
*/
|
|
69
|
+
export class ToolDispatch {
|
|
70
|
+
readonly #host: ToolDispatchHost;
|
|
71
|
+
/**
|
|
72
|
+
* Tool names the user waived confirmation for, for the life of this element
|
|
73
|
+
* or until the principal changes, whichever comes first.
|
|
74
|
+
*
|
|
75
|
+
* Per instance and never persisted: a session decision that outlived the tab
|
|
76
|
+
* would be a permanent grant made by one click, which is the thing
|
|
77
|
+
* `autoConfirm` already exists to say deliberately. Cleared with the element,
|
|
78
|
+
* and by {@link forgetWaivers} when the host names a different principal.
|
|
79
|
+
*/
|
|
80
|
+
readonly #sessionApproved = new Set<string>();
|
|
81
|
+
// The page the current round's context describes, captured when that context
|
|
82
|
+
// was built. `null` until the first round. Compared in `execute` to
|
|
83
|
+
// catch a page that moved under a round still in flight.
|
|
84
|
+
#contextHref: string | null = null;
|
|
85
|
+
|
|
86
|
+
constructor(host: ToolDispatchHost) {
|
|
87
|
+
this.#host = host;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Forget every tool the user waived confirmation for.
|
|
92
|
+
*
|
|
93
|
+
* A waiver is one person's click, and it is only ever that person's to give.
|
|
94
|
+
* `user-key` exists because a logout is a navigation inside one tab rather
|
|
95
|
+
* than a remount, so the element outlives the principal who clicked Always
|
|
96
|
+
* allow -- and without this the next one's destructive calls would run on the
|
|
97
|
+
* previous one's say-so, with no card to show that anyone was asked.
|
|
98
|
+
*/
|
|
99
|
+
forgetWaivers(): void {
|
|
100
|
+
this.#sessionApproved.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build a round's context, recording which page it describes.
|
|
105
|
+
*
|
|
106
|
+
* The AG-UI client re-invokes this at the top of **every** tool round, not
|
|
107
|
+
* once per `send()`, so the page map the agent sees is already refreshed
|
|
108
|
+
* between rounds and the href captured here is the page it was shown for
|
|
109
|
+
* *this* round. {@link execute} compares against it to catch a page that
|
|
110
|
+
* moved under a round still in flight.
|
|
111
|
+
*/
|
|
112
|
+
buildContext(): Context[] {
|
|
113
|
+
this.#contextHref = window.location.href;
|
|
114
|
+
return this.#host.getContext();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Execute one frontend tool call the round produced, against its own card:
|
|
119
|
+
* refused when the page moved under the round, asked about when a rule gates
|
|
120
|
+
* it, and settled with what the handler returned or threw.
|
|
121
|
+
*/
|
|
122
|
+
async execute(call: AgUiToolCall): Promise<ToolExecution | null> {
|
|
123
|
+
// A skill load already rendered as a notice on the stream; it is never a
|
|
124
|
+
// client tool and its result is pydantic-ai's business, so it must not
|
|
125
|
+
// acquire a card here on the way to the not-finished fallback below.
|
|
126
|
+
if (skillNameFrom(call) !== null) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const card = this.#host.transcript.cardFor(call);
|
|
130
|
+
this.#host.transcript.forgetCard(call.id);
|
|
131
|
+
// Kept after the card leaves the awaiting cards: a tool that renders into the
|
|
132
|
+
// transcript places itself against its own card, and by the time it runs the
|
|
133
|
+
// card is no longer reachable by id.
|
|
134
|
+
this.#host.transcript.setCardElement(call.id, card.element);
|
|
135
|
+
// Scoped out of this round's catalog ⇒ not a frontend tool of ours, for
|
|
136
|
+
// this round. A host that offers `delete_record` only on the page where
|
|
137
|
+
// deleting makes sense has said something about *this* run, and a call
|
|
138
|
+
// arriving anyway (a hallucinated name, or one steered by text the model
|
|
139
|
+
// just read) must not find the handler that happens to be registered
|
|
140
|
+
// mount-wide. Treated exactly as an unknown name rather than as a refusal:
|
|
141
|
+
// withholding a tool and never registering it are the same statement, and
|
|
142
|
+
// the branch below already says the honest thing for both.
|
|
143
|
+
const tool = this.#host.tools.wasAdvertised(call.name)
|
|
144
|
+
? this.#host.tools.resolve(call.name)
|
|
145
|
+
: null;
|
|
146
|
+
if (tool === null) {
|
|
147
|
+
// Not a client tool. A server-side tool's real output arrives via
|
|
148
|
+
// `onToolResult` (TOOL_CALL_RESULT) and already settled the card — only
|
|
149
|
+
// fall back when it didn't. When no result ever arrived, the call wasn't
|
|
150
|
+
// executed by either side (no handler, no server result), so the card says
|
|
151
|
+
// it did not finish -- the same words the client sends the agent for it on
|
|
152
|
+
// the next request -- rather than claiming a success nothing achieved. We
|
|
153
|
+
// do NOT show the pending indicator: nothing here triggers another client
|
|
154
|
+
// round, so it would hang after the run ended.
|
|
155
|
+
if (!this.#host.transcript.isServerSettled(call.id)) {
|
|
156
|
+
card.settle(TOOL_CALL_STATUS.INTERRUPTED, this.#host.strings().callNotFinished);
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// The page moved under this round. Acting now would target whatever
|
|
161
|
+
// matches on the new page, and the case worth preventing is a same-named
|
|
162
|
+
// control matching silently — the only way the agent acts on the wrong page
|
|
163
|
+
// without either side noticing.
|
|
164
|
+
//
|
|
165
|
+
// Must precede the confirmation prompt, so the user is never asked to
|
|
166
|
+
// approve an action about to be refused. Navigating tools are exempt, since
|
|
167
|
+
// moving the page is their job, as is read_page, the documented recovery.
|
|
168
|
+
// Gated on a page-map provider: without one there is no read_page to
|
|
169
|
+
// recommend and the host's tools are not page-scoped anyway.
|
|
170
|
+
if (
|
|
171
|
+
this.#host.getPageMap() !== null &&
|
|
172
|
+
call.name !== READ_PAGE_TOOL &&
|
|
173
|
+
!isNavigates(tool.parameters) &&
|
|
174
|
+
this.#pageMoved()
|
|
175
|
+
) {
|
|
176
|
+
const message = this.#host.strings().pageMoved;
|
|
177
|
+
card.settle(TOOL_CALL_STATUS.ERROR, message);
|
|
178
|
+
this.#host.transcript.showPending();
|
|
179
|
+
// Stated so a reload settles this card the same way. The card's own status
|
|
180
|
+
// lives only in the DOM, and the DOM is what a reload throws away.
|
|
181
|
+
return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
|
|
182
|
+
}
|
|
183
|
+
const rule = await this.#confirmationRule(call, tool);
|
|
184
|
+
if (rule === "unanswered") {
|
|
185
|
+
// Settled as a refusal and returned as one, so the run carries on to its
|
|
186
|
+
// next round the way it does after a decline and the agent can say what
|
|
187
|
+
// happened. Not recorded as a decision: no person was asked, and "declined
|
|
188
|
+
// by you" would be a claim about someone who never saw a card.
|
|
189
|
+
const message = this.#host.strings().confirmCheckFailed;
|
|
190
|
+
card.settle(TOOL_CALL_STATUS.DECLINED, message);
|
|
191
|
+
this.#host.transcript.showPending();
|
|
192
|
+
return { content: message, outcome: TOOL_OUTCOME.DENIED };
|
|
193
|
+
}
|
|
194
|
+
if (rule !== null) {
|
|
195
|
+
const request: ConfirmationRequest = { toolName: call.name, args: call.args };
|
|
196
|
+
const confirmText = tool.parameters[X_CONFIRM_KEY];
|
|
197
|
+
if (typeof confirmText === "string") {
|
|
198
|
+
request.message = confirmText;
|
|
199
|
+
}
|
|
200
|
+
// The run loop is suspended on this card; a Stop while it's open aborts
|
|
201
|
+
// the controller, resolving the decision as declined.
|
|
202
|
+
const signal = this.#host.decision.open();
|
|
203
|
+
// Into the turn's answer group, like every other inline card. Appending
|
|
204
|
+
// to the message list made it a sibling *after* the group, so anything
|
|
205
|
+
// that streamed afterwards rendered above it and the prompt drifted to
|
|
206
|
+
// the foot of the turn no matter when it was asked.
|
|
207
|
+
const decision = requestConfirmation(this.#host.transcript.ensureGroup(), request, {
|
|
208
|
+
signal,
|
|
209
|
+
strings: this.#host.strings(),
|
|
210
|
+
// Offered only where it can be honoured -- see `#confirmationRule`.
|
|
211
|
+
...(rule === "destructive"
|
|
212
|
+
? { onAlwaysAllow: () => this.#sessionApproved.add(call.name) }
|
|
213
|
+
: {}),
|
|
214
|
+
});
|
|
215
|
+
this.#host.transcript.updateEmptyState();
|
|
216
|
+
this.#host.transcript.follow();
|
|
217
|
+
const accepted = await decision;
|
|
218
|
+
this.#host.decision.close();
|
|
219
|
+
card.recordDecision(accepted ? "approved" : "declined");
|
|
220
|
+
if (!accepted) {
|
|
221
|
+
const message = this.#host.strings().declinedAction;
|
|
222
|
+
card.settle(TOOL_CALL_STATUS.DECLINED, message);
|
|
223
|
+
this.#host.transcript.showPending();
|
|
224
|
+
// The one outcome with no error text and no server involvement at all:
|
|
225
|
+
// a person said no in this browser. Nothing else records that, so
|
|
226
|
+
// without the annotation the reload showed a green card for an action
|
|
227
|
+
// the user had explicitly refused.
|
|
228
|
+
return { content: message, outcome: TOOL_OUTCOME.DENIED };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// A navigating tool reloads only without a client-side router; with a
|
|
232
|
+
// host `navigate()` (SPA) it routes in-page and the loop just continues.
|
|
233
|
+
const navigates = isNavigates(tool.parameters) && this.#host.navigate() === null;
|
|
234
|
+
if (navigates) {
|
|
235
|
+
// Checkpoint before the handler reloads the page; the history (incl.
|
|
236
|
+
// this tool call) was already persisted when the run that produced it
|
|
237
|
+
// settled. The result is supplied on the next mount via the resume path.
|
|
238
|
+
this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), { toolCallId: call.id });
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
// The call id lets a handler that renders into the transcript find its
|
|
242
|
+
// own card; handlers that only act on the page ignore it.
|
|
243
|
+
const result = await tool.handler(call.args, call.id);
|
|
244
|
+
// Drawn from the arguments rather than the result, so the live path and
|
|
245
|
+
// the replay path render the same thing from the same input.
|
|
246
|
+
if (tool.render !== undefined) {
|
|
247
|
+
this.#host.transcript.renderToolOutput(tool.render, call);
|
|
248
|
+
}
|
|
249
|
+
if (navigates) {
|
|
250
|
+
card.settle(TOOL_CALL_STATUS.DONE, this.#host.strings().navigating);
|
|
251
|
+
return { content: "", halt: true };
|
|
252
|
+
}
|
|
253
|
+
const content = JSON.stringify(result ?? null);
|
|
254
|
+
card.settle(TOOL_CALL_STATUS.DONE, content);
|
|
255
|
+
this.#host.transcript.showPending();
|
|
256
|
+
return { content };
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (navigates) {
|
|
259
|
+
// The navigation never happened; drop the dangling checkpoint.
|
|
260
|
+
this.#host.conversationStore().saveCheckpoint(this.#host.threadId(), null);
|
|
261
|
+
}
|
|
262
|
+
// The handler's own message, verbatim, in two places at once: the card,
|
|
263
|
+
// which the user sees, and the tool result, which goes to the endpoint,
|
|
264
|
+
// is persisted there and is replayed to the model on every later round.
|
|
265
|
+
// Kept verbatim because a real reason is what lets the agent recover —
|
|
266
|
+
// and said out loud on `registerTool`, because the second destination is
|
|
267
|
+
// invisible from the host's side and is not one it can take back.
|
|
268
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
269
|
+
card.settle(TOOL_CALL_STATUS.ERROR, message);
|
|
270
|
+
this.#host.transcript.showPending();
|
|
271
|
+
return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Render an approval card per server-side-tool interrupt and collect the
|
|
277
|
+
* user's decisions (approve → run it, deny → decline it).
|
|
278
|
+
*
|
|
279
|
+
* **One card per gated call, in that call's own tool card, all at once.** A run
|
|
280
|
+
* can defer several calls, and the wire answers each independently — so the UI
|
|
281
|
+
* has to let a person answer each independently, which means saying which is
|
|
282
|
+
* which. The prompt cannot: it comes from the tool's `x-confirm` and is
|
|
283
|
+
* identical for every call of that tool. The tool card can, by position, and it
|
|
284
|
+
* is already showing the arguments. Asking them serially was the other half of
|
|
285
|
+
* the problem: the second question only appeared once the first was answered,
|
|
286
|
+
* so a person could neither compare them nor tell that more were coming.
|
|
287
|
+
*
|
|
288
|
+
* Each gated card is marked `deferred` for the wait. That is not cosmetic — at
|
|
289
|
+
* `pending` it read "running…" while the stream was over and the server idle.
|
|
290
|
+
*
|
|
291
|
+
* The run is suspended on these cards. A Stop while any is open aborts the
|
|
292
|
+
* shared pending decision, resolving every still-open card as
|
|
293
|
+
* denied. An approved tool runs on the follow-up resume run and streams its
|
|
294
|
+
* result into the same card (returned to `pending`, since it now really is
|
|
295
|
+
* running); a denied one settles here, as no result will ever arrive.
|
|
296
|
+
*/
|
|
297
|
+
async resolveInterrupts(
|
|
298
|
+
interrupts: readonly Interrupt[],
|
|
299
|
+
): Promise<Record<string, InterruptResponse>> {
|
|
300
|
+
// One controller covers the whole batch: a single Stop denies all of them.
|
|
301
|
+
const signal = this.#host.decision.open();
|
|
302
|
+
// The run has stopped and is waiting on a person. Nothing else on screen
|
|
303
|
+
// says so to a screen reader: the cards appear inside the transcript, which
|
|
304
|
+
// is deliberately not a live region, so without this the run simply goes
|
|
305
|
+
// quiet and the user has no reason to go looking.
|
|
306
|
+
this.#host.announce(
|
|
307
|
+
fillUiString(this.#host.strings().announceAwaitingDecision, { count: interrupts.length }),
|
|
308
|
+
);
|
|
309
|
+
this.#host.transcript.hidePending();
|
|
310
|
+
const answered = await Promise.all(
|
|
311
|
+
interrupts.map(async (interrupt) => {
|
|
312
|
+
const card =
|
|
313
|
+
interrupt.toolCallId !== undefined
|
|
314
|
+
? this.#host.transcript.card(interrupt.toolCallId)
|
|
315
|
+
: undefined;
|
|
316
|
+
const request: ApprovalRequest = {};
|
|
317
|
+
const phrase = confirmPhrase(interrupt) ?? interrupt.message;
|
|
318
|
+
if (phrase !== undefined) {
|
|
319
|
+
request.message = phrase;
|
|
320
|
+
}
|
|
321
|
+
const toolName = card?.element.getAttribute("data-tool-name");
|
|
322
|
+
if (toolName !== null && toolName !== undefined) {
|
|
323
|
+
request.toolName = toolName;
|
|
324
|
+
}
|
|
325
|
+
// Offered only where it can be honoured: the host has said its agent
|
|
326
|
+
// accepts `editedArgs`, and this interrupt named a call whose arguments
|
|
327
|
+
// we still hold.
|
|
328
|
+
let editedArgs: Record<string, unknown> | undefined;
|
|
329
|
+
const editable = this.#host.approveWithEdits() && card !== undefined;
|
|
330
|
+
if (editable) {
|
|
331
|
+
request.args = card.args;
|
|
332
|
+
}
|
|
333
|
+
card?.mark(TOOL_CALL_STATUS.DEFERRED);
|
|
334
|
+
// The built-in card renders into the gated call's own card, falling back
|
|
335
|
+
// to the answer group when the interrupt names no call we hold one for.
|
|
336
|
+
const builtIn = (): Promise<boolean> =>
|
|
337
|
+
requestApproval(card?.approvalSlot ?? this.#host.transcript.ensureGroup(), request, {
|
|
338
|
+
signal,
|
|
339
|
+
strings: this.#host.strings(),
|
|
340
|
+
...(editable
|
|
341
|
+
? {
|
|
342
|
+
onEdit: (args: Record<string, unknown>) => {
|
|
343
|
+
editedArgs = args;
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
: {}),
|
|
347
|
+
});
|
|
348
|
+
// A host-supplied renderer takes full control of the approval UI.
|
|
349
|
+
const renderer = this.#host.approvalRenderer();
|
|
350
|
+
let approved: boolean;
|
|
351
|
+
if (renderer === null) {
|
|
352
|
+
approved = await builtIn();
|
|
353
|
+
} else {
|
|
354
|
+
// Awaited here rather than inside a helper, so an answering renderer
|
|
355
|
+
// takes exactly as many turns to be heard as it always did.
|
|
356
|
+
try {
|
|
357
|
+
// Called on the element, as `this.approvalRenderer(...)` always was.
|
|
358
|
+
approved = await renderer.call(this.#host.element, request, { signal });
|
|
359
|
+
} catch (error) {
|
|
360
|
+
approved = await this.#afterRendererFailed(error, signal, interrupt.id, builtIn);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Same annotation as the client-side confirmation gate. Without it the
|
|
364
|
+
// two gates read differently for the same act: a locally-confirmed call
|
|
365
|
+
// said who let it through and a server-gated one said nothing, which is
|
|
366
|
+
// backwards, since the server-side gate is the one guarding the tools
|
|
367
|
+
// that actually run on the backend.
|
|
368
|
+
card?.recordDecision(approved ? "approved" : "declined");
|
|
369
|
+
if (approved) {
|
|
370
|
+
card?.mark(TOOL_CALL_STATUS.PENDING);
|
|
371
|
+
} else {
|
|
372
|
+
// No TOOL_CALL_RESULT will stream for a denied tool — settle its card
|
|
373
|
+
// now rather than leaving it hanging until the onSettled sweep.
|
|
374
|
+
card?.settle(TOOL_CALL_STATUS.DECLINED, this.#host.strings().declinedAction);
|
|
375
|
+
}
|
|
376
|
+
return { id: interrupt.id, approved, editedArgs };
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
this.#host.transcript.updateEmptyState();
|
|
380
|
+
this.#host.transcript.follow();
|
|
381
|
+
this.#host.decision.close();
|
|
382
|
+
const responses: Record<string, InterruptResponse> = {};
|
|
383
|
+
for (const { id, approved, editedArgs } of answered) {
|
|
384
|
+
// `editedArgs` rides only when the user actually changed something, so a
|
|
385
|
+
// server can tell "approved as proposed" from "approved, but like this".
|
|
386
|
+
responses[id] = approved
|
|
387
|
+
? {
|
|
388
|
+
status: "resolved",
|
|
389
|
+
payload: editedArgs === undefined ? { approved: true } : { approved: true, editedArgs },
|
|
390
|
+
}
|
|
391
|
+
: { status: "cancelled" };
|
|
392
|
+
}
|
|
393
|
+
return responses;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Answer an interrupt whose host renderer threw or rejected instead of
|
|
398
|
+
* answering: put it to the built-in card.
|
|
399
|
+
*
|
|
400
|
+
* The renderer is presentation, not a guard: it decides how the question
|
|
401
|
+
* looks, never whether it is asked. Uncaught, one failure rejected the whole
|
|
402
|
+
* batch, so the run ended on an error bubble quoting the host's message, the
|
|
403
|
+
* server was never answered, and the end-of-run sweep settled the gated card
|
|
404
|
+
* as a green "done" for a call that never ran. The built-in card still puts
|
|
405
|
+
* the decision to a person, so nothing runs without a click, and the run
|
|
406
|
+
* carries on as if no renderer had been set. Reported the way a failed
|
|
407
|
+
* `render` is, and for the same reason: survived is not the same as findable.
|
|
408
|
+
*
|
|
409
|
+
* Except when the wait was already abandoned. A renderer honouring its signal
|
|
410
|
+
* rejects once a Stop fires it, which is the signal working rather than the
|
|
411
|
+
* renderer failing, and a card drawn then would ask about a run the user just
|
|
412
|
+
* ended. So it resolves as not approved, which is what the built-in card
|
|
413
|
+
* resolves on the same abort, and says nothing.
|
|
414
|
+
*/
|
|
415
|
+
#afterRendererFailed(
|
|
416
|
+
error: unknown,
|
|
417
|
+
signal: AbortSignal,
|
|
418
|
+
interruptId: string,
|
|
419
|
+
builtIn: () => Promise<boolean>,
|
|
420
|
+
): Promise<boolean> {
|
|
421
|
+
if (signal.aborted) {
|
|
422
|
+
return Promise.resolve(false);
|
|
423
|
+
}
|
|
424
|
+
// Named by interrupt rather than by tool: a batch can gate several calls of
|
|
425
|
+
// one tool, and the id is the one thing that tells them apart.
|
|
426
|
+
console.warn(
|
|
427
|
+
`ag-ui-chat: approvalRenderer failed for interrupt ${interruptId}, so the built-in approval card asks instead`,
|
|
428
|
+
error,
|
|
429
|
+
);
|
|
430
|
+
return builtIn();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Which rule gates `call`, or `null` when it runs straight through, or
|
|
435
|
+
* `"unanswered"` when the host's predicate threw instead of deciding.
|
|
436
|
+
*
|
|
437
|
+
* The rule, rather than a bare boolean, because it decides whether the user
|
|
438
|
+
* may *waive* the prompt for the rest of the session. Only the default
|
|
439
|
+
* `x-destructive` gate is waivable: `confirmPredicate` is documented as
|
|
440
|
+
* authoritative, so letting one click retire it would silently defeat a host
|
|
441
|
+
* policy — and the session allowlist is consulted on the same path it can
|
|
442
|
+
* be added from, so the button is never offered where honouring it would be
|
|
443
|
+
* refused.
|
|
444
|
+
*/
|
|
445
|
+
async #confirmationRule(call: AgUiToolCall, tool: ClientTool): Promise<ConfirmationRule | null> {
|
|
446
|
+
if (this.#host.autoConfirm()) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
const predicate = this.#host.confirmPredicate();
|
|
450
|
+
if (predicate !== null) {
|
|
451
|
+
try {
|
|
452
|
+
// Called on the element, as `this.confirmPredicate(...)` always was.
|
|
453
|
+
return (await predicate.call(this.#host.element, call.name, call.args)) === true
|
|
454
|
+
? "predicate"
|
|
455
|
+
: null;
|
|
456
|
+
} catch (error) {
|
|
457
|
+
// A guard that cannot answer has not said the call is safe, and for a
|
|
458
|
+
// tool with no `x-destructive` flag the predicate is the only guard
|
|
459
|
+
// there is. So it fails closed: the call is refused outright, rather
|
|
460
|
+
// than run, and rather than put to a card whose one click would run
|
|
461
|
+
// what the host's own policy could not vouch for.
|
|
462
|
+
//
|
|
463
|
+
// Uncaught, the throw ended the run on an error bubble quoting the
|
|
464
|
+
// host's message and left this call's card reading "running…" for
|
|
465
|
+
// good, since dispatch had already taken it out of the settle sweep.
|
|
466
|
+
// The message goes to the console instead, where a render failure is
|
|
467
|
+
// reported, and not on to the endpoint: unlike a handler's, it was
|
|
468
|
+
// never written for the model to read.
|
|
469
|
+
console.warn(
|
|
470
|
+
`ag-ui-chat: confirmPredicate failed for tool ${call.name}, so the call was refused`,
|
|
471
|
+
error,
|
|
472
|
+
);
|
|
473
|
+
return "unanswered";
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (this.#sessionApproved.has(call.name)) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
return isDestructive(tool.parameters) ? "destructive" : null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Whether the page moved since the current round's context was built.
|
|
484
|
+
*
|
|
485
|
+
* `null` means no round has built context yet (nothing to compare), which is
|
|
486
|
+
* not a move.
|
|
487
|
+
*/
|
|
488
|
+
#pageMoved(): boolean {
|
|
489
|
+
return this.#contextHref !== null && this.#contextHref !== window.location.href;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* A server-authored question for a gated call, read off the interrupt's metadata.
|
|
495
|
+
*
|
|
496
|
+
* The question an AG-UI interrupt carries by default is the call itself, spelled
|
|
497
|
+
* out: `Approve create_event({"title": "Design sync", …})?`. Accurate, and not
|
|
498
|
+
* something to put in front of a person. A client-side confirmation has
|
|
499
|
+
* `x-confirm` on the tool's schema for exactly this, so the same key is read here
|
|
500
|
+
* — whichever end gates a call, the phrase comes from one place, and a server
|
|
501
|
+
* that supplies none keeps the generated text.
|
|
502
|
+
*
|
|
503
|
+
* Narrowed rather than trusted: `metadata` is `Record<string, any>` on the wire,
|
|
504
|
+
* so anything at all can arrive under that key, and a non-string would render as
|
|
505
|
+
* "[object Object]" in the one place a person is being asked to allow a write.
|
|
506
|
+
*/
|
|
507
|
+
function confirmPhrase(interrupt: Interrupt): string | undefined {
|
|
508
|
+
const phrase = interrupt.metadata?.[X_CONFIRM_KEY];
|
|
509
|
+
return typeof phrase === "string" && phrase.trim() !== "" ? phrase : undefined;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Why a client tool call is gated behind the confirmation card, or refused
|
|
514
|
+
* before one is drawn.
|
|
515
|
+
*
|
|
516
|
+
* Only `"destructive"` -- the default `x-destructive` gate -- may be waived for
|
|
517
|
+
* the session. `confirmPredicate` is documented as authoritative, so a call it
|
|
518
|
+
* gates keeps asking. `"unanswered"` is a predicate that threw instead of
|
|
519
|
+
* answering, and the call it was asked about is refused without a card.
|
|
520
|
+
*/
|
|
521
|
+
type ConfirmationRule = "destructive" | "predicate" | "unanswered";
|
|
@@ -2,6 +2,8 @@ import { randomUUID } from "@ag-ui/client";
|
|
|
2
2
|
import { ATTACHMENT_STATUS } from "../../constants.js";
|
|
3
3
|
import type { AttachmentRef } from "../../core/attachment.js";
|
|
4
4
|
import type { UploadHandler } from "../../core/upload_attachment.js";
|
|
5
|
+
import { commaTokens } from "../../core/utils.js";
|
|
6
|
+
import { fillUiString } from "../fill_ui_string.js";
|
|
5
7
|
import { formatBytes, iconFor } from "../transcript/attachment_chips.js";
|
|
6
8
|
import { DEFAULT_UI_STRINGS, type UiStrings } from "../ui_strings.js";
|
|
7
9
|
|
|
@@ -141,7 +143,7 @@ export class AttachmentTray {
|
|
|
141
143
|
/** The size/type rejection reason for a file, or `null` when accepted. */
|
|
142
144
|
#reject(file: File): string | null {
|
|
143
145
|
if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
|
|
144
|
-
return this.#strings.tooLarge
|
|
146
|
+
return fillUiString(this.#strings.tooLarge, { size: formatBytes(this.#config.maxBytes) });
|
|
145
147
|
}
|
|
146
148
|
if (!accepts(this.#config.accept, file)) {
|
|
147
149
|
return this.#strings.fileTypeNotAllowed;
|
|
@@ -273,10 +275,9 @@ export class AttachmentTray {
|
|
|
273
275
|
|
|
274
276
|
/** Whether `file` matches an `<input accept>` list (`""` accepts anything). */
|
|
275
277
|
function accepts(accept: string, file: File): boolean {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
.filter((token) => token !== "");
|
|
278
|
+
// Lower-cased after the shared split rather than before the filter: no
|
|
279
|
+
// non-empty token lower-cases to an empty one, so the order cannot matter.
|
|
280
|
+
const tokens = commaTokens(accept).map((token) => token.toLowerCase());
|
|
280
281
|
if (tokens.length === 0) {
|
|
281
282
|
return true;
|
|
282
283
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size the field to its content: one row when empty, growing with what is
|
|
3
|
+
* typed until the CSS ceiling takes over and it scrolls.
|
|
4
|
+
*
|
|
5
|
+
* Resetting to `auto` first is what makes it shrink again — `scrollHeight`
|
|
6
|
+
* never reports less than the current height, so measuring without the reset
|
|
7
|
+
* would ratchet the composer taller and never back down.
|
|
8
|
+
*/
|
|
9
|
+
export function autoGrow(input: HTMLTextAreaElement): void {
|
|
10
|
+
input.style.height = "auto";
|
|
11
|
+
input.style.height = `${input.scrollHeight}px`;
|
|
12
|
+
}
|