fix(agents): keep reply tool snapshots aligned

This commit is contained in:
Peter Steinberger
2026-04-25 04:15:15 +01:00
parent b13545355d
commit 972d8fc1cf
11 changed files with 256 additions and 6 deletions

View File

@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Config/recovery: skip whole-file last-known-good rollback when invalidity is scoped to `plugins.entries.*`, preserving unrelated user settings during plugin schema or host-version skew. Fixes #71289. Thanks @jalehman.
- Agents/tools: keep resolved reply-run configs from being overwritten by stale runtime snapshots, and let empty web runtime metadata fall back to configured provider auto-detection so standard and queued turns expose the same tool set. Fixes #71355. Thanks @c-g14.
- Compaction: honor explicit `agents.defaults.compaction.keepRecentTokens` for manual `/compact`, re-distill safeguard summaries instead of snowballing previous summaries, and enable safeguard summary quality checks by default. Fixes #71357. Thanks @WhiteGiverMa.
- Sessions: honor configured `session.maintenance` settings during load-time maintenance instead of falling back to default entry caps. Fixes #71356. Thanks @comolago.
- Browser/sandbox: pass the resolved `browser.ssrfPolicy` into sandbox browser bridges and refresh cached bridges when the effective policy changes, so sandboxed browser navigation honors private-network opt-ins. Fixes #45153 and #57055. Thanks @jzakirov, @zuoanCo, and @kybrcore.

View File

@@ -1,3 +1,4 @@
import { selectApplicableRuntimeConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolvePluginTools } from "../plugins/tools.js";
import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime.js";
@@ -45,7 +46,11 @@ export function resolveOpenClawPluginToolsForOptions(params: {
...resolveOpenClawPluginToolInputs({
options: params.options,
resolvedConfig: params.resolvedConfig,
runtimeConfig: runtimeSnapshot?.config,
runtimeConfig: selectApplicableRuntimeConfig({
inputConfig: params.resolvedConfig ?? params.options?.config,
runtimeConfig: runtimeSnapshot?.config,
runtimeSourceConfig: runtimeSnapshot?.sourceConfig,
}),
}),
existingToolNames: params.existingToolNames ?? new Set<string>(),
toolAllowlist: params.options?.pluginToolAllowlist,

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js";
import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js";
const hoisted = vi.hoisted(() => ({
@@ -13,6 +14,7 @@ vi.mock("../plugins/tools.js", () => ({
describe("createOpenClawTools browser plugin integration", () => {
afterEach(() => {
hoisted.resolvePluginTools.mockReset();
clearSecretsRuntimeSnapshot();
});
it("keeps the browser tool returned by plugin resolution", () => {
@@ -117,4 +119,57 @@ describe("createOpenClawTools browser plugin integration", () => {
const details = (result.details ?? {}) as { workspaceOnly?: boolean | null };
expect(details.workspaceOnly).toBe(true);
});
it("does not pass a stale active snapshot as plugin runtime config for a resolved run config", () => {
const staleSourceConfig = {
plugins: {
allow: ["old-plugin"],
},
} as OpenClawConfig;
const staleRuntimeConfig = {
plugins: {
allow: ["old-plugin"],
},
} as OpenClawConfig;
const resolvedRunConfig = {
plugins: {
allow: ["browser"],
},
tools: {
experimental: {
planTool: true,
},
},
} as OpenClawConfig;
let capturedRuntimeConfig: OpenClawConfig | undefined;
hoisted.resolvePluginTools.mockImplementation((params: unknown) => {
capturedRuntimeConfig = (params as { context?: { runtimeConfig?: OpenClawConfig } }).context
?.runtimeConfig;
return [];
});
activateSecretsRuntimeSnapshot({
sourceConfig: staleSourceConfig,
config: staleRuntimeConfig,
authStores: [],
warnings: [],
webTools: {
search: {
providerSource: "none",
diagnostics: [],
},
fetch: {
providerSource: "none",
diagnostics: [],
},
diagnostics: [],
},
});
resolveOpenClawPluginToolsForOptions({
options: { config: resolvedRunConfig },
resolvedConfig: resolvedRunConfig,
});
expect(capturedRuntimeConfig).toBe(resolvedRunConfig);
});
});

View File

@@ -17,7 +17,8 @@ vi.mock("../../cli/command-secret-targets.js", () => ({
hoisted.getScopedChannelsCommandSecretTargetsMock(...args),
}));
const { resolveQueuedReplyExecutionConfig } = await import("./agent-runner-utils.js");
const { resolveQueuedReplyExecutionConfig, resolveQueuedReplyRuntimeConfig } =
await import("./agent-runner-utils.js");
const { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
await import("../../config/config.js");
@@ -145,4 +146,46 @@ describe("resolveQueuedReplyExecutionConfig channel scope", () => {
accountId: undefined,
});
});
it("does not replace an already resolved run config with a stale runtime snapshot", () => {
const sourceConfig = {
models: {
providers: {
openai: {
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
models: [],
},
},
},
} as unknown as OpenClawConfig;
const staleRuntimeConfig = {
models: {
providers: {
openai: {
apiKey: "stale-runtime-key",
models: [],
},
},
},
} as unknown as OpenClawConfig;
const scopedResolvedConfig = {
models: {
providers: {
openai: {
apiKey: "fresh-scoped-key",
models: [],
},
},
},
tools: {
experimental: {
planTool: true,
},
},
} as unknown as OpenClawConfig;
setRuntimeConfigSnapshot(staleRuntimeConfig, sourceConfig);
expect(resolveQueuedReplyRuntimeConfig(structuredClone(sourceConfig))).toBe(staleRuntimeConfig);
expect(resolveQueuedReplyRuntimeConfig(scopedResolvedConfig)).toBe(scopedResolvedConfig);
});
});

View File

@@ -11,7 +11,12 @@ import {
getScopedChannelsCommandSecretTargets,
} from "../../cli/command-secret-targets.js";
import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js";
import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../config/config.js";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
selectApplicableRuntimeConfig,
type OpenClawConfig,
} from "../../config/config.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -29,8 +34,16 @@ import type { FollowupRun } from "./queue.js";
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
export function resolveQueuedReplyRuntimeConfig(config: OpenClawConfig): OpenClawConfig {
const runtimeConfig =
typeof getRuntimeConfigSnapshot === "function" ? getRuntimeConfigSnapshot() : null;
const runtimeSourceConfig =
typeof getRuntimeConfigSourceSnapshot === "function" ? getRuntimeConfigSourceSnapshot() : null;
return (
(typeof getRuntimeConfigSnapshot === "function" ? getRuntimeConfigSnapshot() : null) ?? config
selectApplicableRuntimeConfig({
inputConfig: config,
runtimeConfig,
runtimeSourceConfig,
}) ?? config
);
}

View File

@@ -21,6 +21,7 @@ export {
recoverConfigFromJsonRootSuffix,
resetConfigRuntimeState,
resolveConfigSnapshotHash,
selectApplicableRuntimeConfig,
setRuntimeConfigSnapshotRefreshHandler,
setRuntimeConfigSnapshot,
writeConfigFile,

View File

@@ -80,6 +80,7 @@ import {
notifyRuntimeConfigWriteListeners,
registerRuntimeConfigWriteListener,
resetConfigRuntimeState as resetConfigRuntimeStateState,
selectApplicableRuntimeConfig,
setRuntimeConfigSnapshot as setRuntimeConfigSnapshotState,
getRuntimeConfigSnapshotRefreshHandler as getRuntimeConfigSnapshotRefreshHandlerState,
setRuntimeConfigSnapshotRefreshHandler as setRuntimeConfigSnapshotRefreshHandlerState,
@@ -98,6 +99,7 @@ export {
getRuntimeConfigSnapshotState as getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshotState as getRuntimeConfigSourceSnapshot,
resetConfigRuntimeStateState as resetConfigRuntimeState,
selectApplicableRuntimeConfig,
setRuntimeConfigSnapshotState as setRuntimeConfigSnapshot,
setRuntimeConfigSnapshotRefreshHandlerState as setRuntimeConfigSnapshotRefreshHandler,
};

View File

@@ -7,6 +7,7 @@ import {
notifyRuntimeConfigWriteListeners,
registerRuntimeConfigWriteListener,
resetConfigRuntimeState,
selectApplicableRuntimeConfig,
setRuntimeConfigSnapshot,
setRuntimeConfigSnapshotRefreshHandler,
} from "./runtime-snapshot.js";
@@ -70,6 +71,54 @@ describe("runtime snapshot state", () => {
expect(getRuntimeConfigSourceSnapshot()).toEqual(sourceConfig);
});
it("selects runtime config only when input still matches the runtime source", () => {
const sourceConfig: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
models: [],
},
},
},
};
const runtimeConfig: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-runtime-resolved",
models: [],
},
},
},
};
const scopedResolvedConfig: OpenClawConfig = {
...runtimeConfig,
tools: {
experimental: {
planTool: true,
},
},
};
expect(
selectApplicableRuntimeConfig({
inputConfig: structuredClone(sourceConfig),
runtimeConfig,
runtimeSourceConfig: sourceConfig,
}),
).toBe(runtimeConfig);
expect(
selectApplicableRuntimeConfig({
inputConfig: scopedResolvedConfig,
runtimeConfig,
runtimeSourceConfig: sourceConfig,
}),
).toBe(scopedResolvedConfig);
});
it("clears runtime source snapshot when runtime snapshot is cleared", () => {
setRuntimeConfigSnapshot({ gateway: { port: 18789 } }, { gateway: { port: 18789 } });
resetRuntimeConfigState();

View File

@@ -22,6 +22,31 @@ let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
const runtimeConfigWriteListeners = new Set<(event: RuntimeConfigWriteNotification) => void>();
function stableConfigStringify(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value) ?? "null";
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stableConfigStringify(entry)).join(",")}]`;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).toSorted();
return `{${keys
.map((key) => `${JSON.stringify(key)}:${stableConfigStringify(record[key])}`)
.join(",")}}`;
}
function configSnapshotsMatch(left: OpenClawConfig, right: OpenClawConfig): boolean {
if (left === right) {
return true;
}
try {
return stableConfigStringify(left) === stableConfigStringify(right);
} catch {
return false;
}
}
export function setRuntimeConfigSnapshot(
config: OpenClawConfig,
sourceConfig?: OpenClawConfig,
@@ -47,6 +72,32 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null {
return runtimeConfigSourceSnapshot;
}
export function selectApplicableRuntimeConfig(params: {
inputConfig?: OpenClawConfig;
runtimeConfig?: OpenClawConfig | null;
runtimeSourceConfig?: OpenClawConfig | null;
}): OpenClawConfig | undefined {
const runtimeConfig = params.runtimeConfig ?? null;
if (!runtimeConfig) {
return params.inputConfig;
}
const inputConfig = params.inputConfig;
if (!inputConfig) {
return runtimeConfig;
}
if (inputConfig === runtimeConfig) {
return inputConfig;
}
const runtimeSourceConfig = params.runtimeSourceConfig ?? null;
if (!runtimeSourceConfig) {
return runtimeConfig;
}
if (configSnapshotsMatch(inputConfig, runtimeSourceConfig)) {
return runtimeConfig;
}
return inputConfig;
}
export function setRuntimeConfigSnapshotRefreshHandler(
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null,
): void {

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { resolveWebProviderDefinition } from "./provider-runtime-shared.js";
describe("resolveWebProviderDefinition", () => {
it("falls back to auto-detect when runtime metadata has no selected provider", () => {
const resolved = resolveWebProviderDefinition({
config: {},
toolConfig: { enabled: true },
runtimeMetadata: {},
providers: [
{
id: "custom",
},
],
resolveEnabled: () => true,
resolveAutoProviderId: () => "custom",
createTool: ({ provider }) => ({
name: provider.id,
}),
});
expect(resolved).toEqual({
provider: {
id: "custom",
},
definition: {
name: "custom",
},
});
});
});

View File

@@ -133,8 +133,7 @@ export function resolveWebProviderDefinition<
providers,
});
const providerId =
params.providerId ??
(params.runtimeMetadata ? params.runtimeMetadata.selectedProvider : autoProviderId);
params.providerId ?? params.runtimeMetadata?.selectedProvider ?? autoProviderId;
if (!providerId) {
return null;
}