diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6505718f2..4da388df8ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,9 @@ Docs: https://docs.openclaw.ai directly for owner-authorized senders instead of returning `cronParams` and relying on a follow-up generic `cron` tool call. Fixes #70865. (#70937) Thanks @GaosCode. +- Agents/ACP: hide `sessions_spawn` ACP runtime options unless an ACP backend is + loaded, and make `/acp doctor` call out `plugins.allow` blocking bundled + `acpx`. Thanks @vincentkoc. - Agents/subagents: keep queued subagent announces session-only when the requester has no external channel target, avoiding ambiguous multi-channel delivery failures. Fixes #59201. Thanks @larrylhollan. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 6e4c27b2d2e..eab8453bf1d 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -958,7 +958,7 @@ Notes: ```json5 { acp: { - enabled: false, + enabled: true, dispatch: { enabled: true }, backend: "acpx", defaultAgent: "main", @@ -982,9 +982,10 @@ Notes: } ``` -- `enabled`: global ACP feature gate (default: `false`). +- `enabled`: global ACP feature gate (default: `true`; set `false` to hide ACP dispatch and spawn affordances). - `dispatch.enabled`: independent gate for ACP session turn dispatch (default: `true`). Set `false` to keep ACP commands available while blocking execution. - `backend`: default ACP runtime backend id (must match a registered ACP runtime plugin). + If `plugins.allow` is set, include the backend plugin id (for example `acpx`) or the bundled default plugin will not load. - `defaultAgent`: fallback ACP target agent id when spawns do not specify an explicit target. - `allowedAgents`: allowlist of agent ids permitted for ACP runtime sessions; empty means no additional restriction. - `maxConcurrentSessions`: maximum concurrently active ACP sessions. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index a7fa215743a..3ce29df5aad 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -37,6 +37,7 @@ Usually, yes. Fresh installs ship the bundled `acpx` runtime plugin enabled by d First-run gotchas: +- If `plugins.allow` is set, it is a restrictive plugin inventory and must include `acpx`; otherwise the bundled default is intentionally blocked and `/acp doctor` reports the missing allowlist entry. - Target harness adapters (Codex, Claude, etc.) may be fetched on demand with `npx` the first time you use them. - Vendor auth still has to exist on the host for that harness. - If the host has no npm or network access, first-run adapter fetches fail until caches are pre-warmed or the adapter is installed another way. @@ -78,12 +79,14 @@ Natural-language triggers that should route to the ACP runtime: OpenClaw picks `runtime: "acp"`, resolves the harness `agentId`, binds to the current conversation or thread when supported, and routes follow-ups to that session until close/expiry. Codex only follows this path when ACP is explicit or the requested background runtime still needs ACP. -For `sessions_spawn`, `runtime: "acp"` targets ACP harness ids such as `codex`, -`claude`, `gemini`, or `opencode`. Do not pass a normal OpenClaw config agent -id from `agents_list` unless that entry is explicitly configured with -`agents.list[].runtime.type="acp"`; otherwise use the default sub-agent runtime. -When an OpenClaw agent is configured with `runtime.type="acp"`, OpenClaw uses -`runtime.acp.agent` as the underlying harness id. +For `sessions_spawn`, `runtime: "acp"` is advertised only when ACP is enabled, +the requester is not sandboxed, and an ACP runtime backend is loaded. It targets +ACP harness ids such as `codex`, `claude`, `gemini`, or `opencode`. Do not pass +a normal OpenClaw config agent id from `agents_list` unless that entry is +explicitly configured with `agents.list[].runtime.type="acp"`; otherwise use +the default sub-agent runtime. When an OpenClaw agent is configured with +`runtime.type="acp"`, OpenClaw uses `runtime.acp.agent` as the underlying +harness id. ## ACP versus sub-agents @@ -551,7 +554,7 @@ plugin-tools and OpenClaw-tools MCP bridges, and ACP permission modes, see | Symptom | Likely cause | Fix | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `ACP runtime backend is not configured` | Backend plugin missing or disabled. | Install and enable backend plugin, then run `/acp doctor`. | +| `ACP runtime backend is not configured` | Backend plugin missing, disabled, or blocked by `plugins.allow`. | Install and enable backend plugin, include `acpx` in `plugins.allow` when that allowlist is set, then run `/acp doctor`. | | `ACP is disabled by policy (acp.enabled=false)` | ACP globally disabled. | Set `acp.enabled=true`. | | `ACP dispatch is disabled by policy (acp.dispatch.enabled=false)` | Dispatch from normal thread messages disabled. | Set `acp.dispatch.enabled=true`. | | `ACP agent "" is not allowed by policy` | Agent not in allowlist. | Use allowed `agentId` or update `acp.allowedAgents`. | diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 899be1c9df6..9c9132c965e 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -60,7 +60,7 @@ transcript path on disk when you need the raw full transcript. - `--model` and `--thinking` override defaults for that specific run. - Use `info`/`log` to inspect details and output after completion. - `/subagents spawn` is one-shot mode (`mode: "run"`). For persistent thread-bound sessions, use `sessions_spawn` with `thread: true` and `mode: "session"`. -- For ACP harness sessions (Codex, Claude Code, Gemini CLI, OpenCode), use `sessions_spawn` with `runtime: "acp"` and see [ACP Agents](/tools/acp-agents), especially the [ACP delivery model](/tools/acp-agents#delivery-model) when debugging completions or agent-to-agent loops. `runtime: "acp"` expects an external ACP harness id, or an `agents.list[]` entry with `runtime.type="acp"`; use the default sub-agent runtime for normal OpenClaw config agents from `agents_list`. +- For ACP harness sessions (Codex, Claude Code, Gemini CLI, OpenCode), use `sessions_spawn` with `runtime: "acp"` when the tool advertises that runtime, and see [ACP Agents](/tools/acp-agents), especially the [ACP delivery model](/tools/acp-agents#delivery-model) when debugging completions or agent-to-agent loops. OpenClaw hides `runtime: "acp"` until ACP is enabled, the requester is not sandboxed, and a backend plugin such as `acpx` is loaded. `runtime: "acp"` expects an external ACP harness id, or an `agents.list[]` entry with `runtime.type="acp"`; use the default sub-agent runtime for normal OpenClaw config agents from `agents_list`. Primary goals: diff --git a/src/acp/runtime/availability.ts b/src/acp/runtime/availability.ts new file mode 100644 index 00000000000..32a4bed6bd6 --- /dev/null +++ b/src/acp/runtime/availability.ts @@ -0,0 +1,28 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isAcpEnabledByPolicy } from "../policy.js"; +import { getAcpRuntimeBackend } from "./registry.js"; + +export function isAcpRuntimeSpawnAvailable(params: { + config?: OpenClawConfig; + sandboxed?: boolean; + backendId?: string; +}): boolean { + if (params.sandboxed === true) { + return false; + } + if (params.config && !isAcpEnabledByPolicy(params.config)) { + return false; + } + const backend = getAcpRuntimeBackend(params.backendId ?? params.config?.acp?.backend); + if (!backend) { + return false; + } + if (!backend.healthy) { + return true; + } + try { + return backend.healthy(); + } catch { + return false; + } +} diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index a2123200198..4a1b550ea65 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ImageContent } from "@mariozechner/pi-ai"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; +import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { CliBackendConfig } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -111,7 +112,7 @@ export function buildSystemPrompt(params: { heartbeatPrompt: params.heartbeatPrompt, docsPath: params.docsPath, sourcePath: params.sourcePath, - acpEnabled: params.config?.acp?.enabled !== false, + acpEnabled: isAcpRuntimeSpawnAvailable({ config: params.config }), runtimeInfo, toolNames: params.tools.map((tool) => tool.name), modelAliasLines: buildModelAliasLines(params.config), diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index e3055ba0a5b..23fab6de195 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -303,6 +303,7 @@ export function createOpenClawTools( agentGroupSpace: options?.agentGroupSpace, agentMemberRoleIds: options?.agentMemberRoleIds, sandboxed: options?.sandboxed, + config: resolvedConfig, requesterAgentIdOverride: options?.requesterAgentIdOverride, workspaceDir: spawnWorkspaceDir, }), diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 0bcaddf4a6e..85491675fbd 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -7,6 +7,7 @@ import { estimateTokens, SessionManager, } from "@mariozechner/pi-coding-agent"; +import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -763,7 +764,10 @@ export async function compactEmbeddedPiSessionDirect( sourcePath: openClawReferences.sourcePath ?? undefined, ttsHint, promptMode, - acpEnabled: params.config?.acp?.enabled !== false, + acpEnabled: isAcpRuntimeSpawnAvailable({ + config: params.config, + sandboxed: sandboxInfo?.enabled === true, + }), runtimeInfo, reactionGuidance, messageToolHints, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 5092e4142c9..b23e3bf93ee 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -7,6 +7,7 @@ import { DefaultResourceLoader, SessionManager, } from "@mariozechner/pi-coding-agent"; +import { isAcpRuntimeSpawnAvailable } from "../../../acp/runtime/availability.js"; import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import { emitTrustedDiagnosticEvent } from "../../../infra/diagnostic-events.js"; @@ -1117,7 +1118,10 @@ export async function runEmbeddedAttempt( workspaceNotes: workspaceNotes?.length ? workspaceNotes : undefined, reactionGuidance, promptMode: effectivePromptMode, - acpEnabled: params.config?.acp?.enabled !== false, + acpEnabled: isAcpRuntimeSpawnAvailable({ + config: params.config, + sandboxed: sandboxInfo?.enabled === true, + }), runtimeInfo, messageToolHints, sandboxInfo, diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 9710cee6726..cc2d08452e3 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; +import { isAcpRuntimeSpawnAvailable } from "../acp/runtime/availability.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { SubagentSpawnPreparation } from "../context-engine/types.js"; @@ -863,7 +864,10 @@ export async function spawnSubagentDirect( childSessionKey, label: label || undefined, task, - acpEnabled: cfg.acp?.enabled !== false && !childRuntime.sandboxed, + acpEnabled: isAcpRuntimeSpawnAvailable({ + config: cfg, + sandboxed: childRuntime.sandboxed, + }), childDepth, maxSpawnDepth, }); diff --git a/src/agents/tool-description-presets.ts b/src/agents/tool-description-presets.ts index b59095f53ed..f17eac45de4 100644 --- a/src/agents/tool-description-presets.ts +++ b/src/agents/tool-description-presets.ts @@ -7,6 +7,7 @@ export const SESSIONS_HISTORY_TOOL_DISPLAY_SUMMARY = "Read sanitized message history for a visible session."; export const SESSIONS_SEND_TOOL_DISPLAY_SUMMARY = "Send a message to another visible session."; export const SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY = "Spawn sub-agent or ACP sessions."; +export const SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY = "Spawn sub-agent sessions."; export const SESSION_STATUS_TOOL_DISPLAY_SUMMARY = "Show session status, usage, and model state."; export const UPDATE_PLAN_TOOL_DISPLAY_SUMMARY = "Track a short structured work plan."; @@ -31,14 +32,28 @@ export function describeSessionsSendTool(): string { ].join(" "); } -export function describeSessionsSpawnTool(): string { - return [ +export function describeSessionsSpawnTool(options?: { acpAvailable?: boolean }): string { + const baseDescription = [ 'Spawn a clean isolated session by default with `runtime="subagent"` or `runtime="acp"`.', '`mode="run"` is one-shot and `mode="session"` is persistent or thread-bound.', "Subagents inherit the parent workspace directory automatically.", - '`runtime="acp"` is for external ACP harness ids such as codex, claude, gemini, or opencode, or agents configured with `agents.list[].runtime.type="acp"`.', 'For native subagents only, set `context="fork"` when the child needs the current transcript context; otherwise omit it or use `context="isolated"`.', "Use this when the work should happen in a fresh child session instead of the current one.", + ]; + if (options?.acpAvailable === false) { + return baseDescription + .map((line) => + line.replace( + ' with `runtime="subagent"` or `runtime="acp"`', + " with the native subagent runtime", + ), + ) + .join(" "); + } + return [ + ...baseDescription.slice(0, 3), + '`runtime="acp"` is for external ACP harness ids such as codex, claude, gemini, or opencode, or agents configured with `agents.list[].runtime.type="acp"`.', + ...baseDescription.slice(3), ].join(" "); } diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index cb5bd843bfe..7f43549b3f6 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -29,13 +29,16 @@ vi.mock("../subagent-registry.js", () => ({ })); let createSessionsSpawnTool: typeof import("./sessions-spawn-tool.js").createSessionsSpawnTool; +let acpRuntimeRegistry: typeof import("../../acp/runtime/registry.js"); describe("sessions_spawn tool", () => { beforeAll(async () => { ({ createSessionsSpawnTool } = await import("./sessions-spawn-tool.js")); + acpRuntimeRegistry = await import("../../acp/runtime/registry.js"); }); beforeEach(() => { + acpRuntimeRegistry.__testing.resetAcpRuntimeBackendsForTests(); hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({ status: "accepted", childSessionKey: "agent:main:subagent:1", @@ -49,6 +52,114 @@ describe("sessions_spawn tool", () => { hoisted.registerSubagentRunMock.mockReset(); }); + function registerAcpBackendForTest() { + acpRuntimeRegistry.registerAcpRuntimeBackend({ + id: "acpx", + runtime: { + ensureSession: vi.fn(async () => ({ + sessionKey: "agent:codex:acp:1", + backend: "acpx", + runtimeSessionName: "codex", + })), + async *runTurn() {}, + cancel: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }, + }); + } + + it("hides ACP runtime affordances when no ACP backend is loaded", () => { + const tool = createSessionsSpawnTool(); + const schema = tool.parameters as { + properties?: { + runtime?: { enum?: string[] }; + resumeSessionId?: unknown; + streamTo?: unknown; + }; + }; + + expect(tool.displaySummary).toBe("Spawn sub-agent sessions."); + expect(tool.description).not.toContain("ACP"); + expect(tool.description).not.toContain('runtime="acp"'); + expect(schema.properties?.runtime?.enum).toEqual(["subagent"]); + expect(schema.properties?.resumeSessionId).toBeUndefined(); + expect(schema.properties?.streamTo).toBeUndefined(); + }); + + it("advertises ACP runtime affordances when an ACP backend is loaded", () => { + registerAcpBackendForTest(); + + const tool = createSessionsSpawnTool(); + const schema = tool.parameters as { + properties?: { + runtime?: { enum?: string[] }; + resumeSessionId?: unknown; + streamTo?: unknown; + }; + }; + + expect(tool.displaySummary).toBe("Spawn sub-agent or ACP sessions."); + expect(tool.description).toContain('runtime="acp"'); + expect(schema.properties?.runtime?.enum).toEqual(["subagent", "acp"]); + expect(schema.properties?.resumeSessionId).toBeDefined(); + expect(schema.properties?.streamTo).toBeDefined(); + }); + + it("hides ACP runtime affordances when the ACP backend is unhealthy", () => { + acpRuntimeRegistry.registerAcpRuntimeBackend({ + id: "acpx", + healthy: () => false, + runtime: { + ensureSession: vi.fn(async () => ({ + sessionKey: "agent:codex:acp:1", + backend: "acpx", + runtimeSessionName: "codex", + })), + async *runTurn() {}, + cancel: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }, + }); + + const tool = createSessionsSpawnTool(); + const schema = tool.parameters as { properties?: { runtime?: { enum?: string[] } } }; + + expect(tool.description).not.toContain("ACP"); + expect(schema.properties?.runtime?.enum).toEqual(["subagent"]); + }); + + it("rejects stale ACP runtime calls when no ACP backend is loaded", async () => { + const tool = createSessionsSpawnTool(); + + const result = await tool.execute("call-acp-unavailable", { + runtime: "acp", + task: "investigate", + agentId: "codex", + }); + + expect(result.details).toMatchObject({ + status: "error", + role: "codex", + }); + expect(JSON.stringify(result.details)).toContain("no ACP runtime backend is loaded"); + expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled(); + expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled(); + }); + + it("hides ACP runtime affordances when ACP policy is disabled", () => { + registerAcpBackendForTest(); + + const tool = createSessionsSpawnTool({ + config: { + acp: { enabled: false }, + }, + }); + const schema = tool.parameters as { properties?: { runtime?: { enum?: string[] } } }; + + expect(tool.description).not.toContain("ACP"); + expect(schema.properties?.runtime?.enum).toEqual(["subagent"]); + }); + it("uses subagent runtime by default", async () => { const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", @@ -191,6 +302,7 @@ describe("sessions_spawn tool", () => { }); it('rejects lightContext when runtime is not "subagent"', async () => { + registerAcpBackendForTest(); const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", }); @@ -208,6 +320,7 @@ describe("sessions_spawn tool", () => { }); it("routes to ACP runtime when runtime=acp", async () => { + registerAcpBackendForTest(); const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", agentChannel: "quietchat", @@ -251,6 +364,7 @@ describe("sessions_spawn tool", () => { }); it("forwards model override to ACP runtime spawns", async () => { + registerAcpBackendForTest(); const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", }); @@ -273,6 +387,7 @@ describe("sessions_spawn tool", () => { }); it("adds requested role to forwarded ACP failures", async () => { + registerAcpBackendForTest(); hoisted.spawnAcpDirectMock.mockResolvedValueOnce({ status: "forbidden", error: "ACP disabled", @@ -296,10 +411,10 @@ describe("sessions_spawn tool", () => { }); }); - it("forwards ACP sandbox options and requester sandbox context", async () => { + it("forwards ACP sandbox options", async () => { + registerAcpBackendForTest(); const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent", - sandboxed: true, }); await tool.execute("call-2b", { @@ -316,7 +431,6 @@ describe("sessions_spawn tool", () => { }), expect.objectContaining({ agentSessionKey: "agent:main:subagent:parent", - sandboxed: true, }), ); expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith( @@ -331,7 +445,29 @@ describe("sessions_spawn tool", () => { ); }); + it("rejects ACP runtime calls from sandboxed requester sessions", async () => { + registerAcpBackendForTest(); + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:subagent:parent", + sandboxed: true, + }); + + const result = await tool.execute("call-sandboxed-acp", { + runtime: "acp", + task: "investigate", + agentId: "codex", + }); + + expect(result.details).toMatchObject({ + status: "error", + role: "codex", + }); + expect(JSON.stringify(result.details)).toContain("sandboxed sessions"); + expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled(); + }); + it("passes resumeSessionId through to ACP spawns", async () => { + registerAcpBackendForTest(); const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", }); @@ -369,6 +505,7 @@ describe("sessions_spawn tool", () => { }); it("rejects attachments for ACP runtime", async () => { + registerAcpBackendForTest(); const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main", agentChannel: "quietchat", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 576da3abc53..7b98cc32843 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -1,5 +1,7 @@ import { Type } from "typebox"; +import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; import { loadConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; @@ -13,6 +15,7 @@ import { } from "../subagent-spawn.js"; import { describeSessionsSpawnTool, + SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY, SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY, } from "../tool-description-presets.js"; import type { AnyAgentTool } from "./common.js"; @@ -97,60 +100,79 @@ async function cleanupUntrackedAcpSession(sessionKey: string): Promise { } } -const SessionsSpawnToolSchema = Type.Object({ - task: Type.String(), - label: Type.Optional(Type.String()), - runtime: optionalStringEnum(SESSIONS_SPAWN_RUNTIMES), - agentId: Type.Optional(Type.String()), - resumeSessionId: Type.Optional( - Type.String({ - description: - 'Resume an existing agent session by its ID (e.g. a Codex session UUID from ~/.codex/sessions/). Requires runtime="acp". The agent replays conversation history via session/load instead of starting fresh.', - }), - ), - model: Type.Optional(Type.String()), - thinking: Type.Optional(Type.String()), - cwd: Type.Optional(Type.String()), - runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), - // Back-compat: older callers used timeoutSeconds for this tool. - timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), - thread: Type.Optional(Type.Boolean()), - mode: optionalStringEnum(SUBAGENT_SPAWN_MODES), - cleanup: optionalStringEnum(["delete", "keep"] as const), - sandbox: optionalStringEnum(SESSIONS_SPAWN_SANDBOX_MODES), - context: optionalStringEnum(SUBAGENT_SPAWN_CONTEXT_MODES, { - description: - 'Native subagent context mode. Omit or use "isolated" for a clean child session; use "fork" only when the child needs the requester transcript context.', - }), - streamTo: optionalStringEnum(SESSIONS_SPAWN_ACP_STREAM_TARGETS), - lightContext: Type.Optional( - Type.Boolean({ - description: - "When true, spawned subagent runs use lightweight bootstrap context. Only applies to runtime='subagent'.", - }), - ), - - // Inline attachments (snapshot-by-value). - // NOTE: Attachment contents are redacted from transcript persistence by sanitizeToolCallInputs. - attachments: Type.Optional( - Type.Array( - Type.Object({ - name: Type.String(), - content: Type.String(), - encoding: Type.Optional(optionalStringEnum(["utf8", "base64"] as const)), - mimeType: Type.Optional(Type.String()), - }), - { maxItems: 50 }, +function createSessionsSpawnToolSchema(params: { acpAvailable: boolean }) { + const schema = { + task: Type.String(), + label: Type.Optional(Type.String()), + runtime: optionalStringEnum( + params.acpAvailable ? SESSIONS_SPAWN_RUNTIMES : (["subagent"] as const), ), - ), - attachAs: Type.Optional( - Type.Object({ - // Where the spawned agent should look for attachments. - // Kept as a hint; implementation materializes into the child workspace. - mountPath: Type.Optional(Type.String()), + agentId: Type.Optional(Type.String()), + model: Type.Optional(Type.String()), + thinking: Type.Optional(Type.String()), + cwd: Type.Optional(Type.String()), + runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), + // Back-compat: older callers used timeoutSeconds for this tool. + timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), + thread: Type.Optional(Type.Boolean()), + mode: optionalStringEnum(SUBAGENT_SPAWN_MODES), + cleanup: optionalStringEnum(["delete", "keep"] as const), + sandbox: optionalStringEnum(SESSIONS_SPAWN_SANDBOX_MODES), + context: optionalStringEnum(SUBAGENT_SPAWN_CONTEXT_MODES, { + description: + 'Native subagent context mode. Omit or use "isolated" for a clean child session; use "fork" only when the child needs the requester transcript context.', }), - ), -}); + lightContext: Type.Optional( + Type.Boolean({ + description: + "When true, spawned subagent runs use lightweight bootstrap context. Only applies to runtime='subagent'.", + }), + ), + + // Inline attachments (snapshot-by-value). + // NOTE: Attachment contents are redacted from transcript persistence by sanitizeToolCallInputs. + attachments: Type.Optional( + Type.Array( + Type.Object({ + name: Type.String(), + content: Type.String(), + encoding: Type.Optional(optionalStringEnum(["utf8", "base64"] as const)), + mimeType: Type.Optional(Type.String()), + }), + { maxItems: 50 }, + ), + ), + attachAs: Type.Optional( + Type.Object({ + // Where the spawned agent should look for attachments. + // Kept as a hint; implementation materializes into the child workspace. + mountPath: Type.Optional(Type.String()), + }), + ), + ...(params.acpAvailable + ? { + resumeSessionId: Type.Optional( + Type.String({ + description: + 'Resume an existing agent session by its ID (e.g. a Codex session UUID from ~/.codex/sessions/). Requires runtime="acp". The agent replays conversation history via session/load instead of starting fresh.', + }), + ), + streamTo: optionalStringEnum(SESSIONS_SPAWN_ACP_STREAM_TARGETS), + } + : {}), + }; + return Type.Object(schema); +} + +function resolveAcpUnavailableMessage(opts?: { sandboxed?: boolean; config?: OpenClawConfig }) { + if (opts?.sandboxed === true) { + return 'runtime="acp" is unavailable from sandboxed sessions because ACP sessions run on the host. Use runtime="subagent".'; + } + if (opts?.config?.acp?.enabled === false) { + return 'runtime="acp" is unavailable because ACP is disabled by policy (`acp.enabled=false`). Use runtime="subagent".'; + } + return 'runtime="acp" is unavailable in this session because no ACP runtime backend is loaded. Enable the acpx plugin or use runtime="subagent".'; +} export function createSessionsSpawnTool( opts?: { @@ -160,16 +182,23 @@ export function createSessionsSpawnTool( agentTo?: string; agentThreadId?: string | number; sandboxed?: boolean; + config?: OpenClawConfig; /** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */ requesterAgentIdOverride?: string; } & SpawnedToolContext, ): AnyAgentTool { + const acpAvailable = isAcpRuntimeSpawnAvailable({ + config: opts?.config, + sandboxed: opts?.sandboxed, + }); return { label: "Sessions", name: "sessions_spawn", - displaySummary: SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY, - description: describeSessionsSpawnTool(), - parameters: SessionsSpawnToolSchema, + displaySummary: acpAvailable + ? SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY + : SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY, + description: describeSessionsSpawnTool({ acpAvailable }), + parameters: createSessionsSpawnToolSchema({ acpAvailable }), execute: async (_toolCallId, args) => { const params = args as Record; const unsupportedParam = UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS.find((key) => @@ -197,6 +226,14 @@ export function createSessionsSpawnTool( params.context === "fork" || params.context === "isolated" ? params.context : undefined; const streamTo = params.streamTo === "parent" ? "parent" : undefined; const lightContext = params.lightContext === true; + const roleContext = requestedAgentId ? { role: requestedAgentId } : {}; + if (runtime === "acp" && !acpAvailable) { + return jsonResult({ + status: "error", + error: resolveAcpUnavailableMessage(opts), + ...roleContext, + }); + } if (runtime === "acp" && lightContext) { throw new Error("lightContext is only supported for runtime='subagent'."); } @@ -224,8 +261,6 @@ export function createSessionsSpawnTool( }>) : undefined; - const roleContext = requestedAgentId ? { role: requestedAgentId } : {}; - if (streamTo && runtime !== "acp") { return jsonResult({ status: "error", diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 1ea6b3edd73..f9405b3bd65 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -1911,6 +1911,25 @@ describe("/acp command", () => { expect(result?.reply?.text).toContain("next:"); }); + it("explains when acpx is blocked by plugins.allow", async () => { + hoisted.getAcpRuntimeBackendMock.mockReturnValue(null); + hoisted.requireAcpRuntimeBackendMock.mockImplementation(() => { + throw new AcpRuntimeError( + "ACP_BACKEND_MISSING", + "ACP runtime backend is not configured. Install and enable the acpx runtime plugin.", + ); + }); + + const result = await runDiscordAcpCommand("/acp doctor", { + ...baseCfg, + plugins: { allow: ["discord"] }, + }); + + expect(result?.reply?.text).toContain("pluginActivation: blocked"); + expect(result?.reply?.text).toContain("acpx"); + expect(result?.reply?.text).toContain('add "acpx" to plugins.allow'); + }); + it("shows deterministic install instructions via /acp install", async () => { const result = await runDiscordAcpCommand("/acp install", baseCfg); diff --git a/src/auto-reply/reply/commands-acp/diagnostics.ts b/src/auto-reply/reply/commands-acp/diagnostics.ts index 1c96d9fde76..a6e1daeae9e 100644 --- a/src/auto-reply/reply/commands-acp/diagnostics.ts +++ b/src/auto-reply/reply/commands-acp/diagnostics.ts @@ -22,6 +22,23 @@ import { } from "./shared.js"; import { resolveBoundAcpThreadSessionKey } from "./targets.js"; +function isBackendPluginBlockedByAllowlist(params: { + cfg: HandleCommandsParams["cfg"]; + backendId: string; +}): boolean { + const allow = params.cfg.plugins?.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return false; + } + const normalizedBackendId = normalizeLowercaseStringOrEmpty(params.backendId); + if (!normalizedBackendId) { + return false; + } + return !allow.some( + (pluginId) => normalizeLowercaseStringOrEmpty(pluginId) === normalizedBackendId, + ); +} + export async function handleAcpDoctorAction( params: HandleCommandsParams, restTokens: string[], @@ -56,6 +73,13 @@ export async function handleAcpDoctorAction( } else { lines.push("registeredBackend: (none)"); } + const backendBlockedByAllowlist = isBackendPluginBlockedByAllowlist({ + cfg: params.cfg, + backendId, + }); + if (backendBlockedByAllowlist) { + lines.push(`pluginActivation: blocked (${backendId} is missing from plugins.allow)`); + } if (registeredBackend?.runtime.doctor) { try { @@ -102,6 +126,9 @@ export async function handleAcpDoctorAction( }); lines.push("healthy: no"); lines.push(formatAcpRuntimeErrorText(acpError)); + if (backendBlockedByAllowlist) { + lines.push(`next: add "${backendId}" to plugins.allow or unset plugins.allow.`); + } lines.push(`next: ${installHint}`); lines.push(`next: openclaw config set plugins.entries.${backendId}.enabled true`); if (normalizeLowercaseStringOrEmpty(backendId) === "acpx") { diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index 2ca45e3c950..ee61da42cf4 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -1,4 +1,5 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; import { resolveSessionAgentIds } from "../../agents/agent-scope.js"; import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js"; import { canExecRequestNode } from "../../agents/exec-defaults.js"; @@ -162,7 +163,10 @@ export async function resolveCommandsSystemPromptBundle( skillsPrompt, heartbeatPrompt: undefined, ttsHint, - acpEnabled: params.cfg?.acp?.enabled !== false, + acpEnabled: isAcpRuntimeSpawnAvailable({ + config: params.cfg, + sandboxed: sandboxRuntime.sandboxed, + }), runtimeInfo, sandboxInfo, memoryCitationsMode: params.cfg?.memory?.citations, diff --git a/src/config/plugin-auto-enable.providers.test.ts b/src/config/plugin-auto-enable.providers.test.ts index 1426d40d1d3..2ba9749931c 100644 --- a/src/config/plugin-auto-enable.providers.test.ts +++ b/src/config/plugin-auto-enable.providers.test.ts @@ -306,6 +306,9 @@ describe("applyPluginAutoEnable providers", () => { acp: { enabled: true, }, + plugins: { + allow: ["telegram"], + }, }, candidates: [ { @@ -317,6 +320,7 @@ describe("applyPluginAutoEnable providers", () => { env, }); + expect(result.config.plugins?.allow).toEqual(["telegram", "acpx"]); expect(result.config.plugins?.entries?.acpx?.enabled).toBe(true); expect(result.changes.join("\n")).toContain("ACP runtime configured, enabled automatically."); });