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).
|
# fixed transcript, not respond to evolving conversation).
|
||||||
_MAX_TOOL_ROUNDS = 4
|
_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.
|
_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:
|
TRANSCRIPT FORMAT:
|
||||||
- Events that happened ("went grocery shopping", "finished the network restage")
|
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")
|
- Encounters with people ("had coffee with Sarah", "called Mom")
|
||||||
- Decisions ("going to switch jobs", "won't pursue the contract")
|
- 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")
|
- Observations about the user's state or world ("the new place is loud", "feeling tired")
|
||||||
- Plans and commitments ("watching a show tonight", "dentist Thursday")
|
- Plans and commitments ("watching a show tonight", "dentist Thursday")
|
||||||
- Small accomplishments or changes the user made ("installed the new AP", "shipped the migration")
|
- 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:
|
TOOL USE — additive only.
|
||||||
- Use record_moment to capture each distinct beat. One tool call per beat — do not collapse multiple beats into one.
|
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.
|
||||||
- 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.
|
- `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.
|
||||||
- If the user explicitly references an existing task by name, prefer update_task to mark progress. If they describe finishing something, set status=done.
|
- `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.
|
||||||
- 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.
|
- `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.
|
||||||
- Skip meta-conversational fragments ("ok", "thanks", "got it") — those are not journal beats.
|
- `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.
|
||||||
- Match the user's voice when writing moment content. First-person or imperative. Never "the user mentioned…" / "user reports…" framing.
|
- `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:
|
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."
|
- "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).
|
- "" (empty if nothing was captured — perfectly fine).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -126,17 +172,23 @@ class CuratorRunResult:
|
|||||||
def _format_transcript(messages: list[Message]) -> str:
|
def _format_transcript(messages: list[Message]) -> str:
|
||||||
"""Render a list of Message rows as a plain transcript the curator can read.
|
"""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
|
Only user + assistant messages are included. System messages (instructions
|
||||||
curator has full context, but the curator's own focus is on extracting
|
the chat model received) and tool-result messages (JSON noise from prior
|
||||||
beats from user messages.
|
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] = []
|
lines: list[str] = []
|
||||||
for m in messages:
|
for m in messages:
|
||||||
if not m.content:
|
if not m.content:
|
||||||
continue
|
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 "??:??"
|
ts = m.created_at.strftime("%H:%M") if m.created_at else "??:??"
|
||||||
role = m.role.capitalize() if m.role else "Unknown"
|
role_label = role.capitalize()
|
||||||
lines.append(f"[{ts}] {role}: {m.content.strip()}")
|
lines.append(f"[{ts}] {role_label}: {m.content.strip()}")
|
||||||
return "\n".join(lines)
|
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.
|
# Falls back to OLLAMA_MODEL if no background model is configured.
|
||||||
model = await get_setting(user_id, "background_model", "") or Config.OLLAMA_MODEL
|
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)
|
transcript = _format_transcript(messages)
|
||||||
|
|
||||||
user_prompt = (
|
user_prompt = (
|
||||||
|
|||||||
Reference in New Issue
Block a user