Skip to content
Open
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
13 changes: 12 additions & 1 deletion packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1065,7 +1065,7 @@ pi.on("tool_call", (event, ctx) => {

### ctx.getContextUsage()

Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages.
Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages. The optional `reserveTokens` field reports the configured compaction reserve, or zero when automatic compaction is disabled.

```typescript
const usage = ctx.getContextUsage();
Expand Down Expand Up @@ -1123,6 +1123,17 @@ This has the same shape and mutability as `before_agent_start` `event.systemProm

This reports the current base prompt inputs. It does not include per-turn `before_agent_start` chained system-prompt changes, later `context` event message mutations, or `before_provider_request` payload rewrites.

### ctx.getContextReportSnapshot()

Returns a point-in-time snapshot of the data needed to report context composition, or `undefined` when the host does not provide it. The snapshot contains the effective system prompt, the base system-prompt options, the registered tool catalog, current MCP server names, the active message list, the model context window, and the configured compaction reserve.

```typescript
const snapshot = ctx.getContextReportSnapshot?.();
const toolCount = snapshot?.tools.length ?? 0;
```

Like `getSystemPromptOptions()`, this can include full prompt, context-file, and message contents. Treat it as sensitive extension-local data and avoid exposing it through command lists, logs, or autocomplete metadata. Prefer it over re-deriving context data when a command only needs a read-only report.

### ctx.waitForIdle()

Wait for the agent to fully settle, including automatic retries, auto-compaction retries, and queued continuations:
Expand Down
9 changes: 9 additions & 0 deletions packages/coding-agent/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Type `/` in the editor to open command completion. Extensions can register custo
| `/new` | Start a new session |
| `/name <name>` | Set session display name |
| `/session` | Show session file, ID, messages, tokens, and cost |
| `/context [all]` | Show the estimated current context breakdown; `all` expands skills, context files, and individual tools |
| `/tree` | Jump to any point in the session and continue from there |
| `/trust` | Save project trust decision for future sessions |
| `/fork` | Create a new session from a previous user message |
Expand Down Expand Up @@ -94,6 +95,14 @@ Useful session commands:
- `/clone` duplicates the current active branch into a new session file.
- `/compact` summarizes older messages to free context.

### Current Context Breakdown

`/context` shows a read-only estimate of the current context window. Instructions, tool definitions, and conversation are grouped separately. Conversation is further divided into user input, AI answers, thinking, tool calls, tool output, compaction summaries, and injected context. The meter also shows free space and the configured compaction reserve.

Use `/context all`, or press `a` in the panel, to expand individual skills, context files, and tools. The report reads the active session and tool catalog without starting a model turn. When the active model is a Step endpoint, it asks the platform token-count endpoint for segmented totals: instructions, tool definitions, the full conversation, and each conversation category. Those requests send request-shaped payloads to the tokenizer only; they do not generate a model response. If the endpoint is unavailable, unauthenticated, rate-limited, or the active provider is not Step, `/context` falls back to local structural estimates. The header shows whether the totals came from the token-count API, a mix of both, or local estimates.

Local fallback values are estimates of the current request structure, not cumulative billing or cache statistics; `/usage` owns those views. Local fallback counts ASCII text at about four characters per token, non-ASCII text at about one token per code point, and images as fixed blocks.

See [Sessions](sessions.md) and [Compaction](compaction.md) for details.

## Context Files
Expand Down
27 changes: 25 additions & 2 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
streamSimple,
} from "@step-harness/providers/compat";
import { getAgentDir } from "../config.ts";
import { getStepMcpStatuses } from "../step/mcp.ts";
import { redactSecretString } from "../step/secret-redaction.ts";
import { getThemeByName, theme } from "../theme/theme.ts";
import { stripFrontmatter } from "../utils/frontmatter.ts";
Expand Down Expand Up @@ -85,6 +86,7 @@ import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";
import {
type ContextReportSnapshot,
type ContextUsage,
type ExtensionCommandContextActions,
type ExtensionErrorListener,
Expand Down Expand Up @@ -3400,6 +3402,7 @@ export class AgentSession {
},
getSystemPrompt: () => this.systemPrompt,
getSystemPromptOptions: () => this._baseSystemPromptOptions,
getContextReportSnapshot: () => this.createContextReportSnapshot(),
getAutoRetryEnabled: () => this.autoRetryEnabled,
setAutoRetryEnabled: (enabled) => this.setAutoRetryEnabled(enabled),
},
Expand Down Expand Up @@ -4142,13 +4145,14 @@ export class AgentSession {

const contextWindow = model.contextWindow ?? 0;
if (contextWindow <= 0) return undefined;
const reserveTokens = this.autoCompactionEnabled ? this.settingsManager.getCompactionReserveTokens() : 0;
if (this._getLiveContext()) {
const tokens = this._workingContextTokens({
systemPrompt: this.systemPrompt,
messages: this.agent.state.messages,
tools: this.agent.state.tools,
});
return { tokens, contextWindow, percent: (tokens / contextWindow) * 100 };
return { tokens, contextWindow, percent: (tokens / contextWindow) * 100, reserveTokens };
}

// After compaction, the last assistant usage reflects pre-compaction context size.
Expand Down Expand Up @@ -4176,7 +4180,7 @@ export class AgentSession {
}

if (!hasPostCompactionUsage) {
return { tokens: null, contextWindow, percent: null };
return { tokens: null, contextWindow, percent: null, reserveTokens };
}
}

Expand All @@ -4187,6 +4191,25 @@ export class AgentSession {
tokens: estimate.tokens,
contextWindow,
percent,
reserveTokens,
};
}

/** Snapshot the inputs a /context report needs, including tools that are registered but not active. */
private createContextReportSnapshot(): ContextReportSnapshot {
const contextUsage = this.getContextUsage();
const systemPromptOptions: BuildSystemPromptOptions = {
...this._baseSystemPromptOptions,
contextFiles: this._baseSystemPromptOptions.contextFiles ?? [],
};
return {
systemPrompt: this.systemPrompt,
systemPromptOptions,
tools: this.getAllTools(),
mcpServerNames: getStepMcpStatuses().map((status) => status.name),
messages: this.agent.state.messages,
contextWindow: contextUsage?.contextWindow ?? this.model?.contextWindow ?? 0,
reserveTokens: contextUsage?.reserveTokens,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type {
ContextEvent,
// Event Results
ContextEventResult,
ContextReportSnapshot,
ContextUsage,
CustomToolCallEvent,
CustomToolResultEvent,
Expand Down
7 changes: 7 additions & 0 deletions packages/coding-agent/src/core/extensions/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
CompactOptions,
ContextEvent,
ContextEventResult,
ContextReportSnapshot,
ContextUsage,
EntryRenderer,
Extension,
Expand Down Expand Up @@ -288,6 +289,7 @@ export class ExtensionRunner {
private compactFn: (options?: CompactOptions) => void = () => {};
private getSystemPromptFn: () => string = () => "";
private getSystemPromptOptionsFn: () => BuildSystemPromptOptions = () => ({ cwd: this.cwd });
private getContextReportSnapshotFn: () => ContextReportSnapshot | undefined = () => undefined;
private getAutoRetryEnabledFn: (() => boolean) | undefined;
private setAutoRetryEnabledFn: ((enabled: boolean) => void) | undefined;
private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
Expand Down Expand Up @@ -356,6 +358,7 @@ export class ExtensionRunner {
this.compactFn = contextActions.compact;
this.getSystemPromptFn = contextActions.getSystemPrompt;
this.getSystemPromptOptionsFn = contextActions.getSystemPromptOptions ?? (() => ({ cwd: this.cwd }));
this.getContextReportSnapshotFn = contextActions.getContextReportSnapshot ?? (() => undefined);
this.getAutoRetryEnabledFn = contextActions.getAutoRetryEnabled;
this.setAutoRetryEnabledFn = contextActions.setAutoRetryEnabled;

Expand Down Expand Up @@ -861,6 +864,10 @@ export class ExtensionRunner {
this.assertActive();
return this.getSystemPromptOptionsFn();
};
context.getContextReportSnapshot = () => {
this.assertActive();
return this.getContextReportSnapshotFn();
};
context.waitForIdle = () => {
this.assertActive();
return this.waitForIdleFn();
Expand Down
17 changes: 17 additions & 0 deletions packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ export interface ContextUsage {
contextWindow: number;
/** Context usage as percentage of context window, or null if tokens is unknown. */
percent: number | null;
/** Tokens held back for compaction and the next response; zero when automatic compaction is disabled. */
reserveTokens?: number;
}

export interface CompactOptions {
Expand Down Expand Up @@ -424,6 +426,8 @@ export interface ExtensionContext {
export interface ExtensionCommandContext extends ExtensionContext {
/** Get the current base system-prompt construction options. */
getSystemPromptOptions(): BuildSystemPromptOptions;
/** Snapshot the current system prompt, messages, and tool catalog for context composition reports. */
getContextReportSnapshot?(): ContextReportSnapshot | undefined;

/** Wait for the agent to finish streaming */
waitForIdle(): Promise<void>;
Expand Down Expand Up @@ -1739,6 +1743,18 @@ export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters

export type GetAllToolsHandler = () => ToolInfo[];

/** A point-in-time snapshot of the data needed to report context-token composition. */
export interface ContextReportSnapshot {
systemPrompt: string;
systemPromptOptions: BuildSystemPromptOptions;
tools: readonly ToolInfo[];
/** Names of MCP servers whose remote tools are currently registered. */
mcpServerNames: readonly string[];
messages: readonly AgentMessage[];
contextWindow: number;
reserveTokens?: number;
}

export type GetCommandsHandler = () => SlashCommandInfo[];

export type SetActiveToolsHandler = (toolNames: string[]) => void;
Expand Down Expand Up @@ -1818,6 +1834,7 @@ export interface ExtensionContextActions {
compact: (options?: CompactOptions) => void;
getSystemPrompt: () => string;
getSystemPromptOptions?: () => BuildSystemPromptOptions;
getContextReportSnapshot?: () => ContextReportSnapshot | undefined;
/** Optional native retry controls for product presets such as Step Autopilot. */
getAutoRetryEnabled?: () => boolean;
setAutoRetryEnabled?: (enabled: boolean) => void;
Expand Down
16 changes: 11 additions & 5 deletions packages/coding-agent/src/core/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,16 @@ function loadSkillFromFile(
* Skills with disableModelInvocation=true are excluded from the prompt
* (they can only be invoked explicitly via /skill:name commands).
*/
export function formatSkillForPrompt(skill: Skill): string {
return [
" <skill>",
` <name>${escapeXml(skill.name)}</name>`,
` <description>${escapeXml(skill.description)}</description>`,
` <location>${escapeXml(skill.filePath)}</location>`,
" </skill>",
].join("\n");
}

export function formatSkillsForPrompt(skills: Skill[], readTool: "read" | "read_file" = "read"): string {
const visibleSkills = skills.filter((s) => !s.disableModelInvocation);

Expand All @@ -342,11 +352,7 @@ export function formatSkillsForPrompt(skills: Skill[], readTool: "read" | "read_
];

for (const skill of visibleSkills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <description>${escapeXml(skill.description)}</description>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
lines.push(" </skill>");
lines.push(formatSkillForPrompt(skill));
}

lines.push("</available_skills>");
Expand Down
107 changes: 107 additions & 0 deletions packages/coding-agent/src/render/context-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
type Component,
getKeybindings,
type KeybindingsManager,
matchesKey,
type TUI,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@step-harness/pi-tui";
import {
type ContextMeterSegment,
type ContextReport,
contextMeterSegments,
contextReportRows,
} from "../step/context-report.ts";
import type { Theme } from "../theme/theme.ts";

export interface ContextPanelOptions {
report: ContextReport;
showAll?: boolean;
theme: Theme;
tui?: TUI;
onClose: () => void;
}

const SEGMENT_COLORS: Record<ContextMeterSegment, Parameters<Theme["fg"]>[0]> = {
instructions: "accent",
tools: "warning",
conversation: "success",
reserve: "dim",
free: "muted",
};

export function formatContextReportText(report: ContextReport, showAll = false): string {
const rows = contextReportRows(report, showAll);
const meter = contextMeterSegments(report)
.map((segment) => (segment === "free" ? "░" : segment === "reserve" ? "▒" : "█"))
.join("");
if (meter) rows.splice(2, 0, meter);
rows.push("", showAll ? "A summary · Esc close" : "A all details · Esc close");
return rows.join("\n");
}

/** Read-only current-context report. It never starts a model turn; token totals may come from the provider tokenizer endpoint. */
export class ContextPanel implements Component {
private readonly options: Required<Pick<ContextPanelOptions, "report" | "theme" | "onClose">> & ContextPanelOptions;
private readonly keybindings: KeybindingsManager;
private showAll: boolean;
private scroll = 0;

constructor(options: ContextPanelOptions, keybindings: KeybindingsManager = getKeybindings()) {
this.options = options;
this.keybindings = keybindings;
this.showAll = options.showAll ?? false;
}

invalidate(): void {}

handleInput(data: string): void {
if (this.keybindings.matches(data, "tui.select.cancel") || data === "\u0003") {
this.options.onClose();
return;
}
if (data === "a" || data === "A") {
this.showAll = !this.showAll;
this.scroll = 0;
this.options.tui?.requestRender();
return;
}
if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, "pageUp")) {
this.scroll = Math.max(0, this.scroll - (matchesKey(data, "pageUp") ? 10 : 1));
} else if (this.keybindings.matches(data, "tui.select.down") || matchesKey(data, "pageDown")) {
this.scroll += matchesKey(data, "pageDown") ? 10 : 1;
}
this.options.tui?.requestRender();
}

private meter(width: number): string {
const theme = this.options.theme;
return contextMeterSegments(this.options.report, Math.min(40, Math.max(1, width)))
.map((segment) =>
theme.fg(SEGMENT_COLORS[segment], segment === "free" ? "░" : segment === "reserve" ? "▒" : "█"),
)
.join("");
}

render(width: number): string[] {
const safeWidth = Math.max(1, Math.floor(width));
const innerWidth = Math.max(1, safeWidth - 2);
const rows = contextReportRows(this.options.report, this.showAll);
if (this.options.report.usedTokens !== null && this.options.report.contextWindow > 0) {
rows.splice(2, 0, this.meter(innerWidth));
}
rows.push(
"",
this.options.theme.fg("muted", this.showAll ? "A summary · Esc close" : "A all details · Esc close"),
);
const wrapped = rows.flatMap((row) => wrapTextWithAnsi(row, innerWidth));
const height = Math.max(5, (this.options.tui?.terminal.rows ?? wrapped.length + 4) - 6);
const maxScroll = Math.max(0, wrapped.length - height);
this.scroll = Math.max(0, Math.min(this.scroll, maxScroll));
return wrapped
.slice(this.scroll, this.scroll + height)
.map((row) => ` ${visibleWidth(row) > innerWidth ? truncateToWidth(row, innerWidth, "", false) : row}`);
}
}
Loading
Loading