Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
d1419b7
fix: Keep working-state reporting alive through provider turns
AlexanderZ-Band Oct 18, 2026
8ec26b3
fix: Preserve standalone worker binding across tool wrappers
AlexanderZ-Band Oct 18, 2026
1476457
test: Avoid the E2E peer fixture reservation in runtime tests
AlexanderZ-Band Oct 18, 2026
8bfeac0
style: Format the renamed activity platform fixture
AlexanderZ-Band Oct 18, 2026
a397d8c
test: Use real time for randomized scheduler grace controls
AlexanderZ-Band Oct 18, 2026
0342d96
feat: Deliver compatible activity detail through the working-state re…
AlexanderZ-Band Oct 18, 2026
d76f571
style: Format detail ownership fixture parameters
AlexanderZ-Band Oct 18, 2026
1842638
test: Avoid reserved peer fixtures in activity wire tests
AlexanderZ-Band Oct 18, 2026
06d8124
test: Preserve backlog ordering across valid recovery retries
AlexanderZ-Band Oct 18, 2026
1249060
Merge remote-tracking branch 'origin/main' into feat/python-sdk-repor…
AlexanderZ-Band Oct 20, 2026
1ff4c54
Merge remote-tracking branch 'origin/feat/python-sdk-report-activity-…
AlexanderZ-Band Oct 20, 2026
f2ff776
Merge remote-tracking branch 'origin/main' into feat/python-sdk-repor…
AlexanderZ-Band Oct 20, 2026
2f4082e
Merge remote-tracking branch 'origin/feat/python-sdk-report-activity-…
AlexanderZ-Band Oct 20, 2026
650f8d7
Merge remote-tracking branch 'origin/main' into feat/python-sdk-repor…
AlexanderZ-Band Oct 20, 2026
6a62878
refactor: Flatten the activity compatibility retry
AlexanderZ-Band Oct 20, 2026
4a04046
fix: Learn activity detail support from response headers
AlexanderZ-Band Oct 20, 2026
eac1fdd
fix: Forward activity detail through the baseline ControlLink
AlexanderZ-Band Oct 20, 2026
9a1430b
refactor: Keep one detail reset and one ownership recheck in the repo…
AlexanderZ-Band Oct 20, 2026
435ae73
test: Scope the recovery-jitter patch to resync's random reference
AlexanderZ-Band Oct 20, 2026
90bc52a
fix: Log a content-free cause when activity detail is downgraded
AlexanderZ-Band Oct 20, 2026
3c665de
refactor: Drop the unreachable cycle token and pin reporter guards
AlexanderZ-Band Oct 20, 2026
2c4a9f7
test: Share one activity-support isolation helper
AlexanderZ-Band Oct 20, 2026
823eeca
Merge upstream main through #838 into the activity delivery branch
Oct 20, 2026
7298896
Merge origin/main (#833, #882, #883, #885) into the activity delivery…
AlexanderZ-Band Oct 21, 2026
9d0953d
refactor: Keep activity detail on the turn and send it from the keep-…
AlexanderZ-Band Oct 21, 2026
e0541b9
Merge remote-tracking branch 'origin/main' into feat/python-sdk-repor…
AlexanderZ-Band Oct 21, 2026
6860082
refactor: Return early for a missing platform version
AlexanderZ-Band Oct 21, 2026
1faf28b
docs: Describe activity detail and the per-report deadline in reporte…
AlexanderZ-Band Oct 21, 2026
978c0ac
test: Split the activity deadline test and pin the exact deadline
AlexanderZ-Band Oct 21, 2026
dfd81ca
test: Prove activity detail through realistic turn flows
AlexanderZ-Band Oct 21, 2026
5037fb9
refactor: Move the activity producers beside the observer they report to
AlexanderZ-Band Oct 21, 2026
ec7f54c
test: Share one set of activity report bodies across the activity suites
AlexanderZ-Band Oct 21, 2026
1b98ee9
test: Prove visible activity changes report without waiting for a kee…
AlexanderZ-Band Oct 21, 2026
904dbe8
docs: State that an aborted owner's reports carry no detail
AlexanderZ-Band Oct 21, 2026
9f7d2ea
test: Split the activity detail suite by concern and flush before neg…
AlexanderZ-Band Oct 21, 2026
40843c7
Merge remote-tracking branch 'origin/main' into feat/python-sdk-repor…
AlexanderZ-Band Oct 21, 2026
7b29330
Merge remote-tracking branch 'origin/main' into feat/python-sdk-repor…
AlexanderZ-Band Oct 21, 2026
842935c
Merge branch 'main' into feat/python-sdk-report-activity-detail-to-th…
AlexanderZ-Band Oct 21, 2026
a2bb026
test: avoid repeated interpreter startup in CI coverage guard
AlexanderZ-Band Oct 21, 2026
3f59acf
feat: Report Band tool calls as turn activity
AlexanderZ-Band Oct 21, 2026
81740a2
refactor: Share the agent-tool method lookup between the turn decorators
AlexanderZ-Band Oct 21, 2026
9a8261b
Merge remote-tracking branch 'origin/main' into feat/python-sdk-repor…
AlexanderZ-Band Oct 21, 2026
faea662
fix: Make tool_activity the single owner of what a tool shows
AlexanderZ-Band Oct 21, 2026
770875f
fix: Show slack_send_message as turn activity and tighten activity tests
AlexanderZ-Band Oct 21, 2026
9071d10
Merge the delivery branch into the producers branch
AlexanderZ-Band Oct 21, 2026
429d478
style: Sort the activity kit imports
AlexanderZ-Band Oct 21, 2026
3483221
Merge remote-tracking branch 'origin/main' into the producers branch
AlexanderZ-Band Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ jobs:
tests/integrations/test_crewai_flow_real_sdk.py \
tests/integrations/test_crewai_real_tools.py \
tests/integrations/test_crewai_completion.py \
tests/integrations/crewai/activity/test_execution.py \
tests/integrations/crewai/activity/test_send_event.py \
tests/test_capability_gating_e2e.py \
tests/framework_conformance/test_files_image_passthrough_matrix.py \
tests/framework_conformance/ -k crewai
Expand Down Expand Up @@ -325,6 +327,8 @@ jobs:
tests/integrations/parlant/test_customtools.py \
tests/integrations/parlant/test_filetools.py \
tests/integrations/parlant/test_guard.py \
tests/integrations/parlant/activity/test_execution.py \
tests/integrations/parlant/activity/test_send_event.py \
tests/integrations/parlant/test_preambletask.py \
tests/integrations/parlant/test_selection.py \
tests/integrations/parlant/test_server.py \
Expand Down
10 changes: 10 additions & 0 deletions docs/turn-outcome.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,16 @@ change wakes the reporter's keep-alive, which sends the latest detail, so
changes made while a report is in flight coalesce. The duration cap stops both kinds of refresh
without cancelling provider work; actual completion still clears.

Every Band tool call shows as a `tool_call` with its name, or `memory` while it
saves a memory, then returns to reasoning when it ends, even on failure or
cancellation. `band.runtime.toolactivity` owns that mapping: wrap any other tool
call in its `tool_activity`. Only the outermost call reports, so a Band tool
that calls other Band tools shows as itself throughout. `send_event` reports
only where a model invoked it, since adapters also post their own events
through it. A tool's end lands on the turn it started on, even if its tools are
rebound to a newer turn meanwhile, and a failed report never changes the tool's
outcome.

Detail compatibility is learned from the `X-API-Version` header the platform
puts on every activity response. Reports stay boolean until a response in the
process advertises agent API 1.25.0 or later; a missing or older version keeps
Expand Down
4 changes: 3 additions & 1 deletion src/band/adapters/strands.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
reject_conflicting_tool_names,
)
from band.runtime.prompts import render_system_prompt, with_system_updates
from band.runtime.toolactivity import tool_activity
from band.runtime.tools import (
ALL_TOOL_NAMES,
ToolCallOutcome,
Expand Down Expand Up @@ -354,7 +355,8 @@ async def _execute(self, arguments: dict[str, Any]) -> ToolCallOutcome:
Callable[..., Awaitable[object]],
getattr(self._tools, self._definition.method_name),
)
value = await method(**validated)
async with tool_activity(self._tools, self._definition.name):
value = await method(**validated)
except Exception as error:
logger.exception("Platform tool %s failed", self.tool_name)
message = f"Error executing {self.tool_name}: {error}"
Expand Down
6 changes: 6 additions & 0 deletions src/band/integrations/claude_sdk/dedup_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
from collections import OrderedDict
from typing import TYPE_CHECKING, Any

from band.runtime.activity import ActivityObserver, activity_observer_of

if TYPE_CHECKING:
from band.core.protocols import AgentToolsProtocol

Expand Down Expand Up @@ -199,6 +201,10 @@ async def send_message(

# --- transparent passthrough --------------------------------------

@property
def _activity_observer(self) -> ActivityObserver | None:
return activity_observer_of(self._inner)
Comment thread
AlexanderZ-Band marked this conversation as resolved.

def __getattr__(self, name: str) -> Any:
# ``__getattr__`` only runs for attributes not found via the
# normal MRO. ``send_message`` is defined above, so it never
Expand Down
8 changes: 5 additions & 3 deletions src/band/integrations/crewai/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from band.core.protocols import AgentToolsProtocol
from band.core.task_types import TaskAssignmentStatus, TaskLifecycleState, TaskListState
from band.integrations.crewai.reporting import CrewAIToolReporter
from band.runtime.toolactivity import tool_activity
from band.runtime.tools import (
BandTool,
file_content_placeholder,
Expand Down Expand Up @@ -288,9 +289,10 @@ async def _send_event(call: Invocation, **kwargs: Any) -> Any:
# bypassing args_schema, and _execute_tool turns the ValueError into the
# error result the caller expects.
args = validate_tool_arguments(BandTool.SEND_EVENT, SEND_EVENT_ARGS_SCHEMA, kwargs)
await call.tools.send_event(
args["content"], args["message_type"], metadata=args.get("metadata")
)
async with tool_activity(call.tools, BandTool.SEND_EVENT):
Comment thread
AlexanderZ-Band marked this conversation as resolved.
await call.tools.send_event(
args["content"], args["message_type"], metadata=args.get("metadata")
)
return succeeded("Event sent")


Expand Down
8 changes: 5 additions & 3 deletions src/band/integrations/crewai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
execute_custom_tool,
get_custom_tool_name,
)
from band.runtime.toolactivity import tool_activity
from band.runtime.tools import (
EVENT_TOOL_NAMES,
BandTool,
Expand Down Expand Up @@ -219,9 +220,10 @@ class CustomCrewAITool(BaseTool):
def _run(self, *_args: Any, **kwargs: Any) -> Any:
async def execute(tools: AgentToolsProtocol) -> str:
await reporter.report_call(tools, tool_name, kwargs)
result = await execute_custom_tool(
tool=definition, arguments=kwargs, turn=tools.turn
)
async with tool_activity(tools, tool_name):
Comment thread
AlexanderZ-Band marked this conversation as resolved.
result = await execute_custom_tool(
tool=definition, arguments=kwargs, turn=tools.turn
)
await reporter.report_result(tools, tool_name, result)
return json.dumps({"status": "success", "result": result}, default=str)

Expand Down
4 changes: 3 additions & 1 deletion src/band/integrations/mcp/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
execute_custom_tool,
get_custom_tool_name,
)
from band.runtime.toolactivity import tool_activity
from band.runtime.tools import (
CHAT_ID_FIELD_NAME,
CHAT_ID_MAX_LENGTH,
Expand Down Expand Up @@ -219,7 +220,8 @@ async def dispatch_tool(
"""
method = resolve_tool_method(tools, definition)
try:
return await method(**arguments)
async with tool_activity(tools, definition.name):
Comment thread
AlexanderZ-Band marked this conversation as resolved.
return await method(**arguments)
Comment thread
AlexanderZ-Band marked this conversation as resolved.
except (ValueError, BandToolError) as error:
raise enrich_send_message_error(definition, tools, error) from error

Expand Down
6 changes: 4 additions & 2 deletions src/band/integrations/parlant/bandtools/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
split_mentions,
)
from band.integrations.parlant.sessiontools import require_session_tools
from band.runtime.tools import ParticipantAddStatus, serialize_tool_result
from band.runtime.toolactivity import tool_activity
from band.runtime.tools import BandTool, ParticipantAddStatus, serialize_tool_result

EVENT_MESSAGE_TYPES: tuple[str, ...] = get_args(EventMessageType)

Expand Down Expand Up @@ -59,7 +60,8 @@ async def band_send_event(
if message_type not in EVENT_MESSAGE_TYPES:
return invalid_choice("message_type", message_type, EVENT_MESSAGE_TYPES)

await tools.send_event(content, message_type, None)
async with tool_activity(tools, BandTool.SEND_EVENT):
await tools.send_event(content, message_type, None)
return ToolResult(data=f"Event ({message_type}) sent successfully")


Expand Down
14 changes: 8 additions & 6 deletions src/band/integrations/parlant/customtools.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
require_session_tools,
)
from band.runtime.custom_tools import CustomToolDef, execute_custom_tool
from band.runtime.toolactivity import tool_activity
from band.runtime.tools import tool_result_text

# Parlant delivers every argument as a string, which only lax validation
Expand All @@ -30,12 +31,13 @@ def build_custom_tool(tool_def: CustomToolDef) -> Any:

async def run(context: ToolContext, **arguments: Any) -> ToolResult:
tools = require_session_tools(context)
result = await execute_custom_tool(
tool_def,
parse_delivered(arguments, described.fields),
turn=tools.turn,
strict=STRICT_VALIDATION,
)
async with tool_activity(tools, described.name):
result = await execute_custom_tool(
tool_def,
parse_delivered(arguments, described.fields),
turn=tools.turn,
strict=STRICT_VALIDATION,
)
return ToolResult(data=tool_result_text(result))

run.__name__ = described.name
Expand Down
4 changes: 3 additions & 1 deletion src/band/integrations/pydantic_ai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from band.core.protocols import AgentToolsProtocol
from band.core.tool_filter import sanitize_tool_schema
from band.core.types import AdapterFeatures, MessageType
from band.runtime.toolactivity import tool_activity
from band.runtime.tools import (
BandTool,
ToolDefinition,
Expand Down Expand Up @@ -202,7 +203,8 @@ async def dispatch(ctx: RunContext[AgentToolsProtocol], **arguments: Any) -> Any
# something to hand the model as a tool error it could act on.
method = _resolve_method(ctx.deps, definition)
try:
result = await method(**kwargs)
async with tool_activity(ctx.deps, definition.name):
result = await method(**kwargs)
except Exception as error: # noqa: BLE001 -- tool calls may raise any exception type; must surface to the LLM as an error string, not crash the turn
return await _dispatch_failed(ctx, definition, error, notify_room=True)
try:
Expand Down
13 changes: 8 additions & 5 deletions src/band/integrations/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
dispose_adapter,
dispose_framework,
)
from band.runtime.toolactivity import tool_activity
from band.runtime.tools import AgentTools, ToolCallOutcome, TurnEffect
from band.runtime.tools.schema import is_failed_tool_output

Expand Down Expand Up @@ -162,6 +163,7 @@ def __init__(
# adapter judging ``wrap`` sees what the brain did through this tee.
self._ctx = wrap._ctx
self._scope = wrap._scope
self._activity_observer = wrap._activity_observer
Comment thread
AlexanderZ-Band marked this conversation as resolved.
Comment thread
AlexanderZ-Band marked this conversation as resolved.
self.turn = wrap.turn
self._slack = slack
self._binding = binding
Expand Down Expand Up @@ -214,11 +216,12 @@ async def slack_send_message(self, content: str) -> dict[str, Any]:
failed (e.g. the channel was deleted, the bot was kicked).
"""
try:
await self._slack.chat_postMessage(
channel=self._binding.channel,
text=content,
thread_ts=self._binding.thread_ts,
)
async with tool_activity(self, SLACK_SEND_MESSAGE_TOOL_NAME):
await self._slack.chat_postMessage(
channel=self._binding.channel,
text=content,
thread_ts=self._binding.thread_ts,
)
except Exception as exc:
logger.exception(
"slack_send_message failed (channel=%s thread_ts=%s)",
Expand Down
28 changes: 10 additions & 18 deletions src/band/runtime/activity.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Names-only room activity detail, the per-turn state that holds it, and the
producers a turn reports it through."""
observer a turn reports it through."""

from __future__ import annotations

from dataclasses import dataclass
from inspect import getattr_static
from typing import Protocol

from band_rest.types.agent_activity_detail import AgentActivityDetail
Expand Down Expand Up @@ -56,25 +57,16 @@ def apply(
self.waiting = waiting


def _observer(tools: object) -> ActivityObserver | None:
def activity_observer_of(tools: object) -> ActivityObserver | None:
Comment thread
AlexanderZ-Band marked this conversation as resolved.
"""The turn observer bound to ``tools``, if it declares one."""
# Only a declared attribute counts: a dynamic proxy (``__getattr__``, a
# mock) would conjure an observer it never had. The second, dynamic read
# resolves a declared property such as a wrapper's pass-through.
if getattr_static(tools, "_activity_observer", None) is None:
return None
return getattr(tools, "_activity_observer", None)


async def report_tool_activity(tools: object, name: str) -> None:
if observer := _observer(tools):
await observer.update(tool_call_activity(name))


async def end_tool_activity(tools: object) -> None:
if observer := _observer(tools):
await observer.update(REASONING)


async def report_memory_activity(tools: object) -> None:
if observer := _observer(tools):
await observer.update(MEMORY)


async def set_permission_wait(tools: object, waiting: bool) -> None:
if observer := _observer(tools):
if observer := activity_observer_of(tools):
await observer.set_manual_wait(waiting)
113 changes: 113 additions & 0 deletions src/band/runtime/toolactivity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""What a tool call shows as its turn's activity, and the one place it is reported."""

from __future__ import annotations

import functools
import logging
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from contextvars import ContextVar
from typing import Any

from band_rest.types.agent_activity_detail import AgentActivityDetail

from band.runtime.activity import (
MEMORY,
REASONING,
ActivityObserver,
activity_observer_of,
tool_call_activity,
)
from band.runtime.tools.effects import turn_effect
from band.runtime.tools.registry import (
MEMORY_TOOL_NAMES,
ToolsClass,
agent_tool_methods,
)
from band.runtime.tools.types import BandTool

logger = logging.getLogger(__name__)

MEMORY_SAVE_TOOLS = frozenset(
name for name in MEMORY_TOOL_NAMES if turn_effect(name).did_work
)

# Set while a tool call is shown, so a Band tool another tool calls stays hidden.
_showing_tool: ContextVar[bool] = ContextVar("band_showing_tool", default=False)


def tool_detail(name: str) -> AgentActivityDetail:
"""What the room sees while ``name`` runs."""
return MEMORY if name in MEMORY_SAVE_TOOLS else tool_call_activity(name)
Comment thread
AlexanderZ-Band marked this conversation as resolved.


def method_reports_activity(name: str) -> bool:
"""Whether a tools method reports itself; adapters post their own events
through ``send_event``, so only a model's call to it reports."""
return name != BandTool.SEND_EVENT
Comment thread
AlexanderZ-Band marked this conversation as resolved.


async def _show(observer: ActivityObserver, detail: AgentActivityDetail) -> None:
"""Activity is best-effort: a failed report never changes the tool's outcome."""
try:
await observer.update(detail)
except Exception:
logger.warning("Could not report %s activity", detail.kind, exc_info=True)


@asynccontextmanager
async def tool_activity(tools: object, name: str) -> AsyncIterator[None]:
"""Show ``name`` as the turn's activity until the tool ends.

Only the outermost call reports, so a tool that calls other Band tools
shows as itself throughout. The observer is resolved once so the end lands
on the turn the tool started on, even if ``tools`` is rebound meanwhile.
"""
observer = activity_observer_of(tools)
if observer is None or _showing_tool.get():
yield
return
token = _showing_tool.set(True)
try:
await _show(observer, tool_detail(name))
yield
finally:
_showing_tool.reset(token)
await _show(observer, REASONING)
Comment thread
AlexanderZ-Band marked this conversation as resolved.


async def _show_on(tools: object, detail: AgentActivityDetail) -> None:
if observer := activity_observer_of(tools):
await _show(observer, detail)


async def report_tool_activity(tools: object, name: str) -> None:
Comment thread
AlexanderZ-Band marked this conversation as resolved.
"""Show ``name`` for a tool whose start and end arrive as separate events."""
await _show_on(tools, tool_detail(name))


async def end_tool_activity(tools: object) -> None:
await _show_on(tools, REASONING)


async def report_memory_activity(tools: object) -> None:
await _show_on(tools, MEMORY)


def _observing(
tool_name: str, method: Callable[..., Awaitable[Any]]
) -> Callable[..., Awaitable[Any]]:
@functools.wraps(method)
async def observe(self: Any, *args: Any, **kwargs: Any) -> Any:
async with tool_activity(self, tool_name):
return await method(self, *args, **kwargs)

return observe


def reports_tool_activity(cls: ToolsClass) -> ToolsClass:
"""Report each agent tool method that ``method_reports_activity`` selects."""
for definition, method in agent_tool_methods(cls):
if method_reports_activity(definition.name):
setattr(cls, definition.method_name, _observing(definition.name, method))
return cls
Loading
Loading