diff --git a/src/dialectic/core.py b/src/dialectic/core.py index 57964c87..95fe4cb5 100644 --- a/src/dialectic/core.py +++ b/src/dialectic/core.py @@ -14,7 +14,12 @@ from nanoid import generate as generate_nanoid from pydantic import BaseModel from src import crud -from src.config import ConfiguredModelSettings, ReasoningLevel, settings +from src.config import ( + ConfiguredModelSettings, + DialecticLevelSettings, + ReasoningLevel, + settings, +) from src.dependencies import tracked_db from src.dialectic import prompts from src.embedding_client import embedding_client @@ -139,6 +144,20 @@ class DialecticAgent: tools = [t for t in tools if t.get("name") != "get_reasoning_chain"] return tools + def _tool_choice( + self, level_settings: DialecticLevelSettings + ) -> str | dict[str, Any] | None: + """Pick the tool_choice for this query. + + Defaults to whatever the reasoning level configures. Subclasses override + when the agent has no prefetched corpus to fall back on and so must + search before it can answer. Forcing "required"/"any" here costs exactly + one tool round rather than pinning the loop: `execute_tool_loop` relaxes + it to "auto" after the first iteration so the model can still stop and + synthesize. + """ + return level_settings.TOOL_CHOICE + async def _initialize_session_history(self) -> None: """Fetch and inject session history into the system prompt if configured.""" if self._session_history_initialized: @@ -505,7 +524,7 @@ class DialecticAgent: prompt="", # Ignored since we pass messages max_tokens=max_tokens, tools=tools, - tool_choice=level_settings.TOOL_CHOICE, + tool_choice=self._tool_choice(level_settings), tool_executor=tool_executor, max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, messages=self.messages, @@ -581,7 +600,7 @@ class DialecticAgent: stream=True, stream_final_only=True, tools=tools, - tool_choice=level_settings.TOOL_CHOICE, + tool_choice=self._tool_choice(level_settings), tool_executor=tool_executor, max_tool_iterations=level_settings.MAX_TOOL_ITERATIONS, messages=self.messages, diff --git a/src/dialectic/prompts.py b/src/dialectic/prompts.py index 5dfe6604..4d2fbc70 100644 --- a/src/dialectic/prompts.py +++ b/src/dialectic/prompts.py @@ -396,7 +396,11 @@ If this query is restricted to a session or a set of sessions, message tools alr 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. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use. +5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use, and do not describe a search you did not run. + +## NO CLARIFYING QUESTIONS + +Your answer goes to a program, not to someone who can reply. No one will answer a question you ask, approve a plan you propose, or pick from options you offer — your response ends the exchange. So never ask which lookup to run, never lay out a plan and stop, never present a menu. Run the searches yourself and answer from what they return. Empty results are a complete answer; an unanswered question is not. ## NEVER FABRICATE diff --git a/src/dialectic/workspace.py b/src/dialectic/workspace.py index 5383cd75..1a93d300 100644 --- a/src/dialectic/workspace.py +++ b/src/dialectic/workspace.py @@ -20,7 +20,7 @@ from collections.abc import Callable from typing import Any from src import crud -from src.config import ReasoningLevel, settings +from src.config import DialecticLevelSettings, ReasoningLevel, settings from src.dependencies import tracked_db from src.dialectic import prompts from src.dialectic.core import DialecticAgent @@ -161,6 +161,30 @@ class WorkspaceDialecticAgent(DialecticAgent): tools = [t for t in tools if t.get("name") not in unscopable] return tools + def _tool_choice( + self, level_settings: DialecticLevelSettings + ) -> str | dict[str, Any] | None: + """Require a tool call on the first turn. + + The pair agent prefetches the observations relevant to its query, so it + can legitimately answer from context alone. This agent's prefetch is an + orientation overview — scale, active peers, their cards — not the corpus. + Left free to skip tools, the model treats that overview as everything it + has: it answers when the overview happens to carry the fact, and + otherwise writes out the search it should have run and asks the caller + which option to take. Workspace chat has no caller to answer, so that + response is dead on arrival. + + Recall is the job, so make the first search mandatory and let the loop + relax to "auto" afterwards. Any other value a level configures is passed + through untouched, so this only overrides the two cases that let the + model opt out entirely. + """ + choice = level_settings.TOOL_CHOICE + if choice is None or choice == "auto": + return "required" + return choice + async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]: return await create_workspace_tool_executor( workspace_name=self.workspace_name, diff --git a/tests/test_workspace_chat.py b/tests/test_workspace_chat.py index daed20e8..75462b17 100644 --- a/tests/test_workspace_chat.py +++ b/tests/test_workspace_chat.py @@ -9,7 +9,7 @@ import asyncio import json from collections.abc import Callable from contextlib import asynccontextmanager -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from typing import Any @@ -82,7 +82,7 @@ async def workspace_test_data( await db_session.flush() # Create messages - now = datetime.now(timezone.utc) + now = datetime.now(UTC) messages: list[models.Message] = [] for i in range(6): peer_name = [peer1.name, peer2.name, peer3.name][i % 3] @@ -593,7 +593,7 @@ class TestSearchMemoryWorkspace: content="I really like programming in Python", seq_in_session=1, token_count=10, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) db_session.add(msg) await db_session.flush() @@ -919,7 +919,7 @@ class TestGetObservationContextWorkspace: content="LEAKED_FROM_OTHER_SESSION", seq_in_session=messages[0].seq_in_session, token_count=10, - created_at=datetime.now(timezone.utc), + created_at=datetime.now(UTC), ) db_session.add(leaked_message) await db_session.commit() @@ -1253,3 +1253,63 @@ class TestWorkspaceChatPrompt: } assert agent.messages[0]["content"] == workspace_agent_system_prompt(offered) assert agent._prefetch_heading() == "Workspace overview (prefetched)" # pyright: ignore[reportPrivateUsage] + + def test_forbids_clarifying_questions(self) -> None: + """The endpoint is non-interactive, so the prompt must say so. + + Without this the model answers a recall query with a plan and a menu of + lookups for a caller that cannot reply. The pair agent talks to a peer + and is deliberately left alone. + """ + from src.dialectic.prompts import ( + agent_system_prompt, + workspace_agent_system_prompt, + ) + + prompt = workspace_agent_system_prompt() + assert "NO CLARIFYING QUESTIONS" in prompt + assert "NO CLARIFYING QUESTIONS" not in agent_system_prompt( + "alice", "alice", None, None + ) + + +class TestWorkspaceToolChoice: + """The workspace agent must search before it answers. + + Its prefetch is an orientation overview, not the corpus, so a turn with no + tool call ends the loop with whatever the overview happened to contain. + """ + + @pytest.mark.parametrize("level", ["minimal", "low", "medium", "high", "max"]) + def test_first_turn_requires_a_tool_call(self, level: str) -> None: + from src.config import settings + from src.dialectic.workspace import WorkspaceDialecticAgent + + agent = WorkspaceDialecticAgent(workspace_name="w", reasoning_level=level) # pyright: ignore[reportArgumentType] + level_settings = settings.DIALECTIC.LEVELS[level] # pyright: ignore[reportArgumentType] + assert agent._tool_choice(level_settings) == "required" # pyright: ignore[reportPrivateUsage] + + def test_pair_agent_keeps_the_configured_choice(self) -> None: + from src.config import settings + from src.dialectic.core import DialecticAgent + + agent = DialecticAgent( + workspace_name="w", session_name=None, observer="a", observed="a" + ) + level_settings = settings.DIALECTIC.LEVELS["low"] + assert ( + agent._tool_choice(level_settings) # pyright: ignore[reportPrivateUsage] + == level_settings.TOOL_CHOICE + ) + + def test_a_configured_non_auto_choice_is_passed_through(self) -> None: + from src.config import DialecticLevelSettings, settings + from src.dialectic.workspace import WorkspaceDialecticAgent + + agent = WorkspaceDialecticAgent(workspace_name="w") + pinned = DialecticLevelSettings( + MODEL_CONFIG=settings.DIALECTIC.LEVELS["low"].MODEL_CONFIG, + MAX_TOOL_ITERATIONS=5, + TOOL_CHOICE="none", + ) + assert agent._tool_choice(pinned) == "none" # pyright: ignore[reportPrivateUsage]