feat(briefing): agentic slot injections + persist tool-call receipts
PR 1.5 + PR 2 of the agentic briefing rollout. Extends the agentic
path to cover the morning/midday/afternoon check-in slots in addition
to the 4am compilation, and persists the full tool-call sequence
into the briefing conversation so chat follow-ups see the receipts.
run_slot_injection now honors the briefing_mode setting the same way
run_compilation does: agentic mode routes through run_agentic_briefing
with a check-in system prompt variant, legacy falls back automatically
if the new path returns empty. Signature changed from str to
(str, dict) to surface the agentic message list to the caller.
_agentic_system_prompt now takes the user's local-day window and
timezone, pre-computed by run_agentic_briefing, and embeds them in
the prompt. This eliminates a whole class of "wrong day" bugs that
would otherwise happen when the model tried to translate "today" into
ISO 8601 ranges without knowing the user's timezone.
briefing_scheduler._run_slot_for_user now calls _persist_agentic_messages
after each slot, which walks the agentic message list and stores
every intermediate assistant turn (with its tool calls and results
folded into the flat storage format the existing chat loader expects)
as a real message row. The synthetic "[Midday briefing update]"
user-role messages are no longer created — final prose is written
with metadata.briefing_slot so the UI can still identify it as a
scheduled entry. The agentic user-trigger ("Generate my morning
briefing…") is deliberately skipped so it doesn't recreate the same
fake-user-message problem we're trying to remove.
briefing_conversations.post_message now accepts tool_calls, matching
the schema the Message model already supports. This lets scheduled
briefings write structured tool-use history without reaching into
the model layer.
Net effect with briefing_mode="agentic" on:
- All four slots are grounded in tool results, no more hallucinated
events or tasks
- Chat follow-ups in the briefing conversation see morning's
list_events → [] receipt (and everything else), so "what meeting?"
gets an honest "nothing on the calendar" reply grounded in data
- No more fake [Briefing update] user messages in the chat scroll
Still to come (PR 3): UI polish — tool-call status row, inline cards
for list_events/list_tasks results, visual treatment for slot messages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,8 +46,15 @@ async def post_message(
|
||||
role: str,
|
||||
content: str,
|
||||
metadata: dict | None = None,
|
||||
tool_calls: list | None = None,
|
||||
) -> Message:
|
||||
"""Append a message to a briefing conversation."""
|
||||
"""Append a message to a briefing conversation.
|
||||
|
||||
``tool_calls`` is accepted on assistant-role messages so the full
|
||||
agentic briefing sequence (assistant tool-call turns and tool-role
|
||||
results) can be persisted as real conversation rows, keeping the
|
||||
receipts in context on chat follow-ups.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
@@ -55,6 +62,7 @@ async def post_message(
|
||||
content=content,
|
||||
status="complete",
|
||||
msg_metadata=metadata,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
session.add(msg)
|
||||
# Bump conversation updated_at
|
||||
|
||||
@@ -461,13 +461,35 @@ _BRIEFING_AGENT_MAX_ROUNDS = 8
|
||||
_BRIEFING_AGENT_NUM_CTX = 8192
|
||||
|
||||
|
||||
def _agentic_system_prompt(profile_body: str, slot: str) -> str:
|
||||
def _agentic_system_prompt(
|
||||
profile_body: str,
|
||||
slot: str,
|
||||
today_iso: str,
|
||||
tz_name: str,
|
||||
day_from_iso: str,
|
||||
day_to_iso: 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.
|
||||
|
||||
Pre-computes today's window in the user's local timezone so the
|
||||
model can call date-sensitive tools (list_events, list_tasks filters)
|
||||
without having to do any timezone math itself — eliminating a whole
|
||||
class of "wrong day" bugs.
|
||||
"""
|
||||
tz_block = (
|
||||
f"Today is {today_iso} ({tz_name}). "
|
||||
f"When calling list_events for today, use:\n"
|
||||
f" date_from = {day_from_iso}\n"
|
||||
f" date_to = {day_to_iso}\n"
|
||||
f"These are already the correct local-day boundaries — do not convert "
|
||||
f"them to UTC or any other timezone. For other date ranges, compute in "
|
||||
f"the same timezone.\n\n"
|
||||
)
|
||||
|
||||
if slot == "compilation":
|
||||
base = (
|
||||
"You are the user's personal assistant giving their full morning briefing. "
|
||||
@@ -505,6 +527,7 @@ def _agentic_system_prompt(profile_body: str, slot: str) -> str:
|
||||
"- 3 to 5 sentences, natural prose, no markdown.\n\n"
|
||||
)
|
||||
|
||||
base = tz_block + base
|
||||
if profile_body:
|
||||
base += f"User profile (tone and preferences):\n{profile_body}\n"
|
||||
return base
|
||||
@@ -551,7 +574,6 @@ async def run_agentic_briefing(
|
||||
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)
|
||||
@@ -563,10 +585,28 @@ async def run_agentic_briefing(
|
||||
)
|
||||
return "", []
|
||||
|
||||
date_str = _date.today().isoformat()
|
||||
# Compute today's window in the user's local timezone so the model
|
||||
# receives ready-to-use ISO 8601 boundaries and never has to do its
|
||||
# own tz math when calling date-sensitive tools like list_events.
|
||||
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
user_tz = ZoneInfo(tz_name)
|
||||
except ZoneInfoNotFoundError:
|
||||
user_tz = ZoneInfo("UTC")
|
||||
tz_name = "UTC"
|
||||
now_local = datetime.now(user_tz)
|
||||
today_iso = now_local.date().isoformat()
|
||||
day_start = datetime(now_local.year, now_local.month, now_local.day, 0, 0, 0, tzinfo=user_tz)
|
||||
day_end = datetime(now_local.year, now_local.month, now_local.day, 23, 59, 59, tzinfo=user_tz)
|
||||
day_from_iso = day_start.isoformat()
|
||||
day_to_iso = day_end.isoformat()
|
||||
|
||||
system_prompt = _agentic_system_prompt(
|
||||
profile_context, slot, today_iso, tz_name, day_from_iso, day_to_iso,
|
||||
)
|
||||
messages: list[dict] = [
|
||||
{"role": "system", "content": _agentic_system_prompt(profile_context, slot)},
|
||||
{"role": "user", "content": _agentic_user_trigger(slot, date_str)},
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": _agentic_user_trigger(slot, today_iso)},
|
||||
]
|
||||
|
||||
final_text = ""
|
||||
@@ -1040,27 +1080,58 @@ async def run_compilation(
|
||||
return briefing_text, metadata
|
||||
|
||||
|
||||
async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str:
|
||||
async def run_slot_injection(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Lighter update for 8am/12pm/4pm — gathers fresh data and produces a slot-specific
|
||||
update prompt. Returns the text to inject as a new user→assistant exchange.
|
||||
Lighter update for 8am/12pm/4pm — generates a slot-specific check-in.
|
||||
|
||||
Honors the ``briefing_mode`` setting just like ``run_compilation``:
|
||||
agentic mode routes through the tool-use loop, legacy mode runs the
|
||||
one-shot synthesis path. Falls back to legacy automatically if
|
||||
agentic returns empty.
|
||||
|
||||
Returns ``(text, metadata)`` where metadata may contain
|
||||
``agentic_messages`` (the full tool-call sequence) if the agentic
|
||||
path was used.
|
||||
"""
|
||||
if model is None:
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
internal_data, external_data, temp_unit = await asyncio.gather(
|
||||
_gather_internal(user_id),
|
||||
_gather_external(user_id),
|
||||
_get_temp_unit(user_id),
|
||||
)
|
||||
briefing_mode = await get_setting(user_id, "briefing_mode", "legacy")
|
||||
agentic_messages: list[dict] = []
|
||||
text = ""
|
||||
|
||||
system = (
|
||||
f"You are a personal assistant giving a brief {slot} check-in. "
|
||||
"The user already had their morning briefing — focus only on what's changed or newly relevant. "
|
||||
"Speak naturally in 2-3 sentences, no markdown formatting, no headers or bullet points."
|
||||
)
|
||||
return await _llm_synthesise(
|
||||
system,
|
||||
_unified_user_prompt(internal_data, external_data, slot, temp_unit),
|
||||
model,
|
||||
)
|
||||
if briefing_mode == "agentic":
|
||||
text, agentic_messages = await run_agentic_briefing(
|
||||
user_id, slot, model, conv_id=None,
|
||||
)
|
||||
if not text:
|
||||
logger.warning(
|
||||
"Agentic slot injection returned empty for user %d slot %s — falling back to legacy",
|
||||
user_id, slot,
|
||||
)
|
||||
|
||||
if not text:
|
||||
internal_data, external_data, temp_unit = await asyncio.gather(
|
||||
_gather_internal(user_id),
|
||||
_gather_external(user_id),
|
||||
_get_temp_unit(user_id),
|
||||
)
|
||||
system = (
|
||||
f"You are a personal assistant giving a brief {slot} check-in. "
|
||||
"The user already had their morning briefing — focus only on what's changed or newly relevant. "
|
||||
"Speak naturally in 2-3 sentences, no markdown formatting, no headers or bullet points."
|
||||
)
|
||||
text = await _llm_synthesise(
|
||||
system,
|
||||
_unified_user_prompt(internal_data, external_data, slot, temp_unit),
|
||||
model,
|
||||
)
|
||||
|
||||
metadata: dict = {}
|
||||
if agentic_messages:
|
||||
metadata["agentic_messages"] = agentic_messages
|
||||
return text, metadata
|
||||
|
||||
@@ -191,6 +191,141 @@ async def _auto_pause_stale_projects(user_id: int) -> list[str]:
|
||||
return paused
|
||||
|
||||
|
||||
async def _persist_agentic_messages(
|
||||
conv_id: int,
|
||||
slot: str,
|
||||
metadata: dict | None,
|
||||
) -> None:
|
||||
"""Persist the intermediate turns from an agentic briefing run.
|
||||
|
||||
``metadata["agentic_messages"]`` is the full message list the agent
|
||||
generated — system prompt, user trigger, assistant tool-call turns,
|
||||
tool-role results, and the final assistant prose.
|
||||
|
||||
To stay compatible with the existing chat loader in ``routes/chat.py``,
|
||||
tool results are folded back into the parent assistant message's
|
||||
``tool_calls[i]["result"]`` field rather than being persisted as
|
||||
separate ``role="tool"`` rows. This matches how regular chat
|
||||
persists agentic turns, so the follow-up chat endpoint can rehydrate
|
||||
the tool sequence using its existing logic.
|
||||
|
||||
Persists everything except the system prompt (implicit in the chat
|
||||
pipeline) and the final assistant prose (the caller posts that
|
||||
separately with the user-facing metadata block). The synthetic user
|
||||
trigger message is persisted so Ollama sees a user→assistant→user
|
||||
sequence rather than an orphaned assistant reply — it's tagged as
|
||||
intermediate so the UI can hide it.
|
||||
|
||||
Legacy (non-agentic) briefings have no ``agentic_messages`` and this
|
||||
function is a no-op.
|
||||
"""
|
||||
from fabledassistant.services.briefing_conversations import post_message
|
||||
|
||||
if not metadata:
|
||||
return
|
||||
agentic_messages = metadata.get("agentic_messages") or []
|
||||
if not agentic_messages:
|
||||
return
|
||||
|
||||
# Drop the system prompt (index 0) and the final assistant prose
|
||||
# (last item). The caller posts the final prose as its own message.
|
||||
middle = agentic_messages[1:-1]
|
||||
|
||||
# Walk the middle sequence, pairing each assistant tool-call turn
|
||||
# with the tool-role results that immediately follow it.
|
||||
i = 0
|
||||
n = len(middle)
|
||||
while i < n:
|
||||
m = middle[i]
|
||||
role = m.get("role", "")
|
||||
content = m.get("content", "") or ""
|
||||
tag = {"briefing_slot": slot, "briefing_intermediate": True}
|
||||
|
||||
if role == "assistant" and m.get("tool_calls"):
|
||||
# Collect subsequent tool-role results, matching them
|
||||
# positionally onto this assistant's tool_calls. Normalise
|
||||
# each entry to the flat storage format the chat loader
|
||||
# expects: {"function": <name>, "arguments": <args>,
|
||||
# "result": <result>, "status": "success"|"error"}.
|
||||
raw_tool_calls = list(m["tool_calls"])
|
||||
flat_tool_calls: list[dict] = []
|
||||
result_idx = 0
|
||||
j = i + 1
|
||||
|
||||
import json as _json
|
||||
for raw_tc in raw_tool_calls:
|
||||
fn = raw_tc.get("function") or {}
|
||||
name = fn.get("name") if isinstance(fn, dict) else str(fn)
|
||||
arguments = fn.get("arguments") if isinstance(fn, dict) else {}
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = _json.loads(arguments)
|
||||
except Exception:
|
||||
arguments = {}
|
||||
|
||||
# Pair up with the next available tool-role message
|
||||
parsed_result: object = {}
|
||||
status = "success"
|
||||
if j < n and middle[j].get("role") == "tool":
|
||||
tool_content = middle[j].get("content", "") or ""
|
||||
try:
|
||||
parsed_result = _json.loads(tool_content)
|
||||
except Exception:
|
||||
parsed_result = tool_content
|
||||
if isinstance(parsed_result, dict) and parsed_result.get("success") is False:
|
||||
status = "error"
|
||||
j += 1
|
||||
result_idx += 1
|
||||
|
||||
flat_tool_calls.append({
|
||||
"function": name,
|
||||
"arguments": arguments,
|
||||
"result": parsed_result,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
try:
|
||||
await post_message(
|
||||
conv_id, "assistant", content,
|
||||
metadata=tag,
|
||||
tool_calls=flat_tool_calls,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist agentic assistant turn for conv %d slot %s",
|
||||
conv_id, slot, exc_info=True,
|
||||
)
|
||||
i = j # skip the tool results we just folded in
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
# Unpaired tool result — shouldn't normally happen, but be
|
||||
# defensive and persist it as an assistant-visible note so we
|
||||
# don't lose the receipt entirely.
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
# Skip the synthetic user trigger ("Generate my morning briefing…").
|
||||
# Persisting it would recreate the exact "[Midday briefing update]"
|
||||
# problem PR 2 is designed to eliminate: fake user messages
|
||||
# cluttering chat history. The LLM can follow an all-assistant
|
||||
# sequence just fine since the chat endpoint injects the real
|
||||
# system prompt on follow-up.
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# assistant without tool_calls — persist as-is (rare intermediate)
|
||||
try:
|
||||
await post_message(conv_id, role, content, metadata=tag)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist agentic %s message for conv %d slot %s",
|
||||
role, conv_id, slot, exc_info=True,
|
||||
)
|
||||
i += 1
|
||||
|
||||
|
||||
async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
||||
"""Execute one slot job for one user."""
|
||||
from fabledassistant.services.briefing_conversations import (
|
||||
@@ -248,14 +383,28 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
||||
conv = await get_or_create_today_conversation(user_id, model)
|
||||
text, metadata = await run_compilation(user_id, slot, model)
|
||||
if text:
|
||||
await post_message(conv.id, "assistant", text, metadata=metadata)
|
||||
# Persist the agentic tool-call sequence as its own messages
|
||||
# so follow-up chat can see the receipts. Each intermediate
|
||||
# message is tagged with briefing_slot so the chat context
|
||||
# loader can decide whether to include them in history.
|
||||
await _persist_agentic_messages(conv.id, slot, metadata)
|
||||
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
|
||||
final_meta["briefing_slot"] = slot
|
||||
await post_message(conv.id, "assistant", text, metadata=final_meta)
|
||||
|
||||
else:
|
||||
conv = await get_or_create_today_conversation(user_id, model)
|
||||
text = await run_slot_injection(user_id, slot, model)
|
||||
text, slot_metadata = await run_slot_injection(user_id, slot, model)
|
||||
if text:
|
||||
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
|
||||
await post_message(conv.id, "assistant", text)
|
||||
# No more synthetic "[Midday briefing update]" user-role
|
||||
# messages. Slot updates are plain assistant messages tagged
|
||||
# with briefing_slot so the chat endpoint can filter them
|
||||
# from the LLM's view of history on follow-ups (they remain
|
||||
# visible in the UI).
|
||||
await _persist_agentic_messages(conv.id, slot, slot_metadata)
|
||||
final_meta = {k: v for k, v in slot_metadata.items() if k != "agentic_messages"}
|
||||
final_meta["briefing_slot"] = slot
|
||||
await post_message(conv.id, "assistant", text, metadata=final_meta)
|
||||
|
||||
try:
|
||||
from fabledassistant.services.push import send_push_notification
|
||||
|
||||
Reference in New Issue
Block a user