@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.
- package/package.json +6 -5
- package/src/execution/__tests__/__fixtures__/notifier-golden-snapshots.json +53 -0
- package/src/execution/__tests__/notifier-flush.test.ts +193 -7
- package/src/execution/__tests__/notifier-golden-snapshot.test.ts +261 -0
- package/src/execution/__tests__/status-refactor.test.ts +5 -5
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +211 -7
- package/src/execution/notifier.ts +182 -222
- package/src/execution/session-pending.ts +4 -1
- package/src/execution/subagent-service.ts +11 -4
- package/src/execution/ui-request-handler-factory.ts +40 -3
- package/src/index.ts +3 -0
- package/src/injectors/__tests__/helpers/injector-test-mocks.ts +104 -0
- package/src/injectors/__tests__/model-list-injector.test.ts +139 -0
- package/src/injectors/__tests__/subagent-list-injector.test.ts +59 -69
- package/src/injectors/__tests__/workflow-list-injector.test.ts +57 -67
- package/src/injectors/model-list-injector.ts +138 -0
- package/src/injectors/subagent-list-injector.ts +33 -24
- package/src/injectors/workflow-list-injector.ts +16 -25
- package/src/orchestration/config-loader.ts +5 -1
- package/src/orchestration/models/types.ts +1 -1
- package/src/shared/__tests__/resource-discovery.test.ts +276 -5
- package/src/shared/resource-discovery.ts +93 -6
- package/src/shared/xml-injection.ts +35 -0
|
@@ -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
|
|
101
|
-
*
|
|
102
|
-
* Map.set
|
|
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
|
|
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 (
|
|
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
|
-
/**
|
|
144
|
-
function
|
|
145
|
-
return
|
|
146
|
-
.replace(/&/g, "&")
|
|
147
|
-
.replace(/</g, "<")
|
|
148
|
-
.replace(/>/g, ">")
|
|
149
|
-
.replace(/"/g, """)
|
|
150
|
-
.replace(/'/g, "'");
|
|
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
|
|
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
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
|
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, "&")
|
|
144
|
-
.replace(/</g, "<")
|
|
145
|
-
.replace(/>/g, ">")
|
|
146
|
-
.replace(/"/g, """)
|
|
147
|
-
.replace(/'/g, "'");
|
|
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
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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-
|
|
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
|