mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(system-agent): local-model viability — context cap, thinking off, route-aware timeout (#109445)
* 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
This commit is contained in:
committed by
GitHub
parent
92146f9f80
commit
784ede0af1
@@ -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<string, unknown> | undefined)?.num_ctx).toBe(202752);
|
||||
expect((payloadSeen?.options as Record<string, unknown> | undefined)?.num_ctx).toBe(32_768);
|
||||
});
|
||||
|
||||
it("owns replay policy for OpenAI-compatible and native Ollama routes", () => {
|
||||
|
||||
@@ -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<typeof buildOllamaProvider>[1],
|
||||
): Promise<ModelProviderConfig> {
|
||||
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<ProviderRuntimeModel | undefined> {
|
||||
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<ProviderAugmentModelCatalogContext["entries"]> {
|
||||
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);
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
|
||||
@@ -232,6 +232,8 @@ describe("runSystemAgentTurn", () => {
|
||||
authProfileId: "openai:p2",
|
||||
authProfileIdSource: "user",
|
||||
config: binding.execution.runConfig,
|
||||
thinkLevel: "off",
|
||||
timeoutMs: 120_000,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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<PropertyKey, unknown>
|
||||
)[Symbol.for("openclaw.systemAgentTimeoutTestApi")] as {
|
||||
resolveSystemAgentAssistantTimeoutFromManifests: (params: {
|
||||
route: { modelLabel: string; provider: string };
|
||||
plugins: ReadonlyArray<{
|
||||
modelPricing?: { providers?: Record<string, { external?: boolean }> };
|
||||
}>;
|
||||
}) => 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);
|
||||
});
|
||||
});
|
||||
@@ -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<PluginManifestRecord, "modelPricing">;
|
||||
|
||||
function resolveSystemAgentAssistantTimeoutFromManifests(params: {
|
||||
route: Pick<SystemAgentConfiguredRoute, "modelLabel" | "provider">;
|
||||
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<PropertyKey, unknown>)[Symbol.for("openclaw.systemAgentTimeoutTestApi")] = {
|
||||
resolveSystemAgentAssistantTimeoutFromManifests,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string>;
|
||||
removeTempDir?: (dir: string) => Promise<void>;
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user