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"]