50d123e4d2
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>
1127 lines
36 KiB
Python
1127 lines
36 KiB
Python
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,
|
||
)
|