Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b935de5
fix: Restart a Band MCP server in place on a new port
AlexanderZ-Band Oct 3, 2026
ba4e7ae
fix: Restart claude_sdk's crashed Band MCP server and recycle stale s…
AlexanderZ-Band Oct 3, 2026
5f99aa3
fix: Heal ACP rooms whose session dials a crashed Band MCP server
AlexanderZ-Band Oct 3, 2026
4e85089
fix: Re-register OpenCode's Band MCP server after it crashes
AlexanderZ-Band Oct 3, 2026
b646ee6
refactor: Heal every Band MCP backend through one restart_if_crashed()
AlexanderZ-Band Oct 3, 2026
627fd20
fix: Repoint Letta's registration at a restarted Band MCP server
AlexanderZ-Band Oct 3, 2026
64489c7
fix: Clean up a cancelled LocalMCPServer start
AlexanderZ-Band Oct 3, 2026
9b63083
refactor: Own every adapter's Band MCP backend through one SharedBand…
AlexanderZ-Band Oct 3, 2026
9b634e5
refactor: Replace a dead Band MCP backend instead of restarting it in…
AlexanderZ-Band Oct 3, 2026
a030cea
refactor: Decide reuse where it happens for ACP sessions and Letta's …
AlexanderZ-Band Oct 3, 2026
d42e465
fix: Stop restarting a stopped session manager and leaking retired Cu…
AlexanderZ-Band Oct 3, 2026
9bb39ee
fix: Share one session-manager shutdown and release ACP sessions afte…
AlexanderZ-Band Oct 3, 2026
9052bc5
fix: Forget an ACP session's bootstrap under the lock and its subclas…
AlexanderZ-Band Oct 3, 2026
4723c38
fix: Give an ACP room whose Band URL went stale a fresh session, neve…
AlexanderZ-Band Oct 3, 2026
377e508
fix: Settle teardown commands queued behind a session-manager stop, k…
AlexanderZ-Band Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ assert adapter.config.command == ("codex-acp",)
default), the adapter hosts one loopback `LocalMCPServer` and gives each room's
session that room's endpoint (`/rooms/<room>/mcp`, or `/sse`), so the tools take no
`chat_id` and the prompt never states one. A reloaded session gets the same endpoint.
If that server dies, the next message replaces it on a new port, and a room whose
session still dials the old one gets a fresh session with the transcript replayed.
- **An external Band MCP server takes the room as an argument.** With
`inject_band_tools=False` (a remote `band-mcp`), the session's first prompt states
`Current chat_id` for its tools to use.
Expand Down
4 changes: 3 additions & 1 deletion docs/adapters/claude_sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ CLI launch options and chat approvals are nested groups:
- **Band tools are bound to the room.** The adapter hosts one loopback
`LocalMCPServer` and gives each room's session that room's endpoint
(`/rooms/<room>/mcp`), so the tools take no `chat_id` and the prompt never
states one.
states one. If that server dies, the next message replaces it on a new port
and each room's session resumes against it on that room's next message; a
turn already in flight loses its Band tools until it ends.
- **Assistant text is never posted.** The adapter only debug-logs it. A reply
reaches the room through the `band_send_message` tool, and a turn that ends
with no successful reply or action tool call is reported to the room as an
Expand Down
4 changes: 3 additions & 1 deletion docs/adapters/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ Four invariants are easy to break and expensive to rediscover:
registrations globally by name. Each agent registers under a name derived from
its Band identity, and every prompt scopes tool visibility to that
registration (deny the shared namespace, then re-allow its own — OpenCode
applies the last matching rule).
applies the last matching rule). If the adapter's Band MCP server dies, the
next turn replaces it on a new port and re-registers it under the same name,
which OpenCode treats as a replacement and reconnects.
- **The model is told the current `chat_id` every turn.** The band MCP tools'
schemas require it, so without the per-turn Room Context block the platform
tools are uncallable.
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ opencode = [
"mcp>=1.28.1,<2",
]
letta = [
"letta-client>=0.1.0",
"letta-client>=1.0.0",
# Floor pinned to crewai's own transitive pin (mcp~=1.28.1) -- see `desktop` above.
"mcp>=1.28.1,<2",
]
Expand Down Expand Up @@ -237,7 +237,7 @@ dev = [
# Include Strands Agents for testing
"strands-agents[openai]>=1.40,<2",
# Include letta-client for testing
"letta-client>=0.1.0",
"letta-client>=1.0.0",
# Include bridge deps for testing
"aiohttp>=3.9,<4",
"python-dotenv>=1.2.2",
Expand Down
118 changes: 58 additions & 60 deletions src/band/adapters/claude_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,14 @@
DedupingAgentTools,
)
from band.integrations.claude_sdk.prompts import generate_claude_sdk_agent_prompt
from band.integrations.claude_sdk.session_manager import ClaudeSessionManager
from band.integrations.mcp.backends import (
BandMCPBackend,
from band.integrations.claude_sdk.session_manager import (
ClaudeSessionManager,
ClaudeSessionManagerStoppedError,
)
from band.integrations.mcp import (
BandMCPBackendSettings,
BandMCPTransport,
create_band_mcp_backend,
SharedBandMCPBackend,
)
from band.runtime.custom_tools import (
CustomToolDef,
Expand Down Expand Up @@ -582,9 +585,10 @@ def __init__(
)
self.config = config or ClaudeSDKAdapterConfig()

# Session manager and MCP backend (created after start)
# Created in on_started.
Comment thread
AlexanderZ-Band marked this conversation as resolved.
self._session_manager: ClaudeSessionManager | None = None
self._mcp_backend: BandMCPBackend | None = None

self._mcp = SharedBandMCPBackend(self._mcp_settings)

# Per-room tools: the adapter's own sends use them directly, while the
# MCP server's tool calls go through _mcp_room_tools (see _bind_mcp_tools).
Expand Down Expand Up @@ -650,9 +654,8 @@ async def on_started(self, agent_name: str, agent_description: str) -> None:
"""Create MCP server and session manager after agent metadata is fetched."""
await super().on_started(agent_name, agent_description)

# Each room's session dials its own room-bound endpoint on this
# backend (see _room_mcp_servers); tools resolve via _mcp_room_tools.
self._mcp_backend = await self._create_mcp_backend()
await self._mcp.reopen()
mcp_backend = await self._mcp.ensure()

# Generate system prompt with agent info
system_prompt = generate_claude_sdk_agent_prompt(
Expand All @@ -671,7 +674,7 @@ async def on_started(self, agent_name: str, agent_description: str) -> None:
model=resolved_model,
fallback_model=self.config.fallback_model,
system_prompt=system_prompt,
allowed_tools=[*self._mcp_backend.allowed_tools, TOOL_SEARCH],
allowed_tools=[*mcp_backend.allowed_tools, TOOL_SEARCH],
# Same values as the SDK's PermissionMode (pinned by tests/adapters/claude_sdk/test_config.py).
permission_mode=cast("PermissionMode", self.config.permission_mode),
effort=self.config.effort,
Expand Down Expand Up @@ -732,34 +735,24 @@ async def on_started(self, agent_name: str, agent_description: str) -> None:
self._approval_label,
)

async def _create_mcp_backend(self) -> BandMCPBackend:
"""Create shared MCP backend that uses stored room tools."""
tool_definitions = list(
iter_tool_definitions(capabilities=self.features.capabilities)
)
backend = await create_band_mcp_backend(
tool_definitions=tool_definitions,
def _mcp_settings(self) -> BandMCPBackendSettings:
return BandMCPBackendSettings(
tool_definitions=list(
iter_tool_definitions(capabilities=self.features.capabilities)
),
get_tools=self._mcp_room_tools.get,
additional_tools=self._custom_tools,
room_bound=True,
)

logger.info(
"Band MCP server started with %s tools (%s custom)",
len(backend.allowed_tools),
len(self._custom_tools),
)

return backend

def _room_mcp_servers(self, room_id: str) -> dict[str, McpServerConfig]:
"""A room session's MCP servers: the Band endpoint bound to that room."""
if self._mcp_backend is None:
if (backend := self._mcp.current) is None:
raise RuntimeError("Band MCP backend is not started")
return {
BAND_MCP_SERVER_NAME: McpHttpServerConfig(
type="http",
url=self._mcp_backend.endpoint(BandMCPTransport.HTTP, room_id),
url=backend.endpoint(BandMCPTransport.HTTP, room_id),
)
}

Expand Down Expand Up @@ -832,44 +825,28 @@ async def on_message(
)
return

await self._mcp.ensure()
Comment thread
AlexanderZ-Band marked this conversation as resolved.

# The manager only resumes when it has to create the client: on
# bootstrap, or after a retired client (see _retire_client).
stored_session_id = (
history.session_id if is_session_bootstrap else None
) or self._session_ids.get(room_id)

# Get or create Claude SDK client for this room (optionally resuming)
try:
client = await self._session_manager.get_or_create_session(
room_id, resume_session_id=stored_session_id
client = await self._open_session(
manager=self._session_manager,
room_id=room_id,
resume_session_id=stored_session_id,
)
except Exception as resume_exc:
if stored_session_id:
logger.warning(
"Room %s: Session resume failed (session_id=%s): %s. "
"Creating new session",
room_id,
stored_session_id,
resume_exc,
)
try:
client = await self._session_manager.get_or_create_session(
room_id, resume_session_id=None
)
except Exception:
logger.exception(
"Room %s: Fresh session creation also failed", room_id
)
await tools.send_failure(
AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE)
)
raise
else:
logger.exception("Room %s: Session creation failed", room_id)
await tools.send_failure(
AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE)
)
raise
except ClaudeSessionManagerStoppedError:
Comment thread
AlexanderZ-Band marked this conversation as resolved.
raise
except Exception:
logger.exception("Room %s: Session creation failed", room_id)
await tools.send_failure(
AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE)
)
raise

# Initialize history for this room on first message
if is_session_bootstrap:
Expand Down Expand Up @@ -955,6 +932,29 @@ async def on_message(
if self._turn_release.get(room_id) is release_future:
del self._turn_release[room_id]

@staticmethod
async def _open_session(
manager: ClaudeSessionManager, room_id: str, resume_session_id: str | None
) -> ClaudeSDKClient:
"""The room's client, starting a fresh session when the resume fails."""
try:
return await manager.get_or_create_session(
room_id, resume_session_id=resume_session_id
)
except ClaudeSessionManagerStoppedError:
raise
except Exception as resume_exc:
if not resume_session_id:
raise
logger.warning(
"Room %s: Session resume failed (session_id=%s): %s. "
"Creating new session",
room_id,
resume_session_id,
resume_exc,
)
return await manager.get_or_create_session(room_id, resume_session_id=None)

async def _run_turn(
self,
client: ClaudeSDKClient,
Expand Down Expand Up @@ -1615,9 +1615,7 @@ async def cleanup_all(self) -> None:
await self._cancel_turn(room_id)
if self._session_manager:
await self._session_manager.stop()
if self._mcp_backend:
await self._mcp_backend.stop()
self._mcp_backend = None
await self._mcp.close(final=True)
self._room_tools.clear()
self._mcp_room_tools.clear()
self._session_context.clear()
Expand Down
6 changes: 3 additions & 3 deletions src/band/adapters/cursor_acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,14 +305,14 @@ async def _get_or_create_session(
return session_id, created

async def on_cleanup(self, room_id: str) -> None:
session_id = self._room_to_session.get(room_id)
# Wakes any decision _run_turn is parked on; the task itself keeps
# running detached and winds down on its own (via _on_background_task_done)
# once the runtime this stops out from under it closes the connection.
self._cancel_room_decisions(room_id)
await super().on_cleanup(room_id)
if session_id is not None:
self._cursor_profile.forget_session(session_id)

def _forget_session(self, session_id: str) -> None:
self._cursor_profile.forget_session(session_id)

async def on_interrupt(self, room_id: str, mode: ControlMode) -> None:
"""A room /stop or interrupt must also reach a turn parked on a
Expand Down
Loading
Loading