Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,16 @@ jobs:
/tmp/test-install/bin/python <<'PYEOF'
from band import Agent, BandLink, AgentRuntime
from band.config import load_agent_config
from band_sdk_core import ClaimRegistry, ParticipantRoster, RetryTracker
from band_sdk_core import (
ClaimRegistry,
ParticipantRoster,
RetryTracker,
evaluate_adapter_result,
evaluate_delivery_event,
evaluate_drain_candidate,
evaluate_next_message,
is_self_echo,
)

# Prove the pinned band_sdk_core wheel is callable, not just importable,
# from this isolated install -- not only from the dev checkout via uv sync.
Expand All @@ -266,6 +275,18 @@ jobs:
assert tracker.max_retries == 1
roster = ParticipantRoster()
assert roster.list() == []
assert is_self_echo("agent-1", "Agent", "agent-1") is True
assert evaluate_drain_candidate(None, [], "agent-1") == {"decision": "no_candidate"}
assert evaluate_delivery_event("room_added", "room-1", {}, "agent-1") == {
"decision": "ignored",
"event_type": "room_added",
}
assert evaluate_next_message("msg-1", None) == {"decision": "no_pending"}
assert evaluate_adapter_result("room-1", "msg-1", True) == {
"decision": "processed",
"room_id": "room-1",
"message_id": "msg-1",
}
print('Core imports successful')
PYEOF

Expand Down
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ validation; build a `kwargs` dict and omit the key instead. See
The SDK subscribes to Phoenix Channels (agent/chat/user rooms, participants,
tasks) and hydrates each event's payload into a typed, rule-free
`WirePayload` projection without re-validating. See
[docs/websocket-events.md](docs/websocket-events.md) for the channel table
and payload field reference.
[docs/websocket-events.md](docs/websocket-events.md) for the channel table,
payload field reference, and `band-sdk-core`'s delivery-lifecycle decisions.
Whenever code here starts calling a `band_sdk_core` symbol it didn't use
before, extend `.github/workflows/ci.yml`'s wheel-smoke step to prove that
symbol is callable from the isolated pinned wheel, not just importable.

## Contact Event Handling

Expand Down
12 changes: 12 additions & 0 deletions docs/websocket-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ re-validating by `WirePayload.from_wire` (`src/band/client/streaming/wire.py`).
Every model inherits `WirePayload`, which sets `ConfigDict(extra="allow")`
once for all of them.

`band-sdk-core` is also where the one-shot delivery-lifecycle *decisions*
live — `evaluate_delivery_event`/`evaluate_next_message`/
`evaluate_drain_candidate`/`evaluate_adapter_result` — not just payload
validation. `OneShotInvoker` (`src/band/runtime/oneshot.py`) is a thin
caller-owns-the-loop wrapper around those four functions: the
ignore/cleanup/self-echo/invocation routing, the drain-candidate
classification, and the ack decision are core's, not the SDK's own logic.
`ExecutionContext` is a different machine with its own dedup model
(`metadata.delivery_status`) and does not call `evaluate_delivery_event`/
`evaluate_next_message`/`evaluate_drain_candidate` — it shares only the
`is_self_echo` predicate with `OneShotInvoker`.

```python notest
MessageCreatedPayload:
id, content, message_type, sender_id, sender_type,
Expand Down
2 changes: 1 addition & 1 deletion examples/agentcore/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Inside `OneShotInvoker.handle_event`, each invocation:

When weather invocation B starts (after A finishes), B's
`get_next_message` returns `204 No Content` — A drained it. B exits with
`{"status": "no_pending"}` without an LLM call.
`{"status": "no_pending"}` (`OneShotStatus.NO_PENDING`) without an LLM call.

This is the same in-band claim/process semantics the SDK's
`ExecutionContext` uses in the normal long-running Agent flow;
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ classifiers = [

dependencies = [
"band-client-rest==0.0.27",
"band-sdk-core==2.0.0",
"band-sdk-core==2.2.0",
"phoenix-channels-python-client>=0.2.4",
"python-dotenv>=1.2.2",
"pydantic>=2.0",
Expand Down
3 changes: 2 additions & 1 deletion src/band/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from .presence import RoomPresence
from .execution import Execution, ExecutionContext, ExecutionHandler
from .runtime import AgentRuntime
from .oneshot import OneShotInvoker, OneShotEnvelopeError
from .oneshot import OneShotEnvelopeError, OneShotInvoker, OneShotStatus

# Tools
from .tools import (
Expand Down Expand Up @@ -73,6 +73,7 @@
"AgentRuntime",
"OneShotInvoker",
"OneShotEnvelopeError",
"OneShotStatus",
# Tools
"AgentTools",
"HumanTools",
Expand Down
30 changes: 19 additions & 11 deletions src/band/runtime/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
runtime_checkable,
)

from band_sdk_core import ClaimRegistry, ParticipantRoster, RetryTracker
from band_sdk_core import ClaimRegistry, ParticipantRoster, RetryTracker, is_self_echo

from band.client.rest import DEFAULT_REQUEST_OPTIONS
from band.client.streaming import ControlMode, DeliveryStatus
Expand Down Expand Up @@ -1414,11 +1414,15 @@ async def _process_backlog_message(
"""
msg_id = msg.id

# Skip messages from self (agent's own messages) to avoid infinite loops
if (
self._agent_id
and msg.sender_type == "Agent"
and msg.sender_id == self._agent_id
# Skip messages from self (agent's own messages) to avoid infinite loops.
# ``PlatformMessage`` declares these as ``str``, but it's a plain
# dataclass filled from Fern models -- a backend null reaches here as
# ``None`` and would otherwise raise ``TypeError`` from is_self_echo
# (a pyo3 extension with non-Optional ``str`` params).
if self._agent_id and is_self_echo(
sender_id=msg.sender_id or "",
sender_type=msg.sender_type or "",
agent_id=self._agent_id,
):
logger.debug("Skipping self-message %s", msg_id)
return BacklogProcessResult.ADVANCED
Expand Down Expand Up @@ -1811,11 +1815,15 @@ async def _process_event(self, event: PlatformEvent) -> bool:

# For messages: check if we should skip
if isinstance(event, MessageEvent) and msg_id and payload:
# Skip messages from self (agent's own messages) to avoid infinite loops
if (
self._agent_id
and payload.sender_type == "Agent"
and payload.sender_id == self._agent_id
# Skip messages from self (agent's own messages) to avoid infinite
# loops. ``payload`` is hydrated via ``model_construct``, which
# bypasses pydantic validation -- a null sender field would
# otherwise raise ``TypeError`` from is_self_echo's non-Optional
# ``str`` params.
if self._agent_id and is_self_echo(
sender_id=payload.sender_id or "",
sender_type=payload.sender_type or "",
agent_id=self._agent_id,
):
logger.debug("Skipping self-message %s", msg_id)
return True
Expand Down
Loading