Skip to content

Commit e8311d6

Browse files
committed
refactor(agents): split cli session reuse drift contract
1 parent ab0d604 commit e8311d6

7 files changed

Lines changed: 211 additions & 77 deletions

File tree

src/agents/cli-runner.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ import {
2929
loadCliSessionContextEngineMessages,
3030
loadCliSessionHistoryMessages,
3131
} from "./cli-runner/session-history.js";
32-
import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";
32+
import type {
33+
CliReusableSession,
34+
PreparedCliRunContext,
35+
RunCliAgentParams,
36+
} from "./cli-runner/types.js";
3337
import { claudeCliSessionTranscriptHasContent as claudeCliSessionTranscriptHasContentImpl } from "./command/attempt-execution.helpers.js";
3438
import { classifyFailoverReason, isFailoverErrorMessage } from "./embedded-agent-helpers.js";
3539
import type { EmbeddedAgentRunResult } from "./embedded-agent-runner.js";
@@ -86,6 +90,12 @@ function isClaudeCliProvider(provider: string): boolean {
8690
return provider.trim().toLowerCase() === "claude-cli";
8791
}
8892

93+
function resolveReusableCliSessionId(reusableCliSession: CliReusableSession): string | undefined {
94+
return reusableCliSession.mode === "reuse" || reusableCliSession.mode === "reuse-with-drift"
95+
? reusableCliSession.sessionId
96+
: undefined;
97+
}
98+
8999
function shouldRetryFreshCliSessionAfterFailover(params: {
90100
error: FailoverError;
91101
hasHistoryPrompt: boolean;
@@ -727,7 +737,9 @@ export async function runPreparedCliAgent(
727737
sessionId: "",
728738
provider: params.provider,
729739
model: context.modelId,
730-
...(context.reusableCliSession.sessionId ? { clearCliSessionBinding: true } : {}),
740+
...(resolveReusableCliSessionId(context.reusableCliSession)
741+
? { clearCliSessionBinding: true }
742+
: {}),
731743
},
732744
},
733745
didSendViaMessagingTool: true,
@@ -1158,18 +1170,19 @@ export async function runPreparedCliAgent(
11581170
ctx: hookContext,
11591171
hookRunner,
11601172
});
1173+
const reusableCliSessionId = resolveReusableCliSessionId(context.reusableCliSession);
11611174
try {
11621175
return await finishCliAttempt(
1163-
await executeCliAttempt(context.reusableCliSession.sessionId),
1164-
context.reusableCliSession.sessionId,
1176+
await executeCliAttempt(reusableCliSessionId),
1177+
reusableCliSessionId,
11651178
);
11661179
} catch (err) {
11671180
const deliveredFailure = await finishDeliveredFailure(err);
11681181
if (deliveredFailure) {
11691182
return deliveredFailure;
11701183
}
11711184
if (isFailoverError(err)) {
1172-
const retryableSessionId = context.reusableCliSession.sessionId;
1185+
const retryableSessionId = reusableCliSessionId;
11731186
if (
11741187
shouldRetryFreshCliSessionAfterFailover({
11751188
error: err,

src/agents/cli-runner/execute.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ import {
9090
formatCliBackendOutputDigest,
9191
LEGACY_CLAUDE_CLI_LOG_OUTPUT_ENV,
9292
} from "./log.js";
93-
import type { PreparedCliRunContext } from "./types.js";
93+
import type { CliReusableSession, PreparedCliRunContext } from "./types.js";
9494

9595
const executeDeps = {
9696
getProcessSupervisor: getProcessSupervisorImpl,
@@ -377,6 +377,21 @@ function fingerprintCliSessionId(sessionId?: string): string {
377377
return crypto.createHash("sha256").update(trimmed).digest("hex").slice(0, 12);
378378
}
379379

380+
function formatCliSessionReuseLogState(reusableSession: CliReusableSession): string {
381+
switch (reusableSession.mode) {
382+
case "reuse":
383+
return "reusable";
384+
case "reuse-with-drift":
385+
return `reusable-drift:${reusableSession.drift.reasons.join(",")}`;
386+
case "invalidate":
387+
return `invalidated:${reusableSession.invalidatedReason}`;
388+
case "none":
389+
return "none";
390+
}
391+
const exhaustive: never = reusableSession;
392+
return exhaustive;
393+
}
394+
380395
/** Builds the compact execution summary logged before a CLI backend run. */
381396
export function buildCliExecLogLine(params: {
382397
provider: string;
@@ -386,15 +401,9 @@ export function buildCliExecLogLine(params: {
386401
useResume: boolean;
387402
cliSessionId?: string;
388403
resolvedSessionId?: string;
389-
reusableSessionId?: string;
390-
invalidatedReason?: string;
404+
reusableSession: CliReusableSession;
391405
hasHistoryPrompt: boolean;
392406
}): string {
393-
const reuseState = params.reusableSessionId
394-
? "reusable"
395-
: params.invalidatedReason
396-
? `invalidated:${params.invalidatedReason}`
397-
: "none";
398407
return [
399408
`cli exec: provider=${params.provider}`,
400409
`model=${params.model}`,
@@ -403,7 +412,7 @@ export function buildCliExecLogLine(params: {
403412
`useResume=${params.useResume ? "true" : "false"}`,
404413
`session=${params.cliSessionId ? "present" : "none"}`,
405414
`resumeSession=${params.useResume ? fingerprintCliSessionId(params.resolvedSessionId) : "none"}`,
406-
`reuse=${reuseState}`,
415+
`reuse=${formatCliSessionReuseLogState(params.reusableSession)}`,
407416
`historyPrompt=${params.hasHistoryPrompt ? "present" : "none"}`,
408417
].join(" ");
409418
}
@@ -445,13 +454,15 @@ export async function executePreparedCliRun(
445454
const useResume = Boolean(
446455
cliSessionIdToUse && resolvedSessionId && backend.resumeArgs && backend.resumeArgs.length > 0,
447456
);
457+
const resendSystemPromptForSoftResume = context.reusableCliSession.mode === "reuse-with-drift";
448458
const systemPromptArg = resolveSystemPromptUsage({
449459
backend,
450-
isNewSession: isNew,
460+
isNewSession: isNew || resendSystemPromptForSoftResume,
451461
systemPrompt: context.systemPrompt,
452462
});
453463
const systemPromptFile =
454-
systemPromptArg && (!useResume || backend.systemPromptWhen === "always")
464+
systemPromptArg &&
465+
(!useResume || backend.systemPromptWhen === "always" || resendSystemPromptForSoftResume)
455466
? await executeDeps.writeCliSystemPromptFile({
456467
backend,
457468
systemPrompt: systemPromptArg,
@@ -522,6 +533,7 @@ export async function executePreparedCliRun(
522533
imagePaths,
523534
promptArg: argsPrompt,
524535
useResume,
536+
sendSystemPromptOnResume: resendSystemPromptForSoftResume,
525537
});
526538

527539
const claudeOwnerKey = buildClaudeOwnerKey({
@@ -662,8 +674,7 @@ export async function executePreparedCliRun(
662674
useResume,
663675
cliSessionId: cliSessionIdToUse,
664676
resolvedSessionId,
665-
reusableSessionId: context.reusableCliSession.sessionId,
666-
invalidatedReason: context.reusableCliSession.invalidatedReason,
677+
reusableSession: context.reusableCliSession,
667678
hasHistoryPrompt: Boolean(context.openClawHistoryPrompt),
668679
}),
669680
);

src/agents/cli-runner/helpers.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -497,20 +497,25 @@ export function buildCliArgs(params: {
497497
imagePaths?: string[];
498498
promptArg?: string;
499499
useResume: boolean;
500+
sendSystemPromptOnResume?: boolean;
500501
}): string[] {
501502
const args: string[] = [...params.baseArgs];
503+
const shouldSendSystemPrompt =
504+
!params.useResume ||
505+
params.backend.systemPromptWhen === "always" ||
506+
params.sendSystemPromptOnResume;
502507
if (params.backend.modelArg && params.modelId) {
503508
args.push(params.backend.modelArg, params.modelId);
504509
}
505510
if (
506-
(!params.useResume || params.backend.systemPromptWhen === "always") &&
511+
shouldSendSystemPrompt &&
507512
params.systemPrompt &&
508513
params.systemPromptFilePath &&
509514
params.backend.systemPromptFileArg
510515
) {
511516
args.push(params.backend.systemPromptFileArg, params.systemPromptFilePath);
512517
} else if (
513-
(!params.useResume || params.backend.systemPromptWhen === "always") &&
518+
shouldSendSystemPrompt &&
514519
params.systemPrompt &&
515520
params.systemPromptFilePath &&
516521
params.backend.systemPromptFileConfigKey
@@ -522,11 +527,7 @@ export function buildCliArgs(params: {
522527
params.systemPromptFilePath,
523528
),
524529
);
525-
} else if (
526-
(!params.useResume || params.backend.systemPromptWhen === "always") &&
527-
params.systemPrompt &&
528-
params.backend.systemPromptArg
529-
) {
530+
} else if (shouldSendSystemPrompt && params.systemPrompt && params.backend.systemPromptArg) {
530531
args.push(params.backend.systemPromptArg, stripSystemPromptCacheBoundary(params.systemPrompt));
531532
}
532533
if (!params.useResume && params.sessionId) {

src/agents/cli-runner/prepare.ts

Lines changed: 104 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
66
import { getRuntimeConfig } from "../../config/config.js";
7+
import type { CliBackendConfig } from "../../config/types.agent-defaults.js";
78
import {
89
assertContextEngineHostSupport,
910
buildGenericCliContextEngineHostSupport,
@@ -110,6 +111,79 @@ const prepareDeps = {
110111
resolveApiKeyForProfile,
111112
};
112113

114+
function resolveReusableCliSessionId(reusableCliSession: CliReusableSession): string | undefined {
115+
return reusableCliSession.mode === "reuse" || reusableCliSession.mode === "reuse-with-drift"
116+
? reusableCliSession.sessionId
117+
: undefined;
118+
}
119+
120+
function resolveCliSessionInvalidatedReason(
121+
reusableCliSession: CliReusableSession,
122+
): Extract<CliReusableSession, { mode: "invalidate" }>["invalidatedReason"] | undefined {
123+
return reusableCliSession.mode === "invalidate"
124+
? reusableCliSession.invalidatedReason
125+
: undefined;
126+
}
127+
128+
function canApplySystemPromptOnResume(backend: CliBackendConfig): boolean {
129+
return (
130+
backend.systemPromptWhen !== "never" &&
131+
Boolean(
132+
backend.systemPromptArg || backend.systemPromptFileArg || backend.systemPromptFileConfigKey,
133+
)
134+
);
135+
}
136+
137+
function buildCliSessionDriftUserContext(params: {
138+
reusableCliSession: CliReusableSession;
139+
extraSystemPrompt: string;
140+
promptToolNames: string[];
141+
}): string | undefined {
142+
const { reusableCliSession } = params;
143+
if (reusableCliSession.mode !== "reuse-with-drift") {
144+
return undefined;
145+
}
146+
const lines = [
147+
`OpenClaw resumed this CLI session after prompt content changed. Follow the current turn's instructions; changed=${reusableCliSession.drift.reasons.join(",")}.`,
148+
];
149+
if (reusableCliSession.drift.reasons.includes("system-prompt") && params.extraSystemPrompt) {
150+
lines.push(`Current session context:\n${params.extraSystemPrompt}`);
151+
}
152+
if (reusableCliSession.drift.reasons.includes("prompt-tools")) {
153+
lines.push(
154+
`Current prompt tool surface: ${
155+
params.promptToolNames.length > 0 ? params.promptToolNames.join(", ") : "none"
156+
}`,
157+
);
158+
}
159+
return lines.join("\n\n");
160+
}
161+
162+
function prependCliSessionDriftUserContext(
163+
context: RunCliAgentParams["currentInboundContext"],
164+
reusableCliSession: CliReusableSession,
165+
driftContext: {
166+
extraSystemPrompt: string;
167+
promptToolNames: string[];
168+
},
169+
): RunCliAgentParams["currentInboundContext"] {
170+
const note = buildCliSessionDriftUserContext({
171+
reusableCliSession,
172+
...driftContext,
173+
});
174+
if (!note) {
175+
return context;
176+
}
177+
if (!context) {
178+
return { text: note };
179+
}
180+
return {
181+
...context,
182+
text: [note, context.text].join("\n\n"),
183+
...(context.resumableText ? { resumableText: [note, context.resumableText].join("\n\n") } : {}),
184+
};
185+
}
186+
113187
async function resolveCliSkillsPrompt(params: {
114188
agentId: string;
115189
config: RunCliAgentParams["config"];
@@ -618,7 +692,7 @@ export async function prepareCliRunContext(
618692
? hashCliSessionText(JSON.stringify(promptTools.map((tool) => tool.name).toSorted()))
619693
: undefined;
620694
const reusableCliSessionCandidate: CliReusableSession = isSideQuestion
621-
? {}
695+
? { mode: "none" }
622696
: params.cliSessionBinding
623697
? resolveCliSessionReuse({
624698
binding: params.cliSessionBinding,
@@ -633,9 +707,15 @@ export async function prepareCliRunContext(
633707
mcpResumeHash: preparedBackendFinal.mcpResumeHash,
634708
})
635709
: params.cliSessionId
636-
? { sessionId: params.cliSessionId }
637-
: {};
638-
const candidateClaudeCliSessionId = reusableCliSessionCandidate.sessionId?.trim() || undefined;
710+
? { mode: "reuse", sessionId: params.cliSessionId }
711+
: { mode: "none" };
712+
const backendReusableCliSession: CliReusableSession =
713+
reusableCliSessionCandidate.mode === "reuse-with-drift" &&
714+
!canApplySystemPromptOnResume(preparedBackendFinal.backend)
715+
? { mode: "invalidate", invalidatedReason: "system-prompt" }
716+
: reusableCliSessionCandidate;
717+
const candidateClaudeCliSessionId =
718+
resolveReusableCliSessionId(backendReusableCliSession)?.trim() || undefined;
639719
const hasClaudeCliCandidate =
640720
candidateClaudeCliSessionId !== undefined && isClaudeCliProvider(params.provider);
641721
const claudeCliTranscriptMissing =
@@ -651,18 +731,20 @@ export async function prepareCliRunContext(
651731
sessionId: candidateClaudeCliSessionId,
652732
workspaceDir: cwd,
653733
}));
654-
const claudeCliInvalidatedReason: CliReusableSession["invalidatedReason"] | undefined =
734+
const claudeCliInvalidatedReason: "missing-transcript" | "orphaned-tool-use" | undefined =
655735
claudeCliTranscriptMissing
656736
? "missing-transcript"
657737
: claudeCliTranscriptOrphanedToolUse
658738
? "orphaned-tool-use"
659739
: undefined;
660740
const reusableCliSession: CliReusableSession = claudeCliInvalidatedReason
661-
? { invalidatedReason: claudeCliInvalidatedReason }
662-
: reusableCliSessionCandidate;
663-
if (reusableCliSession.invalidatedReason) {
741+
? { mode: "invalidate", invalidatedReason: claudeCliInvalidatedReason }
742+
: backendReusableCliSession;
743+
const reusableCliSessionId = resolveReusableCliSessionId(reusableCliSession);
744+
const invalidatedReason = resolveCliSessionInvalidatedReason(reusableCliSession);
745+
if (invalidatedReason) {
664746
cliBackendLog.info(
665-
`cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`,
747+
`cli session reset: provider=${params.provider} reason=${invalidatedReason}`,
666748
);
667749
}
668750
let openClawHistoryMessages: unknown[] | undefined;
@@ -804,15 +886,23 @@ export async function prepareCliRunContext(
804886
}
805887
let historyPromptCurrentTurn = preparedPrompt;
806888
if (!isSideQuestion) {
889+
const currentInboundContext = prependCliSessionDriftUserContext(
890+
params.currentInboundContext,
891+
reusableCliSession,
892+
{
893+
extraSystemPrompt,
894+
promptToolNames: promptTools.map((tool) => tool.name).toSorted(),
895+
},
896+
);
807897
const fullCurrentInboundPrompt = buildCurrentInboundPrompt({
808-
context: params.currentInboundContext,
898+
context: currentInboundContext,
809899
prompt: preparedPrompt,
810900
});
811901
const runCurrentInboundPrompt = buildCurrentInboundPrompt({
812-
context: params.currentInboundContext,
902+
context: currentInboundContext,
813903
prompt: preparedPrompt,
814904
preferResumableText:
815-
params.currentInboundEventKind === "room_event" && Boolean(reusableCliSession.sessionId),
905+
params.currentInboundEventKind === "room_event" && Boolean(reusableCliSessionId),
816906
});
817907
historyPromptCurrentTurn = annotateInterSessionPromptText(
818908
fullCurrentInboundPrompt,
@@ -825,11 +915,9 @@ export async function prepareCliRunContext(
825915
}
826916
const allowRawTranscriptReseed =
827917
backendResolved.config.reseedFromRawTranscriptWhenUncompacted === true;
828-
const rawTranscriptReseedReason = reusableCliSession.sessionId
829-
? "session-expired"
830-
: reusableCliSession.invalidatedReason;
918+
const rawTranscriptReseedReason = reusableCliSessionId ? "session-expired" : invalidatedReason;
831919
const shouldPrepareOpenClawHistoryPrompt =
832-
!isSideQuestion && (!reusableCliSession.sessionId || allowRawTranscriptReseed);
920+
!isSideQuestion && (!reusableCliSessionId || allowRawTranscriptReseed);
833921
const openClawHistoryPrompt = shouldPrepareOpenClawHistoryPrompt
834922
? buildCliSessionHistoryPrompt({
835923
messages: await loadCliSessionReseedMessages({

src/agents/cli-runner/session-history.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type HistoryEntry = {
5959
type RawTranscriptReseedReason =
6060
| "auth-profile"
6161
| "auth-epoch"
62+
| "message-policy"
6263
| "system-prompt"
6364
| "cwd"
6465
| "mcp"
@@ -69,6 +70,7 @@ type RawTranscriptReseedReason =
6970
const RAW_TRANSCRIPT_RESEED_ALLOWED_REASONS = new Set<RawTranscriptReseedReason>([
7071
"missing-transcript",
7172
"orphaned-tool-use",
73+
"message-policy",
7274
"system-prompt",
7375
"cwd",
7476
"mcp",

0 commit comments

Comments
 (0)