diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da2897b890..c18fb4a8f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,7 @@ Docs: https://docs.openclaw.ai - Hooks/session-memory: pass the resolved agent workspace into gateway `/new` and `/reset` session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc. - CLI/approvals: raise the default `openclaw approvals get` gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading `Config unavailable.` note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana. - Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc. - +- Agents/context engine: compact engine-owned sessions from the first tool-loop delta and preserve ingest fallback when `afterTurn` is absent, so long-running tool loops can stay bounded without dropping engine state. (#63555) Thanks @Bikkies. ## 2026.4.12 ### Changes @@ -330,6 +330,7 @@ Docs: https://docs.openclaw.ai - Agents/inbound metadata: strip NUL bytes from serialized inbound context blocks before they reach backend spawn args, so malformed message metadata cannot crash agent spawn with `ERR_INVALID_ARG_VALUE`. (#65389) Thanks @adminfedres and @vincentkoc. - iMessage: retry transient `watch.subscribe` startup failures before tearing down the monitor, so brief local transport stalls do not immediately bounce the channel. (#65393) Thanks @vincentkoc. - Status/session_status: move shared session status text into a neutral internal status module and keep the tool importing a local runtime shim, so built `session_status` no longer depends on reply command internals or a bundler-opaque runtime import. (#65807) Thanks @dutifulbob. +- QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims. ## 2026.4.9 diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index cc70688ed7f..83606788450 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -991,6 +991,7 @@ export async function runEmbeddedAttempt( throw new Error("Embedded agent session missing"); } const activeSession = session; + let prePromptMessageCount = activeSession.messages.length; abortSessionForYield = () => { yieldAbortSettled = Promise.resolve(activeSession.abort()); }; @@ -1016,6 +1017,7 @@ export async function runEmbeddedAttempt( sessionFile: params.sessionFile, tokenBudget: params.contextTokenBudget, modelId: params.modelId, + getPrePromptMessageCount: () => prePromptMessageCount, }); } const cacheTrace = createCacheTrace({ @@ -1662,7 +1664,6 @@ export async function runEmbeddedAttempt( let promptError: unknown = null; let preflightRecovery: EmbeddedRunAttemptResult["preflightRecovery"]; let promptErrorSource: "prompt" | "compaction" | "precheck" | null = null; - let prePromptMessageCount = activeSession.messages.length; let skipPromptSubmission = false; try { const promptStartedAt = Date.now(); diff --git a/src/agents/pi-embedded-runner/tool-result-context-guard.test.ts b/src/agents/pi-embedded-runner/tool-result-context-guard.test.ts index 113878beca0..b75e8aaa350 100644 --- a/src/agents/pi-embedded-runner/tool-result-context-guard.test.ts +++ b/src/agents/pi-embedded-runner/tool-result-context-guard.test.ts @@ -245,6 +245,8 @@ describe("installToolResultContextGuard", () => { type MockedEngine = ContextEngine & { afterTurn: ReturnType; assemble: ReturnType; + ingest: ReturnType; + ingestBatch?: ReturnType; }; function makeMockEngine( @@ -254,6 +256,11 @@ function makeMockEngine( ) => Promise<{ messages: AgentMessage[]; estimatedTokens: number }>; afterTurn?: (params: Parameters>[0]) => Promise; omitAfterTurn?: boolean; + ingest?: (params: Parameters[0]) => Promise<{ ingested: boolean }>; + ingestBatch?: ( + params: Parameters>[0], + ) => Promise<{ ingestedCount: number }>; + omitIngestBatch?: boolean; } = {}, ): MockedEngine { const defaultAfterTurn = vi.fn(async () => {}); @@ -261,12 +268,24 @@ function makeMockEngine( messages: params.messages, estimatedTokens: 0, })); + const defaultIngest = vi.fn(async () => ({ ingested: true })); + const defaultIngestBatch = vi.fn( + async (params: Parameters>[0]) => ({ + ingestedCount: params.messages.length, + }), + ); const afterTurn = overrides.omitAfterTurn ? undefined : overrides.afterTurn ? vi.fn(overrides.afterTurn) : defaultAfterTurn; const assemble = overrides.assemble ? vi.fn(overrides.assemble) : defaultAssemble; + const ingest = overrides.ingest ? vi.fn(overrides.ingest) : defaultIngest; + const ingestBatch = overrides.omitIngestBatch + ? undefined + : overrides.ingestBatch + ? vi.fn(overrides.ingestBatch) + : defaultIngestBatch; const engine = { info: { id: "test-engine", @@ -274,8 +293,9 @@ function makeMockEngine( version: "0.0.1", ownsCompaction: true, }, - ingest: async () => ({ ingested: true }), + ingest, assemble, + ...(ingestBatch ? { ingestBatch } : {}), ...(afterTurn ? { afterTurn } : {}), } as unknown as MockedEngine; return engine; @@ -298,6 +318,7 @@ describe("installContextEngineLoopHook", () => { function installHook( agent: ReturnType, engine: MockedEngine, + prePromptCount?: number, ): () => void { return installContextEngineLoopHook({ agent, @@ -307,13 +328,14 @@ describe("installContextEngineLoopHook", () => { sessionFile, tokenBudget, modelId, + ...(prePromptCount !== undefined ? { getPrePromptMessageCount: () => prePromptCount } : {}), }); } - it("returns early on the first call without calling afterTurn or assemble", async () => { + it("returns early when the current messages match the pre-prompt baseline", async () => { const agent = makeGuardableAgent(); const engine = makeMockEngine(); - installHook(agent, engine); + installHook(agent, engine, 2); const messages = [makeUser("first"), makeToolResult("call_1", "result")]; const transformed = await callTransform(agent, messages); @@ -323,6 +345,22 @@ describe("installContextEngineLoopHook", () => { expect(engine.assemble).not.toHaveBeenCalled(); }); + it("processes the first call when messages already exceed the pre-prompt baseline", async () => { + const agent = makeGuardableAgent(); + const engine = makeMockEngine(); + installHook(agent, engine, 1); + + const messages = [makeUser("first"), makeToolResult("call_1", "result")]; + await callTransform(agent, messages); + + expect(engine.afterTurn).toHaveBeenCalledTimes(1); + expect(engine.afterTurn.mock.calls[0]?.[0]).toMatchObject({ + prePromptMessageCount: 1, + messages, + }); + expect(engine.assemble).toHaveBeenCalledTimes(1); + }); + it("calls afterTurn and assemble when new messages are appended after the first call", async () => { const agent = makeGuardableAgent(); const engine = makeMockEngine(); @@ -442,7 +480,7 @@ describe("installContextEngineLoopHook", () => { expect(sourceMessages).toEqual(sourceCopy); }); - it("still calls assemble when engine lacks afterTurn but new messages arrive", async () => { + it("ingests new messages in batches when afterTurn is absent", async () => { const agent = makeGuardableAgent(); const engine = makeMockEngine({ omitAfterTurn: true }); installHook(agent, engine); @@ -456,9 +494,33 @@ describe("installContextEngineLoopHook", () => { const batch2 = [...batch1, makeUser("third"), makeToolResult("call_3", "r3")]; await callTransform(agent, batch2); + expect(engine.ingestBatch).toHaveBeenCalledTimes(2); + expect(engine.ingestBatch?.mock.calls[0]?.[0]).toMatchObject({ + messages: [makeUser("second"), makeToolResult("call_2", "r2")], + }); + expect(engine.ingestBatch?.mock.calls[1]?.[0]).toMatchObject({ + messages: [makeUser("third"), makeToolResult("call_3", "r3")], + }); expect(engine.assemble).toHaveBeenCalledTimes(2); }); + it("falls back to per-message ingest when ingestBatch is absent", async () => { + const agent = makeGuardableAgent(); + const engine = makeMockEngine({ omitAfterTurn: true, omitIngestBatch: true }); + installHook(agent, engine, 1); + + const messages = [makeUser("first"), makeToolResult("call_1", "r1")]; + await callTransform(agent, messages); + + expect(engine.ingest).toHaveBeenCalledTimes(1); + expect(engine.ingest.mock.calls[0]?.[0]).toMatchObject({ + sessionId, + sessionKey, + message: makeToolResult("call_1", "r1"), + }); + expect(engine.assemble).toHaveBeenCalledTimes(1); + }); + it("falls through to source messages when engine.afterTurn throws", async () => { const agent = makeGuardableAgent(); const engine = makeMockEngine({ diff --git a/src/agents/pi-embedded-runner/tool-result-context-guard.ts b/src/agents/pi-embedded-runner/tool-result-context-guard.ts index 14fbb3d48c5..601097d58b7 100644 --- a/src/agents/pi-embedded-runner/tool-result-context-guard.ts +++ b/src/agents/pi-embedded-runner/tool-result-context-guard.ts @@ -197,6 +197,7 @@ export function installContextEngineLoopHook(params: { sessionFile: string; tokenBudget?: number; modelId: string; + getPrePromptMessageCount?: () => number; }): () => void { const { contextEngine, sessionId, sessionKey, sessionFile, tokenBudget, modelId } = params; const mutableAgent = params.agent as GuardableAgentRecord; @@ -210,26 +211,52 @@ export function installContextEngineLoopHook(params: { : messages; const sourceMessages = Array.isArray(transformed) ? transformed : messages; - if (lastSeenLength === null) { - lastSeenLength = sourceMessages.length; - } + // Seed the loop fence from the attempt's pre-prompt message count when available. + // This keeps the first real post-tool-call iteration eligible for compaction even + // if the hook's first observed call happens after tool results were appended. + const prePromptMessageCount = Math.max( + 0, + Math.min( + sourceMessages.length, + lastSeenLength ?? params.getPrePromptMessageCount?.() ?? sourceMessages.length, + ), + ); + lastSeenLength = prePromptMessageCount; - const hasNewMessages = sourceMessages.length > lastSeenLength; + const hasNewMessages = sourceMessages.length > prePromptMessageCount; if (!hasNewMessages) { return lastAssembledView ?? sourceMessages; } try { if (typeof contextEngine.afterTurn === "function") { - const prePromptCount = lastSeenLength; await contextEngine.afterTurn({ sessionId, sessionKey, sessionFile, messages: sourceMessages, - prePromptMessageCount: prePromptCount, + prePromptMessageCount, tokenBudget, }); + } else { + const newMessages = sourceMessages.slice(prePromptMessageCount); + if (newMessages.length > 0) { + if (typeof contextEngine.ingestBatch === "function") { + await contextEngine.ingestBatch({ + sessionId, + sessionKey, + messages: newMessages, + }); + } else { + for (const message of newMessages) { + await contextEngine.ingest({ + sessionId, + sessionKey, + message, + }); + } + } + } } lastSeenLength = sourceMessages.length; const assembled = await contextEngine.assemble({