Checkpoint Odysseus local update

This commit is contained in:
pewdiepie-archdaemon
2026-07-07 00:50:07 +00:00
parent 5f6e6a2c4a
commit 017903de61
66 changed files with 22349 additions and 982 deletions
+500 -32
View File
@@ -43,6 +43,44 @@ from src.agent_tools import (
logger = logging.getLogger(__name__)
def _looks_like_notes_list_request(text: str) -> bool:
"""Whether the user is asking to see existing notes, not create one."""
t = (text or "").lower()
return bool(
re.search(r"\b(what|show|list|see|current|existing|all|my)\b.{0,60}\bnotes?\b", t)
or re.search(r"\bnotes?\b.{0,60}\b(what|show|list|see|current|existing|all|my)\b", t)
)
def _note_list_summary_from_tool_output(raw: str, max_items: int = 20) -> str:
"""Format manage_notes list/search output for chat without an LLM pass."""
if not isinstance(raw, str) or not raw.strip():
return ""
titles: list[str] = []
for line in raw.splitlines():
m = re.match(r"^\s*-\s+\[[^\]]+\]\s+\*\*(.*?)\*\*(.*)$", line)
if not m:
continue
title = re.sub(r"\s+", " ", m.group(1)).strip()
suffix = re.sub(r"\s+", " ", m.group(2) or "").strip()
label = f"{title} {suffix}".strip()
if label:
titles.append(label)
if len(titles) >= max_items:
break
if not titles:
if re.search(r"\b(no notes|0 notes|found 0)\b", raw, re.IGNORECASE):
return "No notes found."
return ""
total = len(re.findall(r"^\s*-\s+\[[^\]]+\]\s+\*\*", raw, re.MULTILINE))
heading_count = total or len(titles)
lines = [f"Here are your notes ({heading_count}):"]
lines.extend(f"- {title}" for title in titles)
if total and total > len(titles):
lines.append(f"- ...and {total - len(titles)} more")
return "\n".join(lines)
def _load_mcp_disabled_map() -> Dict[str, set]:
"""Load per-server disabled tool sets from the database."""
from core.database import McpServer, SessionLocal
@@ -73,6 +111,7 @@ _AGENT_RULES = """\
## Rules
- Only use tools when needed. Don't search for things you already know.
- For web lookup/search/latest/current requests, use `web_search` or `web_fetch`. Do NOT use `bash`, `python`, `curl`, `requests`, or scraping code for web lookup unless web tools are disabled or already failed.
- If `web_search` is listed in this prompt, web search is available. Do NOT tell the user search/web tools are unavailable.
- These exact tags execute automatically. For showing code examples, use ```shell, ```sh, ```py, etc. instead.
- Multiple tool blocks per response OK. 60s timeout per tool, 10K char output limit.
- Code/content >15 lines ```create_document (NOT in chat). Short snippets OK in chat.
@@ -121,6 +160,7 @@ _API_AGENT_RULES = """\
- Only call tools when they materially help answer the request.
- You MUST use tools to take action do not describe what you would do. Act, don't narrate.
- For web lookup/search/latest/current requests, call `web_search` or `web_fetch`. Do NOT use shell, Python, curl, requests, or scraping code for web lookup unless web tools are unavailable or already failed.
- If `web_search` is listed in this prompt, web search is available. Do NOT tell the user search/web tools are unavailable.
- Keep answers concise unless the user asks for depth.
- For long code or content, use document tools instead of pasting large blocks into chat.
- Long-form or structured writing is a document by default when the user asks to write/create/make/generate it and the answer would be more than a short paragraph. Call create_document instead of dumping the full content in chat.
@@ -281,7 +321,7 @@ _DOMAIN_RULES = {
}
_DOMAIN_TOOL_MAP = {
"web": {"web_search", "web_fetch", "trigger_research", "manage_research"},
"web": {"web_search", "web_fetch"},
"documents": {"create_document", "edit_document", "update_document", "suggest_document", "manage_documents"},
"email": {"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "archive_email", "delete_email", "mark_email_read", "resolve_contact", "manage_contact"},
"cookbook": {"download_model", "serve_model", "serve_preset", "list_serve_presets", "list_served_models", "stop_served_model", "tail_serve_output", "list_downloads", "cancel_download", "search_hf_models", "list_cached_models", "list_cookbook_servers", "adopt_served_model"},
@@ -339,6 +379,7 @@ Or with JSON for fresh news:
{"query": "<your query>", "time_filter": "day"}
```
Search the web for a SINGLE quick fact/lookup mid-task. For news / "today" / "latest" queries, pass `time_filter` ("day", "week", "month", or "year"). NOT for "research X" / "do research on X" / "look into X" requests those mean a multi-source DEEP RESEARCH job: use `trigger_research` instead (it runs in the Deep Research sidebar and produces a full report). web_search = one quick query; trigger_research = a researched report.
If this `web_search` tool section is visible, search is available. Do NOT tell the user web/search tools are unavailable.
Use this instead of `bash`, `curl`, `python`, `requests`, or scraping code for web lookup/search/latest/current requests.""",
"web_fetch": """\
@@ -616,16 +657,6 @@ def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool
if one_liners:
parts.append("## Additional tools\n" + "\n".join(one_liners))
# Mention tools that exist but weren't included
all_known = set(TOOL_SECTIONS.keys())
not_shown = all_known - included - disabled
if not_shown:
sample = sorted(not_shown)[:5]
hint = ", ".join(sample)
if len(not_shown) > 5:
hint += f", ... ({len(not_shown) - 5} more)"
parts.append(f"(Other tools available when needed: {hint})")
parts.append(_AGENT_RULES)
parts.extend(_domain_rules_for_tools(included))
return "\n\n".join(parts)
@@ -765,6 +796,59 @@ def _extract_last_user_message(messages: List[Dict]) -> str:
return ""
_REMINDER_TIME_RE = re.compile(
r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?"
r"|\b\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b",
re.IGNORECASE,
)
def _extract_reminder_due_from_user(text: str) -> str:
t = str(text or "").strip()
if not re.search(r"\b(remind|reminder|alarm)\b", t, re.IGNORECASE):
return ""
m = _REMINDER_TIME_RE.search(t)
return m.group(0).strip() if m else ""
def _repair_manage_notes_reminder_block(block: ToolBlock, last_user: str) -> ToolBlock:
"""Carry reminder time from the user message when the model omits due_date."""
if block.tool_type != "manage_notes":
return block
try:
args = json.loads(block.content or "{}")
except Exception:
return block
if not isinstance(args, dict) or args.get("due_date"):
return block
action = str(args.get("action") or "").replace("-", "_").strip().lower()
if action not in {"add", "create", "new", "save", "remind", "reminder"}:
return block
due = _extract_reminder_due_from_user(last_user)
if not due:
return block
args["due_date"] = due
if not args.get("title"):
cleaned = re.sub(r"\b(make|create|add|set)\b", "", str(last_user or ""), flags=re.IGNORECASE)
cleaned = re.sub(r"\b(a|an)?\s*(reminder|alarm)\b", "", cleaned, flags=re.IGNORECASE)
cleaned = _REMINDER_TIME_RE.sub("", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip(" :,-") or "Reminder"
args["title"] = cleaned[:120]
return ToolBlock(block.tool_type, json.dumps(args, ensure_ascii=False))
def _user_turn_count(messages: List[Dict]) -> int:
"""Count real user turns in the message list."""
count = 0
for msg in messages or []:
if msg.get("role") == "user":
count += 1
return count
def _insert_before_latest_user(messages: List[Dict], context_msg: Dict) -> List[Dict]:
"""Insert a context message immediately before the latest user turn."""
out = list(messages or [])
@@ -975,7 +1059,7 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
domains.add("cookbook")
if has(r"\b(emails?|mails?|gmail|inbox|reply|forward|cc|bcc|send email|compose email|draft email|message chris|message him|message her)\b"):
domains.add("email")
if has(r"\b(note|todo|to-do|checklist|task list|remind me|reminder|buy|pickup|pick up)\b"):
if has(r"\b(notes?|todos?|to-dos?|checklists?|task list|remind me|reminders?|buy|pickup|pick up)\b"):
domains.add("notes_calendar_tasks")
if has(r"\b(every day|every morning|every evening|recurring|automatically|cron|scheduled task|background task)\b"):
domains.add("notes_calendar_tasks")
@@ -1114,6 +1198,18 @@ def _turn_targets_active_document(intent: Dict[str, object], last_user: str, act
))
def _is_email_document_obj(active_document) -> bool:
if active_document is None:
return False
raw_doc = getattr(active_document, "current_content", "") or ""
title_l = (getattr(active_document, "title", "") or "").strip().lower()
return (
getattr(active_document, "language", None) == "email"
or title_l in {"new email", "new mail", "new message"}
or ("To:" in raw_doc[:400] and "Subject:" in raw_doc[:400] and "\n---\n" in raw_doc)
)
def _minimal_saved_memory_message(messages: List[Dict]) -> Optional[Dict]:
facts: List[str] = []
seen = set()
@@ -1141,9 +1237,9 @@ def _minimal_saved_memory_message(messages: List[Dict]) -> Optional[Dict]:
continue
seen.add(fact)
facts.append(fact)
if len(facts) >= 12:
if len(facts) >= 8:
break
if len(facts) >= 12:
if len(facts) >= 8:
break
if not facts:
return None
@@ -1160,6 +1256,41 @@ def _minimal_saved_memory_message(messages: List[Dict]) -> Optional[Dict]:
}
def _compact_email_draft_context(raw: str, *, max_own_chars: int = 1200, max_history_chars: int = 1200) -> str:
"""Compact an email compose document for prompt injection.
The editor/backend preserve quoted history mechanically, so the model only
needs enough of the previous message to understand what to answer.
"""
text = raw or ""
if "\n---\n" not in text:
return text[:3500] + ("\n...[truncated]" if len(text) > 3500 else "")
header, body = text.split("\n---\n", 1)
literal = "---------- Previous message ----------"
idx = body.find(literal)
if idx >= 0:
own = body[:idx].strip()
history = body[idx:].strip()
else:
own = body.strip()
history = ""
if len(own) > max_own_chars:
own = own[:max_own_chars].rstrip() + "\n...[draft body truncated]"
if len(history) > max_history_chars:
history = history[:max_history_chars].rstrip() + "\n...[quoted history truncated; full history is preserved by Odysseus]"
if history:
body_out = (
f"{own}\n\n" if own else ""
) + (
"QUOTED HISTORY EXCERPT FOR CONTEXT ONLY -- do not rewrite or include this excerpt in your tool output; "
"Odysseus preserves the full quoted thread below the reply automatically.\n"
f"{history}"
)
else:
body_out = own
return header.rstrip() + "\n---\n" + body_out.strip()
def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream_create: bool = False) -> List[Dict]:
"""Tiny prompt path for the Odysseus document LoRA.
@@ -1182,6 +1313,10 @@ def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream
else:
system = (
"You are Odysseus. Edit or suggest changes to the active document using exactly one fenced tool block when needed.\n"
"The active document content is authoritative. Apply the user's request to that content; do not append the user's instruction as document text.\n"
"Preserve the current title, language, structure, and existing meaning unless the user explicitly asks to change them.\n"
"If the user asks for ALL CAPS/uppercase/lowercase, transform the existing document text itself.\n"
"If the user refers to line numbers, use the numbered active document lines; never include the line numbers or tabs in FIND/REPLACE text.\n"
"If the user asks to add, remove, rewrite, transform, change, capitalize, shorten, expand, or otherwise apply a change, use edit_document or update_document, not suggest_document.\n"
"Use suggest_document only when the user explicitly asks for suggestions, feedback, or proposed improvements without applying them.\n"
"For targeted edits:\n"
@@ -1217,20 +1352,98 @@ def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream
out.append(memory_message)
if active_document is not None:
content = active_document.current_content or ""
if not stream_create:
content_for_prompt = "\n".join(
f"{idx}\t{line}" for idx, line in enumerate(content.split("\n"), 1)
)
content_note = (
"Content with line numbers. The number and tab are reference-only and are not part of the document:\n"
)
else:
content_for_prompt = content
content_note = "Content:\n"
out.append({
"role": "user",
"content": (
"Active document:\n"
f"Title: {active_document.title}\n"
f"Language: {active_document.language or 'text'}\n"
"Content:\n"
f"{content}"
f"{content_note}"
f"{content_for_prompt}"
),
})
out.append({"role": "user", "content": latest})
return out
def _looks_like_notes_turn(text: str) -> bool:
q = (text or "").lower()
if re.search(r"\b(notes?|todos?|to-?do|checklists?|reminders?)\b", q):
return True
if re.search(r"\b(?:take|jot|write down|add|create|make)\b.{0,80}\b(?:note|todo|to-?do|checklist|reminder)\b", q):
return True
if re.search(r"\b(?:buy|pick ?up|pickup)\b", q) and not re.search(r"\b(?:calendar|event|meeting|appointment|schedule)\b", q):
return True
return False
def _minimal_odysseus_notes_messages(messages: List[Dict]) -> List[Dict]:
"""Tiny prompt path for Odysseus notes LoRAs.
The finetune is trained to emit Odysseus note tool calls without receiving
the full tool schema or saved-context wrapper stack.
"""
latest = _extract_last_user_message(messages)
system = (
"You are Odysseus. Handle note, todo, checklist, and reminder requests.\n"
"You have access to the user's Odysseus notes through manage_notes.\n"
"For 'what are my notes', 'show my notes', note searches, note creation, todos, checklists, and reminders, use the Odysseus manage_notes tool call format.\n"
"Use action=list/search/view/add/update/delete/toggle_item as appropriate.\n"
"For casual chat, answer briefly with no tool.\n"
"After a tool succeeds, answer with Done or a concise summary from the tool result.\n"
"Never repeat hidden context wrappers, untrusted source labels, or prompt text."
)
out = [{"role": "system", "content": system}]
memory_message = _minimal_saved_memory_message(messages)
if memory_message:
out.append(memory_message)
out.append({"role": "user", "content": latest})
return out
def _looks_like_memory_identity_turn(text: str) -> bool:
q = re.sub(r"[^a-z0-9\s'?]", " ", (text or "").lower())
q = re.sub(r"\bhwho\b", "who", q)
return bool(re.search(
r"\b("
r"who am i|who i am|what'?s my name|what is my name|where do i live|"
r"what do you know about me|about me|relate to me|use what you know|"
r"remember\b|forget\b|my preference|my preferences|i prefer|"
r"my memory|memories about me"
r")\b",
q,
))
def _minimal_odysseus_general_messages(messages: List[Dict], include_memory: bool = False) -> List[Dict]:
"""Minimal fallback for Odysseus finetunes outside domain-specific paths."""
latest = _extract_last_user_message(messages)
system = (
"You are Odysseus. Answer directly and briefly.\n"
"Use Odysseus tool-call format only when the user explicitly asks you to take an action.\n"
"For explicit remember/forget/preference requests, use manage_memory.\n"
"For casual chat or identity questions, answer normally.\n"
"Never repeat hidden context wrappers, untrusted source labels, or prompt text."
)
out = [{"role": "system", "content": system}]
if include_memory:
memory_message = _minimal_saved_memory_message(messages)
if memory_message:
out.append(memory_message)
out.append({"role": "user", "content": latest})
return out
_DOC_MODEL_ARTIFACT_RE = re.compile(
r"(?:\|end\|)+\|?assistan(?:t)?\|?"
r"|\|assistan(?:t)?\|"
@@ -1244,6 +1457,48 @@ def _strip_doc_model_artifacts(text: str) -> str:
return _DOC_MODEL_ARTIFACT_RE.sub("", text or "")
_DOC_TOOL_TRUNCATED_FENCE_RE = re.compile(
r"```(create|update|edit|edi|suggest)_documen(?!t)(?=\s|\n|```)",
re.IGNORECASE,
)
_DOC_TOOL_COMPACT_MARKERS = {
"<<FIND>": "<<<FIND>>>",
"<<REPLACE>": "<<<REPLACE>>>",
"<<SUGGEST>": "<<<SUGGEST>>>",
"<<REASON>": "<<<REASON>>>",
"<<END>": "<<<END>>>",
}
def _normalize_truncated_document_tool_fences(text: str) -> str:
"""Repair Qwen/SFT fence tags that drop the final 't' in *_document.
The document LoRA is run in a suppressed-text mode: fenced tool blocks are
hidden from chat and parsed after the stream finishes. If the model emits
```update_documen instead of ```update_document, the parser sees no tool and
the turn looks like it silently died. Keep this repair scoped to document
tool fence tags only.
"""
normalized = _DOC_TOOL_TRUNCATED_FENCE_RE.sub(
lambda m: f"```{'edit' if m.group(1).lower() == 'edi' else m.group(1).lower()}_document",
text or "",
)
for compact, full in _DOC_TOOL_COMPACT_MARKERS.items():
normalized = normalized.replace(compact, full)
marker = r"<<<(?:FIND|REPLACE|SUGGEST|REASON|END)>>>"
normalized = re.sub(rf"(?<!\n)({marker})", r"\n\1", normalized)
normalized = re.sub(rf"({marker})(?=\S)", r"\1\n", normalized)
normalized = re.sub(
r"(<<<(?:REPLACE|SUGGEST|REASON)>>>)\n(<<<END>>>)",
r"\1\n\n\2",
normalized,
)
normalized = re.sub(r"\n(```)", r"\1", normalized)
return normalized
def _normalize_stream_document_fences(text: str, target_tool: str = "create_document") -> str:
"""Treat visible ```document/documen blocks as document tool blocks.
@@ -1252,7 +1507,9 @@ def _normalize_stream_document_fences(text: str, target_tool: str = "create_docu
the same shape is a full replacement of the open document, so map it to
update_document and drop the title/language header lines.
"""
text = _strip_doc_model_artifacts(text or "")
text = _normalize_truncated_document_tool_fences(
_strip_doc_model_artifacts(text or "")
)
def repl(match: re.Match) -> str:
body = match.group(1) or ""
@@ -1403,6 +1660,7 @@ def _build_system_prompt(
_email_style_message = None
_integ_message = None
_mcp_desc_message = None
_active_doc_is_email_doc = False
if active_document:
set_active_document(active_document.id)
_doc_raw = active_document.current_content or ""
@@ -1418,19 +1676,23 @@ def _build_system_prompt(
or _doc_title_l in {"new email", "new mail", "new message"}
or ("To:" in _doc_raw[:400] and "Subject:" in _doc_raw[:400] and "\n---\n" in _doc_raw)
)
_active_doc_is_email_doc = _is_email_doc
if _is_email_doc:
_email_prompt_doc = _compact_email_draft_context(_doc_raw)
doc_ctx = (
f'ACTIVE EMAIL DRAFT (open in editor — the user is looking at this right now)\n'
f'Title: "{active_document.title}"\n'
f'```\n{_doc_raw}\n```\n\n'
f'```\n{_email_prompt_doc}\n```\n\n'
f'This is the current email compose window, not a normal document library item. If the user says "write", "draft", "reply", "make it say", or "write the email" without naming another target, edit THIS email draft.\n\n'
f'When the user asks you to write, reply to, or improve this email:\n'
f'1. Use `update_document` to replace the ENTIRE content — keep all the header lines (To, Subject, In-Reply-To, References, X-Source-UID, X-Source-Folder, X-Attachments) and the `---` separator EXACTLY as they are.\n'
f'2. Replace ONLY the body text (the part after `---`). If there is a quoted original email (lines starting with `>`), keep that quoted block unchanged BELOW your new reply.\n'
f'1. Use `update_document` to update this email draft — keep all header lines (To, Subject, In-Reply-To, References, X-Source-UID, X-Source-Folder, X-Attachments) and the `---` separator EXACTLY as they are.\n'
f'2. Replace ONLY the new reply text above `---------- Previous message ----------`. You may omit the quoted history from your tool output; Odysseus preserves everything from that separator downward automatically.\n'
f'3. Write the reply body above the quoted original. Use the saved email writing style when present.\n'
f'4. Identity is critical: write as the logged-in user / mailbox owner only. NEVER sign as the recipient, original sender, quoted sender, spouse, assistant, company, or any third party. If adding a signature, use only the name/signature implied by the saved email writing style.\n'
f'5. Mechanical style is critical: never use em dash/en dash; use --. Never use curly apostrophes. For English emails, use Hi/Hiya from the saved style rather than Hey unless the user explicitly asks for Hey.\n'
f'6. Do NOT use create_document — the email is already open, you must update it.\n\n'
f'6. Do NOT use create_document — the email is already open, you must update it.\n'
f'7. Do NOT call read_email/list_emails for this turn. The open email draft above is the source of truth, and the quoted history excerpt is enough context for a reply.\n'
f'8. After a successful tool call, answer with a brief confirmation only. Do not paste the full email back into chat unless the user asks.\n\n'
f'Do NOT ask the user to paste or share the email — you already have it above.'
)
else:
@@ -1544,7 +1806,7 @@ def _build_system_prompt(
# resolve to the real UID instead of the agent inventing a fresh .md
# draft with fake headers. This is the email equivalent of _doc_message.
_email_message = None
if active_email and active_email.get("uid"):
if active_email and active_email.get("uid") and not _active_doc_is_email_doc:
_em_uid = active_email.get("uid", "")
_em_folder = active_email.get("folder", "INBOX")
_em_account = active_email.get("account", "")
@@ -2344,6 +2606,7 @@ async def stream_agent_loop(
workspace: Optional[str] = None,
forced_tools: Optional[Set[str]] = None,
uploaded_files: Optional[List[Dict]] = None,
workload: str = "foreground",
_is_teacher_run: bool = False,
) -> AsyncGenerator[str, None]:
"""Streaming agent loop generator.
@@ -2387,13 +2650,23 @@ async def stream_agent_loop(
_t0 = time.time()
_needs_admin = _detect_admin_intent(messages)
_last_user = _extract_last_user_message(messages)
_ody_qwen_finetune_model = (model or "").lower().startswith("odysseus-qwen3")
_ody_memory_identity_turn = _looks_like_memory_identity_turn(_last_user)
_intent = _classify_agent_request(messages, _last_user)
_low_signal_turn = bool(_intent.get("low_signal"))
_casual_low_signal_turn = _is_casual_low_signal(_last_user)
_existing_conversation = _user_turn_count(messages) > 1
_active_document_relevant = _turn_targets_active_document(_intent, _last_user, active_document)
_active_email_draft_relevant = _active_document_relevant and _is_email_document_obj(active_document)
if _active_email_draft_relevant:
disabled_tools.update({
"list_email_accounts", "list_emails", "read_email",
"mcp__email__list_emails", "mcp__email__read_email",
})
_prompt_active_document = active_document if _active_document_relevant else None
_direct_low_signal = (
_low_signal_turn
and not _existing_conversation
and not bool(_intent.get("continuation"))
and not plan_mode
and not approved_plan
@@ -2416,10 +2689,22 @@ async def stream_agent_loop(
_active_document_relevant,
_retrieval_query[:200],
)
if _low_signal_turn and _existing_conversation:
logger.info(
"[agent] keeping contextual path for low-signal turn in existing conversation latest=%r",
_last_user[:80],
)
_mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {}
if _direct_low_signal:
logger.info("[agent] direct low-signal reply path for latest=%r", _last_user[:80])
direct_messages = [{"role": "user", "content": _last_user}]
direct_messages = (
_minimal_odysseus_general_messages(
messages,
include_memory=True,
)
if _ody_qwen_finetune_model
else [{"role": "user", "content": _last_user}]
)
direct_response = ""
direct_start = time.time()
direct_actual_model = model
@@ -2435,6 +2720,7 @@ async def stream_agent_loop(
tools=None,
timeout=int(get_setting("agent_stream_timeout_seconds", 300) or 300),
session_id=session_id,
workload=workload,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
@@ -2527,7 +2813,18 @@ async def stream_agent_loop(
if not guide_only and not _relevant_tools:
try:
from src.tool_index import get_tool_index, ALWAYS_AVAILABLE
tool_idx = get_tool_index()
try:
tool_idx = await asyncio.wait_for(
asyncio.to_thread(get_tool_index),
timeout=_TOOL_SELECTION_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning(
"[tool-rag] Tool index init exceeded %.1fs; falling back to always-available tools",
_TOOL_SELECTION_TIMEOUT_SECONDS,
)
tool_idx = None
_relevant_tools = set(ALWAYS_AVAILABLE)
if tool_idx:
if mcp_mgr:
try:
@@ -2589,6 +2886,13 @@ async def stream_agent_loop(
_relevant_tools.add("ui_control")
if "web" in (_intent.get("domains") or set()):
_relevant_tools.update({"web_search", "web_fetch"})
_removed_web_blocks = sorted({"web_search", "web_fetch"} & disabled_tools)
if _removed_web_blocks:
disabled_tools.difference_update({"web_search", "web_fetch"})
logger.info(
"[agent-intent] web turn forced search tools enabled; removed disabled=%s",
_removed_web_blocks,
)
if "ui" in (_intent.get("domains") or set()):
_relevant_tools.add("ui_control")
@@ -2598,6 +2902,19 @@ async def stream_agent_loop(
# panel is open.
if _relevant_tools is not None and _active_document_relevant:
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
if _active_email_draft_relevant:
# The open compose document already contains the recipient,
# subject, source UID, and quoted previous-message excerpt. Reading
# the same email again through IMAP/MCP is slow, token-heavy, and
# can hang. Keep draft editing tools, drop email fetch tools.
_email_fetch_tools = {
"list_email_accounts", "list_emails", "read_email",
"mcp__email__list_emails", "mcp__email__read_email",
}
removed = sorted(_relevant_tools & _email_fetch_tools)
if removed:
_relevant_tools.difference_update(_email_fetch_tools)
logger.info("[agent-intent] active email draft pruned fetch tools=%s", removed)
# Current-turn chat uploads are real files under the upload/data root. Make
# the read-side file/document tools visible immediately so the agent can
@@ -2608,14 +2925,15 @@ async def stream_agent_loop(
_relevant_tools = set(ALWAYS_AVAILABLE)
_relevant_tools.update({"read_file", "grep", "ls", "manage_documents"})
# Per-request UI toggles are stronger than retrieval. If the user turns on
# Search, the model must see the search tools even when the latest text is a
# typo or otherwise low-signal for tool RAG.
# Per-request forced tools are stronger than retrieval. Search toggles and
# explicit lookup turns must make web tools visible even when tool RAG
# misses them; route-level disabled_tools decides what else is allowed.
if not guide_only and forced_tools:
forced_set = {t for t in forced_tools if t not in disabled_tools}
if _relevant_tools is None:
from src.tool_index import ALWAYS_AVAILABLE
_relevant_tools = set(ALWAYS_AVAILABLE)
_relevant_tools.update(t for t in forced_tools if t not in disabled_tools)
_relevant_tools.update(forced_set)
# The skill index injected by _build_system_prompt tells the model to
# call `manage_skills action=view`, and Jaccard-matched skills are pasted
@@ -2657,7 +2975,7 @@ async def stream_agent_loop(
_intent_domains = set(_intent.get("domains") or set())
_ody_doc_finetune_mode = (
(model or "").lower().startswith("odysseus-qwen3")
_ody_qwen_finetune_model
and (
"documents" in _intent_domains
or _active_document_relevant
@@ -2666,6 +2984,14 @@ async def stream_agent_loop(
and "files" not in _intent_domains
and not guide_only
)
_ody_notes_finetune_mode = (
_ody_qwen_finetune_model
and not _ody_doc_finetune_mode
and ("notes_calendar_tasks" in _intent_domains or _looks_like_notes_turn(_last_user))
and _looks_like_notes_turn(_last_user)
and "files" not in _intent_domains
and not guide_only
)
_ody_doc_stream_create_mode = _ody_doc_finetune_mode and _prompt_active_document is None
if _ody_doc_finetune_mode and _relevant_tools is not None:
if _prompt_active_document is not None:
@@ -2676,6 +3002,9 @@ async def stream_agent_loop(
else:
_relevant_tools = {"create_document", "ask_user", "update_plan"}
logger.info("[agent-intent] odysseus doc finetune tool clamp=%s", sorted(_relevant_tools))
elif _ody_notes_finetune_mode and _relevant_tools is not None:
_relevant_tools = {"manage_notes", "ask_user", "update_plan"}
logger.info("[agent-intent] odysseus notes finetune tool clamp=%s", sorted(_relevant_tools))
if (
_relevant_tools is not None
@@ -2804,6 +3133,24 @@ async def stream_agent_loop(
_ody_doc_stream_create_mode,
len(messages),
)
elif _ody_notes_finetune_mode and not plan_mode and not approved_plan and not guide_only:
messages = _minimal_odysseus_notes_messages(messages)
mcp_schemas = []
logger.info(
"[agent-intent] odysseus notes minimal prompt active messages=%s",
len(messages),
)
elif _ody_qwen_finetune_model and not plan_mode and not approved_plan and not guide_only:
messages = _minimal_odysseus_general_messages(
messages,
include_memory=True,
)
mcp_schemas = []
logger.info(
"[agent-intent] odysseus general minimal prompt active include_memory=%s messages=%s",
_ody_memory_identity_turn,
len(messages),
)
if plan_mode and not guide_only:
# Steer the model to investigate-then-propose. Hard tool gating handles
# every write path except shell; this directive is what keeps the
@@ -2918,6 +3265,7 @@ async def stream_agent_loop(
requested_model = model
actual_model = model
total_tool_calls = 0 # for budget enforcement
_ody_notes_tool_completed = False
# Loop-breaker state. Small models (e.g. deepseek-v4-flash) can get
# stuck firing the same tool call over and over with no text — burns
@@ -3015,7 +3363,7 @@ async def stream_agent_loop(
if s.get("function", {}).get("name") not in _ADMIN_SCHEMA_NAMES
]
all_tool_schemas = base_schemas + mcp_schemas
if _ody_doc_finetune_mode:
if _ody_qwen_finetune_model:
all_tool_schemas = []
if disabled_tools:
all_tool_schemas = [
@@ -3065,6 +3413,7 @@ async def stream_agent_loop(
tool_choice_none=_ody_doc_finetune_mode,
timeout=agent_stream_timeout,
session_id=session_id,
workload=workload,
):
if not _round_first_event_logged:
_round_first_event_logged = True
@@ -3186,11 +3535,15 @@ async def stream_agent_loop(
if data.get("thinking"):
round_reasoning += data["delta"]
else:
_delta_text = _strip_doc_model_artifacts(data["delta"]) if _ody_doc_finetune_mode else data["delta"]
_delta_text = (
_strip_doc_model_artifacts(data["delta"])
if _ody_qwen_finetune_model
else data["delta"]
)
round_response += _delta_text
full_response += _delta_text
data["delta"] = _delta_text
if not _ody_doc_finetune_mode or data.get("thinking"):
if not _ody_qwen_finetune_model or data.get("thinking"):
yield f"data: {json.dumps(data)}\n\n"
# Detect text-fence doc streaming. Normal agent prompts
# use ```create_document; the doc LoRA streaming path
@@ -3311,6 +3664,59 @@ async def stream_agent_loop(
else converted_calls[:1]
)
if _ody_qwen_finetune_model and tool_blocks:
_allowed_memory_write_actions = {"add", "edit", "update", "delete", "delete_all"}
_explicit_memory_browse = bool(re.search(
r"\b(search|list|show|open|view)\b.{0,40}\b(memories|memory|brain)\b",
_last_user.lower(),
))
_filtered_tool_blocks = []
_filtered_converted_calls = []
_dropped_memory_lookup = False
for _idx, _block in enumerate(tool_blocks):
if _block.tool_type != "manage_memory":
_filtered_tool_blocks.append(_block)
if _idx < len(converted_calls):
_filtered_converted_calls.append(converted_calls[_idx])
continue
_action = ""
try:
_args = json.loads(_block.content or "{}")
if isinstance(_args, dict):
_action = str(_args.get("action") or "").lower()
except Exception:
_action = ""
if _action in {"list", "search", "view", "get", "read"} and not _explicit_memory_browse:
_dropped_memory_lookup = True
elif _action in _allowed_memory_write_actions and re.search(
r"\b(remember|forget|preference|prefer|save this about me|update memory|delete memory)\b",
_last_user.lower(),
):
_filtered_tool_blocks.append(_block)
if _idx < len(converted_calls):
_filtered_converted_calls.append(converted_calls[_idx])
else:
_dropped_memory_lookup = True
if _dropped_memory_lookup:
logger.info(
"[agent-intent] odysseus qwen dropped manage_memory lookup; answering from compact memory"
)
tool_blocks = _filtered_tool_blocks
converted_calls = _filtered_converted_calls
if used_native:
native_tool_calls = _filtered_converted_calls
if not tool_blocks:
_force_answer = True
messages.append({
"role": "system",
"content": (
"Answer the user's identity/personal-memory question from the compact "
"saved memory facts already provided. Do not call manage_memory or any tool."
),
})
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'
continue
# Force-answer round: we told the model to STOP calling tools and
# answer. If it ignored that and emitted a (possibly DSML) tool
# call anyway, discard it — don't execute, don't re-loop. Keep
@@ -3395,6 +3801,8 @@ async def stream_agent_loop(
# on reload (#3222 follow-up).
cleaned_round = strip_tool_blocks(round_response, skip_fenced=(_is_api_model and not used_native and not guide_only)).strip()
round_texts.append(cleaned_round)
if _ody_qwen_finetune_model and not tool_blocks and cleaned_round:
yield f'data: {json.dumps({"delta": cleaned_round})}\n\n'
if not tool_blocks:
# ── Completion verifier (mechanism 3a) ────────────────────
@@ -3590,6 +3998,7 @@ async def stream_agent_loop(
tool_result_texts = [] # plain text for native tool role messages
budget_hit = False
for i, block in enumerate(tool_blocks):
block = _repair_manage_notes_reminder_block(block, _last_user)
# --- Tool budget check ---
if max_tool_calls > 0 and total_tool_calls >= max_tool_calls:
yield f'data: {json.dumps({"type": "budget_exceeded", "limit": max_tool_calls, "used": total_tool_calls})}\n\n'
@@ -3856,6 +4265,39 @@ async def stream_agent_loop(
tool_output_data["diff"] = result["diff"]
yield f'data: {json.dumps(tool_output_data)}\n\n'
if block.tool_type == "manage_notes":
_notes_action = ""
try:
_notes_args = json.loads(block.content or "{}")
if isinstance(_notes_args, dict):
_notes_action = str(_notes_args.get("action") or "").lower()
except Exception:
_notes_action = ""
_notes_text = ""
if not result.get("error"):
if _notes_action in {"list", "search", "find", "view", "lis"}:
_notes_text = _note_list_summary_from_tool_output(
result.get("output") or result.get("results") or result.get("content") or ""
)
elif _notes_action in {"add", "update", "delete", "toggle_item"}:
_notes_text = str(
result.get("response")
or result.get("output")
or result.get("results")
or ""
).strip()
if _notes_text.startswith("AI: "):
_notes_text = _notes_text[4:].strip()
if _notes_text and not re.match(r"^(done|note|item|deleted)\b", _notes_text, re.IGNORECASE):
_notes_text = f"Done — {_notes_text}"
if _notes_text:
_clean_current = strip_tool_blocks(full_response).strip()
if _notes_text not in _clean_current:
_prefix = "\n\n" if _clean_current else ""
full_response = (_clean_current + _prefix + _notes_text).strip()
yield f'data: {json.dumps({"delta": _prefix + _notes_text})}\n\n'
_ody_notes_tool_completed = True
# This must be the final UI event for ask_user: the frontend appends
# the card below the now-settled tool node and cancels any between-
# round spinner. The turn ends after the current tool batch.
@@ -3900,6 +4342,7 @@ async def stream_agent_loop(
_title = (result.get("note_title") or "").strip()
_label = f"View note: {_title}" if _title else "View note"
_anchor = f"\n\n[{_label}](#note-{_nid})\n"
full_response = (full_response.rstrip() + _anchor).strip()
yield 'data: ' + json.dumps({"delta": _anchor}) + '\n\n'
# Save for history persistence
@@ -3971,6 +4414,10 @@ async def stream_agent_loop(
logger.info("[agent] odysseus doc tool completed after one textual tool block")
break
if _ody_notes_finetune_mode and _ody_notes_tool_completed:
logger.info("[agent] odysseus notes completed from deterministic tool output")
break
# Feed results back to LLM for next round
# Pass the CONVERTED calls (aligned 1:1 with tool_result_texts), not the
# raw native_tool_calls: a call that failed to convert is dropped from
@@ -4011,6 +4458,27 @@ async def stream_agent_loop(
if _fallback_chunk:
yield _fallback_chunk
# Do not persist raw textual tool-call JSON / role markers as assistant
# prose. Local finetunes may emit those before the parser catches and
# executes them; saved history should contain only the user-facing answer.
full_response = strip_tool_blocks(full_response).strip()
if _ody_notes_finetune_mode and tool_events:
for _ev in reversed(tool_events):
if _ev.get("tool") != "manage_notes":
continue
_notes_action = ""
try:
_cmd_args = json.loads(_ev.get("command") or "{}")
if isinstance(_cmd_args, dict):
_notes_action = str(_cmd_args.get("action") or "").lower()
except Exception:
_notes_action = ""
if _notes_action in {"list", "search", "find", "view", "lis"}:
_notes_summary = _note_list_summary_from_tool_output(_ev.get("output") or "")
if _notes_summary:
full_response = _notes_summary
break
# --- Final metrics ---
total_duration = time.time() - total_start
metrics = _compute_final_metrics(