@zhushanwen/pi-subagent-workflow 8.5.0 → 8.6.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/package.json +7 -6
- package/src/execution/__tests__/bg-notify-render.test.ts +73 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +6 -2
- package/src/execution/__tests__/delivery-methods.test.ts +38 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +11 -0
- package/src/execution/__tests__/execution-record.test.ts +110 -0
- package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +63 -32
- package/src/execution/__tests__/helpers/spawn-mock.ts +4 -0
- package/src/execution/__tests__/index-session-start.test.ts +86 -7
- package/src/execution/__tests__/lifecycle-manager.test.ts +46 -0
- package/src/execution/__tests__/list-fields.test.ts +45 -14
- package/src/execution/__tests__/model-resolver.test.ts +57 -5
- package/src/execution/__tests__/notifier-flush.test.ts +64 -26
- package/src/execution/__tests__/notify-ledger.test.ts +826 -0
- package/src/execution/__tests__/output-collector.test.ts +299 -2
- package/src/execution/__tests__/rpc-mode.test.ts +1 -1
- package/src/execution/__tests__/run-spawn-edges.test.ts +44 -1
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +39 -0
- package/src/execution/__tests__/spawn-args.test.ts +37 -26
- package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +94 -1
- package/src/execution/__tests__/timeout-integration.test.ts +220 -2
- package/src/execution/__tests__/tool-action.test.ts +92 -1
- package/src/execution/agent-registry.ts +6 -0
- package/src/execution/argv-mirror.ts +5 -1
- package/src/execution/concurrency-pool.ts +1 -1
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +13 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +11 -1
- package/src/execution/engine/types.ts +6 -1
- package/src/execution/execute-options-mapper.ts +8 -7
- package/src/execution/execution-record.ts +60 -1
- package/src/execution/lifecycle-manager.ts +23 -1
- package/src/execution/model-config-service.ts +16 -1
- package/src/execution/model-resolver.ts +31 -59
- package/src/execution/notifier.ts +105 -35
- package/src/execution/notify-ledger.ts +580 -0
- package/src/execution/output-collector.ts +143 -3
- package/src/execution/session-runner.ts +304 -71
- package/src/execution/subagent-service.ts +24 -2
- package/src/execution/subprocess-agent-runner.ts +14 -0
- package/src/execution/types.ts +68 -5
- package/src/execution/ui-request-queue.ts +14 -4
- package/src/index.ts +54 -1
- package/src/interface/__tests__/subagent-tool-path-guard.test.ts +157 -0
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +12 -0
- package/src/interface/bg-notify-render.ts +33 -12
- package/src/interface/helpers.ts +2 -2
- package/src/interface/subagent-actions.ts +26 -9
- package/src/interface/subagent-tool-schema.ts +156 -0
- package/src/interface/subagent-tool.ts +56 -125
- package/src/interface/subagents.ts +2 -2
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +16 -3
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +0 -6
- package/src/orchestration/__tests__/agent-call-stream.test.ts +0 -5
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +89 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +137 -0
- package/src/orchestration/__tests__/jsonl-run-store-corrupt-entry.test.ts +150 -0
- package/src/orchestration/__tests__/jsonl-run-store-retention.test.ts +202 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +326 -3
- package/src/orchestration/__tests__/lifecycle.test.ts +41 -7
- package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +57 -3
- package/src/orchestration/__tests__/skill-discovery.test.ts +44 -0
- package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +43 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +21 -2
- package/src/orchestration/agent-opts-resolver.ts +104 -23
- package/src/orchestration/error-recovery.ts +189 -33
- package/src/orchestration/execute-agent-call.ts +39 -0
- package/src/orchestration/jsonl-run-store.ts +121 -7
- package/src/orchestration/launcher.ts +60 -15
- package/src/orchestration/lifecycle.ts +10 -7
- package/src/orchestration/models/__tests__/budget.test.ts +1 -61
- package/src/orchestration/models/budget.ts +5 -35
- package/src/orchestration/models/run-runtime.ts +24 -9
- package/src/orchestration/models/types.ts +9 -0
- package/src/orchestration/script-lint.ts +1 -1
- package/src/orchestration/skill-discovery.ts +31 -8
- package/src/orchestration/worker-script-builder.ts +16 -3
- package/src/shared/__tests__/model-ref.test.ts +306 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +1 -1
- package/src/shared/__tests__/timer-delay.test.ts +61 -0
- package/src/shared/model-ref.ts +286 -0
- package/src/shared/schema-env.ts +44 -0
- package/src/shared/schema-jsonify.ts +6 -4
- package/src/shared/timer-delay.ts +54 -0
- package/workflows/review-fix-loop-utils.cjs +9 -7
- package/workflows/review-fix-loop.js +20 -12
- package/src/orchestration/__tests__/concurrency-gate.test.ts +0 -125
- package/src/orchestration/concurrency-gate.ts +0 -69
|
@@ -31,6 +31,7 @@ import type { AgentOutcome, EngineHandle } from "./engine/types.ts";
|
|
|
31
31
|
import { mapToExecuteOptions, mergeTimeoutSignal } from "./execute-options-mapper.ts";
|
|
32
32
|
import { getModelConfigService } from "./model-config-service.ts";
|
|
33
33
|
import type { ModelInfo } from "./model-resolver.ts";
|
|
34
|
+
import { modelRefFromVerified } from "../shared/model-ref";
|
|
34
35
|
import { registerSpawnedChildForRecord } from "./session-runner.ts";
|
|
35
36
|
import type { SubagentStream } from "./stream-sink.ts";
|
|
36
37
|
import type { SubagentService } from "./subagent-service.ts";
|
|
@@ -185,6 +186,19 @@ export class SubprocessAgentRunner implements AgentRunner {
|
|
|
185
186
|
};
|
|
186
187
|
|
|
187
188
|
try {
|
|
189
|
+
// ── [U1 D2] RunContext.modelRef 接入:ctxModel 继承路径的孪生守卫 ──
|
|
190
|
+
// ctxModel 是运行时已验证的 ModelInfo(豁免 registry 存在性复查),但继承产出的
|
|
191
|
+
// canonical 串与显式入参走同一个 pi pattern 引擎,孪生守卫同等适用(modelRefFromVerified)。
|
|
192
|
+
// 守卫在 engine.run 之前同步拒绝:含孪生 registry 下不产生任何 record/spawn,
|
|
193
|
+
// 失败走下方 catch → errorResult(错误文案含恢复指引)。
|
|
194
|
+
// RunContext 类型本身定义在 engine/port.ts(跨模块 port,不在本单元领地),
|
|
195
|
+
// 故接入点为构造 runCtx 前的守卫调用;pi 链路下游 resolveModel 的 ctxModel 分支
|
|
196
|
+
// 有同一守卫(model-resolver.ts),两处共用同一入口函数。
|
|
197
|
+
if (this.ctxModel) {
|
|
198
|
+
const modelService = getModelConfigService();
|
|
199
|
+
if (modelService) modelRefFromVerified(this.ctxModel, modelService.getModelRegistry());
|
|
200
|
+
}
|
|
201
|
+
|
|
188
202
|
// ── D-A9: timeoutMs 合并 signal(超时 abort 带 HOST_TIMEOUT_ABORT_REASON 标记)──
|
|
189
203
|
const mergedSignal = mergeTimeoutSignal(signal, opts.timeoutMs);
|
|
190
204
|
|
package/src/execution/types.ts
CHANGED
|
@@ -61,6 +61,37 @@ export type ExecutionStatus = "running" | "closed";
|
|
|
61
61
|
*/
|
|
62
62
|
export type ClosedReason = 'parent-shutdown' | 'parent-fork' | 'parent-new' | 'user-close' | 'cancelled' | 'gc';
|
|
63
63
|
|
|
64
|
+
/** ClosedReason 全枚举值(运行时守卫用——防御性解析外部输入时校验成员资格)。 */
|
|
65
|
+
export const CLOSED_REASONS: readonly ClosedReason[] = [
|
|
66
|
+
'parent-shutdown',
|
|
67
|
+
'parent-fork',
|
|
68
|
+
'parent-new',
|
|
69
|
+
'user-close',
|
|
70
|
+
'cancelled',
|
|
71
|
+
'gc',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 终态三态对外语义(U3 C-outcome 一等披露)。
|
|
76
|
+
*
|
|
77
|
+
* 由 completeRecord 唯一写入点按 deriveOutcome 一次计算(判定顺序:cancelled 优先
|
|
78
|
+
* → error 非空 → completed),消费方(project/list/notify 文案/渲染器)只读本字段,
|
|
79
|
+
* 不再各自手写成败推导 switch(三处同构 switch 已随 U3 收敛删除)。
|
|
80
|
+
*
|
|
81
|
+
* [D6 显式取舍] parent-shutdown/parent-fork/parent-new 合成关闭(subagent-service
|
|
82
|
+
* disposeAllRecords 合成 result 恒写 error:"closed due to ...")落 "failed"——语义为
|
|
83
|
+
* 「父进程关闭时子 agent 未完成即失败」,选定行为而非疏漏,勿当 bug 改回 cancelled。
|
|
84
|
+
*/
|
|
85
|
+
export type ExecutionOutcome = "completed" | "failed" | "cancelled";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 对外投影的 outcome 联合:含历史 record(outcome 字段诞生前的存量数据)兼容态。
|
|
89
|
+
* 投影层(projectOutcome 唯一出口)对无 outcome 字段的 closed record 按
|
|
90
|
+
* deriveOutcome(closedReason, error) 兜底派生;"closed-legacy" 预留给连派生输入都
|
|
91
|
+
* 不足以判读的存量形态,消费方必须处理该成员(不得因未知值崩溃)。
|
|
92
|
+
*/
|
|
93
|
+
export type ProjectedOutcome = ExecutionOutcome | "closed-legacy";
|
|
94
|
+
|
|
64
95
|
/**
|
|
65
96
|
* 对外四态(设计决策 10 细则 3):内部 ExecutionStatus(v4 B-1 两态)收敛为 agent
|
|
66
97
|
* 可理解的状态语义。真实映射只有两条:
|
|
@@ -417,6 +448,12 @@ export interface ExecutionRecord {
|
|
|
417
448
|
* 由 tryTransition(record, "closed", reason) 写入;投影层按需派生对外语义。
|
|
418
449
|
* 向后兼容:旧 record 无此字段,按 gc 处理(通用完成/失败)。 */
|
|
419
450
|
closedReason?: ClosedReason;
|
|
451
|
+
/**
|
|
452
|
+
* 终态三态对外语义(U3 C-outcome)。completeRecord 唯一写入点按 deriveOutcome
|
|
453
|
+
* 一次计算,消费方只读本字段不再自行推导。向后兼容:旧 record / 磁盘重建
|
|
454
|
+
* record 无此字段,投影层按 projectOutcome 兜底(closed-legacy 语义)。
|
|
455
|
+
*/
|
|
456
|
+
outcome?: ExecutionOutcome;
|
|
420
457
|
/** 完整执行内容,按 turn 组织。createRecord 初始化为 [空 turn]。 */
|
|
421
458
|
turns: Turn[];
|
|
422
459
|
/** turn 计数(= turns.filter(closed).length,冗余存储供投影直接读)。 */
|
|
@@ -520,6 +557,11 @@ export interface ExecutionRecord {
|
|
|
520
557
|
*/
|
|
521
558
|
export interface SubagentToolDetails {
|
|
522
559
|
status: ExecutionStatus;
|
|
560
|
+
/**
|
|
561
|
+
* 终态三态对外语义(U3 C-outcome,projectOutcome 唯一出口)。running → undefined;
|
|
562
|
+
* 历史数据无 outcome 字段时兜底派生(见 ProjectedOutcome)。
|
|
563
|
+
*/
|
|
564
|
+
outcome?: ProjectedOutcome;
|
|
523
565
|
mode: ExecutionMode;
|
|
524
566
|
agent: string;
|
|
525
567
|
model: string;
|
|
@@ -564,6 +606,10 @@ export interface ExecuteOptions {
|
|
|
564
606
|
schema?: Record<string, unknown>;
|
|
565
607
|
/** D-A6 bridge: workflow schemaEnv 经 ExecuteOptions 透传到 runSpawn childEnv。 */
|
|
566
608
|
schemaEnv?: string;
|
|
609
|
+
/**
|
|
610
|
+
* Turn 上限 limiter。显式 0/负 = 显式不限:压过 SPAWN_WATCHDOG_ENV 兑底不挂
|
|
611
|
+
* watchdog(SP-6 参数 > env,U5);undefined 未传才由 env 兑底。
|
|
612
|
+
*/
|
|
567
613
|
maxTurns?: number;
|
|
568
614
|
graceTurns?: number;
|
|
569
615
|
/** sync 模式来自 Pi tool 框架;background 模式 hub 忽略,自建 controller。 */
|
|
@@ -587,6 +633,7 @@ export interface ExecuteOptions {
|
|
|
587
633
|
/**
|
|
588
634
|
* 空闲超时毫秒数(仅 conversation 模式有意义)。覆盖默认 5min idle timeout。
|
|
589
635
|
* 优先级:参数 > env XYZ_SUBAGENT_IDLE_TIMEOUT_MS > 默认 300000ms。
|
|
636
|
+
* 显式传 0/负数 = 禁用 idle GC(不挂 timer;旧实现 0 会落成 setTimeout(0) 立即 kill)。
|
|
590
637
|
*/
|
|
591
638
|
idleTimeoutMs?: number;
|
|
592
639
|
/**
|
|
@@ -643,10 +690,13 @@ export interface SubagentListItem {
|
|
|
643
690
|
/** 可冷路径 resume(running 且无活进程句柄)。[v4 A-6] B-1「可续聊」对外表达,
|
|
644
691
|
* agent 据 list 判断哪些 running subagent 实际可续聊(vs 正在忙)。 */
|
|
645
692
|
resumable?: boolean;
|
|
646
|
-
/**
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
|
|
693
|
+
/**
|
|
694
|
+
* 终态三态对外语义(U3 C-outcome 一等披露,projectOutcome 唯一出口):
|
|
695
|
+
* completed / failed / cancelled,历史 record 无 outcome 字段时兜底派生,
|
|
696
|
+
* 不可判读的存量形态为 "closed-legacy"。GUI pane / agent 据此判读成败,
|
|
697
|
+
* 无需翻 error 字段原文(S5)。
|
|
698
|
+
*/
|
|
699
|
+
outcome?: ProjectedOutcome;
|
|
650
700
|
}
|
|
651
701
|
|
|
652
702
|
/** background 启动的内层响应(挂在 SubagentToolResult.bgResponse)。 */
|
|
@@ -655,6 +705,19 @@ export interface BgResponse {
|
|
|
655
705
|
mode: "background";
|
|
656
706
|
/** 启动提示文案("detached, will notify on completion")。 */
|
|
657
707
|
message: string;
|
|
708
|
+
/**
|
|
709
|
+
* 终态三态语义(U3 C-outcome 对外 JSON 契约完备位)。start 时点 record 尚未终态,
|
|
710
|
+
* 恒 undefined(JSON.stringify 落键省略);终态成败语义经 list items[].outcome
|
|
711
|
+
* 披露。旧字段 status/mode/message 原样保留(向后兼容)。
|
|
712
|
+
*/
|
|
713
|
+
outcome?: ProjectedOutcome;
|
|
714
|
+
/**
|
|
715
|
+
* 通知投递契约回显位(U1 预置,U2 通知账本的契约声明)。恒值
|
|
716
|
+
* "ledger+at-least-once":主 agent 在当前 run 结束或有限延迟内收到完成通知,
|
|
717
|
+
* 送达保证为 at-least-once + notifyId 幂等可识别。字段与填充由 U1 负责,
|
|
718
|
+
* 值语义由 U2(execution/notify-ledger.ts)兑现。
|
|
719
|
+
*/
|
|
720
|
+
notifyContract: "ledger+at-least-once";
|
|
658
721
|
}
|
|
659
722
|
|
|
660
723
|
/** list 的内层响应(挂在 SubagentToolResult.listResponse)。 */
|
|
@@ -697,7 +760,7 @@ export interface CloseResponse {
|
|
|
697
760
|
* - close → closeResponse(subagentId 有值;sessionFile 无意义,可为 null)
|
|
698
761
|
*/
|
|
699
762
|
export type SubagentToolResult =
|
|
700
|
-
| { action: "start"; subagentId: string; sessionFile: string | null; slug: string; bgResponse: BgResponse; __gui__?: GuiRenderResult }
|
|
763
|
+
| { action: "start"; subagentId: string; sessionFile: string | null; slug: string; /** registry 全等回显(U1):放行即与 registry 条目全等,"provider/id" 形态。 */ model: string; bgResponse: BgResponse; __gui__?: GuiRenderResult }
|
|
701
764
|
| { action: "list"; subagentId: null; sessionFile: null; listResponse: ListResponse; __gui__?: GuiRenderResult }
|
|
702
765
|
| { action: "cancel"; subagentId: string; sessionFile: null; cancelResponse: CancelResponse; __gui__?: GuiRenderResult }
|
|
703
766
|
| { action: "message"; subagentId: string; sessionFile: null; messageResponse: MessageResponse; __gui__?: GuiRenderResult }
|
|
@@ -55,10 +55,20 @@ export function createUiRequestQueue(
|
|
|
55
55
|
if (processing || queue.length === 0 || closed) return;
|
|
56
56
|
processing = true;
|
|
57
57
|
const { id, request, signal } = queue.shift()!;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// [F2] .catch 在 .finally 之前:handleUiRequest 是 async 函数,任何同步异常(parseChannel
|
|
59
|
+
// 解析 throw / respond → writeStdinLine 的 EPIPE 同步 throw / catch 分支内 respond 再次
|
|
60
|
+
// throw)都会变成 rejection。旧链只有 .finally——rejection 穿透后无人接 →
|
|
61
|
+
// unhandledRejection(Node 15+ 默认 mode=throw)可崩父进程。记 error 后吞掉,
|
|
62
|
+
// .finally 照常释放 processing 推进队列(单个请求失败不阻塞后续 UI 请求)。
|
|
63
|
+
handleUiRequest(child, id, request, ctx, signal)
|
|
64
|
+
.catch((err: unknown) => {
|
|
65
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
66
|
+
logger.error(`[subagents] ui request ${id} (${request.method}) failed unexpectedly: ${m}`);
|
|
67
|
+
})
|
|
68
|
+
.finally(() => {
|
|
69
|
+
processing = false;
|
|
70
|
+
processNext();
|
|
71
|
+
});
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
// [R3] 子进程退出时 abort 所有 pending handler,队列不再阻塞
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as path from "node:path";
|
|
18
18
|
|
|
19
|
-
import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, SessionCompactEvent, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
22
22
|
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
ModelConfigService,
|
|
40
40
|
setModelConfigService,
|
|
41
41
|
} from "./execution/model-config-service.ts";
|
|
42
|
+
import { bindNotifyLedgerHost, getBoundNotifyLedger, type NotifyLedgerHost } from "./execution/notify-ledger.ts";
|
|
42
43
|
import { IDENTITY_CUSTOM_TYPE, type SubagentIdentityData } from "./execution/session-reconstructor.ts";
|
|
43
44
|
import type { ExecutionMode, SubagentRecord } from "./execution/types.ts";
|
|
44
45
|
import { maybeCleanupExpiredSessionFiles } from "./execution/session-file-gc.ts";
|
|
@@ -410,6 +411,39 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
410
411
|
}
|
|
411
412
|
}
|
|
412
413
|
|
|
414
|
+
// ── [U2] 通知账本装配 + 重启恢复(设计 D4:存在性 / 可达性分离)──
|
|
415
|
+
// bind 先于 service.initSession(notifier.revive 在其内——notify() 经
|
|
416
|
+
// getBoundNotifyLedger 消费账本)。recoverFromSession 扫 ledger/ack 两列 entry
|
|
417
|
+
// 差集:未销账号重放投递(已销账零重发,notifyId 幂等);fork 继承未销账
|
|
418
|
+
// pending 属可接受语义(D4 归属规则——扫描域 = 单 session 文件,幂等键作用域
|
|
419
|
+
// 随文件域隔离)。compaction 存活情况归 session_compact handler 的条件降级(P-B4
|
|
420
|
+
// 探针阶段 5 实测,见 notify-ledger.ts compactionCheck)。
|
|
421
|
+
try {
|
|
422
|
+
const ledgerHost: NotifyLedgerHost = {
|
|
423
|
+
appendLedgerEntry: (customType, data) => {
|
|
424
|
+
pi.appendEntry(customType, data);
|
|
425
|
+
},
|
|
426
|
+
readSessionEntries: () => ctx.sessionManager.getEntries(),
|
|
427
|
+
isIdle: () => ctx.isIdle(),
|
|
428
|
+
onAgentSettled: (handler) => {
|
|
429
|
+
pi.on("agent_settled", handler);
|
|
430
|
+
},
|
|
431
|
+
sendDelivery: (message) => {
|
|
432
|
+
// D5 单通道:唯一发送形态 = sendCustomMessage({triggerTurn:true}),
|
|
433
|
+
// courier 已在发送前二次复查 isIdle,多通道投递选项已删(D5)。
|
|
434
|
+
pi.sendMessage(message, { triggerTurn: true });
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
// U4:重放观测已内聚到 ledger 分桶日志(recoveryReplays 桶经 extensionLogger
|
|
438
|
+
// 通道落盘),此处不再重复打日志。
|
|
439
|
+
bindNotifyLedgerHost(ledgerHost).recoverFromSession();
|
|
440
|
+
} catch (err) {
|
|
441
|
+
// 账本装配失败不阻断 session_start(通知退回 notifier 的内核路径)
|
|
442
|
+
logger.warn("[subagents] notify ledger bind failed", {
|
|
443
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
413
447
|
// ── subagents 域:双 Service 装配 ──
|
|
414
448
|
const existingService = getSubagentService();
|
|
415
449
|
const existingModelService = getModelConfigService();
|
|
@@ -588,6 +622,25 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
588
622
|
});
|
|
589
623
|
});
|
|
590
624
|
|
|
625
|
+
// ════════════════════════════════════════════════════════════
|
|
626
|
+
// [U2 P-B4 降级] session_compact:compaction 对 custom entry 保留行为实装未
|
|
627
|
+
// 验证——检测 ledger/ack entry 被 compaction 清除时按内存态补写(notify-ledger
|
|
628
|
+
// compactionCheck;未清除则 no-op)。内存态在 compaction 后仍活着,作为补写源;
|
|
629
|
+
// 重启后的权威仍是两列 entry 差集(内存不承担销账职责)。
|
|
630
|
+
// ═══════════════════════════════════════════════════════
|
|
631
|
+
pi.on("session_compact", (_event: SessionCompactEvent, _ctx: ExtensionContext) => {
|
|
632
|
+
try {
|
|
633
|
+
const rewritten = getBoundNotifyLedger()?.compactionCheck() ?? 0;
|
|
634
|
+
if (rewritten > 0) {
|
|
635
|
+
logger.warn(`[subagents] notify ledger entries lost to compaction; rewrote ${rewritten} from memory`);
|
|
636
|
+
}
|
|
637
|
+
} catch (err) {
|
|
638
|
+
logger.warn("[subagents] notify ledger compactionCheck failed", {
|
|
639
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
591
644
|
// ════════════════════════════════════════════════════════════
|
|
592
645
|
// [U7] before_agent_start:引擎模型段注入(defaultEngine 非 pi 且引擎实现
|
|
593
646
|
// listModels 时追加 <available_<engine>_models>——每 turn 重判 config,改配置
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// subagent tool 路径类参数守卫测试(三通道对称审查修复 + MF-13)。
|
|
2
|
+
//
|
|
3
|
+
// 修复背景:subagent tool 的 skillPath 参数此前零校验直传 session-runner 拼
|
|
4
|
+
// `--skill <path>`;cwd 仅 description 声明 "Must be an absolute path" 无运行时
|
|
5
|
+
// 闸。schema pattern(^/)经 pi agent-loop 运行时强校验已是强制(PS-20:
|
|
6
|
+
// agent-loop.js:403-404 → validation.js:247-273),工具层守卫定位 = defense-in-depth
|
|
7
|
+
// + schema 表达力缺口——`..` 穿越语义超出 pattern 能力(^/ 放行 "/a/../b"),
|
|
8
|
+
// 守卫在 executeSubagent start 分支 immediate throw(与 action 枚举守卫同风格)。
|
|
9
|
+
//
|
|
10
|
+
// 本文件锁住:
|
|
11
|
+
// 1. skillPath / cwd 含 `..` 穿越段 → 同步 reject,service.execute 零触达
|
|
12
|
+
// 2. skillPath / cwd 相对路径 → 同步 reject
|
|
13
|
+
// 3. 合法绝对路径放行 → 参数原样透传 service.execute(守卫零误伤对照)
|
|
14
|
+
//
|
|
15
|
+
// harness 复用 sdk-contract.test.ts 的 mock 链(pi-ai/typebox/subagent-service),
|
|
16
|
+
// registerSubagentTool 后捕获 execute 回调直接调用。
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("@earendil-works/pi-ai", () => ({
|
|
21
|
+
StringEnum: (values: string[]) => ({ type: "string", enum: values }),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("typebox", () => ({
|
|
24
|
+
Type: {
|
|
25
|
+
Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
|
|
26
|
+
Optional: (schema: unknown) => ({ ...(schema as object), optional: true }),
|
|
27
|
+
String: (opts?: Record<string, unknown>) => ({ type: "string", ...opts }),
|
|
28
|
+
Boolean: () => ({ type: "boolean" }),
|
|
29
|
+
Number: (opts?: Record<string, unknown>) => ({ type: "number", ...opts }),
|
|
30
|
+
Array: (items: unknown) => ({ type: "array", items }),
|
|
31
|
+
Record: (key: unknown, value: unknown) => ({ type: "object", additionalProperties: value, key }),
|
|
32
|
+
Unknown: () => ({ type: "unknown" }),
|
|
33
|
+
Union: (members: unknown[]) => ({ type: "union", members }),
|
|
34
|
+
Literal: (value: unknown) => ({ type: "literal", value }),
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Mock getSubagentService:断言「守卫拒绝时 service.execute 零触达」的承重证据。
|
|
39
|
+
const { mockServiceExecute } = vi.hoisted(() => ({
|
|
40
|
+
mockServiceExecute: vi.fn(),
|
|
41
|
+
}));
|
|
42
|
+
vi.mock("../../execution/subagent-service.ts", () => ({
|
|
43
|
+
getSubagentService: () => ({ execute: mockServiceExecute }),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import { registerSubagentTool } from "../../interface/subagent-tool.ts";
|
|
47
|
+
import { mockExtensionApi } from "../../execution/__tests__/helpers/mock-extension-api.ts";
|
|
48
|
+
|
|
49
|
+
type ExecuteCb = (...args: unknown[]) => Promise<unknown>;
|
|
50
|
+
|
|
51
|
+
/** 注册 tool 并捕获 execute 回调(sdk-contract 同款)。 */
|
|
52
|
+
function captureExecute(): ExecuteCb {
|
|
53
|
+
let captured: ExecuteCb | undefined;
|
|
54
|
+
const pi = mockExtensionApi({
|
|
55
|
+
registerTool: (tool: unknown) => {
|
|
56
|
+
captured = (tool as { execute: ExecuteCb }).execute;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
registerSubagentTool(pi);
|
|
60
|
+
if (!captured) throw new Error("subagent tool not registered");
|
|
61
|
+
return captured;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 合法 start 入参基线(守卫测试只变动 skillPath/cwd 字段)。 */
|
|
65
|
+
function baseParams(over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
66
|
+
return { action: "start", task: "guard task", slug: "path-guard", ...over };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 合法 execute 返回值 stub(放行路径 adapter 包装用)。 */
|
|
70
|
+
function stubHandle() {
|
|
71
|
+
return {
|
|
72
|
+
mode: "background",
|
|
73
|
+
subagentId: "sa-path-guard",
|
|
74
|
+
sessionFile: "/tmp/session.jsonl",
|
|
75
|
+
details: { slug: "path-guard", model: "test/model" },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("subagent tool 路径守卫(skillPath/cwd:绝对路径 + 禁 .. 穿越)", () => {
|
|
80
|
+
let execute: ExecuteCb;
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
vi.clearAllMocks();
|
|
84
|
+
mockServiceExecute.mockResolvedValue(stubHandle());
|
|
85
|
+
execute = captureExecute();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── `..` 穿越段拒绝 ──
|
|
89
|
+
|
|
90
|
+
it("skillPath 含 .. 穿越段 → immediate reject,service.execute 零触达", async () => {
|
|
91
|
+
await expect(
|
|
92
|
+
execute("call-1", baseParams({ skillPath: "../../etc/passwd" }), undefined, undefined, undefined),
|
|
93
|
+
).rejects.toThrow(/skillPath must not contain '\.\.' path segments/);
|
|
94
|
+
|
|
95
|
+
expect(mockServiceExecute).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("cwd 含 .. 穿越段 → immediate reject,service.execute 零触达", async () => {
|
|
99
|
+
await expect(
|
|
100
|
+
execute("call-2", baseParams({ cwd: "/safe/root/../../unsafe" }), undefined, undefined, undefined),
|
|
101
|
+
).rejects.toThrow(/cwd must not contain '\.\.' path segments/);
|
|
102
|
+
|
|
103
|
+
expect(mockServiceExecute).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── 相对路径拒绝(`~` 缩写不是绝对路径,一并拒)──
|
|
107
|
+
|
|
108
|
+
it("skillPath 相对路径 → immediate reject(报错指引展开为绝对路径)", async () => {
|
|
109
|
+
await expect(
|
|
110
|
+
execute("call-3", baseParams({ skillPath: ".agents/skills/my-skill" }), undefined, undefined, undefined),
|
|
111
|
+
).rejects.toThrow(/skillPath must be an absolute path.*Expand '~' yourself/s);
|
|
112
|
+
|
|
113
|
+
expect(mockServiceExecute).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("cwd 相对路径 → immediate reject", async () => {
|
|
117
|
+
await expect(
|
|
118
|
+
execute("call-4", baseParams({ cwd: "relative/dir" }), undefined, undefined, undefined),
|
|
119
|
+
).rejects.toThrow(/cwd must be an absolute path/);
|
|
120
|
+
|
|
121
|
+
expect(mockServiceExecute).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("cwd `~` 缩写 → immediate reject(下游 spawn cwd 不展开 ~)", async () => {
|
|
125
|
+
await expect(
|
|
126
|
+
execute("call-5", baseParams({ cwd: "~/project" }), undefined, undefined, undefined),
|
|
127
|
+
).rejects.toThrow(/cwd must be an absolute path/);
|
|
128
|
+
|
|
129
|
+
expect(mockServiceExecute).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── 放行对照(守卫零误伤)──
|
|
133
|
+
|
|
134
|
+
it("合法绝对路径 skillPath/cwd 放行,参数原样透传 service.execute", async () => {
|
|
135
|
+
await execute(
|
|
136
|
+
"call-ok",
|
|
137
|
+
baseParams({ skillPath: "/work/project/.agents/skills/my-skill", cwd: "/work/project" }),
|
|
138
|
+
undefined,
|
|
139
|
+
undefined,
|
|
140
|
+
undefined,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(mockServiceExecute).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(mockServiceExecute).toHaveBeenCalledWith(
|
|
145
|
+
expect.objectContaining({
|
|
146
|
+
skillPath: "/work/project/.agents/skills/my-skill",
|
|
147
|
+
cwd: "/work/project",
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("不传 skillPath/cwd → 守卫不介入(undefined 合法缺省)", async () => {
|
|
153
|
+
await execute("call-none", baseParams(), undefined, undefined, undefined);
|
|
154
|
+
|
|
155
|
+
expect(mockServiceExecute).toHaveBeenCalledTimes(1);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -114,6 +114,18 @@ describe("subagent tool description — 行为约束器(非功能说明书)"
|
|
|
114
114
|
expect(DESCRIPTION).not.toContain('"sa_');
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
it("Examples 示例 agent 值必须是绝对路径 .md 形态(显式 ref 硬守卫拒绝裸名)", () => {
|
|
118
|
+
// 显式 agent ref 走硬守卫:getRequiredAgentConfig → loadByPath(ref, true),
|
|
119
|
+
// 裸名(normalizeRef 非绝对路径 → null)同步 throw `Invalid agent ref` +
|
|
120
|
+
// <available_subagents> 恢复指引(explicit-agent-ref-guard.test.ts 锁定拒绝路径)。
|
|
121
|
+
// 示例若教裸名(历史漂移:"agent":"coder"),弱模型照抄即必败调用——浪费一轮
|
|
122
|
+
// 并系统性教唆反模式。与 subagentId 格式测试同风格:示例必须与实际契约一致。
|
|
123
|
+
// ① 零裸名:任何 "agent":"<非/>" 形态都禁止
|
|
124
|
+
expect(DESCRIPTION).not.toMatch(/"agent":"(?!\/)[^"]*"/);
|
|
125
|
+
// ② 正例在位且为绝对路径 .md 形态(<available_subagents> 注入的 <location> 形态)
|
|
126
|
+
expect(DESCRIPTION).toMatch(/"agent":"\/[^"]*\.md"/);
|
|
127
|
+
});
|
|
128
|
+
|
|
117
129
|
it("agent 字段 description 不写死枚举,指向 <available_subagents>(通用化防漂移)", () => {
|
|
118
130
|
// 原实现把 9 个内置 agent 名写死在 schema field description(防漏),
|
|
119
131
|
// 但枚举与包内 agents/*.md 存在漂移风险(新增/删除 agent 要手改两处)。
|
|
@@ -23,6 +23,9 @@ import type { Component } from "@earendil-works/pi-tui";
|
|
|
23
23
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
24
24
|
|
|
25
25
|
import { displayAgentName } from "../shared/agent-ref.ts";
|
|
26
|
+
import { deriveOutcome } from "../execution/execution-record.ts";
|
|
27
|
+
import { CLOSED_REASONS } from "../execution/types.ts";
|
|
28
|
+
import type { ClosedReason, ExecutionOutcome } from "../execution/types.ts";
|
|
26
29
|
import {
|
|
27
30
|
firstLine,
|
|
28
31
|
padToVisible,
|
|
@@ -55,8 +58,10 @@ interface BgNotifyRecord {
|
|
|
55
58
|
id: string;
|
|
56
59
|
/** v4 B-1: closed(终态,含 cancelled)或 running(对话模式轮次完成,旧 idle)。 */
|
|
57
60
|
status: "running" | "closed";
|
|
58
|
-
/** L2
|
|
59
|
-
closedReason?:
|
|
61
|
+
/** L2 关闭原因子枚举(内部诊断 + outcome 兑底派生输入;经 toClosedReason 防御性收窄)。 */
|
|
62
|
+
closedReason?: ClosedReason;
|
|
63
|
+
/** 终态三态对外语义(U3 C-outcome)。缺失(升级前旧消息重放)时按 deriveOutcome 兑底。 */
|
|
64
|
+
outcome?: ExecutionOutcome;
|
|
60
65
|
agent: string;
|
|
61
66
|
model?: string;
|
|
62
67
|
result?: string;
|
|
@@ -218,12 +223,15 @@ function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
|
|
|
218
223
|
const modelPart = record.model
|
|
219
224
|
? ` ${t.fg("dim", "·")} ${t.fg("accent", truncLine(record.model, MODEL_MAX_WIDTH))}`
|
|
220
225
|
: "";
|
|
221
|
-
//
|
|
222
|
-
|
|
226
|
+
// U3 C-outcome:verb 与正文分流只读 outcome——单一权威派生(升级前旧消息重放等
|
|
227
|
+
// details 缺 outcome 的存量形态经 deriveOutcome(closedReason, error) 兑底,非同构
|
|
228
|
+
// 重写)。判定先于 patchFile:failed 分支不展示 patch/result(失败轮也会写
|
|
229
|
+
// patchFile,历史 bug 存档见 deriveOutcome 注释)。
|
|
230
|
+
const outcome = record.outcome ?? deriveOutcome(record.closedReason, record.error);
|
|
223
231
|
let verb: string;
|
|
224
|
-
if (
|
|
232
|
+
if (outcome === "cancelled") {
|
|
225
233
|
verb = "cancelled";
|
|
226
|
-
} else if (
|
|
234
|
+
} else if (outcome === "failed") {
|
|
227
235
|
verb = "failed";
|
|
228
236
|
} else {
|
|
229
237
|
verb = "finished";
|
|
@@ -233,13 +241,12 @@ function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
|
|
|
233
241
|
|
|
234
242
|
switch (record.status) {
|
|
235
243
|
case "closed": {
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
if (r === "cancelled") {
|
|
244
|
+
// U3 C-outcome:cancelled 无正文;failed 显示错误;否则结果/patch(同上,分流只读 outcome)。
|
|
245
|
+
if (outcome === "cancelled") {
|
|
239
246
|
return [head];
|
|
240
247
|
}
|
|
241
|
-
if (
|
|
242
|
-
return [head, t.fg("dim", truncLine(`Error: ${firstLineSanitized(record.error)}`, BODY_MAX_WIDTH))];
|
|
248
|
+
if (outcome === "failed") {
|
|
249
|
+
return [head, t.fg("dim", truncLine(`Error: ${firstLineSanitized(record.error ?? "")}`, BODY_MAX_WIDTH))];
|
|
243
250
|
}
|
|
244
251
|
if (!record.result && !record.patchFile) return [head];
|
|
245
252
|
const lines: string[] = [];
|
|
@@ -275,6 +282,19 @@ function extractBatch(details: unknown): BgNotifyRecord[] | undefined {
|
|
|
275
282
|
return records.length > 0 ? records : undefined;
|
|
276
283
|
}
|
|
277
284
|
|
|
285
|
+
/**
|
|
286
|
+
* details.closedReason 防御性收窄:任意字符串 → ClosedReason | undefined。
|
|
287
|
+
* 旧数据/外部构造的非法值按缺失处理,交由 deriveOutcome 兑底(消费方不崩溃)。
|
|
288
|
+
*/
|
|
289
|
+
function toClosedReason(value: unknown): ClosedReason | undefined {
|
|
290
|
+
return CLOSED_REASONS.find((reason) => reason === value);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** details.outcome 防御性收窄:仅接受三态枚举值,其余按缺失处理。 */
|
|
294
|
+
function toOutcome(value: unknown): ExecutionOutcome | undefined {
|
|
295
|
+
return value === "completed" || value === "failed" || value === "cancelled" ? value : undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
278
298
|
/**
|
|
279
299
|
* 从 message.details 防御性提取 BgNotifyRecord。
|
|
280
300
|
* 结构不全(缺 status / agent)返回 undefined。
|
|
@@ -297,7 +317,8 @@ function extractBgNotifyRecord(details: unknown): BgNotifyRecord | undefined {
|
|
|
297
317
|
model: typeof d.model === "string" ? d.model : undefined,
|
|
298
318
|
result: typeof d.result === "string" ? d.result : undefined,
|
|
299
319
|
error: typeof d.error === "string" ? d.error : undefined,
|
|
300
|
-
closedReason:
|
|
320
|
+
closedReason: toClosedReason(d.closedReason),
|
|
321
|
+
outcome: toOutcome(d.outcome),
|
|
301
322
|
round: typeof d.round === "number" ? d.round : undefined,
|
|
302
323
|
// [MF#1] 提取 patchFile(worktree background 完成通知携带)。
|
|
303
324
|
patchFile: typeof d.patchFile === "string" ? d.patchFile : undefined,
|
package/src/interface/helpers.ts
CHANGED
|
@@ -238,7 +238,7 @@ export function notifyDone(
|
|
|
238
238
|
|
|
239
239
|
const content = parts.join("\n");
|
|
240
240
|
|
|
241
|
-
// deliverAs:"steer" + triggerTurn:true —— workflow 完成作为 steering
|
|
241
|
+
// deliverAs:"steer" + triggerTurn:true —— workflow 完成作为 steering 消息注入(g4-allow: 存量待迁移——结果语义通知,账本化迁移登记 pi-boundary-reliability 附录 B 待办)
|
|
242
242
|
// 并立即唤醒 parent agent 处理结果(与 subagent 的 followUp+triggerTurn 对称)
|
|
243
243
|
const details: WorkflowNotifyDetails = {
|
|
244
244
|
runId,
|
|
@@ -275,7 +275,7 @@ export function notifyDone(
|
|
|
275
275
|
display: true,
|
|
276
276
|
details,
|
|
277
277
|
},
|
|
278
|
-
{ triggerTurn: true, deliverAs: "steer" },
|
|
278
|
+
{ triggerTurn: true, deliverAs: "steer" }, // g4-allow: 存量待迁移——workflow 完成通知属结果语义,迁移切片复用 U2 账本设施(附录 B 待办)
|
|
279
279
|
);
|
|
280
280
|
}
|
|
281
281
|
|