nanobot/nanobot/agent/tools/message.py

258 lines
10 KiB
Python

"""Message tool for sending messages to users."""
# pyright: reportIncompatibleMethodOverride=false
from contextvars import ContextVar, Token
from pathlib import Path
from typing import Any, Awaitable, Callable, cast
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext, current_request_context
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
from nanobot.bus.events import OutboundMessage
from nanobot.config.paths import get_workspace_path
from nanobot.security.workspace_access import current_tool_workspace
@tool_parameters(
tool_parameters_schema(
content=StringSchema(
"Message content for proactive or cross-channel delivery. "
"Do not use this for a normal reply in the current chat."
),
channel=StringSchema(
"Optional target channel for cross-channel/proactive delivery. "
"Do not set this to the current runtime channel for a normal reply."
),
chat_id=StringSchema(
"Optional target chat/user ID for cross-channel/proactive delivery. "
"On WebSocket/WebUI turns: omit chat_id to use the server's conversation id "
"(never pass client_id values like anon-…). "
"Do not set this to the current runtime chat for a normal reply."
),
media=ArraySchema(
StringSchema(""),
description=(
"Optional list of existing file paths to attach. "
"Use artifact paths returned by generate_image here when delivering generated images."
),
),
buttons=ArraySchema(
ArraySchema(StringSchema("Button label")),
description="Optional: inline keyboard buttons as list of rows, each row is list of button labels.",
),
required=["content"],
)
)
class MessageTool(Tool):
"""Tool to send messages to users on chat channels."""
def __init__(
self,
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
default_channel: str = "",
default_chat_id: str = "",
default_message_id: str | None = None,
workspace: str | Path | None = None,
restrict_to_workspace: bool = False,
):
self._send_callback = send_callback
self._workspace = (
Path(workspace).expanduser() if workspace is not None else get_workspace_path()
)
self._restrict_to_workspace = restrict_to_workspace
self._fallback_channel = default_channel
self._fallback_chat_id = default_chat_id
self._fallback_message_id = default_message_id
self._fallback_metadata: dict[str, Any] = {}
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
self._suppress_delivery_var: ContextVar[bool] = ContextVar(
"message_suppress_delivery",
default=False,
)
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
send_callback = ctx.bus.publish_outbound if ctx.bus else None
return cls(
send_callback=send_callback,
workspace=ctx.workspace,
restrict_to_workspace=ctx.config.restrict_to_workspace,
)
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
"""Set the callback for sending messages."""
self._send_callback = callback
def start_turn(self) -> None:
"""Reset per-turn send tracking."""
self._sent_in_turn = False
def set_suppress_delivery(self, active: bool) -> Token[bool]:
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
return self._suppress_delivery_var.set(active)
def reset_suppress_delivery(self, token: Token[bool]) -> None:
"""Restore previous delivery-suppression state."""
self._suppress_delivery_var.reset(token)
@property
def _sent_in_turn(self) -> bool:
return self._sent_in_turn_var.get()
@_sent_in_turn.setter
def _sent_in_turn(self, value: bool) -> None:
self._sent_in_turn_var.set(value)
@property
def name(self) -> str:
return "message"
@property
def description(self) -> str:
return (
"Proactively send a message to a user/channel, optionally with file attachments. "
"Use this for reminders, cross-channel delivery, or explicit proactive sends. "
"Do not use this for the normal reply in the current chat: answer naturally instead. "
"If channel/chat_id would target the current runtime conversation, do not call this tool "
"unless the user explicitly asked you to proactively send an existing file attachment. "
"When generate_image creates images in the current chat, use the message tool "
"with the artifact paths in the media parameter to deliver the images to the user. "
"For proactive attachment delivery, use the 'media' parameter with file paths. "
"Do NOT use read_file to send files — that only reads content for your own analysis."
)
def _resolve_media(self, media: list[str]) -> list[str]:
"""Resolve local media attachments and enforce workspace restriction when enabled."""
resolved: list[str] = []
access = current_tool_workspace(
self._workspace,
restrict_to_workspace=self._restrict_to_workspace,
)
workspace = access.project_path or self._workspace
for p in media:
if p.startswith(("http://", "https://")):
resolved.append(p)
elif not access.restrict_to_workspace:
path = Path(p).expanduser()
resolved.append(p if path.is_absolute() else str(workspace / path))
else:
resolved.append(str(resolve_workspace_path(p, workspace, access.allowed_root)))
return resolved
async def execute(
self,
content: str,
channel: str | None = None,
chat_id: str | None = None,
message_id: str | None = None,
media: list[str] | None = None,
buttons: Any = None,
**kwargs: Any,
) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
from nanobot.utils.helpers import strip_think
content = strip_think(content)
button_rows: list[list[str]] | None = None
if buttons is not None:
raw_buttons = cast(list[Any], buttons) if isinstance(buttons, list) else None
if raw_buttons is None or any(
not isinstance(row, list)
or any(not isinstance(label, str) for label in cast(list[Any], row))
for row in raw_buttons
):
return ToolResult.error("Error: buttons must be a list of list of strings")
button_rows = cast(list[list[str]], raw_buttons)
request_ctx = current_request_context()
default_channel = (
request_ctx.channel if request_ctx is not None else self._fallback_channel
)
default_chat_id = (
request_ctx.chat_id if request_ctx is not None else self._fallback_chat_id
)
default_message_id = (
request_ctx.message_id
if request_ctx is not None
else self._fallback_message_id
)
default_metadata = (
request_ctx.metadata
if request_ctx is not None
else self._fallback_metadata
)
channel = channel or default_channel
explicit_chat_id = chat_id
if (
default_channel == "websocket"
and channel == "websocket"
and explicit_chat_id is not None
and str(explicit_chat_id).strip() != ""
and str(explicit_chat_id).strip() != str(default_chat_id).strip()
):
return ToolResult.error(
"Error: chat_id does not match the active WebSocket conversation. "
"Omit chat_id (and usually channel) so delivery uses the current "
"conversation id from context — WebSocket client_id strings "
"(e.g. anon-…) are not chat ids."
)
chat_id = chat_id or default_chat_id
# Only inherit default message_id when targeting the same channel+chat.
# Cross-chat sends must not carry the original message_id, because
# some channels (e.g. Feishu) use it to determine the target
# conversation via their Reply API, which would route the message
# to the wrong chat entirely.
same_target = channel == default_channel and chat_id == default_chat_id
if same_target:
message_id = message_id or default_message_id
else:
message_id = None
if not channel or not chat_id:
return ToolResult.error("Error: No target channel/chat specified")
if not self._send_callback:
return ToolResult.error("Error: Message sending not configured")
if media:
try:
media = self._resolve_media(media)
except (OSError, PermissionError, ValueError) as e:
return ToolResult.error(f"Error: media path is not allowed: {str(e)}")
metadata = dict(default_metadata) if same_target else {}
if message_id:
metadata["message_id"] = message_id
if media:
metadata["_record_channel_delivery"] = True
msg = OutboundMessage(
channel=channel,
chat_id=chat_id,
content=content,
media=media or [],
buttons=button_rows or [],
metadata=metadata,
)
if self._suppress_delivery_var.get():
logger.debug("MessageTool: delivery suppressed during internal check")
return f"Message acknowledged for {channel}:{chat_id} (not delivered)"
try:
await self._send_callback(msg)
if channel == default_channel and chat_id == default_chat_id:
self._sent_in_turn = True
media_info = f" with {len(media)} attachments" if media else ""
button_info = (
f" with {sum(len(row) for row in button_rows)} button(s)"
if button_rows
else ""
)
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
except Exception as e:
return ToolResult.error(f"Error sending message: {str(e)}")