Agent: unify bootstrap truncation warning handling (#32769)

Merged via squash.

Prepared head SHA: 5d6d4ddfa6
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-03-03 16:28:38 -05:00
committed by GitHub
parent 3ad3a90db3
commit e4b4486a96
34 changed files with 1488 additions and 224 deletions

View File

@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
import { runCliAgent } from "../../agents/cli-runner.js";
import { getCliSessionId } from "../../agents/cli-session.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
@@ -125,6 +126,9 @@ export async function runAgentTurnWithFallback(params: {
let fallbackAttempts: RuntimeFallbackAttempt[] = [];
let didResetAfterCompactionFailure = false;
let didRetryTransientHttpError = false;
let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
params.getActiveSessionEntry()?.systemPromptReport,
);
while (true) {
try {
@@ -222,8 +226,16 @@ export async function runAgentTurnWithFallback(params: {
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
cliSessionId,
bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature:
bootstrapPromptWarningSignaturesSeen[
bootstrapPromptWarningSignaturesSeen.length - 1
],
images: params.opts?.images,
});
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
result.meta?.systemPromptReport,
);
// CLI backends don't emit streaming assistant events, so we need to
// emit one with the final text so server-chat can populate its buffer
@@ -293,140 +305,151 @@ export async function runAgentTurnWithFallback(params: {
runId,
authProfile,
});
return runEmbeddedPiAgent({
...embeddedContext,
trigger: params.isHeartbeat ? "heartbeat" : "user",
groupId: resolveGroupSessionKey(params.sessionCtx)?.id,
groupChannel:
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
...senderContext,
...runBaseParams,
prompt: params.commandBody,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
toolResultFormat: (() => {
const channel = resolveMessageChannel(
params.sessionCtx.Surface,
params.sessionCtx.Provider,
);
if (!channel) {
return "markdown";
}
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
})(),
suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings,
bootstrapContextMode: params.opts?.bootstrapContextMode,
bootstrapContextRunKind: params.opts?.isHeartbeat ? "heartbeat" : "default",
images: params.opts?.images,
abortSignal: params.opts?.abortSignal,
blockReplyBreak: params.resolvedBlockStreamingBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: async (payload) => {
const textForTyping = await handlePartialForTyping(payload);
if (!params.opts?.onPartialReply || textForTyping === undefined) {
return;
}
await params.opts.onPartialReply({
text: textForTyping,
mediaUrls: payload.mediaUrls,
});
},
onAssistantMessageStart: async () => {
await params.typingSignals.signalMessageStart();
await params.opts?.onAssistantMessageStart?.();
},
onReasoningStream:
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
? async (payload) => {
await params.typingSignals.signalReasoningDelta();
await params.opts?.onReasoningStream?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
}
: undefined,
onReasoningEnd: params.opts?.onReasoningEnd,
onAgentEvent: async (evt) => {
// Signal run start only after the embedded agent emits real activity.
const hasLifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data.phase === "string";
if (evt.stream !== "lifecycle" || hasLifecyclePhase) {
notifyAgentRunStart();
}
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if (evt.stream === "tool") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const name = typeof evt.data.name === "string" ? evt.data.name : undefined;
if (phase === "start" || phase === "update") {
await params.typingSignals.signalToolStart();
await params.opts?.onToolStart?.({ name, phase });
return (async () => {
const result = await runEmbeddedPiAgent({
...embeddedContext,
trigger: params.isHeartbeat ? "heartbeat" : "user",
groupId: resolveGroupSessionKey(params.sessionCtx)?.id,
groupChannel:
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
...senderContext,
...runBaseParams,
prompt: params.commandBody,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
toolResultFormat: (() => {
const channel = resolveMessageChannel(
params.sessionCtx.Surface,
params.sessionCtx.Provider,
);
if (!channel) {
return "markdown";
}
}
// Track auto-compaction completion
if (evt.stream === "compaction") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "end") {
autoCompactionCompleted = true;
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
})(),
suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings,
bootstrapContextMode: params.opts?.bootstrapContextMode,
bootstrapContextRunKind: params.opts?.isHeartbeat ? "heartbeat" : "default",
images: params.opts?.images,
abortSignal: params.opts?.abortSignal,
blockReplyBreak: params.resolvedBlockStreamingBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: async (payload) => {
const textForTyping = await handlePartialForTyping(payload);
if (!params.opts?.onPartialReply || textForTyping === undefined) {
return;
}
}
},
// Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
// even when regular block streaming is disabled. The handler sends directly
// via opts.onBlockReply when the pipeline isn't available.
onBlockReply: params.opts?.onBlockReply
? createBlockReplyDeliveryHandler({
onBlockReply: params.opts.onBlockReply,
currentMessageId:
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid,
normalizeStreamingText,
applyReplyToMode: params.applyReplyToMode,
typingSignals: params.typingSignals,
blockStreamingEnabled: params.blockStreamingEnabled,
blockReplyPipeline,
directlySentBlockKeys,
})
: undefined,
onBlockReplyFlush:
params.blockStreamingEnabled && blockReplyPipeline
? async () => {
await blockReplyPipeline.flush({ force: true });
}
: undefined,
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
onToolResult: onToolResult
? (() => {
// Serialize tool result delivery to preserve message ordering.
// Without this, concurrent tool callbacks race through typing signals
// and message sends, causing out-of-order delivery to the user.
// See: https://github.com/openclaw/openclaw/issues/11044
let toolResultChain: Promise<void> = Promise.resolve();
return (payload: ReplyPayload) => {
toolResultChain = toolResultChain
.then(async () => {
const { text, skip } = normalizeStreamingText(payload);
if (skip) {
return;
}
await params.typingSignals.signalTextDelta(text);
await onToolResult({
text,
mediaUrls: payload.mediaUrls,
});
})
.catch((err) => {
// Keep chain healthy after an error so later tool results still deliver.
logVerbose(`tool result delivery failed: ${String(err)}`);
await params.opts.onPartialReply({
text: textForTyping,
mediaUrls: payload.mediaUrls,
});
},
onAssistantMessageStart: async () => {
await params.typingSignals.signalMessageStart();
await params.opts?.onAssistantMessageStart?.();
},
onReasoningStream:
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
? async (payload) => {
await params.typingSignals.signalReasoningDelta();
await params.opts?.onReasoningStream?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
const task = toolResultChain.finally(() => {
params.pendingToolTasks.delete(task);
});
params.pendingToolTasks.add(task);
};
})()
: undefined,
});
}
: undefined,
onReasoningEnd: params.opts?.onReasoningEnd,
onAgentEvent: async (evt) => {
// Signal run start only after the embedded agent emits real activity.
const hasLifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data.phase === "string";
if (evt.stream !== "lifecycle" || hasLifecyclePhase) {
notifyAgentRunStart();
}
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if (evt.stream === "tool") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const name = typeof evt.data.name === "string" ? evt.data.name : undefined;
if (phase === "start" || phase === "update") {
await params.typingSignals.signalToolStart();
await params.opts?.onToolStart?.({ name, phase });
}
}
// Track auto-compaction completion
if (evt.stream === "compaction") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "end") {
autoCompactionCompleted = true;
}
}
},
// Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
// even when regular block streaming is disabled. The handler sends directly
// via opts.onBlockReply when the pipeline isn't available.
onBlockReply: params.opts?.onBlockReply
? createBlockReplyDeliveryHandler({
onBlockReply: params.opts.onBlockReply,
currentMessageId:
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid,
normalizeStreamingText,
applyReplyToMode: params.applyReplyToMode,
typingSignals: params.typingSignals,
blockStreamingEnabled: params.blockStreamingEnabled,
blockReplyPipeline,
directlySentBlockKeys,
})
: undefined,
onBlockReplyFlush:
params.blockStreamingEnabled && blockReplyPipeline
? async () => {
await blockReplyPipeline.flush({ force: true });
}
: undefined,
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature:
bootstrapPromptWarningSignaturesSeen[
bootstrapPromptWarningSignaturesSeen.length - 1
],
onToolResult: onToolResult
? (() => {
// Serialize tool result delivery to preserve message ordering.
// Without this, concurrent tool callbacks race through typing signals
// and message sends, causing out-of-order delivery to the user.
// See: https://github.com/openclaw/openclaw/issues/11044
let toolResultChain: Promise<void> = Promise.resolve();
return (payload: ReplyPayload) => {
toolResultChain = toolResultChain
.then(async () => {
const { text, skip } = normalizeStreamingText(payload);
if (skip) {
return;
}
await params.typingSignals.signalTextDelta(text);
await onToolResult({
text,
mediaUrls: payload.mediaUrls,
});
})
.catch((err) => {
// Keep chain healthy after an error so later tool results still deliver.
logVerbose(`tool result delivery failed: ${String(err)}`);
});
const task = toolResultChain.finally(() => {
params.pendingToolTasks.delete(task);
});
params.pendingToolTasks.add(task);
};
})()
: undefined,
});
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
result.meta?.systemPromptReport,
);
return result;
})();
},
});
runResult = fallbackResult.result;

View File

@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
import { buildContextReply } from "./commands-context-report.js";
import type { HandleCommandsParams } from "./commands-types.js";
function makeParams(commandBodyNormalized: string, truncated: boolean): HandleCommandsParams {
function makeParams(
commandBodyNormalized: string,
truncated: boolean,
options?: { omitBootstrapLimits?: boolean },
): HandleCommandsParams {
return {
command: {
commandBodyNormalized,
@@ -25,8 +29,8 @@ function makeParams(commandBodyNormalized: string, truncated: boolean): HandleCo
source: "run",
generatedAt: Date.now(),
workspaceDir: "/tmp/workspace",
bootstrapMaxChars: 20_000,
bootstrapTotalMaxChars: 150_000,
bootstrapMaxChars: options?.omitBootstrapLimits ? undefined : 20_000,
bootstrapTotalMaxChars: options?.omitBootstrapLimits ? undefined : 150_000,
sandbox: { mode: "off", sandboxed: false },
systemPrompt: {
chars: 1_000,
@@ -67,13 +71,22 @@ describe("buildContextReply", () => {
const result = await buildContextReply(makeParams("/context list", true));
expect(result.text).toContain("Bootstrap max/total: 150,000 chars");
expect(result.text).toContain("⚠ Bootstrap context is over configured limits");
expect(result.text).toContain(
"Causes: 1 file(s) exceeded max/file; raw total exceeded max/total.",
);
expect(result.text).toContain("Causes: 1 file(s) exceeded max/file.");
});
it("does not show bootstrap truncation warning when there is no truncation", async () => {
const result = await buildContextReply(makeParams("/context list", false));
expect(result.text).not.toContain("Bootstrap context is over configured limits");
});
it("falls back to config defaults when legacy reports are missing bootstrap limits", async () => {
const result = await buildContextReply(
makeParams("/context list", false, {
omitBootstrapLimits: true,
}),
);
expect(result.text).toContain("Bootstrap max/file: 20,000 chars");
expect(result.text).toContain("Bootstrap max/total: 150,000 chars");
expect(result.text).not.toContain("Bootstrap max/file: ? chars");
});
});

View File

@@ -1,3 +1,4 @@
import { analyzeBootstrapBudget } from "../../agents/bootstrap-budget.js";
import {
resolveBootstrapMaxChars,
resolveBootstrapTotalMaxChars,
@@ -141,37 +142,49 @@ export async function buildContextReply(params: HandleCommandsParams): Promise<R
: "Tools: (none)";
const systemPromptLine = `System prompt (${report.source}): ${formatCharsAndTokens(report.systemPrompt.chars)} (Project Context ${formatCharsAndTokens(report.systemPrompt.projectContextChars)})`;
const workspaceLabel = report.workspaceDir ?? params.workspaceDir;
const bootstrapMaxLabel =
typeof report.bootstrapMaxChars === "number"
? `${formatInt(report.bootstrapMaxChars)} chars`
: "? chars";
const bootstrapTotalLabel =
typeof report.bootstrapTotalMaxChars === "number"
? `${formatInt(report.bootstrapTotalMaxChars)} chars`
: "? chars";
const bootstrapMaxChars = report.bootstrapMaxChars;
const bootstrapTotalMaxChars = report.bootstrapTotalMaxChars;
const nonMissingBootstrapFiles = report.injectedWorkspaceFiles.filter((f) => !f.missing);
const truncatedBootstrapFiles = nonMissingBootstrapFiles.filter((f) => f.truncated);
const rawBootstrapChars = nonMissingBootstrapFiles.reduce((sum, file) => sum + file.rawChars, 0);
const injectedBootstrapChars = nonMissingBootstrapFiles.reduce(
(sum, file) => sum + file.injectedChars,
0,
const bootstrapMaxChars =
typeof report.bootstrapMaxChars === "number" &&
Number.isFinite(report.bootstrapMaxChars) &&
report.bootstrapMaxChars > 0
? report.bootstrapMaxChars
: resolveBootstrapMaxChars(params.cfg);
const bootstrapTotalMaxChars =
typeof report.bootstrapTotalMaxChars === "number" &&
Number.isFinite(report.bootstrapTotalMaxChars) &&
report.bootstrapTotalMaxChars > 0
? report.bootstrapTotalMaxChars
: resolveBootstrapTotalMaxChars(params.cfg);
const bootstrapMaxLabel = `${formatInt(bootstrapMaxChars)} chars`;
const bootstrapTotalLabel = `${formatInt(bootstrapTotalMaxChars)} chars`;
const bootstrapAnalysis = analyzeBootstrapBudget({
files: report.injectedWorkspaceFiles,
bootstrapMaxChars,
bootstrapTotalMaxChars,
});
const truncatedBootstrapFiles = bootstrapAnalysis.truncatedFiles;
const truncationCauseCounts = truncatedBootstrapFiles.reduce(
(acc, file) => {
for (const cause of file.causes) {
if (cause === "per-file-limit") {
acc.perFile += 1;
} else if (cause === "total-limit") {
acc.total += 1;
}
}
return acc;
},
{ perFile: 0, total: 0 },
);
const perFileOverLimitCount =
typeof bootstrapMaxChars === "number"
? nonMissingBootstrapFiles.filter((f) => f.rawChars > bootstrapMaxChars).length
: 0;
const totalOverLimit =
typeof bootstrapTotalMaxChars === "number" && rawBootstrapChars > bootstrapTotalMaxChars;
const truncationCauseParts = [
perFileOverLimitCount > 0 ? `${perFileOverLimitCount} file(s) exceeded max/file` : null,
totalOverLimit ? "raw total exceeded max/total" : null,
truncationCauseCounts.perFile > 0
? `${truncationCauseCounts.perFile} file(s) exceeded max/file`
: null,
truncationCauseCounts.total > 0 ? `${truncationCauseCounts.total} file(s) hit max/total` : null,
].filter(Boolean);
const bootstrapWarningLines =
truncatedBootstrapFiles.length > 0
? [
`⚠ Bootstrap context is over configured limits: ${truncatedBootstrapFiles.length} file(s) truncated (${formatInt(rawBootstrapChars)} raw chars -> ${formatInt(injectedBootstrapChars)} injected chars).`,
`⚠ Bootstrap context is over configured limits: ${truncatedBootstrapFiles.length} file(s) truncated (${formatInt(bootstrapAnalysis.totals.rawChars)} raw chars -> ${formatInt(bootstrapAnalysis.totals.injectedChars)} injected chars).`,
...(truncationCauseParts.length ? [`Causes: ${truncationCauseParts.join("; ")}.`] : []),
"Tip: increase `agents.defaults.bootstrapMaxChars` and/or `agents.defaults.bootstrapTotalMaxChars` if this truncation is not intentional.",
]