fix: gate acp spawn affordances

This commit is contained in:
Peter Steinberger
2026-04-26 00:30:02 +01:00
parent d228463120
commit 12c16576cd
17 changed files with 368 additions and 78 deletions

View File

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

View File

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

View File

@@ -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 "<id>" is not allowed by policy` | Agent not in allowlist. | Use allowed `agentId` or update `acp.allowedAgents`. |

View File

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

View File

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

View File

@@ -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),

View File

@@ -303,6 +303,7 @@ export function createOpenClawTools(
agentGroupSpace: options?.agentGroupSpace,
agentMemberRoleIds: options?.agentMemberRoleIds,
sandboxed: options?.sandboxed,
config: resolvedConfig,
requesterAgentIdOverride: options?.requesterAgentIdOverride,
workspaceDir: spawnWorkspaceDir,
}),

View File

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

View File

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

View File

@@ -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,
});

View File

@@ -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(" ");
}

View File

@@ -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",

View File

@@ -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<void> {
}
}
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<string, unknown>;
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",

View File

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

View File

@@ -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") {

View File

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

View File

@@ -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.");
});