feat(notes): attach selected Notes/Todos as Agent context (#6495)

* feat: add Notes and Todos as agent chat context

- Send selected Note/Todo IDs from the Notes UI
- Load selected notes with owner scoping in chat routes
- Format note content and checklist items for the model
- Inject selected notes as untrusted chat context
- Add a selected-context indicator beside Shell Access
- Keep selected context associated with the active chat session

* Improved selected notes context handling and discouragin redundant note lookup and permission requests

* Fix selected notes UI when switching chats

* fix(notes): harden selected context lifecycle and validation

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
This commit is contained in:
Beyrem Weslati
2026-10-13 18:57:19 +01:00
committed by GitHub
co-authored by Alexandre Teixeira
parent a8c147b238
commit 8feb50e894
9 changed files with 324 additions and 2 deletions
+18
View File
@@ -1010,6 +1010,7 @@ async def build_chat_context(
chat_processor,
message: str,
session_id: str,
selected_notes_context: str | None = None,
preset_id=None,
att_ids: list = None,
use_web=None,
@@ -1182,6 +1183,23 @@ async def build_chat_context(
if search_context and allow_tool_preprocessing and not casual_low_signal:
preface.append(untrusted_context_message("prefetched search context", search_context))
# Inject selected notes as context
if selected_notes_context:
selected_notes_context = (
"The following selected notes and todos are reference context explicitly chosen "
"by the user. Use them directly when relevant to the user's request. Do not "
"ask for permission to use this context, and do not call note-management "
"tools just to retrieve the same notes; use those tools only for a requested "
"change or when this context is insufficient.\n\n"
+ selected_notes_context
)
preface.append(
untrusted_context_message(
"Selected notes and todos",
selected_notes_context,
)
)
# YouTube transcripts
for transcript in preprocessed.youtube_transcripts:
preface.append(untrusted_context_message("youtube transcript", transcript))
+82 -1
View File
@@ -58,7 +58,7 @@ from src.auth_helpers import (
from routes.session_routes import _verify_session_owner
from routes.document_helpers import _owner_session_filter
from core.database import SessionLocal, get_session_mode, set_session_mode
from core.database import Session as DBSession, ChatMessage as DBChatMessage
from core.database import Session as DBSession, ChatMessage as DBChatMessage, Note
from core.database import Document as DBDocument, ModelEndpoint
from core.log_safety import redact_url
from routes.research_routes import _resolve_research_endpoint
@@ -2153,6 +2153,68 @@ def _set_user_time_from_request(request: Request) -> None:
except Exception:
pass
def _parse_selected_note_ids(raw) -> list[str]:
"""Validate user-selected Note IDs before passing them to SQLAlchemy."""
if raw is None or raw == "":
return []
if not isinstance(raw, str) or len(raw) > 8192:
raise HTTPException(400, "Invalid selected_note_ids payload")
try:
parsed = json.loads(raw)
except (TypeError, ValueError) as exc:
raise HTTPException(400, "Invalid selected_note_ids JSON") from exc
if (
not isinstance(parsed, list)
or len(parsed) > 50
or any(
not isinstance(note_id, str)
or not note_id.strip()
or len(note_id) > 128
for note_id in parsed
)
):
raise HTTPException(400, "selected_note_ids must be an array of up to 50 non-empty string IDs")
return list(dict.fromkeys(parsed))
def _format_selected_notes_context(notes) -> str:
"""Format selected Notes/Todos as reference context for the model."""
sections = []
for index, note in enumerate(notes, start=1):
lines = [
f"--- Selected Note {index} ---",
f"Title: {note.title or '(untitled)'}",
f"Type: {note.note_type or 'note'}",
]
if note.content:
lines.extend([
"Content:",
str(note.content),
])
if note.items:
try:
items = json.loads(note.items)
except (TypeError, json.JSONDecodeError):
items = []
if isinstance(items, list):
lines.append("Items:")
for item in items:
if not isinstance(item, dict):
continue
status = "done" if item.get("done") else "pending"
text = str(item.get("text") or "").strip()
if text:
lines.append(f"- [{status}] {text}")
sections.append("\n".join(lines))
return "\n\n".join(sections)
def _resolve_prompt_thinking_mode(explicit_mode, preset_id, preset_manager):
"""Use an explicit request override, then fall back to the active preset."""
@@ -2423,6 +2485,8 @@ def setup_chat_routes(
form_data = await request.form()
message = form_data.get("message")
session = form_data.get("session")
selected_note_ids = _parse_selected_note_ids(form_data.get("selected_note_ids"))
attachments = form_data.get("attachments")
use_web = form_data.get("use_web")
use_research = form_data.get("use_research")
@@ -2730,6 +2794,22 @@ def setup_chat_routes(
current_rejected=workspace_rejected,
)
owner = effective_user(request)
selected_notes = []
if selected_note_ids:
notes_db = SessionLocal()
try:
selected_notes = (
notes_db.query(Note)
.filter(
Note.id.in_(selected_note_ids),
Note.owner == owner,
)
.all()
)
finally:
notes_db.close()
selected_notes_context = _format_selected_notes_context(selected_notes)
if tool_approval_id:
_reject_delegated_tool_approval(request)
from src.agent_runtime.authority import require_user_approval_request
@@ -3001,6 +3081,7 @@ def setup_chat_routes(
sess, request, chat_handler, chat_processor,
message=message,
session_id=session,
selected_notes_context=selected_notes_context,
preset_id=preset_id,
att_ids=att_ids,
use_web=use_web,