From 78fe4ee66f00ce39669815001d3438d24ecb1d8a Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Tue, 25 Aug 2026 11:07:29 -0400 Subject: [PATCH] fix(dialectic): revamp workspace and pair chat system prompts Teach both agents what Honcho, peers, and the harness are instead of comparing them to each other. Render only the tools the request actually offers, and drop the pair prompt's call to a write tool that is not in the loadout. --- src/dialectic/core.py | 16 ++- src/dialectic/prompts.py | 247 ++++++++++++++++++++++---------- src/dialectic/workspace.py | 22 ++- tests/test_dialectic_prompts.py | 94 ++++++++++++ tests/test_workspace_chat.py | 33 +++++ 5 files changed, 329 insertions(+), 83 deletions(-) create mode 100644 tests/test_dialectic_prompts.py 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]