diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index e06cb9e72b1..2ecfa97a5c1 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -790,15 +790,15 @@ roots. `/acp` has convenience commands and a generic setter. Equivalent operations: -| Command | Maps to | Notes | -| ---------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/acp model ` | runtime config key `model` | For Codex ACP, OpenClaw normalizes `openai-codex/` to the adapter model id and maps slash reasoning suffixes such as `openai-codex/gpt-5.4/high` to `reasoning_effort`. | -| `/acp set thinking ` | runtime config key `thinking` | For Codex ACP, OpenClaw sends the corresponding `reasoning_effort` where the adapter supports one. | -| `/acp permissions ` | runtime config key `approval_policy` | - | -| `/acp timeout ` | runtime config key `timeout` | - | -| `/acp cwd ` | runtime cwd override | Direct update. | -| `/acp set ` | generic | `key=cwd` uses the cwd override path. | -| `/acp reset-options` | clears all runtime overrides | - | +| Command | Maps to | Notes | +| ---------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/acp model ` | runtime config key `model` | For Codex ACP, OpenClaw normalizes `openai-codex/` to the adapter model id and maps slash reasoning suffixes such as `openai-codex/gpt-5.4/high` to `reasoning_effort`. | +| `/acp set thinking ` | canonical option `thinking` | OpenClaw sends the backend-advertised equivalent when present, preferring `thinking`, then `effort`, `reasoning_effort`, or `thought_level`. For Codex ACP, the adapter maps values to `reasoning_effort`. | +| `/acp permissions ` | canonical option `permissionProfile` | OpenClaw sends the backend-advertised equivalent when present, such as `approval_policy`, `permission_profile`, `permissions`, or `permission_mode`. | +| `/acp timeout ` | canonical option `timeoutSeconds` | OpenClaw sends the backend-advertised equivalent when present, such as `timeout` or `timeout_seconds`. | +| `/acp cwd ` | runtime cwd override | Direct update. | +| `/acp set ` | generic | `key=cwd` uses the cwd override path. | +| `/acp reset-options` | clears all runtime overrides | - | ## acpx harness, plugin setup, and permissions diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index 3ef47f06b34..52ffe1c9b1a 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -75,6 +75,7 @@ import { mergeRuntimeOptions, normalizeRuntimeOptions, normalizeText, + resolveRuntimeConfigOptionKey, resolveRuntimeOptionsFromMeta, runtimeOptionsEqual, validateRuntimeConfigOptionInput, @@ -588,7 +589,11 @@ export class AcpSessionManager { meta: resolvedMeta, }); const inferredPatch = inferRuntimeOptionPatchFromConfigOption(key, value); - const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle }); + const capabilities = await this.resolveRuntimeCapabilities({ + runtime, + handle, + includeStatusConfigOptionKeys: true, + }); if ( !capabilities.controls.includes("session/set_config_option") || !runtime.setConfigOption @@ -601,13 +606,17 @@ export class AcpSessionManager { const advertisedKeys = new Set( (capabilities.configOptionKeys ?? []) - .map((entry) => normalizeText(entry)) - .filter(Boolean) as string[], + .map((entry) => normalizeLowercaseStringOrEmpty(entry)) + .filter(Boolean), ); - if (advertisedKeys.size > 0 && !advertisedKeys.has(key)) { + const wireKey = resolveRuntimeConfigOptionKey(key, capabilities.configOptionKeys); + if ( + advertisedKeys.size > 0 && + !advertisedKeys.has(normalizeLowercaseStringOrEmpty(wireKey)) + ) { throw new AcpRuntimeError( "ACP_BACKEND_UNSUPPORTED_CONTROL", - `ACP backend "${handle.backend || meta.backend}" does not accept config key "${key}".`, + `ACP backend "${handle.backend || meta.backend}" does not accept config key "${wireKey}".`, ); } @@ -615,7 +624,7 @@ export class AcpSessionManager { run: async () => await runtime.setConfigOption!({ handle, - key, + key: wireKey, value, }), fallbackCode: "ACP_TURN_FAILED", @@ -1879,6 +1888,7 @@ export class AcpSessionManager { private async resolveRuntimeCapabilities(params: { runtime: AcpRuntime; handle: AcpRuntimeHandle; + includeStatusConfigOptionKeys?: boolean; }): Promise { return await resolveManagerRuntimeCapabilities(params); } diff --git a/src/acp/control-plane/manager.runtime-controls.ts b/src/acp/control-plane/manager.runtime-controls.ts index 6c2b9e0a267..91b9773403a 100644 --- a/src/acp/control-plane/manager.runtime-controls.ts +++ b/src/acp/control-plane/manager.runtime-controls.ts @@ -1,5 +1,11 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "../runtime/errors.js"; -import type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeHandle } from "../runtime/types.js"; +import type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeHandle, + AcpRuntimeStatus, +} from "../runtime/types.js"; import type { SessionAcpMeta } from "./manager.types.js"; import { createUnsupportedControlError } from "./manager.utils.js"; import type { CachedRuntimeState } from "./runtime-cache.js"; @@ -10,9 +16,39 @@ import { resolveRuntimeOptionsFromMeta, } from "./runtime-options.js"; +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function extractConfigOptionKeys(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((entry) => { + if (typeof entry === "string") { + return normalizeText(entry); + } + const record = asRecord(entry); + return normalizeText(record?.id ?? record?.key); + }) + .filter(Boolean) as string[]; +} + +function extractRuntimeStatusConfigOptionKeys(status: AcpRuntimeStatus | undefined): string[] { + const details = asRecord(status?.details); + return [ + ...extractConfigOptionKeys(details?.configOptions), + ...extractConfigOptionKeys(details?.config_options), + ]; +} + export async function resolveManagerRuntimeCapabilities(params: { runtime: AcpRuntime; handle: AcpRuntimeHandle; + includeStatusConfigOptionKeys?: boolean; }): Promise { let reported: AcpRuntimeCapabilities | undefined; if (params.runtime.getCapabilities) { @@ -32,12 +68,30 @@ export async function resolveManagerRuntimeCapabilities(params: { if (params.runtime.getStatus) { controls.add("session/status"); } - const normalizedKeys = (reported?.configOptionKeys ?? []) - .map((entry) => normalizeText(entry)) - .filter(Boolean) as string[]; + const normalizedKeys = new Set( + (reported?.configOptionKeys ?? []) + .map((entry) => normalizeText(entry)) + .filter(Boolean) as string[], + ); + if ( + normalizedKeys.size === 0 && + params.includeStatusConfigOptionKeys && + params.runtime.getStatus + ) { + try { + const status = await params.runtime.getStatus({ handle: params.handle }); + for (const key of extractRuntimeStatusConfigOptionKeys(status)) { + normalizedKeys.add(key); + } + } catch { + // Status-derived option keys are an optional refinement. Keep the + // capability result usable for runtimes that expose controls but cannot + // answer status before a turn. + } + } return { controls: [...controls].toSorted(), - ...(normalizedKeys.length > 0 ? { configOptionKeys: normalizedKeys } : {}), + ...(normalizedKeys.size > 0 ? { configOptionKeys: [...normalizedKeys] } : {}), }; } @@ -55,17 +109,19 @@ export async function applyManagerRuntimeControls(params: { return; } + const needsConfigOptionKeys = buildRuntimeConfigOptionPairs(options).length > 0; const capabilities = await resolveManagerRuntimeCapabilities({ runtime: params.runtime, handle: params.handle, + includeStatusConfigOptionKeys: needsConfigOptionKeys, }); const backend = params.handle.backend || params.meta.backend; const runtimeMode = normalizeText(options.runtimeMode); - const configOptions = buildRuntimeConfigOptionPairs(options); + const configOptions = buildRuntimeConfigOptionPairs(options, capabilities.configOptionKeys); const advertisedKeys = new Set( (capabilities.configOptionKeys ?? []) - .map((entry) => normalizeText(entry)) - .filter(Boolean) as string[], + .map((entry) => normalizeLowercaseStringOrEmpty(entry)) + .filter(Boolean), ); await withAcpRuntimeErrorBoundary({ @@ -94,7 +150,10 @@ export async function applyManagerRuntimeControls(params: { }); } for (const [key, value] of configOptions) { - if (advertisedKeys.size > 0 && !advertisedKeys.has(key)) { + if ( + advertisedKeys.size > 0 && + !advertisedKeys.has(normalizeLowercaseStringOrEmpty(key)) + ) { throw new AcpRuntimeError( "ACP_BACKEND_UNSUPPORTED_CONTROL", `ACP backend "${backend}" does not accept config key "${key}".`, diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 16cb9317712..627df68ecdb 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -3005,6 +3005,117 @@ describe("AcpSessionManager", () => { ); }); + it("maps persisted thinking runtime options to advertised effort config keys before running turns", async () => { + const runtimeState = createRuntime(); + runtimeState.getCapabilities.mockResolvedValue({ + controls: ["session/set_mode", "session/set_config_option", "session/status"], + configOptionKeys: ["mode", "model", "effort"], + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:claude:acp:session-1", + storeSessionKey: "agent:claude:acp:session-1", + acp: { + ...readySessionMeta({ agent: "claude" }), + runtimeOptions: { + thinking: "high", + }, + }, + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:claude:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }); + + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "effort", + value: "high", + }), + ); + expect(runtimeState.setConfigOption).not.toHaveBeenCalledWith( + expect.objectContaining({ + key: "thinking", + }), + ); + }); + + it("maps persisted runtime options to backend-advertised aliases before running turns", async () => { + const runtimeState = createRuntime(); + runtimeState.getCapabilities.mockResolvedValue({ + controls: ["session/set_config_option", "session/status"], + configOptionKeys: ["model", "thought_level", "permissions", "timeout_seconds"], + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:gemini:acp:session-1", + storeSessionKey: "agent:gemini:acp:session-1", + acp: { + ...readySessionMeta({ agent: "gemini" }), + runtimeOptions: { + model: "gemini-3-flash-preview", + thinking: "high", + permissionProfile: "strict", + timeoutSeconds: 120, + }, + }, + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey: "agent:gemini:acp:session-1", + text: "do work", + mode: "prompt", + requestId: "run-1", + }); + + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "thought_level", + value: "high", + }), + ); + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "permissions", + value: "strict", + }), + ); + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "timeout_seconds", + value: "120", + }), + ); + expect(runtimeState.setConfigOption).not.toHaveBeenCalledWith( + expect.objectContaining({ + key: "thinking", + }), + ); + expect(runtimeState.setConfigOption).not.toHaveBeenCalledWith( + expect.objectContaining({ + key: "approval_policy", + }), + ); + expect(runtimeState.setConfigOption).not.toHaveBeenCalledWith( + expect.objectContaining({ + key: "timeout", + }), + ); + }); + it("re-ensures runtime handles after cwd runtime option updates", async () => { const runtimeState = createRuntime(); hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ @@ -3110,6 +3221,111 @@ describe("AcpSessionManager", () => { }); }); + it("maps explicit thinking config updates to advertised effort keys", async () => { + const runtimeState = createRuntime(); + runtimeState.getCapabilities.mockResolvedValue({ + controls: ["session/set_config_option", "session/status"], + configOptionKeys: ["effort"], + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:claude:acp:session-1", + storeSessionKey: "agent:claude:acp:session-1", + acp: readySessionMeta({ agent: "claude" }), + }); + + const manager = new AcpSessionManager(); + const nextOptions = await manager.setSessionConfigOption({ + cfg: baseCfg, + sessionKey: "agent:claude:acp:session-1", + key: "thinking", + value: "high", + }); + + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "effort", + value: "high", + }), + ); + expect(nextOptions).toEqual({ thinking: "high" }); + }); + + it("maps thinking config updates using status config options when capabilities omit keys", async () => { + const runtimeState = createRuntime(); + runtimeState.getCapabilities.mockResolvedValue({ + controls: ["session/set_config_option", "session/status"], + }); + runtimeState.getStatus.mockResolvedValue({ + summary: "status=alive", + details: { + configOptions: [{ id: "mode" }, { id: "model" }, { id: "effort" }], + }, + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:claude:acp:session-1", + storeSessionKey: "agent:claude:acp:session-1", + acp: readySessionMeta({ agent: "claude" }), + }); + + const manager = new AcpSessionManager(); + const nextOptions = await manager.setSessionConfigOption({ + cfg: baseCfg, + sessionKey: "agent:claude:acp:session-1", + key: "thinking", + value: "high", + }); + + expect(runtimeState.getStatus).toHaveBeenCalled(); + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "effort", + value: "high", + }), + ); + expect(nextOptions).toEqual({ thinking: "high" }); + }); + + it("persists explicit native effort config updates as canonical thinking options", async () => { + const runtimeState = createRuntime(); + runtimeState.getCapabilities.mockResolvedValue({ + controls: ["session/set_config_option", "session/status"], + configOptionKeys: ["effort"], + }); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:claude:acp:session-1", + storeSessionKey: "agent:claude:acp:session-1", + acp: readySessionMeta({ agent: "claude" }), + }); + + const manager = new AcpSessionManager(); + const nextOptions = await manager.setSessionConfigOption({ + cfg: baseCfg, + sessionKey: "agent:claude:acp:session-1", + key: "effort", + value: "high", + }); + + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "effort", + value: "high", + }), + ); + expect(nextOptions).toEqual({ thinking: "high" }); + }); + it("rejects invalid runtime option values before backend controls run", async () => { const runtimeState = createRuntime(); hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ diff --git a/src/acp/control-plane/runtime-options.ts b/src/acp/control-plane/runtime-options.ts index 41f28e9eb04..b6293ec3342 100644 --- a/src/acp/control-plane/runtime-options.ts +++ b/src/acp/control-plane/runtime-options.ts @@ -18,6 +18,12 @@ const MAX_BACKEND_OPTION_VALUE_LENGTH = 512; const MAX_BACKEND_EXTRAS = 32; const SAFE_OPTION_KEY_RE = /^[a-z0-9][a-z0-9._:-]*$/i; +const RUNTIME_CONFIG_OPTION_ALIASES = { + model: ["model"], + thinking: ["thinking", "effort", "reasoning_effort", "thought_level"], + permissionProfile: ["approval_policy", "permission_profile", "permissions", "permission_mode"], + timeoutSeconds: ["timeout", "timeout_seconds"], +} as const; function failInvalidOption(message: string): never { throw new AcpRuntimeError("ACP_INVALID_RUNTIME_OPTION", message); @@ -315,29 +321,87 @@ export function buildRuntimeControlSignature(options: AcpSessionRuntimeOptions): export function buildRuntimeConfigOptionPairs( options: AcpSessionRuntimeOptions, + advertisedConfigOptionKeys?: readonly string[], ): Array<[string, string]> { const normalized = normalizeRuntimeOptions(options); const pairs = new Map(); if (normalized.model) { - pairs.set("model", normalized.model); + pairs.set(resolveRuntimeConfigOptionKey("model", advertisedConfigOptionKeys), normalized.model); } if (normalized.thinking) { - pairs.set("thinking", normalized.thinking); + pairs.set( + resolveRuntimeConfigOptionKey("thinking", advertisedConfigOptionKeys), + normalized.thinking, + ); } if (normalized.permissionProfile) { - pairs.set("approval_policy", normalized.permissionProfile); + pairs.set( + resolveRuntimeConfigOptionKey("approval_policy", advertisedConfigOptionKeys), + normalized.permissionProfile, + ); } if (typeof normalized.timeoutSeconds === "number") { - pairs.set("timeout", String(normalized.timeoutSeconds)); + pairs.set( + resolveRuntimeConfigOptionKey("timeout", advertisedConfigOptionKeys), + String(normalized.timeoutSeconds), + ); } for (const [key, value] of Object.entries(normalized.backendExtras ?? {})) { - if (!pairs.has(key)) { - pairs.set(key, value); + const wireKey = resolveRuntimeConfigOptionKey(key, advertisedConfigOptionKeys); + if (!pairs.has(wireKey)) { + pairs.set(wireKey, value); } } return [...pairs.entries()]; } +function buildAdvertisedConfigOptionKeyMap( + advertisedConfigOptionKeys?: readonly string[], +): Map { + const advertisedKeys = new Map(); + for (const rawKey of advertisedConfigOptionKeys ?? []) { + const key = normalizeText(rawKey); + const normalizedKey = normalizeLowercaseStringOrEmpty(key); + if (key && normalizedKey && !advertisedKeys.has(normalizedKey)) { + advertisedKeys.set(normalizedKey, key); + } + } + return advertisedKeys; +} + +function resolveRuntimeConfigOptionAliases(key: string): readonly string[] { + const normalizedKey = normalizeLowercaseStringOrEmpty(key); + for (const aliases of Object.values(RUNTIME_CONFIG_OPTION_ALIASES)) { + if (aliases.some((alias) => normalizeLowercaseStringOrEmpty(alias) === normalizedKey)) { + return aliases; + } + } + return [key]; +} + +export function resolveRuntimeConfigOptionKey( + key: string, + advertisedConfigOptionKeys?: readonly string[], +): string { + const normalizedKey = normalizeText(key) ?? ""; + const normalizedLookupKey = normalizeLowercaseStringOrEmpty(normalizedKey); + const advertisedKeys = buildAdvertisedConfigOptionKeyMap(advertisedConfigOptionKeys); + if (!normalizedKey || advertisedKeys.size === 0) { + return normalizedKey; + } + const exactAdvertisedKey = advertisedKeys.get(normalizedLookupKey); + if (exactAdvertisedKey) { + return exactAdvertisedKey; + } + for (const alias of resolveRuntimeConfigOptionAliases(normalizedKey)) { + const advertisedAlias = advertisedKeys.get(normalizeLowercaseStringOrEmpty(alias)); + if (advertisedAlias) { + return advertisedAlias; + } + } + return normalizedKey; +} + export function inferRuntimeOptionPatchFromConfigOption( key: string, value: string, @@ -349,6 +413,7 @@ export function inferRuntimeOptionPatchFromConfigOption( } if ( normalizedKey === "thinking" || + normalizedKey === "effort" || normalizedKey === "thought_level" || normalizedKey === "reasoning_effort" ) {