219 lines
9.1 KiB
Python
219 lines
9.1 KiB
Python
"""Workspace-level dialectic agent.
|
|
|
|
Answers queries across ALL peers in a workspace. Where DialecticAgent is
|
|
bound to a single (observer, observed) pair, this agent routes first —
|
|
workspace stats, active peers, and peer cards are prefetched for
|
|
orientation; message search is workspace-flat and reveals which peers
|
|
discussed a topic — and then recalls through the same pair-scoped
|
|
observation machinery, supplying the pair as tool arguments.
|
|
|
|
Observation search deliberately stays pair-scoped: it matches both the
|
|
(observer, observed) collection ownership and the per-pair vector-store
|
|
namespaces, and avoids retrieval dilution from a workspace-flat top-k.
|
|
|
|
Design carried over from plastic-labs/honcho#373 (Dan), re-grown on the
|
|
current DialecticAgent seams instead of a base-class extraction.
|
|
"""
|
|
|
|
import logging
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
from src import crud
|
|
from src.config import DialecticLevelSettings, ReasoningLevel, settings
|
|
from src.dependencies import tracked_db
|
|
from src.dialectic import prompts
|
|
from src.dialectic.core import DialecticAgent
|
|
from src.llm.types import LLMTelemetryContext
|
|
from src.utils.agent_tools import (
|
|
WORKSPACE_DIALECTIC_TOOLS,
|
|
WORKSPACE_TOOLS_MINIMAL,
|
|
create_workspace_tool_executor,
|
|
format_workspace_stats,
|
|
)
|
|
from src.utils.evidence import EvidenceAccumulator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# How many active peers (with their self peer cards) to inject at prefetch.
|
|
# Routing-obvious queries should resolve without a discovery tool round —
|
|
# each avoided tool round is a full model turn (~1.3s measured).
|
|
_PREFETCH_ACTIVE_PEERS = 5
|
|
|
|
|
|
class WorkspaceDialecticAgent(DialecticAgent):
|
|
"""Dialectic agent scoped to a whole workspace instead of a peer pair."""
|
|
|
|
def __init__(
|
|
self,
|
|
workspace_name: str,
|
|
session_name: str | None = None,
|
|
metric_key: str | None = None,
|
|
reasoning_level: ReasoningLevel = "low",
|
|
session_id: str | None = None,
|
|
session_allowlist: list[str] | None = None,
|
|
evidence: EvidenceAccumulator | None = None,
|
|
) -> None:
|
|
super().__init__(
|
|
workspace_name=workspace_name,
|
|
session_name=session_name,
|
|
observer="",
|
|
observed="",
|
|
metric_key=metric_key,
|
|
reasoning_level=reasoning_level,
|
|
session_id=session_id,
|
|
session_allowlist=session_allowlist,
|
|
evidence=evidence,
|
|
)
|
|
# Replace the pair-oriented system prompt with the workspace one.
|
|
self.messages[0] = {
|
|
"role": "system",
|
|
"content": prompts.workspace_agent_system_prompt(
|
|
{
|
|
name
|
|
for tool in self._select_tools()
|
|
if isinstance((name := tool.get("name")), str)
|
|
}
|
|
),
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# DialecticAgent seams
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _prefetch_relevant_observations(self, query: str) -> str | None:
|
|
"""Orientation + routing prefetch: stats, active peers, peer cards.
|
|
|
|
No semantic retrieval here — a workspace-flat observation top-k
|
|
would be dominated by the most verbose peers. Instead give the
|
|
agent what it needs to ROUTE: who is here, who is active, and what
|
|
is known about them at a glance.
|
|
"""
|
|
_ = query
|
|
# Like the base agent, prefetch failure degrades to no prefetched
|
|
# block rather than failing the whole request (the caller in
|
|
# _prepare_query does not guard this).
|
|
try:
|
|
async with tracked_db("dialectic.workspace_prefetch", read_only=True) as db:
|
|
stats = await crud.get_workspace_stats(
|
|
db,
|
|
self.workspace_name,
|
|
session_names=self.session_allowlist,
|
|
)
|
|
if stats.peer_count == 0:
|
|
return None
|
|
peers = await crud.get_active_peers(
|
|
db,
|
|
self.workspace_name,
|
|
limit=_PREFETCH_ACTIVE_PEERS,
|
|
session_names=self.session_allowlist,
|
|
)
|
|
# `peers` is already allowlist-filtered, but a peer card is a
|
|
# single cross-session aggregate: an in-scope peer's card can
|
|
# still carry facts derived from sessions outside the scope.
|
|
# Drop cards entirely under an allowlist — same rule the
|
|
# get_peer_card tool enforces — and route on stats alone.
|
|
cards: dict[str, list[str]] = {}
|
|
if self.session_allowlist is None:
|
|
for peer in peers:
|
|
card = await crud.get_peer_card(
|
|
db,
|
|
workspace_name=self.workspace_name,
|
|
observer=peer.name,
|
|
observed=peer.name,
|
|
)
|
|
if card:
|
|
cards[peer.name] = card
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to prefetch workspace overview for workspace=%s",
|
|
self.workspace_name,
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
return format_workspace_stats(stats, peers, cards)
|
|
|
|
def _prefetch_heading(self) -> str:
|
|
return "Workspace overview (prefetched)"
|
|
|
|
def _prefetch_intro(self) -> str:
|
|
return (
|
|
"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]]:
|
|
tools = (
|
|
WORKSPACE_TOOLS_MINIMAL
|
|
if self.reasoning_level == "minimal"
|
|
else WORKSPACE_DIALECTIC_TOOLS
|
|
)
|
|
# Mirror the base agent's allowlist rule, for both tools that cannot
|
|
# honor an allowlist: reasoning chains traverse provenance across
|
|
# sessions, and a peer card is one cross-session aggregate with no
|
|
# per-session attribution. Both fail closed in their handlers too;
|
|
# dropping them here avoids paying the schema tokens and a wasted
|
|
# turn on a tool that can only refuse.
|
|
if self.session_allowlist is not None:
|
|
unscopable = {"get_reasoning_chain", "get_peer_card"}
|
|
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,
|
|
session_name=self.session_name,
|
|
session_allowlist=self.session_allowlist,
|
|
history_token_limit=settings.DIALECTIC.HISTORY_TOKEN_LIMIT,
|
|
run_id=self._run_id,
|
|
agent_type="workspace_dialectic",
|
|
parent_category="dialectic",
|
|
evidence=self.evidence,
|
|
)
|
|
|
|
# Workspace chat shares the base "dialectic_chat" Langfuse trace name;
|
|
# scope is distinguished by the agent_type/track_name below.
|
|
|
|
def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext:
|
|
return LLMTelemetryContext(
|
|
workspace_name=self.workspace_name,
|
|
call_purpose="dialectic.answer",
|
|
parent_category="dialectic",
|
|
agent_type="workspace_dialectic",
|
|
run_id=self._run_id,
|
|
trace_id=self._run_id,
|
|
span_id=self._run_id,
|
|
session_id=self.session_id,
|
|
peer_name="(workspace)",
|
|
track_name=track_name or "Workspace Dialectic Agent",
|
|
)
|