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
11 changes: 11 additions & 0 deletions packages/coding-agent/docs/context-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ conversation; the canonical session history remains intact. CLM uses the
configured model and retains native summary compaction and provider-overflow
recovery as fallbacks.

## Request notice stability

Working-context status and pressure notices remain at their original positions
in the request view during append-only conversation turns. They are not written
to the canonical transcript. Retries reuse the same notice content and timestamp;
new tool results and user messages follow previously issued notices. An edited,
shortened, or different context projection clears the retained notices so a
branch switch or compaction does not replay stale advice into the new context.
This preserves ordinary prompt-prefix reuse without preventing intentional
context edits.

## Select a compression mode

No flag or configuration change is needed to use CLM. An existing explicit
Expand Down
6 changes: 3 additions & 3 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
type LiveContextOutcome,
type LiveContextStatus,
} from "./compaction/live-context/manager.ts";
import { StableContextNotices } from "./compaction/live-context/stable-notices.ts";
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";
Expand Down Expand Up @@ -882,6 +883,7 @@ export class AgentSession {

private _installLiveContext(): void {
const transform = this.agent.transformContext;
const notices = new StableContextNotices();
this.agent.transformContext = async (raw, signal) => {
const live = this._getLiveContext();
if (!live) return transform ? transform(raw, signal) : raw;
Expand Down Expand Up @@ -911,9 +913,7 @@ export class AgentSession {
this.model?.contextWindow ?? 0,
guidanceOptions,
);
return notice || pressure
? [...messages, ...(notice ? [notice] : []), ...(pressure ? [pressure] : [])]
: messages;
return notices.project(messages, [...(notice ? [notice] : []), ...(pressure ? [pressure] : [])]);
};
const stream = this.agent.streamFunction;
this._liveContextBaseStream = stream;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { AgentMessage } from "@step-harness/agent-core";

/** Keeps request-only notices at their original boundary during append-only turns. */
export class StableContextNotices {
private previous: string[] = [];
private notices: { index: number; message: AgentMessage }[] = [];

project(messages: AgentMessage[], added: AgentMessage[]): AgentMessage[] {
const fingerprints = messages.map((message) => JSON.stringify(message));
// A branch switch, compaction or edited projection starts a new context epoch.
// Never replay notices belonging to a different history into that epoch.
if (this.previous.some((value, index) => fingerprints[index] !== value)) {
this.notices = [];
}
this.previous = fingerprints;
for (const message of added) this.notices.push({ index: messages.length, message });
if (this.notices.length === 0) return messages;

const result: AgentMessage[] = [];
let start = 0;
for (const notice of this.notices) {
result.push(...messages.slice(start, notice.index), notice.message);
start = notice.index;
}
result.push(...messages.slice(start));
return result;
}
}
54 changes: 54 additions & 0 deletions packages/coding-agent/test/stable-context-notices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { AgentMessage } from "@step-harness/agent-core";
import { describe, expect, it } from "vitest";
import { StableContextNotices } from "../src/core/compaction/live-context/stable-notices.ts";

const user = (content: string, timestamp = 1): AgentMessage => ({ role: "user", content, timestamp });
const notice = (content: string): AgentMessage => ({
role: "custom",
customType: "live-context-status",
display: false,
content,
timestamp: 123,
});

describe("stable request context notices", () => {
it("retains a one-shot notice before the newly appended conversation", () => {
const state = new StableContextNotices();
const raw = [user("read A")];
const notification = notice("pressure");
const first = state.project(raw, [notification]);
const next = state.project([...raw, user("continue", 2)], []);
expect(next.slice(0, first.length)).toEqual(first);
expect(next).toEqual([...raw, notification, user("continue", 2)]);
expect(raw).toEqual([user("read A")]);
});

it("keeps multiple notifications in order and does not duplicate them on retries", () => {
const state = new StableContextNotices();
const raw = [user("read A")];
const a = notice("accepted");
const b = notice("pressure");
const first = state.project(raw, [a, b]);
expect(state.project(structuredClone(raw), [])).toEqual(first);
const extended = [...raw, user("continue", 2)];
const c = notice("another tier");
const second = state.project(extended, [c]);
expect(second).toEqual([...raw, a, b, extended[1], c]);
expect(state.project(extended, [])).toEqual(second);
});

it.each(["edit", "shrink", "branch"])("drops stale notices on a %s context change", (change) => {
const state = new StableContextNotices();
const raw = [user("old"), user("tail", 2)];
state.project(raw, [notice("old pressure")]);
const changed = change === "shrink" ? raw.slice(0, 1) : [user(change), raw[1]];
const fresh = notice("new epoch");
expect(state.project(changed, [fresh])).toEqual([...changed, fresh]);
expect(state.project([...changed, user("continue", 3)], [])).toEqual([...changed, fresh, user("continue", 3)]);
});

it("returns the original view when no notification has been issued", () => {
const raw = [user("continue")];
expect(new StableContextNotices().project(raw, [])).toBe(raw);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ describe("AgentSession CLM context integration", () => {
await harness.session.prompt("Fix the parser and retain the public API.");
await harness.session.prompt("Check progress.");
let next: Context | undefined;
let sentMessages: Context["messages"] = [];
harness.setResponses([
fauxAssistantMessage(fauxToolCall("organize_context", {}), { stopReason: "toolUse" }),
(context) => {
next = context;
sentMessages = structuredClone(context.messages);
return fauxAssistantMessage("done");
},
]);
Expand All @@ -48,6 +50,16 @@ describe("AgentSession CLM context integration", () => {
expect(next!.messages.some((m) => m.role === "toolResult" && m.toolName === "organize_context")).toBe(true);
expect(JSON.stringify(harness.sessionManager.getEntries())).toContain("Verbose old exploration");
expect(harness.session.getLiveContextStatus()!.revision).toBe(1);
expect(JSON.stringify(sentMessages)).toContain("Context revision 1 accepted");
harness.setResponses([
(context) => {
expect(context.messages.slice(0, sentMessages.length)).toEqual(sentMessages);
expect(JSON.stringify(context.messages).match(/Context revision 1 accepted/g)).toHaveLength(1);
return fauxAssistantMessage("continued");
},
]);
await harness.session.prompt("continue");
expect(JSON.stringify(harness.sessionManager.getEntries())).not.toContain('"customType":"live-context-status"');
});
it("can inspect a large context index and edit it before native threshold compaction", async () => {
let h: Harness;
Expand Down
Loading