diff --git a/src/dialectic/core.py b/src/dialectic/core.py index 786866e1..57964c87 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -105,7 +105,15 @@ class DialecticAgent: { "role": "system", "content": prompts.agent_system_prompt( - observer, observed, observer_peer_card, observed_peer_card + observer, + observed, + observer_peer_card, + observed_peer_card, + available_tools={ + name + for tool in self._select_tools() + if isinstance((name := tool.get("name")), str) + }, ), } ] @@ -303,7 +311,7 @@ class DialecticAgent: if prefetched_observations: user_content = ( f"Query: {query}\n\n" - f"## Relevant Observations (prefetched)\n" + f"## {self._prefetch_heading()}\n" f"{self._prefetch_intro()}\n\n" f"{prefetched_observations}" ) @@ -336,6 +344,10 @@ class DialecticAgent: parent_category="dialectic", ) + def _prefetch_heading(self) -> str: + """Heading for the prefetched block in the user message.""" + return "Relevant Observations (prefetched)" + def _prefetch_intro(self) -> str: """Sentence introducing the prefetched block in the user message.""" return ( diff --git a/src/dialectic/prompts.py b/src/dialectic/prompts.py index 7f98a0e1..5dfe6604 100644 --- a/src/dialectic/prompts.py +++ b/src/dialectic/prompts.py @@ -2,24 +2,138 @@ System prompts for the Dialectic Agent. """ +from collections.abc import Iterable + +# Curated tool docs, keyed by the `name` each loadout actually exposes. +# `_select_tools` filters this set per request (minimal / session allowlist). +_PAIR_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [ + ( + "Memory", + [ + ( + "search_memory", + "Semantic search over conclusions about this pair.", + ), + ( + "get_reasoning_chain", + "Premises and downstream conclusions for a specific conclusion.", + ), + ( + "get_observation_context", + "Messages around a specific conclusion.", + ), + ], + ), + ( + "Conversation", + [ + ( + "search_messages", + "Semantic search over messages in this query's scope.", + ), + ( + "grep_messages", + "Exact text search. Use for names, dates, keywords.", + ), + ( + "get_messages_by_date_range", + "Messages in a time window.", + ), + ( + "search_messages_temporal", + "Semantic search with a date filter.", + ), + ], + ), +] + +_WORKSPACE_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [ + ( + "Discovery", + [ + ( + "get_workspace_stats", + "Counts (peers, sessions, messages), date range, and the most active peers.", + ), + ], + ), + ( + "Memory (pair-scoped — you must name the pair)", + [ + ( + "search_memory", + "Semantic search over conclusions. Requires `observer` and `observed`. For a peer's own representation, set both to the same name. Use different names only when you want one peer's view of another.", + ), + ( + "get_peer_card", + "Biographical summary for a pair. Same observer/observed rule.", + ), + ( + "get_reasoning_chain", + "Premises and downstream conclusions for a specific conclusion.", + ), + ], + ), + ( + "Conversation (workspace-wide — results include `peer_name`)", + [ + ("search_messages", "Semantic search over messages."), + ("grep_messages", "Exact text search."), + ( + "get_observation_context", + "Messages around a specific conclusion.", + ), + ("get_messages_by_date_range", "Messages in a time window."), + ("search_messages_temporal", "Semantic search with a date filter."), + ], + ), +] + +PAIR_PROMPT_TOOLS: frozenset[str] = frozenset( + name for _, items in _PAIR_TOOL_GROUPS for name, _ in items +) +WORKSPACE_PROMPT_TOOLS: frozenset[str] = frozenset( + name for _, items in _WORKSPACE_TOOL_GROUPS for name, _ in items +) + + +def _available_tool_names( + available_tools: Iterable[str] | None, + default: frozenset[str], +) -> frozenset[str]: + if available_tools is None: + return default + return frozenset(available_tools) + + +def _render_tool_groups( + available: frozenset[str], + groups: list[tuple[str, list[tuple[str, str]]]], +) -> str: + parts: list[str] = [] + for heading, items in groups: + lines = [f"- `{name}`: {desc}" for name, desc in items if name in available] + if lines: + parts.append(f"**{heading}**\n" + "\n".join(lines)) + return "\n\n".join(parts) + def agent_system_prompt( observer: str, observed: str, observer_peer_card: list[str] | None, observed_peer_card: list[str] | None, + available_tools: Iterable[str] | None = None, ) -> str: - """ - Generate the agent system prompt for the dialectic agent. + """System prompt for pair-scoped dialectic recall. Args: observer: The peer making the query observed: The peer being queried about observer_peer_card: Biographical information about the observer observed_peer_card: Biographical information about the observed peer - - Returns: - Formatted system prompt string for the agent + available_tools: Tool names offered on this request. Defaults to the + full pair loadout. """ # Determine if we have any peer card data peer_cards_enabled = ( @@ -79,25 +193,25 @@ Peer cards are **constructed summaries** - they are synthesized from the same ob - The peer card is a convenience summary, not a separate source of truth """ - return f""" -You are a helpful and concise context synthesis agent that answers questions about users by gathering relevant information from a memory system. + tools = _available_tool_names(available_tools, PAIR_PROMPT_TOOLS) + tools_section = _render_tool_groups(tools, _PAIR_TOOL_GROUPS) -Always give users the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. You have many tools for gathering context. Search wisely. + return f""" +You are Honcho's dialectic: a recall agent that answers questions from memory about one peer, or about one peer's understanding of another. + +Honcho is a memory system. Applications record conversations; Honcho derives conclusions about the people and agents in them. You are the query interface for one observer/observed pair. You do not speak as a participant. You search memory and synthesize a grounded answer. + +A **peer** is any participant, human or AI. A **session** is a conversation they take part in. A **message** is a raw turn. A **conclusion** (tools may say observation) is a derived or stored fact about a peer, kept in this pair. A **peer card** is a short constructed bio for the pair, synthesized from the same conclusions — a convenience summary, not a separate source of truth. + +Always give the asker the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. Search wisely. {perspective_section} {peer_card_explanation} -## AVAILABLE TOOLS +## TOOLS -**Observation Tools (read):** -- `search_memory`: Semantic search over observations about the peer. Use for specific topics. -- `get_reasoning_chain`: **CRITICAL for grounding answers**. Use this to traverse the reasoning tree for any observation. Shows premises (what it's based on) and conclusions (what depends on it). +Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have. -**Conversation Tools (read):** -- `search_messages`: Semantic search over messages in the session. -- `grep_messages`: Grep for text matches in messages. Use for specific names, dates, keywords. -- `get_observation_context`: Get messages surrounding specific observations. -- `get_messages_by_date_range`: Get messages within a specific time period. -- `search_messages_temporal`: Semantic search with date filtering. +{tools_section} ## WORKFLOW @@ -166,10 +280,6 @@ Always give users the answer *they expect* based on the message history -- the g - Apply user preferences to your response style if relevant - **For enumeration questions**: Before answering, ask yourself "Could there be more items I haven't found?" If you haven't done multiple grep searches AND a semantic search, keep searching -8. **Save novel deductions** (optional): - - If you discovered new insights by combining existing observations - - Use `create_observations_deductive` to save these for future queries - ## CRITICAL: HANDLING CONTRADICTORY INFORMATION As you search, actively watch for contradictions - cases where the user has made conflicting statements: @@ -237,75 +347,62 @@ Do not explain your tool usage - just provide the synthesized answer. """ -def workspace_agent_system_prompt() -> str: - """ - Generate the system prompt for the workspace-level dialectic agent. +def workspace_agent_system_prompt( + available_tools: Iterable[str] | None = None, +) -> str: + """System prompt for workspace-wide dialectic recall.""" + tools = _available_tool_names(available_tools, WORKSPACE_PROMPT_TOOLS) + tools_section = _render_tool_groups(tools, _WORKSPACE_TOOL_GROUPS) + return f""" +You are Honcho's workspace dialectic: a recall agent that answers questions about everyone and everything stored in this workspace. - Uses an analytics-first approach: stats -> message search -> targeted - observations to discover relevant peers rather than listing all of them. +## HONCHO - Returns: - Formatted system prompt string for the workspace agent - """ - return """ -You are a workspace-level analysis agent that can query memory across ALL peers in this workspace. You can synthesize information from any peer relationship's stored conclusions, insights, and conversation history. +Honcho is a memory system. Applications record conversations here; Honcho derives conclusions about the people and agents in those conversations. You are the query interface over one workspace. You do not speak as a participant. You search memory and synthesize a grounded answer. -You do not start anchored to any single peer: discover which peers are relevant first, then query each peer relationship individually to search, compare, and correlate information across them. +## THIS WORKSPACE -## AVAILABLE TOOLS +A workspace is one isolated tenant. Everything you can see belongs to it. Inside it: -**Discovery Tools:** -- `get_workspace_stats`: Get workspace-level counts (peers, sessions, messages), date range, and the most active peers. Use this to orient yourself and discover which peers are relevant. +- **Peer**: any participant, human or AI. Both are first-class. +- **Session**: a conversation that one or more peers take part in. +- **Message**: a raw turn someone said in a session. Messages are the source material. +- **Conclusion** (tools may say observation): a fact Honcho derived, or that was stored, about a peer. Conclusions live in a pair: + - `observer` is whose model this is + - `observed` is who the fact is about + - A peer's own model of themselves is `observer` = `observed` = that peer's name. Most information lives there. + - One peer's model of another is `observer` = Alice, `observed` = Bob. +- **Peer card**: a short constructed bio for a pair, synthesized from the same conclusions. It is a convenience summary, not a separate source of truth. -**Memory Tools (read):** -- `search_memory`: **(PRIMARY TOOL)** Semantic search within a specific peer representation. **Requires `observer` and `observed` parameters.** For a peer's global representation (where most information lives), set observer and observed to the **same** peer name. Only use different observer/observed when seeking one peer's specific understanding of another. -- `get_peer_card`: Get biographical summary for a specific peer relationship. Requires `observer` and `observed` parameters. For a peer's self-representation, use the same name for both. -- `get_reasoning_chain`: Traverse the reasoning tree for any conclusion. Shows premises and derived insights. +You are not bound to any one peer. Discover who is relevant, then query each pair individually. -**Conversation Tools (read):** -- `search_messages`: Semantic search over messages across all sessions. Messages include peer_name, so results reveal which peers discussed a topic. -- `grep_messages`: Exact text search across all messages. -- `get_observation_context`: Get messages surrounding specific conclusions. -- `get_messages_by_date_range`: Get messages within a specific time period. -- `search_messages_temporal`: Semantic search with date filtering. +## TOOLS + +Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have. + +{tools_section} + +Message search is how you find peers the overview missed. Memory search is how you learn about a peer once you know their name. + +If this query is restricted to a session or a set of sessions, message tools already honor that restriction. Peer cards and reasoning chains may be unavailable then, because they span sessions. ## WORKFLOW -1. **Orient yourself**: Workspace stats and the most active peers are provided in your query context. Use `get_workspace_stats` if you need to refresh them, or go straight to message/memory search if the query names specific peers. +1. **Orient**. Scale and the most active peers are already in your query context. Call `get_workspace_stats` only if you need a refresh. If the query names a peer, go straight to that peer. -2. **Discover relevant peers through search**: Use `search_messages` or `grep_messages` to find which peers have discussed the topic. Message results include peer names, making them a powerful discovery layer. +2. **Discover**. If you do not know who is relevant, use `search_messages` or `grep_messages`. Hits carry peer names. -3. **Drill into specific peer representations**: Once you know which peers are relevant, use `search_memory(observer=peer, observed=peer, query=...)` to search their global representation. - - For cross-peer questions, call `search_memory` for each relevant peer's global representation - - Only use different observer/observed when seeking one peer's specific understanding of another +3. **Recall**. For each relevant peer, `search_memory(observer=name, observed=name, query=...)`. For comparisons, search each peer separately, then compare. Only use a mixed observer/observed pair when the question is specifically about one peer's understanding of another. -4. **ALWAYS ATTRIBUTE INFORMATION**: When presenting findings, always indicate which peer the information came from. Example: "According to insights about Alice, she..." or "Bob mentioned that..." +4. **Attribute**. Every fact you state names the peer it is about. If it is a cross-peer view, also name whose model it came from. Example: "Alice is a violinist." / "From Bob's model of Alice, …" -5. **Cross-peer synthesis**: When asked about patterns or commonalities: - - Search each relevant peer pair individually - - Compare findings across peers explicitly - - Note both similarities and differences +5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use. -6. **Synthesize your response**: - - Directly answer the query - - Ground your response in specific information you gathered - - Always attribute information to the specific peer it came from - - For aggregation questions, enumerate findings per peer +## NEVER FABRICATE -## CRITICAL: NEVER FABRICATE INFORMATION +State only what you found. If you have related context but not the asked-for detail, say what you know and what you don't. "I don't have information about X" is the correct answer when memory is empty. Do not guess, hedge-invent, or fill gaps with general knowledge. -- Only state what you found in the memory system -- If you find context but not the specific answer, say what you know and what you don't -- A confident "I don't have information about X" is always correct -- Never invent details or guess +## CONCLUSION LEVELS -## CRITICAL: ATTRIBUTION - -Every piece of information you share must be attributed to the peer it came from. Never present information without indicating its source peer. This is essential for workspace-level queries where information spans multiple peers. - -Do not explain your tool usage - just provide the synthesized answer. - -## OBSERVATION LEVELS - -Observations carry a level: `explicit` observations are derived per-session (session-pure), while higher-level observations (deductive/inductive, produced in dreaming) consolidate across sessions. When synthesizing cross-session or cross-peer answers, prefer higher-level observations and use `get_reasoning_chain` to ground them in their premises. +`explicit` conclusions are derived from a single session. Deductive and inductive conclusions consolidate across sessions. Prefer those for cross-session or cross-peer answers, and use `get_reasoning_chain` to check their premises. """ diff --git a/src/dialectic/workspace.py b/src/dialectic/workspace.py index 7096add6..5383cd75 100644 --- a/src/dialectic/workspace.py +++ b/src/dialectic/workspace.py @@ -65,7 +65,13 @@ class WorkspaceDialecticAgent(DialecticAgent): # Replace the pair-oriented system prompt with the workspace one. self.messages[0] = { "role": "system", - "content": prompts.workspace_agent_system_prompt(), + "content": prompts.workspace_agent_system_prompt( + { + name + for tool in self._select_tools() + if isinstance((name := tool.get("name")), str) + } + ), } # ------------------------------------------------------------------ @@ -125,13 +131,17 @@ class WorkspaceDialecticAgent(DialecticAgent): return format_workspace_stats(stats, peers, cards) + def _prefetch_heading(self) -> str: + return "Workspace overview (prefetched)" + def _prefetch_intro(self) -> str: return ( - "Workspace overview and most-active peers with any known " - "biographical facts. Use this to route: query a specific peer's " - "memory with search_memory (observer and observed set to that " - "peer's name), or use search_messages / get_workspace_stats to " - "discover peers this overview does not cover." + "Workspace scale, the most active peers, and any known " + "biographical facts about them. Use this to decide who is " + "relevant, then search that peer's own representation with " + "search_memory (observer and observed both set to their name), " + "or search_messages / get_workspace_stats to find peers not " + "listed here." ) def _select_tools(self) -> list[dict[str, Any]]: diff --git a/tests/test_dialectic_prompts.py b/tests/test_dialectic_prompts.py new file mode 100644 index 00000000..d926d0bc --- /dev/null +++ b/tests/test_dialectic_prompts.py @@ -0,0 +1,94 @@ +"""Contracts for dialectic system prompts vs the tool loadouts they describe.""" + +import re + +from src.dialectic.core import DialecticAgent +from src.dialectic.prompts import ( + PAIR_PROMPT_TOOLS, + WORKSPACE_PROMPT_TOOLS, + agent_system_prompt, + workspace_agent_system_prompt, +) +from src.dialectic.workspace import WorkspaceDialecticAgent +from src.utils.agent_tools import ( + DIALECTIC_TOOLS, + DIALECTIC_TOOLS_MINIMAL, + TOOLS, + WORKSPACE_DIALECTIC_TOOLS, + WORKSPACE_TOOLS_MINIMAL, +) + +_ALL_TOOL_NAMES = {spec["name"] for spec in TOOLS.values()} + + +def _loadout_names(tools: list[dict[str, object]]) -> set[str]: + return {name for tool in tools if isinstance((name := tool.get("name")), str)} + + +def _mentioned_tools(text: str) -> set[str]: + return { + match + for match in re.findall(r"`([a-z_][a-z0-9_]*)`", text) + if match in _ALL_TOOL_NAMES + } + + +def _tools_catalog(prompt: str) -> str: + start = prompt.index("## TOOLS") + rest = prompt[start:] + next_heading = rest.find("\n## ", 1) + return rest if next_heading == -1 else rest[:next_heading] + + +class TestPromptLoadouts: + def test_pair_docs_match_dialectic_tools(self) -> None: + assert _loadout_names(DIALECTIC_TOOLS) == PAIR_PROMPT_TOOLS + + def test_workspace_docs_match_workspace_tools(self) -> None: + assert _loadout_names(WORKSPACE_DIALECTIC_TOOLS) == WORKSPACE_PROMPT_TOOLS + + def test_catalog_lists_only_offered_workspace_tools(self) -> None: + offered = _loadout_names(WORKSPACE_TOOLS_MINIMAL) + catalog = _tools_catalog(workspace_agent_system_prompt(offered)) + assert _mentioned_tools(catalog) == offered + + +class TestPairAgentPrompt: + def test_teaches_honcho_world_without_workspace_sibling(self) -> None: + prompt = agent_system_prompt("alice", "alice", None, None).lower() + assert "workspace dialectic" not in prompt + assert "peer-level" not in prompt + for term in ("honcho", "peer", "session", "message", "conclusion"): + assert term in prompt + + def test_does_not_offer_removed_write_tools(self) -> None: + prompt = agent_system_prompt("alice", "alice", None, None) + assert "create_observations_deductive" not in prompt + assert "create_observations" not in prompt + + def test_agent_lists_selected_tools(self) -> None: + agent = DialecticAgent( + workspace_name="w", + session_name=None, + observer="alice", + observed="alice", + reasoning_level="minimal", + ) + offered = _loadout_names(DIALECTIC_TOOLS_MINIMAL) + catalog = _tools_catalog(agent.messages[0]["content"]) + assert _mentioned_tools(catalog) == offered + assert agent.messages[0]["content"] == agent_system_prompt( + "alice", "alice", None, None, available_tools=offered + ) + + +class TestWorkspaceAgentPrompt: + def test_minimal_agent_matches_filtered_prompt(self) -> None: + agent = WorkspaceDialecticAgent(workspace_name="w", reasoning_level="minimal") + offered = _loadout_names(WORKSPACE_TOOLS_MINIMAL) + prompt = agent.messages[0]["content"] + assert prompt == workspace_agent_system_prompt(offered) + catalog = _tools_catalog(prompt) + assert _mentioned_tools(catalog) == offered + assert "get_peer_card" not in catalog + assert "get_reasoning_chain" not in catalog diff --git a/tests/test_workspace_chat.py b/tests/test_workspace_chat.py index 8b73c4a7..daed20e8 100644 --- a/tests/test_workspace_chat.py +++ b/tests/test_workspace_chat.py @@ -1220,3 +1220,36 @@ async def test_workspace_prefetch_failure_degrades_to_none( result = await agent._prefetch_relevant_observations("q") # pyright: ignore[reportPrivateUsage] assert result is None + + +class TestWorkspaceChatPrompt: + def test_teaches_honcho_world_without_sibling_agent(self) -> None: + from src.dialectic.prompts import workspace_agent_system_prompt + + prompt = workspace_agent_system_prompt().lower() + assert "peer-level" not in prompt + assert "unlike a peer" not in prompt + for term in ( + "honcho", + "workspace", + "peer", + "session", + "message", + "conclusion", + "observer", + "observed", + ): + assert term in prompt + + def test_agent_uses_workspace_prompt_and_prefetch_heading(self) -> None: + from src.dialectic.prompts import workspace_agent_system_prompt + from src.dialectic.workspace import WorkspaceDialecticAgent + + agent = WorkspaceDialecticAgent(workspace_name="w") + offered = { + name + for tool in agent._select_tools() # pyright: ignore[reportPrivateUsage] + if isinstance((name := tool.get("name")), str) + } + assert agent.messages[0]["content"] == workspace_agent_system_prompt(offered) + assert agent._prefetch_heading() == "Workspace overview (prefetched)" # pyright: ignore[reportPrivateUsage]