@inobit/pi-instabox 0.0.0-stage → 0.1.0

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/src/tools.ts ADDED
@@ -0,0 +1,322 @@
1
+ /**
2
+ * @inobit/pi-instabox — 远端桌面工具 → pi 工具定义 + 转发(方案 §4.2 D2、§4.3、§4.8、§5.1、T14)。
3
+ *
4
+ * 契约:
5
+ * - 一个远端 `tools/list` 条目 → 一个 pi 工具,名字固定 `instabox_desktop_<remote_name>`。
6
+ * 名字必须稳定:远端换版本只换 schema、不换名字,codemode 脚本里写死的
7
+ * `instabox_desktop_click` 才不会失效。
8
+ * - `label` 必填(R7):`instabox desktop <remote_name>`。**口径以此为淮**:plan R7 写的是
9
+ * `instabox <remote_name>`,但桌面工具的 label 带 `desktop` 更明确(与工具名 `instabox_desktop_*`
10
+ * 同口径),后续文档/T15 如有冲突一律按本文件文案。`description` 透传远端文案(远端缺省时
11
+ * 给一句自解释的兜底,不让模型面对空描述)。
12
+ * - `parameters` = `injectBoxParam(jsonSchemaToTypeBox(remote.inputSchema))`:`box` 只加在**顶层**
13
+ * `properties`、永不进 `required`。远端自带 `box`(或顶层不是 object schema)→ 不注入、
14
+ * 只 `warn`(解析链退到 2–5 级,方案 §4.8 ④)。
15
+ * - `exposure: "codemode"`(裁决 V2:本模块连 `ExtensionAPI` 都不碰,绝不 `setActiveTools`);
16
+ * 所有工具共享同一 `namespace`(`instabox-desktop`,别名 `instabox_desktop`),codemode 才会
17
+ * 按组展示、`describeNamespace()` 才找得到它们。
18
+ * - `execute`:取 `box` 参数 → 注入的 `resolve`(5 级解析链)→ `pool.get`(复用/唤醒/建连)→
19
+ * `client.callTool(remote_name, 其余参数, signal)`。结果**首行固定** `[box=<name>]`(第一个
20
+ * text content),远端 `content` 原样透传(含 image),`isError` 保留并追加一行修复建议。
21
+ * - 失败一律转 `{ isError: true }` 结果,**绝不 throw**;`signal` 全程透传(abort 由 `pool` /
22
+ * `remote` 归一成错误文本,本模块不吞也不改写)。
23
+ * - `box` 从转发参数里删掉:它是本层参数,不是远端工具的实参(`injectBoxParam` 没注入成功时
24
+ * 也一样剥掉——`params.box` 的消费不依赖 schema 里有没有它)。
25
+ * - `resolve` 的结果原样使用:`skippedConfig`(配置 `defaultBox` 写错)的每会话一次 warn 由 T15
26
+ * 在闭包里负责,本模块不为它加回调。`ctx` 作为**第二个可选参数**透传给 `resolve`,让装配层
27
+ * 能在每次调用时实时读会话分支(解析链第 2 级是「本轮最后一条 user 消息」,不能缓存)。
28
+ * - 注入面:`resolve` / `pool` / `listBoxes` / `warn` 全部可替;单测不 spawn、不联网、不读盘。
29
+ * - 埋点:每次 `execute` 记一条 `tool.call`(`tool` / `box` / `ok` / `ms` / `isError`),成功、失败、
30
+ * 异常三条路径都经 `finally` 落账;`tool` 用远端名(与 `remote.call` 同一拼写),只记箱名与工具名,
31
+ * 不记参数与结果内容。
32
+ */
33
+
34
+ import type {
35
+ AgentToolResult,
36
+ ExtensionToolContext,
37
+ ToolDefinition,
38
+ ToolNamespace,
39
+ } from "@earendil-works/pi-coding-agent";
40
+ import type { BoxInfo } from "./boxes.ts";
41
+ import { logEvent } from "./log.ts";
42
+ import type { Pool } from "./pool.ts";
43
+ import { type CallToolResult, type RemoteClient, type RemoteTool } from "./remote.ts";
44
+ import type { ResolveResult } from "./resolve.ts";
45
+ import { injectBoxParam, jsonSchemaToTypeBox } from "./schema.ts";
46
+
47
+ /** 工具名前缀:远端工具名原样拼在后面,不做大小写/分隔符变换(名字要可预测)。 */
48
+ export const DESKTOP_TOOL_PREFIX = "instabox_desktop_";
49
+
50
+ /** codemode 命名空间名(codemode 列表按组展示时用的键)。 */
51
+ export const DESKTOP_NAMESPACE_NAME = "instabox-desktop";
52
+
53
+ /** 命名空间别名:`describeNamespace()` / `searchTools({ namespace })` 用短名查找。 */
54
+ export const DESKTOP_NAMESPACE_ALIASES = ["instabox_desktop"] as const;
55
+
56
+ /** 每调用目标箱的参数名(顶层、可选;解析链第 1 级)。 */
57
+ export const BOX_PARAM = "box";
58
+
59
+ /** 命名空间摘要(按组展示一次)。 */
60
+ export const DESKTOP_NAMESPACE_DESCRIPTION =
61
+ "instabox sandbox desktops (remote instabox-computer-use): screenshots, clicks, typing and app control on a box's X11 desktop. Every call targets exactly one box; pass box=… to choose it per call.";
62
+
63
+ /** 命名空间用法说明(`describeNamespace()` 才返回,不进工具列表)。 */
64
+ export const DESKTOP_NAMESPACE_INSTRUCTIONS = [
65
+ "Each tool takes an optional `box` parameter; omit it to use the session target box (set with /instabox-use <box>). An unknown box name is an error — the extension never falls back to a different box silently.",
66
+ "The first line of every result is [box=<name>]: it states which box actually ran the call, so check it whenever more than one box is in play.",
67
+ 'Desktop screenshots are expensive. For browser work prefer the DevTools server when one is configured: discover it with describeNamespace("mcp__instabox_devtools") or searchTools("devtools") and only use desktop clicks/screenshots when DevTools cannot do the job.',
68
+ ].join("\n");
69
+
70
+ /** `isError` 结果追加的一行:说清「没有自动重试、也没有换箱」,给出两条可执行补救。 */
71
+ const IS_ERROR_REMEDY =
72
+ 'instabox: the remote tool reported an error (shown above unchanged). Nothing was retried and no other box was used — pass box="<name>" to target a different box, or run /instabox-status to review targets.';
73
+
74
+ /** 候选箱名的行前缀(与 `resolve.ts` 的失败文案同一口径,用于去重判断)。 */
75
+ const CANDIDATES_PREFIX = "available boxes: ";
76
+
77
+ /**
78
+ * `injectBoxParam` 没给 warning 时的兜底文案。
79
+ *
80
+ * `injectBoxParam` 目前**总**会在 `injected === false` 时带上 `warning`(两种降级各有文案),所以
81
+ * 这条正常路径走不到;保留是因为该字段在类型上是可选的,真走到这里说明调用点漏传文案,日志里
82
+ * 也不该冒出 `undefined`。
83
+ */
84
+ const NOT_INJECTED_FALLBACK =
85
+ "the remote inputSchema could not take the instabox `box` parameter, so per-call box targeting is unavailable for this tool";
86
+
87
+ /** 工具结果的内容数组类型(`TextContent | ImageContent`,不额外 import `pi-ai`)。 */
88
+ type ToolContent = AgentToolResult["content"];
89
+
90
+ /** 本模块的注入面:宿主运行时只以类型出现(`import type`,编译后擦除)。 */
91
+ export interface BuildToolDefinitionsInput {
92
+ /** 当前目标箱模板里的远端工具(per-box,来自 `template.ts` 的缓存/拉取结果) */
93
+ remoteTools: readonly RemoteTool[];
94
+ /** per-box 客户端池(复用 / 唤醒 / 建连都在池里,本模块只调 `get`) */
95
+ pool: Pool;
96
+ /**
97
+ * 解析链闭包(T15 里包 `resolveBox` + `ls` + 会话目标 + 配置);**失败不 throw,回结果**。
98
+ * 第二个参数是本次调用的 `ExtensionToolContext`,装配层据此**实时**读会话分支;
99
+ * 只取 `paramBox` 的单参实现仍然合法(可选参数,向后兼容)。
100
+ */
101
+ resolve: (paramBox?: string, ctx?: ExtensionToolContext) => Promise<ResolveResult>;
102
+ /** 当前可用箱(解析失败且 `message` 未带候选时,用它在错误文本里补一行候选) */
103
+ listBoxes: () => Promise<{ boxes: BoxInfo[] }>;
104
+ /** 降级/冲突出口(远端 schema 放不下 `box` 时各报一次) */
105
+ warn?: (message: string) => void;
106
+ }
107
+
108
+ /** 远端工具名 → pi 工具名(稳定、可预测)。 */
109
+ export function desktopToolName(remoteName: string): string {
110
+ return `${DESKTOP_TOOL_PREFIX}${remoteName}`;
111
+ }
112
+
113
+ /** 远端工具名 → `label`(R7:必填,带前缀,单测断言唯一);文案口径见文件头。 */
114
+ export function desktopToolLabel(remoteName: string): string {
115
+ return `instabox desktop ${remoteName}`;
116
+ }
117
+
118
+ /**
119
+ * 把当前箱的远端工具定义成 pi 工具(每个 `tools/list` 条目一个)。
120
+ *
121
+ * 纯构造:不读配置、不连远端、不注册;`remoteTools` 为空时返回 `[]`(无 CLI/无缓存即零工具)。
122
+ */
123
+ export function buildToolDefinitions(input: BuildToolDefinitionsInput): ToolDefinition[] {
124
+ const namespace: ToolNamespace = {
125
+ name: DESKTOP_NAMESPACE_NAME,
126
+ aliases: DESKTOP_NAMESPACE_ALIASES,
127
+ description: DESKTOP_NAMESPACE_DESCRIPTION,
128
+ instructions: DESKTOP_NAMESPACE_INSTRUCTIONS,
129
+ };
130
+ const definitions: ToolDefinition[] = [];
131
+ const seen = new Set<string>();
132
+ for (const remote of input.remoteTools) {
133
+ // 同名远端工具 → 只保留第一个并 warn 一次:`registerTool` 同名是静默替换,留两份会让模型
134
+ // 看到重复声明,而实际只有后注册的那份生效(描述/schema 到底哪份对,肉眼看不出来)。
135
+ if (seen.has(remote.name)) {
136
+ input.warn?.(
137
+ `instabox desktop tool "${remote.name}" appears more than once in the remote tools/list; only the first definition is kept`,
138
+ );
139
+ continue;
140
+ }
141
+ seen.add(remote.name);
142
+ definitions.push(buildToolDefinition(input, remote, namespace));
143
+ }
144
+ return definitions;
145
+ }
146
+
147
+ function buildToolDefinition(
148
+ input: BuildToolDefinitionsInput,
149
+ remote: RemoteTool,
150
+ namespace: ToolNamespace,
151
+ ): ToolDefinition {
152
+ const { schema, injected, warning } = injectBoxParam(jsonSchemaToTypeBox(remote.inputSchema));
153
+ if (!injected) input.warn?.(`instabox desktop tool "${remote.name}": ${warning ?? NOT_INJECTED_FALLBACK}`);
154
+ return {
155
+ name: desktopToolName(remote.name),
156
+ label: desktopToolLabel(remote.name),
157
+ description: descriptionOf(remote),
158
+ parameters: schema,
159
+ exposure: "codemode",
160
+ namespace,
161
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
162
+ const startedAt = Date.now();
163
+ // 埋点面:`box` 在解析成功后才知;`ok` = 远端正常回了结果,`isError` = 返回给宿主的错误标记
164
+ let box: string | undefined;
165
+ let ok = false;
166
+ let isError = true;
167
+ try {
168
+ const boxParam = boxParamOf(params);
169
+ let resolution: ResolveResult;
170
+ try {
171
+ // `ctx` 透传给解析链:每次调用都要实时读分支(解析链第 2 级不能缓存)
172
+ resolution = await input.resolve(boxParam, ctx);
173
+ } catch (error) {
174
+ return errorResult(`instabox: box resolution failed: ${messageOf(error)}`);
175
+ }
176
+ if (!resolution.ok) return errorResult(await failureText(resolution, input.listBoxes));
177
+ box = resolution.box;
178
+
179
+ let client: RemoteClient;
180
+ try {
181
+ const got = await input.pool.get(box, {
182
+ signal,
183
+ onProgress: (message) => onUpdate?.({ content: [textItem(message)], details: undefined }),
184
+ });
185
+ if (!got.ok) return errorResult(`instabox: cannot use box "${box}": ${got.error}`);
186
+ client = got.client;
187
+ } catch (error) {
188
+ return errorResult(`instabox: cannot use box "${box}": ${messageOf(error)}`);
189
+ }
190
+
191
+ let called: CallToolResult;
192
+ try {
193
+ called = await client.callTool(remote.name, forwardArgs(params), signal);
194
+ } catch (error) {
195
+ return errorResult(`instabox: ${remote.name} on box "${box}" failed: ${messageOf(error)}`);
196
+ }
197
+
198
+ const content: ToolContent = [textItem(`[box=${box}]`), ...remoteContent(called.content)];
199
+ if (called.isError === true) {
200
+ content.push(textItem(IS_ERROR_REMEDY));
201
+ return { content, details: undefined, isError: true };
202
+ }
203
+ ok = true;
204
+ isError = false;
205
+ return { content, details: undefined };
206
+ } finally {
207
+ // 三条路径(成功 / 远端报错或本层失败 / 抛错)都在这里收口,埋点本身绝不外抛。
208
+ // `tool` 用远端名(与 `remote.call` 同一拼写,便于跨层对账);pi 工具名只多一层前缀。
209
+ logEvent("tool.call", {
210
+ tool: remote.name,
211
+ box,
212
+ ok,
213
+ ms: Date.now() - startedAt,
214
+ isError,
215
+ });
216
+ }
217
+ },
218
+ };
219
+ }
220
+
221
+ /** 远端描述透传;远端没给(或只有空白)时给一句自解释的兜底,避免模型面对空描述。 */
222
+ function descriptionOf(remote: RemoteTool): string {
223
+ const description = remote.description?.trim() ?? "";
224
+ if (description !== "") return description;
225
+ return `instabox desktop tool "${remote.name}" (the remote tools/list entry has no description)`;
226
+ }
227
+
228
+ /**
229
+ * 事件流上的 `params` 形状由 schema 校验保证,但校验层之外(如直接调用 `execute`)什么都可能传进来,
230
+ * 所以这里一律按 `unknown` 读:读不到就是「没传 `box`」,落到解析链第 2 级。
231
+ */
232
+ function boxParamOf(params: unknown): string | undefined {
233
+ if (!isRecord(params)) return undefined;
234
+ const value = params[BOX_PARAM];
235
+ if (typeof value !== "string") return undefined;
236
+ const trimmed = value.trim();
237
+ return trimmed === "" ? undefined : trimmed;
238
+ }
239
+
240
+ /** 转发给远端的实参:去掉本层的 `box`(远端工具不需要它,留着会被 schema 拒绝)。 */
241
+ function forwardArgs(params: unknown): Record<string, unknown> {
242
+ const args: Record<string, unknown> = {};
243
+ if (!isRecord(params)) return args;
244
+ for (const [key, value] of Object.entries(params)) {
245
+ if (key !== BOX_PARAM) args[key] = value;
246
+ }
247
+ return args;
248
+ }
249
+
250
+ /**
251
+ * 远端 MCP `content` 原样透传:MCP 的 text/image 项与 pi 的 `TextContent`/`ImageContent` 同形
252
+ * (`{type:"text",text}` / `{type:"image",data,mimeType}`),本层不重排、不截断、不裁剪——
253
+ * 能否渲染由宿主决定,替远端决定会静默丢结果。
254
+ *
255
+ * 断言里也包含非 text/image block(`resource`/`resource_link` 等):它们原样透传,provider 侧
256
+ * 若不支持该 block 类型,由上游(pi 的 provider 适配层)处理,本层不做转换。
257
+ */
258
+ function remoteContent(raw: unknown): ToolContent {
259
+ if (!Array.isArray(raw)) return [];
260
+ return raw as ToolContent;
261
+ }
262
+
263
+ /**
264
+ * 解析失败的可展示文本:`resolve` 的 `message` 已自带候选时原样用;否则从 `listBoxes` 补一行
265
+ * 候选(`message + 候选`,不重复列同一批名字,也不把候选当默认值——解析失败不降级)。
266
+ *
267
+ * 三条出口都有用例覆盖:无候选(只留 message)、`message` 已含候选(不重复)、需补候选。
268
+ */
269
+ async function failureText(
270
+ failure: { message: string; candidates: string[] },
271
+ listBoxes: () => Promise<{ boxes: BoxInfo[] }>,
272
+ ): Promise<string> {
273
+ const candidates = await candidatesFor(failure.candidates, listBoxes);
274
+ if (candidates.length === 0) return failure.message;
275
+ const line = `${CANDIDATES_PREFIX}${candidates.join(", ")}`;
276
+ return failure.message.includes(line) ? failure.message : `${failure.message}\n${line}`;
277
+ }
278
+
279
+ /** 候选来源:解析结果优先;为空(或只有空名)时才去问 `listBoxes`,失败就当没有候选。 */
280
+ async function candidatesFor(
281
+ candidates: readonly string[],
282
+ listBoxes: () => Promise<{ boxes: BoxInfo[] }>,
283
+ ): Promise<string[]> {
284
+ const fromResult = uniqueNames(candidates);
285
+ if (fromResult.length > 0) return fromResult;
286
+ try {
287
+ const listed = await listBoxes();
288
+ return uniqueNames(listed.boxes.map((box) => box.name));
289
+ } catch {
290
+ return [];
291
+ }
292
+ }
293
+
294
+ /** 去重并丢掉空名(保持传入顺序)。 */
295
+ function uniqueNames(names: readonly string[]): string[] {
296
+ const seen = new Set<string>();
297
+ const out: string[] = [];
298
+ for (const name of names) {
299
+ if (name === "" || seen.has(name)) continue;
300
+ seen.add(name);
301
+ out.push(name);
302
+ }
303
+ return out;
304
+ }
305
+
306
+ /** 失败结果:单条 text、无 details、`isError: true`(plan §4.7 ⑦:绝不 throw 到会话层)。 */
307
+ function errorResult(text: string): AgentToolResult {
308
+ return { content: [textItem(text)], details: undefined, isError: true };
309
+ }
310
+
311
+ /** text content 项(不用字符串字面量对象,避免漏掉 `type` 的收窄)。 */
312
+ function textItem(text: string): ToolContent[number] {
313
+ return { type: "text", text };
314
+ }
315
+
316
+ function isRecord(value: unknown): value is Record<string, unknown> {
317
+ return typeof value === "object" && value !== null && !Array.isArray(value);
318
+ }
319
+
320
+ function messageOf(error: unknown): string {
321
+ return error instanceof Error ? error.message : String(error);
322
+ }