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:
Bryan Van Deusen
2026-04-10 14:13:55 -04:00
parent 3f3156db07
commit aebb6baa2c
3 changed files with 494 additions and 7 deletions
@@ -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