mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agents): remove stale runner state plumbing (#118495)
* refactor(agents): remove stale runner state plumbing * refactor(agents): keep bootstrap warning owner reachable * refactor(agents): inline bootstrap warning predicates * refactor(agents): extract bootstrap warning contracts * refactor(agents): keep bootstrap detail type private * refactor(auto-reply): drop vacuous abort returns
This commit is contained in:
committed by
GitHub
parent
f62e42b69f
commit
e797e69969
@@ -0,0 +1,22 @@
|
||||
import { copyPluginToolMeta } from "../plugins/tools.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "./channel-tool-metadata.js";
|
||||
import { copyCodeModeControlToolIdentity } from "./code-mode-control-tools.js";
|
||||
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";
|
||||
|
||||
/**
|
||||
* Preserve identity-backed tool metadata that object spread cannot carry.
|
||||
* Losing it detaches policy, hooks, presentation, and control-flow ownership.
|
||||
*/
|
||||
export function copyAgentToolMetadata<T extends AnyAgentTool>(source: AnyAgentTool, target: T): T {
|
||||
if (source === target) {
|
||||
return target;
|
||||
}
|
||||
copyPluginToolMeta(source, target);
|
||||
copyChannelAgentToolMeta(source as never, target as never);
|
||||
copyBeforeToolCallHookMarker(source, target);
|
||||
copyToolTerminalPresentation(source, target);
|
||||
copyCodeModeControlToolIdentity(source, target);
|
||||
return target;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { createAbortError } from "../infra/abort-signal.js";
|
||||
/**
|
||||
* Abort-signal wrapping for agent tools.
|
||||
* Combines per-call cancellation with run-level aborts while preserving plugin,
|
||||
* channel, and before_tool_call metadata on wrapped tools.
|
||||
* Combines per-call cancellation with run-level aborts while preserving
|
||||
* identity-backed metadata on wrapped tools.
|
||||
*/
|
||||
import { copyPluginToolMeta } from "../plugins/tools.js";
|
||||
import { copyAgentToolMetadata } from "./agent-tool-metadata.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "./channel-tools.js";
|
||||
|
||||
function throwAbortError(): never {
|
||||
throw createAbortError("Aborted");
|
||||
@@ -72,8 +70,5 @@ export function wrapToolWithAbortSignal(
|
||||
);
|
||||
},
|
||||
};
|
||||
copyPluginToolMeta(tool, wrappedTool);
|
||||
copyChannelAgentToolMeta(tool as never, wrappedTool as never);
|
||||
copyBeforeToolCallHookMarker(tool, wrappedTool);
|
||||
return wrappedTool;
|
||||
return copyAgentToolMetadata(tool, wrappedTool);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ export {
|
||||
peekAdjustedParamsForToolCall,
|
||||
} from "./agent-tools.before-tool-call.state.js";
|
||||
export {
|
||||
copyBeforeToolCallHookMarker,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
setBeforeToolCallDiagnosticsEnabled,
|
||||
} from "./before-tool-call-metadata.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { copyPluginToolMeta } from "../plugins/tools.js";
|
||||
import { copyAgentToolMetadata } from "./agent-tool-metadata.js";
|
||||
/**
|
||||
* Adjusts exec/process tool descriptions for long-running follow-up behavior.
|
||||
* Cron-aware runs can point models at scheduled follow-ups; cronless runs keep
|
||||
@@ -6,18 +6,11 @@ import { copyPluginToolMeta } from "../plugins/tools.js";
|
||||
*/
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { describeExecTool, describeProcessTool } from "./bash-tools.descriptions.js";
|
||||
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "./channel-tools.js";
|
||||
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";
|
||||
import { isAutomationsToolName } from "./tools/automations-tool-name.js";
|
||||
|
||||
function replaceDescription(tool: AnyAgentTool, description: string): AnyAgentTool {
|
||||
const updated = { ...tool, description };
|
||||
copyPluginToolMeta(tool, updated);
|
||||
copyChannelAgentToolMeta(tool as never, updated as never);
|
||||
copyBeforeToolCallHookMarker(tool, updated);
|
||||
copyToolTerminalPresentation(tool, updated);
|
||||
return updated;
|
||||
return copyAgentToolMetadata(tool, updated);
|
||||
}
|
||||
|
||||
/** Return tools with exec/process descriptions adjusted for cron availability. */
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
} from "./agent-tools.ring-zero-context.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { stubTool } from "./test-helpers/fast-tool-stubs.js";
|
||||
import {
|
||||
getToolTerminalPresentation,
|
||||
setToolTerminalPresentation,
|
||||
} from "./tool-terminal-presentation.js";
|
||||
|
||||
type ExecuteMock = ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -215,6 +219,18 @@ describe("wrapToolWithAbortSignal", () => {
|
||||
});
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves terminal presentation metadata on abort-wrapped tools", () => {
|
||||
const formatter = () => ({ text: "done" });
|
||||
const tool = setToolTerminalPresentation(
|
||||
asAgentTool({ name: "presented", execute: vi.fn() }),
|
||||
formatter,
|
||||
);
|
||||
|
||||
const wrapped = wrapToolWithAbortSignal(tool, new AbortController().signal);
|
||||
|
||||
expect(getToolTerminalPresentation(wrapped)).toBe(formatter);
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("./channel-tools.js", () => {
|
||||
|
||||
@@ -4,14 +4,11 @@ import {
|
||||
} from "@openclaw/ai/internal/openai";
|
||||
/**
|
||||
* Tool schema normalization wrappers.
|
||||
* Applies provider-compatible parameter schema cleanup while preserving plugin
|
||||
* and channel metadata on normalized tools.
|
||||
* Applies provider-compatible parameter schema cleanup while preserving
|
||||
* identity-backed metadata on normalized tools.
|
||||
*/
|
||||
import { copyPluginToolMeta } from "../plugins/tools.js";
|
||||
import { copyAgentToolMetadata } from "./agent-tool-metadata.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "./channel-tools.js";
|
||||
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";
|
||||
|
||||
function isObjectSchemaWithNoRequiredParams(schema: unknown): boolean {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
@@ -69,13 +66,6 @@ export function normalizeToolParameters(
|
||||
tool: AnyAgentTool,
|
||||
options?: ToolParameterSchemaOptions,
|
||||
): AnyAgentTool {
|
||||
function preserveToolMeta(target: AnyAgentTool): AnyAgentTool {
|
||||
copyPluginToolMeta(tool, target);
|
||||
copyChannelAgentToolMeta(tool as never, target as never);
|
||||
copyBeforeToolCallHookMarker(tool, target);
|
||||
copyToolTerminalPresentation(tool, target);
|
||||
return target;
|
||||
}
|
||||
const schema =
|
||||
tool.parameters && typeof tool.parameters === "object"
|
||||
? (tool.parameters as Record<string, unknown>)
|
||||
@@ -84,7 +74,7 @@ export function normalizeToolParameters(
|
||||
return tool;
|
||||
}
|
||||
const parameters = normalizeToolParameterSchema(schema, options);
|
||||
return preserveToolMeta({
|
||||
return copyAgentToolMetadata(tool, {
|
||||
...tool,
|
||||
...addEmptyObjectArgumentPreparation(tool, parameters),
|
||||
parameters,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type {
|
||||
BootstrapBudgetAnalysis,
|
||||
BootstrapPromptWarning,
|
||||
BootstrapPromptWarningMode,
|
||||
BootstrapTruncationCause,
|
||||
} from "./bootstrap-budget.types.js";
|
||||
import { USER_BOOTSTRAP_MAX_CHARS } from "./embedded-agent-helpers/bootstrap.js";
|
||||
|
||||
const DEFAULT_BOOTSTRAP_PROMPT_WARNING_MAX_FILES = 3;
|
||||
const DEFAULT_BOOTSTRAP_PROMPT_WARNING_SIGNATURE_HISTORY_MAX = 32;
|
||||
|
||||
function formatWarningCause(cause: BootstrapTruncationCause): string {
|
||||
return cause === "per-file-limit" ? "max/file" : "max/total";
|
||||
}
|
||||
|
||||
export function normalizeBootstrapWarningSignatures(signatures?: string[]): string[] {
|
||||
if (!Array.isArray(signatures) || signatures.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const signature of signatures) {
|
||||
const value = normalizeOptionalString(signature) ?? "";
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function appendSeenSignature(signatures: string[], signature: string): string[] {
|
||||
if (!signature.trim() || signatures.includes(signature)) {
|
||||
return signatures;
|
||||
}
|
||||
const next = [...signatures, signature];
|
||||
return next.length <= DEFAULT_BOOTSTRAP_PROMPT_WARNING_SIGNATURE_HISTORY_MAX
|
||||
? next
|
||||
: next.slice(-DEFAULT_BOOTSTRAP_PROMPT_WARNING_SIGNATURE_HISTORY_MAX);
|
||||
}
|
||||
|
||||
function buildBootstrapTruncationSignature(analysis: BootstrapBudgetAnalysis): string | undefined {
|
||||
if (!analysis.hasTruncation) {
|
||||
return undefined;
|
||||
}
|
||||
const files = analysis.truncatedFiles
|
||||
.map((file) => ({
|
||||
path: file.path || file.name,
|
||||
rawChars: file.rawChars,
|
||||
injectedChars: file.injectedChars,
|
||||
causes: [...file.causes].toSorted(),
|
||||
}))
|
||||
.toSorted((a, b) => {
|
||||
const pathCmp = a.path.localeCompare(b.path);
|
||||
if (pathCmp !== 0) {
|
||||
return pathCmp;
|
||||
}
|
||||
if (a.rawChars !== b.rawChars) {
|
||||
return a.rawChars - b.rawChars;
|
||||
}
|
||||
if (a.injectedChars !== b.injectedChars) {
|
||||
return a.injectedChars - b.injectedChars;
|
||||
}
|
||||
return a.causes.join("+").localeCompare(b.causes.join("+"));
|
||||
});
|
||||
return JSON.stringify({
|
||||
bootstrapMaxChars: analysis.totals.bootstrapMaxChars,
|
||||
bootstrapTotalMaxChars: analysis.totals.bootstrapTotalMaxChars,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
function formatBootstrapTruncationWarningLines(params: {
|
||||
analysis: BootstrapBudgetAnalysis;
|
||||
maxFiles?: number;
|
||||
}): string[] {
|
||||
if (!params.analysis.hasTruncation) {
|
||||
return [];
|
||||
}
|
||||
const maxFiles =
|
||||
typeof params.maxFiles === "number" && Number.isFinite(params.maxFiles) && params.maxFiles > 0
|
||||
? Math.floor(params.maxFiles)
|
||||
: DEFAULT_BOOTSTRAP_PROMPT_WARNING_MAX_FILES;
|
||||
const lines: string[] = [];
|
||||
const duplicateNameCounts = params.analysis.truncatedFiles.reduce((acc, file) => {
|
||||
acc.set(file.name, (acc.get(file.name) ?? 0) + 1);
|
||||
return acc;
|
||||
}, new Map<string, number>());
|
||||
const topFiles = params.analysis.truncatedFiles.slice(0, maxFiles);
|
||||
for (const file of topFiles) {
|
||||
const pct =
|
||||
file.rawChars > 0
|
||||
? Math.round(((file.rawChars - file.injectedChars) / file.rawChars) * 100)
|
||||
: 0;
|
||||
const causeText =
|
||||
file.causes.length > 0
|
||||
? file.causes.map((cause) => formatWarningCause(cause)).join(", ")
|
||||
: "";
|
||||
const nameLabel =
|
||||
(duplicateNameCounts.get(file.name) ?? 0) > 1 && file.path.trim().length > 0
|
||||
? `${file.name} (${file.path})`
|
||||
: file.name;
|
||||
lines.push(
|
||||
`${nameLabel}: ${file.rawChars} raw -> ${file.injectedChars} injected (~${Math.max(0, pct)}% removed${causeText ? `; ${causeText}` : ""}).`,
|
||||
);
|
||||
}
|
||||
if (params.analysis.truncatedFiles.length > topFiles.length) {
|
||||
lines.push(
|
||||
`+${params.analysis.truncatedFiles.length - topFiles.length} more truncated file(s).`,
|
||||
);
|
||||
}
|
||||
if (params.analysis.truncatedFiles.some((file) => file.name?.toLowerCase() === "agents.md")) {
|
||||
lines.push("AGENTS.md was truncated; read the full AGENTS.md before relying on scoped policy.");
|
||||
}
|
||||
const fixedUserCapApplied = params.analysis.truncatedFiles.some(
|
||||
(file) =>
|
||||
file.name?.toLowerCase() === "user.md" &&
|
||||
file.effectiveFileLimit === USER_BOOTSTRAP_MAX_CHARS &&
|
||||
file.causes.includes("per-file-limit"),
|
||||
);
|
||||
if (fixedUserCapApplied) {
|
||||
lines.push(
|
||||
`USER.md has a fixed ${USER_BOOTSTRAP_MAX_CHARS}-character bootstrap cap; keep it compact.`,
|
||||
);
|
||||
}
|
||||
const configurableLimitApplied = params.analysis.truncatedFiles.some(
|
||||
(file) =>
|
||||
file.name?.toLowerCase() !== "user.md" ||
|
||||
file.effectiveFileLimit < USER_BOOTSTRAP_MAX_CHARS ||
|
||||
file.causes.includes("total-limit"),
|
||||
);
|
||||
if (configurableLimitApplied) {
|
||||
lines.push(
|
||||
"If unintentional, raise agents.defaults.bootstrapMaxChars and/or agents.defaults.bootstrapTotalMaxChars.",
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Decides whether to show a prompt warning and returns the updated dedupe state. */
|
||||
export function buildBootstrapPromptWarning(params: {
|
||||
analysis: BootstrapBudgetAnalysis;
|
||||
mode: BootstrapPromptWarningMode;
|
||||
previousSignature?: string;
|
||||
seenSignatures?: string[];
|
||||
maxFiles?: number;
|
||||
}): BootstrapPromptWarning {
|
||||
const signature = buildBootstrapTruncationSignature(params.analysis);
|
||||
let seenSignatures = normalizeBootstrapWarningSignatures(params.seenSignatures);
|
||||
if (params.previousSignature && !seenSignatures.includes(params.previousSignature)) {
|
||||
seenSignatures = appendSeenSignature(seenSignatures, params.previousSignature);
|
||||
}
|
||||
const hasSeenSignature = Boolean(signature && seenSignatures.includes(signature));
|
||||
const warningShown =
|
||||
params.mode !== "off" && Boolean(signature) && (params.mode === "always" || !hasSeenSignature);
|
||||
const warningSignaturesSeen =
|
||||
signature && params.mode !== "off"
|
||||
? appendSeenSignature(seenSignatures, signature)
|
||||
: seenSignatures;
|
||||
return {
|
||||
signature,
|
||||
warningShown,
|
||||
lines: warningShown
|
||||
? formatBootstrapTruncationWarningLines({
|
||||
analysis: params.analysis,
|
||||
maxFiles: params.maxFiles,
|
||||
})
|
||||
: [],
|
||||
warningSignaturesSeen,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Tests bootstrap context truncation accounting and user-facing warning metadata. */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBootstrapPromptWarning } from "./bootstrap-budget-warning.js";
|
||||
import {
|
||||
appendBootstrapPromptWarning,
|
||||
analyzeBootstrapBudget,
|
||||
buildBootstrapBudgetState,
|
||||
buildBootstrapInjectionStats,
|
||||
buildBootstrapPromptWarning,
|
||||
buildBootstrapPromptWarningNotice,
|
||||
buildBootstrapTruncationReportMeta,
|
||||
resolveBootstrapWarningSignaturesSeen,
|
||||
@@ -12,6 +13,42 @@ import {
|
||||
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
||||
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
||||
|
||||
describe("buildBootstrapBudgetState", () => {
|
||||
it("composes configured limits, ordered injection stats, and warning state", () => {
|
||||
const bootstrapFiles: WorkspaceBootstrapFile[] = [
|
||||
{
|
||||
name: "AGENTS.md",
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "a".repeat(8),
|
||||
missing: false,
|
||||
},
|
||||
{
|
||||
name: "SOUL.md",
|
||||
path: "/tmp/SOUL.md",
|
||||
content: "b".repeat(8),
|
||||
missing: false,
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildBootstrapBudgetState({
|
||||
config: {
|
||||
agents: { defaults: { bootstrapMaxChars: 10, bootstrapTotalMaxChars: 12 } },
|
||||
},
|
||||
bootstrapFiles,
|
||||
injectedFiles: [
|
||||
{ path: "/tmp/AGENTS.md", content: "a".repeat(8) },
|
||||
{ path: "/tmp/SOUL.md", content: "b".repeat(4) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(state.bootstrapMaxChars).toBe(10);
|
||||
expect(state.bootstrapTotalMaxChars).toBe(12);
|
||||
expect(state.bootstrapPromptWarningMode).toBe("always");
|
||||
expect(state.bootstrapAnalysis.truncatedFiles[0]?.causes).toEqual(["total-limit"]);
|
||||
expect(state.bootstrapPromptWarning.warningShown).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBootstrapInjectionStats", () => {
|
||||
it("maps raw and injected sizes and marks truncation", () => {
|
||||
const bootstrapFiles: WorkspaceBootstrapFile[] = [
|
||||
|
||||
+47
-215
@@ -4,54 +4,27 @@
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
buildBootstrapPromptWarning,
|
||||
normalizeBootstrapWarningSignatures,
|
||||
} from "./bootstrap-budget-warning.js";
|
||||
import type {
|
||||
BootstrapBudgetAnalysis,
|
||||
BootstrapInjectionStat,
|
||||
BootstrapPromptWarning,
|
||||
BootstrapPromptWarningMode,
|
||||
BootstrapTruncationCause,
|
||||
} from "./bootstrap-budget.types.js";
|
||||
import type { EmbeddedContextFile } from "./embedded-agent-helpers.js";
|
||||
import { USER_BOOTSTRAP_MAX_CHARS } from "./embedded-agent-helpers/bootstrap.js";
|
||||
import {
|
||||
resolveBootstrapMaxChars,
|
||||
resolveBootstrapTotalMaxChars,
|
||||
USER_BOOTSTRAP_MAX_CHARS,
|
||||
} from "./embedded-agent-helpers/bootstrap.js";
|
||||
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
||||
|
||||
const DEFAULT_BOOTSTRAP_NEAR_LIMIT_RATIO = 0.85;
|
||||
const DEFAULT_BOOTSTRAP_PROMPT_WARNING_MAX_FILES = 3;
|
||||
const DEFAULT_BOOTSTRAP_PROMPT_WARNING_SIGNATURE_HISTORY_MAX = 32;
|
||||
|
||||
type BootstrapTruncationCause = "per-file-limit" | "total-limit";
|
||||
type BootstrapPromptWarningMode = "off" | "once" | "always";
|
||||
|
||||
type BootstrapInjectionStat = {
|
||||
name: string;
|
||||
path: string;
|
||||
missing: boolean;
|
||||
rawChars: number;
|
||||
injectedChars: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
type BootstrapAnalyzedFile = BootstrapInjectionStat & {
|
||||
effectiveFileLimit: number;
|
||||
nearLimit: boolean;
|
||||
causes: BootstrapTruncationCause[];
|
||||
};
|
||||
|
||||
type BootstrapBudgetAnalysis = {
|
||||
files: BootstrapAnalyzedFile[];
|
||||
truncatedFiles: BootstrapAnalyzedFile[];
|
||||
nearLimitFiles: BootstrapAnalyzedFile[];
|
||||
totalNearLimit: boolean;
|
||||
hasTruncation: boolean;
|
||||
totals: {
|
||||
rawChars: number;
|
||||
injectedChars: number;
|
||||
truncatedChars: number;
|
||||
bootstrapMaxChars: number;
|
||||
bootstrapTotalMaxChars: number;
|
||||
nearLimitRatio: number;
|
||||
};
|
||||
};
|
||||
|
||||
type BootstrapPromptWarning = {
|
||||
signature?: string;
|
||||
warningShown: boolean;
|
||||
lines: string[];
|
||||
warningSignaturesSeen: string[];
|
||||
};
|
||||
|
||||
type BootstrapTruncationReportMeta = {
|
||||
warningMode: BootstrapPromptWarningMode;
|
||||
@@ -70,55 +43,12 @@ function normalizePositiveLimit(value: number): number {
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function formatWarningCause(cause: BootstrapTruncationCause): string {
|
||||
return cause === "per-file-limit" ? "max/file" : "max/total";
|
||||
}
|
||||
|
||||
function isAgentsBootstrapName(name: string | undefined): boolean {
|
||||
return name?.toLowerCase() === "agents.md";
|
||||
}
|
||||
|
||||
function isUserBootstrapName(name: string | undefined): boolean {
|
||||
return name?.toLowerCase() === "user.md";
|
||||
}
|
||||
|
||||
function effectiveBootstrapFileLimit(name: string, bootstrapMaxChars: number): number {
|
||||
return name.toLowerCase() === "user.md"
|
||||
? Math.min(bootstrapMaxChars, USER_BOOTSTRAP_MAX_CHARS)
|
||||
: bootstrapMaxChars;
|
||||
}
|
||||
|
||||
function normalizeSeenSignatures(signatures?: string[]): string[] {
|
||||
if (!Array.isArray(signatures) || signatures.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const signature of signatures) {
|
||||
const value = normalizeOptionalString(signature) ?? "";
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function appendSeenSignature(signatures: string[], signature: string): string[] {
|
||||
if (!signature.trim()) {
|
||||
return signatures;
|
||||
}
|
||||
if (signatures.includes(signature)) {
|
||||
return signatures;
|
||||
}
|
||||
const next = [...signatures, signature];
|
||||
if (next.length <= DEFAULT_BOOTSTRAP_PROMPT_WARNING_SIGNATURE_HISTORY_MAX) {
|
||||
return next;
|
||||
}
|
||||
return next.slice(-DEFAULT_BOOTSTRAP_PROMPT_WARNING_SIGNATURE_HISTORY_MAX);
|
||||
}
|
||||
|
||||
/** Restores prompt-warning dedupe state from a previous bootstrap report. */
|
||||
export function resolveBootstrapWarningSignaturesSeen(report?: {
|
||||
bootstrapTruncation?: {
|
||||
@@ -128,7 +58,7 @@ export function resolveBootstrapWarningSignaturesSeen(report?: {
|
||||
};
|
||||
}): string[] {
|
||||
const truncation = report?.bootstrapTruncation;
|
||||
const seenFromReport = normalizeSeenSignatures(truncation?.warningSignaturesSeen);
|
||||
const seenFromReport = normalizeBootstrapWarningSignatures(truncation?.warningSignaturesSeen);
|
||||
if (seenFromReport.length > 0) {
|
||||
return seenFromReport;
|
||||
}
|
||||
@@ -253,136 +183,38 @@ export function analyzeBootstrapBudget(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds a stable signature for once-per-truncation warning suppression. */
|
||||
function buildBootstrapTruncationSignature(analysis: BootstrapBudgetAnalysis): string | undefined {
|
||||
if (!analysis.hasTruncation) {
|
||||
return undefined;
|
||||
}
|
||||
const files = analysis.truncatedFiles
|
||||
.map((file) => ({
|
||||
path: file.path || file.name,
|
||||
rawChars: file.rawChars,
|
||||
injectedChars: file.injectedChars,
|
||||
causes: [...file.causes].toSorted(),
|
||||
}))
|
||||
.toSorted((a, b) => {
|
||||
const pathCmp = a.path.localeCompare(b.path);
|
||||
if (pathCmp !== 0) {
|
||||
return pathCmp;
|
||||
}
|
||||
if (a.rawChars !== b.rawChars) {
|
||||
return a.rawChars - b.rawChars;
|
||||
}
|
||||
if (a.injectedChars !== b.injectedChars) {
|
||||
return a.injectedChars - b.injectedChars;
|
||||
}
|
||||
return a.causes.join("+").localeCompare(b.causes.join("+"));
|
||||
});
|
||||
return JSON.stringify({
|
||||
bootstrapMaxChars: analysis.totals.bootstrapMaxChars,
|
||||
bootstrapTotalMaxChars: analysis.totals.bootstrapTotalMaxChars,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
/** Formats human-readable warning lines for the most important truncated files. */
|
||||
function formatBootstrapTruncationWarningLines(params: {
|
||||
analysis: BootstrapBudgetAnalysis;
|
||||
maxFiles?: number;
|
||||
}): string[] {
|
||||
if (!params.analysis.hasTruncation) {
|
||||
return [];
|
||||
}
|
||||
const maxFiles =
|
||||
typeof params.maxFiles === "number" && Number.isFinite(params.maxFiles) && params.maxFiles > 0
|
||||
? Math.floor(params.maxFiles)
|
||||
: DEFAULT_BOOTSTRAP_PROMPT_WARNING_MAX_FILES;
|
||||
const lines: string[] = [];
|
||||
const duplicateNameCounts = params.analysis.truncatedFiles.reduce((acc, file) => {
|
||||
acc.set(file.name, (acc.get(file.name) ?? 0) + 1);
|
||||
return acc;
|
||||
}, new Map<string, number>());
|
||||
const topFiles = params.analysis.truncatedFiles.slice(0, maxFiles);
|
||||
for (const file of topFiles) {
|
||||
const pct =
|
||||
file.rawChars > 0
|
||||
? Math.round(((file.rawChars - file.injectedChars) / file.rawChars) * 100)
|
||||
: 0;
|
||||
const causeText =
|
||||
file.causes.length > 0
|
||||
? file.causes.map((cause) => formatWarningCause(cause)).join(", ")
|
||||
: "";
|
||||
const nameLabel =
|
||||
(duplicateNameCounts.get(file.name) ?? 0) > 1 && file.path.trim().length > 0
|
||||
? `${file.name} (${file.path})`
|
||||
: file.name;
|
||||
lines.push(
|
||||
`${nameLabel}: ${file.rawChars} raw -> ${file.injectedChars} injected (~${Math.max(0, pct)}% removed${causeText ? `; ${causeText}` : ""}).`,
|
||||
);
|
||||
}
|
||||
if (params.analysis.truncatedFiles.length > topFiles.length) {
|
||||
lines.push(
|
||||
`+${params.analysis.truncatedFiles.length - topFiles.length} more truncated file(s).`,
|
||||
);
|
||||
}
|
||||
if (params.analysis.truncatedFiles.some((file) => isAgentsBootstrapName(file.name))) {
|
||||
lines.push("AGENTS.md was truncated; read the full AGENTS.md before relying on scoped policy.");
|
||||
}
|
||||
const fixedUserCapApplied = params.analysis.truncatedFiles.some(
|
||||
(file) =>
|
||||
isUserBootstrapName(file.name) &&
|
||||
file.effectiveFileLimit === USER_BOOTSTRAP_MAX_CHARS &&
|
||||
file.causes.includes("per-file-limit"),
|
||||
);
|
||||
if (fixedUserCapApplied) {
|
||||
lines.push(
|
||||
`USER.md has a fixed ${USER_BOOTSTRAP_MAX_CHARS}-character bootstrap cap; keep it compact.`,
|
||||
);
|
||||
}
|
||||
const configurableLimitApplied = params.analysis.truncatedFiles.some(
|
||||
(file) =>
|
||||
!isUserBootstrapName(file.name) ||
|
||||
file.effectiveFileLimit < USER_BOOTSTRAP_MAX_CHARS ||
|
||||
file.causes.includes("total-limit"),
|
||||
);
|
||||
if (configurableLimitApplied) {
|
||||
lines.push(
|
||||
"If unintentional, raise agents.defaults.bootstrapMaxChars and/or agents.defaults.bootstrapTotalMaxChars.",
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Decides whether to show a prompt warning and returns the updated dedupe state. */
|
||||
export function buildBootstrapPromptWarning(params: {
|
||||
analysis: BootstrapBudgetAnalysis;
|
||||
mode: BootstrapPromptWarningMode;
|
||||
/** Builds the canonical bootstrap budget diagnosis after caller-owned routing. */
|
||||
export function buildBootstrapBudgetState(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string | null;
|
||||
bootstrapFiles: WorkspaceBootstrapFile[];
|
||||
injectedFiles: EmbeddedContextFile[];
|
||||
previousSignature?: string;
|
||||
seenSignatures?: string[];
|
||||
maxFiles?: number;
|
||||
}): BootstrapPromptWarning {
|
||||
const signature = buildBootstrapTruncationSignature(params.analysis);
|
||||
let seenSignatures = normalizeSeenSignatures(params.seenSignatures);
|
||||
if (params.previousSignature && !seenSignatures.includes(params.previousSignature)) {
|
||||
seenSignatures = appendSeenSignature(seenSignatures, params.previousSignature);
|
||||
}
|
||||
const hasSeenSignature = Boolean(signature && seenSignatures.includes(signature));
|
||||
const warningShown =
|
||||
params.mode !== "off" && Boolean(signature) && (params.mode === "always" || !hasSeenSignature);
|
||||
const warningSignaturesSeen =
|
||||
signature && params.mode !== "off"
|
||||
? appendSeenSignature(seenSignatures, signature)
|
||||
: seenSignatures;
|
||||
}) {
|
||||
const bootstrapMaxChars = resolveBootstrapMaxChars(params.config, params.agentId);
|
||||
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(params.config, params.agentId);
|
||||
const bootstrapAnalysis = analyzeBootstrapBudget({
|
||||
files: buildBootstrapInjectionStats({
|
||||
bootstrapFiles: params.bootstrapFiles,
|
||||
injectedFiles: params.injectedFiles,
|
||||
}),
|
||||
bootstrapMaxChars,
|
||||
bootstrapTotalMaxChars,
|
||||
});
|
||||
const bootstrapPromptWarningMode: BootstrapPromptWarningMode = "always";
|
||||
const bootstrapPromptWarning = buildBootstrapPromptWarning({
|
||||
analysis: bootstrapAnalysis,
|
||||
mode: bootstrapPromptWarningMode,
|
||||
seenSignatures: params.seenSignatures,
|
||||
previousSignature: params.previousSignature,
|
||||
});
|
||||
return {
|
||||
signature,
|
||||
warningShown,
|
||||
lines: warningShown
|
||||
? formatBootstrapTruncationWarningLines({
|
||||
analysis: params.analysis,
|
||||
maxFiles: params.maxFiles,
|
||||
})
|
||||
: [],
|
||||
warningSignaturesSeen,
|
||||
bootstrapAnalysis,
|
||||
bootstrapMaxChars,
|
||||
bootstrapPromptWarning,
|
||||
bootstrapPromptWarningMode,
|
||||
bootstrapTotalMaxChars,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export type BootstrapTruncationCause = "per-file-limit" | "total-limit";
|
||||
export type BootstrapPromptWarningMode = "off" | "once" | "always";
|
||||
|
||||
export type BootstrapInjectionStat = {
|
||||
name: string;
|
||||
path: string;
|
||||
missing: boolean;
|
||||
rawChars: number;
|
||||
injectedChars: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
type BootstrapAnalyzedFile = BootstrapInjectionStat & {
|
||||
effectiveFileLimit: number;
|
||||
nearLimit: boolean;
|
||||
causes: BootstrapTruncationCause[];
|
||||
};
|
||||
|
||||
export type BootstrapBudgetAnalysis = {
|
||||
files: BootstrapAnalyzedFile[];
|
||||
truncatedFiles: BootstrapAnalyzedFile[];
|
||||
nearLimitFiles: BootstrapAnalyzedFile[];
|
||||
totalNearLimit: boolean;
|
||||
hasTruncation: boolean;
|
||||
totals: {
|
||||
rawChars: number;
|
||||
injectedChars: number;
|
||||
truncatedChars: number;
|
||||
bootstrapMaxChars: number;
|
||||
bootstrapTotalMaxChars: number;
|
||||
nearLimitRatio: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type BootstrapPromptWarning = {
|
||||
signature?: string;
|
||||
warningShown: boolean;
|
||||
lines: string[];
|
||||
warningSignaturesSeen: string[];
|
||||
};
|
||||
@@ -61,10 +61,8 @@ import { resolveAuthProfileOrder } from "../auth-profiles/order.js";
|
||||
import { loadAuthProfileStoreForRuntime } from "../auth-profiles/store.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js";
|
||||
import {
|
||||
buildBootstrapInjectionStats,
|
||||
buildBootstrapPromptWarning,
|
||||
buildBootstrapBudgetState,
|
||||
buildBootstrapTruncationReportMeta,
|
||||
analyzeBootstrapBudget,
|
||||
} from "../bootstrap-budget.js";
|
||||
import {
|
||||
makeBootstrapWarn as makeBootstrapWarnImpl,
|
||||
@@ -85,11 +83,6 @@ import {
|
||||
import { resolveContextWindowInfo } from "../context-window-guard.js";
|
||||
import { resolveContextTokensForModel } from "../context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
import {
|
||||
resolveBootstrapMaxChars,
|
||||
resolveBootstrapPromptTruncationWarningMode,
|
||||
resolveBootstrapTotalMaxChars,
|
||||
} from "../embedded-agent-helpers.js";
|
||||
import {
|
||||
applyEmbeddedAttemptToolsAllow,
|
||||
mergeForcedEmbeddedAttemptToolsAllow,
|
||||
@@ -881,20 +874,17 @@ export async function prepareCliRunContext(
|
||||
const bootstrapFilesForInjectionStats = includeBootstrapInSystemContext
|
||||
? bootstrapFiles
|
||||
: bootstrapFiles.filter((file) => file.name !== DEFAULT_BOOTSTRAP_FILENAME);
|
||||
const bootstrapMaxChars = resolveBootstrapMaxChars(params.config, sessionAgentId);
|
||||
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(params.config, sessionAgentId);
|
||||
const bootstrapAnalysis = analyzeBootstrapBudget({
|
||||
files: buildBootstrapInjectionStats({
|
||||
bootstrapFiles: bootstrapFilesForInjectionStats,
|
||||
injectedFiles: contextFiles,
|
||||
}),
|
||||
const {
|
||||
bootstrapAnalysis,
|
||||
bootstrapMaxChars,
|
||||
bootstrapPromptWarning,
|
||||
bootstrapPromptWarningMode,
|
||||
bootstrapTotalMaxChars,
|
||||
});
|
||||
const bootstrapPromptWarningMode = resolveBootstrapPromptTruncationWarningMode(params.config);
|
||||
const bootstrapPromptWarning = buildBootstrapPromptWarning({
|
||||
analysis: bootstrapAnalysis,
|
||||
mode: bootstrapPromptWarningMode,
|
||||
} = buildBootstrapBudgetState({
|
||||
config: params.config,
|
||||
agentId: sessionAgentId,
|
||||
bootstrapFiles: bootstrapFilesForInjectionStats,
|
||||
injectedFiles: contextFiles,
|
||||
seenSignatures: params.bootstrapPromptWarningSignaturesSeen,
|
||||
previousSignature: params.bootstrapPromptWarningSignature,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
resolveBootstrapMaxChars,
|
||||
resolveBootstrapPromptTruncationWarningMode,
|
||||
resolveBootstrapTotalMaxChars,
|
||||
} from "./embedded-agent-helpers.js";
|
||||
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
||||
@@ -12,7 +11,6 @@ import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const EXPECTED_DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000;
|
||||
const EXPECTED_DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS = 60_000;
|
||||
const EXPECTED_DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE = "always";
|
||||
|
||||
const makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
@@ -369,36 +367,3 @@ describe("bootstrap limit resolvers", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBootstrapPromptTruncationWarningMode", () => {
|
||||
it("defaults to always", () => {
|
||||
expect(resolveBootstrapPromptTruncationWarningMode()).toBe("always");
|
||||
expect(EXPECTED_DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE).toBe("always");
|
||||
});
|
||||
|
||||
it("ignores retired explicit modes", () => {
|
||||
expect(
|
||||
resolveBootstrapPromptTruncationWarningMode({
|
||||
agents: { defaults: { bootstrapPromptTruncationWarning: "off" } },
|
||||
} as OpenClawConfig),
|
||||
).toBe("always");
|
||||
expect(
|
||||
resolveBootstrapPromptTruncationWarningMode({
|
||||
agents: { defaults: { bootstrapPromptTruncationWarning: "once" } },
|
||||
} as OpenClawConfig),
|
||||
).toBe("always");
|
||||
expect(
|
||||
resolveBootstrapPromptTruncationWarningMode({
|
||||
agents: { defaults: { bootstrapPromptTruncationWarning: "always" } },
|
||||
} as OpenClawConfig),
|
||||
).toBe("always");
|
||||
});
|
||||
|
||||
it("falls back to default for invalid values", () => {
|
||||
expect(
|
||||
resolveBootstrapPromptTruncationWarningMode({
|
||||
agents: { defaults: { bootstrapPromptTruncationWarning: "invalid" } },
|
||||
} as unknown as OpenClawConfig),
|
||||
).toBe(EXPECTED_DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
export {
|
||||
buildBootstrapContextFiles,
|
||||
resolveBootstrapMaxChars,
|
||||
resolveBootstrapPromptTruncationWarningMode,
|
||||
resolveBootstrapTotalMaxChars,
|
||||
} from "./embedded-agent-helpers/bootstrap.js";
|
||||
export {
|
||||
|
||||
@@ -91,7 +91,6 @@ const DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS = 60_000;
|
||||
// USER.md stays directive-sized so profile guidance cannot crowd out project
|
||||
// rules or durable facts from the shared bootstrap budget.
|
||||
export const USER_BOOTSTRAP_MAX_CHARS = 4_000;
|
||||
const DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE = "always";
|
||||
const MIN_BOOTSTRAP_FILE_BUDGET_CHARS = 64;
|
||||
// Ratios split `contentBudget` (= maxChars − marker.length − join separators), not `maxChars`.
|
||||
// The marker and "\n" separators are already reserved before this split runs; these ratios
|
||||
@@ -146,12 +145,6 @@ export function resolveBootstrapTotalMaxChars(
|
||||
return DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS;
|
||||
}
|
||||
|
||||
export function resolveBootstrapPromptTruncationWarningMode(
|
||||
_cfg?: OpenClawConfig,
|
||||
): "off" | "once" | "always" {
|
||||
return DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE;
|
||||
}
|
||||
|
||||
function isAgentsBootstrapFile(fileName: string | undefined): boolean {
|
||||
return fileName?.toLowerCase() === AGENTS_BOOTSTRAP_FILENAME.toLowerCase();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { isEmbeddedMode } from "../../../infra/embedded-mode.js";
|
||||
import {
|
||||
analyzeBootstrapBudget,
|
||||
buildBootstrapInjectionStats,
|
||||
buildBootstrapPromptWarning,
|
||||
} from "../../bootstrap-budget.js";
|
||||
import { buildBootstrapBudgetState } from "../../bootstrap-budget.js";
|
||||
import {
|
||||
buildBootstrapContextForFiles,
|
||||
hasCompletedBootstrapTurn,
|
||||
@@ -16,11 +12,6 @@ import {
|
||||
isPrimaryBootstrapRun,
|
||||
resolveWorkspaceBootstrapRouting,
|
||||
} from "../../bootstrap-routing.js";
|
||||
import {
|
||||
resolveBootstrapMaxChars,
|
||||
resolveBootstrapPromptTruncationWarningMode,
|
||||
resolveBootstrapTotalMaxChars,
|
||||
} from "../../embedded-agent-helpers.js";
|
||||
import {
|
||||
DEFAULT_BOOTSTRAP_FILENAME,
|
||||
isWorkspaceBootstrapPending,
|
||||
@@ -144,23 +135,11 @@ export async function prepareEmbeddedAttemptBootstrap(params: {
|
||||
const bootstrapFilesForInjectionStats = bootstrapRouting.includeBootstrapInSystemContext
|
||||
? hookAdjustedBootstrapFiles
|
||||
: hookAdjustedBootstrapFiles.filter((file) => file.name !== DEFAULT_BOOTSTRAP_FILENAME);
|
||||
const bootstrapMaxChars = resolveBootstrapMaxChars(attempt.config, params.sessionAgentId);
|
||||
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(
|
||||
attempt.config,
|
||||
params.sessionAgentId,
|
||||
);
|
||||
const bootstrapAnalysis = analyzeBootstrapBudget({
|
||||
files: buildBootstrapInjectionStats({
|
||||
bootstrapFiles: bootstrapFilesForInjectionStats,
|
||||
injectedFiles: contextFiles,
|
||||
}),
|
||||
bootstrapMaxChars,
|
||||
bootstrapTotalMaxChars,
|
||||
});
|
||||
const bootstrapPromptWarningMode = resolveBootstrapPromptTruncationWarningMode(attempt.config);
|
||||
const bootstrapPromptWarning = buildBootstrapPromptWarning({
|
||||
analysis: bootstrapAnalysis,
|
||||
mode: bootstrapPromptWarningMode,
|
||||
const bootstrapBudget = buildBootstrapBudgetState({
|
||||
config: attempt.config,
|
||||
agentId: params.sessionAgentId,
|
||||
bootstrapFiles: bootstrapFilesForInjectionStats,
|
||||
injectedFiles: contextFiles,
|
||||
seenSignatures: attempt.bootstrapPromptWarningSignaturesSeen,
|
||||
previousSignature: attempt.bootstrapPromptWarningSignature,
|
||||
});
|
||||
@@ -179,12 +158,8 @@ export async function prepareEmbeddedAttemptBootstrap(params: {
|
||||
}
|
||||
|
||||
return {
|
||||
bootstrapAnalysis,
|
||||
bootstrapMaxChars,
|
||||
...bootstrapBudget,
|
||||
bootstrapMode,
|
||||
bootstrapPromptWarning,
|
||||
bootstrapPromptWarningMode,
|
||||
bootstrapTotalMaxChars,
|
||||
contextFiles,
|
||||
hookAdjustedBootstrapFiles,
|
||||
shouldRecordCompletedBootstrapTurn,
|
||||
|
||||
+1
-1
@@ -1,10 +1,10 @@
|
||||
// Coverage for bootstrap warning text in system prompt assembly.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBootstrapPromptWarning } from "../../bootstrap-budget-warning.js";
|
||||
import {
|
||||
analyzeBootstrapBudget,
|
||||
buildBootstrapPromptWarningNotice,
|
||||
buildBootstrapInjectionStats,
|
||||
buildBootstrapPromptWarning,
|
||||
} from "../../bootstrap-budget.js";
|
||||
import { composeSystemPromptWithHookContext } from "./attempt.thread-helpers.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getPluginToolMeta, setPluginToolMeta } from "../../../plugins/tools.js";
|
||||
import {
|
||||
BEFORE_TOOL_CALL_SOURCE_TOOL,
|
||||
BEFORE_TOOL_CALL_WRAPPED,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
} from "../../before-tool-call-metadata.js";
|
||||
import { getChannelAgentToolMeta, setChannelAgentToolMeta } from "../../channel-tool-metadata.js";
|
||||
import { isCodeModeControlTool, markCodeModeControlTool } from "../../code-mode-control-tools.js";
|
||||
import {
|
||||
@@ -127,21 +132,20 @@ describe("heartbeat wrapper metadata preservation", () => {
|
||||
|
||||
it("preserves before-tool-call marker on heartbeat-wrapped tools", () => {
|
||||
const source: Record<string, unknown> = { name: "test-tool", execute: vi.fn() as never };
|
||||
// Simulate a tool that has gone through the before-tool-call hook
|
||||
Object.defineProperty(source, Symbol.for("openclaw:beforeToolCallWrapped"), {
|
||||
Object.defineProperty(source, BEFORE_TOOL_CALL_WRAPPED, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
});
|
||||
Object.defineProperty(source, Symbol.for("openclaw:beforeToolCallSourceTool"), {
|
||||
value: { name: "inner-tool" },
|
||||
const sourceTool = { name: "inner-tool" };
|
||||
Object.defineProperty(source, BEFORE_TOOL_CALL_SOURCE_TOOL, {
|
||||
value: sourceTool,
|
||||
enumerable: false,
|
||||
});
|
||||
|
||||
const wrapped = wrapEmbeddedAttemptToolWithActivity(source as never, RUN) as typeof source;
|
||||
|
||||
expect((wrapped as Record<symbol, unknown>)[Symbol.for("openclaw:beforeToolCallWrapped")]).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isToolWrappedWithBeforeToolCallHook(wrapped as never)).toBe(true);
|
||||
expect((wrapped as Record<symbol, unknown>)[BEFORE_TOOL_CALL_SOURCE_TOOL]).toBe(sourceTool);
|
||||
});
|
||||
|
||||
it("preserves terminal presentation metadata on heartbeat-wrapped tools", () => {
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { copyPluginToolMeta } from "../../../plugins/tools.js";
|
||||
import {
|
||||
clearToolActivityRun,
|
||||
getLastToolActivityMs,
|
||||
notifyToolActivity,
|
||||
onToolActivity,
|
||||
} from "../../../shared/tool-activity-heartbeat.js";
|
||||
import { copyBeforeToolCallHookMarker } from "../../agent-tools.before-tool-call.js";
|
||||
import { copyChannelAgentToolMeta } from "../../channel-tools.js";
|
||||
import { copyCodeModeControlToolIdentity } from "../../code-mode-control-tools.js";
|
||||
import { copyToolTerminalPresentation } from "../../tool-terminal-presentation.js";
|
||||
import { copyAgentToolMetadata } from "../../agent-tool-metadata.js";
|
||||
import type { AnyAgentTool } from "../../tools/common.js";
|
||||
|
||||
export { clearToolActivityRun, getLastToolActivityMs, notifyToolActivity, onToolActivity };
|
||||
@@ -33,11 +29,6 @@ export function wrapEmbeddedAttemptToolWithActivity<T extends AnyAgentTool>(
|
||||
}
|
||||
}) as typeof originalExecute,
|
||||
} as T;
|
||||
// Tool metadata lives in identity-keyed WeakMaps, so object spread is insufficient.
|
||||
copyPluginToolMeta(tool, wrappedTool);
|
||||
copyChannelAgentToolMeta(tool, wrappedTool);
|
||||
copyBeforeToolCallHookMarker(tool, wrappedTool);
|
||||
copyToolTerminalPresentation(tool, wrappedTool);
|
||||
copyCodeModeControlToolIdentity(tool, wrappedTool);
|
||||
return wrappedTool;
|
||||
// Tool metadata is identity-keyed, so object spread is insufficient.
|
||||
return copyAgentToolMetadata(tool, wrappedTool);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import type { TSchema } from "typebox";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { ProviderRuntimePluginHandle } from "../../plugins/provider-hook-runtime.js";
|
||||
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
|
||||
import { copyPluginToolMeta } from "../../plugins/tools.js";
|
||||
import { copyBeforeToolCallHookMarker } from "../before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "../channel-tools.js";
|
||||
import { copyAgentToolMetadata } from "../agent-tool-metadata.js";
|
||||
import {
|
||||
logProviderToolSchemaDiagnostics,
|
||||
normalizeProviderToolSchemas,
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
filterProviderNormalizableTools,
|
||||
type RuntimeToolSchemaDiagnostic,
|
||||
} from "../tool-schema-projection.js";
|
||||
import { copyToolTerminalPresentation } from "../tool-terminal-presentation.js";
|
||||
import type { AnyAgentTool } from "../tools/common.js";
|
||||
import type { AgentRuntimePlan } from "./types.js";
|
||||
|
||||
@@ -71,10 +68,7 @@ function copyRuntimeToolMetadata(source: AgentTool, target: AgentTool): void {
|
||||
if (source.outputSchema !== undefined) {
|
||||
target.outputSchema = source.outputSchema;
|
||||
}
|
||||
copyPluginToolMeta(source as never, target as never);
|
||||
copyChannelAgentToolMeta(source as never, target as never);
|
||||
copyBeforeToolCallHookMarker(source as never, target as never);
|
||||
copyToolTerminalPresentation(source as never, target as never);
|
||||
copyAgentToolMetadata(source as never, target as never);
|
||||
}
|
||||
|
||||
// Duplicate names cannot be matched by map lookup alone, so same-index matches
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Ambient trusted caller context for model-mediated Gateway tool calls.
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { copyPluginToolMeta } from "../../plugins/tools.js";
|
||||
import { copyBeforeToolCallHookMarker } from "../before-tool-call-metadata.js";
|
||||
import { copyChannelAgentToolMeta } from "../channel-tools.js";
|
||||
import { copyToolTerminalPresentation } from "../tool-terminal-presentation.js";
|
||||
import { copyAgentToolMetadata } from "../agent-tool-metadata.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
|
||||
type GatewayToolCallerIdentity = {
|
||||
@@ -76,11 +73,7 @@ export function wrapToolWithGatewayCallerIdentity(
|
||||
execute: async (...args) =>
|
||||
await withGatewayToolCallerIdentity(identity, async () => await tool.execute?.(...args)),
|
||||
};
|
||||
copyPluginToolMeta(tool, wrapped);
|
||||
copyChannelAgentToolMeta(tool as never, wrapped as never);
|
||||
copyBeforeToolCallHookMarker(tool, wrapped);
|
||||
copyToolTerminalPresentation(tool, wrapped);
|
||||
return wrapped;
|
||||
return copyAgentToolMetadata(tool, wrapped);
|
||||
}
|
||||
|
||||
export function createGatewayToolCallerWrapper(
|
||||
|
||||
@@ -75,7 +75,6 @@ export async function chooseDispatchRoute(state: PrepareDispatchOperationReadySt
|
||||
const shouldSuppressDefaultToolProgressMessages = () => !shouldEmitVerboseProgress();
|
||||
const shouldSendVerboseProgressMessages = () => !shouldSuppressDefaultToolProgressMessages();
|
||||
const shouldSendToolSummaries = () => shouldSendVerboseProgressMessages();
|
||||
const shouldSendToolStartStatuses = false;
|
||||
const notifiedSessionMetadataChangeKeys = new Set<string>();
|
||||
const routeState: { sessionMetadataChangesForResult?: CommandSessionMetadataChange[] } = {};
|
||||
const notifySessionMetadataChanges = (
|
||||
@@ -600,7 +599,6 @@ export async function chooseDispatchRoute(state: PrepareDispatchOperationReadySt
|
||||
shouldSuppressDefaultToolProgressMessages,
|
||||
shouldSendVerboseProgressMessages,
|
||||
shouldSendToolSummaries,
|
||||
shouldSendToolStartStatuses,
|
||||
notifySessionMetadataChanges,
|
||||
shouldDeliverVerboseProgressDespiteSourceSuppression,
|
||||
shouldDeliverForcedToolProgressDespiteSourceSuppression,
|
||||
|
||||
@@ -41,7 +41,6 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
|
||||
markProgress,
|
||||
markVisibleToolErrorProgress,
|
||||
maybeApplyTtsWithFinalizationLease,
|
||||
maybeSendWorkingStatus,
|
||||
normalizeReplyMediaPayload,
|
||||
notifySessionMetadataChanges,
|
||||
onToolResultFromReplyOptions,
|
||||
@@ -339,24 +338,6 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
|
||||
) {
|
||||
await state.onApprovalEventFromReplyOptions?.(payload);
|
||||
}
|
||||
if (isDispatchOperationAborted()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
payload.phase !== "requested" ||
|
||||
shouldSuppressDefaultToolProgressMessages()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const label = state.summarizeApprovalLabel({
|
||||
status: payload.status,
|
||||
command: payload.command,
|
||||
message: payload.message,
|
||||
});
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
await maybeSendWorkingStatus(label);
|
||||
},
|
||||
onPatchSummary: async (payload) => {
|
||||
if (isDispatchOperationAborted()) {
|
||||
@@ -378,20 +359,6 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
|
||||
) {
|
||||
await state.onPatchSummaryFromReplyOptions?.(payload);
|
||||
}
|
||||
if (isDispatchOperationAborted()) {
|
||||
return;
|
||||
}
|
||||
if (payload.phase !== "end" || shouldSuppressDefaultToolProgressMessages()) {
|
||||
return;
|
||||
}
|
||||
const label = state.summarizePatchLabel({
|
||||
summary: payload.summary,
|
||||
title: payload.title,
|
||||
});
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
await maybeSendWorkingStatus(label);
|
||||
},
|
||||
onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => {
|
||||
markProgress();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import {
|
||||
isFastModeAutoProgressPayload,
|
||||
resolveSendableOutboundReplyParts,
|
||||
@@ -55,16 +54,7 @@ export async function prepareDispatchExecution(state: ChooseDispatchRouteReadySt
|
||||
);
|
||||
}
|
||||
|
||||
const toolStartStatusesSent = new Set<string>();
|
||||
let toolStartStatusCount = 0;
|
||||
let didSendPlanStatusNotice = false;
|
||||
const normalizeWorkingLabel = (label: string) => {
|
||||
const collapsed = label.replace(/\s+/g, " ").trim();
|
||||
if (collapsed.length <= 80) {
|
||||
return collapsed;
|
||||
}
|
||||
return `${truncateUtf16Safe(collapsed, 77).trimEnd()}...`;
|
||||
};
|
||||
const formatPlanUpdateText = (payload: { explanation?: string; steps?: AgentPlanStep[] }) => {
|
||||
const explanation = payload.explanation?.replace(/\s+/g, " ").trim();
|
||||
const steps = (payload.steps ?? [])
|
||||
@@ -78,32 +68,6 @@ export async function prepareDispatchExecution(state: ChooseDispatchRouteReadySt
|
||||
}
|
||||
return explanation || "Planning next steps.";
|
||||
};
|
||||
const maybeSendWorkingStatus = async (label: string): Promise<void> => {
|
||||
if (shouldSuppressProgressDelivery()) {
|
||||
return;
|
||||
}
|
||||
const normalizedLabel = normalizeWorkingLabel(label);
|
||||
if (
|
||||
!shouldEmitVerboseProgress() ||
|
||||
!state.shouldSendToolStartStatuses ||
|
||||
!normalizedLabel ||
|
||||
toolStartStatusCount >= 2 ||
|
||||
toolStartStatusesSent.has(normalizedLabel)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
toolStartStatusesSent.add(normalizedLabel);
|
||||
toolStartStatusCount += 1;
|
||||
const payload: ReplyPayload = {
|
||||
text: `Working: ${normalizedLabel}`,
|
||||
};
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(payload, undefined, false);
|
||||
return;
|
||||
}
|
||||
markInboundDedupeReplayUnsafe();
|
||||
turnLedger.sendQueued("tool", payload);
|
||||
};
|
||||
const sendPlanUpdate = async (payload: {
|
||||
explanation?: string;
|
||||
steps?: AgentPlanStep[];
|
||||
@@ -127,38 +91,6 @@ export async function prepareDispatchExecution(state: ChooseDispatchRouteReadySt
|
||||
markInboundDedupeReplayUnsafe();
|
||||
turnLedger.sendQueued("tool", replyPayload);
|
||||
};
|
||||
const summarizeApprovalLabel = (payload: {
|
||||
status?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
}) => {
|
||||
if (payload.status === "pending") {
|
||||
const command = normalizeOptionalString(payload.command);
|
||||
if (command) {
|
||||
return normalizeWorkingLabel(`awaiting approval: ${command}`);
|
||||
}
|
||||
return "awaiting approval";
|
||||
}
|
||||
if (payload.status === "unavailable") {
|
||||
const message = normalizeOptionalString(payload.message);
|
||||
if (message) {
|
||||
return normalizeWorkingLabel(message);
|
||||
}
|
||||
return "approval unavailable";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const summarizePatchLabel = (payload: { summary?: string; title?: string }) => {
|
||||
const summary = normalizeOptionalString(payload.summary);
|
||||
if (summary) {
|
||||
return normalizeWorkingLabel(summary);
|
||||
}
|
||||
const title = normalizeOptionalString(payload.title);
|
||||
if (title) {
|
||||
return normalizeWorkingLabel(title);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
// Track accumulated block text for TTS generation after streaming completes.
|
||||
// When block streaming succeeds, there's no final reply, so we need to generate
|
||||
// TTS audio separately from the accumulated block content.
|
||||
@@ -484,10 +416,7 @@ export async function prepareDispatchExecution(state: ChooseDispatchRouteReadySt
|
||||
: withFullRuntimeReplyConfig(cfg);
|
||||
state.recordAgentDispatchStarted();
|
||||
const nextState = extendPreparedDispatchState(state, {
|
||||
maybeSendWorkingStatus,
|
||||
sendPlanUpdate,
|
||||
summarizeApprovalLabel,
|
||||
summarizePatchLabel,
|
||||
cleanBlockTtsDirectiveText,
|
||||
resolveToolDeliveryPayload,
|
||||
typing,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Prompt composition scenarios build reusable agent prompt fixtures.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { buildBootstrapPromptWarning } from "../../../src/agents/bootstrap-budget-warning.js";
|
||||
import {
|
||||
appendBootstrapPromptWarning,
|
||||
analyzeBootstrapBudget,
|
||||
buildBootstrapInjectionStats,
|
||||
buildBootstrapPromptWarning,
|
||||
} from "../../../src/agents/bootstrap-budget.js";
|
||||
import { resolveBootstrapContextForRun } from "../../../src/agents/bootstrap-files.js";
|
||||
import { buildCurrentInboundPrompt } from "../../../src/agents/embedded-agent-runner/run/runtime-context-prompt.js";
|
||||
|
||||
Reference in New Issue
Block a user