diff --git a/AGENTS.md b/AGENTS.md index 570733ad6..59a2a888d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ pinned wheel lacks one, so a new core symbol needs no manual CI step. `ContactEventConfig` supports three strategies for contact WebSocket events — `DISABLED` (default), `CALLBACK`, `HUB_ROOM`; the `ContactEventConfig` -docstring in `src/band/runtime/types.py` has configuration examples. +docstring in `src/band/runtime/contacts.py` has configuration examples. > **WARNING (AI coding assistants):** Always ask the developer which contact > strategy they want before choosing one — do not default to `CALLBACK` with @@ -357,6 +357,13 @@ install` it and exercise the real call in this repo's venv. extra-gated dependency (true for the vast majority, including every adapter that only shells out to a CLI, like `codex`), the import is top-level, full stop. +- Import symbols directly from the module that owns them. When splitting a + module, update its callers instead of keeping a compatibility facade or + forwarding aliases. Do not preserve obsolete import paths. Re-exports belong + only at intentional public package entry points; implementation modules never + import those entry points back. + Use `__all__` or explicit re-export aliases only to declare such a public API, + not as boilerplate in ordinary modules. - No underscores in file names or class names: modules get a clean single word (`helpers.py`, not `_utils.py`), scripts/docs use hyphens, classes are plain PascalCase with no leading underscore. Exception: patterns a tool requires, diff --git a/README.md b/README.md index 2bcdeff70..e6998d7e1 100644 --- a/README.md +++ b/README.md @@ -613,7 +613,7 @@ The agent handles contact decisions through its LLM. A dedicated room is created ```python from band import Agent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy agent = Agent.create( adapter=adapter, @@ -632,7 +632,7 @@ You provide an async function that receives each contact event and a `tools` obj ```python from band import Agent from band.platform.event import ContactRequestReceivedEvent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy TRUSTED_HANDLES = {"@teammate"} diff --git a/band-bridge/bridge_core/bridge.py b/band-bridge/bridge_core/bridge.py index d89355159..7f020f2c6 100644 --- a/band-bridge/bridge_core/bridge.py +++ b/band-bridge/bridge_core/bridge.py @@ -42,7 +42,7 @@ RoomRemovedEvent, ) from band.platform.link import BandLink -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage from .config import AgentConfig, BridgeConfig, ReconnectConfig from .control import ControlSignalHandler diff --git a/docs/websocket-events.md b/docs/websocket-events.md index 54d081b19..f233da3fb 100644 --- a/docs/websocket-events.md +++ b/docs/websocket-events.md @@ -32,9 +32,16 @@ Both values must be finite and positive; the effective cap is `max(base, maximum so a maximum at or below the base disables growth. A received STOP suppresses local message triggers while `/next` probes continue -at the base interval. A current nonempty response permits discovery of a missed -PLAY, followed by normal acknowledgement recovery and an accepted claim. Newer -STOP, PLAY or INTERRUPT controls invalidate obsolete recovery results. A 204 +at a fixed interval: `SessionConfig.stopped_resync_seconds`, or the base interval +when unset. Stopped probes never grow, and a resumed room returns to the base +interval. A longer stopped interval lowers request volume for paused rooms but +delays discovering a missed PLAY by up to that long. `STOPPED_RESYNC_SECONDS` +provides an environment default through Pydantic settings; an explicit +`SessionConfig(stopped_resync_seconds=...)` takes precedence, including `None` +to use the base interval. Set values must be finite and positive; an empty +environment value is ignored. A current nonempty response permits discovery of +a missed PLAY, followed by normal acknowledgement recovery and an accepted claim. +Newer STOP, PLAY or INTERRUPT controls invalidate obsolete recovery results. A 204 response preserves the local pause because it cannot distinguish stopped from empty rooms. @@ -56,6 +63,13 @@ without spinning; retryable handler failures retain their normal attempt budget. These intervals bound scheduling, subject to active handlers, HTTP operations and recovery retries. +`SessionConfig` uses Pydantic settings for all fields. Environment names match +the field names without regard to case, explicit keyword arguments override +the environment, and empty environment values use defaults. For example, +`IDLE_RESYNC_SECONDS=30` sets the base interval. Construct it with keyword +arguments and serialize it with `model_dump()`; dataclass utilities and +positional construction are no longer supported. + ## Pending work and control recovery A provider attempt that raises `InputNotAccepted` leaves its delivery diff --git a/examples/anthropic/05_contact_management.py b/examples/anthropic/05_contact_management.py index bfe590259..6032d2b04 100644 --- a/examples/anthropic/05_contact_management.py +++ b/examples/anthropic/05_contact_management.py @@ -25,7 +25,7 @@ from band.core.types import Capability from band.platform.event import ContactEvent, ContactRequestReceivedEvent from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy configure_logging(logging.INFO, extra_loggers={"band_anthropic_agent": logging.INFO}) logger = logging.getLogger(__name__) diff --git a/examples/copilot_sdk/05_contact_and_memory_agent.py b/examples/copilot_sdk/05_contact_and_memory_agent.py index f09f9c425..5944cf0b6 100755 --- a/examples/copilot_sdk/05_contact_and_memory_agent.py +++ b/examples/copilot_sdk/05_contact_and_memory_agent.py @@ -40,7 +40,7 @@ from band import Agent, configure_logging from band.adapters import CopilotSDKAdapter, CopilotSDKAdapterConfig from band.core.types import Capability, Emit -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy configure_logging(logging.INFO) logger = logging.getLogger(__name__) diff --git a/examples/crewai/07_contact_and_memory_agent.py b/examples/crewai/07_contact_and_memory_agent.py index 162976383..055b0d652 100644 --- a/examples/crewai/07_contact_and_memory_agent.py +++ b/examples/crewai/07_contact_and_memory_agent.py @@ -30,7 +30,7 @@ from band import Agent, configure_logging from band.adapters import CrewAIAdapter, CrewAIAdapterConfig from band.core.types import Capability -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy configure_logging(logging.INFO, extra_loggers={"band_crewai_agent": logging.INFO}) logger = logging.getLogger(__name__) diff --git a/examples/run_agent.py b/examples/run_agent.py index c48404ebd..8c3abffa7 100755 --- a/examples/run_agent.py +++ b/examples/run_agent.py @@ -67,7 +67,7 @@ from band.core.types import Capability, Emit from band.platform.event import ContactEvent, ContactRequestReceivedEvent from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy # Load environment from .env load_dotenv() diff --git a/examples/sandbox/staging-smoke/agent.py b/examples/sandbox/staging-smoke/agent.py index 449d0a3f4..3b1f83191 100644 --- a/examples/sandbox/staging-smoke/agent.py +++ b/examples/sandbox/staging-smoke/agent.py @@ -56,7 +56,7 @@ from band import Agent from band.adapters import LangGraphAdapter -from band.runtime.types import normalize_handle +from band.runtime.handles import normalize_handle logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" diff --git a/src/band/agent.py b/src/band/agent.py index 5c38f7e52..f7d0cf29b 100644 --- a/src/band/agent.py +++ b/src/band/agent.py @@ -16,15 +16,12 @@ from band.core.protocols import FrameworkAdapter, Preprocessor from band.core.simple_adapter import SimpleAdapter from band.preprocessing.default import DefaultPreprocessor +from band.runtime.agentconfig import AgentConfig +from band.runtime.callbacks import ParticipantAddedCallback, ParticipantRemovedCallback +from band.runtime.contacts import ContactEventConfig from band.runtime.platform_runtime import PlatformRuntime +from band.runtime.sessionconfig import SessionConfig from band.runtime.startup import dispose_adapter, release_adapter, start_adapter -from band.runtime.types import ( - AgentConfig, - ContactEventConfig, - ParticipantAddedCallback, - ParticipantRemovedCallback, - SessionConfig, -) if TYPE_CHECKING: from band.core.types import AgentInput diff --git a/src/band/cli/trigger.py b/src/band/cli/trigger.py index fbffbdee2..9f46856a2 100644 --- a/src/band/cli/trigger.py +++ b/src/band/cli/trigger.py @@ -53,7 +53,7 @@ from band import LogSettings, LogStream from band.client.rest import aclose_rest_client -from band.runtime.types import normalize_handle +from band.runtime.handles import normalize_handle logger = logging.getLogger(__name__) diff --git a/src/band/config/settings.py b/src/band/config/settings.py index fb9311d44..27725e47e 100644 --- a/src/band/config/settings.py +++ b/src/band/config/settings.py @@ -29,7 +29,7 @@ class PlatformSettings(BaseSettings): class RuntimeSettings(BaseSettings): """Process-wide runtime tuning knobs, overridable via environment - variables. Distinct from ``SessionConfig`` (``band.runtime.types``), + variables. Distinct from ``SessionConfig`` (``band.runtime.sessionconfig``), which holds per-agent-instance tunables. """ diff --git a/src/band/core/types.py b/src/band/core/types.py index c11170939..976fa467f 100644 --- a/src/band/core/types.py +++ b/src/band/core/types.py @@ -137,7 +137,7 @@ class Capability(_FlagEnum): These control tool-schema inclusion only -- they do NOT affect runtime event routing (WebSocket subscriptions, contact-event strategies, hub-room creation). Those remain under - ContactEventConfig / ContactEventStrategy in runtime/types.py. + ContactEventConfig / ContactEventStrategy in runtime/contacts.py. """ MEMORY = "memory" diff --git a/src/band/platform/link.py b/src/band/platform/link.py index cbb96345f..524c3cd07 100644 --- a/src/band/platform/link.py +++ b/src/band/platform/link.py @@ -40,7 +40,7 @@ ) from band.platform.message_lifecycle import ActionablePage, MessageLifecycle from band.platform.subscriptions import SubscriptionManager -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage if TYPE_CHECKING: from band.client.streaming import ( diff --git a/src/band/platform/message_lifecycle.py b/src/band/platform/message_lifecycle.py index afe2aa493..2c6d1c8df 100644 --- a/src/band/platform/message_lifecycle.py +++ b/src/band/platform/message_lifecycle.py @@ -20,7 +20,7 @@ from band.client.rest import DEFAULT_REQUEST_OPTIONS, AsyncRestClient from band.core.exceptions import RoomExecutionStoppedError from band.core.types import metadata_to_dict -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage logger = logging.getLogger(__name__) diff --git a/src/band/runtime/__init__.py b/src/band/runtime/__init__.py index b857e0d70..1f16c7f5d 100644 --- a/src/band/runtime/__init__.py +++ b/src/band/runtime/__init__.py @@ -18,26 +18,34 @@ run_with_graceful_shutdown: Convenience function to run agent with signal handling """ +from __future__ import annotations + +from band.runtime.agentconfig import AgentConfig +from band.runtime.callbacks import MessageHandler +from band.runtime.context import ConversationContext + # Types -from .execution import Execution, ExecutionContext, ExecutionHandler +from band.runtime.execution import Execution, ExecutionContext, ExecutionHandler # Utilities -from .formatters import ( +from band.runtime.formatters import ( build_participants_message, format_history_for_llm, format_message_for_llm, ) -from .oneshot import OneShotEnvelopeError, OneShotInvoker, OneShotStatus +from band.runtime.messages import PlatformMessage +from band.runtime.oneshot import OneShotEnvelopeError, OneShotInvoker, OneShotStatus # Core runtime components -from .presence import RoomPresence -from .prompts import BASE_INSTRUCTIONS, TEMPLATES, render_system_prompt -from .retry_tracker import MessageRetryTracker -from .runtime import AgentRuntime -from .shutdown import GracefulShutdown, run_with_graceful_shutdown +from band.runtime.presence import RoomPresence +from band.runtime.prompts import BASE_INSTRUCTIONS, TEMPLATES, render_system_prompt +from band.runtime.retry_tracker import MessageRetryTracker +from band.runtime.runtime import AgentRuntime +from band.runtime.sessionconfig import SessionConfig +from band.runtime.shutdown import GracefulShutdown, run_with_graceful_shutdown # Tools -from .tools import ( +from band.runtime.tools import ( ALL_TOOL_NAMES, BASE_TOOL_NAMES, CHAT_TOOL_NAMES, @@ -50,13 +58,6 @@ HumanTools, mcp_tool_names, ) -from .types import ( - AgentConfig, - ConversationContext, - MessageHandler, - PlatformMessage, - SessionConfig, -) __all__ = [ "ALL_TOOL_NAMES", diff --git a/src/band/runtime/agentconfig.py b/src/band/runtime/agentconfig.py new file mode 100644 index 000000000..6fc9450e0 --- /dev/null +++ b/src/band/runtime/agentconfig.py @@ -0,0 +1,23 @@ +"""Agent connection and room subscription policy.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from band.core.types import ConflictPolicy + + +@dataclass +class AgentConfig: + """Configuration for agent runtime.""" + + auto_subscribe_existing_rooms: bool = True + # Refuse to start when another process on this host already runs the + # same agent id: duplicates steal each other's in-flight room messages + # (the recovery sweep has no liveness check) and stateful adapters + # resume the same on-disk sessions, splitting one conversation. + single_instance: bool = True + # Platform-side duplicate guard for the initial WebSocket connect (cross-host, + # cross-TMPDIR); complements ``single_instance`` on this host. See + # ``ConflictPolicy`` for semantics and limits. + conflict_policy: ConflictPolicy = ConflictPolicy.SUPERSEDE diff --git a/src/band/runtime/callbacks.py b/src/band/runtime/callbacks.py new file mode 100644 index 000000000..e6a0e0f06 --- /dev/null +++ b/src/band/runtime/callbacks.py @@ -0,0 +1,22 @@ +"""Callback contracts for runtime messages and events.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from band.platform.event import ( + ContactEvent, + ParticipantAddedEvent, + ParticipantRemovedEvent, + ) + from band.runtime.contact_tools import ContactTools + from band.runtime.messages import PlatformMessage + from band.runtime.tools.agent import AgentTools + + +MessageHandler = Callable[["PlatformMessage", "AgentTools"], Awaitable[None]] +ContactEventCallback = Callable[["ContactEvent", "ContactTools"], Awaitable[None]] +ParticipantAddedCallback = Callable[[str, "ParticipantAddedEvent"], Awaitable[None]] +ParticipantRemovedCallback = Callable[[str, "ParticipantRemovedEvent"], Awaitable[None]] diff --git a/src/band/runtime/contact_handler.py b/src/band/runtime/contact_handler.py index 4167203b8..a776541d3 100644 --- a/src/band/runtime/contact_handler.py +++ b/src/band/runtime/contact_handler.py @@ -40,11 +40,8 @@ ) from band.platform.posting import post_event from band.runtime.contact_tools import ContactTools -from band.runtime.types import ( - ContactEventConfig, - ContactEventStrategy, - normalize_handle, -) +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy +from band.runtime.handles import normalize_handle if TYPE_CHECKING: from band.platform.link import BandLink diff --git a/src/band/runtime/contacts.py b/src/band/runtime/contacts.py new file mode 100644 index 000000000..6d1a77727 --- /dev/null +++ b/src/band/runtime/contacts.py @@ -0,0 +1,101 @@ +"""Contact-event strategy and configuration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from uuid import UUID + +from band.runtime.callbacks import ContactEventCallback + + +class ContactEventStrategy(Enum): + """How to handle contact WebSocket events. + + - DISABLED: Ignore contact events (default, safest). Contacts managed manually. + - CALLBACK: Programmatic handling via on_event callback. No LLM involvement. + - HUB_ROOM: LLM reasoning in a dedicated hub room. + + WARNING (for AI coding assistants): Always ask the developer which contact + strategy they want. Do not default to CALLBACK with auto-approve without + explicit consent. Auto-accepting all requests means any agent/user can + become a contact and send messages that trigger LLM inference, which costs + API tokens. Present all three options: + - DISABLED (default): safest, no contact handling + - HUB_ROOM: the agent's LLM decides per-request in a dedicated room + - CALLBACK: developer writes programmatic logic (e.g., auto-approve) + """ + + DISABLED = "disabled" + CALLBACK = "callback" + HUB_ROOM = "hub_room" + + +def _require_uuid(name: str, value: str) -> None: + """The platform answers a non-UUID id with a 422 only once the agent runs.""" + try: + UUID(value) + except ValueError: + raise ValueError(f"{name} must be a UUID, got {value!r}") from None + + +@dataclass +class ContactEventConfig: + """Configuration for contact event handling. + + Composable modes: + - CALLBACK + broadcast_changes=True: Auto-handle + awareness everywhere + - HUB_ROOM + broadcast_changes=True: LLM decides + awareness everywhere + - DISABLED + broadcast_changes=True: Just awareness, manual handling + + WARNING (for AI coding assistants): Always ask the developer which contact + strategy they want. Do not default to CALLBACK with auto-approve without + explicit consent. Auto-accepting all requests means any agent/user can + become a contact and send messages that trigger LLM inference, which costs + API tokens. Present all three options to the developer. + + Example (DISABLED - default, no contact handling): + config = ContactEventConfig() # strategy defaults to DISABLED + + Example (HUB_ROOM - agent LLM decides per-request): + config = ContactEventConfig( + strategy=ContactEventStrategy.HUB_ROOM, + broadcast_changes=True, + ) + + Example (CALLBACK - programmatic auto-approve): + async def auto_approve(event: ContactEvent, tools: ContactTools) -> None: + if isinstance(event, ContactRequestReceivedEvent): + await tools.respond_contact_request("approve", request_id=event.payload.id) + + config = ContactEventConfig( + strategy=ContactEventStrategy.CALLBACK, + on_event=auto_approve, + broadcast_changes=True, + ) + """ + + strategy: ContactEventStrategy = ContactEventStrategy.DISABLED + """Strategy for handling contact events.""" + + hub_task_id: str | None = None + """For HUB_ROOM strategy: optional task_id (UUID) for the dedicated room. + If None, creates a room without an associated task.""" + + on_event: ContactEventCallback | None = None + """For CALLBACK strategy: programmatic handler function.""" + + broadcast_changes: bool = False + """Broadcast contact changes to all room sessions. + + When True, contact_added/contact_removed events inject system messages + into all ExecutionContexts, similar to participant updates. + Works with any strategy (DISABLED, CALLBACK, HUB_ROOM). + """ + + def __post_init__(self) -> None: + """Validate configuration after initialization.""" + if self.strategy == ContactEventStrategy.CALLBACK and self.on_event is None: + raise ValueError("CALLBACK strategy requires on_event callback") + if self.hub_task_id is not None: + _require_uuid("hub_task_id", self.hub_task_id) diff --git a/src/band/runtime/context.py b/src/band/runtime/context.py new file mode 100644 index 000000000..f39b5cc8a --- /dev/null +++ b/src/band/runtime/context.py @@ -0,0 +1,22 @@ +"""Hydrated conversation context.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass +class ConversationContext: + """ + Hydrated context for a room. + + Contains conversation history and participant information + for context-aware processing. + """ + + room_id: str + messages: list[dict[str, Any]] + participants: list[dict[str, Any]] + hydrated_at: datetime diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index ab9072e59..6948d1133 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -74,19 +74,16 @@ PlatformEvent, ReconnectedEvent, ) +from band.runtime.callbacks import ParticipantAddedCallback, ParticipantRemovedCallback +from band.runtime.context import ConversationContext from band.runtime.context_serialization import context_item_to_dict from band.runtime.cycle import TurnScope from band.runtime.formatters import build_participants_message, format_history_for_llm +from band.runtime.messages import PlatformMessage from band.runtime.participants import log_roster_call, log_roster_error from band.runtime.resync import ResyncSchedule +from band.runtime.sessionconfig import SessionConfig from band.runtime.tools.agent import AgentTools -from band.runtime.types import ( - ConversationContext, - ParticipantAddedCallback, - ParticipantRemovedCallback, - PlatformMessage, - SessionConfig, -) from band.runtime.working_state import WorkingStateReporter if TYPE_CHECKING: @@ -407,7 +404,9 @@ def __init__( self._pending_system_messages: list[str] = [] self._pending_resync: ResyncRequest | None = None self._resync_schedule = ResyncSchedule( - self.config.idle_resync_seconds, self.config.idle_resync_max_seconds + self.config.idle_resync_seconds, + self.config.idle_resync_max_seconds, + stopped=self.config.stopped_resync_seconds, ) self._resync_handled_work = False self._queue_wait: asyncio.Timeout | None = None @@ -423,7 +422,7 @@ def __init__( # Cleared in the per-message ``finally``. self._turn_failure_reported: bool = False - # Received STOP suppresses local triggers. Base-interval probes discover + # Received STOP suppresses local triggers. Periodic probes discover # a missed PLAY; platform claims remain authoritative. self._stopped: bool = False # The first stop's graceful result; a retry only settles retained work. @@ -761,7 +760,9 @@ def _resume_room(self) -> None: def _reset_resync_interval(self) -> None: if self._resync_schedule.due_at is None: return - self._resync_schedule.reset(asyncio.get_running_loop().time()) + self._resync_schedule.reset( + asyncio.get_running_loop().time(), stopped=self._stopped + ) if self._queue_wait is not None and not self._queue_wait.expired(): self._queue_wait.reschedule(self._resync_schedule.due_at) @@ -1418,7 +1419,8 @@ async def _reconcile(self) -> None: await self._scan_controls() self._resync_schedule.complete( asyncio.get_running_loop().time(), - empty=outcome is ResyncOutcome.EMPTY and not self._stopped, + empty=outcome is ResyncOutcome.EMPTY, + stopped=self._stopped, ) async def _wait_until_resync_complete(self) -> ResyncOutcome: diff --git a/src/band/runtime/handles.py b/src/band/runtime/handles.py new file mode 100644 index 000000000..c390fca9c --- /dev/null +++ b/src/band/runtime/handles.py @@ -0,0 +1,10 @@ +"""Canonical formatting for platform handles.""" + +from __future__ import annotations + + +def normalize_handle(handle: str | None) -> str | None: + """Return a nonempty handle with its @ prefix, or None.""" + if not handle: + return None + return handle if handle.startswith("@") else f"@{handle}" diff --git a/src/band/runtime/messages.py b/src/band/runtime/messages.py new file mode 100644 index 000000000..00b085a43 --- /dev/null +++ b/src/band/runtime/messages.py @@ -0,0 +1,35 @@ +"""Runtime projection of a platform message.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass +class PlatformMessage: + """ + Message from platform (normalized for adapters). + + This is the message format passed to MessageHandlers. + """ + + id: str + room_id: str + content: str + sender_id: str + sender_type: str # "User", "Agent", "System" + sender_name: str | None + message_type: str + metadata: dict[str, Any] + created_at: datetime + + def format_for_llm(self) -> str: + """ + Format message with sender prefix for LLM consumption. + + Returns string in format: [SENDER_NAME]: message content + """ + sender = self.sender_name or self.sender_type + return f"[{sender}]: {self.content}" diff --git a/src/band/runtime/oneshot.py b/src/band/runtime/oneshot.py index d43367eb1..bda78fed3 100644 --- a/src/band/runtime/oneshot.py +++ b/src/band/runtime/oneshot.py @@ -81,13 +81,13 @@ format_history_for_llm, replace_uuid_mentions, ) -from band.runtime.participants import participant_snapshot -from band.runtime.startup import dispose_adapter, release_adapter, start_adapter -from band.runtime.tools import AgentTools # BandLink.get_next_message returns this dataclass, not band.core.types' # same-named one that _build_platform_message below constructs. -from band.runtime.types import PlatformMessage as RestPlatformMessage +from band.runtime.messages import PlatformMessage as RestPlatformMessage +from band.runtime.participants import participant_snapshot +from band.runtime.startup import dispose_adapter, release_adapter, start_adapter +from band.runtime.tools import AgentTools logger = logging.getLogger(__name__) diff --git a/src/band/runtime/platform_runtime.py b/src/band/runtime/platform_runtime.py index 945101a1f..c5011411c 100644 --- a/src/band/runtime/platform_runtime.py +++ b/src/band/runtime/platform_runtime.py @@ -15,18 +15,14 @@ from band.core.types import PlatformConnection from band.platform.event import ContactEvent, MessageEvent, PlatformEvent from band.platform.link import BandLink +from band.runtime.agentconfig import AgentConfig +from band.runtime.callbacks import ParticipantAddedCallback, ParticipantRemovedCallback from band.runtime.contact_handler import ContactEventHandler +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy from band.runtime.execution import ExecutionContext from band.runtime.runtime import AgentRuntime +from band.runtime.sessionconfig import SessionConfig from band.runtime.single_instance import SingleInstanceGuard -from band.runtime.types import ( - AgentConfig, - ContactEventConfig, - ContactEventStrategy, - ParticipantAddedCallback, - ParticipantRemovedCallback, - SessionConfig, -) logger = logging.getLogger(__name__) diff --git a/src/band/runtime/resync.py b/src/band/runtime/resync.py index 4a912e2d6..7aec3bbfd 100644 --- a/src/band/runtime/resync.py +++ b/src/band/runtime/resync.py @@ -8,9 +8,12 @@ class ResyncSchedule: """Keep periodic recovery independent of queue activity.""" - def __init__(self, base: float, maximum: float) -> None: + def __init__( + self, base: float, maximum: float, *, stopped: float | None = None + ) -> None: self.base = base self.maximum = max(base, maximum) + self.stopped = base if stopped is None else stopped self.interval = base self.due_at: float | None = None @@ -18,12 +21,18 @@ def start(self, now: float) -> None: self.interval = self.base self.due_at = now + random.uniform(0, self.base) - def reset(self, now: float) -> None: - self.interval = self.base + def reset(self, now: float, *, stopped: bool) -> None: + self.interval = self.stopped if stopped else self.base if self.due_at is not None: - self.due_at = min(self.due_at, now + self.base) - - def complete(self, now: float, *, empty: bool) -> None: - self.interval = min(self.interval * 2, self.maximum) if empty else self.base + self.due_at = min(self.due_at, now + self.interval) + + def complete(self, now: float, *, empty: bool, stopped: bool) -> None: + if stopped: + # Stopped probes never grow: they are how a missed PLAY is found. + self.interval = self.stopped + elif empty: + self.interval = min(self.interval * 2, self.maximum) + else: + self.interval = self.base if self.due_at is not None and self.due_at <= now: self.due_at = now + self.interval diff --git a/src/band/runtime/runtime.py b/src/band/runtime/runtime.py index a5097cb6d..35cbdad7c 100644 --- a/src/band/runtime/runtime.py +++ b/src/band/runtime/runtime.py @@ -15,16 +15,13 @@ from band.client.streaming import ControlMode from band.platform.event import PlatformEvent +from band.runtime.callbacks import ParticipantAddedCallback, ParticipantRemovedCallback from band.runtime.control import ControlDeduplicator, apply_control, control_targets from band.runtime.cycle import TurnScope from band.runtime.execution import Execution, ExecutionContext, ExecutionHandler from band.runtime.presence import RoomPresence from band.runtime.rooms import RoomExecutions, request_resync -from band.runtime.types import ( - ParticipantAddedCallback, - ParticipantRemovedCallback, - SessionConfig, -) +from band.runtime.sessionconfig import SessionConfig if TYPE_CHECKING: from band.client.streaming import AgentControlPayload diff --git a/src/band/runtime/sessionconfig.py b/src/band/runtime/sessionconfig.py new file mode 100644 index 000000000..9c9b73171 --- /dev/null +++ b/src/band/runtime/sessionconfig.py @@ -0,0 +1,107 @@ +"""Per-session execution settings and timing constraints.""" + +from __future__ import annotations + +from typing import Annotated, Self + +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +# The platform clears working state after this TTL if refreshes stop. +# Keep-alive validation leaves headroom for a missed or slow refresh. +PLATFORM_WORKING_STATE_TTL_SECONDS: float = 10.0 + + +def _require_positive_when_set(name: str, value: float | None) -> None: + """Shared guard for the optional-timeout fields below: unset (None) means + unbounded and is always valid; a set value must be strictly positive.""" + if value is not None and value <= 0: + raise ValueError(f"{name} must be > 0 when set (got {value})") + + +PositiveFiniteSeconds = Annotated[float, Field(gt=0, allow_inf_nan=False)] + + +class SessionConfig(BaseSettings): + """Execution settings; explicit values override case-insensitive environment values.""" + + model_config = SettingsConfigDict( + extra="ignore", + case_sensitive=False, + env_ignore_empty=True, + ) + + enable_context_cache: bool = True + context_cache_ttl_seconds: int = 300 + max_context_messages: int = 100 + # Max attempts per message before permanently failing. band_sdk_core.RetryTracker's + # constructor validates this range itself -- no second Python-side check. + max_message_retries: int = 1 + enable_context_hydration: bool = True # Whether to fetch history from platform API + # Base reconciliation interval, also used for retries and, unless + # stopped_resync_seconds is set, for stopped-room probes. + # Queue traffic cannot postpone a check; active handlers remain serial. + idle_resync_seconds: PositiveFiniteSeconds = 60.0 + + # --- Working-state (boolean "is the agent reasoning") reporting --- + # Kill-switch: disable all working-state reporting instantly if needed. + enable_working_state: bool = True + # Keep-alive cadence: how often working:true is refreshed while a cycle runs. + # Must be < TTL/2 so a single missed/slow ping still lands inside the TTL. + working_keep_alive_seconds: float = 3.0 + # Per-POST deadline for each activity report (must be < cadence so a slow + # POST can't pile onto the next keep-alive tick). + working_request_timeout_seconds: int = 2 + # Optional upper bound on how long we keep asserting working:true for one + # cycle. When exceeded we stop refreshing (platform TTL clears it); we never + # cancel the reasoning. None = unbounded. NOT a hang-killer. + max_working_state_seconds: float | None = None + + # Bound the handler so a stalled adapter cannot leave input processing forever. + # Expiry cancels reasoning and raises TimeoutError through normal failure/retry + # handling. Hydration and claim/ack calls are outside this bound; None is unbounded. + max_cycle_seconds: float | None = None + + # Post an `error` event when a message fails its final attempt and the + # adapter didn't report it; otherwise the room can't tell it from a message + # never received. + report_turn_failures_to_room: bool = True + + # Empty checks double the interval up to max(base, maximum). Only the first + # periodic check is spread within the base interval. Equal caps disable growth. + idle_resync_max_seconds: PositiveFiniteSeconds = 120.0 + + # Fixed interval for /next probes while a received STOP pauses the room. + # These probes are the only way to discover a missed PLAY, so a longer + # interval delays resuming by up to that long. None uses idle_resync_seconds. + stopped_resync_seconds: PositiveFiniteSeconds | None = None + + @model_validator(mode="after") + def validate_timing(self) -> Self: + # Working-state invariants only matter when reporting is enabled. + if self.enable_working_state: + ttl_half = PLATFORM_WORKING_STATE_TTL_SECONDS / 2 + if self.working_keep_alive_seconds <= 0: + raise ValueError( + f"working_keep_alive_seconds must be > 0 (got {self.working_keep_alive_seconds})" + ) + if self.working_keep_alive_seconds >= ttl_half: + raise ValueError( + f"working_keep_alive_seconds must be < TTL/2 ({ttl_half}) to keep TTL " + f"headroom (got {self.working_keep_alive_seconds})" + ) + if self.working_request_timeout_seconds <= 0: + raise ValueError( + f"working_request_timeout_seconds must be > 0 (got {self.working_request_timeout_seconds})" + ) + if self.working_request_timeout_seconds >= self.working_keep_alive_seconds: + raise ValueError( + f"working_request_timeout_seconds ({self.working_request_timeout_seconds}) must be < " + f"working_keep_alive_seconds ({self.working_keep_alive_seconds}) so a slow POST can't stack" + ) + _require_positive_when_set( + "max_working_state_seconds", self.max_working_state_seconds + ) + + _require_positive_when_set("max_cycle_seconds", self.max_cycle_seconds) + return self diff --git a/src/band/runtime/types.py b/src/band/runtime/types.py deleted file mode 100644 index 6e39dfb6e..000000000 --- a/src/band/runtime/types.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Runtime types for Band agent SDK. - -Extracted from core/types.py - data structures used across the runtime layer. -""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from datetime import datetime -from enum import Enum -from math import isfinite -from typing import TYPE_CHECKING, Any -from uuid import UUID - -from band.core.types import ConflictPolicy - - -def normalize_handle(handle: str | None) -> str | None: - """ - Normalize a handle to always include the @ prefix. - - Handles may or may not include the @ prefix depending on the source. - This function ensures consistent formatting. - - Args: - handle: The handle to normalize (may or may not have @ prefix) - - Returns: - Handle with @ prefix, or None if input is None/empty - """ - if not handle: - return None - return handle if handle.startswith("@") else f"@{handle}" - - -if TYPE_CHECKING: - from band.platform.event import ( - ContactEvent, - ParticipantAddedEvent, - ParticipantRemovedEvent, - ) - - from .contact_tools import ContactTools - from .tools import AgentTools - - -@dataclass -class AgentConfig: - """Configuration for agent runtime.""" - - auto_subscribe_existing_rooms: bool = True - # Refuse to start when another process on this host already runs the - # same agent id: duplicates steal each other's in-flight room messages - # (the recovery sweep has no liveness check) and stateful adapters - # resume the same on-disk sessions, splitting one conversation. - single_instance: bool = True - # Platform-side duplicate guard for the initial WebSocket connect (cross-host, - # cross-TMPDIR); complements ``single_instance`` on this host. See - # ``ConflictPolicy`` for semantics and limits. - conflict_policy: ConflictPolicy = ConflictPolicy.SUPERSEDE - - -# Platform-side TTL (seconds) for the boolean working-state indicator. The -# adapter must refresh well within this window; if refreshes stop (crash, hang, -# disconnect) the platform clears the indicator. Mirrors the Ticket B contract -# (TTL <= 10s). Used only to derive the keep-alive cadence guard below. -PLATFORM_WORKING_STATE_TTL_SECONDS: float = 10.0 - - -def _require_positive_when_set(name: str, value: float | None) -> None: - """Shared guard for the optional-timeout fields below: unset (None) means - unbounded and is always valid; a set value must be strictly positive.""" - if value is not None and value <= 0: - raise ValueError(f"{name} must be > 0 when set (got {value})") - - -@dataclass -class SessionConfig: - """Configuration for execution context.""" - - enable_context_cache: bool = True - context_cache_ttl_seconds: int = 300 - max_context_messages: int = 100 - # Max attempts per message before permanently failing. band_sdk_core.RetryTracker's - # constructor validates this range itself -- no second Python-side check. - max_message_retries: int = 1 - enable_context_hydration: bool = True # Whether to fetch history from platform API - # Base reconciliation interval, also used for stopped probes and retries. - # Queue traffic cannot postpone a check; active handlers remain serial. - idle_resync_seconds: float = 60.0 - - # --- Working-state (boolean "is the agent reasoning") reporting --- - # Kill-switch: disable all working-state reporting instantly if needed. - enable_working_state: bool = True - # Keep-alive cadence: how often working:true is refreshed while a cycle runs. - # Must be < TTL/2 so a single missed/slow ping still lands inside the TTL. - working_keep_alive_seconds: float = 3.0 - # Per-POST deadline for each activity report (must be < cadence so a slow - # POST can't pile onto the next keep-alive tick). - working_request_timeout_seconds: int = 2 - # Optional upper bound on how long we keep asserting working:true for one - # cycle. When exceeded we stop refreshing (platform TTL clears it); we never - # cancel the reasoning. None = unbounded. NOT a hang-killer. - max_working_state_seconds: float | None = None - - # Upper bound on one reasoning cycle (the handler invoked by _run_cycle). - # Unlike max_working_state_seconds, exceeding this DOES cancel the cycle. - # This covers the handler cycle, not context hydration or message claim/ack - # calls. A handler stuck awaiting an external call (e.g. a wedged adapter - # subprocess) would otherwise leave its message in 'processing' forever. - # On expiry the cycle is cancelled and TimeoutError propagates through the - # normal handler-exception path (mark_failed + retry), the same as any - # other handler error. None = unbounded (default — matches prior behavior - # for callers that never opt in). - max_cycle_seconds: float | None = None - - # Post an `error` event when a message fails its final attempt and the - # adapter didn't report it; otherwise the room can't tell it from a message - # never received. - report_turn_failures_to_room: bool = True - - # Empty checks double the interval up to max(base, maximum). Only the first - # periodic check is spread within the base interval. Equal caps disable growth. - idle_resync_max_seconds: float = 120.0 - - def __post_init__(self) -> None: - for name in ("idle_resync_seconds", "idle_resync_max_seconds"): - value = getattr(self, name) - if not isfinite(value) or value <= 0: - raise ValueError(f"{name} must be finite and > 0 (got {value})") - - # Working-state invariants only matter when reporting is enabled. - if self.enable_working_state: - ttl_half = PLATFORM_WORKING_STATE_TTL_SECONDS / 2 - if self.working_keep_alive_seconds <= 0: - raise ValueError( - f"working_keep_alive_seconds must be > 0 (got {self.working_keep_alive_seconds})" - ) - if self.working_keep_alive_seconds >= ttl_half: - raise ValueError( - f"working_keep_alive_seconds must be < TTL/2 ({ttl_half}) to keep TTL " - f"headroom (got {self.working_keep_alive_seconds})" - ) - if self.working_request_timeout_seconds <= 0: - raise ValueError( - f"working_request_timeout_seconds must be > 0 (got {self.working_request_timeout_seconds})" - ) - if self.working_request_timeout_seconds >= self.working_keep_alive_seconds: - raise ValueError( - f"working_request_timeout_seconds ({self.working_request_timeout_seconds}) must be < " - f"working_keep_alive_seconds ({self.working_keep_alive_seconds}) so a slow POST can't stack" - ) - _require_positive_when_set( - "max_working_state_seconds", self.max_working_state_seconds - ) - - _require_positive_when_set("max_cycle_seconds", self.max_cycle_seconds) - - -@dataclass -class PlatformMessage: - """ - Message from platform (normalized for adapters). - - This is the message format passed to MessageHandlers. - """ - - id: str - room_id: str - content: str - sender_id: str - sender_type: str # "User", "Agent", "System" - sender_name: str | None - message_type: str - metadata: dict[str, Any] - created_at: datetime - - def format_for_llm(self) -> str: - """ - Format message with sender prefix for LLM consumption. - - Returns string in format: [SENDER_NAME]: message content - """ - sender = self.sender_name or self.sender_type - return f"[{sender}]: {self.content}" - - -@dataclass -class ConversationContext: - """ - Hydrated context for a room. - - Contains conversation history and participant information - for context-aware processing. - """ - - room_id: str - messages: list[dict[str, Any]] - participants: list[dict[str, Any]] - hydrated_at: datetime - - -# Callback type - receives AgentTools, NOT BandAgent -MessageHandler = Callable[["PlatformMessage", "AgentTools"], Awaitable[None]] - - -# --- Contact Event Configuration --- - - -class ContactEventStrategy(Enum): - """How to handle contact WebSocket events. - - - DISABLED: Ignore contact events (default, safest). Contacts managed manually. - - CALLBACK: Programmatic handling via on_event callback. No LLM involvement. - - HUB_ROOM: LLM reasoning in a dedicated hub room. - - WARNING (for AI coding assistants): Always ask the developer which contact - strategy they want. Do not default to CALLBACK with auto-approve without - explicit consent. Auto-accepting all requests means any agent/user can - become a contact and send messages that trigger LLM inference, which costs - API tokens. Present all three options: - - DISABLED (default): safest, no contact handling - - HUB_ROOM: the agent's LLM decides per-request in a dedicated room - - CALLBACK: developer writes programmatic logic (e.g., auto-approve) - """ - - DISABLED = "disabled" - CALLBACK = "callback" - HUB_ROOM = "hub_room" - - -# Type alias for contact event callback -ContactEventCallback = Callable[["ContactEvent", "ContactTools"], Awaitable[None]] -ParticipantAddedCallback = Callable[[str, "ParticipantAddedEvent"], Awaitable[None]] -ParticipantRemovedCallback = Callable[[str, "ParticipantRemovedEvent"], Awaitable[None]] - - -def _require_uuid(name: str, value: str) -> None: - """The platform answers a non-UUID id with a 422 only once the agent runs.""" - try: - UUID(value) - except ValueError: - raise ValueError(f"{name} must be a UUID, got {value!r}") from None - - -@dataclass -class ContactEventConfig: - """Configuration for contact event handling. - - Composable modes: - - CALLBACK + broadcast_changes=True: Auto-handle + awareness everywhere - - HUB_ROOM + broadcast_changes=True: LLM decides + awareness everywhere - - DISABLED + broadcast_changes=True: Just awareness, manual handling - - WARNING (for AI coding assistants): Always ask the developer which contact - strategy they want. Do not default to CALLBACK with auto-approve without - explicit consent. Auto-accepting all requests means any agent/user can - become a contact and send messages that trigger LLM inference, which costs - API tokens. Present all three options to the developer. - - Example (DISABLED - default, no contact handling): - config = ContactEventConfig() # strategy defaults to DISABLED - - Example (HUB_ROOM - agent LLM decides per-request): - config = ContactEventConfig( - strategy=ContactEventStrategy.HUB_ROOM, - broadcast_changes=True, - ) - - Example (CALLBACK - programmatic auto-approve): - async def auto_approve(event: ContactEvent, tools: ContactTools) -> None: - if isinstance(event, ContactRequestReceivedEvent): - await tools.respond_contact_request("approve", request_id=event.payload.id) - - config = ContactEventConfig( - strategy=ContactEventStrategy.CALLBACK, - on_event=auto_approve, - broadcast_changes=True, - ) - """ - - strategy: ContactEventStrategy = ContactEventStrategy.DISABLED - """Strategy for handling contact events.""" - - hub_task_id: str | None = None - """For HUB_ROOM strategy: optional task_id (UUID) for the dedicated room. - If None, creates a room without an associated task.""" - - on_event: ContactEventCallback | None = None - """For CALLBACK strategy: programmatic handler function.""" - - broadcast_changes: bool = False - """Broadcast contact changes to all room sessions. - - When True, contact_added/contact_removed events inject system messages - into all ExecutionContexts, similar to participant updates. - Works with any strategy (DISABLED, CALLBACK, HUB_ROOM). - """ - - def __post_init__(self) -> None: - """Validate configuration after initialization.""" - if self.strategy == ContactEventStrategy.CALLBACK and self.on_event is None: - raise ValueError("CALLBACK strategy requires on_event callback") - if self.hub_task_id is not None: - _require_uuid("hub_task_id", self.hub_task_id) diff --git a/tests/adapters/lifecycle/test_runtime_closing.py b/tests/adapters/lifecycle/test_runtime_closing.py index aa849c96d..34778998c 100644 --- a/tests/adapters/lifecycle/test_runtime_closing.py +++ b/tests/adapters/lifecycle/test_runtime_closing.py @@ -7,7 +7,7 @@ import pytest from band.platform.link import BandLink -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.adapters.hold import Hold from tests.adapters.lifecycle.kit import RetentionHost from tests.adapters.lifecycle.runtime import ReplyingPlatform diff --git a/tests/agent/test_create_factory.py b/tests/agent/test_create_factory.py index 70a10283e..3fecc9265 100644 --- a/tests/agent/test_create_factory.py +++ b/tests/agent/test_create_factory.py @@ -5,7 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch from band.agent import Agent -from band.runtime.types import AgentConfig, SessionConfig +from band.runtime.agentconfig import AgentConfig +from band.runtime.sessionconfig import SessionConfig class TestCreateFactory: diff --git a/tests/agent/test_startup_race_condition.py b/tests/agent/test_startup_race_condition.py index b86777db7..36e01ff91 100644 --- a/tests/agent/test_startup_race_condition.py +++ b/tests/agent/test_startup_race_condition.py @@ -14,7 +14,7 @@ ) from band.core.simple_adapter import SimpleAdapter from band.platform.event import MessageEvent -from band.runtime.types import ConversationContext +from band.runtime.context import ConversationContext from band.testing.platform import platform_connection_stub diff --git a/tests/agentcontacts/test_contact_broadcast.py b/tests/agentcontacts/test_contact_broadcast.py index 524b6b03f..a47fd3a02 100644 --- a/tests/agentcontacts/test_contact_broadcast.py +++ b/tests/agentcontacts/test_contact_broadcast.py @@ -3,7 +3,7 @@ from __future__ import annotations from band.agent import Agent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy class TestAgentContactBroadcast: diff --git a/tests/agentcontacts/test_contact_subscription.py b/tests/agentcontacts/test_contact_subscription.py index d7288cfb3..fd06afb23 100644 --- a/tests/agentcontacts/test_contact_subscription.py +++ b/tests/agentcontacts/test_contact_subscription.py @@ -7,7 +7,7 @@ import pytest from band.agent import Agent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy class TestAgentContactSubscription: diff --git a/tests/agentcontacts/test_create_contact_config.py b/tests/agentcontacts/test_create_contact_config.py index 9fea68093..e9d453977 100644 --- a/tests/agentcontacts/test_create_contact_config.py +++ b/tests/agentcontacts/test_create_contact_config.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from band.agent import Agent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy class TestAgentCreateContactConfig: diff --git a/tests/agentcontacts/test_platform_runtime_contact_setup.py b/tests/agentcontacts/test_platform_runtime_contact_setup.py index bc3b44594..6bb15f744 100644 --- a/tests/agentcontacts/test_platform_runtime_contact_setup.py +++ b/tests/agentcontacts/test_platform_runtime_contact_setup.py @@ -6,8 +6,8 @@ import pytest +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import ContactEventConfig, ContactEventStrategy class TestPlatformRuntimeContactSetup: diff --git a/tests/agentcontacts/test_startup_order.py b/tests/agentcontacts/test_startup_order.py index f77e4aa3c..43a3a25cf 100644 --- a/tests/agentcontacts/test_startup_order.py +++ b/tests/agentcontacts/test_startup_order.py @@ -6,8 +6,8 @@ import pytest +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import ContactEventConfig, ContactEventStrategy class TestStartupOrder: diff --git a/tests/bridge/test_bridge.py b/tests/bridge/test_bridge.py index d9f5a511a..a8fe239e1 100644 --- a/tests/bridge/test_bridge.py +++ b/tests/bridge/test_bridge.py @@ -18,7 +18,7 @@ RoomDeletedEvent, RoomRemovedEvent, ) -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage from .conftest import FakeForwarder, make_http_agent, make_link_mock diff --git a/tests/conftest.py b/tests/conftest.py index 43bd04728..90cf0cad0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,8 +63,8 @@ RoomRemovedEvent, ) from band.platform.link import BandLink +from band.runtime.messages import PlatformMessage from band.runtime.single_instance import SingleInstanceGuard -from band.runtime.types import PlatformMessage from tests.paths import ENV_TEST_FILE # Enable the `pytester` fixture (must live in the root conftest) so hook/plugin behaviour diff --git a/tests/e2e/baseline/smoke/inspection/test_next_actionable_semantics.py b/tests/e2e/baseline/smoke/inspection/test_next_actionable_semantics.py index 6b5831885..2ce122900 100644 --- a/tests/e2e/baseline/smoke/inspection/test_next_actionable_semantics.py +++ b/tests/e2e/baseline/smoke/inspection/test_next_actionable_semantics.py @@ -29,7 +29,7 @@ from band.core.exceptions import RoomExecutionStoppedError from band.platform.link import BandLink from band.platform.posting import post_event, post_message -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage from tests.e2e.baseline.agents import Lane, lane from tests.e2e.baseline.settings import BaselineSettings from tests.e2e.baseline.toolkit.capture import CaptureFactory diff --git a/tests/e2e/baseline/toolkit/control.py b/tests/e2e/baseline/toolkit/control.py index 9eae9b4ac..70e5c1764 100644 --- a/tests/e2e/baseline/toolkit/control.py +++ b/tests/e2e/baseline/toolkit/control.py @@ -17,7 +17,7 @@ from band.platform.link import BandLink from band.runtime.execution import ExecutionContext from band.runtime.runtime import AgentRuntime -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.e2e.baseline.settings import BaselineSettings from tests.e2e.baseline.toolkit.logs import sdk_logs_at from tests.e2e.baseline.toolkit.provisioning import ProvisionedAgent diff --git a/tests/e2e/baseline/toolkit/provisioning.py b/tests/e2e/baseline/toolkit/provisioning.py index f5ecc5105..0f6385a65 100644 --- a/tests/e2e/baseline/toolkit/provisioning.py +++ b/tests/e2e/baseline/toolkit/provisioning.py @@ -36,7 +36,7 @@ from band.agent import Agent from band.core.simple_adapter import SimpleAdapter -from band.runtime.types import AgentConfig +from band.runtime.agentconfig import AgentConfig from band.workspaces import WorkspaceResolver from tests.e2e.baseline.settings import BaselineSettings from tests.e2e.baseline.toolkit.adapters import build_adapter diff --git a/tests/integration/test_agent_contacts.py b/tests/integration/test_agent_contacts.py index e177baac6..ad2a7a4d1 100644 --- a/tests/integration/test_agent_contacts.py +++ b/tests/integration/test_agent_contacts.py @@ -21,7 +21,7 @@ ContactEvent, ) from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy PYDANTIC_AI_MODEL = f"openai:{OPENAI_MODEL}" diff --git a/tests/integration/test_callback_strategy.py b/tests/integration/test_callback_strategy.py index 214cd773b..2e6e9b9c6 100644 --- a/tests/integration/test_callback_strategy.py +++ b/tests/integration/test_callback_strategy.py @@ -24,7 +24,7 @@ ) from band.runtime.contact_handler import ContactEventHandler from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy from tests.integration.conftest import requires_api, requires_multi_agent logger = logging.getLogger(__name__) diff --git a/tests/integration/test_hub_room_strategy.py b/tests/integration/test_hub_room_strategy.py index 7e189e8b3..0c360caff 100644 --- a/tests/integration/test_hub_room_strategy.py +++ b/tests/integration/test_hub_room_strategy.py @@ -20,7 +20,7 @@ from band.platform.link import BandLink from band.runtime.contact_handler import ContactEventHandler from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy from tests.integration.conftest import ( fetch_all_context, requires_api, diff --git a/tests/integrations/acp/test_cursor_message_delivery.py b/tests/integrations/acp/test_cursor_message_delivery.py index ae93f13a0..87e133ac6 100644 --- a/tests/integrations/acp/test_cursor_message_delivery.py +++ b/tests/integrations/acp/test_cursor_message_delivery.py @@ -9,7 +9,7 @@ from band.adapters.cursor_acp import CursorACPAdapter, CursorACPAdapterConfig from band.client.streaming import ControlMode from band.platform.link import BandLink -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent, allow_or_reject from tests.integrations.acp.acp_toolkit.harness import pair_in_process diff --git a/tests/markdown_docs/globals.py b/tests/markdown_docs/globals.py index 8fcb782ae..6daa35e99 100644 --- a/tests/markdown_docs/globals.py +++ b/tests/markdown_docs/globals.py @@ -19,7 +19,7 @@ from band import AdapterFeatures, BandConfigError, Capability, Emit from band import Agent as BandAgent from band.platform.event import ContactRequestReceivedEvent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy MARKDOWN_AGENT_ID = "markdown-docs-agent" MARKDOWN_RESEARCHER_AGENT_ID = "markdown-docs-researcher" diff --git a/tests/platformruntime/test_initialization.py b/tests/platformruntime/test_initialization.py index b0a28f9db..f5b942b09 100644 --- a/tests/platformruntime/test_initialization.py +++ b/tests/platformruntime/test_initialization.py @@ -2,8 +2,9 @@ from __future__ import annotations +from band.runtime.agentconfig import AgentConfig from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import AgentConfig, SessionConfig +from band.runtime.sessionconfig import SessionConfig class TestInitialization: diff --git a/tests/platformruntime/test_interrupted_cleanup.py b/tests/platformruntime/test_interrupted_cleanup.py index f2f2ea504..25b3e0949 100644 --- a/tests/platformruntime/test_interrupted_cleanup.py +++ b/tests/platformruntime/test_interrupted_cleanup.py @@ -8,8 +8,8 @@ import pytest from band.core.exceptions import AgentAlreadyRunningError, CleanupIncompleteError +from band.runtime.contacts import ContactEventConfig from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import ContactEventConfig from tests.platformruntime.kit import ROOMS, serving_link from tests.runtime.conftest import wait_for_condition from tests.runtime.helpers import RoomCleanup diff --git a/tests/platformruntime/test_single_instance.py b/tests/platformruntime/test_single_instance.py index 100b87608..0599bc864 100644 --- a/tests/platformruntime/test_single_instance.py +++ b/tests/platformruntime/test_single_instance.py @@ -7,8 +7,8 @@ import pytest from band.core.exceptions import AgentAlreadyRunningError +from band.runtime.agentconfig import AgentConfig from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import AgentConfig class TestSingleInstance: diff --git a/tests/platformruntime/test_start.py b/tests/platformruntime/test_start.py index 48833db1f..e0fc99947 100644 --- a/tests/platformruntime/test_start.py +++ b/tests/platformruntime/test_start.py @@ -8,9 +8,10 @@ from band_rest.core.api_error import ApiError from band.core.types import ConflictPolicy +from band.runtime.agentconfig import AgentConfig from band.runtime.capabilities import FeatureFlag from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import AgentConfig, SessionConfig +from band.runtime.sessionconfig import SessionConfig class TestStart: diff --git a/tests/preprocessing/test_commitment.py b/tests/preprocessing/test_commitment.py index f3bfd2f11..13bdbecf5 100644 --- a/tests/preprocessing/test_commitment.py +++ b/tests/preprocessing/test_commitment.py @@ -15,7 +15,7 @@ from band.core.types import AgentInput, PlatformMessage from band.preprocessing.default import DefaultPreprocessor from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.preprocessing.test_default import make_message_event diff --git a/tests/preprocessing/test_controls.py b/tests/preprocessing/test_controls.py index 602918469..d3f2bd7b0 100644 --- a/tests/preprocessing/test_controls.py +++ b/tests/preprocessing/test_controls.py @@ -13,7 +13,7 @@ from band.platform.link import BandLink from band.preprocessing.default import DefaultPreprocessor from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.runtime.helpers import AGENT_ID, ROOM_ID, LifecyclePlatform, rest_client_over from tests.runtime.test_deferral import ApprovalAdapter diff --git a/tests/preprocessing/test_default.py b/tests/preprocessing/test_default.py index 063917c39..532fc0ef8 100644 --- a/tests/preprocessing/test_default.py +++ b/tests/preprocessing/test_default.py @@ -13,7 +13,7 @@ RoomAddedEvent, ) from band.preprocessing.default import DefaultPreprocessor -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig def make_message_payload( diff --git a/tests/runtime/conftest.py b/tests/runtime/conftest.py index 581be1694..46f8a06ed 100644 --- a/tests/runtime/conftest.py +++ b/tests/runtime/conftest.py @@ -7,7 +7,7 @@ these fixtures the same way it would break production, instead of a loose mock silently tolerating the drift. -Note: ``band.runtime.types.PlatformMessage`` (used by the root +Note: ``band.runtime.messages.PlatformMessage`` (used by the root ``tests/conftest.py`` WS-event builders) is a *different* class from ``band.core.types.PlatformMessage`` used here — the two aren't interchangeable, so don't reach for ``sample_platform_message`` from the root diff --git a/tests/runtime/executioninterrupt/kit.py b/tests/runtime/executioninterrupt/kit.py index d8545a173..9ebd388f5 100644 --- a/tests/runtime/executioninterrupt/kit.py +++ b/tests/runtime/executioninterrupt/kit.py @@ -18,7 +18,8 @@ from band.platform.link import BandLink from band.runtime.cycle import TurnScope from band.runtime.execution import ExecutionContext -from band.runtime.types import PlatformMessage, SessionConfig +from band.runtime.messages import PlatformMessage +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.runtime.helpers import AGENT_ID, ROOM_ID diff --git a/tests/runtime/executioninterrupt/test_backlog_interrupt.py b/tests/runtime/executioninterrupt/test_backlog_interrupt.py index 1c810ee84..769149aaa 100644 --- a/tests/runtime/executioninterrupt/test_backlog_interrupt.py +++ b/tests/runtime/executioninterrupt/test_backlog_interrupt.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock from band.runtime.execution import ExecutionContext, ResyncOutcome, TurnOutcome -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import BlockingHandler from tests.runtime.executioninterrupt.kit import ( backlog_message, diff --git a/tests/runtime/executioninterrupt/test_cycle_watchdog.py b/tests/runtime/executioninterrupt/test_cycle_watchdog.py index 0cc73fde2..24d0eeb75 100644 --- a/tests/runtime/executioninterrupt/test_cycle_watchdog.py +++ b/tests/runtime/executioninterrupt/test_cycle_watchdog.py @@ -6,7 +6,7 @@ from unittest.mock import patch from band.runtime.execution import ExecutionContext, TurnOutcome -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import BlockingHandler, make_message_event from tests.runtime.executioninterrupt.kit import ( assert_fresh_cycle_still_propagates_shutdown_cancel, diff --git a/tests/runtime/executioninterrupt/test_late_rest_refusals.py b/tests/runtime/executioninterrupt/test_late_rest_refusals.py index 3e7bca076..b80c7de4a 100644 --- a/tests/runtime/executioninterrupt/test_late_rest_refusals.py +++ b/tests/runtime/executioninterrupt/test_late_rest_refusals.py @@ -9,8 +9,8 @@ from band.client.streaming import ControlMode from band.runtime.execution import ExecutionContext +from band.runtime.sessionconfig import SessionConfig from band.runtime.tools.agent import AgentTools -from band.runtime.types import SessionConfig from tests.conftest import make_message_event from tests.runtime.executioninterrupt.kit import ( backlog_message, diff --git a/tests/runtime/helpers.py b/tests/runtime/helpers.py index 534c6c4d5..0a96e4340 100644 --- a/tests/runtime/helpers.py +++ b/tests/runtime/helpers.py @@ -29,7 +29,7 @@ from band.platform.event import MessageEvent from band.platform.link import BandLink from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.identifiers import UUID_ID diff --git a/tests/runtime/resync/conftest.py b/tests/runtime/resync/conftest.py index 2f7b0a80c..d0bb19cc0 100644 --- a/tests/runtime/resync/conftest.py +++ b/tests/runtime/resync/conftest.py @@ -13,14 +13,13 @@ from band.platform.event import MessageEvent from band.platform.link import BandLink from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.runtime.helpers import ( AGENT_ID, ROOM_ID, - LifecyclePlatform, rest_client_over, ) -from tests.runtime.resync.kit import ReconciledRoom +from tests.runtime.resync.kit import ReconciledRoom, ReconciliationPeer @pytest.fixture @@ -74,9 +73,10 @@ def mock_handler(): @pytest_asyncio.fixture(loop_scope="function") async def reconciled_room( monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, ) -> AsyncIterator[ReconciledRoom]: monkeypatch.setattr(random, "uniform", lambda lower, upper: upper) - peer = LifecyclePlatform() + peer = ReconciliationPeer() invoked: list[str] = [] async def handler(ctx: ExecutionContext, event: Any) -> None: @@ -92,7 +92,9 @@ async def handler(ctx: ExecutionContext, event: Any) -> None: handler, agent_id=AGENT_ID, config=SessionConfig( - enable_context_hydration=False, enable_working_state=False + enable_context_hydration=False, + enable_working_state=False, + **getattr(request, "param", {}), ), ) try: diff --git a/tests/runtime/resync/kit.py b/tests/runtime/resync/kit.py index 4fcfb2d1c..5d3be76db 100644 --- a/tests/runtime/resync/kit.py +++ b/tests/runtime/resync/kit.py @@ -2,15 +2,38 @@ from __future__ import annotations +import asyncio from datetime import UTC, datetime +import httpx + from band.runtime.execution import ExecutionContext -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage from tests.runtime.helpers import ( LifecyclePlatform, ) -ReconciledRoom = tuple[ExecutionContext, LifecyclePlatform, list[str]] + +class ReconciliationPeer(LifecyclePlatform): + """Expose polling observations without test-side delays.""" + + def __init__(self) -> None: + super().__init__() + self._next_read = asyncio.Event() + + def answer(self, request: httpx.Request) -> httpx.Response: + response = super().answer(request) + if request.url.path.endswith("/next"): + self._next_read.set() + return response + + async def wait_for_reads(self, count: int) -> None: + while len(self.next_reads) < count: + self._next_read.clear() + await self._next_read.wait() + + +ReconciledRoom = tuple[ExecutionContext, ReconciliationPeer, list[str]] def make_platform_message( diff --git a/tests/runtime/resync/test_idle_timeout.py b/tests/runtime/resync/test_idle_timeout.py index fe898d013..116038c78 100644 --- a/tests/runtime/resync/test_idle_timeout.py +++ b/tests/runtime/resync/test_idle_timeout.py @@ -5,7 +5,7 @@ import random from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.runtime.conftest import wait_for_condition diff --git a/tests/runtime/resync/test_interval_validation.py b/tests/runtime/resync/test_interval_validation.py index a3909daff..9fdec637c 100644 --- a/tests/runtime/resync/test_interval_validation.py +++ b/tests/runtime/resync/test_interval_validation.py @@ -4,14 +4,15 @@ import pytest -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig class TestIntervalValidation: """Reconciliation intervals must be finite and positive.""" @pytest.mark.parametrize( - "field", ["idle_resync_seconds", "idle_resync_max_seconds"] + "field", + ["idle_resync_seconds", "idle_resync_max_seconds", "stopped_resync_seconds"], ) @pytest.mark.parametrize( "value", [0, -1, float("nan"), float("inf"), -float("inf")] @@ -21,3 +22,10 @@ def test_unsafe_reconciliation_intervals_are_rejected( ) -> None: with pytest.raises(ValueError, match=field): SessionConfig(**{field: value}) + + @pytest.mark.parametrize( + "field", ["idle_resync_seconds", "idle_resync_max_seconds"] + ) + def test_required_intervals_reject_none(self, field: str) -> None: + with pytest.raises(ValueError, match=field): + SessionConfig(**{field: None}) diff --git a/tests/runtime/resync/test_reconciliation_cadence.py b/tests/runtime/resync/test_reconciliation_cadence.py index 837cab3ba..d70717f74 100644 --- a/tests/runtime/resync/test_reconciliation_cadence.py +++ b/tests/runtime/resync/test_reconciliation_cadence.py @@ -12,8 +12,6 @@ from band.client.streaming import ParticipantRemovedPayload from band.platform.event import ParticipantRemovedEvent from band.runtime.execution import ExecutionContext -from band.runtime.resync import ResyncSchedule -from band.runtime.types import SessionConfig from tests.conftest import make_message_event from tests.runtime.conftest import wait_for_condition from tests.runtime.helpers import ( @@ -23,10 +21,10 @@ from tests.runtime.resync.kit import ReconciledRoom +@pytest.mark.looptime(end=1000) class TestReconciliationCadence: """Periodic /next reconciliation: its interval, growth and what resets it.""" - @pytest.mark.looptime async def test_traffic_cannot_postpone_missed_message_recovery( self, reconciled_room: ReconciledRoom, @@ -49,7 +47,6 @@ async def test_traffic_cannot_postpone_missed_message_recovery( assert invoked == ["missed"] assert peer.marked("processed") == ["missed"] - @pytest.mark.looptime async def test_quiet_reconciliation_grows_to_conservative_cap( self, reconciled_room: ReconciledRoom, @@ -61,7 +58,6 @@ async def test_quiet_reconciliation_grows_to_conservative_cap( assert peer.next_reads == pytest.approx([0, 60, 180, 300]) assert invoked == [] - @pytest.mark.looptime async def test_new_work_brings_backed_off_deadline_forward( self, reconciled_room: ReconciledRoom, @@ -78,7 +74,6 @@ async def test_new_work_brings_backed_off_deadline_forward( assert invoked == ["live"] assert peer.next_reads[-1] == pytest.approx(work_at + 60) - @pytest.mark.looptime async def test_duplicate_delivery_does_not_reset_quiet_growth( self, reconciled_room: ReconciledRoom, @@ -93,7 +88,6 @@ async def test_duplicate_delivery_does_not_reset_quiet_growth( assert peer.next_reads == pytest.approx([0, 60, 180]) assert invoked == [] - @pytest.mark.looptime async def test_stop_shortens_an_already_armed_quiet_wait( self, reconciled_room: ReconciledRoom, @@ -109,7 +103,65 @@ async def test_stop_shortens_an_already_armed_quiet_wait( assert peer.next_reads[-1] == pytest.approx(stopped_at + 60) assert invoked == [] - @pytest.mark.looptime + @pytest.mark.parametrize( + "reconciled_room", [{"stopped_resync_seconds": 300}], indirect=True + ) + async def test_stopped_room_probes_at_its_configured_interval( + self, + reconciled_room: ReconciledRoom, + ) -> None: + ctx, peer, invoked = reconciled_room + await ctx.start() + await peer.wait_for_reads(1) + ctx.stop_room() + peer.stopped = True + peer.add_message("paused") + await peer.wait_for_reads(4) + assert peer.next_reads == pytest.approx([0, 60, 360, 660]) + assert invoked == [] + + @pytest.mark.parametrize( + "reconciled_room", [{"stopped_resync_seconds": 300}], indirect=True + ) + async def test_missed_play_restores_base_interval( + self, + reconciled_room: ReconciledRoom, + ) -> None: + ctx, peer, invoked = reconciled_room + await ctx.start() + await peer.wait_for_reads(1) + ctx.stop_room() + peer.stopped = True + peer.add_message("resumed") + await peer.wait_for_reads(2) + peer.stopped = False + await peer.wait_for_reads(5) + assert invoked == ["resumed"] + # The resuming probe drains /next until empty, so 360 is read twice. + assert peer.next_reads == pytest.approx([0, 60, 360, 360, 420]) + + @pytest.mark.parametrize( + "reconciled_room", [{"stopped_resync_seconds": 300}], indirect=True + ) + async def test_play_reconciles_immediately_and_restores_active_cadence( + self, reconciled_room: ReconciledRoom + ) -> None: + ctx, peer, _ = reconciled_room + await ctx.start() + await peer.wait_for_reads(1) + ctx.stop_room() + peer.stopped = True + await peer.wait_for_reads(2) + + peer.stopped = False + played_at = asyncio.get_running_loop().time() + await ctx.resume_room() + await peer.wait_for_reads(5) + + assert peer.next_reads == pytest.approx( + [0, 60, played_at, played_at + 60, played_at + 180] + ) + async def test_deadline_does_not_cancel_handler_or_issue_catchup_burst( self, reconciled_room: ReconciledRoom, @@ -138,20 +190,18 @@ async def handler(context: ExecutionContext, event: Any) -> None: assert peer.marked("processed") == ["slow"] assert len(peer.next_reads) == 2 - @pytest.mark.looptime + @pytest.mark.parametrize( + "reconciled_room", [{"idle_resync_seconds": 300}], indirect=True + ) async def test_custom_base_above_default_maximum_keeps_its_interval( self, reconciled_room: ReconciledRoom, ) -> None: ctx, peer, _ = reconciled_room - ctx.config = SessionConfig(idle_resync_seconds=300, enable_working_state=False) - ctx._resync_schedule = ResyncSchedule(300, 120) await ctx.start() - await wait_for_condition(lambda: ctx._sync_complete) - await asyncio.sleep(601) + await peer.wait_for_reads(3) assert peer.next_reads == pytest.approx([0, 300, 600]) - @pytest.mark.looptime async def test_initial_spread_does_not_shorten_recurring_intervals( self, reconciled_room: ReconciledRoom, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -162,7 +212,6 @@ async def test_initial_spread_does_not_shorten_recurring_intervals( await asyncio.sleep(271) assert peer.next_reads == pytest.approx([0, 30, 150, 270]) - @pytest.mark.looptime async def test_http_error_retains_base_retry_cadence( self, reconciled_room: ReconciledRoom, diff --git a/tests/runtime/resync/test_stopped_interval_settings.py b/tests/runtime/resync/test_stopped_interval_settings.py new file mode 100644 index 000000000..f0491bd29 --- /dev/null +++ b/tests/runtime/resync/test_stopped_interval_settings.py @@ -0,0 +1,40 @@ +"""Stopped-room settings resolve and validate at session construction.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from band.runtime.sessionconfig import SessionConfig + + +class TestStoppedIntervalSettings: + @pytest.mark.parametrize( + ("environment", "overrides", "expected"), + [ + ("300", {}, 300), + ("", {}, None), + ("300", {"stopped_resync_seconds": 15}, 15), + ("300", {"stopped_resync_seconds": None}, None), + ("invalid", {"stopped_resync_seconds": 15}, 15), + ], + ids=["environment", "empty", "explicit", "base", "overridden-invalid-env"], + ) + def test_explicit_interval_overrides_environment_default( + self, + monkeypatch: pytest.MonkeyPatch, + environment: str, + overrides: dict[str, float | None], + expected: float | None, + ) -> None: + monkeypatch.setenv("STOPPED_RESYNC_SECONDS", environment) + config = SessionConfig(**overrides) + assert config.stopped_resync_seconds == expected + + @pytest.mark.parametrize("value", ["invalid", "0", "-1", "nan", "inf", "-inf"]) + def test_invalid_environment_interval_fails_before_execution( + self, monkeypatch: pytest.MonkeyPatch, value: str + ) -> None: + monkeypatch.setenv("STOPPED_RESYNC_SECONDS", value) + with pytest.raises(ValidationError, match="stopped_resync_seconds"): + SessionConfig() diff --git a/tests/runtime/runtimecontrol/test_stop_survives_reconnect.py b/tests/runtime/runtimecontrol/test_stop_survives_reconnect.py index 467437e46..310a5d191 100644 --- a/tests/runtime/runtimecontrol/test_stop_survives_reconnect.py +++ b/tests/runtime/runtimecontrol/test_stop_survives_reconnect.py @@ -6,7 +6,7 @@ from band.platform.event import ReconnectedEvent from band.runtime.execution import ExecutionContext -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage class TestStopSurvivesReconnect: diff --git a/tests/runtime/test_activity_stop.py b/tests/runtime/test_activity_stop.py index 352fac52b..27e061a48 100644 --- a/tests/runtime/test_activity_stop.py +++ b/tests/runtime/test_activity_stop.py @@ -16,8 +16,8 @@ from band.platform.link import BandLink from band.runtime.execution import ExecutionContext, TurnOutcome from band.runtime.runtime import AgentRuntime +from band.runtime.sessionconfig import SessionConfig from band.runtime.tools.agent import AgentTools -from band.runtime.types import SessionConfig from tests.conftest import BlockingHandler, make_message_event from tests.runtime.helpers import ( AGENT_ID, diff --git a/tests/runtime/test_broadcast_changes.py b/tests/runtime/test_broadcast_changes.py index d01397b15..0a35ac030 100644 --- a/tests/runtime/test_broadcast_changes.py +++ b/tests/runtime/test_broadcast_changes.py @@ -17,7 +17,7 @@ ContactRequestUpdatedEvent, ) from band.runtime.contact_handler import ContactEventHandler -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy @pytest.fixture diff --git a/tests/runtime/test_contact_config.py b/tests/runtime/test_contact_config.py index d3c56fe9e..295730d62 100644 --- a/tests/runtime/test_contact_config.py +++ b/tests/runtime/test_contact_config.py @@ -2,7 +2,7 @@ import pytest -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy class TestContactEventStrategy: diff --git a/tests/runtime/test_contact_handler.py b/tests/runtime/test_contact_handler.py index cbd906f3e..30d63871e 100644 --- a/tests/runtime/test_contact_handler.py +++ b/tests/runtime/test_contact_handler.py @@ -24,7 +24,7 @@ ContactEventHandler, ) from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy @pytest.fixture diff --git a/tests/runtime/test_deferral.py b/tests/runtime/test_deferral.py index 665345126..be2dca088 100644 --- a/tests/runtime/test_deferral.py +++ b/tests/runtime/test_deferral.py @@ -13,7 +13,7 @@ from band.platform.event import ReconnectedEvent from band.platform.link import BandLink from band.runtime.oneshot import OneShotInvoker, OneShotStatus -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.runtime.conftest import wait_for_condition from tests.runtime.helpers import ( diff --git a/tests/runtime/test_execution.py b/tests/runtime/test_execution.py index 694330403..8fdb098d5 100644 --- a/tests/runtime/test_execution.py +++ b/tests/runtime/test_execution.py @@ -21,6 +21,7 @@ from band.logging_config import TRACE_CONTEXT, trace_context_scope from band.platform.event import PlatformEvent from band.platform.link import BandLink +from band.runtime.context import ConversationContext from band.runtime.execution import ( Execution, ExecutionContext, @@ -30,8 +31,9 @@ _error_label, _observe_phase, ) +from band.runtime.messages import PlatformMessage +from band.runtime.sessionconfig import SessionConfig from band.runtime.tools import AgentTools -from band.runtime.types import ConversationContext, PlatformMessage, SessionConfig # Import test helpers from conftest from tests.conftest import ( diff --git a/tests/runtime/test_execution_working_state.py b/tests/runtime/test_execution_working_state.py index ffb6bedcc..fa39effdf 100644 --- a/tests/runtime/test_execution_working_state.py +++ b/tests/runtime/test_execution_working_state.py @@ -9,7 +9,8 @@ from band.platform.event import ReconnectedEvent from band.runtime.execution import ExecutionContext -from band.runtime.types import PlatformMessage, SessionConfig +from band.runtime.messages import PlatformMessage +from band.runtime.sessionconfig import SessionConfig from tests.conftest import ( make_message_event, make_participant_added_event, diff --git a/tests/runtime/test_session_settings.py b/tests/runtime/test_session_settings.py new file mode 100644 index 000000000..f46fd08d6 --- /dev/null +++ b/tests/runtime/test_session_settings.py @@ -0,0 +1,44 @@ +"""Session configuration resolves environment values and explicit overrides.""" + +from __future__ import annotations + +import pytest + +from band.runtime.sessionconfig import SessionConfig + + +class TestSessionSettings: + def test_environment_values_are_typed_and_explicit_values_take_precedence( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("ENABLE_CONTEXT_HYDRATION", "false") + monkeypatch.setenv("MAX_CONTEXT_MESSAGES", "25") + monkeypatch.setenv("IDLE_RESYNC_SECONDS", "30") + monkeypatch.setenv("MAX_CYCLE_SECONDS", "") + + config = SessionConfig(max_context_messages=50) + + assert config.model_dump( + include={ + "enable_context_hydration", + "max_context_messages", + "idle_resync_seconds", + "max_cycle_seconds", + } + ) == { + "enable_context_hydration": False, + "max_context_messages": 50, + "idle_resync_seconds": 30, + "max_cycle_seconds": None, + } + + def test_environment_timing_is_checked_against_working_state_constraints( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("WORKING_KEEP_ALIVE_SECONDS", "5") + + with pytest.raises(ValueError, match="working_keep_alive_seconds"): + SessionConfig(enable_working_state=True) + + config = SessionConfig(enable_working_state=False) + assert config.working_keep_alive_seconds == 5 diff --git a/tests/runtime/test_turn_verdict.py b/tests/runtime/test_turn_verdict.py index d17b1373d..a4c12b81c 100644 --- a/tests/runtime/test_turn_verdict.py +++ b/tests/runtime/test_turn_verdict.py @@ -33,9 +33,9 @@ PlatformMessage, ) from band.runtime.execution import ExecutionContext +from band.runtime.sessionconfig import SessionConfig from band.runtime.tools import BandTool from band.runtime.tools.agent import AgentTools -from band.runtime.types import SessionConfig from band.testing import MISSING_REPLY_FAILURE from tests.adapters.codexturns import ( FakeCodexClient, diff --git a/tests/runtime/turnadmission/conftest.py b/tests/runtime/turnadmission/conftest.py index 86d6b47d6..18c99fb5d 100644 --- a/tests/runtime/turnadmission/conftest.py +++ b/tests/runtime/turnadmission/conftest.py @@ -11,7 +11,7 @@ from band.platform.link import BandLink from band.runtime.cycle import TurnScope from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.runtime.helpers import ( AGENT_ID, LifecyclePlatform, diff --git a/tests/scenarios/world.py b/tests/scenarios/world.py index 5929984a6..4a95a9e89 100644 --- a/tests/scenarios/world.py +++ b/tests/scenarios/world.py @@ -11,7 +11,7 @@ from band.platform.link import BandLink from band.runtime.execution import Execution, ExecutionContext from band.runtime.runtime import AgentRuntime -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from band.testing import fake_phoenix_server from band.testing.phoenix_server import FakePhoenixServer from tests.runtime.helpers import rest_client_over diff --git a/tests/test_readme_snippets.py b/tests/test_readme_snippets.py index 4cedbd6da..f641c7e91 100644 --- a/tests/test_readme_snippets.py +++ b/tests/test_readme_snippets.py @@ -13,6 +13,8 @@ import pytest from pydantic import BaseModel, Field +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy + try: from band.adapters import PydanticAIAdapter as _PydanticAICheck # noqa: F401 @@ -522,15 +524,6 @@ def graph_factory(band_tools): class TestContactManagementSnippets: """README shows ContactEventConfig with HUB_ROOM and CALLBACK strategies.""" - def test_contact_event_imports(self) -> None: - from band.runtime.types import ( # noqa: PLC0415 -- pins the exact import path this test exercises - ContactEventStrategy, - ) - - assert ContactEventStrategy.DISABLED is not None - assert ContactEventStrategy.HUB_ROOM is not None - assert ContactEventStrategy.CALLBACK is not None - @patch.dict( os.environ, { @@ -543,10 +536,6 @@ def test_hub_room_config(self) -> None: from band import ( # noqa: PLC0415 -- pins the exact import path this test exercises Agent, ) - from band.runtime.types import ( # noqa: PLC0415 -- pins the exact import path this test exercises - ContactEventConfig, - ContactEventStrategy, - ) adapter = MagicMock() @@ -576,10 +565,6 @@ def test_callback_config(self) -> None: from band.platform.event import ( # noqa: PLC0415 -- pins the exact import path this test exercises ContactRequestReceivedEvent, ) - from band.runtime.types import ( # noqa: PLC0415 -- pins the exact import path this test exercises - ContactEventConfig, - ContactEventStrategy, - ) TRUSTED_HANDLES = {"@teammate"} diff --git a/tests/test_session_config.py b/tests/test_session_config.py index 08fd9bce7..b5d272657 100644 --- a/tests/test_session_config.py +++ b/tests/test_session_config.py @@ -15,7 +15,7 @@ import pytest from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig # Import test helpers from conftest from tests.conftest import make_message_event diff --git a/tests/test_session_shutdown.py b/tests/test_session_shutdown.py index c9e1c90ff..67142414e 100644 --- a/tests/test_session_shutdown.py +++ b/tests/test_session_shutdown.py @@ -15,7 +15,7 @@ import pytest from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig # Import test helpers from conftest from tests.conftest import make_message_event diff --git a/tests/test_session_sync.py b/tests/test_session_sync.py index 6fbcf7b5f..5f6d3328f 100644 --- a/tests/test_session_sync.py +++ b/tests/test_session_sync.py @@ -16,7 +16,8 @@ from band.platform.event import ReconnectedEvent from band.runtime.execution import ExecutionContext, ResyncRequest -from band.runtime.types import PlatformMessage, SessionConfig +from band.runtime.messages import PlatformMessage +from band.runtime.sessionconfig import SessionConfig # Import test helpers from conftest from tests.conftest import make_message_event, make_participant_added_event