Repository navigation
fix!: judge every turn with band-sdk-core's turn outcome - #729
AlexanderZ-Band merged 19 commits into
Conversation
Plumbing for one turn-outcome rule from band-sdk-core (2.8.0): - Pin band-sdk-core==2.8.0; build the Band tool effect table and the band_no_reply definition from core, with parity tests. - band.core.turn.Turn wraps core's TurnLedger; report_unsettled_turn is the one place a missing-reply verdict becomes a room-visible failure. - @records_turn_effects records each agent tool method's effect on tools.turn, so dispatch, direct calls, the crewai/parlant catalogs and deliver_reply all count. A raise or a blank send records nothing. - send_notice / deliver_notice post an adapter's own messages without counting as the reply; settle() ends a turn the adapter answered itself. relay_reply is the one gate for a model's final text. test_reply_boundary guards direct send_message calls. - Custom tools take a required turn and record their declared effect; ACP (external band-mcp) and Letta (external mode) record Band tools from the stream; the Slack tee shares the ledger; per-message tools are bound after busy and approval checks. - Fix Letta reading tool_return_message.tool_name instead of name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
One judge for every Python adapter. A turn that replied, declined via band_no_reply, did real work, was settled by the adapter or already reported a failure is complete. Any other turn is reported once with core's missing-reply text (provider band-runtime) and its delivery is marked FAILED. There is no nudge and no recovery. - SimpleAdapter.on_event (OneShot included) runs the verdict. Contact-hub turns are not judged. a2a, a2a_gateway, crewai_flow, BandACPServerAdapter, parlant and a static-graph LangGraph adapter opt out via judges_turns; Slack delegates to its inner adapter. - Detached turns (claude_sdk, codex, opencode, cursor_acp) are judged on their normal completion path. A cancelled, cleaned-up or interrupted turn posts nothing. - Each adapter's own reply judging is deleted: crewai ReplyTracker flags, strands terminal_fired, pydantic_ai tool_executed, claude_sdk replied_this_turn, copilot_sdk's no-reply RuntimeError, opencode's filler text, ACP turn_replied_in_room, letta's settle-on-call and auto_relay=False check, parlant's module-global sent flags, codex settled_reply. - BASE_INSTRUCTIONS and band_send_message's description teach band_no_reply. docs/turn-outcome.md describes the rule. - Correct "the platform retries" wording: the running agent skips a message once it failed max_message_retries times. - Conformance probes for every judged adapter, runtime verdict rows, detached-turn flows, and two E2E smokes (no_reply, missing reply). BREAKING CHANGE: Every SimpleAdapter turn is now judged by band-sdk-core's turn-outcome rule. Adapters that silently dropped a turn (langgraph, anthropic, gemini, google_adk, agno, letta, codex/ACP empty turns, copilot_sdk empty text) now report it and mark it FAILED. An undeclared custom tool counts as observe. settles_turn_reply, is_terminal_success and missing_reply_error are removed from band.runtime.tools. Custom SimpleAdapter subclasses are judged too: post adapter notices with send_notice and call tools.turn.settle(), because a send_message or deliver_reply post counts as the turn's reply. AgentToolsProtocol gains turn and send_notice. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
DecisionRegistry.wait used asyncio.wait_for, which on Python 3.11 returns an answer that lands in the same tick as a cancel of the waiting task and drops the cancel. Room cleanup does exactly that: it resolves a parked decision, then the ACP connection shutdown cancels the request task. The task carried on into a sender loop that had already exited, and cleanup waited on it forever. asyncio.timeout() delivers the cancel, matching ExecutionContext's event wait. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
A bare token with a mention reads as an FYI, which the peer may now decline with band_no_reply, so the delegation smoke asked for a reply it gave the peer no reason to send. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
An event is not a reply, so a turn that only emits band_send_event, or says nothing to an FYI-shaped "remember this fact", is now reported as a missing reply and marked FAILED. The event smokes end their turn with band_no_reply, and each burst-recall turn asks for a short acknowledgement. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
… repeat Teaching band_no_reply dropped the base prompt's "a turn that ends with the answer written as plain text delivers nothing", which no change meant to remove. The copilot shared-client smoke resent "Please say hello." into a session that had already answered it, a repeat the model may now decline; the successor adapter asks something new instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
OpenCode ends the turn on its question-reject route, which carries no feedback, so a question the room rejected, auto_reject dismissed, or a timeout expired left the turn without a reply and the turn rule reported it as a missing reply. The adapter now sends the rejection as a declining answer, the way a permission reject already carries feedback, so the model sees the refusal and answers the room. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
Review of the turn-outcome change: 21 findings (6 issue, 6 suggestion, 9 nit). Two confirmed defects sit on lines outside GitHub's diff, so they are here rather than inline:
- issue:
src/band/integrations/claude_sdk/dedup_tools.py:175— when a later turn'sband_send_messagehits the dedup cache (same text and mentions within 30 s), the wrapper returnscached_resultwithout callinginner.send_message, so that turn's freshAgentToolsnever records REPLY andon_eventreports a missing reply and marks the message FAILED. Before this PR the turn was judged from the successful tool result. The in-flight join path (existing is not None) has the same gap. Proof: a repro that fails at head (TurnResultAlreadyReported: turn ended without a reply) and passes on main. - issue:
src/band/integrations/slack/adapter.py:898— Slack-originated turns callself._inner.on_message(...)directly and never reachSimpleAdapter.on_event, now the only judge. With the brains' own judges deleted, a Slack turn that posts nothing is no longer reported (a pydantic_ai brain answering in plain text: head emits['task', 'thought', 'task'], main emits[..., 'error']), and claude_sdk's detached report is off on this path too. Proof: a repro that fails at head and passes on main.
- Slack-originated turns now run through SimpleAdapter.run_judged_turn, the same judge on_event uses, so a silent Slack turn is reported again; its already-reported failure is not re-logged as a background-task crash. - A band_send_message the claude_sdk dedup wrapper suppresses (cache hit or joined in-flight send) records REPLY on the current turn, so a CLI retry of the previous reply is no longer a false missing reply. - report_unsettled_turn logs a WARNING naming the room; judge_detached_turn replaces four open-coded detached checks; MISSING_REPLY is the one failure definition the fakes reuse. - strands/pydantic_ai record a declared effect once: the hook covers native tools only, CustomToolDef tools record through execute_custom_tool. - Tests: judging opt-outs pinned per adapter, Codex interrupted-turn notice, undeclared custom-tool rows for six adapters, dedup and Slack regressions; FakeAgentTools.chat replaces repeated content projections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
bandzalkin
left a comment
There was a problem hiding this comment.
Architectural interaction review: #729 with #730 / #731 — HELD
Scope: Focused review of #729's ACP stream-to-turn-ledger boundary at b6b86a7ea462, combined with the revised busy-retry behavior in #731 and backlog recovery in #730. This is not a full approval or rejection of #729's other adapters.
Constraints: The shared turn ledger must remain the canonical judge, but effects must belong to the delivery whose outcome it judges. An ACP session/prompt rejected with -32003 and data.reason=session_busy owns no remote work; session notifications received during that request can belong to an already-running autonomous turn.
Decisions: Keep the shared ledger and core effect mapping. Attempt-local staging in RoomTurnEmitter is smaller and safer than adding another verdict policy or clearing the delivery-wide ledger from the ACP retry loop. Narration can remain live.
Finding A1-729 — High, demonstrated: src/band/integrations/acp/room_emitter.py:76–78,113–126 records completed external Band-tool effects immediately into tools.turn. The unsuccessful-close guard at lines 178–182 runs too late to discard those effects. With external tool recording enabled, an autonomous band_send_message completion received before a structured busy rejection records REPLY on the new delivery. #731 then retries with a fresh collector/emitter, but the same delivery ledger still contains that REPLY. relay_reply suppresses the accepted retry's answer.
Proof: Real ACP socketpair regression test_rejected_attempt_cannot_suppress_accepted_reply: first request streams an autonomous completed band_send_message, then raises structured busy; second request streams The accepted turn's answer.. Revised #731 alone passes. The combined worktree fails: actual reply.texts == [], expected ["The accepted turn's answer."] (1 failed, 48 deselected). Command: uv run --no-sync python -m pytest tests/integrations/acp/test_client_adapter_behavior.py -q -k rejected_attempt after combining the PRs and bringing in #731's regression.
Smallest correction / architectural verdict: Stage external completed-tool effects per emitter; commit them to the canonical turn ledger only on successful prompt close, before relay_reply. Discard staged effects on rejection, timeout, or cancellation, while retaining live narration. Add the above regression for the combined behavior. HELD for combined integration until that boundary is corrected; #729 remains review-only in this fix round.
- Guard that every SimpleAdapter in band is registered in ADAPTER_CONFIGS or named with what covers its verdict (probe, brain, or unjudged). - Runtime verdict rows for core's send_event-only and reply-then-observe fixtures. - docs/turn-outcome.md says why the relay is only a fallback and the prompt keeps its plain-text line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
Compared with band-sdk-typescript#306 (INT-1672), which implements the same core rule. 7 findings: 1 issue:, 4 suggestion:, 2 nit:. The issue (the empty OpenCode question) is blocking. This is posted as a comment because GitHub doesn't allow requesting changes on your own PR.
One finding falls outside the diff:
src/band/runtime/execution.py:2171(and:1571for backlog): nit: a missing reply now leaveson_eventasTurnResultAlreadyReportedand reaches the genericlogger.exception("Error processing %s"). Every silent turn, an expected verdict, therefore logs at ERROR with a traceback, on top ofreport_unsettled_turn's WARNING naming the room. CatchingTurnResultAlreadyReportedbefore the generic handler and logging it at WARNING or DEBUG keeps ERROR alerting for real failures.
Adopted in TS from this PR (band-sdk-typescript@d3a3147): the judged gate on detached turns, ACP renaming a call from a later update (Cursor's "MCP: tool"), ACP relaying its text runs as one answer, OpenCode custom tools resolving their room's turn, the platform's blank rule in the relay, and a live missing-reply check.
- OpenCode answers a malformed, question-less ask with an empty decline instead of the reject route, which blocks the model's loop; the reject route is no longer used. The decline text is true for auto-reject and expiry too. - Turn.detach() always marks the turn released; judge_detached_turn checks judged. A released contact-hub turn reporting later no longer marks the context's next message as already reported. - A send the claude_sdk dedup suppresses records nothing: it is an earlier turn's send re-issued, so it no longer marks a later, silent turn replied. - RoomTurnEmitter stages out-of-process tool effects and commits them only when the prompt closes successfully, so a rejected attempt's streamed work cannot suppress a retry's answer. - A turn failure already reported to the room logs at DEBUG in the runtime instead of ERROR with a traceback. - Guards: covered adapters must map to a real probe; every send_message, deliver_reply and relay_reply call is pinned per file with its reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
|
Replies to the two review bodies, both addressed in 9cafdc2: @bandzalkin, A1-729 (the ACP stream-to-ledger boundary): confirmed and fixed as you proposed. Cross-SDK review, the out-of-diff nit ( Correction to my earlier comment about the dedup wrapper: following the cross-SDK review, a suppressed duplicate now records nothing, rather than counting as the current turn's reply. Details are on that thread. |
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
Second review pass at 9cafdc2: 18 inline comments plus 1 in this body. 5 issues, 6 suggestions, 8 nits. Posted as a comment because GitHub doesn't allow requesting changes on your own PR; the five issues are blocking.
One finding sits outside GitHub's diff (the file now matches main):
- issue:
src/band/integrations/claude_sdk/dedup_tools.py:175, flagged by groups C and E. The dedup cache outlivesupdate_innerand is keyed only by (content, mentions), and a hit now records nothing. A later turn whose model deliberately repeats an earlier answer within 30 s therefore has its post dropped, sees success, and is then reported as a missing reply and marked FAILED. Before 9cafdc2 the hit recorded REPLY, but that marked a later silent turn replied when the CLI re-issued an old send. The cache can't tell those two cases apart, so this needs a decision rather than another flip.
|
Cross-SDK note from band-sdk-typescript#306's second review: a custom tool that resolves to a failure value still records its declared effect here.
TS fixed this in band-sdk-typescript@3562f5b. |
… opt-out A missing-reply post that failed still raised TurnResultAlreadyReported, so the runtime skipped its own fallback report and logged the turn at DEBUG. It now raises a plain error in that case. The report also honors SessionConfig.report_turn_failures_to_room, as the runtime's own failure reports do. Claude SDK and Codex log a WARNING for the failures they report, since the runtime now logs reported failures at DEBUG. Also adds tests for both log levels on the live and backlog paths, pins TurnEffect.did_work to core's ledger, moves the relaying flag onto each turn-outcome probe, and uses core's TurnVerdict in the conformance suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
|
On the review-body finding ( |
The runtime skipped its fallback report for any TurnResultAlreadyReported, but the adapters raise it right after send_failure without checking that the post landed (claude_sdk, codex, letta, a2a and the missing-reply verdict). It now trusts only the context's record of a posted failure. That covers every raise site with one rule, so the missing-reply-only unsettled_turn_error is gone. That also stops a Slack turn whose report failed from logging as a background crash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
|
Focused logical-bugs pass on the cycle-2 fixes found two confirmed defects, both fixed in f091057:
Root-cause fix: Guards:
|
…-outcome-INT-1671 OpenCode: kept this branch's removal of the replied_via_room_tool bookkeeping (the turn ledger records it), and took main's _emit_turn_usage(room_id, turn). Approval room: took main's _await_close refactor without the no-text-reply filler check this branch removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
Design-decision record for peer review: 16 inline comments, each resolved as soon as it was posted. Each one states a choice that has already been made and checked, and why, so it doesn't come back as a new finding. To challenge a decision, reopen its thread.
Decisions with no diff line to attach to:
- claude_sdk dedup (
src/band/integrations/claude_sdk/dedup_tools.py, unchanged from main): a dedup hit records nothing on the turn. The cache can't tell a CLI re-issuing an old send apart from a model repeating an answer within 30 s. Recording REPLY would hide a real missing reply, so we accepted the louder failure. Decided in the review thread. - Retries: a turn failure is reported only on its final delivery attempt, which is main's behavior. It matters only when
max_message_retries > 1. Follow-up: INT-1677. - Live coverage gaps: INT-1678 tracks the live
band_no_replycells for codex, opencode, cursor_acp, copilot_acp and letta, plus the OpenCode question-decline row.
bandzalkin
left a comment
There was a problem hiding this comment.
Scope
Reviewed 37cef255cc425e79af40fe0f3a1c5ebf643445d9 against cc5dac33de5c7e0a5173560c5c12697a8b2e32be: the complete PR diff, affected owners/callers, adapter and protocol boundaries, tests, documentation, and all prior review discussions. Pushed the authorized corrections in 37cef255, then re-reviewed them.
Constraints
The core ledger remains the canonical judge. Effects must belong to the delivery that performed successful work; notices are not replies. Recorded decisions and explicit deferrals remain respected, including Claude dedup behavior, retry-budget reporting, and the deferred live-adapter cells.
Decisions
Keep effect recording at successful tool completion; stage external ACP effects until an accepted prompt closes. Preserve tuple/native recording separation and self-hosted MCP ownership. No additional judge or runtime dependency.
Findings and proof
Three confirmed problems were corrected before this approval:
- Explicit custom-tool failure values no longer record ACT/REPLY/DECLINE or suppress fallback, including native Pydantic AI and serialized Strands results. This addresses the existing failure-value note.
- Parlant preserves all non-preamble final segments already present in one event batch before relaying once.
- Letta correlates grouped external MCP returns by
tool_call_id, recording every successful effect rather than only the first result.
Regression failures were observed before each correction. Real execution-context, native Strands, installed Parlant event-type, Letta wire-type, and socketpair ACP probes exercised success, failure, suppression, cancellation, ordering, and ownership boundaries. The earlier ACP attribution finding is corrected at this revision.
Final local verification: 6,591 passed / 156 skipped in the full non-live suite, including conformance; isolated CrewAI 105 passed / 6 skipped and Parlant 111 passed / 5 skipped; 28 markdown snippet tests passed. Repository-wide Ruff lint/format and Pyrefly passed, as did both scoped framework type checks, wheel build, and isolated extras-free wheel smoke. Baseline collection succeeded in all three dependency environments.
Verdict
APPROVED. No blocking correctness or architectural finding remains. No paid live-platform/provider run was performed, and this approval does not claim that CI for the newly pushed SHA has completed.
This reverts commit 7eb311c. Letta always emits a tool_call_message before its tool_return_message: create_parallel_tool_messages_from_llm_response appends the assistant (calls) message before the grouped tool message, and both the non-streamed LettaResponse and the stream_tokens=False conversation stream convert them in list order. The single-pass id->name lookup was already correct; the order-independent two-pass scan guarded an order the server never produces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW717aA3yKxzE31y18jB4r
Letta's grouped tool_returns carry no tool name, so a return is joined to its call by tool_call_id. Track the calls awaiting a return and pop each one as its result lands, with one path for both the grouped and the single-return shapes, instead of a name map plus a legacy branch. Every parallel call and result is now reported, not only the first, and a multimodal result serializes. Test fakes are real letta-client models, so the adapter no longer guards against MagicMock attributes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW717aA3yKxzE31y18jB4r
…-outcome-INT-1671 Custom tools keep recording their declared turn effect on room-bound endpoints: build_custom_tool_registration takes get_tools plus room_from_connection, advertising chat_id only on a multi-room endpoint. claude_sdk binds room tools after the busy guard, then ensures its HTTP backend. OpenCode tests dispatch model band calls through the resolver the faked backend start recorded (BandMCPCalls over backends_created_by). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW717aA3yKxzE31y18jB4r
Also imports the opencode probe's helpers at module level: the module already imports the opencode adapter (and so mcp), which every lane's venv carries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WW717aA3yKxzE31y18jB4r
…3 event re-posts Squashed from band-ai#713 at 61d0105, resolved over upstream main: ProviderRunError and RoomExecutionStoppedError sit side by side, and band-ai#729's detached-turn test models a running room now that a stopped room skips event posts. Upstream-PR: band-ai#713
…3 event re-posts Squashed from band-ai#713 at 61d0105, resolved over upstream main: ProviderRunError and RoomExecutionStoppedError sit side by side, and band-ai#729's detached-turn test models a running room now that a stopped room skips event posts. Upstream-PR: band-ai#713
…3 event re-posts Squashed from band-ai#713 at 61d0105, resolved over upstream main: ProviderRunError and RoomExecutionStoppedError sit side by side, and band-ai#729's detached-turn test models a running room now that a stopped room skips event posts. Upstream-PR: band-ai#713
…3 event re-posts Squashed from band-ai#713 at 61d0105, resolved over upstream main: ProviderRunError and RoomExecutionStoppedError sit side by side, and band-ai#729's detached-turn test models a running room now that a stopped room skips event posts. Upstream-PR: band-ai#713
…3 event re-posts Squashed from band-ai#713 at 61d0105, resolved over upstream main: ProviderRunError and RoomExecutionStoppedError sit side by side, and band-ai#729's detached-turn test models a running room now that a stopped room skips event posts. Upstream-PR: band-ai#713
…3 event re-posts Squashed from band-ai#713 at 61d0105, resolved over upstream main: ProviderRunError and RoomExecutionStoppedError sit side by side, and band-ai#729's detached-turn test models a running room now that a stopped room skips event posts. Upstream-PR: band-ai#713
…3 event re-posts Squashed from band-ai#713 at 61d0105, resolved over upstream main: ProviderRunError and RoomExecutionStoppedError sit side by side, and band-ai#729's detached-turn test models a running room now that a stopped room skips event posts. Upstream-PR: band-ai#713
…3 event re-posts Squashed from band-ai#713 at 61d0105, resolved over upstream main: ProviderRunError and RoomExecutionStoppedError sit side by side, and band-ai#729's detached-turn test models a running room now that a stopped room skips event posts. Upstream-PR: band-ai#713
…3 event re-posts Squashed from band-ai#713 at 61d0105, resolved over upstream main: ProviderRunError and RoomExecutionStoppedError sit side by side, and band-ai#729's detached-turn test models a running room now that a stopped room skips event posts. Upstream-PR: band-ai#713
…3 event re-posts Squashed from band-ai#713 at 61d0105, resolved over upstream main: ProviderRunError and RoomExecutionStoppedError sit side by side, and band-ai#729's detached-turn test models a running room now that a stopped room skips event posts. Upstream-PR: band-ai#713
Summary
Every Python adapter's turn is now judged by one rule from band-sdk-core 2.8.0, and the outcome is reported honestly (Phase 2 of INT-1668, the Python sibling of INT-1672; plan attached to INT-1671).
band_no_reply, did real work, was settled by the adapter, or already reported a failure.band-runtime) and the delivery is marked FAILED. No nudge, no recovery.This fixes the silent loss in nightly 37177615499 (
test_recall_and_ignore_crosstalk_in_busy_room[crewai]): a failedband_send_message, aband_send_event, then an empty completion used to be reported PROCESSED.Part 1 — plumbing (
refactor:commit)band-sdk-core==2.8.0; the Band tool effect table andband_no_reply's definition come from core (parity tests intests/core/test_turn_types.py).band.core.turn.Turn(wraps core'sTurnLedger) on everyAgentTools/FakeAgentToolsastools.turn;@records_turn_effectsrecords each tool method's effect, so dispatch, direct calls, crewai's catalog, parlant anddeliver_replyall count.send_notice/deliver_noticefor adapter-originated posts (approval prompts, busy notices,/status, help, timeouts);turn.settle()when the adapter answers a message itself;relay_replyis the one gate for a model's final text.tests/framework_conformance/test_reply_boundary.pypins every reply-path call per file.turnand record their declared effect; ACP (external band-mcp) and Letta (external mode) record Band tools from the stream; the Slack tee shares the ledger; per-message tools bind after busy/approval checks.tool_return_message.tool_name(always "unknown"); it isname.Part 2 — one judge (
fix!:commit)SimpleAdapter.run_judged_turn, whichon_event(OneShot included) and the Slack adapter's Slack-inbound path both call; detached turns (claude_sdk, codex, opencode, cursor_acp) are judged on their normal completion path, never afinally; a cancelled, cleaned-up or interrupted turn posts nothing.judges_turns: a2a, a2a_gateway, crewai_flow,BandACPServerAdapter, parlant, a static-graph=LangGraph adapter; Slack delegates; contact-hub turns are never judged.ReplyTrackerflags, strandsterminal_fired, pydantic_aitool_executed, claude_sdkreplied_this_turn, copilot_sdk's no-replyRuntimeError, opencode's filler text, ACPturn_replied_in_room, letta's settle-on-call, parlant's module-global sent flags, codexsettled_reply).BASE_INSTRUCTIONSandband_send_message's description teachband_no_reply; newdocs/turn-outcome.md(runnable snippets) linked from AGENTS.md; "the platform retries" wording corrected.test_turn_outcome.py+turnprobes/), runtime verdict rows through the realExecutionContext(tests/runtime/test_turn_verdict.py), ledger tests, per-adapter verdict tests, mid-turn prompt and detached-turn flows; two new E2E smokes (test_no_reply.py,test_missing_reply_reported.py) and the regeneratedexpected-cells.json.Deviations from the plan, and why
Turn,report_unsettled_turn,judge_detached_turnandMISSING_REPLYlive inband.core.turn,TURN_FAILURE_PROVIDERinband.core.protocols, andis_contact_hub_turnplus the synthetic-sender constants inband.core.types.SimpleAdapter(core) can't importband.runtimebecause of an import cycle (runtime/__init__→oneshot→SimpleAdapter).records_turn_effectsstays inband.runtime.turn.Turn.detach()always marks a turn released, andjudge_detached_turnreports only a turn that is both judged and detached, so a contact-hub turn parked on a decision is never reported later.deliver_notice(send_noticewrapped inDeliveryFailedError, likedeliver_reply) for adapter posts inside provider try/except blocks.on_interruptsettles the parked turn, so a/stopnever produces a missing-reply report.(InputModel, handler)pairs) keep their declared effect in strands and pydantic_ai, recorded from those adapters' existing hooks.(InputModel, handler)pairs record once, throughexecute_custom_tool. Agno tools and ready-made LangChain tools count asobserve(documented).relay_reply.auto_relay=Falseno longer runs its own missing-reply check: it logs and drops the final text, and the shared verdict decides the turn.send_failurefrom its own provider, which completes the turn, so the verdict never adds a second report.test_missing_reply_reported.pyremovesband_send_message/band_no_replywithexclude_toolsso the turn is silent by construction instead of by prompt. anthropic, claude_sdk, gemini and google_adk ignoreinclude_tools/exclude_tools(a pre-existing gap), so a model could still decline; they are N/A for that one smoke, and their verdict stays covered by the conformance probes. copilot_sdk is N/A because it relays the model's final text.Follow-up commits
DecisionRegistry.waitusesasyncio.timeout()instead ofwait_for. On Python 3.11,wait_fordrops a cancel that arrives in the same tick as the answer, so a cleaned-up turn carried on. This was pre-existing, and it surfaced as a 3.11 CI timeout in cursor_acp.OpenCode answers a rejected question with a decline (
DECLINED_QUESTION_ANSWER) instead of callingPOST /question/{id}/reject. That route carries no feedback, so OpenCode ended the turn and the model never replied. Verified live 2/2. A malformed question with nothing to answer still goes to the reject route.The base prompt's plain-text warning ("a turn that ends with the answer written as plain text delivers nothing") is restored.
E2E fixtures: event-only and remember-only smoke turns end with
band_no_reply, the delegating peer asks a question instead of forwarding a bare token, and the copilot successor's repeat prompt is varied.Review fixes:
report_unsettled_turnlogs a WARNING naming the room.judges_turnsopt-out, a Codex interrupted-turn notice, and an undeclared custom tool for six adapters.Cross-SDK and ACP-boundary reviews (9cafdc2):
RoomTurnEmitterstages out-of-process effects until the prompt closes successfully.Second review pass (2741048):
_turn_failure_reported), not because an adapter raisedTurnResultAlreadyReported. A report that didn't post now gets the generic fallback, at every raise site (f091057).SessionConfig.report_turn_failures_to_room.TurnEffect.did_workto core's ledger.relays, and the conformance suite uses core'sTurnVerdict.For reviewers
The non-obvious design choices are recorded as resolved inline comments prefixed
decision:(review "Design-decision record"). Each states the choice, why, and its guard; to challenge one, reopen its thread rather than starting a new one. main is merged in (c4653ee).Follow-ups
max_message_retries > 1.band_no_replycells for codex, opencode, cursor_acp, copilot_acp and letta, plus a live OpenCode question-decline row.Release note
BREAKING CHANGE: Every
SimpleAdapterturn is now judged by band-sdk-core's turn-outcome rule.observe.settles_turn_reply,is_terminal_successandmissing_reply_errorare removed fromband.runtime.tools.SimpleAdaptersubclasses are judged too. Their own notices should usesend_noticeandtools.turn.settle(), because a post throughsend_message/deliver_replycounts as the turn's reply.AgentToolsProtocolgainsturnandsend_notice.BASE_INSTRUCTIONSandband_send_message's description teachband_no_reply.Test plan
uv run ruff check .,uv run ruff format --check .,uv run pyrefly checkdev --all-packages; CI's crewai and parlant job commands indev-crewai/dev-parlantgh workflow run e2e.yml -f lane=all -f os=all🤖 Generated with Claude Code
https://claude.ai/code/session_016nwuEBtdtneuDkJHcvijDQ