mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:51:37 +00:00
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:
committed by
GitHub
parent
3ad3a90db3
commit
e4b4486a96
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user