@zhushanwen/pi-subagents 0.0.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/agents/context-builder.md +19 -0
- package/agents/oracle.md +19 -0
- package/agents/planner.md +19 -0
- package/agents/researcher.md +19 -0
- package/agents/reviewer.md +19 -0
- package/agents/scout.md +19 -0
- package/agents/worker.md +18 -0
- package/index.ts +1 -0
- package/package.json +59 -0
- package/src/commands/subagents.ts +78 -0
- package/src/core/agent-registry.ts +222 -0
- package/src/core/concurrency-pool.ts +78 -0
- package/src/core/event-bridge.ts +199 -0
- package/src/core/execution-record.ts +500 -0
- package/src/core/model-resolver.ts +206 -0
- package/src/core/output-collector.ts +118 -0
- package/src/core/path-encoding.ts +16 -0
- package/src/core/session-factory.ts +365 -0
- package/src/core/session-runner.ts +303 -0
- package/src/core/turn-limiter.ts +71 -0
- package/src/index.ts +104 -0
- package/src/runtime/config/config.ts +170 -0
- package/src/runtime/discovery-config.ts +135 -0
- package/src/runtime/execution/history-store.ts +196 -0
- package/src/runtime/execution/notifier.ts +209 -0
- package/src/runtime/execution/record-store.ts +280 -0
- package/src/runtime/model-config-service.ts +265 -0
- package/src/runtime/session-file-gc.ts +70 -0
- package/src/runtime/subagent-service.ts +549 -0
- package/src/tools/subagent-tool.ts +286 -0
- package/src/tui/bg-notify-render.ts +139 -0
- package/src/tui/config-wizard.ts +253 -0
- package/src/tui/format-helpers.ts +37 -0
- package/src/tui/format.ts +332 -0
- package/src/tui/list-view.ts +883 -0
- package/src/tui/tool-render.ts +467 -0
- package/src/types.ts +334 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// src/tools/subagent-tool.ts
|
|
2
|
+
//
|
|
3
|
+
// `subagent` LLM 工具。薄壳——参数解析 + 调 runtime.execute。
|
|
4
|
+
// 不创建 state、不节流 onUpdate、不写 history(全部在 runtime 层统一)。
|
|
5
|
+
//
|
|
6
|
+
// 设计说明:renderCall/renderResult/execute 三个回调均抽成模块级 const +
|
|
7
|
+
// 顶层 type alias。原因:stub 的 registerTool(tool: unknown) 参数是 unknown,
|
|
8
|
+
// 在其对象字面量内直接标注从 pi-coding-agent 导入的泛型(AgentToolResult<X>、
|
|
9
|
+
// Theme、ExtensionContext)会触发 TS2307 误报(probe5d/5f 验证)。
|
|
10
|
+
// 抽到顶层后参数类型由 alias 提供,绕过该 quirk。
|
|
11
|
+
|
|
12
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
13
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
14
|
+
import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { Type } from "@sinclair/typebox";
|
|
16
|
+
|
|
17
|
+
import { getSubagentService } from "../runtime/subagent-service.ts";
|
|
18
|
+
import { extractAgentName } from "../tui/format.ts";
|
|
19
|
+
import { type RenderContext,renderSubagentCall, renderSubagentResult } from "../tui/tool-render.ts";
|
|
20
|
+
import type { SubagentToolDetails } from "../types.ts";
|
|
21
|
+
|
|
22
|
+
// ============================================================
|
|
23
|
+
// 回调类型(抽 alias 绕 registerTool(unknown) 的 TS2307 误报)
|
|
24
|
+
// ============================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* execute 回调的 params 类型(手写副本,因为 stub registerTool 是 unknown,
|
|
28
|
+
* 无法从 SubagentParams schema 反向推断参数类型。与 old 实现一致)。
|
|
29
|
+
*/
|
|
30
|
+
interface SubagentExecuteParams {
|
|
31
|
+
task?: string;
|
|
32
|
+
agent?: string;
|
|
33
|
+
wait?: boolean;
|
|
34
|
+
backgroundId?: string;
|
|
35
|
+
model?: string;
|
|
36
|
+
thinkingLevel?: string;
|
|
37
|
+
skillPath?: string;
|
|
38
|
+
appendSystemPrompt?: string[];
|
|
39
|
+
schema?: Record<string, unknown>;
|
|
40
|
+
maxTurns?: number;
|
|
41
|
+
graceTurns?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type SubagentExecuteCb = (
|
|
45
|
+
toolCallId: string,
|
|
46
|
+
params: SubagentExecuteParams,
|
|
47
|
+
signal: AbortSignal | undefined,
|
|
48
|
+
onUpdate?: (partialResult: AgentToolResult<SubagentToolDetails>) => void,
|
|
49
|
+
ctx?: ExtensionContext,
|
|
50
|
+
) => Promise<AgentToolResult<SubagentToolDetails>>;
|
|
51
|
+
|
|
52
|
+
type SubagentRenderCallCb = (args: unknown, theme: Theme, ctx: RenderContext) => Component;
|
|
53
|
+
|
|
54
|
+
type SubagentRenderResultCb = (
|
|
55
|
+
result: AgentToolResult<SubagentToolDetails>,
|
|
56
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
57
|
+
theme: Theme,
|
|
58
|
+
ctx: RenderContext,
|
|
59
|
+
) => Component;
|
|
60
|
+
|
|
61
|
+
// ============================================================
|
|
62
|
+
// Params schema
|
|
63
|
+
// ============================================================
|
|
64
|
+
|
|
65
|
+
export const SubagentParams = Type.Object({
|
|
66
|
+
task: Type.Optional(Type.String({
|
|
67
|
+
description: "The task for the subagent to execute. Required to start a new subagent. Omit only when polling an existing background subagent (use backgroundId instead).",
|
|
68
|
+
})),
|
|
69
|
+
agent: Type.Optional(Type.String({
|
|
70
|
+
description: 'Agent name (defines system prompt + tools). Defaults to "worker". Available agents: worker (general), researcher (read-only exploration), scout, planner, reviewer, oracle, context-builder. Custom agents can be defined in config.',
|
|
71
|
+
})),
|
|
72
|
+
wait: Type.Optional(Type.Boolean({
|
|
73
|
+
description: "Execution mode. true (default) = sync: blocks until the subagent finishes, returns its result directly. false = background: returns a backgroundId immediately while the subagent runs detached; on completion a message is auto-injected that triggers a new turn so you can process the result — no need to sleep/poll. Use false for parallel fan-out (multiple wait:false calls in one message run concurrently in the pool, default maxConcurrent=4) or for long tasks you don't want to block on.",
|
|
74
|
+
})),
|
|
75
|
+
backgroundId: Type.Optional(Type.String({
|
|
76
|
+
description: "Poll an existing background subagent by its id. The id is returned when you start a subagent with wait:false. Returns current status (running/done/failed) and result if finished. Do NOT pass this when starting a new task — it is for checking progress of a previously started background subagent only.",
|
|
77
|
+
})),
|
|
78
|
+
model: Type.Optional(Type.String({
|
|
79
|
+
description: 'Model override in "provider/modelId" format (e.g. "anthropic/claude-sonnet-4.5"). If omitted, uses the agent\'s configured default model.',
|
|
80
|
+
})),
|
|
81
|
+
thinkingLevel: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const)),
|
|
82
|
+
skillPath: Type.Optional(Type.String()),
|
|
83
|
+
appendSystemPrompt: Type.Optional(Type.Array(Type.String())),
|
|
84
|
+
schema: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
85
|
+
maxTurns: Type.Optional(Type.Number()),
|
|
86
|
+
graceTurns: Type.Optional(Type.Number()),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ============================================================
|
|
90
|
+
// renderCall 预解析 helper
|
|
91
|
+
// ============================================================
|
|
92
|
+
|
|
93
|
+
// extractAgentName 已上移到 ../tui/format.ts 共享(tool-render / subagent-tool 复用)。
|
|
94
|
+
|
|
95
|
+
/** 从 unknown args 安全提取 model/thinkingLevel override(传给 resolveModel)。 */
|
|
96
|
+
function extractModelOverride(args: unknown): { model?: string; thinkingLevel?: string } | undefined {
|
|
97
|
+
if (typeof args !== "object" || args === null) return undefined;
|
|
98
|
+
const a = args as { model?: unknown; thinkingLevel?: unknown };
|
|
99
|
+
const override: { model?: string; thinkingLevel?: string } = {};
|
|
100
|
+
if (typeof a.model === "string" && a.model.length > 0) override.model = a.model;
|
|
101
|
+
if (typeof a.thinkingLevel === "string" && a.thinkingLevel.length > 0) override.thinkingLevel = a.thinkingLevel;
|
|
102
|
+
return Object.keys(override).length > 0 ? override : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============================================================
|
|
106
|
+
// 注册
|
|
107
|
+
// ============================================================
|
|
108
|
+
|
|
109
|
+
/** 注册 `subagent` 工具。由工厂调用。 */
|
|
110
|
+
export function registerSubagentTool(pi: ExtensionAPI): void {
|
|
111
|
+
pi.registerTool({
|
|
112
|
+
name: "subagent",
|
|
113
|
+
label: "Subagent",
|
|
114
|
+
description: `Delegate a task to a specialized subagent.
|
|
115
|
+
|
|
116
|
+
CRITICAL — this tool is registered with executionMode "sequential": multiple \`subagent\` calls in the SAME message run one-after-another, NOT in parallel. The first must finish before the next starts. To get real concurrency, use background mode (wait:false) — background calls return immediately and the underlying tasks run concurrently in the pool (default maxConcurrent=4; extras queue).
|
|
117
|
+
|
|
118
|
+
## Modes
|
|
119
|
+
|
|
120
|
+
- sync (wait:true, default): blocks until the subagent finishes, returns its result. Use when the next step needs the result.
|
|
121
|
+
- background (wait:false): returns a backgroundId immediately; the subagent runs detached and keeps running even if you stop. On completion a message is auto-injected into the conversation that triggers a new turn so you can process the result.
|
|
122
|
+
|
|
123
|
+
## Calling patterns
|
|
124
|
+
|
|
125
|
+
- single — one sync subagent for one task (the common case).
|
|
126
|
+
- chain — dependent steps where B needs A's output: sync calls across turns (A's result informs B's prompt). Sequential by nature.
|
|
127
|
+
- parallel / fan-out — N independent tasks running concurrently: send N \`subagent\` calls with wait:false in the SAME message. Each returns a backgroundId at once; tasks run concurrently. Then do other work, or just stop.
|
|
128
|
+
- background — one long-running task you don't want to block on: wait:false, then move on.
|
|
129
|
+
|
|
130
|
+
## After launching background — do NOT wait
|
|
131
|
+
|
|
132
|
+
Completion auto-notifies you (a message is injected that wakes your next turn). So:
|
|
133
|
+
- DO NOT sleep, busy-wait, or poll in a loop after launching.
|
|
134
|
+
- DO useful non-overlapping work if you have any.
|
|
135
|
+
- Otherwise STOP. Stopping is correct — the completion notification will wake you. It is not giving up.
|
|
136
|
+
|
|
137
|
+
## Polling (backgroundId)
|
|
138
|
+
|
|
139
|
+
Poll only for a concrete reason (e.g. user asks "is it done yet?"). Reflexive polling right after launch is an anti-pattern — the completion notification already covers it.
|
|
140
|
+
|
|
141
|
+
## Anti-patterns
|
|
142
|
+
|
|
143
|
+
- Multiple sync (wait:true) calls in one message expecting parallelism → they serialize; a slow first call delays the rest and long chains may get interrupted.
|
|
144
|
+
- Launching background, then sleeping/polling instead of working or stopping.
|
|
145
|
+
- Using background for a result you need right now → use sync.`,
|
|
146
|
+
executionMode: "sequential",
|
|
147
|
+
parameters: SubagentParams,
|
|
148
|
+
renderCall: subagentRenderCall,
|
|
149
|
+
renderResult: subagentRenderResult,
|
|
150
|
+
execute: executeSubagent,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ============================================================
|
|
155
|
+
// 回调实现(模块级 const)
|
|
156
|
+
// ============================================================
|
|
157
|
+
|
|
158
|
+
const subagentRenderCall: SubagentRenderCallCb = (args, theme, ctx) => {
|
|
159
|
+
// 预解析 model(同步):renderCall 在 execute 前调用,但 model 解析是同步的
|
|
160
|
+
// (只读配置 + sessionState)。让标题行能显示 model/thinking,不必等 execute。
|
|
161
|
+
// service 未就绪(session 未 init)或解析失败时降级——只显示 agent 名。
|
|
162
|
+
const agent = extractAgentName(args);
|
|
163
|
+
const override = extractModelOverride(args);
|
|
164
|
+
let resolved: { model: string; thinkingLevel?: string } | undefined;
|
|
165
|
+
try {
|
|
166
|
+
const service = getSubagentService();
|
|
167
|
+
const r = service?.resolveModel(agent, override);
|
|
168
|
+
if (r) resolved = { model: `${r.model.provider}/${r.model.id}`, thinkingLevel: r.thinkingLevel };
|
|
169
|
+
} catch {
|
|
170
|
+
// service 未注册或 modelRegistry 未注入,降级
|
|
171
|
+
}
|
|
172
|
+
return renderSubagentCall(args, theme, ctx, resolved);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const subagentRenderResult: SubagentRenderResultCb = (result, options, theme, ctx) =>
|
|
176
|
+
renderSubagentResult(result, options, theme, ctx);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* execute 实现。
|
|
180
|
+
*
|
|
181
|
+
* ╔══════════════════════════════════════════════════════════════════╗
|
|
182
|
+
* ║ rt = getRuntime() —— 未初始化 throw ║
|
|
183
|
+
* ║ ║
|
|
184
|
+
* ║ ── Mode 3: poll ────────────────────────────────────────────── ║
|
|
185
|
+
* ║ if (params.backgroundId): ║
|
|
186
|
+
* ║ query = rt.query(backgroundId) ◄── store.snapshot + project ║
|
|
187
|
+
* ║ 不存在 throw;running → 返回 running details ║
|
|
188
|
+
* ║ settled → 返回 project(顶层 turns/tokens,不钻 result) ║
|
|
189
|
+
* ║ return { content, details } ║
|
|
190
|
+
* ║ ║
|
|
191
|
+
* ║ ── task 必填校验 ─────────────────────────────────────────── ║
|
|
192
|
+
* ║ rt.assertAgentExists(params.agent) ◄── fail-fast 未知 agent ║
|
|
193
|
+
* ║ ║
|
|
194
|
+
* ║ ── mode 判定 ───────────────────────────────────────────── ║
|
|
195
|
+
* ║ params.wait > agent.defaultBackground > "sync" ║
|
|
196
|
+
* ║ ║
|
|
197
|
+
* ║ ── 预解析 model(仅显式 override 失败时 throw)─────────── ║
|
|
198
|
+
* ║ resolved = rt.resolveModelForAgent(agent, override) ║
|
|
199
|
+
* ║ hasExplicitOverride && !resolved → throw ║
|
|
200
|
+
* ║ ║
|
|
201
|
+
* ║ ── 调 runtime.execute(统一入口)──────────────────────── ║
|
|
202
|
+
* ║ handle = await rt.execute({ ║
|
|
203
|
+
* ║ task, agent, mode, model, thinkingLevel, ║
|
|
204
|
+
* ║ skillPath, appendSystemPrompt, schema, maxTurns, graceTurns, ║
|
|
205
|
+
* ║ signal, ║
|
|
206
|
+
* ║ onUpdate: (details) => onUpdate?.({ content, details }), ║
|
|
207
|
+
* ║ }) ║
|
|
208
|
+
* ║ ║
|
|
209
|
+
* ║ ── 返回 ───────────────────────────────────────────────── ║
|
|
210
|
+
* ║ handle.mode==="background": ║
|
|
211
|
+
* ║ 返回 backgroundId(LLM 后续 poll) ║
|
|
212
|
+
* ║ handle.mode==="sync": ║
|
|
213
|
+
* ║ details = project(handle.record) ║
|
|
214
|
+
* ║ return { content: resultText, details } ║
|
|
215
|
+
* ╚══════════════════════════════════════════════════════════════════╝
|
|
216
|
+
*/
|
|
217
|
+
const executeSubagent: SubagentExecuteCb = async (
|
|
218
|
+
_toolCallId,
|
|
219
|
+
params,
|
|
220
|
+
signal,
|
|
221
|
+
onUpdate,
|
|
222
|
+
_ctx,
|
|
223
|
+
) => {
|
|
224
|
+
const service = getSubagentService();
|
|
225
|
+
if (!service) throw new Error("subagents runtime not initialized");
|
|
226
|
+
|
|
227
|
+
// ── poll 路径 ──
|
|
228
|
+
if (params.backgroundId) {
|
|
229
|
+
const result = service.query(params.backgroundId);
|
|
230
|
+
if (!result) throw new Error(`No subagent record with id "${params.backgroundId}"`);
|
|
231
|
+
// 按 status 分支:done→result;failed/cancelled→暴露 error(不掩盖失败,M5 修复)
|
|
232
|
+
const text = result.status === "running"
|
|
233
|
+
? `Subagent ${result.id} is still running (${result.turns} turns).`
|
|
234
|
+
: result.status === "done"
|
|
235
|
+
? (result.result ?? `Subagent ${result.id} finished.`)
|
|
236
|
+
: `Subagent ${result.id} ${result.status}${result.error ? `: ${result.error}` : ""}.`;
|
|
237
|
+
const content = [{ type: "text" as const, text }];
|
|
238
|
+
return { content, details: result };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── task 必填 ──
|
|
242
|
+
if (!params.task) throw new Error("task is required");
|
|
243
|
+
|
|
244
|
+
// ── 调 service.execute(mode 判定 + agent 校验 + 执行全在 service 内部)──
|
|
245
|
+
// D-1:取消首次确认拦截——不再注入 onConfirmCategory。
|
|
246
|
+
const handle = await service.execute({
|
|
247
|
+
task: params.task,
|
|
248
|
+
agent: params.agent,
|
|
249
|
+
wait: params.wait,
|
|
250
|
+
model: params.model,
|
|
251
|
+
thinkingLevel: params.thinkingLevel,
|
|
252
|
+
skillPath: params.skillPath,
|
|
253
|
+
appendSystemPrompt: params.appendSystemPrompt,
|
|
254
|
+
schema: params.schema,
|
|
255
|
+
maxTurns: params.maxTurns,
|
|
256
|
+
graceTurns: params.graceTurns,
|
|
257
|
+
signal,
|
|
258
|
+
onUpdate: onUpdate
|
|
259
|
+
? (details) => {
|
|
260
|
+
onUpdate({ content: [{ type: "text", text: details.result ?? "" }], details });
|
|
261
|
+
}
|
|
262
|
+
: undefined,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ── 返回 ──
|
|
266
|
+
if (handle.mode === "background") {
|
|
267
|
+
// background:立即返回 backgroundId + 完整 details(status=running),
|
|
268
|
+
// 让 tool block 能正常渲染 running 态(而非 "did not produce details")。
|
|
269
|
+
// 后台进度靠 progress widget(execute return 后 tool block 无法继续更新)。
|
|
270
|
+
return { content: [{ type: "text", text: `Background subagent started: ${handle.backgroundId}` }],
|
|
271
|
+
details: handle.details };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// sync: details 用 project 投影的 SubagentToolDetails(含 elapsedSeconds/currentActivity),
|
|
275
|
+
// 而非 record snapshot(后者缺 TUI 渲染字段)。
|
|
276
|
+
// schema 模式:优先输出 parsedOutput 的 JSON(调用方期望结构化数据,非 agent 文本)。
|
|
277
|
+
// 失败/取消时 record.result 为空字符串,但 error 只进 details.error ——
|
|
278
|
+
// 父 agent 只读 content 会误判「成功但无返回值」。这里与 poll 路径对齐:
|
|
279
|
+
// 把 error 拼进 content,保证失败「出声」(项目「错误要出声」约定)。
|
|
280
|
+
const syncText = handle.details.parsedOutput !== undefined
|
|
281
|
+
? JSON.stringify(handle.details.parsedOutput)
|
|
282
|
+
: (handle.record.result
|
|
283
|
+
|| (handle.details.error ? `Subagent failed: ${handle.details.error}` : "Subagent failed."));
|
|
284
|
+
return { content: [{ type: "text", text: syncText }],
|
|
285
|
+
details: handle.details };
|
|
286
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// src/tui/bg-notify-render.ts
|
|
2
|
+
//
|
|
3
|
+
// background 完成通知的对话流渲染器。
|
|
4
|
+
// pi.registerMessageRenderer("subagent-bg-notify", ...) 注册。
|
|
5
|
+
//
|
|
6
|
+
// notifier.ts 设 display:true + triggerTurn:true:
|
|
7
|
+
// - display:true → Pi 创建 CustomMessageComponent(customMessageBg 背景色块,
|
|
8
|
+
// 与 tool block 的 toolSuccessBg 视觉区分),调本 renderer 渲染内容
|
|
9
|
+
// - triggerTurn:true → 唤醒父 agent 下一 turn,让 LLM 看到「X 完成」
|
|
10
|
+
//
|
|
11
|
+
// 渲染内容(紧凑单行/双行):
|
|
12
|
+
// ✓ agent — 摘要首行 (id)
|
|
13
|
+
// ✗ agent — Error: 错误首行 (id)
|
|
14
|
+
// ■ agent — cancelled (id)
|
|
15
|
+
//
|
|
16
|
+
// 注意:不调 theme.bg()——背景色由 Pi 的 CustomMessageComponent 容器施加
|
|
17
|
+
// (customMessageBg)。组件只负责前景内容。
|
|
18
|
+
|
|
19
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
20
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
21
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
import { firstLine, statusGlyph, type ThemeLike,truncLine } from "./format.ts";
|
|
24
|
+
|
|
25
|
+
/** agent 名最大显示宽度。 */
|
|
26
|
+
const AGENT_MAX_WIDTH = 40;
|
|
27
|
+
/** 完成块正文最大显示宽度。 */
|
|
28
|
+
const BODY_MAX_WIDTH = 80;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 渲染 background 完成通知。
|
|
32
|
+
*
|
|
33
|
+
* 契约(Pi MessageRenderer,core/extensions/types.ts:1060):
|
|
34
|
+
* (message: CustomMessage, options: { expanded }, theme: Theme) => Component | undefined
|
|
35
|
+
*
|
|
36
|
+
* display:true 时 Pi 调本方法,返回 Component 渲染到 customMessageBg 块。
|
|
37
|
+
* details 异常 → 返回 undefined 走 Pi 默认渲染(兜底)。
|
|
38
|
+
*
|
|
39
|
+
* details 两种形态:
|
|
40
|
+
* - 单条:BgNotifyRecord(status/agent/id/result/error)
|
|
41
|
+
* - 批量:{ batch: true, items: BgNotifyRecord[] }(notifier 合并窗口内多条完成)
|
|
42
|
+
*/
|
|
43
|
+
export function renderBgNotifyMessage(
|
|
44
|
+
message: { details?: unknown },
|
|
45
|
+
_options: { expanded: boolean },
|
|
46
|
+
theme: Theme,
|
|
47
|
+
): Component | undefined {
|
|
48
|
+
const t = theme as ThemeLike;
|
|
49
|
+
|
|
50
|
+
// 批量分支:多条合并,各自渲染一行
|
|
51
|
+
const batch = extractBatch(message.details);
|
|
52
|
+
if (batch) {
|
|
53
|
+
const lines = batch.map((r) => renderRecordLine(r, t));
|
|
54
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 单条分支
|
|
58
|
+
const record = extractBgNotifyRecord(message.details);
|
|
59
|
+
if (!record) return undefined;
|
|
60
|
+
return new Text(renderRecordLine(record, t), 0, 0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 渲染单条 record 为一行文本(头 + 首行预览 + id)。 */
|
|
64
|
+
function renderRecordLine(
|
|
65
|
+
record: { id: string; status: "done" | "failed" | "cancelled"; agent: string; result?: string; error?: string },
|
|
66
|
+
t: ThemeLike,
|
|
67
|
+
): string {
|
|
68
|
+
const glyph = statusGlyph(record.status);
|
|
69
|
+
const icon = glyph.icon ?? "•";
|
|
70
|
+
const agent = truncLine(record.agent, AGENT_MAX_WIDTH);
|
|
71
|
+
const head = `${t.fg(glyph.color, icon)} ${t.bold(agent)}`;
|
|
72
|
+
|
|
73
|
+
let body: string;
|
|
74
|
+
switch (record.status) {
|
|
75
|
+
case "done":
|
|
76
|
+
body = record.result ? firstLineSanitized(record.result) : "(completed)";
|
|
77
|
+
break;
|
|
78
|
+
case "failed":
|
|
79
|
+
body = `Error: ${record.error ? firstLineSanitized(record.error) : "(unknown)"}`;
|
|
80
|
+
break;
|
|
81
|
+
case "cancelled":
|
|
82
|
+
body = "cancelled";
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
body = "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const idStr = t.fg("dim", ` (${record.id})`);
|
|
89
|
+
return `${head} — ${t.fg("dim", truncLine(body, BODY_MAX_WIDTH))}${idStr}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 从 message.details 防御性提取批量 record。
|
|
94
|
+
* 形态:{ batch: true, items: BgNotifyRecord[] }。结构不全返回 undefined。
|
|
95
|
+
*/
|
|
96
|
+
function extractBatch(
|
|
97
|
+
details: unknown,
|
|
98
|
+
): { id: string; status: "done" | "failed" | "cancelled"; agent: string; result?: string; error?: string }[] | undefined {
|
|
99
|
+
if (typeof details !== "object" || details === null) return undefined;
|
|
100
|
+
const d = details as Record<string, unknown>;
|
|
101
|
+
if (d.batch !== true || !Array.isArray(d.items)) return undefined;
|
|
102
|
+
const records: { id: string; status: "done" | "failed" | "cancelled"; agent: string; result?: string; error?: string }[] = [];
|
|
103
|
+
for (const item of d.items) {
|
|
104
|
+
const r = extractBgNotifyRecord(item);
|
|
105
|
+
if (r) records.push(r);
|
|
106
|
+
}
|
|
107
|
+
return records.length > 0 ? records : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 从 message.details 防御性提取 BgNotifyRecord。
|
|
112
|
+
* 结构不全(缺 status / agent)返回 undefined。
|
|
113
|
+
*/
|
|
114
|
+
function extractBgNotifyRecord(
|
|
115
|
+
details: unknown,
|
|
116
|
+
): { id: string; status: "done" | "failed" | "cancelled"; agent: string; result?: string; error?: string } | undefined {
|
|
117
|
+
if (typeof details !== "object" || details === null) return undefined;
|
|
118
|
+
const d = details as Record<string, unknown>;
|
|
119
|
+
const status = d.status;
|
|
120
|
+
const agent = d.agent;
|
|
121
|
+
if (
|
|
122
|
+
(status !== "done" && status !== "failed" && status !== "cancelled") ||
|
|
123
|
+
typeof agent !== "string"
|
|
124
|
+
) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
id: typeof d.id === "string" ? d.id : "",
|
|
129
|
+
status,
|
|
130
|
+
agent,
|
|
131
|
+
result: typeof d.result === "string" ? d.result : undefined,
|
|
132
|
+
error: typeof d.error === "string" ? d.error : undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// firstLine 取首非空行(共享自 ./format.ts);本文件额外压 \r\t 防多行展开。
|
|
137
|
+
function firstLineSanitized(text: string): string {
|
|
138
|
+
return firstLine(text).replace(/[\r\t]+/g, " ");
|
|
139
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// src/tui/config-wizard.ts
|
|
2
|
+
//
|
|
3
|
+
// /subagents config 交互式向导。
|
|
4
|
+
// 用 Pi 内置的 ctx.ui.select / input / notify(都是 awaitable Promise)串成多级菜单。
|
|
5
|
+
//
|
|
6
|
+
// 数据闭环:
|
|
7
|
+
// modelService.getGlobalConfig()(副本)→ mutate → modelService.saveGlobalConfig(内存 + 落盘)
|
|
8
|
+
// YOLO 是 session 级(sessionState),用 modelService.toggleYolo(不落盘 config.json)。
|
|
9
|
+
//
|
|
10
|
+
// UI 接口(WizardUi)与 Pi ExtensionUIContext 的 select/input/notify 子集 duck-type 对齐(测试可 mock)。
|
|
11
|
+
|
|
12
|
+
import { availableThinkingLevels, type ModelInfo, type ModelRegistryLike } from "../core/model-resolver.ts";
|
|
13
|
+
import type { ModelConfigService } from "../runtime/model-config-service.ts";
|
|
14
|
+
import type { CategoryDefinition, SubagentsGlobalConfig } from "../types.ts";
|
|
15
|
+
|
|
16
|
+
/** wizard 依赖的 UI 接口(与 ExtensionUIContext 的 select/input/notify 对齐)。 */
|
|
17
|
+
export interface WizardUi {
|
|
18
|
+
select(title: string, options: string[], opts?: { signal?: AbortSignal; timeout?: number }): Promise<string | undefined>;
|
|
19
|
+
input(title: string, placeholder?: string, opts?: { signal?: AbortSignal; timeout?: number }): Promise<string | undefined>;
|
|
20
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 运行配置向导。
|
|
25
|
+
*
|
|
26
|
+
* ╔══════════════════════════════════════════════════════════════╗
|
|
27
|
+
* ║ 主菜单 select(循环,直到用户选"退出"): ║
|
|
28
|
+
// ║ - 切换 YOLO 模式(toggleYolo,session 级) ║
|
|
29
|
+
// ║ - 选择 category → 修改 model/thinkingLevel(落盘) ║
|
|
30
|
+
// ║ - 修改 maxConcurrent(落盘) ║
|
|
31
|
+
// ║ - 修改 fallback 模型(落盘) ║
|
|
32
|
+
// ║ - 退出 ║
|
|
33
|
+
// ║ ║
|
|
34
|
+
// ║ 修改 globalConfig 的项 mutate 副本后调 saveGlobalConfig 持久化 ║
|
|
35
|
+
// ║ modelService 未初始化或 registry 未注入 → notify + return ║
|
|
36
|
+
// ╚══════════════════════════════════════════════════════════════╝
|
|
37
|
+
*/
|
|
38
|
+
export async function runConfigWizard(
|
|
39
|
+
ui: WizardUi,
|
|
40
|
+
args: string[],
|
|
41
|
+
modelService: ModelConfigService,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
void args; // 预留:未来支持 /subagents config <category> 直跳
|
|
44
|
+
|
|
45
|
+
const config = modelService.getGlobalConfig();
|
|
46
|
+
const registry = safeGetRegistry(modelService);
|
|
47
|
+
if (!registry) {
|
|
48
|
+
ui.notify("subagents 模型注册表未就绪(session 未初始化)", "error");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 主循环
|
|
53
|
+
for (;;) {
|
|
54
|
+
const yoloState = modelService.getSessionState().yoloMode ? "on" : "off";
|
|
55
|
+
const mainChoice = await ui.select("Subagents config", [
|
|
56
|
+
`切换 YOLO 模式(当前: ${yoloState})`,
|
|
57
|
+
"修改 category 模型",
|
|
58
|
+
`修改 maxConcurrent(当前: ${config.maxConcurrent})`,
|
|
59
|
+
`修改 fallback 模型(当前: ${config.fallback.model})`,
|
|
60
|
+
"退出",
|
|
61
|
+
]);
|
|
62
|
+
if (mainChoice === undefined || mainChoice === "退出") return;
|
|
63
|
+
|
|
64
|
+
if (mainChoice.startsWith("切换 YOLO")) {
|
|
65
|
+
const newVal = modelService.toggleYolo();
|
|
66
|
+
ui.notify(`YOLO 模式: ${newVal ? "on" : "off"}`, "info");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (mainChoice.startsWith("修改 category")) {
|
|
70
|
+
await editCategoryModel(ui, config, registry, modelService);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (mainChoice.startsWith("修改 maxConcurrent")) {
|
|
74
|
+
await editMaxConcurrent(ui, config, modelService);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (mainChoice.startsWith("修改 fallback")) {
|
|
78
|
+
await editFallbackModel(ui, config, registry, modelService);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================
|
|
85
|
+
// 子流程:修改 category 模型
|
|
86
|
+
// ============================================================
|
|
87
|
+
|
|
88
|
+
/** 修改某个 category 的 model + thinkingLevel,落盘。 */
|
|
89
|
+
async function editCategoryModel(
|
|
90
|
+
ui: WizardUi,
|
|
91
|
+
config: SubagentsGlobalConfig,
|
|
92
|
+
registry: ModelRegistryLike,
|
|
93
|
+
modelService: ModelConfigService,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const catNames = Object.keys(config.categories);
|
|
96
|
+
if (catNames.length === 0) {
|
|
97
|
+
ui.notify("没有可配置的 category", "warning");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const available = registry.getAvailable();
|
|
101
|
+
const availableIds = new Set(available.map((m) => `${m.provider}/${m.id}`));
|
|
102
|
+
// label 显示真实模型——若 config 配的 model 在 registry 无效,标降级提示
|
|
103
|
+
// (resolveModel 会 fallback 到 fallback.model 或 ctx.model,用户应感知)
|
|
104
|
+
const catLabels = catNames.map((n) => {
|
|
105
|
+
const def = config.categories[n]!;
|
|
106
|
+
const valid = availableIds.has(def.model);
|
|
107
|
+
const suffix = valid ? "" : ` ⚠ 无效(将降级到 ${config.fallback.model})`;
|
|
108
|
+
return `${def.label} (${n}) = ${def.model}${suffix}`;
|
|
109
|
+
});
|
|
110
|
+
const catChoice = await ui.select("选择 category", catLabels);
|
|
111
|
+
if (catChoice === undefined) return;
|
|
112
|
+
const catIdx = catLabels.indexOf(catChoice);
|
|
113
|
+
if (catIdx < 0) return;
|
|
114
|
+
const catName = catNames[catIdx]!;
|
|
115
|
+
|
|
116
|
+
if (available.length === 0) {
|
|
117
|
+
ui.notify("没有可用的模型(检查 modelRegistry 鉴权)", "warning");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const modelOptions = available.map((m) => modelLabel(m));
|
|
121
|
+
const modelChoice = await ui.select(`选择 ${catName} 的模型`, modelOptions);
|
|
122
|
+
if (modelChoice === undefined) return;
|
|
123
|
+
const modelIdx = modelOptions.indexOf(modelChoice);
|
|
124
|
+
if (modelIdx < 0) return;
|
|
125
|
+
const model = available[modelIdx]!;
|
|
126
|
+
const modelStr = `${model.provider}/${model.id}`;
|
|
127
|
+
|
|
128
|
+
// thinkingLevel(按 model.thinkingLevelMap 过滤——不同 model 支持的级别不同)
|
|
129
|
+
const thinkingLevels = [...availableThinkingLevels(model)];
|
|
130
|
+
if (thinkingLevels.length === 0) {
|
|
131
|
+
// 非 reasoning 或无 map 信息:写 undefined,跳过选择
|
|
132
|
+
config.categories[catName] = { ...config.categories[catName]!, model: modelStr, thinkingLevel: undefined };
|
|
133
|
+
await saveAndNotify(ui, config, modelService, `${catName} → ${modelStr} · thinking off`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const current = config.categories[catName]!.thinkingLevel ?? "off";
|
|
137
|
+
const thinkingChoice = await ui.select(
|
|
138
|
+
`选择 thinking level(当前: ${current})`,
|
|
139
|
+
thinkingLevels,
|
|
140
|
+
);
|
|
141
|
+
if (thinkingChoice === undefined) return;
|
|
142
|
+
|
|
143
|
+
const newDef: CategoryDefinition = {
|
|
144
|
+
...config.categories[catName]!,
|
|
145
|
+
model: modelStr,
|
|
146
|
+
thinkingLevel: thinkingChoice,
|
|
147
|
+
};
|
|
148
|
+
config.categories[catName] = newDef;
|
|
149
|
+
await saveAndNotify(ui, config, modelService, `${catName} → ${modelStr} · thinking ${thinkingChoice}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ============================================================
|
|
153
|
+
// 子流程:修改 maxConcurrent
|
|
154
|
+
// ============================================================
|
|
155
|
+
|
|
156
|
+
/** 修改 maxConcurrent(正整数校验),落盘。 */
|
|
157
|
+
async function editMaxConcurrent(
|
|
158
|
+
ui: WizardUi,
|
|
159
|
+
config: SubagentsGlobalConfig,
|
|
160
|
+
modelService: ModelConfigService,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const input = await ui.input(`maxConcurrent(当前 ${config.maxConcurrent},输入正整数)`);
|
|
163
|
+
if (input === undefined) return;
|
|
164
|
+
const n = Number.parseInt(input, 10);
|
|
165
|
+
if (!Number.isFinite(n) || n < 1 || String(n) !== input.trim()) {
|
|
166
|
+
ui.notify("无效值(需为正整数),未修改", "warning");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
config.maxConcurrent = n;
|
|
170
|
+
await saveAndNotify(ui, config, modelService, `maxConcurrent = ${n}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============================================================
|
|
174
|
+
// 子流程:修改 fallback 模型
|
|
175
|
+
// ============================================================
|
|
176
|
+
|
|
177
|
+
/** 修改 fallback 的 model + thinkingLevel,落盘。 */
|
|
178
|
+
async function editFallbackModel(
|
|
179
|
+
ui: WizardUi,
|
|
180
|
+
config: SubagentsGlobalConfig,
|
|
181
|
+
registry: ModelRegistryLike,
|
|
182
|
+
modelService: ModelConfigService,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
const available = registry.getAvailable();
|
|
185
|
+
if (available.length === 0) {
|
|
186
|
+
ui.notify("没有可用的模型", "warning");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const modelOptions = available.map((m) => modelLabel(m));
|
|
190
|
+
const modelChoice = await ui.select("选择 fallback 模型", modelOptions);
|
|
191
|
+
if (modelChoice === undefined) return;
|
|
192
|
+
const modelIdx = modelOptions.indexOf(modelChoice);
|
|
193
|
+
if (modelIdx < 0) return;
|
|
194
|
+
const model = available[modelIdx]!;
|
|
195
|
+
const modelStr = `${model.provider}/${model.id}`;
|
|
196
|
+
|
|
197
|
+
// thinkingLevel(按 model.thinkingLevelMap 过滤)
|
|
198
|
+
const thinkingLevels = [...availableThinkingLevels(model)];
|
|
199
|
+
let thinkingLevel: string | undefined;
|
|
200
|
+
if (thinkingLevels.length > 0) {
|
|
201
|
+
const choice = await ui.select("选择 fallback thinking level", thinkingLevels);
|
|
202
|
+
if (choice === undefined) return;
|
|
203
|
+
thinkingLevel = choice;
|
|
204
|
+
}
|
|
205
|
+
// 无可用级别(非 reasoning / 无 map)→ thinkingLevel = undefined
|
|
206
|
+
|
|
207
|
+
config.fallback = { model: modelStr, thinkingLevel };
|
|
208
|
+
await saveAndNotify(ui, config, modelService, `fallback → ${modelStr} · thinking ${thinkingLevel ?? "off"}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============================================================
|
|
212
|
+
// 辅助
|
|
213
|
+
// ============================================================
|
|
214
|
+
|
|
215
|
+
/** 模型选项 label(provider/id — name)。 */
|
|
216
|
+
function modelLabel(m: ModelInfo): string {
|
|
217
|
+
return `${m.provider}/${m.id} — ${m.name}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 保存全局配置并通知用户。
|
|
222
|
+
*
|
|
223
|
+
* 做两件事(单一职责拆分会割裂调用方 try/catch,合并在此更内聚):
|
|
224
|
+
* 1. modelService.saveGlobalConfig —— 更新 Service 内存副本 + 原子写 config.json
|
|
225
|
+
* 2. ui.notify —— 向用户反馈结果(成功显示摘要,失败显示错误)
|
|
226
|
+
*
|
|
227
|
+
* 失败不抛(向导继续可用),由 notify error 兜底。
|
|
228
|
+
*/
|
|
229
|
+
async function saveAndNotify(
|
|
230
|
+
ui: WizardUi,
|
|
231
|
+
config: SubagentsGlobalConfig,
|
|
232
|
+
modelService: ModelConfigService,
|
|
233
|
+
summary: string,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
try {
|
|
236
|
+
await modelService.saveGlobalConfig(config);
|
|
237
|
+
ui.notify(`已保存:${summary}`, "info");
|
|
238
|
+
} catch (err) {
|
|
239
|
+
ui.notify(`保存失败:${String(err)}`, "error");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 安全获取 modelRegistry(initModel 未调用时返回 undefined 而非 throw)。
|
|
245
|
+
* wizard 用它做 guard,避免 hub 未就绪时抛错中断向导。
|
|
246
|
+
*/
|
|
247
|
+
function safeGetRegistry(modelService: ModelConfigService): ModelRegistryLike | undefined {
|
|
248
|
+
try {
|
|
249
|
+
return modelService.getModelRegistry();
|
|
250
|
+
} catch {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|