Skip to content

Commit 34d2577

Browse files
committed
fix(context): show transcript conversation in /context map
1 parent 2806195 commit 34d2577

6 files changed

Lines changed: 112 additions & 14 deletions

File tree

docs/concepts/context.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,16 @@ Top tools (schema size):
7777

7878
### `/context map`
7979

80-
Sends an image generated from the latest cached run report. Before a normal message has produced a run report in the session, `/context map` returns an unavailable message instead of rendering an estimate. Rectangle area is proportional to tracked prompt characters:
80+
Sends an image generated from the latest cached run report plus the session transcript. Before a normal message has produced a run report in the session, `/context map` returns an unavailable message instead of rendering an estimate. Rectangle area is proportional to tracked prompt characters:
8181

82+
- conversation transcript (user messages, assistant replies, tool results, compaction summaries), plus per-turn runtime context and hook prompt additions that reach only the model
8283
- injected workspace files
8384
- base system prompt text
8485
- skill prompt entries
8586
- tool JSON schemas
8687

88+
The conversation group grows as the session does, so the map changes turn over turn; after compaction it collapses into a summaries tile.
89+
8790
`/context list`, `/context detail`, and `/context json` can still inspect an on-demand estimate when no run report is cached.
8891

8992
## What counts toward the context window

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4507,6 +4507,10 @@ export async function runEmbeddedAttempt(
45074507
runtimeContextChars: promptSubmission.runtimeOnly
45084508
? (runtimeSystemContext?.length ?? 0)
45094509
: (runtimeContextForHook?.length ?? 0),
4510+
// promptForSession is what persists to the transcript; hook
4511+
// prepend/append context reaches only the model, so record the
4512+
// delta or transcript-based context accounting undercounts it.
4513+
modelOnlyPromptChars: Math.max(0, promptForModel.length - promptForSession.length),
45104514
};
45114515
}
45124516
const systemPromptForHook = systemPromptText;

src/auto-reply/reply/commands-context-report.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,45 @@ describe("buildContextReply", () => {
328328
}
329329
});
330330

331-
it("counts room events as event context in context maps", async () => {
331+
it("includes transcript conversation size in context maps", async () => {
332+
await withTranscript(
333+
[
334+
{ role: "user", content: "abcd", timestamp: 1 },
335+
{ role: "assistant", content: [{ type: "text", text: "efghij" }], timestamp: 2 },
336+
{
337+
role: "toolResult",
338+
content: [{ type: "text", text: "klmno" }],
339+
timestamp: 3,
340+
toolCallId: "call-1",
341+
toolName: "read",
342+
},
343+
],
344+
async (sessionFile) => {
345+
const result = await buildContextReply(
346+
makeParams("/context map", false, {
347+
contextTokens: 8_192,
348+
totalTokens: 900,
349+
sessionId: "session",
350+
sessionFile,
351+
}),
352+
);
353+
if (!result.mediaUrl) {
354+
throw new Error("missing context map media path");
355+
}
356+
try {
357+
const png = await readFile(result.mediaUrl);
358+
expect(result.text).toContain("Conversation: 20 chars (~5 tok)");
359+
expect(result.trustedLocalMedia).toBe(true);
360+
expect(result.sensitiveMedia).toBe(true);
361+
expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
362+
} finally {
363+
await unlink(result.mediaUrl);
364+
}
365+
},
366+
);
367+
});
368+
369+
it("counts model-only turn context but not the persisted current-turn prompt", async () => {
332370
const result = await buildContextReply(
333371
makeParams("/context map", false, {
334372
contextTokens: 8_192,
@@ -337,14 +375,16 @@ describe("buildContextReply", () => {
337375
kind: "room_event",
338376
promptChars: 11,
339377
runtimeContextChars: 17,
378+
modelOnlyPromptChars: 5,
340379
},
341380
}),
342381
);
343382
if (!result.mediaUrl) {
344383
throw new Error("missing context map media path");
345384
}
346385
try {
347-
expect(result.text).toContain("Tracked: 10,548 chars");
386+
expect(result.text).toContain("Tracked: 10,542 chars");
387+
expect(result.text).toContain("Conversation: 22 chars (~6 tok)");
348388
} finally {
349389
await unlink(result.mediaUrl);
350390
}

src/auto-reply/reply/commands-context-report.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
resolveBootstrapMaxChars,
88
resolveBootstrapTotalMaxChars,
99
} from "../../agents/embedded-agent-helpers/bootstrap.js";
10+
import {
11+
createMessageCharEstimateCache,
12+
estimateMessageCharsCached,
13+
} from "../../agents/embedded-agent-runner/tool-result-char-estimator.js";
1014
import type { AgentMessage } from "../../agents/runtime/index.js";
1115
import { buildSystemPromptReport } from "../../agents/system-prompt-report.js";
1216
import {
@@ -182,12 +186,55 @@ export async function buildContextReply(params: HandleCommandsParams): Promise<R
182186
].join("\n"),
183187
};
184188
}
189+
const sessionId = targetSessionEntry?.sessionId?.trim();
190+
const messages = sessionId
191+
? (readSessionMessages(
192+
sessionId,
193+
params.storePath,
194+
targetSessionEntry?.sessionFile,
195+
) as AgentMessage[])
196+
: [];
197+
const estimateCache = createMessageCharEstimateCache();
198+
const conversationTotals = messages.reduce(
199+
(totals, message) => {
200+
const chars = estimateMessageCharsCached(message, estimateCache);
201+
if (chars === 0) {
202+
return totals;
203+
}
204+
if (message.role === "user") {
205+
totals.user += chars;
206+
} else if (message.role === "assistant") {
207+
totals.assistant += chars;
208+
} else if (message.role === "toolResult") {
209+
totals.toolResults += chars;
210+
} else if (message.role === "branchSummary" || message.role === "compactionSummary") {
211+
totals.summaries += chars;
212+
} else {
213+
totals.other += chars;
214+
}
215+
return totals;
216+
},
217+
{ user: 0, assistant: 0, toolResults: 0, summaries: 0, other: 0 },
218+
);
219+
const conversation = [
220+
{ name: "User", value: conversationTotals.user },
221+
{ name: "Assistant", value: conversationTotals.assistant },
222+
{ name: "Tool results", value: conversationTotals.toolResults },
223+
{ name: "Summaries", value: conversationTotals.summaries },
224+
{ name: "Other", value: conversationTotals.other },
225+
// Runtime context and hook prompt additions reach only the model, never
226+
// the transcript; without these leaves the map undercounts model-visible
227+
// context. The persisted turn prompt is already counted above.
228+
{ name: "Runtime context", value: report.currentTurn?.runtimeContextChars ?? 0 },
229+
{ name: "Model-only prompt", value: report.currentTurn?.modelOnlyPromptChars ?? 0 },
230+
].filter((leaf) => leaf.value > 0);
185231
const treemap = await renderContextTreemapPng({
186232
report,
187233
session: {
188234
cachedContextTokens: cachedContextUsageTokens ?? null,
189235
contextWindowTokens: session.contextTokens,
190236
},
237+
conversation,
191238
});
192239
return {
193240
text: treemap.caption,

src/auto-reply/reply/context-treemap.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,11 @@ function treemapGroup(params: { name: string; color: Rgba; leaves: TreemapLeaf[]
334334
return { ...params, value: totalValue(params.leaves) };
335335
}
336336

337-
function buildGroups(report: SessionSystemPromptReport): TreemapGroup[] {
337+
function buildGroups(params: {
338+
report: SessionSystemPromptReport;
339+
conversation: TreemapLeaf[];
340+
}): TreemapGroup[] {
341+
const { report } = params;
338342
const injectedTotal = report.injectedWorkspaceFiles.reduce(
339343
(sum, file) => sum + file.injectedChars,
340344
0,
@@ -345,17 +349,11 @@ function buildGroups(report: SessionSystemPromptReport): TreemapGroup[] {
345349
const tools = report.tools.entries
346350
.map((tool) => ({ name: tool.name, value: tool.schemaChars ?? 0 }))
347351
.filter((tool) => tool.value > 0);
348-
const currentTurnLeaves = report.currentTurn
349-
? [
350-
{ name: "Model prompt", value: report.currentTurn.promptChars },
351-
{ name: "Runtime context", value: report.currentTurn.runtimeContextChars },
352-
].filter((leaf) => leaf.value > 0)
353-
: [];
354352
const groups = [
355353
treemapGroup({
356-
name: report.currentTurn?.kind === "room_event" ? "Room event" : "Current turn",
357-
color: rgba(72, 135, 197),
358-
leaves: currentTurnLeaves,
354+
name: "Conversation",
355+
color: rgba(201, 82, 96),
356+
leaves: params.conversation,
359357
}),
360358
treemapGroup({
361359
name: "Workspace files",
@@ -455,8 +453,10 @@ function drawLegend(canvas: PngCanvas, groups: TreemapGroup[], rect: Rect, total
455453
export async function renderContextTreemapPng(params: {
456454
report: SessionSystemPromptReport;
457455
session: ContextTreemapSessionStats;
456+
conversation: TreemapLeaf[];
458457
}): Promise<{ path: string; trackedChars: number; caption: string }> {
459-
const groups = buildGroups(params.report);
458+
const groups = buildGroups({ report: params.report, conversation: params.conversation });
459+
const conversationChars = totalValue(params.conversation);
460460
const trackedChars = totalValue(groups);
461461
const canvas = new PngCanvas();
462462
canvas.fill(rgba(238, 241, 245));
@@ -507,6 +507,7 @@ export async function renderContextTreemapPng(params: {
507507
"Context treemap",
508508
`Source: ${params.report.source}`,
509509
`Tracked: ${formatInt(trackedChars)} chars (~${formatInt(estimateTokensFromChars(trackedChars))} tok)`,
510+
`Conversation: ${formatInt(conversationChars)} chars (~${formatInt(estimateTokensFromChars(conversationChars))} tok)`,
510511
params.session.cachedContextTokens == null
511512
? "Actual cached context: unavailable"
512513
: `Actual cached context: ${formatInt(params.session.cachedContextTokens)} tok`,

src/config/sessions/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,9 @@ export type SessionSystemPromptReport = {
744744
kind?: "user_request" | "room_event";
745745
promptChars: number;
746746
runtimeContextChars: number;
747+
// Hook prepend/append context sent to the model but absent from the
748+
// persisted transcript prompt; consumers add it on top of transcript sums.
749+
modelOnlyPromptChars?: number;
747750
};
748751
injectedWorkspaceFiles: Array<{
749752
name: string;

0 commit comments

Comments
 (0)