Add task brief research agent: Stage 1 deep-research + canvas draft emission
- run_task_brief_research() runner with brief-specific tool set and max_steps=12 - New agents: client_agent (list_clients, get_client) and relations_agent (query_relations) - search_associative tool wrapping MemoryMiddleware semantic search - BRIEF_RESEARCH_TOOLS constant: read-only task/project/note/timeline + memory + client/relations - canvas block extraction in output_formatter (splits visible text from <canvas> draft) - device_ws.py: task_brief_research request type; emits canvas_draft mutation on stream_end - Stage 2 briefMode: briefing_context injected into floating system prompt when present - briefingContext kwarg wired through compile_prompt call chain Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,10 @@ from typing import Any, Literal
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from app.agents.client_agent import CLIENT_TOOLS
|
||||
from app.agents.note_agent import NOTE_TOOLS
|
||||
from app.agents.project_agent import PROJECT_TOOLS
|
||||
from app.agents.relations_agent import make_query_relations_tool
|
||||
from app.agents.task_agent import TASK_TOOLS
|
||||
from app.agents.timeline_agent import TIMELINE_TOOLS
|
||||
from app.core.agent_session_buffer import session_buffer
|
||||
@@ -303,6 +305,80 @@ For specific dates not listed, compute local-midnight in the user timezone and c
|
||||
{request_context}\
|
||||
"""
|
||||
|
||||
_TASK_BRIEF_RESEARCH_SYSTEM_PROMPT = """\
|
||||
You are an executive assistant preparing a briefing dossier for your principal before they act on a specific task.
|
||||
Your job: gather all relevant context, synthesize it into a tight actionable dossier, and — if the task requires writing (email, message, document) — produce a ready-to-use draft.{user_identity}
|
||||
|
||||
# Research workflow
|
||||
Follow these steps in order, using tools:
|
||||
1. Read the task fully (title, description, due date, priority, status, project, comments).
|
||||
2. Fetch the parent project (`get_project`) to understand scope, aiSummary, and any linked client.
|
||||
3. If the project has a clientId: call `get_client(id)` to retrieve full client details.
|
||||
4. Call `query_relations` (subject_label=client_name or task subject) to find cross-project connections — e.g. the same client appearing in multiple projects.
|
||||
5. Search associative memory (`search_associative`) and archival memory (`archival_memory_search`) using the task title + client name as query phrases to surface relevant past interactions.
|
||||
6. Read core memory blocks for tone preference, language, and user style: `memory_get("tone_preference")`, `memory_get("language")`.
|
||||
7. Determine task kind: is this a writing task (email reply, message, follow-up, proposal)? If yes, draft a ready-to-send piece.
|
||||
|
||||
# Output structure
|
||||
Write the briefing in the user's language. Use this exact structure:
|
||||
|
||||
**What needs to be done**
|
||||
(1–2 sentences, concrete and specific — what action the user must take)
|
||||
|
||||
**Context you should know**
|
||||
(bullet points covering: client background, related projects, prior interactions, tone/style notes, any relevant deadlines or dependencies)
|
||||
|
||||
**Suggested first step**
|
||||
(one specific, immediately actionable instruction)
|
||||
|
||||
If this is a writing task, append a canvas block at the very end:
|
||||
<canvas kind="email|document|message">
|
||||
...ready-to-use draft here...
|
||||
</canvas>
|
||||
|
||||
Do NOT include the canvas block for non-writing tasks.
|
||||
Do NOT repeat verbatim task fields the user already sees in the UI.
|
||||
Be concrete — no vague advice. Every bullet should be a fact that changes what the user does.
|
||||
|
||||
# Date context
|
||||
{date_context}
|
||||
|
||||
# Language
|
||||
{language_instruction}
|
||||
|
||||
# Known people & projects
|
||||
{relational_memory}
|
||||
|
||||
# Request context
|
||||
{request_context}\
|
||||
"""
|
||||
|
||||
_TASK_BRIEF_FOLLOWUP_SYSTEM_PROMPT = """\
|
||||
You are an executive assistant continuing a conversation with your principal.
|
||||
You have already prepared and delivered a research briefing for the active task. The user has read it.{user_identity}
|
||||
|
||||
Your briefing:
|
||||
---
|
||||
{briefing_context}
|
||||
---
|
||||
|
||||
Continue from here. Do NOT repeat the briefing. Refer to it when relevant.
|
||||
Help the user execute: edit drafts, refine wording, look up additional details, plan next steps.
|
||||
Stay terse — your principal is a busy executive.
|
||||
|
||||
# Date context
|
||||
{date_context}
|
||||
|
||||
# Language
|
||||
{language_instruction}
|
||||
|
||||
# Known people & projects
|
||||
{relational_memory}
|
||||
|
||||
# Request context
|
||||
{request_context}\
|
||||
"""
|
||||
|
||||
_FLOATING_DOMAIN_CLASSIFIER_PROMPT = (
|
||||
"You are a strict domain classifier for websocket floating requests. "
|
||||
"Return ONLY a JSON object with keys: type, id, section. "
|
||||
@@ -679,6 +755,25 @@ def _memory_tools(user_id: str, trace_id: str | None) -> list[Any]:
|
||||
lines = [f"- {item}" for item in results]
|
||||
return "Recall memory results:\n" + "\n".join(lines)
|
||||
|
||||
@tool
|
||||
async def search_associative(query: str, limit: int = 5) -> str:
|
||||
"""Semantic search across associative (archival) memory for a given query.
|
||||
|
||||
Use this to surface long-term memories related to a topic, client, or task
|
||||
that may not appear in recent episodes.
|
||||
|
||||
query: natural-language search phrase.
|
||||
limit: max results (default 5).
|
||||
"""
|
||||
logger.info("deep_agent: search_associative trace=%s user=%s query=%s", trace_id or "-", user_id, query[:80])
|
||||
async with async_session() as db:
|
||||
memory = MemoryMiddleware(db)
|
||||
results = await memory.search_archival(user_id, query, top_k=limit)
|
||||
if not results:
|
||||
return "No associative memory results found."
|
||||
lines = [f"- {item}" for item in results]
|
||||
return "Associative memory results:\n" + "\n".join(lines)
|
||||
|
||||
return [
|
||||
memory_list_blocks,
|
||||
memory_get,
|
||||
@@ -689,16 +784,33 @@ def _memory_tools(user_id: str, trace_id: str | None) -> list[Any]:
|
||||
archival_memory_insert,
|
||||
archival_memory_search,
|
||||
conversation_search,
|
||||
search_associative,
|
||||
]
|
||||
|
||||
|
||||
def _read_only_memory_tools(user_id: str, trace_id: str | None) -> list[Any]:
|
||||
"""Return memory tools that only read — safe for the read-only brief-agent subset."""
|
||||
all_mem = _memory_tools(user_id, trace_id)
|
||||
_read_names = {"memory_list_blocks", "memory_get", "archival_memory_search", "conversation_search"}
|
||||
_read_names = {
|
||||
"memory_list_blocks", "memory_get", "archival_memory_search",
|
||||
"conversation_search", "search_associative",
|
||||
}
|
||||
return [t for t in all_mem if t.name in _read_names]
|
||||
|
||||
|
||||
def _brief_research_tools(user_id: str, trace_id: str | None) -> list[Any]:
|
||||
"""Return the full tool palette for Stage-1 task brief research (read-only)."""
|
||||
return [
|
||||
*TASK_TOOLS,
|
||||
*PROJECT_TOOLS,
|
||||
*NOTE_TOOLS,
|
||||
*TIMELINE_TOOLS,
|
||||
*CLIENT_TOOLS,
|
||||
*_read_only_memory_tools(user_id, trace_id),
|
||||
make_query_relations_tool(user_id, trace_id),
|
||||
]
|
||||
|
||||
|
||||
def _all_tools_for_user(user_id: str, trace_id: str | None) -> list[Any]:
|
||||
return [*_all_tools(), *_memory_tools(user_id, trace_id)]
|
||||
|
||||
@@ -1249,7 +1361,29 @@ async def run_floating_stream(
|
||||
domain = await _infer_floating_domain(message, prepared_context)
|
||||
yield "floating_domain", domain
|
||||
|
||||
system_prompt, langfuse_prompt = _build_system_prompt("floating_system", _FLOATING_SYSTEM_PROMPT, prepared_context)
|
||||
brief_mode: bool = bool(context.get("brief_mode"))
|
||||
briefing_context_text: str = str(context.get("briefing_context") or "").strip()
|
||||
|
||||
if brief_mode and briefing_context_text:
|
||||
# Stage 2: inject briefing as ground truth context.
|
||||
# Pre-substitute {briefing_context} in the template (handles both Langfuse {{}} and fallback {})
|
||||
# before compile_prompt sees the remaining standard variables.
|
||||
template, langfuse_prompt = get_prompt_or_fallback(
|
||||
"task_brief_followup_system",
|
||||
_TASK_BRIEF_FOLLOWUP_SYSTEM_PROMPT,
|
||||
)
|
||||
system_prompt = compile_prompt(
|
||||
template, langfuse_prompt,
|
||||
date_context=_datetime_context_injection(prepared_context).strip(),
|
||||
language_instruction=_language_instruction(prepared_context).strip(),
|
||||
user_identity=_user_identity_injection(prepared_context).strip(),
|
||||
relational_memory=_relational_memory_injection(prepared_context).strip(),
|
||||
proactive_hints=_proactive_hints_injection(prepared_context).strip(),
|
||||
request_context=_request_context_block(prepared_context),
|
||||
briefing_context=briefing_context_text,
|
||||
)
|
||||
else:
|
||||
system_prompt, langfuse_prompt = _build_system_prompt("floating_system", _FLOATING_SYSTEM_PROMPT, prepared_context)
|
||||
sanitizer = _FloatingStreamSanitizer()
|
||||
emitted_sanitized = False
|
||||
raw_chunks: list[str] = []
|
||||
@@ -1283,6 +1417,49 @@ async def run_floating_stream(
|
||||
yield "token", _fallback_from_raw_floating_text("".join(raw_chunks))
|
||||
|
||||
|
||||
async def run_task_brief_research_stream(
|
||||
user_id: str,
|
||||
task_id: str,
|
||||
context: dict[str, Any],
|
||||
) -> AsyncGenerator[tuple[str, Any], None]:
|
||||
"""Stage-1 executive assistant: deep research for one task.
|
||||
|
||||
Yields ``("token", chunk)`` events like other stream runners.
|
||||
The final concatenated text may contain a ``<canvas kind="...">...</canvas>`` block
|
||||
which the WS handler strips and emits as a ``canvas_draft`` mutation.
|
||||
"""
|
||||
prepared_context = await _prepare_context(f"task:{task_id}", context)
|
||||
tools = _brief_research_tools(user_id, _trace_id_from_context(prepared_context))
|
||||
|
||||
# Inject task_id so the agent knows what to look up first.
|
||||
research_message = (
|
||||
f"Prepare a briefing dossier for task ID: {task_id}\n"
|
||||
"Follow the research workflow: read the task, then project, then client, "
|
||||
"then cross-project relations, then relevant memory. "
|
||||
"End with a concrete suggested first step. "
|
||||
"If this is a writing task, include a <canvas kind=\"...\"> draft."
|
||||
)
|
||||
|
||||
system_prompt, langfuse_prompt = _build_system_prompt(
|
||||
"task_brief_research_system",
|
||||
_TASK_BRIEF_RESEARCH_SYSTEM_PROMPT,
|
||||
prepared_context,
|
||||
)
|
||||
|
||||
async for event in _run_single_agent_stream(
|
||||
user_id=user_id,
|
||||
system_prompt=system_prompt,
|
||||
message=research_message,
|
||||
context=prepared_context,
|
||||
max_steps=12,
|
||||
langfuse_prompt=langfuse_prompt,
|
||||
agent_name="task-brief-agent",
|
||||
tools=tools,
|
||||
conversation_history=None,
|
||||
):
|
||||
yield event
|
||||
|
||||
|
||||
async def update_core_memory(user_id: str, key: str, value: str) -> None:
|
||||
"""Compatibility helper kept for callers that expect explicit memory update API."""
|
||||
async with async_session() as db:
|
||||
|
||||
@@ -107,6 +107,7 @@ _AGENT_MODEL_SETTINGS: dict[str, Callable[[], str]] = {
|
||||
"unified-processor": lambda: settings.LLM_MODEL_UNIFIED_PROCESSOR or settings.LLM_MODEL,
|
||||
"cloud-processor": lambda: settings.LLM_MODEL_CLOUD_PROCESSOR or settings.LLM_MODEL,
|
||||
"brief-agent": lambda: settings.LLM_MODEL_BRIEF_AGENT or settings.LLM_MODEL,
|
||||
"task-brief-agent": lambda: settings.LLM_MODEL_TASK_BRIEF_AGENT or settings.LLM_MODEL,
|
||||
"setup": lambda: settings.LLM_MODEL_SETUP_AGENT or settings.LLM_MODEL,
|
||||
"memory-extractor": lambda: settings.LLM_MODEL_MEMORY_EXTRACTOR or "gpt-4o-mini",
|
||||
"memory-miner": lambda: settings.LLM_MODEL_MEMORY_MINER or "gpt-4o-mini",
|
||||
|
||||
@@ -2,11 +2,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from app.schemas import WsFloatingDomain, WsStreamEnd, WsStreamStart, WsStreamText
|
||||
|
||||
# Matches <canvas kind="...">...</canvas> blocks (single-line or multiline).
|
||||
_CANVAS_BLOCK_RE = re.compile(
|
||||
r'<canvas\s+kind=["\']([^"\']+)["\']>(.*?)</canvas>',
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_canvas_block(text: str) -> tuple[str, str | None, str | None]:
|
||||
"""Strip the first <canvas kind="...">...</canvas> block from *text*.
|
||||
|
||||
Returns ``(visible_text, canvas_content, canvas_kind)``.
|
||||
``canvas_content`` and ``canvas_kind`` are ``None`` when no block is found.
|
||||
"""
|
||||
match = _CANVAS_BLOCK_RE.search(text)
|
||||
if not match:
|
||||
return text, None, None
|
||||
|
||||
canvas_kind = match.group(1).strip()
|
||||
canvas_content = match.group(2).strip()
|
||||
visible = text[: match.start()] + text[match.end() :]
|
||||
visible = visible.strip()
|
||||
return visible, canvas_content, canvas_kind
|
||||
|
||||
WsFrame = WsStreamStart | WsStreamText | WsStreamEnd | WsFloatingDomain
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user