diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a5cf7658db..c46aec3a7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Agents/runtime: memoize transcript replay-policy resolution for stable config and process-env runs while preserving custom-env provider hook behavior. Thanks @DmitryPogodaev. - Infra/path-guards: add a fast path for canonical absolute POSIX containment checks, avoiding repeated `path.resolve` and `path.relative` work in hot filesystem walkers. Refs #75895, #75575, and #68782. Thanks @Enderfga. - Tools: add a platform-level tool descriptor planner for descriptor-first visibility, generic availability checks, and executor references. Thanks @shakkernerd. - Docs/Codex: clarify that ChatGPT/Codex subscription setups should use `openai/gpt-*` with `agentRuntime.id: "codex"` for native Codex runtime, while `openai-codex/*` remains the PI OAuth route. Thanks @pashpashpash. diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 5d97a3fb802..10b3cc437d3 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -1,4 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveProviderRuntimePlugin } from "../plugins/provider-hook-runtime.js"; vi.mock("../plugins/provider-hook-runtime.js", async () => { const replayHelpers = await vi.importActual< @@ -13,6 +15,7 @@ vi.mock("../plugins/provider-hook-runtime.js", async () => { "anthropic", "google", "github-copilot", + "env-sensitive", "kilocode", "kimi", "kimi-code", @@ -38,9 +41,20 @@ vi.mock("../plugins/provider-hook-runtime.js", async () => { return {}; } return { - buildReplayPolicy: (context?: { modelId?: string; modelApi?: string }) => { + buildReplayPolicy: (context?: { + modelId?: string; + modelApi?: string; + env?: NodeJS.ProcessEnv; + }) => { const modelId = context?.modelId?.toLowerCase() ?? ""; switch (provider) { + case "env-sensitive": + return { + sanitizeToolCallIds: context?.env?.OPENCLAW_TEST_TRANSCRIPT_POLICY === "strict", + ...(context?.env?.OPENCLAW_TEST_TRANSCRIPT_POLICY === "strict" + ? { toolCallIdMode: "strict" as const } + : {}), + }; case "amazon-bedrock": case "anthropic": return { @@ -190,6 +204,7 @@ vi.mock("../plugins/provider-hook-runtime.js", async () => { let resolveTranscriptPolicy: typeof import("./transcript-policy.js").resolveTranscriptPolicy; let shouldAllowProviderOwnedThinkingReplay: typeof import("./transcript-policy.js").shouldAllowProviderOwnedThinkingReplay; +const mockResolveProviderRuntimePlugin = vi.mocked(resolveProviderRuntimePlugin); describe("resolveTranscriptPolicy", () => { beforeAll(async () => { @@ -225,6 +240,56 @@ describe("resolveTranscriptPolicy", () => { expect(policy.toolCallIdMode).toBe("strict"); }); + it("memoizes replay policy resolution for the same config and process env", () => { + const config = {} as OpenClawConfig; + + resolveTranscriptPolicy({ + provider: "mistral", + modelId: "mistral-large-latest", + config, + env: process.env, + }); + resolveTranscriptPolicy({ + provider: "mistral", + modelId: "mistral-large-latest", + config, + env: process.env, + }); + + expect(mockResolveProviderRuntimePlugin).toHaveBeenCalledTimes(1); + }); + + it("does not reuse cached replay policies across custom env objects", () => { + const config = {} as OpenClawConfig; + const strictEnv = { + ...process.env, + OPENCLAW_TEST_TRANSCRIPT_POLICY: "strict", + }; + const looseEnv = { + ...process.env, + OPENCLAW_TEST_TRANSCRIPT_POLICY: "loose", + }; + + const strictPolicy = resolveTranscriptPolicy({ + provider: "env-sensitive", + modelId: "env-demo", + config, + env: strictEnv, + }); + const loosePolicy = resolveTranscriptPolicy({ + provider: "env-sensitive", + modelId: "env-demo", + config, + env: looseEnv, + }); + + expect(strictPolicy.sanitizeToolCallIds).toBe(true); + expect(strictPolicy.toolCallIdMode).toBe("strict"); + expect(loosePolicy.sanitizeToolCallIds).toBe(false); + expect(loosePolicy.toolCallIdMode).toBeUndefined(); + expect(mockResolveProviderRuntimePlugin).toHaveBeenCalledTimes(2); + }); + it("enables sanitizeToolCallIds for Google provider", () => { const policy = resolveTranscriptPolicy({ provider: "google", diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index ea02a45263e..f686ae4a916 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js"; import { resolveProviderRuntimePlugin } from "../plugins/provider-hook-runtime.js"; import { shouldPreserveThinkingBlocks } from "../plugins/provider-replay-helpers.js"; import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; @@ -177,6 +178,39 @@ function mergeTranscriptPolicy( }; } +const transcriptPolicyCache = new WeakMap>(); + +function canCacheTranscriptPolicy(params: { + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): params is { config: OpenClawConfig; env?: NodeJS.ProcessEnv } { + if (!params.config) { + return false; + } + return !params.env || params.env === process.env; +} + +function resolveTranscriptPolicyCacheKey(params: { + modelApi?: string | null; + provider: string; + modelId?: string | null; + config: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string { + return JSON.stringify({ + provider: params.provider, + modelApi: params.modelApi ?? "", + modelId: params.modelId ?? "", + workspaceDir: params.workspaceDir ?? "", + pluginControlPlane: resolvePluginControlPlaneFingerprint({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }), + }); +} + export function resolveTranscriptPolicy(params: { modelApi?: string | null; provider?: string | null; @@ -187,6 +221,15 @@ export function resolveTranscriptPolicy(params: { model?: ProviderRuntimeModel; }): TranscriptPolicy { const provider = normalizeProviderId(params.provider ?? ""); + const cacheKey = canCacheTranscriptPolicy(params) + ? resolveTranscriptPolicyCacheKey({ ...params, provider, config: params.config }) + : undefined; + if (cacheKey) { + const cached = transcriptPolicyCache.get(params.config)?.get(cacheKey); + if (cached) { + return cached; + } + } const runtimePlugin = provider ? resolveProviderRuntimePlugin({ provider, @@ -208,15 +251,21 @@ export function resolveTranscriptPolicy(params: { // Once a provider adopts the replay-policy hook, replay policy should come // from the plugin, not from transport-family defaults in core. const buildReplayPolicy = runtimePlugin?.buildReplayPolicy; - if (buildReplayPolicy) { - const pluginPolicy = buildReplayPolicy(context); - return mergeTranscriptPolicy(pluginPolicy ?? undefined); + const policy = buildReplayPolicy + ? mergeTranscriptPolicy(buildReplayPolicy(context) ?? undefined) + : mergeTranscriptPolicy( + buildUnownedProviderTransportReplayFallback({ + modelApi: params.modelApi, + modelId: params.modelId, + }), + ); + if (cacheKey) { + let configCache = transcriptPolicyCache.get(params.config); + if (!configCache) { + configCache = new Map(); + transcriptPolicyCache.set(params.config, configCache); + } + configCache.set(cacheKey, policy); } - - return mergeTranscriptPolicy( - buildUnownedProviderTransportReplayFallback({ - modelApi: params.modelApi, - modelId: params.modelId, - }), - ); + return policy; }