@springbrand/agent-runtime 0.1.3-alpha.1 → 0.1.3-alpha.10
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 +12 -3
- package/src/adapter/cloudflare/index.ts +56 -0
- package/src/adapter/cloudflare/resources/runtime-resources.ts +89 -0
- package/src/adapter/cloudflare/sandbox/adapter.ts +1513 -0
- package/src/adapter/cloudflare/sandbox/id.ts +23 -0
- package/src/adapter/cloudflare/sandbox/policy.ts +15 -0
- package/src/adapter/cloudflare/subagent/definition.ts +574 -0
- package/src/adapter/cloudflare/subagent/runner.ts +175 -0
- package/src/adapter/cloudflare/subagent/tools.ts +254 -0
- package/src/adapter/cloudflare/universal-agent/hooks.ts +35 -0
- package/src/adapter/cloudflare/universal-agent/preparation.ts +277 -0
- package/src/adapter/cloudflare/universal-agent/tools.ts +80 -0
- package/src/adapter/cloudflare/workspace/git-fs.ts +178 -0
- package/src/adapter/cloudflare/workspace/publisher.ts +31 -0
- package/src/adapter/cloudflare/workspace/scoped-workspace.ts +376 -0
- package/src/adapter/cloudflare/workspace/version-control.ts +374 -0
- package/src/agent-tool-runtime.ts +152 -0
- package/src/db/agent-tool.repo.ts +27 -0
- package/src/db/index.ts +33 -0
- package/src/db/interaction.repo.ts +185 -0
- package/src/db/schema.ts +25 -1
- package/src/db/submission.repo.ts +63 -1
- package/src/index.ts +57 -21
- package/src/kernel/approval-lifecycle.ts +41 -6
- package/src/kernel/bindings.ts +73 -9
- package/src/kernel/interaction-lifecycle.ts +395 -0
- package/src/kernel/public-contracts.ts +2 -0
- package/src/kernel/recoverable-chat-agent.ts +104 -6
- package/src/kernel/runtime-assembly-view.ts +37 -0
- package/src/kernel/runtime-assembly.ts +41 -0
- package/src/kernel/runtime-config.ts +4 -0
- package/src/kernel/runtime-load.ts +191 -0
- package/src/kernel/state.ts +12 -1
- package/src/kernel/submission-lifecycle.ts +33 -2
- package/src/layers/orchestration/temporary-agent/core.ts +12 -1
- package/src/layers/orchestration/temporary-agent/runner.ts +1 -2
- package/src/lib/mcp.ts +7 -3
- package/src/lib/prompt.ts +1 -1
- package/src/lib/telemetry-dev.ts +7 -4
- package/src/pi/assembly/context.ts +3 -3
- package/src/pi/assembly/extensions.ts +11 -22
- package/src/pi/assembly/snapshot.ts +6 -3
- package/src/pi/message/contract.ts +7 -0
- package/src/pi/message/conversion.ts +9 -1
- package/src/pi/runtime-adapter/assembly.ts +26 -31
- package/src/pi/runtime-adapter/execution.ts +198 -15
- package/src/pi/runtime-adapter/index.ts +24 -8
- package/src/pi/runtime-adapter/models.ts +382 -35
- package/src/pi/runtime-adapter/recovery.ts +188 -1
- package/src/pi/runtime-adapter/transcript.ts +61 -3
- package/src/pi/tool/ai-adapter.ts +58 -1
- package/src/pi/tool/base.ts +190 -12
- package/src/pi/tool/compiler.ts +34 -1
- package/src/pi/tool/core-host.ts +19 -24
- package/src/pi/tool/core.ts +30 -120
- package/src/pi/tool/gateway.ts +54 -0
- package/src/pi/tool/index.ts +2 -0
- package/src/pi/tool/mcp.ts +96 -68
- package/src/pi/tool/schedule.ts +41 -19
- package/src/pi/tool/skill.ts +126 -420
- package/src/pi/tool/subagent.ts +14 -2
- package/src/pi/tool/web-fetch.ts +281 -0
- package/src/pi/tool/web-search/api.ts +34 -18
- package/src/pi/tool/web-search/web-search.ts +0 -1
- package/src/pi/tool/workspace-revision.ts +64 -0
- package/src/pi/tool/workspace-sandbox.ts +105 -263
- package/src/pi/turn/index.ts +20 -0
- package/src/pi/turn/interaction.ts +181 -0
- package/src/pi/turn/tool-recovery.ts +244 -1
- package/src/runtime-agent-context.ts +112 -0
- package/src/runtime-agent.ts +568 -321
- package/src/{plugins.ts → runtime-assembler.ts} +372 -398
- package/src/runtime-definition.ts +175 -0
- package/src/runtime.ts +835 -204
- package/src/tool-registry.ts +143 -0
- package/src/workspace-versioning.ts +46 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
applyRecoveredPiApprovalDecision,
|
|
3
|
+
applyRecoveredPiToolInteractionSettlement,
|
|
3
4
|
commitPiRecoveryContinuation,
|
|
4
5
|
encodePiToolRecoveryMilestone,
|
|
5
6
|
planPiToolRecovery,
|
|
6
7
|
replayPiToolRecovery,
|
|
8
|
+
stagePiAssemblyRepin,
|
|
7
9
|
stagePiRecoveryContinuation,
|
|
8
10
|
type PiToolApproval,
|
|
11
|
+
type PiToolInteraction,
|
|
12
|
+
type PiToolInteractionCancelReason,
|
|
9
13
|
type PiToolRecoveryMilestone,
|
|
10
14
|
type PiToolRecoveryPlan,
|
|
11
15
|
type PiToolRecoveryState,
|
|
@@ -55,6 +59,32 @@ export type PiRecoveryCommand =
|
|
|
55
59
|
readonly kind: "record-approval";
|
|
56
60
|
readonly approval: PiToolApproval;
|
|
57
61
|
}
|
|
62
|
+
| {
|
|
63
|
+
readonly kind: "record-interaction";
|
|
64
|
+
readonly interaction: PiToolInteraction;
|
|
65
|
+
}
|
|
66
|
+
/** 停在人机等待期间换了装配:记下这次被承认的身份迁移。 */
|
|
67
|
+
| {
|
|
68
|
+
readonly kind: "repin-assembly";
|
|
69
|
+
readonly nextAssemblyRevision: string;
|
|
70
|
+
}
|
|
71
|
+
| {
|
|
72
|
+
readonly kind: "interaction-settlement";
|
|
73
|
+
readonly interactionId: string;
|
|
74
|
+
readonly settlement:
|
|
75
|
+
| {
|
|
76
|
+
readonly kind: "respond";
|
|
77
|
+
readonly response: unknown;
|
|
78
|
+
readonly result: {
|
|
79
|
+
readonly content: ToolResultMessage["content"];
|
|
80
|
+
readonly details: unknown;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
readonly kind: "cancel";
|
|
85
|
+
readonly reason: PiToolInteractionCancelReason;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
58
88
|
| {
|
|
59
89
|
readonly kind: "record-tool-result";
|
|
60
90
|
readonly toolCallId: string;
|
|
@@ -109,6 +139,17 @@ export type PiDurableMutation =
|
|
|
109
139
|
readonly status: "approved" | "rejected";
|
|
110
140
|
readonly decidedAt: number;
|
|
111
141
|
readonly reason?: string;
|
|
142
|
+
}
|
|
143
|
+
| {
|
|
144
|
+
readonly kind: "record-interaction";
|
|
145
|
+
readonly interaction: PiToolInteraction;
|
|
146
|
+
}
|
|
147
|
+
| {
|
|
148
|
+
readonly kind: "settle-interaction";
|
|
149
|
+
readonly interactionId: string;
|
|
150
|
+
readonly status: "responded" | "cancelled";
|
|
151
|
+
readonly settledAt: number;
|
|
152
|
+
readonly responseJson?: string;
|
|
112
153
|
};
|
|
113
154
|
|
|
114
155
|
export type PiRecoveryEffect =
|
|
@@ -131,7 +172,11 @@ export type PiRecoveryEffect =
|
|
|
131
172
|
}
|
|
132
173
|
| {
|
|
133
174
|
readonly kind: "wait";
|
|
134
|
-
readonly reason?:
|
|
175
|
+
readonly reason?:
|
|
176
|
+
| "approval"
|
|
177
|
+
| "interaction"
|
|
178
|
+
| "uncertain-tool"
|
|
179
|
+
| "complete";
|
|
135
180
|
}
|
|
136
181
|
| { readonly kind: "resume-turn" };
|
|
137
182
|
|
|
@@ -198,6 +243,31 @@ function approvalExecutionId(continuationKey: string): string | null {
|
|
|
198
243
|
return match?.[1] ?? null;
|
|
199
244
|
}
|
|
200
245
|
|
|
246
|
+
// 把 Tool 自己的 settle 映射产出的内容包成一条权威 ToolResult。
|
|
247
|
+
// interaction-settlement 命令处理 respond 分支时调用它。
|
|
248
|
+
// 记录必须已经存在 —— 拿不到 toolCallId/toolName 就无法把结果绑回原调用,这时失败关闭而不是编一个 id。
|
|
249
|
+
function interactionToolResult(
|
|
250
|
+
interaction: PiToolInteraction | undefined,
|
|
251
|
+
result: {
|
|
252
|
+
readonly content: ToolResultMessage["content"];
|
|
253
|
+
readonly details: unknown;
|
|
254
|
+
},
|
|
255
|
+
timestamp: number,
|
|
256
|
+
): ToolResultMessage {
|
|
257
|
+
if (!interaction) {
|
|
258
|
+
throw new Error("Pi Tool interaction is missing for its response");
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
role: "toolResult",
|
|
262
|
+
toolCallId: interaction.toolCallId,
|
|
263
|
+
toolName: interaction.toolName,
|
|
264
|
+
content: result.content,
|
|
265
|
+
details: result.details,
|
|
266
|
+
isError: false,
|
|
267
|
+
timestamp,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
201
271
|
// 把纯恢复计划翻译成持久化操作和 Runtime 下一步动作。
|
|
202
272
|
// decidePiRecovery 在命令应用并重放最新状态后调用它。
|
|
203
273
|
// pending 续跑必须先形成里程碑并重放,再允许 dispatch,避免外部动作早于可恢复状态落盘。
|
|
@@ -223,6 +293,11 @@ function fromPlan(
|
|
|
223
293
|
mutations: [],
|
|
224
294
|
effect: { kind: "wait", reason: "approval" },
|
|
225
295
|
};
|
|
296
|
+
case "parked-interaction":
|
|
297
|
+
return {
|
|
298
|
+
mutations: [],
|
|
299
|
+
effect: { kind: "wait", reason: "interaction" },
|
|
300
|
+
};
|
|
226
301
|
case "park-uncertain-tool":
|
|
227
302
|
return {
|
|
228
303
|
mutations: [],
|
|
@@ -425,6 +500,112 @@ export function decidePiRecovery(
|
|
|
425
500
|
applied = true;
|
|
426
501
|
}
|
|
427
502
|
}
|
|
503
|
+
if (input.command.kind === "repin-assembly") {
|
|
504
|
+
const milestone = stagePiAssemblyRepin(
|
|
505
|
+
state,
|
|
506
|
+
input.command.nextAssemblyRevision,
|
|
507
|
+
input.now,
|
|
508
|
+
);
|
|
509
|
+
if (milestone) {
|
|
510
|
+
const mutation = milestoneMutation(
|
|
511
|
+
`assembly-repin:${input.command.nextAssemblyRevision}`,
|
|
512
|
+
milestone,
|
|
513
|
+
);
|
|
514
|
+
mutations.push(mutation);
|
|
515
|
+
bodies = [...bodies, mutation.body];
|
|
516
|
+
state = replayPiToolRecovery(bodies);
|
|
517
|
+
applied = true;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (input.command.kind === "record-interaction") {
|
|
521
|
+
const interaction = input.command.interaction;
|
|
522
|
+
const existing = state.interactions[interaction.interactionId];
|
|
523
|
+
if (existing) {
|
|
524
|
+
// 同一个 interactionId 必须描述同一次 Tool 调用,否则重放会把响应投给错的调用。
|
|
525
|
+
// 口径与 record-approval 的冲突检查一致。
|
|
526
|
+
if (
|
|
527
|
+
existing.requestId !== interaction.requestId ||
|
|
528
|
+
existing.toolCallId !== interaction.toolCallId ||
|
|
529
|
+
existing.toolName !== interaction.toolName ||
|
|
530
|
+
existing.inputJson !== interaction.inputJson
|
|
531
|
+
) {
|
|
532
|
+
throw new Error(
|
|
533
|
+
`Conflicting Pi Tool interaction: ${interaction.interactionId}`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
const mutation = milestoneMutation(
|
|
538
|
+
`interaction:${interaction.interactionId}:${interaction.status}`,
|
|
539
|
+
{
|
|
540
|
+
...recoveryIdentity(input),
|
|
541
|
+
type: "interaction",
|
|
542
|
+
interaction,
|
|
543
|
+
},
|
|
544
|
+
);
|
|
545
|
+
mutations.push(
|
|
546
|
+
{ kind: "record-interaction", interaction },
|
|
547
|
+
mutation,
|
|
548
|
+
);
|
|
549
|
+
bodies = [...bodies, mutation.body];
|
|
550
|
+
state = replayPiToolRecovery(bodies);
|
|
551
|
+
applied = true;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (input.command.kind === "interaction-settlement") {
|
|
555
|
+
const command = input.command;
|
|
556
|
+
const settlement = applyRecoveredPiToolInteractionSettlement(
|
|
557
|
+
state,
|
|
558
|
+
command.settlement.kind === "respond"
|
|
559
|
+
? {
|
|
560
|
+
interactionId: command.interactionId,
|
|
561
|
+
kind: "respond",
|
|
562
|
+
response: command.settlement.response,
|
|
563
|
+
toolResult: interactionToolResult(
|
|
564
|
+
state.interactions[command.interactionId],
|
|
565
|
+
command.settlement.result,
|
|
566
|
+
input.now,
|
|
567
|
+
),
|
|
568
|
+
settledAt: input.now,
|
|
569
|
+
}
|
|
570
|
+
: {
|
|
571
|
+
interactionId: command.interactionId,
|
|
572
|
+
kind: "cancel",
|
|
573
|
+
reason: command.settlement.reason,
|
|
574
|
+
settledAt: input.now,
|
|
575
|
+
},
|
|
576
|
+
);
|
|
577
|
+
applied = settlement.outcome.kind !== "noop";
|
|
578
|
+
if (applied) {
|
|
579
|
+
const record = settlement.milestones.find(
|
|
580
|
+
(milestone) => milestone.type === "interaction",
|
|
581
|
+
);
|
|
582
|
+
if (!record || record.type !== "interaction") {
|
|
583
|
+
throw new Error("Pi interaction settlement milestone is missing");
|
|
584
|
+
}
|
|
585
|
+
mutations.push({
|
|
586
|
+
kind: "settle-interaction",
|
|
587
|
+
interactionId: record.interaction.interactionId,
|
|
588
|
+
status: record.interaction.status as "responded" | "cancelled",
|
|
589
|
+
settledAt: record.interaction.respondedAt ?? input.now,
|
|
590
|
+
...(record.interaction.responseJson === undefined
|
|
591
|
+
? {}
|
|
592
|
+
: { responseJson: record.interaction.responseJson }),
|
|
593
|
+
});
|
|
594
|
+
settlement.milestones.forEach((milestone, index) => {
|
|
595
|
+
mutations.push(milestoneMutation(
|
|
596
|
+
milestone.type === "interaction"
|
|
597
|
+
? `interaction:${command.interactionId}:${record.interaction.status}`
|
|
598
|
+
: `interaction-result:${command.interactionId}:${index}`,
|
|
599
|
+
milestone,
|
|
600
|
+
));
|
|
601
|
+
});
|
|
602
|
+
bodies = [
|
|
603
|
+
...bodies,
|
|
604
|
+
...settlement.milestones.map(encodePiToolRecoveryMilestone),
|
|
605
|
+
];
|
|
606
|
+
state = replayPiToolRecovery(bodies);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
428
609
|
if (input.command.kind === "record-tool-result") {
|
|
429
610
|
const command = input.command;
|
|
430
611
|
const toolResult: ToolResultMessage = {
|
|
@@ -649,6 +830,12 @@ export class PiRuntimeRecoveryAdapter
|
|
|
649
830
|
reason: "Pi Turn is waiting for Tool approval",
|
|
650
831
|
} as const;
|
|
651
832
|
}
|
|
833
|
+
if (decision.effect.reason === "interaction") {
|
|
834
|
+
return {
|
|
835
|
+
kind: "park",
|
|
836
|
+
reason: "Pi Turn is waiting for a client Tool interaction response",
|
|
837
|
+
} as const;
|
|
838
|
+
}
|
|
652
839
|
if (decision.effect.reason === "uncertain-tool") {
|
|
653
840
|
return {
|
|
654
841
|
kind: "park",
|
|
@@ -29,6 +29,10 @@ import {
|
|
|
29
29
|
import type { PiCanonicalMessageCommit } from "./execution";
|
|
30
30
|
import type { PiRecoveredToolSettlement } from "./recovery";
|
|
31
31
|
import type { RuntimeModelUsageEvent } from "../../kernel/bindings";
|
|
32
|
+
import {
|
|
33
|
+
isModelStreamStallMessage,
|
|
34
|
+
isRecoverableAssistantError,
|
|
35
|
+
} from "./models";
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
38
|
* 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
|
|
@@ -196,6 +200,15 @@ function hostText(message: UserMessage | AssistantMessage): string {
|
|
|
196
200
|
export class PiRuntimeTranscript {
|
|
197
201
|
private readonly store: PiTranscriptStore;
|
|
198
202
|
|
|
203
|
+
private modelBranch() {
|
|
204
|
+
return this.store.branch().filter((entry) =>
|
|
205
|
+
entry.type !== "message" ||
|
|
206
|
+
entry.message.role !== "assistant" ||
|
|
207
|
+
(!isModelStreamStallMessage(entry.message.errorMessage) &&
|
|
208
|
+
!isRecoverableAssistantError(entry.message))
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
199
212
|
/**
|
|
200
213
|
* 用当前 Agent 的 SQLite、Runtime 持久化端口和 Pi 模型表创建 Transcript。
|
|
201
214
|
*
|
|
@@ -383,7 +396,7 @@ export class PiRuntimeTranscript {
|
|
|
383
396
|
* 这里只返回 `message` entry;compaction 等 Session entry 由上下文转换阶段处理,不能混入原始消息列表。
|
|
384
397
|
*/
|
|
385
398
|
async canonicalMessages(): Promise<AgentMessage[]> {
|
|
386
|
-
return this.
|
|
399
|
+
return this.modelBranch().flatMap((entry) =>
|
|
387
400
|
entry.type === "message" ? [entry.message] : []
|
|
388
401
|
);
|
|
389
402
|
}
|
|
@@ -449,7 +462,7 @@ export class PiRuntimeTranscript {
|
|
|
449
462
|
onCompactionPersisted?: (event: RuntimeModelUsageEvent) => void;
|
|
450
463
|
},
|
|
451
464
|
): Promise<AgentMessage[]> {
|
|
452
|
-
const branch = this.
|
|
465
|
+
const branch = this.modelBranch();
|
|
453
466
|
const current = branch.flatMap((entry) =>
|
|
454
467
|
entry.type === "message" ? [entry.message] : []
|
|
455
468
|
);
|
|
@@ -616,10 +629,47 @@ export class PiRuntimeTranscript {
|
|
|
616
629
|
async browserMessages(): Promise<UIMessage[]> {
|
|
617
630
|
const result: UIMessage[] = [];
|
|
618
631
|
const seenAssistantSubmissions = new Set<string>();
|
|
632
|
+
const entries = await this.storedMessages();
|
|
633
|
+
const assistantSubmissions = new Set(entries.flatMap((entry) =>
|
|
634
|
+
entry.submissionId && entry.message.role === "assistant"
|
|
635
|
+
? [entry.submissionId]
|
|
636
|
+
: []
|
|
637
|
+
));
|
|
638
|
+
let pendingFailedSubmissionId: string | undefined;
|
|
639
|
+
const flushFailedSubmission = () => {
|
|
640
|
+
if (!pendingFailedSubmissionId) return;
|
|
641
|
+
const submissionId = pendingFailedSubmissionId;
|
|
642
|
+
pendingFailedSubmissionId = undefined;
|
|
643
|
+
const submission = this.durability.findSubmissionProjection(submissionId);
|
|
644
|
+
if (submission?.status !== "error") return;
|
|
645
|
+
const completedAt = submission.completedAt ?? submission.createdAt;
|
|
646
|
+
result.push({
|
|
647
|
+
id: submission.assistantMessageId,
|
|
648
|
+
role: "assistant",
|
|
649
|
+
parts: [],
|
|
650
|
+
metadata: {
|
|
651
|
+
createdAt: submission.createdAt,
|
|
652
|
+
completedAt,
|
|
653
|
+
turnDurationMs: Math.max(
|
|
654
|
+
0,
|
|
655
|
+
completedAt - submission.createdAt,
|
|
656
|
+
),
|
|
657
|
+
turnStatus: "error",
|
|
658
|
+
...(submission.error ? { error: submission.error } : {}),
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
seenAssistantSubmissions.add(submissionId);
|
|
662
|
+
};
|
|
619
663
|
let activeAssistant:
|
|
620
664
|
| { submissionId: string; index: number }
|
|
621
665
|
| undefined;
|
|
622
|
-
for (const entry of
|
|
666
|
+
for (const entry of entries) {
|
|
667
|
+
if (
|
|
668
|
+
pendingFailedSubmissionId &&
|
|
669
|
+
entry.submissionId !== pendingFailedSubmissionId
|
|
670
|
+
) {
|
|
671
|
+
flushFailedSubmission();
|
|
672
|
+
}
|
|
623
673
|
if (entry.message.role === "user") {
|
|
624
674
|
const body = this.durability.readUserSidecar(entry.id);
|
|
625
675
|
result.push(userUIMessage(entry.message, {
|
|
@@ -629,6 +679,13 @@ export class PiRuntimeTranscript {
|
|
|
629
679
|
? JSON.parse(body) as UIUserSidecar
|
|
630
680
|
: undefined,
|
|
631
681
|
}));
|
|
682
|
+
if (
|
|
683
|
+
entry.submissionId &&
|
|
684
|
+
!assistantSubmissions.has(entry.submissionId) &&
|
|
685
|
+
!seenAssistantSubmissions.has(entry.submissionId)
|
|
686
|
+
) {
|
|
687
|
+
pendingFailedSubmissionId = entry.submissionId;
|
|
688
|
+
}
|
|
632
689
|
activeAssistant = undefined;
|
|
633
690
|
continue;
|
|
634
691
|
}
|
|
@@ -744,6 +801,7 @@ export class PiRuntimeTranscript {
|
|
|
744
801
|
}
|
|
745
802
|
}
|
|
746
803
|
}
|
|
804
|
+
flushFailedSubmission();
|
|
747
805
|
return result;
|
|
748
806
|
}
|
|
749
807
|
|
|
@@ -38,6 +38,52 @@ function resolveDescription(tool: AiTool): string {
|
|
|
38
38
|
return typeof raw === "string" ? raw : "";
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function modelContent(
|
|
42
|
+
output: unknown,
|
|
43
|
+
): AgentToolResult<unknown>["content"] | null {
|
|
44
|
+
if (typeof output !== "object" || output === null) return null;
|
|
45
|
+
const modelOutput = output as { type?: unknown; value?: unknown };
|
|
46
|
+
if (
|
|
47
|
+
(modelOutput.type === "text" || modelOutput.type === "error-text") &&
|
|
48
|
+
typeof modelOutput.value === "string"
|
|
49
|
+
) {
|
|
50
|
+
return [{ type: "text", text: modelOutput.value }];
|
|
51
|
+
}
|
|
52
|
+
if (modelOutput.type === "json") {
|
|
53
|
+
return [{ type: "text", text: serializeOutput(modelOutput.value).text }];
|
|
54
|
+
}
|
|
55
|
+
if (modelOutput.type !== "content" || !Array.isArray(modelOutput.value)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const content: AgentToolResult<unknown>["content"] = [];
|
|
60
|
+
for (const block of modelOutput.value) {
|
|
61
|
+
if (typeof block !== "object" || block === null) continue;
|
|
62
|
+
const item = block as {
|
|
63
|
+
type?: unknown;
|
|
64
|
+
text?: unknown;
|
|
65
|
+
data?: unknown;
|
|
66
|
+
mediaType?: unknown;
|
|
67
|
+
};
|
|
68
|
+
if (item.type === "text" && typeof item.text === "string") {
|
|
69
|
+
content.push({ type: "text", text: item.text });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
(item.type === "file-data" || item.type === "image-data") &&
|
|
74
|
+
typeof item.data === "string" &&
|
|
75
|
+
typeof item.mediaType === "string"
|
|
76
|
+
) {
|
|
77
|
+
content.push({
|
|
78
|
+
type: "image",
|
|
79
|
+
data: item.data,
|
|
80
|
+
mimeType: item.mediaType,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return content.length > 0 ? content : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
41
87
|
/**
|
|
42
88
|
* 把一个可执行的 ai-sdk Tool 转成 Pi AgentTool。
|
|
43
89
|
*
|
|
@@ -91,8 +137,19 @@ export function aiToolToPi(
|
|
|
91
137
|
context: undefined,
|
|
92
138
|
});
|
|
93
139
|
const details = options.details ? options.details(raw) : raw;
|
|
140
|
+
const toModelOutput = (tool as { toModelOutput?: unknown })
|
|
141
|
+
.toModelOutput;
|
|
142
|
+
const content = typeof toModelOutput === "function"
|
|
143
|
+
? modelContent(await (toModelOutput as (input: {
|
|
144
|
+
input: unknown;
|
|
145
|
+
output: unknown;
|
|
146
|
+
}) => unknown)({ input: params, output: raw }))
|
|
147
|
+
: null;
|
|
94
148
|
return {
|
|
95
|
-
content: [{
|
|
149
|
+
content: content ?? [{
|
|
150
|
+
type: "text",
|
|
151
|
+
text: serializeOutput(details).text,
|
|
152
|
+
}],
|
|
96
153
|
details,
|
|
97
154
|
};
|
|
98
155
|
},
|
package/src/pi/tool/base.ts
CHANGED
|
@@ -2,9 +2,16 @@ import type {
|
|
|
2
2
|
AgentTool,
|
|
3
3
|
AgentToolResult,
|
|
4
4
|
} from "@earendil-works/pi-agent-core";
|
|
5
|
-
import { Type, type TSchema } from "@earendil-works/pi-ai";
|
|
5
|
+
import { Type, type Static, type TSchema } from "@earendil-works/pi-ai";
|
|
6
|
+
import { estimateStringTokens } from "agents/experimental/memory/utils";
|
|
7
|
+
import type { RuntimeMemoryPort } from "../../kernel/bindings";
|
|
8
|
+
import type { RuntimeMemoryProfile } from "../../kernel/profile";
|
|
6
9
|
import { serializeOutput } from "../../lib/artifacts";
|
|
7
10
|
import type { PiToolCandidate } from "./compiler";
|
|
11
|
+
import {
|
|
12
|
+
toolRegistryFromPiCandidates,
|
|
13
|
+
type ToolRegistry,
|
|
14
|
+
} from "../../tool-registry";
|
|
8
15
|
import { webSearchPiToolCandidate } from "./web-search";
|
|
9
16
|
import type { WebSearch } from "./web-search/api";
|
|
10
17
|
|
|
@@ -23,6 +30,34 @@ const askUserParameters = Type.Object({
|
|
|
23
30
|
})),
|
|
24
31
|
});
|
|
25
32
|
|
|
33
|
+
/**
|
|
34
|
+
* `ask_user` 的客户端响应体。
|
|
35
|
+
*
|
|
36
|
+
* `selections` 是选中的选项原文(多选时多于一项),`text` 是可选的补充说明。
|
|
37
|
+
* 刻意用结构化数组而不是拼好的字符串 —— 选项本身可能含分隔符,拼了再拆是有损的。
|
|
38
|
+
*/
|
|
39
|
+
const askUserResponse = Type.Object({
|
|
40
|
+
selections: Type.Array(Type.String()),
|
|
41
|
+
text: Type.Optional(Type.String()),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// 手写校验而不是拉 TypeBox 的 compiler:这里只需要判真假,
|
|
45
|
+
// 且校验必须在 Worker 冷启动路径上零成本。
|
|
46
|
+
function isAskUserResponse(value: unknown): boolean {
|
|
47
|
+
if (typeof value !== "object" || value === null) return false;
|
|
48
|
+
const record = value as Record<string, unknown>;
|
|
49
|
+
if (!Array.isArray(record.selections)) return false;
|
|
50
|
+
if (!record.selections.every((item) => typeof item === "string")) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (record.text !== undefined && typeof record.text !== "string") {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
// 什么都没选、也没写字,等于没回答 —— 不该被当成一次有效结算。
|
|
57
|
+
return record.selections.length > 0 ||
|
|
58
|
+
(typeof record.text === "string" && record.text.trim().length > 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
const suggestFollowupsParameters = Type.Object({
|
|
27
62
|
items: Type.Array(Type.String({
|
|
28
63
|
description: "A follow-up the user could ask next, phrased as a request.",
|
|
@@ -42,13 +77,63 @@ const updatePlanParameters = Type.Object({
|
|
|
42
77
|
Type.Literal("pending"),
|
|
43
78
|
Type.Literal("in_progress"),
|
|
44
79
|
Type.Literal("done"),
|
|
45
|
-
], {
|
|
80
|
+
], {
|
|
81
|
+
description:
|
|
82
|
+
'Current status. Use "done" for a finished step; never use "completed".',
|
|
83
|
+
}),
|
|
46
84
|
}), {
|
|
47
85
|
description:
|
|
48
86
|
"The complete, ordered plan. Overwrites any previously reported plan.",
|
|
49
87
|
}),
|
|
50
88
|
});
|
|
51
89
|
|
|
90
|
+
type UpdatePlanArguments = Static<typeof updatePlanParameters>;
|
|
91
|
+
|
|
92
|
+
function normalizeStepStatus(
|
|
93
|
+
status: unknown,
|
|
94
|
+
): UpdatePlanArguments["steps"][number]["status"] {
|
|
95
|
+
switch (status) {
|
|
96
|
+
case "done":
|
|
97
|
+
case "completed": // Anthropic models sometimes output "completed"
|
|
98
|
+
return "done";
|
|
99
|
+
case "in_progress":
|
|
100
|
+
case "inProgress": // camelCase variant
|
|
101
|
+
return "in_progress";
|
|
102
|
+
default: // covers "pending", "todo" (Google), and any other unknown value
|
|
103
|
+
return "pending";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function normalizeUpdatePlanArguments(
|
|
108
|
+
input: unknown,
|
|
109
|
+
): UpdatePlanArguments {
|
|
110
|
+
if (input === null || typeof input !== "object") {
|
|
111
|
+
return input as UpdatePlanArguments;
|
|
112
|
+
}
|
|
113
|
+
const value = input as Record<string, unknown>;
|
|
114
|
+
if (!Array.isArray(value.steps)) return input as UpdatePlanArguments;
|
|
115
|
+
return {
|
|
116
|
+
...value,
|
|
117
|
+
steps: value.steps.map((step) => {
|
|
118
|
+
if (step === null || typeof step !== "object") return step;
|
|
119
|
+
const s = step as Record<string, unknown>;
|
|
120
|
+
return { ...s, status: normalizeStepStatus(s.status) };
|
|
121
|
+
}),
|
|
122
|
+
} as UpdatePlanArguments;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const setContextParameters = Type.Object({
|
|
126
|
+
label: Type.Union([
|
|
127
|
+
Type.Literal("memory"),
|
|
128
|
+
Type.Literal("preferences"),
|
|
129
|
+
]),
|
|
130
|
+
content: Type.String(),
|
|
131
|
+
action: Type.Optional(Type.Union([
|
|
132
|
+
Type.Literal("replace"),
|
|
133
|
+
Type.Literal("append"),
|
|
134
|
+
])),
|
|
135
|
+
});
|
|
136
|
+
|
|
52
137
|
function result<T>(details: T): AgentToolResult<T> {
|
|
53
138
|
return {
|
|
54
139
|
content: [{ type: "text", text: serializeOutput(details).text }],
|
|
@@ -59,7 +144,6 @@ function result<T>(details: T): AgentToolResult<T> {
|
|
|
59
144
|
function candidate<T extends TSchema>(tool: AgentTool<T>): PiToolCandidate {
|
|
60
145
|
return {
|
|
61
146
|
owner: "runtime-base",
|
|
62
|
-
authorized: true,
|
|
63
147
|
requiredExecutionLevel: "safe",
|
|
64
148
|
source: "action",
|
|
65
149
|
tool,
|
|
@@ -71,16 +155,41 @@ export function basePiToolCandidates(
|
|
|
71
155
|
webSearch?: WebSearch,
|
|
72
156
|
): PiToolCandidate[] {
|
|
73
157
|
return [
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
158
|
+
// ask_user 是 client-settled tool:它没有 execute,结果由用户点选后经
|
|
159
|
+
// respondToolInteraction 投递回来。工具调用会一直 park 到那时候。
|
|
160
|
+
{
|
|
161
|
+
...candidate({
|
|
162
|
+
name: "ask_user",
|
|
163
|
+
label: "Ask user",
|
|
164
|
+
description:
|
|
165
|
+
"Ask the user to choose among a small set of options. Call this tool whenever you need the user to make a choice from limited options — do NOT write plain text like 'please pick A / B / C'. The user's choice comes back to you as this tool's result, so just continue once you have it. Do NOT end your turn after calling this tool.",
|
|
166
|
+
parameters: askUserParameters,
|
|
167
|
+
// 永远不会被调用:执行链在 interaction 闸就 park 住了。留一个失败关闭的
|
|
168
|
+
// 实现,是为了万一哪次改动绕过了那道闸,能立刻炸出来而不是静默返回空答案。
|
|
169
|
+
async execute() {
|
|
170
|
+
throw new Error(
|
|
171
|
+
"ask_user is client-settled and must not execute on the server",
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
interaction: {
|
|
176
|
+
validateResponse: isAskUserResponse,
|
|
177
|
+
settle: (_input, response) => {
|
|
178
|
+
const answer = response as Static<typeof askUserResponse>;
|
|
179
|
+
const parts = [
|
|
180
|
+
...answer.selections,
|
|
181
|
+
...(answer.text?.trim() ? [answer.text.trim()] : []),
|
|
182
|
+
];
|
|
183
|
+
return {
|
|
184
|
+
content: [{
|
|
185
|
+
type: "text",
|
|
186
|
+
text: `The user answered: ${parts.join(" / ")}`,
|
|
187
|
+
}],
|
|
188
|
+
details: answer,
|
|
189
|
+
};
|
|
190
|
+
},
|
|
82
191
|
},
|
|
83
|
-
}
|
|
192
|
+
},
|
|
84
193
|
candidate({
|
|
85
194
|
name: "suggest_followups",
|
|
86
195
|
label: "Suggest follow-ups",
|
|
@@ -97,6 +206,7 @@ export function basePiToolCandidates(
|
|
|
97
206
|
description:
|
|
98
207
|
"Maintain the user-visible plan for the current task. Call this whenever a task involves 2 or more steps, and again every time the plan or a step's status changes. Always pass the FULL plan — it replaces the previous plan entirely (idempotent overwrite), so omitted steps disappear.",
|
|
99
208
|
parameters: updatePlanParameters,
|
|
209
|
+
prepareArguments: normalizeUpdatePlanArguments,
|
|
100
210
|
async execute(_toolCallId, input) {
|
|
101
211
|
return result({
|
|
102
212
|
ok: true,
|
|
@@ -108,3 +218,71 @@ export function basePiToolCandidates(
|
|
|
108
218
|
...(webSearch ? [webSearchPiToolCandidate(webSearch)] : []),
|
|
109
219
|
];
|
|
110
220
|
}
|
|
221
|
+
|
|
222
|
+
/** Writable hot-memory Tool restored from Think Session context blocks. */
|
|
223
|
+
export function memoryPiToolCandidate(
|
|
224
|
+
memory: RuntimeMemoryPort,
|
|
225
|
+
profile: Pick<
|
|
226
|
+
RuntimeMemoryProfile,
|
|
227
|
+
"memoryTokens" | "preferencesTokens"
|
|
228
|
+
>,
|
|
229
|
+
): PiToolCandidate {
|
|
230
|
+
const queues = new Map<string, Promise<void>>();
|
|
231
|
+
const tool: AgentTool<typeof setContextParameters> = {
|
|
232
|
+
name: "set_context",
|
|
233
|
+
label: "Set context",
|
|
234
|
+
description:
|
|
235
|
+
"Replace or append durable working context. Use memory for facts and active context; use preferences for tone, format, tools, and workflow preferences.",
|
|
236
|
+
parameters: setContextParameters,
|
|
237
|
+
async execute(_toolCallId, { label, content, action = "replace" }) {
|
|
238
|
+
const run = async () => {
|
|
239
|
+
const existing = action === "append"
|
|
240
|
+
? await memory.get(label) ?? ""
|
|
241
|
+
: "";
|
|
242
|
+
const separator = existing && !content.startsWith("\n") ? "\n" : "";
|
|
243
|
+
const updated = `${existing}${separator}${content}`;
|
|
244
|
+
const maxTokens = label === "memory"
|
|
245
|
+
? profile.memoryTokens
|
|
246
|
+
: profile.preferencesTokens;
|
|
247
|
+
const tokens = estimateStringTokens(updated);
|
|
248
|
+
if (tokens > maxTokens) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`Block "${label}" exceeds maxTokens: ${tokens} > ${maxTokens}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
await memory.set(label, updated);
|
|
254
|
+
return result({ label, action, tokens, maxTokens });
|
|
255
|
+
};
|
|
256
|
+
const tail = queues.get(label) ?? Promise.resolve();
|
|
257
|
+
const output = tail.then(run);
|
|
258
|
+
const next = output.then(
|
|
259
|
+
() => {},
|
|
260
|
+
() => {},
|
|
261
|
+
);
|
|
262
|
+
queues.set(label, next);
|
|
263
|
+
void next.then(() => {
|
|
264
|
+
if (queues.get(label) === next) queues.delete(label);
|
|
265
|
+
});
|
|
266
|
+
return output;
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
return {
|
|
270
|
+
owner: "runtime-memory",
|
|
271
|
+
requiredExecutionLevel: "safe",
|
|
272
|
+
source: "action",
|
|
273
|
+
tool,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 从 Memory Port 生成 `set_context` Tool。 */
|
|
278
|
+
export function createMemoryTools(
|
|
279
|
+
memory: RuntimeMemoryPort,
|
|
280
|
+
profile: Pick<
|
|
281
|
+
RuntimeMemoryProfile,
|
|
282
|
+
"memoryTokens" | "preferencesTokens"
|
|
283
|
+
>,
|
|
284
|
+
): ToolRegistry {
|
|
285
|
+
return toolRegistryFromPiCandidates([
|
|
286
|
+
memoryPiToolCandidate(memory, profile),
|
|
287
|
+
]);
|
|
288
|
+
}
|