@ably/ai-transport 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -111
- package/dist/ably-ai-transport.js +2401 -1387
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +116 -42
- package/dist/core/agent.d.ts +44 -0
- package/dist/core/channel-options.d.ts +57 -0
- package/dist/core/codec/codec-event.d.ts +9 -0
- package/dist/core/codec/decoder.d.ts +24 -24
- package/dist/core/codec/define-codec.d.ts +100 -0
- package/dist/core/codec/encoder.d.ts +10 -12
- package/dist/core/codec/field-bag.d.ts +85 -0
- package/dist/core/codec/fields.d.ts +141 -0
- package/dist/core/codec/index.d.ts +8 -2
- package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
- package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
- package/dist/core/codec/input-descriptors.d.ts +281 -0
- package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
- package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
- package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
- package/dist/core/codec/output-descriptors.d.ts +237 -0
- package/dist/core/codec/types.d.ts +470 -119
- package/dist/core/codec/well-known-inputs.d.ts +52 -0
- package/dist/core/transport/agent-session.d.ts +10 -0
- package/dist/core/transport/agent-view.d.ts +296 -0
- package/dist/core/transport/client-session.d.ts +13 -0
- package/dist/core/transport/decode-fold.d.ts +55 -0
- package/dist/core/transport/headers.d.ts +121 -14
- package/dist/core/transport/index.d.ts +5 -6
- package/dist/core/transport/internal/bounded-map.d.ts +20 -0
- package/dist/core/transport/invocation.d.ts +74 -0
- package/dist/core/transport/load-history-pages.d.ts +71 -0
- package/dist/core/transport/load-history.d.ts +44 -0
- package/dist/core/transport/pipe-stream.d.ts +9 -9
- package/dist/core/transport/run-manager.d.ts +76 -0
- package/dist/core/transport/session-support.d.ts +55 -0
- package/dist/core/transport/tree.d.ts +523 -109
- package/dist/core/transport/types/agent.d.ts +375 -0
- package/dist/core/transport/types/client.d.ts +201 -0
- package/dist/core/transport/types/shared.d.ts +24 -0
- package/dist/core/transport/types/tree.d.ts +357 -0
- package/dist/core/transport/types/view.d.ts +249 -0
- package/dist/core/transport/types.d.ts +13 -553
- package/dist/core/transport/view.d.ts +390 -84
- package/dist/core/transport/wire-log.d.ts +102 -0
- package/dist/errors.d.ts +27 -10
- package/dist/index.d.ts +8 -9
- package/dist/logger.d.ts +12 -0
- package/dist/react/ably-ai-transport-react.js +1365 -1010
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/client-session-context.d.ts +37 -0
- package/dist/react/contexts/client-session-provider.d.ts +56 -0
- package/dist/react/create-session-hooks.d.ts +116 -0
- package/dist/react/index.d.ts +13 -12
- package/dist/react/internal/skipped-session.d.ts +8 -0
- package/dist/react/internal/use-resolved-session.d.ts +36 -0
- package/dist/react/use-ably-messages.d.ts +17 -14
- package/dist/react/use-client-session.d.ts +81 -0
- package/dist/react/use-create-view.d.ts +14 -13
- package/dist/react/use-tree.d.ts +30 -15
- package/dist/react/use-view.d.ts +81 -50
- package/dist/utils.d.ts +48 -71
- package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
- package/dist/vercel/codec/events.d.ts +50 -0
- package/dist/vercel/codec/fields.d.ts +44 -0
- package/dist/vercel/codec/fold-content.d.ts +16 -0
- package/dist/vercel/codec/fold-data.d.ts +16 -0
- package/dist/vercel/codec/fold-input.d.ts +67 -0
- package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
- package/dist/vercel/codec/fold-text.d.ts +16 -0
- package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
- package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
- package/dist/vercel/codec/index.d.ts +7 -20
- package/dist/vercel/codec/inputs.d.ts +11 -0
- package/dist/vercel/codec/outputs.d.ts +11 -0
- package/dist/vercel/codec/reducer-state.d.ts +121 -0
- package/dist/vercel/codec/reducer.d.ts +62 -0
- package/dist/vercel/codec/tool-transitions.d.ts +2 -8
- package/dist/vercel/codec/wire-data.d.ts +34 -0
- package/dist/vercel/index.d.ts +5 -5
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -45
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
- package/dist/vercel/react/index.d.ts +1 -2
- package/dist/vercel/react/use-chat-transport.d.ts +30 -26
- package/dist/vercel/react/use-message-sync.d.ts +17 -30
- package/dist/vercel/run-end-reason.d.ts +84 -0
- package/dist/vercel/tool-part.d.ts +21 -0
- package/dist/vercel/transport/chat-transport.d.ts +41 -24
- package/dist/vercel/transport/index.d.ts +24 -20
- package/dist/vercel/transport/run-output-stream.d.ts +54 -0
- package/dist/version.d.ts +2 -0
- package/package.json +31 -24
- package/src/constants.ts +124 -51
- package/src/core/agent.ts +92 -0
- package/src/core/channel-options.ts +89 -0
- package/src/core/codec/codec-event.ts +27 -0
- package/src/core/codec/decoder.ts +202 -105
- package/src/core/codec/define-codec.ts +432 -0
- package/src/core/codec/encoder.ts +114 -107
- package/src/core/codec/field-bag.ts +142 -0
- package/src/core/codec/fields.ts +193 -0
- package/src/core/codec/index.ts +56 -6
- package/src/core/codec/input-descriptor-decoder.ts +97 -0
- package/src/core/codec/input-descriptor-encoder.ts +150 -0
- package/src/core/codec/input-descriptors.ts +373 -0
- package/src/core/codec/lifecycle-tracker.ts +10 -9
- package/src/core/codec/output-descriptor-decoder.ts +139 -0
- package/src/core/codec/output-descriptor-encoder.ts +101 -0
- package/src/core/codec/output-descriptors.ts +307 -0
- package/src/core/codec/types.ts +505 -126
- package/src/core/codec/well-known-inputs.ts +96 -0
- package/src/core/transport/agent-session.ts +1085 -0
- package/src/core/transport/agent-view.ts +738 -0
- package/src/core/transport/client-session.ts +780 -0
- package/src/core/transport/decode-fold.ts +101 -0
- package/src/core/transport/headers.ts +234 -22
- package/src/core/transport/index.ts +27 -27
- package/src/core/transport/internal/bounded-map.ts +27 -0
- package/src/core/transport/invocation.ts +98 -0
- package/src/core/transport/load-history-pages.ts +220 -0
- package/src/core/transport/load-history.ts +271 -0
- package/src/core/transport/pipe-stream.ts +63 -39
- package/src/core/transport/run-manager.ts +243 -0
- package/src/core/transport/session-support.ts +96 -0
- package/src/core/transport/tree.ts +1293 -308
- package/src/core/transport/types/agent.ts +434 -0
- package/src/core/transport/types/client.ts +247 -0
- package/src/core/transport/types/shared.ts +27 -0
- package/src/core/transport/types/tree.ts +393 -0
- package/src/core/transport/types/view.ts +288 -0
- package/src/core/transport/types.ts +13 -706
- package/src/core/transport/view.ts +1229 -450
- package/src/core/transport/wire-log.ts +189 -0
- package/src/errors.ts +29 -9
- package/src/event-emitter.ts +3 -2
- package/src/index.ts +86 -42
- package/src/logger.ts +14 -1
- package/src/react/contexts/client-session-context.ts +41 -0
- package/src/react/contexts/client-session-provider.tsx +222 -0
- package/src/react/create-session-hooks.ts +141 -0
- package/src/react/index.ts +24 -13
- package/src/react/internal/skipped-session.ts +62 -0
- package/src/react/internal/use-resolved-session.ts +63 -0
- package/src/react/use-ably-messages.ts +32 -22
- package/src/react/use-client-session.ts +178 -0
- package/src/react/use-create-view.ts +33 -29
- package/src/react/use-tree.ts +61 -30
- package/src/react/use-view.ts +138 -96
- package/src/utils.ts +83 -131
- package/src/vercel/codec/decode-lifecycle.ts +70 -0
- package/src/vercel/codec/events.ts +85 -0
- package/src/vercel/codec/fields.ts +58 -0
- package/src/vercel/codec/fold-content.ts +54 -0
- package/src/vercel/codec/fold-data.ts +46 -0
- package/src/vercel/codec/fold-input.ts +255 -0
- package/src/vercel/codec/fold-lifecycle.ts +85 -0
- package/src/vercel/codec/fold-text.ts +55 -0
- package/src/vercel/codec/fold-tool-input.ts +86 -0
- package/src/vercel/codec/fold-tool-output.ts +79 -0
- package/src/vercel/codec/index.ts +28 -21
- package/src/vercel/codec/inputs.ts +116 -0
- package/src/vercel/codec/outputs.ts +207 -0
- package/src/vercel/codec/reducer-state.ts +169 -0
- package/src/vercel/codec/reducer.ts +191 -0
- package/src/vercel/codec/tool-transitions.ts +3 -14
- package/src/vercel/codec/wire-data.ts +64 -0
- package/src/vercel/index.ts +7 -19
- package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
- package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
- package/src/vercel/react/index.ts +3 -5
- package/src/vercel/react/use-chat-transport.ts +44 -66
- package/src/vercel/react/use-message-sync.ts +75 -39
- package/src/vercel/run-end-reason.ts +157 -0
- package/src/vercel/tool-part.ts +25 -0
- package/src/vercel/transport/chat-transport.ts +380 -98
- package/src/vercel/transport/index.ts +38 -37
- package/src/vercel/transport/run-output-stream.ts +169 -0
- package/src/version.ts +2 -0
- package/dist/core/transport/client-transport.d.ts +0 -10
- package/dist/core/transport/decode-history.d.ts +0 -43
- package/dist/core/transport/server-transport.d.ts +0 -7
- package/dist/core/transport/stream-router.d.ts +0 -29
- package/dist/core/transport/turn-manager.d.ts +0 -37
- package/dist/react/contexts/transport-context.d.ts +0 -31
- package/dist/react/contexts/transport-provider.d.ts +0 -49
- package/dist/react/create-transport-hooks.d.ts +0 -124
- package/dist/react/use-active-turns.d.ts +0 -12
- package/dist/react/use-client-transport.d.ts +0 -80
- package/dist/vercel/codec/accumulator.d.ts +0 -21
- package/dist/vercel/codec/decoder.d.ts +0 -22
- package/dist/vercel/codec/encoder.d.ts +0 -41
- package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
- package/dist/vercel/tool-approvals.d.ts +0 -124
- package/dist/vercel/tool-events.d.ts +0 -26
- package/src/core/transport/client-transport.ts +0 -977
- package/src/core/transport/decode-history.ts +0 -485
- package/src/core/transport/server-transport.ts +0 -612
- package/src/core/transport/stream-router.ts +0 -136
- package/src/core/transport/turn-manager.ts +0 -165
- package/src/react/contexts/transport-context.ts +0 -37
- package/src/react/contexts/transport-provider.tsx +0 -164
- package/src/react/create-transport-hooks.ts +0 -144
- package/src/react/use-active-turns.ts +0 -72
- package/src/react/use-client-transport.ts +0 -197
- package/src/vercel/codec/accumulator.ts +0 -588
- package/src/vercel/codec/decoder.ts +0 -618
- package/src/vercel/codec/encoder.ts +0 -410
- package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
- package/src/vercel/tool-approvals.ts +0 -380
- package/src/vercel/tool-events.ts +0 -53
|
@@ -1,380 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-side helpers for processing a tool-approval turn.
|
|
3
|
-
*
|
|
4
|
-
* When a Vercel AI SDK tool is marked `needsApproval`, `streamText` pauses
|
|
5
|
-
* after emitting a `dynamic-tool` part in state `approval-requested`. To
|
|
6
|
-
* resume, the server must:
|
|
7
|
-
*
|
|
8
|
-
* 1. Patch the UIMessage history so the pending tool part reflects the
|
|
9
|
-
* user's decision (`approval-responded` or `output-denied`).
|
|
10
|
-
* 2. Strip the client-appended "Approved: …" user message, because
|
|
11
|
-
* `streamText`'s multi-step loop only auto-executes pending tool
|
|
12
|
-
* calls when the conversation ends on a tool/assistant message.
|
|
13
|
-
* 3. Disable `needsApproval` on just-approved tools so the multi-step
|
|
14
|
-
* loop doesn't immediately pause again on the same tool.
|
|
15
|
-
* 4. Redirect the resulting `tool-output-available` / `tool-output-error`
|
|
16
|
-
* chunks back to the ORIGINAL assistant message (the one that held
|
|
17
|
-
* the `approval-requested` part) via `x-ably-amend`, instead of
|
|
18
|
-
* letting them land on the new assistant message this turn produces.
|
|
19
|
-
*
|
|
20
|
-
* `prepareApprovalTurn` covers steps 1–3; `streamResponseWithApprovalRedirect`
|
|
21
|
-
* covers step 4.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import type * as AI from 'ai';
|
|
25
|
-
import { convertToModelMessages } from 'ai';
|
|
26
|
-
|
|
27
|
-
import { HEADER_AMEND } from '../constants.js';
|
|
28
|
-
import type { MessageNode, StreamResponseOptions, StreamResult, Turn } from '../core/transport/types.js';
|
|
29
|
-
import { stripUndefined } from '../utils.js';
|
|
30
|
-
import { toolBase } from './codec/tool-transitions.js';
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Tool-part transition helpers (private — only used by applyToolApprovalsToHistory)
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
// Build the `approval-responded` variant of a DynamicToolUIPart. Pure.
|
|
37
|
-
const applyApprovalResponseToPart = (
|
|
38
|
-
part: AI.DynamicToolUIPart,
|
|
39
|
-
approvalId: string,
|
|
40
|
-
approved: boolean,
|
|
41
|
-
reason: string | undefined,
|
|
42
|
-
): AI.DynamicToolUIPart =>
|
|
43
|
-
stripUndefined({
|
|
44
|
-
...toolBase(part),
|
|
45
|
-
state: 'approval-responded' as const,
|
|
46
|
-
input: part.input,
|
|
47
|
-
approval: stripUndefined({ id: approvalId, approved, reason }),
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
// Build the `output-denied` variant of a DynamicToolUIPart. Pure.
|
|
51
|
-
const applyApprovalDeniedToPart = (part: AI.DynamicToolUIPart, approvalId: string): AI.DynamicToolUIPart => ({
|
|
52
|
-
...toolBase(part),
|
|
53
|
-
state: 'output-denied',
|
|
54
|
-
input: part.input,
|
|
55
|
-
approval: { id: approvalId, approved: false as const },
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
// Wire type
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* A user's decision on a pending tool approval. The client ships an array of
|
|
64
|
-
* these to the server in the POST body; the server feeds them to
|
|
65
|
-
* `prepareApprovalTurn` (to patch history) and
|
|
66
|
-
* `streamResponseWithApprovalRedirect` (to route tool outputs back to the
|
|
67
|
-
* original assistant message).
|
|
68
|
-
*
|
|
69
|
-
* Intentionally does not carry `toolName` or `input` — those are redundant
|
|
70
|
-
* with what's already on the UIMessage history part.
|
|
71
|
-
*/
|
|
72
|
-
export interface ToolApprovalDecision {
|
|
73
|
-
/**
|
|
74
|
-
* The `toolCallId` of the pending `dynamic-tool` part being approved/denied.
|
|
75
|
-
* Must match a part already in the history; decisions that don't match any
|
|
76
|
-
* part are ignored by {@link applyToolApprovalsToHistory}.
|
|
77
|
-
*/
|
|
78
|
-
toolCallId: string;
|
|
79
|
-
/** Whether the user approved or denied the tool call. */
|
|
80
|
-
approved: boolean;
|
|
81
|
-
/**
|
|
82
|
-
* The `x-ably-msg-id` of the assistant message whose `dynamic-tool` part
|
|
83
|
-
* is being responded to. When approved and the tool executes successfully,
|
|
84
|
-
* the output is published cross-turn targeting this message.
|
|
85
|
-
*/
|
|
86
|
-
targetMsgId: string;
|
|
87
|
-
/** Optional reason accompanying the response. */
|
|
88
|
-
reason?: string;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
// History patching
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Patch `dynamic-tool` parts in the history to reflect a batch of approval
|
|
97
|
-
* decisions. Pure — returns a new array; input is not mutated.
|
|
98
|
-
*
|
|
99
|
-
* Approved decisions transition the matching part to `approval-responded`,
|
|
100
|
-
* which `convertToModelMessages` will expand into a `tool-approval-response`
|
|
101
|
-
* model message for `streamText`'s multi-step loop. Denied decisions
|
|
102
|
-
* transition to `output-denied`.
|
|
103
|
-
*
|
|
104
|
-
* Messages and parts whose `toolCallId` is not referenced by any decision
|
|
105
|
-
* are passed through by reference.
|
|
106
|
-
* @param messages - The UIMessage history (user + assistant messages).
|
|
107
|
-
* @param decisions - Approval decisions keyed by `toolCallId`.
|
|
108
|
-
* @returns A new array with matching tool parts transitioned.
|
|
109
|
-
*/
|
|
110
|
-
export const applyToolApprovalsToHistory = (
|
|
111
|
-
messages: AI.UIMessage[],
|
|
112
|
-
decisions: ToolApprovalDecision[],
|
|
113
|
-
): AI.UIMessage[] => {
|
|
114
|
-
if (decisions.length === 0) return messages;
|
|
115
|
-
const byToolCallId = new Map(decisions.map((d) => [d.toolCallId, d]));
|
|
116
|
-
|
|
117
|
-
return messages.map((msg) => {
|
|
118
|
-
let patchedParts: AI.UIMessage['parts'] | undefined;
|
|
119
|
-
|
|
120
|
-
for (const [index, part] of msg.parts.entries()) {
|
|
121
|
-
if (part.type !== 'dynamic-tool') continue;
|
|
122
|
-
const decision = byToolCallId.get(part.toolCallId);
|
|
123
|
-
if (!decision) continue;
|
|
124
|
-
|
|
125
|
-
// Preserve an existing approval id if the part already has one
|
|
126
|
-
// (it was set when the approval-request chunk arrived); otherwise mint
|
|
127
|
-
// a new id so the emitted tool-approval-response has a stable handle.
|
|
128
|
-
const approvalId = part.approval?.id ?? crypto.randomUUID();
|
|
129
|
-
const replacement = decision.approved
|
|
130
|
-
? applyApprovalResponseToPart(part, approvalId, true, decision.reason)
|
|
131
|
-
: applyApprovalDeniedToPart(part, approvalId);
|
|
132
|
-
|
|
133
|
-
patchedParts ??= [...msg.parts];
|
|
134
|
-
patchedParts[index] = replacement;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return patchedParts ? { ...msg, parts: patchedParts } : msg;
|
|
138
|
-
});
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
// Tool manipulation
|
|
143
|
-
// ---------------------------------------------------------------------------
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Derive the set of tool names that have just been approved by walking the
|
|
147
|
-
* (pre-patch) history for `dynamic-tool` parts whose `toolCallId` matches an
|
|
148
|
-
* approved decision.
|
|
149
|
-
* @param messages - The full UIMessage history.
|
|
150
|
-
* @param decisions - Approval decisions for this request.
|
|
151
|
-
* @returns The set of tool names that were just approved.
|
|
152
|
-
*/
|
|
153
|
-
const approvedToolNames = (messages: AI.UIMessage[], decisions: ToolApprovalDecision[]): Set<string> => {
|
|
154
|
-
const approvedIds = new Set(decisions.filter((d) => d.approved).map((d) => d.toolCallId));
|
|
155
|
-
if (approvedIds.size === 0) return new Set();
|
|
156
|
-
|
|
157
|
-
const names = new Set<string>();
|
|
158
|
-
for (const msg of messages) {
|
|
159
|
-
for (const part of msg.parts) {
|
|
160
|
-
if (part.type === 'dynamic-tool' && approvedIds.has(part.toolCallId)) {
|
|
161
|
-
names.add(part.toolName);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return names;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Return a tool dict with `needsApproval: false` forced on any tool whose
|
|
170
|
-
* name is in `approvedNames`. Prevents an infinite approval loop when
|
|
171
|
-
* `streamText`'s multi-step loop calls an approved tool again after
|
|
172
|
-
* executing it.
|
|
173
|
-
*
|
|
174
|
-
* The generic uses `object` (not `AI.Tool`) for its value constraint so
|
|
175
|
-
* duplicate peer-dep resolutions — common when the SDK and the consuming app
|
|
176
|
-
* each pull their own copy of `ai` — still type-check. Every real Vercel Tool
|
|
177
|
-
* is structurally an object, so the constraint holds in practice.
|
|
178
|
-
* @param tools - The tool dictionary.
|
|
179
|
-
* @param approvedNames - Names of tools whose `needsApproval` should be disabled.
|
|
180
|
-
* @returns A new tool dict with the flag cleared on matching entries; input returned unchanged when the set is empty.
|
|
181
|
-
*/
|
|
182
|
-
const disableApprovalFor = <T extends Record<string, object>>(tools: T, approvedNames: ReadonlySet<string>): T => {
|
|
183
|
-
if (approvedNames.size === 0) return tools;
|
|
184
|
-
const entries = Object.entries(tools).map(([name, def]) =>
|
|
185
|
-
approvedNames.has(name) ? ([name, { ...def, needsApproval: false }] as const) : ([name, def] as const),
|
|
186
|
-
);
|
|
187
|
-
// CAST: Object.fromEntries loses the exact T shape in its return type, but
|
|
188
|
-
// we preserve every key and only set an existing optional field, so the T
|
|
189
|
-
// contract holds at runtime.
|
|
190
|
-
return Object.fromEntries(entries) as T;
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
// ---------------------------------------------------------------------------
|
|
194
|
-
// Orchestration
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
|
|
197
|
-
/** Options for {@link prepareApprovalTurn}. */
|
|
198
|
-
export interface PrepareApprovalTurnOptions<T extends Record<string, object>> {
|
|
199
|
-
/** The full UIMessage history (user + assistant messages for this conversation). */
|
|
200
|
-
messages: AI.UIMessage[];
|
|
201
|
-
/** The user's approval decisions for this request, if any. */
|
|
202
|
-
decisions: ToolApprovalDecision[] | undefined;
|
|
203
|
-
/**
|
|
204
|
-
* The tool dictionary that will be passed to `streamText`. Typed with a
|
|
205
|
-
* structural `object` value constraint so it accepts `Record<string, Tool>`
|
|
206
|
-
* regardless of which copy of the `ai` peer dep typed it.
|
|
207
|
-
*/
|
|
208
|
-
tools: T;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** Result of {@link prepareApprovalTurn}. */
|
|
212
|
-
export interface PrepareApprovalTurnResult<T extends Record<string, object>> {
|
|
213
|
-
/** Model-format messages ready to pass to `streamText({ messages })`. */
|
|
214
|
-
modelMessages: AI.ModelMessage[];
|
|
215
|
-
/** Tools with `needsApproval` disabled for any tool that was just approved. */
|
|
216
|
-
tools: T;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* One-shot transform to ready a history + tool dict for a `streamText` call
|
|
221
|
-
* on an approval turn. Returns the patched model-message array and the
|
|
222
|
-
* effective tools dict.
|
|
223
|
-
*
|
|
224
|
-
* When `decisions` is absent or empty, this is a thin wrapper around
|
|
225
|
-
* `convertToModelMessages(messages)` that returns the original tools — so
|
|
226
|
-
* callers can use it uniformly regardless of whether the request carries
|
|
227
|
-
* approvals.
|
|
228
|
-
* @param options - See {@link PrepareApprovalTurnOptions}.
|
|
229
|
-
* @returns See {@link PrepareApprovalTurnResult}.
|
|
230
|
-
*/
|
|
231
|
-
export const prepareApprovalTurn = async <T extends Record<string, object>>(
|
|
232
|
-
options: PrepareApprovalTurnOptions<T>,
|
|
233
|
-
): Promise<PrepareApprovalTurnResult<T>> => {
|
|
234
|
-
const { messages, decisions, tools } = options;
|
|
235
|
-
|
|
236
|
-
if (!decisions || decisions.length === 0) {
|
|
237
|
-
return { modelMessages: await convertToModelMessages(messages), tools };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const patched = applyToolApprovalsToHistory(messages, decisions);
|
|
241
|
-
const converted = await convertToModelMessages(patched);
|
|
242
|
-
|
|
243
|
-
// Strip the client-appended "Approved: …" / "Denied: …" user message so
|
|
244
|
-
// `streamText`'s multi-step loop auto-executes the pending tool call.
|
|
245
|
-
const modelMessages = converted.at(-1)?.role === 'user' ? converted.slice(0, -1) : converted;
|
|
246
|
-
|
|
247
|
-
const effectiveTools = disableApprovalFor(tools, approvedToolNames(messages, decisions));
|
|
248
|
-
|
|
249
|
-
return { modelMessages, tools: effectiveTools };
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
// ---------------------------------------------------------------------------
|
|
253
|
-
// Stream response with cross-turn redirect
|
|
254
|
-
// ---------------------------------------------------------------------------
|
|
255
|
-
|
|
256
|
-
/** Options for {@link streamResponseWithApprovalRedirect}. */
|
|
257
|
-
export interface StreamResponseWithApprovalRedirectOptions extends StreamResponseOptions<AI.UIMessageChunk> {
|
|
258
|
-
/**
|
|
259
|
-
* The approval decisions this turn is resolving. Only approved decisions
|
|
260
|
-
* redirect tool outputs — denied decisions have already been reflected
|
|
261
|
-
* in the history and produce no tool output to capture.
|
|
262
|
-
*/
|
|
263
|
-
decisions: ToolApprovalDecision[] | undefined;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Pipe a UIMessage chunk stream through the turn's encoder, but redirect
|
|
268
|
-
* `tool-output-available` / `tool-output-error` chunks for approved tools to
|
|
269
|
-
* the original assistant message via `x-ably-amend`.
|
|
270
|
-
*
|
|
271
|
-
* Without this redirect, the tool output would land on the new assistant
|
|
272
|
-
* message produced this turn — leaving the original message stuck in
|
|
273
|
-
* `approval-responded` state. The redirect uses a per-event
|
|
274
|
-
* {@link StreamResponseOptions.resolveWriteOptions} hook: when a matching
|
|
275
|
-
* chunk reaches the encoder, it is published with the target's `msgId`
|
|
276
|
-
* and an `x-ably-amend` header so the client merges the output onto the
|
|
277
|
-
* original message instead of the current-turn one.
|
|
278
|
-
*
|
|
279
|
-
* To preserve "no amendments on cancel" semantics — a partial turn must
|
|
280
|
-
* not leave torn-off tool outputs on the original message — redirect-
|
|
281
|
-
* target chunks are held in a small TransformStream buffer and only
|
|
282
|
-
* released to the encoder when the source stream closes normally. If the
|
|
283
|
-
* turn's `abortSignal` fires before the flush, the buffer is discarded.
|
|
284
|
-
* Non-redirect chunks are enqueued inline and are unaffected by the buffer.
|
|
285
|
-
* @param turn - The active server turn.
|
|
286
|
-
* @param stream - The UIMessage chunk stream to pipe through the encoder.
|
|
287
|
-
* @param options - Stream options plus the approval decisions to redirect.
|
|
288
|
-
* @returns The underlying `streamResponse` result.
|
|
289
|
-
*/
|
|
290
|
-
// The redirect-eligible subset of UIMessageChunk — narrow enough for the type
|
|
291
|
-
// guard below to tell TypeScript that `event.toolCallId` is defined.
|
|
292
|
-
type RedirectTargetChunk = Extract<AI.UIMessageChunk, { type: 'tool-output-available' | 'tool-output-error' }>;
|
|
293
|
-
|
|
294
|
-
export const streamResponseWithApprovalRedirect = (
|
|
295
|
-
turn: Turn<AI.UIMessageChunk, AI.UIMessage>,
|
|
296
|
-
stream: ReadableStream<AI.UIMessageChunk>,
|
|
297
|
-
options: StreamResponseWithApprovalRedirectOptions,
|
|
298
|
-
// eslint-disable-next-line @typescript-eslint/promise-function-async -- body only returns other promises; an async wrapper would add a pointless microtask hop
|
|
299
|
-
): Promise<StreamResult> => {
|
|
300
|
-
const { decisions, ...streamOptions } = options;
|
|
301
|
-
|
|
302
|
-
const targets = new Map<string, string>();
|
|
303
|
-
for (const decision of decisions ?? []) {
|
|
304
|
-
if (decision.approved) targets.set(decision.toolCallId, decision.targetMsgId);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
if (targets.size === 0) return turn.streamResponse(stream, streamOptions);
|
|
308
|
-
|
|
309
|
-
const isRedirectTarget = (event: AI.UIMessageChunk): event is RedirectTargetChunk =>
|
|
310
|
-
(event.type === 'tool-output-available' || event.type === 'tool-output-error') && targets.has(event.toolCallId);
|
|
311
|
-
|
|
312
|
-
const buffer: AI.UIMessageChunk[] = [];
|
|
313
|
-
const guarded = stream.pipeThrough(
|
|
314
|
-
new TransformStream<AI.UIMessageChunk, AI.UIMessageChunk>({
|
|
315
|
-
transform: (chunk, controller) => {
|
|
316
|
-
if (isRedirectTarget(chunk)) {
|
|
317
|
-
buffer.push(chunk);
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
controller.enqueue(chunk);
|
|
321
|
-
},
|
|
322
|
-
flush: (controller) => {
|
|
323
|
-
if (turn.abortSignal.aborted) return;
|
|
324
|
-
for (const chunk of buffer) controller.enqueue(chunk);
|
|
325
|
-
},
|
|
326
|
-
}),
|
|
327
|
-
);
|
|
328
|
-
|
|
329
|
-
return turn.streamResponse(guarded, {
|
|
330
|
-
...streamOptions,
|
|
331
|
-
resolveWriteOptions: (event) => {
|
|
332
|
-
if (!isRedirectTarget(event)) return;
|
|
333
|
-
const target = targets.get(event.toolCallId);
|
|
334
|
-
if (target === undefined) return;
|
|
335
|
-
return { messageId: target, extras: { headers: { [HEADER_AMEND]: target } } };
|
|
336
|
-
},
|
|
337
|
-
});
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
// ---------------------------------------------------------------------------
|
|
341
|
-
// History-scan helper (useChat-style routes)
|
|
342
|
-
// ---------------------------------------------------------------------------
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Walk the conversation history and synthesize a {@link ToolApprovalDecision}
|
|
346
|
-
* for each `dynamic-tool` part in `approval-responded` (approved) or
|
|
347
|
-
* `output-denied` (denied) state.
|
|
348
|
-
*
|
|
349
|
-
* Use in server routes where the client flips the tool part state directly
|
|
350
|
-
* (via useChat's `addToolApprovalResponse` and our
|
|
351
|
-
* `useStagedAddToolApprovalResponse`) and ships it through the history
|
|
352
|
-
* overlay instead of a separate `toolApprovals` body field.
|
|
353
|
-
* @param history - The conversation history nodes from the POST body.
|
|
354
|
-
* @returns Approval decisions derived from the history, in walk order.
|
|
355
|
-
*/
|
|
356
|
-
export const extractApprovalDecisionsFromHistory = (
|
|
357
|
-
history: readonly MessageNode<AI.UIMessage>[],
|
|
358
|
-
): ToolApprovalDecision[] => {
|
|
359
|
-
const decisions: ToolApprovalDecision[] = [];
|
|
360
|
-
for (const node of history) {
|
|
361
|
-
for (const part of node.message.parts) {
|
|
362
|
-
if (part.type !== 'dynamic-tool') continue;
|
|
363
|
-
if (part.state === 'approval-responded') {
|
|
364
|
-
decisions.push({
|
|
365
|
-
toolCallId: part.toolCallId,
|
|
366
|
-
approved: true,
|
|
367
|
-
targetMsgId: node.msgId,
|
|
368
|
-
...(part.approval.reason === undefined ? {} : { reason: part.approval.reason }),
|
|
369
|
-
});
|
|
370
|
-
} else if (part.state === 'output-denied') {
|
|
371
|
-
decisions.push({
|
|
372
|
-
toolCallId: part.toolCallId,
|
|
373
|
-
approved: false,
|
|
374
|
-
targetMsgId: node.msgId,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
return decisions;
|
|
380
|
-
};
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-side helper for folding client-shipped events into an in-memory
|
|
3
|
-
* history array before handing it to `convertToModelMessages` / `streamText`.
|
|
4
|
-
*
|
|
5
|
-
* When a client-executed tool resolves, the client stages the resulting
|
|
6
|
-
* `tool-output-available` / `tool-output-error` chunk via
|
|
7
|
-
* `transport.stageEvents(msgId, [...])`. The next send flushes it into the
|
|
8
|
-
* POST body's `events` field. The server republishes the event on the
|
|
9
|
-
* channel via `turn.addEvents`, and must also merge it into the in-memory
|
|
10
|
-
* history so the LLM sees the tool result this turn.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type * as AI from 'ai';
|
|
14
|
-
|
|
15
|
-
import type { EventsNode, MessageNode } from '../core/transport/types.js';
|
|
16
|
-
import { createAccumulator } from './codec/accumulator.js';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Fold a batch of client-shipped events into an in-memory history array.
|
|
20
|
-
*
|
|
21
|
-
* Mirrors the optimistic tree update in
|
|
22
|
-
* `DefaultClientTransport._internalSend` (src/core/transport/client-transport.ts)
|
|
23
|
-
* so the server can rebuild the same message state before handing it to
|
|
24
|
-
* `convertToModelMessages` / `streamText`.
|
|
25
|
-
* @param events - The events shipped by the client.
|
|
26
|
-
* @param nodes - The history messages from the POST body.
|
|
27
|
-
* @returns A new array with tool-result events applied to the matching
|
|
28
|
-
* messages. Non-targeted messages are passed through unchanged.
|
|
29
|
-
*/
|
|
30
|
-
export const applyToolEventsToHistory = (
|
|
31
|
-
events: EventsNode<AI.UIMessageChunk>[],
|
|
32
|
-
nodes: MessageNode<AI.UIMessage>[],
|
|
33
|
-
): MessageNode<AI.UIMessage>[] => {
|
|
34
|
-
if (events.length === 0) return nodes;
|
|
35
|
-
const eventsByMsgId = new Map(events.map((e) => [e.msgId, e]));
|
|
36
|
-
|
|
37
|
-
return nodes.map((node) => {
|
|
38
|
-
const evNode = eventsByMsgId.get(node.msgId);
|
|
39
|
-
if (!evNode) return node;
|
|
40
|
-
|
|
41
|
-
const accumulator = createAccumulator();
|
|
42
|
-
accumulator.initMessage(node.msgId, node.message);
|
|
43
|
-
accumulator.processOutputs(
|
|
44
|
-
evNode.events.map((event) => ({
|
|
45
|
-
kind: 'event' as const,
|
|
46
|
-
event,
|
|
47
|
-
messageId: node.msgId,
|
|
48
|
-
})),
|
|
49
|
-
);
|
|
50
|
-
const updated = accumulator.messages.at(-1);
|
|
51
|
-
return updated ? { ...node, message: updated } : node;
|
|
52
|
-
});
|
|
53
|
-
};
|