b8ccc88b58
Compaction replaces a tool's text with its artifact, and the search artifact only carries UI snippets, so the full chunk the retriever found never reached the model. Search tools now attach the full text as model_context, which compaction prefers and shortens under budget pressure while keeping source ids; the persisted artifact and UI are unchanged. The per-chunk header drops the raw metadata dict (about a fifth of search output tokens) for title, date and source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1232 lines
40 KiB
Python
1232 lines
40 KiB
Python
import os
|
||
from functools import partial
|
||
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_core.utils.function_calling import convert_to_openai_tool
|
||
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,
|
||
search_results_model_context,
|
||
truncate,
|
||
unique,
|
||
)
|
||
from blueprints.conversation.context import RESPONSE_RESERVE_TOKENS, build_context
|
||
from blueprints.conversation.memory import save_memory
|
||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||
from blueprints.conversation.tool_routing import TOOL_ROUTING_MODES, ToolRouter
|
||
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"},
|
||
# Ask llama-server for token usage on the final chunk (prompt size logging).
|
||
stream_usage=True,
|
||
max_tokens=RESPONSE_RESERVE_TOKENS,
|
||
)
|
||
else:
|
||
llama_chat = None
|
||
|
||
openai_fallback = ChatOpenAI(model="gpt-5-mini", max_tokens=RESPONSE_RESERVE_TOKENS)
|
||
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, start_date: str = "", end_date: str = "", k: int = 8
|
||
):
|
||
"""Search Simba's medical records, vet documents, and household documents.
|
||
|
||
Args:
|
||
query: Question or information need
|
||
start_date: Inclusive document date YYYY-MM-DD, or empty for all dates.
|
||
end_date: Inclusive document date YYYY-MM-DD, or empty for all dates.
|
||
k: Maximum results, from 1 to 20.
|
||
"""
|
||
try:
|
||
kwargs = (
|
||
{"start_date": start_date, "end_date": end_date, "k": k}
|
||
if start_date or end_date or k != 8
|
||
else {}
|
||
)
|
||
serialized, docs = await query_vector_store(
|
||
query=query, source="paperless", **kwargs
|
||
)
|
||
except ValueError as exc:
|
||
return str(exc), None
|
||
artifact = search_documents_artifact(query, "paperless", docs)
|
||
if artifact is not None:
|
||
artifact["model_context"] = search_results_model_context(docs, "paperless")
|
||
return serialized, artifact
|
||
|
||
|
||
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, start_date: str = "", end_date: str = "", k: int = 8
|
||
):
|
||
"""Search personal Obsidian vault notes.
|
||
|
||
Args:
|
||
query: Search query
|
||
start_date: Inclusive note date YYYY-MM-DD, or empty for all dates.
|
||
end_date: Inclusive note date YYYY-MM-DD, or empty for all dates.
|
||
k: Maximum results, from 1 to 20.
|
||
"""
|
||
if not obsidian_enabled:
|
||
return OBSIDIAN_NOT_CONFIGURED, None
|
||
|
||
try:
|
||
# Query vector store filtered to obsidian source only
|
||
kwargs = (
|
||
{"start_date": start_date, "end_date": end_date, "k": k}
|
||
if start_date or end_date or k != 8
|
||
else {}
|
||
)
|
||
serialized, docs = await query_vector_store(
|
||
query=query, source="obsidian", **kwargs
|
||
)
|
||
artifact = search_documents_artifact(query, "obsidian", docs)
|
||
if artifact is not None:
|
||
artifact["model_context"] = search_results_model_context(docs, "obsidian")
|
||
return serialized, artifact
|
||
|
||
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, start_line: int = 1, max_lines: int | None = None
|
||
):
|
||
"""Read an Obsidian note, optionally selecting a small range of content lines.
|
||
|
||
Use line ranges to retrieve omitted sections of a large note.
|
||
|
||
Args:
|
||
relative_path: Path relative to vault root (e.g., "notes/my-note.md")
|
||
start_line: First content line to read, starting at 1 (excludes frontmatter).
|
||
max_lines: Number of lines to read, or None for all remaining lines.
|
||
"""
|
||
if not obsidian_enabled:
|
||
return OBSIDIAN_NOT_CONFIGURED, None
|
||
if start_line < 1 or (max_lines is not None and max_lines < 1):
|
||
return "start_line and max_lines must be positive integers.", 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"
|
||
content = content_data["content"]
|
||
if start_line != 1 or max_lines is not None:
|
||
lines = content.splitlines()
|
||
if start_line > len(lines):
|
||
return (
|
||
f"File: {note['path']} has {len(lines)} content lines; start_line is beyond the end.",
|
||
None,
|
||
)
|
||
end_line = (
|
||
min(len(lines), start_line - 1 + max_lines)
|
||
if max_lines is not None
|
||
else len(lines)
|
||
)
|
||
content = "\n".join(lines[start_line - 1 : end_line])
|
||
result += (
|
||
f"Content lines {start_line}-{end_line} of {len(lines)}:\n{content}\n\n"
|
||
)
|
||
else:
|
||
result += f"Content:\n{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"
|
||
|
||
artifact = _note_artifact(note["path"], content_data)
|
||
if start_line != 1 or max_lines is not None:
|
||
artifact["model_context"] = {
|
||
"markdown": content,
|
||
"read_range": {
|
||
"start_line": start_line,
|
||
"end_line": end_line,
|
||
"total_lines": len(lines),
|
||
},
|
||
}
|
||
return result, artifact
|
||
|
||
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)
|
||
|
||
|
||
def _build_prompt(state, config: RunnableConfig = None, *, tool_schemas=()) -> list:
|
||
"""Inject the per-request system prompt, replacing any prior system messages."""
|
||
configurable = (config or {}).get("configurable", {})
|
||
system_prompt = 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)]
|
||
summary = configurable.get("conversation_summary")
|
||
kwargs = {"summary_message": summary} if summary is not None else {}
|
||
return build_context(non_system, system_prompt, list(tool_schemas), **kwargs)
|
||
|
||
|
||
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.
|
||
"""
|
||
extra_tools = list(extra_tools)
|
||
# Keep the guarded Google tools executable for admins even when no MCP tools
|
||
# are configured and app.admin_agent aliases app.main_agent. Neither routing
|
||
# mode binds their schemas for a regular user. Extra tools exist only on the
|
||
# admin graph and are independently gated by the same invocation config.
|
||
agent_tools = tools + extra_tools
|
||
router = ToolRouter(agent_tools, admin_tools=extra_tools)
|
||
mode = os.getenv("TOOL_ROUTING_MODE", "static").strip().lower()
|
||
if mode not in TOOL_ROUTING_MODES:
|
||
raise ValueError(
|
||
f"TOOL_ROUTING_MODE must be one of {TOOL_ROUTING_MODES}, got {mode!r}"
|
||
)
|
||
dynamic = mode == "dynamic"
|
||
|
||
def routed_model(messages, config: RunnableConfig):
|
||
is_admin = (config or {}).get("configurable", {}).get("is_admin", False)
|
||
selected = (
|
||
router.select(messages, is_admin=is_admin)
|
||
if dynamic
|
||
else router.select_static(is_admin=is_admin)
|
||
)
|
||
schemas = [convert_to_openai_tool(t) for t in selected]
|
||
fallback = openai_fallback.bind_tools(selected)
|
||
bound = (
|
||
llama_chat.bind_tools(selected).with_fallbacks([fallback])
|
||
if llama_chat
|
||
else fallback
|
||
)
|
||
return RunnableLambda(partial(_build_prompt, tool_schemas=schemas)) | bound
|
||
|
||
def select_model(state, runtime):
|
||
# Installed LangGraph's dynamic model API accepts a Runnable. The default
|
||
# prompt forwards messages; RunnableLambda receives the invocation config
|
||
# and invokes the returned prompt/model chain with that same input/config.
|
||
return RunnableLambda(routed_model)
|
||
|
||
return create_react_agent(
|
||
model=select_model,
|
||
tools=agent_tools + ([router.discovery] if dynamic else []),
|
||
checkpointer=checkpointer,
|
||
)
|