274 lines
12 KiB
Python
274 lines
12 KiB
Python
"""Message tool for sending messages to users."""
|
|
|
|
from contextvars import ContextVar
|
|
from pathlib import Path
|
|
from typing import Any, Awaitable, Callable
|
|
|
|
from loguru import logger
|
|
|
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
|
from nanobot.agent.tools.context import ContextAware, RequestContext
|
|
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, ContextAware):
|
|
"""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._default_channel: ContextVar[str] = ContextVar(
|
|
"message_default_channel", default=default_channel
|
|
)
|
|
self._default_chat_id: ContextVar[str] = ContextVar(
|
|
"message_default_chat_id", default=default_chat_id
|
|
)
|
|
self._default_message_id: ContextVar[str | None] = ContextVar(
|
|
"message_default_message_id",
|
|
default=default_message_id,
|
|
)
|
|
self._default_metadata: ContextVar[dict[str, Any]] = ContextVar(
|
|
"message_default_metadata",
|
|
default={},
|
|
)
|
|
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
|
|
self._turn_delivered_media_var: ContextVar[tuple[str, ...]] = ContextVar(
|
|
"message_turn_delivered_media",
|
|
default=(),
|
|
)
|
|
self._record_channel_delivery_var: ContextVar[bool] = ContextVar(
|
|
"message_record_channel_delivery",
|
|
default=False,
|
|
)
|
|
self._suppress_delivery_var: ContextVar[bool] = ContextVar(
|
|
"message_suppress_delivery",
|
|
default=False,
|
|
)
|
|
|
|
@classmethod
|
|
def create(cls, ctx: Any) -> 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_context(self, ctx: RequestContext) -> None:
|
|
"""Set the current message context."""
|
|
self._default_channel.set(ctx.channel)
|
|
self._default_chat_id.set(ctx.chat_id)
|
|
self._default_message_id.set(ctx.message_id)
|
|
self._default_metadata.set(dict(ctx.metadata or {}))
|
|
|
|
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
|
|
self._turn_delivered_media_var.set(())
|
|
|
|
def turn_delivered_media_paths(self) -> list[str]:
|
|
"""Absolute paths attached via this tool to the active chat in the current turn."""
|
|
return list(self._turn_delivered_media_var.get())
|
|
|
|
def set_record_channel_delivery(self, active: bool):
|
|
"""Mark tool-sent messages as proactive channel deliveries."""
|
|
return self._record_channel_delivery_var.set(active)
|
|
|
|
def reset_record_channel_delivery(self, token) -> None:
|
|
"""Restore previous proactive delivery recording state."""
|
|
self._record_channel_delivery_var.reset(token)
|
|
|
|
def set_suppress_delivery(self, active: bool):
|
|
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
|
|
return self._suppress_delivery_var.set(active)
|
|
|
|
def reset_suppress_delivery(self, token) -> 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: list[list[str]] | None = None,
|
|
**kwargs: Any,
|
|
) -> str:
|
|
from nanobot.utils.helpers import strip_think
|
|
|
|
content = strip_think(content)
|
|
|
|
if buttons is not None:
|
|
if not isinstance(buttons, list) or any(
|
|
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
|
|
for row in buttons
|
|
):
|
|
return ToolResult.error("Error: buttons must be a list of list of strings")
|
|
default_channel = self._default_channel.get()
|
|
default_chat_id = self._default_chat_id.get()
|
|
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 self._default_message_id.get()
|
|
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(self._default_metadata.get()) if same_target else {}
|
|
if message_id:
|
|
metadata["message_id"] = message_id
|
|
if self._record_channel_delivery_var.get() or media:
|
|
metadata["_record_channel_delivery"] = True
|
|
|
|
msg = OutboundMessage(
|
|
channel=channel,
|
|
chat_id=chat_id,
|
|
content=content,
|
|
media=media or [],
|
|
buttons=buttons 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
|
|
if media:
|
|
prev = self._turn_delivered_media_var.get()
|
|
self._turn_delivered_media_var.set(prev + tuple(str(p) for p in media))
|
|
media_info = f" with {len(media)} attachments" if media else ""
|
|
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons 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)}")
|