Files
simbarag/blueprints/conversation/agents.py
T
ryan 50d123e4d2 Return artifacts from Mealie, Obsidian and search tools
Convert mealie_get_recipe, mealie_meal_plans, mealie_todays_meals,
mealie_shopping_lists, obsidian_read_note, journal_get_today,
obsidian_search_notes and simba_search to content_and_artifact. The text
the LLM sees is unchanged; the artifact is a JSON-safe ToolArtifact dict
per the Cowork UI contract (mealie.recipe, mealie.meal_plan,
mealie.shopping_lists, obsidian.note, search.documents). Error, empty and
not-configured paths return no artifact.

simba_search's artifact changes from a list of Documents (unused) to a
search.documents dict.

New optional env vars: MEALIE_GROUP_SLUG (default "home") for Mealie v2
recipe links, OBSIDIAN_VAULT_NAME for obsidian://open links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:10:07 -04:00

1127 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
from typing import cast
import httpx
from dotenv import load_dotenv
from langchain.chat_models import BaseChatModel
from langchain.tools import tool
from langgraph.prebuilt import create_react_agent
from langchain_core.messages import SystemMessage
from langchain_core.runnables import RunnableConfig, RunnableLambda
from langchain_openai import ChatOpenAI
from tavily import AsyncTavilyClient
from blueprints.conversation.artifacts_util import (
NOTE_MAX_CHARS,
format_date_range,
format_day,
json_safe,
make_artifact,
mealie_source_ref,
note_title,
obsidian_source_ref,
search_documents_artifact,
truncate,
unique,
)
from blueprints.conversation.memory import save_memory
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
from blueprints.rag.logic import query_vector_store
from blueprints.ynab.sync import ynab_configured
from blueprints.ynab.tools import YNAB_TOOLS
from utils.google_service import GoogleService
from utils.mealie_service import MealieService
from utils.obsidian_service import ObsidianService
from utils.simbakit_service import SimbaKitService
# Load environment variables
load_dotenv()
# Configure LLM with llama-server or OpenAI fallback
# Read timeout between streamed chunks. The OpenAI client defaults to 600s,
# which means a wedged llama-server blocks a request for 10 minutes before
# erroring. With a tight timeout, a hang raises quickly so
# with_fallbacks() below kicks in and the conversation (rebuilt from the
# DB each request) continues seamlessly on OpenAI.
LLAMA_READ_TIMEOUT = float(os.getenv("LLAMA_TIMEOUT_SECONDS", "120"))
llama_url = os.getenv("LLAMA_SERVER_URL")
if llama_url:
llama_chat = ChatOpenAI(
base_url=llama_url,
api_key="not-needed",
model=os.getenv("LLAMA_MODEL_NAME", "llama-3.1-8b-instruct"),
timeout=httpx.Timeout(LLAMA_READ_TIMEOUT, connect=10.0),
max_retries=1,
model_kwargs={"reasoning_effort": "low"},
)
else:
llama_chat = None
openai_fallback = ChatOpenAI(model="gpt-5-mini")
model_with_fallback = cast(
BaseChatModel,
llama_chat.with_fallbacks([openai_fallback]) if llama_chat else openai_fallback,
)
client = AsyncTavilyClient(api_key=os.getenv("TAVILY_API_KEY", ""))
# Initialize SimbaKit service (will only work if SIMBAKIT_URL is set)
try:
simbakit_service = SimbaKitService()
simbakit_enabled = True
except (ValueError, Exception) as e:
print(f"SimbaKit service not initialized: {e}")
simbakit_enabled = False
# Initialize Mealie service (will only work if MEALIE_BASE_URL is set)
try:
mealie_service = MealieService()
mealie_enabled = True
except (ValueError, Exception) as e:
print(f"Mealie service not initialized: {e}")
mealie_enabled = False
# Initialize Obsidian service (will only work if OBSIDIAN_VAULT_PATH is set)
try:
obsidian_service = ObsidianService()
obsidian_enabled = True
except (ValueError, Exception) as e:
print(f"Obsidian service not initialized: {e}")
obsidian_enabled = False
# Initialize Google service (Calendar + Gmail) if token file is present
try:
google_service = GoogleService()
google_calendar_enabled = True
google_gmail_enabled = True
except (ValueError, FileNotFoundError, Exception) as e:
print(f"Google service not initialized: {e}")
google_service = None
google_calendar_enabled = False
google_gmail_enabled = False
@tool
def get_current_date() -> str:
"""Return today's date (YYYY-MM-DD). Use whenever the current date is needed."""
from datetime import datetime
from zoneinfo import ZoneInfo
return datetime.now(ZoneInfo("America/New_York")).strftime("%Y-%m-%d")
@tool
async def web_search(query: str) -> str:
"""Search the web for current information not in the knowledge base.
Args:
query: Search query
"""
response = await client.search(query=query, search_depth="basic")
results = response.get("results", [])
if not results:
return "No results found for the query."
formatted = "\n\n".join(
[
f"**{result['title']}**\n{result['content']}\nSource: {result['url']}"
for result in results[:5]
]
)
return formatted
@tool(response_format="content_and_artifact")
async def simba_search(query: str):
"""Search Simba's medical records, vet documents, and household documents.
Args:
query: Question or information need
"""
print(f"[SIMBA SEARCH] Tool called with query: {query}")
serialized, docs = await query_vector_store(query=query, source="paperless")
print(f"[SIMBA SEARCH] Found {len(docs)} documents")
print(f"[SIMBA SEARCH] Serialized result length: {len(serialized)}")
print(f"[SIMBA SEARCH] First 200 chars: {serialized[:200]}")
return serialized, search_documents_artifact(query, "paperless", docs)
MEALIE_NOT_CONFIGURED = "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
def _meal_entry(meal: dict) -> dict:
"""Meal plan entry in the `mealie.meal_plan` artifact shape."""
entry = {
"entry_type": meal["entry_type"],
"name": meal["recipe_name"] or meal["title"] or "Untitled",
}
if meal.get("recipe_slug"):
entry["recipe_slug"] = meal["recipe_slug"]
if meal.get("note"):
entry["note"] = meal["note"]
return entry
@tool(response_format="content_and_artifact")
async def mealie_todays_meals():
"""Get today's planned meals from Mealie."""
if not mealie_enabled:
return MEALIE_NOT_CONFIGURED, None
try:
result = await mealie_service.get_todays_meals()
if result["total_meals"] == 0:
return f"No meals planned for today ({result['today']}).", None
lines = [f"Meals planned for today ({result['today']}):"]
for meal in result["meals"]:
name = meal["recipe_name"] or meal["title"] or "Untitled"
lines.append(f"- {meal['entry_type'].capitalize()}: {name}")
if meal["note"]:
lines.append(f" Note: {meal['note']}")
today = result["today"]
artifact = make_artifact(
"mealie.meal_plan",
f"Today's meals · {format_day(today)}",
{
"start_date": today,
"end_date": today,
"days": [
{
"date": today,
"meals": [_meal_entry(m) for m in result["meals"]],
}
],
},
)
return "\n".join(lines), artifact
except Exception as e:
return f"Error fetching today's meals: {str(e)}", None
@tool(response_format="content_and_artifact")
async def mealie_meal_plans(start_date: str = "", end_date: str = ""):
"""Get Mealie meal plans for a date range.
Args:
start_date: YYYY-MM-DD (default: today)
end_date: YYYY-MM-DD (default: 7 days out)
"""
if not mealie_enabled:
return MEALIE_NOT_CONFIGURED, None
try:
result = await mealie_service.get_meal_plans(
start_date=start_date or None,
end_date=end_date or None,
)
if result["total_plans"] == 0:
return (
f"No meal plans found from {result['start_date']} to {result['end_date']}.",
None,
)
lines = [
f"Meal plans from {result['start_date']} to {result['end_date']} ({result['total_plans']} meals):"
]
days = []
for date, plans in sorted(result["plans_by_date"].items()):
lines.append(f"\n{date}:")
for plan in plans:
name = plan["recipe_name"] or plan["title"] or "Untitled"
lines.append(f" - {plan['entry_type'].capitalize()}: {name}")
if plan["note"]:
lines.append(f" Note: {plan['note']}")
days.append({"date": date, "meals": [_meal_entry(p) for p in plans]})
artifact = make_artifact(
"mealie.meal_plan",
f"Meal plan · {format_date_range(result['start_date'], result['end_date'])}",
{
"start_date": result["start_date"],
"end_date": result["end_date"],
"days": days,
},
)
return "\n".join(lines), artifact
except Exception as e:
return f"Error fetching meal plans: {str(e)}", None
@tool(response_format="content_and_artifact")
async def mealie_get_recipe(recipe_slug: str):
"""Get full recipe details (ingredients, instructions, times) from Mealie.
Args:
recipe_slug: Recipe slug from meal plan data
"""
if not mealie_enabled:
return MEALIE_NOT_CONFIGURED, None
try:
recipe = await mealie_service.get_recipe(recipe_slug)
lines = [f"**{recipe['name']}**"]
if recipe["description"]:
lines.append(recipe["description"])
lines.append("")
if recipe["servings"]:
lines.append(f"Servings: {recipe['servings']}")
times = []
if recipe["prep_time"]:
times.append(f"Prep: {recipe['prep_time']}")
if recipe["cook_time"]:
times.append(f"Cook: {recipe['cook_time']}")
if recipe["total_time"]:
times.append(f"Total: {recipe['total_time']}")
if times:
lines.append(" | ".join(times))
if recipe["ingredients"]:
lines.append("\nIngredients:")
for ing in recipe["ingredients"]:
lines.append(f"- {ing['display']}")
if recipe["instructions"]:
lines.append("\nInstructions:")
for i, step in enumerate(recipe["instructions"], 1):
text = step.get("text", "")
lines.append(f"{i}. {text}")
if recipe["tags"]:
lines.append(f"\nTags: {', '.join(recipe['tags'])}")
instructions = []
for step in recipe["instructions"]:
instruction = {"text": step.get("text", "")}
if step.get("title"):
instruction["title"] = step["title"]
instructions.append(instruction)
slug = recipe.get("slug") or recipe_slug
artifact = make_artifact(
"mealie.recipe",
recipe["name"],
json_safe(
{
"slug": slug,
"name": recipe["name"],
"description": recipe["description"],
"servings": recipe["servings"],
"prep_time": recipe["prep_time"],
"cook_time": recipe["cook_time"],
"total_time": recipe["total_time"],
"ingredients": [ing["display"] for ing in recipe["ingredients"]],
"instructions": instructions,
"tags": recipe["tags"],
}
),
mealie_source_ref(slug),
)
return "\n".join(lines), artifact
except Exception as e:
return f"Error fetching recipe: {str(e)}", None
@tool(response_format="content_and_artifact")
async def mealie_shopping_lists():
"""Get Mealie shopping lists with needed and checked-off items."""
if not mealie_enabled:
return MEALIE_NOT_CONFIGURED, None
try:
result = await mealie_service.get_shopping_lists()
if result["total_lists"] == 0:
return "No shopping lists found.", None
lines = [f"Shopping lists ({result['total_unchecked_items']} items needed):"]
for sl in result["shopping_lists"]:
lines.append(
f"\n**{sl['name']}** ({len(sl['unchecked_items'])} items needed)"
)
if sl["unchecked_items"]:
for item in sl["unchecked_items"]:
qty = (
f" x{item['quantity']}"
if item["quantity"] and item["quantity"] != 1
else ""
)
lines.append(f" - [ ] {item['name']}{qty}")
if sl["checked_items"]:
lines.append(
f" ({len(sl['checked_items'])} items already checked off)"
)
lists = [
{
"name": sl["name"],
"items": [
{
"name": item["name"],
"quantity": item["quantity"],
"checked": False,
}
for item in sl["unchecked_items"]
]
+ [
{
"name": item["name"],
"quantity": item["quantity"],
"checked": True,
}
for item in sl["checked_items"]
],
}
for sl in result["shopping_lists"]
]
artifact = make_artifact(
"mealie.shopping_lists",
"Shopping lists",
json_safe({"lists": lists}),
)
return "\n".join(lines), artifact
except Exception as e:
return f"Error fetching shopping lists: {str(e)}", None
@tool
async def mealie_create_meal_plan(
date: str,
entry_type: str = "dinner",
recipe_slug: str = "",
title: str = "",
note: str = "",
) -> str:
"""Add a meal to the Mealie meal plan.
Args:
date: YYYY-MM-DD
entry_type: breakfast, lunch, dinner, or side (default: dinner)
recipe_slug: Recipe slug (optional)
title: Custom title if no recipe (optional)
note: Notes (optional)
"""
if not mealie_enabled:
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
try:
result = await mealie_service.create_meal_plan(
date=date,
entry_type=entry_type,
recipe_slug=recipe_slug or None,
title=title or None,
note=note or None,
)
name = result.get("recipe_name") or result.get("title") or "meal"
return f"Added {result['entry_type']} to {result['date']}: {name}"
except Exception as e:
return f"Error creating meal plan: {str(e)}"
OBSIDIAN_NOT_CONFIGURED = "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
def _note_artifact(path: str, parsed: dict, title: str | None = None) -> dict:
"""`obsidian.note` artifact from ObsidianService.parse_markdown output."""
frontmatter = json_safe(parsed["metadata"])
title = title or note_title(path, frontmatter)
return make_artifact(
"obsidian.note",
title,
{
"path": path,
"title": title,
"markdown": truncate(parsed["content"], NOTE_MAX_CHARS),
"tags": unique(parsed["tags"]),
"frontmatter": frontmatter,
},
obsidian_source_ref(path),
)
@tool(response_format="content_and_artifact")
async def obsidian_search_notes(query: str):
"""Search personal Obsidian vault notes.
Args:
query: Search query
"""
if not obsidian_enabled:
return OBSIDIAN_NOT_CONFIGURED, None
try:
# Query vector store filtered to obsidian source only
serialized, docs = await query_vector_store(query=query, source="obsidian")
return serialized, search_documents_artifact(query, "obsidian", docs)
except Exception as e:
return f"Error searching Obsidian notes: {str(e)}", None
@tool(response_format="content_and_artifact")
async def obsidian_read_note(relative_path: str):
"""Read the full content of a specific Obsidian note by path.
Args:
relative_path: Path relative to vault root (e.g., "notes/my-note.md")
"""
if not obsidian_enabled:
return OBSIDIAN_NOT_CONFIGURED, None
try:
note = obsidian_service.read_note(relative_path)
content_data = note["content"]
result = f"File: {note['path']}\n\n"
result += f"Frontmatter:\n{content_data['metadata']}\n\n"
result += f"Content:\n{content_data['content']}\n\n"
result += f"Tags: {', '.join(content_data['tags'])}\n"
result += f"Contains {len(content_data['wikilinks'])} wikilinks and {len(content_data['embeds'])} embeds"
return result, _note_artifact(note["path"], content_data)
except FileNotFoundError:
return (
f"Note not found at '{relative_path}'. Please check the path is correct.",
None,
)
except Exception as e:
return f"Error reading note: {str(e)}", None
@tool
async def obsidian_create_note(
title: str,
content: str,
folder: str = "notes",
tags: str = "",
) -> str:
"""Create a new note in the Obsidian vault.
Args:
title: Note title (used as filename)
content: Body content
folder: Target folder (default: "notes")
tags: Comma-separated tags (optional)
"""
if not obsidian_enabled:
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
try:
# Parse tags from comma-separated string
tag_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
relative_path = obsidian_service.create_note(
title=title,
content=content,
folder=folder,
tags=tag_list,
)
return f"Successfully created note: {relative_path}"
except Exception as e:
return f"Error creating note: {str(e)}"
@tool(response_format="content_and_artifact")
def journal_get_today():
"""Get today's full daily journal note including tasks and log entries."""
if not obsidian_enabled:
return "Obsidian integration is not configured.", None
try:
note = obsidian_service.get_daily_note()
if not note["found"]:
return (
f"No daily note found for {note['date']}. Use journal_add_task to create one.",
None,
)
artifact = _note_artifact(
note["path"],
obsidian_service.parse_markdown(note["content"]),
title=f"Daily note · {format_day(note['date'])}",
)
return f"Daily note for {note['date']}:\n\n{note['content']}", artifact
except Exception as e:
return f"Error reading daily note: {str(e)}", None
@tool
def journal_get_tasks(date: str = "") -> str:
"""Get tasks and their completion status from a daily journal note.
Args:
date: YYYY-MM-DD (default: today)
"""
if not obsidian_enabled:
return "Obsidian integration is not configured."
try:
from datetime import datetime as dt
parsed_date = dt.strptime(date, "%Y-%m-%d") if date else None
result = obsidian_service.get_daily_tasks(parsed_date)
if not result["found"]:
return f"No daily note found for {result['date']}."
if not result["tasks"]:
return f"No tasks found in the {result['date']} note."
lines = [f"Tasks for {result['date']}:"]
for task in result["tasks"]:
status = "[x]" if task["done"] else "[ ]"
lines.append(f"- {status} {task['text']}")
return "\n".join(lines)
except Exception as e:
return f"Error reading tasks: {str(e)}"
@tool
def journal_add_task(task: str, date: str = "") -> str:
"""Add a task to a daily journal note.
Args:
task: Task description
date: YYYY-MM-DD (default: today)
"""
if not obsidian_enabled:
return "Obsidian integration is not configured."
try:
from datetime import datetime as dt
parsed_date = dt.strptime(date, "%Y-%m-%d") if date else None
result = obsidian_service.add_task_to_daily_note(task, parsed_date)
if result["success"]:
note_date = date or dt.now().strftime("%Y-%m-%d")
extra = " (created new note)" if result["created_note"] else ""
return f"Added task '{task}' to {note_date}{extra}."
return "Failed to add task."
except Exception as e:
return f"Error adding task: {str(e)}"
@tool
def journal_complete_task(task: str, date: str = "") -> str:
"""Mark a task as complete in a daily journal note.
Args:
task: Task text to match (exact or partial)
date: YYYY-MM-DD (default: today)
"""
if not obsidian_enabled:
return "Obsidian integration is not configured."
try:
from datetime import datetime as dt
parsed_date = dt.strptime(date, "%Y-%m-%d") if date else None
result = obsidian_service.complete_task_in_daily_note(task, parsed_date)
if result["success"]:
return f"Marked '{result['completed_task']}' as complete."
return f"Could not complete task: {result.get('error', 'unknown error')}"
except Exception as e:
return f"Error completing task: {str(e)}"
@tool
async def obsidian_create_task(
title: str,
content: str = "",
folder: str = "tasks",
due_date: str = "",
tags: str = "",
) -> str:
"""Create a task note in the Obsidian vault.
Args:
title: Task title
content: Description (optional)
folder: Target folder (default: "tasks")
due_date: YYYY-MM-DD (optional)
tags: Comma-separated tags (optional)
"""
if not obsidian_enabled:
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
try:
# Parse tags from comma-separated string
tag_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
relative_path = obsidian_service.create_task(
title=title,
content=content,
folder=folder,
due_date=due_date or None,
tags=tag_list,
)
return f"Successfully created task: {relative_path}"
except Exception as e:
return f"Error creating task: {str(e)}"
@tool
async def save_user_memory(content: str, config: RunnableConfig) -> str:
"""Save a persistent fact or preference about the user for future conversations. Only use when user explicitly asks to remember something or shares a durable preference.
Args:
content: Standalone statement to remember (e.g., "User prefers metric units")
"""
user_id = config["configurable"]["user_id"]
return await save_memory(user_id=user_id, content=content)
@tool
async def get_calendar_events(
time_range: str = "today",
days: int = 0,
calendar_id: str = "primary",
*,
config: RunnableConfig,
) -> str:
"""Get Google Calendar events.
Args:
time_range: "today", "tomorrow", or "week" (default: "today")
days: Number of upcoming days to show (overrides time_range if > 0)
calendar_id: Calendar ID (default: "primary")
"""
if not config["configurable"].get("is_admin"):
return "Calendar access is restricted to admin users."
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
tz = ZoneInfo("America/New_York")
now = datetime.now(tz)
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
if days > 0:
end = start + timedelta(days=days)
elif time_range == "tomorrow":
start += timedelta(days=1)
end = start + timedelta(days=1)
elif time_range == "week":
end = start + timedelta(days=7)
else:
end = start + timedelta(days=1)
events = await google_service.get_calendar_events(
time_min=start.isoformat(),
time_max=end.isoformat(),
calendar_id=calendar_id,
)
if not events:
return f"No events found for {start.date()} to {end.date()}."
lines = [f"Calendar events ({start.date()} to {end.date()}):"]
for event in events:
event_start = event.get("start", {})
event_end = event.get("end", {})
start_str = event_start.get("dateTime", event_start.get("date", ""))
end_str = event_end.get("dateTime", event_end.get("date", ""))
summary = event.get("summary", "Untitled")
location = event.get("location", "")
description = (event.get("description") or "")[:80]
line = f"- {summary} ({start_str}"
if end_str and end_str != start_str:
line += f" – {end_str}"
line += ")"
if location:
line += f" @ {location}"
if description:
line += f"\n {description}"
lines.append(line)
return "\n".join(lines)
@tool
async def create_calendar_event(
summary: str,
start_time: str,
end_time: str,
description: str = "",
location: str = "",
calendar_id: str = "primary",
*,
config: RunnableConfig,
) -> str:
"""Create a Google Calendar event.
Args:
summary: Event title
start_time: RFC3339 start time (e.g. "2026-09-16T14:00:00-04:00")
end_time: RFC3339 end time
description: Description (optional)
location: Location (optional)
calendar_id: Calendar ID (default: "primary")
"""
if not config["configurable"].get("is_admin"):
return "Calendar access is restricted to admin users."
event = await google_service.create_calendar_event(
summary=summary,
start_time=start_time,
end_time=end_time,
description=description,
location=location,
calendar_id=calendar_id,
)
link = event.get("htmlLink", "")
return (
f"Created event '{event.get('summary')}' on {event.get('start', {}).get('dateTime', '')}."
+ (f" Link: {link}" if link else "")
)
@tool
async def gmail_search_emails(
query: str,
max_results: int = 10,
*,
config: RunnableConfig,
) -> str:
"""Search Gmail using Gmail search syntax.
Args:
query: Gmail search query (e.g. "from:bank unread", "subject:invoice")
max_results: Max results (default: 10)
"""
if not config["configurable"].get("is_admin"):
return "Gmail access is restricted to admin users."
emails = await google_service.search_emails(query=query, max_results=max_results)
if not emails:
return f"No emails found for query: {query}"
lines = [f"Found {len(emails)} email(s) for '{query}':"]
for email in emails:
lines.append(
f"\n[{email['id']}] {email['subject']}\n"
f" From: {email['from']} | {email['date']}\n"
f" {email['snippet']}"
)
return "\n".join(lines)
@tool
async def gmail_get_email(
message_id: str,
*,
config: RunnableConfig,
) -> str:
"""Get the full content of a Gmail message by ID (from gmail_search_emails results).
Args:
message_id: Message ID
"""
if not config["configurable"].get("is_admin"):
return "Gmail access is restricted to admin users."
email = await google_service.get_email(message_id=message_id)
return (
f"From: {email['from']}\n"
f"To: {email['to']}\n"
f"Subject: {email['subject']}\n"
f"Date: {email['date']}\n"
f"\n{email['body']}"
)
@tool
async def simbakit_device_status() -> str:
"""Get PetKit smart device statuses and active alerts (litter box, fountain, feeder)."""
if not simbakit_enabled:
return "SimbaKit integration is not configured. Please set SIMBAKIT_URL environment variable."
try:
devices = await simbakit_service.get_devices()
alerts = await simbakit_service.get_alerts()
return f"{devices}\n\n{alerts}"
except Exception as e:
return f"Error fetching device status: {str(e)}"
@tool
async def simbakit_weight_history(pet_name: str = "", days: int = 30) -> str:
"""Get pet weight history and trends from the PetKit litter box.
Args:
pet_name: Pet name filter (optional)
days: Days of history (default 30)
"""
if not simbakit_enabled:
return "SimbaKit integration is not configured. Please set SIMBAKIT_URL environment variable."
try:
return await simbakit_service.get_pet_weights(
pet_name=pet_name or None,
days=days,
)
except Exception as e:
return f"Error fetching weight history: {str(e)}"
@tool
async def simbakit_litter_activity(pet_name: str = "", days: int = 7) -> str:
"""Get litter box visit statistics and recent events from PetKit.
Args:
pet_name: Pet name filter (optional)
days: Days of history (default 7)
"""
if not simbakit_enabled:
return "SimbaKit integration is not configured. Please set SIMBAKIT_URL environment variable."
try:
return await simbakit_service.get_litter_events(
pet_name=pet_name or None,
days=days,
)
except Exception as e:
return f"Error fetching litter activity: {str(e)}"
@tool
async def simbakit_drinking_activity(pet_name: str = "", days: int = 7) -> str:
"""Get water fountain drinking activity and session stats from PetKit.
Args:
pet_name: Pet name filter (optional)
days: Days of history (default 7)
"""
if not simbakit_enabled:
return "SimbaKit integration is not configured. Please set SIMBAKIT_URL environment variable."
try:
return await simbakit_service.get_drinking_events(
pet_name=pet_name or None,
days=days,
)
except Exception as e:
return f"Error fetching drinking activity: {str(e)}"
# Create tools list based on what's available
tools = [get_current_date, simba_search, web_search, save_user_memory]
if simbakit_enabled:
tools.extend(
[
simbakit_device_status,
simbakit_weight_history,
simbakit_litter_activity,
simbakit_drinking_activity,
]
)
if ynab_configured():
tools.extend(YNAB_TOOLS)
if mealie_enabled:
tools.extend(
[
mealie_todays_meals,
mealie_meal_plans,
mealie_get_recipe,
mealie_shopping_lists,
mealie_create_meal_plan,
]
)
if obsidian_enabled:
tools.extend(
[
obsidian_search_notes,
obsidian_read_note,
obsidian_create_note,
obsidian_create_task,
journal_get_today,
journal_get_tasks,
journal_add_task,
journal_complete_task,
]
)
if google_calendar_enabled:
tools.extend([get_calendar_events, create_calendar_event])
if google_gmail_enabled:
tools.extend([gmail_search_emails, gmail_get_email])
_BELI_CATEGORY_CODES = {
"restaurant": "RES",
"restaurants": "RES",
"food": "RES",
"dessert": "DES",
"desserts": "DES",
"cafe": "DES",
"cafes": "DES",
"coffee": "DES",
"bar": "BAR",
"bars": "BAR",
"drinks": "BAR",
"bakery": "BAK",
"bakeries": "BAK",
}
_BELI_CATEGORY_LABELS = {
"RES": "Restaurant",
"DES": "Cafe/Dessert",
"BAR": "Bar",
"BAK": "Bakery",
}
@tool
async def beli_search_restaurants(
status: str = "",
city: str = "",
cuisine: str = "",
name: str = "",
category: str = "",
friends_only: bool = False,
) -> str:
"""Search Beli restaurant, bar, cafe, and bakery rankings.
Args:
status: 'been' or 'want_to_try' (default: all)
city: City filter (partial match)
cuisine: Cuisine filter (partial match)
name: Name filter (partial match)
category: 'restaurant', 'bar', 'cafe', 'bakery', or 'dessert'. IMPORTANT: pass
category='restaurant' when user asks for restaurants or places to eat.
friends_only: True when user asks what friends like/recommend; False for own rankings.
"""
from blueprints.beli.models import BeliRestaurant, BeliStatus
try:
qs = BeliRestaurant.all()
if friends_only:
qs = qs.exclude(friend_beli_user_id="")
else:
qs = qs.filter(friend_beli_user_id="")
if status:
try:
qs = qs.filter(status=BeliStatus(status))
except ValueError:
return f"Invalid status '{status}'. Valid values: been, want_to_try."
if category:
code = _BELI_CATEGORY_CODES.get(category.lower().strip())
if code:
qs = qs.filter(beli_category=code)
if city:
qs = qs.filter(city__icontains=city)
if name:
qs = qs.filter(name__icontains=name)
if friends_only:
# Fetch broadly ordered by rank, then sample up to 3 top picks per friend
rows = await qs.order_by("rank_index", "name").limit(500).all()
if cuisine:
cuisine_lower = cuisine.lower()
rows = [
r
for r in rows
if any(cuisine_lower in c.lower() for c in (r.cuisine or []))
]
seen: dict[str, int] = {}
restaurants = []
for r in rows:
fid = r.friend_beli_user_id
if seen.get(fid, 0) < 3:
restaurants.append(r)
seen[fid] = seen.get(fid, 0) + 1
if len(restaurants) >= 15:
break
else:
restaurants = await qs.order_by("rank_index", "name").limit(15).all()
if cuisine and not friends_only:
cuisine_lower = cuisine.lower()
restaurants = [
r
for r in restaurants
if any(cuisine_lower in c.lower() for c in (r.cuisine or []))
]
if not restaurants:
return "No matching places found in your Beli lists."
lines = []
for r in restaurants:
score = f" — ★{r.score}" if r.score is not None else ""
rank = f" #{r.rank_index}" if r.rank_index is not None else ""
location = ", ".join(filter(None, [r.neighborhood, r.city]))
cuisines = ", ".join(r.cuisine) if r.cuisine else ""
price = f" ({r.price_range})" if r.price_range else ""
status_label = r.status.value.replace("_", " ").title()
cat_label = _BELI_CATEGORY_LABELS.get(r.beli_category, "")
cat = f" [{cat_label}]" if cat_label else ""
attribution = (
f" [via {r.friend_name or r.friend_beli_user_id}]"
if r.friend_beli_user_id
else ""
)
line = f"• {r.name}{price}{rank}{score}{cat} [{status_label}]{attribution}"
if location:
line += f" — {location}"
if cuisines:
line += f" ({cuisines})"
if r.notes:
line += f"\n Note: {r.notes}"
lines.append(line)
return f"Found {len(lines)} place(s):\n\n" + "\n".join(lines)
except Exception as e:
return f"Error searching Beli places: {e}"
tools.append(beli_search_restaurants)
@RunnableLambda
def _build_prompt(state, config: RunnableConfig = None) -> list:
"""Inject the per-request system prompt, replacing any prior system messages."""
system_prompt = (
(config or {}).get("configurable", {}).get("system_prompt", SIMBA_SYSTEM_PROMPT)
)
# LangGraph passes the full state dict {"messages": [...]}, not just the list
messages = state["messages"] if isinstance(state, dict) else state
non_system = [m for m in messages if not isinstance(m, SystemMessage)]
# Keep only the most recent messages to stay within the 32k context window.
# Tool call/result pairs each count as 2 messages, so 12 ≈ 4-6 conversational turns.
non_system = non_system[-12:]
return [SystemMessage(content=system_prompt)] + non_system
def build_agent(checkpointer=None, extra_tools=()):
"""Compile and return the LangGraph agent.
``extra_tools`` are appended to the shared tool list, e.g. admin-only
Homebox and BookStack tools for the admin agent.
"""
return create_react_agent(
model=model_with_fallback,
tools=tools + list(extra_tools),
prompt=_build_prompt,
checkpointer=checkpointer,
)