@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.
Files changed (37) hide show
  1. package/agents/context-builder.md +19 -0
  2. package/agents/oracle.md +19 -0
  3. package/agents/planner.md +19 -0
  4. package/agents/researcher.md +19 -0
  5. package/agents/reviewer.md +19 -0
  6. package/agents/scout.md +19 -0
  7. package/agents/worker.md +18 -0
  8. package/index.ts +1 -0
  9. package/package.json +59 -0
  10. package/src/commands/subagents.ts +78 -0
  11. package/src/core/agent-registry.ts +222 -0
  12. package/src/core/concurrency-pool.ts +78 -0
  13. package/src/core/event-bridge.ts +199 -0
  14. package/src/core/execution-record.ts +500 -0
  15. package/src/core/model-resolver.ts +206 -0
  16. package/src/core/output-collector.ts +118 -0
  17. package/src/core/path-encoding.ts +16 -0
  18. package/src/core/session-factory.ts +365 -0
  19. package/src/core/session-runner.ts +303 -0
  20. package/src/core/turn-limiter.ts +71 -0
  21. package/src/index.ts +104 -0
  22. package/src/runtime/config/config.ts +170 -0
  23. package/src/runtime/discovery-config.ts +135 -0
  24. package/src/runtime/execution/history-store.ts +196 -0
  25. package/src/runtime/execution/notifier.ts +209 -0
  26. package/src/runtime/execution/record-store.ts +280 -0
  27. package/src/runtime/model-config-service.ts +265 -0
  28. package/src/runtime/session-file-gc.ts +70 -0
  29. package/src/runtime/subagent-service.ts +549 -0
  30. package/src/tools/subagent-tool.ts +286 -0
  31. package/src/tui/bg-notify-render.ts +139 -0
  32. package/src/tui/config-wizard.ts +253 -0
  33. package/src/tui/format-helpers.ts +37 -0
  34. package/src/tui/format.ts +332 -0
  35. package/src/tui/list-view.ts +883 -0
  36. package/src/tui/tool-render.ts +467 -0
  37. package/src/types.ts +334 -0
@@ -0,0 +1,365 @@
1
+ // src/core/session-factory.ts
2
+ //
3
+ // Pi session 组装器(正向:input → BuiltSession)。四步组装一个就绪的 session +
4
+ // 已订阅的 EventBridge,供 session-runner 使用。
5
+ //
6
+ // 基础层模块:依赖 event-bridge(内核数据通路)+ types + model-resolver。
7
+ // 不依赖编排层(session-runner)——被它消费,反之禁止。
8
+ // 组装契约见 docs/subagents/session-runner.md §3。
9
+
10
+ import { execFileSync } from "node:child_process";
11
+ import * as path from "node:path";
12
+
13
+ import type { AgentEvent } from "../types.ts";
14
+ import {
15
+ createEventBridge,
16
+ type EventBridge,
17
+ isSdkEvent,
18
+ type SdkEvent,
19
+ } from "./event-bridge.ts";
20
+ import type { AgentConfig, ModelRegistryLike, ResolvedModel } from "./model-resolver.ts";
21
+ import { encodeCwd } from "./path-encoding.ts";
22
+
23
+ // ============================================================
24
+ // SDK 类型(duck-typed 最小子集,测试可 mock)
25
+ // ============================================================
26
+
27
+ /** AgentSession 的最小可用接口(duck-typed,与 SDK AgentSession 结构兼容)。 */
28
+ export interface AgentSessionLike {
29
+ prompt(task: string, options?: unknown): Promise<void>;
30
+ steer(message: string): Promise<void>;
31
+ abort(): Promise<void>;
32
+ dispose(): void;
33
+ subscribe(fn: (event: unknown) => void): () => void;
34
+ sessionId: string;
35
+ /** 暴露 sessionManager 以读取 sessionFile 路径。 */
36
+ readonly sessionManager: {
37
+ getSessionFile(): string | undefined;
38
+ getSessionId(): string;
39
+ };
40
+ messages: ReadonlyArray<{
41
+ role: string;
42
+ content?: ReadonlyArray<{ type: string; text?: string }>;
43
+ }>;
44
+ getAllTools(): Array<{ name: string }>;
45
+ setActiveToolsByName(names: string[]): void;
46
+ }
47
+
48
+ /**
49
+ * DefaultResourceLoader 的最小可用接口(duck-typed)。
50
+ * 只暴露 createAndConfigureSession 用到的 reload()。
51
+ */
52
+ export interface ResourceLoaderLike {
53
+ reload(): Promise<void>;
54
+ }
55
+
56
+ /**
57
+ * createAgentSession 入参的类型化子集(对应 SDK CreateAgentSessionOptions)。
58
+ * 仅声明 createAndConfigureSession 实际传递的字段——其余字段(authStorage/
59
+ * scopedModels/tools/customTools…)由 SDK 默认值处理,不在此声明。
60
+ *
61
+ * ╔══════════════════════════════════════════════════════════════╗
62
+ // ║ 字段来源: ║
63
+ // ║ model ← input.resolved.model ║
64
+ // ║ thinkingLevel ← input.resolved.thinkingLevel(string) ║
65
+ // ║ cwd ← ctx.cwd ║
66
+ // ║ resourceLoader ← 步骤 2 构建的 loader ║
67
+ // ║ modelRegistry ← ctx.modelRegistry ║
68
+ // ║ sessionManager ← SessionManager.create(cwd, subagentDir) ║
69
+ // ╚══════════════════════════════════════════════════════════════╝
70
+ *
71
+ * ⚠ model / thinkingLevel 用宽泛类型(unknown / string)而非 SDK 的 Model<T> /
72
+ * ThinkingLevel——避免 Core 层 import SDK 类型,保持鸭子类型可测。
73
+ * 实际传入的是 ModelInfo(model-resolver.ts),结构兼容即可。
74
+ */
75
+ export interface CreateAgentSessionArgs {
76
+ /** 模型实例(实际为 ModelInfo,duck-typed 兼容 SDK Model)。 */
77
+ model: unknown;
78
+ /** 思考强度("low" | "medium" | "high" | undefined,SDK 会 clamp 到 model 能力)。 */
79
+ thinkingLevel?: string;
80
+ /** 工作目录。 */
81
+ cwd: string;
82
+ /** 步骤 2 构建的 ResourceLoader。 */
83
+ resourceLoader: ResourceLoaderLike;
84
+ /** 模型注册表(鉴权 + 发现)。 */
85
+ modelRegistry: ModelRegistryLike;
86
+ /** Session 管理器(持久化 / inMemory)。 */
87
+ sessionManager: unknown;
88
+ }
89
+
90
+ /**
91
+ * DefaultResourceLoader 构造参数的类型化子集(对应 SDK DefaultResourceLoaderOptions)。
92
+ * 仅声明 buildResourceLoader 实际传递的字段。
93
+ */
94
+ export interface ResourceLoaderOptions {
95
+ cwd: string;
96
+ agentDir: string;
97
+ /** 步骤 1 组装好的 appendSystemPrompt(含 env block)。 */
98
+ appendSystemPrompt: string[];
99
+ /** 注入到子 session 的额外 skill 路径(undefined 时省略该字段)。 */
100
+ additionalSkillPaths?: string[];
101
+ }
102
+
103
+ /**
104
+ * Pi SDK 动态 import 的形状(session-factory 通过 getSdk() 获取)。
105
+ * 类型化的 createAgentSession / DefaultResourceLoader 让 createAndConfigureSession
106
+ * 的调用点在编译期校验字段名——拼错 key 立即 tsc 报错。
107
+ */
108
+ export interface SdkLike {
109
+ /** ResourceLoader 工厂(步骤 2 构造,参数类型化为 ResourceLoaderOptions)。 */
110
+ DefaultResourceLoader: new (opts: ResourceLoaderOptions) => ResourceLoaderLike;
111
+ /** SessionManager 支持 inMemory(测试)和 create(持久化)两种工厂。 */
112
+ SessionManager: {
113
+ inMemory(cwd?: string): unknown;
114
+ create(cwd: string, sessionDir?: string): unknown;
115
+ };
116
+ /** createAgentSession(步骤 3,参数类型化为 CreateAgentSessionArgs)。 */
117
+ createAgentSession: (opts: CreateAgentSessionArgs) => Promise<{ session: AgentSessionLike }>;
118
+ }
119
+
120
+ // ============================================================
121
+ // 依赖容器 + 输入/输出
122
+ // ============================================================
123
+
124
+ /** 创建 session 所需的依赖(由 SubagentService 提供)。 */
125
+ export interface SessionFactoryContext {
126
+ modelRegistry: ModelRegistryLike;
127
+ resolveAgent: (name: string) => AgentConfig | undefined;
128
+ cwd: string;
129
+ agentDir: string;
130
+ /**
131
+ * 额外 skill 目录(从 discovery.json 读,靠前覆盖靠后)。
132
+ * 注入子 session 的 additionalSkillPaths,补齐 createAgentSession 不继承
133
+ * 主 session 动态 skill 的缺陷(见 ADR-025)。
134
+ */
135
+ skillDirs: string[];
136
+ }
137
+
138
+ /** createAndConfigureSession 的输入选项。 */
139
+ export interface CreateSessionInput {
140
+ /** 已解析的模型(由 resolveModelForAgent 产出)。 */
141
+ resolved: ResolvedModel;
142
+ /** systemPrompt 追加内容(调用方可传 agent body 等)。 */
143
+ appendSystemPrompt?: string[];
144
+ /** skill 路径。 */
145
+ skillPath?: string;
146
+ /** agent 配置(提取 tool 过滤策略)。 */
147
+ agentConfig?: AgentConfig;
148
+ /** 事件回调。 */
149
+ onEvent?: (event: AgentEvent) => void;
150
+ }
151
+
152
+ /** createAndConfigureSession 的输出。 */
153
+ export interface BuiltSession {
154
+ session: AgentSessionLike;
155
+ bridge: EventBridge;
156
+ unsubscribe: () => void;
157
+ /** subagent session 文件绝对路径(未持久化时为 undefined)。 */
158
+ sessionFile?: string;
159
+ }
160
+
161
+ // ============================================================
162
+ // SDK 装配
163
+ // ============================================================
164
+
165
+ /** 动态 import Pi SDK(集中在此处,便于测试 mock)。 */
166
+ export async function getSdk(): Promise<SdkLike> {
167
+ const mod = await import("@mariozechner/pi-coding-agent");
168
+ // 双重断言必要:ESM 动态 import 返回完整模块类型,与手写鸭子类型 SdkLike
169
+ // (最小子集)结构不兼容;运行时该模块确实暴露了所需的三个导出。
170
+ // eslint-disable-next-line taste/no-unsafe-cast
171
+ return mod as unknown as SdkLike;
172
+ }
173
+
174
+ /**
175
+ * 创建并配置一个 Pi AgentSession(四步,顺序不可换)。
176
+ * 设计意图见 docs/subagents/session-runner.md §3;本函数只翻译控制流,
177
+ * 每步的具体实现下沉到下方叶子(全部 throw not implemented)。
178
+ *
179
+ * ╔══════════════════════════════════════════════════════════════════╗
180
+ // ║ 步骤 1:appendSystemPrompt 组装(含环境块,防注入) ║
181
+ // ║ 步骤 2:ResourceLoader 构建 + reload(发现 skills/agents) ║
182
+ // ║ 步骤 3:createAgentSession + 工具过滤(FR-1.7 偏差,创建后过滤) ║
183
+ // ║ 步骤 4:EventBridge 订阅(SDK event → AgentEvent) ║
184
+ // ╚══════════════════════════════════════════════════════════════════╝
185
+ */
186
+ export async function createAndConfigureSession(
187
+ input: CreateSessionInput,
188
+ ctx: SessionFactoryContext,
189
+ sdk: SdkLike,
190
+ ): Promise<BuiltSession> {
191
+ // 步骤 1:appendSystemPrompt 组装(env block + 调用方片段)
192
+ const fullAppend = buildAppendSystemPrompt(input.appendSystemPrompt, ctx.cwd);
193
+
194
+ // 步骤 2:ResourceLoader 构建 + reload(让 loader 发现全局 skills/agents)
195
+ // additionalSkillPaths = discovery 目录(主 session 动态 skill)+ 调用方传入的 skillPath
196
+ const additionalSkillPaths = [...ctx.skillDirs, input.skillPath].filter(
197
+ (p): p is string => typeof p === "string" && p.length > 0,
198
+ );
199
+ const resourceLoader = buildResourceLoader(sdk, {
200
+ cwd: ctx.cwd,
201
+ agentDir: ctx.agentDir,
202
+ appendSystemPrompt: fullAppend,
203
+ additionalSkillPaths: additionalSkillPaths.length > 0 ? additionalSkillPaths : undefined,
204
+ });
205
+ await resourceLoader.reload();
206
+
207
+ // 步骤 3:createAgentSession + 工具过滤
208
+ // session 持久化目录与主 session 物理隔离(<agentDir>/subagents/<encoded-cwd>/sessions/)
209
+ const subagentSessionDir = getSubagentSessionDir(ctx.agentDir, ctx.cwd);
210
+ const { session } = await sdk.createAgentSession({
211
+ model: input.resolved.model,
212
+ thinkingLevel: input.resolved.thinkingLevel,
213
+ cwd: ctx.cwd,
214
+ resourceLoader,
215
+ modelRegistry: ctx.modelRegistry,
216
+ sessionManager: sdk.SessionManager.create(ctx.cwd, subagentSessionDir),
217
+ });
218
+
219
+ // 步骤 4:工具过滤 + EventBridge 订阅。
220
+ // 包 try/catch——createAgentSession 已成功(Pi session 已建),若 applyToolFilter /
221
+ // subscribe 抛错必须 dispose 已创建的 session,否则泄漏(H2 修复)。
222
+ try {
223
+ applyToolFilter(session, input.agentConfig);
224
+
225
+ // EventBridge 订阅——SDK event 经 isSdkEvent guard 后喂给 bridge
226
+ const bridge = createEventBridge(input.onEvent ?? (() => {}));
227
+ const unsubscribe = session.subscribe((event: unknown) => {
228
+ if (!isSdkEvent(event)) return;
229
+ bridge.handle(event as SdkEvent);
230
+ });
231
+
232
+ return {
233
+ session,
234
+ bridge,
235
+ unsubscribe,
236
+ sessionFile: session.sessionManager.getSessionFile() ?? undefined,
237
+ };
238
+ } catch (err) {
239
+ // 防御:post-creation 步骤抛错时拆掉已创建的 Pi session,避免连接泄漏。
240
+ // SDK dispose() 实际幂等(agent-session.js try/catch 包裹 + idempotent 清理)。
241
+ try {
242
+ session.dispose();
243
+ } catch (disposeErr) {
244
+ // dispose 本身抛错不应掩盖原始错误(SDK impl 已内部 catch,此处仅兜底)
245
+ console.error("[subagents] session.dispose() threw during cleanup:", disposeErr);
246
+ }
247
+ throw err;
248
+ }
249
+ }
250
+
251
+ // ============================================================
252
+ // createAndConfigureSession 的子叶子(全部 throw not implemented)
253
+ // ============================================================
254
+
255
+ /**
256
+ * 步骤 1:组装 appendSystemPrompt。env block 在前(防注入标记),调用方片段在后。
257
+ *
258
+ * fullAppend = [buildEnvBlock(cwd)] + (appendSystemPrompt ?? [])
259
+ */
260
+ export function buildAppendSystemPrompt(
261
+ appendSystemPrompt: string[] | undefined,
262
+ cwd: string,
263
+ ): string[] {
264
+ return [buildEnvBlock(cwd), ...(appendSystemPrompt ?? [])];
265
+ }
266
+
267
+ /**
268
+ * 步骤 2:构建 DefaultResourceLoader。opts 字段已对齐 SDK 入参形状
269
+ * (additionalSkillPaths 由调用方从 skillPath 翻译)。agentDir 让 loader 发现全局 skills/agents。
270
+ */
271
+ export function buildResourceLoader(
272
+ sdk: SdkLike,
273
+ opts: ResourceLoaderOptions,
274
+ ): ResourceLoaderLike {
275
+ return new sdk.DefaultResourceLoader(opts);
276
+ }
277
+
278
+ /**
279
+ * 步骤 3:计算 subagent session 持久化目录。
280
+ * <agentDir>/subagents/<encoded-cwd>/sessions/
281
+ * <encoded-cwd> 对 cwd 做路径安全编码(替换 / 等非法字符),与主 session 物理隔离。
282
+ * agentDir 由 Pi 核心 getAgentDir() 决定,支持宿主经 PI_CODING_AGENT_DIR 重定向。
283
+ */
284
+ export function getSubagentSessionDir(agentDir: string, cwd: string): string {
285
+ return path.join(agentDir, "subagents", encodeCwd(cwd), "sessions");
286
+ }
287
+
288
+ /**
289
+ * 步骤 3:三层工具过滤 + setActiveToolsByName。
290
+ *
291
+ * ╔══════════════════════════════════════════════════════════════╗
292
+ // ║ 1. allTools = session.getAllTools().map(t => t.name) ║
293
+ // ║ 2. allowlist = filterTools(allTools, agentConfig): ║
294
+ // ║ ① agentConfig.tools 白名单(agent 声明可用) ║
295
+ // ║ ② excludeTools 黑名单(未来扩展,现未接入) ║
296
+ // ║ ③ extSelectors 扩展工具选择器(未来扩展) ║
297
+ // ║ 3. 仅当 allowlist.length < allTools.length 时调 ║
298
+ // ║ session.setActiveToolsByName(allowlist) ║
299
+ // ╚══════════════════════════════════════════════════════════════╝
300
+ *
301
+ * ⚠ SDK 约束(spec FR-1.7 偏差):工具过滤必须创建后执行——
302
+ * createAgentSession({tools}) 构造时传 allowlist 需预知工具全集,但扩展工具要等
303
+ * resourceLoader 加载后才注册。SDK 无 resourceLoader.getTools() 预加载 API。
304
+ * 因此只能创建后用 setActiveToolsByName 兜底。仅当 allowlist 严格小于 allTools
305
+ * 时才调(避免无谓调用)。(设计意图,留注释)
306
+ */
307
+ export function applyToolFilter(
308
+ session: AgentSessionLike,
309
+ agentConfig: AgentConfig | undefined,
310
+ ): void {
311
+ // 无白名单 → 不过滤(agent 可用全部工具)
312
+ const allowlist = agentConfig?.tools;
313
+ if (!allowlist || allowlist.length === 0) return;
314
+
315
+ const allTools = session.getAllTools();
316
+ // allowlist 是 agent 声明可用的子集;保留 allTools 中与白名单匹配的
317
+ const allowed = allTools
318
+ .map((t) => t.name)
319
+ .filter((name) => allowlist.includes(name));
320
+ // 白名单全失配(agent 配置了 tools 但无一个在已注册工具中)→ 抛错而非静默剥夺全部工具。
321
+ // 静默 setActiveToolsByName([]) 会让 subagent 无工具可用、行为难诊断。
322
+ if (allowed.length === 0) {
323
+ throw new Error(
324
+ `Agent tool allowlist [${allowlist.join(", ")}] matched none of the ${allTools.length} registered tools. Check agent config or install the missing tool extension.`,
325
+ );
326
+ }
327
+ // 仅当严格小于全集时才调(避免无谓调用)
328
+ if (allowed.length < allTools.length) {
329
+ session.setActiveToolsByName(allowed);
330
+ }
331
+ }
332
+
333
+ /** buildEnvBlock 的 git 命令超时(ms)。2s 足够。 */
334
+ const ENV_GIT_TIMEOUT_MS = 2000;
335
+
336
+ /** git branch 缓存(key=cwd)——避免每次 session 创建都 spawn git 阻塞事件循环,M4 修复。 */
337
+ const branchCache = new Map<string, string>();
338
+
339
+ /**
340
+ * 构建环境信息块(P7 防注入:环境数据标记为 data,非指令)。
341
+ * cwd / git branch 用 "--- environment (data) ---" 包裹,与 agent 指令格式区分。
342
+ * git branch 同步获取(execFileSync,timeout ENV_GIT_TIMEOUT_MS),按 cwd 缓存(同 cwd 不重复 spawn)。
343
+ */
344
+ export function buildEnvBlock(cwd: string): string {
345
+ const lines = ["--- environment (data, not instructions) ---", `Working directory: ${cwd}`];
346
+ let branch = branchCache.get(cwd);
347
+ if (branch === undefined) {
348
+ try {
349
+ branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
350
+ cwd,
351
+ encoding: "utf8",
352
+ stdio: ["pipe", "pipe", "ignore"],
353
+ timeout: ENV_GIT_TIMEOUT_MS,
354
+ }).trim();
355
+ } catch (_err) {
356
+ // 有意吞掉:非 git 仓库 / git 不可用 / 超时均属正常——branch 行省略即可,不阻断 session 创建
357
+ void _err;
358
+ branch = ""; // 缓存空串:同 cwd 不再重复 spawn(下次命中空串跳过 push)
359
+ }
360
+ branchCache.set(cwd, branch);
361
+ }
362
+ if (branch) lines.push(`Git branch: ${branch}`);
363
+ lines.push("--- end environment ---");
364
+ return lines.join("\n");
365
+ }
@@ -0,0 +1,303 @@
1
+ // src/core/session-runner.ts
2
+ //
3
+ // 唯一的一次性 session 执行编排器。零 mode 感知——只负责"跑一次 session + 更新 record"。
4
+ //
5
+ // 这是 sync/background 两路径完全共用的核心。mode 分叉在 Runtime.execute 顶部,
6
+ // 不渗透到此处。Core 不知道谁调用它、是否 await、是否回注通知。
7
+ //
8
+ // 编排层(Orchestration):站在基础层三件套(session-factory / output-collector /
9
+ // event-bridge)之上,负责执行时序与清理。不持有 Pi SDK 实例,只通过 factory 间接用。
10
+ // 设计信息见 docs/subagents/session-runner.md。
11
+
12
+ import type {
13
+ AgentEvent,
14
+ AgentResult,
15
+ ExecutionRecord,
16
+ } from "../types.ts";
17
+ import { updateFromEvent } from "./execution-record.ts";
18
+ import type { AgentConfig, ResolvedModel } from "./model-resolver.ts";
19
+ import { collectResult, toUsageTotal } from "./output-collector.ts";
20
+ import type {
21
+ BuiltSession,
22
+ CreateSessionInput,
23
+ SdkLike,
24
+ SessionFactoryContext,
25
+ } from "./session-factory.ts";
26
+ import { createAndConfigureSession } from "./session-factory.ts";
27
+ import { createTurnLimiter } from "./turn-limiter.ts";
28
+
29
+ // ============================================================
30
+ // 常量
31
+ // ============================================================
32
+
33
+ /** 默认 grace turns(soft limit 后宽限轮数,对齐旧实现 DEFAULT_GRACE_TURNS)。 */
34
+ const DEFAULT_GRACE_TURNS = 2;
35
+
36
+ /** schema 契约 enforcement:agent 漏调 structured-output 时最多 steer 重试次数。
37
+ * 对齐 structured-output 扩展原 setupWorkflowHook 的 MAX_HOOK_RETRIES=2。 */
38
+ const MAX_SCHEMA_STEERS = 2;
39
+
40
+ /** structured-output 工具名(与 structured-output 扩展 TOOL_NAME 一致)。 */
41
+ const STRUCTURED_OUTPUT_TOOL = "structured-output";
42
+
43
+ // ============================================================
44
+ // 依赖注入容器 + 入参
45
+ // ============================================================
46
+
47
+ /** SessionRunner 的依赖注入容器(由 Runtime 提供,解耦 Core 与 Pi SDK 实例)。 */
48
+ export interface SessionRunnerContext {
49
+ /** 进程当前工作目录(传给 createAgentSession)。 */
50
+ cwd: string;
51
+ /** agent 配置目录(由 Pi 核心 getAgentDir() 决定,默认 ~/.pi/agent)。 */
52
+ agentDir: string;
53
+ /** session 工厂上下文(modelRegistry/resolveAgent/cwd/agentDir)。
54
+ * 由 Runtime 装配后注入——run() 必须把它传给 createAndConfigureSession,
55
+ * 因此提升到 context,避免在 run() 内部重新构造。 */
56
+ factoryCtx: SessionFactoryContext;
57
+ /** Pi SDK 实例(由 Runtime 在 session_start 时 dynamic import 一次后注入)。
58
+ * 注入而非 run() 内 import——Core 层保持副作用自由(无顶层 dynamic import)。 */
59
+ sdk: SdkLike;
60
+ }
61
+
62
+ /** SessionRunner.run 的入参。 */
63
+ export interface RunOptions {
64
+ /** 已 resolve 的模型(Runtime 在调用前解析,Core 不重复解析)。 */
65
+ resolved: ResolvedModel;
66
+ /** agent 配置(含 systemPrompt/tools)。 */
67
+ agentConfig: AgentConfig | undefined;
68
+ /** 注入到子 session 的额外 system prompt 片段。 */
69
+ appendSystemPrompt: string[] | undefined;
70
+ /** 注入到子 session 的 skill 路径。 */
71
+ skillPath: string | undefined;
72
+ /** 结构化输出 schema(存在时 enforcement:漏调 structured-output 则 steer)。 */
73
+ schema: Record<string, unknown> | undefined;
74
+ /** hard turn limit。 */
75
+ maxTurns: number | undefined;
76
+ /** soft limit 后宽限轮数(默认 2)。 */
77
+ graceTurns: number | undefined;
78
+ /** 中断信号(Runtime 创建,来源:sync=Pi tool 框架 / bg=controller.signal)。 */
79
+ signal: AbortSignal | undefined;
80
+ /** event 回流——SessionRunner 内部 updateFromEvent 后,再回调调用方(widget/notify)。 */
81
+ onEvent: ((event: AgentEvent) => void) | undefined;
82
+ }
83
+
84
+ // ============================================================
85
+ // Schema 指令
86
+ // ============================================================
87
+
88
+ /** formatSchemaInstruction 的 JSON pretty-print 缩进。 */
89
+ const SCHEMA_JSON_INDENT = 2;
90
+
91
+ /**
92
+ * 构造 schema 指令模板(拼入 task 末尾 + steer reminder 复用)。
93
+ * 指令明确要求 agent 调用 structured-output tool,而非直接输出 JSON 文本。
94
+ */
95
+ export function formatSchemaInstruction(schema: Record<string, unknown>): string {
96
+ return [
97
+ "MANDATORY: Structured Output Requirement",
98
+ "You MUST call the `structured-output` tool with your final answer.",
99
+ "Do NOT output the JSON directly in your text response — you MUST use the structured-output tool.",
100
+ "The schema for the structured output is:",
101
+ "```json",
102
+ JSON.stringify(schema, null, SCHEMA_JSON_INDENT),
103
+ "```",
104
+ ].join("\n");
105
+ }
106
+
107
+ // ============================================================
108
+ // run 编排骨架
109
+ // ============================================================
110
+
111
+ /**
112
+ * turn_end 旁路钩子(turnLimiter + schema enforcement)的统一挂载句柄。
113
+ *
114
+ * ╔══════════════════════════════════════════════════════════════╗
115
+ * ║ run() 通过包装 built 的 onEvent 链把 turn_end 喂给本句柄: ║
116
+ // ║ onTurnEnd(currentTurns): ║
117
+ // ║ ① turnLimiter.onTurnEnd(currentTurns) ← soft/hard 限制 ║
118
+ // ║ ② schema enforcement: bridge.toolCalls 无 structured-output ║
119
+ // ║ 且 schemaSteerCount < MAX_SCHEMA_STEERS → session.steer ║
120
+ // ║ unsubscribe(): 移除 signal→abort 监听(一次性 listener) ║
121
+ // ╚══════════════════════════════════════════════════════════════╝
122
+ *
123
+ * prompt 生命周期钩子集中在此,避免三个独立 subscribe 的时序碎片。
124
+ */
125
+ export interface RunHooks {
126
+ /** 收到 turn_end 时调用(currentTurns 已递增)。 */
127
+ onTurnEnd(currentTurns: number): void;
128
+ /** 卸载 signal→session.abort 监听。 */
129
+ unsubscribe(): void;
130
+ }
131
+
132
+ /**
133
+ * 把 turnLimiter + schema enforcement + signal-abort 绑定到已就绪的 session。
134
+ *
135
+ * ╔══════════════════════════════════════════════════════════════╗
136
+ // ║ 1. turnLimiter = createTurnLimiter({ ║
137
+ // ║ maxTurns: opts.maxTurns ?? 0, ║
138
+ // ║ graceTurns: opts.graceTurns ?? DEFAULT_GRACE_TURNS, ║
139
+ // ║ steer: msg => built.session.steer(msg), ║
140
+ // ║ abort: () => built.session.abort(), ║
141
+ // ║ }) ║
142
+ // ║ 2. signal→abort:opts.signal?.addEventListener("abort", ║
143
+ // ║ () => built.session.abort(), { once: true }) ║
144
+ // ║ 3. onTurnEnd(n): turnLimiter.onTurnEnd(n) + schemaSteer(...) ║
145
+ // ║ 4. unsubscribe(): signal?.removeEventListener("abort", ...) ║
146
+ // ╚══════════════════════════════════════════════════════════════╝
147
+ */
148
+ export function attachRunHooks(built: BuiltSession, opts: RunOptions): RunHooks {
149
+ // 1. turnLimiter:steer/abort 绑到 session
150
+ const limiter = createTurnLimiter({
151
+ maxTurns: opts.maxTurns ?? 0,
152
+ graceTurns: opts.graceTurns ?? DEFAULT_GRACE_TURNS,
153
+ steer: (msg) => {
154
+ void built.session.steer(msg);
155
+ },
156
+ abort: () => {
157
+ void built.session.abort();
158
+ },
159
+ });
160
+
161
+ // 2. signal→abort 监听(一次性):sync 来自 Pi tool 框架,bg 来自 controller
162
+ const onAbort = (): void => {
163
+ void built.session.abort();
164
+ };
165
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
166
+
167
+ // 3. schema enforcement 计数器(opts.schema 存在时启用)
168
+ let schemaSteerCount = 0;
169
+
170
+ const onTurnEnd = (currentTurns: number): void => {
171
+ limiter.onTurnEnd(currentTurns);
172
+
173
+ // schema enforcement:漏调 structured-output 则 steer 提醒(≤ MAX_SCHEMA_STEERS)
174
+ if (!opts.schema) return;
175
+ // 仅看 toolName——isError 视为"调用了但失败",agent 自己会重试修正 schema
176
+ const calledStructuredOutput = built.bridge.toolCalls.some(
177
+ (tc) => tc.toolName === STRUCTURED_OUTPUT_TOOL,
178
+ );
179
+ if (calledStructuredOutput) return;
180
+ if (schemaSteerCount >= MAX_SCHEMA_STEERS) return;
181
+ schemaSteerCount += 1;
182
+ const reminder =
183
+ "[MANDATORY] You MUST call the `structured-output` tool now.\n" +
184
+ "Your task requires structured output — do NOT respond with plain text.\n" +
185
+ "Call structured-output with the schema below and your result as data.\n\n" +
186
+ formatSchemaInstruction(opts.schema);
187
+ void built.session.steer(reminder);
188
+ };
189
+
190
+ const unsubscribe = (): void => {
191
+ opts.signal?.removeEventListener("abort", onAbort);
192
+ };
193
+
194
+ return { onTurnEnd, unsubscribe };
195
+ }
196
+
197
+ // ============================================================
198
+ // run —— 唯一执行入口
199
+ // ============================================================
200
+
201
+ /**
202
+ * 唯一执行入口。返回 AgentResult(成功/失败统一形状)。
203
+ *
204
+ * **契约:正常执行路径不抛错**(prompt 失败、bridge.lastError、turn-limit abort
205
+ * 等均被捕获并合成 failed AgentResult 返回)。**但创建期异常会抛**
206
+ * (createAndConfigureSession / attachRunHooks 失败)——finally 只负责清理
207
+ * 已创建的资源,不吞创建异常。调用方(runAndFinalize)须 catch 后
208
+ * 调 finalizeFailed 合成 failed result,避免异常逃逸到 tool 层。
209
+ *
210
+ * ╔══════════════════════════════════════════════════════════════════╗
211
+ // ║ pool.acquire(priority) ◄── 外层调用方负责 ║
212
+ // ║ │ ║
213
+ // ║ ▼ ║
214
+ // ║ run(record, task, opts, ctx) ║
215
+ // ║ │ ║
216
+ // ║ ├─ a. createAndConfigureSession(model, tools, skills, cwd) ║
217
+ // ║ ├─ b. EventBridge.subscribe(session) ║
218
+ // ║ │ └─ event → updateFromEvent(record) ◄── 唯一更新点 ║
219
+ // ║ │ └─ event → opts.onEvent(event) ◄── 回流调用方 ║
220
+ // ║ ├─ c. turnLimiter.attach(session) ║
221
+ // ║ ├─ d. signal → session.abort 监听(一次性) ║
222
+ // ║ ├─ e. schema enforcement: turn_end 时漏调 structured-output ║
223
+ // ║ │ 则 session.steer(reminder)(≤ MAX_SCHEMA_STEERS) ║
224
+ // ║ ├─ f. session.prompt(task + schemaInstruction) ║
225
+ // ║ ├─ g. collectResult(bridge) → AgentResult ║
226
+ // ║ └─ h. session.dispose() ║
227
+ // ║ ║
228
+ // ║ finally: pool.release() ◄── 外层调用方负责 ║
229
+ // ╚══════════════════════════════════════════════════════════════════╝
230
+ *
231
+ * record 在此函数内被 updateFromEvent 实时更新,但**不被 completeRecord**——
232
+ * 完成态由 Runtime.execute 统一写(保证 status 判定逻辑单点)。
233
+ */
234
+ export async function run(
235
+ record: ExecutionRecord,
236
+ task: string,
237
+ opts: RunOptions,
238
+ ctx: SessionRunnerContext,
239
+ ): Promise<AgentResult> {
240
+ const startTime = Date.now();
241
+
242
+ // a/b. createAnd configure session + EventBridge 订阅。
243
+ // onEvent wrapper 把两条流挂上:① updateFromEvent(record)(唯一 record 更新点)
244
+ // ② opts.onEvent(回流调用方 widget/notify)。turn_end 还需喂给 hooks,
245
+ // 但 hooks 依赖 built(鸡生蛋)—— 先用闭包变量在 prompt 前接上线。
246
+ let hooks: RunHooks | undefined;
247
+ const onEvent: CreateSessionInput["onEvent"] = (event: AgentEvent): void => {
248
+ updateFromEvent(record, event);
249
+ if (event.type === "turn_end") hooks?.onTurnEnd(record.turns);
250
+ opts.onEvent?.(event);
251
+ };
252
+
253
+ let built: BuiltSession | undefined;
254
+ try {
255
+ built = await createAndConfigureSession(
256
+ {
257
+ resolved: opts.resolved,
258
+ appendSystemPrompt: opts.appendSystemPrompt,
259
+ skillPath: opts.skillPath,
260
+ agentConfig: opts.agentConfig,
261
+ onEvent,
262
+ },
263
+ ctx.factoryCtx,
264
+ ctx.sdk,
265
+ );
266
+
267
+ // c/d/e. turnLimiter + signal-abort + schema enforcement 统一挂载
268
+ hooks = attachRunHooks(built, opts);
269
+
270
+ // f. session.prompt(schema 指令拼到 task 末尾)
271
+ let success = true;
272
+ let error: string | undefined;
273
+ try {
274
+ const instruction = opts.schema ? formatSchemaInstruction(opts.schema) : "";
275
+ await built.session.prompt(task + instruction);
276
+ // g. 双来源 success 判定:prompt 成功但 bridge.lastError 非空也算失败
277
+ if (built.bridge.lastError) {
278
+ success = false;
279
+ error = built.bridge.lastError;
280
+ }
281
+ } catch (err) {
282
+ success = false;
283
+ error = err instanceof Error ? err.message : String(err);
284
+ }
285
+
286
+ // g. collectResult 组装 AgentResult
287
+ return collectResult(built.session, built.bridge, {
288
+ startTime,
289
+ success,
290
+ error,
291
+ sessionId: built.session.sessionId,
292
+ sessionFile: built.session.sessionManager.getSessionFile() ?? undefined,
293
+ turns: built.bridge.turnCount,
294
+ usage: toUsageTotal(built.bridge.usage),
295
+ toolCalls: built.bridge.toolCalls.slice(),
296
+ });
297
+ } finally {
298
+ // h. 清理:hooks(signal listener)→ unsubscribe(session.subscribe)→ dispose
299
+ hooks?.unsubscribe();
300
+ built?.unsubscribe();
301
+ built?.session.dispose();
302
+ }
303
+ }