diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts index ce345bc0a52..9e693891f1d 100644 --- a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { type ModelAliasIndex, modelKey } from "../agents/model-selection.js"; -import { resolveModelDirectiveSelection } from "./reply/model-selection.js"; +import type { ModelAliasIndex } from "../agents/model-selection-shared.js"; +import { resolveModelDirectiveSelection } from "./reply/model-selection-directive.js"; const emptyAliasIndex: ModelAliasIndex = { byAlias: new Map(), @@ -79,7 +79,7 @@ describe("directive behavior model fuzzy selection", () => { }, ], ]), - byKey: new Map([[modelKey("moonshot", "kimi-k2-0905-preview"), ["Kimi"]]]), + byKey: new Map([["moonshot/kimi-k2-0905-preview", ["Kimi"]]]), }; expect( diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts new file mode 100644 index 00000000000..17d74415602 --- /dev/null +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -0,0 +1,83 @@ +import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; +import type { resolveProviderScopedAuthProfile } from "./agent-runner-auth-profile.js"; +import type { FollowupRun } from "./queue.js"; + +export type ReasoningTagProviderResolver = ( + provider: string, + options: { + config: FollowupRun["run"]["config"]; + workspaceDir: string; + modelId: string; + }, +) => boolean; + +export const resolveEnforceFinalTagWithResolver = ( + run: FollowupRun["run"], + provider: string, + model = run.model, + isReasoningTagProvider?: ReasoningTagProviderResolver, +) => + (run.skipProviderRuntimeHints ? false : undefined) ?? + (run.enforceFinalTag || + isReasoningTagProvider?.(provider, { + config: run.config, + workspaceDir: run.workspaceDir, + modelId: model, + }) || + false); + +export function resolveModelFallbackOptions(run: FollowupRun["run"]) { + const config = run.config; + return { + cfg: config, + provider: run.provider, + model: run.model, + agentDir: run.agentDir, + fallbacksOverride: resolveRunModelFallbacksOverride({ + cfg: config, + agentId: run.agentId, + sessionKey: run.sessionKey, + }), + }; +} + +export function buildEmbeddedRunBaseParams(params: { + run: FollowupRun["run"]; + provider: string; + model: string; + runId: string; + authProfile: ReturnType; + allowTransientCooldownProbe?: boolean; + isReasoningTagProvider?: ReasoningTagProviderResolver; +}) { + const config = params.run.config; + return { + sessionFile: params.run.sessionFile, + workspaceDir: params.run.workspaceDir, + agentDir: params.run.agentDir, + config, + skillsSnapshot: params.run.skillsSnapshot, + ownerNumbers: params.run.ownerNumbers, + inputProvenance: params.run.inputProvenance, + senderIsOwner: params.run.senderIsOwner, + enforceFinalTag: resolveEnforceFinalTagWithResolver( + params.run, + params.provider, + params.model, + params.isReasoningTagProvider, + ), + silentExpected: params.run.silentExpected, + allowEmptyAssistantReplyAsSilent: params.run.allowEmptyAssistantReplyAsSilent, + provider: params.provider, + model: params.model, + ...params.authProfile, + thinkLevel: params.run.thinkLevel, + verboseLevel: params.run.verboseLevel, + reasoningLevel: params.run.reasoningLevel, + execOverrides: params.run.execOverrides, + bashElevated: params.run.bashElevated, + timeoutMs: params.run.timeoutMs, + runId: params.runId, + allowTransientCooldownProbe: params.allowTransientCooldownProbe, + }; +} diff --git a/src/auto-reply/reply/agent-runner-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-runtime-config.test.ts index 056dab5d735..24a012e6801 100644 --- a/src/auto-reply/reply/agent-runner-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-runtime-config.test.ts @@ -2,12 +2,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot, - type OpenClawConfig, -} from "../../config/config.js"; -import { - buildEmbeddedRunBaseParams, - resolveProviderScopedAuthProfile, -} from "./agent-runner-utils.js"; +} from "../../config/runtime-snapshot.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { buildEmbeddedRunBaseParams } from "./agent-runner-run-params.js"; import type { FollowupRun } from "./queue.js"; function makeRun(config: OpenClawConfig): FollowupRun["run"] { @@ -73,10 +70,7 @@ describe("buildEmbeddedRunBaseParams runtime config", () => { provider: "openai", model: "gpt-4.1-mini", runId: "run-1", - authProfile: resolveProviderScopedAuthProfile({ - provider: "openai", - primaryProvider: "openai", - }), + authProfile: {}, }); expect(resolved.config).toBe(resolvedRunConfig); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 089eab6f70a..ae32192899c 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,4 +1,3 @@ -import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelId, @@ -28,6 +27,12 @@ import { resolveRunAuthProfile, } from "./agent-runner-auth-profile.js"; export { resolveProviderScopedAuthProfile, resolveRunAuthProfile }; +import { + buildEmbeddedRunBaseParams as buildEmbeddedRunBaseParamsCore, + resolveModelFallbackOptions, + resolveEnforceFinalTagWithResolver, +} from "./agent-runner-run-params.js"; +export { resolveModelFallbackOptions } from "./agent-runner-run-params.js"; import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js"; import type { FollowupRun } from "./queue.js"; @@ -173,63 +178,15 @@ export const resolveEnforceFinalTag = ( run: FollowupRun["run"], provider: string, model = run.model, -) => - (run.skipProviderRuntimeHints ? false : undefined) ?? - (run.enforceFinalTag || - isReasoningTagProvider(provider, { - config: run.config, - workspaceDir: run.workspaceDir, - modelId: model, - })); +) => resolveEnforceFinalTagWithResolver(run, provider, model, isReasoningTagProvider); -export function resolveModelFallbackOptions(run: FollowupRun["run"]) { - const config = run.config; - return { - cfg: config, - provider: run.provider, - model: run.model, - agentDir: run.agentDir, - fallbacksOverride: resolveRunModelFallbacksOverride({ - cfg: config, - agentId: run.agentId, - sessionKey: run.sessionKey, - }), - }; -} - -export function buildEmbeddedRunBaseParams(params: { - run: FollowupRun["run"]; - provider: string; - model: string; - runId: string; - authProfile: ReturnType; - allowTransientCooldownProbe?: boolean; -}) { - const config = params.run.config; - return { - sessionFile: params.run.sessionFile, - workspaceDir: params.run.workspaceDir, - agentDir: params.run.agentDir, - config, - skillsSnapshot: params.run.skillsSnapshot, - ownerNumbers: params.run.ownerNumbers, - inputProvenance: params.run.inputProvenance, - senderIsOwner: params.run.senderIsOwner, - enforceFinalTag: resolveEnforceFinalTag(params.run, params.provider, params.model), - silentExpected: params.run.silentExpected, - allowEmptyAssistantReplyAsSilent: params.run.allowEmptyAssistantReplyAsSilent, - provider: params.provider, - model: params.model, - ...params.authProfile, - thinkLevel: params.run.thinkLevel, - verboseLevel: params.run.verboseLevel, - reasoningLevel: params.run.reasoningLevel, - execOverrides: params.run.execOverrides, - bashElevated: params.run.bashElevated, - timeoutMs: params.run.timeoutMs, - runId: params.runId, - allowTransientCooldownProbe: params.allowTransientCooldownProbe, - }; +export function buildEmbeddedRunBaseParams( + params: Parameters[0], +) { + return buildEmbeddedRunBaseParamsCore({ + ...params, + isReasoningTagProvider, + }); } export function buildEmbeddedContextFromTemplate(params: { diff --git a/src/auto-reply/reply/model-selection-directive.ts b/src/auto-reply/reply/model-selection-directive.ts index 77935e36f18..1b537aa1b7b 100644 --- a/src/auto-reply/reply/model-selection-directive.ts +++ b/src/auto-reply/reply/model-selection-directive.ts @@ -1,10 +1,18 @@ -import { modelKey, normalizeProviderId } from "../../agents/model-selection-normalize.js"; -import { - resolveModelRefFromString, - type ModelAliasIndex, -} from "../../agents/model-selection-shared.js"; +import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js"; +import { normalizeProviderId } from "../../agents/provider-id.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +export type ModelAliasIndex = { + byAlias: Map< + string, + { + alias: string; + ref: { provider: string; model: string }; + } + >; + byKey: Map; +}; + export type ModelDirectiveSelection = { provider: string; model: string; @@ -24,6 +32,53 @@ const FUZZY_VARIANT_TOKENS = [ "nano", ]; +function modelKey(provider: string, model: string): string { + const providerId = provider.trim(); + const modelId = model.trim(); + if (!providerId) { + return modelId; + } + if (!modelId) { + return providerId; + } + return normalizeLowercaseStringOrEmpty(modelId).startsWith( + `${normalizeLowercaseStringOrEmpty(providerId)}/`, + ) + ? modelId + : `${providerId}/${modelId}`; +} + +function resolveModelRefFromDirectiveString(params: { + raw: string; + defaultProvider: string; + aliasIndex: ModelAliasIndex; +}): { ref: { provider: string; model: string }; alias?: string } | null { + const { model } = splitTrailingAuthProfile(params.raw); + if (!model) { + return null; + } + if (!model.includes("/")) { + const aliasKey = normalizeLowercaseStringOrEmpty(model); + const aliasMatch = params.aliasIndex.byAlias.get(aliasKey); + if (aliasMatch) { + return { ref: aliasMatch.ref, alias: aliasMatch.alias }; + } + } + const trimmed = model.trim(); + const slash = trimmed.indexOf("/"); + const providerRaw = slash === -1 ? params.defaultProvider : trimmed.slice(0, slash).trim(); + const modelRaw = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim(); + if (!providerRaw || !modelRaw) { + return null; + } + return { + ref: { + provider: normalizeProviderId(providerRaw), + model: modelRaw, + }, + }; +} + function boundedLevenshteinDistance(a: string, b: string, maxDistance: number): number | null { if (a === b) { return 0; @@ -299,7 +354,7 @@ export function resolveModelDirectiveSelection(params: { return { selection: buildSelection(best.provider, best.model) }; }; - const resolved = resolveModelRefFromString({ + const resolved = resolveModelRefFromDirectiveString({ raw: rawTrimmed, defaultProvider, aliasIndex,