fleet-memory/hindsight-api/hindsight_api/engine/reflect/prompts.py
Nicolò Boschi 63a65d0723
feat: improve mental model refresh and add directives (#166)
* feat: improve mental model refresh and add directives

* feat: improve mental model refresh and add directives

* tags

* ui

* fix

* fix

* update

* update
2026-01-19 11:38:35 +01:00

762 lines
29 KiB
Python

"""
System prompts for the reflect agent.
"""
import json
from typing import Any
def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]:
"""
Extract directive rules as a list of strings.
Args:
directives: List of directive mental models with observations
Returns:
List of directive rule strings
"""
rules = []
for directive in directives:
directive_name = directive.get("name", "")
observations = directive.get("observations", [])
if observations:
for obs in observations:
# Support both Pydantic Observation objects and dicts
if hasattr(obs, "title"):
title = obs.title
content = obs.content
else:
title = obs.get("title", "")
content = obs.get("content", "")
if title and content:
rules.append(f"**{title}**: {content}")
elif content:
rules.append(content)
elif directive_name:
# Fallback to description if no observations
desc = directive.get("description", "")
if desc:
rules.append(f"**{directive_name}**: {desc}")
return rules
def build_directives_section(directives: list[dict[str, Any]]) -> str:
"""
Build the directives section for the system prompt.
Directives are hard rules that MUST be followed in all responses.
Args:
directives: List of directive mental models with observations
"""
if not directives:
return ""
rules = _extract_directive_rules(directives)
if not rules:
return ""
parts = [
"## DIRECTIVES (MANDATORY)",
"These are hard rules you MUST follow in ALL responses:",
"",
]
for rule in rules:
parts.append(f"- {rule}")
parts.extend(
[
"",
"NEVER violate these directives, even if other context suggests otherwise.",
"IMPORTANT: Do NOT explain or justify how you handled directives in your answer. Just follow them silently.",
"",
]
)
return "\n".join(parts)
def build_directives_reminder(directives: list[dict[str, Any]]) -> str:
"""
Build a reminder section for directives to place at the end of the prompt.
Args:
directives: List of directive mental models with observations
"""
if not directives:
return ""
rules = _extract_directive_rules(directives)
if not rules:
return ""
parts = [
"",
"## REMINDER: MANDATORY DIRECTIVES",
"Before responding, ensure your answer complies with ALL of these directives:",
"",
]
for i, rule in enumerate(rules, 1):
parts.append(f"{i}. {rule}")
parts.append("")
parts.append("Your response will be REJECTED if it violates any directive above.")
parts.append("Do NOT include any commentary about how you handled directives - just follow them.")
return "\n".join(parts)
def build_system_prompt_for_tools(
bank_profile: dict[str, Any],
context: str | None = None,
directives: list[dict[str, Any]] | None = None,
) -> str:
"""
Build the system prompt for tool-calling reflect agent.
This is a simplified prompt since tools are defined separately via the tools parameter.
Args:
bank_profile: Bank profile with name and mission
context: Optional additional context
directives: Optional list of directive mental models to inject as hard rules
"""
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
no_info_rule = (
"- Only say 'I don't have information' AFTER trying list_mental_models AND recall with no relevant results"
)
parts = []
# Inject directives at the VERY START for maximum prominence
if directives:
parts.append(build_directives_section(directives))
parts.extend(
[
"You are a reflection agent that answers questions by reasoning over retrieved memories.",
"",
]
)
parts.extend(
[
"## CRITICAL RULES",
"- You must NEVER fabricate information that has no basis in retrieved data",
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
"- You MUST call recall() before saying you don't have information",
no_info_rule,
"",
"## How to Reason",
"- If memories mention someone did an activity, you can infer they likely enjoyed it",
"- Synthesize a coherent narrative from related memories",
"- Be a thoughtful interpreter, not just a literal repeater",
"- When the exact answer isn't stated, use what IS stated to give the best answer",
"",
"## Query Strategy (IMPORTANT)",
"recall() uses semantic search. NEVER just echo the user's question - decompose it into targeted searches:",
"",
"BAD: User asks 'recurring lesson themes between students' → recall('recurring lesson themes between students')",
"GOOD: Break it down into component searches:",
" 1. recall('lessons') - find all lesson-related memories",
" 2. recall('teaching sessions') - alternative phrasing",
" 3. recall('student progress') - find student-related memories",
" 4. recall('topics taught') - find subject matter",
"",
"Think: What ENTITIES and CONCEPTS does this question involve? Search for each separately.",
"- Questions about patterns → search for the individual instances first",
"- Questions comparing things → search for each thing separately",
"- Questions about relationships → search for each party involved",
"",
"## Workflow",
]
)
# Answer mode: include mental model lookup in workflow
parts.extend(
[
"1. Review the pre-fetched mental models for relevant synthesized knowledge",
"2. If relevant, call get_mental_model(model_id) for full observations",
"3. DECOMPOSE the question into component searches (see Query Strategy above)",
" - Identify entities and concepts in the question",
" - Search for each separately with targeted queries",
"4. Run multiple recall() calls - don't just echo the user's question",
"5. Use expand() if you need more context on specific memories",
"6. BEFORE answering: Check if any person/project/concept from the memories deserves a mental model - use learn() if so",
"7. When ready, call done() with your answer and supporting memory_ids",
"",
"## When to Use learn() - IMPORTANT",
"ACTIVELY look for opportunities to use learn() when you discover:",
"- A person mentioned in 2+ memories who has no mental model yet",
"- A project or concept the user asks about that has no mental model",
"- A pattern or topic worth tracking for future questions",
"",
"DO NOT wait to be asked - proactively create models when you see the need.",
"Example: learn(name='Project Alpha', description='Track goals, status, and key decisions for Project Alpha')",
"",
"## Output Format: Plain Text Answer",
"Call done() with a plain text 'answer' field.",
"- Do NOT use markdown formatting",
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
"- Put memory IDs ONLY in the memory_ids array parameter, not in the answer",
]
)
parts.append("")
parts.append(f"## Memory Bank: {name}")
if mission:
parts.append(f"Mission: {mission}")
# Disposition traits
disposition = bank_profile.get("disposition", {})
if disposition:
traits = []
if "skepticism" in disposition:
traits.append(f"skepticism={disposition['skepticism']}")
if "literalism" in disposition:
traits.append(f"literalism={disposition['literalism']}")
if "empathy" in disposition:
traits.append(f"empathy={disposition['empathy']}")
if traits:
parts.append(f"Disposition: {', '.join(traits)}")
if context:
parts.append(f"\n## Additional Context\n{context}")
# Add directive reminder at the END for recency effect
if directives:
parts.append(build_directives_reminder(directives))
return "\n".join(parts)
def build_agent_prompt(
query: str,
context_history: list[dict],
bank_profile: dict,
additional_context: str | None = None,
) -> str:
"""Build the user prompt for the reflect agent."""
parts = []
# Bank identity
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
parts.append(f"## Memory Bank Context\nName: {name}")
if mission:
parts.append(f"Mission: {mission}")
# Disposition traits if present
disposition = bank_profile.get("disposition", {})
if disposition:
traits = []
if "skepticism" in disposition:
traits.append(f"skepticism={disposition['skepticism']}")
if "literalism" in disposition:
traits.append(f"literalism={disposition['literalism']}")
if "empathy" in disposition:
traits.append(f"empathy={disposition['empathy']}")
if traits:
parts.append(f"Disposition: {', '.join(traits)}")
# Additional context from caller
if additional_context:
parts.append(f"\n## Additional Context\n{additional_context}")
# Tool call history
if context_history:
parts.append("\n## Tool Results (synthesize and reason from this data)")
for i, entry in enumerate(context_history, 1):
tool = entry["tool"]
output = entry["output"]
# Format as proper JSON for LLM readability
try:
output_str = json.dumps(output, indent=2, default=str)
except (TypeError, ValueError):
output_str = str(output)
parts.append(f"\n### Call {i}: {tool}\n```json\n{output_str}\n```")
# The question
parts.append(f"\n## Question\n{query}")
# Instructions
if context_history:
parts.append(
"\n## Instructions\n"
"Based on the tool results above, either call more tools or provide your final answer. "
"Synthesize and reason from the data - make reasonable inferences when helpful. "
"If you have related information, use it to give the best possible answer."
)
else:
parts.append(
"\n## Instructions\n"
"Start by calling list_mental_models() to see available mental models - they contain pre-synthesized knowledge. "
"If a relevant model exists, use get_mental_model(model_id) to get its observations. "
"Then use recall(query) for specific details not covered by mental models."
)
return "\n".join(parts)
def build_final_prompt(
query: str,
context_history: list[dict],
bank_profile: dict,
additional_context: str | None = None,
) -> str:
"""Build the final prompt when forcing a text response (no tools)."""
parts = []
# Bank identity
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
parts.append(f"## Memory Bank Context\nName: {name}")
if mission:
parts.append(f"Mission: {mission}")
# Disposition traits if present
disposition = bank_profile.get("disposition", {})
if disposition:
traits = []
if "skepticism" in disposition:
traits.append(f"skepticism={disposition['skepticism']}")
if "literalism" in disposition:
traits.append(f"literalism={disposition['literalism']}")
if "empathy" in disposition:
traits.append(f"empathy={disposition['empathy']}")
if traits:
parts.append(f"Disposition: {', '.join(traits)}")
# Additional context from caller
if additional_context:
parts.append(f"\n## Additional Context\n{additional_context}")
# Tool call history
if context_history:
parts.append("\n## Retrieved Data (synthesize and reason from this data)")
for entry in context_history:
tool = entry["tool"]
output = entry["output"]
# Format as proper JSON for LLM readability
try:
output_str = json.dumps(output, indent=2, default=str)
except (TypeError, ValueError):
output_str = str(output)
parts.append(f"\n### From {tool}:\n```json\n{output_str}\n```")
else:
parts.append("\n## Retrieved Data\nNo data was retrieved.")
# The question
parts.append(f"\n## Question\n{query}")
# Final instructions
parts.append(
"\n## Instructions\n"
"Provide a thoughtful answer by synthesizing and reasoning from the retrieved data above. "
"You can make reasonable inferences from the memories, but don't completely fabricate information."
"If the exact answer isn't stated, use what IS stated to give the best possible answer. "
"Only say 'I don't have information' if the retrieved data is truly unrelated to the question."
)
return "\n".join(parts)
FINAL_SYSTEM_PROMPT = """You are a thoughtful assistant that synthesizes answers from retrieved memories.
Your approach:
- Reason over the retrieved memories to answer the question
- Make reasonable inferences when the exact answer isn't explicitly stated
- Connect related memories to form a complete picture
- Be helpful - if you have related information, use it to give the best possible answer
Only say "I don't have information" if the retrieved data is truly unrelated to the question.
Do NOT fabricate information that has no basis in the retrieved data."""
# =============================================================================
# 4-Phase Mental Model Reflect Prompts
# =============================================================================
SEED_PHASE_SYSTEM_PROMPT = """You are analyzing memories to discover NEW patterns and generate candidate observations.
Your task is to identify potential observations (beliefs, preferences, patterns, behaviors) that could be part of a mental model about this person/topic.
## Important: Avoid Redundancy
If existing observations are provided, DO NOT generate candidates that are essentially the same.
Focus on discovering NEW patterns not already covered by existing observations.
## Rules
- Generate 5-15 candidate observations for NEW patterns only
- Each candidate should be specific and testable (can be supported or contradicted by evidence)
- Note which memory IDs inspired each candidate (these are seeds, not final evidence)
- Focus on patterns that appear MULTIPLE TIMES across many memories - the more the better
- The best candidates are ones you can find 10, 20, or even 50+ supporting memories for
- Skip patterns that are already covered by existing observations
## Output Format
Return a JSON array of candidate observations:
```json
{
"candidates": [
{
"content": "The specific observation/belief/pattern - be detailed and specific",
"seed_memory_ids": ["memory_id_1", "memory_id_2", "memory_id_3"]
}
]
}
```
Focus on patterns that appear multiple times or have strong signals. Don't generate obvious or trivial observations.
Prefer candidates with MORE seed memories - they're more likely to be real patterns.
Return an empty candidates array if no genuinely new patterns are found."""
def build_seed_phase_prompt(
memories: list[dict],
topic: str | None = None,
existing_observations: list[dict] | None = None,
) -> str:
"""Build the user prompt for the seed phase.
Args:
memories: List of memories to analyze
topic: Optional topic focus for the mental model
existing_observations: Optional list of existing observations to avoid rediscovering
"""
parts = []
if topic:
parts.append(f"## Topic Focus\n{topic}\n")
# Include existing observations so we don't rediscover them
if existing_observations:
parts.append("## Existing Observations (DO NOT regenerate these)")
parts.append("These patterns are already tracked. Focus on discovering NEW patterns:\n")
for i, obs in enumerate(existing_observations, 1):
title = obs.get("title", "")
content = obs.get("content", "")
parts.append(f"{i}. **{title}**: {content}\n")
parts.append("")
parts.append("## Memories to Analyze")
parts.append("Review these memories and identify patterns, preferences, beliefs, and behaviors:\n")
for mem in memories:
mem_id = mem.get("id", "unknown")
content = mem.get("content", mem.get("text", ""))
timestamp = mem.get("timestamp", mem.get("created_at", ""))
parts.append(f"[{mem_id}] ({timestamp}): {content}\n")
parts.append("\n## Instructions")
if existing_observations:
parts.append("Generate candidate observations for NEW patterns not already covered above.")
parts.append("If all patterns are already covered by existing observations, return an empty candidates array.")
else:
parts.append("Generate candidate observations based on patterns you see in these memories.")
parts.append("Look for: recurring themes, stated preferences, behavioral patterns, beliefs, values, goals.")
return "\n".join(parts)
VALIDATE_PHASE_SYSTEM_PROMPT = """You are validating candidate observations against evidence.
For each candidate, you have:
- Supporting memories (evidence FOR the observation)
- Contradicting memories (evidence AGAINST the observation)
## Your Task
1. Evaluate each candidate based on the evidence
2. For valid candidates, extract EXACT QUOTES from supporting memories
3. Discard candidates with insufficient or contradicting evidence
4. Merge similar candidates into single, refined observations
## Rules for Quotes
- Quotes must be EXACT text from the memory, not paraphrased
- Each quote should directly support the observation
- The MORE evidence quotes, the BETTER - don't limit yourself, include ALL relevant quotes (10, 20, 50+)
- Observations with only 1-2 quotes are weak and should be discarded unless the evidence is exceptionally strong
- Stronger observations have more supporting evidence - aim for comprehensive coverage
## Output Format
Return validated observations with evidence:
```json
{
"observations": [
{
"title": "Short descriptive title (3-8 words) - like a headline",
"content": "The full observation content - detailed explanation of the pattern/belief",
"evidence": [
{
"memory_id": "exact_memory_id",
"quote": "Exact quote from the memory text",
"relevance": "Brief explanation of how this supports the observation",
"timestamp": "2024-01-15T10:00:00Z"
}
]
}
],
"discarded": [
{
"content": "The discarded candidate",
"reason": "Why it was discarded (insufficient evidence, contradicted, etc.)"
}
],
"merged": [
{
"from": ["candidate 1 content", "candidate 2 content"],
"into": "The merged observation content"
}
]
}
```
## Title Guidelines
- Title should be a SHORT label (like "Prefers morning meetings" or "Coffee enthusiast")
- NOT a truncated version of the content
- Think of it as a category/tag for the observation
Be rigorous: only keep observations with clear, verifiable evidence from multiple memories."""
def build_validate_phase_prompt(candidates_with_evidence: list[dict]) -> str:
"""Build the user prompt for the validate phase."""
parts = ["## Candidates to Validate\n"]
for i, item in enumerate(candidates_with_evidence, 1):
candidate = item.get("candidate", {})
supporting = item.get("supporting_memories", [])
contradicting = item.get("contradicting_memories", [])
parts.append(f"### Candidate {i}: {candidate.get('content', '')}")
if supporting:
parts.append("\n**Supporting Evidence:**")
for mem in supporting:
mem_id = mem.get("id", "unknown")
content = mem.get("content", mem.get("text", ""))
timestamp = mem.get("timestamp", mem.get("created_at", ""))
parts.append(f"- [{mem_id}] ({timestamp}): {content}")
if contradicting:
parts.append("\n**Contradicting Evidence:**")
for mem in contradicting:
mem_id = mem.get("id", "unknown")
content = mem.get("content", mem.get("text", ""))
timestamp = mem.get("timestamp", mem.get("created_at", ""))
parts.append(f"- [{mem_id}] ({timestamp}): {content}")
if not supporting and not contradicting:
parts.append("\n*No additional evidence found*")
parts.append("")
parts.append("## Instructions")
parts.append("1. Evaluate each candidate based on its evidence")
parts.append("2. Keep candidates with strong supporting evidence")
parts.append("3. Discard candidates with no evidence or strong contradictions")
parts.append("4. Merge similar candidates")
parts.append("5. Extract EXACT quotes (copy-paste from memory text) for evidence")
return "\n".join(parts)
COMPARE_PHASE_SYSTEM_PROMPT = """You are merging new observations with an existing mental model.
You have:
- EXISTING observations (from the current mental model)
- NEW observations (from this reflect cycle)
## Your Task
Produce the final, complete mental model by:
1. Keeping existing observations that are still valid
2. Updating existing observations with new evidence (ADD new evidence to existing)
3. Adding new observations that don't overlap with existing
4. Removing existing observations that are contradicted by new evidence
5. Merging overlapping observations
## Rules
- The final model should have no contradictions
- Each observation must have evidence with exact quotes
- COMBINE evidence from both existing and new observations
- If an existing observation has new supporting evidence, ADD ALL the new evidence to it
- Include ALL relevant evidence - the more quotes the better (10, 20, 50+ is great)
- Observations with more evidence are more reliable - don't limit the number of quotes
## Output Format
Return the complete, final mental model:
```json
{
"observations": [
{
"title": "Short descriptive title (3-8 words)",
"content": "Full observation content - detailed explanation",
"evidence": [
{
"memory_id": "id",
"quote": "exact quote",
"relevance": "explanation",
"timestamp": "ISO timestamp"
}
],
"created_at": "ISO timestamp of when observation was first created"
}
],
"changes": {
"kept": ["Observation that was kept unchanged"],
"updated": [{"from": "old content", "to": "new content", "reason": "why"}],
"added": ["New observation that was added"],
"removed": [{"content": "removed observation", "reason": "why removed"}],
"merged": [{"from": ["obs1", "obs2"], "into": "merged observation"}]
}
}
```"""
def build_compare_phase_prompt(
existing_observations: list[dict],
new_observations: list[dict],
) -> str:
"""Build the user prompt for the compare phase."""
parts = []
parts.append("## Existing Mental Model Observations")
if existing_observations:
for i, obs in enumerate(existing_observations, 1):
title = obs.get("title", "")
content = obs.get("content", obs.get("text", ""))
evidence = obs.get("evidence", [])
parts.append(f"\n### Existing {i}: {title}")
parts.append(f"Content: {content}")
if evidence:
parts.append(f"Evidence ({len(evidence)} items):")
for ev in evidence[:5]: # Show max 5 evidence items
parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"')
if len(evidence) > 5:
parts.append(f" ... and {len(evidence) - 5} more")
else:
parts.append("*No existing observations*")
parts.append("\n## New Observations from This Reflect")
if new_observations:
for i, obs in enumerate(new_observations, 1):
title = obs.get("title", "")
content = obs.get("content", "")
evidence = obs.get("evidence", [])
parts.append(f"\n### New {i}: {title}")
parts.append(f"Content: {content}")
if evidence:
parts.append(f"Evidence ({len(evidence)} items):")
for ev in evidence:
parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"')
else:
parts.append("*No new observations*")
parts.append("\n## Instructions")
parts.append("Merge these into a coherent, non-contradictory mental model.")
parts.append("Preserve all valid evidence. Remove stale or contradicted observations.")
return "\n".join(parts)
# =============================================================================
# UPDATE EXISTING Phase Prompts (for diff-based refresh)
# =============================================================================
UPDATE_EXISTING_SYSTEM_PROMPT = """You are updating existing observations with newly found evidence.
For each existing observation, you have been given:
- The original observation (title, content, existing evidence)
- Newly found supporting memories
- Newly found contradicting memories
## Your Task
1. Extract EXACT QUOTES from new supporting memories to add to the observation
2. Flag observations with strong contradicting evidence for potential removal
3. Keep existing evidence intact - only ADD new evidence
## Rules for Quotes
- Quotes must be EXACT text from the memory, not paraphrased
- Each quote should directly support the observation
- Include ALL relevant quotes from the new memories
## Output Format
Return updated observations with new evidence:
```json
{
"updated_observations": [
{
"title": "Original title",
"content": "Original content",
"existing_evidence_count": 5,
"new_evidence": [
{
"memory_id": "exact_memory_id",
"quote": "Exact quote from the memory text",
"relevance": "Brief explanation of how this supports the observation",
"timestamp": "2024-01-15T10:00:00Z"
}
],
"has_contradiction": false,
"contradiction_note": null
}
]
}
```
If an observation has strong contradicting evidence, set has_contradiction=true and explain in contradiction_note."""
def build_update_existing_prompt(observations_with_evidence: list[dict]) -> str:
"""Build the user prompt for the update existing phase.
Args:
observations_with_evidence: List of existing observations with new evidence found
"""
parts = ["## Existing Observations to Update\n"]
for i, item in enumerate(observations_with_evidence, 1):
obs = item.get("observation", {})
supporting = item.get("supporting_memories", [])
contradicting = item.get("contradicting_memories", [])
title = obs.get("title", "")
content = obs.get("content", "")
existing_evidence = obs.get("evidence", [])
parts.append(f"### Observation {i}: {title}")
parts.append(f"Content: {content}")
parts.append(f"Existing evidence count: {len(existing_evidence)}")
if supporting:
parts.append("\n**New Supporting Memories:**")
for mem in supporting:
mem_id = mem.get("id", "unknown")
mem_content = mem.get("content", mem.get("text", ""))
timestamp = mem.get("timestamp", mem.get("created_at", ""))
parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}")
if contradicting:
parts.append("\n**New Contradicting Memories:**")
for mem in contradicting:
mem_id = mem.get("id", "unknown")
mem_content = mem.get("content", mem.get("text", ""))
timestamp = mem.get("timestamp", mem.get("created_at", ""))
parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}")
if not supporting and not contradicting:
parts.append("\n*No new evidence found*")
parts.append("")
parts.append("## Instructions")
parts.append("1. Extract EXACT quotes from new supporting memories")
parts.append("2. Flag observations with strong contradictions")
parts.append("3. Return the updated observations with new evidence added")
return "\n".join(parts)