From 638bfc0bf57698fca1002ccded95334001d854c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 15 May 2026 19:58:49 +0100 Subject: [PATCH] fix: honor wildcard model runtime policy --- CHANGELOG.md | 1 + src/agents/harness/selection.test.ts | 12 +++ src/agents/model-runtime-policy.test.ts | 113 ++++++++++++++++++++++++ src/agents/model-runtime-policy.ts | 47 +++++++--- 4 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 src/agents/model-runtime-policy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a790afd52ad..02ac9a9b272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI-compatible: honor per-model `max_completion_tokens`/`max_tokens` params in embedded OpenAI-completions runs so high-token Kimi-style routes keep their configured completion cap. Fixes #82230. Thanks @albert-zen. - Agents/local: install a local gateway request scope around trusted `openclaw agent --local` runs, so subagent completion announces can use in-process gateway dispatch without crashing. Fixes #82140. Thanks @Kushmaro. - Discord: validate message-read results before normalizing channel history and report unexpected payloads with a Discord boundary error instead of `map is not a function`. Fixes #82252. Thanks @jessewunderlich. +- Agents/runtime: apply `agents.defaults.models["provider/*"].agentRuntime` as provider-wide model runtime policy while preserving exact model runtime precedence. Fixes #82243. Thanks @rendrag-git. - Telegram/active-memory: run blocking memory recall through the Telegram provider for direct-message turns even when the hook context carries the raw chat id, preventing embedded recall from launching against an invalid numeric channel. Fixes #82177. Thanks @cslash-zz. - Control UI/WebChat: keep optimistic image messages from embedding large inline `data:` previews and preserve image-only user turns in chat history, avoiding browser stack overflows when sending image attachments. Fixes #82182. Thanks @ExploreSheep. - Agents/media: preserve message-tool-only delivery for generated music and video completion handoffs, so group/channel completions do not finish without posting the generated attachment. diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 57dccb83dc6..733b448ca66 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -220,6 +220,18 @@ describe("runAgentHarnessAttempt", () => { expect(piRunAttempt).toHaveBeenCalledTimes(1); }); + it("honors provider wildcard PI runtime policy for OpenAI agent model runs", async () => { + registerSuccessfulCodexHarness(); + + const result = await runAgentHarnessAttempt({ + ...createAttemptParams(agentModelRuntimeConfig("openai/*", "pi")), + provider: "openai", + modelId: "gpt-5.4", + }); + expect(result.sessionIdUsed).toBe("pi"); + expect(piRunAttempt).toHaveBeenCalledTimes(1); + }); + it("annotates non-ok harness result classifications for outer model fallback", async () => { const classify = vi.fn>(() => "empty" as const); registerAgentHarness( diff --git a/src/agents/model-runtime-policy.test.ts b/src/agents/model-runtime-policy.test.ts new file mode 100644 index 00000000000..106f512bb71 --- /dev/null +++ b/src/agents/model-runtime-policy.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveModelRuntimePolicy } from "./model-runtime-policy.js"; + +describe("resolveModelRuntimePolicy", () => { + it("honors provider wildcard agent model runtime policy entries", () => { + const config = { + agents: { + defaults: { + models: { + "vllm/*": { agentRuntime: { id: "pi" } }, + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveModelRuntimePolicy({ + config, + provider: "vllm", + modelId: "qwen-local", + }), + ).toEqual({ + policy: { id: "pi" }, + source: "model", + }); + }); + + it("prefers exact agent model runtime policy entries over provider wildcards", () => { + const config = { + agents: { + defaults: { + models: { + "vllm/*": { agentRuntime: { id: "pi" } }, + "vllm/qwen-local": { agentRuntime: { id: "codex" } }, + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveModelRuntimePolicy({ + config, + provider: "vllm", + modelId: "qwen-local", + }), + ).toEqual({ + policy: { id: "codex" }, + source: "model", + }); + }); + + it("prefers exact provider model runtime policy over agent provider wildcards", () => { + const config = { + agents: { + defaults: { + models: { + "vllm/*": { agentRuntime: { id: "pi" } }, + }, + }, + }, + models: { + providers: { + vllm: { + models: [{ id: "qwen-local", agentRuntime: { id: "codex" } }], + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveModelRuntimePolicy({ + config, + provider: "vllm", + modelId: "qwen-local", + }), + ).toEqual({ + policy: { id: "codex" }, + source: "model", + }); + }); + + it("prefers agent provider wildcard runtime policy over provider runtime policy", () => { + const config = { + agents: { + defaults: { + models: { + "vllm/*": { agentRuntime: { id: "pi" } }, + }, + }, + }, + models: { + providers: { + vllm: { + agentRuntime: { id: "codex" }, + models: [], + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveModelRuntimePolicy({ + config, + provider: "vllm", + modelId: "qwen-local", + }), + ).toEqual({ + policy: { id: "pi" }, + source: "model", + }); + }); +}); diff --git a/src/agents/model-runtime-policy.ts b/src/agents/model-runtime-policy.ts index 824de2a0363..7efcf480225 100644 --- a/src/agents/model-runtime-policy.ts +++ b/src/agents/model-runtime-policy.ts @@ -13,6 +13,8 @@ export type ResolvedModelRuntimePolicy = { source?: ModelRuntimePolicySource; }; +type ModelEntryMatchKind = "none" | "exact" | "provider-wildcard"; + function hasRuntimePolicy(value: AgentRuntimePolicyConfig | undefined): boolean { return Boolean(value?.id?.trim()); } @@ -63,26 +65,41 @@ function modelEntryMatches(params: { provider: string | undefined; modelId: string; }): boolean { + return modelEntryMatchKind(params) === "exact"; +} + +function modelEntryMatchKind(params: { + entry: Pick; + provider: string | undefined; + modelId: string; +}): ModelEntryMatchKind { const entryId = params.entry.id.trim(); if (entryId === params.modelId) { - return true; + return "exact"; } const slash = entryId.indexOf("/"); if (slash <= 0) { - return false; + return "none"; } - return ( - normalizeProviderId(entryId.slice(0, slash)) === normalizeProviderId(params.provider ?? "") && - entryId.slice(slash + 1).trim() === params.modelId - ); + if (normalizeProviderId(entryId.slice(0, slash)) !== normalizeProviderId(params.provider ?? "")) { + return "none"; + } + const entryModelId = entryId.slice(slash + 1).trim(); + if (entryModelId === params.modelId) { + return "exact"; + } + if (entryModelId === "*") { + return "provider-wildcard"; + } + return "none"; } -function modelKeyMatches(params: { +function modelKeyMatchKind(params: { key: string; provider: string | undefined; modelId: string; -}): boolean { - return modelEntryMatches({ +}): ModelEntryMatchKind { + return modelEntryMatchKind({ entry: { id: params.key }, provider: params.provider, modelId: params.modelId, @@ -95,6 +112,7 @@ function resolveAgentModelEntryRuntimePolicy(params: { modelId?: string; agentId?: string; sessionKey?: string; + matchKind: Exclude; }): ResolvedModelRuntimePolicy { const modelId = normalizeModelIdForProvider(params.provider, params.modelId); if (!params.config || !modelId) { @@ -115,7 +133,7 @@ function resolveAgentModelEntryRuntimePolicy(params: { for (const models of modelMaps) { for (const [key, entry] of Object.entries(models ?? {})) { if ( - modelKeyMatches({ key, provider: params.provider, modelId }) && + modelKeyMatchKind({ key, provider: params.provider, modelId }) === params.matchKind && hasRuntimePolicy(entry?.agentRuntime) ) { return { policy: entry.agentRuntime, source: "model" }; @@ -146,7 +164,7 @@ export function resolveModelRuntimePolicy(params: { agentId?: string; sessionKey?: string; }): ResolvedModelRuntimePolicy { - const agentModelPolicy = resolveAgentModelEntryRuntimePolicy(params); + const agentModelPolicy = resolveAgentModelEntryRuntimePolicy({ ...params, matchKind: "exact" }); if (agentModelPolicy.policy) { return agentModelPolicy; } @@ -159,6 +177,13 @@ export function resolveModelRuntimePolicy(params: { if (hasRuntimePolicy(modelConfig?.agentRuntime)) { return { policy: modelConfig?.agentRuntime, source: "model" }; } + const agentWildcardModelPolicy = resolveAgentModelEntryRuntimePolicy({ + ...params, + matchKind: "provider-wildcard", + }); + if (agentWildcardModelPolicy.policy) { + return agentWildcardModelPolicy; + } if (hasRuntimePolicy(providerConfig?.agentRuntime)) { return { policy: providerConfig?.agentRuntime, source: "provider" }; }