Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/runtime/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,41 @@ describe("createAgentRuntime", () => {
}
});

it("persists the turn's context usage onto the stored session", async () => {
const runtime = await createAgentRuntime({
workingDir,
approvalLevel: 5,
overrides: {
browserBackend: backend,
skipLlamaHealthCheck: true,
llamaComplete: async () => ({
content: JSON.stringify({
tool: "reply",
args: { text: "hi back" },
}),
timing: { promptTokens: 777, predictedTokens: 3 },
slotId: 0,
cacheReused: false,
}),
},
});
try {
const session = runtime.createSession();
const result = await runtime.runTurn(session, "hello", { maxSteps: 5 });
// `prompt_built` seeded the snapshot; `llm_completed`'s tokenizer
// count (777) replaced the estimate before the stamp.
expect(result.session.contextUsage).toBeDefined();
expect(result.session.contextUsage?.tokens).toBe(777);
expect(result.session.contextUsage?.sections.length).toBeGreaterThan(0);
// The stored row carries the same snapshot, so a later process —
// the TUI reopening this session — can restore the gauge.
const reloaded = runtime.sessionStore.load(session.id)!;
expect(reloaded.contextUsage).toEqual(result.session.contextUsage);
} finally {
await runtime.shutdown();
}
});

it("refreshSkills rebuilds the catalog and notifies listeners", async () => {
let notified: Array<{ name: string }> = [];
const runtime = await createAgentRuntime({
Expand Down
47 changes: 45 additions & 2 deletions src/runtime/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ import type { AgentLoopEvent, RunTurnResult } from "../agent/agent-loop.js";
import {
SessionStore,
createEmptySessionState,
contextUsageFromPrompt,
type ContextUsageState,
type SessionState,
} from "../session/index.js";

Expand Down Expand Up @@ -827,6 +829,15 @@ export async function createAgentRuntime(
* pointer.
*/
const turnContext = new AsyncLocalStorage<{ sessionId: string }>();
/**
* The running turn's window occupancy, per session. Written by
* `emitAgentLoopEvent` (`prompt_built`, refined by `llm_completed`),
* consumed once by `executeTurn` when it stamps the finished session,
* and always cleared in its `finally` so an aborted turn cannot leak
* an entry — or bleed one turn's gauge into a session that never
* built a prompt of its own.
*/
const lastTurnContextUsage = new Map<string, ContextUsageState>();
const steeringInbox = new SteeringInbox();
const turnController = new TurnController({
onHookError: (err, ctxInfo) => {
Expand All @@ -852,6 +863,28 @@ export async function createAgentRuntime(
const recorder = touchRecorder(ctx.sessionId);
recorder?.onAgentEvent(event);
turnController.emit(ctx.sessionId, event);
// Track the turn's window occupancy so `executeTurn` can stamp it
// onto the session before the post-turn save. Mirrors the TUI's own
// reduction: the `prompt_built` estimate, refined by the provider's
// real tokenizer count when the completion reports one.
if (event.type === "llm_event") {
const step = event.event;
if (step.type === "prompt_built") {
lastTurnContextUsage.set(
ctx.sessionId,
contextUsageFromPrompt(step.prompt),
);
} else if (step.type === "llm_completed") {
const counted = step.completion.timing?.promptTokens ?? 0;
const usage = lastTurnContextUsage.get(ctx.sessionId);
if (counted > 0 && usage) {
lastTurnContextUsage.set(ctx.sessionId, {
...usage,
tokens: counted,
});
}
}
}
}
if (event.type === "loop_failed") {
captureError(errorReporter, event.error, {
Expand Down Expand Up @@ -2310,9 +2343,19 @@ export async function createAgentRuntime(
maxSteps: runOptions.maxSteps ?? config.agent.maxSteps,
signal: runOptions.signal ?? new AbortController().signal,
});
sessionStore.save(result.session);
return result;
// Stamp the turn's window occupancy so the stored session can
// restore the TUI's context gauge when it is reopened. A turn
// that built no prompt (failed before step 1) leaves whatever
// snapshot the previous turn persisted.
const usage = lastTurnContextUsage.get(session.id);
const finished =
usage === undefined
? result.session
: { ...result.session, contextUsage: usage };
sessionStore.save(finished);
return usage === undefined ? result : { ...result, session: finished };
} finally {
lastTurnContextUsage.delete(session.id);
activeTraceSessions.delete(session.id);
// A delete that arrived mid-turn was deferred to keep the pin honest;
// complete it now that nothing is writing through the recorder.
Expand Down
154 changes: 154 additions & 0 deletions src/session/context-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { BuiltPrompt } from "../prompt/build-prompt-types.js";

/**
* What the last built prompt actually put in the model's context window.
*
* Lives in the session module (not the TUI) because the figure is a
* property of the *session*, not of the screen that happens to render
* it: `executeTurn` stamps the latest snapshot onto `SessionState` so a
* session reopened tomorrow — or switched to from another thread —
* shows its window fill immediately instead of a blank chip until the
* next turn rebuilds a prompt.
*
* Every field is a snapshot of the most recent `prompt_built`, refined by
* the completion's own token count when the provider reports one.
*/
export interface ContextUsageState {
/**
* Tokens in the last prompt. An estimate at `prompt_built` time
* (`estimateTokens` over-counts by design), replaced by the real
* tokenizer count once the step completes and the provider reports
* `promptTokens`.
*/
tokens: number | null;
/**
* Physical window the prompt was built against, when the runtime knows
* it. `null` on cloud providers, where the model profile carries no
* window — the chip resolves those from the model catalogue instead.
*/
contextWindow: number | null;
/** Turns `packConversation` dropped to make the transcript fit. */
droppedTurns: number;
/** Tokens the `### conversation` section actually rendered to. */
conversationTokens: number;
/**
* Ceiling that section is packed to — `conversationCapEffective`. The
* one number that says when older turns start being dropped, and the
* only budget figure that is defined even when nobody knows the
* physical window (the clamp falls back to the configured cap).
*/
conversationCap: number | null;
/**
* The cap as configured (`agent.conversationMaxTokens`), before the
* window clamp. Equal to `conversationCap` when config is what binds;
* larger when the window is. That comparison is the only way to tell
* an operator which knob actually moves their limit.
*/
conversationCapConfigured: number | null;
/**
* The configured cap is `0` — auto. `conversationCapConfigured` is
* then a fallback rather than a ceiling, so the comparison above says
* nothing and the panel must not name `agent.conversationMaxTokens`
* as what is holding the transcript down. Nothing is: the window is.
*/
conversationCapAuto: boolean;
/** Macro-turns the prompt carried. */
conversationPairs: number;
/** Macro-turns dropped whole. */
droppedPairs: number;
/** `agent.conversationMaxPairs` in force. */
conversationPairsCap: number;
/** Which limit trimmed history, when either did. */
conversationBoundBy: "pairs" | "tokens" | null;
/**
* Token cost of each macro-turn, oldest first — enough to price a
* different pair count without building another prompt, so moving the
* dial redraws the gauge while the operator is looking at it.
*/
pairCosts: readonly number[];
/** Per-section breakdown, for the detail view. Empty before the first prompt. */
sections: readonly ContextUsageSection[];
}

export interface ContextUsageSection {
label: string;
tokens: number;
}

/** A window nothing has been built against yet. */
export const EMPTY_CONTEXT_USAGE: ContextUsageState = {
tokens: null,
contextWindow: null,
droppedTurns: 0,
conversationTokens: 0,
conversationCap: null,
conversationCapConfigured: null,
conversationCapAuto: false,
conversationPairs: 0,
droppedPairs: 0,
conversationPairsCap: 0,
conversationBoundBy: null,
pairCosts: [],
sections: [],
};

/**
* The transcript's row label. Exported because the context panel has to
* find that one row to recalculate it when the task count changes, and
* matching on a literal string in two files is a bug waiting for someone
* to reword one of them.
*/
export const CONVERSATION_SECTION_LABEL = "conversation";

/**
* Order the sections are shown in: the fixed cost first, then the
* transcript, then everything the memory fabric contributed, then the
* small stuff. Not the order `BuiltPrompt.tokens` declares them in —
* that one follows the prompt's own assembly, which is not how anyone
* reads a bill.
*/
const SECTIONS: readonly {
key: keyof BuiltPrompt["tokens"];
label: string;
}[] = [
{ key: "stablePrefix", label: "prompt scaffold" },
{ key: "conversation", label: CONVERSATION_SECTION_LABEL },
{ key: "recalled", label: "recalled memory" },
{ key: "memoryIndex", label: "memory index" },
{ key: "worldSnapshot", label: "world snapshot" },
{ key: "loadedTools", label: "loaded tools" },
{ key: "loadedSkills", label: "loaded skills" },
{ key: "sessionFacts", label: "session facts" },
{ key: "profile", label: "profile" },
{ key: "taskPolicy", label: "task policy" },
];

/**
* Project a built prompt into the readout the composer shows.
*
* Sections that cost nothing are dropped rather than listed as zeros: a
* session with no skills loaded should not have to read the word
* "skills" to find that out.
*/
export function contextUsageFromPrompt(prompt: BuiltPrompt): ContextUsageState {
const sections: ContextUsageSection[] = [];
for (const { key, label } of SECTIONS) {
const tokens = prompt.tokens[key];
if (tokens > 0) sections.push({ label, tokens });
}
return {
tokens: prompt.tokens.total,
contextWindow: prompt.contextWindow,
droppedTurns: prompt.droppedTurns,
conversationTokens: prompt.tokens.conversation,
conversationCap: prompt.conversationCapEffective,
conversationCapConfigured: prompt.limits.conversation,
conversationCapAuto: prompt.conversationCapAuto,
conversationPairs: prompt.conversationPairs,
droppedPairs: prompt.droppedPairs,
conversationPairsCap: prompt.conversationPairsCap,
conversationBoundBy: prompt.conversationBoundBy,
pairCosts: prompt.pairCosts,
sections,
};
}
9 changes: 9 additions & 0 deletions src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,12 @@ export type {
ConversationTurn,
PackedConversation,
} from "./conversation-turn.js";
export {
CONVERSATION_SECTION_LABEL,
EMPTY_CONTEXT_USAGE,
contextUsageFromPrompt,
} from "./context-usage.js";
export type {
ContextUsageState,
ContextUsageSection,
} from "./context-usage.js";
11 changes: 11 additions & 0 deletions src/session/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
appendTurn,
type ConversationTurn,
} from "./conversation-turn.js";
import type { ContextUsageState } from "./context-usage.js";

export type SessionStatus =
| "pending"
Expand Down Expand Up @@ -124,6 +125,16 @@ export interface SessionState {
createdAt: number;
updatedAt: number;
lastError: string | null;
/**
* Snapshot of the last turn's window occupancy — what the TUI's
* context chip draws. Stamped by `executeTurn` right before the
* post-turn save (every origin funnels through it), so reopening or
* switching into a session restores the gauge immediately instead of
* showing nothing until the next prompt is built. Deliberately NOT
* ephemeral: the whole point is to survive the process. Absent on
* sessions that predate the field or have never run a turn.
*/
contextUsage?: ContextUsageState;
/**
* Free-form session metadata. Reserved keys (set by the runtime, not
* the agent — agents may read but must not overwrite them):
Expand Down
22 changes: 22 additions & 0 deletions src/session/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionStore } from "./session-store.js";
import { createEmptySessionState } from "./session-state.js";
import { EMPTY_CONTEXT_USAGE } from "./context-usage.js";

describe("SessionStore", () => {
let tmp: string;
Expand Down Expand Up @@ -59,6 +60,27 @@ describe("SessionStore", () => {
expect(loaded?.loadedTools[0]?.name).toBe("os.git.show");
});

it("round-trips the persisted context-usage snapshot", () => {
const initial = createEmptySessionState({
id: "s-ctx",
workingDir: "/w",
});
const snapshot = {
...EMPTY_CONTEXT_USAGE,
tokens: 12_345,
contextWindow: 131_072,
conversationTokens: 9_000,
conversationPairs: 4,
sections: [{ label: "conversation", tokens: 9_000 }],
};
store.save({ ...initial, contextUsage: snapshot });
const loaded = store.load("s-ctx");
expect(loaded?.contextUsage).toEqual(snapshot);
// A session written before the field existed simply has none.
store.save(createEmptySessionState({ id: "s-old", workingDir: "/w" }));
expect(store.load("s-old")?.contextUsage).toBeUndefined();
});

it("updates an existing session in place", () => {
const state = createEmptySessionState({
id: "s2",
Expand Down
7 changes: 7 additions & 0 deletions src/tui/chat-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,10 @@ export class ChatOrchestrator {
workingDir: loaded.workingDir,
messages: turnsToMessages(loaded.turns),
running,
// Restore the context gauge this session persisted with its last
// turn (absent on threads that never ran one — the reducer then
// resets the chip rather than keeping the old thread's figure).
...(loaded.contextUsage ? { contextUsage: loaded.contextUsage } : {}),
});
// The stored snapshot above misses everything the still-running
// turn has said (a turn saves only when it finishes — for a thread
Expand Down Expand Up @@ -1061,6 +1065,9 @@ export class ChatOrchestrator {
sessionId: turnSessionId,
workingDir: this.session.workingDir,
messages: turnsToMessages(this.session.turns),
...(this.session.contextUsage
? { contextUsage: this.session.contextUsage }
: {}),
});
}
const next = this.queue.shift();
Expand Down
Loading
Loading