diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e91f0745f3..b2f0a816216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,9 @@ Docs: https://docs.openclaw.ai - ACP/sessions_spawn: reject normal OpenClaw config agent ids when callers explicitly request `runtime="acp"`, while allowing agents configured with `runtime.type="acp"` to resolve to their ACP harness id. Fixes #63914. +- ACP/sessions_spawn: apply `runTimeoutSeconds` to ACP child turns and dispatch + those turns on the background subagent lane, so quota-stalled ACP harnesses do + not occupy the main agent lane indefinitely. Fixes #68823. - ACP/models: document that non-Codex ACP model overrides require adapter support for ACP `models` plus `session/set_model`, so unsupported harnesses fail clearly instead of silently falling back to their defaults. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 4b6ceda4cbb..a7fa215743a 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -336,6 +336,7 @@ Interface details: - `resumeSessionId` (optional): resume an existing ACP session instead of creating a new one. The agent replays its conversation history via `session/load`. Requires `runtime: "acp"`. - `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events. - When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`.acp-stream.jsonl`) you can tail for full relay history. +- `runTimeoutSeconds` (optional): aborts the ACP child turn after N seconds. `0` keeps the turn on the gateway's no-timeout path. The same value is applied to the Gateway run and ACP runtime so stalled/quota-exhausted harnesses do not occupy the parent agent lane indefinitely. - `model` (optional): explicit model override for the ACP child session. Codex ACP spawns normalize OpenClaw Codex refs such as `openai-codex/gpt-5.4` to Codex ACP startup config before `session/new`; slash forms such as `openai-codex/gpt-5.4/high` also set Codex ACP reasoning effort. Other harnesses must advertise ACP `models` and support `session/set_model`; otherwise OpenClaw/acpx fails clearly instead of silently falling back to the target agent default. - `thinking` (optional): explicit thinking/reasoning effort for the ACP child session. For Codex ACP, `minimal` maps to low effort, `low`/`medium`/`high`/`xhigh` map directly, and `off` omits the reasoning-effort startup override. @@ -359,6 +360,7 @@ One-shot ACP sessions spawned by another agent run are background children, simi - The parent asks for work with `sessions_spawn({ runtime: "acp", mode: "run" })`. - The child runs in its own ACP harness session. +- Child turns run on the same background lane used by native sub-agent spawns, so a slow ACP harness does not block unrelated main-session work. - Completion reports back through the internal task-completion announce path. - The parent rewrites the child result in normal assistant voice when a user-facing reply is useful. diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index a573fc54363..059dfd96afc 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -168,6 +168,8 @@ type AgentCallParams = { channel?: string; to?: string; threadId?: string; + lane?: string; + timeout?: number; }; type CrossAgentWorkspaceFixture = { workspaceRoot: string; @@ -330,6 +332,12 @@ function expectAgentGatewayCall(overrides: AgentCallParams): void { expect(agentCall?.params?.channel).toBe(overrides.channel); expect(agentCall?.params?.to).toBe(overrides.to); expect(agentCall?.params?.threadId).toBe(overrides.threadId); + if (Object.hasOwn(overrides, "lane")) { + expect(agentCall?.params?.lane).toBe(overrides.lane); + } + if (Object.hasOwn(overrides, "timeout")) { + expect(agentCall?.params?.timeout).toBe(overrides.timeout); + } } function resolveMatrixRoomTargetForTest(value: string | undefined): string | undefined { @@ -701,6 +709,7 @@ describe("spawnAcpDirect", () => { expect(agentCall?.params?.to).toBe("channel:child-thread"); expect(agentCall?.params?.threadId).toBe("child-thread"); expect(agentCall?.params?.deliver).toBe(true); + expect(agentCall?.params?.lane).toBe("subagent"); expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: expect.stringMatching(/^agent:codex:acp:/), @@ -742,6 +751,33 @@ describe("spawnAcpDirect", () => { ); }); + it("applies ACP spawn run timeout to runtime options and dispatch", async () => { + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + runTimeoutSeconds: 45, + }, + { + agentSessionKey: "agent:main:main", + }, + ); + + expectAcceptedSpawn(result); + expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: expect.stringMatching(/^agent:codex:acp:/), + agent: "codex", + runtimeOptions: { + timeoutSeconds: 45, + }, + }), + ); + const agentCall = findAgentGatewayCall(); + expect(agentCall?.params?.lane).toBe("subagent"); + expect(agentCall?.params?.timeout).toBe(45); + }); + it("rejects OpenClaw config agent ids when runtime=acp targets a native agent", async () => { replaceSpawnConfig({ ...createDefaultSpawnConfig(), diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index f7b753ac34d..fd4529d52c0 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -70,6 +70,7 @@ import { startAcpSpawnParentStreamRelay, } from "./acp-spawn-parent-stream.js"; import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; +import { AGENT_LANE_SUBAGENT } from "./lanes.js"; import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js"; import { resolveSpawnedWorkspaceInheritance } from "./spawned-context.js"; @@ -99,6 +100,7 @@ export type SpawnAcpParams = { resumeSessionId?: string; model?: string; thinking?: string; + runTimeoutSeconds?: number; cwd?: string; mode?: SpawnAcpMode; thread?: boolean; @@ -854,6 +856,7 @@ async function initializeAcpSpawnRuntime(params: { resumeSessionId?: string; model?: string; thinking?: string; + runTimeoutSeconds?: number; cwd?: string; }): Promise { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.targetAgentId }); @@ -879,10 +882,11 @@ async function initializeAcpSpawnRuntime(params: { mode: params.runtimeMode, resumeSessionId: params.resumeSessionId, runtimeOptions: - params.model || params.thinking + params.model || params.thinking || params.runTimeoutSeconds ? { ...(params.model ? { model: params.model } : {}), ...(params.thinking ? { thinking: params.thinking } : {}), + ...(params.runTimeoutSeconds ? { timeoutSeconds: params.runTimeoutSeconds } : {}), } : undefined, cwd: params.cwd, @@ -1229,6 +1233,7 @@ export async function spawnAcpDirect( resumeSessionId: params.resumeSessionId, model: params.model, thinking: params.thinking, + runTimeoutSeconds: params.runTimeoutSeconds, cwd: runtimeCwd, }); initializedRuntime = initializedSession.runtimeCloseHandle; @@ -1312,6 +1317,8 @@ export async function spawnAcpDirect( threadId: deliveryPlan.threadId, idempotencyKey: childIdem, deliver: deliveryPlan.useInlineDelivery, + lane: AGENT_LANE_SUBAGENT, + ...(params.runTimeoutSeconds != null ? { timeout: params.runTimeoutSeconds } : {}), label: params.label || undefined, }, timeoutMs: 10_000, diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index d570f02080b..cb5bd843bfe 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -221,6 +221,7 @@ describe("sessions_spawn tool", () => { task: "investigate the failing CI run", agentId: "codex", cwd: "/workspace", + runTimeoutSeconds: 45, thread: true, mode: "session", streamTo: "parent", @@ -236,6 +237,7 @@ describe("sessions_spawn tool", () => { task: "investigate the failing CI run", agentId: "codex", cwd: "/workspace", + runTimeoutSeconds: 45, thread: true, mode: "session", streamTo: "parent", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 1372fbd2231..576da3abc53 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -260,6 +260,7 @@ export function createSessionsSpawnTool( resumeSessionId, model: modelOverride, thinking: thinkingOverrideRaw, + runTimeoutSeconds, cwd, mode: mode === "run" || mode === "session" ? mode : undefined, thread,