From 784ede0af1cf2d8e4f3b4ae47224929323cc7805 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 18:11:22 -0700 Subject: [PATCH] =?UTF-8?q?feat(system-agent):=20local-model=20viability?= =?UTF-8?q?=20=E2=80=94=20context=20cap,=20thinking=20off,=20route-aware?= =?UTF-8?q?=20timeout=20(#109445)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: improve system agent local model viability * fix: forward Ollama effective context cap * fix(system-agent): keep flat 120s agent-turn budget * fix(system-agent): keep manifests timeout helper module-local --- extensions/ollama/index.test.ts | 15 +++--- extensions/ollama/index.ts | 45 +++++++++++++--- extensions/ollama/provider-discovery.ts | 5 +- extensions/ollama/src/defaults.ts | 1 + .../ollama/src/discovery-shared.test.ts | 36 +++++++++++-- extensions/ollama/src/provider-models.test.ts | 17 ++++++ extensions/ollama/src/provider-models.ts | 20 +++++++ extensions/ollama/src/stream-runtime.test.ts | 27 ++++++++++ extensions/ollama/src/stream.ts | 23 ++++++-- src/system-agent/agent-turn.test.ts | 2 + src/system-agent/agent-turn.ts | 4 ++ src/system-agent/assistant-prompts.ts | 4 +- src/system-agent/assistant-timeout.test.ts | 51 ++++++++++++++++++ src/system-agent/assistant-timeout.ts | 53 +++++++++++++++++++ src/system-agent/assistant.configured.test.ts | 3 ++ src/system-agent/assistant.ts | 9 +++- 16 files changed, 287 insertions(+), 28 deletions(-) create mode 100644 src/system-agent/assistant-timeout.test.ts create mode 100644 src/system-agent/assistant-timeout.ts diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index e5a1107ef0a5..6305d3d8f1e0 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -1725,13 +1725,12 @@ describe("ollama plugin", () => { it("resolves GLM-5.2 from the cloud fallback catalog", () => { const provider = registerOllamaCloudProvider(); + const model = provider.resolveDynamicModel?.({ + provider: "ollama-cloud", + modelId: "glm-5.2:cloud", + } as never); - expect( - provider.resolveDynamicModel?.({ - provider: "ollama-cloud", - modelId: "glm-5.2:cloud", - } as never), - ).toEqual( + expect(model).toEqual( expect.objectContaining({ provider: "ollama-cloud", id: "glm-5.2:cloud", @@ -1740,6 +1739,7 @@ describe("ollama plugin", () => { reasoning: true, }), ); + expect(model?.contextTokens).toBeUndefined(); }); it("does not mint synthetic auth for public IPv4 baseUrl", () => { @@ -1786,6 +1786,7 @@ describe("ollama plugin", () => { id: "qwen3:32b", baseUrl: "http://127.0.0.1:11434/v1", contextWindow: 202_752, + contextTokens: 32_768, }, streamFn: baseStreamFn, }); @@ -1795,7 +1796,7 @@ describe("ollama plugin", () => { } void wrapped({} as never, {} as never, {}); expect(baseStreamFn).toHaveBeenCalledTimes(1); - expect((payloadSeen?.options as Record | undefined)?.num_ctx).toBe(202752); + expect((payloadSeen?.options as Record | undefined)?.num_ctx).toBe(32_768); }); it("owns replay policy for OpenAI-compatible and native Ollama routes", () => { diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 3de13a078261..6aee2289129a 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -67,6 +67,10 @@ import { createOllamaNodeInvokePolicy, } from "./src/node-inference.js"; import { readProviderBaseUrl } from "./src/provider-base-url.js"; +import { + capLocalOllamaModelContext, + capLocalOllamaProviderContext, +} from "./src/provider-models.js"; import { OLLAMA_INCOMPLETE_STREAM_ERROR, createConfiguredOllamaCompatStreamWrapper, @@ -101,6 +105,13 @@ const OLLAMA_CLOUD_DEFAULT_MODEL_REF = `${OLLAMA_CLOUD_PROVIDER_ID}/${OLLAMA_CLO const OLLAMA_CONFIGURED_SHOW_CONCURRENCY = 4; const OLLAMA_CONFIGURED_SHOW_MAX_MODELS = 8; +async function buildLocalOllamaProvider( + configuredBaseUrl?: string, + opts?: Parameters[1], +): Promise { + return capLocalOllamaProviderContext(await buildOllamaProvider(configuredBaseUrl, opts)); +} + async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) { const pluginConfig = resolvePluginConfigObject(ctx.config, OLLAMA_PROVIDER_ID) as | OllamaPluginConfig @@ -131,7 +142,14 @@ async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) ownerValue = OLLAMA_DEFAULT_API_KEY; } } - return model ? { existing, provider, model, ownerValue } : null; + return model + ? { + existing, + provider: capLocalOllamaProviderContext(provider), + model: capLocalOllamaModelContext(model), + ownerValue, + } + : null; } function buildDynamicCacheKey(provider: string, baseUrl: string | undefined): string { @@ -164,6 +182,9 @@ function toDynamicOllamaModel(params: { input: input.length > 0 ? input : ["text"], cost: params.model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: params.model.contextWindow ?? 8192, + ...(params.model.contextTokens !== undefined + ? { contextTokens: params.model.contextTokens } + : {}), maxTokens: params.model.maxTokens ?? 8192, ...(params.model.compat ? { compat: params.model.compat as never } : {}), ...(params.model.params ? { params: params.model.params } : {}), @@ -421,6 +442,7 @@ async function resolveRequestedDynamicOllamaModel(params: { providerConfig: ModelProviderConfig; modelId: string; showApiKey?: string; + capContextTokens?: boolean; }): Promise { const showBaseUrl = readProviderBaseUrl(params.providerConfig) ?? OLLAMA_DEFAULT_BASE_URL; const showInfo = params.showApiKey @@ -429,14 +451,16 @@ async function resolveRequestedDynamicOllamaModel(params: { if (typeof showInfo.contextWindow !== "number" && (showInfo.capabilities?.length ?? 0) === 0) { return undefined; } + const definition = buildOllamaModelDefinition( + params.modelId, + showInfo.contextWindow, + showInfo.capabilities, + ); + const model = params.capContextTokens ? capLocalOllamaModelContext(definition) : definition; return toDynamicOllamaModel({ provider: params.provider, providerConfig: params.providerConfig, - model: buildOllamaModelDefinition( - params.modelId, - showInfo.contextWindow, - showInfo.capabilities, - ), + model, }); } @@ -447,6 +471,7 @@ async function augmentConfiguredOllamaCatalogModels(params: { provider: string; entries: ProviderAugmentModelCatalogContext["entries"]; resolveProviderApiKey: ProviderAugmentModelCatalogContext["resolveProviderApiKey"]; + capContextTokens?: boolean; }): Promise { const models = collectConfiguredOllamaModelIds({ config: params.config, @@ -488,6 +513,7 @@ async function augmentConfiguredOllamaCatalogModels(params: { providerConfig, modelId: model.id, showApiKey, + capContextTokens: params.capContextTokens, }); return requested ? { @@ -498,6 +524,7 @@ async function augmentConfiguredOllamaCatalogModels(params: { reasoning: requested.reasoning, input: requested.input, contextWindow: requested.contextWindow, + contextTokens: requested.contextTokens, compat: requested.compat, } : undefined; @@ -745,7 +772,7 @@ export default definePluginEntry({ await resolveOllamaDiscoveryResult({ ctx, pluginConfig: resolveCurrentPluginConfig(ctx.config), - buildProvider: buildOllamaProvider, + buildProvider: buildLocalOllamaProvider, }), }, wizard: { @@ -801,6 +828,7 @@ export default definePluginEntry({ provider: OLLAMA_PROVIDER_ID, entries: ctx.entries, resolveProviderApiKey: ctx.resolveProviderApiKey, + capContextTokens: true, }), createEmbeddingProvider: async ({ config, model, provider: embeddingProvider, remote }) => { const { provider, client } = await createOllamaEmbeddingProvider({ @@ -838,7 +866,7 @@ export default definePluginEntry({ return; } const baseUrl = readProviderBaseUrl(providerConfig); - const provider = await buildOllamaProvider(baseUrl, { quiet: true }); + const provider = await buildLocalOllamaProvider(baseUrl, { quiet: true }); const dynamicApi = providerConfig?.api ?? provider.api; const dynamicProvider = { ...provider, @@ -861,6 +889,7 @@ export default definePluginEntry({ provider: ctx.provider, providerConfig: dynamicProvider, modelId: ctx.modelId, + capContextTokens: true, }); if (requestedModel) { dynamicModels.push(requestedModel); diff --git a/extensions/ollama/provider-discovery.ts b/extensions/ollama/provider-discovery.ts index 1597331474f9..f3df8001ccc2 100644 --- a/extensions/ollama/provider-discovery.ts +++ b/extensions/ollama/provider-discovery.ts @@ -8,7 +8,7 @@ import { shouldUseSyntheticOllamaAuth, type OllamaPluginConfig, } from "./src/discovery-shared.js"; -import { buildOllamaProvider } from "./src/provider-models.js"; +import { buildOllamaProvider, capLocalOllamaProviderContext } from "./src/provider-models.js"; type OllamaProviderPlugin = { id: string; @@ -41,7 +41,8 @@ async function runOllamaDiscovery(ctx: ProviderCatalogContext) { return await resolveOllamaDiscoveryResult({ ctx, pluginConfig: resolveOllamaPluginConfig(ctx), - buildProvider: buildOllamaProvider, + buildProvider: async (...args) => + capLocalOllamaProviderContext(await buildOllamaProvider(...args)), }); } diff --git a/extensions/ollama/src/defaults.ts b/extensions/ollama/src/defaults.ts index 6e7f3651655a..53d6087e57b2 100644 --- a/extensions/ollama/src/defaults.ts +++ b/extensions/ollama/src/defaults.ts @@ -13,6 +13,7 @@ export const OLLAMA_CLOUD_DEFAULT_MODELS = [ ] as const; export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000; +export const OLLAMA_LOCAL_CONTEXT_TOKENS = 32_768; export const OLLAMA_DEFAULT_MAX_TOKENS = 8192; export const OLLAMA_DEFAULT_COST = { input: 0, diff --git a/extensions/ollama/src/discovery-shared.test.ts b/extensions/ollama/src/discovery-shared.test.ts index 9f669597a59a..461d6dbdca24 100644 --- a/extensions/ollama/src/discovery-shared.test.ts +++ b/extensions/ollama/src/discovery-shared.test.ts @@ -63,9 +63,10 @@ describe("resolveOllamaDiscoveryResult — hosted Ollama Cloud guard", () => { input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, + contextTokens: 24_000, maxTokens: 8192, compat: { supportsTools: true, supportsUsageInStreaming: true }, - params: { num_ctx: 128000 }, + params: { num_ctx: 48_000 }, } satisfies ModelProviderConfig["models"][number]; const buildMockProvider = async ( @@ -130,11 +131,40 @@ describe("resolveOllamaDiscoveryResult — hosted Ollama Cloud guard", () => { expect(result).not.toBeNull(); const discoveryResult = expectDefined(result, "Ollama Cloud discovery result"); expect(discoveryResult.provider.models).toHaveLength(1); - expect(expectDefined(discoveryResult.provider.models[0], "Ollama Cloud model").id).toBe( - "minimax-m3:cloud", + expect(expectDefined(discoveryResult.provider.models[0], "Ollama Cloud model")).toEqual( + cloudModel, ); }); + it("preserves explicit local model context overrides without discovery", async () => { + let providerCalled = false; + const result = await resolveOllamaDiscoveryResult({ + ctx: { + config: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [cloudModel], + }, + }, + }, + }, + env: {}, + resolveProviderApiKey: () => ({}), + }, + pluginConfig: {}, + buildProvider: async () => { + providerCalled = true; + return await buildMockProvider(); + }, + }); + + expect(providerCalled).toBe(false); + expect(result?.provider.models).toEqual([cloudModel]); + }); + it("does not call buildProvider for remote base URL without explicit models", async () => { let providerCalled = false; const trackingBuildProvider = async ( diff --git a/extensions/ollama/src/provider-models.test.ts b/extensions/ollama/src/provider-models.test.ts index 9c9965a4ecb7..150c01253ccd 100644 --- a/extensions/ollama/src/provider-models.test.ts +++ b/extensions/ollama/src/provider-models.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildOllamaProvider, buildOllamaModelDefinition, + capLocalOllamaProviderContext, enrichOllamaModelsWithContext, fetchOllamaModels, queryOllamaModelShowInfo, @@ -22,6 +23,22 @@ describe("ollama provider models", () => { expect(resolveOllamaApiBase("http://127.0.0.1:11434///")).toBe("http://127.0.0.1:11434"); }); + it("caps local discovered runtime context while preserving native metadata", () => { + const provider = capLocalOllamaProviderContext({ + api: "ollama", + baseUrl: "http://127.0.0.1:11434", + models: [ + buildOllamaModelDefinition("qwen3.5:4b", 262_144), + buildOllamaModelDefinition("small", 16_384), + ], + }); + + expect(provider.models).toEqual([ + expect.objectContaining({ contextWindow: 262_144, contextTokens: 32_768 }), + expect.objectContaining({ contextWindow: 16_384, contextTokens: 16_384 }), + ]); + }); + it("sets discovered models with context windows from /api/show", async () => { const models: OllamaTagModel[] = [{ name: "llama3:8b" }, { name: "deepseek-r1:14b" }]; const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index 6caa4a6730e4..2f3660b55aba 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -11,6 +11,7 @@ import { OLLAMA_DEFAULT_MAX_TOKENS, OLLAMA_GLM52_CLOUD_MODEL_ID, OLLAMA_GLM52_CONTEXT_WINDOW, + OLLAMA_LOCAL_CONTEXT_TOKENS, } from "./defaults.js"; export type OllamaTagModel = { @@ -301,6 +302,25 @@ export function buildOllamaModelDefinition( }; } +export function capLocalOllamaModelContext(model: ModelDefinitionConfig): ModelDefinitionConfig { + if (typeof model.contextWindow !== "number") { + return model; + } + return { + ...model, + // Local Ollama allocates KV cache from num_ctx. Keep native metadata, but cap + // setup-assistant and typical agent turns at 32k; config overlays remain authoritative. + contextTokens: Math.min(OLLAMA_LOCAL_CONTEXT_TOKENS, model.contextWindow), + }; +} + +export function capLocalOllamaProviderContext(provider: ModelProviderConfig): ModelProviderConfig { + return { + ...provider, + models: provider.models?.map(capLocalOllamaModelContext), + }; +} + export async function fetchOllamaModels( baseUrl: string, opts?: { apiKey?: string }, diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 55ea4e148712..40bbbf219748 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -2509,6 +2509,7 @@ describe("createOllamaStreamFn", () => { streaming: false, }, contextWindow: 131072, + contextTokens: 16384, }, options: { temperature: 0.7, maxTokens: 55 }, }); @@ -2540,6 +2541,32 @@ describe("createOllamaStreamFn", () => { ); }); + it("uses effective contextTokens for native Ollama chat options", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}', + ], + async (fetchMock) => { + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + model: { contextWindow: 262_144, contextTokens: 32_768 }, + }); + + await collectStreamEvents(stream); + + const requestInit = getGuardedFetchCall(fetchMock).init ?? {}; + if (typeof requestInit.body !== "string") { + throw new Error("Expected string request body"); + } + const requestBody = JSON.parse(requestInit.body) as { + options: { num_ctx?: number }; + }; + expect(requestBody.options.num_ctx).toBe(32_768); + }, + ); + }); + it("sets top_p=1 for native Ollama greedy sampling requests", async () => { await withMockNdjsonFetch( [ diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 94ee15a98795..eb38f291e66c 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -347,23 +347,36 @@ function resolveOllamaConfiguredNumCtx(model: ProviderRuntimeModel): number | un function resolveOllamaNumCtx(model: ProviderRuntimeModel): number { return ( resolveOllamaConfiguredNumCtx(model) ?? - Math.max(1, Math.floor(model.contextWindow ?? model.maxTokens ?? DEFAULT_CONTEXT_TOKENS)) + Math.max( + 1, + Math.floor( + model.contextTokens ?? model.contextWindow ?? model.maxTokens ?? DEFAULT_CONTEXT_TOKENS, + ), + ) ); } /** * Resolves num_ctx for native /api/chat requests: * 1. explicit `params.num_ctx` set on the model wins, - * 2. otherwise return undefined so Ollama's model, OLLAMA_CONTEXT_LENGTH, - * VRAM, or Modelfile policy decides. + * 2. the effective `contextTokens` runtime cap is forwarded when present, + * 3. otherwise Ollama's model, OLLAMA_CONTEXT_LENGTH, VRAM, or Modelfile policy decides. * * This intentionally differs from `resolveOllamaNumCtx` by not falling back * to `DEFAULT_CONTEXT_TOKENS`: that constant is a sane wrapper-side guess for * the OpenAI-compat path, but native `/api/chat` should not force the full - * advertised catalog context for local models unless the operator opted in. + * advertised `contextWindow`; only an explicit runtime cap or operator override is forwarded. */ function resolveOllamaNativeNumCtx(model: ProviderRuntimeModel): number | undefined { - return resolveOllamaConfiguredNumCtx(model); + const configured = resolveOllamaConfiguredNumCtx(model); + if (configured !== undefined) { + return configured; + } + const effective = model.contextTokens; + if (typeof effective !== "number" || !Number.isFinite(effective) || effective <= 0) { + return undefined; + } + return Math.floor(effective); } function resolveOllamaModelOptions(model: ProviderRuntimeModel): Record { diff --git a/src/system-agent/agent-turn.test.ts b/src/system-agent/agent-turn.test.ts index 0c40c2684c09..008eab1f5cd7 100644 --- a/src/system-agent/agent-turn.test.ts +++ b/src/system-agent/agent-turn.test.ts @@ -232,6 +232,8 @@ describe("runSystemAgentTurn", () => { authProfileId: "openai:p2", authProfileIdSource: "user", config: binding.execution.runConfig, + thinkLevel: "off", + timeoutMs: 120_000, }), ); diff --git a/src/system-agent/agent-turn.ts b/src/system-agent/agent-turn.ts index da2025d57d18..24f84ca0b9da 100644 --- a/src/system-agent/agent-turn.ts +++ b/src/system-agent/agent-turn.ts @@ -29,6 +29,9 @@ import { * Turns share one persistent session so the conversation has genuine * multi-turn memory. Inference setup must succeed before this runner is entered. */ +// Flat budget for both route classes: agent-loop turns run multi-step tool +// calls, so even metered external routes need the full window, and 120s +// already covers local startup + generation (planner evidence). const AGENT_TURN_TIMEOUT_MS = 120_000; const SYSTEM_AGENT_MCP_TOOL_NAME = "mcp__openclaw__openclaw"; @@ -323,6 +326,7 @@ async function runSystemAgentTurnWithDeps( config: plan.runConfig, prompt: params.input, timeoutMs: AGENT_TURN_TIMEOUT_MS, + thinkLevel: "off" as const, runId, messageChannel: "openclaw", messageProvider: "openclaw", diff --git a/src/system-agent/assistant-prompts.ts b/src/system-agent/assistant-prompts.ts index d972891383f4..0aaf7496575f 100644 --- a/src/system-agent/assistant-prompts.ts +++ b/src/system-agent/assistant-prompts.ts @@ -9,8 +9,10 @@ import type { SystemAgentOverview } from "./overview.js"; * touch the system through OpenClaw's typed command vocabulary; parsing * stays deliberately narrow so free-form model text never executes directly. */ -/** Timeout for one assistant turn (local CLI backends cold-start slowly). */ +/** Timeout for one assistant turn on an external, potentially metered route. */ export const SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS = 30_000; +/** Local startup stages can consume nearly 30s before dispatch; leave inference a real budget. */ +export const SYSTEM_AGENT_ASSISTANT_LOCAL_TIMEOUT_MS = 120_000; /** System prompt: persona plus the closed command vocabulary. */ export const SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT = [ diff --git a/src/system-agent/assistant-timeout.test.ts b/src/system-agent/assistant-timeout.test.ts new file mode 100644 index 000000000000..ecb740fd86ac --- /dev/null +++ b/src/system-agent/assistant-timeout.test.ts @@ -0,0 +1,51 @@ +// System-agent timeout tests cover manifest-owned local-route classification. +import { describe, expect, it } from "vitest"; +import { + SYSTEM_AGENT_ASSISTANT_LOCAL_TIMEOUT_MS, + SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS, +} from "./assistant-prompts.js"; +import "./assistant-timeout.js"; + +const { resolveSystemAgentAssistantTimeoutFromManifests } = ( + globalThis as Record +)[Symbol.for("openclaw.systemAgentTimeoutTestApi")] as { + resolveSystemAgentAssistantTimeoutFromManifests: (params: { + route: { modelLabel: string; provider: string }; + plugins: ReadonlyArray<{ + modelPricing?: { providers?: Record }; + }>; + }) => number; +}; + +describe("system-agent assistant timeout", () => { + it.each([ + { + name: "external provider", + provider: "openai", + modelLabel: "openai/gpt-5.5", + external: true, + expected: SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS, + }, + { + name: "local provider", + provider: "ollama", + modelLabel: "ollama/qwen3.5:4b", + external: false, + expected: SYSTEM_AGENT_ASSISTANT_LOCAL_TIMEOUT_MS, + }, + { + name: "hosted sibling provider", + provider: "ollama-cloud", + modelLabel: "ollama-cloud/glm-5.2:cloud", + external: true, + expected: SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS, + }, + ])("uses the $name budget", ({ provider, modelLabel, external, expected }) => { + expect( + resolveSystemAgentAssistantTimeoutFromManifests({ + route: { provider, modelLabel }, + plugins: [{ modelPricing: { providers: { [provider]: { external } } } }], + }), + ).toBe(expected); + }); +}); diff --git a/src/system-agent/assistant-timeout.ts b/src/system-agent/assistant-timeout.ts new file mode 100644 index 000000000000..519c7d1ed84d --- /dev/null +++ b/src/system-agent/assistant-timeout.ts @@ -0,0 +1,53 @@ +// Resolves the system-agent turn budget from manifest-owned provider metadata. +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import { + SYSTEM_AGENT_ASSISTANT_LOCAL_TIMEOUT_MS, + SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS, +} from "./assistant-prompts.js"; +import type { SystemAgentConfiguredRoute } from "./inference-route.js"; + +type SystemAgentPricingManifest = Pick; + +function resolveSystemAgentAssistantTimeoutFromManifests(params: { + route: Pick; + plugins: readonly SystemAgentPricingManifest[]; +}): number { + const providers = new Set([ + normalizeProviderId(params.route.provider), + normalizeProviderId(params.route.modelLabel.split("/", 1)[0] ?? ""), + ]); + const isLocal = params.plugins.some((plugin) => + Object.entries(plugin.modelPricing?.providers ?? {}).some( + ([provider, pricing]) => + providers.has(normalizeProviderId(provider)) && pricing.external === false, + ), + ); + return isLocal ? SYSTEM_AGENT_ASSISTANT_LOCAL_TIMEOUT_MS : SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS; +} + +export function resolveSystemAgentAssistantTimeoutMs(route: SystemAgentConfiguredRoute): number { + try { + const workspaceDir = resolveAgentWorkspaceDir(route.runConfig, route.agentId); + const snapshot = resolvePluginMetadataSnapshot({ + config: route.runConfig, + workspaceDir, + env: process.env, + allowWorkspaceScopedCurrent: true, + }); + return resolveSystemAgentAssistantTimeoutFromManifests({ + route, + plugins: snapshot.plugins, + }); + } catch { + return SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS; + } +} + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.systemAgentTimeoutTestApi")] = { + resolveSystemAgentAssistantTimeoutFromManifests, + }; +} diff --git a/src/system-agent/assistant.configured.test.ts b/src/system-agent/assistant.configured.test.ts index 1a619ee59c42..0d36c9eb260a 100644 --- a/src/system-agent/assistant.configured.test.ts +++ b/src/system-agent/assistant.configured.test.ts @@ -306,6 +306,7 @@ describe("OpenClaw configured-model planner", () => { runEmbeddedAgent: runEmbeddedAgent as never, createTempDir: async () => "/tmp/openclaw-planner", removeTempDir: async () => {}, + resolveAssistantTimeoutMs: () => 120_000, }, }); @@ -325,6 +326,8 @@ describe("OpenClaw configured-model planner", () => { disableTools: true, disableTrajectory: true, toolsAllow: [], + thinkLevel: "off", + timeoutMs: 120_000, }), ); expect(runEmbeddedAgent).toHaveBeenCalledWith( diff --git a/src/system-agent/assistant.ts b/src/system-agent/assistant.ts index 23e3c6714ad6..37c0bde19a44 100644 --- a/src/system-agent/assistant.ts +++ b/src/system-agent/assistant.ts @@ -5,12 +5,12 @@ import os from "node:os"; import path from "node:path"; import { SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT, - SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS, buildSystemAgentAssistantUserPrompt, parseSystemAgentAssistantPlanText, type SystemAgentAssistantPlan, type SystemAgentAssistantTurn, } from "./assistant-prompts.js"; +import { resolveSystemAgentAssistantTimeoutMs } from "./assistant-timeout.js"; import { SystemAgentInferenceUnavailableError } from "./inference-error.js"; import type { SystemAgentOverview } from "./overview.js"; import { @@ -43,6 +43,7 @@ export type SystemAgentConfiguredModelPlannerDeps = SystemAgentVerifiedInference runEmbeddedAgent?: RunEmbeddedAgentFn; createTempDir?: () => Promise; removeTempDir?: (dir: string) => Promise; + resolveAssistantTimeoutMs?: typeof resolveSystemAgentAssistantTimeoutMs; }; export async function planSystemAgentCommand(params: { @@ -90,6 +91,9 @@ export async function planSystemAgentCommandWithConfiguredModel(params: { let plan: SystemAgentAssistantPlan | null; try { const runId = `openclaw-planner-${randomUUID()}`; + const timeoutMs = ( + params.deps?.resolveAssistantTimeoutMs ?? resolveSystemAgentAssistantTimeoutMs + )(route); const shared = { sessionId: `${runId}-session`, agentId: "openclaw", @@ -102,7 +106,8 @@ export async function planSystemAgentCommandWithConfiguredModel(params: { prompt, provider: route.provider, model: route.model, - timeoutMs: SYSTEM_AGENT_ASSISTANT_TIMEOUT_MS, + timeoutMs, + thinkLevel: "off" as const, runId, extraSystemPrompt: SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT, extraSystemPromptStatic: SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT,