From 5d67cfcc9e7bc3ea8758827db07fea1ae993ca4a Mon Sep 17 00:00:00 2001 From: h Date: Sun, 30 Aug 2026 02:26:35 +0200 Subject: [PATCH] fix(gateway_tools,conversations): say refused in user turns, the reply reaches the human itself --- src/beaver_gateway/core/conversations.py | 5 ++++ src/beaver_gateway/core/gateway_tools.py | 10 ++++++- tests/test_conversations.py | 38 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/beaver_gateway/core/conversations.py b/src/beaver_gateway/core/conversations.py index 0611e43..c10594b 100644 --- a/src/beaver_gateway/core/conversations.py +++ b/src/beaver_gateway/core/conversations.py @@ -883,6 +883,11 @@ class Conversations: self._ensure_worker(cast("int", conv.id)) return item + def turn_origin(self, conv: Conversation) -> str | None: + """Origin of the running turn (``user``, ``inject``, ...); None when idle.""" + runner = self._runners.get(cast("int", conv.id)) + return runner.origin if runner is not None and runner.turn_id else None + async def say(self, conv: Conversation, text: str) -> dict[str, Any]: runner = self._runners.get(cast("int", conv.id)) _log.info("say[%s]: %s", conv.external_id, text[:200]) diff --git a/src/beaver_gateway/core/gateway_tools.py b/src/beaver_gateway/core/gateway_tools.py index da52fd6..dd8efd2 100644 --- a/src/beaver_gateway/core/gateway_tools.py +++ b/src/beaver_gateway/core/gateway_tools.py @@ -26,6 +26,11 @@ __all__ = ["SERVER_NAME", "TOOL_NAMES", "build_tool_server"] _log = logging.getLogger("beaver_gateway.core.gateway_tools") SERVER_NAME = "gateway" +SAY_IN_USER_TURN = ( + "not delivered: this turn was started by the human's message, so your " + "reply text reaches them by itself - put what you wanted to say into the " + "reply instead of repeating it here" +) TOOL_NAMES = ("read_conversation", "spawn", "say", "schedule", "inject", "close_chat") @@ -118,11 +123,14 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]: "say", "Say something to the human in the frontend this conversation is bound " "to. The only way an inject-started turn can speak; silence is simply " - "not calling it.", + "not calling it. Refused in a turn started by the human's own message: " + "there your reply text reaches them by itself, so just write the reply.", {"text": str}, ) async def say(args: dict[str, Any]) -> dict[str, Any]: conv = await current() + if conversations.turn_origin(conv) == "user": + return _error(SAY_IN_USER_TURN) await conversations.say(conv, str(args["text"])) return _text("ok") diff --git a/tests/test_conversations.py b/tests/test_conversations.py index 47976f8..00b241e 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -22,6 +22,7 @@ from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend from beaver_gateway.core.bus import EventBus from beaver_gateway.core.conversations import Conversations, ConversationTexts, parse_at +from beaver_gateway.core.gateway_tools import SAY_IN_USER_TURN, _tools from beaver_gateway.core.registry import AgentRegistry from beaver_gateway.core.sessions import SessionPool from beaver_gateway.core.transcript import ( @@ -253,6 +254,43 @@ async def test_two_messages_run_one_at_a_time_in_order(world: World) -> None: assert row.session_id == ScriptedClient.instances[0].session_id +async def test_say_is_refused_in_user_turns_and_works_in_inject_turns( + world: World, +) -> None: + conv = await world.conversations.create(kind="master", agent="a", origin="test") + say = next( + t for t in _tools(world.conversations, conv.external_id) if t.name == "say" + ) + said: list[str] = [] + + async def watch() -> None: + async for event in world.bus.stream(conversation_id=conv.external_id): + if event["type"] == "say": + said.append(str(event["text"])) + + watcher = asyncio.create_task(watch()) + ScriptedClient.hold = asyncio.Event() + await world.conversations.post(conv, "hi") + await asyncio.sleep(0.2) + assert world.conversations.turn_origin(conv) == "user" + out = await say.handler({"text": "dup"}) + assert out.get("is_error") and out["content"][0]["text"] == SAY_IN_USER_TURN + ScriptedClient.hold.set() + await world.settle(conv, 1) + assert world.conversations.turn_origin(conv) is None + ScriptedClient.hold = asyncio.Event() + await world.conversations.inject(conv, "tick", urgency="urgent", origin="крон") + await asyncio.sleep(0.2) + assert world.conversations.turn_origin(conv) == "inject" + out = await say.handler({"text": "psst"}) + assert not out.get("is_error") + ScriptedClient.hold.set() + await world.settle(conv, 2) + await asyncio.sleep(0.1) + watcher.cancel() + assert said == ["psst"] + + async def test_urgent_interrupts_and_goes_first(world: World) -> None: conv = await world.conversations.create(kind="master", agent="a", origin="test") ScriptedClient.hold = asyncio.Event()