refactor(agents): extract attempt session boundaries

This commit is contained in:
Peter Steinberger
2026-07-14 03:22:23 +01:00
parent 130480871e
commit b9c947237a
5 changed files with 360 additions and 106 deletions
@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from "vitest";
import { buildTimestampPrefix } from "../../../gateway/server-methods/agent-timestamp.js";
import type { AgentMessage } from "../../runtime/index.js";
import type { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
import type { AgentSession } from "../../sessions/index.js";
import { prepareEmbeddedAttemptSessionBoundary } from "./attempt-session-boundary.js";
function createActiveSession(messages: AgentMessage[] = []) {
const reset = vi.fn();
const convertToLlm = vi.fn((input: AgentMessage[]) => input as never);
const activeSession = {
agent: {
reset,
state: { messages },
convertToLlm,
},
} as unknown as Pick<AgentSession, "agent">;
return { activeSession, convertToLlm, reset };
}
function createSessionManager(
overrides: Record<string, unknown> = {},
): ReturnType<typeof guardSessionManager> {
return {
getLeafEntry: () => undefined,
...overrides,
} as unknown as ReturnType<typeof guardSessionManager>;
}
describe("prepareEmbeddedAttemptSessionBoundary", () => {
it("resets restored state and preserves exact prompt bytes for raw model probes", async () => {
const { activeSession, reset } = createActiveSession();
const setActiveSessionSystemPrompt = vi.fn();
const boundary = prepareEmbeddedAttemptSessionBoundary({
activeSession,
attempt: { prompt: "exact probe" },
isRawModelRun: true,
preparedUserTurnMessage: undefined,
sessionManager: createSessionManager(),
setActiveSessionSystemPrompt,
});
const converted = await activeSession.agent.convertToLlm([
{ role: "user", content: [{ type: "text", text: "exact probe" }], timestamp: 1 },
]);
expect(reset).toHaveBeenCalledOnce();
expect(setActiveSessionSystemPrompt).toHaveBeenCalledWith("");
expect(boundary).toMatchObject({
boundaryTimezone: undefined,
includeBoundaryTimestamp: false,
orphanRepair: undefined,
});
expect((converted[0] as { content?: unknown }).content).toBe("exact probe");
});
it("applies the prepared current-turn timestamp at the LLM boundary", async () => {
const { activeSession } = createActiveSession();
const preparedTimestamp = 1_717_570_800_000;
const boundary = prepareEmbeddedAttemptSessionBoundary({
activeSession,
attempt: {
config: { agents: { defaults: { userTimezone: "UTC" } } },
prompt: "Current ask",
trigger: "user",
},
isRawModelRun: false,
preparedUserTurnMessage: undefined,
sessionManager: createSessionManager(),
setActiveSessionSystemPrompt: vi.fn(),
});
boundary.setCurrentUserTimestampOverride({
timestamp: preparedTimestamp,
text: "Current ask",
});
const converted = await activeSession.agent.convertToLlm([
{
role: "user",
content: [{ type: "text", text: "Current ask" }],
timestamp: preparedTimestamp + 60_000,
},
]);
expect((converted[0] as { content?: unknown }).content).toBe(
`${buildTimestampPrefix(new Date(preparedTimestamp), { timezone: "UTC" })}Current ask`,
);
});
it("repairs an orphaned user leaf before rebuilding active session messages", () => {
const repairedMessages: AgentMessage[] = [
{ role: "user", content: [{ type: "text", text: "repaired" }], timestamp: 2 },
];
const { activeSession } = createActiveSession([
{ role: "user", content: [{ type: "text", text: "old" }], timestamp: 1 },
]);
const branch = vi.fn();
const clearNextUserMessagePersistenceSuppression = vi.fn();
const onUserMessagePersistenceInvalidated = vi.fn();
const sessionManager = createSessionManager({
getLeafEntry: () => ({
id: "user-leaf",
parentId: "parent-entry",
type: "message",
timestamp: "2026-07-13T00:00:00.000Z",
message: { role: "user", content: "old" },
}),
branch,
clearNextUserMessagePersistenceSuppression,
buildSessionContext: () => ({ messages: repairedMessages }),
});
const boundary = prepareEmbeddedAttemptSessionBoundary({
activeSession,
attempt: {
onUserMessagePersistenceInvalidated,
prompt: "new",
trigger: "user",
},
isRawModelRun: false,
preparedUserTurnMessage: undefined,
sessionManager,
setActiveSessionSystemPrompt: vi.fn(),
});
expect(boundary.orphanRepair?.removeLeaf).toBe(true);
expect(branch).toHaveBeenCalledWith("parent-entry");
expect(clearNextUserMessagePersistenceSuppression).toHaveBeenCalledOnce();
expect(onUserMessagePersistenceInvalidated).toHaveBeenCalledOnce();
expect(activeSession.agent.state.messages).toBe(repairedMessages);
});
});
@@ -0,0 +1,119 @@
/** Prepares the restored transcript at the LLM boundary for one attempt. */
import { resolveUserTimezone } from "../../date-time.js";
import { relocateCurrentRuntimeContextCarrierToTail } from "../../internal-runtime-context.js";
import type { AgentMessage } from "../../runtime/index.js";
import type { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
import type { AgentSession } from "../../sessions/index.js";
import {
replayTrailingEntriesForOrphanRepair,
resolveOrphanRepairPlan,
} from "./attempt-orphan-repair.js";
import { normalizeMessagesForLlmBoundary } from "./attempt.llm-boundary.js";
import { detachPrePersistedCurrentUserTurn } from "./pre-persisted-user-turn.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
type SessionBoundaryAttempt = Pick<
EmbeddedRunAttemptParams,
| "config"
| "onUserMessagePersistenceInvalidated"
| "prompt"
| "suppressNextUserMessagePersistence"
| "trigger"
| "userTurnTranscriptRecorder"
>;
type LlmBoundaryOptions = NonNullable<Parameters<typeof normalizeMessagesForLlmBoundary>[1]>;
export type CurrentUserTimestampOverride = NonNullable<
LlmBoundaryOptions["currentUserTimestampOverride"]
>;
export function prepareEmbeddedAttemptSessionBoundary(input: {
activeSession: Pick<AgentSession, "agent">;
attempt: SessionBoundaryAttempt;
isRawModelRun: boolean;
preparedUserTurnMessage: AgentMessage | undefined;
sessionManager: ReturnType<typeof guardSessionManager>;
setActiveSessionSystemPrompt: (systemPrompt: string) => void;
}): {
boundaryTimezone: string | undefined;
includeBoundaryTimestamp: boolean;
orphanRepair: ReturnType<typeof resolveOrphanRepairPlan>;
setCurrentUserTimestampOverride: (override: CurrentUserTimestampOverride | undefined) => void;
} {
const { activeSession, attempt, isRawModelRun, sessionManager } = input;
if (isRawModelRun) {
// Raw probes measure only the requested provider prompt. Restored history,
// queued work, and the normal system prompt would contaminate it.
activeSession.agent.reset();
input.setActiveSessionSystemPrompt("");
}
const orphanRepair = isRawModelRun
? undefined
: resolveOrphanRepairPlan({
sessionManager,
prompt: attempt.prompt,
trigger: attempt.trigger,
});
if (orphanRepair?.removeLeaf) {
if (orphanRepair.messageEntry.parentId) {
sessionManager.branch(orphanRepair.messageEntry.parentId);
} else {
sessionManager.resetLeaf();
}
replayTrailingEntriesForOrphanRepair(sessionManager, orphanRepair.trailingEntries);
// The old canonical user turn is gone. Its persistence suppression must not
// discard the merged replacement prompt.
sessionManager.clearNextUserMessagePersistenceSuppression?.();
attempt.onUserMessagePersistenceInvalidated?.();
activeSession.agent.state.messages = sessionManager.buildSessionContext().messages;
}
detachPrePersistedCurrentUserTurn({
activeSession,
preparedUserTurnMessage: input.preparedUserTurnMessage,
suppressNextUserMessagePersistence: attempt.suppressNextUserMessagePersistence,
userTurnAlreadyPersisted: attempt.userTurnTranscriptRecorder?.hasPersisted() === true,
});
// This is the single timestamping source for user messages sent to the LLM.
// Raw probes retain exact prompt bytes.
const boundaryTimezone = isRawModelRun
? undefined
: resolveUserTimezone(attempt.config?.agents?.defaults?.userTimezone);
const includeBoundaryTimestamp =
!isRawModelRun && attempt.config?.agents?.defaults?.envelopeTimestamp !== "off";
let currentUserTimestampOverride: CurrentUserTimestampOverride | undefined;
const buildBoundaryOptions = (): LlmBoundaryOptions | undefined => {
if (isRawModelRun) {
return undefined;
}
return {
...(boundaryTimezone ? { timezone: boundaryTimezone } : {}),
...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }),
...(currentUserTimestampOverride ? { currentUserTimestampOverride } : {}),
};
};
if (typeof activeSession.agent.convertToLlm === "function") {
const baseConvertToLlm = activeSession.agent.convertToLlm.bind(activeSession.agent);
activeSession.agent.convertToLlm = async (messages) =>
await baseConvertToLlm(
// Wire-only relocation keeps the request append-only through the active
// user turn without changing position-sensitive precheck normalization.
relocateCurrentRuntimeContextCarrierToTail(
normalizeMessagesForLlmBoundary(messages, buildBoundaryOptions()),
),
);
}
return {
boundaryTimezone,
includeBoundaryTimestamp,
orphanRepair,
setCurrentUserTimestampOverride: (override) => {
currentUserTimestampOverride = override;
},
};
}
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentSession } from "../../sessions/index.js";
import { createEmbeddedAttemptSessionSettleTracker } from "./attempt-session-settle.js";
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
describe("createEmbeddedAttemptSessionSettleTracker", () => {
it("waits for both prompt and abort settlement during cleanup", async () => {
const prompt = deferred();
const abort = deferred();
const abortSession = vi.fn(() => abort.promise);
const tracker = createEmbeddedAttemptSessionSettleTracker({
abort: abortSession,
} as unknown as Pick<AgentSession, "abort">);
void tracker.trackPromptSettlePromise(prompt.promise);
const abortReason = new Error("stop");
void tracker.abortActiveSession(abortReason);
const settled = tracker.buildAbortSettlePromise();
expect(settled).not.toBeNull();
expect(abortSession).toHaveBeenCalledWith(abortReason);
let finished = false;
void settled?.then(() => {
finished = true;
});
prompt.resolve();
await Promise.resolve();
expect(finished).toBe(false);
abort.resolve();
await settled;
expect(finished).toBe(true);
expect(tracker.buildAbortSettlePromise()).toBeNull();
});
it("settles rejected prompt work without leaking it into later cleanup", async () => {
const tracker = createEmbeddedAttemptSessionSettleTracker({
abort: async () => undefined,
} as unknown as Pick<AgentSession, "abort">);
void tracker.trackPromptSettlePromise(Promise.reject(new Error("prompt failed")));
await expect(tracker.buildAbortSettlePromise()).resolves.toBeUndefined();
expect(tracker.buildAbortSettlePromise()).toBeNull();
});
});
@@ -0,0 +1,43 @@
/** Tracks native prompt and abort settlement through attempt cleanup. */
import type { AgentSession } from "../../sessions/index.js";
export function createEmbeddedAttemptSessionSettleTracker(
activeSession: Pick<AgentSession, "abort">,
): {
abortActiveSession: (reason?: unknown) => Promise<void>;
buildAbortSettlePromise: () => Promise<void> | null;
trackPromptSettlePromise: (promise: Promise<void>) => Promise<void>;
} {
const inFlightPromptSettlePromises = new Set<Promise<void>>();
const inFlightAbortSettlePromises = new Set<Promise<void>>();
const trackSettlePromise = (
promises: Set<Promise<void>>,
promise: Promise<void>,
): Promise<void> => {
promises.add(promise);
void promise.then(
() => {
promises.delete(promise);
},
() => {
promises.delete(promise);
},
);
return promise;
};
const trackPromptSettlePromise = (promise: Promise<void>): Promise<void> =>
trackSettlePromise(inFlightPromptSettlePromises, promise);
const abortActiveSession = (reason?: unknown): Promise<void> =>
trackSettlePromise(inFlightAbortSettlePromises, Promise.resolve(activeSession.abort(reason)));
const buildAbortSettlePromise = (): Promise<void> | null => {
const promises = [...inFlightPromptSettlePromises, ...inFlightAbortSettlePromises];
return promises.length === 0 ? null : Promise.allSettled(promises).then(() => undefined);
};
return {
abortActiveSession,
buildAbortSettlePromise,
trackPromptSettlePromise,
};
}
+14 -106
View File
@@ -36,12 +36,10 @@ import { resolveAgentDir, resolveSessionAgentIds } from "../../agent-scope.js";
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
import { isHeartbeatLifecycleRunKind } from "../../bootstrap-mode.js";
import { createCacheTrace } from "../../cache-trace.js";
import { resolveUserTimezone } from "../../date-time.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js";
import { countActiveToolExecutions } from "../../embedded-agent-subscribe.handlers.tools.js";
import { isSignalTimeoutReason } from "../../failover-error.js";
import { resolveImageSanitizationLimits } from "../../image-sanitization.js";
import { relocateCurrentRuntimeContextCarrierToTail } from "../../internal-runtime-context.js";
import type { AgentMessage } from "../../runtime/index.js";
import type { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
import { acquireSessionWriteLock } from "../../session-write-lock.js";
@@ -80,10 +78,6 @@ import { prepareEmbeddedAttemptBootstrap } from "./attempt-bootstrap-prepare.js"
import { prepareEmbeddedAttemptBundleTools } from "./attempt-bundle-tools.js";
import { summarizeSessionContext } from "./attempt-context-summary.js";
import { prepareEmbeddedAttemptHistory } from "./attempt-history-prepare.js";
import {
replayTrailingEntriesForOrphanRepair,
resolveOrphanRepairPlan,
} from "./attempt-orphan-repair.js";
import { prepareEmbeddedAttemptPromptAssembly } from "./attempt-prompt-assembly.js";
import { prepareEmbeddedAttemptPromptContext } from "./attempt-prompt-context.js";
import {
@@ -92,8 +86,10 @@ import {
} from "./attempt-prompt-preflight.js";
import { submitEmbeddedAttemptPrompt } from "./attempt-prompt-submit.js";
import { completeEmbeddedAttemptResult } from "./attempt-result.js";
import { prepareEmbeddedAttemptSessionBoundary } from "./attempt-session-boundary.js";
import { cleanupEmbeddedAttemptSessionPhase } from "./attempt-session-cleanup.js";
import { prepareEmbeddedAttemptSessionManager } from "./attempt-session-manager-prepare.js";
import { createEmbeddedAttemptSessionSettleTracker } from "./attempt-session-settle.js";
import { prepareEmbeddedAttemptAgentSession } from "./attempt-session.js";
import { prepareEmbeddedAttemptSetup } from "./attempt-setup.js";
import { createEmbeddedRunStageTracker } from "./attempt-stage-timing.js";
@@ -117,7 +113,6 @@ import {
resolveAttemptTrajectorySessionFile,
} from "./attempt-transcript-helpers.js";
import { buildLoopPromptCacheInfo } from "./attempt.context-engine-helpers.js";
import { normalizeMessagesForLlmBoundary } from "./attempt.llm-boundary.js";
import {
buildAfterTurnRuntimeContext,
resolvePromptSubmissionSkipReason,
@@ -141,7 +136,6 @@ import { shouldFlagCompactionTimeout } from "./compaction-timeout.js";
import { installHistoryImagePruneContextTransform } from "./history-image-prune.js";
import { detectAndLoadPromptImages } from "./images.js";
import { isMidTurnPrecheckSignal, type MidTurnPrecheckRequest } from "./midturn-precheck.js";
import { detachPrePersistedCurrentUserTurn } from "./pre-persisted-user-turn.js";
import { PREEMPTIVE_OVERFLOW_ERROR_TEXT } from "./preemptive-compaction.js";
import { clearToolActivityRun } from "./tool-activity-heartbeat.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
@@ -587,113 +581,25 @@ export async function runEmbeddedAttempt(
sessionLockController,
sessionManager,
});
if (isRawModelRun) {
// Raw model probes should measure exactly the requested prompt against
// the selected provider/model. Reset clears restored transcript state
// and queues; the empty system prompt prevents the runtime from rebuilding the
// normal OpenClaw agent/tool prompt when `session.prompt()` starts.
activeSession.agent.reset();
setActiveSessionSystemPrompt("");
}
const orphanRepair = isRawModelRun
? undefined
: resolveOrphanRepairPlan({
sessionManager,
prompt: params.prompt,
trigger: params.trigger,
});
if (orphanRepair?.removeLeaf) {
if (orphanRepair.messageEntry.parentId) {
sessionManager.branch(orphanRepair.messageEntry.parentId);
} else {
sessionManager.resetLeaf();
}
replayTrailingEntriesForOrphanRepair(sessionManager, orphanRepair.trailingEntries);
// Suppression assumes the canonical user turn still exists. Orphan repair
// removed it, so the replacement prompt must become the one durable copy.
sessionManager.clearNextUserMessagePersistenceSuppression?.();
params.onUserMessagePersistenceInvalidated?.();
activeSession.agent.state.messages = sessionManager.buildSessionContext().messages;
}
detachPrePersistedCurrentUserTurn({
const sessionBoundary = prepareEmbeddedAttemptSessionBoundary({
activeSession,
attempt: params,
isRawModelRun,
preparedUserTurnMessage,
suppressNextUserMessagePersistence: params.suppressNextUserMessagePersistence,
userTurnAlreadyPersisted: params.userTurnTranscriptRecorder?.hasPersisted() === true,
sessionManager,
setActiveSessionSystemPrompt,
});
// Single source for the per-message timestamp prefix (issue #3658):
// normal embedded runs stamp every user message from its own timestamp.
// Raw model probes must keep the requested prompt text exact.
const boundaryTimezone = isRawModelRun
? undefined
: resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const includeBoundaryTimestamp =
!isRawModelRun && params.config?.agents?.defaults?.envelopeTimestamp !== "off";
let currentUserTimestampOverride:
| { timestamp: number; text: string; alternateText?: string }
| undefined;
const buildBoundaryOptions = () => {
if (isRawModelRun) {
return undefined;
}
return {
...(boundaryTimezone ? { timezone: boundaryTimezone } : {}),
...(includeBoundaryTimestamp ? {} : { includeTimestamp: false }),
...(currentUserTimestampOverride ? { currentUserTimestampOverride } : {}),
};
};
if (typeof activeSession.agent.convertToLlm === "function") {
const baseConvertToLlm = activeSession.agent.convertToLlm.bind(activeSession.agent);
activeSession.agent.convertToLlm = async (messages) =>
await baseConvertToLlm(
// Wire-only: move the current-turn runtime-context carrier to the
// absolute tail so the request is an append-only prefix-extension
// through the active user turn (see the function's cache rationale).
// Applied here, not inside normalizeMessagesForLlmBoundary, because
// normalizeMessagesForCurrentPromptBoundary slices off its appended
// prompt by position and must not see the carrier relocated past it.
relocateCurrentRuntimeContextCarrierToTail(
normalizeMessagesForLlmBoundary(messages, buildBoundaryOptions()),
),
);
}
const { boundaryTimezone, includeBoundaryTimestamp, orphanRepair } = sessionBoundary;
let prePromptMessageCount = activeSession.messages.length;
// Session-owned projections survive attempt teardown so already-sent tool results
// cannot rewrite the provider prompt-cache tail between turns (#99495).
const sessionPromptState = getEmbeddedSessionPromptState(params.sessionId);
const toolResultPromptProjectionState = sessionPromptState.toolResults;
let contextEngineAfterTurnCheckpoint: number | null = null;
const inFlightPromptSettlePromises = new Set<Promise<void>>();
const inFlightAbortSettlePromises = new Set<Promise<void>>();
const trackSettlePromise = (
promises: Set<Promise<void>>,
promise: Promise<void>,
): Promise<void> => {
promises.add(promise);
void promise.then(
() => {
promises.delete(promise);
},
() => {
promises.delete(promise);
},
);
return promise;
};
const trackPromptSettlePromise = (promise: Promise<void>): Promise<void> =>
trackSettlePromise(inFlightPromptSettlePromises, promise);
const trackAbortSettlePromise = (promise: Promise<void>): Promise<void> =>
trackSettlePromise(inFlightAbortSettlePromises, promise);
const abortActiveSession = (reason?: unknown): Promise<void> =>
trackAbortSettlePromise(Promise.resolve(activeSession.abort(reason)));
const sessionSettleTracker = createEmbeddedAttemptSessionSettleTracker(activeSession);
const { abortActiveSession, trackPromptSettlePromise } = sessionSettleTracker;
abortActiveSessionForExternalSignal = abortActiveSession;
buildAbortSettlePromise = (): Promise<void> | null => {
const promises = [...inFlightPromptSettlePromises, ...inFlightAbortSettlePromises];
if (promises.length === 0) {
return null;
}
return Promise.allSettled(promises).then(() => undefined);
};
buildAbortSettlePromise = sessionSettleTracker.buildAbortSettlePromise;
abortSessionForYield = () => {
yieldAbortSettled = abortActiveSession(SESSIONS_YIELD_ABORT_REASON);
};
@@ -1255,7 +1161,9 @@ export async function runEmbeddedAttempt(
systemPromptForHook,
} = promptContext;
prePromptMessageCount = promptContext.prePromptMessageCount;
currentUserTimestampOverride = promptContext.currentUserTimestampOverride;
sessionBoundary.setCurrentUserTimestampOverride(
promptContext.currentUserTimestampOverride,
);
if (aggregatePressureEngaged) {
// Compaction and aggregate truncation both target about half the window;
// compact-then-truncate prevents re-hitting the same cap on the next turn.