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
57 changes: 57 additions & 0 deletions src/band/core/activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Room-visible activity detail and the choice that governs tool names.

A tool name reaches the room the same way its narration does: an agent that
does not report tool calls must not leak their names through the live
indicator. Every reporter funnels through the execution context's observer,
so the decision is applied once there and never per adapter.
"""

from __future__ import annotations

from collections.abc import Callable, Collection
from typing import TYPE_CHECKING

from band_rest.types.agent_activity_detail import AgentActivityDetail

from band.core.types import Emit

if TYPE_CHECKING:
from band.runtime.activity import ActivityObserver


class ToolNamesObserver:
"""Hide tool names while leaving every other activity detail intact.

Permission waits and memory activity name no content, so they report
whatever the tool-name choice is. ``hidden`` is consulted at report time,
never bound at construction: an observer attached before the adapter's
emit sync must still follow the turn's live choice.
"""

def __init__(
self, inner: ActivityObserver, hidden: Callable[[], bool] = lambda: True
) -> None:
self._inner = inner
self._hidden = hidden

async def update(self, activity: AgentActivityDetail) -> None:
if activity.kind == "tool_call" and self._hidden():
await self._inner.update(AgentActivityDetail(kind="reasoning"))
return
await self._inner.update(activity)

async def set_manual_wait(self, waiting: bool) -> None:
await self._inner.set_manual_wait(waiting)


def sync_activity_tool_names(tools: object, emit: Collection[Emit]) -> None:
"""Push the adapter's tool-narration choice onto the bound context.

``Emit.TOOL_CALLS`` absent means the room sees ``reasoning`` where a tool
runs. Tools without an execution context (fakes, unbound instances) have
no room indicator to gate.
"""
ctx = getattr(tools, "_ctx", None)
setter = getattr(ctx, "set_activity_tool_names", None)
if setter is not None:
setter(Emit.TOOL_CALLS in emit)
4 changes: 4 additions & 0 deletions src/band/core/simple_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from band.client.rest import AsyncRestClient
from band.client.streaming import ControlMode
from band.core.activity import sync_activity_tool_names
from band.core.disposal import AdapterDisposal, CleanupCallback
from band.core.exceptions import BandConfigError, InputNotAccepted
from band.core.handoff import context_handoff
Expand Down Expand Up @@ -441,6 +442,9 @@ async def on_event(self, inp: AgentInput) -> None:
else:
converted_history = inp.history

# One turn, one gate decision: the live tool-name detail follows
# the same choice as tool-call narration (band.core.activity).
sync_activity_tool_names(inp.tools, self.features.emit)
await self.run_judged_turn(
msg=inp.msg,
tools=inp.tools,
Expand Down
22 changes: 21 additions & 1 deletion src/band/runtime/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
MessageCreatedPayload,
MessageMetadata,
)
from band.core.activity import ToolNamesObserver
from band.core.exceptions import (
CleanupIncompleteError,
InputNotAccepted,
Expand Down Expand Up @@ -432,6 +433,10 @@ def __init__(
self._activity_owner: TurnScope | None = None
self._activity_stop_scope: TurnScope | None = None
self._activity_completion: asyncio.Task[None] | None = None
# Tool names follow the adapter's tool-narration choice; the default
# matches emit's opt-out semantics, so only adapters that drop
# Emit.TOOL_CALLS (via sync_activity_tool_names) hide them.
self._activity_tool_names = True
self._control_revision = 0
self._on_platform_stop = on_platform_stop
self._shutdown_task: asyncio.Task[bool] | None = None
Expand Down Expand Up @@ -1944,8 +1949,23 @@ async def _start_activity(self, scope: TurnScope) -> None:
self._activity_owner = scope
await self._working_reporter.start()

def set_activity_tool_names(self, enabled: bool) -> None:
"""Gate tool names in room activity to match tool-call narration.

Permission waits and memory activity are unaffected: they name no
content. The gate is consulted at report time, so an observer bound
before this change (a continuation's adapter pumping tool events while
the runner re-reads the choice) still follows the live choice.
"""
self._activity_tool_names = enabled

def activity_observer(self, scope: TurnScope | None) -> ActivityObserver | None:
return ScopeActivityObserver(self, scope) if scope is not None else None
if scope is None:
return None
return ToolNamesObserver(
ScopeActivityObserver(self, scope),
hidden=lambda: not self._activity_tool_names,
)

@property
def _visible_activity(self) -> AgentActivityDetail | None:
Expand Down
153 changes: 153 additions & 0 deletions tests/runtime/activityownership/test_namegate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Tool names reach the room's activity detail only while tool-call narration
is on; permission and memory activity name no content and report either way."""

from __future__ import annotations

from collections.abc import Callable
from types import SimpleNamespace

import pytest

from band.core.activity import sync_activity_tool_names
from band.core.types import Emit
from band.platform.event import PlatformEvent
from band.runtime.activity import set_permission_wait, tool_call_activity
from band.runtime.execution import ExecutionContext
from band.runtime.toolactivity import (
end_tool_activity,
report_memory_activity,
report_tool_activity,
)
from band.runtime.tools.agent import AgentTools
from tests.activitysupport import (
MEMORY_BODY,
PERMISSION_BODY,
REASONING_BODY,
)
from tests.conftest import make_message_event
from tests.runtime.activityownership.kit import ActivityPlatform


def tool_names(platform: ActivityPlatform) -> list[str]:
return [
body["activity"]["name"]
for body in platform.activity_bodies
if body.get("activity", {}).get("kind") == "tool_call"
]


@pytest.mark.looptime
class TestActivityToolNameGate:
async def test_tool_names_report_while_tool_calls_are_emitted(
self,
execution: Callable[..., ExecutionContext],
activity_platform: ActivityPlatform,
) -> None:
async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None:
tools = AgentTools.from_context(ctx)
await activity_platform.shown(report_tool_activity(tools, "Bash"))
await activity_platform.shown(end_tool_activity(tools))

ctx = execution(handler)
await ctx._process_event(make_message_event())
assert tool_names(activity_platform) == ["Bash"]

async def test_tool_names_are_dropped_when_tool_calls_are_not_emitted(
self,
execution: Callable[..., ExecutionContext],
activity_platform: ActivityPlatform,
) -> None:
async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None:
tools = AgentTools.from_context(ctx)
ctx.set_activity_tool_names(False)
# Hidden, a tool reads as the reasoning already shown: nothing new
# reaches the wire until the next visible step.
await report_tool_activity(tools, "Bash")
await activity_platform.shown(report_memory_activity(tools))
await end_tool_activity(tools)

ctx = execution(handler)
await ctx._process_event(make_message_event())
assert tool_names(activity_platform) == []
assert activity_platform.activity_bodies[:2] == [REASONING_BODY, MEMORY_BODY]

async def test_permission_and_memory_survive_the_name_gate(
self,
execution: Callable[..., ExecutionContext],
activity_platform: ActivityPlatform,
) -> None:
async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None:
tools = AgentTools.from_context(ctx)
ctx.set_activity_tool_names(False)
await activity_platform.shown(set_permission_wait(tools, True))
await activity_platform.shown(set_permission_wait(tools, False))
await activity_platform.shown(report_memory_activity(tools))

ctx = execution(handler)
await ctx._process_event(make_message_event())
assert PERMISSION_BODY in activity_platform.activity_bodies
assert MEMORY_BODY in activity_platform.activity_bodies
assert tool_names(activity_platform) == []

async def test_flip_after_binding_hides_names_in_the_running_turn(
self,
execution: Callable[..., ExecutionContext],
activity_platform: ActivityPlatform,
) -> None:
"""The observer is bound to the turn before the adapter's emit sync
flips the flag; the gate reads the live choice at report time."""

async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None:
tools = AgentTools.from_context(ctx)
observer = ctx.activity_observer(ctx.current_scope)
assert observer is not None
ctx.set_activity_tool_names(False)
await observer.update(tool_call_activity("Bash"))
await activity_platform.shown(report_memory_activity(tools))

ctx = execution(handler)
await ctx._process_event(make_message_event())
assert tool_names(activity_platform) == []

async def test_sync_resolves_names_from_the_emit_choice(
self, execution: Callable[..., ExecutionContext]
) -> None:
async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None:
tools = SimpleNamespace(_ctx=ctx)
sync_activity_tool_names(tools, frozenset({Emit.TOOL_CALLS}))
assert ctx._activity_tool_names is True
sync_activity_tool_names(tools, frozenset({Emit.THOUGHTS}))
assert ctx._activity_tool_names is False
sync_activity_tool_names(SimpleNamespace(), frozenset())
assert ctx._activity_tool_names is False

ctx = execution(handler)
await ctx._process_event(make_message_event())

async def test_continuation_frame_after_takeover_never_reaches_wire(
self,
execution: Callable[..., ExecutionContext],
activity_platform: ActivityPlatform,
) -> None:
"""An observer that outlives its turn (a background continuation) is
fenced once the next room-message turn owns activity: dropping the
ownership check in ``observe_activity`` surfaces a third named frame."""
bound: list = []
turns = {"n": 0}

async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None:
turns["n"] += 1
tools = AgentTools.from_context(ctx)
if turns["n"] == 1:
bound.append(ctx.activity_observer(ctx.current_scope))
await activity_platform.shown(report_tool_activity(tools, "bash"))
return
# The new turn owns activity; the continuation then wakes up.
await activity_platform.shown(report_tool_activity(tools, "exec"))
await bound[0].update(tool_call_activity("band_send_message"))
await activity_platform.shown(report_memory_activity(tools))

ctx = execution(handler)
await ctx._process_event(make_message_event(msg_id="turn-one"))
await ctx._process_event(make_message_event(msg_id="turn-two"))
assert tool_names(activity_platform) == ["bash", "exec"]
Loading