@springbrand/agent-runtime 0.2.0-alpha.25 → 0.2.0-alpha.28
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/kernel/public-contracts.ts +1 -0
- package/src/kernel/submission-lifecycle.ts +5 -6
- package/src/lib/prompt.ts +2 -2
- package/src/pi/runtime-adapter/execution.ts +38 -1
- package/src/pi/tool/base.ts +3 -2
- package/src/pi/tool/compiler.ts +33 -4
- package/src/pi/tool/core-host.ts +6 -6
- package/src/pi/tool/skill.ts +1 -1
- package/src/pi/tool/workspace-sandbox.ts +1 -7
- package/src/runtime-assembler.ts +15 -3
- package/src/runtime.ts +47 -2
package/package.json
CHANGED
|
@@ -17,6 +17,7 @@ export * from "../pi/message";
|
|
|
17
17
|
export * from "./receipts";
|
|
18
18
|
export * from "./state";
|
|
19
19
|
export * from "../lib/execution-level";
|
|
20
|
+
export type { WorkspaceRevision, WorkspaceRevisionManifest } from "../workspace-versioning";
|
|
20
21
|
export type {
|
|
21
22
|
RuntimeEventConfirmation,
|
|
22
23
|
RuntimeLifecycleFact,
|
|
@@ -639,12 +639,13 @@ export class SubmissionLifecycle<
|
|
|
639
639
|
}
|
|
640
640
|
|
|
641
641
|
/**
|
|
642
|
-
*
|
|
642
|
+
* 为一条未结束提交记录取消终态,并中断活动执行器。
|
|
643
643
|
*
|
|
644
644
|
* @remarks
|
|
645
645
|
* 客户端取消事件和 `cancelSubmissionById` RPC 按提交标识调用;不存在或已结束时返回 `ok: false`。
|
|
646
646
|
*
|
|
647
|
-
*
|
|
647
|
+
* 先在事务中保存取消原因和恢复意图,再中断活动执行器并在返回前写入终态,
|
|
648
|
+
* 使任何不配合取消的执行器都不能让调用方继续观察到 running。
|
|
648
649
|
*/
|
|
649
650
|
async cancel(
|
|
650
651
|
submissionId: string,
|
|
@@ -660,10 +661,8 @@ export class SubmissionLifecycle<
|
|
|
660
661
|
});
|
|
661
662
|
const active = this.activeBySubmission.get(submissionId);
|
|
662
663
|
if (active) this.options.abortActive(active);
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
this.pump();
|
|
666
|
-
}
|
|
664
|
+
await this.finish(submission, "aborted", reason);
|
|
665
|
+
if (!active) this.pump();
|
|
667
666
|
return { ok: true };
|
|
668
667
|
}
|
|
669
668
|
|
package/src/lib/prompt.ts
CHANGED
|
@@ -49,7 +49,6 @@ export const TOOLS =
|
|
|
49
49
|
"inside execute. Any operation that needs the same Tool more than once, two or more related file, Skill, Extension, MCP, or Host Tool calls, " +
|
|
50
50
|
"or branching or repetition MUST use one execute. Do not make repeated top-level calls for that work. Inside execute, use state.* for " +
|
|
51
51
|
"workspace file operations, tools.* for other host capabilities, a loop for repeated calls, and Promise.all for independent calls. " +
|
|
52
|
-
"For materialize_skill_resource, materialize every known set of Skill resources in one execute; never start one execute per resource. " +
|
|
53
52
|
"Tools available only at the top level must stay Direct. " +
|
|
54
53
|
"For web tasks, use web_search for web discovery, current facts, cited research, " +
|
|
55
54
|
"and public URL analysis. For web access specifically, use execute Code Mode only for raw or customized network requests, structured " +
|
|
@@ -106,7 +105,8 @@ export const FILES =
|
|
|
106
105
|
export const INTERACTION =
|
|
107
106
|
"Interaction: When progress requires user decisions, put all 1-4 necessary questions in one ask_user call " +
|
|
108
107
|
"instead of asking each question separately or writing 'please choose A/B/C' as text. Use 2-6 options for " +
|
|
109
|
-
"a bounded choice
|
|
108
|
+
"a bounded choice; for required free text, omit the options field entirely — never pass an empty or single-item options array; " +
|
|
109
|
+
"continue the same turn when its result arrives. " +
|
|
110
110
|
"Don't use it when a sensible default lets you proceed. " +
|
|
111
111
|
"After completing a substantive task (report, analysis, multi-step job): write your full answer " +
|
|
112
112
|
"FIRST, then call suggest_followups (2-4 directions) as the very last action and end the turn — " +
|
|
@@ -210,6 +210,41 @@ function projectToolResultsForModel(
|
|
|
210
210
|
return changed ? projected : [...messages];
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
function orderLateToolResultsBeforeTurnAbortMarker(
|
|
214
|
+
messages: readonly AgentMessage[],
|
|
215
|
+
): AgentMessage[] {
|
|
216
|
+
const ordered: AgentMessage[] = [];
|
|
217
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
218
|
+
const message = messages[index]!;
|
|
219
|
+
const previous = ordered.at(-1);
|
|
220
|
+
if (
|
|
221
|
+
message.role !== "custom" ||
|
|
222
|
+
message.customType !== "turn-aborted" ||
|
|
223
|
+
previous?.role !== "assistant"
|
|
224
|
+
) {
|
|
225
|
+
ordered.push(message);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const pending = new Set(
|
|
229
|
+
previous.content.flatMap((part) =>
|
|
230
|
+
part.type === "toolCall" ? [part.id] : []
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
let cursor = index + 1;
|
|
234
|
+
while (true) {
|
|
235
|
+
const result = messages[cursor];
|
|
236
|
+
if (result?.role !== "toolResult" || !pending.has(result.toolCallId)) {
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
ordered.push(result);
|
|
240
|
+
cursor += 1;
|
|
241
|
+
}
|
|
242
|
+
ordered.push(message);
|
|
243
|
+
index = cursor - 1;
|
|
244
|
+
}
|
|
245
|
+
return ordered;
|
|
246
|
+
}
|
|
247
|
+
|
|
213
248
|
class PiTurnAdapter {
|
|
214
249
|
private piInstance?: PiCore;
|
|
215
250
|
private abortRequested = false;
|
|
@@ -304,7 +339,9 @@ class PiTurnAdapter {
|
|
|
304
339
|
transformContext: async (messages, signal) => {
|
|
305
340
|
const ctx = await this.opts.transformContext(messages, signal);
|
|
306
341
|
return transformMessages(
|
|
307
|
-
|
|
342
|
+
orderLateToolResultsBeforeTurnAbortMarker(
|
|
343
|
+
projectToolResultsForModel(ctx, selfBounded),
|
|
344
|
+
) as Message[],
|
|
308
345
|
this.opts.pi.model,
|
|
309
346
|
) as AgentMessage[];
|
|
310
347
|
},
|
package/src/pi/tool/base.ts
CHANGED
|
@@ -24,7 +24,8 @@ const askUserQuestion = Type.Object({
|
|
|
24
24
|
minItems: 2,
|
|
25
25
|
maxItems: 6,
|
|
26
26
|
uniqueItems: true,
|
|
27
|
-
description:
|
|
27
|
+
description:
|
|
28
|
+
"For a bounded choice, provide 2-6 mutually distinct options. For required free text, omit this field entirely; never pass an empty or single-item array.",
|
|
28
29
|
})),
|
|
29
30
|
multiSelect: Type.Optional(Type.Boolean({
|
|
30
31
|
description:
|
|
@@ -237,7 +238,7 @@ export function basePiToolCandidates(
|
|
|
237
238
|
name: "ask_user",
|
|
238
239
|
label: "Ask user",
|
|
239
240
|
description:
|
|
240
|
-
"Ask all 1-4 user questions needed to continue in one call.
|
|
241
|
+
"Ask all 1-4 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. Do not make one call per question or write plain text like 'please pick A / B / C'. The answers return as this tool's result, so continue the same turn after calling it.",
|
|
241
242
|
parameters: askUserParameters,
|
|
242
243
|
// 永远不会被调用:执行链在 interaction 闸就 park 住了。留一个失败关闭的
|
|
243
244
|
// 实现,是为了万一哪次改动绕过了那道闸,能立刻炸出来而不是静默返回空答案。
|
package/src/pi/tool/compiler.ts
CHANGED
|
@@ -204,6 +204,27 @@ async function durableResult(
|
|
|
204
204
|
return boundDurableToolOutput(result) as AgentToolResult<unknown>;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
async function untilAborted<T>(
|
|
208
|
+
signal: AbortSignal | undefined,
|
|
209
|
+
run: () => T | Promise<T>,
|
|
210
|
+
): Promise<T> {
|
|
211
|
+
// ponytail: 这会立即释放 Turn,但无法杀死不配合的进程内副作用;需要物理终止的 Tool 必须放进可销毁的隔离执行器。
|
|
212
|
+
signal?.throwIfAborted();
|
|
213
|
+
if (!signal) return run();
|
|
214
|
+
let onAbort!: () => void;
|
|
215
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
216
|
+
onAbort = () => reject(
|
|
217
|
+
signal.reason ?? new DOMException("Aborted", "AbortError"),
|
|
218
|
+
);
|
|
219
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
return await Promise.race([run(), aborted]);
|
|
223
|
+
} finally {
|
|
224
|
+
signal.removeEventListener("abort", onAbort);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
207
228
|
// 记录一次工具失败并在 Turn 达到上限时打开断路器。
|
|
208
229
|
// 受治理 execute 方法在工具或结算失败后调用。
|
|
209
230
|
// 同时记录单输入次数和 Turn 总数,是为了既阻止原样重试又防止换参数无限失败。
|
|
@@ -327,11 +348,19 @@ function governedTool(
|
|
|
327
348
|
}
|
|
328
349
|
|
|
329
350
|
let result: AgentToolResult<unknown>;
|
|
351
|
+
const guardedUpdate: typeof onUpdate = onUpdate
|
|
352
|
+
? (update) => {
|
|
353
|
+
if (!signal?.aborted) onUpdate(update);
|
|
354
|
+
}
|
|
355
|
+
: undefined;
|
|
330
356
|
try {
|
|
331
|
-
result = await
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
357
|
+
result = await untilAborted(
|
|
358
|
+
signal,
|
|
359
|
+
async () => durableResult(
|
|
360
|
+
await execute(toolCallId, args, signal, guardedUpdate),
|
|
361
|
+
candidate,
|
|
362
|
+
options.workspace,
|
|
363
|
+
),
|
|
335
364
|
);
|
|
336
365
|
} catch (cause) {
|
|
337
366
|
const message = errorMessage(cause);
|
package/src/pi/tool/core-host.ts
CHANGED
|
@@ -85,6 +85,8 @@ function previewNote(base: string): string {
|
|
|
85
85
|
|
|
86
86
|
function browserToolDescription(): string {
|
|
87
87
|
return "Run JavaScript against a live browser over the Chrome DevTools Protocol through the `cdp` connector. " +
|
|
88
|
+
"Use the bare lexical identifiers `cdp` and `codemode`. They are not properties of `globalThis` and are not importable modules. " +
|
|
89
|
+
"Never use `globalThis.cdp`, `globalThis.codemode`, or `import(\"cdp\")`; never import `cdp`. " +
|
|
88
90
|
"Each top-level `browser_execute` call is one-shot: it starts a fresh browser and closes it when the call returns. " +
|
|
89
91
|
"Never reuse a `targetId` or `sessionId` from an earlier browser_execute call. " +
|
|
90
92
|
"Create targets, attach, navigate, interact, inspect diagnostics, and capture screenshots within the same call. " +
|
|
@@ -131,16 +133,14 @@ export function createBrowserExecutionFactory(options: {
|
|
|
131
133
|
if (!tool) {
|
|
132
134
|
throw new Error("Upstream createBrowserTools did not return browser_execute");
|
|
133
135
|
}
|
|
134
|
-
const port =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
};
|
|
138
|
-
if (!options.previewBaseUrl) return port;
|
|
136
|
+
const port = toCodeExecutionPort(tool, "browser_execute");
|
|
137
|
+
const description = `${port.description}\n\n${browserToolDescription()}`;
|
|
138
|
+
if (!options.previewBaseUrl) return { ...port, description };
|
|
139
139
|
// 前置而不是追加:上游的 codemode 描述很长,缀在末尾的一句会被读丢 ——
|
|
140
140
|
// 2026-08-13 手动冒烟里模型就没看见它,转而去猜 `file://` 和 `https://workspace.local/`。
|
|
141
141
|
return {
|
|
142
142
|
...port,
|
|
143
|
-
description: `${previewNote(options.previewBaseUrl)}\n\n${
|
|
143
|
+
description: `${previewNote(options.previewBaseUrl)}\n\n${description}`,
|
|
144
144
|
};
|
|
145
145
|
},
|
|
146
146
|
};
|
package/src/pi/tool/skill.ts
CHANGED
|
@@ -111,7 +111,7 @@ function budgetSkillResources(skill: LoadedSkill): LoadedSkill {
|
|
|
111
111
|
if (portable.length > 0) {
|
|
112
112
|
lines.push(
|
|
113
113
|
`> If any of these resources are needed, copy all required ones into the Workspace ` +
|
|
114
|
-
`in one execute with materialize_skill_resource: ` +
|
|
114
|
+
`in one execute with tools.materialize_skill_resource: ` +
|
|
115
115
|
`${portable.map((entry) => entry.path).join(", ")}.`,
|
|
116
116
|
);
|
|
117
117
|
}
|
|
@@ -399,13 +399,7 @@ export function workspacePiToolCandidates(
|
|
|
399
399
|
const bash = aiToolToPi(
|
|
400
400
|
"bash",
|
|
401
401
|
createBashTool({ ops: workspace }),
|
|
402
|
-
{
|
|
403
|
-
label: "Workspace Bash",
|
|
404
|
-
description:
|
|
405
|
-
"Run a Bash script over Workspace files for shell-style workflows that combine multiple file operations. " +
|
|
406
|
-
"The Workspace is mounted at / and changes are written back. This is a virtual filesystem, not a machine: " +
|
|
407
|
-
"there is no network or system utilities; use execute for fetch and sandbox_exec for system commands.",
|
|
408
|
-
},
|
|
402
|
+
{ label: "Workspace Bash" },
|
|
409
403
|
);
|
|
410
404
|
|
|
411
405
|
return [
|
package/src/runtime-assembler.ts
CHANGED
|
@@ -268,12 +268,24 @@ async function createToolSurface(
|
|
|
268
268
|
typeof candidate.tool.execute === "function",
|
|
269
269
|
);
|
|
270
270
|
const codeExecutionTools = Object.freeze([...mergeable]);
|
|
271
|
+
const codeExecution = input.codeExecution.create(
|
|
272
|
+
toolRegistryFromPiCandidates(codeExecutionTools),
|
|
273
|
+
);
|
|
274
|
+
const materializeGuidance = codeExecutionTools.some(
|
|
275
|
+
({ tool }) => tool.name === "materialize_skill_resource",
|
|
276
|
+
)
|
|
277
|
+
? "Skill resource copying is available only inside execute through " +
|
|
278
|
+
"`tools.materialize_skill_resource({ name, path, destination })`; " +
|
|
279
|
+
"no Direct Tool is registered for it. " +
|
|
280
|
+
"Use a loop or Promise.all when copying multiple resources.\n\n"
|
|
281
|
+
: "";
|
|
271
282
|
return Object.freeze([
|
|
272
283
|
Object.freeze({
|
|
273
284
|
...codeExecutionPiToolCandidate(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
285
|
+
{
|
|
286
|
+
...codeExecution,
|
|
287
|
+
description: materializeGuidance + codeExecution.description,
|
|
288
|
+
},
|
|
277
289
|
),
|
|
278
290
|
codeExecutionTools,
|
|
279
291
|
}),
|
package/src/runtime.ts
CHANGED
|
@@ -613,7 +613,13 @@ export abstract class AgentRuntimeKernel<
|
|
|
613
613
|
resumeSubmission: async (submissionId) => {
|
|
614
614
|
await this.submissions.recover(submissionId);
|
|
615
615
|
},
|
|
616
|
-
onInteractionsChanged: () =>
|
|
616
|
+
onInteractionsChanged: async () => {
|
|
617
|
+
this.broadcast(json({
|
|
618
|
+
type: MessageType.CF_AGENT_CHAT_MESSAGES,
|
|
619
|
+
messages: await this.getMessages(),
|
|
620
|
+
}));
|
|
621
|
+
await this.broadcastApprovals();
|
|
622
|
+
},
|
|
617
623
|
onTelemetryRequested: (interaction) => {
|
|
618
624
|
this.telemetry.capture("interactionRequested", {
|
|
619
625
|
submissionId: interaction.submissionId,
|
|
@@ -2554,6 +2560,36 @@ export abstract class AgentRuntimeKernel<
|
|
|
2554
2560
|
return { kind: "settled" };
|
|
2555
2561
|
case "resume-turn": {
|
|
2556
2562
|
const last = (await this.transcript.canonicalMessages()).at(-1);
|
|
2563
|
+
if (last?.role === "assistant" && last.stopReason === "toolUse") {
|
|
2564
|
+
// Tool input 会在真正执行前持久化。恢复状态仍为 idle 证明这些
|
|
2565
|
+
// canonical Tool Call 还没越过 attempt 边界,包括非幂等 Tool 也可以安全首次执行。
|
|
2566
|
+
const unstartedCalls = last.content.filter((part) =>
|
|
2567
|
+
part.type === "toolCall"
|
|
2568
|
+
);
|
|
2569
|
+
if (unstartedCalls.length > 0) {
|
|
2570
|
+
for (const call of unstartedCalls) {
|
|
2571
|
+
try {
|
|
2572
|
+
if (
|
|
2573
|
+
!await adapter.retryTool({
|
|
2574
|
+
toolName: call.name,
|
|
2575
|
+
toolCallId: call.id,
|
|
2576
|
+
input: call.arguments,
|
|
2577
|
+
})
|
|
2578
|
+
) {
|
|
2579
|
+
return {
|
|
2580
|
+
kind: "unresumable",
|
|
2581
|
+
reason:
|
|
2582
|
+
`SpringBrand could not resume this turn: Tool "${call.name}" is no longer available.`,
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
} catch {
|
|
2586
|
+
// The governed Tool persisted its bounded error ToolResult.
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
decision = await this.materializeRecoveredToolResults(submission);
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2557
2593
|
// Pi 的 agentLoopContinue 拒绝从 assistant 消息继续。悬空的尾部 assistant
|
|
2558
2594
|
// 意味着这条 transcript 续不下去了 —— 判断出来就必须收尾,不能默默退场。
|
|
2559
2595
|
return last?.role === "user" || last?.role === "toolResult"
|
|
@@ -3060,9 +3096,18 @@ export abstract class AgentRuntimeKernel<
|
|
|
3060
3096
|
});
|
|
3061
3097
|
},
|
|
3062
3098
|
);
|
|
3099
|
+
const stoppedAfterScheduling = this.readSubmission(submissionId)
|
|
3100
|
+
?.abortReason;
|
|
3101
|
+
if (recoveryOutcome !== "disabled" && stoppedAfterScheduling) {
|
|
3102
|
+
await this.settleChatRecovery(
|
|
3103
|
+
submission.requestId,
|
|
3104
|
+
"skipped",
|
|
3105
|
+
stoppedAfterScheduling,
|
|
3106
|
+
);
|
|
3107
|
+
}
|
|
3063
3108
|
if (
|
|
3064
3109
|
recoveryOutcome !== "disabled" &&
|
|
3065
|
-
!
|
|
3110
|
+
!stoppedAfterScheduling
|
|
3066
3111
|
) {
|
|
3067
3112
|
// 只有恢复通道的调用方(`_chatRecoveryRetry`)靠这次重抛判定 "scheduled"。
|
|
3068
3113
|
// 让步续跑由 alarm 驱动,重抛只会把那次 alarm 变成一次失败。
|