@trim21/personal-pi-extensions 0.1.683 → 0.1.685

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/aft/tools.ts CHANGED
@@ -20,18 +20,24 @@ import {
20
20
  type StatusSnapshot,
21
21
  } from "@cortexkit/aft-bridge";
22
22
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
23
- import { type Static, Type } from "typebox";
23
+ import { type Static, type TSchema, Type } from "typebox";
24
24
  import { Value } from "typebox/value";
25
25
 
26
26
  import { formatDisplayPath, formatSubtitlePath, resolvePathArg } from "../lib/path.js";
27
27
  import { type ToolPendant } from "../lib/pendant.js";
28
- import type { ToolBus } from "../lib/tool-bus.js";
28
+ import { defineStructuredTool, type StructuredResult, type ToolBus } from "../lib/tool-bus.js";
29
29
  import {
30
30
  type AftState,
31
31
  callAftTool,
32
32
  resolveSessionId,
33
33
  SEMANTIC_INDEX_WAIT_TIMEOUT_MS,
34
34
  } from "./bridge.js";
35
+ import {
36
+ aftCallgraphStructuredSchema,
37
+ aftOutlineStructuredSchema,
38
+ aftSearchStructuredSchema,
39
+ aftZoomStructuredSchema,
40
+ } from "./schemas.js";
35
41
 
36
42
  /** 工具使用指南,以 markdown 形式维护,读起来像文档。 */
37
43
  const OUTLINE_PROMPT = readFileSync(
@@ -79,6 +85,24 @@ export function compactArgs(args: Record<string, unknown>): Record<string, unkno
79
85
  );
80
86
  }
81
87
 
88
+ /**
89
+ * 结构化载荷:引擎自己的响应字段(去掉 envelope 的 request id)原样给脚本,外加我们
90
+ * 渲染的文本。引擎的 `success` / `code` 保留在载荷里——软失败(symbol_not_found、
91
+ * callgraph_building、search_lanes_unavailable)是「工具跑成了、引擎给出否定答案」,
92
+ * 脚本据 `code` 分支,而不是被当成调用异常。
93
+ */
94
+ function aftPayload<TOutput extends TSchema>(
95
+ response: Record<string, unknown>,
96
+ text: string,
97
+ ): StructuredResult<Static<TOutput>> {
98
+ const value: Record<string, unknown> = { ...response, text };
99
+ // envelope 的 request id 是传输层的事,不进载荷
100
+ delete value.id;
101
+ // 载荷是引擎响应的原样透传(schema 只声明我们确证过的字段,其余允许通过),所以
102
+ // 编译期只能断言;类型正确性由脚本侧的声明与总线运行期的 Value.Parse 复核共同保证。
103
+ return { ok: true, value: value as Static<TOutput> };
104
+ }
105
+
82
106
  /** 人类视角的调用记录:input params + 与 LLM 相同的输出结果。 */
83
107
  function buildPendantMarkdown(params: {
84
108
  title: string;
@@ -127,60 +151,64 @@ const OutlineParams = Type.Object(
127
151
  );
128
152
 
129
153
  export function registerOutlineTool(bus: ToolBus, ctx: AftToolContext): void {
130
- bus.register({
131
- name: "aft_outline",
132
- label: "aft_outline",
133
- description: [
134
- "输出代码文件、目录的结构化大纲:函数/类/类型等符号及其行号范围;Markdown/HTML 返回标题层级。",
135
- "用它在读取具体内容之前先了解文件结构。",
136
- "深入了解某个符号用 aft_zoom;看跨文件调用关系用 aft_callgraph。",
137
- "target 支持:文件路径(带签名的符号大纲)、目录路径(递归最多 200 文件)。只接受单个 target。",
138
- "target 为目录时默认返回扁平文件树(语言、顶层符号数、字节大小);传 files: false 可改回符号大纲。",
139
- ].join("\n"),
140
- promptSnippet: "Output structural outline of a file/directory",
141
- promptGuidelines: [OUTLINE_PROMPT, AFT_GUIDELINES],
142
- parameters: OutlineParams,
143
- async execute(_id, params, signal, _onUpdate, extCtx) {
144
- const target = coerceTargetParam(params.target);
145
- if (typeof target !== "string" || target.length === 0) {
146
- throw new Error("'target' must be a single path (array targets are not supported)");
147
- }
148
- const resolved = resolvePathArg(extCtx.cwd, target);
149
- let filesMode = coerceBoolean(params.files);
150
- if (params.files === undefined) {
151
- const stats = await stat(resolved).catch(() => null);
152
- filesMode = stats?.isDirectory() ?? false;
153
- }
154
- const rawArgs = compactArgs({
155
- target: resolved,
156
- files: filesMode || undefined,
157
- includeTests: params.includeTests,
158
- });
159
-
160
- const subtitle = buildOutlineSubtitle(extCtx.cwd, target);
161
-
162
- const { text, response } = await callAftTool(
163
- bridgeFor(ctx),
164
- "outline",
165
- rawArgs,
166
- resolveSessionId(extCtx),
167
- undefined,
168
- undefined,
169
- signal,
170
- );
171
- const truncated = response.truncated === true;
172
- return {
173
- content: [{ type: "text", text }],
174
- details: {
175
- truncated,
176
- params,
177
- pendant: {
178
- subtitle,
179
- } satisfies ToolPendant,
180
- },
181
- };
182
- },
183
- });
154
+ bus.register(
155
+ defineStructuredTool({
156
+ name: "aft_outline",
157
+ label: "aft_outline",
158
+ description: [
159
+ "输出代码文件、目录的结构化大纲:函数/类/类型等符号及其行号范围;Markdown/HTML 返回标题层级。",
160
+ "用它在读取具体内容之前先了解文件结构。",
161
+ "深入了解某个符号用 aft_zoom;看跨文件调用关系用 aft_callgraph。",
162
+ "target 支持:文件路径(带签名的符号大纲)、目录路径(递归最多 200 文件)。只接受单个 target。",
163
+ "target 为目录时默认返回扁平文件树(语言、顶层符号数、字节大小);传 files: false 可改回符号大纲。",
164
+ ].join("\n"),
165
+ promptSnippet: "Output structural outline of a file/directory",
166
+ promptGuidelines: [OUTLINE_PROMPT, AFT_GUIDELINES],
167
+ parameters: OutlineParams,
168
+ structuredSchema: aftOutlineStructuredSchema,
169
+ async execute(_id, params, signal, _onUpdate, extCtx) {
170
+ const target = coerceTargetParam(params.target);
171
+ if (typeof target !== "string" || target.length === 0) {
172
+ throw new Error("'target' must be a single path (array targets are not supported)");
173
+ }
174
+ const resolved = resolvePathArg(extCtx.cwd, target);
175
+ let filesMode = coerceBoolean(params.files);
176
+ if (params.files === undefined) {
177
+ const stats = await stat(resolved).catch(() => null);
178
+ filesMode = stats?.isDirectory() ?? false;
179
+ }
180
+ const rawArgs = compactArgs({
181
+ target: resolved,
182
+ files: filesMode || undefined,
183
+ includeTests: params.includeTests,
184
+ });
185
+
186
+ const subtitle = buildOutlineSubtitle(extCtx.cwd, target);
187
+
188
+ const { text, response } = await callAftTool(
189
+ bridgeFor(ctx),
190
+ "outline",
191
+ rawArgs,
192
+ resolveSessionId(extCtx),
193
+ undefined,
194
+ undefined,
195
+ signal,
196
+ );
197
+ const truncated = response.truncated === true;
198
+ return {
199
+ content: [{ type: "text", text }],
200
+ details: {
201
+ truncated,
202
+ params,
203
+ pendant: {
204
+ subtitle,
205
+ } satisfies ToolPendant,
206
+ },
207
+ structuredResult: aftPayload<typeof aftOutlineStructuredSchema>(response, text),
208
+ };
209
+ },
210
+ }),
211
+ );
184
212
  }
185
213
 
186
214
  /** 构建 aft_outline pendant 的 subtitle:`target="…"`(路径过长时显示上一级目录加文件名)。 */
@@ -211,61 +239,65 @@ const ZoomParams = Type.Object(
211
239
  );
212
240
 
213
241
  export function registerZoomTool(bus: ToolBus, ctx: AftToolContext): void {
214
- bus.register({
215
- name: "aft_zoom",
216
- label: "aft_zoom",
217
- description: [
218
- "查看命名符号(函数/类/类型)的完整源码,或 Markdown/HTML 的标题段落内容。",
219
- "需要理解某个具体符号时用它(读整个文件用 read)。",
220
- "callgraph: true 时附带同文件内的调用关系标注。",
221
- "同文件多符号用 `symbols` 数组。",
222
- ].join("\n"),
223
- promptSnippet: "Inspect the full source of a named symbol",
224
- promptGuidelines: [ZOOM_PROMPT],
225
- parameters: ZoomParams,
226
- async execute(_id, params, signal, _onUpdate, extCtx) {
227
- const rawArgs = compactArgs({
228
- filePath: resolvePathArg(extCtx.cwd, params.path),
229
- symbols: params.symbols,
230
- contextLines: coerceOptionalInt(
231
- params.contextLines,
232
- "contextLines",
233
- 1,
234
- Number.MAX_SAFE_INTEGER,
235
- ),
236
- callgraph: coerceBoolean(params.callgraph) || undefined,
237
- });
238
-
239
- const subtitle = buildZoomSubtitle(extCtx.cwd, params);
240
-
241
- const { text, response } = await callAftTool(
242
- bridgeFor(ctx),
243
- "zoom",
244
- rawArgs,
245
- resolveSessionId(extCtx),
246
- undefined,
247
- undefined,
248
- signal,
249
- );
250
- const truncated = response.truncated === true;
251
- return {
252
- content: [{ type: "text", text }],
253
- details: {
254
- truncated,
255
- params,
256
- pendant: {
257
- subtitle,
258
- markdown: buildPendantMarkdown({
259
- title: "aft_zoom",
260
- input: params,
261
- output: text,
262
- truncated,
263
- }),
264
- } satisfies ToolPendant,
265
- },
266
- };
267
- },
268
- });
242
+ bus.register(
243
+ defineStructuredTool({
244
+ name: "aft_zoom",
245
+ label: "aft_zoom",
246
+ description: [
247
+ "查看命名符号(函数/类/类型)的完整源码,或 Markdown/HTML 的标题段落内容。",
248
+ "需要理解某个具体符号时用它(读整个文件用 read)。",
249
+ "callgraph: true 时附带同文件内的调用关系标注。",
250
+ "同文件多符号用 `symbols` 数组。",
251
+ ].join("\n"),
252
+ promptSnippet: "Inspect the full source of a named symbol",
253
+ promptGuidelines: [ZOOM_PROMPT],
254
+ parameters: ZoomParams,
255
+ structuredSchema: aftZoomStructuredSchema,
256
+ async execute(_id, params, signal, _onUpdate, extCtx) {
257
+ const rawArgs = compactArgs({
258
+ filePath: resolvePathArg(extCtx.cwd, params.path),
259
+ symbols: params.symbols,
260
+ contextLines: coerceOptionalInt(
261
+ params.contextLines,
262
+ "contextLines",
263
+ 1,
264
+ Number.MAX_SAFE_INTEGER,
265
+ ),
266
+ callgraph: coerceBoolean(params.callgraph) || undefined,
267
+ });
268
+
269
+ const subtitle = buildZoomSubtitle(extCtx.cwd, params);
270
+
271
+ const { text, response } = await callAftTool(
272
+ bridgeFor(ctx),
273
+ "zoom",
274
+ rawArgs,
275
+ resolveSessionId(extCtx),
276
+ undefined,
277
+ undefined,
278
+ signal,
279
+ );
280
+ const truncated = response.truncated === true;
281
+ return {
282
+ content: [{ type: "text", text }],
283
+ details: {
284
+ truncated,
285
+ params,
286
+ pendant: {
287
+ subtitle,
288
+ markdown: buildPendantMarkdown({
289
+ title: "aft_zoom",
290
+ input: params,
291
+ output: text,
292
+ truncated,
293
+ }),
294
+ } satisfies ToolPendant,
295
+ },
296
+ structuredResult: aftPayload<typeof aftZoomStructuredSchema>(response, text),
297
+ };
298
+ },
299
+ }),
300
+ );
269
301
  }
270
302
 
271
303
  /** 构建 aft_zoom pendant 的 subtitle:`path="…" symbol="…"`。 */
@@ -363,60 +395,64 @@ export async function callCallgraphWithBuildRetry(
363
395
  }
364
396
 
365
397
  export function registerCallgraphTool(bus: ToolBus, ctx: AftToolContext): void {
366
- bus.register({
367
- name: "aft_callgraph",
368
- label: "aft_callgraph",
369
- description: [
370
- "基于真实调用图回答代码关系问题(谁调用我、影响面、调用链),替代 grep + read 的链条式排查。",
371
- "op 语义:callers=调用点(改名/改签名前用);impact=影响面(改一个符号会波及谁);",
372
- "call_tree=该函数调用了什么;trace_to=从入口如何执行到某符号;",
373
- "trace_to_symbol=两符号间最短路径(需 toSymbol,歧义时需 toPath);trace_data=追踪值在参数/赋值间的流转(需 expression)。",
374
- "标记:~ = 仅按名字解析的边(可能指向同名符号);[unresolved] = 未解析到定义的调用点。",
375
- ].join("\n"),
376
- promptSnippet: "Call graph and data-flow navigation",
377
- promptGuidelines: [CALLGRAPH_PROMPT],
378
- parameters: CallgraphParams,
379
- async execute(_id, params, signal, _onUpdate, extCtx) {
380
- const rawArgs = compactArgs({
381
- op: params.op,
382
- filePath: resolvePathArg(extCtx.cwd, params.path),
383
- symbol: params.symbol,
384
- depth: coerceOptionalInt(params.depth, "depth", 1, Number.MAX_SAFE_INTEGER),
385
- expression: params.expression,
386
- toSymbol: params.toSymbol,
387
- toFile: params.toPath ? resolvePathArg(extCtx.cwd, params.toPath) : undefined,
388
- includeTests: params.includeTests,
389
- includeUnresolved: params.includeUnresolved,
390
- });
391
-
392
- const { text, response } = await callCallgraphWithBuildRetry(
393
- bridgeFor(ctx),
394
- rawArgs,
395
- extCtx,
396
- signal,
397
- );
398
- const out =
399
- text ||
400
- formatCallgraphSections(params.op, response, PLAIN_CALLGRAPH_THEME, {
401
- includeUnresolved: coerceBoolean(params.includeUnresolved),
402
- }).join("\n");
403
- const truncated = response.truncated === true;
404
- return {
405
- content: [{ type: "text", text: out }],
406
- details: {
407
- truncated,
408
- pendant: {
409
- markdown: buildPendantMarkdown({
410
- title: "aft_callgraph",
411
- input: params,
412
- output: out,
413
- truncated,
414
- }),
415
- } satisfies ToolPendant,
416
- },
417
- };
418
- },
419
- });
398
+ bus.register(
399
+ defineStructuredTool({
400
+ name: "aft_callgraph",
401
+ label: "aft_callgraph",
402
+ description: [
403
+ "基于真实调用图回答代码关系问题(谁调用我、影响面、调用链),替代 grep + read 的链条式排查。",
404
+ "op 语义:callers=调用点(改名/改签名前用);impact=影响面(改一个符号会波及谁);",
405
+ "call_tree=该函数调用了什么;trace_to=从入口如何执行到某符号;",
406
+ "trace_to_symbol=两符号间最短路径(需 toSymbol,歧义时需 toPath);trace_data=追踪值在参数/赋值间的流转(需 expression)。",
407
+ "标记:~ = 仅按名字解析的边(可能指向同名符号);[unresolved] = 未解析到定义的调用点。",
408
+ ].join("\n"),
409
+ promptSnippet: "Call graph and data-flow navigation",
410
+ promptGuidelines: [CALLGRAPH_PROMPT],
411
+ parameters: CallgraphParams,
412
+ structuredSchema: aftCallgraphStructuredSchema,
413
+ async execute(_id, params, signal, _onUpdate, extCtx) {
414
+ const rawArgs = compactArgs({
415
+ op: params.op,
416
+ filePath: resolvePathArg(extCtx.cwd, params.path),
417
+ symbol: params.symbol,
418
+ depth: coerceOptionalInt(params.depth, "depth", 1, Number.MAX_SAFE_INTEGER),
419
+ expression: params.expression,
420
+ toSymbol: params.toSymbol,
421
+ toFile: params.toPath ? resolvePathArg(extCtx.cwd, params.toPath) : undefined,
422
+ includeTests: params.includeTests,
423
+ includeUnresolved: params.includeUnresolved,
424
+ });
425
+
426
+ const { text, response } = await callCallgraphWithBuildRetry(
427
+ bridgeFor(ctx),
428
+ rawArgs,
429
+ extCtx,
430
+ signal,
431
+ );
432
+ const out =
433
+ text ||
434
+ formatCallgraphSections(params.op, response, PLAIN_CALLGRAPH_THEME, {
435
+ includeUnresolved: coerceBoolean(params.includeUnresolved),
436
+ }).join("\n");
437
+ const truncated = response.truncated === true;
438
+ return {
439
+ content: [{ type: "text", text: out }],
440
+ details: {
441
+ truncated,
442
+ pendant: {
443
+ markdown: buildPendantMarkdown({
444
+ title: "aft_callgraph",
445
+ input: params,
446
+ output: out,
447
+ truncated,
448
+ }),
449
+ } satisfies ToolPendant,
450
+ },
451
+ structuredResult: aftPayload<typeof aftCallgraphStructuredSchema>(response, out),
452
+ };
453
+ },
454
+ }),
455
+ );
420
456
  }
421
457
 
422
458
  /**
@@ -612,75 +648,79 @@ const SearchParams = Type.Object(
612
648
  );
613
649
 
614
650
  export function registerSearchTool(bus: ToolBus, ctx: AftToolContext): void {
615
- bus.register({
616
- name: "aft_search",
617
- label: "aft_search",
618
- description: [
619
- "一个工具完成代码搜索:概念、标识符、错误串、正则、字面量、文件名自动路由到合适的引擎并按相关度排序。",
620
- "概念类查询('ORM 如何构建并执行查询')用自然语言整句——语义通道理解意图并匹配 docstring 和注释;",
621
- "精确名字、字符串、正则保持简短('^export'、'Cargo.lock')。",
622
- "索引首次构建时本调用会阻塞到构建完成,避免返回部分结果。",
623
- ].join("\n"),
624
- promptSnippet: "Search code by meaning or exact text",
625
- promptGuidelines: [SEARCH_PROMPT],
626
- parameters: SearchParams,
627
- async execute(_id, params, signal, onUpdate, extCtx) {
628
- if (typeof params.query !== "string" || params.query.trim().length === 0) {
629
- throw new Error("'query' must be a non-empty string");
630
- }
631
- const rawArgs = compactArgs({
632
- query: params.query,
633
- topK: params.topK,
634
- includeTests: params.includeTests,
635
- });
636
-
637
- const bridge = bridgeFor(ctx);
638
- const formatProgress = createSemanticIndexProgressFormatter();
639
- const stopProgress =
640
- onUpdate === undefined
641
- ? undefined
642
- : subscribeBridgeStatus(bridge, (snapshot) => {
643
- const text = formatProgress(snapshot);
644
- if (text !== undefined) {
645
- onUpdate({ content: [{ type: "text", text }], details: undefined });
646
- }
647
- });
648
- let response: Record<string, unknown>;
649
- let text: string;
650
- try {
651
- ({ text, response } = await callAftTool(
652
- bridge,
653
- "search",
654
- rawArgs,
655
- resolveSessionId(extCtx),
656
- {
657
- // 默认 search 传输超时仅 60s,会早于索引等待(600s)触发;覆盖为等待
658
- // 上限 + 常规执行预算。超时只说明响应被挤掉而非 bridge 挂死,保留
659
- // 常驻的语义索引/LSP 状态。
660
- transportTimeoutMs: SEMANTIC_INDEX_WAIT_TIMEOUT_MS + 60_000,
661
- keepBridgeOnTimeout: true,
651
+ bus.register(
652
+ defineStructuredTool({
653
+ name: "aft_search",
654
+ label: "aft_search",
655
+ description: [
656
+ "一个工具完成代码搜索:概念、标识符、错误串、正则、字面量、文件名自动路由到合适的引擎并按相关度排序。",
657
+ "概念类查询('ORM 如何构建并执行查询')用自然语言整句——语义通道理解意图并匹配 docstring 和注释;",
658
+ "精确名字、字符串、正则保持简短('^export'、'Cargo.lock')。",
659
+ "索引首次构建时本调用会阻塞到构建完成,避免返回部分结果。",
660
+ ].join("\n"),
661
+ promptSnippet: "Search code by meaning or exact text",
662
+ promptGuidelines: [SEARCH_PROMPT],
663
+ parameters: SearchParams,
664
+ structuredSchema: aftSearchStructuredSchema,
665
+ async execute(_id, params, signal, onUpdate, extCtx) {
666
+ if (typeof params.query !== "string" || params.query.trim().length === 0) {
667
+ throw new Error("'query' must be a non-empty string");
668
+ }
669
+ const rawArgs = compactArgs({
670
+ query: params.query,
671
+ topK: params.topK,
672
+ includeTests: params.includeTests,
673
+ });
674
+
675
+ const bridge = bridgeFor(ctx);
676
+ const formatProgress = createSemanticIndexProgressFormatter();
677
+ const stopProgress =
678
+ onUpdate === undefined
679
+ ? undefined
680
+ : subscribeBridgeStatus(bridge, (snapshot) => {
681
+ const text = formatProgress(snapshot);
682
+ if (text !== undefined) {
683
+ onUpdate({ content: [{ type: "text", text }], details: undefined });
684
+ }
685
+ });
686
+ let response: Record<string, unknown>;
687
+ let text: string;
688
+ try {
689
+ ({ text, response } = await callAftTool(
690
+ bridge,
691
+ "search",
692
+ rawArgs,
693
+ resolveSessionId(extCtx),
694
+ {
695
+ // 默认 search 传输超时仅 60s,会早于索引等待(600s)触发;覆盖为等待
696
+ // 上限 + 常规执行预算。超时只说明响应被挤掉而非 bridge 挂死,保留
697
+ // 常驻的语义索引/LSP 状态。
698
+ transportTimeoutMs: SEMANTIC_INDEX_WAIT_TIMEOUT_MS + 60_000,
699
+ keepBridgeOnTimeout: true,
700
+ },
701
+ undefined,
702
+ signal,
703
+ ));
704
+ } finally {
705
+ stopProgress?.();
706
+ }
707
+ const truncated = response.truncated === true;
708
+ return {
709
+ content: [{ type: "text", text }],
710
+ details: {
711
+ truncated,
712
+ pendant: {
713
+ markdown: buildPendantMarkdown({
714
+ title: "aft_search",
715
+ input: params,
716
+ output: text,
717
+ truncated,
718
+ }),
719
+ } satisfies ToolPendant,
662
720
  },
663
- undefined,
664
- signal,
665
- ));
666
- } finally {
667
- stopProgress?.();
668
- }
669
- const truncated = response.truncated === true;
670
- return {
671
- content: [{ type: "text", text }],
672
- details: {
673
- truncated,
674
- pendant: {
675
- markdown: buildPendantMarkdown({
676
- title: "aft_search",
677
- input: params,
678
- output: text,
679
- truncated,
680
- }),
681
- } satisfies ToolPendant,
682
- },
683
- };
684
- },
685
- });
721
+ structuredResult: aftPayload<typeof aftSearchStructuredSchema>(response, text),
722
+ };
723
+ },
724
+ }),
725
+ );
686
726
  }
@@ -12,7 +12,8 @@ interface ToolLike {
12
12
  name: string;
13
13
  description?: string;
14
14
  parameters?: unknown;
15
- structuredSchema?: unknown;
15
+ /** 只有声明了它的工具才可调用(见 `tool.ts` 的准入条件),因此这里是必需的。 */
16
+ structuredSchema: unknown;
16
17
  }
17
18
 
18
19
  type JsonSchema = Record<string, unknown>;
@@ -88,8 +89,7 @@ function renderType(schema: unknown, indent: string): string {
88
89
  function renderOverload(tool: ToolLike): string {
89
90
  const summary = tool.description?.split("\n", 1)[0]?.trim();
90
91
  const doc = summary ? `/** ${summary} */\n` : "";
91
- const output =
92
- tool.structuredSchema === undefined ? "string" : renderType(tool.structuredSchema, "");
92
+ const output = renderType(tool.structuredSchema, "");
93
93
  return `${doc}declare function call(name: ${JSON.stringify(tool.name)}, args: ${renderType(tool.parameters, "")}): Promise<${output}>;`;
94
94
  }
95
95
 
@@ -5,7 +5,7 @@
5
5
  * 自己的确认层),以及 `fs.read` / `fs.write` 两个文件原语(同样由主线程执行,与文件工具
6
6
  * 共用写审批与已读记账,见 fs.ts)。
7
7
  *
8
- * 可调用集合:总线上实际注册的工具减去 `EXCLUDED_TOOL_NAMES`,执行时再与 active
8
+ * 可调用集合:总线上实际注册的、**声明了 `structuredSchema`** 的工具,执行时再与 active
9
9
  * 列表求交——pi 自己的 `defaultTools` / `--tools` / 子代理白名单的排除因此同样生效。
10
10
  *
11
11
  * wasm 在注册这个工具时编译一次(`createCodemodeSandbox`),worker 复用编译结果。
@@ -32,30 +32,16 @@ import { CODEMODE_SOURCE_GRAMMAR, DEFAULT_OUTPUT_TOKENS, parseCodemodeSource } f
32
32
  export const CODEMODE_TOOL_NAME = "codemode";
33
33
 
34
34
  /**
35
- * 不暴露给脚本的工具:
36
- * - codemode 自身(防递归);
37
- * - spawn-agent:它启动一个新的隔离会话,成本与运行时长都不适合放进脚本编排;
38
- * - 两套文件工具集的读写工具:脚本用 `fs.read` / `fs.write`(原文、不截断、按路径整体
39
- * 写入),不重复给一套为 LLM 上下文设计的行号/锚点语义;
40
- * - 两套工具集的搜索工具:脚本用 `call("Bash", { command })` 跑 `rg` / `grep` 更顺手——
41
- * 退出码可用、能拼管道,而这两个工具是给模型看结果的(相对路径、分组渲染、分页尾巴),
42
- * 声明块(尤其 Grep 的参数表与三选一输出)也白占 codemode 的描述篇幅。
35
+ * 准入条件:工具**声明了 `structuredSchema`** 才进 codemode。
36
+ *
37
+ * 脚本拿到的返回值必须有确定的形状——只给文本的工具在脚本里既没法当数据用(得解析文本),
38
+ * 也没有返回类型能写进声明。用「有没有结构化输出」当门槛,这条规则自己会维持一致:不给工具
39
+ * 加 schema 就不进集合,不需要维护一份会随工具增减而漂移的黑名单。因此下列工具天然不在集合
40
+ * 里,理由各自成立:`codemode` 自身与 `spawn-agent`(没有 schema)、文件读写工具(脚本用
41
+ * `fs.read` / `fs.write`,不重复一套为 LLM 上下文设计的行号/锚点语义)、搜索工具(脚本用
42
+ * `call("Bash", { command })` 跑 `rg`,退出码可用、能拼管道)、`lsp-rename`(写工具)以及
43
+ * talk / 会话工具(会把执行时间交给外部输入)。
43
44
  */
44
- const EXCLUDED_TOOL_NAMES: ReadonlySet<string> = new Set([
45
- CODEMODE_TOOL_NAME,
46
- "spawn-agent",
47
- "Read",
48
- "Edit",
49
- "Write",
50
- "read",
51
- "edit",
52
- "write",
53
- "Grep",
54
- "Glob",
55
- "grep",
56
- "glob",
57
- ]);
58
-
59
45
  /** 估计 token 用的字符数(与 pi 一致)。 */
60
46
  const CHARS_PER_TOKEN = 4;
61
47
 
@@ -78,7 +64,8 @@ interface CallableTool {
78
64
  name: string;
79
65
  description?: string;
80
66
  parameters?: unknown;
81
- structuredSchema?: unknown;
67
+ /** 准入条件:进集合的工具一定带着它(见上面的注释)。 */
68
+ structuredSchema: unknown;
82
69
  }
83
70
 
84
71
  /** 只在 pi 有 active 工具概念时才求交(子代理、`--tools` 等场景)。 */
@@ -94,7 +81,7 @@ function allowedToolNames(pi: ExtensionAPI): Set<string> | undefined {
94
81
  function collectTools(bus: ToolBus, allowed: Set<string> | undefined): CallableTool[] {
95
82
  return bus
96
83
  .list()
97
- .filter((definition) => !EXCLUDED_TOOL_NAMES.has(definition.name))
84
+ .filter((definition) => definition.structuredSchema !== undefined)
98
85
  .filter((definition) => allowed === undefined || allowed.has(definition.name))
99
86
  .map((definition) => ({
100
87
  name: definition.name,