fix(gateway_tools,conversations): say refused in user turns, the reply reaches the human itself

This commit is contained in:
hh
2026-08-30 02:26:35 +02:00
parent b4853b9fd0
commit 5d67cfcc9e
3 changed files with 52 additions and 1 deletions
+5
View File
@@ -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])
+9 -1
View File
@@ -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")
+38
View File
@@ -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()