Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 46 additions & 135 deletions components/adapters/deepseek-harness/src/eviction-cycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,11 @@
* `mapTaskUpdatesToRegistryPatch` + `applySessionTaskRegistryPatch`.
*/

import {
applySessionTaskRegistryPatch,
type SessionTaskRegistry,
} from "@lightrsi/history";
import { mapTaskUpdatesToRegistryPatch, type TaskStateEstimator } from "@lightrsi/eviction";
import type { SessionTaskRegistry } from "@lightrsi/history";
import { runCanonicalEvictionCycle, type TaskStateEstimator } from "@lightrsi/eviction";

import { buildDshDeltaView, buildDshRawSemanticSnapshot } from "./session-codec.js";
import { runTaskStateEstimate } from "./lifecycle-estimator.js";
import { applySafetyPolicy, type SafetyItem, type TaskState } from "./safety-policy.js";
import { buildDshRawSemanticSnapshot } from "./session-codec.js";
import type { SafetyItem } from "./safety-policy.js";
import {
applyEvictionTransaction,
type AppendableSession,
Expand All @@ -30,7 +26,7 @@ import {
type TransactionResult,
} from "./surface-transaction.js";
import type { DshLogEventWithMeta, DshMessage } from "./types.js";
import { assistantCallIds, resultCallId } from "./tool-closure.js";
import { assistantCallIds, buildToolPairs, resultCallId } from "./tool-closure.js";

/** The session view the cycle needs: durable log + surface + append (R4). */
export interface CycleSession extends AppendableSession {
Expand Down Expand Up @@ -125,28 +121,6 @@ export function describeEffectiveItems(
return items;
}

/** Classify an item's task state from the registry (never re-inferred from content). */
function classify(
item: EffectiveItem,
registry: SessionTaskRegistry,
currentTurn: number,
): { taskState: TaskState; current: boolean } {
if (item.turn >= currentTurn) return { taskState: "current", current: true };

const turnAbsId = `${registry.sessionId}:t${item.turn}`;
const taskIds = registry.turnToTaskIds[turnAbsId] ?? [];
const evictable = new Set(registry.evictableTaskIds);
const completed = new Set(registry.completedTaskIds);
const active = new Set(registry.activeTaskIds);

// Removable only if EVERY owning task is completed/evictable and none active.
if (taskIds.length > 0 && taskIds.every((id) => evictable.has(id) || completed.has(id)) && !taskIds.some((id) => active.has(id))) {
return { taskState: "completed", current: false };
}
if (taskIds.some((id) => active.has(id))) return { taskState: "active", current: false };
return { taskState: "unresolved", current: false };
}

function currentTurnOf(events: readonly DshLogEventWithMeta[]): number {
let turn = 0;
for (const e of events) {
Expand All @@ -156,7 +130,11 @@ function currentTurnOf(events: readonly DshLogEventWithMeta[]): number {
return turn;
}

/** Run one eviction cycle. Registry in → updated registry + transaction result out. */
/**
* Run one eviction cycle. Registry in → updated registry + transaction result out.
* The cycle itself is the shared `runCanonicalEvictionCycle`; this wrapper
* supplies DSH's snapshot, surface items, tool pairs, and native transaction.
*/
export async function runDshEvictionCycle(params: {
session: CycleSession;
registry: SessionTaskRegistry;
Expand All @@ -168,123 +146,56 @@ export async function runDshEvictionCycle(params: {
allowSurfaceMutation?: boolean;
persistRegistry?: (registry: SessionTaskRegistry, expectedVersion: number) => void | Promise<void>;
}): Promise<EvictionCycleResult> {
const { session, estimator, computeRevision } = params;
const { session, computeRevision } = params;

const snapshot = buildDshRawSemanticSnapshot(session.id, session.events, {
surfaceEventSeqs: session.surface.nodes,
});
const delta = buildDshDeltaView(snapshot, { fromTurnSeqExclusive: params.registry.lastProcessedTurnSeq });
let registry = params.registry;
let registryPersistedBeforeMutation = false;
let registryExpectedVersion = params.registry.version;
if (delta.coveredTurnAbsIds.length > 0) {
// Estimator → registry update (shared mapper; no registry logic reinvented).
const output = await runTaskStateEstimate(estimator, { registry: params.registry, delta });
const { patch } = mapTaskUpdatesToRegistryPatch({
registry: params.registry,
updates: output.taskUpdates,
coveredTurnAbsIds: delta.coveredTurnAbsIds,
toTurnSeqInclusive: delta.toTurnSeqInclusive,
});
registry = applySessionTaskRegistryPatch(params.registry, patch);
// Registry CAS is a precondition for canonical mutation. Persist task state
// first, but keep the watermark behind until the surface transaction lands;
// otherwise a partial append would permanently hide its unprocessed tail.
const pendingRegistry = {
...registry,
lastProcessedTurnSeq: params.registry.lastProcessedTurnSeq,
};
await params.persistRegistry?.(pendingRegistry, params.registry.version);
registry = pendingRegistry;
registryPersistedBeforeMutation = params.persistRegistry !== undefined;
registryExpectedVersion = pendingRegistry.version;
}

// Classify effective items against the updated registry, then R3 safety filter.
const currentTurn = currentTurnOf(session.events);
const effective = describeEffectiveItems(session.events, session.surface.nodes);
const bySeq = new Map(effective.map((it) => [it.seq, it]));
const safetyItems: SafetyItem[] = effective.map((it) => {
const c = classify(it, registry, currentTurn);
return {
const evictionId = params.evictionId ?? `dsh-evict-${session.id}-${currentTurn}`;

const cycle = await runCanonicalEvictionCycle<number, TransactionResult>({
snapshot,
registry: params.registry,
estimator: params.estimator,
items: effective.map((it) => ({
sourceEventSeq: it.seq,
turn: it.turn,
kind: it.kind,
taskState: c.taskState,
current: c.current,
callIds: it.callIds,
chars: it.chars,
};
});

const decision = applySafetyPolicy(
safetyItems,
session.surface.nodes,
session.events,
params.minBlockChars ?? 0,
);
if (decision.evictSeqs.length === 0) {
registry = { ...registry, lastProcessedTurnSeq: delta.toTurnSeqInclusive };
if (registryPersistedBeforeMutation && params.persistRegistry) {
await params.persistRegistry(registry, registryExpectedVersion);
}
return {
registry,
result: emptyResult(),
registryPersisted: true,
status: delta.coveredTurnAbsIds.length === 0 ? "no-delta" : "empty",
};
}

// Context Cleaner needs the same completed-task registry as automatic
// eviction, but a Cleaner-only profile must never remove context before the
// user makes an explicit selection. Advance the durable lifecycle
// watermark after a successful estimate while leaving DSH's surface intact.
if (params.allowSurfaceMutation === false) {
registry = { ...registry, lastProcessedTurnSeq: delta.toTurnSeqInclusive };
if (registryPersistedBeforeMutation && params.persistRegistry) {
await params.persistRegistry(registry, registryExpectedVersion);
}
return {
registry,
result: emptyResult(),
registryPersisted: true,
status: "empty",
};
}

// Build the plan and apply it as a canonical transaction (R4).
const targets: EvictionTarget[] = decision.evictSeqs.map((seq) => {
const it = bySeq.get(seq);
if (!it) throw new Error(`eviction target ${seq} is not a current surface item`);
return buildEvictionTarget(it, params.evictionId ?? `dsh-evict-${session.id}-${currentTurn}`);
})),
effectiveSeqs: session.surface.nodes,
pairs: buildToolPairs(session.events, session.surface.nodes),
currentTurn,
minBlockChars: params.minBlockChars,
allowSurfaceMutation: params.allowSurfaceMutation,
compareSeqs: (a, b) => a - b,
persistRegistry: params.persistRegistry,
emptyResult,
apply(evictSeqs) {
// Build the plan and apply it as a canonical transaction (R4).
const targets: EvictionTarget[] = evictSeqs.map((seq) => {
const it = bySeq.get(seq);
if (!it) throw new Error(`eviction target ${seq} is not a current surface item`);
return buildEvictionTarget(it, evictionId);
});
const plan: EvictionPlan = {
evictionId,
revision: computeRevision(session),
targets,
};
return applyEvictionTransaction(session, plan, computeRevision);
},
});
const plan: EvictionPlan = {
evictionId: params.evictionId ?? `dsh-evict-${session.id}-${currentTurn}`,
revision: computeRevision(session),
targets,
};
const result = applyEvictionTransaction(session, plan, computeRevision);

const shouldAdvanceWatermark = result.status === "committed";
const finalRegistry = shouldAdvanceWatermark
? { ...registry, lastProcessedTurnSeq: delta.toTurnSeqInclusive }
: registry;
let registryPersisted = true;
if (shouldAdvanceWatermark && registryPersistedBeforeMutation && params.persistRegistry) {
try {
await params.persistRegistry(finalRegistry, registryExpectedVersion);
} catch {
// Replacements already landed. Keep the in-memory result truthful and let
// the next cycle recover the stale watermark from the canonical surface.
registryPersisted = false;
}
}

return {
registry: finalRegistry,
result,
registryPersisted,
status: result.status === "committed" || result.status === "partial" ? "applied" : "deferred",
registry: cycle.registry,
result: cycle.result,
registryPersisted: cycle.registryPersisted,
status: cycle.status,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
*/

import {
createApiTaskStateEstimator,
createConfiguredTaskStateEstimator,
isTaskStateEstimatorConfigured,
type TaskStateEstimator,
type TaskStateEstimatorApiConfig,
type TaskStateEstimatorInput,
Expand All @@ -23,7 +24,7 @@ import type { DshEstimatorConfig } from "./config.js";

/** True only when the estimator has the endpoint + credentials it needs to run. */
export function isEstimatorConfigured(cfg: DshEstimatorConfig): boolean {
return Boolean(cfg.baseUrl && cfg.apiKey && cfg.model);
return isTaskStateEstimatorConfigured(cfg);
}

/** Map the DSH adapter config onto the shared estimator config (field-for-field). */
Expand All @@ -48,9 +49,7 @@ export function toEstimatorApiConfig(cfg: DshEstimatorConfig): TaskStateEstimato
* lets the pre-step handler fail open and defer.
*/
export function createDshTaskStateEstimator(cfg: DshEstimatorConfig): TaskStateEstimator | undefined {
if (cfg.enabled === false) return undefined;
if (!isEstimatorConfigured(cfg)) return undefined;
return createApiTaskStateEstimator(toEstimatorApiConfig(cfg));
return createConfiguredTaskStateEstimator(toEstimatorApiConfig(cfg));
}

/** Run the shared estimator over the codec-produced input. Output is opaque here. */
Expand Down
105 changes: 20 additions & 85 deletions components/adapters/deepseek-harness/src/safety-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,23 @@
* (tests/fixtures/session-events.json).
*/

import { buildToolPairs, isEvictablePair, type ClosureEvent } from "./tool-closure.js";
import {
applySurfaceSafetyPolicy,
type SurfaceItemKind,
type SurfaceSafetyAction,
type SurfaceSafetyItem,
type SurfaceTaskState,
} from "@lightrsi/eviction";

export type TaskState = "completed" | "unresolved" | "current" | "active" | "blocked";
import { buildToolPairs, type ClosureEvent } from "./tool-closure.js";

export type ItemKind = "message" | "tool_call" | "tool_result" | "compaction_checkpoint" | string;
export type TaskState = SurfaceTaskState;

export interface SafetyItem {
sourceEventSeq: number;
kind: ItemKind;
taskState: TaskState;
current: boolean;
/** Present for tool_call / tool_result items; ties the item to its pair. */
callIds?: readonly string[];
/** Visible characters that would actually be replaced. */
chars?: number;
}
export type ItemKind = SurfaceItemKind;

export type SafetyItem = SurfaceSafetyItem<number>;

export type SafetyAction = "evict" | "keep";
export type SafetyAction = SurfaceSafetyAction;

export interface SafetyDecision {
action: Map<number, SafetyAction>;
Expand All @@ -43,88 +42,24 @@ export interface SafetyDecision {
deferredCallIds: string[];
}

/** True when an item is individually clearable (ignoring tool-group coupling). */
function isClearable(item: SafetyItem, effective: Set<number>): boolean {
if (!effective.has(item.sourceEventSeq)) return false;
if (item.current) return false;
if (item.kind === "compaction_checkpoint") return false;
return item.taskState === "completed";
}

/**
* Apply the safety policy. `events` is only needed to re-derive tool pairs over
* the effective surface; item task state comes from the caller (the estimator /
* registry), never re-inferred here.
* registry), never re-inferred here. The policy itself is the shared
* canonical-surface policy in `@lightrsi/eviction`.
*/
export function applySafetyPolicy(
items: readonly SafetyItem[],
effectiveSeqs: Iterable<number>,
events: readonly ClosureEvent[],
minBlockChars = 0,
): SafetyDecision {
const effective = new Set(effectiveSeqs);
const action = new Map<number, SafetyAction>();
for (const item of items) action.set(item.sourceEventSeq, "keep"); // default keep

const effective = [...effectiveSeqs];
const pairs = buildToolPairs(events, effective);
const itemsBySeq = new Map(items.map((it) => [it.sourceEventSeq, it]));
const deferredCallIds: string[] = [];

// DSH keeps tool calls inside assistant messages. Preserve that call envelope
// and only rewrite a result when its pair is strict and both sides are safe.
const tooledSeqs = new Set<number>();
const unsafeToolSeqs = new Set<number>();
const unsafeCallEnvelopeSeqs = new Set<number>();
for (const pair of pairs.values()) {
const memberSeqs = [...pair.callSeqs, ...pair.resultSeqs];
for (const seq of memberSeqs) tooledSeqs.add(seq);

const everyMemberClearable = memberSeqs.every((seq) => {
const it = itemsBySeq.get(seq);
return it ? isClearable(it, effective) : false;
});

if (!isEvictablePair(pair.status) || !everyMemberClearable) {
for (const seq of memberSeqs) unsafeToolSeqs.add(seq);
for (const seq of pair.callSeqs) unsafeCallEnvelopeSeqs.add(seq);
deferredCallIds.push(pair.callId);
}
}

// One assistant message may carry multiple calls. If any call in that shared
// envelope is unsafe, no sibling result may be rewritten independently.
for (const pair of pairs.values()) {
if (!pair.callSeqs.some((seq) => unsafeCallEnvelopeSeqs.has(seq))) continue;
for (const seq of [...pair.callSeqs, ...pair.resultSeqs]) unsafeToolSeqs.add(seq);
deferredCallIds.push(pair.callId);
}

for (const item of items) {
if (!tooledSeqs.has(item.sourceEventSeq)) continue;
const resultIsLargeEnough = item.kind === "tool_result" && (item.chars ?? 0) >= minBlockChars;
action.set(
item.sourceEventSeq,
resultIsLargeEnough && !unsafeToolSeqs.has(item.sourceEventSeq) ? "evict" : "keep",
);
}

// Non-tool items: evict iff individually clearable.
for (const item of items) {
if (tooledSeqs.has(item.sourceEventSeq)) continue;
action.set(
item.sourceEventSeq,
isClearable(item, effective) && (item.chars ?? 0) >= minBlockChars ? "evict" : "keep",
);
}

const evictSeqs: number[] = [];
const keepSeqs: number[] = [];
for (const [seq, act] of action) (act === "evict" ? evictSeqs : keepSeqs).push(seq);
evictSeqs.sort((a, b) => a - b);
keepSeqs.sort((a, b) => a - b);
const uniqueDeferredCallIds = [...new Set(deferredCallIds)].sort();

return { action, evictSeqs, keepSeqs, deferredCallIds: uniqueDeferredCallIds };
const decision = applySurfaceSafetyPolicy(items, effective, pairs, minBlockChars);
decision.evictSeqs.sort((a, b) => a - b);
decision.keepSeqs.sort((a, b) => a - b);
return decision;
}

/**
Expand Down
Loading
Loading