@springbrand/agent-runtime 0.2.0-alpha.57 → 0.2.0-alpha.59
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/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/pi/message/conversion.ts +8 -3
- package/src/pi/tool/base.ts +123 -14
- package/src/runtime-agent.ts +8 -1
- package/src/runtime.ts +26 -3
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -50,6 +50,7 @@ export * from "./kernel/runtime-config";
|
|
|
50
50
|
export * from "./telemetry";
|
|
51
51
|
export type { RuntimeAssemblyView } from "./kernel/runtime-assembly-view";
|
|
52
52
|
export * from "./runtime-agent";
|
|
53
|
+
export type { ToolInteractionResponsePreparation } from "./runtime";
|
|
53
54
|
export * from "./kernel/state";
|
|
54
55
|
export * from "./workspace-versioning";
|
|
55
56
|
export * from "./lib/execution-level";
|
|
@@ -17,8 +17,13 @@ function timestampOf(message: UIMessage): number {
|
|
|
17
17
|
return typeof createdAt === "number" ? createdAt : Date.now();
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function
|
|
21
|
-
part:
|
|
20
|
+
export function modelAttachmentText(
|
|
21
|
+
part: {
|
|
22
|
+
readonly url: string;
|
|
23
|
+
readonly filename?: string;
|
|
24
|
+
readonly mediaType: string;
|
|
25
|
+
readonly providerMetadata?: unknown;
|
|
26
|
+
},
|
|
22
27
|
): string {
|
|
23
28
|
const name = part.filename?.trim() || "attachment";
|
|
24
29
|
const springbrand = recordOf(recordOf(part.providerMetadata).springbrand);
|
|
@@ -66,7 +71,7 @@ function userContent(
|
|
|
66
71
|
});
|
|
67
72
|
} else {
|
|
68
73
|
content.push(
|
|
69
|
-
{ type: "text", text:
|
|
74
|
+
{ type: "text", text: modelAttachmentText(part) },
|
|
70
75
|
{
|
|
71
76
|
type: "file",
|
|
72
77
|
url: part.url,
|
package/src/pi/tool/base.ts
CHANGED
|
@@ -12,11 +12,12 @@ import { estimateStringTokens } from "agents/experimental/memory/utils";
|
|
|
12
12
|
import type { RuntimeMemoryPort } from "../../kernel/bindings";
|
|
13
13
|
import type { RuntimeMemoryProfile } from "../../kernel/profile";
|
|
14
14
|
import { serializeOutput } from "../../lib/artifacts";
|
|
15
|
+
import { modelAttachmentText } from "../message/conversion";
|
|
15
16
|
import type { PiToolCandidate } from "./compiler";
|
|
16
17
|
import { webSearchPiToolCandidate } from "./web-search";
|
|
17
18
|
import type { WebSearch } from "./web-search/api";
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
+
const askUserOrdinaryQuestion = Type.Object({
|
|
20
21
|
question: Type.String({
|
|
21
22
|
minLength: 1,
|
|
22
23
|
description: "The question to put to the user.",
|
|
@@ -38,12 +39,31 @@ const askUserQuestion = Type.Object({
|
|
|
38
39
|
})),
|
|
39
40
|
}, { additionalProperties: false });
|
|
40
41
|
|
|
42
|
+
const askUserAttachmentQuestion = Type.Object({
|
|
43
|
+
question: Type.String({
|
|
44
|
+
minLength: 1,
|
|
45
|
+
description: "The request for one or more user files.",
|
|
46
|
+
}),
|
|
47
|
+
kind: Type.Literal("attachment", {
|
|
48
|
+
description:
|
|
49
|
+
"Use attachment when the user must upload one or more files to continue.",
|
|
50
|
+
}),
|
|
51
|
+
}, { additionalProperties: false });
|
|
52
|
+
|
|
53
|
+
const askUserQuestion = Type.Union([
|
|
54
|
+
askUserOrdinaryQuestion,
|
|
55
|
+
askUserAttachmentQuestion,
|
|
56
|
+
]);
|
|
57
|
+
|
|
41
58
|
const askUserParameters = Type.Object({
|
|
42
59
|
questions: Type.Array(askUserQuestion, {
|
|
43
60
|
minItems: 1,
|
|
44
61
|
maxItems: 10,
|
|
62
|
+
contains: askUserAttachmentQuestion,
|
|
63
|
+
minContains: 0,
|
|
64
|
+
maxContains: 1,
|
|
45
65
|
description:
|
|
46
|
-
"All user decisions needed to continue. Ask them together in one call.",
|
|
66
|
+
"All user decisions and files needed to continue. Ask them together in one call. At most one question may have kind attachment.",
|
|
47
67
|
}),
|
|
48
68
|
}, { additionalProperties: false });
|
|
49
69
|
|
|
@@ -53,15 +73,20 @@ const askUserParameters = Type.Object({
|
|
|
53
73
|
* `answers` 与 Tool 输入的 `questions` 按下标一一对应。
|
|
54
74
|
* 刻意用结构化数组而不是拼好的字符串 —— 选项本身可能含分隔符,拼了再拆是有损的。
|
|
55
75
|
*/
|
|
56
|
-
const askUserResponse = Type.Object({
|
|
57
|
-
answers: Type.Array(Type.Object({
|
|
58
|
-
selections: Type.Array(Type.String()),
|
|
59
|
-
text: Type.Optional(Type.String()),
|
|
60
|
-
}, { additionalProperties: false })),
|
|
61
|
-
}, { additionalProperties: false });
|
|
62
|
-
|
|
63
76
|
type AskUserInput = Static<typeof askUserParameters>;
|
|
64
|
-
type
|
|
77
|
+
type AskUserFile = {
|
|
78
|
+
readonly type: "file";
|
|
79
|
+
readonly url: string;
|
|
80
|
+
readonly filename: string;
|
|
81
|
+
readonly mediaType: string;
|
|
82
|
+
readonly providerMetadata?: unknown;
|
|
83
|
+
};
|
|
84
|
+
type AskUserResponse = {
|
|
85
|
+
readonly answers: ReadonlyArray<
|
|
86
|
+
| { readonly selections: readonly string[]; readonly text?: string }
|
|
87
|
+
| { readonly attachments: readonly AskUserFile[] }
|
|
88
|
+
>;
|
|
89
|
+
};
|
|
65
90
|
|
|
66
91
|
function recordOf(value: unknown): Record<string, unknown> | null {
|
|
67
92
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
@@ -74,11 +99,23 @@ function questionsOf(value: unknown): AskUserInput["questions"] | null {
|
|
|
74
99
|
if (!Array.isArray(questions) || questions.length < 1 || questions.length > 10) {
|
|
75
100
|
return null;
|
|
76
101
|
}
|
|
102
|
+
let attachmentQuestions = 0;
|
|
77
103
|
for (const value of questions) {
|
|
78
104
|
const question = recordOf(value);
|
|
79
105
|
if (!question || typeof question.question !== "string" || !question.question) {
|
|
80
106
|
return null;
|
|
81
107
|
}
|
|
108
|
+
if ("kind" in question && question.kind === "attachment") {
|
|
109
|
+
attachmentQuestions += 1;
|
|
110
|
+
if (
|
|
111
|
+
attachmentQuestions > 1 ||
|
|
112
|
+
question.options !== undefined ||
|
|
113
|
+
question.multiSelect !== undefined ||
|
|
114
|
+
question.allowCustom !== undefined
|
|
115
|
+
) return null;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (question.kind !== undefined) return null;
|
|
82
119
|
if (
|
|
83
120
|
question.multiSelect !== undefined &&
|
|
84
121
|
typeof question.multiSelect !== "boolean"
|
|
@@ -113,7 +150,35 @@ function isAskUserResponse(input: unknown, value: unknown): boolean {
|
|
|
113
150
|
|
|
114
151
|
return questions.every((question, index) => {
|
|
115
152
|
const answer = recordOf(answers[index]);
|
|
153
|
+
if ("kind" in question && question.kind === "attachment") {
|
|
154
|
+
const attachments = answer?.attachments;
|
|
155
|
+
if (
|
|
156
|
+
!answer ||
|
|
157
|
+
!Array.isArray(attachments) ||
|
|
158
|
+
attachments.length < 1 ||
|
|
159
|
+
attachments.length > 8 ||
|
|
160
|
+
Object.keys(answer).some((key) => key !== "attachments")
|
|
161
|
+
) return false;
|
|
162
|
+
return attachments.every((value) => {
|
|
163
|
+
const file = recordOf(value);
|
|
164
|
+
return Boolean(
|
|
165
|
+
file &&
|
|
166
|
+
file.type === "file" &&
|
|
167
|
+
typeof file.url === "string" && file.url.length > 0 &&
|
|
168
|
+
typeof file.filename === "string" && file.filename.length > 0 &&
|
|
169
|
+
typeof file.mediaType === "string" && file.mediaType.length > 0 &&
|
|
170
|
+
(file.providerMetadata === undefined || recordOf(file.providerMetadata)) &&
|
|
171
|
+
Object.keys(file).every((key) =>
|
|
172
|
+
key === "type" || key === "url" || key === "filename" ||
|
|
173
|
+
key === "mediaType" || key === "providerMetadata"
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
116
178
|
if (!answer || !Array.isArray(answer.selections)) return false;
|
|
179
|
+
if (Object.keys(answer).some((key) => key !== "selections" && key !== "text")) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
117
182
|
if (!answer.selections.every((item) => typeof item === "string")) {
|
|
118
183
|
return false;
|
|
119
184
|
}
|
|
@@ -121,15 +186,16 @@ function isAskUserResponse(input: unknown, value: unknown): boolean {
|
|
|
121
186
|
return false;
|
|
122
187
|
}
|
|
123
188
|
|
|
124
|
-
const
|
|
189
|
+
const ordinary = question as Static<typeof askUserOrdinaryQuestion>;
|
|
190
|
+
const options = ordinary.options ?? [];
|
|
125
191
|
const text = typeof answer.text === "string" ? answer.text : "";
|
|
126
192
|
if (text.length > 2_000) return false;
|
|
127
193
|
if (answer.selections.some((selection) => !options.includes(selection))) {
|
|
128
194
|
return false;
|
|
129
195
|
}
|
|
130
196
|
if (new Set(answer.selections).size !== answer.selections.length) return false;
|
|
131
|
-
if (!
|
|
132
|
-
const allowCustom = options.length === 0 ||
|
|
197
|
+
if (!ordinary.multiSelect && answer.selections.length > 1) return false;
|
|
198
|
+
const allowCustom = options.length === 0 || ordinary.allowCustom === true;
|
|
133
199
|
if (!allowCustom && text.trim()) return false;
|
|
134
200
|
return answer.selections.length > 0 || text.trim().length > 0;
|
|
135
201
|
});
|
|
@@ -256,7 +322,7 @@ export function basePiToolCandidates(
|
|
|
256
322
|
name: "ask_user",
|
|
257
323
|
label: "Ask user",
|
|
258
324
|
description:
|
|
259
|
-
"Ask all 1-10 user questions needed to continue in one call. For a bounded choice, provide 2-6 distinct options. For required free text, omit the options field entirely; never pass an empty, single-item, or more-than-6-item options array.
|
|
325
|
+
"Ask all 1-10 user questions needed to continue in one call. For files, use one question with kind attachment; it accepts multiple files, so do not ask for each file separately or tell the user to use the normal chat composer. For a bounded choice, provide 2-6 distinct options. For required free text, omit the options field entirely; never pass an empty, single-item, or more-than-6-item options array. The answers return as this tool's result, so continue the same turn after calling it.",
|
|
260
326
|
parameters: askUserParameters,
|
|
261
327
|
// 永远不会被调用:执行链在 interaction 闸就 park 住了。留一个失败关闭的
|
|
262
328
|
// 实现,是为了万一哪次改动绕过了那道闸,能立刻炸出来而不是静默返回空答案。
|
|
@@ -273,6 +339,42 @@ export function basePiToolCandidates(
|
|
|
273
339
|
const responseAnswers = (response as AskUserResponse).answers;
|
|
274
340
|
const answers = questions.map((question, index) => {
|
|
275
341
|
const answer = responseAnswers[index]!;
|
|
342
|
+
if (
|
|
343
|
+
"kind" in question && question.kind === "attachment" &&
|
|
344
|
+
"attachments" in answer
|
|
345
|
+
) {
|
|
346
|
+
return {
|
|
347
|
+
question: question.question,
|
|
348
|
+
attachments: answer.attachments.map((file) => {
|
|
349
|
+
const springbrand = recordOf(
|
|
350
|
+
recordOf(file.providerMetadata)?.springbrand,
|
|
351
|
+
);
|
|
352
|
+
return {
|
|
353
|
+
name: file.filename,
|
|
354
|
+
mediaType: file.mediaType,
|
|
355
|
+
size: typeof springbrand?.size === "number"
|
|
356
|
+
? springbrand.size
|
|
357
|
+
: 0,
|
|
358
|
+
cdnUrl: typeof springbrand?.cdnUrl === "string"
|
|
359
|
+
? springbrand.cdnUrl
|
|
360
|
+
: file.url,
|
|
361
|
+
workspacePath: typeof springbrand?.path === "string"
|
|
362
|
+
? springbrand.path
|
|
363
|
+
: "",
|
|
364
|
+
attachmentId: typeof springbrand?.attachmentId === "string"
|
|
365
|
+
? springbrand.attachmentId
|
|
366
|
+
: "",
|
|
367
|
+
contentVersion:
|
|
368
|
+
typeof springbrand?.contentVersion === "string"
|
|
369
|
+
? springbrand.contentVersion
|
|
370
|
+
: "",
|
|
371
|
+
};
|
|
372
|
+
}),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
if (!("selections" in answer)) {
|
|
376
|
+
throw new Error("Invalid ask_user answer");
|
|
377
|
+
}
|
|
276
378
|
return {
|
|
277
379
|
question: question.question,
|
|
278
380
|
selections: answer.selections,
|
|
@@ -283,6 +385,13 @@ export function basePiToolCandidates(
|
|
|
283
385
|
content: [{
|
|
284
386
|
type: "text",
|
|
285
387
|
text: `The user answered:\n${answers.map((answer, index) => {
|
|
388
|
+
if ("attachments" in answer) {
|
|
389
|
+
const responseAnswer = responseAnswers[index]!;
|
|
390
|
+
if (!("attachments" in responseAnswer)) return "";
|
|
391
|
+
return `${index + 1}. ${answer.question}\n${responseAnswer.attachments
|
|
392
|
+
.map((file) => ` - ${modelAttachmentText(file)}`)
|
|
393
|
+
.join("\n")}`;
|
|
394
|
+
}
|
|
286
395
|
const parts = [
|
|
287
396
|
...answer.selections,
|
|
288
397
|
...(answer.text ? [answer.text] : []),
|
package/src/runtime-agent.ts
CHANGED
|
@@ -310,6 +310,13 @@ export interface RuntimeAgentConfigControls<Command, Change> {
|
|
|
310
310
|
): Promise<RuntimeConfigUpdateResult<Change>>;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/** Type-only Host extension seam implemented by AgentRuntimeKernel. */
|
|
314
|
+
declare class RuntimeAgentHostSeam {
|
|
315
|
+
protected prepareToolInteractionResponse(
|
|
316
|
+
input: import("./runtime").ToolInteractionResponsePreparation,
|
|
317
|
+
): Promise<unknown | null>;
|
|
318
|
+
}
|
|
319
|
+
|
|
313
320
|
/**
|
|
314
321
|
* 表示 `defineRuntimeAgent` 生成类的实例。
|
|
315
322
|
*
|
|
@@ -320,7 +327,7 @@ export type RuntimeAgentInstance<
|
|
|
320
327
|
Env extends Cloudflare.Env = Cloudflare.Env,
|
|
321
328
|
Command = never,
|
|
322
329
|
Change = never,
|
|
323
|
-
> = Agent<Env, RuntimeState> & RuntimeAgentControls &
|
|
330
|
+
> = Agent<Env, RuntimeState> & RuntimeAgentHostSeam & RuntimeAgentControls &
|
|
324
331
|
RuntimeAgentConfigControls<Command, Change> & AgentToolChildAdapter<unknown, string>;
|
|
325
332
|
|
|
326
333
|
/**
|
package/src/runtime.ts
CHANGED
|
@@ -405,6 +405,13 @@ interface PendingTemporaryAgentApproval {
|
|
|
405
405
|
resolve(decision: TemporaryAgentApprovalDecision): void;
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
export interface ToolInteractionResponsePreparation {
|
|
409
|
+
readonly toolCallId: string;
|
|
410
|
+
readonly toolName: string;
|
|
411
|
+
readonly toolInput: unknown;
|
|
412
|
+
readonly response: unknown;
|
|
413
|
+
}
|
|
414
|
+
|
|
408
415
|
// #endregion
|
|
409
416
|
|
|
410
417
|
/**
|
|
@@ -432,6 +439,13 @@ export abstract class AgentRuntimeKernel<
|
|
|
432
439
|
|
|
433
440
|
protected abstract ensureRuntimeReady(): Promise<void>;
|
|
434
441
|
|
|
442
|
+
/** Let the Host prepare a validated client response before Tool settlement. */
|
|
443
|
+
protected prepareToolInteractionResponse(
|
|
444
|
+
input: ToolInteractionResponsePreparation,
|
|
445
|
+
): Promise<unknown | null> {
|
|
446
|
+
return Promise.resolve(input.response);
|
|
447
|
+
}
|
|
448
|
+
|
|
435
449
|
private activeRuntime?: ActiveRuntime;
|
|
436
450
|
private telemetryCoordinator?: RuntimeTelemetryCoordinator;
|
|
437
451
|
private piAdapter?: PiRuntimeAdapter;
|
|
@@ -4283,10 +4297,19 @@ export abstract class AgentRuntimeKernel<
|
|
|
4283
4297
|
|
|
4284
4298
|
const input = safeParseJson(pending.inputJson);
|
|
4285
4299
|
if (!spec.validateResponse(input, response)) return { ok: false };
|
|
4300
|
+
const prepared = await this.prepareToolInteractionResponse({
|
|
4301
|
+
toolCallId,
|
|
4302
|
+
toolName: pending.toolName,
|
|
4303
|
+
toolInput: input,
|
|
4304
|
+
response,
|
|
4305
|
+
});
|
|
4306
|
+
if (prepared === null || !spec.validateResponse(input, prepared)) {
|
|
4307
|
+
return { ok: false };
|
|
4308
|
+
}
|
|
4286
4309
|
const settled = spec.settle
|
|
4287
|
-
? spec.settle(input,
|
|
4288
|
-
: defaultInteractionResult(
|
|
4289
|
-
return this.interactions.respond(pending.interactionId,
|
|
4310
|
+
? spec.settle(input, prepared)
|
|
4311
|
+
: defaultInteractionResult(prepared);
|
|
4312
|
+
return this.interactions.respond(pending.interactionId, prepared, settled);
|
|
4290
4313
|
}
|
|
4291
4314
|
|
|
4292
4315
|
/**
|