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
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Changed

- Regular root sessions now reconcile selected harness memory and evolved skills at construction, idle reload, and eligible manual compaction boundaries.

## [0.3.2] - 2026-09-29

### New Features
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ Configure delivery in [settings](docs/settings.md): `steeringMode` and `followUp

### Harness Evolution

`/evolve` shows evidence eligibility, jobs, memory proposals, generated skill candidates, readable supporting and contradicting evidence, draft diffs, validation, evaluation, target revisions, activation, and rollback. `/version` selects a complete immutable harness revision. Memory and evolved skills change together at the next prompt boundary; in-flight and admitted queued requests remain pinned. The footer shows the active harness and background activity. See [Harness Evolution](docs/evolution.md).
`/evolve` shows evidence eligibility, jobs, memory proposals, generated skill candidates, readable supporting and contradicting evidence, draft diffs, validation, evaluation, target revisions, activation, and rollback. `/version` selects a complete immutable harness revision. Memory and evolved skills change together at regular session construction, an idle prompt boundary, an idle reload, or successful manual compaction without a queued continuation; in-flight and admitted queued requests remain pinned. The footer shows the active harness and background activity. See [Harness Evolution](docs/evolution.md).

---

Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/evolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ A proposed skill is a candidate, not a bundle member. An accepted candidate has

## Request boundaries

Harness selection is resolved at prompt admission. A fresh prompt observes the selected revision; in-flight work and already admitted queued messages keep their pinned memory and skill versions. Background candidate validation reads the atomically selected revision directly, so it does not depend on a session reaching its next prompt boundary first. If `/version` changes while an evolution job is running, its memory activation continues from the frozen base and cannot replace the newer selection. Separate skill work can still activate a compatible descendant later.
Harness selection is resolved when a regular root session is constructed, at idle prompt admission, during an idle reload after resources rebuild, and after a successful manual compaction when no continuation was already queued. A fresh prompt observes the selected revision; in-flight work and already admitted queued messages keep their pinned memory and skill versions. Fixed and explicitly pinned subagent sessions do not follow workspace selection automatically. Background candidate validation reads the atomically selected revision directly, so it does not depend on a session reaching its next prompt boundary first. If `/version` changes while an evolution job is running, its memory activation continues from the frozen base and cannot replace the newer selection. Separate skill work can still activate a compatible descendant later.

The footer identifies the active harness and shows background evolution activity. `/skills` lists only evolved skills from the active revision whose workspace identity matches the current workspace. Harness selection is workspace-scoped: sessions in the same workspace observe each other's selections at prompt boundaries, while sessions in other workspaces keep their own selected revision.

Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ This is progressive disclosure: only names, descriptions, and locations are alwa

### Evolved Skills

Evolved skills are immutable, workspace-scoped packages in a harness revision. The package version covers `SKILL.md` and every bundled UTF-8 supporting file, while the content digest identifies `SKILL.md` itself. Diffex exposes a package only when the active revision and workspace identity match. Selecting another revision changes `EVOLVE.md` memory and the evolved-skill set together at the next prompt boundary; in-flight and already admitted queued requests remain pinned.
Evolved skills are immutable, workspace-scoped packages in a harness revision. The package version covers `SKILL.md` and every bundled UTF-8 supporting file, while the content digest identifies `SKILL.md` itself. Diffex exposes a package only when the active revision and workspace identity match. Selecting another revision changes `EVOLVE.md` memory and the evolved-skill set together at regular root-session construction, an idle prompt boundary, an idle reload, or successful manual compaction without a queued continuation; in-flight and already admitted queued requests remain pinned.

An evolved reference binds its skill ID, content version, digest, workspace, and harness revision. A reference from an inactive revision is rejected. `/reload` refreshes installed skills while preserving the evolved overlay from the active harness. Accepted evolved skills activate automatically after validation and any configured evaluation finish. Use `/evolve` to inspect provenance, validation, available evaluation results, activation, and rollback. See [Harness Evolution](evolution.md).

Expand Down
6 changes: 5 additions & 1 deletion packages/coding-agent/src/core/agent-session-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export async function createInternalAgentSessionFromServices(
internalCustomTools: readonly ToolDefinition[] = [],
): Promise<CreateAgentSessionResult> {
registerSubagentRuntimeDiagnosticSink(options.sessionManager, options.services.diagnostics);
return createInternalAgentSession(
const result = await createInternalAgentSession(
{
backgroundProcessPolicy: options.backgroundProcessPolicy,
cwd: options.services.cwd,
Expand All @@ -265,4 +265,8 @@ export async function createInternalAgentSessionFromServices(
},
internalCustomTools,
);
if (result.harnessStateWarning) {
options.services.diagnostics.push({ type: "warning", message: result.harnessStateWarning });
}
return result;
}
26 changes: 24 additions & 2 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1475,7 +1475,7 @@ export class AgentSession {
await this._notifyHarnessActivated(activatedBundle.manifest.version);
}

private async _refreshSelectedHarnessAtPromptBoundary(): Promise<void> {
private async _reconcileSelectedHarness(): Promise<void> {
if (!this._harnessStateStore || this._pinHarnessVersion || hasFixedHarnessSubagentSession(this.sessionManager)) {
return;
}
Expand Down Expand Up @@ -2344,7 +2344,7 @@ export class AgentSession {
);
}

if (!this.isStreaming) await this._refreshSelectedHarnessAtPromptBoundary();
if (!this.isStreaming) await this._reconcileSelectedHarness();
signal?.throwIfAborted();

// Emit input event for extension interception (before skill/template expansion)
Expand Down Expand Up @@ -3381,6 +3381,14 @@ export class AgentSession {
usage,
willRetry: false,
});
let refreshError: string | undefined;
if (!this.agent.hasQueuedMessages()) {
try {
await this._reconcileSelectedHarness();
} catch (error) {
refreshError = error instanceof Error ? error.message : String(error);
}
}
// compaction_end listeners may submit queued prompts, so expose idle state before notifying them.
this._compactionAbortController = undefined;
this._emit({
Expand All @@ -3389,6 +3397,7 @@ export class AgentSession {
result: compactionResult,
aborted: false,
willRetry: false,
errorMessage: refreshError,
});
return compactionResult;
} catch (error) {
Expand Down Expand Up @@ -4175,6 +4184,18 @@ export class AgentSession {
}

async reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void> {
if (
this._promptAdmissionInProgress ||
this._harnessSwitchInProgress ||
this.isStreaming ||
this.agent.hasQueuedMessages() ||
this.isRetrying ||
this._manualCompactionOperations > 0 ||
this.isCompacting ||
this.isBashRunning
) {
throw new Error("Cannot reload while the session is busy.");
}
await this._subagentController?.reset();
const oldRunner = this._extensionRunner;
const previousFlagValues = oldRunner.getFlagValues();
Expand All @@ -4189,6 +4210,7 @@ export class AgentSession {
flagValues: previousFlagValues,
includeAllExtensionTools: true,
});
await this._reconcileSelectedHarness();

const hasBindings =
this._extensionUIContext ||
Expand Down
19 changes: 18 additions & 1 deletion packages/coding-agent/src/core/harness-state-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ export interface LoadedHarnessState {
warning: string | undefined;
}

/**
* Load the selected harness from an existing store. This preserves the
* startup fallback used when a late selection read cannot be completed.
*/
export async function loadSelectedHarnessState(store: HarnessStateStore): Promise<LoadedHarnessState> {
try {
return { store, bundle: await store.loadCurrent(), warning: undefined };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
store,
bundle: undefined,
warning: `Harness state could not be activated: ${message}. Continuing with the built-in harness.`,
};
}
}

/**
* Initialize the shared harness revision store and load this workspace's selected revision.
* Invalid selected state is deliberately left untouched so `/version` can repair it.
Expand All @@ -53,7 +70,6 @@ export async function loadWorkspaceHarnessState(
const store = new HarnessStateStore(agentDir, { workspace });
try {
await store.initialize();
return { store, bundle: await store.loadCurrent(), warning: undefined };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
Expand All @@ -62,4 +78,5 @@ export async function loadWorkspaceHarnessState(
warning: `Harness state could not be activated: ${message}. Continuing with the built-in harness.`,
};
}
return loadSelectedHarnessState(store);
}
11 changes: 8 additions & 3 deletions packages/coding-agent/src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import {
loadSelectedHarnessState,
loadWorkspaceHarnessState,
resolveEvolutionWorkspaceStateDirectory,
resolveHarnessWorkspace,
Expand Down Expand Up @@ -51,6 +52,7 @@ import {
resolveRestoredSubagentConfig,
} from "./subagents/services.ts";
import { RootLocalEphemeralSubagentSessionManagerFactory } from "./subagents/session-manager-factory.ts";
import { hasFixedHarnessSubagentSession } from "./subagents/session-runtime.ts";
import type { SubagentEscalationCallback, SubagentSupervisionOptions } from "./subagents/types.ts";
import { time } from "./timings.ts";
import {
Expand Down Expand Up @@ -440,9 +442,6 @@ export async function createInternalAgentSession(
const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd());
const workspace = await resolveHarnessWorkspace(cwd);
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
const loadedHarnessState = options.harnessStateStore
? { store: options.harnessStateStore, bundle: options.harnessVersionBundle, warning: undefined }
: await loadWorkspaceHarnessState(agentDir, workspace.workspace);
const subagentOptions = normalizeSubagentOptions(options.subagents);
const telemetryContext = options.telemetryContext ?? NOOP_TELEMETRY_CONTEXT;
assertNoReservedSubagentTools(options.customTools);
Expand All @@ -454,6 +453,7 @@ export async function createInternalAgentSession(

const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir));
const usesFixedHarness = options.pinHarnessVersion === true || hasFixedHarnessSubagentSession(sessionManager);
const subagentController =
subagentOptions === false
? undefined
Expand Down Expand Up @@ -667,6 +667,11 @@ export async function createInternalAgentSession(
sessionManager.appendThinkingLevelChange(thinkingLevel);
}

const loadedHarnessState = options.harnessStateStore
? usesFixedHarness
? { store: options.harnessStateStore, bundle: options.harnessVersionBundle, warning: undefined }
: await loadSelectedHarnessState(options.harnessStateStore)
: await loadWorkspaceHarnessState(agentDir, workspace.workspace);
const session = new AgentSession({
backgroundProcessPolicy: options.backgroundProcessPolicy,
agent,
Expand Down
98 changes: 98 additions & 0 deletions packages/coding-agent/test/suite/agent-session-compaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
type AssistantMessage,
createAssistantMessageEventStream,
fauxAssistantMessage,
type Model,
} from "@diffexai/diffex-ai";
import { HarnessStateStore } from "@diffexai/diffex-harness-state";
import { afterEach, describe, expect, it, vi } from "vitest";
import { estimateTokens } from "../../src/core/compaction/index.ts";
import { createHarness, getUserTexts, type Harness } from "./harness.ts";
Expand Down Expand Up @@ -186,6 +190,100 @@ describe("AgentSession compaction characterization", () => {
expect(harness.session.getLastAssistantText()).toBe("queued response");
});

it("reconciles the selected harness after committed manual compaction without queued work", async () => {
const agentDir = join(tmpdir(), `diffex-compaction-harness-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(agentDir, { recursive: true });
const store = new HarnessStateStore(agentDir);
const root = await store.initialize();
const harness = await createHarness({
harnessStateStore: store,
harnessVersionBundle: root,
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "manual compacted",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
}));
},
],
});
harnesses.push(harness);
try {
seedCompactableSession(harness);
const successor = await store.advance({ memory: "Use the compacted selection." }, root.manifest.version);
expect(successor.status).toBe("advanced");
if (successor.status !== "advanced") throw new Error("Expected harness advance to succeed");

await harness.session.compact();

expect(harness.session.activeHarnessVersion).toBe(successor.bundle.manifest.version);
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});

it("keeps a queued continuation pinned and reports a post-commit harness refresh failure", async () => {
const agentDir = join(tmpdir(), `diffex-compaction-harness-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(agentDir, { recursive: true });
const store = new HarnessStateStore(agentDir);
const root = await store.initialize();
const harness = await createHarness({
harnessStateStore: store,
harnessVersionBundle: root,
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "manual compacted",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
}));
},
],
});
harnesses.push(harness);
try {
seedCompactableSession(harness);
const successor = await store.advance({ memory: "Do not activate while queued." }, root.manifest.version);
expect(successor.status).toBe("advanced");
if (successor.status !== "advanced") throw new Error("Expected harness advance to succeed");
harness.session.agent.followUp({
role: "custom",
customType: "test",
content: [{ type: "text", text: "queued continuation" }],
display: false,
timestamp: Date.now(),
});

await harness.session.compact();

expect(harness.session.activeHarnessVersion).toBe(root.manifest.version);
harness.session.agent.clearAllQueues();
seedCompactableSession(harness);
vi.spyOn(store, "loadCurrent").mockRejectedValueOnce(new Error("selection read failed"));

const result = await harness.session.compact();

expect(result.summary).toContain("manual compacted");
expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({
reason: "manual",
aborted: false,
result,
errorMessage: "selection read failed",
});
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});

it("throws when compacting without a model", async () => {
const harness = await createHarness();
harnesses.push(harness);
Expand Down
Loading
Loading