Skip to content

Commit c4949e9

Browse files
anshi2018Anshi Agarwalbradygaster
authored
feat: track context window utilization (#2064)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Anshi Agarwal <anshiagarwal@microsoft.com> Co-authored-by: Brady Gaster <41929050+bradygaster@users.noreply.github.com> Copilot-Session: c8ae7013-1eb9-4f99-8762-ea407e3633f3
1 parent 0e389d2 commit c4949e9

21 files changed

Lines changed: 1085 additions & 21 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@bradygaster/squad-cli": patch
3+
"@bradygaster/squad-sdk": patch
4+
---
5+
6+
Track context-window utilization from runtime and estimated token data, publish typed events and OpenTelemetry metrics, warn interactive-shell users at a configurable threshold, and report estimated utilization from `squad watch`.

‎docs/src/content/docs/reference/cli.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ First match wins.
354354
|----------|---------|--------|
355355
| `SQUAD_CLIENT` | Detected client platform | `cli`, `vscode` |
356356
| `COPILOT_TOKEN` | Copilot auth token (SDK usage) | Token string |
357+
| `SQUAD_CONTEXT_WARNING_THRESHOLD` | Interactive-shell context utilization warning threshold | Fraction greater than `0` and at most `1` (default: `0.8`) |
357358

358359
---
359360

‎docs/src/content/docs/reference/config.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ First match wins.
195195
|----------|---------|
196196
| `SQUAD_CLIENT` | Detected client (`cli` or `vscode`) |
197197
| `COPILOT_TOKEN` | Auth token for SDK usage |
198+
| `SQUAD_CONTEXT_WARNING_THRESHOLD` | Interactive-shell context warning threshold as a fraction greater than `0` and at most `1` (default: `0.8`) |
198199

199200
---
200201

‎docs/src/content/docs/reference/sdk.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,28 @@ await telemetry.shutdown();
317317
await shutdownOTel();
318318
```
319319

320-
The root package also exports streaming helpers, cost tracking, offline mode, runtime events, OpenTelemetry metrics, and internationalization helpers.
320+
`StreamingPipeline` can also track the latest context-window occupancy for each attached session. Runtime `context_usage` events are exact; regular usage events provide an estimate when a model context-window resolver is configured.
321+
322+
```typescript
323+
import { RuntimeEventBus, StreamingPipeline } from '@bradygaster/squad-sdk';
324+
325+
const eventBus = new RuntimeEventBus();
326+
const pipeline = new StreamingPipeline({
327+
eventBus,
328+
contextWarningThreshold: 0.8,
329+
resolveContextWindow: model => model === 'gpt-5.4' ? 200_000 : undefined,
330+
});
331+
332+
pipeline.onContextUtilization(snapshot => {
333+
if (snapshot.thresholdCrossed) {
334+
console.warn(`${snapshot.sessionId} context is ${(snapshot.utilization * 100).toFixed(1)}% full`);
335+
}
336+
});
337+
```
338+
339+
Every accepted sample is also emitted as a typed `context:utilization` event and recorded in the `squad.context.utilization` OpenTelemetry gauge. Exact runtime samples take precedence over later estimates for the same session.
340+
341+
The root package also exports cost tracking, offline mode, runtime events, OpenTelemetry metrics, and internationalization helpers.
321342

322343
## Other Public Modules
323344

‎packages/squad-cli/src/cli/commands/watch/agent-spawn.ts‎

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,85 @@
1010
*/
1111

1212
import { execFile, execFileSync } from 'node:child_process';
13+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
14+
import { randomUUID } from 'node:crypto';
15+
import path from 'node:path';
1316
import type { WatchContext } from './types.js';
1417
import { withAdditionalMcpConfig } from '../../core/copilot-invocation.js';
1518

1619
/** True when running on Windows — used to gate `shell: true`. */
1720
export const IS_WINDOWS = process.platform === 'win32';
1821

22+
export interface CopilotUsageOutput {
23+
currentModel: string;
24+
lastCallInputTokens: number;
25+
lastCallOutputTokens: number;
26+
}
27+
28+
export interface AgentSpawnResult {
29+
success: boolean;
30+
error?: string;
31+
usage?: CopilotUsageOutput;
32+
usageError?: string;
33+
}
34+
35+
export interface CopilotUsageCapture {
36+
args: string[];
37+
filePath: string;
38+
}
39+
40+
function isNonNegativeInteger(value: unknown): value is number {
41+
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
42+
}
43+
44+
/** Add Copilot's per-invocation usage output flag under the effective state directory. */
45+
export function withCopilotUsageOutput(
46+
args: string[],
47+
stateRoot: string,
48+
): CopilotUsageCapture {
49+
const logDir = path.join(stateRoot, 'log');
50+
mkdirSync(logDir, { recursive: true });
51+
const filePath = path.join(logDir, `context-usage-${randomUUID()}.json`);
52+
return {
53+
args: [...args, '--usage-output-file', filePath],
54+
filePath,
55+
};
56+
}
57+
58+
/** Strictly parse the subset of Copilot usage output needed for context estimation. */
59+
export function parseCopilotUsageOutput(value: unknown): CopilotUsageOutput | undefined {
60+
if (!value || typeof value !== 'object') return undefined;
61+
const record = value as Record<string, unknown>;
62+
if (
63+
typeof record['currentModel'] !== 'string'
64+
|| record['currentModel'].trim() === ''
65+
|| !isNonNegativeInteger(record['lastCallInputTokens'])
66+
|| !isNonNegativeInteger(record['lastCallOutputTokens'])
67+
) {
68+
return undefined;
69+
}
70+
return {
71+
currentModel: record['currentModel'],
72+
lastCallInputTokens: record['lastCallInputTokens'],
73+
lastCallOutputTokens: record['lastCallOutputTokens'],
74+
};
75+
}
76+
77+
function readCopilotUsageOutput(filePath: string): {
78+
usage?: CopilotUsageOutput;
79+
error?: string;
80+
} {
81+
if (!existsSync(filePath)) {
82+
return { error: 'Copilot usage output was not created' };
83+
}
84+
try {
85+
const usage = parseCopilotUsageOutput(JSON.parse(readFileSync(filePath, 'utf8')));
86+
return usage ? { usage } : { error: 'Copilot usage output has an invalid shape' };
87+
} catch (error) {
88+
return { error: `Could not read Copilot usage output: ${(error as Error).message}` };
89+
}
90+
}
91+
1992
/**
2093
* Escape an argument for safe use with cmd.exe when `shell: true`.
2194
*
@@ -206,9 +279,10 @@ export function spawnAgent(
206279
cwd: string,
207280
timeoutMs: number,
208281
pidTracking?: { tracker: NonNullable<WatchContext['pidTracker']>; label: string },
209-
): Promise<{ success: boolean; error?: string }> {
282+
usageOutputFile?: string,
283+
): Promise<AgentSpawnResult> {
210284
const safeArgs = escapeArgs(args);
211-
return new Promise<{ success: boolean; error?: string }>((resolve) => {
285+
return new Promise<AgentSpawnResult>((resolve) => {
212286
const cp = execFile(
213287
cmd,
214288
safeArgs,
@@ -219,12 +293,31 @@ export function spawnAgent(
219293
shell: IS_WINDOWS,
220294
},
221295
(err) => {
296+
const usageResult = usageOutputFile
297+
? readCopilotUsageOutput(usageOutputFile)
298+
: {};
299+
if (usageOutputFile && existsSync(usageOutputFile)) {
300+
try {
301+
unlinkSync(usageOutputFile);
302+
} catch (cleanupError) {
303+
usageResult.error ??= `Could not remove Copilot usage output: ${(cleanupError as Error).message}`;
304+
}
305+
}
222306
if (err) {
223307
const execErr = err as Error & { killed?: boolean };
224308
const msg = execErr.killed ? 'Timed out' : execErr.message;
225-
resolve({ success: false, error: msg });
309+
resolve({
310+
success: false,
311+
error: msg,
312+
usage: usageResult.usage,
313+
usageError: usageResult.error,
314+
});
226315
} else {
227-
resolve({ success: true });
316+
resolve({
317+
success: true,
318+
usage: usageResult.usage,
319+
usageError: usageResult.error,
320+
});
228321
}
229322
},
230323
);

‎packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts‎

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ import { existsSync } from 'node:fs';
77
import path from 'node:path';
88
import type { WatchCapability, WatchContext, PreflightResult, CapabilityResult } from '../types.js';
99
import type { MachineCapabilities } from '@bradygaster/squad-sdk/ralph/capabilities';
10+
import { SquadClient } from '@bradygaster/squad-sdk/client';
1011
import { createVerboseLogger } from '../verbose.js';
1112
import { loadAgentCharter } from '../../../shell/spawn.js';
12-
import { buildCopilotCommand, spawnAgent } from '../agent-spawn.js';
13+
import {
14+
buildCopilotCommand,
15+
spawnAgent,
16+
withCopilotUsageOutput,
17+
} from '../agent-spawn.js';
18+
import type { AgentSpawnResult, CopilotUsageOutput } from '../agent-spawn.js';
1319

1420
/** Normalized work item for execution. */
1521
export interface ExecutableWorkItem {
@@ -138,7 +144,7 @@ async function executeAll(
138144
issues: ExecutableWorkItem[],
139145
context: WatchContext,
140146
timeoutMs: number,
141-
): Promise<{ success: boolean; error?: string }> {
147+
): Promise<AgentSpawnResult> {
142148
const prompt = buildAgentPrompt(issues, context.teamRoot, context.stateRoot);
143149

144150
// Load Ralph's charter to give the spawned session full specialist context.
@@ -151,15 +157,67 @@ async function executeAll(
151157
}
152158

153159
const fullPrompt = charterPrefix + prompt;
154-
const { cmd, args } = buildCopilotCommand(fullPrompt, context);
160+
const { cmd, args: baseArgs } = buildCopilotCommand(fullPrompt, context);
161+
let usageCapture: ReturnType<typeof withCopilotUsageOutput> | undefined;
162+
let usageSetupError: string | undefined;
163+
if (!context.agentCmd) {
164+
try {
165+
usageCapture = withCopilotUsageOutput(baseArgs, context.stateRoot);
166+
} catch (error) {
167+
usageSetupError = `Could not initialize Copilot usage capture: ${(error as Error).message}`;
168+
}
169+
}
170+
const args = usageCapture?.args ?? baseArgs;
155171

156172
// Track child PID for cleanup on exit/crash
157173
const issueNums = issues.map(i => `#${i.number}`).join(',');
158174
const pidTracking = context.pidTracker
159175
? { tracker: context.pidTracker, label: `copilot-session-${issueNums}` }
160176
: undefined;
161177

162-
return spawnAgent(cmd, args, context.teamRoot, timeoutMs, pidTracking);
178+
const result = await spawnAgent(
179+
cmd,
180+
args,
181+
context.teamRoot,
182+
timeoutMs,
183+
pidTracking,
184+
usageCapture?.filePath,
185+
);
186+
return usageSetupError ? { ...result, usageError: usageSetupError } : result;
187+
}
188+
189+
interface EstimatedContextUtilization {
190+
model: string;
191+
occupiedTokens: number;
192+
contextWindowTokens: number;
193+
utilization: number;
194+
source: 'estimated';
195+
}
196+
197+
async function estimateContextUtilization(
198+
usage: CopilotUsageOutput,
199+
teamRoot: string,
200+
): Promise<EstimatedContextUtilization | undefined> {
201+
const client = new SquadClient({ cwd: teamRoot });
202+
try {
203+
const models = await client.listModels();
204+
const model = models.find(candidate =>
205+
candidate.id === usage.currentModel || candidate.name === usage.currentModel);
206+
const contextWindowTokens = model?.capabilities.limits.max_context_window_tokens;
207+
if (!Number.isInteger(contextWindowTokens) || !contextWindowTokens || contextWindowTokens <= 0) {
208+
return undefined;
209+
}
210+
const occupiedTokens = usage.lastCallInputTokens + usage.lastCallOutputTokens;
211+
return {
212+
model: usage.currentModel,
213+
occupiedTokens,
214+
contextWindowTokens,
215+
utilization: occupiedTokens / contextWindowTokens,
216+
source: 'estimated',
217+
};
218+
} finally {
219+
await client.disconnect();
220+
}
163221
}
164222

165223
export class ExecuteCapability implements WatchCapability {
@@ -209,13 +267,44 @@ export class ExecuteCapability implements WatchCapability {
209267

210268
// Single agent invocation with all issues — agent reads ralph-instructions.md
211269
const result = await executeAll(eligible, context, timeout);
270+
let contextUtilization: EstimatedContextUtilization | undefined;
271+
let contextTelemetryError = result.usageError;
272+
if (result.usage) {
273+
try {
274+
contextUtilization = await estimateContextUtilization(result.usage, context.teamRoot);
275+
if (!contextUtilization) {
276+
contextTelemetryError = `No context-window limit found for model ${result.usage.currentModel}`;
277+
}
278+
} catch (error) {
279+
contextTelemetryError = `Could not resolve model context window: ${(error as Error).message}`;
280+
}
281+
}
282+
283+
const contextSummary = contextUtilization
284+
? `; context ${(contextUtilization.utilization * 100).toFixed(1)}% (${contextUtilization.source})`
285+
: contextTelemetryError
286+
? `; context telemetry unavailable (${contextTelemetryError})`
287+
: '';
288+
if (contextUtilization) {
289+
vlog.log(
290+
`Execute: context ${(contextUtilization.utilization * 100).toFixed(1)}% `
291+
+ `(${contextUtilization.occupiedTokens}/${contextUtilization.contextWindowTokens} tokens, estimated)`,
292+
);
293+
} else if (contextTelemetryError) {
294+
vlog.log(`Execute: ${contextTelemetryError}`);
295+
}
212296

213297
return {
214298
success: result.success,
215299
summary: result.success
216-
? `agent dispatched with ${eligible.length} issues`
217-
: `agent failed: ${result.error}`,
218-
data: { dispatched: eligible.length, success: result.success },
300+
? `agent dispatched with ${eligible.length} issues${contextSummary}`
301+
: `agent failed: ${result.error}${contextSummary}`,
302+
data: {
303+
dispatched: eligible.length,
304+
success: result.success,
305+
...(contextUtilization ? { contextUtilization } : {}),
306+
...(contextTelemetryError ? { contextTelemetryError } : {}),
307+
},
219308
};
220309
} catch (e) {
221310
return { success: false, summary: `execute error: ${(e as Error).message}` };

0 commit comments

Comments
 (0)