pi-crew 0.5.5 → 0.5.7
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/CHANGELOG.md +153 -0
- package/README.md +17 -1
- package/docs/architecture.md +2 -0
- package/docs/migration-v0.4-v0.5.md +19 -2
- package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
- package/package.json +7 -5
- package/src/benchmark/benchmark-runner.ts +45 -0
- package/src/benchmark/feedback-loop.ts +5 -0
- package/src/config/config.ts +38 -4
- package/src/config/defaults.ts +5 -0
- package/src/config/suggestions.ts +8 -0
- package/src/extension/async-notifier.ts +10 -1
- package/src/extension/cross-extension-rpc.ts +1 -1
- package/src/extension/notification-router.ts +18 -0
- package/src/extension/register.ts +13 -17
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/team-tool/anchor.ts +201 -0
- package/src/extension/team-tool/api.ts +2 -1
- package/src/extension/team-tool/auto-summarize.ts +154 -0
- package/src/extension/team-tool/run.ts +37 -2
- package/src/extension/team-tool.ts +44 -2
- package/src/hooks/registry.ts +1 -3
- package/src/observability/event-bus.ts +13 -4
- package/src/observability/event-to-metric.ts +0 -2
- package/src/runtime/anchor-manager.ts +473 -0
- package/src/runtime/async-runner.ts +8 -4
- package/src/runtime/auto-summarize.ts +350 -0
- package/src/runtime/background-runner.ts +2 -1
- package/src/runtime/budget-tracker.ts +354 -0
- package/src/runtime/chain-runner.ts +507 -0
- package/src/runtime/child-pi.ts +24 -6
- package/src/runtime/crash-recovery.ts +5 -4
- package/src/runtime/crew-agent-records.ts +32 -1
- package/src/runtime/custom-tools/irc-tool.ts +13 -0
- package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
- package/src/runtime/delivery-coordinator.ts +10 -3
- package/src/runtime/dynamic-script-runner.ts +482 -0
- package/src/runtime/handoff-manager.ts +589 -0
- package/src/runtime/hidden-handoff.ts +424 -0
- package/src/runtime/live-agent-manager.ts +20 -4
- package/src/runtime/live-session-runtime.ts +39 -4
- package/src/runtime/manifest-cache.ts +2 -1
- package/src/runtime/model-resolver.ts +16 -4
- package/src/runtime/phase-tracker.ts +373 -0
- package/src/runtime/pipeline-runner.ts +514 -0
- package/src/runtime/retry-runner.ts +354 -0
- package/src/runtime/sandbox.ts +252 -0
- package/src/runtime/scheduler.ts +7 -2
- package/src/runtime/subagent-manager.ts +1 -1
- package/src/runtime/task-graph.ts +11 -1
- package/src/runtime/task-runner.ts +15 -1
- package/src/runtime/team-runner.ts +4 -3
- package/src/schema/team-tool-schema.ts +31 -0
- package/src/skills/discover-skills.ts +5 -0
- package/src/state/active-run-registry.ts +19 -3
- package/src/state/contracts.ts +9 -0
- package/src/state/crew-init.ts +3 -3
- package/src/state/decision-ledger.ts +26 -32
- package/src/state/event-log-rotation.ts +2 -2
- package/src/state/event-log.ts +17 -4
- package/src/state/mailbox.ts +35 -1
- package/src/state/run-cache.ts +18 -8
- package/src/tools/safe-bash-extension.ts +1 -0
- package/src/tools/safe-bash.ts +153 -20
- package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
- package/src/ui/powerbar-publisher.ts +1 -0
- package/src/ui/transcript-cache.ts +13 -0
- package/src/utils/bm25-search.ts +16 -8
- package/src/utils/env-filter.ts +8 -5
- package/src/utils/redaction.ts +169 -15
- package/src/utils/sse-parser.ts +10 -1
- package/src/worktree/cleanup.ts +6 -1
- package/workflows/chain.workflow.md +252 -0
- package/workflows/pipeline.workflow.md +27 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutoSummarizeService - Enables auto-summarization with token/tool thresholds.
|
|
3
|
+
*
|
|
4
|
+
* Based on pi-boomerang's autoBoomerang pattern:
|
|
5
|
+
* - toggle() enables/disables auto-summarization
|
|
6
|
+
* - shouldAutoSummarize() checks if task should auto-summarize
|
|
7
|
+
* - Token and tool thresholds control when summarization triggers
|
|
8
|
+
*
|
|
9
|
+
* @see docs/pi-boomerang-integration-plan.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { TaskPacket, TaskResult } from "./handoff-manager.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configuration for AutoSummarizeService.
|
|
16
|
+
*/
|
|
17
|
+
export interface AutoSummarizeConfig {
|
|
18
|
+
/** Whether auto-summarize is enabled */
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
/** Token threshold to trigger summarization */
|
|
21
|
+
threshold: number;
|
|
22
|
+
/** Minimum tools used to trigger summarization (default: 5) */
|
|
23
|
+
minToolsUsed?: number;
|
|
24
|
+
/** Whether to collapse context after summarization */
|
|
25
|
+
collapseContext?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default configuration values.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_AUTO_SUMMARIZE_CONFIG: Required<Omit<AutoSummarizeConfig, "enabled">> = {
|
|
32
|
+
threshold: 5000,
|
|
33
|
+
minToolsUsed: 5,
|
|
34
|
+
collapseContext: true,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options for AutoSummarizeService.
|
|
39
|
+
*/
|
|
40
|
+
export interface AutoSummarizeServiceOptions {
|
|
41
|
+
/** Initial configuration */
|
|
42
|
+
config?: Partial<AutoSummarizeConfig>;
|
|
43
|
+
/** Custom event emitter */
|
|
44
|
+
eventEmitter?: AutoSummarizeEventEmitter;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Event emitter for auto-summarize events.
|
|
49
|
+
*/
|
|
50
|
+
export interface AutoSummarizeEventEmitter {
|
|
51
|
+
emit(event: string, data: unknown): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Event data for auto-summarize toggle event.
|
|
56
|
+
*/
|
|
57
|
+
export interface AutoSummarizeToggledEventData {
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
previousEnabled: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Event data for auto-summarize triggered event.
|
|
64
|
+
*/
|
|
65
|
+
export interface AutoSummarizeTriggeredEventData {
|
|
66
|
+
packet: TaskPacket;
|
|
67
|
+
result: TaskResult;
|
|
68
|
+
trigger: AutoSummarizeTrigger;
|
|
69
|
+
tokenCount: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What triggered the auto-summarize.
|
|
74
|
+
*/
|
|
75
|
+
export type AutoSummarizeTrigger =
|
|
76
|
+
| "token_threshold"
|
|
77
|
+
| "tools_threshold"
|
|
78
|
+
| "manual"
|
|
79
|
+
| "high_usage";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* AutoSummarizeService enables automatic summarization based on configurable thresholds.
|
|
83
|
+
* When enabled, it monitors task completion and triggers summarization for tasks
|
|
84
|
+
* that exceed token or tool usage thresholds.
|
|
85
|
+
*/
|
|
86
|
+
export class AutoSummarizeService {
|
|
87
|
+
private config: AutoSummarizeConfig & Required<Omit<AutoSummarizeConfig, "enabled">>;
|
|
88
|
+
private eventEmitter: AutoSummarizeEventEmitter | null = null;
|
|
89
|
+
|
|
90
|
+
constructor(options: AutoSummarizeServiceOptions = {}) {
|
|
91
|
+
this.config = {
|
|
92
|
+
enabled: options.config?.enabled ?? false,
|
|
93
|
+
threshold: options.config?.threshold ?? DEFAULT_AUTO_SUMMARIZE_CONFIG.threshold,
|
|
94
|
+
minToolsUsed: options.config?.minToolsUsed ?? DEFAULT_AUTO_SUMMARIZE_CONFIG.minToolsUsed,
|
|
95
|
+
collapseContext: options.config?.collapseContext ?? DEFAULT_AUTO_SUMMARIZE_CONFIG.collapseContext,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (options.eventEmitter) {
|
|
99
|
+
this.eventEmitter = options.eventEmitter;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if auto-summarization is currently enabled.
|
|
105
|
+
*/
|
|
106
|
+
isEnabled(): boolean {
|
|
107
|
+
return this.config.enabled;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Toggle auto-summarize mode.
|
|
112
|
+
* Returns the new enabled state.
|
|
113
|
+
*/
|
|
114
|
+
toggle(): boolean {
|
|
115
|
+
const previousEnabled = this.config.enabled;
|
|
116
|
+
this.config.enabled = !this.config.enabled;
|
|
117
|
+
|
|
118
|
+
this.eventEmitter?.emit("auto-summarize:toggled", {
|
|
119
|
+
enabled: this.config.enabled,
|
|
120
|
+
previousEnabled,
|
|
121
|
+
} as AutoSummarizeToggledEventData);
|
|
122
|
+
|
|
123
|
+
return this.config.enabled;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Enable auto-summarize.
|
|
128
|
+
*/
|
|
129
|
+
enable(): void {
|
|
130
|
+
if (!this.config.enabled) {
|
|
131
|
+
this.toggle();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Disable auto-summarize.
|
|
137
|
+
*/
|
|
138
|
+
disable(): void {
|
|
139
|
+
if (this.config.enabled) {
|
|
140
|
+
this.toggle();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if a task should auto-summarize.
|
|
146
|
+
*
|
|
147
|
+
* @param packet - The task packet
|
|
148
|
+
* @param result - The task result
|
|
149
|
+
* @returns True if the task should auto-summarize
|
|
150
|
+
*/
|
|
151
|
+
shouldAutoSummarize(packet: TaskPacket, result: TaskResult): boolean {
|
|
152
|
+
if (!this.config.enabled) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const tokenCount = result.usage?.totalTokens ?? 0;
|
|
157
|
+
|
|
158
|
+
// Check token threshold
|
|
159
|
+
if (tokenCount >= this.config.threshold) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check tools threshold
|
|
164
|
+
const toolsUsed = result.toolsUsed?.length ?? 0;
|
|
165
|
+
if (toolsUsed >= (this.config.minToolsUsed ?? 5)) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// High usage check: high token count relative to tools
|
|
170
|
+
// More tokens per tool suggests complex work that should be summarized
|
|
171
|
+
if (tokenCount > 2000 && toolsUsed >= 3) {
|
|
172
|
+
const tokensPerTool = tokenCount / toolsUsed;
|
|
173
|
+
if (tokensPerTool > 1000) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get the reason why a task should (or should not) auto-summarize.
|
|
183
|
+
*
|
|
184
|
+
* @param packet - The task packet
|
|
185
|
+
* @param result - The task result
|
|
186
|
+
* @returns Object with shouldSummarize flag and reason
|
|
187
|
+
*/
|
|
188
|
+
getAutoSummarizeDecision(packet: TaskPacket, result: TaskResult): AutoSummarizeDecision {
|
|
189
|
+
if (!this.config.enabled) {
|
|
190
|
+
return {
|
|
191
|
+
shouldSummarize: false,
|
|
192
|
+
reason: "auto-summarize is disabled",
|
|
193
|
+
trigger: undefined,
|
|
194
|
+
tokenCount: result.usage?.totalTokens ?? 0,
|
|
195
|
+
toolsUsed: result.toolsUsed?.length ?? 0,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const tokenCount = result.usage?.totalTokens ?? 0;
|
|
200
|
+
const toolsUsed = result.toolsUsed?.length ?? 0;
|
|
201
|
+
|
|
202
|
+
// Check token threshold
|
|
203
|
+
if (tokenCount >= this.config.threshold) {
|
|
204
|
+
return {
|
|
205
|
+
shouldSummarize: true,
|
|
206
|
+
reason: `Token count ${tokenCount} exceeds threshold ${this.config.threshold}`,
|
|
207
|
+
trigger: "token_threshold",
|
|
208
|
+
tokenCount,
|
|
209
|
+
toolsUsed,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check tools threshold
|
|
214
|
+
const minTools = this.config.minToolsUsed ?? 5;
|
|
215
|
+
if (toolsUsed >= minTools) {
|
|
216
|
+
return {
|
|
217
|
+
shouldSummarize: true,
|
|
218
|
+
reason: `Tool count ${toolsUsed} meets minimum ${minTools}`,
|
|
219
|
+
trigger: "tools_threshold",
|
|
220
|
+
tokenCount,
|
|
221
|
+
toolsUsed,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// High usage check
|
|
226
|
+
if (tokenCount > 2000 && toolsUsed >= 3) {
|
|
227
|
+
const tokensPerTool = tokenCount / toolsUsed;
|
|
228
|
+
if (tokensPerTool > 1000) {
|
|
229
|
+
return {
|
|
230
|
+
shouldSummarize: true,
|
|
231
|
+
reason: `High token-to-tool ratio: ${Math.round(tokensPerTool)} tokens/tool`,
|
|
232
|
+
trigger: "high_usage",
|
|
233
|
+
tokenCount,
|
|
234
|
+
toolsUsed,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
shouldSummarize: false,
|
|
241
|
+
reason: `Below thresholds (tokens: ${tokenCount}/${this.config.threshold}, tools: ${toolsUsed}/${minTools})`,
|
|
242
|
+
trigger: undefined,
|
|
243
|
+
tokenCount,
|
|
244
|
+
toolsUsed,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Get the current configuration.
|
|
250
|
+
*/
|
|
251
|
+
getConfig(): AutoSummarizeConfig & Required<Omit<AutoSummarizeConfig, "enabled">> {
|
|
252
|
+
return { ...this.config };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Update configuration.
|
|
257
|
+
*/
|
|
258
|
+
updateConfig(config: Partial<AutoSummarizeConfig>): void {
|
|
259
|
+
const previousEnabled = this.config.enabled;
|
|
260
|
+
|
|
261
|
+
if (config.enabled !== undefined) {
|
|
262
|
+
this.config.enabled = config.enabled;
|
|
263
|
+
}
|
|
264
|
+
if (config.threshold !== undefined) {
|
|
265
|
+
this.config.threshold = config.threshold;
|
|
266
|
+
}
|
|
267
|
+
if (config.minToolsUsed !== undefined) {
|
|
268
|
+
this.config.minToolsUsed = config.minToolsUsed;
|
|
269
|
+
}
|
|
270
|
+
if (config.collapseContext !== undefined) {
|
|
271
|
+
this.config.collapseContext = config.collapseContext;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Emit event if enabled state changed
|
|
275
|
+
if (config.enabled !== undefined && config.enabled !== previousEnabled) {
|
|
276
|
+
this.eventEmitter?.emit("auto-summarize:toggled", {
|
|
277
|
+
enabled: this.config.enabled,
|
|
278
|
+
previousEnabled,
|
|
279
|
+
} as AutoSummarizeToggledEventData);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get current threshold value.
|
|
285
|
+
*/
|
|
286
|
+
getThreshold(): number {
|
|
287
|
+
return this.config.threshold;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Set token threshold.
|
|
292
|
+
*/
|
|
293
|
+
setThreshold(threshold: number): void {
|
|
294
|
+
if (threshold < 0) {
|
|
295
|
+
throw new Error("Threshold must be non-negative");
|
|
296
|
+
}
|
|
297
|
+
this.config.threshold = threshold;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get current minToolsUsed value.
|
|
302
|
+
*/
|
|
303
|
+
getMinToolsUsed(): number {
|
|
304
|
+
return this.config.minToolsUsed ?? 5;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Set minimum tools threshold.
|
|
309
|
+
*/
|
|
310
|
+
setMinToolsUsed(minTools: number): void {
|
|
311
|
+
if (minTools < 0) {
|
|
312
|
+
throw new Error("minToolsUsed must be non-negative");
|
|
313
|
+
}
|
|
314
|
+
this.config.minToolsUsed = minTools;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Check if context should be collapsed after summarization.
|
|
319
|
+
*/
|
|
320
|
+
shouldCollapseContext(): boolean {
|
|
321
|
+
return this.config.collapseContext ?? true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Set event emitter.
|
|
326
|
+
*/
|
|
327
|
+
setEventEmitter(eventEmitter: AutoSummarizeEventEmitter): void {
|
|
328
|
+
this.eventEmitter = eventEmitter;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Decision result from shouldAutoSummarize check.
|
|
334
|
+
*/
|
|
335
|
+
export interface AutoSummarizeDecision {
|
|
336
|
+
shouldSummarize: boolean;
|
|
337
|
+
reason: string;
|
|
338
|
+
trigger: AutoSummarizeTrigger | undefined;
|
|
339
|
+
tokenCount: number;
|
|
340
|
+
toolsUsed: number;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Create an AutoSummarizeService with default options.
|
|
345
|
+
*/
|
|
346
|
+
export function createAutoSummarizeService(
|
|
347
|
+
options?: AutoSummarizeServiceOptions,
|
|
348
|
+
): AutoSummarizeService {
|
|
349
|
+
return new AutoSummarizeService(options);
|
|
350
|
+
}
|
|
@@ -24,6 +24,7 @@ import { directTeamAndWorkflowFromRun } from "./direct-run.ts";
|
|
|
24
24
|
import { expandParallelResearchWorkflow } from "./parallel-research.ts";
|
|
25
25
|
import { writeAsyncStartMarker } from "./async-marker.ts";
|
|
26
26
|
import { startParentGuard, stopParentGuard } from "./parent-guard.ts";
|
|
27
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Heartbeat mechanism: periodically write a heartbeat file so the stale reconciler
|
|
@@ -323,7 +324,7 @@ async function main(): Promise<void> {
|
|
|
323
324
|
if (loaded) {
|
|
324
325
|
// LAZY: live-agent-manager only needed on failure cleanup path; avoid module load at hot path.
|
|
325
326
|
const { terminateLiveAgentsForRun } = await import("./live-agent-manager.ts");
|
|
326
|
-
void terminateLiveAgentsForRun(loaded.manifest.runId, "failed", appendEvent, loaded.manifest.eventsPath).catch(() => {});
|
|
327
|
+
void terminateLiveAgentsForRun(loaded.manifest.runId, "failed", appendEvent, loaded.manifest.eventsPath).catch((error) => logInternalError("background-runner.terminate", error, `runId=${loaded.manifest.runId}`));
|
|
327
328
|
}
|
|
328
329
|
} catch { /* best-effort */ }
|
|
329
330
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget Tracker — token budget tracking for team/subagent execution.
|
|
3
|
+
*
|
|
4
|
+
* Tracks token usage with configurable warning (default 80%) and abort
|
|
5
|
+
* (default 95%) thresholds. Provides spent(), remaining(), warning(),
|
|
6
|
+
* exhausted(), and createAbortSignal() for integration with team-runner.
|
|
7
|
+
*
|
|
8
|
+
* @file src/runtime/budget-tracker.ts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EventEmitter } from "node:events";
|
|
12
|
+
|
|
13
|
+
/** Budget configuration passed to TeamBudgetTracker constructor. */
|
|
14
|
+
export interface BudgetConfig {
|
|
15
|
+
/** Total token budget for the run. */
|
|
16
|
+
total: number;
|
|
17
|
+
/** Warning threshold as fraction of total (default: 0.8 = 80%). */
|
|
18
|
+
warningThreshold?: number;
|
|
19
|
+
/** Abort threshold as fraction of total (default: 0.95 = 95%). */
|
|
20
|
+
abortThreshold?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Internal phase-level accounting for trackUsage breakdown. */
|
|
24
|
+
interface PhaseUsage {
|
|
25
|
+
phaseName: string;
|
|
26
|
+
tokens: number;
|
|
27
|
+
startTime: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Public usage record returned by trackUsage. */
|
|
31
|
+
export interface BudgetUsageRecord {
|
|
32
|
+
/** Total tokens spent after this update. */
|
|
33
|
+
totalSpent: number;
|
|
34
|
+
/** Tokens added in this update. */
|
|
35
|
+
delta: number;
|
|
36
|
+
/** Warning state after this update. */
|
|
37
|
+
isWarning: boolean;
|
|
38
|
+
/** Exhausted state after this update. */
|
|
39
|
+
isExhausted: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Event emitted when budget crosses thresholds. */
|
|
43
|
+
export interface BudgetEvent {
|
|
44
|
+
type: "budget:warning" | "budget:exhausted";
|
|
45
|
+
budget: BudgetSnapshot;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Snapshot of budget state for event payloads. */
|
|
49
|
+
export interface BudgetSnapshot {
|
|
50
|
+
total: number;
|
|
51
|
+
spent: number;
|
|
52
|
+
remaining: number;
|
|
53
|
+
percentUsed: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* TeamBudgetTracker tracks token usage against a configurable budget.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```typescript
|
|
61
|
+
* const tracker = new TeamBudgetTracker({ total: 100000 });
|
|
62
|
+
* tracker.trackUsage(5000);
|
|
63
|
+
* console.log(tracker.spent()); // 5000
|
|
64
|
+
* console.log(tracker.warning()); // false (50% of 100k)
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export class TeamBudgetTracker extends EventEmitter {
|
|
68
|
+
private used = 0;
|
|
69
|
+
private readonly total: number;
|
|
70
|
+
private readonly warningThreshold: number;
|
|
71
|
+
private readonly abortThreshold: number;
|
|
72
|
+
private phaseUsage: PhaseUsage[] = [];
|
|
73
|
+
private warningEmitted = false;
|
|
74
|
+
private exhaustedEmitted = false;
|
|
75
|
+
private abortController: AbortController | null = null;
|
|
76
|
+
private abortInterval: NodeJS.Timeout | null = null;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a new budget tracker.
|
|
80
|
+
* @param config - Budget configuration with total and optional thresholds.
|
|
81
|
+
*/
|
|
82
|
+
constructor(config: BudgetConfig) {
|
|
83
|
+
super();
|
|
84
|
+
this.total = config.total;
|
|
85
|
+
this.warningThreshold = config.warningThreshold ?? 0.8;
|
|
86
|
+
this.abortThreshold = config.abortThreshold ?? 0.95;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Total budget tokens.
|
|
91
|
+
*/
|
|
92
|
+
get totalBudget(): number {
|
|
93
|
+
return this.total;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get total tokens spent.
|
|
98
|
+
*/
|
|
99
|
+
spent(): number {
|
|
100
|
+
return this.used;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get remaining tokens.
|
|
105
|
+
*/
|
|
106
|
+
remaining(): number {
|
|
107
|
+
return this.total - this.used;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Percentage used as decimal (0-1).
|
|
112
|
+
*/
|
|
113
|
+
percentUsed(): number {
|
|
114
|
+
return this.total > 0 ? this.used / this.total : 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if usage has crossed the warning threshold.
|
|
119
|
+
*/
|
|
120
|
+
warning(): boolean {
|
|
121
|
+
return this.percentUsed() >= this.warningThreshold;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check if usage has crossed the abort threshold.
|
|
126
|
+
*/
|
|
127
|
+
exhausted(): boolean {
|
|
128
|
+
return this.percentUsed() >= this.abortThreshold;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check if both warning and exhausted events have been emitted for current usage.
|
|
133
|
+
*/
|
|
134
|
+
isWarningEmitted(): boolean {
|
|
135
|
+
return this.warningEmitted;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if exhausted event has been emitted.
|
|
140
|
+
*/
|
|
141
|
+
isExhaustedEmitted(): boolean {
|
|
142
|
+
return this.exhaustedEmitted;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Track token usage and emit threshold-crossed events.
|
|
147
|
+
*
|
|
148
|
+
* @param tokens - Number of tokens to add to usage.
|
|
149
|
+
* @param phaseName - Optional phase name for breakdown tracking.
|
|
150
|
+
* @returns BudgetUsageRecord with updated totals and thresholds.
|
|
151
|
+
*/
|
|
152
|
+
trackUsage(tokens: number, phaseName?: string): BudgetUsageRecord {
|
|
153
|
+
if (tokens < 0) {
|
|
154
|
+
throw new Error("trackUsage: tokens must be non-negative");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const prevSpent = this.used;
|
|
158
|
+
this.used += tokens;
|
|
159
|
+
|
|
160
|
+
// Phase-level tracking for breakdown reporting
|
|
161
|
+
if (phaseName) {
|
|
162
|
+
const existing = this.phaseUsage.find((p) => p.phaseName === phaseName);
|
|
163
|
+
if (existing) {
|
|
164
|
+
existing.tokens += tokens;
|
|
165
|
+
} else {
|
|
166
|
+
this.phaseUsage.push({ phaseName, tokens, startTime: Date.now() });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const snapshot: BudgetSnapshot = {
|
|
171
|
+
total: this.total,
|
|
172
|
+
spent: this.used,
|
|
173
|
+
remaining: this.remaining(),
|
|
174
|
+
percentUsed: this.percentUsed(),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Emit warning event on threshold crossing
|
|
178
|
+
if (this.warning() && !this.warningEmitted) {
|
|
179
|
+
this.warningEmitted = true;
|
|
180
|
+
this.emit("warning", { type: "budget:warning", budget: snapshot } as BudgetEvent);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Emit exhausted event on threshold crossing
|
|
184
|
+
if (this.exhausted() && !this.exhaustedEmitted) {
|
|
185
|
+
this.exhaustedEmitted = true;
|
|
186
|
+
this.emit("exhausted", { type: "budget:exhausted", budget: snapshot } as BudgetEvent);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
totalSpent: this.used,
|
|
191
|
+
delta: tokens,
|
|
192
|
+
isWarning: this.warning(),
|
|
193
|
+
isExhausted: this.exhausted(),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create an AbortSignal that fires when the budget is exhausted.
|
|
199
|
+
*
|
|
200
|
+
* The signal will be aborted automatically once the abort threshold
|
|
201
|
+
* is crossed. If already exhausted when called, the signal is
|
|
202
|
+
* immediately aborted.
|
|
203
|
+
*
|
|
204
|
+
* @returns AbortSignal that can be passed to subagent execution.
|
|
205
|
+
*/
|
|
206
|
+
createAbortSignal(): AbortSignal {
|
|
207
|
+
// If already exhausted, return immediately aborted signal
|
|
208
|
+
if (this.exhausted()) {
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
controller.abort(new Error("Budget exhausted before signal creation"));
|
|
211
|
+
return controller.signal;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Clear any existing interval before creating new one
|
|
215
|
+
if (this.abortInterval) {
|
|
216
|
+
clearInterval(this.abortInterval);
|
|
217
|
+
this.abortInterval = null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Create controller and set up threshold check
|
|
221
|
+
this.abortController = new AbortController();
|
|
222
|
+
|
|
223
|
+
// Store reference for potential external abort
|
|
224
|
+
const tracker = this;
|
|
225
|
+
|
|
226
|
+
// Return a signal that checks threshold on each access
|
|
227
|
+
// The actual abort happens once exhausted() first returns true
|
|
228
|
+
const signal = this.abortController.signal;
|
|
229
|
+
|
|
230
|
+
// Set up interval check and store the ID for cleanup
|
|
231
|
+
this.abortInterval = setInterval(() => {
|
|
232
|
+
if (tracker.exhausted() && !signal.aborted) {
|
|
233
|
+
tracker.abortController!.abort(
|
|
234
|
+
new Error(`Budget exhausted: ${tracker.spent()}/${tracker.total}`),
|
|
235
|
+
);
|
|
236
|
+
if (tracker.abortInterval) {
|
|
237
|
+
clearInterval(tracker.abortInterval);
|
|
238
|
+
tracker.abortInterval = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}, 1000);
|
|
242
|
+
|
|
243
|
+
// Clean up interval when signal is aborted
|
|
244
|
+
const cleanup = (): void => {
|
|
245
|
+
if (tracker.abortInterval) {
|
|
246
|
+
clearInterval(tracker.abortInterval);
|
|
247
|
+
tracker.abortInterval = null;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
signal.addEventListener("abort", cleanup, { once: true });
|
|
251
|
+
|
|
252
|
+
return signal;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get phase-level usage breakdown.
|
|
257
|
+
* @returns Array of phase usage records.
|
|
258
|
+
*/
|
|
259
|
+
getPhaseBreakdown(): { phaseName: string; tokens: number }[] {
|
|
260
|
+
return this.phaseUsage.map((p) => ({
|
|
261
|
+
phaseName: p.phaseName,
|
|
262
|
+
tokens: p.tokens,
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Reset usage for re-use (e.g., in testing or recovery scenarios).
|
|
268
|
+
* Does not reset emitted flags — use resetAll() for full reset.
|
|
269
|
+
*/
|
|
270
|
+
resetUsage(): void {
|
|
271
|
+
this.used = 0;
|
|
272
|
+
this.phaseUsage = [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Full reset including emitted flags.
|
|
277
|
+
*/
|
|
278
|
+
resetAll(): void {
|
|
279
|
+
this.used = 0;
|
|
280
|
+
this.phaseUsage = [];
|
|
281
|
+
this.warningEmitted = false;
|
|
282
|
+
this.exhaustedEmitted = false;
|
|
283
|
+
this.abortController = null;
|
|
284
|
+
if (this.abortInterval) {
|
|
285
|
+
clearInterval(this.abortInterval);
|
|
286
|
+
this.abortInterval = null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Get current snapshot of budget state.
|
|
292
|
+
*/
|
|
293
|
+
snapshot(): BudgetSnapshot {
|
|
294
|
+
return {
|
|
295
|
+
total: this.total,
|
|
296
|
+
spent: this.used,
|
|
297
|
+
remaining: this.remaining(),
|
|
298
|
+
percentUsed: this.percentUsed(),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Dispose of resources (EventEmitter listeners, timers).
|
|
304
|
+
* Call this when the tracker is no longer needed.
|
|
305
|
+
*/
|
|
306
|
+
dispose(): void {
|
|
307
|
+
this.removeAllListeners();
|
|
308
|
+
if (this.abortInterval) {
|
|
309
|
+
clearInterval(this.abortInterval);
|
|
310
|
+
this.abortInterval = null;
|
|
311
|
+
}
|
|
312
|
+
this.abortController = null;
|
|
313
|
+
this.used = 0;
|
|
314
|
+
this.phaseUsage = [];
|
|
315
|
+
this.warningEmitted = false;
|
|
316
|
+
this.exhaustedEmitted = false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Create a BudgetConfig with reasonable defaults.
|
|
322
|
+
* @param total - Total token budget.
|
|
323
|
+
* @param warningThreshold - Warning threshold (default 0.8).
|
|
324
|
+
* @param abortThreshold - Abort threshold (default 0.95).
|
|
325
|
+
*/
|
|
326
|
+
export function createBudgetConfig(
|
|
327
|
+
total: number,
|
|
328
|
+
warningThreshold = 0.8,
|
|
329
|
+
abortThreshold = 0.95,
|
|
330
|
+
): BudgetConfig {
|
|
331
|
+
return { total, warningThreshold, abortThreshold };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Check if a budget config is valid.
|
|
336
|
+
* @param config - Budget configuration to validate.
|
|
337
|
+
*/
|
|
338
|
+
export function validateBudgetConfig(config: BudgetConfig): { valid: boolean; error?: string } {
|
|
339
|
+
if (typeof config.total !== "number" || config.total <= 0) {
|
|
340
|
+
return { valid: false, error: "total must be a positive number" };
|
|
341
|
+
}
|
|
342
|
+
const warning = config.warningThreshold ?? 0.8;
|
|
343
|
+
const abort = config.abortThreshold ?? 0.95;
|
|
344
|
+
if (warning < 0 || warning > 1) {
|
|
345
|
+
return { valid: false, error: "warningThreshold must be between 0 and 1" };
|
|
346
|
+
}
|
|
347
|
+
if (abort < 0 || abort > 1) {
|
|
348
|
+
return { valid: false, error: "abortThreshold must be between 0 and 1" };
|
|
349
|
+
}
|
|
350
|
+
if (warning >= abort) {
|
|
351
|
+
return { valid: false, error: "warningThreshold must be less than abortThreshold" };
|
|
352
|
+
}
|
|
353
|
+
return { valid: true };
|
|
354
|
+
}
|