mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 08:20:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user