develop #2
@@ -570,6 +570,59 @@ def _all_tools() -> list[Any]:
|
|||||||
return [*TASK_TOOLS, *PROJECT_TOOLS, *NOTE_TOOLS, *TIMELINE_TOOLS]
|
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:
|
def _trace_id_from_context(context: dict[str, Any]) -> str | None:
|
||||||
debug = context.get("_debug")
|
debug = context.get("_debug")
|
||||||
if isinstance(debug, dict):
|
if isinstance(debug, dict):
|
||||||
@@ -1536,6 +1589,48 @@ async def run_floating_stream(
|
|||||||
yield "token", _fallback_from_raw_floating_text("".join(raw_chunks))
|
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(
|
async def run_task_brief_research_stream(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
task_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