@zhushanwen/pi-subagent-workflow 8.1.0 → 8.2.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 +12 -8
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/notifier-golden-snapshots.json +53 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +1 -1
- package/src/execution/__tests__/dialog-queue.test.ts +1 -1
- package/src/execution/__tests__/host-mode.test.ts +1 -1
- 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__/spawn-event-adapter-rpc.test.ts +1 -1
- package/src/execution/__tests__/status-refactor.test.ts +5 -5
- package/src/execution/__tests__/ui-channels.test.ts +1 -1
- package/src/execution/__tests__/ui-interaction-model.test.ts +1 -1
- package/src/execution/__tests__/ui-request-observability.test.ts +1 -1
- package/src/execution/notifier.ts +182 -222
- package/src/execution/session-runner.ts +1 -1
- package/src/execution/subagent-service.ts +11 -4
- 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/__tests__/workflows-e2e.test.ts +2 -2
- package/src/orchestration/config-loader.ts +5 -1
- package/src/orchestration/models/types.ts +1 -1
- package/src/shared/__tests__/resource-discovery.test.ts +79 -0
- package/src/shared/resource-discovery.ts +38 -0
- package/src/shared/xml-injection.ts +35 -0
- package/workflows/review-fix-loop-utils.cjs +1 -1
|
@@ -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
|
/**
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* registry 绕过说明(见末尾 notes):
|
|
22
22
|
* WorkflowScriptRegistryImpl(config) 的扫描源是固定约定目录(.pi/workflows 等),
|
|
23
|
-
* 无法指向 extensions/subagent-workflow/workflows/。为不改源码,这里直接读 .js 文件
|
|
23
|
+
* 无法指向 extensions/universal/subagent-workflow/workflows/。为不改源码,这里直接读 .js 文件
|
|
24
24
|
* 内容 + 手动构造 WorkflowScript 对象,包装为一个满足 WorkflowScriptRegistry 接口
|
|
25
25
|
* 的自定义 registry(loadWorkflowsFromDir)。
|
|
26
26
|
*/
|
|
@@ -48,7 +48,7 @@ import type { WorkflowScriptRegistry } from "../models/workflow-script-registry.
|
|
|
48
48
|
import { WorkerHostImpl } from "../worker-host.ts";
|
|
49
49
|
|
|
50
50
|
// ── 路径:定位真实 workflows 目录 ─────────────────────────────────────────
|
|
51
|
-
// 本测试文件在 src/orchestration/__tests__/,workflows 目录在 extensions/subagent-workflow/workflows/
|
|
51
|
+
// 本测试文件在 src/orchestration/__tests__/,workflows 目录在 extensions/universal/subagent-workflow/workflows/
|
|
52
52
|
// 即 __dirname → .. (orchestration) → .. (src) → .. (subagent-workflow) → workflows
|
|
53
53
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
54
54
|
const WORKFLOWS_DIR = join(__dirname, "..", "..", "..", "workflows");
|
|
@@ -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
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
processPackageSync,
|
|
24
24
|
getCachedFile,
|
|
25
25
|
getCachedFileContent,
|
|
26
|
+
getCachedParsed,
|
|
27
|
+
clearFileCache,
|
|
26
28
|
} from "../resource-discovery.ts";
|
|
27
29
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
28
30
|
|
|
@@ -463,3 +465,80 @@ describe("m5: 统一 mtime 缓存层", () => {
|
|
|
463
465
|
}
|
|
464
466
|
});
|
|
465
467
|
});
|
|
468
|
+
|
|
469
|
+
// ── KV-cache 稳定性改造:解析结果缓存 getCachedParsed ──
|
|
470
|
+
|
|
471
|
+
describe("getCachedParsed(mtime 级解析缓存)", () => {
|
|
472
|
+
beforeEach(() => {
|
|
473
|
+
clearFileCache();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("mtime 未变时 parse 只跑一次(缓存解析结果)", () => {
|
|
477
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "parsed-cache-"));
|
|
478
|
+
const f = path.join(dir, "a.md");
|
|
479
|
+
fs.writeFileSync(f, "---\nname: x\ndescription: y\n---", "utf-8");
|
|
480
|
+
try {
|
|
481
|
+
const parse = vi.fn((content: string) => (content.includes("name: x") ? "OK" : "BAD"));
|
|
482
|
+
const first = getCachedParsed(f, parse);
|
|
483
|
+
const second = getCachedParsed(f, parse);
|
|
484
|
+
expect(first).toBe("OK");
|
|
485
|
+
expect(second).toBe("OK");
|
|
486
|
+
expect(parse).toHaveBeenCalledTimes(1); // 第二次命中缓存,不重 parse
|
|
487
|
+
} finally {
|
|
488
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("mtime 变后重新 parse;文件删除后返回 null 并驱逐", () => {
|
|
493
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "parsed-cache2-"));
|
|
494
|
+
const f = path.join(dir, "a.md");
|
|
495
|
+
fs.writeFileSync(f, "v1", "utf-8");
|
|
496
|
+
try {
|
|
497
|
+
const parse = (content: string) => content;
|
|
498
|
+
expect(getCachedParsed(f, parse)).toBe("v1");
|
|
499
|
+
fs.writeFileSync(f, "v2", "utf-8");
|
|
500
|
+
expect(getCachedParsed(f, parse)).toBe("v2"); // mtime 变 → 重新 parse
|
|
501
|
+
fs.rmSync(f);
|
|
502
|
+
expect(getCachedParsed(f, parse)).toBeNull(); // 删除 → null
|
|
503
|
+
} finally {
|
|
504
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("clearFileCache 同时清空解析缓存", () => {
|
|
509
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "parsed-cache3-"));
|
|
510
|
+
const f = path.join(dir, "a.md");
|
|
511
|
+
fs.writeFileSync(f, "content", "utf-8");
|
|
512
|
+
try {
|
|
513
|
+
const parse = vi.fn(() => "OK");
|
|
514
|
+
getCachedParsed(f, parse);
|
|
515
|
+
clearFileCache();
|
|
516
|
+
getCachedParsed(f, parse);
|
|
517
|
+
expect(parse).toHaveBeenCalledTimes(2); // 缓存被清 → 重新 parse
|
|
518
|
+
} finally {
|
|
519
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("同一 path 的不同 parse 各自独立缓存(缓存键含 parse 身份,防跨 parse 污染)", () => {
|
|
524
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "parsed-cache4-"));
|
|
525
|
+
const f = path.join(dir, "a.md");
|
|
526
|
+
fs.writeFileSync(f, "shared-content", "utf-8");
|
|
527
|
+
try {
|
|
528
|
+
// 模拟真实双 parse 场景:parseAgentFrontmatter vs parseWorkflowMeta 对同一
|
|
529
|
+
// path(agent 与 workflow 发现源理论上可命中同一路径)各自解析
|
|
530
|
+
const parseA = (content: string) => ({ kind: "agent" as const, content });
|
|
531
|
+
const parseW = (content: string) => ({ kind: "workflow" as const, len: content.length });
|
|
532
|
+
const a1 = getCachedParsed(f, parseA);
|
|
533
|
+
// 修复前:缓存键只有 path,这里会命中 parseA 的缓存条目并 as T 断言返回
|
|
534
|
+
// {kind:"agent"}——w1 被污染成错误类型
|
|
535
|
+
const w1 = getCachedParsed(f, parseW);
|
|
536
|
+
const a2 = getCachedParsed(f, parseA);
|
|
537
|
+
expect(a1).toEqual({ kind: "agent", content: "shared-content" });
|
|
538
|
+
expect(w1).toEqual({ kind: "workflow", len: 14 });
|
|
539
|
+
expect(a2).toEqual({ kind: "agent", content: "shared-content" });
|
|
540
|
+
} finally {
|
|
541
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
});
|
|
@@ -174,11 +174,49 @@ export function getCachedFileContent(filePath: string): string | null {
|
|
|
174
174
|
return getCachedFile(filePath)?.content ?? null;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// [perf] 解析结果缓存(KV-cache 稳定性改造):外层 key = parse 函数身份,内层 key =
|
|
178
|
+
// path,value = { mtimeMs, parsed }。key 含 parse 身份是正确性要求——同一 path 可能被
|
|
179
|
+
// 不同 parse(agent frontmatter vs workflow meta)解析,单层 path key 会跨 parse 类型
|
|
180
|
+
// 互相污染缓存(先 parse 的结果被 as T 断言返回)。用普通 Map 而非 WeakMap:
|
|
181
|
+
// clearFileCache 需全量清空(测试隔离),WeakMap 不可遍历;parse 函数均为模块级
|
|
182
|
+
// 常量,强引用无泄漏。复用 getCachedFile 的 mtime 判变——mtime 未变时跳过 parse
|
|
183
|
+
// (frontmatter YAML 解析是重建发现时最大的可省 CPU 项)。parse 的确定性结果(含
|
|
184
|
+
// null,如 frontmatter 非法)均可缓存:同一 content 必然解析出同一结果。失效与
|
|
185
|
+
// mtimeCache 同步(clearFileCache)。
|
|
186
|
+
const parsedCache = new Map<
|
|
187
|
+
(content: string) => unknown,
|
|
188
|
+
Map<string, { mtimeMs: number; parsed: unknown }>
|
|
189
|
+
>();
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* mtime 级解析结果缓存:mtime 未变返回缓存 parsed,变则经 getCachedFile 取 content
|
|
193
|
+
* 重新 parse 并缓存。文件不存在/不可读 → null(并驱逐条目)。缓存按 parse 函数隔离
|
|
194
|
+
* ——同一 path 的不同 parse 互不污染。
|
|
195
|
+
*/
|
|
196
|
+
export function getCachedParsed<T>(filePath: string, parse: (content: string) => T): T | null {
|
|
197
|
+
const file = getCachedFile(filePath);
|
|
198
|
+
let perParse = parsedCache.get(parse);
|
|
199
|
+
if (!file) {
|
|
200
|
+
perParse?.delete(filePath);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
if (!perParse) {
|
|
204
|
+
perParse = new Map();
|
|
205
|
+
parsedCache.set(parse, perParse);
|
|
206
|
+
}
|
|
207
|
+
const entry = perParse.get(filePath);
|
|
208
|
+
if (entry && entry.mtimeMs === file.mtimeMs) return entry.parsed as T;
|
|
209
|
+
const parsed = parse(file.content);
|
|
210
|
+
perParse.set(filePath, { mtimeMs: file.mtimeMs, parsed });
|
|
211
|
+
return parsed;
|
|
212
|
+
}
|
|
213
|
+
|
|
177
214
|
/** 清空(invalidateCache 语义——测试隔离 + mtime 漏判场景手动刷新兜底)。 */
|
|
178
215
|
export function clearFileCache(): void {
|
|
179
216
|
mtimeCache.clear();
|
|
180
217
|
workspaceRootCache.clear();
|
|
181
218
|
manifestCache.clear();
|
|
219
|
+
for (const perParse of parsedCache.values()) perParse.clear();
|
|
182
220
|
}
|
|
183
221
|
|
|
184
222
|
export function findWorkspaceRoot(cwd?: string): string {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/shared/xml-injection.ts
|
|
2
|
+
//
|
|
3
|
+
// XML 注入段渲染共享原语——subagent / workflow / model 三个 injector 的 format
|
|
4
|
+
// 函数曾是三份手写同构(escapeXml 逐字重复 + 同一段落骨架),提取此模块消除重复。
|
|
5
|
+
// 调用方保留各自的排序契约与条目渲染(字段差异大,不强行归一)。
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 转义 XML 特殊字符(注入段进每 turn system prompt,内容含 < > & 等会破坏
|
|
9
|
+
* XML 结构——全部字段过一遍转义防注入段破碎)。
|
|
10
|
+
*/
|
|
11
|
+
export function escapeXml(str: string): string {
|
|
12
|
+
return str
|
|
13
|
+
.replace(/&/g, "&")
|
|
14
|
+
.replace(/</g, "<")
|
|
15
|
+
.replace(/>/g, ">")
|
|
16
|
+
.replace(/"/g, """)
|
|
17
|
+
.replace(/'/g, "'");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* XML 注入段渲染骨架:`"\n\n<tag>"` 前导(衔接宿主 prompt 末尾)+ 引导语 +
|
|
22
|
+
* 条目行 + 闭合标签,以 "\n" join。空条目返回空串(不注入)。
|
|
23
|
+
*
|
|
24
|
+
* 三个 injector 共用此骨架保证段落结构逐字节同构;KV-cache 契约(顺序稳定 =
|
|
25
|
+
* 注入段字节稳定)由调用方排序保证,本函数不重排。
|
|
26
|
+
*/
|
|
27
|
+
export function renderXmlSection(section: {
|
|
28
|
+
tag: string;
|
|
29
|
+
guide: string;
|
|
30
|
+
items: string[];
|
|
31
|
+
}): string {
|
|
32
|
+
if (section.items.length === 0) return "";
|
|
33
|
+
const lines = [`\n\n<${section.tag}>`, section.guide, ...section.items, `</${section.tag}>`];
|
|
34
|
+
return lines.join("\n");
|
|
35
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// review-fix-loop-utils.cjs — review-fix-loop.js 的可测纯函数模块
|
|
2
2
|
//
|
|
3
3
|
// workflow 编排逻辑的纯函数抽到独立 .cjs,
|
|
4
|
-
// 供 vitest 单测直接 require(extensions/subagent-workflow/src/__tests__/review-fix-loop-utils.test.ts)
|
|
4
|
+
// 供 vitest 单测直接 require(extensions/universal/subagent-workflow/src/__tests__/review-fix-loop-utils.test.ts)
|
|
5
5
|
// 与 worker 运行时共用(review-fix-loop.js 经 workerData.scriptPath 定位本文件)。
|
|
6
6
|
//
|
|
7
7
|
// 本文件不依赖 workflow 全局($ARGS/agent/parallel/phase/log),所有需要报错的函数
|