diff --git a/src/fabledassistant/services/briefing_conversations.py b/src/fabledassistant/services/briefing_conversations.py index 08bb0cb..dc6207b 100644 --- a/src/fabledassistant/services/briefing_conversations.py +++ b/src/fabledassistant/services/briefing_conversations.py @@ -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 diff --git a/src/fabledassistant/services/briefing_pipeline.py b/src/fabledassistant/services/briefing_pipeline.py index 662fab1..7b81c7c 100644 --- a/src/fabledassistant/services/briefing_pipeline.py +++ b/src/fabledassistant/services/briefing_pipeline.py @@ -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 diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index 5753c9b..e2b766f 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -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": , "arguments": , + # "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