@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,206 @@
|
|
|
1
|
+
// src/core/model-resolver.ts
|
|
2
|
+
//
|
|
3
|
+
// 5 级 fallback 模型解析链。runtime 层调此函数解析后传入 SessionRunner,
|
|
4
|
+
// SessionRunner 不重复解析。
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ModelRegistry 的最小接口(duck-typed,测试可 mock)。
|
|
8
|
+
* 字段结构与 Pi SDK 的 ctx.modelRegistry 对齐(见 shared/types stub),
|
|
9
|
+
* 保证 `runtime.initSession({ modelRegistry: ctx.modelRegistry })` 类型兼容。
|
|
10
|
+
*/
|
|
11
|
+
export interface ModelRegistryLike {
|
|
12
|
+
/** 返回所有已配置的可用模型。 */
|
|
13
|
+
getAvailable(): ModelInfo[];
|
|
14
|
+
/** 按 (provider, modelId) 查找。 */
|
|
15
|
+
find(provider: string, modelId: string): ModelInfo | undefined;
|
|
16
|
+
/** 校验模型鉴权是否就绪。 */
|
|
17
|
+
hasConfiguredAuth(model: unknown): boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 模型信息(getAvailable 返回元素 / find 返回值)。 */
|
|
21
|
+
export interface ModelInfo {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
provider: string;
|
|
25
|
+
reasoning: boolean;
|
|
26
|
+
thinkingLevelMap?: Record<string, unknown>;
|
|
27
|
+
contextWindow?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** agent .md frontmatter 解析结果。 */
|
|
31
|
+
export interface AgentConfig {
|
|
32
|
+
/** agent 名(文件名 basename)。 */
|
|
33
|
+
name: string;
|
|
34
|
+
/** system prompt(markdown 正文)。 */
|
|
35
|
+
systemPrompt: string;
|
|
36
|
+
/** tool allowlist(三层过滤之一)。 */
|
|
37
|
+
tools?: string[];
|
|
38
|
+
/** 默认模型 override("provider/modelId")。 */
|
|
39
|
+
model?: string;
|
|
40
|
+
/** 默认 thinkingLevel override。 */
|
|
41
|
+
thinkingLevel?: string;
|
|
42
|
+
/** 默认 background 模式(true 时无显式 wait 走 background)。 */
|
|
43
|
+
defaultBackground?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 解析结果(model 实例 + 生效的 thinkingLevel)。复用 ModelInfo 消除重复。 */
|
|
47
|
+
export interface ResolvedModel {
|
|
48
|
+
model: ModelInfo;
|
|
49
|
+
thinkingLevel: string | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** resolveModelForAgent 的入参。 */
|
|
53
|
+
export interface ResolveModelArgs {
|
|
54
|
+
agentName: string;
|
|
55
|
+
agentConfig: AgentConfig | undefined;
|
|
56
|
+
/** agent 推断出的 category。 */
|
|
57
|
+
category: string;
|
|
58
|
+
globalConfig: { categories: Record<string, { model: string; thinkingLevel?: string }>; fallback: { model: string; thinkingLevel?: string } };
|
|
59
|
+
sessionState: { categoryModels: Record<string, { model: string; thinkingLevel?: string }>; agentModels: Record<string, { model: string; thinkingLevel?: string }> };
|
|
60
|
+
modelRegistry: ModelRegistryLike;
|
|
61
|
+
/** 用户显式 override(tool 参数)。最高优先级。 */
|
|
62
|
+
paramOverride?: { model?: string; thinkingLevel?: string };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============================================================
|
|
66
|
+
// 常量
|
|
67
|
+
// ============================================================
|
|
68
|
+
|
|
69
|
+
/** thinking level 支持顺序(低→高),用于 clamp 到 model 可用级别。 */
|
|
70
|
+
const THINKING_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
71
|
+
|
|
72
|
+
/** 解析失败时错误信息列出的可用模型上限(防超长错误信息)。 */
|
|
73
|
+
const MODEL_LIST_LIMIT = 20;
|
|
74
|
+
|
|
75
|
+
/** agent 名 → category 的推断规则(按优先级,命中即返回)。 */
|
|
76
|
+
const NAME_INFERENCE: ReadonlyArray<{ pattern: RegExp; category: string }> = [
|
|
77
|
+
{ pattern: /cod|review|fix|refactor|implement|develop/i, category: "coding" },
|
|
78
|
+
{ pattern: /research|search|investigat|scout|explore/i, category: "research" },
|
|
79
|
+
{ pattern: /test|qa|lint|valid/i, category: "testing" },
|
|
80
|
+
{ pattern: /plan|architect|design|strateg/i, category: "planning" },
|
|
81
|
+
{ pattern: /vision|image|ocr|visual/i, category: "vision" },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/** 候选链条目:modelStr + 该级对应的 thinkingLevel。 */
|
|
85
|
+
interface ModelCandidate {
|
|
86
|
+
modelStr: string;
|
|
87
|
+
thinkingLevel?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 5 级 fallback 解析:
|
|
92
|
+
*
|
|
93
|
+
* ╔═══════════════════════════════════════════════════════════════╗
|
|
94
|
+
// ║ 优先级(高→低): ║
|
|
95
|
+
// ║ 1. paramOverride.model (用户显式指定,tool 参数) ║
|
|
96
|
+
// ║ 2. agent.model (agent .md frontmatter) ║
|
|
97
|
+
// ║ 3. sessionState.agentModels (/subagents config 临时覆盖) ║
|
|
98
|
+
// ║ 4. sessionState.categoryModels + category ║
|
|
99
|
+
// ║ 5. globalConfig.fallback (兜底,保证总有一个 model) ║
|
|
100
|
+
// ║ ║
|
|
101
|
+
// ║ thinkingLevel 同链路解析(无指定时用 model 默认或 undefined) ║
|
|
102
|
+
// ║ 解析失败(前 4 级全不可用)→ 抛错(让 Runtime 决定静默/失败) ║
|
|
103
|
+
// ╚═══════════════════════════════════════════════════════════════╝
|
|
104
|
+
*/
|
|
105
|
+
export function resolveModelForAgent(args: ResolveModelArgs): ResolvedModel {
|
|
106
|
+
const { agentConfig, agentName, category, globalConfig, sessionState, modelRegistry } = args;
|
|
107
|
+
|
|
108
|
+
// 组装候选链(按优先级)
|
|
109
|
+
const candidates: ModelCandidate[] = [];
|
|
110
|
+
if (args.paramOverride?.model) {
|
|
111
|
+
candidates.push({ modelStr: args.paramOverride.model, thinkingLevel: args.paramOverride.thinkingLevel });
|
|
112
|
+
}
|
|
113
|
+
if (agentConfig?.model) {
|
|
114
|
+
candidates.push({ modelStr: agentConfig.model, thinkingLevel: agentConfig.thinkingLevel });
|
|
115
|
+
}
|
|
116
|
+
const agentOverride = sessionState.agentModels[agentName];
|
|
117
|
+
if (agentOverride) {
|
|
118
|
+
candidates.push({ modelStr: agentOverride.model, thinkingLevel: agentOverride.thinkingLevel });
|
|
119
|
+
}
|
|
120
|
+
const categoryModel = sessionState.categoryModels[category] ?? globalConfig.categories[category];
|
|
121
|
+
if (categoryModel) {
|
|
122
|
+
candidates.push({ modelStr: categoryModel.model, thinkingLevel: categoryModel.thinkingLevel });
|
|
123
|
+
}
|
|
124
|
+
candidates.push({ modelStr: globalConfig.fallback.model, thinkingLevel: globalConfig.fallback.thinkingLevel });
|
|
125
|
+
|
|
126
|
+
const tried: string[] = [];
|
|
127
|
+
for (const candidate of candidates) {
|
|
128
|
+
const model = lookupModel(candidate.modelStr, modelRegistry);
|
|
129
|
+
if (!model || !modelRegistry.hasConfiguredAuth(model)) {
|
|
130
|
+
tried.push(candidate.modelStr);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
model,
|
|
135
|
+
thinkingLevel: resolveThinkingLevel(model, candidate.thinkingLevel),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 所有候选失败 → 列出可用模型辅助调试
|
|
140
|
+
const available = modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`);
|
|
141
|
+
throw new Error(
|
|
142
|
+
`No available model for agent "${agentName}". Tried: ${tried.join(", ") || "(none)"}.` +
|
|
143
|
+
(available.length > 0
|
|
144
|
+
? `\nAvailable models:\n ${available.slice(0, MODEL_LIST_LIMIT).join("\n ")}`
|
|
145
|
+
: ""),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 解析 "provider/modelId"(modelId 可含 /,取第一个 / 分割)并查 registry。 */
|
|
150
|
+
function lookupModel(modelStr: string, registry: ModelRegistryLike): ModelInfo | undefined {
|
|
151
|
+
const idx = modelStr.indexOf("/");
|
|
152
|
+
if (idx <= 0) return undefined;
|
|
153
|
+
return registry.find(modelStr.slice(0, idx), modelStr.slice(idx + 1));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 从 model.thinkingLevelMap 提取可用级别,clamp 到最高可用。
|
|
158
|
+
* model.reasoning === false → undefined(不支持 thinking)
|
|
159
|
+
*/
|
|
160
|
+
function resolveThinkingLevel(
|
|
161
|
+
model: { reasoning: boolean; thinkingLevelMap?: Record<string, unknown> },
|
|
162
|
+
requested?: string,
|
|
163
|
+
): string | undefined {
|
|
164
|
+
const levels = availableThinkingLevels(model);
|
|
165
|
+
if (levels.length === 0) return model.reasoning ? requested : undefined;
|
|
166
|
+
if (requested && levels.includes(requested)) return requested;
|
|
167
|
+
// requested 不可用 → 降级到最高可用
|
|
168
|
+
return levels[levels.length - 1];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 列出 model 实际支持的 thinking level(升序)。
|
|
173
|
+
*
|
|
174
|
+
* - model.reasoning === false → [] (不支持 thinking)
|
|
175
|
+
* - 无 thinkingLevelMap → [] (无级别信息,调用方按需透传)
|
|
176
|
+
* - 有 map → THINKING_ORDER 中 map[lvl] != null 的子集(保留升序)
|
|
177
|
+
*
|
|
178
|
+
* confirm 组件 / config-wizard 用它渲染「该模型可选的 thinking 级别」菜单,
|
|
179
|
+
* 取代写死的全集 THINKING_LEVELS(不同 model 支持的级别不同)。
|
|
180
|
+
*/
|
|
181
|
+
export function availableThinkingLevels(
|
|
182
|
+
model: { reasoning: boolean; thinkingLevelMap?: Record<string, unknown> },
|
|
183
|
+
): readonly string[] {
|
|
184
|
+
if (!model.reasoning) return [];
|
|
185
|
+
const map = model.thinkingLevelMap;
|
|
186
|
+
if (!map) return [];
|
|
187
|
+
return THINKING_ORDER.filter((lvl) => map[lvl] != null);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 从 agentName + config 推断 category(agentCategoryOverrides 优先)。 */
|
|
191
|
+
export function inferCategory(
|
|
192
|
+
agentName: string,
|
|
193
|
+
agentConfig: AgentConfig | undefined,
|
|
194
|
+
overrides: Record<string, string>,
|
|
195
|
+
defaultCategory: string,
|
|
196
|
+
): string {
|
|
197
|
+
// 1. 显式 override 命中
|
|
198
|
+
if (overrides[agentName]) return overrides[agentName];
|
|
199
|
+
// 2. agent 名前缀/约定推断
|
|
200
|
+
for (const rule of NAME_INFERENCE) {
|
|
201
|
+
if (rule.pattern.test(agentName)) return rule.category;
|
|
202
|
+
}
|
|
203
|
+
// 3. fallback
|
|
204
|
+
void agentConfig;
|
|
205
|
+
return defaultCategory;
|
|
206
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// src/core/output-collector.ts
|
|
2
|
+
//
|
|
3
|
+
// 结果收集器(逆向:BuiltSession + CollectResultArgs → AgentResult)。
|
|
4
|
+
// 与 session-factory 对称:factory 造 bundle,collector 拆 bundle。
|
|
5
|
+
//
|
|
6
|
+
// 基础层模块:依赖 event-bridge(读累积器)+ session-factory(AgentSessionLike)+ types。
|
|
7
|
+
// 字段来源契约见 docs/subagents/session-runner.md §4。
|
|
8
|
+
|
|
9
|
+
import type { AgentResult, AgentUsage, AgentUsageTotal, ToolCall } from "../types.ts";
|
|
10
|
+
import type { EventBridge } from "./event-bridge.ts";
|
|
11
|
+
import type { AgentSessionLike } from "./session-factory.ts";
|
|
12
|
+
|
|
13
|
+
// ============================================================
|
|
14
|
+
// Result 收集
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
17
|
+
/** collectResult 的入参(字段来源明确,避免多处拼装)。 */
|
|
18
|
+
export interface CollectResultArgs {
|
|
19
|
+
startTime: number;
|
|
20
|
+
success: boolean;
|
|
21
|
+
error: string | undefined;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
sessionFile: string | undefined;
|
|
24
|
+
turns: number;
|
|
25
|
+
usage: AgentUsageTotal | undefined;
|
|
26
|
+
toolCalls: ToolCall[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** structured-output tool 名(与 structured-output 扩展 TOOL_NAME 一致,见 session-runner.ts)。 */
|
|
30
|
+
const STRUCTURED_OUTPUT_TOOL = "structured-output";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 从 toolCalls 提取 structured-output 的 result.details(schema 模式产出)。
|
|
34
|
+
* schema enforcement 保证 agent 调过该 tool(漏调会 steer 重试);这里只做逆向提取。
|
|
35
|
+
* 未调或无 details 返回 undefined。
|
|
36
|
+
*
|
|
37
|
+
* 导出以便直接单测(与 toUsageTotal/collectResponseText 一致,三者同属决定
|
|
38
|
+
* AgentResult 字段来源的纯函数契约)。
|
|
39
|
+
*/
|
|
40
|
+
export function extractParsedOutput(toolCalls: ToolCall[]): unknown {
|
|
41
|
+
for (let i = toolCalls.length - 1; i >= 0; i--) {
|
|
42
|
+
const tc = toolCalls[i]!;
|
|
43
|
+
if (tc.toolName === STRUCTURED_OUTPUT_TOOL && tc.result?.details !== undefined) {
|
|
44
|
+
return tc.result.details;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 从 session + bridge 组装 AgentResult。每个字段来源单一:
|
|
52
|
+
* text ← session.messages 最后一条 assistant message 的 text 部分(倒序找)
|
|
53
|
+
* turns ← bridge.turnCount
|
|
54
|
+
* usage ← bridge.usage(全零则 undefined)
|
|
55
|
+
* toolCalls ← bridge.toolCalls
|
|
56
|
+
* parsedOutput ← toolCalls 找 toolName==="structured-output" 的 result.details
|
|
57
|
+
*
|
|
58
|
+
* success 双来源判定:
|
|
59
|
+
* ① session.prompt() 抛错 → args.success=false
|
|
60
|
+
* ② prompt 成功但 bridge.lastError 非空(message_end stopReason=error)→ success=false
|
|
61
|
+
*/
|
|
62
|
+
export function collectResult(
|
|
63
|
+
session: AgentSessionLike,
|
|
64
|
+
bridge: EventBridge,
|
|
65
|
+
args: CollectResultArgs,
|
|
66
|
+
): AgentResult {
|
|
67
|
+
void bridge; // bridge 累积器已在调用方(run)经 toUsageTotal/slice 提取后传入 args
|
|
68
|
+
return {
|
|
69
|
+
text: collectResponseText(session.messages),
|
|
70
|
+
turns: args.turns,
|
|
71
|
+
durationMs: Date.now() - args.startTime,
|
|
72
|
+
success: args.success,
|
|
73
|
+
error: args.error,
|
|
74
|
+
sessionId: args.sessionId,
|
|
75
|
+
toolCalls: args.toolCalls,
|
|
76
|
+
usage: args.usage,
|
|
77
|
+
sessionFile: args.sessionFile,
|
|
78
|
+
parsedOutput: extractParsedOutput(args.toolCalls),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 从 session.messages 最后一条 assistant message 提取文本。 */
|
|
83
|
+
export function collectResponseText(
|
|
84
|
+
messages: ReadonlyArray<{
|
|
85
|
+
role: string;
|
|
86
|
+
content?: ReadonlyArray<{ type: string; text?: string }>;
|
|
87
|
+
}>,
|
|
88
|
+
): string {
|
|
89
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
90
|
+
const msg = messages[i];
|
|
91
|
+
if (msg.role !== "assistant") continue;
|
|
92
|
+
const parts = msg.content ?? [];
|
|
93
|
+
let text = "";
|
|
94
|
+
for (const part of parts) {
|
|
95
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
96
|
+
text += part.text;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return text;
|
|
100
|
+
}
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================================
|
|
105
|
+
// Usage 纯 helper
|
|
106
|
+
// ============================================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* AgentUsage & {cost} → AgentUsageTotal(全零则 undefined)。
|
|
110
|
+
* 纯函数:run 在 collectResult 前用它规整 bridge.usage。
|
|
111
|
+
*/
|
|
112
|
+
export function toUsageTotal(
|
|
113
|
+
usage: AgentUsage & { cost: number },
|
|
114
|
+
): AgentUsageTotal | undefined {
|
|
115
|
+
const total = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
|
116
|
+
if (total === 0 && usage.cost === 0) return undefined;
|
|
117
|
+
return { ...usage, total };
|
|
118
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/core/path-encoding.ts
|
|
2
|
+
//
|
|
3
|
+
// cwd → 安全目录名的编码逻辑。Core 叶子原语(零依赖)。
|
|
4
|
+
//
|
|
5
|
+
// 被 session-factory(subagent session 持久化目录)与 history-store(history.jsonl
|
|
6
|
+
// 目录)共用——两处需要相同的编码,否则同一 cwd 会落到两个不同目录。
|
|
7
|
+
// 历史上两文件各有一份逐字相同的副本,此文件消除该重复。
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* cwd → 安全目录名。复用 Pi SDK getDefaultSessionDir 的编码逻辑:
|
|
11
|
+
* 去开头单个分隔符,全量替换剩余分隔符/冒号为 `-`,首尾补 `--`。
|
|
12
|
+
* 例:`/Users/x/proj` → `--Users-x-proj--`。
|
|
13
|
+
*/
|
|
14
|
+
export function encodeCwd(cwd: string): string {
|
|
15
|
+
return "--" + cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-") + "--";
|
|
16
|
+
}
|