From f7cf0b5562024cb82a3a5dc1f3a15aadb67c25f1 Mon Sep 17 00:00:00 2001 From: bandzalkin Date: Wed, 21 Oct 2026 20:47:49 +0300 Subject: [PATCH] feat: gate activity tool names on the tool-call emit choice Adapters gate tool-call narration on Emit.TOOL_CALLS, but the activity producers report tool names to the live indicator regardless, so an agent configured without tool narration still leaks names into the room. The choice is read once per turn in SimpleAdapter.on_event and enforced by a ToolNamesObserver wrapped around the context's activity observer, consulted at report time so a continuation bound earlier still follows the live choice. Permission waits and memory activity name no content and report either way. --- src/band/core/activity.py | 57 +++++++ src/band/core/simple_adapter.py | 4 + src/band/runtime/execution.py | 22 ++- .../activityownership/test_namegate.py | 153 ++++++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/band/core/activity.py create mode 100644 tests/runtime/activityownership/test_namegate.py diff --git a/src/band/core/activity.py b/src/band/core/activity.py new file mode 100644 index 000000000..cd4ac494e --- /dev/null +++ b/src/band/core/activity.py @@ -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) diff --git a/src/band/core/simple_adapter.py b/src/band/core/simple_adapter.py index 39623e2a0..4b8217254 100644 --- a/src/band/core/simple_adapter.py +++ b/src/band/core/simple_adapter.py @@ -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 @@ -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, diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index 499b65d6e..81960d2da 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -47,6 +47,7 @@ MessageCreatedPayload, MessageMetadata, ) +from band.core.activity import ToolNamesObserver from band.core.exceptions import ( CleanupIncompleteError, InputNotAccepted, @@ -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 @@ -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: diff --git a/tests/runtime/activityownership/test_namegate.py b/tests/runtime/activityownership/test_namegate.py new file mode 100644 index 000000000..11884b5e9 --- /dev/null +++ b/tests/runtime/activityownership/test_namegate.py @@ -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"]