feat(acp): improve spawn lifecycle β alias resolution, workspace injection, DM binding, and session handback#61081
Conversation
ACP sessions now receive workspace identity/persona files (SOUL.md, AGENTS.md, IDENTITY.md, USER.md, TOOLS.md, MEMORY.md) prepended as [WORKSPACE CONTEXT] in the task string, matching the bootstrap that native agents get automatically. - Skip injection on resume sessions (context already present) - Opt-out via acp.injectBootstrap: false - 50K char budget, non-fatal on errors - Added injectBootstrap field to AcpConfig type Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
β¦strap in Zod schema - Move ACP_BOOTSTRAP_INCLUDE to module scope (avoids per-call allocation) - Add injectBootstrap to the strict Zod config schema so users can actually set acp.injectBootstrap: false without validation errors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
β¦trol block ACP agents can now hand control back to the main agent by running `openclaw acp close-self`. The gateway auto-injects [ACP SESSION CONTROL] instructions (including the session key) into every ACP task string, so agents know how to hand back without any workspace file configuration. - New CLI: `openclaw acp close-self --session-key <key> --reason <text>` - Auto-discovers session key from cwd when --session-key is omitted - Delivers optional handoff --message to DM before unbinding - Controlled by `acp.sessionHandback` config (default: true) - Added sessionHandback field to AcpConfig type Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
resolveTargetAcpAgentId now cross-references agents.list to map profile aliases to runtime.acp.agent and carries profileCwd through to initializeAcpSpawnRuntime. Same lookup applies to the configuredDefault fallback path. Closes openclaw#48136 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address Greptile review: the configuredDefault path returned the raw alias without resolving runtime.acp.agent. Add divergent-alias test case (defaultAgent: "analyst" β agent: "claude") to cover this path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
sessions_spawn now checks the target agent's runtime.type in agents.list config. When runtime.type is "acp", it automatically uses the ACP runtime instead of requiring the caller to explicitly pass runtime="acp". This enables natural language delegation β the main agent calls sessions_spawn(agentId="analyst") and the system routes through ACP automatically based on config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
β¦n control instructions 1. close-self --message was silently dropped because callGateway agent method requires idempotencyKey. Added crypto.randomUUID(). 2. Rewrote [ACP SESSION CONTROL] block so agents hand back after task completion (with summary), on explicit user request, or when off-topic. Previously agents refused to close-self after finishing work. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Register sessionHandback in Zod schema (same P1 as injectBootstrap) - Wait for handoff message delivery before deleting session (race fix) - Remove unused --reason flag and cwd auto-discovery (session key is always injected via [ACP SESSION CONTROL], auto-discovery unreliable) - Simplify redundant ternary in runtime detection - Fix unrelated lint from openclaw#57732 merge (unused import) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
β¦le bindings Allow --bind here to work in Telegram DM conversations by resolving channel-prefixed targets (e.g. telegram:12345). Save previous binding in metadata on rebind and auto-restore on unbind, enabling the main agent + specialist handoff pattern. Refs: openclaw#57448 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
β¦d, align targetKind, restore on TTL expiry
β¦on TTL expiry race
Greptile SummaryThis PR wires up several ACP spawn lifecycle improvements: alias resolution (profile β harness ID), workspace bootstrap injection, DM
Confidence Score: 4/5Not safe to merge as-is: a duplicate One P0 finding (duplicate block-scoped variable declaration) prevents the build from succeeding. All other findings are P2 quality and consistency notes that do not block merge on their own. src/agents/acp-spawn.ts β duplicate Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/agents/acp-spawn.ts
Line: 569-587
Comment:
**Duplicate `const channel` β TypeScript compile error**
`channel` is already declared with `const` at line 535 in the same function scope. Re-declaring it on line 569 causes `Cannot redeclare block-scoped variable 'channel'` and prevents compilation. Drop the duplicate; `channel` is already in scope. Only `target` needs a new binding here. The `channel` references in the new block should also use `channelKey` (the normalised form) to stay consistent with the existing `if (channelKey === "line")` and `if (channelKey === "telegram")` checks above.
```suggestion
const target = params.to?.trim() || "";
if (channelKey === "line") {
const prefixed = target.match(/^line:(?:(?:user|group|room):)?([UCR][a-f0-9]{32})$/i)?.[1];
if (prefixed) {
return prefixed;
}
if (/^[UCR][a-f0-9]{32}$/i.test(target)) {
return target;
}
}
// Strip channel prefix from numeric DM targets (e.g. "telegram:12345678" β "12345678")
if (channelKey && target.toLowerCase().startsWith(`${channelKey}:`)) {
const stripped = target.slice(channelKey.length + 1).trim();
if (stripped && /^\d+$/.test(stripped)) {
return stripped;
}
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/agents/acp-spawn.ts
Line: 570-579
Comment:
**New Line block ignores `groupId`, inconsistent with existing fallback**
The existing Line fallback at line 550 resolves against `params.groupId ?? params.to`, but the new block builds `target` from `params.to` only. A prefixed Line ID supplied via `groupId` (e.g. `groupId: "line:Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"`) would bypass the new parsing and fall through unresolved. Consider deriving `target` from `params.groupId ?? params.to` to match the existing convention.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(acp): guard unbind paths against del..." | Re-trigger Greptile |
| const channel = params.channel?.trim().toLowerCase(); | ||
| const target = params.to?.trim() || ""; | ||
| if (channel === "line") { | ||
| const prefixed = target.match(/^line:(?:(?:user|group|room):)?([UCR][a-f0-9]{32})$/i)?.[1]; | ||
| if (prefixed) { | ||
| return prefixed; | ||
| } | ||
| if (/^[UCR][a-f0-9]{32}$/i.test(target)) { | ||
| return target; | ||
| } | ||
| } | ||
|
|
||
| // Strip channel prefix from numeric DM targets (e.g. "telegram:12345678" β "12345678") | ||
| if (channel && target.toLowerCase().startsWith(`${channel}:`)) { | ||
| const stripped = target.slice(channel.length + 1).trim(); | ||
| if (stripped && /^\d+$/.test(stripped)) { | ||
| return stripped; | ||
| } | ||
| } |
There was a problem hiding this comment.
Duplicate
const channel β TypeScript compile error
channel is already declared with const at line 535 in the same function scope. Re-declaring it on line 569 causes Cannot redeclare block-scoped variable 'channel' and prevents compilation. Drop the duplicate; channel is already in scope. Only target needs a new binding here. The channel references in the new block should also use channelKey (the normalised form) to stay consistent with the existing if (channelKey === "line") and if (channelKey === "telegram") checks above.
| const channel = params.channel?.trim().toLowerCase(); | |
| const target = params.to?.trim() || ""; | |
| if (channel === "line") { | |
| const prefixed = target.match(/^line:(?:(?:user|group|room):)?([UCR][a-f0-9]{32})$/i)?.[1]; | |
| if (prefixed) { | |
| return prefixed; | |
| } | |
| if (/^[UCR][a-f0-9]{32}$/i.test(target)) { | |
| return target; | |
| } | |
| } | |
| // Strip channel prefix from numeric DM targets (e.g. "telegram:12345678" β "12345678") | |
| if (channel && target.toLowerCase().startsWith(`${channel}:`)) { | |
| const stripped = target.slice(channel.length + 1).trim(); | |
| if (stripped && /^\d+$/.test(stripped)) { | |
| return stripped; | |
| } | |
| } | |
| const target = params.to?.trim() || ""; | |
| if (channelKey === "line") { | |
| const prefixed = target.match(/^line:(?:(?:user|group|room):)?([UCR][a-f0-9]{32})$/i)?.[1]; | |
| if (prefixed) { | |
| return prefixed; | |
| } | |
| if (/^[UCR][a-f0-9]{32}$/i.test(target)) { | |
| return target; | |
| } | |
| } | |
| // Strip channel prefix from numeric DM targets (e.g. "telegram:12345678" β "12345678") | |
| if (channelKey && target.toLowerCase().startsWith(`${channelKey}:`)) { | |
| const stripped = target.slice(channelKey.length + 1).trim(); | |
| if (stripped && /^\d+$/.test(stripped)) { | |
| return stripped; | |
| } | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/acp-spawn.ts
Line: 569-587
Comment:
**Duplicate `const channel` β TypeScript compile error**
`channel` is already declared with `const` at line 535 in the same function scope. Re-declaring it on line 569 causes `Cannot redeclare block-scoped variable 'channel'` and prevents compilation. Drop the duplicate; `channel` is already in scope. Only `target` needs a new binding here. The `channel` references in the new block should also use `channelKey` (the normalised form) to stay consistent with the existing `if (channelKey === "line")` and `if (channelKey === "telegram")` checks above.
```suggestion
const target = params.to?.trim() || "";
if (channelKey === "line") {
const prefixed = target.match(/^line:(?:(?:user|group|room):)?([UCR][a-f0-9]{32})$/i)?.[1];
if (prefixed) {
return prefixed;
}
if (/^[UCR][a-f0-9]{32}$/i.test(target)) {
return target;
}
}
// Strip channel prefix from numeric DM targets (e.g. "telegram:12345678" β "12345678")
if (channelKey && target.toLowerCase().startsWith(`${channelKey}:`)) {
const stripped = target.slice(channelKey.length + 1).trim();
if (stripped && /^\d+$/.test(stripped)) {
return stripped;
}
}
```
How can I resolve this? If you propose a fix, please make it concise.| const target = params.to?.trim() || ""; | ||
| if (channel === "line") { | ||
| const prefixed = target.match(/^line:(?:(?:user|group|room):)?([UCR][a-f0-9]{32})$/i)?.[1]; | ||
| if (prefixed) { | ||
| return prefixed; | ||
| } | ||
| if (/^[UCR][a-f0-9]{32}$/i.test(target)) { | ||
| return target; | ||
| } | ||
| } |
There was a problem hiding this comment.
New Line block ignores
groupId, inconsistent with existing fallback
The existing Line fallback at line 550 resolves against params.groupId ?? params.to, but the new block builds target from params.to only. A prefixed Line ID supplied via groupId (e.g. groupId: "line:Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") would bypass the new parsing and fall through unresolved. Consider deriving target from params.groupId ?? params.to to match the existing convention.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/acp-spawn.ts
Line: 570-579
Comment:
**New Line block ignores `groupId`, inconsistent with existing fallback**
The existing Line fallback at line 550 resolves against `params.groupId ?? params.to`, but the new block builds `target` from `params.to` only. A prefixed Line ID supplied via `groupId` (e.g. `groupId: "line:Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"`) would bypass the new parsing and fall through unresolved. Consider deriving `target` from `params.groupId ?? params.to` to match the existing convention.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
π‘ Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bc43f79dce
βΉοΈ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with π.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| method: "agent.wait", | ||
| params: { idempotencyKey }, |
There was a problem hiding this comment.
Wait for handback completion by runId
agent.wait only validates runId, but this call passes idempotencyKey, so it is rejected and immediately swallowed by the .catch(...). When openclaw acp close-self --message ... is used, the command does not actually wait for the handback message run before calling sessions.delete, which can drop the final summary if session deletion races ahead of delivery.
Useful? React with πΒ / π.
| // --- ACP bootstrap injection --- | ||
| let effectiveTask = params.task; | ||
| if (!params.resumeSessionId) { | ||
| const bootstrapWorkspace = params.cwd || resolveAgentWorkspaceDir(cfg, targetAgentId); |
There was a problem hiding this comment.
Use resolved profile workspace for bootstrap injection
Bootstrap loading is keyed off targetAgentId, but after alias resolution that ID can be the ACP harness (analyst -> claude) while the actual workspace is carried separately in targetAgentResult.profileCwd (used later for runtime cwd). In that common alias case, workspace context is read from the wrong directory (or a default fallback), so AGENTS/SOUL instructions from the profile workspace are not injected into the spawned task.
Useful? React with πΒ / π.
There was a problem hiding this comment.
π‘ Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7730c72859
βΉοΈ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with π.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| maybeRestorePreviousBinding(key, record); | ||
| void enqueuePersist(); | ||
| return null; | ||
| return bindingsByConversationKey.get(key) ?? null; |
There was a problem hiding this comment.
Re-check restored binding expiration before returning
pruneExpiredBinding now restores metadata.previousBinding and immediately returns bindingsByConversationKey.get(key), but the restored record may already be expired because previousBinding.expiresAt is copied from the older binding. If a short-TTL binding is overridden by a longer-lived specialist, then expires while the specialist is active, resolveGenericCurrentConversationBinding() can return that expired restored binding for one turn, causing routing to a session that should already be inactive.
Useful? React with πΒ / π.
Problem
When an agent spawns another agent via ACP, the spawned session starts incomplete β aliases don't resolve, workspace context is missing, DMs can't bind, and there's no way for the agent to hand back control.
Additional issues fixed
runtime="acp"even when the agent profile already declaresruntime.type: "acp"Fix
Agent alias resolution in
resolveTargetAcpAgentId:analystβclaude) vialistAgentEntries()profileCwdfrom agent config for workspace inheritanceprofileCwdthroughresolveSpawnedWorkspaceInheritance()+resolveRuntimeCwdForAcpSpawn()so the directory is validated before useBootstrap injection before
initializeAcpSpawnRuntime:loadWorkspaceBootstrapFiles()[WORKSPACE CONTEXT]block to the task string (capped at 50K chars)resumeSessionId) to avoid replaying contextDM conversation binding in
prepareAcpThreadBinding:bind:herefor DM conversations with revertable bindings viaSessionBindingServicebindingIdon restoreSession handback via new
openclaw acp close-selfCLI:/acp closeβ scoped to the agent's own session key onlyidempotencyKeyprevents duplicate closeAuto-detect runtime in
sessions-spawn-tool.ts:runtime.typefrom target agent profile vialistAgentEntries()runtime="acp"automatically so callers don't need to specify itTests for alias resolution, workspace inheritance, bootstrap injection, DM binding lifecycle, TTL race guards, and session handback
Why merge
ACP spawn is the entry point for all agent-to-agent work in OpenClaw. Without these changes, spawned agents start blind β no project context, no alias awareness, no DM support, and no graceful exit. Callers have to manually resolve IDs, skip workspace context, avoid DMs, and wait for timeouts. This PR closes those gaps so spawned agents resolve correctly, start with full context, work in any conversation type, and hand back cleanly when done.