diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1094a7a616..21b1b68cba 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Added an opt-in auto-refine review hook that can ask whether `/refine` should run after turn intervals or compaction checkpoints. + ## [0.2.4] - 2026-07-01 - Changed the agents view to list only sessions the daemon is actively holding, and stopped the daemon from auto-restoring on-disk sessions on startup, so a restarted daemon no longer surfaces a wall of weeks-old sessions; sessions come back via `/resume` or `--resume` ([#295](https://github.com/PrimeIntellect-ai/prime-agent/issues/295)). diff --git a/packages/coding-agent/docs/kernel-and-rlm-recursion.md b/packages/coding-agent/docs/kernel-and-rlm-recursion.md index 30034ac448..fb734fe2d5 100644 --- a/packages/coding-agent/docs/kernel-and-rlm-recursion.md +++ b/packages/coding-agent/docs/kernel-and-rlm-recursion.md @@ -118,9 +118,12 @@ rlm.harness.record_refinement( print(rlm.harness.overview()) ``` -The store writes `harness_state.json` in the global agent harness directory -(`RLM_HARNESS_STATE_DIR`, e.g. `~/.prime/agent/harness/`), so learned state is -shared across sessions. Because the long-lived kernel and the host `/refine` +The store writes `harness_state.json` in the session-local harness directory by +default (`RLM_HARNESS_STATE_DIR`, falling back to `RLM_SESSION_DIR/harness`), so +learned state stays with the session. Explicitly global edits go to the global +agent harness directory (`RLM_GLOBAL_HARNESS_STATE_DIR`, e.g. +`~/.prime/agent/harness/`), which is shared across sessions. Because the +long-lived kernel and the host `/refine` command write the same file from separate processes, the kernel-side store reloads the file whenever its on-disk mtime changes before reading or mutating, so concurrent host edits are merged rather than clobbered. It is intentionally a diff --git a/packages/coding-agent/docs/refine-verification-log.md b/packages/coding-agent/docs/refine-verification-log.md index 9c73efa51b..b2384a68ab 100644 --- a/packages/coding-agent/docs/refine-verification-log.md +++ b/packages/coding-agent/docs/refine-verification-log.md @@ -5,10 +5,14 @@ harness features. It is intentionally artifact-oriented so later benchmark runs can replay the same surfaces. Current design note: refined prompt notes, memories, skills, subagent specs, and -refinement events are global by default under the agent harness directory, for -example `~/.prime/agent/harness/harness_state.json`. Session JSONL entries still +refinement events are session-local by default under the session harness +directory (`RLM_HARNESS_STATE_DIR`, falling back to `RLM_SESSION_DIR/harness`); +explicitly global refinement writes the global agent harness directory +(`RLM_GLOBAL_HARNESS_STATE_DIR`, for example +`~/.prime/agent/harness/harness_state.json`). Session JSONL entries still record refinement results for auditability and rollback evidence. A compact -overview of the global harness state is injected into the default system prompt +overview of the merged global and local harness state is injected into the +default system prompt so the agent can use learned state without first calling `rlm.harness.overview()`. The model-facing `rlm.harness` API uses explicit `create_*`, `update_*`, and `delete_*` calls for memory, skill, subagent, and prompt-note entries. @@ -204,3 +208,10 @@ Covered validation and recovery cases: - Python runtime default backing store through `RLM_HARNESS_STATE_DIR`. - Python runtime unknown-kind rejection for `upsert`, `get`, `delete`, and `list`. + +## 2026-07-01 correction + +When the entries above were recorded, `RLM_HARNESS_STATE_DIR` pointed at the +global harness directory. Since local-by-default refinement landed, the global +directory is `RLM_GLOBAL_HARNESS_STATE_DIR` and `RLM_HARNESS_STATE_DIR` is the +session-local store (falling back to `RLM_SESSION_DIR/harness`). diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8e15397fcb..c3884f201a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -114,15 +114,21 @@ import type { BashExecutionMessage, CustomMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js"; import { + type AutoRefineReason, + type AutoRefineReview, appendGlobalRefinement, applyRefinementProposal, getGlobalHarnessStateDir, + getLocalHarnessStateDir, getRefinementHistory, + type HarnessState, loadGlobalRefinementHistory, loadHarnessState, + mergeHarnessStates, mergeRefinementHistory, planRefinement, type RefinementResult, + reviewAutoRefine, saveHarnessState, } from "./refinement/index.js"; import { resolveConfigValue } from "./resolve-config-value.js"; @@ -309,6 +315,8 @@ export interface AgentSessionConfig { * Only applies to main agents (rlmDepth 0); subagent kernels stay lazy. Default: false. */ prewarmIpythonKernel?: boolean; + /** Test/extension hook for automatic refine review decisions. Defaults to the model-backed review gate. */ + autoRefineReviewer?: AutoRefineReviewer; } export interface ExtensionBindings { @@ -318,6 +326,13 @@ export interface ExtensionBindings { onError?: ExtensionErrorListener; } +export interface AutoRefineReviewRequest { + reason: AutoRefineReason; + turnsSinceLastReview: number; +} + +export type AutoRefineReviewer = (request: AutoRefineReviewRequest, signal?: AbortSignal) => Promise; + /** Options for AgentSession.prompt() */ export interface PromptOptions { /** Whether to expand file-based prompt templates (default: true) */ @@ -391,6 +406,14 @@ function isRlmChildRunCancelled(run: RlmChildRun): boolean { return run.status === "cancelled"; } +function autoRefineInstructions(reason: AutoRefineReason, review: AutoRefineReview): string { + const detail = review.instructions + ? ` +Reviewer instructions: ${review.instructions}` + : ""; + return `Automatic refine review triggered by ${reason}. Only create/update/delete local harness entries if there is clear evidence that should help this session continue. Prefer an empty edits array over speculative or one-off memories. Do not promote anything global unless explicitly requested. Reviewer rationale: ${review.rationale}${detail}`; +} + function parseDepth(value: string | undefined, fallback: number, name: string): number { if (value === undefined || value === "") { return fallback; @@ -564,6 +587,20 @@ export class AgentSession { // Base system prompt (without extension appends) - used to apply fresh appends each turn private _baseSystemPrompt = ""; private _baseSystemPromptOptions!: BuildSystemPromptOptions; + private _assistantTurnsSinceAutoRefine = 0; + private _lastAutoRefineReviewAt = 0; + private _autoRefineInProgress = false; + private _compactAutoRefinePending = false; + private _turnIntervalAutoRefinePending = false; + private _postCompactionContinuationScheduled = false; + private _postCompactionContinuationTimer: ReturnType | undefined; + private _pendingAutoRefineReview: { reason: AutoRefineReason; review: AutoRefineReview } | undefined; + private _autoRefineBranchVersion = 0; + private _autoRefineReviewAbort?: AbortController; + private _refineAbortController?: AbortController; + private readonly _autoRefineReviewer?: AutoRefineReviewer; + /** Settles (never rejects) when the in-flight refine finishes; see _waitForRefineIdle. */ + private _refineInFlight?: Promise; constructor(config: AgentSessionConfig) { this.agent = config.agent; @@ -586,6 +623,7 @@ export class AgentSession { this._rlmDepth = config.rlmDepth ?? parseDepth(process.env.RLM_DEPTH, 0, "RLM_DEPTH"); this._rlmMaxDepth = config.rlmMaxDepth ?? parseDepth(process.env.RLM_MAX_DEPTH, 1, "RLM_MAX_DEPTH"); this._prewarmIpythonKernel = (config.prewarmIpythonKernel ?? false) && this._rlmDepth === 0; + this._autoRefineReviewer = config.autoRefineReviewer; this._rlmSessionDir = config.rlmSessionDir; this._rlmParentNodeId = config.rlmParentNodeId; this._subagentRuntimeHost = config.subagentRuntimeHost; @@ -1027,6 +1065,9 @@ export class AgentSession { } await this._validateCanStartAgentRun(); + // Wait immediately before the handoff so a refine starting during the + // awaits above cannot disconnect event handling under this turn. + await this._waitForRefineIdle(); await this.agent.prompt([message]); await this.waitForRetry(); } @@ -1433,6 +1474,7 @@ export class AgentSession { // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { this._lastAssistantMessage = event.message; + this._assistantTurnsSinceAutoRefine++; const assistantMsg = event.message as AssistantMessage; if (assistantMsg.stopReason !== "error") { @@ -1470,6 +1512,7 @@ export class AgentSession { const compactionWillRetry = await this._checkCompaction(msg); if (!compactionWillRetry) { this._finishGoalForTerminalAssistantMessage(msg); + this._scheduleAutoRefineAfterAgentEnd(); } } } @@ -1687,6 +1730,12 @@ export class AgentSession { return; } this._disposed = true; + // Invalidate scheduled timers and abort any in-flight review so a late + // resolution cannot write harness state or re-subscribe handlers. + this._autoRefineReviewAbort?.abort(); + this._refineAbortController?.abort(); + this._discardPendingAutoRefine({ cancelPostCompactionContinue: true }); + this._autoRefineBranchVersion++; this._cancelActiveRlmChildRuns("Parent session disposed"); for (const unsubscribe of this._retainedRlmChildUnsubscribes.values()) { unsubscribe(); @@ -1905,7 +1954,7 @@ export class AgentSession { toolSnippets, promptGuidelines, allowRecursion: this._rlmDepth < this._rlmMaxDepth, - harnessState: loadHarnessState(getGlobalHarnessStateDir()), + harnessState: this._loadMergedHarnessState(), }; return buildSystemPrompt(this._baseSystemPromptOptions); } @@ -1991,6 +2040,8 @@ export class AgentSession { return; } + await this._waitForRefineIdle(); + // Flush any pending bash messages before the new prompt this._flushPendingBashMessages(); @@ -2074,6 +2125,9 @@ export class AgentSession { } preflightResult?.(true); + // Re-check adjacent to the handoff: extension before_agent_start handlers + // above may have suspended this turn long enough for a refine to start. + await this._waitForRefineIdle(); await this.agent.prompt(messages); await this.waitForRetry(); } @@ -2270,6 +2324,7 @@ export class AgentSession { this.agent.steer(appMessage); } } else if (options?.triggerTurn) { + await this._waitForRefineIdle(); await this.agent.prompt(appMessage); } else { this.agent.state.messages.push(appMessage); @@ -2692,8 +2747,10 @@ export class AgentSession { * @param customInstructions Optional instructions for the compaction summary */ async compact(customInstructions?: string): Promise { + const hadPostCompactionContinue = this._postCompactionContinuationScheduled; this._disconnectFromAgent(); await this.abort(); + let didCompact = false; this._compactionAbortController = new AbortController(); this._emit({ type: "compaction_start", reason: "manual", customInstructions }); @@ -2811,6 +2868,7 @@ export class AgentSession { willRetry: false, customInstructions, }); + didCompact = true; return compactionResult; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -2830,6 +2888,13 @@ export class AgentSession { } finally { this._compactionAbortController = undefined; this._reconnectToAgent(); + if (didCompact) { + this._discardPendingAutoRefine({ cancelPostCompactionContinue: true }); + if (hadPostCompactionContinue) { + this._schedulePostCompactionContinue(); + } + this._scheduleAutoRefine("compact"); + } } } @@ -2841,11 +2906,349 @@ export class AgentSession { this._autoCompactionAbortController?.abort(); } + private _localHarnessStateDir(): string | undefined { + return ( + getLocalHarnessStateDir(this.sessionManager.getSessionArtifactDir()) ?? + (this._rlmSessionDir ? getLocalHarnessStateDir(this._rlmSessionDir) : undefined) + ); + } + + private _autoRefineAllowedForSession(): boolean { + return this._rlmDepth === 0 && this._localHarnessStateDir() !== undefined; + } + + private _cancelPostCompactionContinue(): void { + if (this._postCompactionContinuationTimer) { + clearTimeout(this._postCompactionContinuationTimer); + this._postCompactionContinuationTimer = undefined; + } + this._postCompactionContinuationScheduled = false; + } + + private _discardPendingAutoRefine(options: { cancelPostCompactionContinue?: boolean } = {}): void { + this._compactAutoRefinePending = false; + this._turnIntervalAutoRefinePending = false; + this._pendingAutoRefineReview = undefined; + if (options.cancelPostCompactionContinue) { + this._cancelPostCompactionContinue(); + } + } + + private async _invalidatePendingAutoRefineForBranchChange(): Promise { + this._autoRefineReviewAbort?.abort(); + this._discardPendingAutoRefine({ cancelPostCompactionContinue: true }); + this._assistantTurnsSinceAutoRefine = 0; + this._autoRefineBranchVersion++; + await this._waitForRefineIdle(); + } + + private _scheduleAutoRefineAfterAgentEnd(): void { + if (!this._autoRefineAllowedForSession()) { + return; + } + if (this._pendingAutoRefineReview) { + this._scheduleAutoRefine(this._pendingAutoRefineReview.reason); + return; + } + if (this._compactAutoRefinePending) { + if (this._postCompactionContinuationScheduled) { + return; + } + this._scheduleAutoRefine("compact"); + return; + } + + this._scheduleAutoRefine("turn_interval"); + } + + private _scheduleAutoRefineAfterCompaction(willContinueAfterCompaction: boolean): void { + if (!this._autoRefineAllowedForSession()) { + return; + } + if (willContinueAfterCompaction) { + this._compactAutoRefinePending = true; + return; + } + + this._scheduleAutoRefine("compact"); + } + + private _schedulePostCompactionContinue(): void { + if (this._postCompactionContinuationScheduled) { + return; + } + this._postCompactionContinuationScheduled = true; + this._postCompactionContinuationTimer = setTimeout(() => { + this._postCompactionContinuationTimer = undefined; + void this._runScheduledPostCompactionContinue(); + }, 100); + } + + private async _runScheduledPostCompactionContinue(): Promise { + await this._waitForRefineIdle(); + if (!this._postCompactionContinuationScheduled) { + return; + } + if (this.isStreaming || this.isCompacting) { + this._postCompactionContinuationScheduled = false; + this._schedulePostCompactionContinue(); + return; + } + + this._postCompactionContinuationScheduled = false; + try { + await this.agent.continue(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("already processing")) { + this._schedulePostCompactionContinue(); + } + } + } + + private _shouldSkipAutoRefineForActiveAgent(): boolean { + return this.isStreaming || this.isCompacting; + } + + private _scheduleDeferredAutoRefineIfIdle(): void { + if (this._autoRefineInProgress || this._shouldSkipAutoRefineForActiveAgent() || this._pendingAutoRefineReview) { + return; + } + if (this._turnIntervalAutoRefinePending) { + this._turnIntervalAutoRefinePending = false; + this._scheduleAutoRefine("turn_interval"); + } + } + + private _scheduleAutoRefine(reason: AutoRefineReason, branchVersion = this._autoRefineBranchVersion): void { + setTimeout(() => { + if (branchVersion !== this._autoRefineBranchVersion) { + return; + } + void this._maybeAutoRefine(reason); + }, 0); + } + + private async _maybeAutoRefine(reason: AutoRefineReason): Promise { + if (this._disposed || this._disposing) { + this._discardPendingAutoRefine(); + return; + } + if (!this._autoRefineAllowedForSession()) { + this._discardPendingAutoRefine(); + return; + } + + const settings = this.settingsManager.getAutoRefineSettings(); + if (!settings.enabled) { + this._discardPendingAutoRefine(); + return; + } + if (this._autoRefineInProgress || this._shouldSkipAutoRefineForActiveAgent()) { + if (reason === "compact") { + this._compactAutoRefinePending = true; + } else { + this._turnIntervalAutoRefinePending = true; + } + return; + } + + const nowMs = Date.now(); + const underCooldown = + this._lastAutoRefineReviewAt > 0 && nowMs - this._lastAutoRefineReviewAt < settings.cooldownMs; + + const pendingReview = this._pendingAutoRefineReview; + if (pendingReview) { + // A failed refine stamps the cooldown; keep the pending review for later. + if (underCooldown) { + return; + } + await this._runApprovedRefine(pendingReview.reason, pendingReview.review); + return; + } + + if (reason === "compact" && !settings.compact) { + this._compactAutoRefinePending = false; + reason = "turn_interval"; + } + if (reason === "turn_interval" && this._assistantTurnsSinceAutoRefine < settings.turnInterval) { + return; + } + if (underCooldown) { + if (reason === "compact") { + this._compactAutoRefinePending = true; + } else { + this._turnIntervalAutoRefinePending = true; + } + return; + } + if (reason === "turn_interval") { + this._turnIntervalAutoRefinePending = false; + } + if (!this.model) { + if (reason === "compact") { + this._compactAutoRefinePending = true; + } + return; + } + this._autoRefineInProgress = true; + const turnsSinceLastReview = this._assistantTurnsSinceAutoRefine; + const branchVersion = this._autoRefineBranchVersion; + const reviewAbort = new AbortController(); + this._autoRefineReviewAbort = reviewAbort; + let approvedReview: AutoRefineReview | undefined; + try { + const review = await this._reviewAutoRefine({ reason, turnsSinceLastReview }, reviewAbort.signal); + if (this._disposed || this._disposing || branchVersion !== this._autoRefineBranchVersion) { + return; + } + if (!review.shouldRefine) { + const preserveTurnIntervalReview = + reason === "compact" && this._assistantTurnsSinceAutoRefine >= settings.turnInterval; + if (preserveTurnIntervalReview) { + this._turnIntervalAutoRefinePending = true; + } else { + this._lastAutoRefineReviewAt = nowMs; + this._assistantTurnsSinceAutoRefine = 0; + } + if (reason === "compact") { + this._compactAutoRefinePending = false; + } + return; + } + if (this._shouldSkipAutoRefineForActiveAgent()) { + this._pendingAutoRefineReview = { reason, review }; + return; + } + approvedReview = review; + } catch { + // Failed review: stamp the cooldown so a persistent failure (bad auth, + // unparseable output) doesn't retry a full review on every agent end. + if (branchVersion === this._autoRefineBranchVersion) { + this._lastAutoRefineReviewAt = Date.now(); + } + } finally { + if (this._autoRefineReviewAbort === reviewAbort) { + this._autoRefineReviewAbort = undefined; + } + this._autoRefineInProgress = false; + // When a refine follows, _runApprovedRefine schedules the deferred pass. + if (!approvedReview) { + this._scheduleDeferredAutoRefineIfIdle(); + } + } + if (approvedReview) { + await this._runApprovedRefine(reason, approvedReview); + } + } + + private async _runApprovedRefine(reason: AutoRefineReason, review: AutoRefineReview): Promise { + this._autoRefineInProgress = true; + try { + await this.refine({ instructions: autoRefineInstructions(reason, review) }); + this._pendingAutoRefineReview = undefined; + this._turnIntervalAutoRefinePending = false; + this._lastAutoRefineReviewAt = Date.now(); + this._assistantTurnsSinceAutoRefine = 0; + if (reason === "compact") { + this._compactAutoRefinePending = false; + } + } catch { + // Auto-refine is opportunistic; manual /refine remains available. + // Stamp the cooldown so a persistently failing refine doesn't retry + // (via a retained pending review) on every agent end. + this._lastAutoRefineReviewAt = Date.now(); + } finally { + this._autoRefineInProgress = false; + this._scheduleDeferredAutoRefineIfIdle(); + } + } + + private async _reviewAutoRefine(context: AutoRefineReviewRequest, signal?: AbortSignal): Promise { + if (this._autoRefineReviewer) { + return this._autoRefineReviewer(context, signal); + } + const model = this.model; + if (!model) { + return { shouldRefine: false, rationale: "No model selected." }; + } + const { apiKey, headers } = await this._getRequiredRequestAuth(model); + return reviewAutoRefine( + this.agent.state.messages, + this._loadMergedHarnessState(), + this._loadRefinementHistory(), + model, + apiKey, + context, + headers, + signal, + this.thinkingLevel, + ); + } + + /** Global harness state overlaid with this session's local state, when persisted. */ + private _loadMergedHarnessState(): HarnessState { + const localHarnessStateDir = this._localHarnessStateDir(); + return mergeHarnessStates( + loadHarnessState(getGlobalHarnessStateDir(), "global"), + localHarnessStateDir ? loadHarnessState(localHarnessStateDir, "local") : undefined, + ); + } + + private _loadRefinementHistory(): RefinementResult[] { + return mergeRefinementHistory( + loadGlobalRefinementHistory(getGlobalHarnessStateDir()), + getRefinementHistory(this.sessionManager.getEntries().filter((entry) => entry.type === "custom")), + ); + } + /** - * Refine editable harness state: prompt notes, memory, skills, and subagent specs. + * Refine editable continual harness state: prompt notes, memory, skills, and subagent specs. * The base system prompt is intentionally not editable through this path. */ - async refine(options: { instructions?: string; rollbackId?: string } = {}): Promise { + async refine( + options: { instructions?: string; rollbackId?: string; global?: boolean } = {}, + ): Promise { + while (this._refineInFlight) { + await this._refineInFlight; + } + + const run = this._refine(options); + // Refine detaches session event handling for its whole LLM pass; expose a + // settled promise so turn entry points can wait instead of losing events. + const settled = run.then( + () => undefined, + () => undefined, + ); + this._refineInFlight = settled; + try { + return await run; + } finally { + if (this._refineInFlight === settled) { + this._refineInFlight = undefined; + } + } + } + + /** + * Block a new agent turn until any in-flight refine has reattached event + * handling; otherwise the turn's messages are never persisted or rendered. + * Refine failures surface to the refine caller, not here. + */ + private async _waitForRefineIdle(): Promise { + while (this._refineInFlight) { + await this._refineInFlight; + } + } + + private async _refine( + options: { instructions?: string; rollbackId?: string; global?: boolean } = {}, + ): Promise { + if (this._disposed) { + throw new Error("Cannot refine a disposed session."); + } + const refineAbort = new AbortController(); + this._refineAbortController = refineAbort; this._disconnectFromAgent(); try { @@ -2855,39 +3258,93 @@ export class AgentSession { throw new Error(formatNoModelSelectedMessage()); } - const { apiKey, headers } = await this._getRequiredRequestAuth(this.model); - const harnessStateDir = getGlobalHarnessStateDir(); - const planningState = loadHarnessState(harnessStateDir); - // Harness state is global, so rollback history must be too: merge the global - // cross-session log with this session's entries so a refinement applied in any - // session can be rolled back from here. - const history = mergeRefinementHistory( - loadGlobalRefinementHistory(harnessStateDir), - getRefinementHistory(this.sessionManager.getEntries().filter((entry) => entry.type === "custom")), - ); + const model = this.model; + const { apiKey, headers } = await this._getRequiredRequestAuth(model); + const globalHarnessStateDir = getGlobalHarnessStateDir(); + const localHarnessStateDir = this._localHarnessStateDir(); + const requestedScope = options.global ? "global" : "local"; + if (!options.rollbackId && requestedScope === "local" && !localHarnessStateDir) { + throw new Error("Local harness refinement requires a persisted session; use global refinement instead."); + } + const planningState = + requestedScope === "global" + ? loadHarnessState(globalHarnessStateDir, "global") + : this._loadMergedHarnessState(); + const history = this._loadRefinementHistory(); + const rollbackTarget = options.rollbackId ? history.find((item) => item.id === options.rollbackId) : undefined; const plan = await planRefinement( this.agent.state.messages, planningState, history, - this.model, + model, apiKey, options, headers, - undefined, + refineAbort.signal, this.thinkingLevel, ); - // Re-read the shared state immediately before applying so concurrent kernel - // (`rlm.harness`) or cross-session writes during the LLM pass are not clobbered. - const state = loadHarnessState(harnessStateDir); - const result = applyRefinementProposal(state, plan.proposal, { id: plan.id, rollbackOf: plan.rollbackOf }); - result.harnessStatePath = saveHarnessState(harnessStateDir, state); - appendGlobalRefinement(harnessStateDir, result); + if (this._disposed || refineAbort.signal.aborted) { + throw new Error("Refinement cancelled because the session was disposed."); + } + let targetScope = plan.rollbackScope ?? requestedScope; + let targetHarnessStateDir = targetScope === "global" ? globalHarnessStateDir : localHarnessStateDir; + if (targetScope === "local" && rollbackTarget?.harnessStatePath) { + if (!existsSync(rollbackTarget.harnessStatePath)) { + throw new Error( + `Local refinement ${rollbackTarget.id} state file not found: ${rollbackTarget.harnessStatePath}`, + ); + } + targetHarnessStateDir = dirname(rollbackTarget.harnessStatePath); + // Legacy records predate scope fields and default to "local" but may point + // at the global store; honor the recorded path so its entries stay global. + if (resolve(targetHarnessStateDir) === resolve(globalHarnessStateDir)) { + targetScope = "global"; + } + } + if (!targetHarnessStateDir) { + throw new Error("Local harness refinement requires a persisted session; use global refinement instead."); + } + // Re-read the target state immediately before applying so concurrent kernel + // (`rlm.harness`) writes during the LLM pass are not clobbered. + const state = loadHarnessState(targetHarnessStateDir, targetScope); + const proposal = { + ...plan.proposal, + edits: plan.proposal.edits.map((edit) => { + const localPrefix = "local:"; + const globalPrefix = "global:"; + return { + ...edit, + id: edit.id?.startsWith(localPrefix) + ? edit.id.slice(localPrefix.length) + : edit.id?.startsWith(globalPrefix) + ? edit.id.slice(globalPrefix.length) + : edit.id, + }; + }), + }; + if (this._disposed || refineAbort.signal.aborted) { + throw new Error("Refinement cancelled because the session was disposed."); + } + const result = applyRefinementProposal(state, proposal, { + id: plan.id, + rollbackOf: plan.rollbackOf, + scope: targetScope, + }); + result.harnessStatePath = saveHarnessState(targetHarnessStateDir, state); + if (targetScope === "global") { + appendGlobalRefinement(globalHarnessStateDir, result); + } this.sessionManager.appendCustomEntry("prime-agent.refinement", result); this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); this.agent.state.systemPrompt = this._baseSystemPrompt; return result; } finally { - this._reconnectToAgent(); + if (this._refineAbortController === refineAbort) { + this._refineAbortController = undefined; + } + if (!this._disposed) { + this._reconnectToAgent(); + } } } @@ -3142,6 +3599,8 @@ export class AgentSession { details, }; this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); + const hasQueuedMessages = this.agent.hasQueuedMessages(); + const willContinueAfterCompaction = willRetry || shouldContinueAfterThreshold || hasQueuedMessages; if (willRetry) { const messages = this.agent.state.messages; @@ -3150,16 +3609,16 @@ export class AgentSession { this.agent.state.messages = messages.slice(0, -1); } - setTimeout(() => { - this.agent.continue().catch(() => {}); - }, 100); + this._schedulePostCompactionContinue(); + this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); return true; - } else if (shouldContinueAfterThreshold || this.agent.hasQueuedMessages()) { + } else if (shouldContinueAfterThreshold || hasQueuedMessages) { // Threshold compaction can intentionally stop a tool loop between turns. // Queued follow-up/steering/custom messages can also be waiting. - setTimeout(() => { - this.agent.continue().catch(() => {}); - }, 100); + this._schedulePostCompactionContinue(); + this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); + } else { + this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); } return false; } catch (error) { @@ -3657,11 +4116,15 @@ export class AgentSession { const env: Record = { RLM_DEPTH: String(this._rlmDepth), RLM_MAX_DEPTH: String(this._rlmMaxDepth), - RLM_HARNESS_STATE_DIR: getGlobalHarnessStateDir(), + RLM_GLOBAL_HARNESS_STATE_DIR: getGlobalHarnessStateDir(), }; const rlmSessionDir = this._ensureRlmSessionDir(); if (rlmSessionDir) { env.RLM_SESSION_DIR = rlmSessionDir; + // Keep kernel writes and host reads (system prompt, review, /refine) on + // the same local harness path. Subagents prefer their own artifact dir; + // ephemeral sessions fall back to the RLM session dir once it exists. + env.RLM_HARNESS_STATE_DIR = this._localHarnessStateDir() ?? getLocalHarnessStateDir(rlmSessionDir)!; } this._addWebsearchKeyEnv(env); return env; @@ -4603,6 +5066,10 @@ export class AgentSession { throw new Error(`Entry ${targetId} not found`); } + // Do not switch branches while /refine has detached event handling and is + // about to persist harness/session entries for the current branch. + await this._invalidatePendingAutoRefineForBranchChange(); + // Collect entries to summarize (from old leaf to common ancestor) const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary( this.sessionManager, diff --git a/packages/coding-agent/src/core/kernel/bootstrap.ts b/packages/coding-agent/src/core/kernel/bootstrap.ts index 9728a8d782..cc40abbbc3 100644 --- a/packages/coding-agent/src/core/kernel/bootstrap.ts +++ b/packages/coding-agent/src/core/kernel/bootstrap.ts @@ -51,7 +51,7 @@ const REQUIRED_HARNESS_METHODS = [ "delete_prompt_note", "record_refinement", ]; -const RUNTIME_READY_CHECK = `import inspect; import rlm; from rlm import McpIntegration; from rlm.harness import HarnessEntry; _harness_methods = ${JSON.stringify(REQUIRED_HARNESS_METHODS)}; assert hasattr(rlm, 'run'); assert callable(rlm); assert hasattr(rlm, 'rlm'); assert callable(rlm.rlm); assert callable(rlm.host_request); assert hasattr(rlm, 'harness'); assert hasattr(rlm, 'get_harness_state'); assert hasattr(rlm.rlm, 'harness'); assert hasattr(rlm.rlm, 'get_harness_state'); assert all(callable(getattr(_harness, _method, None)) for _harness in (rlm.harness, rlm.rlm.harness) for _method in _harness_methods); assert 'reference' in HarnessEntry.__dataclass_fields__; assert 'reference' in inspect.signature(rlm.harness.create_skill).parameters; assert 'reference' in inspect.signature(rlm.harness.update_skill).parameters; assert not hasattr(rlm, 'background'); assert not hasattr(rlm.rlm, 'background')`; +const RUNTIME_READY_CHECK = `import inspect; import rlm; from rlm import McpIntegration; from rlm.harness import HarnessEntry; _harness_methods = ${JSON.stringify(REQUIRED_HARNESS_METHODS)}; assert hasattr(rlm, 'run'); assert callable(rlm); assert hasattr(rlm, 'rlm'); assert callable(rlm.rlm); assert callable(rlm.host_request); assert hasattr(rlm, 'harness'); assert hasattr(rlm, 'get_harness_state'); assert hasattr(rlm.rlm, 'harness'); assert hasattr(rlm.rlm, 'get_harness_state'); assert all(callable(getattr(_harness, _method, None)) for _harness in (rlm.harness, rlm.rlm.harness) for _method in _harness_methods); assert 'reference' in HarnessEntry.__dataclass_fields__; assert 'scope' in HarnessEntry.__dataclass_fields__; assert 'reference' in inspect.signature(rlm.harness.create_skill).parameters; assert 'reference' in inspect.signature(rlm.harness.update_skill).parameters; assert 'global_' in inspect.signature(rlm.harness.create_memory).parameters; assert 'global_' in inspect.signature(rlm.get_harness_state).parameters; assert not hasattr(rlm, 'background'); assert not hasattr(rlm.rlm, 'background')`; const BOOTSTRAP_VERSION_FILE = ".bootstrap-version"; const BOOTSTRAP_LOCK_NAME = ".bootstrap.lock"; const BOOTSTRAP_LOCK_RETRY_MS = 100; diff --git a/packages/coding-agent/src/core/prompts/rlm.ts b/packages/coding-agent/src/core/prompts/rlm.ts index 4ea0afd789..0dcc86583d 100644 --- a/packages/coding-agent/src/core/prompts/rlm.ts +++ b/packages/coding-agent/src/core/prompts/rlm.ts @@ -24,11 +24,13 @@ const IPYTHON_CONTROL_PROMPT = [ "", "Python state in the kernel, by contrast, persists across cells: named variables, helper functions, classes, imports, notes, parsed outputs, and helper data structures all remain available in every later turn. Tool calls are themselves Python `await` expressions, so their return values can be bound to variables and composed into program logic just like any other call.", "", - "Global continual harness state is available as `rlm.harness` and `rlm.get_harness_state()`. Use it to record reset-free improvements to prompt notes, memory, reusable skills, and subagent specs that should persist across Prime Agent sessions. Use explicit CRUD calls such as `rlm.harness.create_memory(...)`, `rlm.harness.update_memory(...)`, `rlm.harness.delete_memory(...)`, `rlm.harness.create_skill(...)`, `rlm.harness.update_skill(...)`, `rlm.harness.delete_skill(...)`, `rlm.harness.create_subagent(...)`, `rlm.harness.update_subagent(...)`, `rlm.harness.delete_subagent(...)`, `rlm.harness.create_prompt_note(...)`, `rlm.harness.update_prompt_note(...)`, `rlm.harness.delete_prompt_note(...)`, plus `rlm.harness.record_refinement(...)` and `rlm.harness.overview()`.", + "Continual harness state is available as `rlm.harness` and `rlm.get_harness_state()`. CRUD calls are local to this Prime Agent session by default: `rlm.harness.create_memory(...)`, `rlm.harness.update_memory(...)`, `rlm.harness.delete_memory(...)`, `rlm.harness.create_skill(...)`, `rlm.harness.update_skill(...)`, `rlm.harness.delete_skill(...)`, `rlm.harness.create_subagent(...)`, `rlm.harness.update_subagent(...)`, `rlm.harness.delete_subagent(...)`, `rlm.harness.create_prompt_note(...)`, `rlm.harness.update_prompt_note(...)`, `rlm.harness.delete_prompt_note(...)`, plus `rlm.harness.record_refinement(...)` and `rlm.harness.overview()`. Use `global_=True` only for stable cross-session lessons; Python reserves `global`, so literal `global=True` is invalid syntax.", "", - "RLM-native call contract for refined entries: installed Python skills are called from IPython as `await (...)` with keyword arguments, or as ` ...` from shell when a CLI exists. Harness skill entries are Python REPL skills with an explicit Python `reference` and `arguments` contract. Harness subagent entries are reusable delegation specs; invoke them by turning the spec into a concise task prompt and calling `await rlm('sub-task')`, or `await asyncio.gather(rlm('task1'), rlm('task2'))` for independent parallel subagents. Do not invent non-native wrappers such as `call_skill(...)`, `run_subagent(...)`, or named subagent registries.", + "Terminology: continual harness names the persisted prompt, memory, skill, and subagent layer; RLM names the runtime, IPython kernel, and native call interface exposed to the model.", "", - "Treat harness refinement as a small, evidence-backed update after observing a repeated failure or reusable tactic: diagnose the issue, update the smallest relevant harness component, validate on the next action, then record the outcome. Do not rewrite the whole harness when a focused memory, skill, prompt note, or subagent spec is enough.", + "RLM-native call contract for refined continual harness entries: installed Python skills are called from IPython as `await (...)` with keyword arguments, or as ` ...` from shell when a CLI exists. Continual harness skill entries are Python REPL skills with an explicit Python `reference` and `arguments` contract. Continual harness subagent entries are reusable delegation specs; invoke them by turning the spec into a concise task prompt and calling `await rlm('sub-task')`, or `await asyncio.gather(rlm('task1'), rlm('task2'))` for independent parallel subagents. Do not invent non-native wrappers such as `call_skill(...)`, `run_subagent(...)`, or named subagent registries.", + "", + "Treat continual harness refinement as a small, evidence-backed update after observing a repeated failure or reusable tactic: diagnose the issue, update the smallest relevant continual harness component, validate on the next action, then record the outcome. Do not rewrite the whole continual harness when a focused memory, skill, prompt note, or subagent spec is enough.", ].join("\n"); export function buildRlmPrompt(options: RlmPromptOptions): string { diff --git a/packages/coding-agent/src/core/refinement/refinement.ts b/packages/coding-agent/src/core/refinement/refinement.ts index ac8b4f459c..3791eb5131 100644 --- a/packages/coding-agent/src/core/refinement/refinement.ts +++ b/packages/coding-agent/src/core/refinement/refinement.ts @@ -17,6 +17,7 @@ const DEFAULT_OVERVIEW_CONTENT_LIMIT = 180; export type RefinementKind = "prompt" | "memory" | "skill" | "subagent"; export type RefinementAction = "create" | "update" | "delete"; +export type HarnessScope = "local" | "global"; export interface HarnessEntry { id: string; @@ -24,6 +25,7 @@ export interface HarnessEntry { title: string; content: string; path: string; + scope?: HarnessScope; reference: Record; arguments: Record; metadata: Record; @@ -84,26 +86,55 @@ export interface RefinementResult { appliedEdits: AppliedRefinementEdit[]; harnessStatePath: string; rollbackOf?: string; + scope?: HarnessScope; } export interface RefineOptions { instructions?: string; rollbackId?: string; + global?: boolean; } -const REFINEMENT_SYSTEM_PROMPT = `You are Prime Agent's /refine subsystem. +export type AutoRefineReason = "turn_interval" | "compact"; -Your job is to improve the editable harness state from the current trajectory. +export interface AutoRefineReviewContext { + reason: AutoRefineReason; + turnsSinceLastReview: number; +} + +export interface AutoRefineReview { + shouldRefine: boolean; + rationale: string; + instructions?: string; +} + +const REFINEMENT_SYSTEM_PROMPT = `You are Prime Agent's /refine continual harness subsystem. + +Your job is to improve the editable continual harness state from the current trajectory. This is similar in spirit to context compaction, but instead of summarizing the conversation you emit precise Create, Update, or Delete edits to reusable state. +The continual harness is the persistent, editable set of prompt notes, memories, +skills, and subagent specs that lets Prime Agent improve reusable behavior +outside the token history. +Use "continual harness" for that persistent artifact layer; keep "RLM" for the +runtime, IPython kernel, and native call interface that executes those artifacts. -Editable components: +Continual harness components: - prompt: supplemental prompt notes only. The base system prompt is immutable and MUST NOT be rewritten. - memory: durable facts, decisions, failures, preferences, and outcomes. - skill: installed Python REPL skill. Skill create/update edits MUST include a \`reference\` object with \`{"type":"python"}\`, a Python import, and a callable or call pattern; they also MUST include an \`arguments\` object describing accepted inputs, required fields, defaults, and constraints. Use \`{}\` for \`arguments\` only when the Python callable truly needs no external inputs. Include the RLM-native call form \`await (...)\`. - subagent: reusable delegation specs, including purpose, instructions, and when to invoke. Include the RLM-native call form: create a concise task prompt and call \`await rlm("sub-task")\`; for independent parallel subagents use \`await asyncio.gather(rlm("task1"), rlm("task2"))\`. Do not invent wrappers like \`run_subagent(...)\`. -Use the trajectory, current harness state, and prior refinement history. Prefer +Scope and persistence policy: +- The default editable continual harness store is local to the current Prime Agent session. Use it for session-specific progress, active task state, current-run coordination notes, temporary blockers, and project facts that should not affect other sessions. +- A caller may explicitly request global refinement. Global edits must be stable cross-session lessons, durable user preferences, reusable skills/subagents, or tool/environment facts that should affect future sessions. +- Entry ids in the harness overview may carry a display-only \`local:\` or \`global:\` prefix. Always use the bare id (no prefix) in edits. +- All edits in one refinement apply only to the requested scope's store. During a local refinement, global entries are read-only context: never propose update or delete edits for them; create a local entry instead when a session-specific override is genuinely needed. +- Project/workspace-specific lessons may be persisted globally only when the title, path, or content explicitly names the project/workspace and the lesson is likely to be reused in future sessions for that project. Prefer local edits when the lesson only belongs in the current conversation. +- Use memory for declarative facts and preferences, skill for repeatable procedures exposed as Python calls, prompt for narrow behavioral policy addenda, and subagent for reusable delegation roles. +- When an edit is persisted, include metadata such as \`{"scope":"local"}\` or \`{"scope":"global"}\` when that helps future review understand the intended blast radius. + +Use the trajectory, current continual harness state, and prior refinement history. Prefer small evidence-backed edits. If prior refinements caused issues, rollback or replace the faulty editable entries. Never edit source files directly. Output JSON only with this exact shape: @@ -128,6 +159,18 @@ JSON only with this exact shape: ] }`; +const AUTO_REFINE_REVIEW_SYSTEM_PROMPT = `You are Prime Agent's automatic /refine review gate. + +Decide whether this checkpoint should run /refine. Auto /refine writes local continual harness state by default, so approve when the trajectory contains evidence useful to this session's future turns. +Reject one-off noise, unsupported hypotheses, and transient tool outputs. Ask for global refinement only for durable cross-session lessons or explicitly project-qualified lessons likely to be reused in future sessions. + +Return JSON only: +{ + "shouldRefine": true|false, + "rationale": "short reason", + "instructions": "optional concise instructions for /refine if shouldRefine is true" +}`; + function now(): string { return new Date().toISOString(); } @@ -166,15 +209,46 @@ function objectRecord(value: unknown): Record | undefined { return value as Record; } +function normalizeHarnessScope(value: unknown, fallback: HarnessScope): HarnessScope { + return value === "global" || value === "local" ? value : fallback; +} + +function inferRefinementResultScope(result: RefinementResult): HarnessScope | undefined { + if (result.scope) { + return result.scope; + } + + const scopes = new Set(); + for (const edit of result.appliedEdits) { + const scope = edit.after?.scope ?? edit.before?.scope; + if (scope) { + scopes.add(scope); + } + } + return scopes.size === 1 ? [...scopes][0] : undefined; +} + +function withDefaultRefinementScope(result: RefinementResult, scope: HarnessScope): RefinementResult { + const inferred = inferRefinementResultScope(result); + return { ...result, scope: inferred ?? scope }; +} + export function getGlobalHarnessStateDir(agentDir: string = getAgentDir()): string { return join(agentDir, HARNESS_STATE_DIR_NAME); } +export function getLocalHarnessStateDir(sessionArtifactDir: string | undefined): string | undefined { + return sessionArtifactDir ? join(sessionArtifactDir, HARNESS_STATE_DIR_NAME) : undefined; +} + export function getHarnessStatePath(harnessStateDir: string = getGlobalHarnessStateDir()): string { return join(harnessStateDir, "harness_state.json"); } -export function loadHarnessState(harnessStateDir: string = getGlobalHarnessStateDir()): HarnessState { +export function loadHarnessState( + harnessStateDir: string = getGlobalHarnessStateDir(), + scope: HarnessScope = "global", +): HarnessState { const statePath = getHarnessStatePath(harnessStateDir); if (!existsSync(statePath)) { return emptyHarnessState(); @@ -202,6 +276,7 @@ export function loadHarnessState(harnessStateDir: string = getGlobalHarnessState if (!entry) continue; state.entries[kind][id] = { ...(entry as unknown as HarnessEntry), + scope: normalizeHarnessScope(entry.scope, scope), reference: objectRecord(entry.reference) ?? {}, arguments: objectRecord(entry.arguments) ?? {}, metadata: objectRecord(entry.metadata) ?? {}, @@ -215,6 +290,25 @@ export function loadHarnessState(harnessStateDir: string = getGlobalHarnessState return state; } +export function mergeHarnessStates(globalState: HarnessState, localState?: HarnessState): HarnessState { + const merged = emptyHarnessState(); + merged.schema = Math.max(globalState.schema, localState?.schema ?? 1); + for (const kind of Object.keys(merged.entries) as RefinementKind[]) { + for (const [id, entry] of Object.entries(globalState.entries[kind])) { + const cloned = cloneEntry(entry)!; + merged.entries[kind][id] = { ...cloned, scope: normalizeHarnessScope(cloned.scope, "global") }; + } + for (const [id, entry] of Object.entries(localState?.entries[kind] ?? {})) { + const cloned = cloneEntry(entry)!; + const scopedEntry = { ...cloned, scope: normalizeHarnessScope(cloned.scope, "local") }; + const mergedId = merged.entries[kind][id] ? `${scopedEntry.scope}:${id}` : id; + merged.entries[kind][mergedId] = scopedEntry; + } + } + merged.refinements = [...globalState.refinements, ...(localState?.refinements ?? [])]; + return merged; +} + export function saveHarnessState(harnessStateDir: string, state: HarnessState): string { const statePath = getHarnessStatePath(harnessStateDir); mkdirSync(harnessStateDir, { recursive: true }); @@ -231,10 +325,9 @@ function isRefinementResult(data: unknown): data is RefinementResult { } /** - * Append a refinement result to the global, cross-session history log. The harness - * state itself is global, so rollback evidence must also be global; relying only on - * per-session JSONL entries makes a refinement applied in one session impossible to - * roll back from another. + * Append a global-scope refinement to the cross-session history log so it can be + * rolled back from any session. Local-scope refinements are recorded only in the + * session JSONL and roll back via their recorded harnessStatePath. */ export function appendGlobalRefinement(harnessStateDir: string, result: RefinementResult): string { const historyPath = getRefinementHistoryPath(harnessStateDir); @@ -255,7 +348,7 @@ export function loadGlobalRefinementHistory(harnessStateDir: string = getGlobalH try { const parsed = JSON.parse(trimmed); if (isRefinementResult(parsed)) { - results.push(parsed); + results.push(withDefaultRefinementScope(parsed, "global")); } } catch { // Skip malformed lines so a single bad append cannot break rollback. @@ -277,7 +370,8 @@ export function mergeRefinementHistory( byId.set(result.id, result); } for (const result of session) { - byId.set(result.id, result); + const existing = byId.get(result.id); + byId.set(result.id, result.scope || !existing?.scope ? result : { ...result, scope: existing.scope }); } return [...byId.values()]; } @@ -302,14 +396,16 @@ export function formatHarnessStateForPrompt( const maxRefinements = options.maxRefinements ?? DEFAULT_OVERVIEW_REFINEMENT_LIMIT; const maxContentLength = options.maxContentLength ?? DEFAULT_OVERVIEW_CONTENT_LIMIT; const lines = [ - "# Global Harness State", + "# Continual Harness State", "", - "Persistent harness state is global by default and should influence this session without requiring a tool call.", - "Use these prompt notes, memories, skills, and subagent specs when they are relevant. The base system prompt is immutable; prompt entries below are supplemental notes only.", + "Local continual harness entries belong to this Prime Agent session. Global continual harness entries persist across Prime Agent sessions.", + "The continual harness entries below are compact summaries, not full descriptions. Use them as routing/context hints; inspect or refine the underlying continual harness entry only when detail matters.", + "Default to local continual harness refinement for current task progress, temporary blockers, and session coordination. Use global continual harness refinement only for stable cross-session lessons, durable user preferences, reusable skills/subagents, or explicitly project-qualified facts.", + "Use these continual harness prompt notes, memories, skills, and subagent specs when they are relevant. The base system prompt is immutable; prompt entries below are supplemental notes only.", "", - "When to call `/refine`: after a repeated failure, a reusable tactic emerges, a user corrects behavior that should persist, validation shows a harness entry is wrong, or a skill/subagent/memory/prompt note should be created, updated, deleted, or rolled back. Keep `/refine` edits small and evidence-backed.", + "When to call `/refine`: after a repeated failure, a reusable tactic emerges, a user corrects behavior that should persist locally or globally, validation shows a continual harness entry is wrong, or a skill/subagent/memory/prompt note should be created, updated, deleted, or rolled back. Keep `/refine` continual harness edits small and evidence-backed.", "", - "Call contract: use installed Python skills as `await (...)` in IPython, or ` ...` in shell when a CLI exists. Harness skill entries are Python REPL skills with an explicit Python `reference` and `arguments` contract. Harness subagent entries are invoked by composing a concise task prompt and calling `await rlm('sub-task')`; use `await asyncio.gather(rlm('task1'), rlm('task2'))` for independent parallel subagents. Do not invent wrappers such as `call_skill(...)`, `run_subagent(...)`, or named subagent registries.", + "Call contract: use installed Python skills as `await (...)` in IPython, or ` ...` in shell when a CLI exists. Continual harness skill entries are Python REPL skills with an explicit Python `reference` and `arguments` contract. Continual harness subagent entries are invoked by composing a concise task prompt and calling `await rlm('sub-task')`; use `await asyncio.gather(rlm('task1'), rlm('task2'))` for independent parallel subagents. Do not invent wrappers such as `call_skill(...)`, `run_subagent(...)`, or named subagent registries.", "", ]; @@ -330,7 +426,7 @@ export function formatHarnessStateForPrompt( ? ` ref=${compactText(JSON.stringify(entry.reference), maxContentLength)}` : ""; lines.push( - `- [${entry.id}] ${entry.title} (${entry.path}, v${entry.version})${referenceText}${argumentsText}: ${compactText( + `- [${entry.scope ?? "global"}:${entry.id}] ${entry.title} (${entry.path}, v${entry.version})${referenceText}${argumentsText}: ${compactText( entry.content, maxContentLength, )}`, @@ -377,7 +473,7 @@ function overviewForPrompt(state: HarnessState): string { ? ` ref=${JSON.stringify(entry.reference).slice(0, 240)}` : ""; lines.push( - `- [${entry.id}] ${entry.title} (${entry.path}, v${entry.version})${referenceText}${argumentsText}: ${content}`, + `- [${entry.scope ?? "global"}:${entry.id}] ${entry.title} (${entry.path}, v${entry.version})${referenceText}${argumentsText}: ${content}`, ); } if (entries.length > 40) { @@ -428,7 +524,7 @@ function parseProposal(text: string): RefinementProposal { const record = value as Record; const edits = Array.isArray(record.edits) ? record.edits : []; return { - summary: typeof record.summary === "string" ? record.summary : "Refined harness state", + summary: typeof record.summary === "string" ? record.summary : "Refined continual harness state", rationale: typeof record.rationale === "string" ? record.rationale : "", expectedOutcome: typeof record.expectedOutcome === "string" ? record.expectedOutcome : "", edits: edits @@ -497,7 +593,7 @@ function validateEdit(edit: RefinementEdit, computedId?: string): string | undef export function applyRefinementProposal( state: HarnessState, proposal: RefinementProposal, - options: { id: string; rollbackOf?: string }, + options: { id: string; rollbackOf?: string; scope?: HarnessScope }, ): RefinementResult { const appliedEdits: AppliedRefinementEdit[] = []; for (const edit of proposal.edits) { @@ -537,6 +633,7 @@ export function applyRefinementProposal( title: edit.title ?? before?.title ?? id, content: edit.content ?? before?.content ?? "", path: edit.path ?? before?.path ?? "general", + scope: before?.scope ?? options.scope ?? "local", reference: edit.reference ?? before?.reference ?? {}, arguments: edit.arguments ?? before?.arguments ?? {}, metadata: edit.metadata ?? before?.metadata ?? {}, @@ -567,6 +664,7 @@ export function applyRefinementProposal( appliedEdits, harnessStatePath: "", rollbackOf: options.rollbackOf, + scope: options.scope, }; } @@ -598,7 +696,7 @@ function rollbackProposal(target: RefinementResult): RefinementProposal { } return { summary: `Rollback refinement ${target.id}`, - rationale: `Restores harness state snapshots from refinement ${target.id}.`, + rationale: `Restores continual harness state snapshots from refinement ${target.id}.`, expectedOutcome: "Faulty refinement edits are reverted.", edits, }; @@ -617,6 +715,7 @@ export interface RefinementPlan { proposal: RefinementProposal; id: string; rollbackOf?: string; + rollbackScope?: HarnessScope; } /** @@ -646,14 +745,24 @@ export async function planRefinement( if (!target) { throw new Error(`Refinement ${options.rollbackId} not found`); } - return { proposal: rollbackProposal(target), id, rollbackOf: target.id }; + const fallbackScope: HarnessScope = options.global ? "global" : "local"; + return { + proposal: rollbackProposal(target), + id, + rollbackOf: target.id, + rollbackScope: inferRefinementResultScope(target) ?? fallbackScope, + }; } const conversationText = serializeConversation(convertToLlm(messages)).slice(-80_000); + const scopeInstruction = options.global + ? "Requested refinement scope: global. Only propose stable cross-session continual harness edits, durable user preferences, reusable skills/subagents, or explicitly project-qualified facts that should affect future Prime Agent sessions. Do not persist session-only progress, temporary blockers, or current-run coordination globally." + : "Requested refinement scope: local. Prefer local continual harness edits for current task progress, temporary blockers, current-run coordination, and project facts that are not clearly reusable across Prime Agent sessions. Global entries in the overview are read-only context: do not propose update or delete edits for them; create a local entry instead if an override is needed."; const userPrompt = [ `\n${overviewForPrompt(state)}\n`, `\n${historyForPrompt(history)}\n`, `\n${conversationText}\n`, + `\n${scopeInstruction}\n`, options.instructions ? `\n${options.instructions}\n` : "", "Return only JSON edits. If no useful edit is justified, return an empty edits array with a rationale.", ] @@ -686,6 +795,67 @@ export async function planRefinement( return { proposal: parseProposal(text), id }; } +function parseAutoRefineReview(text: string): AutoRefineReview { + const value = extractJsonObject(text); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Auto-refine review JSON must be an object"); + } + const record = value as Record; + return { + shouldRefine: record.shouldRefine === true, + rationale: typeof record.rationale === "string" ? record.rationale : "No rationale provided.", + instructions: typeof record.instructions === "string" ? record.instructions : undefined, + }; +} + +export async function reviewAutoRefine( + messages: AgentMessage[], + state: HarnessState, + history: RefinementResult[], + model: Model, + apiKey: string, + context: AutoRefineReviewContext, + headers?: Record, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, +): Promise { + const conversationText = serializeConversation(convertToLlm(messages)).slice(-40_000); + const userPrompt = [ + ` +${context.reason}; ${context.turnsSinceLastReview} assistant turns since last auto-refine review +`, + ` +${overviewForPrompt(state)} +`, + ` +${historyForPrompt(history)} +`, + ` +${conversationText} +`, + "Return shouldRefine=true when the trajectory contains evidence useful to this session's future turns. Prefer local harness edits for current task progress, temporary blockers, and current-run coordination. Ask for global refinement only for durable cross-session lessons or explicitly project-qualified facts likely to be reused in future sessions.", + ].join("\n\n"); + // Auto-refine review requires parseable JSON. Keep it non-reasoning so + // reasoning-capable models use final text budget for the JSON object. + void thinkingLevel; + const response = await completeSimple( + model, + { + systemPrompt: AUTO_REFINE_REVIEW_SYSTEM_PROMPT, + messages: [{ role: "user", content: [{ type: "text", text: userPrompt }], timestamp: Date.now() }], + }, + { maxTokens: 1024, signal, apiKey, headers }, + ); + if (response.stopReason === "error") { + throw new Error(`Auto-refine review failed: ${response.errorMessage || "Unknown error"}`); + } + const text = response.content + .filter((content): content is { type: "text"; text: string } => content.type === "text") + .map((content) => content.text) + .join("\n"); + return parseAutoRefineReview(text); +} + export async function refineHarness( messages: AgentMessage[], state: HarnessState, @@ -698,5 +868,9 @@ export async function refineHarness( thinkingLevel?: ThinkingLevel, ): Promise { const plan = await planRefinement(messages, state, history, model, apiKey, options, headers, signal, thinkingLevel); - return applyRefinementProposal(state, plan.proposal, { id: plan.id, rollbackOf: plan.rollbackOf }); + return applyRefinementProposal(state, plan.proposal, { + id: plan.id, + rollbackOf: plan.rollbackOf, + scope: plan.rollbackScope ?? (options.global ? "global" : "local"), + }); } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index cf51456ab8..c925b486e1 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -18,6 +18,13 @@ export interface BranchSummarySettings { skipPrompt?: boolean; // default: false - when true, skips "Summarize branch?" prompt and defaults to no summary } +export interface AutoRefineSettings { + enabled?: boolean; // default: false + turnInterval?: number; // default: 25 assistant turns + compact?: boolean; // default: true + cooldownMs?: number; // default: 20 minutes +} + export interface ProviderRetrySettings { timeoutMs?: number; // SDK/provider request timeout in milliseconds maxRetries?: number; // SDK/provider retry attempts @@ -120,6 +127,7 @@ export interface Settings { followUpMode?: "all" | "one-at-a-time"; theme?: string; compaction?: CompactionSettings; + autoRefine?: AutoRefineSettings; agentTraces?: AgentTracesSettings; branchSummary?: BranchSummarySettings; retry?: RetrySettings; @@ -761,6 +769,15 @@ export class SettingsManager { }; } + getAutoRefineSettings(): { enabled: boolean; turnInterval: number; compact: boolean; cooldownMs: number } { + return { + enabled: this.settings.autoRefine?.enabled ?? false, + turnInterval: Math.max(1, this.settings.autoRefine?.turnInterval ?? 25), + compact: this.settings.autoRefine?.compact ?? true, + cooldownMs: Math.max(0, this.settings.autoRefine?.cooldownMs ?? 20 * 60_000), + }; + } + getBranchSummarySettings(): { reserveTokens: number; skipPrompt: boolean } { return { reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384, diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index bfeaf212d9..5138ba2eb3 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -68,7 +68,7 @@ const CANONICAL_BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ description: "Compact the session context; optional instructions focus the summary", argumentHint: "[instructions]", }, - { name: "refine", description: "Refine editable harness prompt notes, skills, subagents, and memory" }, + { name: "refine", description: "Refine continual harness prompt notes, skills, subagents, and memory" }, { name: "goal", description: "Set or view a persistent goal; supports pause, resume, and clear", diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index bdb8e2cc66..2dd51d1917 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -533,16 +533,25 @@ export class DaemonAgentConnection implements AgentConnection { }); } - async refine(options: { instructions?: string; rollbackId?: string } = {}): Promise { - return this.requestData( - { - type: "refine", - activeSessionId: this.activeSessionId, - instructions: options.instructions, - rollbackId: options.rollbackId, - }, - DAEMON_REFINE_REQUEST_TIMEOUT_MS, - ); + async refine( + options: { instructions?: string; rollbackId?: string; global?: boolean } = {}, + ): Promise { + const command: { + type: "refine"; + activeSessionId: string; + instructions?: string; + rollbackId?: string; + global?: boolean; + } = { + type: "refine", + activeSessionId: this.activeSessionId, + instructions: options.instructions, + rollbackId: options.rollbackId, + }; + if (options.global !== undefined) { + command.global = options.global; + } + return this.requestData(command, DAEMON_REFINE_REQUEST_TIMEOUT_MS); } async abortCompaction(): Promise { diff --git a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts index 72594cc946..13f7f82b7d 100644 --- a/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/in-process-agent-connection.ts @@ -287,7 +287,9 @@ export class InProcessAgentConnection implements AgentConnection { return this.session.compact(customInstructions); } - async refine(options: { instructions?: string; rollbackId?: string } = {}): Promise { + async refine( + options: { instructions?: string; rollbackId?: string; global?: boolean } = {}, + ): Promise { return this.session.refine(options); } diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index 92620bf0b2..7ea201bf2e 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -550,7 +550,7 @@ export interface AgentConnection { setAutoCompactionEnabled(enabled: boolean): Promise; compact(customInstructions?: string): Promise; - refine(options?: { instructions?: string; rollbackId?: string }): Promise; + refine(options?: { instructions?: string; rollbackId?: string; global?: boolean }): Promise; abortCompaction(): Promise; abortBranchSummary(): Promise; abortRetry(): Promise; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 0accec4189..c26d997474 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -1236,6 +1236,7 @@ export class AgentDaemon { const result = await state.runtime.session.refine({ instructions: command.instructions, rollbackId: command.rollbackId, + global: command.global, }); return success(command.id, "refine", result); } diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index f952e38414..f53bba5160 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -210,7 +210,14 @@ export type DaemonCommand = | { id?: string; type: "set_follow_up_mode"; activeSessionId: string; mode: AgentConnectionQueueMode } | { id?: string; type: "set_auto_compaction"; activeSessionId: string; enabled: boolean } | { id?: string; type: "compact"; activeSessionId: string; customInstructions?: string } - | { id?: string; type: "refine"; activeSessionId: string; instructions?: string; rollbackId?: string } + | { + id?: string; + type: "refine"; + activeSessionId: string; + instructions?: string; + rollbackId?: string; + global?: boolean; + } | { id?: string; type: "abort_compaction"; activeSessionId: string } | { id?: string; type: "abort_branch_summary"; activeSessionId: string } | { id?: string; type: "abort_retry"; activeSessionId: string } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 088fdb65ef..be6d09fea3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -7692,9 +7692,14 @@ ${interrupt ? `| \`${interrupt}\` | Interrupt current operation |\n` : ""}| \`${ } private async handleRefineCommand(args?: string): Promise { - const trimmedArgs = args?.trim(); + let trimmedArgs = args?.trim() ?? ""; + const globalOption: { global: boolean } = { global: false }; + if (/^--global(?=\s|$)/.test(trimmedArgs)) { + globalOption.global = true; + trimmedArgs = trimmedArgs.replace(/^--global(?=\s|$)/, "").trim(); + } const rollbackPrefix = "rollback "; - let options: { instructions?: string; rollbackId?: string }; + let options: { instructions?: string; rollbackId?: string; global?: boolean }; if (trimmedArgs === "rollback") { this.showWarning("Usage: /refine rollback "); @@ -7704,7 +7709,12 @@ ${interrupt ? `| \`${interrupt}\` | Interrupt current operation |\n` : ""}| \`${ if (trimmedArgs?.startsWith(rollbackPrefix) && trimmedArgs.slice(rollbackPrefix.length).trim()) { // Rollback uses the global refinement history, not the current trajectory, // so it must work even in a fresh session with no messages yet. - options = { rollbackId: trimmedArgs.slice(rollbackPrefix.length).trim() }; + let rollbackId = trimmedArgs.slice(rollbackPrefix.length).trim(); + if (/\s--global$/.test(rollbackId)) { + globalOption.global = true; + rollbackId = rollbackId.replace(/\s--global$/, "").trim(); + } + options = { rollbackId, ...globalOption }; } else { let messageCount: number; try { @@ -7719,12 +7729,14 @@ ${interrupt ? `| \`${interrupt}\` | Interrupt current operation |\n` : ""}| \`${ this.showWarning("Nothing to refine (no trajectory yet)"); return; } - options = { instructions: args }; + options = { instructions: trimmedArgs || undefined, ...globalOption }; } this.stopWorkingLoader(); this.showStatus( - options.rollbackId ? `Rolling back refinement ${options.rollbackId}...` : "Refining harness state...", + options.rollbackId + ? `Rolling back refinement ${options.rollbackId}...` + : `Refining ${options.global ? "global" : "local"} continual harness state...`, ); try { @@ -7732,7 +7744,9 @@ ${interrupt ? `| \`${interrupt}\` | Interrupt current operation |\n` : ""}| \`${ const applied = result.appliedEdits.filter((edit) => edit.applied).length; const failed = result.appliedEdits.length - applied; const failedSuffix = failed > 0 ? `, ${failed} failed` : ""; - this.showStatus(`Refined harness state: ${applied} edit${applied === 1 ? "" : "s"} applied${failedSuffix}`); + this.showStatus( + `Refined continual harness state: ${applied} edit${applied === 1 ? "" : "s"} applied${failedSuffix}`, + ); this.showStatus(`Harness state: ${result.harnessStatePath}`); } catch (error) { this.showError(`Refinement failed: ${error instanceof Error ? error.message : String(error)}`); diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 7db0af5ba7..ffbd62ff58 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -277,12 +277,23 @@ export class RpcClient { } /** - * Refine editable harness state. + * Refine editable continual harness state. */ - async refine(options: { instructions?: string; rollbackId?: string } = {}): Promise { + async refine( + options: { instructions?: string; rollbackId?: string; global?: boolean } = {}, + ): Promise { // Refinement runs an LLM pass that routinely exceeds the default 30s response // timeout, so use the same extended window as the daemon refine path. - const response = await this.send({ type: "refine", ...options }, REFINE_REQUEST_TIMEOUT_MS); + const command = { type: "refine", instructions: options.instructions, rollbackId: options.rollbackId } as { + type: "refine"; + instructions?: string; + rollbackId?: string; + global?: boolean; + }; + if (options.global !== undefined) { + command.global = options.global; + } + const response = await this.send(command, REFINE_REQUEST_TIMEOUT_MS); return this.getData(response); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index ea22b02442..135c8e2b8e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -515,7 +515,11 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { instructions: "remember this", rollbackId: "refine_previous", }); + expect(fakeClient.requests[1]).not.toHaveProperty("global"); expect(fakeClient.requestTimeouts[0]).toBe(30000); expect(fakeClient.requestTimeouts[1]).toBe(DAEMON_REFINE_REQUEST_TIMEOUT_MS); }); diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index b997828f1b..9bf1221ebb 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; @@ -900,6 +900,7 @@ describe("AgentSession RLM session dir", () => { agentDir?: string, serperKey?: string, loadWebsearchSkill = false, + rlmSessionDir?: string, ): AgentSession { const authStorage = AuthStorage.create(join(tempDir, "auth.json")); authStorage.setRuntimeApiKey("anthropic", "test-key"); @@ -933,6 +934,7 @@ describe("AgentSession RLM session dir", () => { agentDir, modelRegistry: ModelRegistry.create(authStorage, join(tempDir, "models.json")), resourceLoader: createTestResourceLoader({ skills }), + rlmSessionDir, }); return session; } @@ -946,6 +948,8 @@ describe("AgentSession RLM session dir", () => { expect(inspectable._ensureRlmSessionDir()).toBeUndefined(); const env = inspectable._rlmKernelEnv(); expect(env.RLM_SESSION_DIR).toBeUndefined(); + expect(env.RLM_HARNESS_STATE_DIR).toBeUndefined(); + expect(env.RLM_GLOBAL_HARNESS_STATE_DIR).toBeDefined(); expect(env).toMatchObject({ RLM_DEPTH: "0" }); const after = readdirSync(tmpdir()).filter((name) => name.startsWith("prime-agent-rlm-")); @@ -961,6 +965,75 @@ describe("AgentSession RLM session dir", () => { expect(artifactDir).toBeDefined(); expect(inspectable._ensureRlmSessionDir()).toBe(artifactDir); expect(inspectable._rlmKernelEnv().RLM_SESSION_DIR).toBe(artifactDir); + expect(inspectable._rlmKernelEnv().RLM_HARNESS_STATE_DIR).toBe(join(artifactDir!, "harness")); + expect(inspectable._rlmKernelEnv().RLM_GLOBAL_HARNESS_STATE_DIR).toBeDefined(); + }); + + it("points RLM_HARNESS_STATE_DIR at the session's own artifact dir for subagent sessions", () => { + // Subagent layout: the parent assigns rlmSessionDir, but the child's own + // sessionManager persists artifacts (and reads local harness state) elsewhere. + const subDir = join(tempDir, "parent-artifact", "sub-abc12345"); + mkdirSync(subDir, { recursive: true }); + const sessionManager = SessionManager.create(tempDir, subDir); + const root = createSession(sessionManager, undefined, undefined, false, subDir); + const inspectable = root as unknown as InspectableRlmDirSession; + + const artifactDir = sessionManager.getSessionArtifactDir(); + expect(artifactDir).toBeDefined(); + expect(artifactDir).not.toBe(subDir); + const env = inspectable._rlmKernelEnv(); + expect(env.RLM_SESSION_DIR).toBe(subDir); + expect(env.RLM_HARNESS_STATE_DIR).toBe(join(artifactDir!, "harness")); + }); + + it("falls back to the rlm session dir for RLM_HARNESS_STATE_DIR without an artifact dir", () => { + const ephemeralDir = join(tempDir, "ephemeral-rlm"); + mkdirSync(ephemeralDir, { recursive: true }); + const root = createSession(SessionManager.inMemory(tempDir), undefined, undefined, false, ephemeralDir); + const env = (root as unknown as InspectableRlmDirSession)._rlmKernelEnv(); + expect(env.RLM_SESSION_DIR).toBe(ephemeralDir); + expect(env.RLM_HARNESS_STATE_DIR).toBe(join(ephemeralDir, "harness")); + }); + + it("loads the ephemeral RLM harness path into the host system prompt", () => { + const ephemeralDir = join(tempDir, "ephemeral-rlm"); + mkdirSync(join(ephemeralDir, "harness"), { recursive: true }); + writeFileSync( + join(ephemeralDir, "harness", "harness_state.json"), + JSON.stringify({ + schema: 1, + entries: { + prompt: {}, + memory: { + ephemeral_note: { + id: "ephemeral_note", + kind: "memory", + title: "Ephemeral note", + content: "Loaded from the RLM session harness path.", + path: "000", + scope: "local", + reference: {}, + arguments: {}, + metadata: {}, + source: "test", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + version: 1, + }, + }, + skill: {}, + subagent: {}, + }, + refinements: [], + }), + "utf8", + ); + const root = createSession(SessionManager.inMemory(tempDir), undefined, undefined, false, ephemeralDir); + + const prompt = root.systemPrompt; + + expect(prompt).toContain("Ephemeral note"); + expect(prompt).toContain("Loaded from the RLM session harness path."); }); it("exports the configured agentDir to the kernel so skills find auth.json", () => { diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 87ba96faa7..471c516936 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -181,6 +181,49 @@ describe("daemon mode helpers", () => { } }); + it("preserves omitted global scope on daemon refine commands", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { + defaultSessionConfig: { + agentDir: "/tmp/prime-agent-test-agent", + cwd: "/tmp", + }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const refine = vi.fn(async () => ({ + id: "refine_daemon", + appliedEdits: [], + harnessStatePath: "/tmp/harness_state.json", + })); + const state = makeState("active-1") as ActiveSessionState & { + runtime: ActiveSessionState["runtime"] & { + session: { + refine: typeof refine; + }; + }; + }; + state.runtime.session = { refine } as never; + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.sessions.set(state.activeSessionId, state); + + await internals.handleCommand(makeClient("client-1", state.activeSessionId), { + id: "command-1", + type: "refine", + activeSessionId: state.activeSessionId, + instructions: "record local lesson", + }); + + expect(refine).toHaveBeenCalledWith({ + instructions: "record local lesson", + rollbackId: undefined, + global: undefined, + }); + }); + it("defers busy heartbeat cron jobs instead of queueing a follow-up", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { defaultSessionConfig: { diff --git a/packages/coding-agent/test/interactive-mode-refine-command.test.ts b/packages/coding-agent/test/interactive-mode-refine-command.test.ts index 6619132b10..482b58648d 100644 --- a/packages/coding-agent/test/interactive-mode-refine-command.test.ts +++ b/packages/coding-agent/test/interactive-mode-refine-command.test.ts @@ -52,7 +52,88 @@ describe("InteractiveMode.handleRefineCommand", () => { // Rollback uses global history, so the empty-trajectory guard must not block it. expect(context.agentConnection.getSessionStats).not.toHaveBeenCalled(); expect(context.showWarning).not.toHaveBeenCalled(); - expect(context.agentConnection.refine).toHaveBeenCalledWith({ rollbackId: "refine_123" }); + expect(context.agentConnection.refine).toHaveBeenCalledWith({ rollbackId: "refine_123", global: false }); + }); + + test("parses --global after rollback id", async () => { + const context = { + agentConnection: { + getSessionStats: vi.fn().mockResolvedValue({ totalMessages: 0 }), + refine: vi.fn().mockResolvedValue({ appliedEdits: [], harnessStatePath: "/tmp/harness_state.json" }), + }, + stopWorkingLoader: vi.fn(), + showStatus: vi.fn(), + showWarning: vi.fn(), + showError: vi.fn(), + }; + + await handleRefineCommand.call(context, "rollback refine_123 --global"); + + expect(context.agentConnection.getSessionStats).not.toHaveBeenCalled(); + expect(context.agentConnection.refine).toHaveBeenCalledWith({ rollbackId: "refine_123", global: true }); + }); + + test("parses --global before refinement instructions", async () => { + const context = { + agentConnection: { + getSessionStats: vi.fn().mockResolvedValue({ totalMessages: 2 }), + refine: vi.fn().mockResolvedValue({ appliedEdits: [], harnessStatePath: "/tmp/harness_state.json" }), + }, + stopWorkingLoader: vi.fn(), + showStatus: vi.fn(), + showWarning: vi.fn(), + showError: vi.fn(), + }; + + await handleRefineCommand.call(context, "--global focus on validation"); + + expect(context.showStatus).toHaveBeenCalledWith("Refining global continual harness state..."); + expect(context.agentConnection.refine).toHaveBeenCalledWith({ + instructions: "focus on validation", + global: true, + }); + }); + + test("preserves trailing --global in ordinary refinement instructions", async () => { + const context = { + agentConnection: { + getSessionStats: vi.fn().mockResolvedValue({ totalMessages: 2 }), + refine: vi.fn().mockResolvedValue({ appliedEdits: [], harnessStatePath: "/tmp/harness_state.json" }), + }, + stopWorkingLoader: vi.fn(), + showStatus: vi.fn(), + showWarning: vi.fn(), + showError: vi.fn(), + }; + + await handleRefineCommand.call(context, "update docs to explain --global"); + + expect(context.showStatus).toHaveBeenCalledWith("Refining local continual harness state..."); + expect(context.agentConnection.refine).toHaveBeenCalledWith({ + instructions: "update docs to explain --global", + global: false, + }); + }); + + test("shows local scope in status for default refinement", async () => { + const context = { + agentConnection: { + getSessionStats: vi.fn().mockResolvedValue({ totalMessages: 2 }), + refine: vi.fn().mockResolvedValue({ appliedEdits: [], harnessStatePath: "/tmp/harness_state.json" }), + }, + stopWorkingLoader: vi.fn(), + showStatus: vi.fn(), + showWarning: vi.fn(), + showError: vi.fn(), + }; + + await handleRefineCommand.call(context, "focus on validation"); + + expect(context.showStatus).toHaveBeenCalledWith("Refining local continual harness state..."); + expect(context.agentConnection.refine).toHaveBeenCalledWith({ + instructions: "focus on validation", + global: false, + }); }); test("blocks plain refinement when there is no trajectory", async () => { diff --git a/packages/coding-agent/test/refinement.test.ts b/packages/coding-agent/test/refinement.test.ts index 6975eeee45..fcb3304790 100644 --- a/packages/coding-agent/test/refinement.test.ts +++ b/packages/coding-agent/test/refinement.test.ts @@ -8,13 +8,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { appendGlobalRefinement, applyRefinementProposal, + formatHarnessStateForPrompt, getGlobalHarnessStateDir, getHarnessStatePath, + getLocalHarnessStateDir, getRefinementHistory, getRefinementHistoryPath, type HarnessState, loadGlobalRefinementHistory, loadHarnessState, + mergeHarnessStates, mergeRefinementHistory, planRefinement, type RefinementAction, @@ -245,7 +248,7 @@ describe("harness refinement", () => { expect(state.refinements.at(-1)?.changes).toEqual(kinds.map((kind) => `delete ${kind}:${kind}_entry`)); }); - it("applies create, update, and delete edits to editable harness state", () => { + it("applies create, update, and delete edits to editable continual harness state", () => { const state = loadHarnessState(makeTempDir()); const first = applyRefinementProposal( state, @@ -466,9 +469,87 @@ describe("harness refinement", () => { expect(getHarnessStatePath(harnessDir)).toBe(join(agentDir, "harness", "harness_state.json")); }); + it("uses a local harness state directory under the session artifact dir", () => { + const artifactDir = makeTempDir(); + + expect(getLocalHarnessStateDir(artifactDir)).toBe(join(artifactDir, "harness")); + expect(getLocalHarnessStateDir(undefined)).toBeUndefined(); + }); + + it("merges global and local harness state without hiding colliding entries", () => { + const root = makeTempDir(); + const globalState = loadHarnessState(join(root, "global"), "global"); + const localState = loadHarnessState(join(root, "local"), "local"); + applyRefinementProposal( + globalState, + proposal("Global note", [ + { + action: "create", + kind: "memory", + id: "shared", + title: "Shared", + content: "Global content.", + }, + ]), + { id: "refine_global", scope: "global" }, + ); + applyRefinementProposal( + localState, + proposal("Local note", [ + { + action: "create", + kind: "memory", + id: "shared", + title: "Shared", + content: "Local content.", + }, + ]), + { id: "refine_local", scope: "local" }, + ); + + const merged = mergeHarnessStates(globalState, localState); + + expect(merged.entries.memory.shared.content).toBe("Global content."); + expect(merged.entries.memory.shared.scope).toBe("global"); + expect(merged.entries.memory["local:shared"]).toMatchObject({ + id: "shared", + content: "Local content.", + scope: "local", + }); + expect(Object.values(merged.entries.memory).map((entry) => `${entry.scope}:${entry.content}`)).toEqual( + expect.arrayContaining(["global:Global content.", "local:Local content."]), + ); + const promptOverview = formatHarnessStateForPrompt(merged); + expect(promptOverview).toContain("[global:shared]"); + expect(promptOverview).toContain("[local:shared]"); + expect(globalState.entries.memory.shared.scope).toBe("global"); + }); + + it("preserves entry scope stored inside the global harness file", () => { + const root = makeTempDir(); + const globalState = loadHarnessState(join(root, "global"), "global"); + applyRefinementProposal( + globalState, + proposal("Session-local note in shared file", [ + { + action: "create", + kind: "memory", + id: "session_note", + title: "Session note", + content: "Written by a local RLM harness store in a shared file.", + }, + ]), + { id: "refine_local_in_global_file", scope: "local" }, + ); + + const merged = mergeHarnessStates(globalState); + + expect(merged.entries.memory.session_note.scope).toBe("local"); + }); + it("persists harness state in the selected harness directory", () => { const dir = makeTempDir(); - const state = loadHarnessState(dir); + const state = loadHarnessState(dir, "local"); applyRefinementProposal( state, { @@ -489,10 +570,11 @@ describe("harness refinement", () => { ); const statePath = saveHarnessState(dir, state); - const reloaded = loadHarnessState(dir); + const reloaded = loadHarnessState(dir, "local"); expect(statePath.endsWith("harness_state.json")).toBe(true); expect(reloaded.entries.prompt.focused_edits.content).toBe("Prefer small harness edits."); + expect(reloaded.entries.prompt.focused_edits.scope).toBe("local"); expect(reloaded.refinements[0]).toMatchObject({ id: "refine_1", trigger: "Add prompt note", @@ -849,6 +931,20 @@ describe("harness refinement", () => { ); expect(completeSimpleMock).toHaveBeenCalledTimes(1); + expect(completeSimpleMock.mock.calls[0][1]).toMatchObject({ + systemPrompt: expect.stringContaining("The default editable continual harness store is local"), + }); + expect(completeSimpleMock.mock.calls[0][1]).toMatchObject({ + systemPrompt: expect.stringContaining("A caller may explicitly request global refinement"), + }); + expect(completeSimpleMock.mock.calls[0][1]).toMatchObject({ + systemPrompt: expect.stringContaining("Always use the bare id (no prefix) in edits"), + }); + expect(completeSimpleMock.mock.calls[0][1]).toMatchObject({ + systemPrompt: expect.stringContaining( + "During a local refinement, global entries are read-only context: never propose update or delete edits for them", + ), + }); expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ maxTokens: 4096, apiKey: "api-key", @@ -908,6 +1004,7 @@ describe("harness refinement", () => { }); expect(rollback.rollbackOf).toBe("refine_target"); + expect(rollback.scope).toBe("local"); expect(rollback.appliedEdits.map((edit) => `${edit.action} ${edit.kind}:${edit.id}`)).toEqual([ "create skill:deleted_skill", "update memory:kept_memory", @@ -965,7 +1062,71 @@ describe("global refinement history", () => { appendGlobalRefinement(dir, second); expect(historyPath).toBe(getRefinementHistoryPath(dir)); - expect(loadGlobalRefinementHistory(dir)).toEqual([first, second]); + expect(loadGlobalRefinementHistory(dir)).toEqual([ + { ...first, scope: "global" }, + { ...second, scope: "global" }, + ]); + }); + + it("defaults legacy global history results to global scope", () => { + const dir = makeTempDir(); + const legacy = sampleResult("refine_legacy_global", { scope: undefined }); + appendFileSync( + getRefinementHistoryPath(dir), + `${JSON.stringify(legacy)} +`, + "utf8", + ); + + expect(loadGlobalRefinementHistory(dir)[0]).toMatchObject({ id: "refine_legacy_global", scope: "global" }); + }); + + it("writes inferred legacy history scope back onto loaded results", () => { + const dir = makeTempDir(); + const legacy = sampleResult("refine_legacy_inferred", { + scope: undefined, + appliedEdits: [ + { + action: "create", + kind: "memory", + id: "legacy_global_memory", + title: "Legacy global memory", + content: "created globally", + applied: true, + after: { + id: "legacy_global_memory", + kind: "memory", + title: "Legacy global memory", + content: "created globally", + path: "general", + scope: "global", + reference: {}, + arguments: {}, + metadata: {}, + source: "refine", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + version: 1, + }, + }, + ], + }); + appendFileSync(getRefinementHistoryPath(dir), `${JSON.stringify(legacy)}\n`, "utf8"); + + expect(loadGlobalRefinementHistory(dir)[0]).toMatchObject({ + id: "refine_legacy_inferred", + scope: "global", + }); + }); + + it("preserves global scope when session history shadows legacy global history", () => { + const globalOld = sampleResult("refine_shared", { scope: "global", summary: "global version" }); + const sessionNew = sampleResult("refine_shared", { scope: undefined, summary: "session version" }); + + const merged = mergeRefinementHistory([globalOld], [sessionNew]); + + expect(merged).toHaveLength(1); + expect(merged[0]).toMatchObject({ id: "refine_shared", summary: "session version", scope: "global" }); }); it("skips malformed history lines without throwing", () => { @@ -976,7 +1137,7 @@ describe("global refinement history", () => { appendFileSync(getRefinementHistoryPath(dir), "not json\n", "utf8"); appendFileSync(getRefinementHistoryPath(dir), `${JSON.stringify({ id: "x" })}\n`, "utf8"); - expect(loadGlobalRefinementHistory(dir)).toEqual([valid]); + expect(loadGlobalRefinementHistory(dir)).toEqual([{ ...valid, scope: "global" }]); }); it("merges global and session history, preferring session entries by id", () => { @@ -1029,6 +1190,9 @@ describe("global refinement history", () => { // so applying must be the only thing that mutates state. expect(plan.proposal.edits).toHaveLength(1); expect(plan.id).toMatch(/^refine_/); + const userPrompt = completeSimpleMock.mock.calls[0][1].messages[0].content[0].text; + expect(userPrompt).toContain("Requested refinement scope: local"); + expect(userPrompt).toContain("Global entries in the overview are read-only context"); expect(state.entries.memory.planned_memory).toBeUndefined(); expect(state.refinements).toHaveLength(0); @@ -1037,6 +1201,33 @@ describe("global refinement history", () => { expect(state.entries.memory.planned_memory).toBeDefined(); }); + it("adds global-only scope policy when planning a global refinement", async () => { + const state = loadHarnessState(makeTempDir(), "global"); + completeSimpleMock.mockResolvedValueOnce( + assistantText( + JSON.stringify({ + summary: "No global edit", + rationale: "No durable cross-session lesson.", + expectedOutcome: "No change.", + edits: [], + }), + ), + ); + + await planRefinement( + [{ role: "user", content: "remember this only if global", timestamp: Date.now() } satisfies AgentMessage], + state, + [], + createRefineModel(false), + "api-key", + { global: true }, + ); + + const userPrompt = completeSimpleMock.mock.calls[0][1].messages[0].content[0].text; + expect(userPrompt).toContain("Requested refinement scope: global"); + expect(userPrompt).toContain("Do not persist session-only progress"); + }); + it("plans a rollback without mutating harness state", async () => { const dir = makeTempDir(); const state = loadHarnessState(dir); @@ -1053,6 +1244,7 @@ describe("global refinement history", () => { }); expect(plan.rollbackOf).toBe("refine_rollback_target"); + expect(plan.rollbackScope).toBe("local"); // The entry still exists until the proposal is applied. expect(state.entries.memory.rollback_me).toBeDefined(); applyRefinementProposal(state, plan.proposal, { id: plan.id, rollbackOf: plan.rollbackOf }); @@ -1089,6 +1281,74 @@ describe("global refinement history", () => { }); expect(rollback.rollbackOf).toBe("refine_session_a"); + expect(rollback.scope).toBe("local"); expect(sessionBState.entries.memory.session_a_memory).toBeUndefined(); }); + + it("plans rollback against the recorded global scope when --global is omitted", async () => { + const dir = makeTempDir(); + const state = loadHarnessState(dir, "global"); + const target = applyRefinementProposal( + state, + proposal("Global refinement", [ + { + action: "create", + kind: "memory", + id: "global_memory", + title: "Global memory", + content: "Created globally.", + }, + ]), + { id: "refine_global_target", scope: "global" }, + ); + expect(target.scope).toBe("global"); + + const plan = await planRefinement([], state, [target], {} as never, "api-key", { + rollbackId: "refine_global_target", + }); + + expect(plan.rollbackOf).toBe("refine_global_target"); + expect(plan.rollbackScope).toBe("global"); + const rollback = applyRefinementProposal(state, plan.proposal, { + id: plan.id, + rollbackOf: plan.rollbackOf, + scope: plan.rollbackScope, + }); + expect(rollback.scope).toBe("global"); + expect(state.entries.memory.global_memory).toBeUndefined(); + }); + + it("infers rollback scope from legacy global edits without top-level scope", async () => { + const dir = makeTempDir(); + const state = loadHarnessState(dir, "global"); + const target = applyRefinementProposal( + state, + proposal("Legacy global refinement", [ + { + action: "create", + kind: "memory", + id: "legacy_global_memory", + title: "Legacy global memory", + content: "Created globally before result.scope existed.", + }, + ]), + { id: "refine_legacy_global", scope: "global" }, + ); + const legacyTarget = { + ...target, + scope: undefined, + appliedEdits: target.appliedEdits.map((edit) => ({ + ...edit, + before: edit.before ? { ...edit.before, scope: undefined } : undefined, + after: edit.after ? { ...edit.after, scope: undefined } : undefined, + })), + }; + const legacyHistory = mergeRefinementHistory([{ ...legacyTarget, scope: "global" }], [legacyTarget]); + + const plan = await planRefinement([], state, legacyHistory, {} as never, "api-key", { + rollbackId: "refine_legacy_global", + }); + + expect(plan.rollbackScope).toBe("global"); + }); }); diff --git a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts index 07fac54d57..ed129a7e5d 100644 --- a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts +++ b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts @@ -95,6 +95,7 @@ function sleep(ms: number): Promise { function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: number; model?: Model }): { runtimeHost: AgentSessionRuntime; + session: AgentSession; cleanup: () => Promise; } { const tempDir = join(tmpdir(), `pi-rpc-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -152,6 +153,7 @@ function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: number return { runtimeHost, + session, cleanup: async () => { try { if (session.isStreaming) { @@ -170,16 +172,17 @@ function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: number async function startRpcMode(options: { withAuth: boolean; responseDelayMs: number; model?: Model }): Promise<{ lineHandler: (line: string) => void; + session: AgentSession; cleanup: () => Promise; }> { rpcIo.outputLines = []; rpcIo.lineHandler = undefined; - const { runtimeHost, cleanup } = createRuntimeHost(options); + const { runtimeHost, session, cleanup } = createRuntimeHost(options); void runRpcMode(runtimeHost); await vi.waitFor(() => expect(rpcIo.lineHandler).toBeDefined()); - return { lineHandler: rpcIo.lineHandler!, cleanup }; + return { lineHandler: rpcIo.lineHandler!, session, cleanup }; } describe("RPC prompt response semantics", () => { @@ -283,4 +286,41 @@ describe("RPC prompt response semantics", () => { await cleanup(); } }); + + it("preserves omitted global scope on RPC refine commands", async () => { + const { lineHandler, session, cleanup } = await startRpcMode({ withAuth: true, responseDelayMs: 0 }); + const refine = vi.spyOn(session, "refine").mockResolvedValue({ + id: "refine_rpc", + summary: "RPC refinement", + rationale: "Test refine scope default", + expectedOutcome: "Preserve local default", + appliedEdits: [], + harnessStatePath: "/tmp/harness_state.json", + scope: "local", + }); + + try { + lineHandler(JSON.stringify({ id: "r1", type: "refine", instructions: "record local lesson" })); + + await vi.waitFor(() => { + expect(refine).toHaveBeenCalledWith({ + instructions: "record local lesson", + rollbackId: undefined, + global: undefined, + }); + expect(parseOutputLines(rpcIo.outputLines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "r1", + type: "response", + command: "refine", + success: true, + }), + ]), + ); + }); + } finally { + await cleanup(); + } + }); }); diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index a21014894e..e92233152a 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -83,6 +83,42 @@ describe("AgentSession compaction characterization", () => { expect(harness.session.messages[0]?.role).toBe("compactionSummary"); }); + it("reschedules a pending post-compaction continuation after successful manual compaction", async () => { + vi.useFakeTimers(); + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => ({ + compaction: { + summary: "summary from extension", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: { source: "extension" }, + }, + })); + }, + ], + }); + harnesses.push(harness); + const internals = harness.session as unknown as { + _schedulePostCompactionContinue(): void; + _cancelPostCompactionContinue(): void; + _postCompactionContinuationScheduled: boolean; + }; + try { + await harness.session.prompt("one"); + await harness.session.prompt("two"); + internals._schedulePostCompactionContinue(); + + await harness.session.compact(); + + expect(internals._postCompactionContinuationScheduled).toBe(true); + } finally { + internals._cancelPostCompactionContinue(); + } + }); + it("throws when compacting without a model", async () => { const harness = await createHarness(); harnesses.push(harness); diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index a3bf6ed4c9..2f8f71a600 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1,10 +1,62 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + applyRefinementProposal, + getGlobalHarnessStateDir, + getHarnessStatePath, + getLocalHarnessStateDir, + type HarnessEntry, + loadGlobalRefinementHistory, + loadHarnessState, + type RefinementResult, + saveHarnessState, +} from "../../src/core/refinement/index.js"; import { createHarness, getAssistantTexts, getMessageText, getUserTexts, type Harness } from "./harness.js"; +type AutoRefineReason = "turn_interval" | "compact"; + +type AutoRefineInternals = { + _maybeAutoRefine(reason: AutoRefineReason): Promise; + _scheduleAutoRefine(reason: AutoRefineReason): void; + _scheduleAutoRefineAfterCompaction(willContinueAfterCompaction: boolean): void; + _scheduleAutoRefineAfterAgentEnd(): void; + _schedulePostCompactionContinue(): void; + _invalidatePendingAutoRefineForBranchChange(): Promise; + _cancelPostCompactionContinue(): void; + _assistantTurnsSinceAutoRefine: number; + _lastAutoRefineReviewAt: number; + _compactAutoRefinePending: boolean; + _turnIntervalAutoRefinePending: boolean; + _postCompactionContinuationScheduled: boolean; + _pendingAutoRefineReview?: unknown; + _autoRefineInProgress: boolean; + _autoRefineBranchVersion: number; +}; + +function emptyRefinementResult(): RefinementResult { + return { + id: "refine_test", + summary: "test refinement", + rationale: "test rationale", + expectedOutcome: "test outcome", + appliedEdits: [], + harnessStatePath: "/tmp/harness_state.json", + }; +} + +function createAutoRefineHarness(options: Parameters[0] = {}): Promise { + return createHarness({ ...options, persistSession: true }); +} + +function setAgentStreaming(harness: Harness, isStreaming: boolean): void { + (harness.session.agent.state as { isStreaming: boolean }).isStreaming = isStreaming; +} + async function createWaitingHarness( options: { tools?: AgentTool[]; @@ -66,6 +118,1220 @@ describe("AgentSession queue characterization", () => { } }); + it("auto-refine review runs after the configured turn interval", async () => { + const reviewer = vi.fn(async () => ({ + shouldRefine: true, + rationale: "durable lesson found", + instructions: "capture the durable lesson", + })); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 2, cooldownMs: 0 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const refine = vi.spyOn(harness.session, "refine").mockResolvedValue(emptyRefinementResult()); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 2; + + await internals._maybeAutoRefine("turn_interval"); + + expect(reviewer).toHaveBeenCalledWith( + { reason: "turn_interval", turnsSinceLastReview: 2 }, + expect.any(AbortSignal), + ); + expect(refine).toHaveBeenCalledWith( + expect.objectContaining({ instructions: expect.stringContaining("capture the durable lesson") }), + ); + expect(refine).toHaveBeenCalledWith( + expect.objectContaining({ instructions: expect.stringContaining("local harness entries") }), + ); + expect(refine).toHaveBeenCalledWith( + expect.objectContaining({ instructions: expect.stringContaining("Do not promote anything global") }), + ); + expect(internals._assistantTurnsSinceAutoRefine).toBe(0); + }); + + it("auto-refine compact hook does not require the turn interval", async () => { + const reviewer = vi.fn(async () => ({ shouldRefine: false, rationale: "nothing durable" })); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const refine = vi.spyOn(harness.session, "refine").mockResolvedValue(emptyRefinementResult()); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 0; + + await internals._maybeAutoRefine("compact"); + + expect(reviewer).toHaveBeenCalledWith({ reason: "compact", turnsSinceLastReview: 0 }, expect.any(AbortSignal)); + expect(refine).not.toHaveBeenCalled(); + }); + + it("falls back to turn-interval review when compact auto-refine is disabled", async () => { + const reviewer = vi.fn(async () => ({ shouldRefine: false, rationale: "nothing durable" })); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, compact: false, turnInterval: 2, cooldownMs: 0 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 2; + + await internals._maybeAutoRefine("compact"); + + expect(reviewer).toHaveBeenCalledWith( + { reason: "turn_interval", turnsSinceLastReview: 2 }, + expect.any(AbortSignal), + ); + expect(internals._compactAutoRefinePending).toBe(false); + }); + + it("declined compact review preserves an already-due turn interval", async () => { + const reviewer = vi.fn(async () => ({ shouldRefine: false, rationale: "nothing compact-specific" })); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 2, cooldownMs: 0 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 2; + const scheduleAutoRefine = vi.spyOn(internals, "_scheduleAutoRefine").mockImplementation(() => {}); + + await internals._maybeAutoRefine("compact"); + + expect(internals._assistantTurnsSinceAutoRefine).toBe(2); + expect(scheduleAutoRefine).toHaveBeenCalledWith("turn_interval"); + }); + + it("auto-refine compact hook waits for planned post-compaction continuation", async () => { + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const scheduleAutoRefine = vi.spyOn(internals, "_scheduleAutoRefine").mockImplementation(() => {}); + + internals._scheduleAutoRefineAfterCompaction(true); + + expect(internals._compactAutoRefinePending).toBe(true); + expect(scheduleAutoRefine).not.toHaveBeenCalled(); + + internals._scheduleAutoRefineAfterAgentEnd(); + + expect(internals._compactAutoRefinePending).toBe(true); + expect(scheduleAutoRefine).toHaveBeenCalledWith("compact"); + expect(scheduleAutoRefine).toHaveBeenCalledTimes(1); + }); + + it("auto-refine compact hook waits until the scheduled post-compaction continuation starts", async () => { + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const scheduleAutoRefine = vi.spyOn(internals, "_scheduleAutoRefine").mockImplementation(() => {}); + internals._compactAutoRefinePending = true; + internals._postCompactionContinuationScheduled = true; + + internals._scheduleAutoRefineAfterAgentEnd(); + + expect(scheduleAutoRefine).not.toHaveBeenCalled(); + + internals._postCompactionContinuationScheduled = false; + internals._scheduleAutoRefineAfterAgentEnd(); + + expect(scheduleAutoRefine).toHaveBeenCalledWith("compact"); + expect(scheduleAutoRefine).toHaveBeenCalledTimes(1); + }); + + it("auto-refine compact hook runs immediately when no post-compaction continuation is planned", async () => { + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const scheduleAutoRefine = vi.spyOn(internals, "_scheduleAutoRefine").mockImplementation(() => {}); + + internals._scheduleAutoRefineAfterCompaction(false); + + expect(internals._compactAutoRefinePending).toBe(false); + expect(scheduleAutoRefine).toHaveBeenCalledWith("compact"); + }); + + it("runs a turn-interval review after a concurrent compact review declines", async () => { + vi.useFakeTimers(); + let releaseCompactReview: (() => void) | undefined; + const compactReviewGate = new Promise((resolve) => { + releaseCompactReview = resolve; + }); + const reviewer = vi.fn(async ({ reason }: { reason: AutoRefineReason }) => { + if (reason === "compact") { + await compactReviewGate; + } + return { shouldRefine: false, rationale: `${reason} found nothing durable` }; + }); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 2, cooldownMs: 0 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 2; + + try { + const compactReview = internals._maybeAutoRefine("compact"); + await Promise.resolve(); + await internals._maybeAutoRefine("turn_interval"); + + expect(internals._turnIntervalAutoRefinePending).toBe(true); + + releaseCompactReview?.(); + await compactReview; + await vi.runOnlyPendingTimersAsync(); + + expect(reviewer.mock.calls.map(([context]) => context.reason)).toEqual(["compact", "turn_interval"]); + expect(internals._turnIntervalAutoRefinePending).toBe(false); + expect(internals._assistantTurnsSinceAutoRefine).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("retries a scheduled post-compaction continuation when another run starts first", async () => { + vi.useFakeTimers(); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const continueAgent = vi + .spyOn(harness.session.agent, "continue") + .mockRejectedValueOnce(new Error("Agent is already processing. Wait for completion before continuing.")) + .mockResolvedValueOnce(); + + try { + internals._schedulePostCompactionContinue(); + await vi.advanceTimersByTimeAsync(100); + + expect(continueAgent).toHaveBeenCalledTimes(1); + expect(internals._postCompactionContinuationScheduled).toBe(true); + + await vi.advanceTimersByTimeAsync(100); + + expect(continueAgent).toHaveBeenCalledTimes(2); + expect(internals._postCompactionContinuationScheduled).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels scheduled post-compaction continuation on branch changes", async () => { + vi.useFakeTimers(); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const continueAgent = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); + + try { + internals._schedulePostCompactionContinue(); + await internals._invalidatePendingAutoRefineForBranchChange(); + await vi.advanceTimersByTimeAsync(100); + + expect(continueAgent).not.toHaveBeenCalled(); + expect(internals._postCompactionContinuationScheduled).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps scheduled post-compaction continuation when manual compaction is skipped", async () => { + vi.useFakeTimers(); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + try { + internals._schedulePostCompactionContinue(); + + await expect(harness.session.compact()).rejects.toThrow("Session is too short to compact"); + + expect(internals._postCompactionContinuationScheduled).toBe(true); + } finally { + internals._cancelPostCompactionContinue(); + vi.useRealTimers(); + } + }); + + it("does not run scheduled auto-refine after branch navigation", async () => { + vi.useFakeTimers(); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const maybeAutoRefine = vi.spyOn(internals, "_maybeAutoRefine").mockResolvedValue(); + try { + internals._scheduleAutoRefine("compact"); + await internals._invalidatePendingAutoRefineForBranchChange(); + await vi.runAllTimersAsync(); + + expect(maybeAutoRefine).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("auto-refine compact hook defers an approved refine if the agent becomes active during review", async () => { + let finishReview: (() => void) | undefined; + const reviewStarted = new Promise((resolve) => { + finishReview = resolve; + }); + const reviewer = vi.fn(async () => { + await reviewStarted; + setAgentStreaming(harness, true); + return { shouldRefine: true, rationale: "durable lesson" }; + }); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const refine = vi.spyOn(harness.session, "refine").mockResolvedValue(emptyRefinementResult()); + + const autoRefinePromise = internals._maybeAutoRefine("compact"); + expect(reviewer).toHaveBeenCalledWith({ reason: "compact", turnsSinceLastReview: 0 }, expect.any(AbortSignal)); + finishReview?.(); + await autoRefinePromise; + + expect(refine).not.toHaveBeenCalled(); + expect(internals._pendingAutoRefineReview).toBeDefined(); + expect(internals._compactAutoRefinePending).toBe(false); + }); + + it("auto-refine turn interval defers an approved refine if the agent becomes active during review", async () => { + let finishReview: (() => void) | undefined; + const reviewStarted = new Promise((resolve) => { + finishReview = resolve; + }); + const reviewer = vi.fn(async () => { + await reviewStarted; + setAgentStreaming(harness, true); + return { shouldRefine: true, rationale: "durable lesson" }; + }); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 2, cooldownMs: 60_000 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 2; + const refine = vi.spyOn(harness.session, "refine").mockResolvedValue(emptyRefinementResult()); + + const autoRefinePromise = internals._maybeAutoRefine("turn_interval"); + expect(reviewer).toHaveBeenCalledWith( + { reason: "turn_interval", turnsSinceLastReview: 2 }, + expect.any(AbortSignal), + ); + finishReview?.(); + await autoRefinePromise; + + expect(refine).not.toHaveBeenCalled(); + expect(internals._pendingAutoRefineReview).toBeDefined(); + + setAgentStreaming(harness, false); + await internals._maybeAutoRefine("turn_interval"); + + expect(reviewer).toHaveBeenCalledTimes(1); + expect(refine).toHaveBeenCalledWith( + expect.objectContaining({ instructions: expect.stringContaining("durable lesson") }), + ); + expect(internals._pendingAutoRefineReview).toBeUndefined(); + }); + + it("auto-refine pending review uses the in-progress guard and catches refine failures", async () => { + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 2, cooldownMs: 60_000 } }, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._pendingAutoRefineReview = { + reason: "turn_interval", + review: { shouldRefine: true, rationale: "durable lesson" }, + }; + let guardWasSetDuringRefine = false; + const refine = vi.spyOn(harness.session, "refine").mockImplementation(async () => { + guardWasSetDuringRefine = internals._autoRefineInProgress; + throw new Error("refine failed"); + }); + + await internals._maybeAutoRefine("turn_interval"); + + expect(refine).toHaveBeenCalledWith( + expect.objectContaining({ instructions: expect.stringContaining("durable lesson") }), + ); + expect(guardWasSetDuringRefine).toBe(true); + expect(internals._autoRefineInProgress).toBe(false); + expect(internals._pendingAutoRefineReview).toBeDefined(); + // The failure stamps the cooldown so the retained pending review does not + // retry on every agent end. + expect(internals._lastAutoRefineReviewAt).toBeGreaterThan(0); + + refine.mockResolvedValueOnce(emptyRefinementResult()); + await internals._maybeAutoRefine("turn_interval"); + + expect(refine).toHaveBeenCalledTimes(1); + expect(internals._pendingAutoRefineReview).toBeDefined(); + + internals._lastAutoRefineReviewAt = 0; + await internals._maybeAutoRefine("turn_interval"); + + expect(internals._pendingAutoRefineReview).toBeUndefined(); + }); + + it("keeps the turn counter and stamps the cooldown when an approved immediate refine fails", async () => { + const reviewer = vi.fn(async () => ({ shouldRefine: true, rationale: "durable lesson" })); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 2, cooldownMs: 60_000 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 2; + vi.spyOn(harness.session, "refine").mockRejectedValueOnce(new Error("refine failed")); + + await internals._maybeAutoRefine("turn_interval"); + + expect(reviewer).toHaveBeenCalledWith( + { reason: "turn_interval", turnsSinceLastReview: 2 }, + expect.any(AbortSignal), + ); + expect(internals._assistantTurnsSinceAutoRefine).toBe(2); + expect(internals._lastAutoRefineReviewAt).toBeGreaterThan(0); + }); + + it("does not refine when a review resolves after the session is disposed", async () => { + let finishReview: (() => void) | undefined; + const reviewGate = new Promise((resolve) => { + finishReview = resolve; + }); + const signals: Array = []; + const reviewer = vi.fn( + async (_context: { reason: AutoRefineReason; turnsSinceLastReview: number }, signal?: AbortSignal) => { + signals.push(signal); + await reviewGate; + return { shouldRefine: true, rationale: "durable lesson" }; + }, + ); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 0 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 1; + const refine = vi.spyOn(harness.session, "refine").mockResolvedValue(emptyRefinementResult()); + + const autoRefinePromise = internals._maybeAutoRefine("turn_interval"); + expect(reviewer).toHaveBeenCalledTimes(1); + const entriesBeforeDispose = harness.sessionManager.getEntries().length; + harness.session.dispose(); + expect(signals[0]?.aborted).toBe(true); + finishReview?.(); + await autoRefinePromise; + + expect(refine).not.toHaveBeenCalled(); + expect(internals._pendingAutoRefineReview).toBeUndefined(); + expect(harness.sessionManager.getEntries().length).toBe(entriesBeforeDispose); + + // Disposal also invalidates any newly scheduled auto-refine. + await internals._maybeAutoRefine("turn_interval"); + expect(reviewer).toHaveBeenCalledTimes(1); + }); + + it("stamps the cooldown when the auto-refine review fails", async () => { + const reviewer = vi.fn(async () => { + throw new Error("review failed"); + }); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 60_000 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 1; + + await internals._maybeAutoRefine("turn_interval"); + + expect(reviewer).toHaveBeenCalledTimes(1); + expect(internals._lastAutoRefineReviewAt).toBeGreaterThan(0); + + await internals._maybeAutoRefine("turn_interval"); + + expect(reviewer).toHaveBeenCalledTimes(1); + }); + + it("auto-refine pending review respects the cooldown", async () => { + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 60_000 } }, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._pendingAutoRefineReview = { + reason: "turn_interval", + review: { shouldRefine: true, rationale: "durable lesson" }, + }; + internals._lastAutoRefineReviewAt = Date.now(); + const refine = vi.spyOn(harness.session, "refine").mockResolvedValue(emptyRefinementResult()); + + await internals._maybeAutoRefine("turn_interval"); + + expect(refine).not.toHaveBeenCalled(); + expect(internals._pendingAutoRefineReview).toBeDefined(); + }); + + it("serializes concurrent refine calls", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + let releaseFirstPlan: (() => void) | undefined; + const firstPlanGate = new Promise((resolve) => { + releaseFirstPlan = resolve; + }); + let firstPlanStarted: (() => void) | undefined; + const firstPlanStartedPromise = new Promise((resolve) => { + firstPlanStarted = resolve; + }); + harness.setResponses([ + async () => { + firstPlanStarted?.(); + await firstPlanGate; + return fauxAssistantMessage( + JSON.stringify({ + summary: "first", + rationale: "first refine", + expectedOutcome: "first finished", + edits: [], + }), + ); + }, + fauxAssistantMessage( + JSON.stringify({ + summary: "second", + rationale: "second refine", + expectedOutcome: "second finished", + edits: [], + }), + ), + ]); + + const firstRefine = harness.session.refine({ instructions: "first refine" }); + await firstPlanStartedPromise; + const secondRefine = harness.session.refine({ instructions: "second refine" }); + await Promise.resolve(); + + expect(harness.getPendingResponseCount()).toBe(1); + + releaseFirstPlan?.(); + await firstRefine; + await secondRefine; + + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("does not persist or reconnect an in-flight refine after dispose", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + let releasePlan: (() => void) | undefined; + const planGate = new Promise((resolve) => { + releasePlan = resolve; + }); + let planStarted: (() => void) | undefined; + const planStartedPromise = new Promise((resolve) => { + planStarted = resolve; + }); + harness.setResponses([ + async () => { + planStarted?.(); + await planGate; + return fauxAssistantMessage( + JSON.stringify({ + summary: "stale refine", + rationale: "the session was disposed before apply", + expectedOutcome: "nothing is persisted", + edits: [ + { + action: "create", + kind: "memory", + id: "stale_after_dispose", + title: "Stale after dispose", + content: "This must not be saved.", + }, + ], + }), + ); + }, + ]); + const internals = harness.session as unknown as { _reconnectToAgent(): void }; + const reconnect = vi.spyOn(internals, "_reconnectToAgent"); + const entriesBeforeDispose = harness.sessionManager.getEntries().length; + + const refine = harness.session.refine({ instructions: "write stale state" }); + await planStartedPromise; + harness.session.dispose(); + releasePlan?.(); + + await expect(refine).rejects.toThrow(); + expect(reconnect).not.toHaveBeenCalled(); + expect(harness.sessionManager.getEntries()).toHaveLength(entriesBeforeDispose); + }); + + it("clears pending auto-refine state when navigating to another branch", async () => { + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 0 } }, + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]); + await harness.session.prompt("first"); + await harness.session.prompt("second"); + const targetEntry = harness.sessionManager + .getEntries() + .find((entry) => entry.type === "message" && entry.message.role === "user"); + expect(targetEntry).toBeDefined(); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 5; + internals._compactAutoRefinePending = true; + internals._pendingAutoRefineReview = { + reason: "compact", + review: { shouldRefine: true, rationale: "old branch" }, + }; + + await harness.session.navigateTree(targetEntry!.id, { summarize: false }); + + expect(internals._compactAutoRefinePending).toBe(false); + expect(internals._pendingAutoRefineReview).toBeUndefined(); + expect(internals._assistantTurnsSinceAutoRefine).toBe(0); + }); + + it("does not apply stale auto-refine cooldown when a review completes after branch navigation", async () => { + let finishReview: (() => void) | undefined; + const reviewStarted = new Promise((resolve) => { + finishReview = resolve; + }); + const reviewer = vi.fn(async () => { + await reviewStarted; + return { shouldRefine: true, rationale: "old branch" }; + }); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 2, cooldownMs: 60_000 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 2; + const refine = vi.spyOn(harness.session, "refine").mockResolvedValue(emptyRefinementResult()); + const beforeReviewAt = internals._lastAutoRefineReviewAt; + + const autoRefinePromise = internals._maybeAutoRefine("turn_interval"); + expect(reviewer).toHaveBeenCalledWith( + { reason: "turn_interval", turnsSinceLastReview: 2 }, + expect.any(AbortSignal), + ); + await internals._invalidatePendingAutoRefineForBranchChange(); + finishReview?.(); + await autoRefinePromise; + + expect(refine).not.toHaveBeenCalled(); + expect(internals._lastAutoRefineReviewAt).toBe(beforeReviewAt); + expect(internals._assistantTurnsSinceAutoRefine).toBe(0); + expect(internals._pendingAutoRefineReview).toBeUndefined(); + }); + + it("auto-refine is skipped for sessions without a local harness directory", async () => { + const reviewer = vi.fn(async () => ({ shouldRefine: true, rationale: "durable lesson" })); + const harness = await createHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 0 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 1; + const refine = vi.spyOn(harness.session, "refine").mockResolvedValue(emptyRefinementResult()); + const scheduleAutoRefine = vi.spyOn(internals, "_scheduleAutoRefine").mockImplementation(() => {}); + + await internals._maybeAutoRefine("turn_interval"); + internals._scheduleAutoRefineAfterCompaction(false); + internals._scheduleAutoRefineAfterAgentEnd(); + + expect(reviewer).not.toHaveBeenCalled(); + expect(refine).not.toHaveBeenCalled(); + expect(scheduleAutoRefine).not.toHaveBeenCalled(); + }); + + it("auto-refine is skipped for subagent sessions", async () => { + const reviewer = vi.fn(async () => ({ shouldRefine: true, rationale: "durable lesson" })); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 0 } }, + rlmDepth: 1, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 1; + const scheduleAutoRefine = vi.spyOn(internals, "_scheduleAutoRefine").mockImplementation(() => {}); + + await internals._maybeAutoRefine("turn_interval"); + internals._scheduleAutoRefineAfterCompaction(false); + internals._scheduleAutoRefineAfterAgentEnd(); + + expect(reviewer).not.toHaveBeenCalled(); + expect(scheduleAutoRefine).not.toHaveBeenCalled(); + }); + + it("preserves compact auto-refine pending state when no model is selected", async () => { + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 0 } }, + }); + harnesses.push(harness); + const state = harness.session.agent.state as { model: typeof harness.session.agent.state.model | undefined }; + state.model = undefined; + const internals = harness.session as unknown as AutoRefineInternals; + + await internals._maybeAutoRefine("compact"); + + expect(internals._compactAutoRefinePending).toBe(true); + }); + + it("auto-refine review obeys the cooldown", async () => { + const reviewer = vi.fn(async () => ({ shouldRefine: true, rationale: "durable lesson" })); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 60_000 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 1; + internals._lastAutoRefineReviewAt = Date.now(); + + await internals._maybeAutoRefine("turn_interval"); + + expect(reviewer).not.toHaveBeenCalled(); + }); + + it("auto-refine preserves a turn-interval checkpoint when cooldown is active", async () => { + const reviewer = vi.fn(async () => ({ shouldRefine: true, rationale: "durable lesson" })); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 60_000 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 1; + internals._lastAutoRefineReviewAt = Date.now(); + + await internals._maybeAutoRefine("turn_interval"); + + expect(reviewer).not.toHaveBeenCalled(); + expect(internals._turnIntervalAutoRefinePending).toBe(true); + }); + + it("auto-refine preserves a compact checkpoint when cooldown is active", async () => { + const reviewer = vi.fn(async () => ({ shouldRefine: true, rationale: "durable lesson" })); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 60_000 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._lastAutoRefineReviewAt = Date.now(); + + await internals._maybeAutoRefine("compact"); + + expect(reviewer).not.toHaveBeenCalled(); + expect(internals._compactAutoRefinePending).toBe(true); + }); + + it("queued follow-up messages do not make an idle agent active for auto-refine", async () => { + const reviewer = vi.fn(async () => ({ + shouldRefine: true, + rationale: "durable lesson found", + instructions: "capture the durable lesson", + })); + const harness = await createAutoRefineHarness({ + settings: { autoRefine: { enabled: true, turnInterval: 1, cooldownMs: 0 } }, + autoRefineReviewer: reviewer, + }); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + internals._assistantTurnsSinceAutoRefine = 1; + vi.spyOn(harness.session.agent, "hasQueuedMessages").mockReturnValue(true); + const refine = vi.spyOn(harness.session, "refine").mockResolvedValue(emptyRefinementResult()); + + await internals._maybeAutoRefine("turn_interval"); + + expect(reviewer).toHaveBeenCalledWith( + { reason: "turn_interval", turnsSinceLastReview: 1 }, + expect.any(AbortSignal), + ); + expect(refine).toHaveBeenCalled(); + }); + + it("strips local display prefixes before applying local refine edits", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + const globalDir = getGlobalHarnessStateDir(); + const localDir = getLocalHarnessStateDir(harness.sessionManager.getSessionArtifactDir())!; + const globalState = loadHarnessState(globalDir, "global"); + const localState = loadHarnessState(localDir, "local"); + applyRefinementProposal( + globalState, + { + summary: "Global shared memory", + rationale: "seed", + expectedOutcome: "seeded", + edits: [ + { + action: "create", + kind: "memory", + id: "shared", + title: "Shared", + content: "Global content", + }, + ], + }, + { id: "seed_global", scope: "global" }, + ); + applyRefinementProposal( + localState, + { + summary: "Local shared memory", + rationale: "seed", + expectedOutcome: "seeded", + edits: [ + { + action: "create", + kind: "memory", + id: "shared", + title: "Shared", + content: "Local content", + }, + ], + }, + { id: "seed_local", scope: "local" }, + ); + saveHarnessState(globalDir, globalState); + saveHarnessState(localDir, localState); + harness.setResponses([ + fauxAssistantMessage( + JSON.stringify({ + summary: "Update local shared memory", + rationale: "The local display id was selected from merged state.", + expectedOutcome: "Only the local entry changes.", + edits: [ + { + action: "update", + kind: "memory", + id: "local:shared", + title: "Shared", + content: "Updated local content", + }, + ], + }), + ), + ]); + + const result = await harness.session.refine({ instructions: "update the local shared memory" }); + + expect(result.appliedEdits[0]).toMatchObject({ id: "shared", applied: true }); + expect(loadHarnessState(localDir, "local").entries.memory.shared.content).toBe("Updated local content"); + expect(loadHarnessState(globalDir, "global").entries.memory.shared.content).toBe("Global content"); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + + it("strips global display prefixes before applying local refine edits", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + const localDir = getLocalHarnessStateDir(harness.sessionManager.getSessionArtifactDir())!; + const localState = loadHarnessState(localDir, "local"); + applyRefinementProposal( + localState, + { + summary: "Local memory", + rationale: "seed", + expectedOutcome: "seeded", + edits: [ + { + action: "create", + kind: "memory", + id: "shared", + title: "Shared", + content: "Local content", + }, + ], + }, + { id: "seed_local", scope: "local" }, + ); + saveHarnessState(localDir, localState); + harness.setResponses([ + fauxAssistantMessage( + JSON.stringify({ + summary: "Update local memory", + rationale: "The display id came from merged state.", + expectedOutcome: "The local entry changes without a prefixed id.", + edits: [ + { + action: "update", + kind: "memory", + id: "global:shared", + title: "Shared", + content: "Updated local content", + }, + ], + }), + ), + ]); + + const result = await harness.session.refine({ instructions: "update local memory" }); + + expect(result.appliedEdits[0]).toMatchObject({ id: "shared", applied: true }); + expect(loadHarnessState(localDir, "local").entries.memory.shared.content).toBe("Updated local content"); + expect(loadHarnessState(localDir, "local").entries.memory["global:shared"]).toBeUndefined(); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + + it("strips global display prefixes before applying global refine edits", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + const globalDir = getGlobalHarnessStateDir(); + const globalState = loadHarnessState(globalDir, "global"); + applyRefinementProposal( + globalState, + { + summary: "Global shared memory", + rationale: "seed", + expectedOutcome: "seeded", + edits: [ + { + action: "create", + kind: "memory", + id: "shared", + title: "Shared", + content: "Global content", + }, + ], + }, + { id: "seed_global", scope: "global" }, + ); + saveHarnessState(globalDir, globalState); + harness.setResponses([ + fauxAssistantMessage( + JSON.stringify({ + summary: "Update global shared memory", + rationale: "The global display id was selected from the overview.", + expectedOutcome: "Only the global entry changes.", + edits: [ + { + action: "update", + kind: "memory", + id: "global:shared", + title: "Shared", + content: "Updated global content", + }, + ], + }), + ), + ]); + + const result = await harness.session.refine({ + instructions: "update the global shared memory", + global: true, + }); + + expect(result.appliedEdits[0]).toMatchObject({ id: "shared", applied: true }); + expect(loadHarnessState(globalDir, "global").entries.memory.shared.content).toBe("Updated global content"); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + + it("rolls back copied local refinement history against the original local harness state", async () => { + const original = await createAutoRefineHarness(); + const branched = await createAutoRefineHarness(); + harnesses.push(original, branched); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${original.tempDir}/agent`; + try { + const originalLocalDir = getLocalHarnessStateDir(original.sessionManager.getSessionArtifactDir())!; + const branchedLocalDir = getLocalHarnessStateDir(branched.sessionManager.getSessionArtifactDir())!; + const branchedState = loadHarnessState(branchedLocalDir, "local"); + applyRefinementProposal( + branchedState, + { + summary: "Branch local memory", + rationale: "seed", + expectedOutcome: "seeded", + edits: [ + { + action: "create", + kind: "memory", + id: "remember_me", + title: "Branch memory", + content: "Branch content should survive rollback of copied history.", + }, + ], + }, + { id: "seed_branch", scope: "local" }, + ); + saveHarnessState(branchedLocalDir, branchedState); + original.setResponses([ + fauxAssistantMessage( + JSON.stringify({ + summary: "Create original local memory", + rationale: "seed", + expectedOutcome: "Original local entry exists.", + edits: [ + { + action: "create", + kind: "memory", + id: "remember_me", + title: "Original memory", + content: "Original content should be rolled back.", + }, + ], + }), + ), + ]); + + const originalRefinement = await original.session.refine({ instructions: "remember this locally" }); + branched.sessionManager.appendCustomEntry("prime-agent.refinement", originalRefinement); + expect(loadHarnessState(originalLocalDir, "local").entries.memory.remember_me.content).toBe( + "Original content should be rolled back.", + ); + + await branched.session.refine({ rollbackId: originalRefinement.id }); + + expect(loadHarnessState(originalLocalDir, "local").entries.memory.remember_me).toBeUndefined(); + expect(loadHarnessState(branchedLocalDir, "local").entries.memory.remember_me.content).toBe( + "Branch content should survive rollback of copied history.", + ); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + + it("persists a prompt started while a background refine is in flight", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + let releasePlan: (() => void) | undefined; + const planGate = new Promise((resolve) => { + releasePlan = resolve; + }); + let planStarted: (() => void) | undefined; + const planStartedPromise = new Promise((resolve) => { + planStarted = resolve; + }); + harness.setResponses([ + async () => { + planStarted?.(); + await planGate; + return fauxAssistantMessage( + JSON.stringify({ + summary: "no-op", + rationale: "nothing to change", + expectedOutcome: "unchanged", + edits: [], + }), + ); + }, + fauxAssistantMessage("prompt reply"), + ]); + + const refinePromise = harness.session.refine({ instructions: "background refine" }); + await planStartedPromise; + + const promptPromise = harness.session.prompt("hello during refine"); + await new Promise((resolve) => setTimeout(resolve, 10)); + // The prompt must wait for the refine (its response is still queued); + // running now would drop its events while the session is detached. + expect(harness.getPendingResponseCount()).toBe(1); + + releasePlan?.(); + await refinePromise; + await promptPromise; + + expect( + harness + .eventsOfType("message_end") + .some((event) => event.message.role === "assistant" && getMessageText(event.message) === "prompt reply"), + ).toBe(true); + const persistedAssistants = harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "message" && entry.message.role === "assistant"); + expect(persistedAssistants).toHaveLength(1); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + + it("rolls back a local refinement in a non-persisted session via the recorded state path", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + const recordedDir = join(harness.tempDir, "recorded-local", "harness"); + const recordedState = loadHarnessState(recordedDir, "local"); + const seeded = applyRefinementProposal( + recordedState, + { + summary: "Seed local memory", + rationale: "seed", + expectedOutcome: "seeded", + edits: [ + { + action: "create", + kind: "memory", + id: "remember_me", + title: "Remember", + content: "Content to roll back", + }, + ], + }, + { id: "refine_recorded", scope: "local" }, + ); + seeded.harnessStatePath = saveHarnessState(recordedDir, recordedState); + harness.sessionManager.appendCustomEntry("prime-agent.refinement", seeded); + + const result = await harness.session.refine({ rollbackId: "refine_recorded" }); + + expect(result.rollbackOf).toBe("refine_recorded"); + expect(loadHarnessState(recordedDir, "local").entries.memory.remember_me).toBeUndefined(); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + + it("keeps a legacy scope-less rollback in the global store with global scope", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + const globalDir = getGlobalHarnessStateDir(); + const timestamp = new Date().toISOString(); + // Legacy (pre-scope) store: entries carry no scope fields. + const legacyEntry = (id: string, content: string): HarnessEntry => ({ + id, + kind: "memory", + title: id, + content, + path: "general", + reference: {}, + arguments: {}, + metadata: {}, + source: "refine", + created_at: timestamp, + updated_at: timestamp, + version: 1, + }); + mkdirSync(globalDir, { recursive: true }); + writeFileSync( + getHarnessStatePath(globalDir), + JSON.stringify({ + schema: 1, + entries: { + prompt: {}, + memory: { + legacy_target: legacyEntry("legacy_target", "Rolled back"), + keep_me: legacyEntry("keep_me", "Untouched"), + }, + skill: {}, + subagent: {}, + }, + refinements: [], + }), + ); + const legacyRefinement: RefinementResult = { + id: "refine_legacy", + summary: "legacy refinement", + rationale: "legacy", + expectedOutcome: "legacy", + appliedEdits: [ + { + action: "create", + kind: "memory", + id: "legacy_target", + applied: true, + after: legacyEntry("legacy_target", "Rolled back"), + }, + ], + harnessStatePath: getHarnessStatePath(globalDir), + }; + harness.sessionManager.appendCustomEntry("prime-agent.refinement", legacyRefinement); + + const result = await harness.session.refine({ rollbackId: "refine_legacy" }); + + expect(result.scope).toBe("global"); + const stored = JSON.parse(readFileSync(getHarnessStatePath(globalDir), "utf8")); + expect(stored.entries.memory.legacy_target).toBeUndefined(); + expect(stored.entries.memory.keep_me.scope).toBe("global"); + const rollbackRecord = loadGlobalRefinementHistory(globalDir).find( + (item) => item.rollbackOf === "refine_legacy", + ); + expect(rollbackRecord).toBeDefined(); + expect(rollbackRecord?.scope).toBe("global"); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + it("dispatches extension commands immediately when prompted while idle", async () => { const commandRuns: string[] = []; const harness = await createHarness({ diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index 16a8182ec4..d72c4faa74 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -9,7 +9,7 @@ import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; import { Agent } from "@earendil-works/pi-agent-core"; import type { FauxModelDefinition, FauxProviderRegistration, FauxResponseStep, Model } from "@earendil-works/pi-ai"; import { registerFauxProvider } from "@earendil-works/pi-ai"; -import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.js"; +import { AgentSession, type AgentSessionEvent, type AutoRefineReviewer } from "../../src/core/agent-session.js"; import { AuthStorage } from "../../src/core/auth-storage.js"; import type { ExtensionRunner } from "../../src/core/extensions/index.js"; import { convertToLlm } from "../../src/core/messages.js"; @@ -63,6 +63,9 @@ export interface HarnessOptions { resourceLoader?: ResourceLoader; extensionFactories?: Array; withConfiguredAuth?: boolean; + persistSession?: boolean; + rlmDepth?: number; + autoRefineReviewer?: AutoRefineReviewer; } export interface Harness { @@ -100,7 +103,9 @@ export async function createHarness(options: HarnessOptions = {}): Promise { "", "Python state in the kernel, by contrast, persists across cells: named variables, helper functions, classes, imports, notes, parsed outputs, and helper data structures all remain available in every later turn. Tool calls are themselves Python `await` expressions, so their return values can be bound to variables and composed into program logic just like any other call.", "", - "Global continual harness state is available as `rlm.harness` and `rlm.get_harness_state()`. Use it to record reset-free improvements to prompt notes, memory, reusable skills, and subagent specs that should persist across Prime Agent sessions. Use explicit CRUD calls such as `rlm.harness.create_memory(...)`, `rlm.harness.update_memory(...)`, `rlm.harness.delete_memory(...)`, `rlm.harness.create_skill(...)`, `rlm.harness.update_skill(...)`, `rlm.harness.delete_skill(...)`, `rlm.harness.create_subagent(...)`, `rlm.harness.update_subagent(...)`, `rlm.harness.delete_subagent(...)`, `rlm.harness.create_prompt_note(...)`, `rlm.harness.update_prompt_note(...)`, `rlm.harness.delete_prompt_note(...)`, plus `rlm.harness.record_refinement(...)` and `rlm.harness.overview()`.", + "Continual harness state is available as `rlm.harness` and `rlm.get_harness_state()`. CRUD calls are local to this Prime Agent session by default: `rlm.harness.create_memory(...)`, `rlm.harness.update_memory(...)`, `rlm.harness.delete_memory(...)`, `rlm.harness.create_skill(...)`, `rlm.harness.update_skill(...)`, `rlm.harness.delete_skill(...)`, `rlm.harness.create_subagent(...)`, `rlm.harness.update_subagent(...)`, `rlm.harness.delete_subagent(...)`, `rlm.harness.create_prompt_note(...)`, `rlm.harness.update_prompt_note(...)`, `rlm.harness.delete_prompt_note(...)`, plus `rlm.harness.record_refinement(...)` and `rlm.harness.overview()`. Use `global_=True` only for stable cross-session lessons; Python reserves `global`, so literal `global=True` is invalid syntax.", "", - "RLM-native call contract for refined entries: installed Python skills are called from IPython as `await (...)` with keyword arguments, or as ` ...` from shell when a CLI exists. Harness skill entries are Python REPL skills with an explicit Python `reference` and `arguments` contract. Harness subagent entries are reusable delegation specs; invoke them by turning the spec into a concise task prompt and calling `await rlm('sub-task')`, or `await asyncio.gather(rlm('task1'), rlm('task2'))` for independent parallel subagents. Do not invent non-native wrappers such as `call_skill(...)`, `run_subagent(...)`, or named subagent registries.", + "Terminology: continual harness names the persisted prompt, memory, skill, and subagent layer; RLM names the runtime, IPython kernel, and native call interface exposed to the model.", "", - "Treat harness refinement as a small, evidence-backed update after observing a repeated failure or reusable tactic: diagnose the issue, update the smallest relevant harness component, validate on the next action, then record the outcome. Do not rewrite the whole harness when a focused memory, skill, prompt note, or subagent spec is enough.", + "RLM-native call contract for refined continual harness entries: installed Python skills are called from IPython as `await (...)` with keyword arguments, or as ` ...` from shell when a CLI exists. Continual harness skill entries are Python REPL skills with an explicit Python `reference` and `arguments` contract. Continual harness subagent entries are reusable delegation specs; invoke them by turning the spec into a concise task prompt and calling `await rlm('sub-task')`, or `await asyncio.gather(rlm('task1'), rlm('task2'))` for independent parallel subagents. Do not invent non-native wrappers such as `call_skill(...)`, `run_subagent(...)`, or named subagent registries.", + "", + "Treat continual harness refinement as a small, evidence-backed update after observing a repeated failure or reusable tactic: diagnose the issue, update the smallest relevant continual harness component, validate on the next action, then record the outcome. Do not rewrite the whole continual harness when a focused memory, skill, prompt note, or subagent spec is enough.", ].join("\n"), ); }); @@ -240,23 +242,25 @@ describe("buildSystemPrompt", () => { harnessState, }); - expect(prompt).toContain("# Global Harness State"); - expect(prompt).toContain("Persistent harness state is global by default"); + expect(prompt).toContain("# Continual Harness State"); + expect(prompt).toContain("Local continual harness entries belong to this Prime Agent session"); + expect(prompt).toContain("The continual harness entries below are compact summaries, not full descriptions"); + expect(prompt).toContain("Use global continual harness refinement only for stable cross-session lessons"); expect(prompt).toContain("When to call `/refine`"); expect(prompt).toContain("Call contract: use installed Python skills as `await (...)`"); - expect(prompt).toContain("Harness skill entries are Python REPL skills"); - expect(prompt).toContain("Harness subagent entries are invoked by composing a concise task prompt"); + expect(prompt).toContain("Continual harness skill entries are Python REPL skills"); + expect(prompt).toContain("Continual harness subagent entries are invoked by composing a concise task prompt"); expect(prompt).toContain("await rlm('sub-task')"); expect(prompt).toContain("after a repeated failure"); expect(prompt).toContain("a reusable tactic emerges"); - expect(prompt).toContain("validation shows a harness entry is wrong"); - expect(prompt).toContain("[focused_edits] Focused edits (policy, v1)"); - expect(prompt).toContain("[validation] Validation (repo/prime-agent, v2): Run `npm run check`"); - expect(prompt).toContain("[review_refinement] Review refinement (quality, v1)"); - expect(prompt).toContain("[refinement_reviewer] Refinement reviewer (review, v1)"); + expect(prompt).toContain("validation shows a continual harness entry is wrong"); + expect(prompt).toContain("[global:focused_edits] Focused edits (policy, v1)"); + expect(prompt).toContain("[global:validation] Validation (repo/prime-agent, v2): Run `npm run check`"); + expect(prompt).toContain("[global:review_refinement] Review refinement (quality, v1)"); + expect(prompt).toContain("[global:refinement_reviewer] Refinement reviewer (review, v1)"); expect(prompt).toContain("recent refinements: 1"); expect(prompt).toContain("[refine_1] Observed validation miss: create memory:validation"); - expect(prompt.indexOf("# Global Harness State")).toBeGreaterThan(prompt.indexOf("Conversation log:")); + expect(prompt.indexOf("# Continual Harness State")).toBeGreaterThan(prompt.indexOf("Conversation log:")); }); test("keeps injected harness context compact", () => { @@ -338,17 +342,17 @@ describe("buildSystemPrompt", () => { expect(prompt).toContain("Avoid `!cmd` shell escapes for project commands"); expect(prompt).toContain("Each `%%bash` cell runs in a throw-away subshell"); expect(prompt).toContain("Python state in the kernel, by contrast, persists across cells"); - expect(prompt).toContain("Global continual harness state is available as `rlm.harness`"); - expect(prompt).toContain( - "record reset-free improvements to prompt notes, memory, reusable skills, and subagent specs", - ); + expect(prompt).toContain("Continual harness state is available as `rlm.harness`"); + expect(prompt).toContain("CRUD calls are local to this Prime Agent session by default"); + expect(prompt).toContain("global_=True"); expect(prompt).toContain("rlm.harness.create_memory"); expect(prompt).toContain("rlm.harness.update_skill"); expect(prompt).toContain("rlm.harness.delete_subagent"); expect(prompt).toContain("rlm.harness.create_prompt_note"); expect(prompt).not.toContain("rlm.harness.upsert_skill"); expect(prompt).toContain("rlm.harness.record_refinement"); - expect(prompt).toContain("RLM-native call contract for refined entries"); + expect(prompt).toContain("continual harness names the persisted prompt"); + expect(prompt).toContain("RLM-native call contract for refined continual harness entries"); expect(prompt).toContain("await (...)"); expect(prompt).toContain("Python `reference` and `arguments` contract"); expect(prompt).toContain("await asyncio.gather(rlm('task1'), rlm('task2'))"); @@ -405,13 +409,15 @@ describe("buildSystemPrompt", () => { }); expect(prompt).toContain("custom body"); - expect(prompt).toContain("# Global Harness State"); - expect(prompt).toContain("[custom_memory] Custom memory (custom, v1)"); + expect(prompt).toContain("# Continual Harness State"); + expect(prompt).toContain("[global:custom_memory] Custom memory (custom, v1)"); expect(prompt).not.toContain("# IPython Kernel Guidance"); expect(prompt).not.toContain("You are a general purpose agent that uses code to solve tasks."); - expect(prompt.indexOf("Current working directory: /repo")).toBeLessThan(prompt.indexOf("# Global Harness State")); + expect(prompt.indexOf("Current working directory: /repo")).toBeLessThan( + prompt.indexOf("# Continual Harness State"), + ); expect(prompt.indexOf("Current working directory: /repo")).toBeLessThan(prompt.indexOf("custom append")); - expect(prompt.indexOf("# Global Harness State")).toBeLessThan(prompt.indexOf("custom append")); + expect(prompt.indexOf("# Continual Harness State")).toBeLessThan(prompt.indexOf("custom append")); }); test("append system prompt content is included after the rlm harness prompt", () => { diff --git a/prime-agent-runtime/src/rlm/__init__.py b/prime-agent-runtime/src/rlm/__init__.py index 91ccc4032c..e2ef222263 100644 --- a/prime-agent-runtime/src/rlm/__init__.py +++ b/prime-agent-runtime/src/rlm/__init__.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any -from .harness import HarnessEntry, HarnessState, RefinementEvent, get_harness_state +from .harness import HarnessEntry, HarnessScope, HarnessState, RefinementEvent, get_harness_state try: from ipykernel.comm import Comm @@ -164,13 +164,55 @@ async def run(prompt: str, **kwargs: Any) -> RLMResult: return _result_from_payload(payload) -try: - _harness_state = get_harness_state() -except Exception: # pragma: no cover - harness state must never break `import rlm` - # Importing rlm runs inside the kernel; a failure here would take down the whole - # kernel. Fall back to a true in-memory store (no path resolution, no disk) so the - # failure cannot recur and refinement is merely degraded, not fatal. - _harness_state = HarnessState(in_memory=True) +class _HarnessProxy: + """Resolve the harness state against the current environment on every access. + + The kernel forkserver preimports rlm in a template process before per-session + env vars exist; a state bound at import time would freeze that (env-less) + resolution into every forked kernel. Resolving per access picks up the env + applied after fork. Resolution must never raise (a failure inside the kernel + namespace would take down the kernel). When the local store is genuinely + unconfigured (no session env, e.g. --no-session) reads see an empty view but + local writes raise instructively instead of vanishing on kernel exit; any + other resolution failure degrades to a shared in-memory store until local + resolution starts succeeding. + """ + + _fallback: HarnessState | None = None + _unpersisted: HarnessState | None = None + + def _resolve(self) -> HarnessState: + try: + return get_harness_state() + except RuntimeError as exc: + if "Local harness state requires" in str(exc): + if _HarnessProxy._unpersisted is None: + _HarnessProxy._unpersisted = HarnessState( + in_memory=True, + local_write_error=( + f"{exc} This session has no persistent local harness store; " + "pass global_=True to persist across sessions." + ), + ) + return _HarnessProxy._unpersisted + return self._degraded() + except Exception: # pragma: no cover - harness access must never raise + return self._degraded() + + @staticmethod + def _degraded() -> HarnessState: + if _HarnessProxy._fallback is None: + _HarnessProxy._fallback = HarnessState(in_memory=True) + return _HarnessProxy._fallback + + def __getattr__(self, name: str) -> Any: + return getattr(self._resolve(), name) + + def __repr__(self) -> str: + return repr(self._resolve()) + + +_harness_state = _HarnessProxy() class _RLMCallable: @@ -197,6 +239,7 @@ async def __call__(self, prompt: str, **kwargs: Any) -> RLMResult: __all__ = [ "HarnessEntry", + "HarnessScope", "HarnessState", "McpIntegration", "McpToolError", diff --git a/prime-agent-runtime/src/rlm/harness.py b/prime-agent-runtime/src/rlm/harness.py index 4bf49e213d..e23b4a019c 100644 --- a/prime-agent-runtime/src/rlm/harness.py +++ b/prime-agent-runtime/src/rlm/harness.py @@ -16,11 +16,12 @@ from typing import Any, Literal HarnessKind = Literal["prompt", "memory", "skill", "subagent"] +HarnessScope = Literal["local", "global"] _DEFAULT_FILE_NAME = "harness_state.json" _DEFAULT_HARNESS_DIR_NAME = "harness" _KINDS: tuple[HarnessKind, ...] = ("prompt", "memory", "skill", "subagent") -_state_cache: dict[Path, "HarnessState"] = {} +_state_cache: dict[tuple[Path, HarnessScope], "HarnessState"] = {} def _now() -> str: @@ -42,8 +43,48 @@ def _agent_dir() -> Path: return Path(raw).expanduser().resolve() -def _state_file(state_dir: str | Path | None = None) -> Path: - root = state_dir or os.environ.get("RLM_HARNESS_STATE_DIR") +def _resolve_global_flag(global_: bool = False, extra: dict[str, Any] | None = None) -> bool: + extra = dict(extra or {}) + if "global" in extra: + value = extra.pop("global") + if not isinstance(value, bool): + raise TypeError(f"global must be a bool, got {type(value).__name__}") + global_ = value + if extra: + unexpected = next(iter(extra)) + raise TypeError(f"unexpected keyword argument {unexpected!r}") + return bool(global_) + + +def _strip_scope_prefix(id: str | None, global_: bool) -> tuple[str | None, bool]: + # overview() displays entries as [local:id]/[global:id]; accept those ids + # verbatim. A global: prefix routes to the global store unless the caller + # already forced a scope via global_. + if isinstance(id, str): + scope, sep, rest = id.partition(":") + if sep and rest and scope in ("local", "global"): + return rest, global_ or scope == "global" + return id, global_ + + +def _env_dir(name: str) -> str | None: + # Set-but-empty env values must behave as unset; a bare "" would skip the + # session-dir fallback and land local writes in the global agent-dir default. + value = (os.environ.get(name) or "").strip() + return value or None + + +def _state_file(state_dir: str | Path | None = None, *, global_: bool = False) -> Path: + root: str | Path | None = state_dir + if root is None: + root = _env_dir("RLM_GLOBAL_HARNESS_STATE_DIR") if global_ else _env_dir("RLM_HARNESS_STATE_DIR") + if root is None and not global_ and (session_dir := _env_dir("RLM_SESSION_DIR")): + root = Path(session_dir) / _DEFAULT_HARNESS_DIR_NAME + if root is None and not global_: + raise RuntimeError( + "Local harness state requires RLM_HARNESS_STATE_DIR or RLM_SESSION_DIR. " + "Use get_harness_state(global_=True) for global state." + ) if root: return Path(root).expanduser().resolve() / _DEFAULT_FILE_NAME return _agent_dir() / _DEFAULT_HARNESS_DIR_NAME / _DEFAULT_FILE_NAME @@ -58,6 +99,7 @@ class HarnessEntry: title: str content: str path: str = "general" + scope: HarnessScope = "local" reference: dict[str, Any] = field(default_factory=dict) arguments: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict) @@ -99,20 +141,40 @@ def _validate_python_skill_reference(reference: dict[str, Any] | None) -> dict[s class HarnessState: """CRUD store for reset-free harness refinement state.""" - def __init__(self, file_path: str | Path | None = None, *, in_memory: bool = False): + def __init__( + self, + file_path: str | Path | None = None, + *, + in_memory: bool = False, + scope: HarnessScope = "local", + local_write_error: str | None = None, + ): # in_memory mode never resolves or touches a path. It is the safe fallback when # path resolution itself fails, so constructing it cannot re-raise that error. if in_memory: self.file_path: Path | None = None else: - self.file_path = Path(file_path).expanduser().resolve() if file_path else _state_file() + self.file_path = ( + Path(file_path).expanduser().resolve() + if file_path + else _state_file(global_=(scope == "global")) + ) + self.scope: HarnessScope = scope + # When set, local mutations raise instead of vanishing into a volatile + # store; reads and global_=True delegation keep working. + self._local_write_error = local_write_error self.entries: dict[HarnessKind, dict[str, HarnessEntry]] = {kind: {} for kind in _KINDS} self.refinements: list[RefinementEvent] = [] + self._global_target_state_dir: Path | None = None # mtime of the file as of the last load/save, used to detect out-of-process # writes (e.g. the host `/refine` command) and avoid clobbering them. self._loaded_mtime: int | None = None self.load() + def _ensure_local_writable(self) -> None: + if self._local_write_error is not None: + raise RuntimeError(self._local_write_error) + def _disk_mtime(self) -> int | None: if self.file_path is None: return None @@ -168,6 +230,8 @@ def load(self) -> "HarnessState": continue if not isinstance(entry_data.get("path"), str): entry_data["path"] = "general" + if entry_data.get("scope") not in ("local", "global"): + entry_data["scope"] = self.scope if not isinstance(entry_data.get("source"), str): entry_data["source"] = "agent" version = entry_data.get("version", 1) @@ -209,6 +273,14 @@ def load(self) -> "HarnessState": self._loaded_mtime = mtime return self + def _global_target(self, global_: bool, extra: dict[str, Any] | None = None) -> "HarnessState | None": + if not _resolve_global_flag(global_, extra): + return None + target = get_harness_state(state_dir=self._global_target_state_dir, global_=True) + if self.file_path is not None and target.file_path == self.file_path and target.scope == self.scope: + return None + return target + def save(self) -> "HarnessState": if self.file_path is None: # in_memory fallback: nothing to persist. @@ -239,7 +311,23 @@ def upsert( arguments: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, source: str = "agent", + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: + id, global_ = _strip_scope_prefix(id, global_) + if target := self._global_target(global_, kwargs): + return target.upsert( + kind, + title, + content, + id=id, + path=path, + reference=reference, + arguments=arguments, + metadata=metadata, + source=source, + ) + self._ensure_local_writable() self._sync_from_disk() return self._upsert( kind, @@ -301,6 +389,7 @@ def _upsert( title=title, content=content, path=path if path is not None else "general", + scope=self.scope, reference=dict(reference or {}), arguments=dict(arguments or {}), metadata=dict(metadata or {}), @@ -310,13 +399,20 @@ def _upsert( self.save() return entry - def get(self, kind: HarnessKind, id: str) -> HarnessEntry | None: + def get(self, kind: HarnessKind, id: str, *, global_: bool = False, **kwargs: Any) -> HarnessEntry | None: + id, global_ = _strip_scope_prefix(id, global_) + if target := self._global_target(global_, kwargs): + return target.get(kind, id) self._sync_from_disk() if kind not in self.entries: raise ValueError(f"unknown harness kind {kind!r}; expected one of {_KINDS}") return self.entries[kind].get(id) - def delete(self, kind: HarnessKind, id: str) -> bool: + def delete(self, kind: HarnessKind, id: str, *, global_: bool = False, **kwargs: Any) -> bool: + id, global_ = _strip_scope_prefix(id, global_) + if target := self._global_target(global_, kwargs): + return target.delete(kind, id) + self._ensure_local_writable() self._sync_from_disk() if kind not in self.entries: raise ValueError(f"unknown harness kind {kind!r}; expected one of {_KINDS}") @@ -326,7 +422,9 @@ def delete(self, kind: HarnessKind, id: str) -> bool: self.save() return True - def list(self, kind: HarnessKind | None = None) -> list[HarnessEntry]: + def list(self, kind: HarnessKind | None = None, *, global_: bool = False, **kwargs: Any) -> list[HarnessEntry]: + if target := self._global_target(global_, kwargs): + return target.list(kind) self._sync_from_disk() kinds = [kind] if kind else list(_KINDS) records: list[HarnessEntry] = [] @@ -348,7 +446,23 @@ def create( arguments: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, source: str = "agent", + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: + id, global_ = _strip_scope_prefix(id, global_) + if target := self._global_target(global_, kwargs): + return target.create( + kind, + title, + content, + id=id, + path=path, + reference=reference, + arguments=arguments, + metadata=metadata, + source=source, + ) + self._ensure_local_writable() self._sync_from_disk() if kind not in self.entries: raise ValueError(f"unknown harness kind {kind!r}; expected one of {_KINDS}") @@ -379,7 +493,23 @@ def update( arguments: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, source: str = "agent", + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: + id, global_ = _strip_scope_prefix(id, global_) + if target := self._global_target(global_, kwargs): + return target.update( + kind, + id, + title, + content, + path=path, + reference=reference, + arguments=arguments, + metadata=metadata, + source=source, + ) + self._ensure_local_writable() self._sync_from_disk() if kind not in self.entries: raise ValueError(f"unknown harness kind {kind!r}; expected one of {_KINDS}") @@ -405,8 +535,10 @@ def create_memory( id: str | None = None, path: str = "general", metadata: dict[str, Any] | None = None, + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: - return self.create("memory", title, content, id=id, path=path, metadata=metadata) + return self.create("memory", title, content, id=id, path=path, metadata=metadata, global_=global_, **kwargs) def update_memory( self, @@ -416,11 +548,13 @@ def update_memory( *, path: str | None = None, metadata: dict[str, Any] | None = None, + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: - return self.update("memory", id, title, content, path=path, metadata=metadata) + return self.update("memory", id, title, content, path=path, metadata=metadata, global_=global_, **kwargs) - def delete_memory(self, id: str) -> bool: - return self.delete("memory", id) + def delete_memory(self, id: str, *, global_: bool = False, **kwargs: Any) -> bool: + return self.delete("memory", id, global_=global_, **kwargs) def create_prompt_note( self, @@ -430,8 +564,10 @@ def create_prompt_note( id: str | None = None, path: str = "policy", metadata: dict[str, Any] | None = None, + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: - return self.create("prompt", title, content, id=id, path=path, metadata=metadata) + return self.create("prompt", title, content, id=id, path=path, metadata=metadata, global_=global_, **kwargs) def update_prompt_note( self, @@ -441,11 +577,13 @@ def update_prompt_note( *, path: str | None = None, metadata: dict[str, Any] | None = None, + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: - return self.update("prompt", id, title, content, path=path, metadata=metadata) + return self.update("prompt", id, title, content, path=path, metadata=metadata, global_=global_, **kwargs) - def delete_prompt_note(self, id: str) -> bool: - return self.delete("prompt", id) + def delete_prompt_note(self, id: str, *, global_: bool = False, **kwargs: Any) -> bool: + return self.delete("prompt", id, global_=global_, **kwargs) def create_skill( self, @@ -457,6 +595,8 @@ def create_skill( reference: dict[str, Any] | None = None, arguments: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: return self.create( "skill", @@ -467,6 +607,8 @@ def create_skill( reference=_validate_python_skill_reference(reference), arguments=arguments, metadata=metadata, + global_=global_, + **kwargs, ) def update_skill( @@ -479,6 +621,8 @@ def update_skill( reference: dict[str, Any] | None = None, arguments: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None, + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: # Only validate a reference when one is supplied; omitting it preserves the # existing reference (see _upsert) rather than forcing every title/content-only @@ -493,10 +637,12 @@ def update_skill( reference=validated_reference, arguments=arguments, metadata=metadata, + global_=global_, + **kwargs, ) - def delete_skill(self, id: str) -> bool: - return self.delete("skill", id) + def delete_skill(self, id: str, *, global_: bool = False, **kwargs: Any) -> bool: + return self.delete("skill", id, global_=global_, **kwargs) def create_subagent( self, @@ -506,8 +652,10 @@ def create_subagent( id: str | None = None, path: str = "general", metadata: dict[str, Any] | None = None, + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: - return self.create("subagent", title, content, id=id, path=path, metadata=metadata) + return self.create("subagent", title, content, id=id, path=path, metadata=metadata, global_=global_, **kwargs) def update_subagent( self, @@ -517,11 +665,13 @@ def update_subagent( *, path: str | None = None, metadata: dict[str, Any] | None = None, + global_: bool = False, + **kwargs: Any, ) -> HarnessEntry: - return self.update("subagent", id, title, content, path=path, metadata=metadata) + return self.update("subagent", id, title, content, path=path, metadata=metadata, global_=global_, **kwargs) - def delete_subagent(self, id: str) -> bool: - return self.delete("subagent", id) + def delete_subagent(self, id: str, *, global_: bool = False, **kwargs: Any) -> bool: + return self.delete("subagent", id, global_=global_, **kwargs) def record_refinement( self, @@ -531,7 +681,12 @@ def record_refinement( evidence: str = "", outcome: str = "", id: str | None = None, + global_: bool = False, + **kwargs: Any, ) -> RefinementEvent: + if target := self._global_target(global_, kwargs): + return target.record_refinement(trigger, changes, evidence=evidence, outcome=outcome, id=id) + self._ensure_local_writable() self._sync_from_disk() event_id = id or f"refine_{len(self.refinements) + 1:04d}" normalized_changes = [changes] if isinstance(changes, str) else list(changes) @@ -563,10 +718,12 @@ def plan_refinement( plan.append(f"Immediate validation step: {next_step}") return plan - def overview(self, *, max_entries_per_kind: int = 20) -> str: + def overview(self, *, max_entries_per_kind: int = 20, global_: bool = False, **kwargs: Any) -> str: + if target := self._global_target(global_, kwargs): + return target.overview(max_entries_per_kind=max_entries_per_kind) self._sync_from_disk() lines = [ - f"Harness state: {self.file_path}", + f"Harness state ({self.scope}): {self.file_path}", "Call contract: installed Python skills use await (...) or a matching shell CLI; " "harness skill entries are Python REPL skills and must include a Python reference plus arguments. " "Subagent specs are invoked by composing a concise task prompt and calling await rlm('sub-task'), " @@ -592,7 +749,7 @@ def overview(self, *, max_entries_per_kind: int = 20) -> str: reference_text = f"{reference_text[:117]}..." reference_summary = f" ref={reference_text}" lines.append( - f" - [{entry.id}] {entry.title} ({entry.path}, v{entry.version})" + f" - [{entry.scope}:{entry.id}] {entry.title} ({entry.path}, v{entry.version})" f"{reference_summary}{argument_summary}: {summary}" ) overflow = len(self.entries[kind]) - len(records) @@ -606,10 +763,13 @@ def overview(self, *, max_entries_per_kind: int = 20) -> str: lines.append("refinements: 0") return "\n".join(lines) - def snapshot(self) -> dict[str, Any]: + def snapshot(self, *, global_: bool = False, **kwargs: Any) -> dict[str, Any]: + if target := self._global_target(global_, kwargs): + return target.snapshot() self._sync_from_disk() return { "file_path": str(self.file_path), + "scope": self.scope, "entries": { kind: {entry_id: asdict(entry) for entry_id, entry in records.items()} for kind, records in self.entries.items() @@ -618,19 +778,37 @@ def snapshot(self) -> dict[str, Any]: } -def get_harness_state(state_dir: str | Path | None = None) -> HarnessState: - """Return the cached global harness state, or a state for an explicit directory.""" - file_path = _state_file(state_dir) - state = _state_cache.get(file_path) +def get_harness_state( + state_dir: str | Path | None = None, *, global_: bool = False, **kwargs: Any +) -> HarnessState: + """Return the cached local harness state, or global when requested.""" + global_ = _resolve_global_flag(global_, kwargs) + file_path = _state_file(state_dir, global_=global_) + scope: HarnessScope = "global" if global_ else "local" + cache_key = (file_path, scope) + state = _state_cache.get(cache_key) if state is None: - state = HarnessState(file_path) - _state_cache[file_path] = state + state = HarnessState(file_path, scope=scope) + # Recorded at construction only: an instance created from env defaults must + # keep targeting RLM_GLOBAL_HARNESS_STATE_DIR even when a later explicit + # state_dir call aliases the same local file. An explicit dir that merely + # aliases the env resolution must not sandbox later global_=True writes + # either, so pin only when the explicit dir actually diverges. + if state_dir is not None: + try: + env_file: Path | None = _state_file(global_=global_) + except RuntimeError: + env_file = None + if file_path != env_file: + state._global_target_state_dir = Path(state_dir).expanduser().resolve() + _state_cache[cache_key] = state return state __all__ = [ "HarnessEntry", "HarnessKind", + "HarnessScope", "HarnessState", "RefinementEvent", "get_harness_state", diff --git a/prime-agent-runtime/test/test_harness.py b/prime-agent-runtime/test/test_harness.py index fae6400eda..d7706e3651 100644 --- a/prime-agent-runtime/test/test_harness.py +++ b/prime-agent-runtime/test/test_harness.py @@ -2,6 +2,8 @@ import json import os +import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -329,24 +331,83 @@ def test_update_preserves_omitted_path(self) -> None: self.assertEqual(state.get("memory", "grouped").path, "repo/other") def test_in_memory_state_never_touches_disk(self) -> None: + previous = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") with tempfile.TemporaryDirectory() as temp_dir: - previous = os.environ.get("RLM_HARNESS_STATE_DIR") os.environ["RLM_HARNESS_STATE_DIR"] = temp_dir + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) try: state = HarnessState(in_memory=True) created = state.create_memory("Volatile", "in memory only", id="volatile") state.record_refinement("trigger", ["change"]) + + self.assertIsNone(state.file_path) + self.assertEqual(created.content, "in memory only") + self.assertEqual(state.get("memory", "volatile").content, "in memory only") + # Local in-memory operations do not resolve or persist a path. + self.assertEqual(list(Path(temp_dir).iterdir()), []) finally: if previous is None: os.environ.pop("RLM_HARNESS_STATE_DIR", None) else: os.environ["RLM_HARNESS_STATE_DIR"] = previous + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + def test_in_memory_state_global_flag_uses_global_env_store(self) -> None: + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + global_dir = Path(temp_dir) / "global" + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(global_dir) + try: + state = HarnessState(in_memory=True) + global_entry = state.create_memory("Global note", "persisted", id="global_note", global_=True) + finally: + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + self.assertIsNone(state.file_path) + self.assertEqual(global_entry.scope, "global") + self.assertEqual(global_entry.content, "persisted") + self.assertIsNone(state.get("memory", "global_note")) + self.assertEqual( + HarnessState(global_dir / "harness_state.json", scope="global").get("memory", "global_note").content, + "persisted", + ) + + def test_in_memory_state_global_flag_uses_default_global_store(self) -> None: + previous_agent_dir = os.environ.get("PRIME_AGENT_CODING_AGENT_DIR") + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + agent_dir = Path(temp_dir) / "agent" + os.environ["PRIME_AGENT_CODING_AGENT_DIR"] = str(agent_dir) + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + try: + state = HarnessState(in_memory=True) + global_entry = state.create_memory("Default global", "persisted", id="default_global", global_=True) + finally: + if previous_agent_dir is None: + os.environ.pop("PRIME_AGENT_CODING_AGENT_DIR", None) + else: + os.environ["PRIME_AGENT_CODING_AGENT_DIR"] = previous_agent_dir + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global self.assertIsNone(state.file_path) - self.assertEqual(created.content, "in memory only") - self.assertEqual(state.get("memory", "volatile").content, "in memory only") - # No path was resolved, so nothing was persisted anywhere under the dir. - self.assertEqual(list(Path(temp_dir).iterdir()), []) + self.assertEqual(global_entry.scope, "global") + self.assertIsNone(state.get("memory", "default_global")) + self.assertEqual( + HarnessState(agent_dir / "harness" / "harness_state.json", scope="global") + .get("memory", "default_global") + .content, + "persisted", + ) def test_reloads_external_writes_before_mutating(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -413,6 +474,85 @@ def test_explicit_state_dir_cache_uses_harness_state_file(self) -> None: self.assertIs(state, again) self.assertEqual(state.file_path, Path(temp_dir).resolve() / "harness_state.json") + def test_explicit_state_dir_global_flag_uses_matching_state_file(self) -> None: + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + explicit_dir = Path(temp_dir) / "explicit" + env_global_dir = Path(temp_dir) / "env-global" + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(env_global_dir) + try: + state = get_harness_state(explicit_dir) + global_entry = state.create_memory("Scoped global", "custom dir", id="scoped_global", global_=True) + finally: + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + self.assertEqual(global_entry.scope, "global") + self.assertIsNotNone( + HarnessState(explicit_dir / "harness_state.json", scope="global").get("memory", "scoped_global") + ) + self.assertFalse((env_global_dir / "harness_state.json").exists()) + + def test_env_default_state_keeps_env_global_target_after_explicit_dir_cache_hit(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + local_dir = Path(temp_dir) / "local" + env_global_dir = Path(temp_dir) / "env-global" + os.environ["RLM_HARNESS_STATE_DIR"] = str(local_dir) + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(env_global_dir) + try: + cached_from_env = get_harness_state() + # An explicit state_dir that aliases the env local dir must not + # redirect the env-default singleton's global target. + cached_from_explicit = get_harness_state(local_dir) + global_entry = cached_from_env.create_memory( + "Env global", + "still targets the env global dir", + id="env_global_after_hit", + global_=True, + ) + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + self.assertIs(cached_from_env, cached_from_explicit) + self.assertEqual(global_entry.scope, "global") + self.assertIsNotNone( + HarnessState(env_global_dir / "harness_state.json", scope="global").get( + "memory", "env_global_after_hit" + ) + ) + self.assertIsNone( + HarnessState(local_dir / "harness_state.json").get("memory", "env_global_after_hit") + ) + + def test_local_state_requires_local_path(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_session = os.environ.get("RLM_SESSION_DIR") + try: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + os.environ.pop("RLM_SESSION_DIR", None) + with self.assertRaisesRegex(RuntimeError, "Local harness state requires"): + HarnessState() + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_session is None: + os.environ.pop("RLM_SESSION_DIR", None) + else: + os.environ["RLM_SESSION_DIR"] = previous_session + def test_default_state_uses_global_harness_env_dir(self) -> None: previous = os.environ.get("RLM_HARNESS_STATE_DIR") with tempfile.TemporaryDirectory() as temp_dir: @@ -427,6 +567,342 @@ def test_default_state_uses_global_harness_env_dir(self) -> None: self.assertEqual(state.file_path, Path(temp_dir).resolve() / "harness_state.json") + def test_global_scope_default_state_uses_global_harness_env_dir(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + local_dir = Path(temp_dir) / "local" + global_dir = Path(temp_dir) / "global" + os.environ["RLM_HARNESS_STATE_DIR"] = str(local_dir) + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(global_dir) + try: + state = HarnessState(scope="global") + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + self.assertEqual(state.scope, "global") + self.assertEqual(state.file_path, global_dir.resolve() / "harness_state.json") + + def test_default_state_is_local_and_global_flag_targets_global_store(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + local_dir = Path(temp_dir) / "local" + global_dir = Path(temp_dir) / "global" + os.environ["RLM_HARNESS_STATE_DIR"] = str(local_dir) + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(global_dir) + try: + state = get_harness_state() + global_state = get_harness_state(global_=True) + local_entry = state.create_memory("Local note", "Only this session.", id="local_note") + global_entry = state.create_memory("Global note", "All sessions.", id="global_note", global_=True) + kwargs_entry = state.create_memory( + "Kwargs global note", + "All sessions via kwargs.", + id="kwargs_global_note", + **{"global": True}, + ) + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + self.assertEqual(state.file_path, local_dir.resolve() / "harness_state.json") + self.assertEqual(global_state.file_path, global_dir.resolve() / "harness_state.json") + self.assertEqual(local_entry.scope, "local") + self.assertEqual(global_entry.scope, "global") + self.assertEqual(kwargs_entry.scope, "global") + self.assertIsNotNone(HarnessState(local_dir / "harness_state.json").get("memory", "local_note")) + self.assertIsNone(HarnessState(local_dir / "harness_state.json").get("memory", "global_note")) + self.assertIsNotNone(HarnessState(global_dir / "harness_state.json", scope="global").get("memory", "global_note")) + self.assertIsNotNone( + HarnessState(global_dir / "harness_state.json", scope="global").get("memory", "kwargs_global_note") + ) + + def test_global_kwarg_must_be_boolean(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + state = HarnessState(Path(temp_dir) / "harness_state.json") + + with self.assertRaisesRegex(TypeError, "global must be a bool"): + state.create_memory("Bad global flag", "bad", id="bad_global", **{"global": "false"}) + + def test_state_cache_keeps_scope_distinct_when_local_and_global_share_a_file(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + os.environ["RLM_HARNESS_STATE_DIR"] = temp_dir + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = temp_dir + try: + state = get_harness_state() + global_state = get_harness_state(global_=True) + local_entry = state.create_memory("Local note", "Only this session.", id="local_note") + global_entry = state.create_memory("Global note", "All sessions.", id="global_note", global_=True) + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + self.assertIsNot(state, global_state) + self.assertEqual(state.file_path, global_state.file_path) + self.assertEqual(state.scope, "local") + self.assertEqual(global_state.scope, "global") + self.assertEqual(local_entry.scope, "local") + self.assertEqual(global_entry.scope, "global") + reloaded = HarnessState(Path(temp_dir) / "harness_state.json") + self.assertEqual(reloaded.get("memory", "local_note").scope, "local") + self.assertEqual(reloaded.get("memory", "global_note").scope, "global") + + def test_scope_prefixed_ids_route_to_the_displayed_scope(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + local_dir = Path(temp_dir) / "local" + global_dir = Path(temp_dir) / "global" + os.environ["RLM_HARNESS_STATE_DIR"] = str(local_dir) + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(global_dir) + try: + state = get_harness_state() + state.create_memory("Global note", "v1", id="routed", global_=True) + + # The overview displays [global:routed]; that id must be usable as-is + # and imply the global scope without passing global_. + updated = state.update_memory("global:routed", "Global note", "v2") + self.assertEqual(updated.scope, "global") + self.assertEqual(state.get("memory", "global:routed").content, "v2") + self.assertIsNone(state.get("memory", "routed")) + + state.create_memory("Local note", "local", id="local_note") + self.assertEqual(state.get("memory", "local:local_note").content, "local") + self.assertTrue(state.delete_memory("local:local_note")) + self.assertIsNone(state.get("memory", "local_note")) + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + self.assertEqual( + HarnessState(global_dir / "harness_state.json", scope="global").get("memory", "routed").content, + "v2", + ) + + def test_create_with_prefixed_id_does_not_mint_literal_id(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + local_dir = Path(temp_dir) / "local" + global_dir = Path(temp_dir) / "global" + os.environ["RLM_HARNESS_STATE_DIR"] = str(local_dir) + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(global_dir) + try: + state = get_harness_state() + entry = state.create_memory("Validation", "content", id="global:validation") + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + self.assertEqual(entry.id, "validation") + self.assertEqual(entry.scope, "global") + global_store = HarnessState(global_dir / "harness_state.json", scope="global") + self.assertIsNotNone(global_store.get("memory", "validation")) + self.assertIsNone(global_store.get("memory", "global:validation")) + self.assertFalse((local_dir / "harness_state.json").exists()) + + def test_module_harness_binds_lazily_to_env_set_after_import(self) -> None: + # Forkserver scenario: rlm is imported in the template process without the + # per-session env; the child applies env after fork. rlm.harness must then + # resolve against the new env instead of a store frozen at import time. + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_session = os.environ.get("RLM_SESSION_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + try: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + os.environ.pop("RLM_SESSION_DIR", None) + # Without local env, local writes fail loudly instead of vanishing. + with self.assertRaisesRegex(RuntimeError, "global_=True"): + package_harness.create_memory("Volatile", "pre-env", id="pre_env") + + os.environ["RLM_HARNESS_STATE_DIR"] = temp_dir + entry = package_harness.create_memory("Session note", "persisted", id="session_note") + self.assertIsNone(package_harness.get("memory", "pre_env")) + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_session is None: + os.environ.pop("RLM_SESSION_DIR", None) + else: + os.environ["RLM_SESSION_DIR"] = previous_session + + self.assertEqual(entry.scope, "local") + reloaded = HarnessState(Path(temp_dir) / "harness_state.json") + self.assertEqual(reloaded.get("memory", "session_note").content, "persisted") + + def test_module_harness_without_env_raises_on_local_writes_and_reads_work(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_session = os.environ.get("RLM_SESSION_DIR") + try: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + os.environ.pop("RLM_SESSION_DIR", None) + + for mutate in ( + lambda: package_harness.create_memory("Lost", "content", id="lost"), + lambda: package_harness.update_memory("lost", "Lost", "content"), + lambda: package_harness.delete_memory("lost"), + lambda: package_harness.upsert("memory", "Lost", "content", id="lost"), + lambda: package_harness.record_refinement("trigger", ["change"]), + ): + with self.assertRaisesRegex(RuntimeError, "Local harness state requires.*global_=True"): + mutate() + + # Reads keep working against an empty view. + self.assertIsNone(package_harness.get("memory", "lost")) + self.assertEqual(package_harness.list(), []) + self.assertIn("memory: 0", package_harness.overview()) + self.assertEqual(package_harness.snapshot()["refinements"], []) + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_session is None: + os.environ.pop("RLM_SESSION_DIR", None) + else: + os.environ["RLM_SESSION_DIR"] = previous_session + + def test_module_harness_without_env_still_routes_global_writes(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_session = os.environ.get("RLM_SESSION_DIR") + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + global_dir = Path(temp_dir) / "global" + try: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + os.environ.pop("RLM_SESSION_DIR", None) + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(global_dir) + entry = package_harness.create_memory("Lesson", "keep me", id="no_session_lesson", global_=True) + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_session is None: + os.environ.pop("RLM_SESSION_DIR", None) + else: + os.environ["RLM_SESSION_DIR"] = previous_session + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + self.assertEqual(entry.scope, "global") + self.assertEqual( + HarnessState(global_dir / "harness_state.json", scope="global").get("memory", "no_session_lesson").content, + "keep me", + ) + + def test_import_rlm_without_env_does_not_raise(self) -> None: + env = dict(os.environ) + env.pop("RLM_HARNESS_STATE_DIR", None) + env.pop("RLM_SESSION_DIR", None) + env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src") + result = subprocess.run( + [sys.executable, "-c", "import rlm; repr(rlm.harness); rlm.harness.overview(); rlm.harness.create_memory"], + env=env, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_empty_local_state_dir_env_is_treated_as_unset(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_session = os.environ.get("RLM_SESSION_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + try: + # Empty local dir must not fall through to the global agent-dir default. + os.environ["RLM_HARNESS_STATE_DIR"] = "" + os.environ.pop("RLM_SESSION_DIR", None) + with self.assertRaisesRegex(RuntimeError, "Local harness state requires"): + HarnessState() + + # With a session dir it takes the session fallback instead. + os.environ["RLM_SESSION_DIR"] = temp_dir + state = HarnessState() + self.assertEqual(state.file_path, Path(temp_dir).resolve() / "harness" / "harness_state.json") + + # A whitespace-only session dir is also unset. + os.environ["RLM_SESSION_DIR"] = " " + with self.assertRaisesRegex(RuntimeError, "Local harness state requires"): + HarnessState() + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_session is None: + os.environ.pop("RLM_SESSION_DIR", None) + else: + os.environ["RLM_SESSION_DIR"] = previous_session + + def test_explicit_dir_aliasing_env_local_dir_keeps_env_global_target(self) -> None: + previous_local = os.environ.get("RLM_HARNESS_STATE_DIR") + previous_global = os.environ.get("RLM_GLOBAL_HARNESS_STATE_DIR") + with tempfile.TemporaryDirectory() as temp_dir: + local_dir = Path(temp_dir) / "local" + env_global_dir = Path(temp_dir) / "env-global" + os.environ["RLM_HARNESS_STATE_DIR"] = str(local_dir) + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = str(env_global_dir) + try: + # First construction happens via an explicit dir that merely aliases + # the env local dir; global writes must still hit the env global dir. + state = get_harness_state(local_dir) + global_entry = state.create_memory("Aliased", "still global", id="alias_global", global_=True) + finally: + if previous_local is None: + os.environ.pop("RLM_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_HARNESS_STATE_DIR"] = previous_local + if previous_global is None: + os.environ.pop("RLM_GLOBAL_HARNESS_STATE_DIR", None) + else: + os.environ["RLM_GLOBAL_HARNESS_STATE_DIR"] = previous_global + + self.assertEqual(global_entry.scope, "global") + self.assertIsNotNone( + HarnessState(env_global_dir / "harness_state.json", scope="global").get("memory", "alias_global") + ) + self.assertIsNone( + HarnessState(local_dir / "harness_state.json").get("memory", "alias_global") + ) + def test_callable_rlm_exposes_harness_state_helpers(self) -> None: self.assertIs(callable_rlm.harness, package_harness) self.assertIs(callable_rlm.get_harness_state, get_harness_state)