From 0d97101665c12e343876e69e7f934a7c73e88226 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 3 Mar 2026 18:55:27 -0500 Subject: [PATCH] Agents: preserve bootstrap warning dedupe across followup runs --- CHANGELOG.md | 1 + src/auto-reply/reply/agent-runner-memory.ts | 16 +++- .../agent-runner.runreplyagent.e2e.test.ts | 77 ++++++++++++++++++- src/auto-reply/reply/followup-runner.test.ts | 64 +++++++++++++++ src/auto-reply/reply/followup-runner.ts | 20 ++++- 5 files changed, 172 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2284c1a0dc3..36040c434f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - iOS/TTS playback fallback: keep voice playback resilient by switching from PCM to MP3 when provider format support is unavailable, while avoiding sticky fallback on generic local playback errors. (#33032) thanks @mbelinky. - Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin. - Telegram/plugin outbound hook parity: run `message_sending` + `message_sent` in Telegram reply delivery, include reply-path hook metadata (`mediaUrls`, `threadId`), and report `message_sent.success=false` when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee. +- Agents/bootstrap truncation warning handling: unify bootstrap budget/truncation analysis across embedded + CLI runtime, `/context`, and `openclaw doctor`; add `agents.defaults.bootstrapPromptTruncationWarning` (`off|once|always`, default `once`) and persist warning-signature metadata so truncation warnings are consistent and deduped across turns. (#32769) Thanks @gumadeiras. - Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras. - Agents/Session startup date grounding: substitute `YYYY-MM-DD` placeholders in startup/post-compaction AGENTS context and append runtime current-time lines for `/new` and `/reset` prompts so daily-memory references resolve correctly. (#32381) Thanks @chengzhichao-xydt. - Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone. diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index e14946ce8c2..19b3449422c 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { estimateMessagesTokens } from "../../agents/compaction.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { isCliProvider } from "../../agents/model-selection.js"; @@ -452,6 +453,10 @@ export async function runMemoryFlushIfNeeded(params: { let activeSessionEntry = entry ?? params.sessionEntry; const activeSessionStore = params.sessionStore; + let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + activeSessionEntry?.systemPromptReport ?? + (params.sessionKey ? activeSessionStore?.[params.sessionKey]?.systemPromptReport : undefined), + ); const flushRunId = crypto.randomUUID(); if (params.sessionKey) { registerAgentRunContext(flushRunId, { @@ -469,7 +474,7 @@ export async function runMemoryFlushIfNeeded(params: { try { await runWithModelFallback({ ...resolveModelFallbackOptions(params.followupRun.run), - run: (provider, model) => { + run: async (provider, model) => { const { authProfile, embeddedContext, senderContext } = buildEmbeddedRunContexts({ run: params.followupRun.run, sessionCtx: params.sessionCtx, @@ -483,7 +488,7 @@ export async function runMemoryFlushIfNeeded(params: { runId: flushRunId, authProfile, }); - return runEmbeddedPiAgent({ + const result = await runEmbeddedPiAgent({ ...embeddedContext, ...senderContext, ...runBaseParams, @@ -493,6 +498,9 @@ export async function runMemoryFlushIfNeeded(params: { cfg: params.cfg, }), extraSystemPrompt: flushSystemPrompt, + bootstrapPromptWarningSignaturesSeen, + bootstrapPromptWarningSignature: + bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1], onAgentEvent: (evt) => { if (evt.stream === "compaction") { const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; @@ -502,6 +510,10 @@ export async function runMemoryFlushIfNeeded(params: { } }, }); + bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + result.meta?.systemPromptReport, + ); + return result; }, }); let memoryFlushCompactionCount = diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index d05819f754c..a4f689412ab 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -28,6 +28,8 @@ type AgentRunParams = { type EmbeddedRunParams = { prompt?: string; extraSystemPrompt?: string; + bootstrapPromptWarningSignaturesSeen?: string[]; + bootstrapPromptWarningSignature?: string; onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void; }; @@ -1114,7 +1116,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const sessionId = "session"; const storePath = path.join(stateDir, "sessions", "sessions.json"); const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry = { + const sessionEntry: SessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath, @@ -1478,7 +1480,7 @@ describe("runReplyAgent memory flush", () => { it("skips memory flush for CLI providers", async () => { await withTempStore(async (storePath) => { const sessionKey = "main"; - const sessionEntry = { + const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now(), totalTokens: 80_000, @@ -1577,6 +1579,77 @@ describe("runReplyAgent memory flush", () => { }); }); + it("passes stored bootstrap warning signatures to memory flush runs", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + systemPromptReport: { + source: "run", + generatedAt: Date.now(), + systemPrompt: { + chars: 1, + projectContextChars: 0, + nonProjectContextChars: 1, + }, + injectedWorkspaceFiles: [], + skills: { + promptChars: 0, + entries: [], + }, + tools: { + listChars: 0, + schemaChars: 0, + entries: [], + }, + bootstrapTruncation: { + warningMode: "once", + warningShown: true, + promptWarningSignature: "sig-b", + warningSignaturesSeen: ["sig-a", "sig-b"], + truncatedFiles: 1, + nearLimitFiles: 0, + totalNearLimit: false, + }, + }, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push(params); + if (params.prompt?.includes("Pre-compaction memory flush.")) { + return { payloads: [], meta: {} }; + } + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.bootstrapPromptWarningSignaturesSeen).toEqual(["sig-a", "sig-b"]); + expect(calls[0]?.bootstrapPromptWarningSignature).toBe("sig-b"); + }); + }); + it("runs a memory flush turn and updates session metadata", async () => { await withTempStore(async (storePath) => { const sessionKey = "main"; diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index ae737b68fe3..a02ce0b2038 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -163,6 +163,70 @@ describe("createFollowupRunner compaction", () => { }); }); +describe("createFollowupRunner bootstrap warning dedupe", () => { + it("passes stored warning signature history to embedded followup runs", async () => { + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [], + meta: {}, + }); + + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + systemPromptReport: { + source: "run", + generatedAt: Date.now(), + systemPrompt: { + chars: 1, + projectContextChars: 0, + nonProjectContextChars: 1, + }, + injectedWorkspaceFiles: [], + skills: { + promptChars: 0, + entries: [], + }, + tools: { + listChars: 0, + schemaChars: 0, + entries: [], + }, + bootstrapTruncation: { + warningMode: "once", + warningShown: true, + promptWarningSignature: "sig-b", + warningSignaturesSeen: ["sig-a", "sig-b"], + truncatedFiles: 1, + nearLimitFiles: 0, + totalNearLimit: false, + }, + }, + }; + const sessionStore: Record = { main: sessionEntry }; + + const runner = createFollowupRunner({ + opts: { onBlockReply: vi.fn(async () => {}) }, + typing: createMockTypingController(), + typingMode: "instant", + sessionEntry, + sessionStore, + sessionKey: "main", + defaultModel: "anthropic/claude-opus-4-5", + }); + + await runner(baseQueuedRun()); + + const call = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0] as + | { + bootstrapPromptWarningSignaturesSeen?: string[]; + bootstrapPromptWarningSignature?: string; + } + | undefined; + expect(call?.bootstrapPromptWarningSignaturesSeen).toEqual(["sig-a", "sig-b"]); + expect(call?.bootstrapPromptWarningSignature).toBe("sig-b"); + }); +}); + describe("createFollowupRunner messaging tool dedupe", () => { function createMessagingDedupeRunner( onBlockReply: (payload: unknown) => Promise, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 2a9cf9a550f..0d796f37dae 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; +import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; @@ -140,6 +141,11 @@ export function createFollowupRunner(params: { let runResult: Awaited>; let fallbackProvider = queued.run.provider; let fallbackModel = queued.run.model; + const activeSessionEntry = + (sessionKey ? sessionStore?.[sessionKey] : undefined) ?? sessionEntry; + let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + activeSessionEntry?.systemPromptReport, + ); try { const fallbackResult = await runWithModelFallback({ cfg: queued.run.config, @@ -151,9 +157,9 @@ export function createFollowupRunner(params: { agentId: queued.run.agentId, sessionKey: queued.run.sessionKey, }), - run: (provider, model) => { + run: async (provider, model) => { const authProfile = resolveRunAuthProfile(queued.run, provider); - return runEmbeddedPiAgent({ + const result = await runEmbeddedPiAgent({ sessionId: queued.run.sessionId, sessionKey: queued.run.sessionKey, agentId: queued.run.agentId, @@ -195,6 +201,11 @@ export function createFollowupRunner(params: { timeoutMs: queued.run.timeoutMs, runId, blockReplyBreak: queued.run.blockReplyBreak, + bootstrapPromptWarningSignaturesSeen, + bootstrapPromptWarningSignature: + bootstrapPromptWarningSignaturesSeen[ + bootstrapPromptWarningSignaturesSeen.length - 1 + ], onAgentEvent: (evt) => { if (evt.stream !== "compaction") { return; @@ -205,6 +216,10 @@ export function createFollowupRunner(params: { } }, }); + bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + result.meta?.systemPromptReport, + ); + return result; }, }); runResult = fallbackResult.result; @@ -235,6 +250,7 @@ export function createFollowupRunner(params: { modelUsed, providerUsed: fallbackProvider, contextTokensUsed, + systemPromptReport: runResult.meta?.systemPromptReport, logLabel: "followup", }); }