0b8fe7b2df
Load an allowlisted subset of bookstack-mcp tools (search/read plus create/update books, chapters, pages and comments; no deletes) over stdio and add them to the admin agent alongside Homebox. Extract the shared allowlist + is_admin wrapping into utils/mcp_tools.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
from datetime import datetime, timezone
|
|
|
|
import tortoise.exceptions
|
|
from langchain_core.messages import ToolMessage
|
|
from quart import current_app
|
|
|
|
import blueprints.users.models
|
|
|
|
from .models import Conversation, ConversationMessage
|
|
|
|
# Cap persisted tool output so replayed history stays within the LLM context
|
|
# budget (llama-server slots are 32k tokens; base prompt + tools already use ~6k).
|
|
TOOL_RESULT_MAX_CHARS = 4000
|
|
|
|
|
|
def tool_result_text(tool_name: str, output) -> str:
|
|
"""Format a tool's output for persistence as a conversation message."""
|
|
content = getattr(output, "content", output)
|
|
if isinstance(content, (list, tuple)):
|
|
content = "\n".join(str(item) for item in content)
|
|
text = str(content)
|
|
if len(text) > TOOL_RESULT_MAX_CHARS:
|
|
text = text[:TOOL_RESULT_MAX_CHARS] + "\n[truncated]"
|
|
return f"[{tool_name} result]\n{text}"
|
|
|
|
|
|
def get_agent_for(user: blueprints.users.models.User):
|
|
"""Admins get the agent with admin-only tools (Homebox, BookStack); everyone else the main one."""
|
|
if user.is_admin():
|
|
return getattr(current_app, "admin_agent", current_app.main_agent)
|
|
return current_app.main_agent
|
|
|
|
|
|
async def persist_agent_tool_messages(
|
|
conversation: Conversation,
|
|
user: blueprints.users.models.User,
|
|
response_messages: list,
|
|
) -> None:
|
|
"""Persist ToolMessages from an agent response as 'tool' conversation messages.
|
|
|
|
Keeps fetched context (web searches, RAG chunks, etc.) available to later
|
|
turns, which otherwise only see the final text answers.
|
|
"""
|
|
for msg in response_messages:
|
|
if isinstance(msg, ToolMessage):
|
|
await add_message_to_conversation(
|
|
conversation=conversation,
|
|
message=tool_result_text(msg.name or "tool", msg),
|
|
speaker="tool",
|
|
user=user,
|
|
)
|
|
|
|
|
|
async def create_conversation(name: str = "") -> Conversation:
|
|
conversation = await Conversation.create(name=name)
|
|
return conversation
|
|
|
|
|
|
async def add_message_to_conversation(
|
|
conversation: Conversation,
|
|
message: str,
|
|
speaker: str,
|
|
user: blueprints.users.models.User,
|
|
image_key: str | None = None,
|
|
message_handle: str | None = None,
|
|
) -> ConversationMessage:
|
|
# Bump updated_at on every message so conversations sort by last activity.
|
|
# `update_fields` bypasses the auto_now recompute, so set it explicitly.
|
|
update_fields = ["updated_at"]
|
|
conversation.updated_at = datetime.now(timezone.utc)
|
|
|
|
# Name the conversation after the first user message
|
|
if speaker == "user" and not await conversation.messages.all().exists():
|
|
conversation.name = message[:100]
|
|
update_fields.append("name")
|
|
|
|
await conversation.save(update_fields=update_fields)
|
|
|
|
message = await ConversationMessage.create(
|
|
text=message,
|
|
speaker=speaker,
|
|
conversation=conversation,
|
|
image_key=image_key,
|
|
message_handle=message_handle,
|
|
)
|
|
|
|
return message
|
|
|
|
|
|
async def get_the_only_conversation() -> Conversation:
|
|
try:
|
|
conversation = await Conversation.all().first()
|
|
if conversation is None:
|
|
conversation = await Conversation.create(name="simba_chat")
|
|
except Exception as _e:
|
|
conversation = await Conversation.create(name="simba_chat")
|
|
|
|
return conversation
|
|
|
|
|
|
async def get_conversation_for_user(user: blueprints.users.models.User) -> Conversation:
|
|
try:
|
|
conversation = await Conversation.get(user=user)
|
|
except tortoise.exceptions.MultipleObjectsReturned:
|
|
conversation = (
|
|
await Conversation.filter(user=user).order_by("created_at").first()
|
|
)
|
|
except tortoise.exceptions.DoesNotExist:
|
|
conversation = await Conversation.create(
|
|
name=f"{user.username}'s chat", user=user
|
|
)
|
|
return conversation
|
|
|
|
|
|
async def get_conversation_for_channel(
|
|
user: blueprints.users.models.User, channel: str
|
|
) -> Conversation:
|
|
conversation = await (
|
|
Conversation.filter(user=user, channel=channel).order_by("-updated_at").first()
|
|
)
|
|
if conversation is None:
|
|
conversation = await Conversation.create(
|
|
name=f"{user.username}'s {channel} chat", user=user, channel=channel
|
|
)
|
|
return conversation
|
|
|
|
|
|
async def get_conversation_by_id(id: str) -> Conversation:
|
|
return await Conversation.get(id=id)
|
|
|
|
|
|
async def get_conversation_transcript(
|
|
user: blueprints.users.models.User, conversation: Conversation
|
|
) -> str:
|
|
messages = []
|
|
for message in conversation.messages:
|
|
messages.append(f"{message.speaker} at {message.created_at}: {message.text}")
|
|
|
|
return "\n".join(messages)
|