From d76f52b57833dfa1386c6f06564d3019a50c92cf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 22 May 2026 22:11:25 -0400 Subject: [PATCH] feat(curator): additive-only tool scope; transcript shows User/Assistant only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related tightenings to the curator's behavior, both driven by user questions about scope (2026-05-23): 1. **Tighten the prompt to extract beats only from User: lines.** The transcript shows each message prefixed with role (User: / Assistant:). The previous prompt instructed the model to capture beats but didn't explicitly forbid using Assistant: content as a source. A small or medium model could read 'It sounds like you had coffee with Sarah' from an Assistant: line and turn it into a moment, even though that's the assistant paraphrasing the user — not a user statement. New prompt explicitly: Only User: lines are journal entries. Assistant: lines are context for disambiguation only. Never create a record from content that appears only in Assistant: text. 2. **Additive-only tool allowlist for the curator.** The curator previously had access to the full journal tool set — including update_*, delete_*, create_event, set_rag_scope, etc. The architecture removed tools from the chat for exactly the reason that confidently-wrong tool calls corrupt user data; the curator faces the same risk async. Filtering the tool list at curator-time keeps the boundary tight even if the system prompt fails to dissuade the model from hallucinated tool names. New _CURATOR_ALLOWED_TOOLS frozenset includes: - Additive primary work: record_moment, create_note (handles both notes and tasks via status), log_work (appends to existing task timeline — additive on its own row), save_person, save_place, create_project, create_milestone. - Read-only helpers needed for entity resolution: search_notes, search_projects, search_journal, list_tasks, list_projects, list_milestones, read_note, get_project, get_profile. Explicitly excluded: every update_*, every delete_*, create_event (calendar events need explicit user intent, not curator inference), set_rag_scope, lookup/research_topic/search_images (different surface entirely). Two-layer enforcement: the system prompt lists what's available and forbids the rest, AND the actual tools list passed to Ollama is filtered to the allowlist. So even if the model hallucinates a forbidden tool name, the call can't fire — execute_tool returns 'Unknown tool: '. Bonus cleanup: _format_transcript now skips system and tool-role messages. They were noise for the curator's task (system prompts are instructions, tool results are JSON from prior calls). The narrowed transcript matches the contract the prompt enforces. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fabledassistant/services/curator.py | 97 ++++++++++++++++++++----- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/src/fabledassistant/services/curator.py b/src/fabledassistant/services/curator.py index 8401b89..11bad97 100644 --- a/src/fabledassistant/services/curator.py +++ b/src/fabledassistant/services/curator.py @@ -42,28 +42,74 @@ logger = logging.getLogger(__name__) # fixed transcript, not respond to evolving conversation). _MAX_TOOL_ROUNDS = 4 +# Curator tool allowlist. Additive operations only — no updates, no +# deletes. Risk model: the curator can be confidently wrong, and the +# user is not in the loop when it runs. Adds are easily undone by the +# user (delete the moment, delete the task). Updates and deletes are +# not — a curator that confidently overwrites a task title with the +# wrong value is worse than a curator that creates a duplicate task. +# +# Plus a small set of read-only helpers the curator needs for entity +# resolution before it can safely link names → ids on the additive +# calls. +_CURATOR_ALLOWED_TOOLS: frozenset[str] = frozenset({ + # Additive — primary curator work + "record_moment", + "create_note", # also creates tasks (status='todo') + "log_work", # appends to a task's work-log timeline + "save_person", + "save_place", + "create_project", + "create_milestone", + # Read-only — supporting lookups for entity resolution + "search_notes", + "search_projects", + "search_journal", + "list_tasks", + "list_projects", + "list_milestones", + "read_note", + "get_project", + "get_profile", +}) + _CURATOR_SYSTEM_PROMPT = """You are a curator reading a fragment of the user's journal conversation. Your job is to capture meaningful beats as structured records using the tools provided. You do NOT respond to the user — your only output is tool calls. -Beats worth recording: -- Events that happened ("went grocery shopping", "finished the network restage") +TRANSCRIPT FORMAT: +The transcript shows lines prefixed with either `User:` or `Assistant:`. Only the `User:` lines are journal entries. The `Assistant:` lines are context — they tell you what was said back, so you can disambiguate references ("which Sarah?") — but they are NEVER journal beats themselves. NEVER create a moment or any other record based on content that appears only in an `Assistant:` line. If a user line is short and the meaning depends on the assistant's question, capture the user's intent in your own concise phrasing, not by quoting the assistant. + +WHAT TO CAPTURE (from `User:` lines only): +- Events that happened ("I went grocery shopping", "finished the network restage") - Encounters with people ("had coffee with Sarah", "called Mom") - Decisions ("going to switch jobs", "won't pursue the contract") - Observations about the user's state or world ("the new place is loud", "feeling tired") - Plans and commitments ("watching a show tonight", "dentist Thursday") - Small accomplishments or changes the user made ("installed the new AP", "shipped the migration") +- New tasks or todos the user wants to track ("I need to call mom tomorrow", "remind me to renew the domain") +- Knowledge worth saving as a note ("the dhcp issue at Bedford was caused by stale leases") -Rules: -- Use record_moment to capture each distinct beat. One tool call per beat — do not collapse multiple beats into one. -- When linking to entities (people, places, tasks, notes), use the *_names parameters and let the server resolve. Never invent ids. -- Before linking a task by title, call search_notes to confirm it exists. If you have not searched, do not pass task_titles. -- If the user explicitly references an existing task by name, prefer update_task to mark progress. If they describe finishing something, set status=done. -- If the user mentions a person or place you do not already know about, you may call save_person or save_place to create the entry. Otherwise skip new-entity creation — better to omit a link than to invent the wrong one. -- Skip meta-conversational fragments ("ok", "thanks", "got it") — those are not journal beats. -- Match the user's voice when writing moment content. First-person or imperative. Never "the user mentioned…" / "user reports…" framing. +TOOL USE — additive only. +You have these tools available; nothing else exists. The architecture intentionally excludes update and delete operations from your reach — a confidently-wrong update is worse than a duplicate create that the user can prune. + +- `record_moment` — for journal beats. One tool call per beat; do not collapse multiple beats. Write content in the user's voice (first-person or imperative); NEVER "the user mentioned…" framing. +- `create_note` — create a task (set status='todo' plus due_date/priority if mentioned) OR a knowledge note (omit status). Use this when the user states a new commitment with a clear scope, or shares a chunk of reusable knowledge. +- `log_work` — append a progress entry to an existing task. Use this when the user describes work they did on a task that already exists. ALWAYS call search_notes first to confirm the task exists by title or keyword; if no match, prefer create_note with status='todo' instead, or skip. +- `save_person` / `save_place` — create a new entry only when the user mentions someone or somewhere you have no prior reference for. Otherwise skip — better to omit a link on a moment than to invent the wrong entity. +- `create_project` / `create_milestone` — only when the user is clearly starting a NEW project or milestone they intend to track. Don't infer projects from passing mentions. + +ENTITY LINKING (on record_moment): +- Use the *_names parameters (person_names, place_names, task_titles, note_titles). The server resolves names → ids. +- Before passing task_titles or note_titles, call search_notes to confirm the title exists. Don't invent titles. + +WHAT NOT TO DO: +- Don't capture content from `Assistant:` lines. +- Don't try to update or delete anything (you have no tools for it). +- Don't fire tool calls for purely meta-conversational fragments ("ok", "thanks", "got it"). After the tool calls, you may emit one short summary sentence (≤ 20 words) describing what you captured. The summary is shown back to the chat model in subsequent turns so it stays aware of recent topics; it is NOT shown to the user directly. Examples: - "Captured network restage progress and a coffee mention with Sarah." -- "Recorded plan for tonight; nothing else stood out." +- "Logged work on the Famous Supply task; recorded a tired but accomplished feeling." +- "Created a task for the domain renewal next month." - "" (empty if nothing was captured — perfectly fine). """ @@ -126,17 +172,23 @@ class CuratorRunResult: def _format_transcript(messages: list[Message]) -> str: """Render a list of Message rows as a plain transcript the curator can read. - Tool-call messages and previous assistant content are included so the - curator has full context, but the curator's own focus is on extracting - beats from user messages. + Only user + assistant messages are included. System messages (instructions + the chat model received) and tool-result messages (JSON noise from prior + tool calls) are noise for the curator's task; including them risked the + curator extracting from system text. The system prompt explicitly tells + the curator to extract ONLY from `User:` lines and to treat `Assistant:` + lines as context, so the format mirrors that contract. """ lines: list[str] = [] for m in messages: if not m.content: continue + role = (m.role or "").lower() + if role not in ("user", "assistant"): + continue ts = m.created_at.strftime("%H:%M") if m.created_at else "??:??" - role = m.role.capitalize() if m.role else "Unknown" - lines.append(f"[{ts}] {role}: {m.content.strip()}") + role_label = role.capitalize() + lines.append(f"[{ts}] {role_label}: {m.content.strip()}") return "\n".join(lines) @@ -211,7 +263,18 @@ async def run_curator_for_conversation( # Falls back to OLLAMA_MODEL if no background model is configured. model = await get_setting(user_id, "background_model", "") or Config.OLLAMA_MODEL - tools = await get_tools_for_user(user_id, conversation_type="journal") + # Filter the journal tool set down to the curator's additive-only + # allowlist (see _CURATOR_ALLOWED_TOOLS comment). Belt-and-suspenders + # with the system prompt: if the prompt fails to dissuade the model + # from update/delete, the tool list literally doesn't include them + # so the call can't fire even if hallucinated. execute_tool also + # treats unknown names as errors, so the filter is the canonical + # boundary for what the curator can actually do. + all_tools = await get_tools_for_user(user_id, conversation_type="journal") + tools = [ + t for t in all_tools + if (t.get("function") or {}).get("name") in _CURATOR_ALLOWED_TOOLS + ] transcript = _format_transcript(messages) user_prompt = (