@springbrand/agent-runtime 0.2.0-alpha.19 → 0.2.0-alpha.20

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.2.0-alpha.19",
3
+ "version": "0.2.0-alpha.20",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -4,7 +4,6 @@ import type {
4
4
  RunAgentToolOptions,
5
5
  } from "agents";
6
6
  import type {
7
- RuntimeBrowserPort,
8
7
  RuntimeMemoryPort,
9
8
  RuntimePlatformPort,
10
9
  RuntimeSubagentPort,
@@ -15,7 +14,11 @@ import type { RuntimeDegradation } from "../../../kernel/degradation";
15
14
  import type { RuntimeMemoryProfile } from "../../../kernel/profile";
16
15
  import { AGENT_TYPES } from "../../../layers/orchestration/subagents/agent-types/registry";
17
16
  import type { RuntimeAgentConfigContext } from "../../../runtime-agent-context";
18
- import { createWorkspaceCodeExecutionFactory } from "../../../pi/tool/core-host";
17
+ import {
18
+ createBrowserExecutionFactory,
19
+ createWorkspaceCodeExecutionFactory,
20
+ type RuntimeBrowserBinding,
21
+ } from "../../../pi/tool/core-host";
19
22
  import {
20
23
  createCloudflareSandboxAdapter,
21
24
  type SandboxAdmission,
@@ -33,7 +36,8 @@ export type PlatformLoader = () => Promise<RuntimePlatformPort>;
33
36
 
34
37
  export interface CloudflarePlatformBindings {
35
38
  LOADER: WorkerLoader;
36
- BROWSER?: RuntimeBrowserPort;
39
+ /** Browser Rendering 绑定(可选):CDP Code Mode 的 `browser_execute` 由它开出浏览器会话。 */
40
+ BROWSER?: RuntimeBrowserBinding;
37
41
  }
38
42
 
39
43
  export interface WorkspaceRequirement {
@@ -97,7 +101,18 @@ export function createWorkspaceLoader(
97
101
 
98
102
  export function createPlatformLoader<
99
103
  Env extends Cloudflare.Env & CloudflarePlatformBindings,
100
- >(context: RuntimeAgentConfigContext<Env>): PlatformLoader {
104
+ >(
105
+ context: RuntimeAgentConfigContext<Env>,
106
+ options: {
107
+ /**
108
+ * 本 Session 的 Workspace preview URL(可以带短期签名 query)。
109
+ *
110
+ * 只有 Host 知道公开 origin、这个 Session 的身份和签名方式,所以只能由 Host 传进来。
111
+ * 调用方拿不到就别传 —— 浏览器工具会因此不声称自己能打开产物。
112
+ */
113
+ previewBaseUrl?: string;
114
+ } = {},
115
+ ): PlatformLoader {
101
116
  let cached: ReturnType<PlatformLoader> | undefined;
102
117
  return () => cached ??= Promise.resolve().then(() => {
103
118
  const exports = (context.ctx as unknown as {
@@ -107,7 +122,19 @@ export function createPlatformLoader<
107
122
  }).exports;
108
123
  return {
109
124
  loader: context.env.LOADER,
110
- ...(context.env.BROWSER ? { browser: context.env.BROWSER } : {}),
125
+ // 绑定缺失时整条浏览器能力不进 Platform Port,Tool Surface 因此注册空集。
126
+ ...(context.env.BROWSER
127
+ ? {
128
+ browser: createBrowserExecutionFactory({
129
+ ctx: context.ctx,
130
+ loader: context.env.LOADER,
131
+ browser: context.env.BROWSER,
132
+ ...(options.previewBaseUrl
133
+ ? { previewBaseUrl: options.previewBaseUrl }
134
+ : {}),
135
+ }),
136
+ }
137
+ : {}),
111
138
  outbound: () => exports.HttpGateway({}),
112
139
  };
113
140
  });
package/src/index.ts CHANGED
@@ -135,10 +135,12 @@ export {
135
135
  export { skillPiToolCandidates } from "./pi/tool";
136
136
  export type { PiSkillBinding } from "./pi/tool";
137
137
  export {
138
- browserQuickActionPiToolCandidates,
138
+ BROWSER_EXECUTE_TOOL_NAME,
139
+ browserExecutionPiToolCandidate,
139
140
  codeExecutionPiToolCandidate,
140
141
  } from "./pi/tool";
141
142
  export {
143
+ createBrowserExecutionFactory,
142
144
  createWorkspaceCodeExecutionFactory,
143
145
  } from "./pi/tool";
144
146
  export {
@@ -2,7 +2,7 @@ import type { SkillSource } from "agents/skills";
2
2
  import type { ScheduleSpec } from "./receipts";
3
3
  import type { RuntimeActivityProjection } from "./state";
4
4
  import type { ExecutionLevel } from "../lib/execution-level";
5
- import type { RuntimeCodeExecutionFactory } from "../tool-registry";
5
+ import type { ToolRegistry } from "./tool-surface";
6
6
  import type {
7
7
  RuntimeEventConfirmation,
8
8
  RuntimeLifecycleFact,
@@ -856,26 +856,23 @@ export interface RuntimeTurnEventsPort {
856
856
  }
857
857
 
858
858
  /**
859
- * 向 Runtime 浏览器工具提供 Browser Run Quick Action 调用。
859
+ * 向 Runtime 提供一个真实无头浏览器的 CDP Code Mode 执行能力。
860
860
  *
861
861
  * @remarks
862
- * Platform Plugin 在 Browser 绑定存在时生成快捷操作工具,工具在读取、提取或抓取页面时调用。
862
+ * Platform Plugin 在 Browser 绑定存在时准备它,Tool Surface 决定注册 `browser_execute` 时才调用 `create()`。
863
863
  *
864
- * 该 Port 保留 Browser Run 的通用 `action + options` 协议,具体工具仍由 `agents/browser` 辅助函数组装。
864
+ * 该 Port 只承载「能不能开出一个浏览器执行端口」,浏览器会话生命周期与 CDP 协议细节都留在 `agents/browser`,
865
+ * 避免本仓复制一份会随上游漂移的协议。旧的 Quick Action `action + options` 单方法协议已于 2026-08-13 退役:
866
+ * 复测确认本地绑定至今不实现 `quickAction()`,该面在本仓从未执行过。
865
867
  */
866
868
  export interface RuntimeBrowserPort {
867
869
  /**
868
- * 执行一个 Browser Run Quick Action 并返回原始响应。
870
+ * 创建本次装配的浏览器代码执行端口。
869
871
  *
870
872
  * @remarks
871
- * `agents/browser` 的快捷操作辅助函数在对应 Pi 工具执行期调用。
872
- *
873
- * Runtime 不复制 Browser Run 的选项联合类型,避免两份协议随上游漂移。
873
+ * Tool Surface 在浏览器工具确实可见时调用一次,延迟到此刻是为了让被 deny 的装配不白建连接器。
874
874
  */
875
- quickAction(
876
- action: string,
877
- options: unknown,
878
- ): Promise<Response>;
875
+ create(): RuntimeCodeExecutionPort;
879
876
  }
880
877
 
881
878
  // #endregion
@@ -942,7 +939,7 @@ export interface RuntimeProviderPort {
942
939
  export interface RuntimePlatformPort {
943
940
  /** Workspace Codemode 在创建 Dynamic Worker 执行器时使用的 Worker Loader。 */
944
941
  loader: WorkerLoader;
945
- /** Platform Plugin 存在 Browser Run 绑定时用它生成浏览器工具。 */
942
+ /** Platform Plugin 存在 Browser Run 绑定时用它生成 `browser_execute`;缺失即整条浏览器 Tool 面不注册。 */
946
943
  browser?: RuntimeBrowserPort;
947
944
  /**
948
945
  * 为一次 Dynamic Worker 组装取得已限定的网络出口。
@@ -1021,6 +1018,18 @@ export interface RuntimeSkillSourceBinding {
1021
1018
  *
1022
1019
  * 它不携带业务 ID、Repository、数据库 Key、任意能力注册表或凭据配置;术语见 `../index.ts`。
1023
1020
  */
1021
+ /**
1022
+ * 延迟到最终 Tool Surface 完成后再创建 Code Mode 执行能力。
1023
+ *
1024
+ * @remarks
1025
+ * 定义在这里而不是 `tool-registry.ts`:它的返回类型是本文件的
1026
+ * `RuntimeCodeExecutionPort`,而 `RuntimeBindings` 又要引用这个工厂 —— 放在
1027
+ * tool-registry 会让两个模块互相 import。`tool-registry.ts` 继续对外导出它。
1028
+ */
1029
+ export interface RuntimeCodeExecutionFactory {
1030
+ create(tools: ToolRegistry): RuntimeCodeExecutionPort;
1031
+ }
1032
+
1024
1033
  export interface RuntimeBindings {
1025
1034
  provider: RuntimeProviderPort;
1026
1035
  platform: RuntimePlatformPort;
@@ -0,0 +1,41 @@
1
+ import type { ExecutionLevel } from "../lib/execution-level";
2
+ import type { PiToolCandidate } from "../pi/tool/compiler";
3
+
4
+ /**
5
+ * Tool Surface 的形状类型。
6
+ *
7
+ * @remarks
8
+ * `kernel/bindings.ts` 和 `tool-registry.ts` 都要用它们,所以它们不能住在其中任何
9
+ * 一边:`RuntimeBindings.codeExecution` 的工厂签名需要 `ToolRegistry`,而
10
+ * `tool-registry.ts` 又需要 bindings 里的各个 Port —— 两边互相 import 就形成了
11
+ * depcruise 的 no-circular 违规(`bindings → tool-registry → bindings`)。
12
+ *
13
+ * 这里只放形状,不放行为:注册表的合并、筛选和构造仍然留在 `tool-registry.ts`。
14
+ *
15
+ * 术语见 `../index.ts`。
16
+ */
17
+
18
+ /** 模型调用 Tool 时传给 `execute` 的上下文。 */
19
+ export interface ToolContext {
20
+ readonly toolCallId: string;
21
+ readonly signal: AbortSignal;
22
+ }
23
+
24
+ /** Definition 作者声明的一个 Tool。 */
25
+ export interface ToolSpec extends Partial<
26
+ Omit<PiToolCandidate, "tool" | "requiredExecutionLevel">
27
+ > {
28
+ readonly label: string;
29
+ readonly description: string;
30
+ readonly parameters: unknown;
31
+ readonly requiredExecutionLevel: ExecutionLevel;
32
+ /** Require a fresh human decision for every call, regardless of execution level. */
33
+ readonly alwaysRequiresApproval?: boolean;
34
+ readonly execute: (
35
+ input: unknown,
36
+ ctx: ToolContext,
37
+ ) => Promise<unknown>;
38
+ }
39
+
40
+ /** 一次装配声明的全部 Tool,键即模型可见的工具名。 */
41
+ export type ToolRegistry = Record<string, ToolSpec>;
@@ -15,14 +15,48 @@ import {
15
15
  * must be given an explicit protected path here.
16
16
  */
17
17
 
18
- /** Maximum string leaf retained in durable tool output. */
19
- const STORAGE_LEAF_MAX_CHARS = 32 * 1024;
18
+ /**
19
+ * 预算常量的排序不变量(改任何一个都必须同时复核其余三个):
20
+ *
21
+ * MODEL_LEAF_MAX_CHARS < MODEL_REPLAY_THRESHOLD < SPILL_THRESHOLD
22
+ * ≤ STORAGE_LEAF_MAX_CHARS
23
+ * 4 KB 16 KB 32 KB 64 KB
24
+ *
25
+ * 这四个数回答四个不同问题。曾经其中两个共用一个常量,结果“放宽外置”和“收紧
26
+ * 历史”这两个互相冲突的诉求被绑在同一个旋钮上,只能二选一。
27
+ */
20
28
 
21
- /** Serialized outputs above this threshold get a narrower model view. */
22
- const MODEL_VIEW_THRESHOLD = 8 * 1024;
29
+ /**
30
+ * 持久化单个字符串叶子的上限。
31
+ *
32
+ * MUST ≥ 单页 read 的上限(见 `workspace-sandbox.ts` 的 `READ_PAGE_MAX_CHARS`),
33
+ * 否则持久记录会比模型当轮看到的内容还少,会话恢复后出现凭空缺页。
34
+ */
35
+ export const STORAGE_LEAF_MAX_CHARS = 64 * 1024;
23
36
 
24
- /** Maximum string leaf exposed in a structure-preserving model view. */
25
- const MODEL_LEAF_MAX_CHARS = 500;
37
+ /**
38
+ * 一次新的 Tool 结果多大才值得外置成 Workspace 文件。
39
+ *
40
+ * 只影响当轮新产生的结果,不影响历史重放。
41
+ */
42
+ export const SPILL_THRESHOLD = 32 * 1024;
43
+
44
+ /**
45
+ * 历史里一条旧 Tool 结果重放多少。
46
+ *
47
+ * 这条最紧,因为它对每个 Turn 的**全部**历史消息生效(见
48
+ * `pi/runtime-adapter/execution.ts` 的 `projectToolResultsForModel`):
49
+ * 放宽 1 KB 就是 N 条历史 × 1 KB。
50
+ */
51
+ export const MODEL_REPLAY_THRESHOLD = 16 * 1024;
52
+
53
+ /**
54
+ * 重放时单个字符串叶子的上限。
55
+ *
56
+ * 曾经是 500,小到让 Code Mode 这类返回长字符串的结果在**第二轮**就塌成碎片,
57
+ * 模型只能反复换姿势(`Object.values()`、`slice()`)去捞已经不存在的内容。
58
+ */
59
+ export const MODEL_LEAF_MAX_CHARS = 4 * 1024;
26
60
 
27
61
  const ELISION_RESERVE = 40;
28
62
  const PREVIEW_CHARS = 600;
@@ -118,7 +152,7 @@ export async function spillDurableToolOutput(
118
152
  },
119
153
  ): Promise<ArtifactRef | null> {
120
154
  const { text, ext } = serializeOutput(value);
121
- if (text.length <= MODEL_VIEW_THRESHOLD || !options.workspace) return null;
155
+ if (text.length <= SPILL_THRESHOLD || !options.workspace) return null;
122
156
  try {
123
157
  const hash = await sha256Hex(text);
124
158
  const path = `${DEFAULT_SPILL_DIR}/${hash.slice(0, 24)}.${ext}`;
@@ -133,9 +167,19 @@ export async function spillDurableToolOutput(
133
167
  bytes: new TextEncoder().encode(text).byteLength,
134
168
  hash: hash.slice(0, 16),
135
169
  preview: text.slice(0, PREVIEW_CHARS),
170
+ // 出口指令必须是可执行的:只说“去 read 这个路径”会诱导模型整读,而整读一个
171
+ // 刚刚因为过大被外置的文件毫无意义。这里明确要求分页,并告知 read 会返回
172
+ // nextOffset / eof 以便继续。
173
+ // 出口指令必须是可执行的,而且必须只承诺模型真的拿得到的东西:Provider 只把
174
+ // Tool 结果的 content 发给模型,所以这里不能引用只存在于 details 的字段。
175
+ // 溢出产物是 JSON,一个大字符串叶子会整块挤在一行上,而按行分页追不回被行宽
176
+ // 截断的内容 —— 那种情况要走 grep / bash,不能让模型以为 read 一定够用。
136
177
  note:
137
178
  "Output was large and has been saved to the Workspace file above. " +
138
- "Use the read tool with that path to retrieve the full content when needed.",
179
+ "Read it in pages with read(path, offset, limit) do not read it whole; " +
180
+ "each page ends with a footer telling you the line range and the next offset. " +
181
+ "This file is JSON, so a single large value can sit on one very long line: " +
182
+ "if a page reports that lines were cut short, use grep or bash on the path instead.",
139
183
  };
140
184
  } catch {
141
185
  return null;
@@ -145,7 +189,9 @@ export async function spillDurableToolOutput(
145
189
  // Bounds leaves first, then removes oldest array entries until the view fits.
146
190
  function structureView(output: unknown): unknown {
147
191
  const capped = truncateStringLeaves(output, MODEL_LEAF_MAX_CHARS);
148
- if (serializeOutput(capped).text.length <= MODEL_VIEW_THRESHOLD) return capped;
192
+ if (serializeOutput(capped).text.length <= MODEL_REPLAY_THRESHOLD) {
193
+ return capped;
194
+ }
149
195
  if (typeof capped !== "object" || capped === null) return capped;
150
196
 
151
197
  const record = { ...(capped as Record<string, unknown>) };
@@ -159,7 +205,7 @@ function structureView(output: unknown): unknown {
159
205
  while (
160
206
  items.length > 1 &&
161
207
  serializeOutput({ ...record, [arrayKey]: items }).text.length >
162
- MODEL_VIEW_THRESHOLD
208
+ MODEL_REPLAY_THRESHOLD
163
209
  ) {
164
210
  items.shift();
165
211
  dropped += 1;
@@ -178,7 +224,7 @@ function structureView(output: unknown): unknown {
178
224
  /** Creates a non-destructive, smaller Tool-result projection for the model. */
179
225
  export function projectToolOutputForModel(output: unknown): unknown {
180
226
  try {
181
- return serializeOutput(output).text.length <= MODEL_VIEW_THRESHOLD
227
+ return serializeOutput(output).text.length <= MODEL_REPLAY_THRESHOLD
182
228
  ? output
183
229
  : structureView(output);
184
230
  } catch {
package/src/lib/prompt.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Stable system-prompt sections shared by every Runtime profile.
3
3
  *
4
- * Only `PERSONA` is replaceable by `RuntimeProfile.systemPrompt`. The global
5
- * `SYSTEM_PROMPT`, environment, tool and safety facts remain because a custom
6
- * Agent persona must not redefine capabilities that the running kernel does
7
- * not actually provide.
4
+ * Only `PERSONA` is replaceable by `RuntimeProfile.systemPrompt`. Environment,
5
+ * tool and safety facts remain because a custom Agent persona must not redefine
6
+ * capabilities that the running kernel does not actually provide.
8
7
  * Dynamic skills, memory and context blocks are injected by their own modules
9
8
  * so this static prefix remains cacheable.
10
9
  */
@@ -15,13 +14,6 @@ export const PERSONA =
15
14
  "You can recall user-managed cold memory across sessions and keep working memory within the current Session, " +
16
15
  "manage files in your workspace, and use execute Code Mode for network requests and tool composition.";
17
16
 
18
- // Base constraint injected alongside every Agent-specific persona.
19
- export const SYSTEM_PROMPT =
20
- "Web development: when writing a website or web page, use browser-ready mode: emit browser-ready static files " +
21
- "that run directly in Workspace preview. Use ./ or ../ for every Workspace asset, never a root-relative path; " +
22
- "load CSS from HTML instead of importing it from JavaScript. Resolve third-party ESM with a full pinned HTTPS URL " +
23
- "or import map to a pinned CORS-enabled CDN. Do not require Vite, Node.js, bundling, or Sandbox at preview time.";
24
-
25
17
  // Explains how to reach capabilities instead of listing unsupported substitutes.
26
18
  export const RUNTIME =
27
19
  "Runtime: this agent runs on Cloudflare Workers. When execute is present, its Code Mode Dynamic Worker is your " +
@@ -56,7 +48,8 @@ export const TOOLS =
56
48
  "Tools: When execute is present, call a top-level Tool directly only for a single standalone Tool call or when that Tool is unavailable " +
57
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, " +
58
50
  "or branching or repetition MUST use one execute. Do not make repeated top-level calls for that work. Inside execute, use state.* for " +
59
- "workspace file operations, tools.* for other host capabilities, a loop for repeated calls, and Promise.all for independent non-CDP calls. " +
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. " +
60
53
  "Tools available only at the top level must stay Direct. " +
61
54
  "For web tasks, use web_search for web discovery, current facts, cited research, " +
62
55
  "and public URL analysis. For web access specifically, use execute Code Mode only for raw or customized network requests, structured " +
@@ -75,6 +68,30 @@ export const TOOLS =
75
68
  "only independent top-level-only Direct Tools should be issued together in one turn. When you reference " +
76
69
  "code, cite it as file_path:line_number.";
77
70
 
71
+ // The browser tool is the only way to observe what a page actually does; its
72
+ // failure mode is returning nothing and looking clean, so the shape is fixed here.
73
+ export const BROWSER =
74
+ "Browser: When browser_execute is present, it is for one thing execute cannot do — what a page actually " +
75
+ "does in a real browser: rendered result, console output, runtime exceptions, failed resource loads, CSP " +
76
+ "blocks, and state after an interaction. Everything else stays with execute: computation, files, and " +
77
+ "ordinary HTTP requests (fetching HTML is execute's job, not the browser's). " +
78
+ "After you write or change a web page in the workspace, open it once with browser_execute before you call " +
79
+ "the work done, and say in your reply what you checked and what you saw. " +
80
+ "The cdp connector is request/response only: it delivers no CDP events. Enabling Runtime/Log/Network and " +
81
+ "then reading cdp.getDebugLog() returns method names with no payload — a page full of errors reads as clean. " +
82
+ "So the fixed shape is: create a target and attach for a sessionId; BEFORE Page.navigate, install a page-side " +
83
+ "collector via Page.addScriptToEvaluateOnNewDocument that buffers console.*, a capture-phase window 'error' " +
84
+ "listener (it catches both uncaught exceptions and failed <script>/<img>/<link> loads), 'unhandledrejection' " +
85
+ "and 'securitypolicyviolation' into one page global; then navigate, poll Runtime.evaluate for " +
86
+ "document.readyState === 'complete' (you cannot await an event), then Runtime.evaluate that global back with " +
87
+ "returnByValue. When visual appearance matters, call Page.captureScreenshot and return " +
88
+ "`{ image: { mimeType: 'image/png', data: screenshot.data }, observations }`; the Runtime projects that image " +
89
+ "back to you, so evaluate the visible hierarchy, spacing, clipping and overlap against the request as well as " +
90
+ "the textual diagnostics. Issue CDP calls sequentially — never Promise.all — because call order is recorded for replay. " +
91
+ "Fix what you find and re-check; when the page comes back clean, stop — do not re-verify a page that is " +
92
+ "already clean. If browser_execute is absent, say plainly that you could not verify the page in a browser " +
93
+ "instead of implying you did.";
94
+
78
95
  // Uploaded-file routing prevents lossy markdown conversions from becoming data sources.
79
96
  export const FILES =
80
97
  "Uploaded files live under /uploads/ in your workspace; convertible formats have a companion " +
@@ -112,7 +129,7 @@ export const MEMORY =
112
129
  * @remarks
113
130
  * Pi 快照装配在生成每次 Runtime Snapshot 时调用;调用方只替换角色文本,不要重复拼接其他段落。
114
131
  *
115
- * 全局 SYSTEM_PROMPT 固定前置;Runtime、行为、规划、Tool、文件、交互和 Memory 按固定顺序追加,
132
+ * Runtime、行为、规划、Tool、浏览器、文件、交互和 Memory 按固定顺序追加,
116
133
  * 因为自定义角色不应重新定义实际 Kernel 没有的能力。
117
134
  *
118
135
  * 各段保持静态,动态 Skill、Context 和 Memory 由各自模块注入,避免这个可缓存前缀每回合改变。
@@ -121,12 +138,12 @@ export const MEMORY =
121
138
  */
122
139
  export function assembleSystemPrompt(base?: string | null): string {
123
140
  return [
124
- SYSTEM_PROMPT,
125
141
  base ?? PERSONA,
126
142
  RUNTIME,
127
143
  BEHAVIOR,
128
144
  PLANNING,
129
145
  TOOLS,
146
+ BROWSER,
130
147
  FILES,
131
148
  INTERACTION,
132
149
  MEMORY,
@@ -139,7 +156,7 @@ export function assembleSystemPrompt(base?: string | null): string {
139
156
  * @remarks
140
157
  * Pi SubAgent 在启动有界子任务前调用;调用方应传入该子代理类型自己的 persona。
141
158
  *
142
- * 子代理共享真实执行环境和基本行为,但不获得主 Agent 专属的规划、Memory 和用户交互指令,因为这些责任留在主循环。
159
+ * 子代理共享真实执行环境和基本行为,但不获得主 Agent 专属的 Skill、规划、Memory 和用户交互指令,因为这些责任留在主循环。
143
160
  *
144
161
  * 不要直接改成复用 `assembleSystemPrompt`,否则子代理会收到它没有的 Session 记忆和对话交互责任。
145
162
  *
@@ -70,6 +70,10 @@ const WRAP_UP_MESSAGE = "You have reached this run's model-turn budget. Stop " +
70
70
  "completed, what you did not finish, and the exact next step you would " +
71
71
  "take. Do not start new work.";
72
72
 
73
+ const TOOL_FAILURE_WRAP_UP_MESSAGE = "This turn reached its tool failure limit. " +
74
+ "Stop calling tools now. Reply with a final message that explains what " +
75
+ "failed and the exact next step the user should take. Do not start new work.";
76
+
73
77
  // 收尾开始后最多再放行几个模型回合。工具已经清空,正常一轮就结束了;
74
78
  // 留出余量是给 steering 消息,但不能因此把本片的 CPU 上限一起取消。
75
79
  const MAX_WRAP_UP_TURNS = 2;
@@ -159,12 +163,37 @@ interface PiTurnAdapterOptions {
159
163
  readonly modelTurnsConsumed: number;
160
164
  }
161
165
 
166
+ /**
167
+ * 收集那些结果已经由工具自己收口的 Tool 名字。
168
+ *
169
+ * @remarks
170
+ * `run` 在建 PiCore 前调用,把结果交给 `projectToolResultsForModel`。
171
+ *
172
+ * `outputBudget: "structure"` 的语义就是“这个结果不需要 Runtime 再替它省”:read
173
+ * 按页返回并在页脚写明覆盖的行号,activate_skill 返回的是模型接下来要照做的指令,
174
+ * Code Mode 的结果由它自己收口。对这些再套一次叶子上限,模型看到的正文会和结果里
175
+ * 写明的范围对不上 —— 页脚说 `lines 1-307`,正文却被从中间挖空。
176
+ *
177
+ * 其余工具仍然照常压缩:这道闸按分类放行,不是整体放宽。
178
+ */
179
+ function selfBoundedToolNames(
180
+ candidates: readonly PiToolCandidate[],
181
+ ): ReadonlySet<string> {
182
+ return new Set(
183
+ candidates
184
+ .filter((candidate) => candidate.outputBudget?.kind === "structure")
185
+ .map((candidate) => candidate.tool.name),
186
+ );
187
+ }
188
+
162
189
  function projectToolResultsForModel(
163
190
  messages: readonly AgentMessage[],
191
+ selfBounded: ReadonlySet<string>,
164
192
  ): AgentMessage[] {
165
193
  let changed = false;
166
194
  const projected = messages.map((message) => {
167
195
  if (message.role !== "toolResult") return message;
196
+ if (selfBounded.has(message.toolName)) return message;
168
197
  const payload = {
169
198
  content: message.content,
170
199
  details: message.details,
@@ -252,6 +281,7 @@ class PiTurnAdapter {
252
281
  signal?.throwIfAborted();
253
282
  const canonicalMessages = await this.opts.canonicalMessages();
254
283
  const tools = this.compile(opts.tools);
284
+ const selfBounded = selfBoundedToolNames(opts.tools);
255
285
  // `modelTurns` 是跨执行片的累计数,`sliceTurns` 只数本片 —— 前者管任务预算,
256
286
  // 后者管 CPU 预算,两个边界互不替代。
257
287
  let modelTurns = this.opts.modelTurnsConsumed;
@@ -274,7 +304,7 @@ class PiTurnAdapter {
274
304
  transformContext: async (messages, signal) => {
275
305
  const ctx = await this.opts.transformContext(messages, signal);
276
306
  return transformMessages(
277
- projectToolResultsForModel(ctx) as Message[],
307
+ projectToolResultsForModel(ctx, selfBounded) as Message[],
278
308
  this.opts.pi.model,
279
309
  ) as AgentMessage[];
280
310
  },
@@ -289,9 +319,13 @@ class PiTurnAdapter {
289
319
  wrapUpTurns += 1;
290
320
  return undefined;
291
321
  }
292
- if (modelTurns < MAX_MODEL_TURNS_PER_SUBMISSION - 1) return undefined;
293
- // 预算见底:清空工具表并注入收尾指令,逼出一条真正的终止助手消息。
294
- // 直接判失败的话用户拿不到任何交代;继续让步则等于没有预算。
322
+ const toolFailureLimitReached = this.governance.wrapUpRequested();
323
+ if (
324
+ !toolFailureLimitReached &&
325
+ modelTurns < MAX_MODEL_TURNS_PER_SUBMISSION - 1
326
+ ) return undefined;
327
+ // 预算见底或工具熔断:清空工具表并注入收尾指令,逼出一条真正的
328
+ // 终止助手消息。直接判失败的话用户拿不到任何交代。
295
329
  // 这条指令不进 newMessages,因此不会写进 transcript —— 它是控制指令,不是历史。
296
330
  wrappingUp = true;
297
331
  return {
@@ -302,7 +336,12 @@ class PiTurnAdapter {
302
336
  ...context.messages,
303
337
  {
304
338
  role: "user" as const,
305
- content: [{ type: "text" as const, text: WRAP_UP_MESSAGE }],
339
+ content: [{
340
+ type: "text" as const,
341
+ text: toolFailureLimitReached
342
+ ? TOOL_FAILURE_WRAP_UP_MESSAGE
343
+ : WRAP_UP_MESSAGE,
344
+ }],
306
345
  timestamp: Date.now(),
307
346
  },
308
347
  ],
@@ -48,6 +48,8 @@ export interface PiToolCandidate {
48
48
  readonly tool: AgentTool<any, any>;
49
49
  /** Keep this Tool Direct-only instead of also offering it through Code Mode. */
50
50
  readonly direct?: true;
51
+ /** Offer this Tool only through Code Mode, never as a top-level Tool. */
52
+ readonly codeExecutionOnly?: true;
51
53
  /** @internal Tools also callable through this Code Mode candidate. */
52
54
  readonly codeExecutionTools?: readonly PiToolCandidate[];
53
55
  /** Conservative maximum used in the stable Runtime descriptor. */
@@ -85,6 +87,7 @@ const governanceState = Symbol("PiToolGovernanceState");
85
87
  /** 向 Pi Agent 提供工具后置钩子,并为编译器保留同一 Turn 的治理状态。 */
86
88
  export interface PiToolGovernance {
87
89
  readonly afterToolCall: NonNullable<AgentOptions["afterToolCall"]>;
90
+ readonly wrapUpRequested: () => boolean;
88
91
  readonly [governanceState]: PiToolGovernanceState;
89
92
  }
90
93
 
@@ -222,7 +225,7 @@ function recordFailure(
222
225
 
223
226
  // 在执行前判断这次工具调用是否已超过重试或 Turn 上限。
224
227
  // 受治理 execute 方法对每个调用首先调用它。
225
- // 被阻断的 call id 必须记入 terminalCalls,才能让 Pi 的 afterToolCall 在返回错误后终止 Turn。
228
+ // 被阻断的 call id 必须记入 terminalCalls,才能让 Pi 在返回错误后进入无工具收尾回合。
226
229
  function blockReason(
227
230
  state: PiToolGovernanceState | undefined,
228
231
  toolCallId: string,
@@ -263,15 +266,16 @@ export function createPiToolGovernance(): PiToolGovernance {
263
266
  };
264
267
  const governance: PiToolGovernance = {
265
268
  [governanceState]: state,
266
- // 在必须终止的工具调用后告诉 Pi 停止当前 Turn。
267
- // Pi Agent 在每次工具执行结束后调用,并传入实际 toolCall id
268
- // 只消费 terminalCalls 中的 id,可避免一次失败误终止后续无关工具调用。
269
+ // Pi Agent 在每次工具执行结束后调用,并传入实际 toolCall id。只消费
270
+ // terminalCalls 中的 id,可避免一次失败误触发收尾;不能在这里直接
271
+ // terminate,否则模型没有机会产出权威 assistant 消息。
269
272
  async afterToolCall(context) {
270
273
  if (!state.terminalCalls.delete(context.toolCall.id)) {
271
274
  return;
272
275
  }
273
- return { terminate: true };
276
+ state.aborted = true;
274
277
  },
278
+ wrapUpRequested: () => state.aborted,
275
279
  };
276
280
  return governance;
277
281
  }