@assistant-ui/react-ag-ui 0.0.56 → 0.0.58
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 +16 -0
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts +5 -17
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +133 -234
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +54 -25
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +31 -4
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +419 -123
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +9 -0
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +4 -1
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +3 -1
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
- package/dist/runtime/adapter/tool-approval.js +11 -12
- package/dist/runtime/adapter/tool-approval.js.map +1 -1
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +176 -45
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/types.d.ts +42 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +12 -9
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +10 -10
- package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
- package/src/runtime/AgUiThreadRuntimeCore.ts +190 -319
- package/src/runtime/adapter/conversions.ts +128 -36
- package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
- package/src/runtime/adapter/run-aggregator.ts +670 -148
- package/src/runtime/adapter/subscriber.ts +10 -1
- package/src/runtime/adapter/tool-approval.test.ts +75 -0
- package/src/runtime/adapter/tool-approval.ts +17 -12
- package/src/runtime/event-parser.ts +216 -36
- package/src/runtime/types.ts +70 -12
- package/src/useAgUiRuntime.approval.test.tsx +34 -24
- package/src/useAgUiRuntime.history.test.tsx +76 -0
- package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
- package/src/useAgUiRuntime.ts +12 -9
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { InputContent, RunAgentParameters } from "@ag-ui/client";
|
|
4
|
+
import { generateId } from "@assistant-ui/core";
|
|
4
5
|
import type {
|
|
5
6
|
ThreadMessageLike as CoreThreadMessageLike,
|
|
6
7
|
PartProviderMetadata,
|
|
8
|
+
ThreadMessage,
|
|
7
9
|
ToolCallMessagePartMcpMetadata,
|
|
8
10
|
ToolModelContentPart,
|
|
9
11
|
} from "@assistant-ui/core";
|
|
10
12
|
import {
|
|
11
13
|
getAutoStatus,
|
|
12
|
-
httpUrlPattern,
|
|
13
14
|
parseDataUrl,
|
|
15
|
+
resolveFilePartSource,
|
|
14
16
|
} from "@assistant-ui/core/internal";
|
|
15
17
|
import { type Tool, toToolsJSONSchema } from "assistant-stream";
|
|
16
18
|
import type { ReadonlyJSONObject } from "assistant-stream/utils";
|
|
@@ -105,8 +107,17 @@ type ToolCallPart = {
|
|
|
105
107
|
modelContent?: readonly ToolModelContentPart[];
|
|
106
108
|
unstable_toolMessageId?: string;
|
|
107
109
|
mcp?: ToolCallMessagePartMcpMetadata;
|
|
110
|
+
messages?: readonly ThreadMessage[];
|
|
111
|
+
approval?: CoreToolCallPartApproval;
|
|
108
112
|
};
|
|
109
113
|
|
|
114
|
+
type CoreToolCallPartApproval = NonNullable<
|
|
115
|
+
Extract<
|
|
116
|
+
Exclude<CoreThreadMessageLike["content"], string>[number],
|
|
117
|
+
{ type: "tool-call" }
|
|
118
|
+
>["approval"]
|
|
119
|
+
>;
|
|
120
|
+
|
|
110
121
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
111
122
|
typeof value === "object" && value !== null;
|
|
112
123
|
|
|
@@ -177,13 +188,6 @@ function parseJSONText(value: string): unknown {
|
|
|
177
188
|
}
|
|
178
189
|
}
|
|
179
190
|
|
|
180
|
-
function generateId(): string {
|
|
181
|
-
return (
|
|
182
|
-
(globalThis.crypto as { randomUUID?: () => string })?.randomUUID?.() ??
|
|
183
|
-
Math.random().toString(36).slice(2)
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
191
|
function normalizeToolCall(part: ToolCallPart): {
|
|
188
192
|
id: string;
|
|
189
193
|
call: AgUiToolCall;
|
|
@@ -242,17 +246,20 @@ function buildInputSource(
|
|
|
242
246
|
declaredMimeType: string | undefined,
|
|
243
247
|
sourceType?: string,
|
|
244
248
|
): InputContentSource {
|
|
245
|
-
|
|
249
|
+
const source = resolveFilePartSource({
|
|
250
|
+
data: value,
|
|
251
|
+
mimeType: declaredMimeType ?? "application/octet-stream",
|
|
252
|
+
sourceType,
|
|
253
|
+
});
|
|
254
|
+
if (source.kind === "url") {
|
|
246
255
|
return declaredMimeType !== undefined
|
|
247
|
-
? { type: "url", value, mimeType: declaredMimeType }
|
|
248
|
-
: { type: "url", value };
|
|
256
|
+
? { type: "url", value: source.url, mimeType: declaredMimeType }
|
|
257
|
+
: { type: "url", value: source.url };
|
|
249
258
|
}
|
|
250
|
-
const parsed = parseDataUrl(value);
|
|
251
259
|
return {
|
|
252
260
|
type: "data",
|
|
253
|
-
value:
|
|
254
|
-
mimeType:
|
|
255
|
-
parsed?.mimeType ?? declaredMimeType ?? "application/octet-stream",
|
|
261
|
+
value: source.data,
|
|
262
|
+
mimeType: source.mimeType,
|
|
256
263
|
};
|
|
257
264
|
}
|
|
258
265
|
|
|
@@ -662,7 +669,7 @@ function toAssistantSnapshotMessage(
|
|
|
662
669
|
}
|
|
663
670
|
|
|
664
671
|
function toUserOrSystemSnapshotMessage(
|
|
665
|
-
role: "user" | "system",
|
|
672
|
+
role: "user" | "system" | "developer",
|
|
666
673
|
rawMessage: Record<string, unknown>,
|
|
667
674
|
): CoreThreadMessageLike {
|
|
668
675
|
const messageName = getString(rawMessage, "name");
|
|
@@ -670,10 +677,23 @@ function toUserOrSystemSnapshotMessage(
|
|
|
670
677
|
role === "user" ? toSnapshotAttachments(rawMessage.content) : [];
|
|
671
678
|
return {
|
|
672
679
|
id: getString(rawMessage, "id") ?? generateId(),
|
|
673
|
-
role
|
|
680
|
+
// The internal message model has no developer role; it rides as a system
|
|
681
|
+
// message with its wire role kept in metadata so the export restores it.
|
|
682
|
+
role: role === "developer" ? "system" : role,
|
|
674
683
|
content: extractText(rawMessage.content),
|
|
675
684
|
...(messageName !== undefined ? { name: messageName } : {}),
|
|
676
685
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
686
|
+
...(role === "developer"
|
|
687
|
+
? {
|
|
688
|
+
metadata: {
|
|
689
|
+
custom: {
|
|
690
|
+
[AG_UI_METADATA_NAMESPACE]: {
|
|
691
|
+
role: "developer",
|
|
692
|
+
} satisfies AgUiCustomMetadata,
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
}
|
|
696
|
+
: {}),
|
|
677
697
|
};
|
|
678
698
|
}
|
|
679
699
|
|
|
@@ -939,7 +959,7 @@ export function fromAgUiMessages(
|
|
|
939
959
|
continue;
|
|
940
960
|
}
|
|
941
961
|
|
|
942
|
-
if (role === "user" || role === "system") {
|
|
962
|
+
if (role === "user" || role === "system" || role === "developer") {
|
|
943
963
|
converted.push(toUserOrSystemSnapshotMessage(role, rawMessage));
|
|
944
964
|
}
|
|
945
965
|
}
|
|
@@ -1041,36 +1061,96 @@ function convertAssistantMessage(
|
|
|
1041
1061
|
return;
|
|
1042
1062
|
}
|
|
1043
1063
|
|
|
1064
|
+
// A subagent's tool calls live on nested assistant messages. Before
|
|
1065
|
+
// subagent attribution they flattened to root and went out with this
|
|
1066
|
+
// assistant record as their antecedent, so the resume payload restores
|
|
1067
|
+
// exactly that shape: the calls join this record's toolCalls and their
|
|
1068
|
+
// results follow as tool records — never a tool record without its call.
|
|
1069
|
+
// The nested assistant content itself is backend-owned state and is not
|
|
1070
|
+
// re-sent.
|
|
1071
|
+
const nestedToolCalls: {
|
|
1072
|
+
id: string;
|
|
1073
|
+
call: AgUiToolCall;
|
|
1074
|
+
part: ToolCallPart;
|
|
1075
|
+
}[] = [];
|
|
1076
|
+
for (const { part } of toolCalls) {
|
|
1077
|
+
collectNestedToolCalls(part, nestedToolCalls);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1044
1080
|
converted.push({
|
|
1045
1081
|
id: message.id,
|
|
1046
1082
|
role: "assistant",
|
|
1047
1083
|
content,
|
|
1048
1084
|
...(message.name ? { name: message.name } : {}),
|
|
1049
|
-
...(toolCalls.length > 0
|
|
1050
|
-
? {
|
|
1085
|
+
...(toolCalls.length + nestedToolCalls.length > 0
|
|
1086
|
+
? {
|
|
1087
|
+
toolCalls: [...toolCalls, ...nestedToolCalls].map(
|
|
1088
|
+
(entry) => entry.call,
|
|
1089
|
+
),
|
|
1090
|
+
}
|
|
1051
1091
|
: {}),
|
|
1052
1092
|
});
|
|
1053
1093
|
|
|
1054
1094
|
for (const { id: toolCallId, part } of toolCalls) {
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1095
|
+
emitToolResult(toolCallId, part, converted);
|
|
1096
|
+
}
|
|
1097
|
+
for (const { id: toolCallId, part } of nestedToolCalls) {
|
|
1098
|
+
// A result recorded while the call's approval gate is still open must not
|
|
1099
|
+
// reach the backend as if the gate had been decided.
|
|
1100
|
+
const gateOpen =
|
|
1101
|
+
part.approval != null &&
|
|
1102
|
+
part.approval.approved === undefined &&
|
|
1103
|
+
part.approval.resolution === undefined;
|
|
1104
|
+
if (gateOpen) continue;
|
|
1105
|
+
emitToolResult(toolCallId, part, converted);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1063
1108
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1109
|
+
function collectNestedToolCalls(
|
|
1110
|
+
part: ToolCallPart,
|
|
1111
|
+
out: { id: string; call: AgUiToolCall; part: ToolCallPart }[],
|
|
1112
|
+
): void {
|
|
1113
|
+
for (const nested of part.messages ?? []) {
|
|
1114
|
+
if (!isObject(nested) || nested.role !== "assistant") continue;
|
|
1115
|
+
const nestedContent = Array.isArray(nested.content) ? nested.content : [];
|
|
1116
|
+
for (const nestedPart of nestedContent) {
|
|
1117
|
+
if (!isObject(nestedPart) || nestedPart.type !== "tool-call") continue;
|
|
1118
|
+
const nestedToolCall = nestedPart as ToolCallPart;
|
|
1119
|
+
if (
|
|
1120
|
+
typeof nestedToolCall.toolCallId !== "string" ||
|
|
1121
|
+
nestedToolCall.toolCallId.startsWith("a2ui:")
|
|
1122
|
+
) {
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
out.push({ ...normalizeToolCall(nestedToolCall), part: nestedToolCall });
|
|
1126
|
+
collectNestedToolCalls(nestedToolCall, out);
|
|
1127
|
+
}
|
|
1071
1128
|
}
|
|
1072
1129
|
}
|
|
1073
1130
|
|
|
1131
|
+
function emitToolResult(
|
|
1132
|
+
toolCallId: string,
|
|
1133
|
+
part: ToolCallPart,
|
|
1134
|
+
converted: AgUiMessage[],
|
|
1135
|
+
): void {
|
|
1136
|
+
if (part.result === undefined) return;
|
|
1137
|
+
|
|
1138
|
+
const resultContent =
|
|
1139
|
+
part.modelContent !== undefined
|
|
1140
|
+
? extractText(part.modelContent)
|
|
1141
|
+
: typeof part.result === "string"
|
|
1142
|
+
? part.result
|
|
1143
|
+
: JSON.stringify(part.result);
|
|
1144
|
+
|
|
1145
|
+
converted.push({
|
|
1146
|
+
id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
|
|
1147
|
+
role: "tool",
|
|
1148
|
+
content: resultContent,
|
|
1149
|
+
toolCallId,
|
|
1150
|
+
...(part.isError ? { error: resultContent } : {}),
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1074
1154
|
function convertToolMessage(
|
|
1075
1155
|
message: NormalizedThreadMessageLike,
|
|
1076
1156
|
converted: AgUiMessage[],
|
|
@@ -1137,9 +1217,21 @@ export function toAgUiMessages(
|
|
|
1137
1217
|
}
|
|
1138
1218
|
|
|
1139
1219
|
if (message.role === "system" || message.role === "developer") {
|
|
1220
|
+
const custom = isObject(message.metadata)
|
|
1221
|
+
? message.metadata.custom
|
|
1222
|
+
: undefined;
|
|
1223
|
+
const namespaced = isObject(custom)
|
|
1224
|
+
? custom[AG_UI_METADATA_NAMESPACE]
|
|
1225
|
+
: undefined;
|
|
1226
|
+
const wireRole =
|
|
1227
|
+
message.role === "system" &&
|
|
1228
|
+
isObject(namespaced) &&
|
|
1229
|
+
namespaced.role === "developer"
|
|
1230
|
+
? ("developer" as const)
|
|
1231
|
+
: message.role;
|
|
1140
1232
|
converted.push({
|
|
1141
1233
|
id: message.id,
|
|
1142
|
-
role:
|
|
1234
|
+
role: wireRole,
|
|
1143
1235
|
content: extractText(message.content),
|
|
1144
1236
|
...(message.name ? { name: message.name } : {}),
|
|
1145
1237
|
});
|
|
@@ -100,4 +100,132 @@ describe("RunAggregator tool approval projection", () => {
|
|
|
100
100
|
?.interrupts,
|
|
101
101
|
).toEqual([GATE]);
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `boundToolCallIds` names every call the run produced, root or nested, so a
|
|
106
|
+
* gate on a call inside a subagent message is bound and projects onto the
|
|
107
|
+
* nested part rather than collapsing the batch. The approval seams walk
|
|
108
|
+
* `ToolCallMessagePart.messages`, which is what makes a subagent-scoped gate
|
|
109
|
+
* answerable the same way a root one is.
|
|
110
|
+
*/
|
|
111
|
+
it("projects a batch that names a subagent-scoped tool call onto both the root and nested parts", () => {
|
|
112
|
+
const emitted: ChatModelRunResult[] = [];
|
|
113
|
+
const aggregator = new RunAggregator({
|
|
114
|
+
showThinking: false,
|
|
115
|
+
logger: noopLogger,
|
|
116
|
+
emit: (result) => {
|
|
117
|
+
emitted.push(result);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const ROOT_GATE: AgUiInterrupt = {
|
|
122
|
+
id: "int-root",
|
|
123
|
+
reason: "tool_call",
|
|
124
|
+
toolCallId: "tc-root",
|
|
125
|
+
message: "Delete /tmp/root?",
|
|
126
|
+
};
|
|
127
|
+
const SUBAGENT_GATE: AgUiInterrupt = {
|
|
128
|
+
id: "int-sub",
|
|
129
|
+
reason: "tool_call",
|
|
130
|
+
toolCallId: "tc-sub",
|
|
131
|
+
message: "Delete /tmp/sub?",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
aggregator.handle({ type: "RUN_STARTED", runId: "run-1" });
|
|
135
|
+
aggregator.handle({
|
|
136
|
+
type: "TOOL_CALL_START",
|
|
137
|
+
toolCallId: "tc-root",
|
|
138
|
+
toolCallName: "delete_file",
|
|
139
|
+
});
|
|
140
|
+
aggregator.handle({
|
|
141
|
+
type: "TOOL_CALL_ARGS",
|
|
142
|
+
toolCallId: "tc-root",
|
|
143
|
+
delta: '{"path":"/tmp/root"}',
|
|
144
|
+
});
|
|
145
|
+
aggregator.handle({ type: "TOOL_CALL_END", toolCallId: "tc-root" });
|
|
146
|
+
aggregator.handle({
|
|
147
|
+
type: "SUBAGENT_STARTED",
|
|
148
|
+
subagentRunId: "sub-1",
|
|
149
|
+
name: "cleanup",
|
|
150
|
+
parentToolCallId: "tc-root",
|
|
151
|
+
} as any);
|
|
152
|
+
aggregator.handle({
|
|
153
|
+
type: "TOOL_CALL_START",
|
|
154
|
+
toolCallId: "tc-sub",
|
|
155
|
+
toolCallName: "delete_file",
|
|
156
|
+
subagentRunId: "sub-1",
|
|
157
|
+
} as any);
|
|
158
|
+
aggregator.handle({
|
|
159
|
+
type: "TOOL_CALL_ARGS",
|
|
160
|
+
toolCallId: "tc-sub",
|
|
161
|
+
delta: '{"path":"/tmp/sub"}',
|
|
162
|
+
subagentRunId: "sub-1",
|
|
163
|
+
} as any);
|
|
164
|
+
aggregator.handle({
|
|
165
|
+
type: "TOOL_CALL_END",
|
|
166
|
+
toolCallId: "tc-sub",
|
|
167
|
+
subagentRunId: "sub-1",
|
|
168
|
+
} as any);
|
|
169
|
+
aggregator.handle({
|
|
170
|
+
type: "RUN_FINISHED",
|
|
171
|
+
runId: "run-1",
|
|
172
|
+
outcome: {
|
|
173
|
+
type: "interrupt",
|
|
174
|
+
interrupts: [ROOT_GATE, SUBAGENT_GATE],
|
|
175
|
+
},
|
|
176
|
+
} as any);
|
|
177
|
+
|
|
178
|
+
const result = emitted[emitted.length - 1]!;
|
|
179
|
+
expect(result.status).toMatchObject({
|
|
180
|
+
type: "requires-action",
|
|
181
|
+
reason: "interrupt",
|
|
182
|
+
});
|
|
183
|
+
const content = result.content ?? [];
|
|
184
|
+
const rootPart = content.find(
|
|
185
|
+
(p: any) => p.type === "tool-call" && p.toolCallId === "tc-root",
|
|
186
|
+
) as any;
|
|
187
|
+
expect(rootPart?.approval).toEqual({
|
|
188
|
+
id: "int-root",
|
|
189
|
+
prompt: "Delete /tmp/root?",
|
|
190
|
+
});
|
|
191
|
+
const nestedPart = rootPart?.messages?.[0]?.content.find(
|
|
192
|
+
(p: any) => p.type === "tool-call" && p.toolCallId === "tc-sub",
|
|
193
|
+
);
|
|
194
|
+
expect(nestedPart?.approval).toEqual({
|
|
195
|
+
id: "int-sub",
|
|
196
|
+
prompt: "Delete /tmp/sub?",
|
|
197
|
+
});
|
|
198
|
+
// The interrupts survive on the message metadata so the app's bespoke
|
|
199
|
+
// interrupt hooks (useAgUiSubmitInterruptResponses / steerAway) can still
|
|
200
|
+
// resolve the batch.
|
|
201
|
+
expect(
|
|
202
|
+
(result.metadata?.custom as Record<string, any> | undefined)?.agui
|
|
203
|
+
?.interrupts,
|
|
204
|
+
).toEqual([ROOT_GATE, SUBAGENT_GATE]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("still projects a root gate whose batch is entirely root-scoped, even while an unrelated subagent tool call is in flight", () => {
|
|
208
|
+
const { aggregator, last } = gatedAggregator();
|
|
209
|
+
|
|
210
|
+
aggregator.handle({
|
|
211
|
+
type: "SUBAGENT_STARTED",
|
|
212
|
+
subagentRunId: "sub-1",
|
|
213
|
+
name: "cleanup",
|
|
214
|
+
} as any);
|
|
215
|
+
aggregator.handle({
|
|
216
|
+
type: "TOOL_CALL_START",
|
|
217
|
+
toolCallId: "tc-unrelated",
|
|
218
|
+
toolCallName: "noop",
|
|
219
|
+
subagentRunId: "sub-1",
|
|
220
|
+
} as any);
|
|
221
|
+
|
|
222
|
+
expect(last().status).toMatchObject({
|
|
223
|
+
type: "requires-action",
|
|
224
|
+
reason: "interrupt",
|
|
225
|
+
});
|
|
226
|
+
expect(approvalOf(last())).toEqual({
|
|
227
|
+
id: "int-1",
|
|
228
|
+
prompt: "Delete /tmp/a?",
|
|
229
|
+
});
|
|
230
|
+
});
|
|
103
231
|
});
|