@assistant-ui/react-google-adk 0.0.25 → 0.0.27
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/dist/AdkClient.d.ts.map +1 -1
- package/dist/AdkClient.js +13 -3
- package/dist/AdkClient.js.map +1 -1
- package/dist/AdkEventAccumulator.d.ts +2 -0
- package/dist/AdkEventAccumulator.d.ts.map +1 -1
- package/dist/AdkEventAccumulator.js +32 -10
- package/dist/AdkEventAccumulator.js.map +1 -1
- package/dist/AdkSessionAdapter.d.ts.map +1 -1
- package/dist/AdkSessionAdapter.js +2 -1
- package/dist/AdkSessionAdapter.js.map +1 -1
- package/dist/adkToolApproval.d.ts +40 -0
- package/dist/adkToolApproval.d.ts.map +1 -0
- package/dist/adkToolApproval.js +166 -0
- package/dist/adkToolApproval.js.map +1 -0
- package/dist/convertAdkMessages.d.ts +3 -1
- package/dist/convertAdkMessages.d.ts.map +1 -1
- package/dist/convertAdkMessages.js +27 -16
- package/dist/convertAdkMessages.js.map +1 -1
- package/dist/convertToAdkMessages.d.ts +49 -0
- package/dist/convertToAdkMessages.d.ts.map +1 -0
- package/dist/convertToAdkMessages.js +86 -0
- package/dist/convertToAdkMessages.js.map +1 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +5 -14
- package/dist/hooks.js.map +1 -1
- package/dist/parseAdkEvent.d.ts +6 -0
- package/dist/parseAdkEvent.d.ts.map +1 -0
- package/dist/parseAdkEvent.js +16 -0
- package/dist/parseAdkEvent.js.map +1 -0
- package/dist/server/parseAdkRequest.d.ts.map +1 -1
- package/dist/server/parseAdkRequest.js +79 -13
- package/dist/server/parseAdkRequest.js.map +1 -1
- package/dist/useAdkMessages.d.ts.map +1 -1
- package/dist/useAdkMessages.js +110 -79
- package/dist/useAdkMessages.js.map +1 -1
- package/dist/useAdkRuntime.d.ts.map +1 -1
- package/dist/useAdkRuntime.js +44 -129
- package/dist/useAdkRuntime.js.map +1 -1
- package/package.json +6 -7
- package/src/AdkClient.test.ts +137 -0
- package/src/AdkClient.ts +15 -3
- package/src/AdkEventAccumulator.test.ts +161 -1
- package/src/AdkEventAccumulator.ts +44 -8
- package/src/AdkSessionAdapter.test.ts +145 -1
- package/src/AdkSessionAdapter.ts +7 -2
- package/src/adkToolApproval.test.ts +345 -0
- package/src/adkToolApproval.ts +215 -0
- package/src/convertAdkMessages.ts +72 -43
- package/src/{useAdkRuntime.test.ts → convertToAdkMessages.test.ts} +1 -1
- package/src/convertToAdkMessages.ts +139 -0
- package/src/hooks.ts +7 -19
- package/src/parseAdkEvent.ts +40 -0
- package/src/server/parseAdkRequest.test.ts +141 -0
- package/src/server/parseAdkRequest.ts +145 -13
- package/src/useAdkMessages.test.ts +313 -0
- package/src/useAdkMessages.ts +72 -25
- package/src/useAdkRuntime.ts +66 -188
- package/src/useAdkRuntimeApproval.test.tsx +344 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateId,
|
|
3
|
+
getExternalStoreMessages,
|
|
4
|
+
type AppendMessage,
|
|
5
|
+
type ThreadMessage,
|
|
6
|
+
} from "@assistant-ui/core";
|
|
7
|
+
import {
|
|
8
|
+
createToolCallCancellationStub,
|
|
9
|
+
parseDataUrl,
|
|
10
|
+
resolveFilePartSource,
|
|
11
|
+
scanPendingToolCalls,
|
|
12
|
+
} from "@assistant-ui/core/internal";
|
|
13
|
+
import type { AdkMessage } from "./types";
|
|
14
|
+
|
|
15
|
+
/** Exported for unit tests. */
|
|
16
|
+
export const getMessageContent = (msg: AppendMessage) => {
|
|
17
|
+
const allContent = [
|
|
18
|
+
...msg.content,
|
|
19
|
+
...(msg.attachments?.flatMap((a) => a.content) ?? []),
|
|
20
|
+
];
|
|
21
|
+
const content = allContent.flatMap((part) => {
|
|
22
|
+
const type = part.type;
|
|
23
|
+
switch (type) {
|
|
24
|
+
case "text":
|
|
25
|
+
return { type: "text" as const, text: part.text };
|
|
26
|
+
case "image":
|
|
27
|
+
return { type: "image_url" as const, url: part.image };
|
|
28
|
+
case "file": {
|
|
29
|
+
const source = resolveFilePartSource(part);
|
|
30
|
+
if (source.kind === "url") {
|
|
31
|
+
return {
|
|
32
|
+
type: "file_url" as const,
|
|
33
|
+
url: source.url,
|
|
34
|
+
mimeType: part.mimeType,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
type: "file" as const,
|
|
39
|
+
mimeType: part.mimeType,
|
|
40
|
+
// Lands in Gemini `inlineData.data`, which takes bare base64, so a
|
|
41
|
+
// data URL envelope is stripped rather than forwarded.
|
|
42
|
+
data: source.data,
|
|
43
|
+
...(part.filename != null && { filename: part.filename }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
case "audio": {
|
|
47
|
+
const parsed = parseDataUrl(part.audio.data);
|
|
48
|
+
return {
|
|
49
|
+
type: "file" as const,
|
|
50
|
+
mimeType: `audio/${part.audio.format}`,
|
|
51
|
+
data: parsed?.data ?? part.audio.data,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
case "data":
|
|
55
|
+
return [];
|
|
56
|
+
|
|
57
|
+
case "tool-call":
|
|
58
|
+
throw new Error("Tool call appends are not supported.");
|
|
59
|
+
|
|
60
|
+
default: {
|
|
61
|
+
const _exhaustiveCheck: "reasoning" | "source" | "generative-ui" = type;
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Unsupported append message part type: ${_exhaustiveCheck}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (content.length === 1 && content[0]?.type === "text") {
|
|
70
|
+
return content[0].text ?? "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return content;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Exported for unit tests. */
|
|
77
|
+
export const getPendingToolCalls = (messages: AdkMessage[]) => {
|
|
78
|
+
return scanPendingToolCalls(
|
|
79
|
+
messages,
|
|
80
|
+
(message) => {
|
|
81
|
+
if (message.type === "ai") {
|
|
82
|
+
return { toolCalls: message.tool_calls ?? [] };
|
|
83
|
+
}
|
|
84
|
+
if (message.type === "tool") {
|
|
85
|
+
return { toolCallId: message.tool_call_id };
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
},
|
|
89
|
+
(toolCall) => toolCall.id,
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Exported for unit tests.
|
|
95
|
+
*
|
|
96
|
+
* Returns `{cancelled: true}` tool responses for pending tool calls when the
|
|
97
|
+
* user sends a new turn, EXCEPT for HITL interrupts marked via
|
|
98
|
+
* `long_running_tool_ids` (`adk_request_input`, `adk_request_confirmation`,
|
|
99
|
+
* `adk_request_credential`). Those must be answered through a dedicated tool
|
|
100
|
+
* UI + submit helper, not auto-cancelled.
|
|
101
|
+
*/
|
|
102
|
+
export const getPendingCancellations = (
|
|
103
|
+
messages: AdkMessage[],
|
|
104
|
+
longRunningToolIds: readonly string[],
|
|
105
|
+
): Array<AdkMessage & { type: "tool" }> => {
|
|
106
|
+
const longRunningSet = new Set(longRunningToolIds);
|
|
107
|
+
return getPendingToolCalls(messages)
|
|
108
|
+
.filter((t) => !longRunningSet.has(t.id))
|
|
109
|
+
.map(
|
|
110
|
+
(t) =>
|
|
111
|
+
({
|
|
112
|
+
id: generateId(),
|
|
113
|
+
...createToolCallCancellationStub(t),
|
|
114
|
+
}) satisfies AdkMessage & { type: "tool" },
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const truncateAdkMessages = (
|
|
119
|
+
threadMessages: readonly ThreadMessage[],
|
|
120
|
+
parentId: string | null,
|
|
121
|
+
): AdkMessage[] => {
|
|
122
|
+
if (parentId === null) return [];
|
|
123
|
+
const parentIndex = threadMessages.findIndex((m) => m.id === parentId);
|
|
124
|
+
if (parentIndex === -1) return [];
|
|
125
|
+
const truncated: AdkMessage[] = [];
|
|
126
|
+
for (let i = 0; i <= parentIndex && i < threadMessages.length; i++) {
|
|
127
|
+
truncated.push(...getExternalStoreMessages<AdkMessage>(threadMessages[i]!));
|
|
128
|
+
}
|
|
129
|
+
return truncated;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const toAdkUserMessage = (
|
|
133
|
+
msg: AppendMessage,
|
|
134
|
+
id = generateId(),
|
|
135
|
+
): AdkMessage & { type: "human"; id: string } => ({
|
|
136
|
+
id,
|
|
137
|
+
type: "human",
|
|
138
|
+
content: getMessageContent(msg),
|
|
139
|
+
});
|
package/src/hooks.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { generateId } from "@assistant-ui/core";
|
|
1
2
|
import { useAui } from "@assistant-ui/store";
|
|
2
|
-
import { v4 as uuidv4 } from "uuid";
|
|
3
3
|
import type { ReadonlyJSONValue } from "assistant-stream/utils";
|
|
4
4
|
import { adkExtras } from "./adkExtras";
|
|
5
|
+
import { toAdkConfirmationReply } from "./adkToolApproval";
|
|
5
6
|
import type {
|
|
6
7
|
AdkMessage,
|
|
7
8
|
AdkSendMessageConfig,
|
|
@@ -66,22 +67,9 @@ export const useAdkConfirmTool = () => {
|
|
|
66
67
|
confirmed: boolean,
|
|
67
68
|
payload?: ReadonlyJSONValue,
|
|
68
69
|
) =>
|
|
69
|
-
adkExtras
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
id: uuidv4(),
|
|
73
|
-
type: "tool",
|
|
74
|
-
tool_call_id: toolCallId,
|
|
75
|
-
name: "adk_request_confirmation",
|
|
76
|
-
content: JSON.stringify({
|
|
77
|
-
confirmed,
|
|
78
|
-
...(payload != null && { payload }),
|
|
79
|
-
}),
|
|
80
|
-
status: "success",
|
|
81
|
-
},
|
|
82
|
-
],
|
|
83
|
-
{},
|
|
84
|
-
);
|
|
70
|
+
adkExtras
|
|
71
|
+
.get(aui)
|
|
72
|
+
.send([toAdkConfirmationReply(toolCallId, confirmed, payload)], {});
|
|
85
73
|
};
|
|
86
74
|
|
|
87
75
|
/** Returns a function to submit auth credentials for a pending auth request. */
|
|
@@ -91,7 +79,7 @@ export const useAdkSubmitAuth = () => {
|
|
|
91
79
|
adkExtras.get(aui).send(
|
|
92
80
|
[
|
|
93
81
|
{
|
|
94
|
-
id:
|
|
82
|
+
id: generateId(),
|
|
95
83
|
type: "tool",
|
|
96
84
|
tool_call_id: toolCallId,
|
|
97
85
|
name: "adk_request_credential",
|
|
@@ -110,7 +98,7 @@ export const useAdkSubmitInput = () => {
|
|
|
110
98
|
adkExtras.get(aui).send(
|
|
111
99
|
[
|
|
112
100
|
{
|
|
113
|
-
id:
|
|
101
|
+
id: generateId(),
|
|
114
102
|
type: "tool",
|
|
115
103
|
tool_call_id: toolCallId,
|
|
116
104
|
name: "adk_request_input",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AdkEvent } from "./types";
|
|
2
|
+
|
|
3
|
+
export function parseAdkEventValue(
|
|
4
|
+
value: unknown,
|
|
5
|
+
errorPrefix: string,
|
|
6
|
+
): AdkEvent {
|
|
7
|
+
if (
|
|
8
|
+
typeof value !== "object" ||
|
|
9
|
+
value === null ||
|
|
10
|
+
Array.isArray(value) ||
|
|
11
|
+
Object.keys(value).length === 0
|
|
12
|
+
) {
|
|
13
|
+
throw new Error(`${errorPrefix}: expected a non-empty object.`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { id: rawId, ...event } = value as Record<string, unknown>;
|
|
17
|
+
if (
|
|
18
|
+
rawId != null &&
|
|
19
|
+
typeof rawId !== "string" &&
|
|
20
|
+
(typeof rawId !== "number" || !Number.isFinite(rawId))
|
|
21
|
+
) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`${errorPrefix}: expected "id" to be a string or finite number when present.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const errorMessage =
|
|
28
|
+
"error" in event && typeof event.error === "string"
|
|
29
|
+
? event.error
|
|
30
|
+
: undefined;
|
|
31
|
+
return {
|
|
32
|
+
...event,
|
|
33
|
+
...(rawId != null && { id: String(rawId) }),
|
|
34
|
+
...(errorMessage !== undefined &&
|
|
35
|
+
!("errorMessage" in event) &&
|
|
36
|
+
!("error_message" in event) && {
|
|
37
|
+
errorMessage,
|
|
38
|
+
}),
|
|
39
|
+
} as AdkEvent;
|
|
40
|
+
}
|
|
@@ -49,6 +49,33 @@ describe("parseAdkRequest", () => {
|
|
|
49
49
|
expect(result.parts).toHaveLength(2);
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
it("parses each supported ADK part shape", async () => {
|
|
53
|
+
const parts = [
|
|
54
|
+
{ text: "Hello", thought: true },
|
|
55
|
+
{ functionCall: { name: "search", id: "call-1", args: {} } },
|
|
56
|
+
{
|
|
57
|
+
functionResponse: {
|
|
58
|
+
name: "search",
|
|
59
|
+
id: "call-1",
|
|
60
|
+
response: null,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{ executableCode: { code: "print('hello')", language: "python" } },
|
|
64
|
+
{ codeExecutionResult: { output: "hello", outcome: "ok" } },
|
|
65
|
+
{ inlineData: { mimeType: "image/png", data: "abc" } },
|
|
66
|
+
{
|
|
67
|
+
fileData: {
|
|
68
|
+
fileUri: "https://example.com/file",
|
|
69
|
+
mimeType: "text/plain",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
await expect(
|
|
75
|
+
parseAdkRequest(makeRequest({ parts })),
|
|
76
|
+
).resolves.toMatchObject({ parts });
|
|
77
|
+
});
|
|
78
|
+
|
|
52
79
|
it("parses a tool-result request", async () => {
|
|
53
80
|
const result = await parseAdkRequest(
|
|
54
81
|
makeRequest({
|
|
@@ -73,6 +100,7 @@ describe("parseAdkRequest", () => {
|
|
|
73
100
|
makeRequest({
|
|
74
101
|
type: "tool-result",
|
|
75
102
|
toolCallId: "tc-1",
|
|
103
|
+
toolName: "search",
|
|
76
104
|
result: {},
|
|
77
105
|
}),
|
|
78
106
|
);
|
|
@@ -95,6 +123,119 @@ describe("parseAdkRequest", () => {
|
|
|
95
123
|
"Google ADK proxy request body must be a JSON object",
|
|
96
124
|
);
|
|
97
125
|
});
|
|
126
|
+
|
|
127
|
+
it.each([
|
|
128
|
+
[{ message: 42 }, 'field "message"'],
|
|
129
|
+
[{ parts: {} }, 'field "parts"'],
|
|
130
|
+
[{ parts: [null] }, 'field "parts"'],
|
|
131
|
+
[{ type: "unknown", message: "hello" }, 'field "type"'],
|
|
132
|
+
[{ message: "hello", checkpointId: 42 }, 'field "checkpointId"'],
|
|
133
|
+
[{ message: "hello", stateDelta: [] }, 'field "stateDelta"'],
|
|
134
|
+
])("rejects malformed message requests %#", async (body, error) => {
|
|
135
|
+
await expect(parseAdkRequest(makeRequest(body))).rejects.toThrow(error);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it.each([
|
|
139
|
+
[{ parts: [{}] }, 'field "parts[0]"'],
|
|
140
|
+
[{ parts: [{ text: 42 }] }, 'field "parts[0].text"'],
|
|
141
|
+
[
|
|
142
|
+
{ parts: [{ inlineData: { mimeType: "image/png", data: 42 } }] },
|
|
143
|
+
'field "parts[0].inlineData.data"',
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
{ parts: [{ functionCall: { name: "search", args: [] } }] },
|
|
147
|
+
'field "parts[0].functionCall.args"',
|
|
148
|
+
],
|
|
149
|
+
[
|
|
150
|
+
{ parts: [{ functionResponse: { name: 42 } }] },
|
|
151
|
+
'field "parts[0].functionResponse.name"',
|
|
152
|
+
],
|
|
153
|
+
])("rejects malformed nested ADK parts %#", async (body, error) => {
|
|
154
|
+
await expect(parseAdkRequest(makeRequest(body))).rejects.toThrow(error);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("accepts exclude_none and unknown part shapes", async () => {
|
|
158
|
+
const parts = [
|
|
159
|
+
{ functionCall: { name: "get_time", id: "call-1" } },
|
|
160
|
+
{ functionResponse: { name: "search", id: "call-1" } },
|
|
161
|
+
{ text: "hi", inlineData: { mimeType: "image/png", data: "abc" } },
|
|
162
|
+
{ videoMetadata: { fps: 1 } },
|
|
163
|
+
{ text: "hello", thoughtSignature: "sig" },
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
parseAdkRequest(makeRequest({ parts })),
|
|
168
|
+
).resolves.toMatchObject({ parts });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("requires message content", async () => {
|
|
172
|
+
await expect(parseAdkRequest(makeRequest({}))).rejects.toThrow(
|
|
173
|
+
'expected a "message" string or a "parts" array',
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it.each([
|
|
178
|
+
[
|
|
179
|
+
{ type: "message", message: "hello" },
|
|
180
|
+
{ type: "message", text: "hello", config: {} },
|
|
181
|
+
],
|
|
182
|
+
[{ parts: [] }, { type: "message", text: "", parts: [], config: {} }],
|
|
183
|
+
])(
|
|
184
|
+
"preserves supported empty and explicit message shapes %#",
|
|
185
|
+
async (body, expected) => {
|
|
186
|
+
await expect(parseAdkRequest(makeRequest(body))).resolves.toEqual(
|
|
187
|
+
expected,
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
it("accepts empty tool identifiers emitted by id-less ADK calls", async () => {
|
|
193
|
+
await expect(
|
|
194
|
+
parseAdkRequest(
|
|
195
|
+
makeRequest({
|
|
196
|
+
type: "tool-result",
|
|
197
|
+
toolCallId: "",
|
|
198
|
+
toolName: "",
|
|
199
|
+
result: {},
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
).resolves.toMatchObject({
|
|
203
|
+
type: "tool-result",
|
|
204
|
+
toolCallId: "",
|
|
205
|
+
toolName: "",
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it.each([
|
|
210
|
+
[
|
|
211
|
+
{ type: "tool-result", toolName: "search", result: {} },
|
|
212
|
+
'field "toolCallId"',
|
|
213
|
+
],
|
|
214
|
+
[
|
|
215
|
+
{ type: "tool-result", toolCallId: "tc-1", result: {} },
|
|
216
|
+
'field "toolName"',
|
|
217
|
+
],
|
|
218
|
+
[
|
|
219
|
+
{ type: "tool-result", toolCallId: 42, toolName: "search", result: {} },
|
|
220
|
+
'field "toolCallId"',
|
|
221
|
+
],
|
|
222
|
+
[
|
|
223
|
+
{
|
|
224
|
+
type: "tool-result",
|
|
225
|
+
toolCallId: "tc-1",
|
|
226
|
+
toolName: "search",
|
|
227
|
+
result: {},
|
|
228
|
+
isError: "false",
|
|
229
|
+
},
|
|
230
|
+
'field "isError"',
|
|
231
|
+
],
|
|
232
|
+
[
|
|
233
|
+
{ type: "tool-result", toolCallId: "tc-1", toolName: "search" },
|
|
234
|
+
'field "result"',
|
|
235
|
+
],
|
|
236
|
+
])("rejects malformed tool-result requests %#", async (body, error) => {
|
|
237
|
+
await expect(parseAdkRequest(makeRequest(body))).rejects.toThrow(error);
|
|
238
|
+
});
|
|
98
239
|
});
|
|
99
240
|
|
|
100
241
|
describe("toAdkContent", () => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from "@assistant-ui/core/internal";
|
|
1
2
|
import type { AdkSendMessageConfig } from "../types";
|
|
2
3
|
|
|
3
4
|
type ParsedAdkRequest =
|
|
@@ -18,6 +19,107 @@ type ParsedAdkRequest =
|
|
|
18
19
|
stateDelta?: Record<string, unknown> | undefined;
|
|
19
20
|
};
|
|
20
21
|
|
|
22
|
+
const invalidField = (field: string, expectation: string): Error =>
|
|
23
|
+
new Error(
|
|
24
|
+
`Invalid Google ADK proxy request field "${field}": expected ${expectation}.`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const readString = (body: Record<string, unknown>, field: string): string => {
|
|
28
|
+
const value = body[field];
|
|
29
|
+
if (typeof value !== "string") throw invalidField(field, "a string");
|
|
30
|
+
return value;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const readOptionalString = (
|
|
34
|
+
body: Record<string, unknown>,
|
|
35
|
+
field: string,
|
|
36
|
+
): string | undefined => {
|
|
37
|
+
const value = body[field];
|
|
38
|
+
if (value === undefined) return undefined;
|
|
39
|
+
if (typeof value !== "string") throw invalidField(field, "a string");
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const validateOptionalPartString = (
|
|
44
|
+
part: Record<string, unknown>,
|
|
45
|
+
key: string,
|
|
46
|
+
field: string,
|
|
47
|
+
) => {
|
|
48
|
+
if (part[key] !== undefined && typeof part[key] !== "string") {
|
|
49
|
+
throw invalidField(field, "a string");
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Nested fields are validated only when present: ADK serializes with
|
|
54
|
+
// exclude_none semantics and the Part vocabulary grows upstream, so absent
|
|
55
|
+
// fields and unknown part kinds pass through to the runner untouched.
|
|
56
|
+
const validatePart = (part: Record<string, unknown>, index: number) => {
|
|
57
|
+
const field = (name: string) => `parts[${index}].${name}`;
|
|
58
|
+
if (Object.keys(part).length === 0) {
|
|
59
|
+
throw invalidField(`parts[${index}]`, "a non-empty part object");
|
|
60
|
+
}
|
|
61
|
+
if (part.thought !== undefined && typeof part.thought !== "boolean") {
|
|
62
|
+
throw invalidField(field("thought"), "a boolean");
|
|
63
|
+
}
|
|
64
|
+
if (part.text !== undefined && typeof part.text !== "string") {
|
|
65
|
+
throw invalidField(field("text"), "a string");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const validateRecordField = (
|
|
69
|
+
key: string,
|
|
70
|
+
validateContent: (content: Record<string, unknown>) => void,
|
|
71
|
+
) => {
|
|
72
|
+
const value = part[key];
|
|
73
|
+
if (value === undefined) return;
|
|
74
|
+
if (!isRecord(value)) throw invalidField(field(key), "an object");
|
|
75
|
+
validateContent(value);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
validateRecordField("functionCall", (content) => {
|
|
79
|
+
validateOptionalPartString(content, "name", field("functionCall.name"));
|
|
80
|
+
validateOptionalPartString(content, "id", field("functionCall.id"));
|
|
81
|
+
if (content.args !== undefined && !isRecord(content.args)) {
|
|
82
|
+
throw invalidField(field("functionCall.args"), "an object");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
validateRecordField("functionResponse", (content) => {
|
|
86
|
+
validateOptionalPartString(content, "name", field("functionResponse.name"));
|
|
87
|
+
validateOptionalPartString(content, "id", field("functionResponse.id"));
|
|
88
|
+
});
|
|
89
|
+
validateRecordField("executableCode", (content) => {
|
|
90
|
+
validateOptionalPartString(content, "code", field("executableCode.code"));
|
|
91
|
+
validateOptionalPartString(
|
|
92
|
+
content,
|
|
93
|
+
"language",
|
|
94
|
+
field("executableCode.language"),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
validateRecordField("codeExecutionResult", (content) => {
|
|
98
|
+
validateOptionalPartString(
|
|
99
|
+
content,
|
|
100
|
+
"output",
|
|
101
|
+
field("codeExecutionResult.output"),
|
|
102
|
+
);
|
|
103
|
+
validateOptionalPartString(
|
|
104
|
+
content,
|
|
105
|
+
"outcome",
|
|
106
|
+
field("codeExecutionResult.outcome"),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
validateRecordField("inlineData", (content) => {
|
|
110
|
+
validateOptionalPartString(
|
|
111
|
+
content,
|
|
112
|
+
"mimeType",
|
|
113
|
+
field("inlineData.mimeType"),
|
|
114
|
+
);
|
|
115
|
+
validateOptionalPartString(content, "data", field("inlineData.data"));
|
|
116
|
+
});
|
|
117
|
+
validateRecordField("fileData", (content) => {
|
|
118
|
+
validateOptionalPartString(content, "fileUri", field("fileData.fileUri"));
|
|
119
|
+
validateOptionalPartString(content, "mimeType", field("fileData.mimeType"));
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
21
123
|
/**
|
|
22
124
|
* Parses an incoming HTTP request into a structured ADK request.
|
|
23
125
|
*
|
|
@@ -42,44 +144,74 @@ type ParsedAdkRequest =
|
|
|
42
144
|
export const parseAdkRequest = async (
|
|
43
145
|
request: Request,
|
|
44
146
|
): Promise<ParsedAdkRequest> => {
|
|
45
|
-
let body:
|
|
147
|
+
let body: unknown;
|
|
46
148
|
try {
|
|
47
|
-
body =
|
|
149
|
+
body = await request.json();
|
|
48
150
|
} catch {
|
|
49
151
|
throw new Error(
|
|
50
152
|
'Invalid JSON in Google ADK proxy request body. Expected a JSON object like {"message":"Hello"} or {"type":"tool-result",...}.',
|
|
51
153
|
);
|
|
52
154
|
}
|
|
53
155
|
|
|
54
|
-
if (!
|
|
156
|
+
if (!isRecord(body)) {
|
|
55
157
|
throw new Error("Google ADK proxy request body must be a JSON object");
|
|
56
158
|
}
|
|
57
159
|
|
|
58
160
|
const config: AdkSendMessageConfig = {};
|
|
59
161
|
if (body.runConfig !== undefined) config.runConfig = body.runConfig;
|
|
60
|
-
|
|
61
|
-
|
|
162
|
+
const checkpointId = readOptionalString(body, "checkpointId");
|
|
163
|
+
if (checkpointId !== undefined) config.checkpointId = checkpointId;
|
|
62
164
|
|
|
63
|
-
const stateDelta = body.stateDelta
|
|
165
|
+
const stateDelta = body.stateDelta;
|
|
166
|
+
if (stateDelta !== undefined && !isRecord(stateDelta)) {
|
|
167
|
+
throw invalidField("stateDelta", "an object");
|
|
168
|
+
}
|
|
64
169
|
|
|
65
170
|
if (body.type === "tool-result") {
|
|
171
|
+
if (!("result" in body)) {
|
|
172
|
+
throw invalidField("result", "a value");
|
|
173
|
+
}
|
|
174
|
+
if (body.isError !== undefined && typeof body.isError !== "boolean") {
|
|
175
|
+
throw invalidField("isError", "a boolean");
|
|
176
|
+
}
|
|
66
177
|
return {
|
|
67
178
|
type: "tool-result",
|
|
68
|
-
toolCallId: (body
|
|
69
|
-
toolName: (body
|
|
179
|
+
toolCallId: readString(body, "toolCallId"),
|
|
180
|
+
toolName: readString(body, "toolName"),
|
|
70
181
|
result: body.result,
|
|
71
|
-
isError:
|
|
182
|
+
isError: body.isError ?? false,
|
|
72
183
|
config,
|
|
73
184
|
...(stateDelta != null && { stateDelta }),
|
|
74
185
|
};
|
|
75
186
|
}
|
|
76
187
|
|
|
188
|
+
if (body.type !== undefined && body.type !== "message") {
|
|
189
|
+
throw invalidField("type", '"message", "tool-result", or omitted');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const text = body.message;
|
|
193
|
+
if (text !== undefined && typeof text !== "string") {
|
|
194
|
+
throw invalidField("message", "a string");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const parts = body.parts;
|
|
198
|
+
if (
|
|
199
|
+
parts !== undefined &&
|
|
200
|
+
(!Array.isArray(parts) || !parts.every(isRecord))
|
|
201
|
+
) {
|
|
202
|
+
throw invalidField("parts", "an array of objects");
|
|
203
|
+
}
|
|
204
|
+
parts?.forEach(validatePart);
|
|
205
|
+
if (!("message" in body) && !("parts" in body)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
'Invalid Google ADK proxy request: expected a "message" string or a "parts" array.',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
77
211
|
return {
|
|
78
212
|
type: "message",
|
|
79
|
-
text:
|
|
80
|
-
...(
|
|
81
|
-
parts: body.parts as Array<Record<string, unknown>>,
|
|
82
|
-
}),
|
|
213
|
+
text: text ?? "",
|
|
214
|
+
...(parts !== undefined && { parts }),
|
|
83
215
|
config,
|
|
84
216
|
...(stateDelta != null && { stateDelta }),
|
|
85
217
|
};
|