diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 3eda981e8db..005680e70f8 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -560,6 +560,10 @@ Two ways to start an ACP session: normalize OpenAI refs such as `openai/gpt-5.4` to Codex ACP startup config before `session/new`; slash forms such as `openai/gpt-5.4/high` also set Codex ACP reasoning effort. + When omitted, `sessions_spawn({ runtime: "acp" })` uses existing + subagent model defaults (`agents.defaults.subagents.model` or + `agents.list[].subagents.model`) when configured; otherwise it lets the + ACP harness use its own default model. Other harnesses must advertise ACP `models` and support `session/set_model`; otherwise OpenClaw/acpx fails clearly instead of silently falling back to the target agent default. @@ -568,6 +572,9 @@ Two ways to start an ACP session: Explicit thinking/reasoning effort. For Codex ACP, `minimal` maps to low effort, `low`/`medium`/`high`/`xhigh` map directly, and `off` omits the reasoning-effort startup override. + When omitted, ACP spawns use existing subagent thinking defaults and + per-model `agents.defaults.models["provider/model"].params.thinking` + for the selected model. ## Spawn bind and thread modes diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 806e2d5ab49..958f07a7600 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -139,8 +139,8 @@ session to confirm the effective tool list. **Defaults:** -- **Model:** inherits the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`); an explicit `sessions_spawn.model` still wins. -- **Thinking:** inherits the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`); an explicit `sessions_spawn.thinking` still wins. +- **Model:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`). ACP runtime spawns use the same configured subagent model when present; otherwise the ACP harness keeps its own default. An explicit `sessions_spawn.model` still wins. +- **Thinking:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`). ACP runtime spawns also apply `agents.defaults.models["provider/model"].params.thinking` for the selected model. An explicit `sessions_spawn.thinking` still wins. - **Run timeout:** if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). - **Task delivery:** native sub-agents receive the delegated task in their first visible `[Subagent Task]` message. The sub-agent system prompt carries runtime rules and routing context, not a hidden duplicate of the task. diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index ffcf75d84c5..486494c4160 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -929,6 +929,129 @@ describe("spawnAcpDirect", () => { expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/); }); + it("applies existing subagent model and model-profile thinking defaults to ACP runtime options", async () => { + replaceSpawnConfig({ + ...createDefaultSpawnConfig(), + agents: { + defaults: { + subagents: { + allowAgents: ["codex"], + maxSpawnDepth: 2, + model: "openai/gpt-5.4", + }, + models: { + "openai/gpt-5.4": { + params: { thinking: "high" }, + }, + }, + }, + }, + }); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:main", + }, + ); + + expectAcceptedSpawn(result); + expectInitializeSessionFields({ + agent: "codex", + runtimeOptions: { + model: "openai/gpt-5.4", + thinking: "high", + }, + }); + }); + + it("uses configured runtime=acp agent defaults before launching the external ACP agent", async () => { + replaceSpawnConfig({ + ...createDefaultSpawnConfig(), + agents: { + list: [ + { + id: "codex-acp", + runtime: { + type: "acp", + acp: { agent: "codex" }, + }, + subagents: { + model: "openai/gpt-5.5", + thinking: "low", + }, + }, + ], + defaults: { + subagents: { + allowAgents: ["codex"], + maxSpawnDepth: 2, + }, + }, + }, + }); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex-acp", + }, + { + agentSessionKey: "agent:main:main", + }, + ); + + expectAcceptedSpawn(result); + expectInitializeSessionFields({ + agent: "codex", + runtimeOptions: { + model: "openai/gpt-5.5", + thinking: "low", + }, + }); + }); + + it("does not treat a configured runtime=acp agent primary model as an ACP startup model", async () => { + replaceSpawnConfig({ + ...createDefaultSpawnConfig(), + agents: { + list: [ + { + id: "codex-acp", + runtime: { + type: "acp", + acp: { agent: "codex" }, + }, + model: "anthropic/claude-sonnet-4-6", + }, + ], + defaults: { + subagents: { + allowAgents: ["codex"], + maxSpawnDepth: 2, + }, + }, + }, + }); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex-acp", + }, + { + agentSessionKey: "agent:main:main", + }, + ); + + expectAcceptedSpawn(result); + const initInput = expectInitializeSessionFields({ agent: "codex" }); + expect(initInput.runtimeOptions).toBeUndefined(); + }); + it("applies ACP spawn run timeout to runtime options and dispatch", async () => { const result = await spawnAcpDirect( { diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 0c0ac9259e6..31ba2aba98e 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -17,6 +17,7 @@ import { } from "../acp/control-plane/spawn.js"; import { isAcpEnabledByPolicy, resolveAcpAgentPolicyError } from "../acp/policy.js"; import { DEFAULT_HEARTBEAT_EVERY } from "../auto-reply/heartbeat.js"; +import { formatThinkingLevels } from "../auto-reply/thinking.js"; import { resolveChannelDefaultBindingPlacement, resolveInboundConversationResolution, @@ -82,6 +83,10 @@ import { inheritedToolDenyPatch, } from "./inherited-tool-deny.js"; import { AGENT_LANE_SUBAGENT } from "./lanes.js"; +import { + resolveConfiguredSubagentSpawnModelSelection, + resolveThinkingDefault, +} from "./model-selection.js"; import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js"; import { resolveSpawnedWorkspaceInheritance } from "./spawned-context.js"; @@ -93,6 +98,8 @@ import { } from "./subagent-capabilities.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { countActiveRunsForSession, getSubagentRunByChildSessionKey } from "./subagent-registry.js"; +import { splitModelRef } from "./subagent-spawn-plan.js"; +import { resolveSubagentThinkingOverride } from "./subagent-spawn-thinking.js"; import { resolveSubagentTargetPolicy } from "./subagent-target-policy.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js"; @@ -437,7 +444,7 @@ function hasSessionLocalHeartbeatRelayRoute(params: { function resolveTargetAcpAgentId(params: { requestedAgentId?: string; cfg: OpenClawConfig; -}): { ok: true; agentId: string } | { ok: false; error: string } { +}): { ok: true; agentId: string; configAgentId?: string } | { ok: false; error: string } { const requested = normalizeOptionalAgentId(params.requestedAgentId); if (requested) { const configuredAgent = params.cfg.agents?.list?.find( @@ -447,6 +454,7 @@ function resolveTargetAcpAgentId(params: { return { ok: true, agentId: normalizeOptionalAgentId(configuredAgent.runtime.acp?.agent) ?? requested, + configAgentId: requested, }; } if (configuredAgent && !isExplicitlyAllowedAcpAgent(params.cfg, requested)) { @@ -458,7 +466,11 @@ function resolveTargetAcpAgentId(params: { 'Use runtime="acp" only with external ACP harness ids such as codex, claude, droid, gemini, or opencode, or configure agents.list[].runtime.type="acp" with runtime.acp.agent.', }; } - return { ok: true, agentId: requested }; + return { + ok: true, + agentId: requested, + ...(configuredAgent ? { configAgentId: requested } : {}), + }; } const configuredDefault = normalizeOptionalAgentId(params.cfg.acp?.defaultAgent); @@ -975,15 +987,71 @@ function validateAcpResumeSessionOwnership(params: { }; } +type AcpSpawnRuntimeOptions = { + model?: string; + thinking?: string; + timeoutSeconds?: number; +}; + +function resolveAcpSpawnRuntimeOptions(params: { + cfg: OpenClawConfig; + targetAgentId: string; + configAgentId?: string; + model?: string; + thinking?: string; + runTimeoutSeconds?: number; +}): { ok: true; runtimeOptions?: AcpSpawnRuntimeOptions } | { ok: false; error: string } { + const policyAgentId = params.configAgentId ?? params.targetAgentId; + const model = resolveConfiguredSubagentSpawnModelSelection({ + cfg: params.cfg, + agentId: policyAgentId, + modelOverride: params.model, + includeAgentPrimary: false, + }); + const targetAgentConfig = resolveAgentConfig(params.cfg, policyAgentId); + const thinkingPlan = resolveSubagentThinkingOverride({ + cfg: params.cfg, + targetAgentConfig, + thinkingOverrideRaw: params.thinking, + }); + if (thinkingPlan.status === "error") { + const { provider, model: modelId } = splitModelRef(model); + return { + ok: false, + error: `Invalid thinking level "${thinkingPlan.thinkingCandidateRaw}". Use one of: ${formatThinkingLevels(provider, modelId)}.`, + }; + } + + let thinking = thinkingPlan.thinkingOverride; + if (!thinking && model) { + const { provider, model: modelId } = splitModelRef(model); + if (provider && modelId) { + thinking = resolveThinkingDefault({ + cfg: params.cfg, + provider, + model: modelId, + }); + } + } + + const runtimeOptions = + model || thinking || params.runTimeoutSeconds + ? { + ...(model ? { model } : {}), + ...(thinking ? { thinking } : {}), + ...(params.runTimeoutSeconds ? { timeoutSeconds: params.runTimeoutSeconds } : {}), + } + : undefined; + return { ok: true, runtimeOptions }; +} + async function initializeAcpSpawnRuntime(params: { cfg: OpenClawConfig; sessionKey: string; targetAgentId: string; runtimeMode: AcpRuntimeSessionMode; resumeSessionId?: string; - model?: string; - thinking?: string; - runTimeoutSeconds?: number; + runtimeOptions?: AcpSpawnRuntimeOptions; cwd?: string; }): Promise { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.targetAgentId }); @@ -1008,14 +1076,7 @@ async function initializeAcpSpawnRuntime(params: { agent: params.targetAgentId, mode: params.runtimeMode, resumeSessionId: params.resumeSessionId, - runtimeOptions: - params.model || params.thinking || params.runTimeoutSeconds - ? { - ...(params.model ? { model: params.model } : {}), - ...(params.thinking ? { thinking: params.thinking } : {}), - ...(params.runTimeoutSeconds ? { timeoutSeconds: params.runTimeoutSeconds } : {}), - } - : undefined, + runtimeOptions: params.runtimeOptions, cwd: params.cwd, backendId: params.cfg.acp?.backend, }); @@ -1320,6 +1381,21 @@ export async function spawnAcpDirect( error: resumeAuthorization.error, }); } + const runtimeOptionsResult = resolveAcpSpawnRuntimeOptions({ + cfg, + targetAgentId, + configAgentId: targetAgentResult.configAgentId, + model: params.model, + thinking: params.thinking, + runTimeoutSeconds: params.runTimeoutSeconds, + }); + if (!runtimeOptionsResult.ok) { + return createAcpSpawnFailure({ + status: "error", + errorCode: "spawn_failed", + error: runtimeOptionsResult.error, + }); + } const { effectiveStreamToParent } = resolveAcpSpawnStreamPlan({ spawnMode, requestThreadBinding, @@ -1392,9 +1468,7 @@ export async function spawnAcpDirect( targetAgentId, runtimeMode, resumeSessionId: params.resumeSessionId, - model: params.model, - thinking: params.thinking, - runTimeoutSeconds: params.runTimeoutSeconds, + runtimeOptions: runtimeOptionsResult.runtimeOptions, cwd: runtimeCwd, }); initializedRuntime = initializedSession.runtimeCloseHandle; diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 826b1141ca2..8b4483d3f23 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -323,12 +323,13 @@ function resolveAllowedFallbacks(params: { cfg: OpenClawConfig; agentId?: string export function resolveSubagentConfiguredModelSelection(params: { cfg: OpenClawConfig; agentId: string; + includeAgentPrimary?: boolean; }): string | undefined { const agentConfig = resolveAgentConfig(params.cfg, params.agentId); return ( normalizeModelSelection(agentConfig?.subagents?.model) ?? normalizeModelSelection(params.cfg.agents?.defaults?.subagents?.model) ?? - normalizeModelSelection(agentConfig?.model) + (params.includeAgentPrimary === false ? undefined : normalizeModelSelection(agentConfig?.model)) ); } @@ -361,12 +362,16 @@ export function resolveSubagentSpawnModelSelection(params: { cfg: params.cfg, agentId: params.agentId, }); + const configured = resolveConfiguredSubagentSpawnModelSelection({ + cfg: params.cfg, + agentId: params.agentId, + modelOverride: params.modelOverride, + defaultProvider: runtimeDefault.provider, + }); + if (configured) { + return configured; + } const raw = - normalizeModelSelection(params.modelOverride) ?? - resolveSubagentConfiguredModelSelection({ - cfg: params.cfg, - agentId: params.agentId, - }) ?? normalizeModelSelection(resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model)) ?? `${runtimeDefault.provider}/${runtimeDefault.model}`; const aliasIndex = buildModelAliasIndex({ @@ -376,6 +381,36 @@ export function resolveSubagentSpawnModelSelection(params: { return resolveModelThroughAliases(raw, aliasIndex); } +export function resolveConfiguredSubagentSpawnModelSelection(params: { + cfg: OpenClawConfig; + agentId: string; + modelOverride?: unknown; + defaultProvider?: string; + includeAgentPrimary?: boolean; +}): string | undefined { + const raw = + normalizeModelSelection(params.modelOverride) ?? + resolveSubagentConfiguredModelSelection({ + cfg: params.cfg, + agentId: params.agentId, + includeAgentPrimary: params.includeAgentPrimary, + }); + if (!raw) { + return undefined; + } + const defaultProvider = + normalizeOptionalString(params.defaultProvider) ?? + resolveDefaultModelForAgent({ + cfg: params.cfg, + agentId: params.agentId, + }).provider; + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider, + }); + return resolveModelThroughAliases(raw, aliasIndex); +} + export function buildAllowedModelSet( params: { cfg: OpenClawConfig; diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts index 9a9be8495eb..0c03d2b8d59 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; +import { resolveConfiguredSubagentSpawnModelSelection } from "./model-selection.js"; import { resolveConfiguredSubagentRunTimeoutSeconds, resolveSubagentModelAndThinkingPlan, @@ -98,6 +99,23 @@ describe("subagent spawn model + thinking plan", () => { expect(plan.initialSessionPatch.modelOverrideSource).toBe("auto"); }); + it("can resolve only explicit or configured subagent model selections", () => { + expect( + resolveConfiguredSubagentSpawnModelSelection({ + cfg: createConfig(), + agentId: "research", + }), + ).toBeUndefined(); + expect( + resolveConfiguredSubagentSpawnModelSelection({ + cfg: createConfig({ + agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.7" } } }, + }), + agentId: "research", + }), + ).toBe("minimax/MiniMax-M2.7"); + }); + it("prefers per-agent subagent model over defaults", () => { const cfg = createConfig({ agents: {