@zhushanwen/pi-subagent-workflow 0.1.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 +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// 测试框架:vitest
|
|
2
|
+
// 运行命令:npx vitest run src/orchestration/models/__tests__/budget.test.ts
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Budget,
|
|
8
|
+
CACHE_READ_WEIGHT,
|
|
9
|
+
CACHE_WRITE_WEIGHT,
|
|
10
|
+
INPUT_WEIGHT,
|
|
11
|
+
OUTPUT_WEIGHT,
|
|
12
|
+
SOFT_MAX_AGENTS_WARNING,
|
|
13
|
+
} from "../budget.js";
|
|
14
|
+
import type { AgentUsage } from "../types.js";
|
|
15
|
+
|
|
16
|
+
/** 构造合法 AgentUsage,未指定字段补 0。 */
|
|
17
|
+
function usage(partial: Partial<AgentUsage>): AgentUsage {
|
|
18
|
+
return {
|
|
19
|
+
input: 0,
|
|
20
|
+
output: 0,
|
|
21
|
+
cacheRead: 0,
|
|
22
|
+
cacheWrite: 0,
|
|
23
|
+
cost: 0,
|
|
24
|
+
contextTokens: 0,
|
|
25
|
+
turns: 0,
|
|
26
|
+
...partial,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 构造"有毒" usage:刻意注入 undefined / NaN / Infinity,
|
|
32
|
+
* 模拟 provider 返回脏数据或 JSON 反序列化缺字段时的运行时场景。
|
|
33
|
+
*
|
|
34
|
+
* 单层 `as AgentUsage`:AgentUsage 全部必填字段(非全可选结构),
|
|
35
|
+
* 不触发 taste/no-unsafe-cast 的 structuralCast;此处刻意绕过类型系统
|
|
36
|
+
* 以验证 consume() 的运行时 NaN 守卫。
|
|
37
|
+
*/
|
|
38
|
+
function poisonedUsage(
|
|
39
|
+
overrides: Partial<Record<keyof AgentUsage, number | undefined>>,
|
|
40
|
+
): AgentUsage {
|
|
41
|
+
return {
|
|
42
|
+
input: 0,
|
|
43
|
+
output: 0,
|
|
44
|
+
cacheRead: 0,
|
|
45
|
+
cacheWrite: 0,
|
|
46
|
+
cost: 0,
|
|
47
|
+
contextTokens: 0,
|
|
48
|
+
turns: 0,
|
|
49
|
+
...overrides,
|
|
50
|
+
} as AgentUsage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── consume:加权公式 ────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe("Budget.consume 加权公式", () => {
|
|
56
|
+
it(`纯 input → usedTokens = input × ${INPUT_WEIGHT}`, () => {
|
|
57
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
58
|
+
b.consume(usage({ input: 100 }));
|
|
59
|
+
expect(b.usedTokens).toBe(100);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it(`纯 output → usedTokens = output × ${OUTPUT_WEIGHT}(自回归开销最高)`, () => {
|
|
63
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
64
|
+
b.consume(usage({ output: 100 }));
|
|
65
|
+
expect(b.usedTokens).toBe(200);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it(`纯 cacheRead → usedTokens = cacheRead × ${CACHE_READ_WEIGHT}(命中缓存,开销极低)`, () => {
|
|
69
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
70
|
+
b.consume(usage({ cacheRead: 100 }));
|
|
71
|
+
expect(b.usedTokens).toBe(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it(`纯 cacheWrite → usedTokens = cacheWrite × ${CACHE_WRITE_WEIGHT}(首次写缓存不计 budget)`, () => {
|
|
75
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
76
|
+
b.consume(usage({ cacheWrite: 100 }));
|
|
77
|
+
expect(b.usedTokens).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("混合四项加权累加(input×1 + output×2 + cacheRead×0.02 + cacheWrite×0)", () => {
|
|
81
|
+
const b = new Budget({ maxTokens: 10000 });
|
|
82
|
+
b.consume(
|
|
83
|
+
usage({ input: 100, output: 50, cacheRead: 1000, cacheWrite: 500 }),
|
|
84
|
+
);
|
|
85
|
+
// 100×1 + 50×2 + 1000×0.02 + 500×0 = 100 + 100 + 20 + 0 = 220
|
|
86
|
+
expect(b.usedTokens).toBe(220);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("cost 随 consume 累加", () => {
|
|
90
|
+
const b = new Budget();
|
|
91
|
+
b.consume(usage({ cost: 0.3 }));
|
|
92
|
+
expect(b.usedCost).toBe(0.3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("多次 consume 累加(retry 间如实记录,不减)", () => {
|
|
96
|
+
const b = new Budget();
|
|
97
|
+
b.consume(usage({ input: 100, cost: 0.1 }));
|
|
98
|
+
b.consume(usage({ input: 50, output: 50, cost: 0.2 }));
|
|
99
|
+
// tokens: 100×1 + (50×1 + 50×2) = 250
|
|
100
|
+
expect(b.usedTokens).toBe(250);
|
|
101
|
+
// 0.1 + 0.2 浮点误差,用 toBeCloseTo
|
|
102
|
+
expect(b.usedCost).toBeCloseTo(0.3);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── consume:NaN 守卫(TDD,预期主 agent 同步补源码守卫)──────
|
|
107
|
+
//
|
|
108
|
+
// 源码 consume() 当前无守卫:undefined/NaN/Infinity 进入加权公式会产出
|
|
109
|
+
// NaN/Infinity 污染 usedTokens,导致后续 isExceeded/isThresholdReached 永远命中。
|
|
110
|
+
// 以下测试断言「脏字段当 0 处理」——守卫补上前会失败,属预期临时状态。
|
|
111
|
+
|
|
112
|
+
describe("Budget.consume NaN 守卫(undefined/NaN/Infinity 当 0 处理)", () => {
|
|
113
|
+
it("input=undefined → usedTokens 不变(不当 NaN)", () => {
|
|
114
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
115
|
+
b.consume(poisonedUsage({ input: undefined }));
|
|
116
|
+
expect(b.usedTokens).toBe(0);
|
|
117
|
+
expect(Number.isNaN(b.usedTokens)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("output=undefined → usedTokens 不变", () => {
|
|
121
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
122
|
+
b.consume(poisonedUsage({ output: undefined }));
|
|
123
|
+
expect(b.usedTokens).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("input=NaN → usedTokens 不变", () => {
|
|
127
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
128
|
+
b.consume(poisonedUsage({ input: Number.NaN }));
|
|
129
|
+
expect(b.usedTokens).toBe(0);
|
|
130
|
+
expect(Number.isNaN(b.usedTokens)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("output=NaN → usedTokens 不变", () => {
|
|
134
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
135
|
+
b.consume(poisonedUsage({ output: Number.NaN }));
|
|
136
|
+
expect(b.usedTokens).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("cacheRead=NaN / cacheWrite=NaN → usedTokens 不变", () => {
|
|
140
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
141
|
+
b.consume(
|
|
142
|
+
poisonedUsage({ cacheRead: Number.NaN, cacheWrite: Number.NaN }),
|
|
143
|
+
);
|
|
144
|
+
expect(b.usedTokens).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("input=Infinity → usedTokens 不变(Infinity 不烧穿预算)", () => {
|
|
148
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
149
|
+
b.consume(poisonedUsage({ input: Number.POSITIVE_INFINITY }));
|
|
150
|
+
expect(b.usedTokens).toBe(0);
|
|
151
|
+
expect(Number.isFinite(b.usedTokens)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("合法字段与脏字段混合 → 仅累加合法部分(脏字段当 0)", () => {
|
|
155
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
156
|
+
// input=100(合法)+ output=undefined(脏,当 0)
|
|
157
|
+
b.consume(poisonedUsage({ input: 100, output: undefined }));
|
|
158
|
+
expect(b.usedTokens).toBe(100);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("已有累计后再遇脏数据 → usedTokens 保持原值不退化", () => {
|
|
162
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
163
|
+
b.consume(usage({ input: 50 })); // usedTokens = 50
|
|
164
|
+
b.consume(poisonedUsage({ input: undefined }));
|
|
165
|
+
expect(b.usedTokens).toBe(50);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("cost=undefined → usedCost 不当 NaN", () => {
|
|
169
|
+
const b = new Budget();
|
|
170
|
+
b.consume(poisonedUsage({ cost: undefined }));
|
|
171
|
+
expect(b.usedCost).toBe(0);
|
|
172
|
+
expect(Number.isNaN(b.usedCost)).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── incrementCallCount ───────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("Budget.incrementCallCount", () => {
|
|
179
|
+
it("累加调用计数(每次 agent dispatch 后 +1)", () => {
|
|
180
|
+
const b = new Budget();
|
|
181
|
+
expect(b.totalCallCount).toBe(0);
|
|
182
|
+
b.incrementCallCount();
|
|
183
|
+
b.incrementCallCount();
|
|
184
|
+
b.incrementCallCount();
|
|
185
|
+
expect(b.totalCallCount).toBe(3);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── isExceeded ───────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
describe("Budget.isExceeded", () => {
|
|
192
|
+
it("maxTokens=1000, usedTokens=999 → 未超", () => {
|
|
193
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
194
|
+
b.consume(usage({ input: 999 }));
|
|
195
|
+
expect(b.isExceeded()).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("maxTokens=1000, usedTokens=1000 → 超限(边界 >=)", () => {
|
|
199
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
200
|
+
b.consume(usage({ input: 1000 }));
|
|
201
|
+
expect(b.isExceeded()).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("maxTokens=1000, usedTokens=1001 → 超限", () => {
|
|
205
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
206
|
+
b.consume(usage({ input: 1001 }));
|
|
207
|
+
expect(b.isExceeded()).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("maxTokens=0 → 视为不限制(守卫,首个 agent 完成不误判)", () => {
|
|
211
|
+
const b = new Budget({ maxTokens: 0 });
|
|
212
|
+
b.consume(usage({ input: 999999 }));
|
|
213
|
+
expect(b.isExceeded()).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("maxTokens undefined → 视为不限制", () => {
|
|
217
|
+
const b = new Budget();
|
|
218
|
+
b.consume(usage({ input: 999999 }));
|
|
219
|
+
expect(b.isExceeded()).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("maxCost 边界:usedCost >= maxCost → 超限", () => {
|
|
223
|
+
const b = new Budget({ maxCost: 1 });
|
|
224
|
+
b.consume(usage({ cost: 1 }));
|
|
225
|
+
expect(b.isExceeded()).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("maxCost=0 → 视为不限制", () => {
|
|
229
|
+
const b = new Budget({ maxCost: 0 });
|
|
230
|
+
b.consume(usage({ cost: 999 }));
|
|
231
|
+
expect(b.isExceeded()).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("maxCost undefined → 视为不限制", () => {
|
|
235
|
+
const b = new Budget();
|
|
236
|
+
b.consume(usage({ cost: 999 }));
|
|
237
|
+
expect(b.isExceeded()).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("token 与 cost 均未达阈值 → 不超限", () => {
|
|
241
|
+
const b = new Budget({ maxTokens: 1000, maxCost: 10 });
|
|
242
|
+
b.consume(usage({ input: 50, cost: 0.5 }));
|
|
243
|
+
expect(b.isExceeded()).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── remaining ────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
describe("Budget.remaining", () => {
|
|
250
|
+
it("正常:maxTokens - usedTokens", () => {
|
|
251
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
252
|
+
b.consume(usage({ input: 300 }));
|
|
253
|
+
expect(b.remaining()).toBe(700);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("usedTokens > maxTokens → clamp 到 0(不返回负数)", () => {
|
|
257
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
258
|
+
b.consume(usage({ input: 1500 }));
|
|
259
|
+
expect(b.remaining()).toBe(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("maxTokens=1000, usedTokens=1000(恰好用尽)→ 0", () => {
|
|
263
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
264
|
+
b.consume(usage({ input: 1000 }));
|
|
265
|
+
expect(b.remaining()).toBe(0);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("maxTokens undefined → undefined(视为不限制)", () => {
|
|
269
|
+
const b = new Budget();
|
|
270
|
+
expect(b.remaining()).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("maxTokens <= 0 → undefined(视为不限制)", () => {
|
|
274
|
+
const b = new Budget({ maxTokens: 0 });
|
|
275
|
+
expect(b.remaining()).toBeUndefined();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ── isThresholdReached ───────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
describe("Budget.isThresholdReached", () => {
|
|
282
|
+
it("达到 90% 阈值(usedTokens >= maxTokens × 0.9)", () => {
|
|
283
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
284
|
+
b.consume(usage({ input: 900 }));
|
|
285
|
+
expect(b.isThresholdReached(0.9)).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("未达 90% 阈值", () => {
|
|
289
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
290
|
+
b.consume(usage({ input: 899 }));
|
|
291
|
+
expect(b.isThresholdReached(0.9)).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("边界:恰好等于阈值 → true(>= 语义)", () => {
|
|
295
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
296
|
+
b.consume(usage({ input: 950 }));
|
|
297
|
+
expect(b.isThresholdReached(0.95)).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("maxTokens=0 → false(守卫)", () => {
|
|
301
|
+
const b = new Budget({ maxTokens: 0 });
|
|
302
|
+
b.consume(usage({ input: 999999 }));
|
|
303
|
+
expect(b.isThresholdReached(0.9)).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("maxTokens undefined → false", () => {
|
|
307
|
+
const b = new Budget();
|
|
308
|
+
b.consume(usage({ input: 999999 }));
|
|
309
|
+
expect(b.isThresholdReached(0.9)).toBe(false);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("纯查询无状态——重复查询结果一致", () => {
|
|
313
|
+
const b = new Budget({ maxTokens: 1000 });
|
|
314
|
+
b.consume(usage({ input: 950 }));
|
|
315
|
+
expect(b.isThresholdReached(0.9)).toBe(true);
|
|
316
|
+
expect(b.isThresholdReached(0.9)).toBe(true);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ── isSoftLimitReached ───────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
describe("Budget.isSoftLimitReached", () => {
|
|
323
|
+
it(`totalCallCount > ${SOFT_MAX_AGENTS_WARNING} 触发(> 严格语义)`, () => {
|
|
324
|
+
const b = new Budget();
|
|
325
|
+
for (let i = 0; i < SOFT_MAX_AGENTS_WARNING; i++) b.incrementCallCount();
|
|
326
|
+
expect(b.isSoftLimitReached()).toBe(false);
|
|
327
|
+
b.incrementCallCount(); // 501
|
|
328
|
+
expect(b.isSoftLimitReached()).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("无状态——可重复查询(非一次性 flag)", () => {
|
|
332
|
+
const b = new Budget({ totalCallCount: SOFT_MAX_AGENTS_WARNING + 1 });
|
|
333
|
+
expect(b.isSoftLimitReached()).toBe(true);
|
|
334
|
+
expect(b.isSoftLimitReached()).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ── 构造 ─────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
describe("Budget 构造", () => {
|
|
341
|
+
it("默认值全 0 / undefined", () => {
|
|
342
|
+
const b = new Budget();
|
|
343
|
+
expect(b.usedTokens).toBe(0);
|
|
344
|
+
expect(b.usedCost).toBe(0);
|
|
345
|
+
expect(b.totalCallCount).toBe(0);
|
|
346
|
+
expect(b.maxTokens).toBeUndefined();
|
|
347
|
+
expect(b.maxCost).toBeUndefined();
|
|
348
|
+
expect(b.maxTimeMs).toBeUndefined();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("从持久化数据重建(保留全部字段)", () => {
|
|
352
|
+
const b = new Budget({
|
|
353
|
+
maxTokens: 5000,
|
|
354
|
+
maxCost: 2,
|
|
355
|
+
maxTimeMs: 60000,
|
|
356
|
+
usedTokens: 100,
|
|
357
|
+
usedCost: 0.3,
|
|
358
|
+
totalCallCount: 7,
|
|
359
|
+
});
|
|
360
|
+
expect(b.maxTokens).toBe(5000);
|
|
361
|
+
expect(b.maxCost).toBe(2);
|
|
362
|
+
expect(b.maxTimeMs).toBe(60000);
|
|
363
|
+
expect(b.usedTokens).toBe(100);
|
|
364
|
+
expect(b.usedCost).toBe(0.3);
|
|
365
|
+
expect(b.totalCallCount).toBe(7);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — AgentCall 实体
|
|
3
|
+
*
|
|
4
|
+
* 单次 agent 调用的数据 + 不变式守卫(D-12)。纯数据,无 execute 上帝方法——
|
|
5
|
+
* 执行编排(重试+预算+stale 检测)在 execute-agent-call.ts 的 free function。
|
|
6
|
+
*
|
|
7
|
+
* - 状态机:pending → running → done(不可逆)
|
|
8
|
+
* - markRunning 进入 running 并 attempts++(每次 retry 前调用)
|
|
9
|
+
* - markDone(result) 进入 done 并记录结果
|
|
10
|
+
* - traceNode 持有引用,但 AgentCall 不直接改其字段——trace 同步由
|
|
11
|
+
* Trace.update 负责(D-10 单一来源),AgentCall 只持有引用供 executeAgentCall 读取
|
|
12
|
+
*
|
|
13
|
+
* 层归属:Engine。
|
|
14
|
+
*
|
|
15
|
+
* 参考:domain-models.md §5(字段/不变式/设计决策)。
|
|
16
|
+
*/
|
|
17
|
+
import type { AgentCallOpts, AgentResult, ExecutionTraceNode } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
/** AgentCall 生命周期状态。pending→running→done,不可逆。 */
|
|
20
|
+
export type AgentCallStatus = "pending" | "running" | "done";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* AgentCall 实体(在 RunState.calls Map 内)。
|
|
24
|
+
*
|
|
25
|
+
* 不变式:
|
|
26
|
+
* - status 转换严格 pending→running→done,反向抛错
|
|
27
|
+
* - done 时 result 必须已设置(markDone(result) 前置保证)
|
|
28
|
+
* - attempts 反映 dispatch 次数(markRunning 累加,含首次)
|
|
29
|
+
* - **无 execute 方法**(D-12:执行编排由 Engine executeAgentCall 函数承担)
|
|
30
|
+
*/
|
|
31
|
+
export class AgentCall {
|
|
32
|
+
readonly id: number;
|
|
33
|
+
readonly opts: AgentCallOpts;
|
|
34
|
+
status: AgentCallStatus = "pending";
|
|
35
|
+
attempts = 0;
|
|
36
|
+
result?: AgentResult;
|
|
37
|
+
/** Pi subprocess session ID(uuidv7,G-017 归此)。 */
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
/** 与 Trace 共享的节点引用(D-10 单源)。AgentCall 不直接改其字段。 */
|
|
40
|
+
readonly traceNode: ExecutionTraceNode;
|
|
41
|
+
|
|
42
|
+
constructor(id: number, opts: AgentCallOpts, traceNode: ExecutionTraceNode) {
|
|
43
|
+
this.id = id;
|
|
44
|
+
this.opts = opts;
|
|
45
|
+
this.traceNode = traceNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 标记进入 running 状态(dispatch 前)。attempts++(含首次)。
|
|
50
|
+
* @throws 若已 done(不可重启)
|
|
51
|
+
*/
|
|
52
|
+
markRunning(): void {
|
|
53
|
+
if (this.status === "done") {
|
|
54
|
+
throw new Error(`AgentCall ${this.id} already done — cannot mark running`);
|
|
55
|
+
}
|
|
56
|
+
this.status = "running";
|
|
57
|
+
this.attempts += 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 标记完成(成功或失败均调用——result.error 区分)。
|
|
62
|
+
* @throws 若当前非 running(pending 不能直接跳 done,必须先 markRunning)
|
|
63
|
+
*/
|
|
64
|
+
markDone(result: AgentResult): void {
|
|
65
|
+
if (this.status !== "running") {
|
|
66
|
+
throw new Error(`AgentCall ${this.id} must be running to mark done (was ${this.status})`);
|
|
67
|
+
}
|
|
68
|
+
this.result = result;
|
|
69
|
+
this.status = "done";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 记录 pi subprocess session ID(dispatch 成功后)。 */
|
|
73
|
+
setSessionId(sessionId: string): void {
|
|
74
|
+
this.sessionId = sessionId;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — Budget 值对象
|
|
3
|
+
*
|
|
4
|
+
* Token / cost 预算值对象(D-12)。纯数据 + 不变式守卫,无副作用。
|
|
5
|
+
*
|
|
6
|
+
* 设计:
|
|
7
|
+
* - 无 onConsume 回调(值对象不应持可变回调)。
|
|
8
|
+
* - soft limit 通知由 lifecycle 层 consume 后查 isSoftLimitReached 发出(职责分离)。
|
|
9
|
+
* - 90% 预警用查询式 isThresholdReached(无状态,可重复查)。
|
|
10
|
+
* - maxTokens===0 视为不限制(守卫,避免首个 agent 完成误判 budget_limited)。
|
|
11
|
+
*
|
|
12
|
+
* 层归属:Engine。
|
|
13
|
+
*
|
|
14
|
+
* 参考:domain-models.md §4(字段/不变式/操作)。
|
|
15
|
+
*/
|
|
16
|
+
import type { AgentUsage } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/** Soft limit:总调用数超此值发预警(FR-7,从 ConcurrencyGate 迁入)。 */
|
|
19
|
+
export const SOFT_MAX_AGENTS_WARNING = 500;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Budget 加权系数(token 口径)。
|
|
23
|
+
*
|
|
24
|
+
* usedTokens 不再是四项原始 token 简单求和,而是加权后的「等效消耗」,
|
|
25
|
+
* 反映不同 token 桶的真实计费/处理开销差异:
|
|
26
|
+
* - input(非缓存新增):首次见到、需完整处理的新内容,权重 1(基准)
|
|
27
|
+
* - cacheRead(命中缓存):读取历史,开销极低,按 1/50 折算(权重 0.02)
|
|
28
|
+
* - cacheWrite(首次写入缓存):本版本不计入 budget(权重 0)
|
|
29
|
+
* - output:模型自回归生成,开销最高,权重 2
|
|
30
|
+
*
|
|
31
|
+
* 这避免了长 session 中 cacheRead 随轮次单调累积导致 budget 被快速烧穿的失真
|
|
32
|
+
* (详见讨论:cacheRead 在 N 轮里被报 N 次,去重上下文只有一份)。
|
|
33
|
+
*/
|
|
34
|
+
export const INPUT_WEIGHT = 1;
|
|
35
|
+
export const CACHE_READ_WEIGHT = 0.02;
|
|
36
|
+
export const CACHE_WRITE_WEIGHT = 0;
|
|
37
|
+
export const OUTPUT_WEIGHT = 2;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Budget 值对象。
|
|
41
|
+
*
|
|
42
|
+
* 不变式(domain-models.md §4):
|
|
43
|
+
* - maxTokens > 0 守卫:maxTokens===0 或 undefined 视为不限制
|
|
44
|
+
* - maxCost > 0 守卫:同上
|
|
45
|
+
* - consume 只累加,不减;isExceeded 只读
|
|
46
|
+
* - 无回调字段——所有副作用由调用方在 consume 后查询决定
|
|
47
|
+
*/
|
|
48
|
+
export class Budget {
|
|
49
|
+
readonly maxTokens?: number;
|
|
50
|
+
readonly maxCost?: number;
|
|
51
|
+
readonly maxTimeMs?: number;
|
|
52
|
+
usedTokens = 0;
|
|
53
|
+
usedCost = 0;
|
|
54
|
+
/** 总调用计数(soft limit 用,从 ConcurrencyGate.totalCallCount 迁入)。 */
|
|
55
|
+
totalCallCount = 0;
|
|
56
|
+
|
|
57
|
+
constructor(opts: {
|
|
58
|
+
maxTokens?: number;
|
|
59
|
+
maxCost?: number;
|
|
60
|
+
maxTimeMs?: number;
|
|
61
|
+
usedTokens?: number;
|
|
62
|
+
usedCost?: number;
|
|
63
|
+
totalCallCount?: number;
|
|
64
|
+
} = {}) {
|
|
65
|
+
this.maxTokens = opts.maxTokens;
|
|
66
|
+
this.maxCost = opts.maxCost;
|
|
67
|
+
this.maxTimeMs = opts.maxTimeMs;
|
|
68
|
+
this.usedTokens = opts.usedTokens ?? 0;
|
|
69
|
+
this.usedCost = opts.usedCost ?? 0;
|
|
70
|
+
this.totalCallCount = opts.totalCallCount ?? 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 累加一次 agent 调用的 usage(加权口径)。
|
|
75
|
+
*
|
|
76
|
+
* 四项 token 按各自权重(INPUT/CACHE_READ/CACHE_WRITE/OUTPUT_WEIGHT)折算后求和,
|
|
77
|
+
* 而非原始 token 数直接相加。retry 间的真实消耗如实记录,避免预算被低估。
|
|
78
|
+
* 详见上方权重常量的口径说明。
|
|
79
|
+
*/
|
|
80
|
+
consume(usage: AgentUsage): void {
|
|
81
|
+
// NaN 守卫——非有限值当 0 处理,防 usedTokens 变 NaN 导致 isExceeded() 永远 false(预算限制失效)
|
|
82
|
+
const numOrZero = (v: number | undefined): number =>
|
|
83
|
+
typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
84
|
+
this.usedTokens +=
|
|
85
|
+
numOrZero(usage.input) * INPUT_WEIGHT +
|
|
86
|
+
numOrZero(usage.output) * OUTPUT_WEIGHT +
|
|
87
|
+
numOrZero(usage.cacheRead) * CACHE_READ_WEIGHT +
|
|
88
|
+
numOrZero(usage.cacheWrite) * CACHE_WRITE_WEIGHT;
|
|
89
|
+
this.usedCost += numOrZero(usage.cost);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 累加调用计数(每次 agent dispatch 后调用)。 */
|
|
93
|
+
incrementCallCount(): void {
|
|
94
|
+
this.totalCallCount += 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 是否超 token / cost 预算(FR-3)。
|
|
99
|
+
*
|
|
100
|
+
* maxTokens===0 或 undefined 视为不限制(守卫);
|
|
101
|
+
* maxCost===0 或 undefined 视为不限制。
|
|
102
|
+
* 时间预算(maxTimeMs)不由本方法判断——它是 wall-clock 约束,需参照 startedAt,
|
|
103
|
+
* 由 lifecycle 层的 scheduleTimeBudget(runWorkflow/resumeRun 内 setTimeout)
|
|
104
|
+
* 独立调度,到期 abortRun(doneReason="time_limited")。
|
|
105
|
+
*/
|
|
106
|
+
isExceeded(): boolean {
|
|
107
|
+
if (this.maxTokens !== undefined && this.maxTokens > 0 && this.usedTokens >= this.maxTokens) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return this.maxCost !== undefined && this.maxCost > 0 && this.usedCost >= this.maxCost;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 是否达到 soft limit(FR-7)。
|
|
115
|
+
*
|
|
116
|
+
* totalCallCount > SOFT_MAX_AGENTS_WARNING(500)。
|
|
117
|
+
* 调用方(lifecycle)在 consume/incrementCallCount 后查询,
|
|
118
|
+
* 命中时发通知(无状态——可重复查询)。
|
|
119
|
+
*/
|
|
120
|
+
isSoftLimitReached(): boolean {
|
|
121
|
+
return this.totalCallCount > SOFT_MAX_AGENTS_WARNING;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 剩余 token 预算。maxTokens 未设或 ≤0 时返回 undefined(视为不限制)。
|
|
126
|
+
*
|
|
127
|
+
* 嵌套 workflow() 调用时由 executeNestedWorkflow 消费:子 run 的 budgetTokens
|
|
128
|
+
* 继承父 run 的剩余预算,实现父子预算隔离下的总量约束。
|
|
129
|
+
*/
|
|
130
|
+
remaining(): number | undefined {
|
|
131
|
+
if (this.maxTokens === undefined || this.maxTokens <= 0) return undefined;
|
|
132
|
+
return Math.max(0, this.maxTokens - this.usedTokens);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 是否达到 token 预算的给定比例阈值(如 0.9 = 90% 预警)。
|
|
137
|
+
*
|
|
138
|
+
* 纯查询,无状态——调用方负责去重(旧 _budgetWarningSent 语义由 lifecycle 层用
|
|
139
|
+
* 外部 Set 或 once-listener 实现)。maxTokens 未设或为 0 时返回 false。
|
|
140
|
+
*/
|
|
141
|
+
isThresholdReached(ratio: number): boolean {
|
|
142
|
+
return (
|
|
143
|
+
this.maxTokens !== undefined &&
|
|
144
|
+
this.maxTokens > 0 &&
|
|
145
|
+
this.usedTokens >= this.maxTokens * ratio
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|