develop #2
@@ -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,
|
||||
|
||||
74
tests/test_run_contextual.py
Normal file
74
tests/test_run_contextual.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user