Skip to content

Commit 94790e3

Browse files
authored
bridge: emit context-window stats in inbox pull meta (#150)
1 parent 57ccddb commit 94790e3

4 files changed

Lines changed: 136 additions & 16 deletions

File tree

CONFIGURATION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ Set by `sudo baudbot broker register` when using brokered Slack OAuth flow.
113113
| `SLACK_BROKER_DEDUPE_TTL_MS` | Dedupe cache TTL in milliseconds (default: `1200000`) |
114114
| `BAUDBOT_AGENT_VERSION` | Optional override for broker observability `meta.agent_version` (otherwise read from `~/.pi/agent/baudbot-version.json` when available) |
115115

116+
Broker mode also emits best-effort context usage telemetry in inbox pull `meta` by reading `~/.pi/agent/context-usage.json` (written by the `context` extension on session start/turn end/tool results).
117+
116118
### Kernel (Cloud Browsers)
117119

118120
| Variable | Description | How to get it |

pi/extensions/context.ts

Lines changed: 85 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ function extractCostTotal(usage: any): number {
169169
return 0;
170170
}
171171

172-
function sumSessionUsage(ctx: ExtensionCommandContext): {
172+
function sumSessionUsage(ctx: ExtensionContext | ExtensionCommandContext): {
173173
input: number;
174174
output: number;
175175
cacheRead: number;
@@ -206,6 +206,71 @@ function sumSessionUsage(ctx: ExtensionCommandContext): {
206206
};
207207
}
208208

209+
function estimateToolDefinitionTokens(pi: ExtensionAPI): { toolsTokens: number; activeTools: number } {
210+
const TOOL_FUDGE = 1.5;
211+
const activeToolNames = pi.getActiveTools();
212+
const toolInfoByName = new Map(pi.getAllTools().map((t) => [t.name, t] as const));
213+
let toolsTokens = 0;
214+
for (const name of activeToolNames) {
215+
const info = toolInfoByName.get(name);
216+
const blob = `${name}\n${info?.description ?? ""}`;
217+
toolsTokens += estimateTokens(blob);
218+
}
219+
toolsTokens = Math.round(toolsTokens * TOOL_FUDGE);
220+
return { toolsTokens, activeTools: activeToolNames.length };
221+
}
222+
223+
type ContextUsageSnapshot = {
224+
generated_at: string;
225+
session_id: string;
226+
context_window_used_tokens?: number;
227+
context_window_limit_tokens?: number;
228+
context_window_used_pct?: number;
229+
session_total_tokens: number;
230+
session_total_cost_usd: number;
231+
message_tokens?: number;
232+
tools_tokens?: number;
233+
};
234+
235+
function buildContextUsageSnapshot(pi: ExtensionAPI, ctx: ExtensionContext): ContextUsageSnapshot {
236+
const usage = ctx.getContextUsage();
237+
const { toolsTokens } = estimateToolDefinitionTokens(pi);
238+
const messageTokens = usage?.tokens ?? 0;
239+
const contextWindow = usage?.contextWindow ?? 0;
240+
const effectiveTokens = Math.max(0, messageTokens + toolsTokens);
241+
const percent = contextWindow > 0 ? (effectiveTokens / contextWindow) * 100 : 0;
242+
const sessionUsage = sumSessionUsage(ctx);
243+
244+
const snapshot: ContextUsageSnapshot = {
245+
generated_at: new Date().toISOString(),
246+
session_id: ctx.sessionManager.getSessionId(),
247+
session_total_tokens: Math.max(0, sessionUsage.totalTokens),
248+
session_total_cost_usd: Math.max(0, sessionUsage.totalCost),
249+
};
250+
251+
if (contextWindow > 0) {
252+
snapshot.context_window_used_tokens = effectiveTokens;
253+
snapshot.context_window_limit_tokens = contextWindow;
254+
snapshot.context_window_used_pct = percent;
255+
snapshot.message_tokens = messageTokens;
256+
snapshot.tools_tokens = toolsTokens;
257+
}
258+
259+
return snapshot;
260+
}
261+
262+
async function persistContextUsageSnapshot(snapshot: ContextUsageSnapshot): Promise<void> {
263+
const snapshotPath = path.join(os.homedir(), ".pi", "agent", "context-usage.json");
264+
const tmpPath = `${snapshotPath}.tmp`;
265+
try {
266+
await fs.mkdir(path.dirname(snapshotPath), { recursive: true });
267+
await fs.writeFile(tmpPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 });
268+
await fs.rename(tmpPath, snapshotPath);
269+
} catch {
270+
// Best-effort only. Observability should not disrupt agent runtime.
271+
}
272+
}
273+
209274
function shortenPath(p: string, cwd: string): string {
210275
const rp = path.resolve(p);
211276
const rc = path.resolve(cwd);
@@ -449,7 +514,14 @@ export default function contextExtension(pi: ExtensionAPI) {
449514
return best?.name ?? null;
450515
};
451516

517+
const persistSnapshotFromContext = async (ctx: ExtensionContext): Promise<void> => {
518+
const snapshot = buildContextUsageSnapshot(pi, ctx);
519+
await persistContextUsageSnapshot(snapshot);
520+
};
521+
452522
pi.on("tool_result", (event: ToolResultEvent, ctx: ExtensionContext) => {
523+
void persistSnapshotFromContext(ctx);
524+
453525
// Only count successful reads.
454526
if ((event as any).toolName !== "read") return;
455527
if ((event as any).isError) return;
@@ -469,6 +541,14 @@ export default function contextExtension(pi: ExtensionAPI) {
469541
}
470542
});
471543

544+
pi.on("turn_end", async (_event, ctx: ExtensionContext) => {
545+
await persistSnapshotFromContext(ctx);
546+
});
547+
548+
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
549+
await persistSnapshotFromContext(ctx);
550+
});
551+
472552
pi.registerCommand("context", {
473553
description: "Show loaded context overview",
474554
handler: async (_args, ctx: ExtensionCommandContext) => {
@@ -503,18 +583,8 @@ export default function contextExtension(pi: ExtensionAPI) {
503583
const ctxWindow = usage?.contextWindow ?? 0;
504584

505585
// Tool definitions are not part of ctx.getContextUsage() (it estimates message tokens).
506-
// We approximate their token impact from tool name + description, and apply a fudge
507-
// factor to account for parameters/schema/formatting.
508-
const TOOL_FUDGE = 1.5;
509-
const activeToolNames = pi.getActiveTools();
510-
const toolInfoByName = new Map(pi.getAllTools().map((t) => [t.name, t] as const));
511-
let toolsTokens = 0;
512-
for (const name of activeToolNames) {
513-
const info = toolInfoByName.get(name);
514-
const blob = `${name}\n${info?.description ?? ""}`;
515-
toolsTokens += estimateTokens(blob);
516-
}
517-
toolsTokens = Math.round(toolsTokens * TOOL_FUDGE);
586+
// We approximate their token impact from tool name + description.
587+
const { toolsTokens, activeTools } = estimateToolDefinitionTokens(pi);
518588

519589
const effectiveTokens = messageTokens + toolsTokens;
520590
const percent = ctxWindow > 0 ? (effectiveTokens / ctxWindow) * 100 : 0;
@@ -533,7 +603,7 @@ export default function contextExtension(pi: ExtensionAPI) {
533603
lines.push("Window: (unknown)");
534604
}
535605
lines.push(`System: ~${systemPromptTokens.toLocaleString()} tok (AGENTS ~${agentTokens.toLocaleString()})`);
536-
lines.push(`Tools: ~${toolsTokens.toLocaleString()} tok (${activeToolNames.length} active)`);
606+
lines.push(`Tools: ~${toolsTokens.toLocaleString()} tok (${activeTools} active)`);
537607
lines.push(`AGENTS: ${agentFilePaths.length ? joinComma(agentFilePaths) : "(none)"}`);
538608
lines.push(`Extensions (${extensionFiles.length}): ${extensionFiles.length ? joinComma(extensionFiles) : "(none)"}`);
539609
lines.push(`Skills (${skills.length}): ${skills.length ? joinComma(skills) : "(none)"}`);
@@ -559,7 +629,7 @@ export default function contextExtension(pi: ExtensionAPI) {
559629
systemPromptTokens,
560630
agentTokens,
561631
toolsTokens,
562-
activeTools: activeToolNames.length,
632+
activeTools,
563633
}
564634
: null,
565635
agentFiles: agentFilePaths,

slack-bridge/broker-bridge.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const MAX_BACKOFF_MS = 30_000;
5353
const INBOX_PROTOCOL_VERSION = "2026-02-1";
5454
const BROKER_HEALTH_PATH = path.join(homedir(), ".pi", "agent", "broker-health.json");
5555
const BAUDBOT_VERSION_PATH = path.join(homedir(), ".pi", "agent", "baudbot-version.json");
56+
const CONTEXT_USAGE_PATH = path.join(homedir(), ".pi", "agent", "context-usage.json");
5657
const LOG_BUFFER_MAX_LINES = 1000;
5758

5859
const logLineBuffer = [];
@@ -228,10 +229,33 @@ function countActivePiSessions() {
228229
}
229230
}
230231

232+
function readContextUsageSnapshot() {
233+
try {
234+
const raw = fs.readFileSync(CONTEXT_USAGE_PATH, "utf8");
235+
const parsed = JSON.parse(raw);
236+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
237+
238+
const readFiniteNumber = (value) => (typeof value === "number" && Number.isFinite(value) ? value : null);
239+
const snapshot = {
240+
context_window_used_tokens: readFiniteNumber(parsed.context_window_used_tokens),
241+
context_window_limit_tokens: readFiniteNumber(parsed.context_window_limit_tokens),
242+
context_window_used_pct: readFiniteNumber(parsed.context_window_used_pct),
243+
session_total_tokens: readFiniteNumber(parsed.session_total_tokens),
244+
session_total_cost_usd: readFiniteNumber(parsed.session_total_cost_usd),
245+
};
246+
247+
const hasAny = Object.values(snapshot).some((value) => value !== null);
248+
return hasAny ? snapshot : null;
249+
} catch {
250+
return null;
251+
}
252+
}
253+
231254
function buildPullMeta(maxMessages, waitSeconds) {
232255
const { activeSessions, activeDevAgents } = countActivePiSessions();
233256
const bridgeUptimeHours = Math.max(0, (Date.now() - bridgeStartedAtMs) / (1000 * 60 * 60));
234257
const systemUptimeHours = Math.max(0, getSystemUptimeSeconds() / (60 * 60));
258+
const contextUsage = readContextUsageSnapshot();
235259

236260
return {
237261
agent_version: agentVersion,
@@ -246,6 +270,7 @@ function buildPullMeta(maxMessages, waitSeconds) {
246270
poll_count: brokerPollCount + 1,
247271
max_messages: maxMessages,
248272
wait_seconds: waitSeconds,
273+
...(contextUsage ? contextUsage : {}),
249274
};
250275
}
251276

test/broker-bridge.integration.test.mjs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
44
import net from "node:net";
55
import path from "node:path";
66
import { fileURLToPath } from "node:url";
7-
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
7+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
88
import { tmpdir } from "node:os";
99
import sodium from "libsodium-wrappers-sumo";
1010
import {
@@ -502,6 +502,23 @@ describe("broker pull bridge semi-integration", () => {
502502
const signKeypair = sodium.crypto_sign_seed_keypair(new Uint8Array(signingSeed));
503503
let pullPayload = null;
504504

505+
const tempHome = mkdtempSync(path.join(tmpdir(), "baudbot-broker-test-"));
506+
tempDirs.push(tempHome);
507+
const contextUsageDir = path.join(tempHome, ".pi", "agent");
508+
mkdirSync(contextUsageDir, { recursive: true });
509+
writeFileSync(
510+
path.join(contextUsageDir, "context-usage.json"),
511+
`${JSON.stringify({
512+
generated_at: "2026-02-23T00:00:00.000Z",
513+
session_id: "session-test",
514+
context_window_used_tokens: 12345,
515+
context_window_limit_tokens: 200000,
516+
context_window_used_pct: 6.1725,
517+
session_total_tokens: 54321,
518+
session_total_cost_usd: 1.25,
519+
}, null, 2)}\n`,
520+
);
521+
505522
const broker = createServer(async (req, res) => {
506523
if (req.method === "POST" && req.url === "/api/inbox/pull") {
507524
let raw = "";
@@ -547,6 +564,7 @@ describe("broker pull bridge semi-integration", () => {
547564
cwd: bridgeCwd,
548565
env: {
549566
...cleanEnv(),
567+
HOME: tempHome,
550568
SLACK_BROKER_URL: brokerUrl,
551569
SLACK_BROKER_WORKSPACE_ID: workspaceId,
552570
SLACK_BROKER_SERVER_PRIVATE_KEY: b64(32, 11),
@@ -581,6 +599,11 @@ describe("broker pull bridge semi-integration", () => {
581599
expect(typeof pullPayload.meta.agent_version).toBe("string");
582600
expect(pullPayload.meta.heartbeat_runs).toBeGreaterThanOrEqual(0);
583601
expect(pullPayload.meta.heartbeat_consecutive_errors).toBeGreaterThanOrEqual(0);
602+
expect(pullPayload.meta.context_window_used_tokens).toBe(12345);
603+
expect(pullPayload.meta.context_window_limit_tokens).toBe(200000);
604+
expect(pullPayload.meta.context_window_used_pct).toBe(6.1725);
605+
expect(pullPayload.meta.session_total_tokens).toBe(54321);
606+
expect(pullPayload.meta.session_total_cost_usd).toBe(1.25);
584607

585608
const canonical = canonicalizeProtocolRequest(workspaceId, "2026-02-1", "inbox.pull", pullPayload.timestamp, {
586609
max_messages: 10,

0 commit comments

Comments
 (0)