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

### Fixed

- Fixed evolved skills being limited to instruction-only `SKILL.md` files by validating, versioning, persisting, reviewing, and activating complete UTF-8 skill packages.
- Fixed skill-specific rollback leaving an evolved skill active when later harness descendants preserved it.
- Fixed accepted skill candidates failing activation after a compatible harness memory change by safely rebasing them onto the selected revision.
- Fixed the harness footer showing an evolution spinner while self-evolution is disabled.
- Fixed evolution setting changes not reaching already-running Diffex processes, which could let stale processes continue tracing and launch automatic work.
- Fixed an open, idle `/evolve` menu retaining enabled actions after another Diffex process disables evolution.
- Fixed the evolution menu remaining on its loading screen after cancellation and flickering during active-job refreshes.
- Fixed assistant Markdown headings and fenced code blocks appearing with literal source delimiters in the interactive transcript.
- Fixed extension TUI method wrappers recursing indefinitely when delegating to the original method ([#7731](https://github.com/earendil-works/pi/issues/7731)).
Expand Down
14 changes: 7 additions & 7 deletions packages/coding-agent/docs/evolution.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Harness Evolution

Diffex can derive workspace-scoped guidance from persisted invocation evidence and store it in immutable harness revisions. A revision can contain `EVOLVE.md` memory and evolved `skills/<name>/SKILL.md` artifacts. The selected revision changes both together.
Diffex can derive workspace-scoped guidance from persisted invocation evidence and store it in immutable harness revisions. A revision can contain `EVOLVE.md` memory and complete evolved skill packages rooted at `skills/<name>/SKILL.md`, including referenced UTF-8 scripts, assets, and documentation. The selected revision changes both together.

## Controls

Expand All @@ -11,16 +11,16 @@ Use `/evolve` to:
- inspect memory proposals and their evidence;
- inspect skill-candidate provenance, textual draft diffs, deterministic validation, and retrieval/behavior evaluation;
- activate a skill candidate only after its persisted evaluation is accepted;
- inspect target revisions and roll back an activated evolved skill to its immutable parent revision;
- inspect target revisions and roll back an activated evolved skill to its pre-activation revision, even when later descendants preserved it;
- enable or disable future tracing and evolution work.

Skill activation always requires explicit confirmation. Automatic skill activation is not enabled. Candidates that have not passed every required gate remain read-only.
Skill activation always requires explicit confirmation. Automatic skill activation is not enabled. Candidates that have not passed every required gate remain read-only. If harness memory changes after evaluation while the evaluated skill inventory remains identical, activation automatically rebases the candidate onto the selected revision and preserves its memory. Skill-inventory changes remain conflicts because they require fresh retrieval and behavior evaluation.

Use `/version` to inspect compatible revisions for the current workspace and select one. Selection validates the complete bundle. A revision with corrupt, unsupported, or workspace-mismatched artifacts is not activated.

## 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. If `/version` changes while an evolution job is running, the job continues from its frozen base and cannot replace the newer selection.
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, the job continues from its frozen base and cannot replace the newer selection.

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.

Expand All @@ -37,7 +37,7 @@ Evolution model calls use the configured provider and may consume provider quota

## Validation and security

Source traces, tool output, files, and generated drafts are treated as untrusted data. Deterministic validation checks structure, provenance, workspace scope, immutable versions, inventory, and bounded sensitive-data rules. Retrieval and behavioral evaluation are separate gates.
Source traces, tool output, files, and generated drafts are treated as untrusted data. Deterministic validation checks structure, provenance, workspace scope, immutable versions, the closed package inventory, portable paths, local-reference resolution, aggregate size limits, and bounded sensitive-data rules. Retrieval and behavioral evaluation are separate gates.

These checks do not prove semantic safety and are not a sandbox. Review candidate provenance, draft diffs, validation issues, and evaluation results before activation. See [Security](security.md).

Expand All @@ -50,6 +50,6 @@ Evolution state is stored beneath the effective agent directory:
- `evolution-failures/` contains failure reports;
- `harness-state/` contains immutable revisions and the selected pointer.

Interrupted jobs and candidate work resume from durable checkpoints. Writer retries use stable idempotency keys, leases, exact base revisions, and compare-and-swap selection. Rollback selects an existing immutable parent; it does not mutate historical revisions.
Interrupted jobs and candidate work resume from durable checkpoints. Writer retries use stable idempotency keys, leases, exact base revisions, and compare-and-swap selection. Skill rollback follows immutable ancestry to the candidate's application boundary and selects its existing pre-activation revision; it does not mutate historical revisions.

Set `evolution.enabled` to `false` in global settings or use the toggle in `/evolve` to stop future tracing and background/manual evolution controls. Disabling evolution does not unselect the current harness or remove already active evolved skills.
Set `evolution.enabled` to `false` in global settings or use the toggle in `/evolve` to stop future tracing and background/manual evolution controls. Already-running Diffex processes monitor this global setting and stop their trace collectors and evolution workers when another process disables it. Disabling evolution does not unselect the current harness or remove already active evolved skills.
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ Set `DIFFEX_SKIP_VERSION_CHECK=1` to disable the upstream compatibility release
|---------|------|---------|-------------|
| `evolution.enabled` | boolean | `true` | Global-only. Enables future invocation tracing and manual/background evolution controls. Disabling it does not unselect the active harness or remove active evolved skills. |

Already-running Diffex processes monitor this setting. A change made by another process is applied to their trace collectors and evolution workers without a restart.

Automatic work requires more than 1,000,000 estimated unprocessed evidence tokens, at least three eligible settled invocations, and a 20-minute cooldown after the previous automatic run. Evolution model calls may consume provider quota. See [Harness Evolution](evolution.md).

### Compaction
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 artifacts in a harness revision. Diffex exposes them 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 the next prompt boundary; 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. Use `/evolve` to inspect provenance, validation, evaluation, activation, and rollback. See [Harness Evolution](evolution.md).

Expand Down
159 changes: 112 additions & 47 deletions packages/coding-agent/src/core/evolution-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import {
RecursiveReflector,
SkillCandidateBackgroundWorker,
SkillCandidateRegistryWorker,
type SkillDraftValidationEnvironmentSource,
type SkillEvaluator,
SkillWriter,
StructuredDeltaCurator,
StructuredSkillCurator,
Expand All @@ -64,7 +66,7 @@ import { resolveHarnessWorkspace } from "./harness-state-runtime.ts";
import { INVOCATION_TRACE_LOCK_STALE_MS, type InvocationTrace } from "./invocation-trace.ts";
import type { ModelRuntime } from "./model-runtime.ts";
import type { ResourceLoader } from "./resource-loader.ts";
import type { Skill } from "./skills.ts";
import { createEvolvedSkillCatalog, type Skill } from "./skills.ts";

const execFileAsync = promisify(execFile);
const EVOLUTION_DIRECTORY_NAME = "evolution";
Expand Down Expand Up @@ -698,6 +700,55 @@ export function createEvolutionSkillSnapshots(skills: readonly Skill[]): readonl
}));
}

export class SelectedHarnessSkillDraftValidationEnvironmentSource implements SkillDraftValidationEnvironmentSource {
readonly #workspace: { readonly repositoryId: string; readonly directoryId: string };
readonly #harnessState: HarnessStateStore;
readonly #resourceLoader: ResourceLoader;

constructor(options: {
workspace: { readonly repositoryId: string; readonly directoryId: string };
harnessState: HarnessStateStore;
resourceLoader: ResourceLoader;
}) {
this.#workspace = options.workspace;
this.#harnessState = options.harnessState;
this.#resourceLoader = options.resourceLoader;
}

async capture(workspace: Parameters<SkillDraftValidationEnvironmentSource["capture"]>[0], signal?: AbortSignal) {
signal?.throwIfAborted();
if (
workspace.repositoryId !== this.#workspace.repositoryId ||
workspace.directoryId !== this.#workspace.directoryId
) {
throw new Error("Skill validation workspace does not match the repository runtime");
}
const selected = await this.#harnessState.loadCurrent();
signal?.throwIfAborted();
const installedSkills = this.#resourceLoader
.getSkills()
.skills.filter((skill) => skill.sourceKind === "installed");
const evolvedSkills = createEvolvedSkillCatalog(selected, this.#workspace).skills;
const skillVersions = [...installedSkills, ...evolvedSkills]
.map((skill) => ({
skillId: skill.skillId,
version: skill.version,
contentDigest: skill.contentDigest,
}))
.sort(
(left, right) =>
left.skillId.localeCompare(right.skillId) ||
left.version.localeCompare(right.version) ||
left.contentDigest.localeCompare(right.contentDigest),
);
return {
workspace: this.#workspace,
harnessVersion: selected.manifest.version,
skillVersions,
};
}
}

class CodingAgentSnapshotSource implements EvolutionSnapshotSource {
readonly #cwd: string;
readonly #directoryId: string;
Expand Down Expand Up @@ -788,13 +839,22 @@ export interface CodingAgentEvolutionRuntime {
stop(): Promise<void>;
}

export type EvolutionRuntimeEvent =
| EvolutionProgressEvent
| {
readonly type: "enabled-changed";
readonly enabled: boolean;
};

export interface CreateCodingAgentEvolutionRuntimeOptions {
readonly agentDir: string;
readonly cwd: string;
readonly session: AgentSession;
readonly harnessState: HarnessStateStore;
readonly modelRuntime: ModelRuntime;
readonly resourceLoader: ResourceLoader;
/** Optional retrieval and behavior evaluation capability for synthesized skill candidates. */
readonly skillEvaluator?: SkillEvaluator;
}

export async function createCodingAgentEvolutionRuntime(
Expand Down Expand Up @@ -896,41 +956,33 @@ export async function createCodingAgentEvolutionRuntime(
version: VERSION,
artifacts: persistence,
jobs: persistence,
environment: {
async capture(workspace, signal) {
signal?.throwIfAborted();
if (
workspace.repositoryId !== scope.repositoryId ||
workspace.directoryId !== currentDirectory.directoryId
) {
throw new Error("Skill validation workspace does not match the repository runtime");
}
const harnessVersion = options.session.activeHarnessVersion;
if (harnessVersion === undefined) throw new Error("Skill validation requires an active session harness");
return {
workspace: { repositoryId: scope.repositoryId, directoryId: currentDirectory.directoryId },
harnessVersion,
skillVersions: options.resourceLoader.getSkills().skills.map((skill) => ({
skillId: skill.skillId,
version: skill.version,
contentDigest: skill.contentDigest,
})),
};
},
},
environment: new SelectedHarnessSkillDraftValidationEnvironmentSource({
workspace: { repositoryId: scope.repositoryId, directoryId: currentDirectory.directoryId },
harnessState: options.harnessState,
resourceLoader: options.resourceLoader,
}),
});
const candidateWorker = new SkillCandidateBackgroundWorker({
clock,
source: new PollingSkillCandidateWorkSource({ clock, jobs: persistence, store: persistence }),
source: new PollingSkillCandidateWorkSource({
clock,
jobs: persistence,
store: persistence,
registry: candidateRegistry,
evaluatorAvailable: options.skillEvaluator !== undefined,
}),
processor: new SkillCandidateRegistryWorker({
version: VERSION,
clock,
jobs: persistence,
artifacts: persistence,
store: persistence,
leases: persistence,
ownerId: `skill-candidate-worker-${randomUUID()}`,
registry: candidateRegistry,
synthesizer: skillSynthesizer,
validator: skillValidator,
evaluator: options.skillEvaluator,
model,
}),
priority,
Expand Down Expand Up @@ -1012,9 +1064,11 @@ export class EvolutionRuntimeCoordinator implements AgentSessionLifecycleObserve
readonly #trace: InvocationTrace;
readonly #getEnabled: () => boolean;
readonly #setEnabled: (enabled: boolean) => void | Promise<void>;
readonly #listeners = new Set<(event: EvolutionProgressEvent) => void>();
readonly #listeners = new Set<(event: EvolutionRuntimeEvent) => void>();
#runtime: CodingAgentEvolutionRuntime | null = null;
#unsubscribe: (() => void) | null = null;
#enabled: boolean;
#toggleQueue: Promise<void> = Promise.resolve();

constructor(
trace: InvocationTrace,
Expand All @@ -1029,15 +1083,17 @@ export class EvolutionRuntimeCoordinator implements AgentSessionLifecycleObserve
this.#trace = trace;
this.#getEnabled = toggle.getEnabled;
this.#setEnabled = toggle.setEnabled;
this.#enabled = toggle.getEnabled();
}

async replace(runtime: CodingAgentEvolutionRuntime): Promise<void> {
await this.#toggleQueue;
const previous = this.#runtime;
this.#unsubscribe?.();
this.#unsubscribe = null;
this.#runtime = null;
await previous?.stop();
if (this.#getEnabled()) await runtime.start();
if (this.#enabled && this.#getEnabled()) await runtime.start();
this.#runtime = runtime;
this.#unsubscribe = runtime.control.subscribe((event) => {
for (const listener of this.#listeners) listener(event);
Expand All @@ -1046,28 +1102,31 @@ export class EvolutionRuntimeCoordinator implements AgentSessionLifecycleObserve

async operationSettled(settlement: AgentSessionOperationSettlement): Promise<void> {
await this.#trace.operationSettled(settlement);
if (this.#getEnabled()) this.#runtime?.evaluateAutomatic();
if (this.#enabled && this.#getEnabled()) this.#runtime?.evaluateAutomatic();
}

getEnabled(): boolean {
return this.#getEnabled();
return this.#enabled;
}

async setEnabled(enabled: boolean): Promise<void> {
if (this.#getEnabled() === enabled) return;
await Promise.all([
this.#setEnabled(enabled),
this.#trace.setEnabled(enabled),
enabled ? Promise.resolve() : this.#runtime?.stop(),
]);
if (enabled) await this.#runtime?.start();
setEnabled(enabled: boolean): Promise<void> {
const update = this.#toggleQueue.then(async () => {
if (this.#getEnabled() !== enabled) await this.#setEnabled(enabled);
if (this.#enabled === enabled) return;
this.#enabled = enabled;
await Promise.all([this.#trace.setEnabled(enabled), enabled ? Promise.resolve() : this.#runtime?.stop()]);
if (enabled) await this.#runtime?.start();
for (const listener of this.#listeners) listener({ type: "enabled-changed", enabled });
});
this.#toggleQueue = update.catch(() => {});
return update;
}

harnessActivated(version: HarnessVersion): Promise<void> {
return this.#trace.harnessActivated(version);
}

subscribe(listener: (event: EvolutionProgressEvent) => void): () => void {
subscribe(listener: (event: EvolutionRuntimeEvent) => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
Expand All @@ -1077,12 +1136,17 @@ export class EvolutionRuntimeCoordinator implements AgentSessionLifecycleObserve
return this.#runtime.control;
}

#enabledControl(): EvolutionControlService {
if (!this.#enabled || !this.#getEnabled()) throw new Error("Self-evolution is disabled");
return this.#control();
}

overview(): Promise<EvolutionOverview> {
return this.#control().overview();
}

listJobs(): Promise<readonly EvolutionJobSummary[]> {
return this.#control().listJobs();
return this.#enabledControl().listJobs();
}

listHarnessVersionInfo(): Promise<readonly EvolutionHarnessVersionInfo[]> {
Expand All @@ -1094,42 +1158,43 @@ export class EvolutionRuntimeCoordinator implements AgentSessionLifecycleObserve
}

jobDetail(jobId: string): Promise<EvolutionJobDetail> {
return this.#control().getJob(jobId);
return this.#enabledControl().getJob(jobId);
}

proposalDetail(jobId: string, proposalId: string): Promise<EvolutionProposalDetail> {
return this.#control().getProposal(jobId, proposalId);
return this.#enabledControl().getProposal(jobId, proposalId);
}

skillCandidateDetail(jobId: string, candidateId: string) {
return this.#control().getSkillCandidate(jobId, candidateId);
return this.#enabledControl().getSkillCandidate(jobId, candidateId);
}

activateSkillCandidate(jobId: string, candidateId: string) {
return this.#control().activateSkillCandidate(jobId, candidateId);
return this.#enabledControl().activateSkillCandidate(jobId, candidateId);
}

rollbackSkillCandidate(jobId: string, candidateId: string) {
return this.#control().rollbackSkillCandidate(jobId, candidateId);
return this.#enabledControl().rollbackSkillCandidate(jobId, candidateId);
}

startManual(): Promise<EvolutionStartResult> {
return this.#control().startManual();
return this.#enabledControl().startManual();
}

resumeNewest(): Promise<EvolutionJobSummary> {
return this.#control().resumeNewest();
return this.#enabledControl().resumeNewest();
}

cancelJob(jobId: string): Promise<void> {
return this.#control().cancelJob(jobId);
return this.#enabledControl().cancelJob(jobId);
}

rollback(): Promise<EvolutionRollbackAction> {
return this.#control().rollback();
return this.#enabledControl().rollback();
}

async stop(): Promise<void> {
await this.#toggleQueue;
this.#unsubscribe?.();
this.#unsubscribe = null;
const runtime = this.#runtime;
Expand Down
Loading
Loading