@zhushanwen/pi-subagent-workflow 8.1.1 → 8.3.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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Model List Injector
3
+ *
4
+ * 通过 before_agent_start 每 turn 注入 `<available_provider_models>` 段,列出
5
+ * 当前 auth 可用的模型(provider/modelId + 能力 + contextWindow),与
6
+ * `<available_subagents>` / `<available_workflows>` 对称——三者合起来让模型掌握
7
+ * 派发所需的全部资源清单。
8
+ *
9
+ * 背景:模型列表的最大消费者是本包的 subagent/workflow `model` 参数(要求
10
+ * "provider/modelId" 格式,非法值直接 throw)。注入后模型可直接按 id 派发,
11
+ * 无需臆造模型名。
12
+ *
13
+ * 与另两个 injector 的差异:数据源不是文件发现而是 ModelRegistry.getAvailable()
14
+ * (pi 权威的 auth 可用模型快照,纯内存同步调用),因此:
15
+ * - 不需要 session_start 预热 / 渲染缓存 / session_shutdown 清理(无模块级
16
+ * 状态——结构上规避了缓存生命周期问题)
17
+ * - 每 turn 直接渲染;排序 (provider, id) 码点序保证输出字节稳定(turn 间
18
+ * systemPrompt 前缀稳定 = KV cache 友好;跨环境逐字节可复现,与另两个
19
+ * injector 的码点序契约对齐)。数据真实变化(用户中途配置了新 provider)
20
+ * 时下一 turn 自然反映。
21
+ *
22
+ * 立场:本注入段只服务「派发时选模型」,明确告知模型不要在会话中切换主模型
23
+ * (KV cache 不友好);用户明确要求换模型时走 pi 原生 /model 命令(人手动触发)。
24
+ */
25
+
26
+ import type {
27
+ Api,
28
+ Model,
29
+ } from "@earendil-works/pi-ai";
30
+ import type {
31
+ BeforeAgentStartEvent,
32
+ BeforeAgentStartEventResult,
33
+ ExtensionAPI,
34
+ ExtensionContext,
35
+ } from "@earendil-works/pi-coding-agent";
36
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
37
+
38
+ import { escapeXml, renderXmlSection } from "../shared/xml-injection.ts";
39
+
40
+ const logger = getLogger("injector");
41
+
42
+ /** 注入段的最小模型投影(从 Model<Api> 收窄,测试无需构造完整 Model) */
43
+ export interface ModelEntry {
44
+ provider: string;
45
+ id: string;
46
+ name: string;
47
+ reasoning: boolean;
48
+ input: string[];
49
+ contextWindow: number;
50
+ }
51
+
52
+ /** Model<Api> → ModelEntry 投影(只留注入段消费的字段;模块内唯一消费方 setupModelListInjector) */
53
+ function toModelEntry(model: Model<Api>): ModelEntry {
54
+ return {
55
+ provider: model.provider,
56
+ id: model.id,
57
+ name: model.name,
58
+ reasoning: model.reasoning,
59
+ input: [...model.input],
60
+ contextWindow: model.contextWindow,
61
+ };
62
+ }
63
+
64
+ /** 码点序比较(显式契约,禁 localeCompare——宿主 locale 差异会破坏跨环境字节一致)。 */
65
+ function compareByCodepoint(a: string, b: string): number {
66
+ return a < b ? -1 : a > b ? 1 : 0;
67
+ }
68
+
69
+ /** 能力标记:reasoning → "reasoning",input 含 image → "vision"(空则省略 caps 段) */
70
+ function formatCaps(entry: ModelEntry): string {
71
+ const caps: string[] = [];
72
+ if (entry.reasoning) caps.push("reasoning");
73
+ if (entry.input.includes("image")) caps.push("vision");
74
+ return caps.join(",");
75
+ }
76
+
77
+ /**
78
+ * 将模型列表格式化为 XML 注入段。
79
+ *
80
+ * 输入按 (provider, id) 码点序排序——registry 返回顺序不作保证,排序后同一
81
+ * 数据集输出字节稳定。码点序是显式契约(禁 localeCompare——宿主 locale 差异
82
+ * 会破坏跨环境字节一致,见 subagent-list-injector.ts sortByCodepoint 注释),
83
+ * 保证注入段进每 turn system prompt 时跨环境逐字节可复现(cache-probe 前缀
84
+ * 指纹归因 / 换机器 resume 场景依赖此性质)。空列表返回空串(不注入)。
85
+ */
86
+ export function formatModelList(models: ModelEntry[]): string {
87
+ if (models.length === 0) return "";
88
+
89
+ const sorted = [...models].sort((a, b) =>
90
+ a.provider === b.provider
91
+ ? compareByCodepoint(a.id, b.id)
92
+ : compareByCodepoint(a.provider, b.provider),
93
+ );
94
+
95
+ const items = sorted.map((m) => {
96
+ const caps = formatCaps(m);
97
+ return (
98
+ ` <model><id>${escapeXml(`${m.provider}/${m.id}`)}</id>`
99
+ + `<name>${escapeXml(m.name)}</name>`
100
+ + (caps ? `<caps>${caps}</caps>` : "")
101
+ + `<contextWindow>${m.contextWindow}</contextWindow></model>`
102
+ );
103
+ });
104
+ return renderXmlSection({
105
+ tag: "available_provider_models",
106
+ guide: "The following models are available (auth-configured). Use these ids when delegating via the subagent/workflow `model` param (\"provider/modelId\" format) to match the task (e.g. vision models for screenshots, strong reasoners for architecture). Do NOT switch the main conversation model mid-session — per-call model override on delegates only (switching the main model is cache-hostile); use the /model command only when the user explicitly asks to change it.",
107
+ items,
108
+ });
109
+ }
110
+
111
+ /**
112
+ * 注册 before_agent_start handler,注入 `<available_provider_models>` 段。
113
+ *
114
+ * 每 turn 从 ctx.modelRegistry.getAvailable() 同步取快照渲染注入;空列表不
115
+ * 返回 systemPrompt;任何异常被吞掉(记日志),不阻断 agent turn。与 subagent/
116
+ * workflow 注入 handler 链式(pi 串联多 handler 的 systemPrompt 返回值)。
117
+ */
118
+ export function setupModelListInjector(pi: ExtensionAPI): void {
119
+ pi.on(
120
+ "before_agent_start",
121
+ async (
122
+ event: BeforeAgentStartEvent,
123
+ ctx: ExtensionContext,
124
+ ): Promise<BeforeAgentStartEventResult | void> => {
125
+ try {
126
+ const injection = formatModelList(
127
+ ctx.modelRegistry.getAvailable().map(toModelEntry),
128
+ );
129
+ if (!injection) return;
130
+ return { systemPrompt: event.systemPrompt + injection };
131
+ } catch (err) {
132
+ logger.error("[model-list-injector] before_agent_start failed", {
133
+ reason: err instanceof Error ? err.message : String(err),
134
+ });
135
+ }
136
+ },
137
+ );
138
+ }
@@ -35,8 +35,10 @@ import {
35
35
  discoverResources,
36
36
  findWorkspaceRoot,
37
37
  getCachedFileContent,
38
+ getCachedParsed,
38
39
  } from "../shared/resource-discovery.ts";
39
40
  import { parseResourceMeta } from "../shared/meta-parser.ts";
41
+ import { escapeXml, renderXmlSection } from "../shared/xml-injection.ts";
40
42
 
41
43
  const logger = getLogger("injector");
42
44
 
@@ -97,9 +99,14 @@ export function parseAgentFrontmatter(content: string): AgentEntry | null {
97
99
  * 用统一资源发现(ADR-031)发现所有可用 agent。
98
100
  *
99
101
  * discoverResources 返回按文件名 stem 去重、优先级合并后的 DiscoveredResource[]
100
- * (project > user > builtin)。此处逐个解析 frontmatter 提取 name+description,
101
- * 再按 agent name 去重(discoverResources 返回顺序为低→高优先级,高优先级靠后,
102
- * Map.set 后者覆盖前者,故最终保留最高优先级同名 agent)。
102
+ * (project > user > builtin,返回顺序低→高优先级——Map 后写覆盖依赖此序,不可在
103
+ * 发现层重排)。此处逐个解析 frontmatter 提取 name+description(经 getCachedParsed
104
+ * mtime 级缓存),再按 agent name 去重(高优先级靠后,Map.set 后者覆盖前者,故最终
105
+ * 保留最高优先级同名 agent)。
106
+ *
107
+ * 输出按 name 码点序排序(KV-cache 契约):注入段进每 turn system prompt,顺序必须
108
+ * 与文件系统枚举序(readdir 无契约)解耦——目录内容不变时,session_start / fallback /
109
+ * resume 任意重建的渲染结果逐字节一致;仅条目增减时文本才变化。
103
110
  *
104
111
  * 永不抛错——发现本身 fail-safe,单个文件读失败仅记日志。
105
112
  */
@@ -117,11 +124,10 @@ export async function discoverAllAgents(
117
124
  for (const resource of resources) {
118
125
  if (!resource.available) continue;
119
126
  try {
120
- const content = getCachedFileContent(resource.path) ?? "";
121
- const agent = parseAgentFrontmatter(content);
127
+ const agent = getCachedParsed(resource.path, parseAgentFrontmatter);
122
128
  if (agent) {
123
129
  agentMap.set(agent.name, { ...agent, path: resource.path });
124
- } else if (content.trimStart().startsWith("---")) {
130
+ } else if (startsWithFrontmatter(resource.path)) {
125
131
  // m5(评审 M3/F2 + minor-5):仅「有 frontmatter 但解析失败」才 warn
126
132
  // (缺 name/description/examples 单条非法致整体 reject)——README 等
127
133
  // 无 frontmatter 的 .md 不刷 warn(每 turn 扫描)。
@@ -137,17 +143,21 @@ export async function discoverAllAgents(
137
143
  );
138
144
  }
139
145
  }
140
- return [...agentMap.values()];
146
+ return sortByCodepoint([...agentMap.values()], (a) => a.name);
147
+ }
148
+
149
+ /** 码点序排序(显式契约,禁 localeCompare——宿主 locale 差异会破坏跨环境字节一致)。 */
150
+ function sortByCodepoint<T>(items: T[], key: (item: T) => string): T[] {
151
+ return items.sort((a, b) => {
152
+ const ka = key(a);
153
+ const kb = key(b);
154
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
155
+ });
141
156
  }
142
157
 
143
- /** 转义 XML 特殊字符 */
144
- function escapeXml(str: string): string {
145
- return str
146
- .replace(/&/g, "&amp;")
147
- .replace(/</g, "&lt;")
148
- .replace(/>/g, "&gt;")
149
- .replace(/"/g, "&quot;")
150
- .replace(/'/g, "&apos;");
158
+ /** 内容以 frontmatter 分隔符开头(解析失败才值得 warn 的判据)。 */
159
+ function startsWithFrontmatter(filePath: string): boolean {
160
+ return (getCachedFileContent(filePath) ?? "").trimStart().startsWith("---");
151
161
  }
152
162
 
153
163
  /**
@@ -159,11 +169,7 @@ function escapeXml(str: string): string {
159
169
  export function formatAgentList(agents: AgentEntry[]): string {
160
170
  if (agents.length === 0) return "";
161
171
 
162
- const lines = [
163
- "\n\n<available_subagents>",
164
- "The following subagents are available. PRIORITY: when a task involves reading 3+ files, writing 100+ lines, parallel research, or specialized review, delegate to a matching subagent FIRST instead of doing it yourself — this keeps your context focused on orchestration. Do NOT call list to discover available subagents; use list only for running state. When using the subagent tool, ONLY use agent names from this list. If no agent matches your task, pass systemPrompt alongside the agent name to create a dynamic agent.",
165
- ];
166
- for (const agent of agents) {
172
+ const items = agents.map((agent) => {
167
173
  let block = ` <agent><name>${escapeXml(agent.name)}</name><description>${escapeXml(agent.description)}</description>`;
168
174
  // m5:路由样本(when + examples 正反原样渲染——negative 的 action 由作者写
169
175
  // 「不调用(原因)」,渲染器不硬编码;全部内容 escapeXml 防 XML 注入段破坏)
@@ -179,10 +185,13 @@ export function formatAgentList(agents: AgentEntry[]): string {
179
185
  block += `\n <examples>\n${exampleLines.join("\n")}\n </examples>`;
180
186
  }
181
187
  block += `<location>${escapeXml(agent.path)}</location></agent>`;
182
- lines.push(block);
183
- }
184
- lines.push("</available_subagents>");
185
- return lines.join("\n");
188
+ return block;
189
+ });
190
+ return renderXmlSection({
191
+ tag: "available_subagents",
192
+ guide: "The following subagents are available. PRIORITY: when a task involves reading 3+ files, writing 100+ lines, parallel research, or specialized review, delegate to a matching subagent FIRST instead of doing it yourself — this keeps your context focused on orchestration. Do NOT call list to discover available subagents; use list only for running state. When using the subagent tool, ONLY use agent names from this list. If no agent matches your task, pass systemPrompt alongside the agent name to create a dynamic agent.",
193
+ items,
194
+ });
186
195
  }
187
196
 
188
197
  /**
@@ -31,9 +31,10 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
31
31
  import {
32
32
  discoverResources,
33
33
  findWorkspaceRoot,
34
- getCachedFileContent,
34
+ getCachedParsed,
35
35
  } from "../shared/resource-discovery.ts";
36
36
  import { parseResourceMeta } from "../shared/meta-parser.ts";
37
+ import { escapeXml, renderXmlSection } from "../shared/xml-injection.ts";
37
38
 
38
39
  const logger = getLogger("injector");
39
40
 
@@ -107,6 +108,8 @@ export function parseWorkflowMeta(content: string): WorkflowEntry | null {
107
108
 
108
109
  /**
109
110
  * 用统一资源发现发现所有可用 workflow(includeTmp 覆盖 generate 产物)。
111
+ * 解析经 getCachedParsed mtime 级缓存;输出按 name 码点序排序(KV-cache 契约,
112
+ * 见 subagent-list-injector.ts discoverAllAgents 注释)。
110
113
  * 永不抛错——单文件读失败仅记日志。
111
114
  */
112
115
  export async function discoverAllWorkflows(
@@ -124,8 +127,7 @@ export async function discoverAllWorkflows(
124
127
  for (const resource of resources) {
125
128
  if (!resource.available) continue;
126
129
  try {
127
- const content = getCachedFileContent(resource.path) ?? "";
128
- const wf = parseWorkflowMeta(content);
130
+ const wf = getCachedParsed(resource.path, parseWorkflowMeta);
129
131
  if (wf) map.set(wf.name, { ...wf, path: resource.path });
130
132
  } catch (err) {
131
133
  logger.error(
@@ -134,17 +136,9 @@ export async function discoverAllWorkflows(
134
136
  );
135
137
  }
136
138
  }
137
- return [...map.values()];
138
- }
139
-
140
- /** 转义 XML 特殊字符 */
141
- function escapeXml(str: string): string {
142
- return str
143
- .replace(/&/g, "&amp;")
144
- .replace(/</g, "&lt;")
145
- .replace(/>/g, "&gt;")
146
- .replace(/"/g, "&quot;")
147
- .replace(/'/g, "&apos;");
139
+ return [...map.values()].sort((a, b) =>
140
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
141
+ );
148
142
  }
149
143
 
150
144
  /**
@@ -157,19 +151,16 @@ function escapeXml(str: string): string {
157
151
  export function formatWorkflowList(workflows: WorkflowEntry[]): string {
158
152
  if (workflows.length === 0) return "";
159
153
 
160
- const lines = [
161
- "\n\n<available_workflows>",
154
+ const items = workflows.map((wf) =>
155
+ ` <workflow><name>${escapeXml(wf.name)}</name><description>${escapeXml(wf.description)}</description><location>${escapeXml(wf.path)}</location></workflow>`,
156
+ );
157
+ return renderXmlSection({
158
+ tag: "available_workflows",
162
159
  // 引导语与具体 workflow 解耦:不写死内置名(列表本身已含全部 workflow,
163
160
  // 名字/描述每 turn 由 @pi-meta 动态注入),只给通用路由指引 + read location 参数指针。
164
- 'The following workflows are available. Do NOT call list to discover available workflows — they are listed below; use list only for running state. All listed workflows run directly via action:run — do NOT use workflow-script generate for any listed workflow. For parameter details, read the <location> script file (script header has @pi-meta parameters + usage).',
165
- ];
166
- for (const wf of workflows) {
167
- lines.push(
168
- ` <workflow><name>${escapeXml(wf.name)}</name><description>${escapeXml(wf.description)}</description><location>${escapeXml(wf.path)}</location></workflow>`,
169
- );
170
- }
171
- lines.push("</available_workflows>");
172
- return lines.join("\n");
161
+ guide: "The following workflows are available. Do NOT call list to discover available workflows — they are listed below; use list only for running state. All listed workflows run directly via action:run — do NOT use workflow-script generate for any listed workflow. For parameter details, read the <location> script file (script header has @pi-meta parameters + usage).",
162
+ items,
163
+ });
173
164
  }
174
165
 
175
166
  /**
@@ -233,7 +233,11 @@ export async function discoverWorkflows(
233
233
  mergedMap.set(cachedMeta.name, cachedMeta);
234
234
  }
235
235
 
236
- const merged = Array.from(mergedMap.values());
236
+ // name 码点序输出(KV-cache 契约,与 injector 层一致):不依赖发现层
237
+ // 优先级迭代序 / readdir 枚举序,重建结果顺序稳定。
238
+ const merged = Array.from(mergedMap.values()).sort((a, b) =>
239
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
240
+ );
237
241
 
238
242
  // Update cache (scoped to current workspace root)
239
243
  const bucket = getCacheBucket(workspaceRoot);
@@ -92,7 +92,7 @@ export interface AgentCallOpts {
92
92
  * When omitted, agent .md frontmatter thinkingLevel is used (via resolveIdentity/getAgentConfig).
93
93
  */
94
94
  thinkingLevel?: string;
95
- /** Scene name for model-switch advisor recommendation. */
95
+ /** Scene name passed through to the worker for model-selection hints. */
96
96
  scene?: string;
97
97
  /**
98
98
  * Wall-clock timeout in milliseconds. When > 0, aborts the subprocess