fix: apply ACP spawn model defaults

This commit is contained in:
Peter Steinberger 2026-05-31 15:07:24 +01:00
parent fdf6092494
commit ff22b1e9e6
No known key found for this signature in database
6 changed files with 281 additions and 24 deletions

View file

@ -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.
</ParamField>
## Spawn bind and thread modes

View file

@ -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.

View file

@ -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(
{

View file

@ -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<AcpSpawnInitializedRuntime> {
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;

View file

@ -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;

View file

@ -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: {