mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(cli): finalize context engine turns (#81869)
* fix(cli): finalize context engine turns * fix(cli): avoid context engine prepare leak * fix(cli): keep context engine alive after turns * fix(cli): complete context engine lifecycle * fix(cli): preserve context engine maintenance contracts * fix(cli): align context engine transcript finalization * fix(cli): close context engine lifecycle gaps * fix(cli): keep context engine snapshots current * fix(cli): preserve context snapshot entry types * fix(cli): clean up failed context engine prepare * fix(cli): detect resolved session transcripts * fix(cli): preserve context engine lifecycle ownership * docs(changelog): credit CLI context engine fix --------- Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/context engines: bootstrap and finalize non-legacy context engines for CLI turns while preserving transcript snapshots and deferred maintenance ownership. (#81869) Thanks @sahilsatralkar.
|
||||
- Telegram: persist polling updates through restart replay so queued same-topic messages resume in order instead of losing context after a gateway restart. (#82256) Thanks @VACInc.
|
||||
- Gateway/Gmail: abort in-flight Gmail watcher startup and hot-reload restarts before shutdown so reloads cannot spawn `gog serve` after the Gateway is closing. Thanks @frankekn.
|
||||
- MCP plugin tools: forward host MCP `tools/call` `AbortSignal` through `createPluginToolsMcpHandlers().callTool` into plugin `tool.execute`, so host cancellation actually cancels in-flight plugin tool calls instead of letting them run to completion. Fixes #82424. (#82443) Thanks @joshavant.
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ContextEngine } from "../context-engine/types.js";
|
||||
import type { PreparedCliRunContext } from "./cli-runner/types.js";
|
||||
|
||||
const {
|
||||
executePreparedCliRunMock,
|
||||
loadCliSessionContextEngineMessagesMock,
|
||||
loadCliSessionHistoryMessagesMock,
|
||||
getGlobalHookRunnerMock,
|
||||
} = vi.hoisted(() => ({
|
||||
executePreparedCliRunMock: vi.fn(),
|
||||
loadCliSessionContextEngineMessagesMock: vi.fn(),
|
||||
loadCliSessionHistoryMessagesMock: vi.fn(),
|
||||
getGlobalHookRunnerMock: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("./cli-runner/execute.runtime.js", () => ({
|
||||
executePreparedCliRun: executePreparedCliRunMock,
|
||||
}));
|
||||
|
||||
vi.mock("./cli-runner/session-history.js", () => ({
|
||||
loadCliSessionContextEngineMessages: loadCliSessionContextEngineMessagesMock,
|
||||
loadCliSessionHistoryMessages: loadCliSessionHistoryMessagesMock,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: getGlobalHookRunnerMock,
|
||||
}));
|
||||
|
||||
function textMessage(role: "user" | "assistant", text: string, timestamp: number): AgentMessage {
|
||||
return {
|
||||
role,
|
||||
content: [{ type: "text", text }],
|
||||
timestamp,
|
||||
} as AgentMessage;
|
||||
}
|
||||
|
||||
function createContextEngine(overrides: Partial<ContextEngine> = {}): ContextEngine {
|
||||
return {
|
||||
info: { id: "test-context-engine", name: "Test context engine" },
|
||||
ingest: vi.fn(async () => ({ ingested: true })),
|
||||
assemble: vi.fn(async (params) => ({
|
||||
messages: params.messages,
|
||||
estimatedTokens: 0,
|
||||
})),
|
||||
compact: vi.fn(async () => ({ ok: true, compacted: false })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMaintenanceResult() {
|
||||
return {
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
rewrittenEntries: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreparedContext(contextEngine: ContextEngine): PreparedCliRunContext {
|
||||
const backend = {
|
||||
command: "claude",
|
||||
args: ["--print"],
|
||||
output: "text" as const,
|
||||
input: "arg" as const,
|
||||
sessionMode: "existing" as const,
|
||||
serialize: true,
|
||||
};
|
||||
|
||||
return {
|
||||
params: {
|
||||
sessionId: "openclaw-session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
sessionFile: "session.jsonl",
|
||||
workspaceDir: "/tmp/openclaw-cli-context-engine-test",
|
||||
prompt: "visible ask",
|
||||
transcriptPrompt: "transcript visible ask",
|
||||
provider: "claude-cli",
|
||||
model: "sonnet-4.6",
|
||||
thinkLevel: "low",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-1",
|
||||
},
|
||||
started: Date.now(),
|
||||
workspaceDir: "/tmp/openclaw-cli-context-engine-test",
|
||||
backendResolved: {
|
||||
id: "claude-cli",
|
||||
config: backend,
|
||||
bundleMcp: false,
|
||||
pluginId: "anthropic",
|
||||
},
|
||||
preparedBackend: {
|
||||
backend,
|
||||
env: {},
|
||||
},
|
||||
reusableCliSession: {
|
||||
sessionId: "existing-external-cli-session",
|
||||
},
|
||||
hadSessionFile: true,
|
||||
contextEngineConfig: {},
|
||||
contextEngine,
|
||||
contextEngineTurnPrompt: "transcript visible ask",
|
||||
modelId: "sonnet-4.6",
|
||||
normalizedModel: "sonnet-4.6",
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"],
|
||||
bootstrapPromptWarningLines: [],
|
||||
authEpochVersion: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function expectMessageText(message: AgentMessage | undefined, expected: string): void {
|
||||
expect(message).toBeDefined();
|
||||
const content = (message as { content?: unknown } | undefined)?.content;
|
||||
if (typeof content === "string") {
|
||||
expect(content).toBe(expected);
|
||||
return;
|
||||
}
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect((content as unknown[] | undefined)?.[0]).toMatchObject({ type: "text", text: expected });
|
||||
}
|
||||
|
||||
describe("runPreparedCliAgent context engine lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
executePreparedCliRunMock.mockReset();
|
||||
executePreparedCliRunMock.mockResolvedValue({
|
||||
text: " final answer ",
|
||||
rawText: " final answer ",
|
||||
sessionId: "external-cli-session-1",
|
||||
usage: { input: 11, output: 7, total: 18 },
|
||||
finalPromptText: "prompt sent to cli",
|
||||
});
|
||||
loadCliSessionContextEngineMessagesMock.mockReset();
|
||||
loadCliSessionContextEngineMessagesMock.mockResolvedValue([
|
||||
textMessage("user", "old ask", 1),
|
||||
textMessage("assistant", "old answer", 2),
|
||||
]);
|
||||
loadCliSessionHistoryMessagesMock.mockReset();
|
||||
loadCliSessionHistoryMessagesMock.mockResolvedValue([]);
|
||||
getGlobalHookRunnerMock.mockReset();
|
||||
getGlobalHookRunnerMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("finalizes successful CLI turns with the active context engine", async () => {
|
||||
const bootstrap = vi.fn<NonNullable<ContextEngine["bootstrap"]>>(async () => ({
|
||||
bootstrapped: true,
|
||||
}));
|
||||
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
|
||||
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
|
||||
createMaintenanceResult(),
|
||||
);
|
||||
const dispose = vi.fn(async () => {});
|
||||
const contextEngine = createContextEngine({ bootstrap, afterTurn, maintain, dispose });
|
||||
const context = buildPreparedContext(contextEngine);
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
|
||||
const result = await runPreparedCliAgent(context);
|
||||
|
||||
expect(result.meta.agentMeta?.sessionId).toBe("external-cli-session-1");
|
||||
expect(loadCliSessionContextEngineMessagesMock).toHaveBeenCalledWith({
|
||||
sessionId: "openclaw-session-1",
|
||||
sessionFile: "session.jsonl",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
config: undefined,
|
||||
});
|
||||
expect(loadCliSessionHistoryMessagesMock).not.toHaveBeenCalled();
|
||||
expect(bootstrap).toHaveBeenCalledWith({
|
||||
sessionId: "openclaw-session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionFile: "session.jsonl",
|
||||
});
|
||||
expect(afterTurn).toHaveBeenCalledTimes(1);
|
||||
const afterTurnParams = afterTurn.mock.calls[0]?.[0];
|
||||
expect(afterTurnParams).toMatchObject({
|
||||
sessionId: "openclaw-session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionFile: "session.jsonl",
|
||||
prePromptMessageCount: 2,
|
||||
tokenBudget: undefined,
|
||||
runtimeContext: undefined,
|
||||
});
|
||||
expect(afterTurnParams?.messages).toHaveLength(4);
|
||||
expect(afterTurnParams?.messages.slice(0, 2)).toEqual([
|
||||
textMessage("user", "old ask", 1),
|
||||
textMessage("assistant", "old answer", 2),
|
||||
]);
|
||||
expectMessageText(afterTurnParams?.messages[2], "transcript visible ask");
|
||||
expectMessageText(afterTurnParams?.messages[3], "final answer");
|
||||
expect(afterTurnParams?.messages[3]).toMatchObject({
|
||||
role: "assistant",
|
||||
provider: "claude-cli",
|
||||
model: "sonnet-4.6",
|
||||
usage: { input: 11, output: 7, total: 18 },
|
||||
});
|
||||
expect(maintain).toHaveBeenCalledTimes(2);
|
||||
expect(maintain.mock.calls[1]?.[0]).toMatchObject({
|
||||
sessionId: "openclaw-session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionFile: "session.jsonl",
|
||||
runtimeContext: {
|
||||
rewriteTranscriptEntries: expect.any(Function),
|
||||
llm: { complete: expect.any(Function) },
|
||||
},
|
||||
});
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not synthesize a context-engine user turn for empty transcript prompts", async () => {
|
||||
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
|
||||
const dispose = vi.fn(async () => {});
|
||||
const contextEngine = createContextEngine({ afterTurn, dispose });
|
||||
const context = buildPreparedContext(contextEngine);
|
||||
context.params.transcriptPrompt = "";
|
||||
context.contextEngineTurnPrompt = "";
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
|
||||
await runPreparedCliAgent(context);
|
||||
|
||||
const afterTurnParams = afterTurn.mock.calls[0]?.[0];
|
||||
expect(afterTurnParams?.messages).toHaveLength(3);
|
||||
expect(afterTurnParams?.prePromptMessageCount).toBe(2);
|
||||
expect(afterTurnParams?.messages.slice(0, 2)).toEqual([
|
||||
textMessage("user", "old ask", 1),
|
||||
textMessage("assistant", "old answer", 2),
|
||||
]);
|
||||
const turnMessages = afterTurnParams?.messages.slice(afterTurnParams.prePromptMessageCount);
|
||||
expect(turnMessages).toHaveLength(1);
|
||||
expectMessageText(turnMessages?.[0], "final answer");
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not finalize prepared model prompt as transcript turn text", async () => {
|
||||
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
|
||||
const dispose = vi.fn(async () => {});
|
||||
const contextEngine = createContextEngine({ afterTurn, dispose });
|
||||
const context = buildPreparedContext(contextEngine);
|
||||
context.params.prompt = "runtime context\n\noriginal user ask";
|
||||
delete context.params.transcriptPrompt;
|
||||
context.contextEngineTurnPrompt = "original user ask";
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
|
||||
await runPreparedCliAgent(context);
|
||||
|
||||
const afterTurnParams = afterTurn.mock.calls[0]?.[0];
|
||||
expect(afterTurnParams?.messages).toHaveLength(4);
|
||||
expect(afterTurnParams?.prePromptMessageCount).toBe(2);
|
||||
const turnMessages = afterTurnParams?.messages.slice(afterTurnParams.prePromptMessageCount);
|
||||
expect(turnMessages).toHaveLength(2);
|
||||
expectMessageText(turnMessages?.[0], "original user ask");
|
||||
expectMessageText(turnMessages?.[1], "final answer");
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads unbounded context-engine history separately from hook history", async () => {
|
||||
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
|
||||
const dispose = vi.fn(async () => {});
|
||||
const contextEngine = createContextEngine({ afterTurn, dispose });
|
||||
const context = buildPreparedContext(contextEngine);
|
||||
const fullHistory = Array.from({ length: 101 }, (_, index) =>
|
||||
textMessage("user", `old ask ${index}`, index),
|
||||
);
|
||||
loadCliSessionContextEngineMessagesMock.mockResolvedValueOnce(fullHistory);
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
|
||||
await runPreparedCliAgent(context);
|
||||
|
||||
const afterTurnParams = afterTurn.mock.calls[0]?.[0];
|
||||
expect(loadCliSessionContextEngineMessagesMock).toHaveBeenCalledTimes(1);
|
||||
expect(loadCliSessionHistoryMessagesMock).not.toHaveBeenCalled();
|
||||
expect(afterTurnParams?.prePromptMessageCount).toBe(101);
|
||||
expect(afterTurnParams?.messages.slice(0, 101)).toEqual(fullHistory);
|
||||
});
|
||||
|
||||
it("loads context-engine history after bootstrap lifecycle runs", async () => {
|
||||
const postBootstrapHistory = [textMessage("user", "post-bootstrap history", 9)];
|
||||
const bootstrap = vi.fn<NonNullable<ContextEngine["bootstrap"]>>(async () => {
|
||||
loadCliSessionContextEngineMessagesMock.mockResolvedValueOnce(postBootstrapHistory);
|
||||
return { bootstrapped: true };
|
||||
});
|
||||
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
|
||||
const dispose = vi.fn(async () => {});
|
||||
const contextEngine = createContextEngine({ bootstrap, afterTurn, dispose });
|
||||
const context = buildPreparedContext(contextEngine);
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
|
||||
await runPreparedCliAgent(context);
|
||||
|
||||
expect(bootstrap).toHaveBeenCalledTimes(1);
|
||||
expect(loadCliSessionContextEngineMessagesMock).toHaveBeenCalledTimes(1);
|
||||
const bootstrapOrder = bootstrap.mock.invocationCallOrder[0];
|
||||
const loadHistoryOrder = loadCliSessionContextEngineMessagesMock.mock.invocationCallOrder[0];
|
||||
if (typeof bootstrapOrder !== "number" || typeof loadHistoryOrder !== "number") {
|
||||
throw new Error("Expected bootstrap and history load invocation order");
|
||||
}
|
||||
expect(bootstrapOrder).toBeLessThan(loadHistoryOrder);
|
||||
const afterTurnParams = afterTurn.mock.calls[0]?.[0];
|
||||
expect(afterTurnParams?.prePromptMessageCount).toBe(1);
|
||||
expect(afterTurnParams?.messages[0]).toEqual(postBootstrapHistory[0]);
|
||||
});
|
||||
|
||||
it("falls back to ingestBatch and still runs turn maintenance", async () => {
|
||||
const ingestBatch = vi.fn<NonNullable<ContextEngine["ingestBatch"]>>(async () => ({
|
||||
ingestedCount: 2,
|
||||
}));
|
||||
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
|
||||
createMaintenanceResult(),
|
||||
);
|
||||
const dispose = vi.fn(async () => {});
|
||||
const contextEngine = createContextEngine({ ingestBatch, maintain, dispose });
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
|
||||
await runPreparedCliAgent(buildPreparedContext(contextEngine));
|
||||
|
||||
expect(ingestBatch).toHaveBeenCalledTimes(1);
|
||||
const ingestBatchParams = ingestBatch.mock.calls[0]?.[0];
|
||||
expect(ingestBatchParams).toMatchObject({
|
||||
sessionId: "openclaw-session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
expect(ingestBatchParams?.messages).toHaveLength(2);
|
||||
expectMessageText(ingestBatchParams?.messages[0], "transcript visible ask");
|
||||
expectMessageText(ingestBatchParams?.messages[1], "final answer");
|
||||
expect(maintain).toHaveBeenCalledTimes(2);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves deferred maintenance ownership for background engines", async () => {
|
||||
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
|
||||
createMaintenanceResult(),
|
||||
);
|
||||
const dispose = vi.fn(async () => {});
|
||||
const contextEngine = createContextEngine({
|
||||
info: {
|
||||
id: "test-background-context-engine",
|
||||
name: "Test background context engine",
|
||||
turnMaintenanceMode: "background",
|
||||
},
|
||||
maintain,
|
||||
dispose,
|
||||
});
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
const context = buildPreparedContext(contextEngine);
|
||||
|
||||
await runPreparedCliAgent(context);
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(context.contextEngineDeferredTurnMaintenance).toBeDefined();
|
||||
await context.contextEngineDeferredTurnMaintenance;
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dispose background engines when no deferred turn maintenance is queued", async () => {
|
||||
const dispose = vi.fn(async () => {});
|
||||
const contextEngine = createContextEngine({
|
||||
info: {
|
||||
id: "test-background-context-engine",
|
||||
name: "Test background context engine",
|
||||
turnMaintenanceMode: "background",
|
||||
},
|
||||
dispose,
|
||||
});
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
|
||||
await runPreparedCliAgent(buildPreparedContext(contextEngine));
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dispose background engines after failed CLI attempts", async () => {
|
||||
executePreparedCliRunMock.mockRejectedValue(new Error("cli boom"));
|
||||
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
|
||||
createMaintenanceResult(),
|
||||
);
|
||||
const dispose = vi.fn(async () => {});
|
||||
const contextEngine = createContextEngine({
|
||||
info: {
|
||||
id: "test-background-context-engine",
|
||||
name: "Test background context engine",
|
||||
turnMaintenanceMode: "background",
|
||||
},
|
||||
maintain,
|
||||
dispose,
|
||||
});
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
|
||||
await expect(runPreparedCliAgent(buildPreparedContext(contextEngine))).rejects.toThrow(
|
||||
"cli boom",
|
||||
);
|
||||
|
||||
expect(maintain).toHaveBeenCalledTimes(1);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not finalize or run turn maintenance on failed CLI attempts", async () => {
|
||||
executePreparedCliRunMock.mockRejectedValue(new Error("cli boom"));
|
||||
const bootstrap = vi.fn<NonNullable<ContextEngine["bootstrap"]>>(async () => ({
|
||||
bootstrapped: true,
|
||||
}));
|
||||
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
|
||||
const ingestBatch = vi.fn<NonNullable<ContextEngine["ingestBatch"]>>(async () => ({
|
||||
ingestedCount: 0,
|
||||
}));
|
||||
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
|
||||
createMaintenanceResult(),
|
||||
);
|
||||
const dispose = vi.fn(async () => {});
|
||||
const contextEngine = createContextEngine({
|
||||
bootstrap,
|
||||
afterTurn,
|
||||
ingestBatch,
|
||||
maintain,
|
||||
dispose,
|
||||
});
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
|
||||
await expect(runPreparedCliAgent(buildPreparedContext(contextEngine))).rejects.toThrow(
|
||||
"cli boom",
|
||||
);
|
||||
|
||||
expect(bootstrap).toHaveBeenCalledTimes(1);
|
||||
expect(afterTurn).not.toHaveBeenCalled();
|
||||
expect(ingestBatch).not.toHaveBeenCalled();
|
||||
expect(maintain).toHaveBeenCalledTimes(1);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dispose context engines when CLI attempts fail", async () => {
|
||||
executePreparedCliRunMock.mockRejectedValue(new Error("cli boom"));
|
||||
const dispose = vi.fn(async () => {
|
||||
throw new Error("dispose boom");
|
||||
});
|
||||
const contextEngine = createContextEngine({ dispose });
|
||||
const { runPreparedCliAgent } = await import("./cli-runner.js");
|
||||
|
||||
await expect(runPreparedCliAgent(buildPreparedContext(contextEngine))).rejects.toThrow(
|
||||
"cli boom",
|
||||
);
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,8 @@ function buildPreparedContext(params?: {
|
||||
env: {},
|
||||
},
|
||||
reusableCliSession: params?.cliSessionId ? { sessionId: params.cliSessionId } : {},
|
||||
hadSessionFile: false,
|
||||
contextEngineConfig: {},
|
||||
modelId: "gpt-5.4",
|
||||
normalizedModel: "gpt-5.4",
|
||||
contextWindowInfo: {
|
||||
|
||||
@@ -130,6 +130,8 @@ function buildPreparedCliRunContext(params: {
|
||||
...(params.mcpConfigHash ? { mcpConfigHash: params.mcpConfigHash } : {}),
|
||||
},
|
||||
reusableCliSession: {},
|
||||
hadSessionFile: false,
|
||||
contextEngineConfig: {},
|
||||
modelId: params.model,
|
||||
normalizedModel: params.model,
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
@@ -272,6 +274,8 @@ describe("runCliAgent spawn path", () => {
|
||||
env: {},
|
||||
},
|
||||
reusableCliSession: {},
|
||||
hadSessionFile: false,
|
||||
contextEngineConfig: {},
|
||||
modelId: "sonnet",
|
||||
normalizedModel: "sonnet",
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
|
||||
+138
-11
@@ -1,3 +1,4 @@
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
@@ -6,9 +7,17 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
|
||||
import { resolveBlockMessage } from "../plugins/hook-decision-types.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import { loadCliSessionHistoryMessages } from "./cli-runner/session-history.js";
|
||||
import {
|
||||
loadCliSessionContextEngineMessages,
|
||||
loadCliSessionHistoryMessages,
|
||||
} from "./cli-runner/session-history.js";
|
||||
import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";
|
||||
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
|
||||
import {
|
||||
bootstrapHarnessContextEngine,
|
||||
finalizeHarnessContextEngineTurn,
|
||||
runHarnessContextEngineMaintenance,
|
||||
} from "./harness/context-engine-lifecycle.js";
|
||||
import { buildAgentHookContext } from "./harness/hook-context.js";
|
||||
import { buildAgentHookConversationMessages } from "./harness/hook-history.js";
|
||||
import {
|
||||
@@ -72,6 +81,89 @@ function buildCliHookAssistantMessage(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function isAgentMessage(value: unknown): value is AgentMessage {
|
||||
return Boolean(value && typeof value === "object" && "role" in value);
|
||||
}
|
||||
|
||||
function buildCliContextEngineUserMessage(prompt: string): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: prompt,
|
||||
timestamp: Date.now(),
|
||||
} as AgentMessage;
|
||||
}
|
||||
|
||||
function buildCliContextEngineAssistantMessage(params: {
|
||||
text: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
usage?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
total?: number;
|
||||
};
|
||||
}): AgentMessage {
|
||||
return buildCliHookAssistantMessage(params) as AgentMessage;
|
||||
}
|
||||
|
||||
async function finalizeCliContextEngineTurn(params: {
|
||||
context: PreparedCliRunContext;
|
||||
historyMessages: unknown[];
|
||||
assistantText: string;
|
||||
output: Awaited<
|
||||
ReturnType<typeof import("./cli-runner/execute.runtime.js").executePreparedCliRun>
|
||||
>;
|
||||
}): Promise<void> {
|
||||
const { context } = params;
|
||||
if (!context.contextEngine) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { params: runParams } = context;
|
||||
const prePromptMessages = params.historyMessages.filter(isAgentMessage);
|
||||
const turnMessages: AgentMessage[] = [];
|
||||
if (context.contextEngineTurnPrompt) {
|
||||
turnMessages.push(buildCliContextEngineUserMessage(context.contextEngineTurnPrompt));
|
||||
}
|
||||
if (params.assistantText) {
|
||||
turnMessages.push(
|
||||
buildCliContextEngineAssistantMessage({
|
||||
text: params.assistantText,
|
||||
provider: runParams.provider,
|
||||
model: context.modelId,
|
||||
usage: params.output.usage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let deferredTurnMaintenance: Promise<void> | undefined;
|
||||
const result = await finalizeHarnessContextEngineTurn({
|
||||
contextEngine: context.contextEngine,
|
||||
promptError: false,
|
||||
aborted: runParams.abortSignal?.aborted === true,
|
||||
yieldAborted: false,
|
||||
sessionIdUsed: runParams.sessionId,
|
||||
sessionKey: runParams.sessionKey,
|
||||
sessionFile: runParams.sessionFile,
|
||||
messagesSnapshot: [...prePromptMessages, ...turnMessages],
|
||||
prePromptMessageCount: prePromptMessages.length,
|
||||
config: context.contextEngineConfig,
|
||||
runMaintenance: async (maintenanceParams) =>
|
||||
await runHarnessContextEngineMaintenance({
|
||||
...maintenanceParams,
|
||||
onDeferredMaintenance: (promise) => {
|
||||
deferredTurnMaintenance = promise;
|
||||
},
|
||||
}),
|
||||
warn: (message) => log.warn(message),
|
||||
});
|
||||
if (result.postTurnFinalizationSucceeded && deferredTurnMaintenance) {
|
||||
context.contextEngineDeferredTurnMaintenance = deferredTurnMaintenance;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCliAgent(params: RunCliAgentParams): Promise<EmbeddedPiRunResult> {
|
||||
// Cron gate must fire before prepareCliRunContext — that call allocates
|
||||
// backend resources released only by runPreparedCliAgent's try…finally.
|
||||
@@ -138,16 +230,16 @@ export async function runPreparedCliAgent(
|
||||
const hasLlmOutputHooks = hookRunner?.hasHooks("llm_output") === true;
|
||||
const hasAgentEndHooks = hookRunner?.hasHooks("agent_end") === true;
|
||||
const hasBeforeAgentRunHooks = hookRunner?.hasHooks("before_agent_run") === true;
|
||||
const historyMessages =
|
||||
hasLlmInputHooks || hasAgentEndHooks || hasBeforeAgentRunHooks
|
||||
? await loadCliSessionHistoryMessages({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
})
|
||||
: [];
|
||||
const needsHookHistory = hasLlmInputHooks || hasAgentEndHooks || hasBeforeAgentRunHooks;
|
||||
const historyMessages = needsHookHistory
|
||||
? await loadCliSessionHistoryMessages({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
})
|
||||
: [];
|
||||
const llmInputEvent = {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
@@ -416,6 +508,25 @@ export async function runPreparedCliAgent(
|
||||
|
||||
// Try with the provided CLI session ID first
|
||||
try {
|
||||
await bootstrapHarnessContextEngine({
|
||||
hadSessionFile: context.hadSessionFile,
|
||||
contextEngine: context.contextEngine,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionFile: params.sessionFile,
|
||||
config: context.contextEngineConfig,
|
||||
warn: (message) => log.warn(message),
|
||||
});
|
||||
const contextEngineHistoryMessages = context.contextEngine
|
||||
? await loadCliSessionContextEngineMessages({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
})
|
||||
: [];
|
||||
|
||||
if (hasBeforeAgentRunHooks && hookRunner) {
|
||||
let beforeRunResult:
|
||||
| Awaited<ReturnType<NonNullable<typeof hookRunner>["runBeforeAgentRun"]>>
|
||||
@@ -479,7 +590,14 @@ export async function runPreparedCliAgent(
|
||||
const { output, lastAssistant } = await executeCliAttempt(
|
||||
context.reusableCliSession.sessionId,
|
||||
);
|
||||
const assistantText = output.text.trim();
|
||||
const effectiveCliSessionId = output.sessionId ?? context.reusableCliSession.sessionId;
|
||||
await finalizeCliContextEngineTurn({
|
||||
context,
|
||||
historyMessages: context.contextEngine ? contextEngineHistoryMessages : historyMessages,
|
||||
assistantText,
|
||||
output,
|
||||
});
|
||||
runAgentHarnessAgentEndHook({
|
||||
event: {
|
||||
messages: buildAgentEndMessages(lastAssistant),
|
||||
@@ -502,7 +620,16 @@ export async function runPreparedCliAgent(
|
||||
// For now, retry without the session ID to create a new session
|
||||
try {
|
||||
const { output, lastAssistant } = await executeCliAttempt(undefined);
|
||||
const assistantText = output.text.trim();
|
||||
const effectiveCliSessionId = output.sessionId;
|
||||
await finalizeCliContextEngineTurn({
|
||||
context,
|
||||
historyMessages: context.contextEngine
|
||||
? contextEngineHistoryMessages
|
||||
: historyMessages,
|
||||
assistantText,
|
||||
output,
|
||||
});
|
||||
runAgentHarnessAgentEndHook({
|
||||
event: {
|
||||
messages: buildAgentEndMessages(lastAssistant),
|
||||
|
||||
@@ -44,6 +44,8 @@ function buildPreparedCliRunContext(params: {
|
||||
env: {},
|
||||
},
|
||||
reusableCliSession: {},
|
||||
hadSessionFile: false,
|
||||
contextEngineConfig: {},
|
||||
modelId: "model",
|
||||
normalizedModel: "model",
|
||||
systemPrompt: "system",
|
||||
|
||||
@@ -4,6 +4,12 @@ import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { registerLegacyContextEngine } from "../../context-engine/legacy.registration.js";
|
||||
import {
|
||||
registerContextEngine,
|
||||
registerContextEngineForOwner,
|
||||
} from "../../context-engine/registry.js";
|
||||
import type { ContextEngine } from "../../context-engine/types.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { __testing as cliBackendsTesting } from "../cli-backends.js";
|
||||
import { hashCliSessionText } from "../cli-session.js";
|
||||
@@ -15,6 +21,12 @@ import {
|
||||
shouldSkipLocalCliCredentialEpoch,
|
||||
} from "./prepare.js";
|
||||
|
||||
const getRuntimeConfigMock = vi.hoisted(() => vi.fn(() => ({})));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
getRuntimeConfig: getRuntimeConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: vi.fn(() => null),
|
||||
}));
|
||||
@@ -174,12 +186,14 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
resolveOpenClawReferencePaths: vi.fn(async () => ({ docsPath: null, sourcePath: null })),
|
||||
});
|
||||
mockGetGlobalHookRunner.mockReturnValue(null);
|
||||
getRuntimeConfigMock.mockReturnValue({});
|
||||
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(undefined);
|
||||
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cliBackendsTesting.resetDepsForTest();
|
||||
getRuntimeConfigMock.mockReset();
|
||||
mockGetGlobalHookRunner.mockReset();
|
||||
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReset();
|
||||
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReset();
|
||||
@@ -281,6 +295,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
});
|
||||
|
||||
expect(context.params.prompt).toBe("history:2\n\nlatest ask");
|
||||
expect(context.contextEngineTurnPrompt).toBe("latest ask");
|
||||
expect(context.systemPrompt).toBe(
|
||||
"prepend system\n\nhook system\n\nappend system\n\nCurrent model identity: test-cli/test-model. If asked what model you are, answer with this value for the current run.",
|
||||
);
|
||||
@@ -377,6 +392,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
"Sender (untrusted metadata):\nsender_id=U123 trusted hook context\n\nlatest ask\n\ntrusted hook tail",
|
||||
);
|
||||
expect(context.params.transcriptPrompt).toBe("latest ask");
|
||||
expect(context.contextEngineTurnPrompt).toBe("latest ask");
|
||||
expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledTimes(1);
|
||||
const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array<
|
||||
[unknown, unknown]
|
||||
@@ -560,6 +576,174 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not allocate a non-legacy context engine before fallible CLI preparation finishes", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
const engineId = `cli-prepare-late-engine-${Date.now().toString(36)}`;
|
||||
const dispose = vi.fn(async () => {});
|
||||
const factory = vi.fn((): ContextEngine => {
|
||||
return {
|
||||
info: { id: engineId, name: "CLI prepare late engine" },
|
||||
ingest: vi.fn(async () => ({ ingested: true })),
|
||||
assemble: vi.fn(async ({ messages }) => ({ messages, estimatedTokens: 0 })),
|
||||
compact: vi.fn(async () => ({ ok: true, compacted: false })),
|
||||
dispose,
|
||||
};
|
||||
});
|
||||
registerContextEngine(engineId, factory);
|
||||
setCliRunnerPrepareTestDeps({
|
||||
resolveOpenClawReferencePaths: vi.fn(async () => {
|
||||
throw new Error("reference path lookup failed");
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
prepareCliRunContext({
|
||||
sessionId: "session-test",
|
||||
sessionFile,
|
||||
workspaceDir: dir,
|
||||
prompt: "latest ask",
|
||||
provider: "test-cli",
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-prepare-failure",
|
||||
config: {
|
||||
...createCliBackendConfig(),
|
||||
plugins: { slots: { contextEngine: engineId } },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("reference path lookup failed");
|
||||
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans up prepared CLI backend when context-engine resolution fails", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
const cleanup = vi.fn(async () => {});
|
||||
const prepareExecution = vi.fn(async () => ({ cleanup }));
|
||||
registerContextEngineForOwner(
|
||||
"legacy",
|
||||
() => {
|
||||
throw new Error("context engine failed");
|
||||
},
|
||||
"core",
|
||||
{ allowSameOwnerRefresh: true },
|
||||
);
|
||||
cliBackendsTesting.setDepsForTest({
|
||||
resolvePluginSetupCliBackend: () => undefined,
|
||||
resolveRuntimeCliBackends: () => [
|
||||
{
|
||||
id: "test-cli",
|
||||
pluginId: "test-plugin",
|
||||
bundleMcp: false,
|
||||
prepareExecution,
|
||||
config: {
|
||||
command: "test-cli",
|
||||
args: ["--print"],
|
||||
systemPromptArg: "--system-prompt",
|
||||
systemPromptWhen: "first",
|
||||
sessionMode: "existing",
|
||||
output: "text",
|
||||
input: "arg",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
prepareCliRunContext({
|
||||
sessionId: "session-test",
|
||||
sessionFile,
|
||||
workspaceDir: dir,
|
||||
prompt: "latest ask",
|
||||
provider: "test-cli",
|
||||
model: "test-model",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-test-context-engine-resolution-failure",
|
||||
config: createCliBackendConfig(),
|
||||
}),
|
||||
).rejects.toThrow("context engine failed");
|
||||
|
||||
expect(prepareExecution).toHaveBeenCalledOnce();
|
||||
expect(cleanup).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
registerLegacyContextEngine();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses runtime config when resolving the CLI context engine", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
const engineId = `cli-runtime-config-engine-${Date.now().toString(36)}`;
|
||||
const runtimeAgentDir = path.join(dir, "runtime-agent");
|
||||
const runtimeConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, agentDir: runtimeAgentDir }],
|
||||
},
|
||||
plugins: { slots: { contextEngine: engineId } },
|
||||
} satisfies OpenClawConfig;
|
||||
const factory = vi.fn((_ctx: unknown): ContextEngine => {
|
||||
return {
|
||||
info: { id: engineId, name: "CLI runtime config engine" },
|
||||
ingest: vi.fn(async () => ({ ingested: true })),
|
||||
assemble: vi.fn(async ({ messages }) => ({ messages, estimatedTokens: 0 })),
|
||||
compact: vi.fn(async () => ({ ok: true, compacted: false })),
|
||||
};
|
||||
});
|
||||
registerContextEngine(engineId, factory);
|
||||
getRuntimeConfigMock.mockReturnValue(runtimeConfig);
|
||||
cliBackendsTesting.setDepsForTest({
|
||||
resolvePluginSetupCliBackend: () => undefined,
|
||||
resolveRuntimeCliBackends: () => [
|
||||
{
|
||||
id: "test-cli",
|
||||
pluginId: "test-plugin",
|
||||
bundleMcp: false,
|
||||
config: {
|
||||
command: "test-cli",
|
||||
args: ["--print"],
|
||||
systemPromptArg: "--system-prompt",
|
||||
systemPromptWhen: "first",
|
||||
sessionMode: "existing",
|
||||
output: "text",
|
||||
input: "arg",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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-runtime-config-context-engine",
|
||||
});
|
||||
|
||||
expect(context.contextEngine?.info.id).toBe(engineId);
|
||||
expect(context.contextEngineConfig).toBe(runtimeConfig);
|
||||
expect(context.params.config).toBe(runtimeConfig);
|
||||
expect(factory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentDir: runtimeAgentDir,
|
||||
config: runtimeConfig,
|
||||
workspaceDir: dir,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses explicit static prompt text for CLI session reuse hashing", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import { ensureContextEnginesInitialized } from "../../context-engine/init.js";
|
||||
import { resolveContextEngine } from "../../context-engine/registry.js";
|
||||
import { ensureMcpLoopbackServer } from "../../gateway/mcp-http.js";
|
||||
import {
|
||||
createMcpLoopbackServerConfig,
|
||||
@@ -53,6 +55,7 @@ import { buildCliAgentSystemPrompt, normalizeCliModel } from "./helpers.js";
|
||||
import { cliBackendLog } from "./log.js";
|
||||
import {
|
||||
buildCliSessionHistoryPrompt,
|
||||
hasCliSessionTranscript,
|
||||
loadCliSessionHistoryMessages,
|
||||
loadCliSessionReseedMessages,
|
||||
} from "./session-history.js";
|
||||
@@ -475,25 +478,65 @@ export async function prepareCliRunContext(
|
||||
runtimeContextChars: 0,
|
||||
},
|
||||
});
|
||||
const contextEngineConfig = params.config ?? getRuntimeConfig();
|
||||
try {
|
||||
ensureContextEnginesInitialized();
|
||||
const { sessionAgentId: contextEngineSessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: contextEngineConfig,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const contextEngineAgentDir = resolveAgentDir(contextEngineConfig, contextEngineSessionAgentId);
|
||||
const resolvedContextEngine = await resolveContextEngine(contextEngineConfig, {
|
||||
agentDir: contextEngineAgentDir,
|
||||
workspaceDir,
|
||||
});
|
||||
const contextEngine =
|
||||
resolvedContextEngine.info.id !== "legacy" ? resolvedContextEngine : undefined;
|
||||
const hadSessionFile = await hasCliSessionTranscript({
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: contextEngineConfig,
|
||||
});
|
||||
const contextEngineTurnPrompt = params.transcriptPrompt ?? params.prompt;
|
||||
const preparedParams: RunCliAgentParams = {
|
||||
...params,
|
||||
config: contextEngineConfig,
|
||||
prompt: preparedPrompt,
|
||||
};
|
||||
|
||||
return {
|
||||
params: preparedPrompt === params.prompt ? params : { ...params, prompt: preparedPrompt },
|
||||
effectiveAuthProfileId,
|
||||
started,
|
||||
workspaceDir,
|
||||
backendResolved,
|
||||
preparedBackend: preparedBackendFinal,
|
||||
reusableCliSession,
|
||||
modelId,
|
||||
normalizedModel,
|
||||
contextWindowInfo,
|
||||
systemPrompt,
|
||||
systemPromptReport,
|
||||
bootstrapPromptWarningLines: bootstrapPromptWarning.lines,
|
||||
...(openClawHistoryPrompt ? { openClawHistoryPrompt } : {}),
|
||||
heartbeatPrompt,
|
||||
authEpoch,
|
||||
authEpochVersion: CLI_AUTH_EPOCH_VERSION,
|
||||
extraSystemPromptHash,
|
||||
};
|
||||
return {
|
||||
params: preparedParams,
|
||||
effectiveAuthProfileId,
|
||||
started,
|
||||
workspaceDir,
|
||||
backendResolved,
|
||||
preparedBackend: preparedBackendFinal,
|
||||
reusableCliSession,
|
||||
hadSessionFile,
|
||||
contextEngineConfig,
|
||||
contextEngine,
|
||||
contextEngineTurnPrompt,
|
||||
modelId,
|
||||
normalizedModel,
|
||||
contextWindowInfo,
|
||||
systemPrompt,
|
||||
systemPromptReport,
|
||||
bootstrapPromptWarningLines: bootstrapPromptWarning.lines,
|
||||
...(openClawHistoryPrompt ? { openClawHistoryPrompt } : {}),
|
||||
heartbeatPrompt,
|
||||
authEpoch,
|
||||
authEpochVersion: CLI_AUTH_EPOCH_VERSION,
|
||||
extraSystemPromptHash,
|
||||
};
|
||||
} catch (err) {
|
||||
try {
|
||||
await preparedBackendFinal.cleanup?.();
|
||||
} catch (cleanupErr) {
|
||||
cliBackendLog.warn(`cli backend cleanup after prepare failure failed: ${String(cleanupErr)}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildCliSessionHistoryPrompt,
|
||||
hasCliSessionTranscript,
|
||||
loadCliSessionContextEngineMessages,
|
||||
loadCliSessionHistoryMessages,
|
||||
loadCliSessionReseedMessages,
|
||||
MAX_CLI_SESSION_HISTORY_FILE_BYTES,
|
||||
@@ -80,6 +82,19 @@ function expectCompactionSummary(value: unknown, summary: string) {
|
||||
expect(message.summary).toBe(summary);
|
||||
}
|
||||
|
||||
function expectCustomMessage(value: unknown, expected: { customType: string; content: string }) {
|
||||
const message = requireRecord(value, "custom message");
|
||||
expect(message.role).toBe("custom");
|
||||
expect(message.customType).toBe(expected.customType);
|
||||
expect(message.content).toBe(expected.content);
|
||||
}
|
||||
|
||||
function expectBranchSummary(value: unknown, summary: string) {
|
||||
const message = requireRecord(value, "branch summary");
|
||||
expect(message.role).toBe("branchSummary");
|
||||
expect(message.summary).toBe(summary);
|
||||
}
|
||||
|
||||
describe("loadCliSessionHistoryMessages", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -116,6 +131,37 @@ describe("loadCliSessionHistoryMessages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("detects canonical transcripts when callers pass stale external session paths", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-outside-"));
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
createSessionTranscript({
|
||||
rootDir: stateDir,
|
||||
sessionId: "session-test",
|
||||
messages: ["expected history"],
|
||||
});
|
||||
const outsideFile = createSessionTranscript({
|
||||
rootDir: outsideDir,
|
||||
sessionId: "session-test",
|
||||
filePath: path.join(outsideDir, "stale.jsonl"),
|
||||
messages: ["stale history"],
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
hasCliSessionTranscript({
|
||||
sessionId: "session-test",
|
||||
sessionFile: outsideFile,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps only the newest bounded history window", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
@@ -146,6 +192,115 @@ describe("loadCliSessionHistoryMessages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps complete history for context-engine snapshots", 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-context-engine-history",
|
||||
messages: Array.from(
|
||||
{ length: MAX_CLI_SESSION_HISTORY_MESSAGES + 25 },
|
||||
(_, index) => `msg-${index}`,
|
||||
),
|
||||
});
|
||||
|
||||
try {
|
||||
const history = await loadCliSessionContextEngineMessages({
|
||||
sessionId: "session-context-engine-history",
|
||||
sessionFile,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
expect(history).toHaveLength(MAX_CLI_SESSION_HISTORY_MESSAGES + 25);
|
||||
expectMessageFields(history[0], { role: "user", content: "msg-0" });
|
||||
expectMessageFields(history.at(-1), {
|
||||
role: "user",
|
||||
content: `msg-${MAX_CLI_SESSION_HISTORY_MESSAGES + 24}`,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the latest compaction summary and complete tail for context-engine snapshots", 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-context-engine-compacted",
|
||||
messages: ["old ask"],
|
||||
});
|
||||
fs.appendFileSync(
|
||||
sessionFile,
|
||||
`${JSON.stringify({
|
||||
type: "compaction",
|
||||
id: "compact-1",
|
||||
timestamp: new Date(2).toISOString(),
|
||||
summary: "Earlier compacted context",
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.appendFileSync(
|
||||
sessionFile,
|
||||
`${JSON.stringify({
|
||||
type: "custom_message",
|
||||
id: "custom-tail",
|
||||
parentId: "compaction-1",
|
||||
timestamp: new Date(3).toISOString(),
|
||||
customType: "runtime-note",
|
||||
content: "tail custom context",
|
||||
display: false,
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.appendFileSync(
|
||||
sessionFile,
|
||||
`${JSON.stringify({
|
||||
type: "branch_summary",
|
||||
id: "branch-tail",
|
||||
parentId: "custom-tail",
|
||||
fromId: "custom-tail",
|
||||
timestamp: new Date(4).toISOString(),
|
||||
summary: "tail branch context",
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.appendFileSync(
|
||||
sessionFile,
|
||||
`${JSON.stringify({
|
||||
type: "message",
|
||||
id: "msg-tail",
|
||||
parentId: "branch-tail",
|
||||
timestamp: new Date(5).toISOString(),
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "tail answer",
|
||||
timestamp: 5,
|
||||
},
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
try {
|
||||
const history = await loadCliSessionContextEngineMessages({
|
||||
sessionId: "session-context-engine-compacted",
|
||||
sessionFile,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
expect(history).toHaveLength(4);
|
||||
expectCompactionSummary(history[0], "Earlier compacted context");
|
||||
expectCustomMessage(history[1], {
|
||||
customType: "runtime-note",
|
||||
content: "tail custom context",
|
||||
});
|
||||
expectBranchSummary(history[2], "tail branch context");
|
||||
expectMessageFields(history[3], { role: "assistant", content: "tail answer" });
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects symlinked transcripts instead of following them outside the sessions directory", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-outside-"));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import { migrateSessionEntries, parseSessionEntries } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
@@ -26,6 +27,15 @@ type HistoryEntry = {
|
||||
type?: unknown;
|
||||
message?: unknown;
|
||||
summary?: unknown;
|
||||
customType?: unknown;
|
||||
content?: unknown;
|
||||
display?: unknown;
|
||||
details?: unknown;
|
||||
timestamp?: unknown;
|
||||
fromId?: unknown;
|
||||
firstKeptEntryId?: unknown;
|
||||
tokensBefore?: unknown;
|
||||
tokensAfter?: unknown;
|
||||
};
|
||||
|
||||
type RawTranscriptReseedReason =
|
||||
@@ -62,6 +72,48 @@ function coerceHistoryText(content: unknown): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function coerceHistoryTimestamp(value: unknown): number | string {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function historyEntryToContextEngineMessage(entry: HistoryEntry): AgentMessage | undefined {
|
||||
if (entry.type === "message") {
|
||||
return entry.message as AgentMessage;
|
||||
}
|
||||
if (entry.type === "custom_message") {
|
||||
return {
|
||||
role: "custom",
|
||||
customType: typeof entry.customType === "string" ? entry.customType : "custom",
|
||||
content: entry.content,
|
||||
display: entry.display !== false,
|
||||
details: entry.details,
|
||||
timestamp: coerceHistoryTimestamp(entry.timestamp),
|
||||
} as AgentMessage;
|
||||
}
|
||||
if (entry.type === "branch_summary") {
|
||||
return {
|
||||
role: "branchSummary",
|
||||
summary: typeof entry.summary === "string" ? entry.summary : "",
|
||||
fromId: typeof entry.fromId === "string" ? entry.fromId : "root",
|
||||
timestamp: coerceHistoryTimestamp(entry.timestamp),
|
||||
} as AgentMessage;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function loadContextEngineMessagesFromEntries(entries: unknown[]): AgentMessage[] {
|
||||
return entries.flatMap((entry) => {
|
||||
const message = historyEntryToContextEngineMessage(entry as HistoryEntry);
|
||||
return message ? [message] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCliSessionHistoryPrompt(params: {
|
||||
messages: unknown[];
|
||||
prompt: string;
|
||||
@@ -185,6 +237,35 @@ async function loadCliSessionEntries(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasCliSessionTranscript(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
config?: OpenClawConfig;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const { sessionFile, sessionsDir } = resolveSafeCliSessionFile(params);
|
||||
const entryStat = await fsp.lstat(sessionFile);
|
||||
if (!entryStat.isFile() || entryStat.isSymbolicLink()) {
|
||||
return false;
|
||||
}
|
||||
const realSessionsDir = (await safeRealpath(sessionsDir)) ?? path.resolve(sessionsDir);
|
||||
const realSessionFile = await safeRealpath(sessionFile);
|
||||
if (
|
||||
!realSessionFile ||
|
||||
realSessionFile === realSessionsDir ||
|
||||
!isPathInside(realSessionsDir, realSessionFile)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const stat = await fsp.stat(realSessionFile);
|
||||
return stat.isFile() && stat.size <= MAX_CLI_SESSION_HISTORY_FILE_BYTES;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCliSessionHistoryMessages(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
@@ -199,6 +280,49 @@ export async function loadCliSessionHistoryMessages(params: {
|
||||
return limitAgentHookHistoryMessages(history, MAX_CLI_SESSION_HISTORY_MESSAGES);
|
||||
}
|
||||
|
||||
export async function loadCliSessionContextEngineMessages(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
config?: OpenClawConfig;
|
||||
}): Promise<unknown[]> {
|
||||
const entries = await loadCliSessionEntries(params);
|
||||
const latestCompactionIndex = entries.findLastIndex((entry) => {
|
||||
const candidate = entry as HistoryEntry;
|
||||
return candidate.type === "compaction" && typeof candidate.summary === "string";
|
||||
});
|
||||
if (latestCompactionIndex < 0) {
|
||||
return loadContextEngineMessagesFromEntries(entries);
|
||||
}
|
||||
|
||||
const compaction = entries[latestCompactionIndex] as HistoryEntry;
|
||||
const summary = typeof compaction.summary === "string" ? compaction.summary.trim() : "";
|
||||
if (!summary) {
|
||||
return loadContextEngineMessagesFromEntries(entries);
|
||||
}
|
||||
|
||||
const tailMessages = loadContextEngineMessagesFromEntries(
|
||||
entries.slice(latestCompactionIndex + 1),
|
||||
);
|
||||
return [
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary,
|
||||
timestamp: coerceHistoryTimestamp(compaction.timestamp),
|
||||
tokensBefore: typeof compaction.tokensBefore === "number" ? compaction.tokensBefore : 0,
|
||||
...(typeof compaction.tokensAfter === "number"
|
||||
? { tokensAfter: compaction.tokensAfter }
|
||||
: {}),
|
||||
...(typeof compaction.firstKeptEntryId === "string"
|
||||
? { firstKeptEntryId: compaction.firstKeptEntryId }
|
||||
: {}),
|
||||
...(compaction.details !== undefined ? { details: compaction.details } : {}),
|
||||
},
|
||||
...tailMessages,
|
||||
];
|
||||
}
|
||||
|
||||
export async function loadCliSessionReseedMessages(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CliSessionBinding } from "../../config/sessions.js";
|
||||
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||
import type { CliBackendConfig } from "../../config/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { ContextEngine } from "../../context-engine/types.js";
|
||||
import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
|
||||
import type { InputProvenance } from "../../sessions/input-provenance.js";
|
||||
import type { BootstrapContextMode } from "../bootstrap-files.js";
|
||||
@@ -114,6 +115,11 @@ export type PreparedCliRunContext = {
|
||||
backendResolved: ResolvedCliBackend;
|
||||
preparedBackend: CliPreparedBackend;
|
||||
reusableCliSession: CliReusableSession;
|
||||
hadSessionFile: boolean;
|
||||
contextEngineConfig: OpenClawConfig;
|
||||
contextEngine?: ContextEngine;
|
||||
contextEngineTurnPrompt?: string;
|
||||
contextEngineDeferredTurnMaintenance?: Promise<void>;
|
||||
modelId: string;
|
||||
normalizedModel: string;
|
||||
contextWindowInfo?: ContextWindowInfo;
|
||||
|
||||
@@ -230,6 +230,7 @@ export async function runHarnessContextEngineMaintenance(params: {
|
||||
sessionManager?: unknown;
|
||||
runtimeContext?: ContextEngineRuntimeContext;
|
||||
executionMode?: "foreground" | "background";
|
||||
onDeferredMaintenance?: (promise: Promise<void>) => void;
|
||||
config?: SessionWriteLockAcquireTimeoutConfig;
|
||||
}) {
|
||||
return await runContextEngineMaintenance({
|
||||
@@ -243,6 +244,7 @@ export async function runHarnessContextEngineMaintenance(params: {
|
||||
>[0]["sessionManager"],
|
||||
runtimeContext: params.runtimeContext,
|
||||
executionMode: params.executionMode,
|
||||
onDeferredMaintenance: params.onDeferredMaintenance,
|
||||
config: params.config,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -645,6 +645,7 @@ describe("runContextEngineMaintenance", () => {
|
||||
|
||||
const sessionKey = "agent:main:session-rerun";
|
||||
let releaseFirstMaintenance: (() => void) | undefined;
|
||||
let releaseSecondMaintenance: (() => void) | undefined;
|
||||
let maintenanceCalls = 0;
|
||||
const maintain = vi.fn(async () => {
|
||||
maintenanceCalls += 1;
|
||||
@@ -653,6 +654,11 @@ describe("runContextEngineMaintenance", () => {
|
||||
releaseFirstMaintenance = resolve;
|
||||
});
|
||||
}
|
||||
if (maintenanceCalls === 2) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseSecondMaintenance = resolve;
|
||||
});
|
||||
}
|
||||
return {
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
@@ -674,6 +680,7 @@ describe("runContextEngineMaintenance", () => {
|
||||
compact: async () => ({ ok: true, compacted: false }),
|
||||
maintain,
|
||||
} as NonNullable<Parameters<typeof runContextEngineMaintenance>[0]["contextEngine"]>;
|
||||
const deferredPromises: Promise<void>[] = [];
|
||||
|
||||
await runContextEngineMaintenance({
|
||||
contextEngine: backgroundEngine,
|
||||
@@ -681,6 +688,9 @@ describe("runContextEngineMaintenance", () => {
|
||||
sessionKey,
|
||||
sessionFile: "/tmp/session-rerun.jsonl",
|
||||
reason: "turn",
|
||||
onDeferredMaintenance: (promise) => {
|
||||
deferredPromises.push(promise);
|
||||
},
|
||||
});
|
||||
|
||||
await waitForAssertion(() => expect(maintain).toHaveBeenCalledTimes(1));
|
||||
@@ -691,6 +701,14 @@ describe("runContextEngineMaintenance", () => {
|
||||
sessionKey,
|
||||
sessionFile: "/tmp/session-rerun.jsonl",
|
||||
reason: "turn",
|
||||
onDeferredMaintenance: (promise) => {
|
||||
deferredPromises.push(promise);
|
||||
},
|
||||
});
|
||||
expect(deferredPromises).toHaveLength(2);
|
||||
let secondDeferredSettled = false;
|
||||
const secondDeferred = deferredPromises[1].then(() => {
|
||||
secondDeferredSettled = true;
|
||||
});
|
||||
|
||||
if (!releaseFirstMaintenance) {
|
||||
@@ -698,6 +716,15 @@ describe("runContextEngineMaintenance", () => {
|
||||
}
|
||||
releaseFirstMaintenance();
|
||||
await waitForAssertion(() => expect(maintain).toHaveBeenCalledTimes(2));
|
||||
await Promise.resolve();
|
||||
expect(secondDeferredSettled).toBe(false);
|
||||
|
||||
if (!releaseSecondMaintenance) {
|
||||
throw new Error("Expected second maintenance release callback to be initialized");
|
||||
}
|
||||
releaseSecondMaintenance();
|
||||
await secondDeferred;
|
||||
expect(secondDeferredSettled).toBe(true);
|
||||
|
||||
const tasks = listTasksForOwnerKey(sessionKey).filter(
|
||||
(task) => task.taskKind === TURN_MAINTENANCE_TASK_KIND,
|
||||
|
||||
@@ -525,16 +525,18 @@ async function runDeferredTurnMaintenanceWorker(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDeferredTurnMaintenance(params: DeferredTurnMaintenanceScheduleParams): void {
|
||||
function scheduleDeferredTurnMaintenance(
|
||||
params: DeferredTurnMaintenanceScheduleParams,
|
||||
): Promise<void> | undefined {
|
||||
const sessionKey = normalizeSessionKey(params.sessionKey);
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
const activeRun = activeDeferredTurnMaintenanceRuns.get(sessionKey);
|
||||
if (activeRun) {
|
||||
activeRun.rerunRequested = true;
|
||||
activeRun.latestParams = { ...params, sessionKey };
|
||||
return;
|
||||
return activeRun.promise;
|
||||
}
|
||||
|
||||
const existingTask = findActiveSessionTask({
|
||||
@@ -589,7 +591,7 @@ function scheduleDeferredTurnMaintenance(params: DeferredTurnMaintenanceSchedule
|
||||
taskId: task.taskId,
|
||||
error: err,
|
||||
});
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
let state!: DeferredTurnMaintenanceRunState;
|
||||
const trackedPromise = runPromise
|
||||
@@ -600,7 +602,7 @@ function scheduleDeferredTurnMaintenance(params: DeferredTurnMaintenanceSchedule
|
||||
error: err,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
.finally(async () => {
|
||||
schedulerAbort.dispose();
|
||||
const current = activeDeferredTurnMaintenanceRuns.get(sessionKey);
|
||||
if (current !== state) {
|
||||
@@ -611,7 +613,7 @@ function scheduleDeferredTurnMaintenance(params: DeferredTurnMaintenanceSchedule
|
||||
current.rerunRequested && !shutdownTriggered ? current.latestParams : undefined;
|
||||
activeDeferredTurnMaintenanceRuns.delete(sessionKey);
|
||||
if (rerunParams) {
|
||||
scheduleDeferredTurnMaintenance(rerunParams);
|
||||
await scheduleDeferredTurnMaintenance(rerunParams);
|
||||
}
|
||||
});
|
||||
state = {
|
||||
@@ -621,6 +623,7 @@ function scheduleDeferredTurnMaintenance(params: DeferredTurnMaintenanceSchedule
|
||||
};
|
||||
activeDeferredTurnMaintenanceRuns.set(sessionKey, state);
|
||||
void trackedPromise;
|
||||
return trackedPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -636,6 +639,7 @@ export async function runContextEngineMaintenance(params: {
|
||||
runtimeContext?: ContextEngineRuntimeContext;
|
||||
agentId?: string;
|
||||
executionMode?: "foreground" | "background";
|
||||
onDeferredMaintenance?: (promise: Promise<void>) => void;
|
||||
config?: OpenClawConfig;
|
||||
}): Promise<ContextEngineMaintenanceResult | undefined> {
|
||||
if (typeof params.contextEngine?.maintain !== "function") {
|
||||
@@ -650,7 +654,7 @@ export async function runContextEngineMaintenance(params: {
|
||||
|
||||
if (shouldDefer) {
|
||||
try {
|
||||
scheduleDeferredTurnMaintenance({
|
||||
const deferred = scheduleDeferredTurnMaintenance({
|
||||
contextEngine: params.contextEngine,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
@@ -660,6 +664,9 @@ export async function runContextEngineMaintenance(params: {
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
});
|
||||
if (deferred) {
|
||||
params.onDeferredMaintenance?.(deferred);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`failed to schedule deferred context engine maintenance: ${String(err)}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user