Files
simbarag/blueprints/conversation/runtime.py
T
ryan 5809ad3adb
CI / backend (pull_request) Successful in 42s
CI / frontend (pull_request) Failing after 7s
CI / backend (push) Successful in 41s
CI / frontend (push) Failing after 7s
Add durable chat runs, shared agent runtime, reliable jobs, history tools and citations
Shared runtime
- One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for
  web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions,
  memories, summaries and metrics match across channels.
- Serialize turns per checkpoint thread; /query validates input and returns 409
  while a durable run owns the conversation.

Reconnectable chat (durable runs)
- POST /runs creates an idempotent, owner-scoped run; GET .../events replays an
  ordered SSE log by cursor, so generation survives refresh and device switches.
  Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter.
- Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of
  polling; keep buffered text when a run is stopped.
- Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering
  the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded.

Scheduled messages and cron
- Atomic delivery claims, attempt history and explicit retries; ambiguous sends
  become "uncertain" and are never resent automatically.
- Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner.
- Cancelling never rewrites a started delivery; series stop via tombstones.
  Legacy sent rows are not forked into new series. Malformed bodies return 400.

History, integrations and retrieval
- Message-content search, pinned conversations, JSON/Markdown export and a
  memory editor.
- Integration status panel and YNAB data-freshness provenance.
- Date-filtered document/note search with stable citations and a source viewer.

Migration 20 is generated by aerich.

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

172 lines
5.0 KiB
Python

"""Shared agent invocation for authenticated chat and messaging channels."""
import asyncio
import json
import logging
import time
import uuid
import weakref
from contextlib import asynccontextmanager
from dataclasses import dataclass
from langchain_core.messages import convert_to_messages
from quart import current_app
from metrics import AGENT_REQUESTS_TOTAL, AGENT_RESPONSE_SECONDS
from .logic import get_agent_for
from .memory import build_system_prompt_with_memories
# Concurrent turns on one checkpoint thread race on read-modify-write of its
# history, silently dropping a turn. Hold this around preparation + invocation.
_thread_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
weakref.WeakValueDictionary()
)
@asynccontextmanager
async def thread_lock(thread_id: str):
lock = _thread_locks.get(thread_id)
if lock is None:
lock = _thread_locks[thread_id] = asyncio.Lock()
async with lock:
yield
@dataclass
class PreparedTurn:
agent: object
payload: dict
config: dict
async def prepare_summary(agent, payload: dict, config: dict) -> None:
store = getattr(current_app, "conversation_summary_store", None)
if store is None:
return
configurable = config["configurable"]
try:
checkpoint = await agent.aget_state(config)
history = convert_to_messages(checkpoint.values.get("messages", []))
state = await store.update(
configurable["user_id"],
configurable["thread_id"],
history + convert_to_messages(payload["messages"]),
)
configurable["conversation_summary"] = state.as_message()
except Exception:
logging.exception("Failed to prepare conversation summary")
async def prepare_agent_turn(
user,
query: str,
thread_id: str,
*,
channel: str = "web",
messages: list | None = None,
instructions: str = "",
) -> PreparedTurn:
"""Callers resolve and authorize the user/thread before preparing a turn.
Only authenticated server-side user objects determine permissions. Caller
input cannot supply a config that overrides identity or administration.
"""
if user is None or not getattr(user, "id", None):
raise ValueError("An authenticated user is required for agent invocation")
if not isinstance(query, str) or not isinstance(thread_id, str) or not thread_id:
raise ValueError("query and thread_id must be valid strings")
prompt = await build_system_prompt_with_memories(str(user.id), query=query)
if instructions:
prompt += "\n\n" + instructions
config = {
"configurable": {
"user_id": str(user.id),
"is_admin": bool(user.is_admin()),
"thread_id": thread_id,
"system_prompt": prompt,
"channel": channel,
"request_id": str(uuid.uuid4()),
}
}
payload = {
"messages": list(messages)
if messages is not None
else [{"role": "user", "content": query}]
}
agent = get_agent_for(user)
await prepare_summary(agent, payload, config)
return PreparedTurn(agent, payload, config)
def _record(turn: PreparedTurn, started: float, result: str) -> None:
cfg = turn.config["configurable"]
seconds = time.monotonic() - started
AGENT_REQUESTS_TOTAL.labels(channel=cfg["channel"], result=result).inc()
AGENT_RESPONSE_SECONDS.labels(channel=cfg["channel"]).observe(seconds)
logging.info(
json.dumps(
{
"event": "agent_invocation",
"request_id": cfg["request_id"],
"channel": cfg["channel"],
"thread_id": cfg["thread_id"],
"user_id": cfg["user_id"],
"result": result,
"total_ms": round(seconds * 1000),
}
)
)
async def invoke_prepared(turn: PreparedTurn) -> dict:
started = time.monotonic()
result = "success"
try:
return await turn.agent.ainvoke(turn.payload, config=turn.config)
except asyncio.CancelledError:
result = "cancelled"
raise
except Exception:
result = "error"
raise
finally:
_record(turn, started, result)
async def invoke_agent_for_user(
user,
query: str,
thread_id: str,
*,
channel: str = "web",
messages: list | None = None,
instructions: str = "",
) -> dict:
async with thread_lock(thread_id):
turn = await prepare_agent_turn(
user,
query,
thread_id,
channel=channel,
messages=messages,
instructions=instructions,
)
return await invoke_prepared(turn)
async def stream_prepared(turn: PreparedTurn):
started = time.monotonic()
result = "cancelled"
try:
async for event in turn.agent.astream_events(
turn.payload, version="v2", config=turn.config
):
yield event
result = "success"
except Exception:
result = "error"
raise
finally:
_record(turn, started, result)