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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Use `./diffex-test.sh` to run the Diffex-branded coding agent directly from this
| [`packages/telemetry`](packages/telemetry) | Vendor-neutral telemetry contracts and typed schemas. |
| [`packages/ai`](packages/ai) | Unified LLM API with provider and model discovery. |
| [`packages/agent`](packages/agent) | Agent runtime, tool calling, state management, and attachments. |
| [`packages/harness-state`](packages/harness-state) | Immutable, content-addressed harness revisions and global selection. |
| [`packages/protocol`](packages/protocol) | Transport-neutral CBOR protocol for remote sessions. |
| [`packages/client`](packages/client) | Transport-neutral client for remote sessions. |
| [`packages/server`](packages/server) | Experimental remote session server. |
Expand Down
14 changes: 9 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/ai/test/baseten-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("Baseten models", () => {
xhigh: null,
max: "max",
},
input: ["text"],
input: expect.arrayContaining(["text"]),
contextWindow: 1048576,
maxTokens: 262144,
cost: {
Expand Down
13 changes: 13 additions & 0 deletions packages/coding-agent/install-lock/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions packages/coding-agent/npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/coding-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets",
"build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets",
"build:binary": "npm --prefix ../harness-state run build && npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets",
"copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
"copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
"test": "vitest --run",
Expand All @@ -46,6 +46,7 @@
"@earendil-works/diffex-agent-core": "^0.2.0",
"@earendil-works/diffex-ai": "^0.2.0",
"@earendil-works/diffex-client": "^0.2.0",
"@earendil-works/diffex-harness-state": "^0.2.0",
"@earendil-works/diffex-protocol": "^0.2.0",
"@earendil-works/diffex-tui": "^0.2.0",
"@silvia-odwyer/photon-node": "0.3.4",
Expand Down
12 changes: 12 additions & 0 deletions packages/coding-agent/src/core/agent-session-services.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { join } from "node:path";
import type { ThinkingLevel } from "@earendil-works/diffex-agent-core";
import type { Model } from "@earendil-works/diffex-ai";
import type { HarnessStateStore, HarnessVersionBundle } from "@earendil-works/diffex-harness-state";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { loadGlobalHarnessState } from "./harness-state-runtime.ts";
import { ModelRuntime } from "./model-runtime.ts";
import {
DefaultResourceLoader,
Expand Down Expand Up @@ -77,6 +79,8 @@ export interface AgentSessionServices {
modelRuntime: ModelRuntime;
settingsManager: SettingsManager;
resourceLoader: ResourceLoader;
harnessStateStore: HarnessStateStore;
harnessVersionBundle: HarnessVersionBundle | undefined;
diagnostics: AgentSessionRuntimeDiagnostic[];
}

Expand Down Expand Up @@ -146,6 +150,7 @@ export async function createAgentSessionServices(
signal: options.modelRuntimeSignal,
}));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const harnessState = await loadGlobalHarnessState(agentDir);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reloads the global harness pointer for every service creation, including subagent services. A parent retains its in-memory revision, so a concurrent global switch can cause a newly spawned child to use a different revision. If the selected bundle is corrupt, the child silently falls back because the subagent path does not surface these diagnostics. The parent's activated store should be bundled into child session creation. Another option is for the child's independent activation to be explicitly surfaced and documented.

const resourceLoader = new DefaultResourceLoader({
...(options.resourceLoaderOptions ?? {}),
cwd,
Expand All @@ -155,6 +160,9 @@ export async function createAgentSessionServices(
await resourceLoader.reload(options.resourceLoaderReloadOptions);

const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
if (harnessState.warning) {
diagnostics.push({ type: "warning", message: harnessState.warning });
}
const extensionsResult = resourceLoader.getExtensions();
let registeredProvider = false;
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
Expand Down Expand Up @@ -194,6 +202,8 @@ export async function createAgentSessionServices(
modelRuntime,
settingsManager,
resourceLoader,
harnessStateStore: harnessState.store,
harnessVersionBundle: harnessState.bundle,
diagnostics,
};
}
Expand All @@ -214,6 +224,8 @@ export async function createAgentSessionFromServices(
modelRuntime: options.services.modelRuntime,
settingsManager: options.services.settingsManager,
resourceLoader: options.services.resourceLoader,
harnessStateStore: options.services.harnessStateStore,
harnessVersionBundle: options.services.harnessVersionBundle,
sessionManager: options.sessionManager,
model: options.model,
thinkingLevel: options.thinkingLevel,
Expand Down
85 changes: 85 additions & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ import {
resetApiProviders,
streamSimple,
} from "@earendil-works/diffex-ai/compat";
import type {
HarnessStateStore,
HarnessVersion,
HarnessVersionBundle,
HarnessVersionList,
} from "@earendil-works/diffex-harness-state";
import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts";
import { stripFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
Expand Down Expand Up @@ -240,6 +246,10 @@ export interface AgentSessionConfig {
sessionStartEvent?: SessionStartEvent;
/** Lifecycle-owned child-agent controller. */
subagentController?: SubagentController;
/** Global immutable harness revision store. */
harnessStateStore?: HarnessStateStore;
/** Harness revision activated for this session. */
harnessVersionBundle?: HarnessVersionBundle;
}

export interface ExtensionBindings {
Expand Down Expand Up @@ -393,6 +403,9 @@ export class AgentSession {
private _baseSystemPromptOptions!: BuildSystemPromptOptions;
private _systemPromptOverride?: string;
private readonly _subagentController: SubagentController | undefined;
private readonly _harnessStateStore: HarnessStateStore | undefined;
private _harnessVersionBundle: HarnessVersionBundle | undefined;
private _harnessSwitchInProgress = false;
private _subagentWaitAbortController: AbortController | undefined;
private _agentRunAborted = false;
private _disposePromise: Promise<void> | undefined;
Expand All @@ -413,6 +426,8 @@ export class AgentSession {
this._baseToolsOverride = config.baseToolsOverride;
this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
this._subagentController = config.subagentController;
this._harnessStateStore = config.harnessStateStore;
this._harnessVersionBundle = config.harnessVersionBundle;

this._armInitialCurrentPlanRestoration();

Expand Down Expand Up @@ -1071,6 +1086,11 @@ export class AgentSession {
return this.agent.state.systemPrompt;
}

/** Globally selected harness revision activated for this session. */
get activeHarnessVersion(): HarnessVersion | undefined {
return this._harnessVersionBundle?.manifest.version;
}

/** Current retry attempt (0 if not retrying) */
get retryAttempt(): number {
return this._retryAttempt;
Expand Down Expand Up @@ -1106,6 +1126,62 @@ export class AgentSession {
return this._subagentController?.getSummaries() ?? Object.freeze([]);
}

/** List valid harness revisions and diagnostics for unavailable revision paths. */
async listHarnessVersions(): Promise<HarnessVersionList> {
if (!this._harnessStateStore) {
return { versions: Object.freeze([]), diagnostics: Object.freeze([]), currentVersion: null };
}
return this._harnessStateStore.listVersions();
}

/**
* Atomically select and activate a complete harness revision for future requests.
* Conversation history and unrelated resources remain unchanged.
*/
async switchHarnessVersion(version: HarnessVersion): Promise<void> {
if (version === this.activeHarnessVersion) {
return;
}
if (!this._harnessStateStore) {
throw new Error("Harness version switching is unavailable for this session.");
}
if (this._harnessSwitchInProgress) {
throw new Error("Cannot switch harness versions while another switch is in progress.");
}
if (this.isStreaming) {
throw new Error("Cannot switch harness versions while a response is streaming.");
}
if (this.isCompacting) {
throw new Error("Cannot switch harness versions while compaction is in progress.");
}
if (this.isBashRunning) {
throw new Error("Cannot switch harness versions while a bash command is running.");
}

this._harnessSwitchInProgress = true;
try {
const listing = await this._harnessStateStore.listVersions();
const target = listing.versions.find((bundle) => bundle.manifest.version === version);
if (!target) {
const diagnostic = listing.diagnostics.find((entry) => entry.version === version);
throw new Error(
diagnostic
? `Harness version ${version} is unavailable: ${diagnostic.message}`
: `Harness version ${version} was not found.`,
);
}

await this._subagentController?.reset();
const selected = await this._harnessStateStore.selectVersion(version);
this._harnessVersionBundle = selected;
this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
this._systemPromptOverride = undefined;
this.agent.state.systemPrompt = this._baseSystemPrompt;
} finally {
this._harnessSwitchInProgress = false;
}
}

/**
* Set active tools by name.
* Only tools in the registry can be enabled. Unknown tool names are ignored.
Expand Down Expand Up @@ -1239,6 +1315,12 @@ export class AgentSession {
selectedTools: validToolNames,
toolSnippets,
promptGuidelines,
evolutionMemory: this._harnessVersionBundle
? {
version: this._harnessVersionBundle.manifest.version,
memory: this._harnessVersionBundle.state.memory,
}
: undefined,
};
return buildSystemPrompt(this._baseSystemPromptOptions);
}
Expand Down Expand Up @@ -1406,6 +1488,9 @@ export class AgentSession {
* @throws Error if no model selected or no API key available (when not streaming)
*/
async prompt(text: string, options?: PromptOptions): Promise<void> {
if (this._harnessSwitchInProgress) {
throw new Error("Cannot submit a prompt while a harness version switch is in progress.");
}
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
const preflightResult = options?.preflightResult;
let messages: AgentMessage[] | undefined;
Expand Down
26 changes: 26 additions & 0 deletions packages/coding-agent/src/core/harness-state-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { HarnessStateStore, type HarnessVersionBundle } from "@earendil-works/diffex-harness-state";

export interface LoadedHarnessState {
store: HarnessStateStore;
bundle: HarnessVersionBundle | undefined;
warning: string | undefined;
}

/**
* Initialize the global harness store and load its selected revision.
* Invalid selected state is deliberately left untouched so `/version` can repair it.
*/
export async function loadGlobalHarnessState(agentDir: string): Promise<LoadedHarnessState> {
const store = new HarnessStateStore(agentDir);
try {
await store.initialize();
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.`,
};
}
}
Loading
Loading