@zhushanwen/pi-subagent-workflow 9.3.0 → 9.4.1
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 +10 -10
- package/src/host/pi-host.ts +9 -6
- package/src/index.ts +5 -2
- package/src/interface/command/__tests__/commands-resume.test.ts +53 -8
- package/src/interface/command/__tests__/subagent-model.test.ts +242 -0
- package/src/interface/command/command-actions.ts +6 -5
- package/src/interface/command/commands.ts +157 -57
- package/src/interface/command/subagent-model.ts +268 -0
- package/src/interface/tool/__tests__/tool-workflow-resume.test.ts +20 -0
- package/src/interface/tool/tool-workflow.ts +33 -4
- package/src/jsonl-run-store.ts +137 -49
- package/src/session-lifecycle.ts +11 -7
- package/src/workflow-events.ts +36 -0
|
@@ -24,7 +24,13 @@ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-wor
|
|
|
24
24
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
25
25
|
|
|
26
26
|
import type { LauncherDeps } from "@zhushanwen/subagent-core";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
abortRun,
|
|
29
|
+
assertModelInCatalog,
|
|
30
|
+
getModelConfigService,
|
|
31
|
+
getSubagentService,
|
|
32
|
+
resumeRun,
|
|
33
|
+
} from "@zhushanwen/subagent-core";
|
|
28
34
|
import type { WorkflowRun } from "@zhushanwen/subagent-core";
|
|
29
35
|
import { parseWorkflowRpcCommand, type WorkflowRpcAction } from "./command-actions.ts";
|
|
30
36
|
import { createWorkflowsView, type ViewActions } from "../tui/views/WorkflowsView.ts";
|
|
@@ -45,6 +51,13 @@ const STATUS_ORDER: Record<string, number> = {
|
|
|
45
51
|
/** 未知 status 的默认排序权重(排在已知 status 之后)。 */
|
|
46
52
|
const UNKNOWN_STATUS_WEIGHT = 9;
|
|
47
53
|
|
|
54
|
+
/** RPC 非 lifecycle 通知 action 词表(abort/resume 之外的三个纯 notify 分支——
|
|
55
|
+
* rpcSideNotify 的穷举域;abort/resume 在 handleRpcMode 内 if 早退)。 */
|
|
56
|
+
type WorkflowRpcSideAction = Extract<
|
|
57
|
+
WorkflowRpcAction,
|
|
58
|
+
{ action: "lifecycle-removed" | "lifecycle-missing-id" | "noop" }
|
|
59
|
+
>;
|
|
60
|
+
|
|
48
61
|
// ── /workflows command ───────────────────────────────────────
|
|
49
62
|
|
|
50
63
|
/**
|
|
@@ -123,37 +136,7 @@ export function registerWorkflowsCommand(
|
|
|
123
136
|
return;
|
|
124
137
|
}
|
|
125
138
|
|
|
126
|
-
|
|
127
|
-
// (abort 动词在 TUI 侧无既有通道,不在此顺手扩——resume 是本批接入面)
|
|
128
|
-
const trimmedArgs = args.trim();
|
|
129
|
-
const [verb, ...rest] = trimmedArgs.split(/\s+/).filter(Boolean);
|
|
130
|
-
if (verb === "resume") {
|
|
131
|
-
const runId = rest[0];
|
|
132
|
-
if (!runId) {
|
|
133
|
-
ctx.ui.notify("Usage: /workflows resume <runId>", "warning");
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
try {
|
|
137
|
-
await resumeRun(runId, deps);
|
|
138
|
-
ctx.ui.notify(
|
|
139
|
-
`Workflow ${runId}: resuming — completed calls replay at zero token cost, unfinished calls re-dispatched`,
|
|
140
|
-
"info",
|
|
141
|
-
);
|
|
142
|
-
} catch (err) {
|
|
143
|
-
ctx.ui.notify(`Failed to resume workflow ${runId}: ${toErrorMessage(err)}`, "warning");
|
|
144
|
-
}
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// 直接按 runId / 前缀匹配打开
|
|
149
|
-
const directRunId = trimmedArgs;
|
|
150
|
-
if (directRunId) {
|
|
151
|
-
await openByRunId(directRunId, getRuns, ctx, deps);
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// 无参——列表选择
|
|
156
|
-
await openFromList(getRuns, ctx, deps);
|
|
139
|
+
await handleTuiCommand(args, ctx, getRuns, deps);
|
|
157
140
|
},
|
|
158
141
|
});
|
|
159
142
|
}
|
|
@@ -172,37 +155,36 @@ export function registerWorkflowsCommand(
|
|
|
172
155
|
* (F3 定稿——提示语义优先于 missing-id:run 一次性生命周期后不可挂起)
|
|
173
156
|
* - lifecycle-missing-id → Usage 提示
|
|
174
157
|
* - noop → 无 action 或未知 action:GUI 端已屏蔽此 command 入口,此处兜底
|
|
158
|
+
*
|
|
159
|
+
* 分派形态:两个 lifecycle 动作 if 早退(控制流收窄,零断言),其余三个纯
|
|
160
|
+
* notify action 收敛进 rpcSideNotify 的穷举 switch。
|
|
175
161
|
*/
|
|
176
162
|
async function handleRpcMode(
|
|
177
163
|
parsed: WorkflowRpcAction,
|
|
178
164
|
ctx: ExtensionCommandContext,
|
|
179
165
|
deps: LauncherDeps,
|
|
180
166
|
): Promise<void> {
|
|
167
|
+
if (parsed.action === "abort") {
|
|
168
|
+
await abortWithNotify(parsed.runId, deps, ctx);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (parsed.action === "resume") {
|
|
172
|
+
await resumeWithNotify(parsed.runId, parsed.model, deps, ctx);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
rpcSideNotify(parsed, ctx);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 非 lifecycle RPC action(lifecycle-removed / lifecycle-missing-id / noop)的
|
|
180
|
+
* 穷举 notify 分派。default 保留 exhaustiveness 断言:未来新增 action verb 忘加
|
|
181
|
+
* case 时 tsc 报错。
|
|
182
|
+
*/
|
|
183
|
+
function rpcSideNotify(
|
|
184
|
+
parsed: WorkflowRpcSideAction,
|
|
185
|
+
ctx: ExtensionCommandContext,
|
|
186
|
+
): void {
|
|
181
187
|
switch (parsed.action) {
|
|
182
|
-
case "abort": {
|
|
183
|
-
try {
|
|
184
|
-
await abortRun(parsed.runId, deps);
|
|
185
|
-
// abort 单态后 pastTense 固定(原三态拼接随 pause/resume 删除)
|
|
186
|
-
ctx.ui.notify(`Workflow ${parsed.runId}: aborted`, "info");
|
|
187
|
-
} catch (err) {
|
|
188
|
-
const msg = toErrorMessage(err);
|
|
189
|
-
ctx.ui.notify(`Failed to abort workflow ${parsed.runId}: ${msg}`, "warning");
|
|
190
|
-
}
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
case "resume": {
|
|
194
|
-
try {
|
|
195
|
-
await resumeRun(parsed.runId, deps);
|
|
196
|
-
ctx.ui.notify(
|
|
197
|
-
`Workflow ${parsed.runId}: resuming — completed calls replay at zero token cost, unfinished calls re-dispatched`,
|
|
198
|
-
"info",
|
|
199
|
-
);
|
|
200
|
-
} catch (err) {
|
|
201
|
-
const msg = toErrorMessage(err);
|
|
202
|
-
ctx.ui.notify(`Failed to resume workflow ${parsed.runId}: ${msg}`, "warning");
|
|
203
|
-
}
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
188
|
case "lifecycle-removed":
|
|
207
189
|
ctx.ui.notify(
|
|
208
190
|
`Workflow ${parsed.verb} has been removed — runs are one-shot. To stop a run early: /workflows abort <runId>`,
|
|
@@ -210,7 +192,9 @@ async function handleRpcMode(
|
|
|
210
192
|
);
|
|
211
193
|
return;
|
|
212
194
|
case "lifecycle-missing-id":
|
|
213
|
-
|
|
195
|
+
// Usage 按 verb 区分(与 TUI verb 分支同文案——两模式统一):resume 的可选
|
|
196
|
+
// [model] 参数要出现在提示里,GUI 侧缺 runId 时得到的指引不指向旧用法。
|
|
197
|
+
ctx.ui.notify(missingIdUsageText(parsed.verb), "warning");
|
|
214
198
|
return;
|
|
215
199
|
case "noop":
|
|
216
200
|
ctx.ui.notify("View workflows in the composer task tray", "info");
|
|
@@ -223,6 +207,122 @@ async function handleRpcMode(
|
|
|
223
207
|
}
|
|
224
208
|
}
|
|
225
209
|
|
|
210
|
+
/** lifecycle-missing-id 的 Usage 文案:resume 带可选 [model] 参数,其余 verb 仅 runId。 */
|
|
211
|
+
function missingIdUsageText(verb: "abort" | "resume"): string {
|
|
212
|
+
return verb === "resume" ? "Usage: /workflows resume <runId> [model]" : `Usage: /workflows ${verb} <runId>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** abort 执行 + 结果通知(成功/失败均 notify,不向上抛——RPC 通道唯一 abort 入口)。 */
|
|
216
|
+
async function abortWithNotify(
|
|
217
|
+
runId: string,
|
|
218
|
+
deps: LauncherDeps,
|
|
219
|
+
ctx: ExtensionCommandContext,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
try {
|
|
222
|
+
await abortRun(runId, deps);
|
|
223
|
+
// abort 单态后 pastTense 固定(原三态拼接随 pause/resume 删除)
|
|
224
|
+
ctx.ui.notify(`Workflow ${runId}: aborted`, "info");
|
|
225
|
+
} catch (err) {
|
|
226
|
+
ctx.ui.notify(`Failed to abort workflow ${runId}: ${toErrorMessage(err)}`, "warning");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* resume 执行 + 结果通知(TUI verb / RPC verb 两通道共享——同语义同文案,防
|
|
232
|
+
* 双处文案漂移)。显式 model 先做目录预检(assertResumeModelInCatalog),成功/
|
|
233
|
+
* 失败均 notify(不向上抛)。
|
|
234
|
+
*/
|
|
235
|
+
async function resumeWithNotify(
|
|
236
|
+
runId: string,
|
|
237
|
+
model: string | undefined,
|
|
238
|
+
deps: LauncherDeps,
|
|
239
|
+
ctx: ExtensionCommandContext,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
try {
|
|
242
|
+
if (model !== undefined) assertResumeModelInCatalog(model);
|
|
243
|
+
await resumeRun(runId, deps, ...resumeModelArgs(model));
|
|
244
|
+
ctx.ui.notify(resumeStartedText(runId, model), "info");
|
|
245
|
+
} catch (err) {
|
|
246
|
+
ctx.ui.notify(`Failed to resume workflow ${runId}: ${toErrorMessage(err)}`, "warning");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 可选 model → resumeRun 变参(无覆盖 = 空变参,不造覆盖记账)。 */
|
|
251
|
+
function resumeModelArgs(model: string | undefined): [] | [{ model: string }] {
|
|
252
|
+
return model !== undefined ? [{ model }] : [];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** resume 启动通知文案(带显式 model 时回显模型,用户可确认覆盖生效)。 */
|
|
256
|
+
function resumeStartedText(runId: string, model: string | undefined): string {
|
|
257
|
+
return `Workflow ${runId}: resuming${model !== undefined ? ` with model ${model}` : ""} — completed calls replay at zero token cost, unfinished calls re-dispatched`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* TUI 模式命令分派:resume verb → 断点续跑后 notify(不打开面板);带参 → 按
|
|
262
|
+
* runId/前缀匹配打开;无参 → 列表选择。(abort 动词在 TUI 侧无既有通道,不在此
|
|
263
|
+
* 顺手扩——resume 是本批接入面)
|
|
264
|
+
*/
|
|
265
|
+
async function handleTuiCommand(
|
|
266
|
+
args: string,
|
|
267
|
+
ctx: ExtensionCommandContext,
|
|
268
|
+
getRuns: () => Map<string, WorkflowRun>,
|
|
269
|
+
deps: LauncherDeps,
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
const trimmedArgs = args.trim();
|
|
272
|
+
const [verb, ...rest] = trimmedArgs.split(/\s+/).filter(Boolean);
|
|
273
|
+
if (verb === "resume") {
|
|
274
|
+
await resumeTuiVerb(rest, ctx, deps);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// 直接按 runId / 前缀匹配打开
|
|
278
|
+
if (trimmedArgs) {
|
|
279
|
+
await openByRunId(trimmedArgs, getRuns, ctx, deps);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// 无参——列表选择
|
|
283
|
+
await openFromList(getRuns, ctx, deps);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* TUI resume verb:缺 runId 给 Usage 提示。可选第二参数 = 显式模型 canonical ref
|
|
288
|
+
* ('provider/modelId[:thinkingLevel]')——落统一覆盖记账(F1-26 后续项:与
|
|
289
|
+
* setModel 补切同通道,resume 合一步表达)。执行与通知复用 RPC 同款 resumeWithNotify。
|
|
290
|
+
*/
|
|
291
|
+
async function resumeTuiVerb(
|
|
292
|
+
rest: string[],
|
|
293
|
+
ctx: ExtensionCommandContext,
|
|
294
|
+
deps: LauncherDeps,
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
const runId = rest[0];
|
|
297
|
+
if (!runId) {
|
|
298
|
+
ctx.ui.notify("Usage: /workflows resume <runId> [model]", "warning");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
await resumeWithNotify(runId, rest[1], deps, ctx);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 命令通道显式 model 的目录预检(D8 拒单语义,与 tool 通道 run/resume action 同款
|
|
306
|
+
* ——isModelRef 语法闸在 core resumeRun,此处补目录存在性:语法合法但目录查无
|
|
307
|
+
* (如 provider 段拼写错)在写覆盖记账之前同步拒绝,不延迟到派发期,避免「记账已
|
|
308
|
+
* 落盘 + 命令已 notify 成功」的假成功形态;三通道(TUI verb / RPC verb / tool)对称)。
|
|
309
|
+
* throw 落调用方 try/catch,分类化错误文案(含可用模型清单 + 修复指引)经 notify
|
|
310
|
+
* 直达用户。单例缺席(session_start 前不可达)降级跳过 + warn 留痕——派发期
|
|
311
|
+
* identity 解析仍是权威裁决(与 tool 通道降级语义一致)。
|
|
312
|
+
*/
|
|
313
|
+
function assertResumeModelInCatalog(model: string): void {
|
|
314
|
+
const modelService = getModelConfigService();
|
|
315
|
+
if (modelService === null) {
|
|
316
|
+
getLogger("subagent-workflow").warn(
|
|
317
|
+
"[commands] model catalog unavailable (model service not initialized) — resume model catalog check skipped; dispatch-time identity resolution remains authoritative",
|
|
318
|
+
);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
assertModelInCatalog(model, modelService.getModelRegistry(), {
|
|
322
|
+
source: "resume model override",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
226
326
|
/**
|
|
227
327
|
* TUI 模式:按 runId 直接打开对应 run 的 view(精确匹配优先,前缀唯一匹配次之)。
|
|
228
328
|
* 均未命中 → error notify。
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// src/interface/command/subagent-model.ts
|
|
2
|
+
//
|
|
3
|
+
// /subagent-model 命令——subagent/workflow run 执行模型切换的宿主触达通道消费端
|
|
4
|
+
// (subagent-model-switch §7.1.1 通道,U6)。内部 RPC 命令:runtime 网关适配器经
|
|
5
|
+
// client.prompt("/subagent-model <单行JSON>") 短路本 handler(不经主 agent LLM,
|
|
6
|
+
// pi _tryExecuteExtensionCommand 主路径对 / 前缀先行执行)。
|
|
7
|
+
//
|
|
8
|
+
// 通道契约(§7.1.1 四要素):
|
|
9
|
+
// - 载荷 = 单行 JSON {requestId, recordId?|runId?, provider, modelId, thinkingLevel?};
|
|
10
|
+
// - 执行 = 本进程直调宿主 setModel 编排(service.setModel,§7.2 单一三步);
|
|
11
|
+
// - 回执 = 一切结果(SetModelReply 三 scope / 错误 envelope)写结果文件
|
|
12
|
+
// `<subagent 数据根>/model-switch/<requestId>.json` 后正常返回——**全程不抛**;
|
|
13
|
+
// runtime 出站点在 handler 返回后读文件一次(写后读,无轮询无监视)。
|
|
14
|
+
// - 结果文件承载 core SetModelReply 形状(scope 判别)——extension 不依赖
|
|
15
|
+
// @taiji/shared,wire 三形态映射归 runtime 适配器(依赖方向的唯一合法位置)。
|
|
16
|
+
//
|
|
17
|
+
// TUI 分支不存在:本命令是 GUI 通道专用内部命令,非交互入口(照 /subagents RPC
|
|
18
|
+
// verb 的 GUI 屏蔽先例,TUI 手敲仅得指引提示)。
|
|
19
|
+
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
|
|
22
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
ENV_ROOT_CWD,
|
|
27
|
+
getSubagentModelSwitchResultsDir,
|
|
28
|
+
getSubagentService,
|
|
29
|
+
writeAtomicFileSync,
|
|
30
|
+
} from "@zhushanwen/subagent-core";
|
|
31
|
+
import type { ModelSwitchTarget, SetModelReply } from "@zhushanwen/subagent-core";
|
|
32
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
33
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
34
|
+
|
|
35
|
+
const logger = getLogger("subagents");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* requestId 文件名白名单:`[\w-]{1,128}`(与 core record id 白名单同式——防路径穿越;
|
|
39
|
+
* 生产源 = runtime randomUUID,字符集 ⊆ 白名单,本式仅防御畸形通道输入)。
|
|
40
|
+
*/
|
|
41
|
+
const REQUEST_ID_PATTERN = /^[\w-]{1,128}$/;
|
|
42
|
+
|
|
43
|
+
/** 错误 envelope 形状(结果文件内的失败承载;code 值域 = 本文件错误分型常量族)。
|
|
44
|
+
* scope: "error" = 网关 mapResultFileToWireReply 的分派判别键(D3-A4 缺陷修复:
|
|
45
|
+
* 缺 scope 时域内失败被「scope 未知」通道错误吞掉,真实 code/recovery 丢失)。 */
|
|
46
|
+
interface SubagentModelEnvelope { // oe-exempt:20261006:framework:通道结果信封契约形状(单命令单载体,单实现常态)
|
|
47
|
+
scope: "error";
|
|
48
|
+
error: { code: string; message: string; recovery: string };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** /subagent-model 命令的依赖注入面(注册点装配生产实现,单测注入桩)。 */
|
|
52
|
+
export interface SubagentModelCommandDeps { // oe-exempt:20261006:framework:命令 handler 依赖注入面(注册点装配,单实现常态)
|
|
53
|
+
/** 宿主 setModel 编排单例(session_start 前 null——envelope 分型承接)。 */
|
|
54
|
+
getService: () => ReturnType<typeof getSubagentService>;
|
|
55
|
+
/** agentDir 锚(pi SDK 活源,与 session-lifecycle 装配同源)。 */
|
|
56
|
+
resolveAgentDir: () => string;
|
|
57
|
+
/** 树根主 cwd(session-baselines rootCwd 同式推导,见 registerSubagentModelCommand)。 */
|
|
58
|
+
resolveRootCwd: () => string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 载荷解析三态(显式判别:无 requestId = 无法命名结果文件,与字段校验失败分流)。 */
|
|
62
|
+
type ParsedPayload =
|
|
63
|
+
| { ok: true; requestId: string; payload: SubagentModelPayload }
|
|
64
|
+
| { ok: false; requestIdMissing: true }
|
|
65
|
+
| { ok: false; requestIdMissing: false; requestId: string; validationMessage: string };
|
|
66
|
+
|
|
67
|
+
/** 载荷结构(wire 契约 §7.1.1:recordId/runId 二选一,provider/modelId 必填)。
|
|
68
|
+
* 字段经逐项 typeof 抽取组装(JSON 边界不落地 unknown 断言)。 */
|
|
69
|
+
interface SubagentModelPayload { // oe-exempt:20261006:framework:通道载荷契约形状(单命令单载体,单实现常态)
|
|
70
|
+
requestId: string;
|
|
71
|
+
recordId?: string;
|
|
72
|
+
runId?: string;
|
|
73
|
+
provider?: string;
|
|
74
|
+
modelId?: string;
|
|
75
|
+
thinkingLevel?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 非空字符串字段抽取(undefined = 缺失或类型不符,二者同判缺失)。 */
|
|
79
|
+
function nonEmptyString(v: unknown): string | undefined {
|
|
80
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 解析单行 JSON 载荷(§7.1.1 要素 1)。requestId 缺失/非法 → requestIdMissing(无法
|
|
85
|
+
* 命名结果文件,本侧零回执——runtime 出站点超时路径承接通道失败,仅日志留痕);
|
|
86
|
+
* 其余字段校验失败在 requestId 在场时落 envelope(invalid_payload 分型)。
|
|
87
|
+
*/
|
|
88
|
+
function parsePayload(argsStr: string): ParsedPayload {
|
|
89
|
+
let raw: unknown;
|
|
90
|
+
try {
|
|
91
|
+
raw = JSON.parse(argsStr.trim());
|
|
92
|
+
} catch {
|
|
93
|
+
return { ok: false, requestIdMissing: true };
|
|
94
|
+
}
|
|
95
|
+
if (typeof raw !== "object" || raw === null) return { ok: false, requestIdMissing: true };
|
|
96
|
+
const obj = raw as Record<string, unknown>;
|
|
97
|
+
const requestId = nonEmptyString(obj.requestId);
|
|
98
|
+
if (requestId === undefined || !REQUEST_ID_PATTERN.test(requestId)) {
|
|
99
|
+
return { ok: false, requestIdMissing: true };
|
|
100
|
+
}
|
|
101
|
+
const payload: SubagentModelPayload = {
|
|
102
|
+
requestId,
|
|
103
|
+
recordId: nonEmptyString(obj.recordId),
|
|
104
|
+
runId: nonEmptyString(obj.runId),
|
|
105
|
+
provider: nonEmptyString(obj.provider),
|
|
106
|
+
modelId: nonEmptyString(obj.modelId),
|
|
107
|
+
thinkingLevel: nonEmptyString(obj.thinkingLevel),
|
|
108
|
+
};
|
|
109
|
+
const validationMessage = validatePayload(payload);
|
|
110
|
+
if (validationMessage !== undefined) {
|
|
111
|
+
return { ok: false, requestIdMissing: false, requestId, validationMessage };
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, requestId, payload };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 载荷字段校验(requestId 之外的域内畸形 → envelope invalid_payload 的 message 文案)。 */
|
|
117
|
+
function validatePayload(payload: SubagentModelPayload): string | undefined {
|
|
118
|
+
if ((payload.recordId !== undefined) === (payload.runId !== undefined)) {
|
|
119
|
+
return "payload 需要 recordId(chat 域)或 runId(workflow run 级)二选一";
|
|
120
|
+
}
|
|
121
|
+
if (payload.provider === undefined) {
|
|
122
|
+
return "payload 缺 provider(canonical ref 的 provider 段)";
|
|
123
|
+
}
|
|
124
|
+
if (payload.modelId === undefined) {
|
|
125
|
+
return "payload 缺 modelId(canonical ref 的 modelId 段)";
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 结果文件写入(唯一出站原语,永不抛):writeAtomicFileSync(tmp+rename,防 runtime
|
|
132
|
+
* 读到半文件)+ 目录递归建。写失败 = 通道回执丢失(runtime 超时路径承接),日志留痕。
|
|
133
|
+
*/
|
|
134
|
+
function writeResultFile(resultsFile: string, content: string): void {
|
|
135
|
+
try {
|
|
136
|
+
writeAtomicFileSync(resultsFile, content);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
logger.warn(`[subagent-model] result file write failed (runtime will report channel timeout): ${toErrorMessage(err)}`, {
|
|
139
|
+
resultsFile,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 提取引擎分型错误码(EngineError 载荷 .code——分型值域校验交由 runtime 侧)。 */
|
|
145
|
+
function errorCodeOf(err: unknown): string | undefined {
|
|
146
|
+
// `in` 收窄(TS 4.9+):Error & Record<"code", unknown>,零断言读载荷。
|
|
147
|
+
if (!(err instanceof Error) || !("code" in err)) return undefined;
|
|
148
|
+
const code: unknown = err.code;
|
|
149
|
+
return typeof code === "string" && code !== "" ? code : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* RPC 执行体(§7.1.1 要素 2):payload 校验 → 宿主 setModel → 结果文件。
|
|
154
|
+
* **全程不抛**——一切异常 catch 后落错误 envelope;校验失败同落 envelope。
|
|
155
|
+
*/
|
|
156
|
+
export async function runSubagentModelRpc(
|
|
157
|
+
deps: SubagentModelCommandDeps,
|
|
158
|
+
argsStr: string,
|
|
159
|
+
ctx: ExtensionCommandContext,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
const parsed = parsePayload(argsStr);
|
|
162
|
+
if (!parsed.ok) {
|
|
163
|
+
if (parsed.requestIdMissing) {
|
|
164
|
+
// requestId 不可用 = 无关联回执面:日志留痕即可,runtime 超时按通道失败应答
|
|
165
|
+
//(结果文件名 = requestId,载荷缺失时本侧无从落文件)。
|
|
166
|
+
logger.warn("[subagent-model] payload unparseable or requestId missing — no result file, runtime timeout path will report");
|
|
167
|
+
ctx.ui.notify("/subagent-model payload requires requestId — request dropped", "warning");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// requestId 在场 + 字段校验失败:envelope 落盘(invalid_payload 分型)。
|
|
171
|
+
const invalidFile = path.join(
|
|
172
|
+
getSubagentModelSwitchResultsDir(deps.resolveAgentDir(), deps.resolveRootCwd()),
|
|
173
|
+
`${parsed.requestId}.json`,
|
|
174
|
+
);
|
|
175
|
+
writeResultFile(
|
|
176
|
+
invalidFile,
|
|
177
|
+
JSON.stringify({
|
|
178
|
+
scope: "error",
|
|
179
|
+
error: {
|
|
180
|
+
code: "invalid_payload",
|
|
181
|
+
message: parsed.validationMessage,
|
|
182
|
+
recovery: "修正 subagent.setModel 载荷后重试(runtime 协议面问题,正常使用不可达)",
|
|
183
|
+
},
|
|
184
|
+
} satisfies SubagentModelEnvelope),
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const { requestId, payload } = parsed;
|
|
189
|
+
const resultsFile = path.join(
|
|
190
|
+
getSubagentModelSwitchResultsDir(deps.resolveAgentDir(), deps.resolveRootCwd()),
|
|
191
|
+
`${requestId}.json`,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const service = deps.getService();
|
|
196
|
+
if (!service) {
|
|
197
|
+
writeResultFile(
|
|
198
|
+
resultsFile,
|
|
199
|
+
JSON.stringify({
|
|
200
|
+
scope: "error",
|
|
201
|
+
error: {
|
|
202
|
+
code: "subagent_runtime_not_ready",
|
|
203
|
+
message: "subagent 执行运行时未就绪(session 未启动或已 dispose)",
|
|
204
|
+
recovery: "先在该会话派发一条消息启动 subagent 运行时,再重试切换",
|
|
205
|
+
},
|
|
206
|
+
} satisfies SubagentModelEnvelope),
|
|
207
|
+
);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 目标组装(§6.1 作用域分流):recordId → chat 域(getRecordForAction 同 message/close
|
|
212
|
+
// 的统一入口——含归属校验与冷查);runId → run 级(终局/成员清单校验在编排步骤①)。
|
|
213
|
+
let target: ModelSwitchTarget;
|
|
214
|
+
if (typeof payload.recordId === "string") {
|
|
215
|
+
target = { domain: "chat", record: service.chatActions.getRecordForAction(payload.recordId) };
|
|
216
|
+
} else {
|
|
217
|
+
target = { domain: "workflow-run", runId: payload.runId as string };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const thinkingLevel = payload.thinkingLevel as string | undefined;
|
|
221
|
+
const reply: SetModelReply = await service.setModel(
|
|
222
|
+
target,
|
|
223
|
+
{ provider: payload.provider as string, modelId: payload.modelId as string },
|
|
224
|
+
thinkingLevel,
|
|
225
|
+
);
|
|
226
|
+
// 应答三 scope 原样落文件(scope 判别由 runtime 适配器消费——core 形状是唯一权威)。
|
|
227
|
+
writeResultFile(resultsFile, JSON.stringify(reply));
|
|
228
|
+
} catch (err) {
|
|
229
|
+
// §7.5 通道行同族语义:handler 域内失败如实落 envelope(生效状态未知不虚构——
|
|
230
|
+
// 消息携带宿主编排错误原文,恢复动作照通道行)。
|
|
231
|
+
writeResultFile(
|
|
232
|
+
resultsFile,
|
|
233
|
+
JSON.stringify({
|
|
234
|
+
scope: "error",
|
|
235
|
+
error: {
|
|
236
|
+
code: errorCodeOf(err) ?? "subagent_model_switch_failed",
|
|
237
|
+
message: toErrorMessage(err),
|
|
238
|
+
recovery: "重试切换(覆盖替换幂等);当前生效状态以面板与记录链为准",
|
|
239
|
+
},
|
|
240
|
+
} satisfies SubagentModelEnvelope),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 注册 /subagent-model 命令(内部 RPC 通道,taiji GUI 专用)。 */
|
|
246
|
+
export function registerSubagentModelCommand(pi: ExtensionAPI): void {
|
|
247
|
+
pi.registerCommand("subagent-model", {
|
|
248
|
+
description: "Internal RPC: switch subagent/workflow-run model (single-line JSON payload)",
|
|
249
|
+
handler: async (argsStr: string, ctx: ExtensionCommandContext) => {
|
|
250
|
+
if (ctx.mode !== "rpc") {
|
|
251
|
+
ctx.ui.notify("/subagent-model is an internal taiji GUI channel — switch models from the subagent panel", "warning");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// rootCwd 与 session-baselines 构造推导同式(env PI_SUBAGENT_ROOT_CWD 贯穿 ||
|
|
255
|
+
// ctx.cwd)——保证结果目录与 records 落同一 enc 段(enc 段不变量,path-encoding
|
|
256
|
+
// 头注同族约束)。本命令只在主进程执行(子进程无 / 命令通道),env 恒缺省。
|
|
257
|
+
await runSubagentModelRpc(
|
|
258
|
+
{
|
|
259
|
+
getService: getSubagentService,
|
|
260
|
+
resolveAgentDir: getAgentDir,
|
|
261
|
+
resolveRootCwd: () => process.env[ENV_ROOT_CWD] || ctx.cwd,
|
|
262
|
+
},
|
|
263
|
+
argsStr,
|
|
264
|
+
ctx,
|
|
265
|
+
);
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
@@ -130,6 +130,26 @@ describe("actionResume", () => {
|
|
|
130
130
|
expect(options.budgetTokens).toBe(50_000);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
it("model 形参透传(F1-26 后续项:core 落统一覆盖记账——补切 + 无参 resume 合一步)", async () => {
|
|
134
|
+
const recordPath = writeRecordStream([runCreatedFrame('{"a":1}')]);
|
|
135
|
+
await actionResume(
|
|
136
|
+
{ action: "resume", runId: "wf-test", args: { a: 1 }, model: "p2/m2" } as never,
|
|
137
|
+
makeDeps(recordPath) as never,
|
|
138
|
+
);
|
|
139
|
+
const options = vi.mocked(resumeRun).mock.calls[0]![2] as { model?: string };
|
|
140
|
+
expect(options.model).toBe("p2/m2");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("model 缺省 → options 不带 model 键(无参 resume 不写覆盖记账)", async () => {
|
|
144
|
+
const recordPath = writeRecordStream([runCreatedFrame('{"a":1}')]);
|
|
145
|
+
await actionResume(
|
|
146
|
+
{ action: "resume", runId: "wf-test", args: { a: 1 } } as never,
|
|
147
|
+
makeDeps(recordPath) as never,
|
|
148
|
+
);
|
|
149
|
+
const options = vi.mocked(resumeRun).mock.calls[0]![2] as { model?: string };
|
|
150
|
+
expect("model" in options).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
133
153
|
it("tokens 缺省 → options 不带 budgetTokens 键(core 侧跳过覆盖、走三档回落)", async () => {
|
|
134
154
|
await actionResume(
|
|
135
155
|
{ action: "resume", runId: "wf-test" } as never,
|
|
@@ -114,7 +114,11 @@ const WorkflowParams = Type.Object({
|
|
|
114
114
|
Type.String({ description: "Error/reason message (optional, used with abort)" }),
|
|
115
115
|
),
|
|
116
116
|
model: Type.Optional(Type.String({
|
|
117
|
-
description:
|
|
117
|
+
description:
|
|
118
|
+
"Run-level model override in 'provider/modelId' format. When set, all agents spawned by this run inherit it by default (unless a per-call agent() opts.model is set). " +
|
|
119
|
+
"Also accepted on action:\"resume\" — recorded as the run's user override (same channel as a panel switch) and drives all re-dispatched steps. " +
|
|
120
|
+
"A user-issued runtime model override (panel model switch / /subagent-model command) takes precedence over this parameter — user override wins over any explicitly scripted model. " +
|
|
121
|
+
"Omit to inherit the main agent's model.",
|
|
118
122
|
})),
|
|
119
123
|
thinkingLevel: Type.Optional(StringEnum(THINKING_ORDER, {
|
|
120
124
|
description: "Run-level thinkingLevel override (off/minimal/low/medium/high/xhigh/max). All agents in this run inherit it by default. Omit to default each agent to its model's highest available level.",
|
|
@@ -232,14 +236,21 @@ export function registerWorkflowTool(
|
|
|
232
236
|
"completed calls from the record at zero token cost and continues where the run stopped. " +
|
|
233
237
|
"Do NOT resume a settled (done/failed/cancelled) run — start a new run instead.",
|
|
234
238
|
"resume: pass the SAME args as the original run (they are verified field-by-field; a mismatch is rejected " +
|
|
235
|
-
"with the differing fields listed). Omit args to reuse the original ones. Changed args = different intent = new run."
|
|
239
|
+
"with the differing fields listed). Omit args to reuse the original ones. Changed args = different intent = new run. " +
|
|
240
|
+
"To resume with a specific model: pass model ('provider/modelId') on the resume itself — it is recorded as the " +
|
|
241
|
+
"run's user override (same channel as a panel switch), drives all re-dispatched steps, and persists for later " +
|
|
242
|
+
"resumes without the parameter. Alternatively switch the run's model first (panel selector or /subagent-model) " +
|
|
243
|
+
"then resume without model — both are the same override channel.",
|
|
236
244
|
"Call shapes (JSON): " +
|
|
237
245
|
"- run: {\"action\":\"run\",\"name\":\"<script>\",\"args\":{...},\"tokens\":N,\"time\":N,\"model\":\"<provider/modelId>\",\"thinkingLevel\":\"<level>\"}. " +
|
|
238
246
|
"- status: {\"action\":\"status\"}. " +
|
|
239
247
|
"- abort: {\"action\":\"abort\",\"runId\":\"<id>\"} (optional: {\"error\":\"<reason>\"}). " +
|
|
240
|
-
"- resume: {\"action\":\"resume\",\"runId\":\"<id>\",\"args\":{...},\"tokens\":N,\"time\":N} — args/tokens/time optional.",
|
|
248
|
+
"- resume: {\"action\":\"resume\",\"runId\":\"<id>\",\"args\":{...},\"tokens\":N,\"time\":N,\"model\":\"<provider/modelId>\"} — args/tokens/time/model optional.",
|
|
241
249
|
"Budget: Do NOT set tokens/time unless the user explicitly requests a limit. Built-in workflows run unlimited by default.",
|
|
242
|
-
"Model/thinkingLevel: omit by default (inherit main agent's model). Only set model/thinkingLevel when the user explicitly requests a specific model or thinking depth for this run."
|
|
250
|
+
"Model/thinkingLevel: omit by default (inherit main agent's model). Only set model/thinkingLevel when the user explicitly requests a specific model or thinking depth for this run. " +
|
|
251
|
+
"Note: a user-issued runtime model override (panel switch / /subagent-model) takes precedence over the model parameter — an explicitly scripted thinkingLevel still applies " +
|
|
252
|
+
"(explicit levels outrank the override's inline level). If a run's explicitly scripted model seems ignored, " +
|
|
253
|
+
"check for an active user override (run journal model-override frame) before assuming a resolution bug.",
|
|
243
254
|
"Anti-patterns: Flattening args sub-fields (task/items/...) to the top level — they belong inside args. Calling {\"action\":\"run\"} without name.",
|
|
244
255
|
"CRITICAL: For orchestration patterns, ALWAYS use action:run with the <location> absolute " +
|
|
245
256
|
"path of the matching listed workflow — NEVER use workflow-script action:generate to recreate patterns " +
|
|
@@ -565,10 +576,28 @@ export async function actionResume(
|
|
|
565
576
|
assertEntryTimeBudget(params.time);
|
|
566
577
|
assertEntryTokenBudget(params.tokens);
|
|
567
578
|
|
|
579
|
+
// 显式 model 参数的目录预检(run action 同款 D8 拒单语义——查无同步 throw 零写,
|
|
580
|
+
// 单例缺席降级跳过 + warn,派发期 identity 解析仍是权威裁决)。
|
|
581
|
+
if (params.model !== undefined) {
|
|
582
|
+
const modelService = getModelConfigService();
|
|
583
|
+
if (modelService === null) {
|
|
584
|
+
logger.warn(
|
|
585
|
+
"[tool-workflow] model catalog unavailable (model service not initialized) — resume model catalog check skipped; dispatch-time identity resolution remains authoritative",
|
|
586
|
+
);
|
|
587
|
+
} else {
|
|
588
|
+
assertModelInCatalog(params.model, modelService.getModelRegistry(), {
|
|
589
|
+
source: "resume model override",
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
568
594
|
const options: ResumeRunOptions = {
|
|
569
595
|
...(params.time !== undefined ? { budgetTimeMs: params.time } : {}),
|
|
570
596
|
// token 预算显式覆盖(与 time 同款通道;三档回落与落盘归 core 单点)
|
|
571
597
|
...(params.tokens !== undefined ? { budgetTokens: params.tokens } : {}),
|
|
598
|
+
// [F1-26 后续项] 显式 model 透传(core 落统一覆盖记账——「补切 + 无参 resume」
|
|
599
|
+
// 的合一步;格式与目录已在本入口预检,记账落盘归 core 锁段单点)
|
|
600
|
+
...(params.model !== undefined ? { model: params.model } : {}),
|
|
572
601
|
// [§2.5] args 原样下传由 core 判定(D14 单源);journalDir 传壳的 store 同源目录,
|
|
573
602
|
// 否则 core 会按模块锚解析 record 路径——多 session 场景会静默读成「无记录」
|
|
574
603
|
//(D14 静默放行 = 安全语义反转;core 锚点与壳 store 锚点必须同源)。
|