feat(briefing): agentic compilation path behind feature flag (PR 1/N)
First cut of the agentic briefing redesign. Morning compilation can now route through a tool-call loop that grounds every factual claim in an actual tool result, eliminating the hallucinated meetings, tasks, and news items the legacy one-shot path was producing. Behind a per-user `briefing_mode` setting (default "legacy"); falls back to the legacy path automatically if the new path returns empty (e.g. model too weak to drive tool calls reliably). New: services/briefing_tools.py — explicit read-only allowlist of 10 tools (tasks, events, weather, rss, projects, notes). New tools added to tools.py must be opted in by name. Excludes all mutating tools and external search tools (search_images, search_web, research_topic) which are neither useful nor safe for a scheduled background job. New: briefing_pipeline.run_agentic_briefing — wraps the existing stream_chat_with_tools loop with slot-specific system prompts that tell the model to only assert facts from tool results and to be honest when tools return nothing. Max 8 rounds, per-round exception handling, returns the full message list so tool-call receipts can be persisted alongside the prose in a later PR. Sentence-count floors bumped: compilation 6–10 (was 4–8), check-ins 3–5 (was 2–3). Weekly review 5–8. Design doc: docs/2026-04-10-agentic-briefing-design.md Out of scope for this PR (future PRs): slot-injection migration, persisting tool-call receipts into the conversation so chat follow-ups see them, UI polish for tool-call status, sidecar storage for briefings. See the design doc's migration path for details. Enable on an account with: UPDATE settings SET value='agentic' WHERE user_id=<id> AND key='briefing_mode'; or insert the row if missing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -455,7 +455,199 @@ async def _gather_external(user_id: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── LLM synthesis ─────────────────────────────────────────────────────────────
|
||||
# ── Agentic briefing (tool-use loop) ──────────────────────────────────────────
|
||||
|
||||
_BRIEFING_AGENT_MAX_ROUNDS = 8
|
||||
_BRIEFING_AGENT_NUM_CTX = 8192
|
||||
|
||||
|
||||
def _agentic_system_prompt(profile_body: str, slot: str) -> str:
|
||||
"""System prompt for the agentic briefing path.
|
||||
|
||||
Pushes the model to ground every factual claim in a tool result and
|
||||
to be honest when tools return nothing, rather than fabricating
|
||||
content to fill the narrative.
|
||||
"""
|
||||
if slot == "compilation":
|
||||
base = (
|
||||
"You are the user's personal assistant giving their full morning briefing. "
|
||||
"Use the tools available to see what's actually relevant today — tasks due, "
|
||||
"overdue items, events on the calendar, weather, news themes, project state — "
|
||||
"and weave it into a warm, natural-sounding summary.\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
|
||||
"- If a tool returns nothing (no events today, no overdue tasks), say so honestly. "
|
||||
"Don't fabricate items to fill space.\n"
|
||||
"- Write flowing prose. No markdown, no headers, no bullet points.\n"
|
||||
"- Aim for 6 to 10 sentences. Skip topics that have nothing interesting.\n"
|
||||
"- Close on one or two concrete, actionable suggestions.\n\n"
|
||||
)
|
||||
elif slot == "weekly_review":
|
||||
base = (
|
||||
"You are the user's personal assistant delivering a weekly review. "
|
||||
"Use the tools available to see what was accomplished this week, what's still "
|
||||
"overdue, how many notes were captured, and what's coming up in the next seven days. "
|
||||
"Write a reflective recap that celebrates real progress and gently flags what's stuck.\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
|
||||
"- If a category is empty, say so honestly rather than inventing items.\n"
|
||||
"- Write flowing prose. No markdown, no bullet points.\n"
|
||||
"- Aim for 5 to 8 sentences. Reflective and encouraging tone.\n\n"
|
||||
)
|
||||
else: # morning, midday, afternoon check-ins
|
||||
base = (
|
||||
f"You are the user's personal assistant giving a brief {slot} check-in. "
|
||||
"Use tools to see what's changed since this morning. Focus on progress and "
|
||||
"what's still unaddressed.\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see current state. Never assert facts without tool results.\n"
|
||||
"- If nothing meaningful has changed, say so briefly — don't invent progress.\n"
|
||||
"- 3 to 5 sentences, natural prose, no markdown.\n\n"
|
||||
)
|
||||
|
||||
if profile_body:
|
||||
base += f"User profile (tone and preferences):\n{profile_body}\n"
|
||||
return base
|
||||
|
||||
|
||||
def _agentic_user_trigger(slot: str, date_str: str) -> str:
|
||||
"""Seed user-role message that kicks off the agentic run."""
|
||||
labels = {
|
||||
"compilation": "morning briefing",
|
||||
"morning": "morning check-in",
|
||||
"midday": "midday check-in",
|
||||
"afternoon": "afternoon wrap-up",
|
||||
"weekly_review": "weekly review",
|
||||
}
|
||||
label = labels.get(slot, f"{slot} briefing")
|
||||
return f"Generate my {label} for {date_str}."
|
||||
|
||||
|
||||
async def run_agentic_briefing(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str,
|
||||
conv_id: int | None = None,
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""
|
||||
Run the agentic briefing loop for a user and slot.
|
||||
|
||||
Uses the chat pipeline's tool-use loop with a curated read-only tool
|
||||
subset and a slot-specific system prompt. Every fact the model states
|
||||
is either derived from a tool result visible in the returned message
|
||||
list or it's the model hallucinating — so follow-up chat in the same
|
||||
conversation can hold the model to what the tool results actually show.
|
||||
|
||||
Returns ``(final_prose, message_list)`` where ``message_list`` is the
|
||||
full sequence including system, user trigger, tool calls, and tool
|
||||
results. Callers are expected to persist those intermediate turns
|
||||
alongside the final prose so the receipts remain in conversation
|
||||
history on follow-up.
|
||||
|
||||
If the loop fails or the model returns empty prose, returns
|
||||
``("", [])`` and the caller should fall back to the legacy path.
|
||||
"""
|
||||
from fabledassistant.services.llm import stream_chat_with_tools, ChatChunk # noqa: F401
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
from fabledassistant.services.briefing_tools import get_briefing_tools
|
||||
from fabledassistant.services.user_profile import build_profile_context
|
||||
from datetime import date as _date
|
||||
|
||||
profile_context = await build_profile_context(user_id)
|
||||
tools = await get_briefing_tools(user_id)
|
||||
|
||||
if not tools:
|
||||
logger.warning(
|
||||
"Agentic briefing for user %d slot %s: no tools available — aborting",
|
||||
user_id, slot,
|
||||
)
|
||||
return "", []
|
||||
|
||||
date_str = _date.today().isoformat()
|
||||
messages: list[dict] = [
|
||||
{"role": "system", "content": _agentic_system_prompt(profile_context, slot)},
|
||||
{"role": "user", "content": _agentic_user_trigger(slot, date_str)},
|
||||
]
|
||||
|
||||
final_text = ""
|
||||
for round_idx in range(_BRIEFING_AGENT_MAX_ROUNDS):
|
||||
accumulated_content = ""
|
||||
accumulated_tool_calls: list[dict] = []
|
||||
|
||||
try:
|
||||
async for chunk in stream_chat_with_tools(
|
||||
messages, model, tools=tools, think=False,
|
||||
num_ctx=_BRIEFING_AGENT_NUM_CTX,
|
||||
):
|
||||
if chunk.type == "content" and chunk.content:
|
||||
accumulated_content += chunk.content
|
||||
elif chunk.type == "tool_calls" and chunk.tool_calls:
|
||||
accumulated_tool_calls.extend(chunk.tool_calls)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Agentic briefing stream failed (user %d, slot %s, round %d)",
|
||||
user_id, slot, round_idx, exc_info=True,
|
||||
)
|
||||
return "", []
|
||||
|
||||
# Append the assistant turn (content + any tool calls) to history
|
||||
assistant_msg: dict = {"role": "assistant", "content": accumulated_content}
|
||||
if accumulated_tool_calls:
|
||||
assistant_msg["tool_calls"] = accumulated_tool_calls
|
||||
messages.append(assistant_msg)
|
||||
|
||||
# No tool calls → the model is done
|
||||
if not accumulated_tool_calls:
|
||||
final_text = accumulated_content.strip()
|
||||
break
|
||||
|
||||
# Execute each tool call and append results as tool-role messages
|
||||
for tc in accumulated_tool_calls:
|
||||
fn = tc.get("function") or {}
|
||||
tool_name = fn.get("name", "")
|
||||
arguments = fn.get("arguments") or {}
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
import json as _json
|
||||
arguments = _json.loads(arguments)
|
||||
except Exception:
|
||||
arguments = {}
|
||||
|
||||
try:
|
||||
result = await execute_tool(user_id, tool_name, arguments, conv_id=conv_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Tool %s failed during agentic briefing: %s", tool_name, exc,
|
||||
)
|
||||
result = {"success": False, "error": str(exc)}
|
||||
|
||||
# Serialize the result compactly for the model's context
|
||||
import json as _json
|
||||
try:
|
||||
result_str = _json.dumps(result, default=str)[:4000]
|
||||
except Exception:
|
||||
result_str = str(result)[:4000]
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"content": result_str,
|
||||
"tool_name": tool_name,
|
||||
})
|
||||
else:
|
||||
logger.warning(
|
||||
"Agentic briefing hit max rounds (%d) for user %d slot %s — using last content",
|
||||
_BRIEFING_AGENT_MAX_ROUNDS, user_id, slot,
|
||||
)
|
||||
# Walk back to find the last assistant message with non-empty content
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "assistant" and m.get("content"):
|
||||
final_text = m["content"].strip()
|
||||
break
|
||||
|
||||
return final_text, messages
|
||||
|
||||
|
||||
# ── Legacy one-shot synthesis ─────────────────────────────────────────────────
|
||||
|
||||
async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str, num_ctx: int = 4096) -> str:
|
||||
"""Single non-streaming LLM call. Returns the assistant's response text."""
|
||||
@@ -812,17 +1004,34 @@ async def run_compilation(
|
||||
"topic_scores": topic_scores,
|
||||
}
|
||||
|
||||
briefing_text = await _llm_synthesise(
|
||||
_unified_system_prompt(profile_context, slot),
|
||||
_unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
|
||||
model,
|
||||
num_ctx=8192,
|
||||
)
|
||||
briefing_mode = await get_setting(user_id, "briefing_mode", "legacy")
|
||||
agentic_messages: list[dict] = []
|
||||
briefing_text = ""
|
||||
|
||||
if briefing_mode == "agentic":
|
||||
briefing_text, agentic_messages = await run_agentic_briefing(
|
||||
user_id, slot, model, conv_id=None,
|
||||
)
|
||||
if not briefing_text:
|
||||
logger.warning(
|
||||
"Agentic briefing returned empty for user %d slot %s — falling back to legacy path",
|
||||
user_id, slot,
|
||||
)
|
||||
|
||||
if not briefing_text:
|
||||
briefing_text = await _llm_synthesise(
|
||||
_unified_system_prompt(profile_context, slot),
|
||||
_unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
|
||||
model,
|
||||
num_ctx=8192,
|
||||
)
|
||||
|
||||
# ── Post-processing ─────────────────────────────────────────────────────────
|
||||
await upsert_task_snapshots(user_id, all_tasks)
|
||||
|
||||
metadata: dict = {"rss_item_ids": rss_item_ids, "rss_items": rss_items_meta, "weather": weather_card}
|
||||
if agentic_messages:
|
||||
metadata["agentic_messages"] = agentic_messages
|
||||
|
||||
if not briefing_text:
|
||||
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Curated read-only tool subset for agentic briefings.
|
||||
|
||||
The main chat pipeline exposes 40+ tools via ``tools.get_tools_for_user``,
|
||||
including mutating tools (``create_task``, ``delete_note``) and
|
||||
external-search tools (``search_images``, ``search_web``). Neither is
|
||||
appropriate for a scheduled background job that generates briefings —
|
||||
briefings are read-only and should not reach out to the internet on the
|
||||
user's behalf, and leaving high-noise tools in the list increases the
|
||||
chance of spurious calls (e.g. ``search_images`` firing on "what
|
||||
meeting?").
|
||||
|
||||
This module maintains an explicit allowlist. New tools added to
|
||||
``tools.py`` are not automatically exposed to briefings — they must be
|
||||
opted in by name here.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fabledassistant.services.tools import get_tools_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Explicit allowlist — tools a briefing is permitted to call. Read-only only.
|
||||
# Any tool not listed here is invisible to the briefing model.
|
||||
BRIEFING_TOOL_NAMES: frozenset[str] = frozenset({
|
||||
# Tasks — what's actionable today, overdue, or high priority
|
||||
"list_tasks",
|
||||
# Calendar — internal event store and CalDAV (if configured)
|
||||
"list_events",
|
||||
"search_events",
|
||||
# Weather — today's forecast for the user's configured locations
|
||||
"get_weather",
|
||||
# News — RSS items filtered by user preferences
|
||||
"get_rss_items",
|
||||
# Projects — context for prioritization and narrative continuity
|
||||
"list_projects",
|
||||
"search_projects",
|
||||
"get_project",
|
||||
# Notes — surface recent captures for "pick up where you left off"
|
||||
"list_notes",
|
||||
"get_note",
|
||||
})
|
||||
|
||||
|
||||
async def get_briefing_tools(user_id: int) -> list[dict]:
|
||||
"""Return the tool schemas a briefing run is permitted to call.
|
||||
|
||||
Builds the user's full tool list (so user-specific gating such as
|
||||
CalDAV availability still applies) and filters it down to the
|
||||
briefing allowlist.
|
||||
"""
|
||||
all_tools = await get_tools_for_user(user_id)
|
||||
filtered = [
|
||||
t for t in all_tools
|
||||
if t.get("function", {}).get("name") in BRIEFING_TOOL_NAMES
|
||||
]
|
||||
logger.debug(
|
||||
"Briefing tools for user %d: %d of %d selected",
|
||||
user_id, len(filtered), len(all_tools),
|
||||
)
|
||||
return filtered
|
||||
Reference in New Issue
Block a user