fix(cli-runner): gate raw transcript reseed

Summary:
- Gate raw transcript reseeding behind an explicit CLI backend opt-in.
- Keep auth-profile and auth-epoch invalidations from replaying raw transcript history.
- Add regression coverage, docs, config schema/baseline, and changelog entry for #79713.

Verification:
- pnpm exec oxfmt --check --threads=1 CHANGELOG.md docs/gateway/cli-backends.md docs/gateway/config-agents.md src/agents/cli-runner.reliability.test.ts src/agents/cli-runner/prepare.test.ts src/agents/cli-runner/prepare.ts src/agents/cli-runner/session-history.test.ts src/agents/cli-runner/session-history.ts src/config/types.agent-defaults.ts src/config/zod-schema.core.ts
- pnpm run lint:extensions:bundled
- pnpm deadcode:dependencies
- pnpm test src/agents/cli-runner/session-history.test.ts src/agents/cli-runner/prepare.test.ts src/agents/cli-runner.reliability.test.ts src/config/schema.test.ts src/config/zod-schema.agent-defaults.test.ts
- GitHub CI on b63f3afdc4: lint, prod/test types, docs, dependencies, fast contracts, core/agentic shards, and real behavior proof passed.

Co-authored-by: hclsys <hclsys@openclaw.ai>
This commit is contained in:
hcl
2026-05-09 22:52:30 +08:00
committed by GitHub
parent 565e71da98
commit aeb7d07364
11 changed files with 247 additions and 24 deletions

View File

@@ -170,6 +170,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Memory: close temp SQLite handles before failed atomic reindex cleanup and retry Windows EBUSY/EPERM/EACCES temp file removals, so `memory index --force` does not abort or leave temp sidecars on locked filesystems. Fixes #79708. Thanks @LobsterFarmerAmp and @hclsys.
- Agents/CLI: add an explicit `reseedFromRawTranscriptWhenUncompacted` backend opt-in so safe invalidated CLI sessions can reseed from a bounded raw OpenClaw transcript tail before compaction while auth-boundary resets remain no-raw. Fixes #79713. (#79764) Thanks @hclsys.
- Agents/CLI: handle resumed CLI JSONL output and bound supervisor output buffering so resumed runs stay readable without letting noisy child output grow unbounded.
- Codex app-server: honor per-call `timeoutMs`, configured `image_generate` timeouts, and media image-understanding timeouts for dynamic tool calls, capped at 600000 ms, so slow image generation and image analysis no longer fail at the 30s bridge default. Fixes #79810. Thanks @omarshahine.
- Agents/sandbox: include the container workspace path hint in sandbox-root escape errors while preserving shortened host workspace roots. Fixes #79712. Thanks @haumanto and @hclsys.

View File

@@ -1,4 +1,4 @@
bb53a92a54a804d217baf466a4731924653d769db37122c38400cc3b97720c23 config-baseline.json
3b632b0f038846722e2a5012a5eeec2a29048b6e385b591d7bd9122aa0981a20 config-baseline.core.json
335083781741da50b280496b954794bdecba7c1150ce777d37534ccc1ec2c10a config-baseline.json
b629f3b6ec6389eb0709e6f9149d7c3ab50431bb22124019541710873dc52cbb config-baseline.core.json
9edc62ae7dfedabc645470dd03102b813fc780b9108caf675fd661104714206f config-baseline.channel.json
1da42cb10427fb08510f29732493d24851ab915a424f91556569febdd450d9c3 config-baseline.plugin.json

View File

@@ -136,6 +136,9 @@ The provider id becomes the left side of your model ref:
systemPromptWhen: "first",
imageArg: "--image",
imageMode: "repeat",
// Opt in only if this backend may reseed safe invalidated sessions
// from bounded raw OpenClaw transcript history before compaction.
reseedFromRawTranscriptWhenUncompacted: true,
serialize: true,
},
},
@@ -231,6 +234,13 @@ binary is not already on `PATH`.
- Stored CLI sessions are provider-owned continuity. The implicit daily session
reset does not cut them; `/reset` and explicit `session.reset` policies still
do.
- Fresh CLI sessions normally reseed only from OpenClaw's compaction summary
plus post-compaction tail. To recover short sessions that are invalidated
before compaction, a backend can opt in with
`reseedFromRawTranscriptWhenUncompacted: true`. OpenClaw still keeps raw
transcript reseed bounded and limits it to safe invalidations such as missing
CLI transcripts, system-prompt/MCP changes, or session-expired retry; auth
profile or credential-epoch changes never reseed raw transcript history.
Serialization notes:

View File

@@ -484,6 +484,10 @@ Optional CLI backends for text-only fallback runs (no tool calls). Useful as a b
- CLI backends are text-first; tools are always disabled.
- Sessions supported when `sessionArg` is set.
- Image pass-through supported when `imageArg` accepts file paths.
- `reseedFromRawTranscriptWhenUncompacted: true` lets a backend recover safe
invalidated sessions from a bounded raw OpenClaw transcript tail before the
first compaction summary exists. Auth profile or credential-epoch changes
still never raw-reseed.
### `agents.defaults.systemPromptOverride`

View File

@@ -839,27 +839,32 @@ describe("runCliAgent reliability", () => {
);
try {
await expect(
runPreparedCliAgent({
const result = await runPreparedCliAgent({
...buildPreparedContext({
sessionKey: "agent:main:main",
runId: "run-retry-success",
cliSessionId: "thread-123",
openClawHistoryPrompt:
"Continue this conversation using the OpenClaw transcript below.\n\nUser: recovered history\n\n<next_user_message>\nhi\n</next_user_message>",
}),
params: {
...buildPreparedContext({
sessionKey: "agent:main:main",
runId: "run-retry-success",
cliSessionId: "thread-123",
}),
params: {
...buildPreparedContext({
sessionKey: "agent:main:main",
runId: "run-retry-success",
cliSessionId: "thread-123",
}).params,
agentId: "main",
sessionFile,
workspaceDir: dir,
},
}),
).resolves.toMatchObject({
openClawHistoryPrompt:
"Continue this conversation using the OpenClaw transcript below.\n\nUser: recovered history\n\n<next_user_message>\nhi\n</next_user_message>",
}).params,
agentId: "main",
sessionFile,
workspaceDir: dir,
},
});
expect(result).toMatchObject({
payloads: [{ text: "recovered output" }],
});
expect(result.meta.finalPromptText).toContain("User: recovered history");
await vi.waitFor(() => {
expect(hookRunner.runLlmInput).toHaveBeenCalledTimes(1);

View File

@@ -80,7 +80,11 @@ async function createTestMcpLoopbackServer(port = 0) {
}
function createCliBackendConfig(
params: { systemPromptOverride?: string | null; bundleMcp?: boolean } = {},
params: {
systemPromptOverride?: string | null;
bundleMcp?: boolean;
reseedFromRawTranscriptWhenUncompacted?: boolean;
} = {},
): OpenClawConfig {
return {
agents: {
@@ -97,6 +101,9 @@ function createCliBackendConfig(
sessionMode: "existing",
output: "text",
input: "arg",
...(params.reseedFromRawTranscriptWhenUncompacted
? { reseedFromRawTranscriptWhenUncompacted: true }
: {}),
...(params.bundleMcp
? { bundleMcp: true, bundleMcpMode: "claude-config-file" as const }
: {}),
@@ -561,6 +568,89 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}
});
it("prepares raw-tail history for safe invalidations only when the backend opts in", async () => {
const { dir, sessionFile } = createSessionFile();
appendTranscriptEntry(sessionFile, {
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: {
role: "user",
content: "prior no-compaction ask",
timestamp: 1,
},
});
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-raw-reseed-opt-in",
extraSystemPrompt: "changed stable prompt",
extraSystemPromptStatic: "changed stable prompt",
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: hashCliSessionText("old stable prompt"),
},
config: createCliBackendConfig({
systemPromptOverride: null,
reseedFromRawTranscriptWhenUncompacted: true,
}),
});
expect(context.reusableCliSession).toEqual({ invalidatedReason: "system-prompt" });
expect(context.openClawHistoryPrompt).toContain("prior no-compaction ask");
expect(context.openClawHistoryPrompt).toContain("latest ask");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("prepares opted-in raw-tail history for session-expired retry without disabling native resume", async () => {
const { dir, sessionFile } = createSessionFile();
appendTranscriptEntry(sessionFile, {
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: {
role: "user",
content: "prior resumable ask",
timestamp: 1,
},
});
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-session-expired-reseed-opt-in",
cliSessionBinding: {
sessionId: "cli-session",
},
config: createCliBackendConfig({
systemPromptOverride: null,
reseedFromRawTranscriptWhenUncompacted: true,
}),
});
expect(context.reusableCliSession).toEqual({ sessionId: "cli-session" });
expect(context.openClawHistoryPrompt).toContain("prior resumable ask");
expect(context.openClawHistoryPrompt).toContain("latest ask");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("applies direct-run prepend system context helpers on the CLI path", async () => {
const { dir, sessionFile } = createSessionFile();
try {

View File

@@ -406,18 +406,27 @@ export async function prepareCliRunContext(
cliBackendLog.warn(`cli prompt-build hook preparation failed: ${String(error)}`);
}
preparedPrompt = annotateInterSessionPromptText(preparedPrompt, params.inputProvenance);
const openClawHistoryPrompt = reusableCliSession.sessionId
? undefined
: buildCliSessionHistoryPrompt({
const allowRawTranscriptReseed =
backendResolved.config.reseedFromRawTranscriptWhenUncompacted === true;
const rawTranscriptReseedReason = reusableCliSession.sessionId
? "session-expired"
: reusableCliSession.invalidatedReason;
const shouldPrepareOpenClawHistoryPrompt =
!reusableCliSession.sessionId || allowRawTranscriptReseed;
const openClawHistoryPrompt = shouldPrepareOpenClawHistoryPrompt
? buildCliSessionHistoryPrompt({
messages: await loadCliSessionReseedMessages({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
allowRawTranscriptReseed,
rawTranscriptReseedReason,
}),
prompt: preparedPrompt,
});
})
: undefined;
systemPrompt = appendModelIdentitySystemPrompt({
systemPrompt: applyPluginTextReplacements(systemPrompt, backendResolved.textTransforms?.input),
model: modelDisplay,

View File

@@ -249,6 +249,76 @@ describe("loadCliSessionReseedMessages", () => {
}
});
it("reseeds safe invalidated sessions from a bounded raw message tail when explicitly opted in", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const sessionFile = createSessionTranscript({
rootDir: stateDir,
sessionId: "session-opt-in-raw-tail",
messages: Array.from(
{ length: MAX_CLI_SESSION_HISTORY_MESSAGES + 25 },
(_, index) => `raw-${index}`,
),
});
try {
const reseed = await loadCliSessionReseedMessages({
sessionId: "session-opt-in-raw-tail",
sessionFile,
sessionKey: "agent:main:main",
agentId: "main",
allowRawTranscriptReseed: true,
rawTranscriptReseedReason: "missing-transcript",
});
expect(reseed).toHaveLength(MAX_CLI_SESSION_HISTORY_MESSAGES);
expect(reseed[0]).toMatchObject({ role: "user", content: "raw-25" });
expect(reseed.at(-1)).toMatchObject({
role: "user",
content: `raw-${MAX_CLI_SESSION_HISTORY_MESSAGES + 24}`,
});
expect(buildCliSessionHistoryPrompt({ messages: reseed, prompt: "next" })).toContain(
"raw-25",
);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("does not raw-reseed auth-boundary invalidations even when opted in", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const sessionFile = createSessionTranscript({
rootDir: stateDir,
sessionId: "session-auth-boundary",
messages: ["previous account context"],
});
try {
await expect(
loadCliSessionReseedMessages({
sessionId: "session-auth-boundary",
sessionFile,
sessionKey: "agent:main:main",
agentId: "main",
allowRawTranscriptReseed: true,
rawTranscriptReseedReason: "auth-profile",
}),
).resolves.toStrictEqual([]);
await expect(
loadCliSessionReseedMessages({
sessionId: "session-auth-boundary",
sessionFile,
sessionKey: "agent:main:main",
agentId: "main",
allowRawTranscriptReseed: true,
rawTranscriptReseedReason: "auth-epoch",
}),
).resolves.toStrictEqual([]);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("reseeds fresh CLI sessions from the latest compaction summary and post-compaction tail", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);

View File

@@ -28,6 +28,21 @@ type HistoryEntry = {
summary?: unknown;
};
type RawTranscriptReseedReason =
| "auth-profile"
| "auth-epoch"
| "system-prompt"
| "mcp"
| "missing-transcript"
| "session-expired";
const RAW_TRANSCRIPT_RESEED_ALLOWED_REASONS = new Set<RawTranscriptReseedReason>([
"missing-transcript",
"system-prompt",
"mcp",
"session-expired",
]);
function coerceHistoryText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
@@ -190,20 +205,36 @@ export async function loadCliSessionReseedMessages(params: {
sessionKey?: string;
agentId?: string;
config?: OpenClawConfig;
allowRawTranscriptReseed?: boolean;
rawTranscriptReseedReason?: RawTranscriptReseedReason;
}): Promise<unknown[]> {
const entries = await loadCliSessionEntries(params);
const loadRawTail = () => {
if (
params.allowRawTranscriptReseed !== true ||
!params.rawTranscriptReseedReason ||
!RAW_TRANSCRIPT_RESEED_ALLOWED_REASONS.has(params.rawTranscriptReseedReason)
) {
return [];
}
const rawTail = entries.flatMap((entry) => {
const candidate = entry as HistoryEntry;
return candidate.type === "message" ? [candidate.message] : [];
});
return limitAgentHookHistoryMessages(rawTail, MAX_CLI_SESSION_HISTORY_MESSAGES);
};
const latestCompactionIndex = entries.findLastIndex((entry) => {
const candidate = entry as HistoryEntry;
return candidate.type === "compaction" && typeof candidate.summary === "string";
});
if (latestCompactionIndex < 0) {
return [];
return loadRawTail();
}
const compaction = entries[latestCompactionIndex] as HistoryEntry;
const summary = typeof compaction.summary === "string" ? compaction.summary.trim() : "";
if (!summary) {
return [];
return loadRawTail();
}
const tailMessages = entries.slice(latestCompactionIndex + 1).flatMap((entry) => {

View File

@@ -150,6 +150,8 @@ export type CliBackendConfig = {
imagePathScope?: "temp" | "workspace";
/** Serialize runs for this CLI. */
serialize?: boolean;
/** Opt in to bounded raw transcript reseed before compaction for safe session resets. */
reseedFromRawTranscriptWhenUncompacted?: boolean;
/** Runtime reliability tuning for this backend's process lifecycle. */
reliability?: {
/** Live-session output caps for CLIs that stream JSONL through a long-lived process. */

View File

@@ -642,6 +642,7 @@ export const CliBackendSchema = z
imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(),
imagePathScope: z.union([z.literal("temp"), z.literal("workspace")]).optional(),
serialize: z.boolean().optional(),
reseedFromRawTranscriptWhenUncompacted: z.boolean().optional(),
reliability: z
.object({
outputLimits: CliBackendOutputLimitsSchema,