develop #2

Merged
roberto merged 160 commits from develop into main 2026-06-12 15:27:23 +00:00
2 changed files with 169 additions and 0 deletions
Showing only changes of commit e1db7cdf06 - Show all commits

View File

@@ -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,

View 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"