@zhushanwen/pi-subagent-workflow 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/context-builder.md +1 -3
- package/agents/oracle.md +2 -2
- package/agents/planner.md +1 -3
- package/agents/researcher.md +0 -2
- package/agents/reviewer.md +2 -2
- package/agents/scout.md +13 -3
- package/agents/worker.md +0 -2
- package/package.json +5 -3
- package/skills/workflow-script-format/SKILL.md +6 -6
- package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
- package/src/execution/__tests__/concurrency-pool.test.ts +33 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +40 -8
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +60 -0
- package/src/execution/__tests__/sdk-contract.test.ts +5 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +20 -0
- package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
- package/src/execution/__tests__/tool-action.test.ts +26 -4
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/concurrency-pool.ts +38 -6
- package/src/execution/execute-options-mapper.ts +21 -4
- package/src/execution/execution-record.ts +5 -0
- package/src/execution/record-store.ts +2 -0
- package/src/execution/session-reconstructor.ts +11 -0
- package/src/execution/session-runner.ts +12 -0
- package/src/execution/stream-sink.ts +83 -0
- package/src/execution/subagent-service.ts +68 -43
- package/src/execution/subprocess-agent-runner.ts +16 -4
- package/src/execution/types.ts +23 -3
- package/src/index.ts +15 -2
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
- package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
- package/src/interface/command-actions.ts +77 -0
- package/src/interface/commands.ts +40 -4
- package/src/interface/gui-mappers.ts +83 -0
- package/src/interface/helpers.ts +52 -9
- package/src/interface/list-component.ts +3 -1
- package/src/interface/subagent-actions.ts +35 -22
- package/src/interface/subagent-tool.ts +54 -23
- package/src/interface/subagents.ts +45 -5
- package/src/interface/tool-render.ts +16 -5
- package/src/interface/tool-workflow-script.ts +113 -15
- package/src/interface/tool-workflow.ts +92 -34
- package/src/interface/views/WorkflowsView.ts +13 -4
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
- package/src/interface/views/detail-content.ts +20 -0
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
- package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
- package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
- package/src/orchestration/agent-opts-resolver.ts +11 -2
- package/src/orchestration/error-recovery.ts +131 -23
- package/src/orchestration/execute-agent-call.ts +12 -3
- package/src/orchestration/jsonl-run-store.ts +10 -0
- package/src/orchestration/lifecycle.ts +1 -1
- package/src/orchestration/models/agent-call.ts +7 -0
- package/src/orchestration/models/ports.ts +15 -2
- package/src/orchestration/models/run-spec.ts +6 -0
- package/src/orchestration/models/trace.ts +1 -0
- package/src/orchestration/models/types.ts +19 -0
- package/src/orchestration/node-ops.ts +2 -0
- package/src/orchestration/worker-script-builder.ts +1 -0
- package/workflows/README.md +58 -0
- package/workflows/chain.js +107 -0
- package/workflows/map-reduce.js +142 -0
- package/workflows/parallel.js +131 -0
- package/workflows/scatter-gather.js +146 -0
- package/examples/README.md +0 -43
- package/examples/chain.example.js +0 -92
- package/examples/map-reduce.example.js +0 -99
- package/examples/parallel.example.js +0 -82
- package/examples/scatter-gather.example.js +0 -106
- package/src/interface/gui-adapter.ts +0 -136
|
@@ -23,6 +23,7 @@ function makeDetails(over: Partial<SubagentToolDetails> = {}): SubagentToolDetai
|
|
|
23
23
|
agent: "worker",
|
|
24
24
|
model: "test/model",
|
|
25
25
|
thinkingLevel: undefined,
|
|
26
|
+
slug: "test-slug",
|
|
26
27
|
turns: 1,
|
|
27
28
|
totalTokens: 10,
|
|
28
29
|
elapsedSeconds: 1,
|
|
@@ -40,6 +41,7 @@ function makeSnapshot(over: Partial<RecordSnapshot> = {}): RecordSnapshot {
|
|
|
40
41
|
thinkingLevel: undefined,
|
|
41
42
|
mode: "background",
|
|
42
43
|
task: "t",
|
|
44
|
+
slug: "test-slug",
|
|
43
45
|
status: "done",
|
|
44
46
|
eventLog: [],
|
|
45
47
|
turns: 1,
|
|
@@ -74,7 +76,24 @@ describe("startHandler", () => {
|
|
|
74
76
|
|
|
75
77
|
it("task 空白 → throw", async () => {
|
|
76
78
|
const svc = makeService();
|
|
77
|
-
await expect(startHandler(svc, { task: " " }, undefined)).rejects.toThrow(/task is required/);
|
|
79
|
+
await expect(startHandler(svc, { task: " ", slug: "x" }, undefined)).rejects.toThrow(/task is required/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("slug 缺失 → throw", async () => {
|
|
83
|
+
const svc = makeService();
|
|
84
|
+
await expect(startHandler(svc, { task: "ok" }, undefined)).rejects.toThrow(/slug is required/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("slug 空白 → throw", async () => {
|
|
88
|
+
const svc = makeService();
|
|
89
|
+
await expect(startHandler(svc, { task: "ok", slug: " " }, undefined)).rejects.toThrow(/slug is required/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("slug 超 20 字符 → throw", async () => {
|
|
93
|
+
const svc = makeService();
|
|
94
|
+
await expect(
|
|
95
|
+
startHandler(svc, { task: "ok", slug: "a".repeat(21) }, undefined),
|
|
96
|
+
).rejects.toThrow(/≤20 chars/);
|
|
78
97
|
});
|
|
79
98
|
|
|
80
99
|
it("background 启动 → kind=bg + bgResponse.message 含 detached", async () => {
|
|
@@ -86,7 +105,7 @@ describe("startHandler", () => {
|
|
|
86
105
|
details: makeDetails({ status: "running", mode: "background" }),
|
|
87
106
|
})),
|
|
88
107
|
});
|
|
89
|
-
const r = await startHandler(svc, { task: "long" }, undefined);
|
|
108
|
+
const r = await startHandler(svc, { task: "long", slug: "long-running" }, undefined);
|
|
90
109
|
expect(r.kind).toBe("bg");
|
|
91
110
|
if (r.kind !== "bg") return;
|
|
92
111
|
expect(r.subagentId).toBe("bg-1-123");
|
|
@@ -297,11 +316,11 @@ describe("cancelHandler", () => {
|
|
|
297
316
|
// adapter
|
|
298
317
|
// ============================================================
|
|
299
318
|
describe("adapter", () => {
|
|
300
|
-
it("start bg → SubagentToolResult.bgResponse + content 合法 JSON(C3 回归)", () => {
|
|
319
|
+
it("start bg → SubagentToolResult.bgResponse + slug 透传 + content 合法 JSON(C3 回归)", () => {
|
|
301
320
|
const r = adapter({
|
|
302
321
|
action: "start",
|
|
303
322
|
domain: {
|
|
304
|
-
kind: "bg", subagentId: "bg-1", sessionFile: undefined,
|
|
323
|
+
kind: "bg", subagentId: "bg-1", sessionFile: undefined, slug: "extract-urls",
|
|
305
324
|
response: { status: "running", mode: "background", message: "detached, will notify on completion" },
|
|
306
325
|
},
|
|
307
326
|
});
|
|
@@ -309,9 +328,12 @@ describe("adapter", () => {
|
|
|
309
328
|
expect(r.details.subagentId).toBe("bg-1");
|
|
310
329
|
expect(r.details.bgResponse).toBeDefined();
|
|
311
330
|
expect(r.details.sessionFile).toBeNull();
|
|
331
|
+
// slug 透传到 result(renderResult 的 background 行展示用)
|
|
332
|
+
expect(r.details.slug).toBe("extract-urls");
|
|
312
333
|
// content JSON round-trip(bg 序列化回归)
|
|
313
334
|
const parsed = JSON.parse(r.content[0]!.text);
|
|
314
335
|
expect(parsed.bgResponse.message).toMatch(/detached/);
|
|
336
|
+
expect(parsed.slug).toBe("extract-urls");
|
|
315
337
|
});
|
|
316
338
|
|
|
317
339
|
it("list → 最外层 subagentId/sessionFile 为 null", () => {
|
|
@@ -17,6 +17,8 @@ import type { AgentResult as SubagentsAgentResult, AgentUsageTotal, ToolCall } f
|
|
|
17
17
|
* text → content
|
|
18
18
|
* parsedOutput → parsedOutput(structured-output 契约,BC-8)
|
|
19
19
|
* !success && error → error(失败时填,成功时 undefined)
|
|
20
|
+
* !success && !error → error fallback(abort 路径:session-runner 设 success=false,error=undefined,
|
|
21
|
+
* 若直接透传 undefined 则 executeAgentCall 的 `error===undefined` 误判 completed。H4 修复:synthesize fallback)
|
|
20
22
|
* durationMs → durationMs
|
|
21
23
|
* sessionId → sessionId
|
|
22
24
|
* usage (AgentUsageTotal) → usage (AgentUsage: input/output/cacheRead/cacheWrite/cost/contextTokens/turns)
|
|
@@ -31,9 +33,10 @@ export function mapToWorkflowAgentResult(
|
|
|
31
33
|
return {
|
|
32
34
|
content: r.text,
|
|
33
35
|
parsedOutput: r.parsedOutput,
|
|
34
|
-
error: r.success ? undefined : r.error,
|
|
36
|
+
error: r.success ? undefined : (r.error || "Agent call failed (aborted or unknown error)"),
|
|
35
37
|
durationMs: r.durationMs,
|
|
36
38
|
sessionId: r.sessionId,
|
|
39
|
+
sessionFile: r.sessionFile,
|
|
37
40
|
usage: r.usage ? mapUsage(r.usage, r.turns) : undefined,
|
|
38
41
|
toolCalls: r.toolCalls ? mapToolCalls(r.toolCalls) : undefined,
|
|
39
42
|
};
|
|
@@ -2,17 +2,22 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 并发控制 + 优先级排队。background=1000(单一优先级,保留 priority 机制供未来扩展)。
|
|
4
4
|
|
|
5
|
-
/** 队列条目:优先级 + resolver + 入队序号(同优先级 FIFO)。 */
|
|
5
|
+
/** 队列条目:优先级 + resolver + rejecter + 入队序号(同优先级 FIFO)。 */
|
|
6
6
|
interface QueueEntry {
|
|
7
7
|
priority: number;
|
|
8
8
|
resolve: () => void;
|
|
9
|
+
reject: (err: Error) => void;
|
|
9
10
|
seq: number;
|
|
11
|
+
/** abort listener(用于 resolve/abort 时清理)。 */
|
|
12
|
+
onAbort?: () => void;
|
|
13
|
+
/** 关联的 signal(用于 resolve 时 removeEventListener)。 */
|
|
14
|
+
signal?: AbortSignal;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
/** 并发池接口(可注入,便于测试 mock)。 */
|
|
13
18
|
export interface ConcurrencyPool {
|
|
14
|
-
/** 按优先级排队获取槽位(0=最高)。可选 effectiveMaxConcurrent
|
|
15
|
-
acquire(priority: number, effectiveMaxConcurrent?: number): Promise<void>;
|
|
19
|
+
/** 按优先级排队获取槽位(0=最高)。可选 effectiveMaxConcurrent 覆盖实例级默认配额。可选 AbortSignal 在 abort 时 reject 排队条目。 */
|
|
20
|
+
acquire(priority: number, effectiveMaxConcurrent?: number, signal?: AbortSignal): Promise<void>;
|
|
16
21
|
/** 归还槽位。必须无条件执行(finally)。 */
|
|
17
22
|
release(): void;
|
|
18
23
|
/** 当前已占用槽位数(诊断/widget 用)。 */
|
|
@@ -45,7 +50,7 @@ export class DefaultConcurrencyPool implements ConcurrencyPool {
|
|
|
45
50
|
this.maxConcurrent = Math.max(1, maxConcurrent);
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
acquire(priority: number, effectiveMaxConcurrent?: number): Promise<void> {
|
|
53
|
+
acquire(priority: number, effectiveMaxConcurrent?: number, signal?: AbortSignal): Promise<void> {
|
|
49
54
|
// effectiveMaxConcurrent 覆盖实例级默认配额(分层配额:调用方传 max(1, maxConcurrent - depth))。
|
|
50
55
|
// 不修改实例级 maxConcurrent——实例配额是全局共享上限,分层配额是本次 acquire 的局部上限。
|
|
51
56
|
const effective = effectiveMaxConcurrent ?? this.maxConcurrent;
|
|
@@ -53,8 +58,31 @@ export class DefaultConcurrencyPool implements ConcurrencyPool {
|
|
|
53
58
|
this._active += 1;
|
|
54
59
|
return Promise.resolve();
|
|
55
60
|
}
|
|
56
|
-
return new Promise<void>((resolve) => {
|
|
57
|
-
|
|
61
|
+
return new Promise<void>((resolve, reject) => {
|
|
62
|
+
const entry: QueueEntry = { priority, resolve, reject, seq: this.seqCounter++ };
|
|
63
|
+
// H2: abort 时 reject 排队条目并从 queue 移除,防止永久挂起
|
|
64
|
+
if (signal) {
|
|
65
|
+
if (signal.aborted) {
|
|
66
|
+
// S1: abort reject 需带 name="AbortError",对齐 concurrency-gate.ts 的 AbortError 语义
|
|
67
|
+
const err = new Error("acquire aborted");
|
|
68
|
+
err.name = "AbortError";
|
|
69
|
+
reject(err);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
entry.signal = signal;
|
|
73
|
+
entry.onAbort = (): void => {
|
|
74
|
+
const idx = this.queue.indexOf(entry);
|
|
75
|
+
if (idx >= 0) {
|
|
76
|
+
this.queue.splice(idx, 1);
|
|
77
|
+
// S1: abort reject 需带 name="AbortError",与 pre-aborted 分支一致
|
|
78
|
+
const err = new Error("acquire aborted");
|
|
79
|
+
err.name = "AbortError";
|
|
80
|
+
reject(err);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
signal.addEventListener("abort", entry.onAbort, { once: true });
|
|
84
|
+
}
|
|
85
|
+
this.queue.push(entry);
|
|
58
86
|
});
|
|
59
87
|
}
|
|
60
88
|
|
|
@@ -70,6 +98,10 @@ export class DefaultConcurrencyPool implements ConcurrencyPool {
|
|
|
70
98
|
}
|
|
71
99
|
}
|
|
72
100
|
const next = this.queue.splice(bestIdx, 1)[0];
|
|
101
|
+
// H2: 条目已获槽位——移除 abort listener(防 listener 泄漏到长生命周期 signal)
|
|
102
|
+
if (next.onAbort && next.signal) {
|
|
103
|
+
next.signal.removeEventListener("abort", next.onAbort);
|
|
104
|
+
}
|
|
73
105
|
next.resolve();
|
|
74
106
|
// active 不变(一个离开队列立即进入活跃)
|
|
75
107
|
} else if (this._active > 0) {
|
|
@@ -9,6 +9,12 @@ import type { AgentCallOpts } from "../orchestration/models/types.ts";
|
|
|
9
9
|
import type { ModelInfo } from "./model-resolver.ts";
|
|
10
10
|
import type { ExecuteOptions } from "./types.ts";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* slug 最大长度(字符)。subagent/workflow 创建时 slug 超过此值会被截断。
|
|
14
|
+
* 与 subagent tool schema 的 maxLength: 20 保持一致。
|
|
15
|
+
*/
|
|
16
|
+
export const SLUG_MAX_LENGTH = 20;
|
|
17
|
+
|
|
12
18
|
/**
|
|
13
19
|
* D-A2: AgentCallOpts → ExecuteOptions 映射。
|
|
14
20
|
*
|
|
@@ -17,30 +23,41 @@ import type { ExecuteOptions } from "./types.ts";
|
|
|
17
23
|
*
|
|
18
24
|
* 映射规则:
|
|
19
25
|
* prompt → task
|
|
26
|
+
* description → slug(≤20 字符,超长截断。缺失时回落 agent 名)
|
|
20
27
|
* agent → agent
|
|
21
28
|
* schema → schema
|
|
22
29
|
* schemaEnv → schemaEnv(D-A6 bridge)
|
|
23
30
|
* cwd → cwd
|
|
24
|
-
* model → model
|
|
31
|
+
* model → model(显式 override 透传,ctxModel 不再压扁为 id 填底)
|
|
32
|
+
* ctxModel → ctxModel(完整 ModelInfo 对象透传,让 resolveModel 走第 3 层透明传递)
|
|
25
33
|
* skillPath → skillPath
|
|
34
|
+
* thinkingLevel → thinkingLevel(M1: 否则下游 subagent-service 读到 undefined)
|
|
35
|
+
* systemPromptFiles → appendSystemPrompt(M2: resolveAgentOpts 写盘的临时文件需被 session-runner 消费)
|
|
26
36
|
*
|
|
27
37
|
* 忽略字段(委托后由 executeAndAwait 内部机制替代):
|
|
28
|
-
* systemPromptFiles —— resolveIdentity 从 agentConfig.systemPrompt 读
|
|
29
38
|
* timeoutMs —— mergeTimeoutSignal 单独处理
|
|
30
|
-
* scene
|
|
39
|
+
* scene —— subagents 不消费
|
|
40
|
+
*
|
|
41
|
+
* description 原先被忽略,现作为 slug 透传(ExecuteOptions.slug 必填)。
|
|
31
42
|
*/
|
|
32
43
|
export function mapToExecuteOptions(
|
|
33
44
|
opts: AgentCallOpts,
|
|
34
45
|
ctxModel?: ModelInfo,
|
|
35
46
|
): ExecuteOptions {
|
|
47
|
+
// slug:优先 description,缺失回落 agent 名(保证非空)。超长截断。
|
|
48
|
+
const rawSlug = opts.description ?? opts.agent ?? "workflow-agent";
|
|
36
49
|
return {
|
|
37
50
|
task: opts.prompt,
|
|
51
|
+
slug: rawSlug.length > SLUG_MAX_LENGTH ? rawSlug.slice(0, SLUG_MAX_LENGTH) : rawSlug,
|
|
38
52
|
agent: opts.agent,
|
|
39
53
|
schema: opts.schema,
|
|
40
54
|
schemaEnv: opts.schemaEnv,
|
|
41
55
|
cwd: opts.cwd,
|
|
42
|
-
model: opts.model
|
|
56
|
+
model: opts.model,
|
|
57
|
+
ctxModel,
|
|
43
58
|
skillPath: opts.skillPath,
|
|
59
|
+
thinkingLevel: opts.thinkingLevel,
|
|
60
|
+
appendSystemPrompt: opts.systemPromptFiles,
|
|
44
61
|
};
|
|
45
62
|
}
|
|
46
63
|
|
|
@@ -147,6 +147,8 @@ export function createRecord(
|
|
|
147
147
|
thinkingLevel?: string;
|
|
148
148
|
mode: ExecutionMode;
|
|
149
149
|
task: string;
|
|
150
|
+
/** 短标签(≤20 字符),必填。持久化兜底空串。 */
|
|
151
|
+
slug: string;
|
|
150
152
|
startedAt: number;
|
|
151
153
|
/** 根 Pi session ID(session 隔离过滤用)。递归链上所有层同值。 */
|
|
152
154
|
rootSessionId?: string;
|
|
@@ -164,6 +166,7 @@ export function createRecord(
|
|
|
164
166
|
thinkingLevel: identity.thinkingLevel,
|
|
165
167
|
mode: identity.mode,
|
|
166
168
|
task: identity.task,
|
|
169
|
+
slug: identity.slug,
|
|
167
170
|
startedAt: identity.startedAt,
|
|
168
171
|
rootSessionId: identity.rootSessionId,
|
|
169
172
|
parentRecordId: identity.parentRecordId,
|
|
@@ -607,6 +610,7 @@ export function project(record: ExecutionRecord): SubagentToolDetails {
|
|
|
607
610
|
agent: record.agent,
|
|
608
611
|
model: record.model,
|
|
609
612
|
thinkingLevel: record.thinkingLevel,
|
|
613
|
+
slug: record.slug,
|
|
610
614
|
turns: record.turnCount,
|
|
611
615
|
totalTokens: record.totalTokens,
|
|
612
616
|
elapsedSeconds: computeElapsedSeconds(record),
|
|
@@ -657,6 +661,7 @@ export function snapshot(record: ExecutionRecord): RecordSnapshot {
|
|
|
657
661
|
thinkingLevel: record.thinkingLevel,
|
|
658
662
|
mode: record.mode,
|
|
659
663
|
task: record.task,
|
|
664
|
+
slug: record.slug,
|
|
660
665
|
status: record.status,
|
|
661
666
|
turns: record.turnCount,
|
|
662
667
|
totalTokens: record.totalTokens,
|
|
@@ -250,6 +250,7 @@ export class RecordStore {
|
|
|
250
250
|
const rec: SubagentRecord = {
|
|
251
251
|
id: recon.id,
|
|
252
252
|
agent: recon.agent,
|
|
253
|
+
slug: recon.slug,
|
|
253
254
|
status: recon.status, // 临时值,各分支覆盖
|
|
254
255
|
mode: recon.mode,
|
|
255
256
|
startedAt: recon.startedAt,
|
|
@@ -329,6 +330,7 @@ export class RecordStore {
|
|
|
329
330
|
agent: r.agent,
|
|
330
331
|
status: r.status,
|
|
331
332
|
mode: r.mode,
|
|
333
|
+
slug: r.slug,
|
|
332
334
|
startedAt: r.startedAt,
|
|
333
335
|
rootSessionId: r.rootSessionId,
|
|
334
336
|
parentRecordId: r.parentRecordId,
|
|
@@ -84,6 +84,11 @@ export interface SubagentIdentityData {
|
|
|
84
84
|
agent: string;
|
|
85
85
|
mode: ExecutionMode;
|
|
86
86
|
task: string;
|
|
87
|
+
/**
|
|
88
|
+
* 短标签(≤20 字符)。旧文件缺失→读取兜底空串(见 reconstructFromFile)。
|
|
89
|
+
* 写入时(session-runner)必填;读取时可选以兼容旧文件。
|
|
90
|
+
*/
|
|
91
|
+
slug?: string;
|
|
87
92
|
startedAt: number;
|
|
88
93
|
/** 根 Pi session ID(session 隔离过滤用)。旧文件可能缺失。 */
|
|
89
94
|
rootSessionId?: string;
|
|
@@ -126,6 +131,8 @@ export interface ReconstructedRecord {
|
|
|
126
131
|
agent: string;
|
|
127
132
|
mode: ExecutionMode;
|
|
128
133
|
task: string;
|
|
134
|
+
/** 短标签(≤20 字符)。旧文件缺失→兜底空串。 */
|
|
135
|
+
slug: string;
|
|
129
136
|
startedAt: number;
|
|
130
137
|
/** 根 Pi session ID(session 隔离过滤用)。旧文件可能缺失。 */
|
|
131
138
|
rootSessionId: string | undefined;
|
|
@@ -353,6 +360,7 @@ export function reconstructFromFile(sessionFile: string): ReconstructedRecord |
|
|
|
353
360
|
const turn = emptyTurn();
|
|
354
361
|
turns.push(turn);
|
|
355
362
|
|
|
363
|
+
if (!Array.isArray(msg.content)) continue;
|
|
356
364
|
for (const block of msg.content) {
|
|
357
365
|
if (block.type === "text") {
|
|
358
366
|
turn.text += block.text;
|
|
@@ -428,8 +436,11 @@ export function reconstructFromFile(sessionFile: string): ReconstructedRecord |
|
|
|
428
436
|
|
|
429
437
|
// rootSessionId 归一化:新文件读 rootSessionId,旧文件 fallback parentSessionId。
|
|
430
438
|
const rootSessionId = identity.rootSessionId ?? identity.parentSessionId;
|
|
439
|
+
// slug 兜底:旧 identity entry 无 slug 字段 → 空串(与 SubagentRecord.slug: string 契约一致)。
|
|
440
|
+
const slug = identity.slug ?? "";
|
|
431
441
|
return {
|
|
432
442
|
...identity,
|
|
443
|
+
slug,
|
|
433
444
|
rootSessionId,
|
|
434
445
|
parentRecordId: identity.parentRecordId,
|
|
435
446
|
depth: identity.depth ?? 0,
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
parseSpawnLine,
|
|
34
34
|
type SpawnSessionHeader,
|
|
35
35
|
} from "./spawn-event-adapter.ts";
|
|
36
|
+
import type { SubagentStream } from "./stream-sink.ts";
|
|
36
37
|
import {
|
|
37
38
|
cleanupTempPrompt,
|
|
38
39
|
writePromptToTempFile,
|
|
@@ -181,6 +182,12 @@ export interface RunOptions {
|
|
|
181
182
|
signal: AbortSignal | undefined;
|
|
182
183
|
/** event 回流——SessionRunner 内部 updateFromEvent 后,再回调调用方(widget/notify)。 */
|
|
183
184
|
onEvent: ((event: AgentEvent) => void) | undefined;
|
|
185
|
+
/** text_delta streaming 生命周期对象——在 text_delta 到达 onEvent 之前分流。
|
|
186
|
+
* background 模式下 onEvent=undefined,但 text_delta 仍可通过此对象被消费。
|
|
187
|
+
* 由调用方(subagent-service)创建,内部做时间窗合并后转发到 setWidget。
|
|
188
|
+
* workflow 路径(executeAndAwait)不传此字段——其 onEvent 是开的,
|
|
189
|
+
* text_delta 经 onEvent 到 workflow liveRecord,不走 streaming 通道。 */
|
|
190
|
+
stream?: SubagentStream;
|
|
184
191
|
/** D-A6 bridge: workflow schema JSON 字符串,存在时注入 childEnv.PI_WORKFLOW_SCHEMA。
|
|
185
192
|
* workflow 编排层通过 ExecuteOptions.schemaEnv 透传此处,
|
|
186
193
|
* runSpawn 将其注入子进程环境变量,激活 structured-output 扩展注册 tool。
|
|
@@ -474,6 +481,10 @@ export async function runSpawn(
|
|
|
474
481
|
const agentEvent = (event: AgentEvent): void => {
|
|
475
482
|
updateFromEvent(record, event);
|
|
476
483
|
if (event.type === "turn_end") limiter.onTurnEnd(record.turnCount);
|
|
484
|
+
// text_delta 分流到 stream 通道(在 onEvent 之前)。
|
|
485
|
+
// 双通道互斥设计:background 路径 stream 有值、onEvent=undefined;
|
|
486
|
+
// workflow 路径 onEvent 有值、stream=undefined。详见 W3 注释。
|
|
487
|
+
if (event.type === "text_delta") opts.stream?.onDelta(event.delta);
|
|
477
488
|
opts.onEvent?.(event);
|
|
478
489
|
};
|
|
479
490
|
|
|
@@ -667,6 +678,7 @@ export async function runSpawn(
|
|
|
667
678
|
agent: record.agent,
|
|
668
679
|
mode: record.mode,
|
|
669
680
|
task: record.task,
|
|
681
|
+
slug: record.slug,
|
|
670
682
|
startedAt: record.startedAt,
|
|
671
683
|
rootSessionId: record.rootSessionId,
|
|
672
684
|
parentRecordId: record.parentRecordId,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent text_delta streaming sink。
|
|
3
|
+
*
|
|
4
|
+
* background subagent 执行期间,session-runner 的 agentEvent 出口把每个 text_delta
|
|
5
|
+
* 传到 SubagentStream.onDelta。本模块做 100ms 时间窗合并后,通过 StreamSink.setWidget
|
|
6
|
+
* 转发到 RPC stdout(经 ctx.ui.setWidget → extension_ui_request 通道)。
|
|
7
|
+
*
|
|
8
|
+
* SubagentStream 是一个生命周期对象——内聚 buffer/timer 状态 + onDelta/dispose 方法。
|
|
9
|
+
* 调用方(subagent-service)创建后只需在 text_delta 时调 onDelta、终态时调 dispose,
|
|
10
|
+
* 不需要拆散 push/clear 两个函数跨层透传。
|
|
11
|
+
*
|
|
12
|
+
* 设计要点:
|
|
13
|
+
* - leading edge:第一个 delta 立即 flush(前端尽快看到开始)
|
|
14
|
+
* - trailing edge:后续 delta 追加 buffer,timer 到期后 flush
|
|
15
|
+
* - 每次 flush 把 buffer 的累积全文 split("\n") 传给 setWidget
|
|
16
|
+
* - dispose 清除 widget + 清 timer
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** UI streaming sink 的最小接口(ctx.ui.setWidget 的 duck-typed 子集)。
|
|
20
|
+
*
|
|
21
|
+
* 当前只有一个 adapter(index.ts session_start 包装 ctx.ui.setWidget)。
|
|
22
|
+
* 保留接口而非裸函数类型,因为 StreamSink 的语义是「UI sink 契约」——
|
|
23
|
+
* 测试 mock 和未来可能的第二 sink(如写文件)都走此契约。 */
|
|
24
|
+
export interface StreamSink {
|
|
25
|
+
setWidget(key: string, lines: string[] | undefined): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** delta 合并窗口时间(ms)。与 onEventThrottled 的节流间隔对齐。 */
|
|
29
|
+
const STREAM_FLUSH_MS = 100;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* subagent text_delta streaming 生命周期对象。
|
|
33
|
+
*
|
|
34
|
+
* 创建后:
|
|
35
|
+
* - `onDelta(delta)`:session-runner 每次 text_delta 调
|
|
36
|
+
* - `dispose()`:subagent 终态时调,清除 widget + 清 timer
|
|
37
|
+
*
|
|
38
|
+
* buffer/timer 状态全部内聚在此对象,调用方不需要关心合并逻辑。
|
|
39
|
+
*/
|
|
40
|
+
export class SubagentStream {
|
|
41
|
+
private readonly widgetKey: string;
|
|
42
|
+
private readonly sink: StreamSink;
|
|
43
|
+
private buffer = "";
|
|
44
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
45
|
+
private hasFlushed = false;
|
|
46
|
+
private disposed = false;
|
|
47
|
+
|
|
48
|
+
constructor(recordId: string, sink: StreamSink) {
|
|
49
|
+
this.widgetKey = `subagent-stream-${recordId}`;
|
|
50
|
+
this.sink = sink;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 接收一个 text_delta 增量。空串静默丢弃(不消耗 leading edge)。 */
|
|
54
|
+
onDelta(delta: string): void {
|
|
55
|
+
if (this.disposed || delta.length === 0) return;
|
|
56
|
+
this.buffer += delta;
|
|
57
|
+
if (!this.hasFlushed) {
|
|
58
|
+
// leading edge:第一个 delta 立即 flush
|
|
59
|
+
this.hasFlushed = true;
|
|
60
|
+
this.flush();
|
|
61
|
+
} else if (this.timer === undefined) {
|
|
62
|
+
// trailing edge:后续 delta 经 timer 合并
|
|
63
|
+
this.timer = setTimeout(() => this.flush(), STREAM_FLUSH_MS);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 终态清理:清除 widget + 清 timer(幂等)。 */
|
|
68
|
+
dispose(): void {
|
|
69
|
+
if (this.disposed) return;
|
|
70
|
+
this.disposed = true;
|
|
71
|
+
if (this.timer !== undefined) {
|
|
72
|
+
clearTimeout(this.timer);
|
|
73
|
+
this.timer = undefined;
|
|
74
|
+
}
|
|
75
|
+
this.sink.setWidget(this.widgetKey, undefined);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private flush(): void {
|
|
79
|
+
this.timer = undefined;
|
|
80
|
+
if (this.buffer.length === 0 || this.disposed) return;
|
|
81
|
+
this.sink.setWidget(this.widgetKey, this.buffer.split("\n"));
|
|
82
|
+
}
|
|
83
|
+
}
|