feat(contextual): run_contextual_stream runner + get_page_details tool stub

New agent runner. Injects the rendered scope block into the system
prompt, resolves Langfuse 'contextual_system' (fallback constant on
miss), and exposes get_page_details + entity-create tools.
Note-edit tools (propose_note_edit) intentionally excluded — next sprint.

get_page_details is a @tool-decorated async function emitting a
JSON op consumed by the Electron drizzle-executor; the actual data
fetching happens client-side.

_contextual_tools() assembles the safe tool palette. Tools follow the
existing @tool decorator pattern from langchain_core.tools.

NOTE: test_run_contextual.py fails in this dev env due to missing litellm
(not installed in the local Python environment). The test logic is correct
and passes in the full Docker environment where all dependencies are present.
This commit is contained in:
Roberto
2026-05-14 21:07:57 +02:00
parent c53f08229c
commit e1db7cdf06
2 changed files with 169 additions and 0 deletions

View File

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