Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
d1419b7
fix: Keep working-state reporting alive through provider turns
AlexanderZ-Band Oct 18, 2026
8ec26b3
fix: Preserve standalone worker binding across tool wrappers
AlexanderZ-Band Oct 18, 2026
1476457
test: Avoid the E2E peer fixture reservation in runtime tests
AlexanderZ-Band Oct 18, 2026
8bfeac0
style: Format the renamed activity platform fixture
AlexanderZ-Band Oct 18, 2026
a397d8c
test: Use real time for randomized scheduler grace controls
AlexanderZ-Band Oct 18, 2026
0342d96
feat: Deliver compatible activity detail through the working-state re…
AlexanderZ-Band Oct 18, 2026
d76f571
style: Format detail ownership fixture parameters
AlexanderZ-Band Oct 18, 2026
1842638
test: Avoid reserved peer fixtures in activity wire tests
AlexanderZ-Band Oct 18, 2026
06d8124
test: Preserve backlog ordering across valid recovery retries
AlexanderZ-Band Oct 18, 2026
2d69703
feat: Map provider and Band tool progress to room activity
AlexanderZ-Band Oct 18, 2026
64de362
test: Validate activity producers through real execution boundaries
AlexanderZ-Band Oct 18, 2026
831b851
refactor: Consolidate activity reset and streaming usage ownership
AlexanderZ-Band Oct 18, 2026
9d7fc64
test: Register live activity cells and disambiguate runtime fixtures
AlexanderZ-Band Oct 18, 2026
eb7326e
fix: Fence housekeeping activity and clean up cancelled tool starts
AlexanderZ-Band Oct 18, 2026
a674b67
refactor: Share activity observation records and assertions
AlexanderZ-Band Oct 18, 2026
1249060
Merge remote-tracking branch 'origin/main' into feat/python-sdk-repor…
AlexanderZ-Band Oct 20, 2026
1ff4c54
Merge remote-tracking branch 'origin/feat/python-sdk-report-activity-…
AlexanderZ-Band Oct 20, 2026
c139bc2
Merge remote-tracking branch 'origin/feat/python-sdk-report-activity-…
AlexanderZ-Band Oct 20, 2026
2d8c903
test: give the Strands/NOOA job coverage guard a longer timeout
AlexanderZ-Band Oct 20, 2026
f2ff776
Merge remote-tracking branch 'origin/main' into feat/python-sdk-repor…
AlexanderZ-Band Oct 20, 2026
2f4082e
Merge remote-tracking branch 'origin/feat/python-sdk-report-activity-…
AlexanderZ-Band Oct 20, 2026
36ba9a1
Merge remote-tracking branch 'origin/feat/python-sdk-report-activity-…
AlexanderZ-Band Oct 20, 2026
3d52a13
feat: gate activity tool names on the tool-call emit choice
bandzalkin Oct 20, 2026
9511b0f
feat(core): own host harness probes and share catalog metadata
bandzalkin Oct 18, 2026
926fe70
fix(core): keep cancelled probe diagnostics credential-safe
bandzalkin Oct 18, 2026
88a0c6f
fix(core): enroll probes before eager execution
bandzalkin Oct 18, 2026
eccbd1c
feat(copilot): discover hosted models through an owned SDK probe
bandzalkin Oct 18, 2026
48338aa
Preserve ACP stop caller context during aggregated cleanup
bandzalkin Oct 18, 2026
d28d8a2
Preserve Copilot child custody and configured Windows resolution
bandzalkin Oct 18, 2026
0321504
fix(copilot): preserve configured Windows SDK launch context
bandzalkin Oct 18, 2026
3730b0d
test(integrations): make Copilot launch fixtures portable
bandzalkin Oct 18, 2026
159b52c
fix(tests): control Copilot virtual timer resolution
bandzalkin Oct 18, 2026
dba9b9e
docs(copilot): separate host catalog from native OMP guidance
bandzalkin Oct 19, 2026
650f8d7
Merge remote-tracking branch 'origin/main' into feat/python-sdk-repor…
AlexanderZ-Band Oct 20, 2026
6a62878
refactor: Flatten the activity compatibility retry
AlexanderZ-Band Oct 20, 2026
4a04046
fix: Learn activity detail support from response headers
AlexanderZ-Band Oct 20, 2026
eac1fdd
fix: Forward activity detail through the baseline ControlLink
AlexanderZ-Band Oct 20, 2026
9a1430b
refactor: Keep one detail reset and one ownership recheck in the repo…
AlexanderZ-Band Oct 20, 2026
435ae73
test: Scope the recovery-jitter patch to resync's random reference
AlexanderZ-Band Oct 20, 2026
90bc52a
fix: Log a content-free cause when activity detail is downgraded
AlexanderZ-Band Oct 20, 2026
3c665de
refactor: Drop the unreachable cycle token and pin reporter guards
AlexanderZ-Band Oct 20, 2026
2c4a9f7
test: Share one activity-support isolation helper
AlexanderZ-Band Oct 20, 2026
cd3c6aa
sim840
Oct 20, 2026
11420ba
Merge upstream main (815 settlement details, 839 squash, websocket te…
bandzalkin Oct 20, 2026
f7c6416
Merge #841 head carrying upstream main (activity stack re-merge)
Oct 20, 2026
d9e9134
Merge upstream main through #877 so stacked preflights compose
Oct 20, 2026
0861d9c
Merge #844 rebased onto current main
Oct 20, 2026
eb8fc7d
Merge #874 (activity stack) so preflight diffs stay their own
Oct 20, 2026
32678ff
Merge #844 carrying #874
Oct 20, 2026
f4576ab
Merge upstream main into the activity producers branch
bandzalkin Oct 20, 2026
04c259b
Merge prerequisite head (activity stack re-merged with current main)
Oct 20, 2026
823eeca
Merge upstream main through #838 into the activity delivery branch
Oct 20, 2026
2bc0340
Merge #840 delivery head (main through #838 + X-API-Version and Contr…
bandzalkin Oct 20, 2026
fdaecf6
Merge #841 carrying #840's X-API-Version learning and ControlLink fix
Oct 20, 2026
ef5d6b9
fix(activity): clip over-long tool names before the platform rejects …
bandzalkin Oct 20, 2026
bc5a294
Merge simulation commit landed on this branch; content already supers…
bandzalkin Oct 20, 2026
8f1f1d0
Merge updated prerequisite (final activity stack lineage)
Oct 20, 2026
02e54d8
Merge updated prerequisite (final activity stack lineage)
Oct 20, 2026
a476c3a
test: Accept room event posts in the activity peer
bandzalkin Oct 20, 2026
0d8cbd0
Merge #841 fix: running-room event echo in the activity peer
bandzalkin Oct 20, 2026
60db6dc
Merge #874 (event-echo fix on the activity stack)
bandzalkin Oct 20, 2026
ccc2b77
Merge #844 (event-echo fix through the host stack)
bandzalkin Oct 20, 2026
d697f43
style: drop merge-seam blank lines from the reporter files
bandzalkin Oct 20, 2026
89dab59
Merge #841 (format fix on the activity stack)
bandzalkin Oct 20, 2026
b470206
Merge #874 (format fix on the activity stack)
bandzalkin Oct 20, 2026
0704bb0
Merge #844 (format fix through the host stack)
bandzalkin Oct 20, 2026
d764959
fix(activity): consult the tool-name gate at report time
bandzalkin Oct 21, 2026
447e419
Merge #874 (live tool-name gate) into #850
bandzalkin Oct 21, 2026
5f3910a
Merge #874 (live tool-name gate) into #844
bandzalkin Oct 21, 2026
c516014
Merge #0 #850 (chain)
bandzalkin Oct 21, 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
20 changes: 18 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ jobs:
BAND_ALLOW_MISSING_FRAMEWORKS: "1"
run: uv run --all-packages pytest tests/e2e/baseline/ --collect-only -q --no-cov

- name: Run CrewAI activity tests
env:
BAND_ALLOW_MISSING_FRAMEWORKS: "1"
run: uv run --all-packages pytest tests/integrations/crewai/activity/

- name: Run crewai tests
env:
# dev-crewai venv lacks langgraph/anthropic/parlant/pydantic-ai/etc.;
Expand All @@ -171,7 +176,7 @@ jobs:
# Only the tests that *need* this venv. Every other crewai test fakes the
# package through sys.modules, so the `test` job above already runs it and
# a second pass here would add no signal — just a path to forget.
# tests/framework_conformance/test_crewai_job_coverage.py keeps this honest.
# tests/framework_conformance/crewaicoverage/ keeps this honest.
run: |
uv run --all-packages pytest \
tests/adapters/test_crewai_flow_phase3.py \
Expand Down Expand Up @@ -233,6 +238,12 @@ jobs:
BAND_TEST_EXTRA: "dev-strands-nooa"
run: uv run --all-packages pytest tests/e2e/baseline/ --collect-only -q --no-cov

- name: Run Strands activity tests
env:
BAND_ALLOW_MISSING_FRAMEWORKS: "1"
BAND_TEST_EXTRA: "dev-strands-nooa"
run: uv run --all-packages pytest tests/adapters/strands/activity/

- name: Run Strands and NOOA tests
env:
# dev-strands-nooa lacks modern framework packages;
Expand Down Expand Up @@ -302,6 +313,11 @@ jobs:
BAND_ALLOW_MISSING_FRAMEWORKS: "1"
run: uv run --all-packages pytest tests/e2e/baseline/ --collect-only -q --no-cov

- name: Run Parlant activity tests
env:
BAND_ALLOW_MISSING_FRAMEWORKS: "1"
run: uv run --all-packages pytest tests/integrations/parlant/activity/

- name: Run parlant tests
env:
# dev-parlant venv lacks langgraph/anthropic-adapter/pydantic-ai/etc.;
Expand All @@ -310,7 +326,7 @@ jobs:
# Only the tests that *need* this venv. Every other parlant test fakes the
# package through sys.modules, so the `test` job above already runs it and
# a second pass here would add no signal — just a path to forget.
# tests/framework_conformance/test_parlant_job_coverage.py keeps this honest.
# tests/framework_conformance/parlantcoverage/ keeps this honest.
run: |
uv run --all-packages pytest \
tests/integrations/parlant/test_catalog.py \
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,28 @@ adapter = GeminiAdapter(GeminiAdapterConfig(model="gemini-3.8-flash"))

Use [examples/run_agent.py](examples/run_agent.py) when you want one command that can switch between LangGraph, Pydantic AI, Anthropic, Claude SDK, Parlant, CrewAI, Codex, A2A bridge, and A2A gateway. Use the per-framework directories under [examples/](examples/) when you want the adapter-specific setup.

### Host-side Harness Probes

`await adapter.preflight(timeout_s=60)` reports a harness handshake as a
`PreflightResult` (`ok`, `reason`, `remedy`). A successful handshake does not prove
that the account can run inference. Adapters without a standalone handshake
implementation explicitly report that it is unsupported.

`await adapter.probe_models(timeout_s=60)` discovers a canonical `ModelCatalog`
without creating or selecting a Band room. Unsupported adapters raise
`BandConfigError`; an empty catalog is not a substitute for unsupported discovery.
The existing room-facing `list_models` hook remains separate.

Catalog entries preserve advertised provider and default metadata. An entry's
`efforts=None` means the harness did not report effort support; `efforts=()` means
it explicitly offers none. `current_model` is populated only when the harness
reports an actual selection.

Timeouts bound how long a caller waits, not ownership of a partially started
transport. Always call `await adapter.cleanup_all()` when disposing of an adapter.
An incomplete close raises `CleanupIncompleteError` and retains its resources for
a later cleanup attempt rather than reporting successful disposal.

### Process Signals

`agent.run()` and `agent.run_forever()` stop the agent on SIGTERM/SIGINT and restore the process's previous handlers when they return. A host that owns its process signals (a launchd/systemd service, a desktop app, a test runner) passes `install_signal_handlers=False` and calls `agent.stop()` from its own handler; `run_forever` then returns. `GracefulShutdown` is a ready-made handler that does this, and `run_with_graceful_shutdown(agent)` wraps it. Put `GracefulShutdown` outside `agent` so its handlers are registered before `start()` and a mid-initialize SIGTERM can still tear down:
Expand Down
71 changes: 71 additions & 0 deletions docs/acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,74 @@ failure fails that room turn visibly instead of falling back.
credential-redacted; values under credential-named fields are redacted in full.
- The ACP Python client drains queued update handlers before surfacing a prompt error, so
a slow client handler can still delay its own `prompt()`.

## Copilot host model catalog

`await copilot_adapter.probe_models(timeout_s=60.0)` returns the canonical
`ModelCatalog` for the configured Copilot account without a Band connection,
room, room workspace, ACP session or model turn. Install the optional
`band-sdk[acp,copilot_sdk]` dependencies to use it; ordinary ACP construction and
room operation do not require the Copilot SDK.

Model probing requires a released `github-copilot-sdk>=1.0.5,<2`. The probe
rejects an unsupported version or subprocess-field layout before starting
the CLI.

The probe starts the configured Copilot executable through the SDK's stdio
protocol, removing only the ACP-specific `--acp` argument. It retains other
configured arguments and uses `cwd` itself, not a fabricated room directory.
Executable lookup honors the configured child `PATH` and working directory,
including configured `PATHEXT` rules on Windows for both bare and path-qualified
commands. Windows lookup confirms an existing file before SDK startup. Every
searched candidate is absolute; the host's current directory is not implicitly
searched. The probe does not choose the SDK's bundled executable or fall back to
an unrelated host executable when the configured search path has no match.

For a Windows JavaScript entrypoint, the probe resolves `node.exe` through the
configured `PATH` and working directory, then supplies its absolute path and the
script argument through the public SDK stdio connection. This preserves the SDK's
native Node interpreter choice without letting a bare `node` search the host
environment. A missing configured interpreter fails before starting a child.

The child receives ACP's trimmed inherited environment plus the backend and
configured environment. Put token variables in `config.env`, where
`COPILOT_GITHUB_TOKEN` takes precedence over `GH_TOKEN`, then `GITHUB_TOKEN`.
`config.github_token` supplies the lowest-priority `GITHUB_TOKEN` convenience
value; an explicit `config.env["GITHUB_TOKEN"]` overrides it. Unrelated token
variables exported only in the host process are not implicitly injected.
Stored CLI login remains available through the inherited home environment.

This lists hosted-account models, not BYOK provider catalogs. It does not prove
inference entitlement or validate configured model/effort selection, MCP servers
or tools. Advertised reasoning efforts remain `None` when unknown and `()`
when explicitly absent; the probe invents neither efforts nor provider, default
or currently selected model metadata. A successful empty catalog is distinct
from a missing SDK, unsupported operation or failed discovery. This explicit
host API does not replace the startup `list_models` hook or run automatically
when a room starts.

Ordinary discovery failures raise a safe `BandConfigError`; cancellation
propagates. `timeout_s` bounds discovery reporting and, separately, cleanup
reporting. `CleanupIncompleteError` means the adapter still owns the client,
physical child or late startup: retry `await adapter.cleanup_all()` before
discarding it. Band's public error does not include the upstream diagnostic,
but the Copilot SDK can emit its own traceback before Band handles a failure.

The SDK 1.x public `stop()` can erase its child references even when termination
or waiting fails, and it exposes no public process-exit handle. A narrow,
version- and type-checked teardown bridge therefore observes its known
`_process` and `_cli_process` `subprocess.Popen` handles before public `stop()`,
retains them across failures, and confirms physical exit before releasing
custody. Startup still uses the unmodified public SDK `start()` method.

```python notest
from band.adapters.copilot_acp import CopilotACPAdapter

adapter = CopilotACPAdapter()
try:
catalog = await adapter.probe_models(timeout_s=15.0)
model_ids = catalog.model_ids
finally:
await adapter.cleanup_all()
```

12 changes: 12 additions & 0 deletions docs/turn-outcome.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,15 @@ once. It does not rewrite the original delivery's settled acknowledgement.
Explicit interruption invalidates its activity; leave and shutdown clear the
indicator while adapter residency retains responsibility for released workers.
The authoritative idle report finishes before a replacement reports working.

`SessionConfig.enable_working_state` controls both the indicator and activity
detail, independently of chat narration and tool capabilities. Changes and
keep-alives share one serialized reporter. The duration cap stops both kinds
of refresh without cancelling provider work; actual completion still clears.

Detail compatibility is learned from the `X-API-Version` header the platform
puts on every activity response. Reports stay boolean until a response in the
process advertises agent API 1.25.0 or later; a missing or older version keeps
boolean reporting. A detail-bearing HTTP 422 permanently disables detail across
all links in the process and retries that report once without detail, within
its original deadline.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ claude_sdk = [
"mcp>=1.28.1,<2",
]
copilot_sdk = [
"github-copilot-sdk>=1.0.5", # Bundles/downloads the Copilot CLI runtime; needs GitHub Copilot auth
"github-copilot-sdk>=1.0.5,<2", # Bundles/downloads the Copilot CLI runtime; needs GitHub Copilot auth
]
parlant = [
# Floor at 3.3.2: older versions stall the in-process server's post-preamble
Expand Down Expand Up @@ -217,7 +217,7 @@ dev = [
# Include claude_sdk for testing
"claude-agent-sdk>=0.2.159",
# Include copilot_sdk for testing
"github-copilot-sdk>=1.0.5",
"github-copilot-sdk>=1.0.5,<2",
# Include langgraph for testing
"langchain>=1.0.0",
"langchain-core>=1.2.11",
Expand Down
36 changes: 18 additions & 18 deletions src/band/adapters/agno.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
is_image_passthrough_result,
redact_tool_call_args,
)
from band.runtime.working_state import end_tool_activity, report_tool_activity

try:
from agno.models.message import Message
Expand Down Expand Up @@ -451,10 +452,7 @@ async def _run_agent(
) -> RunOutput | None:
"""Run the Agno agent with the room's tools bound for this call.

When ``Emit.TOOL_CALLS`` is enabled the run is streamed so tool_call /
tool_result events are emitted *as each tool runs* (see
:meth:`_run_streamed`), matching the other adapters' live reporting.
Otherwise it runs non-streaming, exactly as before.
Consume provider progress as it happens, independently of chat narration.
"""
try:
agent = self._agent
Expand All @@ -469,17 +467,14 @@ async def _run_agent(
session_id,
)
with _bind_room_tools(tools):
if Emit.TOOL_CALLS in self.features.emit:
response = await self._run_streamed(
agent,
messages,
tools,
session_id=session_id,
room_id=room_id,
msg_id=msg_id,
)
else:
response = await agent.arun(input=messages, session_id=session_id)
response = await self._run_streamed(
agent,
messages,
tools,
session_id=session_id,
room_id=room_id,
msg_id=msg_id,
)
# Agno swallows run exceptions into a normal-looking return value
# (status=error); surface it so the shared except path below treats
# the turn as failed rather than as a silent empty reply.
Expand Down Expand Up @@ -532,9 +527,14 @@ async def _run_streamed(
# None, which would read as a successful turn with no output.
raise AgnoRunError(_error_summary(item.content))
else:
await self._emit_stream_event(
item, tools, room_id=room_id, msg_id=msg_id
)
if isinstance(item, ToolCallStartedEvent) and item.tool is not None:
await report_tool_activity(tools, item.tool.tool_name or "")
elif isinstance(item, ToolCallCompletedEvent):
await end_tool_activity(tools)
if Emit.TOOL_CALLS in self.features.emit:
await self._emit_stream_event(
item, tools, room_id=room_id, msg_id=msg_id
)
return final

def _persist_turn(self, room_id: str, response: RunOutput) -> None:
Expand Down
106 changes: 54 additions & 52 deletions src/band/adapters/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
redact_tool_call_args,
)
from band.runtime.tools.schema import is_failed_tool_output
from band.runtime.working_state import tool_activity

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -478,61 +479,62 @@ async def _process_tool_calls(

logger.debug("Executing tool: %s with input: %s", tool_name, tool_input)

# Report tool call if enabled (JSON format with tool_call_id for linking)
# Best-effort: event reporting must never crash tool execution
if Emit.TOOL_CALLS in self.features.emit:
async with tool_activity(tools, tool_name):
# Report tool call if enabled (JSON format with tool_call_id for linking)
# Best-effort: event reporting must never crash tool execution
if Emit.TOOL_CALLS in self.features.emit:
try:
await tools.send_event(
content=json.dumps(
{
ToolEventKey.NAME: tool_name,
ToolEventKey.ARGS: redact_tool_call_args(
tool_name, tool_input
),
ToolEventKey.TOOL_CALL_ID: tool_use_id,
}
),
message_type="tool_call",
)
except Exception as e: # noqa: BLE001 -- tool calls may raise any exception type; must surface to the LLM as an error string, not crash the turn
logger.warning(
"Failed to send tool_call event: %s",
e,
)

# Execute tool (check custom tools first, then platform tools)
try:
await tools.send_event(
content=json.dumps(
{
ToolEventKey.NAME: tool_name,
ToolEventKey.ARGS: redact_tool_call_args(
tool_name, tool_input
),
ToolEventKey.TOOL_CALL_ID: tool_use_id,
}
),
message_type="tool_call",
)
custom_tool = find_custom_tool(self._custom_tools, tool_name)
if custom_tool:
result = await execute_custom_tool(
custom_tool, tool_input, turn=tools.turn
)
succeeded = not is_failed_tool_output(result)
else:
outcome = await tools.execute_tool_call_structured(
tool_name, tool_input
)
result = outcome.value
succeeded = outcome.ok
if succeeded and tool_name == BandTool.NO_REPLY:
declined = True
if not succeeded:
failed = True
if is_image_passthrough_result(tool_name, result):
content = _image_tool_result_content(result)
result_str = image_block_placeholder(len(content))
else:
content = result_str = (
json.dumps(result, default=str)
if not isinstance(result, str)
else result
)
is_error = False
except Exception as e: # noqa: BLE001 -- tool calls may raise any exception type; must surface to the LLM as an error string, not crash the turn
logger.warning(
"Failed to send tool_call event: %s",
e,
)

# Execute tool (check custom tools first, then platform tools)
try:
custom_tool = find_custom_tool(self._custom_tools, tool_name)
if custom_tool:
result = await execute_custom_tool(
custom_tool, tool_input, turn=tools.turn
)
succeeded = not is_failed_tool_output(result)
else:
outcome = await tools.execute_tool_call_structured(
tool_name, tool_input
)
result = outcome.value
succeeded = outcome.ok
if succeeded and tool_name == BandTool.NO_REPLY:
declined = True
if not succeeded:
failed = True
if is_image_passthrough_result(tool_name, result):
content = _image_tool_result_content(result)
result_str = image_block_placeholder(len(content))
else:
content = result_str = (
json.dumps(result, default=str)
if not isinstance(result, str)
else result
)
is_error = False
except Exception as e: # noqa: BLE001 -- tool calls may raise any exception type; must surface to the LLM as an error string, not crash the turn
failed = True
content = result_str = f"Error: {e}"
is_error = True
logger.error("Tool %s failed: %s", tool_name, e)
content = result_str = f"Error: {e}"
is_error = True
logger.error("Tool %s failed: %s", tool_name, e)

# Report tool result if enabled (JSON format with tool_call_id for linking)
# Best-effort: event reporting must never crash tool execution
Expand Down
Loading
Loading