Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
run: uv lock --check

- name: Install dependencies
run: uv sync --extra dev
run: uv sync --locked --extra dev

- name: Run pre-commit hooks
run: uv run pre-commit run --all-files
Expand Down Expand Up @@ -75,7 +75,7 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --extra dev
run: uv sync --locked --extra dev

- name: Run tests
run: uv run pytest
Expand Down Expand Up @@ -121,7 +121,7 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install crewai dependencies
run: uv sync --extra dev-crewai
run: uv sync --locked --extra dev-crewai

- name: Run crewai tests
env:
Expand Down
44 changes: 23 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,37 +46,39 @@ jobs:
manifest-file: .release-please-manifest.json
target-branch: main

# release-please bumps the version in pyproject.toml but not uv.lock's own
# band-sdk entry, so `uv sync --locked` (the kit image build) fails on the
# release. Re-lock the release PR so the version bump and the lock always
# land together. Runs only when a release PR was opened/updated (not on the
# release-creating merge, where the lock is already consistent).
# release-please bumps pyproject.toml's version but cannot regenerate
# uv.lock (a generated file takes no release-please annotations), so a
# release PR ships a stale lock: CI's `uv lock --check` gate and every
# `uv sync --locked` (including the kit image build) would fail on it.
# Refresh the lock on the release PR so the version bump and the lock
# always land together. Checked out with the App token so the plain
# `git push` below is App-authored and re-triggers the PR's CI (a
# GITHUB_TOKEN-authored push would not — same gotcha as the add-band
# job below).
- name: Checkout the release PR
if: steps.release.outputs.prs_created == 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
token: ${{ steps.app-token.outputs.token }}

- name: Install uv
if: ${{ steps.release.outputs.pr }}
if: steps.release.outputs.prs_created == 'true'
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7

- name: Sync uv.lock into the release PR
if: ${{ steps.release.outputs.pr }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_BRANCH: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
REPO: ${{ github.repository }}
- name: Refresh uv.lock on the release PR
if: steps.release.outputs.prs_created == 'true'
run: |
set -euo pipefail
git fetch origin "$PR_BRANCH"
git checkout -B "$PR_BRANCH" FETCH_HEAD
uv lock
if git diff --quiet -- uv.lock; then
echo "uv.lock already matches the release version."
echo "uv.lock already current for this release PR."
exit 0
fi
git config user.name "band-release-bot"
git config user.email "release-bot@band.ai"
git add uv.lock
git commit -m "chore: sync uv.lock to the release version"
# Push with the App token (not the default GITHUB_TOKEN) so the PR's CI
# re-runs and the `uv lock --check` gate sees the now-consistent lock.
git push "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "HEAD:${PR_BRANCH}"
git commit -m "chore: refresh uv.lock for the release version bump"
git push

publish-band:
needs: [release]
Expand All @@ -101,7 +103,7 @@ jobs:
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7

- name: Install dependencies
run: uv sync --all-groups
run: uv sync --locked --all-groups

- name: Build band-sdk package
run: uv build
Expand Down
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,27 @@ A turn's events must land in the room in the order they happened, because two th

`RoomTurnEmitter` (`room_emitter.py`) is the sink: it posts narration (thought/tool_call/tool_result/plan) live for **every** tool call — including Band messaging tools, with no suppression — and holds **only** the assistant text until close (the text-fallback decision needs the whole turn). `ACPRuntime.prompt(..., on_chunk=emitter.emit)` registers the sink and `flush`es at turn end.

### History replay fallback (Client Adapter)

A **freshly created** ACP session owes the room a transcript replay; a restored one
does not. On bootstrap the adapter first validates the room's persisted session id
with ACP `session/load`; on any miss (no persisted id, unavailable, or erroring
load) the fresh session is seeded with the room's text transcript
(`ACPClientSessionState.replay_messages`, built by the shared
`build_replay_messages` helper in `converters/helpers.py`). A session minted
**off-bootstrap** (the previous runtime was torn down mid-run, e.g. after a prompt
failure) re-fetches the transcript itself via `tools.fetch_room_context`, so a
respawn never starts amnesiac. Replay is injected exactly once into the session's
first prompt under `HISTORY_REPLAY_HEADER`: framed as read-only background (treat as
already handled; never re-execute), with the current message attributed and last
under a nonce'd `[New Message <nonce>]` boundary marker the header names (the nonce
defeats a replayed message spoofing the boundary). Bootstrap history stops
**strictly before** the triggering message (`messages_before` in
`runtime/formatters.py`, applied in `preprocessing/default.py` for every adapter):
later backlog entries are pending turns of their own and never replay. Adapter
narration events (thought/tool_call/tool_result/task) never replay. A successfully
loaded session gets no replay, so history is never doubled.

### Reply Delivery (Client Adapter)

Tool-first with a text fallback, matching `copilot_sdk`/`codex`: if the turn posted via a Band messaging tool, the agent's plain text is **not** also relayed; otherwise the held text is relayed at turn close. The decision lives in `turn_replied_in_room()` (`room_emitter.py`), which reads the collected tool-call stream — the ACP adapter can't flip an in-process flag like the siblings, because its tools may execute out-of-process (remote band-mcp), so it matches `tool_call` title + `completed` status. Which tools count is defined once in `is_room_posting_tool()` / `ROOM_POSTING_TOOL_NAMES` (`src/band/runtime/tools.py`): the SDK's `band_send_message` (also what band-mcp 1.3.2+ advertises, since its registrar reuses the SDK tool definitions) plus the legacy `create_agent_chat_message` spelling from band-mcp ≤1.3.1. This suppression is about the text fallback only — the call's own `tool_call`/`tool_result` narration (below) is never suppressed.
Expand Down
4 changes: 3 additions & 1 deletion examples/acp/copilot_docker/colocated/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ Then message the `copilot_acp_agent` from a Band room.
connection, so a reconnect (e.g. an `ACPRuntime` respawn) lands on a process with no
prior in-memory sessions. The SDK uses ACP's session-load capability before trusting
a persisted ID; unsupported or unavailable sessions get a fresh session before the
prompt is sent. Earlier Copilot conversation state is not resumed after a reconnect.
prompt is sent. Earlier Copilot in-memory state is not resumed after a reconnect;
instead the SDK replays the Band room's transcript into the fresh session's first
prompt, so conversation context survives the restart.
- **Bind loopback.** The `docker run -p 127.0.0.1:8080:8080` above keeps the ACP port
on the host loopback. Copilot here is unauthenticated and runs `--allow-all-tools`,
so expose it off-host only behind your own auth.
Expand Down
4 changes: 3 additions & 1 deletion examples/acp/copilot_docker/compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ and calls Band tools via band-mcp.
connection, so a reconnect (e.g. an `ACPRuntime` respawn) lands on a process with no
prior in-memory sessions. The SDK uses ACP's session-load capability before trusting
a persisted ID; unsupported or unavailable sessions get a fresh session before the
prompt is sent. Earlier Copilot conversation state is not resumed after a reconnect.
prompt is sent. Earlier Copilot in-memory state is not resumed after a reconnect;
instead the SDK replays the Band room's transcript into the fresh session's first
prompt, so conversation context survives the restart.
- **Bind loopback.** `docker-compose.yml` publishes the ACP port as
`127.0.0.1:8080:8080`: an unauthenticated, allow-all `copilot --acp` must not be
reachable off-host. Widen it (behind your own auth) only for a remote SDK host.
Expand Down
13 changes: 2 additions & 11 deletions src/band/adapters/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from pydantic import BaseModel, Field, ValidationError

from band.converters.codex import CodexHistoryConverter
from band.converters.helpers import build_replay_messages
from band.core.exceptions import BandConfigError
from band.core.protocols import AgentToolsProtocol
from band.core.simple_adapter import SimpleAdapter
Expand Down Expand Up @@ -1254,17 +1255,7 @@ def _build_turn_input(
return items, injected_system_prompt

def _format_history_context(self, raw: list[dict[str, Any]]) -> str | None:
text_messages: list[str] = []
for entry in raw:
msg_type = entry.get("message_type", "")
if msg_type not in {"text", "message"}:
continue
content = entry.get("content", "")
if not isinstance(content, str) or not content.strip():
continue
sender = entry.get("sender_name") or entry.get("sender_type") or "Unknown"
text_messages.append(f"[{sender}]: {content}")

text_messages = build_replay_messages(raw)
if not text_messages:
return None

Expand Down
18 changes: 14 additions & 4 deletions src/band/converters/acp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
from typing import TYPE_CHECKING, Any

from band.converters.helpers import build_replay_messages
from band.core.protocols import HistoryConverter

if TYPE_CHECKING:
Expand All @@ -14,14 +15,18 @@


class ACPClientHistoryConverter(HistoryConverter["ACPClientSessionState"]):
"""Extracts room-to-session resume candidates from platform history.
"""Extracts room-to-session resume candidates plus a replayable transcript.

Scans platform history for ACP client-specific metadata. The adapter validates
each candidate with the connected ACP agent before reusing it.

The converter looks for messages with metadata containing:
- acp_client_session_id: The remote ACP agent's session identifier
- acp_client_room_id: The corresponding Band room identifier

It also renders the room's text messages as replay lines (the adapter's
fallback context when no persisted session can be restored); the adapter's
own narration events (thought/tool_call/tool_result/task) are excluded.
"""

def convert(self, raw: list[dict[str, Any]]) -> ACPClientSessionState:
Expand All @@ -31,7 +36,8 @@ def convert(self, raw: list[dict[str, Any]]) -> ACPClientSessionState:
raw: Platform history from format_history_for_llm().

Returns:
ACPClientSessionState with room-to-session resume candidates.
ACPClientSessionState with room-to-session resume candidates and
the room's replayable text transcript.
"""
# Runtime import to avoid circular import at module load time
from band.integrations.acp.client_types import ACPClientSessionState
Expand All @@ -51,11 +57,15 @@ def convert(self, raw: list[dict[str, Any]]) -> ACPClientSessionState:
session_id,
)

state = ACPClientSessionState(room_to_session=room_to_session)
state = ACPClientSessionState(
room_to_session=room_to_session,
replay_messages=build_replay_messages(raw),
)

logger.debug(
"Converted ACP client history: %d room-session candidates",
"Converted ACP client history: %d room-session candidates, %d replay lines",
len(room_to_session),
len(state.replay_messages),
)

return state
8 changes: 4 additions & 4 deletions src/band/converters/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ def parse_iso_datetime(value: Any) -> datetime | None:
def build_replay_messages(raw: list[dict[str, Any]]) -> list[str]:
"""Render the room's text history as ``[sender]: content`` lines.

Used by backend-session adapters (opencode, letta) to re-seed a fresh
backend session from platform history when the previous session cannot be
resumed. Non-text messages (task/tool/thought events) are skipped.
Used by resume-else-replay adapters to re-seed a fresh backend session
from platform history when the previous session cannot be resumed.
Non-text messages (task/tool/thought events) are skipped.
"""
return [line for msg in raw if (line := _replay_line(msg)) is not None]

Expand All @@ -38,7 +38,7 @@ def _replay_line(msg: dict[str, Any]) -> str | None:
if msg.get("message_type") != "text":
return None
content = optional_str(msg.get("content"))
if not content:
if not content or not content.strip():
return None
sender = (
optional_str(msg.get("sender_name"))
Expand Down
Loading
Loading