Skip to content

feat(acp): improve spawn lifecycle β€” alias resolution, workspace injection, DM binding, and session handback#61081

Closed
alexanderkreidich wants to merge 15 commits intoopenclaw:mainfrom
alexanderkreidich:feat/acp-spawn-lifecycle
Closed

feat(acp): improve spawn lifecycle β€” alias resolution, workspace injection, DM binding, and session handback#61081
alexanderkreidich wants to merge 15 commits intoopenclaw:mainfrom
alexanderkreidich:feat/acp-spawn-lifecycle

Conversation

@alexanderkreidich
Copy link
Copy Markdown

@alexanderkreidich alexanderkreidich commented Apr 4, 2026

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.

Current behavior:

  sessions_spawn agentId="analyst"
    β†’ fails: "analyst" not in allowedAgents (it's an alias for "claude")
    β†’ caller must know the raw harness ID

  sessions_spawn agentId="claude" task="analyze the repo"
    β†’ spawns, but agent has no AGENTS.md/SOUL.md/workspace context
    β†’ agent doesn't know what repo it's in or what rules to follow

  /acp spawn codex --thread here (in a DM)
    β†’ fails: thread binding only works in group channels

  spawned agent gets an off-topic question
    β†’ no exit path: runs until timeout or operator /acp close

Additional issues fixed

  1. No workspace cwd inheritance: spawned agents didn't inherit the profile's workspace directory through the validation pipeline
  2. Runtime must be explicit: callers had to pass runtime="acp" even when the agent profile already declares runtime.type: "acp"
  3. TTL expiry race on bindings: a stale TTL timer could delete a just-restored DM binding, breaking the conversation handoff

Fix

  • Agent alias resolution in resolveTargetAcpAgentId:

    • Resolves profile aliases to harness IDs (analyst β†’ claude) via listAgentEntries()
    • Returns profileCwd from agent config for workspace inheritance
    • Feeds profileCwd through resolveSpawnedWorkspaceInheritance() + resolveRuntimeCwdForAcpSpawn() so the directory is validated before use
  • Bootstrap injection before initializeAcpSpawnRuntime:

    • Loads AGENTS.md, SOUL.md, and other workspace files via existing loadWorkspaceBootstrapFiles()
    • Prepends them as a [WORKSPACE CONTEXT] block to the task string (capped at 50K chars)
    • Skipped on session resume (resumeSessionId) to avoid replaying context
  • DM conversation binding in prepareAcpThreadBinding:

    • Adds bind:here for DM conversations with revertable bindings via SessionBindingService
    • Adds Line channel ID parsing and generic channel prefix stripping for conversation ID resolution
    • Guards unbind paths against TTL expiry races with fresh bindingId on restore
  • Session handback via new openclaw acp close-self CLI:

    • Agent-initiated exit: closes own session and hands control back to parent
    • Distinct from operator /acp close β€” scoped to the agent's own session key only
    • Session control instructions auto-injected into task string
    • idempotencyKey prevents duplicate close
  • Auto-detect runtime in sessions-spawn-tool.ts:

    • Reads runtime.type from target agent profile via listAgentEntries()
    • Infers runtime="acp" automatically so callers don't need to specify it
  • Tests 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.

alexanderkreidich and others added 13 commits April 5, 2026 01:09
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>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 4, 2026

Greptile Summary

This PR wires up several ACP spawn lifecycle improvements: alias resolution (profile β†’ harness ID), workspace bootstrap injection, DM bind:here with revertable bindings, and a new openclaw acp close-self handback path.

  • Build-breaking compile error: resolveConversationIdForThreadBinding in src/agents/acp-spawn.ts declares const channel twice in the same function scope (lines 535 and 569), which TypeScript rejects. The duplicate declaration must be removed before this compiles.

Confidence Score: 4/5

Not safe to merge as-is: a duplicate const channel declaration causes a TypeScript compile error.

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 const channel in resolveConversationIdForThreadBinding must be removed before this compiles.

Prompt To Fix All 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.

---

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

Comment thread src/agents/acp-spawn.ts Outdated
Comment on lines +569 to +587
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;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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.

Comment thread src/agents/acp-spawn.ts Outdated
Comment on lines +570 to +579
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;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ’‘ 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".

Comment on lines +37 to +38
method: "agent.wait",
params: { idempotencyKey },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 πŸ‘Β / πŸ‘Ž.

Comment thread src/agents/acp-spawn.ts
// --- ACP bootstrap injection ---
let effectiveTask = params.task;
if (!params.resumeSessionId) {
const bootstrapWorkspace = params.cwd || resolveAgentWorkspaceDir(cfg, targetAgentId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ’‘ 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 πŸ‘Β / πŸ‘Ž.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling channel: telegram Channel integration: telegram cli CLI command changes commands Command implementations size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant