diff --git a/app/core/deep_agent.py b/app/core/deep_agent.py index 97eb87f..3ef4464 100644 --- a/app/core/deep_agent.py +++ b/app/core/deep_agent.py @@ -570,6 +570,59 @@ def _all_tools() -> list[Any]: return [*TASK_TOOLS, *PROJECT_TOOLS, *NOTE_TOOLS, *TIMELINE_TOOLS] +# ── Contextual sidebar tools ────────────────────────────────────────── + + +@tool +async def get_page_details( + entity_type: str = "", + entity_id: str = "", +) -> str: + """Fetch full details for the entity currently in view. + + entity_type: one of 'project' | 'task' | 'note' | 'timeline_event' | + 'tasks_all' | 'projects_all' | 'timeline_all'. + entity_id: UUID of the entity for singular entity views. Omit for list views. + + The Electron drizzle-executor fulfils this op against local SQLite and + returns the row(s) as a JSON tool result. + """ + result = await execute_on_client( + action="get_page_details", + table=entity_type or "unknown", + data={"entityId": entity_id or None}, + ) + if not result: + return "No details found." + return str(result) + + +def _contextual_tools(user_id: str, trace_id: str | None) -> list[Any]: + """Return the tool palette for the contextual sidebar agent. + + Includes get_page_details, entity-create/update tools, and memory tools. + Note-edit tools (propose_note_edit) are intentionally excluded — next sprint. + """ + from app.agents.note_agent import create_note, list_notes, get_note # noqa: PLC0415 + from app.agents.task_agent import create_task, update_task, list_tasks # noqa: PLC0415 + from app.agents.timeline_agent import create_timeline, list_timelines # noqa: PLC0415 + from app.agents.project_agent import PROJECT_TOOLS # noqa: PLC0415 + + return [ + get_page_details, + create_task, + update_task, + list_tasks, + create_note, + list_notes, + get_note, + create_timeline, + list_timelines, + *PROJECT_TOOLS, + *_memory_tools(user_id, trace_id), + ] + + def _trace_id_from_context(context: dict[str, Any]) -> str | None: debug = context.get("_debug") if isinstance(debug, dict): @@ -1536,6 +1589,48 @@ async def run_floating_stream( yield "token", _fallback_from_raw_floating_text("".join(raw_chunks)) +async def run_contextual_stream( + user_id: str, + message: str, + context: dict[str, Any], + scope: "ContextualScope", # type: ignore[name-defined] +) -> AsyncGenerator[tuple[str, Any], None]: + """Run the contextual agent for a single user turn. + + Mirrors run_floating_stream's plumbing but injects the rendered scope + block into the system prompt and exposes the contextual tool set. + Note-edit tools (propose_note_edit) are intentionally excluded. + """ + from app.schemas.contextual import ContextualScope, render_scope_block # noqa: PLC0415 + + prepared_context = await _prepare_context(message, context) + trace_id = _trace_id_from_context(prepared_context) + + template, langfuse_prompt = get_prompt_or_fallback( + "contextual_system", _CONTEXTUAL_SYSTEM_PROMPT, + ) + scope_block = render_scope_block(scope) + # Build system prompt: Langfuse template (or fallback) + scope injection. + # The contextual prompt has no per-request slots like {date_context}, so + # we just append the scope block directly. + system_prompt = template + f"\n\n## Current view\n{scope_block}" + system_prompt += _language_instruction(prepared_context) + + tools = _contextual_tools(user_id, trace_id) + + async for event in _run_single_agent_stream( + user_id=user_id, + system_prompt=system_prompt, + message=message, + context=prepared_context, + langfuse_prompt=langfuse_prompt, + agent_name="contextual-agent", + tools=tools, + conversation_history=context.get("conversation_history"), + ): + yield event + + async def run_task_brief_research_stream( user_id: str, task_id: str, diff --git a/tests/test_run_contextual.py b/tests/test_run_contextual.py new file mode 100644 index 0000000..432d6c5 --- /dev/null +++ b/tests/test_run_contextual.py @@ -0,0 +1,74 @@ +"""Tests for run_contextual_stream. + +These tests monkeypatch _run_single_agent_stream (the actual internal runner) +rather than the plan's fictional _run_agent_loop, matching the real +deep_agent.py architecture. +""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from app.schemas.contextual import ContextualScope + + +@pytest.mark.asyncio +async def test_run_contextual_stream_includes_scope_block(monkeypatch): + """run_contextual_stream must inject the scope block into the system prompt + and include get_page_details in the tool list while excluding note-edit tools.""" + import app.core.deep_agent as deep_agent + + captured = {} + + async def fake_stream( + *, + user_id, + system_prompt, + message, + context, + agent_name="agent", + tools=None, + conversation_history=None, + **kwargs, + ): + captured["sys"] = system_prompt + captured["tool_names"] = [getattr(t, "name", str(t)) for t in (tools or [])] + captured["agent_name"] = agent_name + # Async generator that yields nothing — still satisfies the protocol. + if False: + yield # pragma: no cover + + monkeypatch.setattr(deep_agent, "_run_single_agent_stream", fake_stream) + + scope = ContextualScope( + page="project", + entity_type="project", + entity_id="p1", + entity_name="Acme", + counts={"tasks": 1, "notes": 0, "milestones": 0}, + ) + + context = { + "conversation_history": [], + "_debug": {"session_id": "s1"}, + } + + results = [] + async for item in deep_agent.run_contextual_stream( + user_id="user1", + message="hi", + context=context, + scope=scope, + ): + results.append(item) + + assert "Acme" in captured["sys"], "scope block must appear in system prompt" + assert "Current view" in captured["sys"], "section header must be present" + + names = captured["tool_names"] + assert "get_page_details" in names, "get_page_details tool must be included" + + # Entity-create tools: at least one of these must be present. + assert any(n in names for n in ("create_task", "create_note", "update_task")), ( + "at least one entity-create tool must be present" + ) + + # Note edit tools must NOT be exposed. + assert "propose_note_edit" not in names, "propose_note_edit must be excluded"