feat(curator): additive-only tool scope; transcript shows User/Assistant only
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: <name>'. 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user