Map ACP thinking to advertised effort key

This commit is contained in:
Dan O'Brien
2026-05-09 14:36:39 -04:00
committed by Peter Steinberger
parent ac482047f5
commit ce7053005b
5 changed files with 380 additions and 30 deletions

View File

@@ -790,15 +790,15 @@ roots.
`/acp` has convenience commands and a generic setter. Equivalent
operations:
| Command | Maps to | Notes |
| ---------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/acp model <id>` | runtime config key `model` | For Codex ACP, OpenClaw normalizes `openai-codex/<model>` to the adapter model id and maps slash reasoning suffixes such as `openai-codex/gpt-5.4/high` to `reasoning_effort`. |
| `/acp set thinking <level>` | runtime config key `thinking` | For Codex ACP, OpenClaw sends the corresponding `reasoning_effort` where the adapter supports one. |
| `/acp permissions <profile>` | runtime config key `approval_policy` | - |
| `/acp timeout <seconds>` | runtime config key `timeout` | - |
| `/acp cwd <path>` | runtime cwd override | Direct update. |
| `/acp set <key> <value>` | generic | `key=cwd` uses the cwd override path. |
| `/acp reset-options` | clears all runtime overrides | - |
| Command | Maps to | Notes |
| ---------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/acp model <id>` | runtime config key `model` | For Codex ACP, OpenClaw normalizes `openai-codex/<model>` to the adapter model id and maps slash reasoning suffixes such as `openai-codex/gpt-5.4/high` to `reasoning_effort`. |
| `/acp set thinking <level>` | 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 <profile>` | canonical option `permissionProfile` | OpenClaw sends the backend-advertised equivalent when present, such as `approval_policy`, `permission_profile`, `permissions`, or `permission_mode`. |
| `/acp timeout <seconds>` | canonical option `timeoutSeconds` | OpenClaw sends the backend-advertised equivalent when present, such as `timeout` or `timeout_seconds`. |
| `/acp cwd <path>` | runtime cwd override | Direct update. |
| `/acp set <key> <value>` | generic | `key=cwd` uses the cwd override path. |
| `/acp reset-options` | clears all runtime overrides | - |
## acpx harness, plugin setup, and permissions

View File

@@ -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<AcpRuntimeCapabilities> {
return await resolveManagerRuntimeCapabilities(params);
}

View File

@@ -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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<AcpRuntimeCapabilities> {
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}".`,

View File

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

View File

@@ -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<string, string>();
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<string, string> {
const advertisedKeys = new Map<string, string>();
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"
) {