Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2442944
feat(acp): pass Copilot model and reasoning effort as CLI flags
bandzalkin Sep 29, 2026
c744702
feat(sdk): add a model selection contract to every adapter
AlexanderZ-Band Sep 30, 2026
602e78a
fix(acp): select Copilot model and effort from the live session catalog
AlexanderZ-Band Sep 30, 2026
20209ea
test(e2e): cover Copilot's typed model selection live
AlexanderZ-Band Sep 30, 2026
da711b9
fix(acp): check runtime model switches against the session's live cat…
AlexanderZ-Band Sep 30, 2026
40767bb
test(e2e): refuse an unadvertised Copilot selection under BYOK and ho…
AlexanderZ-Band Sep 30, 2026
3ab4b5b
test(acp): model the fake Copilot on its probed catalog behavior
AlexanderZ-Band Sep 30, 2026
079de57
fix(acp): let the agent's reply, not a cached catalog, confirm each set
AlexanderZ-Band Sep 30, 2026
a50dab1
refactor: trim model selection startup and ACP apply plumbing
AlexanderZ-Band Sep 30, 2026
68b8404
feat(acp): keep a room's model switch across its later sessions
AlexanderZ-Band Sep 30, 2026
b92eebb
fix(acp): carry forward what a switched session runs, not a merged se…
AlexanderZ-Band Sep 30, 2026
87ea6f8
fix(acp): make a room's remembered switch safe to carry and to drop
AlexanderZ-Band Sep 30, 2026
c6ab462
fix(acp): forget a room's switch only when the agent refuses it
AlexanderZ-Band Sep 30, 2026
3033c63
fix(acp): remember a room's switch only when it succeeds
AlexanderZ-Band Sep 30, 2026
0b76aeb
refactor(acp): read each model step from the runtime's catalog
AlexanderZ-Band Sep 30, 2026
11a4577
test(e2e): prove a live Copilot model and effort switch
AlexanderZ-Band Sep 30, 2026
41d59d2
fix(acp): name an ended session as a switch's cause and heal a lost s…
AlexanderZ-Band Sep 30, 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: 1 addition & 1 deletion .github/scripts/setup-copilot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ set -euo pipefail
# Pinned for the same reason as codex: an unpinned global install lets the CLI
# float between runs, so a CLI change lands as an unrelated-looking lane
# failure. Bump deliberately.
COPILOT_CLI_VERSION="${COPILOT_CLI_VERSION:-1.0.80}"
COPILOT_CLI_VERSION="${COPILOT_CLI_VERSION:-1.0.89}"

npm install -g "@github/copilot@${COPILOT_CLI_VERSION}"
copilot --version
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ agent keys and platform URLs should stay aligned with `.env.test` /
- `GOOGLE_API_KEY`: Google API key for Gemini Developer API (for Gemini/Google ADK examples)
- `GOOGLE_GENAI_USE_VERTEXAI`: Set to `true` to use Vertex AI instead of Gemini Developer API
- `GOOGLE_CLOUD_PROJECT`: Google Cloud project ID (required when using Vertex AI)
- `GITHUB_TOKEN`: A Copilot-entitled GitHub token. The baseline `copilot_sdk` and `copilot_acp` builders use Anthropic BYOK and never read it; the only baseline reader is the single Copilot-hosted auth smoke (`test_copilot_hosted_auth_replies`, skips when unset). Also used by Copilot-hosted examples outside the baseline; optional when a stored `copilot login` is present.
- `GITHUB_TOKEN`: A Copilot-entitled GitHub token. The baseline `copilot_sdk` and `copilot_acp` builders use Anthropic BYOK and never read it; the only baseline readers are the Copilot-hosted smokes (`test_copilot_hosted_auth_replies`, `test_copilot_switches_model_and_effort_in_a_live_room`, and the `hosted-effort` cell of `test_copilot_turn_fails_loudly_on_an_unadvertised_selection`, which skip when unset). Also used by Copilot-hosted examples outside the baseline; optional when a stored `copilot login` is present.
- `E2E_TESTS_ENABLED`: Set to `true` to enable E2E tests (default: disabled)
- `E2E_LLM_MODEL`: OpenAI model for E2E tests (default: `gpt-5.4-mini`)
- `E2E_ANTHROPIC_MODEL`: Anthropic model for E2E tests (default: `claude-haiku-4-5` — the baseline judge uses structured outputs, which older Haiku models do not support)
Expand Down
10 changes: 10 additions & 0 deletions src/band/adapters/copilot_acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

from typing_extensions import Unpack

from band.core.model_catalog import ModelSelection
from band.core.types import FeatureKwargs
from band.integrations.acp.client_adapter import (
DEFAULT_TURN_TIMEOUT_SECONDS,
Expand Down Expand Up @@ -70,6 +71,12 @@ class CopilotACPAdapterConfig:
resolve_session_config: SessionConfigResolver | None = None
resolve_permission: PermissionResolver | None = None
turn_timeout_s: float = DEFAULT_TURN_TIMEOUT_SECONDS
# Selected from each session's advertised catalog; a value it does not
# offer fails the turn. Only GitHub-hosted sessions advertise these; under
# BYOK the provider env (COPILOT_MODEL) picks the model. Exclusive with
# resolve_session_config.
model: str | None = None
reasoning_effort: str | None = None


class CopilotACPAdapter(ACPClientAdapter):
Expand Down Expand Up @@ -126,6 +133,9 @@ def __init__(
"inject_band_tools": config.inject_band_tools,
"custom_section": config.custom_section,
"resolve_session_config": config.resolve_session_config,
"model_selection": ModelSelection(
model=config.model, reasoning_effort=config.reasoning_effort
),
"resolve_permission": config.resolve_permission,
"turn_timeout_s": config.turn_timeout_s,
}
Expand Down
38 changes: 11 additions & 27 deletions src/band/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
from band.core.protocols import FrameworkAdapter, Preprocessor
from band.core.simple_adapter import SimpleAdapter
from band.preprocessing.default import DefaultPreprocessor
from band.runtime.capabilities import prune_unsupported
from band.runtime.platform_runtime import PlatformRuntime
from band.runtime.startup import release_adapter, start_adapter
from band.runtime.types import (
AgentConfig,
ContactEventConfig,
Expand Down Expand Up @@ -257,18 +257,11 @@ async def start(self) -> None:
# setattr rather than assignment: FrameworkAdapter is a Protocol, so
# a duck-typed adapter may not declare the attribute.
setattr(self._adapter, "platform", self._runtime.connection) # noqa: B010
if isinstance(self._adapter, SimpleAdapter):
# A bare FrameworkAdapter has no SUPPORTED_CAPABILITIES, so it
# cannot request a gated capability in the first place and
# takes no part in negotiation.
self._adapter.apply_effective_features(
prune_unsupported(
self._adapter.features, self._runtime.feature_flags
)
)
await self._adapter.on_started(
self._runtime.agent_name,
self._runtime.agent_description,
await start_adapter(
self._adapter,
agent_name=self._runtime.agent_name,
agent_description=self._runtime.agent_description,
feature_flags=self._runtime.feature_flags,
)

# 3. NOW start message processing (connects WebSocket)
Expand All @@ -282,10 +275,10 @@ async def start(self) -> None:
on_control=getattr(self._adapter, "on_interrupt", None),
)
except BaseException:
# on_started may have acquired resources (e.g. a CLI runtime
# subprocess); a failed start must release them — stop() won't
# run for an agent that never started.
await self._cleanup_adapter()
# Adapter startup may have acquired resources (e.g. a CLI
# runtime subprocess); a failed start must release them —
# stop() won't run for an agent that never started.
await release_adapter(self._adapter)
raise
except BaseException:
# Idempotent: a failure inside runtime.start() already released.
Expand Down Expand Up @@ -323,23 +316,14 @@ async def stop(self, timeout: float | None = None) -> bool:
# Always release adapter-wide resources (e.g. a CLI runtime
# subprocess, a self-hosted MCP server, an external registration),
# even when the runtime fails to stop cleanly.
await self._cleanup_adapter()
await release_adapter(self._adapter)
self._started = False
_running_agents.discard(self)
logger.info(
"Agent stopped: %s (graceful=%s)", self._runtime.agent_name, graceful
)
return graceful

async def _cleanup_adapter(self) -> None:
"""Release adapter-wide resources, best-effort."""
cleanup_all = getattr(self._adapter, "cleanup_all", None)
if cleanup_all is not None:
try:
await cleanup_all()
except Exception:
logger.exception("Adapter cleanup_all failed")

async def run(
self, shutdown_timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT
) -> None:
Expand Down
148 changes: 148 additions & 0 deletions src/band/core/model_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Model and reasoning-effort selection checked against a harness catalog."""

from __future__ import annotations

from enum import StrEnum

from pydantic import BaseModel, ConfigDict

from band.core.exceptions import BandConfigError
from band.core.validation import listing


class ModelSetting(StrEnum):
"""A selectable model setting, valued as its ``ModelSelection`` field.

Declared in apply order: the model decides which efforts exist.
"""

MODEL = "model"
REASONING_EFFORT = "reasoning_effort"


class ModelSelection(BaseModel):
"""The model and reasoning effort an adapter is configured to use.

``None`` leaves the choice to the harness.
"""

model_config = ConfigDict(frozen=True)

model: str | None = None
reasoning_effort: str | None = None

def value_of(self, setting: ModelSetting) -> str | None:
return getattr(self, setting)

@property
def is_empty(self) -> bool:
return self.model is None and self.reasoning_effort is None


class ModelChoice(BaseModel):
"""One model a harness advertises.

``efforts`` is ``None`` when the harness does not report them for this
model (the effort goes unchecked), and ``()`` when the model offers none.
"""

model_config = ConfigDict(frozen=True)

id: str
label: str | None = None
efforts: tuple[str, ...] | None = None
default_effort: str | None = None
Comment thread
AlexanderZ-Band marked this conversation as resolved.


class ModelCatalog(BaseModel):
"""The models a harness advertises, and the one it currently uses."""

model_config = ConfigDict(frozen=True)

models: tuple[ModelChoice, ...]
current_model: str | None = None

@property
def model_ids(self) -> tuple[str, ...]:
return tuple(choice.id for choice in self.models)

def choice(self, model_id: str) -> ModelChoice | None:
return next((choice for choice in self.models if choice.id == model_id), None)


class ModelSelectionError(BandConfigError):
"""A configured model or effort is not what the harness advertises."""

def __init__(
self,
message: str,
*,
setting: ModelSetting,
value: str,
advertised: tuple[str, ...],
) -> None:
super().__init__(message)
self.setting = setting
self.value = value
self.advertised = advertised


def check_model_selection(
selection: ModelSelection, catalog: ModelCatalog | None
) -> None:
"""Raise ``ModelSelectionError`` when ``catalog`` rejects ``selection``.

A ``None`` catalog means the harness advertises nothing, so nothing is
checked. The effort is checked against the selected model, or the
catalog's current model when none is selected.
"""
if catalog is None:
return
if selection.model is not None:
_check_model(selection.model, catalog)
if selection.reasoning_effort is not None:
_check_effort(
selection.reasoning_effort,
model_id=selection.model or catalog.current_model,
catalog=catalog,
)


def _check_model(model_id: str, catalog: ModelCatalog) -> None:
if catalog.choice(model_id) is not None:
return
raise ModelSelectionError(
f'model "{model_id}" is not advertised; available: '
Comment thread
AlexanderZ-Band marked this conversation as resolved.
f"{listing(catalog.model_ids)}",
setting=ModelSetting.MODEL,
value=model_id,
advertised=catalog.model_ids,
)


def _check_effort(effort: str, *, model_id: str | None, catalog: ModelCatalog) -> None:
choice = catalog.choice(model_id) if model_id is not None else None
if choice is None or choice.efforts is None or effort in choice.efforts:
return
message = (
f'reasoning effort "{effort}" is not advertised for model "{model_id}"; '
f"available: {listing(choice.efforts)}"
if choice.efforts
else f'model "{model_id}" offers no reasoning effort'
)
raise ModelSelectionError(
message,
setting=ModelSetting.REASONING_EFFORT,
value=effort,
advertised=choice.efforts,
)


__all__ = [
"ModelCatalog",
"ModelChoice",
"ModelSelection",
"ModelSelectionError",
"ModelSetting",
"check_model_selection",
]
68 changes: 61 additions & 7 deletions src/band/core/simple_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
from band.client.rest import AsyncRestClient
from band.client.streaming import ControlMode
from band.core.exceptions import BandConfigError
from band.core.model_catalog import (
ModelCatalog,
ModelSelection,
check_model_selection,
)
from band.core.protocols import AgentToolsProtocol, HistoryConverter
from band.core.types import (
USAGE_EVENT_TYPE,
Expand All @@ -26,6 +31,7 @@
PlatformMessage,
TurnUsage,
)
from band.core.validation import listing
from band.logging_config import trace_context_scope

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -58,7 +64,7 @@ def _normalize_flags(


def _describe(values: Iterable[Emit] | Iterable[Capability]) -> str:
return ", ".join(sorted(v.value for v in values)) or "(none)"
return listing(sorted(v.value for v in values))


class SimpleAdapter(ABC, Generic[H]):
Expand Down Expand Up @@ -170,12 +176,11 @@ def _resolve_features(
def apply_effective_features(self, features: AdapterFeatures) -> None:
"""Adopt ``features`` as the post-negotiation feature set.

Called by ``Agent.start()``/``OneShotInvoker.startup()`` after pruning
capabilities the deployment doesn't serve (see
``runtime.capabilities.prune_unsupported``). Override if the adapter
caches something derived from ``self.features`` at construction (e.g.
a tool-definition list built in ``__init__``) so that cache rebuilds
too.
Called from ``runtime.startup.start_adapter`` after pruning capabilities
the deployment doesn't serve (see ``runtime.capabilities.prune_unsupported``).
Override if the adapter caches something derived from ``self.features`` at
construction (e.g. a tool-definition list built in ``__init__``) so that
cache rebuilds too.
"""
self.features = features

Expand Down Expand Up @@ -299,6 +304,55 @@ async def cleanup_all(self) -> None:
release here.
"""

@property
def model_selection(self) -> ModelSelection:
"""The model and reasoning effort this adapter is configured to use.

Adapters with typed model settings override this to project them. The
default selects nothing, leaving the choice to the harness.
"""
return ModelSelection()

async def list_models(self) -> ModelCatalog | None:
"""The models this adapter's harness advertises, or ``None``.

Adapters whose harness reports a catalog override this; ``None`` means
it cannot, so the configured selection goes unchecked. It runs after
``on_started``, so it may use a client started there. Harnesses whose
catalog belongs to a live session (ACP) keep ``None`` and check the
selection per session instead.
"""
return None

async def apply_model_selection(
self, selection: ModelSelection, *, room_id: str
) -> None:
"""Switch ``room_id``'s live conversation to ``selection``.

Adapters whose harness can change model mid-conversation override
this. The default refuses, so a requested switch is never silently
ignored.
"""
del selection, room_id
raise BandConfigError(
f"{type(self).__name__} does not support switching models at runtime"
)

async def startup(
self, agent_name: str, agent_description: str, *, features: AdapterFeatures
) -> None:
"""Start for an agent: negotiated ``features``, ``on_started``, then the
model-selection check.

The runtime calls this once per start; adapters override the hooks it
runs, not this.
"""
self.apply_effective_features(features)
await self.on_started(agent_name, agent_description)
selection = self.model_selection
Comment thread
AlexanderZ-Band marked this conversation as resolved.
if not selection.is_empty:
check_model_selection(selection, await self.list_models())

async def on_started(self, agent_name: str, agent_description: str) -> None:
"""Override for post-start setup."""
self.agent_name = agent_name
Expand Down
10 changes: 9 additions & 1 deletion src/band/core/validation.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
"""Shared cross-field validation rules used by tool input models and their
``AgentTools``-level backstops (some adapters hand-register tools as plain
functions and never construct/validate the input model)."""
functions and never construct/validate the input model), and the shared
wording of rejection messages."""

from __future__ import annotations

from collections.abc import Iterable


def listing(values: Iterable[str]) -> str:
"""The accepted values as a rejection message shows them."""
return ", ".join(values) or "(none)"


def _join_with_or(names: list[str]) -> str:
if len(names) == 1:
Expand Down
Loading
Loading