mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
Checkpoint Odysseus local update
This commit is contained in:
+15
-2
@@ -92,8 +92,21 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
||||
|
||||
# Deep research jobs, not quick conceptual mentions of research.
|
||||
("web", "explicit web search request", rf"{_PLEASE}(?:do|run|use|perform|make)\s+(?:a\s+)?(?:web\s+search|search\s+the\s+web)\b.+"),
|
||||
("web", "web lookup imperative request", rf"{_PLEASE}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
|
||||
("web", "assistant web lookup request", rf"{_ACTION_QUESTION}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
|
||||
("web", "generic search request", rf"{_PLEASE}search\s+(?!(?:my\s+)?(?:chats?|history|sessions?|notes?|todos?|emails?|mail|inbox|documents?|docs|gallery|images?|files?)\b).+"),
|
||||
("web", "web lookup imperative request", rf"{_PLEASE}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google(?:\s+it)?)\b.*"),
|
||||
("web", "short web lookup follow-up", rf"{_PLEASE}(?:just\s+)?(?:look\s+it\s+up|look\s+up|search\s+(?:online|web|now)|search\s+it)\b\s*$"),
|
||||
("web", "assistant short web lookup request", rf"{_ACTION_QUESTION}(?:search|look\s+up|google)(?:\s+(?:online|web|now|it))?\b.*"),
|
||||
("web", "assistant web lookup request", rf"{_ACTION_QUESTION}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google(?:\s+it)?)\b.*"),
|
||||
("web", "assistant weather check request", rf"{_ACTION_QUESTION}(?:check|find|get|look\s+up)\b.{{0,100}}\b(?:weather|forecast)\b.*"),
|
||||
("web", "news lookup request", r"\b(?:news|headlines)\s+(?:in|from|about|for)\s+[\w\s.-]{2,80}\??\s*$"),
|
||||
("web", "forecast lookup request", r"\b(?:hourly|daily|weekly|local)\s+(?:weather\s+)?forecast\b|\b(?:weather\s+)?forecast\s+(?:for|today|tomorrow|now|hourly)\b"),
|
||||
("web", "weather lookup request", r"\bweather\b.{0,80}\b(?:hourly|rain|raining|rin|today|tomorrow|update|current|now)\b|\b(?:hourly|rain|raining|rin)\b.{0,80}\bweather\b"),
|
||||
("web", "rain lookup request", r"\b(?:hourly|daily|weekly|local|today|tomorrow|current|now|update)\b.{0,100}\b(?:rain|raining|rainy|precipitation|showers?)\b|\b(?:rain|raining|rainy|precipitation|showers?)\b.{0,100}\b(?:hourly|daily|weekly|local|today|tomorrow|current|now|update|in|for|at)\b"),
|
||||
("web", "bare weather lookup request", r"\b(?:weather|forecast)\s+(?:in|for|at)?\s*[\w\s.-]{2,80}\??\s*$|\b[\w\s.-]{2,80}\s+(?:weather|forecast)\??\s*$"),
|
||||
("web", "latest info lookup request", r"\b(?:latest|current|newest|recent|up(?: |-)?to(?: |-)?date)\s+(?:info|information|updates?|details?|developments?)\s+(?:on|about|for|in)\s+[\w\s.,:'\"/-]{2,120}\??\s*$"),
|
||||
("web", "current/latest lookup request", r"\b(?:current|latest|today'?s?|right\s+now|live|online)\b.{0,120}\b(?:rate|price|news|weather|forecast|score|exchange|market|status)\b"),
|
||||
("web", "rate/price/news lookup request", r"\b(?:rate|rates|price|prices|news|weather|forecast|score|exchange|currency|market)\b.{0,120}\b(?:now|today|current|latest|online|live|search|look\s+up|find)\b"),
|
||||
("web", "conversion-rate lookup request", r"\b(?:convert|conversion|exchange)\b.{0,120}\b(?:rate|rates|currency|currencies|price|prices)\b"),
|
||||
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
|
||||
|
||||
+500
-32
@@ -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(
|
||||
|
||||
@@ -130,15 +130,70 @@ def _looks_like_email_document(text: str = "", title: str = "") -> bool:
|
||||
return True
|
||||
return bool(_re.search(r"(?im)^To:\s*", s) and _re.search(r"(?im)^Subject:\s*", s))
|
||||
|
||||
def _split_email_header_body(text: str) -> tuple[str, str]:
|
||||
if "\n---\n" in (text or ""):
|
||||
header, body = (text or "").split("\n---\n", 1)
|
||||
return header.rstrip(), body.strip()
|
||||
return (text or "").strip(), ""
|
||||
|
||||
def _split_email_reply_history(body: str) -> tuple[str, str]:
|
||||
"""Split draft body from quoted/original email history.
|
||||
|
||||
Email reply docs keep the original thread below the user's new reply. Models
|
||||
often rewrite only the fresh reply body; this helper keeps the historical
|
||||
block from being wiped when update_document/edit_document replaces content.
|
||||
"""
|
||||
text = body or ""
|
||||
literal = "---------- Previous message ----------"
|
||||
literal_idx = text.find(literal)
|
||||
if literal_idx >= 0:
|
||||
return text[:literal_idx].strip(), text[literal_idx:].strip()
|
||||
patterns = [
|
||||
r"(?m)^On .+ wrote:\s*$",
|
||||
r"(?m)^> .+",
|
||||
]
|
||||
starts = []
|
||||
for pat in patterns:
|
||||
m = re.search(pat, text)
|
||||
if m:
|
||||
starts.append(m.start())
|
||||
if not starts:
|
||||
return text.strip(), ""
|
||||
idx = min(starts)
|
||||
return text[:idx].strip(), text[idx:].strip()
|
||||
|
||||
def _merge_email_headers(old_header: str, new_header: str) -> str:
|
||||
"""Preserve routing/threading metadata if a model omits it."""
|
||||
protected = (
|
||||
"In-Reply-To", "References", "X-Source-UID", "X-Source-Folder",
|
||||
"X-Attachments", "X-Forward-Attachments",
|
||||
)
|
||||
lines = [l for l in (new_header or "").splitlines() if l.strip()]
|
||||
present = {l.split(":", 1)[0].strip().lower() for l in lines if ":" in l}
|
||||
for old_line in (old_header or "").splitlines():
|
||||
if ":" not in old_line:
|
||||
continue
|
||||
key = old_line.split(":", 1)[0].strip()
|
||||
if key in protected and key.lower() not in present:
|
||||
lines.append(old_line)
|
||||
present.add(key.lower())
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
def _coerce_email_document_content(existing: str, incoming: str) -> str:
|
||||
"""Keep email docs in the To/Subject/---/body shape even if a model writes
|
||||
only the body or dumps header labels without the separator."""
|
||||
import re as _re
|
||||
old = existing or ""
|
||||
new = (incoming or "").strip()
|
||||
old_header, old_body = _split_email_header_body(old)
|
||||
_, old_history = _split_email_reply_history(old_body)
|
||||
if "\n---\n" in new:
|
||||
return new
|
||||
header = old.split("\n---\n", 1)[0] if "\n---\n" in old else "To: \nSubject: "
|
||||
new_header, new_body = _split_email_header_body(new)
|
||||
new_own, new_history = _split_email_reply_history(new_body)
|
||||
if old_history and not new_history:
|
||||
new_body = (new_own + "\n\n" + old_history).strip()
|
||||
return _merge_email_headers(old_header, new_header).rstrip() + "\n---\n" + new_body
|
||||
header = old_header if old_header else "To: \nSubject: "
|
||||
if _looks_like_email_document(new):
|
||||
lines = new.splitlines()
|
||||
last_header_idx = -1
|
||||
@@ -152,6 +207,9 @@ def _coerce_email_document_content(existing: str, incoming: str) -> str:
|
||||
body = "\n".join(body_lines).strip()
|
||||
else:
|
||||
body = new
|
||||
_, incoming_history = _split_email_reply_history(body)
|
||||
if old_history and not incoming_history:
|
||||
body = (body.strip() + "\n\n" + old_history).strip()
|
||||
return header.rstrip() + "\n---\n" + body
|
||||
|
||||
def parse_edit_blocks(content: str) -> list:
|
||||
@@ -463,6 +521,42 @@ class EditDocumentTool:
|
||||
if not doc:
|
||||
return {"error": "No documents exist to edit"}
|
||||
|
||||
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
|
||||
blank_find_edits = [e for e in edits if not (e.get("find") or "").strip()]
|
||||
if blank_find_edits:
|
||||
if is_email_doc:
|
||||
replacement_body = (blank_find_edits[0].get("replace") or "").strip()
|
||||
if not replacement_body:
|
||||
return {"error": "No edits applied — blank FIND block had no replacement text"}
|
||||
updated_content = _coerce_email_document_content(doc.current_content or "", replacement_body)
|
||||
applied = 1
|
||||
skipped = max(0, len(edits) - 1)
|
||||
doc.language = "email"
|
||||
new_ver = doc.version_count + 1
|
||||
ver = DocumentVersion(
|
||||
id=str(uuid.uuid4()),
|
||||
document_id=target_id,
|
||||
version_number=new_ver,
|
||||
content=updated_content,
|
||||
summary=f"Edited email body by {_active_model or 'AI'}",
|
||||
source="ai",
|
||||
)
|
||||
doc.current_content = updated_content
|
||||
doc.version_count = new_ver
|
||||
db.add(ver)
|
||||
db.commit()
|
||||
return {
|
||||
"action": "edit",
|
||||
"doc_id": target_id,
|
||||
"title": doc.title,
|
||||
"language": doc.language,
|
||||
"content": updated_content,
|
||||
"version": new_ver,
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
}
|
||||
return {"error": "No edits applied — FIND text cannot be blank"}
|
||||
|
||||
updated_content = doc.current_content
|
||||
applied = 0
|
||||
skipped = 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
@@ -109,8 +110,20 @@ class WebFetchTool:
|
||||
url = "https://" + url
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
def _fetch():
|
||||
kwargs = {"timeout": 10}
|
||||
try:
|
||||
sig = inspect.signature(fetch_webpage_content)
|
||||
if "max_bytes" in sig.parameters:
|
||||
kwargs["max_bytes"] = max_bytes
|
||||
except (TypeError, ValueError):
|
||||
# Some deployed/test shims may not expose a signature.
|
||||
# Prefer compatibility over failing the whole fetch.
|
||||
pass
|
||||
return fetch_webpage_content(url, **kwargs)
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, lambda: fetch_webpage_content(url, timeout=10, max_bytes=max_bytes)),
|
||||
loop.run_in_executor(None, _fetch),
|
||||
timeout=30,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
@@ -47,6 +47,33 @@ def _endpoint_cached_models(ep) -> list:
|
||||
return models if isinstance(models, list) else []
|
||||
|
||||
|
||||
def _endpoint_pinned_models(ep) -> list:
|
||||
raw = getattr(ep, "pinned_models", None)
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
models = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
return []
|
||||
return models if isinstance(models, list) else []
|
||||
|
||||
|
||||
def _is_mlx_deepseek_v4_repo_id(model_id: str) -> bool:
|
||||
return "mlx-community/deepseek-v4" in str(model_id or "").lower()
|
||||
|
||||
|
||||
def _is_mlx_deepseek_v4_shim_id(model_id: str) -> bool:
|
||||
return "/.cache/odysseus/mlx-shims/deepseek-v4" in str(model_id or "").lower()
|
||||
|
||||
|
||||
def _filter_mlx_deepseek_v4_repo_when_shimmed(model_ids) -> list:
|
||||
ids = list(model_ids or [])
|
||||
has_shim = any(_is_mlx_deepseek_v4_shim_id(m) for m in ids)
|
||||
if not has_shim:
|
||||
return ids
|
||||
return [m for m in ids if not _is_mlx_deepseek_v4_repo_id(m)]
|
||||
|
||||
|
||||
def _endpoint_hidden_models(ep) -> set:
|
||||
"""Model ids the admin disabled on this endpoint (the UI's hidden list)."""
|
||||
raw = getattr(ep, "hidden_models", None)
|
||||
@@ -67,7 +94,15 @@ def _endpoint_enabled_models(ep) -> list:
|
||||
raw first one resolves to a model that 400s ("requires terms acceptance").
|
||||
"""
|
||||
hidden = _endpoint_hidden_models(ep)
|
||||
return [m for m in _endpoint_cached_models(ep) if m not in hidden]
|
||||
merged = []
|
||||
seen = set()
|
||||
for m in [*_endpoint_cached_models(ep), *_endpoint_pinned_models(ep)]:
|
||||
if not isinstance(m, str) or not m or m in seen:
|
||||
continue
|
||||
seen.add(m)
|
||||
merged.append(m)
|
||||
merged = _filter_mlx_deepseek_v4_repo_when_shimmed(merged)
|
||||
return [m for m in merged if m not in hidden]
|
||||
|
||||
|
||||
def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Optional[str]]:
|
||||
|
||||
+17
-7
@@ -17,6 +17,7 @@ _ACTIVE_REQUESTS = 0
|
||||
_LAST_ACTIVITY = 0.0
|
||||
_LAST_BROWSER_ACTIVITY = 0.0
|
||||
_COND: asyncio.Condition | None = None
|
||||
_COND_LOOP: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
@@ -47,14 +48,20 @@ def _browser_active_seconds() -> float:
|
||||
|
||||
|
||||
def _condition() -> asyncio.Condition:
|
||||
global _COND
|
||||
if _COND is None:
|
||||
global _COND, _COND_LOOP
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
if _COND is None or _COND_LOOP is not loop:
|
||||
_COND = asyncio.Condition()
|
||||
_COND_LOOP = loop
|
||||
return _COND
|
||||
|
||||
|
||||
_PASSIVE_EXACT_PATHS = {
|
||||
"/api/activity/heartbeat",
|
||||
"/api/client-perf",
|
||||
"/api/tasks/notifications",
|
||||
"/api/research/active",
|
||||
"/api/email/urgency-state",
|
||||
@@ -100,15 +107,18 @@ def _has_recent_browser_activity(now: float | None = None) -> bool:
|
||||
def has_foreground_activity(now: float | None = None) -> bool:
|
||||
"""Return True when foreground browser/model work should stop background jobs.
|
||||
|
||||
This is intentionally narrower than `wait_for_interactive_quiet`: active
|
||||
request tracking is good for delaying task startup, but a running task
|
||||
should not cancel itself just because the UI polls a passive endpoint.
|
||||
Browser heartbeats and active chat streams are the durable "user is here"
|
||||
signals.
|
||||
Passive polling endpoints are excluded by should_track_interactive_request,
|
||||
so active/recent request tracking is safe to use here. This matters during
|
||||
initial page load: the heartbeat may not have landed yet, but the user is
|
||||
already waiting on real UI requests.
|
||||
"""
|
||||
if not _enabled():
|
||||
return False
|
||||
t = now if now is not None else time.monotonic()
|
||||
if _ACTIVE_REQUESTS > 0:
|
||||
return True
|
||||
if _LAST_ACTIVITY > 0 and (t - _LAST_ACTIVITY) < _quiet_seconds():
|
||||
return True
|
||||
return _has_recent_browser_activity(t) or _has_active_chat_stream()
|
||||
|
||||
|
||||
|
||||
+248
-5
@@ -8,13 +8,88 @@ import hashlib
|
||||
import threading
|
||||
import re
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import HTTPException
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from src.model_context import get_context_length, DEFAULT_CONTEXT
|
||||
from src.model_context import get_context_length, DEFAULT_CONTEXT, is_local_endpoint
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCAL_MODEL_LOCK = asyncio.Lock()
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND = 0
|
||||
_LOCAL_MODEL_CURRENT: Dict[str, object] = {}
|
||||
|
||||
|
||||
def _local_model_gate_enabled() -> bool:
|
||||
return os.getenv("ODYSSEUS_LOCAL_MODEL_GATE", "true").lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _gate_workload(workload: Optional[str]) -> str:
|
||||
return "background" if str(workload or "").lower() == "background" else "foreground"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _local_model_slot(target_url: str, model: str, workload: Optional[str] = None):
|
||||
"""Serialize local model traffic, with foreground chat taking priority.
|
||||
|
||||
Most local servers expose one GPU/CPU generation pipe even when their HTTP
|
||||
API accepts multiple requests. Letting scheduled email/tasks and foreground
|
||||
chat hit that pipe together creates the user-visible "streams crossed" and
|
||||
"prompt waited behind a task" failure mode. Cloud providers are left alone.
|
||||
"""
|
||||
if not _local_model_gate_enabled() or not is_local_endpoint(target_url):
|
||||
yield
|
||||
return
|
||||
|
||||
global _LOCAL_MODEL_WAITING_FOREGROUND
|
||||
kind = _gate_workload(workload)
|
||||
current_task = asyncio.current_task()
|
||||
if kind == "foreground":
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND += 1
|
||||
current = dict(_LOCAL_MODEL_CURRENT)
|
||||
if current.get("workload") == "background":
|
||||
task = current.get("task")
|
||||
if isinstance(task, asyncio.Task) and not task.done():
|
||||
logger.info(
|
||||
"[model-gate] cancelling background local model call for foreground request model=%s",
|
||||
model,
|
||||
)
|
||||
task.cancel()
|
||||
else:
|
||||
# Background work should not jump in while the browser/chat is active
|
||||
# or while a foreground request is waiting to acquire the local model.
|
||||
try:
|
||||
from src.interactive_gate import has_foreground_activity
|
||||
except Exception:
|
||||
has_foreground_activity = lambda: False # type: ignore
|
||||
while _LOCAL_MODEL_WAITING_FOREGROUND > 0 or has_foreground_activity():
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
acquired = False
|
||||
try:
|
||||
await _LOCAL_MODEL_LOCK.acquire()
|
||||
acquired = True
|
||||
if kind == "foreground":
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND = max(0, _LOCAL_MODEL_WAITING_FOREGROUND - 1)
|
||||
_LOCAL_MODEL_CURRENT.clear()
|
||||
_LOCAL_MODEL_CURRENT.update({
|
||||
"task": current_task,
|
||||
"workload": kind,
|
||||
"url": target_url,
|
||||
"model": model,
|
||||
"started": time.time(),
|
||||
})
|
||||
yield
|
||||
finally:
|
||||
if kind == "foreground":
|
||||
_LOCAL_MODEL_WAITING_FOREGROUND = max(0, _LOCAL_MODEL_WAITING_FOREGROUND - 1)
|
||||
if acquired and _LOCAL_MODEL_LOCK.locked():
|
||||
owner = _LOCAL_MODEL_CURRENT.get("task")
|
||||
if owner is current_task:
|
||||
_LOCAL_MODEL_CURRENT.clear()
|
||||
_LOCAL_MODEL_LOCK.release()
|
||||
|
||||
class LLMConfig:
|
||||
"""Configuration constants for LLM operations."""
|
||||
DEFAULT_TIMEOUT = 30
|
||||
@@ -199,6 +274,75 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
|
||||
payload["thinking"] = True
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
|
||||
|
||||
|
||||
class _DegenerateStreamGuard:
|
||||
"""Detect local-model token collapse before it floods the UI.
|
||||
|
||||
Some self-hosted models fail by repeating one token forever ("Var Var Var",
|
||||
"Summer Summer ..."). This is not a useful response and can burn context,
|
||||
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
||||
same-token runs or a very dominant repeated token in the recent window.
|
||||
"""
|
||||
|
||||
def __init__(self, model: str):
|
||||
self.model = model or "model"
|
||||
self.last_token = ""
|
||||
self.same_run = 0
|
||||
self.recent_tokens: List[str] = []
|
||||
self.total_chars = 0
|
||||
|
||||
def check(self, text: str) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
self.total_chars += len(text)
|
||||
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text) if len(t) >= 2]
|
||||
if not tokens:
|
||||
return None
|
||||
for token in tokens:
|
||||
if token == self.last_token:
|
||||
self.same_run += 1
|
||||
else:
|
||||
self.last_token = token
|
||||
self.same_run = 1
|
||||
self.recent_tokens.append(token)
|
||||
if len(self.recent_tokens) > 96:
|
||||
self.recent_tokens = self.recent_tokens[-96:]
|
||||
|
||||
reason = None
|
||||
if self.same_run >= 28 and self.total_chars >= 100:
|
||||
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||
elif len(self.recent_tokens) >= 72:
|
||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||
count = self.recent_tokens.count(top)
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
|
||||
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
||||
if not reason and len(self.recent_tokens) >= 80:
|
||||
# Phrase loops are common on some local quantized MLX/MoE models:
|
||||
# "Also be a software developer mode?" repeated forever will not
|
||||
# trip the single-token guard above, but it is still a wedged
|
||||
# generation. Require many repeats of the same 4-gram so normal
|
||||
# prose/list formatting is not interrupted.
|
||||
grams = [tuple(self.recent_tokens[i:i + 4]) for i in range(0, len(self.recent_tokens) - 3)]
|
||||
if grams:
|
||||
top_gram = max(set(grams), key=grams.count)
|
||||
gram_count = grams.count(top_gram)
|
||||
if gram_count >= 10:
|
||||
reason = f"repeated phrase '{' '.join(top_gram)}' {gram_count} times"
|
||||
|
||||
if not reason:
|
||||
return None
|
||||
|
||||
logger.warning("[degenerate-stream] aborting model=%s reason=%s", self.model, reason)
|
||||
message = (
|
||||
f"Stopped generation: {self.model} started repeating tokens "
|
||||
f"({reason}). Try a different model or lower temperature."
|
||||
)
|
||||
return f'event: error\ndata: {json.dumps({"status": 502, "text": message, "error": message})}\n\n'
|
||||
|
||||
|
||||
def _model_activity_key(url: str, model: str) -> str:
|
||||
return f"{(url or '').strip()}|{(model or '').strip()}"
|
||||
|
||||
@@ -755,6 +899,52 @@ def _apply_local_cache_affinity(payload: Dict, url: str, session_id: Optional[st
|
||||
payload.setdefault("cache_prompt", True)
|
||||
|
||||
|
||||
def _is_local_minimax_mlx_request(url: str, model: str) -> bool:
|
||||
"""Local MLX MiniMax-family endpoints need conservative sampling defaults.
|
||||
|
||||
The OpenAI-compatible MLX server accepts repetition/frequency penalties.
|
||||
Some large quantized MiniMax/MoE ports otherwise fall into visible reasoning
|
||||
loops ("Also be...", "No.", etc.) even for trivial prompts.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
m = model.lower()
|
||||
if "minimax" not in m and "mini-max" not in m:
|
||||
return False
|
||||
try:
|
||||
from src.model_context import is_local_endpoint
|
||||
return is_local_endpoint(url)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _apply_local_generation_stability(payload: Dict, url: str, model: str) -> None:
|
||||
if not _is_local_minimax_mlx_request(url, model):
|
||||
return
|
||||
if "temperature" in payload:
|
||||
try:
|
||||
# MiniMax MLX quantized ports are very sensitive to chat/agent
|
||||
# harness size. Character presets can ask for a warmer voice, but
|
||||
# local MiniMax needs a final compatibility clamp or trivial
|
||||
# prompts can fall into visible reasoning/repetition loops.
|
||||
payload["temperature"] = min(float(payload.get("temperature") or 0.2), 0.2)
|
||||
except (TypeError, ValueError):
|
||||
payload["temperature"] = 0.2
|
||||
payload.setdefault("top_p", 0.9)
|
||||
payload.setdefault("top_k", 20)
|
||||
payload.setdefault("repetition_penalty", 1.12)
|
||||
payload.setdefault("repetition_context_size", 256)
|
||||
payload.setdefault("frequency_penalty", 0.08)
|
||||
payload.setdefault("frequency_context_size", 256)
|
||||
payload.setdefault("presence_penalty", 0.02)
|
||||
payload.setdefault("presence_context_size", 256)
|
||||
payload.setdefault("stop", ["<|im_end|>", "<|endoftext|>", "</s>"])
|
||||
# A max_tokens of 0 means "server default/unbounded" for many local
|
||||
# endpoints. Keep simple chats from running forever when the model loops.
|
||||
if not payload.get("max_tokens") and not payload.get("max_completion_tokens"):
|
||||
payload["max_tokens"] = 2048
|
||||
|
||||
|
||||
def _provider_headers(provider: str, headers: Optional[Dict] = None) -> Dict[str, str]:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if isinstance(headers, dict):
|
||||
@@ -1602,6 +1792,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
if max_tokens and max_tokens > 0:
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
payload[tok_key] = max_tokens
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
try:
|
||||
@@ -1711,6 +1902,7 @@ async def llm_call_async(
|
||||
max_retries: int = LLMConfig.MAX_RETRIES,
|
||||
prompt_type: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
workload: str = "foreground",
|
||||
) -> str:
|
||||
"""Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging."""
|
||||
provider = _detect_provider(url)
|
||||
@@ -1748,6 +1940,7 @@ async def llm_call_async(
|
||||
max_tokens=max_tokens,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
workload=workload,
|
||||
):
|
||||
event_is_error = False
|
||||
for line in str(chunk).splitlines():
|
||||
@@ -1813,6 +2006,7 @@ async def llm_call_async(
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
_apply_local_cache_affinity(payload, url, session_id)
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
|
||||
if _is_host_dead(target_url):
|
||||
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
||||
@@ -1823,9 +2017,10 @@ async def llm_call_async(
|
||||
attempt += 1
|
||||
start = time.time()
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
client = _get_http_client()
|
||||
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||
async with _local_model_slot(target_url, model, workload):
|
||||
note_model_activity(target_url, model)
|
||||
client = _get_http_client()
|
||||
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||
duration = time.time() - start
|
||||
if not r.is_success:
|
||||
friendly = _format_upstream_error(r.status_code, r.text, target_url)
|
||||
@@ -1867,11 +2062,45 @@ async def llm_call_async(
|
||||
raise HTTPException(502, f"POST {target_url} failed after {max_retries} attempts: {e}")
|
||||
await asyncio.sleep(LLMConfig.RETRY_DELAY)
|
||||
|
||||
def _stream_target_url(url: str) -> str:
|
||||
provider = _detect_provider(url)
|
||||
if provider == "anthropic":
|
||||
return _normalize_anthropic_url(url)
|
||||
if provider == "ollama":
|
||||
return _normalize_ollama_url(url)
|
||||
if provider == "chatgpt-subscription":
|
||||
return _normalize_chatgpt_subscription_url(url)
|
||||
return _normalize_openai_chat_url(url)
|
||||
|
||||
|
||||
async def stream_llm(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
||||
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
||||
tool_choice_none: bool = False):
|
||||
tool_choice_none: bool = False, workload: str = "foreground"):
|
||||
target_url = _stream_target_url(url)
|
||||
async with _local_model_slot(target_url, model, workload):
|
||||
async for chunk in _stream_llm_inner(
|
||||
url,
|
||||
model,
|
||||
messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
prompt_type=prompt_type,
|
||||
tools=tools,
|
||||
session_id=session_id,
|
||||
tool_choice_none=tool_choice_none,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
||||
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
||||
tool_choice_none: bool = False):
|
||||
"""Stream LLM responses with improved error handling.
|
||||
|
||||
Yields SSE chunks:
|
||||
@@ -1945,6 +2174,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
|
||||
payload["think"] = False
|
||||
_apply_local_cache_affinity(payload, url, session_id)
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
@@ -1961,6 +2191,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
yield f'event: error\ndata: {json.dumps({"error": f"Upstream {_host_key(target_url)} unreachable (cooldown active)", "status": 503})}\n\n'
|
||||
return
|
||||
note_model_activity(target_url, model)
|
||||
degenerate_guard = _DegenerateStreamGuard(model)
|
||||
|
||||
# ── ChatGPT Subscription / Codex Responses streaming ──
|
||||
if provider == "chatgpt-subscription":
|
||||
@@ -1995,6 +2226,10 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
if evt == "response.output_text.delta":
|
||||
delta = data.get("delta") or ""
|
||||
if delta:
|
||||
_degenerate = degenerate_guard.check(delta)
|
||||
if _degenerate:
|
||||
yield _degenerate
|
||||
return
|
||||
yield f'data: {json.dumps({"delta": delta})}\n\n'
|
||||
elif evt == "response.completed":
|
||||
usage = (data.get("response") or {}).get("usage") or data.get("usage") or {}
|
||||
@@ -2327,11 +2562,19 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
|
||||
content = text_part
|
||||
if reasoning:
|
||||
_degenerate = degenerate_guard.check(reasoning)
|
||||
if _degenerate:
|
||||
yield _degenerate
|
||||
return
|
||||
yield _stream_delta_event(reasoning, thinking=True)
|
||||
if content:
|
||||
content = _strip_visible_chat_template_artifacts(content)
|
||||
if not content:
|
||||
continue
|
||||
_degenerate = degenerate_guard.check(content)
|
||||
if _degenerate:
|
||||
yield _degenerate
|
||||
return
|
||||
content = re.sub(r"<mm:think(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
|
||||
stripped = content.lstrip()
|
||||
|
||||
@@ -74,4 +74,5 @@ async def task_llm_call_async(
|
||||
if not candidates:
|
||||
raise RuntimeError("No LLM endpoint available for background task")
|
||||
await wait_for_interactive_quiet("background task LLM")
|
||||
kwargs.setdefault("workload", "background")
|
||||
return await llm_call_async_with_fallback(candidates, messages=messages, **kwargs)
|
||||
|
||||
+25
-2
@@ -868,10 +868,10 @@ class TaskScheduler:
|
||||
# Give the just-finished quiet gate a tiny grace window,
|
||||
# then keep enforcing "background means background" while
|
||||
# a long email/LLM action is already running.
|
||||
await asyncio.sleep(1.0)
|
||||
await asyncio.sleep(0.1)
|
||||
from src.interactive_gate import has_foreground_activity
|
||||
while True:
|
||||
await asyncio.sleep(1.0)
|
||||
await asyncio.sleep(0.25)
|
||||
if has_foreground_activity():
|
||||
foreground_cancel["hit"] = True
|
||||
logger.info("Task '%s' interrupted because Odysseus became active", task.name)
|
||||
@@ -1887,6 +1887,7 @@ class TaskScheduler:
|
||||
disabled_tools=disabled_tools,
|
||||
relevant_tools=relevant_tools,
|
||||
fallbacks=_task_fallbacks,
|
||||
workload="background",
|
||||
):
|
||||
if event_str.startswith("data: ") and not event_str.startswith("data: [DONE]"):
|
||||
try:
|
||||
@@ -2213,6 +2214,28 @@ class TaskScheduler:
|
||||
stopped = self._mark_run_aborted(task_id) or stopped
|
||||
return stopped
|
||||
|
||||
async def stop_background_tasks_for_foreground(self, *, reason: str = "Odysseus became active") -> int:
|
||||
"""Cancel all in-process scheduler tasks because the user is active.
|
||||
|
||||
This is intentionally blunt for scheduled/background work: when the
|
||||
user opens or uses Odysseus, foreground interaction wins immediately.
|
||||
Manual force-runs can be restarted by the user; automatic jobs will be
|
||||
deferred by their cancellation path instead of stealing the app.
|
||||
"""
|
||||
async with self._executing_lock:
|
||||
task_ids = list(self._executing)
|
||||
stopped = 0
|
||||
for task_id in task_ids:
|
||||
handle = self._task_handles.get(task_id)
|
||||
if handle and not handle.done():
|
||||
handle.cancel()
|
||||
stopped += 1
|
||||
if self._mark_run_aborted(task_id):
|
||||
stopped += 1
|
||||
if stopped:
|
||||
logger.info("Stopped %d background scheduler task(s): %s", stopped, reason)
|
||||
return stopped
|
||||
|
||||
async def ensure_defaults(self, owner: str):
|
||||
"""Create default housekeeping tasks for this owner (idempotent per action)."""
|
||||
from core.database import SessionLocal, ScheduledTask
|
||||
|
||||
+4
-2
@@ -405,8 +405,10 @@ class ToolIndex:
|
||||
{"chat_with_model", "ask_teacher", "list_models"},
|
||||
# Deep research intent (incl. common typo "reserach")
|
||||
frozenset({"web search", "search the web", "search online", "look up",
|
||||
"google", "latest", "current", "news", "weather",
|
||||
"forecast", "stock price", "price of"}):
|
||||
"find info online", "find information online",
|
||||
"find info", "find information", "online about",
|
||||
"on the internet", "google", "latest", "current", "news",
|
||||
"weather", "forecast", "stock price", "price of"}):
|
||||
{"web_search", "web_fetch"},
|
||||
frozenset({"research", "reserach", "reasearch", "look into", "investigate",
|
||||
"deep dive", "deep research", "find out about", "study up on",
|
||||
|
||||
@@ -186,6 +186,12 @@ _FUNCTION_MODEL_NAME_RE = re.compile(
|
||||
)
|
||||
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"<parameters>\s*", re.IGNORECASE)
|
||||
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r"</parameters>", re.IGNORECASE)
|
||||
_QWEN_ROLE_MARKER_RE = re.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", re.IGNORECASE)
|
||||
_QWEN_BARE_MARKER_RE = re.compile(
|
||||
r"(?:^|[\t\r\n ])(?:\|?end\|?|/?\|end\|)(?=[\t\r\n ]|$)|"
|
||||
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
# Pattern 5: DeepSeek DSML markup leaking into content. When deepseek
|
||||
@@ -581,6 +587,205 @@ def _parse_raw_web_json_lookup(text: str) -> Optional[tuple[ToolBlock, tuple[int
|
||||
return block, (start, start + end)
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_openai_tool_call_blob(value) -> bool:
|
||||
"""Return True for raw OpenAI-style tool-call JSON leaked as text."""
|
||||
if isinstance(value, list):
|
||||
return bool(value) and all(_looks_like_openai_tool_call_blob(item) for item in value)
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
fn = value.get("function")
|
||||
if isinstance(fn, dict) and isinstance(fn.get("name"), str):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _raw_openai_tool_call_to_block(value) -> Optional[ToolBlock]:
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
block = _raw_openai_tool_call_to_block(item)
|
||||
if block:
|
||||
return block
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
fn = value.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
return None
|
||||
name = str(fn.get("name") or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
tool_type = _TOOL_NAME_MAP.get(name, name)
|
||||
raw_args = fn.get("arguments") or {}
|
||||
try:
|
||||
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
# Common local-model typo seen in raw OpenAI JSON leaks.
|
||||
if "text" not in args and "tex" in args:
|
||||
args["text"] = args.get("tex")
|
||||
|
||||
if tool_type.startswith("mcp__"):
|
||||
return ToolBlock(tool_type, json.dumps(args) if args else "{}")
|
||||
if name in BUILTIN_EMAIL_TOOLS:
|
||||
return ToolBlock(f"mcp__email__{name}", json.dumps(args) if args else "{}")
|
||||
if tool_type not in TOOL_TAGS:
|
||||
return None
|
||||
|
||||
if tool_type == "bash":
|
||||
content = args.get("command", "")
|
||||
elif tool_type == "python":
|
||||
content = args.get("code", "")
|
||||
elif tool_type == "web_search":
|
||||
content = args.get("query", "")
|
||||
queries = args.get("queries")
|
||||
if not content and isinstance(queries, list) and queries:
|
||||
content = str(queries[0])
|
||||
elif not content and queries:
|
||||
content = str(queries)
|
||||
tf = args.get("time_filter")
|
||||
if content and isinstance(tf, str) and tf in ("day", "week", "month", "year"):
|
||||
content = json.dumps({"query": content, "time_filter": tf})
|
||||
elif tool_type == "web_fetch":
|
||||
content = args.get("url") or args.get("domain") or ""
|
||||
elif tool_type == "read_file":
|
||||
content = json.dumps(args) if (args.get("offset") or args.get("limit")) else args.get("path", "")
|
||||
elif tool_type in ("grep", "glob", "ls", "edit_file"):
|
||||
content = json.dumps(args) if args else "{}"
|
||||
elif tool_type == "write_file":
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
elif tool_type == "create_document":
|
||||
parts = [args.get("title", "Untitled")]
|
||||
if args.get("language"):
|
||||
parts.append(args["language"])
|
||||
parts.append(args.get("content", ""))
|
||||
content = "\n".join(parts)
|
||||
elif tool_type == "update_document":
|
||||
content = args.get("content", "")
|
||||
elif tool_type in ("edit_document", "suggest_document"):
|
||||
marker = "SUGGEST" if tool_type == "suggest_document" else "REPLACE"
|
||||
blocks = []
|
||||
for edit in args.get("suggestions" if tool_type == "suggest_document" else "edits", []) or []:
|
||||
if not isinstance(edit, dict):
|
||||
continue
|
||||
block = f'<<<FIND>>>\n{edit.get("find", "")}\n<<<{marker}>>>\n{edit.get("replace", "")}'
|
||||
if tool_type == "suggest_document":
|
||||
block += f'\n<<<REASON>>>\n{edit.get("reason", "")}'
|
||||
blocks.append(block + "\n<<<END>>>")
|
||||
content = "\n".join(blocks)
|
||||
elif tool_type == "search_chats":
|
||||
content = args.get("query", "")
|
||||
elif tool_type == "chat_with_model":
|
||||
content = args.get("model", "") + "\n" + args.get("message", "")
|
||||
elif tool_type == "create_session":
|
||||
content = args.get("name", "Untitled") + "\n" + args.get("model", "")
|
||||
elif tool_type == "list_sessions":
|
||||
content = args.get("filter", "")
|
||||
elif tool_type == "send_to_session":
|
||||
content = args.get("session_id", "") + "\n" + args.get("message", "")
|
||||
elif tool_type == "pipeline":
|
||||
content = json.dumps({"steps": args.get("steps", [])})
|
||||
elif tool_type == "manage_session":
|
||||
action = args.get("action", "")
|
||||
if action == "list":
|
||||
keyword = args.get("keyword", "") or args.get("value", "")
|
||||
content = "list" + (("\n" + keyword) if keyword and keyword.lower() != "current" else "")
|
||||
else:
|
||||
content = action + "\n" + args.get("session_id", "current")
|
||||
if args.get("value"):
|
||||
content += "\n" + args["value"]
|
||||
elif tool_type == "manage_memory":
|
||||
action = args.get("action", "")
|
||||
if action == "add":
|
||||
content = "add\n" + str(args.get("text", ""))
|
||||
if args.get("category"):
|
||||
content += "\n" + str(args["category"])
|
||||
elif action == "edit":
|
||||
content = "edit\n" + str(args.get("memory_id", "")) + "\n" + str(args.get("text", ""))
|
||||
elif action == "delete":
|
||||
content = "delete\n" + str(args.get("memory_id", ""))
|
||||
elif action == "search":
|
||||
content = "search\n" + str(args.get("text", ""))
|
||||
elif action == "list":
|
||||
content = "list" + (("\n" + str(args["category"])) if args.get("category") else "")
|
||||
else:
|
||||
content = action
|
||||
elif tool_type == "ui_control":
|
||||
action = args.get("action", "")
|
||||
name_arg = args.get("name", "")
|
||||
value = args.get("value", "")
|
||||
if action == "open_panel":
|
||||
content = f"open_panel {name_arg or value}"
|
||||
elif action == "toggle":
|
||||
content = f"toggle {name_arg} {value}"
|
||||
else:
|
||||
content = action
|
||||
elif tool_type in ("manage_tasks", "manage_skills", "api_call", "manage_endpoints",
|
||||
"manage_mcp", "manage_webhooks", "manage_tokens",
|
||||
"manage_documents", "manage_settings", "manage_notes",
|
||||
"manage_research", "manage_bg_jobs"):
|
||||
content = json.dumps(args)
|
||||
elif tool_type in ("get_workspace", "list_models"):
|
||||
content = args.get("filter", "") if tool_type == "list_models" else ""
|
||||
else:
|
||||
content = json.dumps(args) if args else ""
|
||||
return ToolBlock(tool_type, str(content or ""))
|
||||
|
||||
|
||||
def _parse_raw_openai_tool_call_json(text: str) -> Optional[ToolBlock]:
|
||||
if not isinstance(text, str) or '"function"' not in text:
|
||||
return None
|
||||
decoder = json.JSONDecoder()
|
||||
for match in re.finditer(r"[\[{]", text):
|
||||
try:
|
||||
parsed, _end = decoder.raw_decode(text[match.start():])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
block = _raw_openai_tool_call_to_block(parsed)
|
||||
if block:
|
||||
return block
|
||||
return None
|
||||
|
||||
|
||||
def _strip_raw_openai_tool_call_json(text: str) -> str:
|
||||
"""Strip raw JSON tool calls such as {"function": {...}, "type": "function"}.
|
||||
|
||||
Some local models emit native tool-call JSON into assistant text. The agent
|
||||
can still parse/execute it through the native path, but the raw payload must
|
||||
not render or persist as prose.
|
||||
"""
|
||||
if not isinstance(text, str) or '"function"' not in text:
|
||||
return text
|
||||
decoder = json.JSONDecoder()
|
||||
pieces = []
|
||||
pos = 0
|
||||
changed = False
|
||||
for match in re.finditer(r"[\[{]", text):
|
||||
start = match.start()
|
||||
if start < pos:
|
||||
continue
|
||||
try:
|
||||
parsed, rel_end = decoder.raw_decode(text[start:])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
end = start + rel_end
|
||||
if not _looks_like_openai_tool_call_blob(parsed):
|
||||
continue
|
||||
pieces.append(text[pos:start])
|
||||
pos = end
|
||||
changed = True
|
||||
# Common broken local-model suffix: a standalone ] before a role marker.
|
||||
while pos < len(text) and text[pos] in " \t\r\n":
|
||||
pos += 1
|
||||
if pos < len(text) and text[pos] == "]":
|
||||
pos += 1
|
||||
if not changed:
|
||||
return text
|
||||
pieces.append(text[pos:])
|
||||
return "".join(pieces)
|
||||
|
||||
def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
|
||||
"""Parse a [TOOL_CALL] block into a ToolBlock.
|
||||
|
||||
@@ -1178,6 +1383,13 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||
if block:
|
||||
blocks.append(block)
|
||||
|
||||
# Pattern 4d: raw OpenAI-style tool-call JSON leaked as assistant text.
|
||||
# Example: {"function":{"arguments":"{\"action\":\"add\"}","name":"manage_memory"},"type":"function"}
|
||||
if not blocks:
|
||||
block = _parse_raw_openai_tool_call_json(text)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
|
||||
# Pattern 6: local text-model web_search call leaked as prose + bare JSON.
|
||||
if not blocks and not skip_fenced:
|
||||
raw_web_json = _parse_raw_web_json_lookup(text)
|
||||
@@ -1225,6 +1437,9 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
|
||||
cleaned = _strip_delimited(cleaned, _TOOL_CODE_OPEN_RE, _TOOL_CODE_CLOSE_RE)
|
||||
cleaned = _GEMMA_TOOL_CALL_RE.sub('', cleaned)
|
||||
cleaned = _strip_delimited(cleaned, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE)
|
||||
cleaned = _strip_raw_openai_tool_call_json(cleaned)
|
||||
cleaned = _QWEN_ROLE_MARKER_RE.sub('', cleaned)
|
||||
cleaned = _QWEN_BARE_MARKER_RE.sub(' ', cleaned)
|
||||
if not skip_fenced:
|
||||
raw_web_json = _parse_raw_web_json_lookup(cleaned)
|
||||
if raw_web_json:
|
||||
|
||||
+27
-7
@@ -18,6 +18,15 @@ from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_REQUIRED_NATIVE_TOOL_ARGS = {
|
||||
"web_search": ("query", "queries"),
|
||||
"web_fetch": ("url",),
|
||||
"read_file": ("path",),
|
||||
"write_file": ("path",),
|
||||
"edit_file": ("path",),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI-compatible function tool schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -576,9 +585,10 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string",
|
||||
"enum": ["list", "view", "add", "update", "delete", "toggle_item"],
|
||||
"enum": ["list", "search", "view", "add", "update", "delete", "toggle_item"],
|
||||
"description": "The action to perform"},
|
||||
"id": {"type": "string", "description": "Note id (for update/delete/toggle_item); 8-char prefix is fine"},
|
||||
"query": {"type": "string", "description": "Search text for action='search'"},
|
||||
"title": {"type": "string", "description": "Note title (for add/update)"},
|
||||
"content": {"type": "string", "description": "Freeform body text. Use this for note_type='note'. Do NOT use this for checklists — pass `checklist_items` instead."},
|
||||
"note_type": {"type": "string", "enum": ["note", "checklist"],
|
||||
@@ -1025,7 +1035,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "manage_contact",
|
||||
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact, update an existing one (email/phone/address), or remove one. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
|
||||
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact, update an existing one (email/phone/address), or remove one. Add does not require email: name + phone or name + address is valid. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1033,9 +1043,9 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"description": "list = show all contacts (with uids); add = create; update = edit by uid; delete = remove by uid."},
|
||||
"uid": {"type": "string", "description": "Contact UID (required for update/delete; get it from action=list)."},
|
||||
"name": {"type": "string", "description": "Contact's display name (for add/update)."},
|
||||
"email": {"type": "string", "description": "Single email address (convenience for add, or the primary email for update)."},
|
||||
"emails": {"type": "array", "items": {"type": "string"}, "description": "Full list of email addresses (for update; first is primary)."},
|
||||
"phones": {"type": "array", "items": {"type": "string"}, "description": "Full list of phone numbers (for update)."},
|
||||
"email": {"type": "string", "description": "Single email address (convenience for add, or the primary email for update). Optional when phone or address is provided."},
|
||||
"emails": {"type": "array", "items": {"type": "string"}, "description": "Full list of email addresses (first is primary)."},
|
||||
"phones": {"type": "array", "items": {"type": "string"}, "description": "Full list of phone numbers. Valid for add/update."},
|
||||
"address": {"type": "string", "description": "Postal/mailing address as a single human-readable string."},
|
||||
},
|
||||
"required": ["action"]
|
||||
@@ -1308,6 +1318,11 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
logger.warning(f"Non-object function call arguments for {name}: {args!r}; treating as empty")
|
||||
args = {}
|
||||
|
||||
required_args = _REQUIRED_NATIVE_TOOL_ARGS.get(tool_type)
|
||||
if required_args and not any(str(args.get(key) or "").strip() for key in required_args):
|
||||
logger.warning(f"Rejecting empty required arguments for function call {name}: {args!r}")
|
||||
return None
|
||||
|
||||
# Allow MCP tools through (namespaced as mcp__serverid__toolname)
|
||||
if tool_type.startswith("mcp__"):
|
||||
content = json.dumps(args) if args else "{}"
|
||||
@@ -1415,15 +1430,20 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
elif tool_type == "manage_memory":
|
||||
action = args.get("action", "")
|
||||
if action == "add":
|
||||
content = "add\n" + args.get("text", "")
|
||||
text = args.get("text") or args.get("value") or args.get("content") or ""
|
||||
if not text and args.get("key"):
|
||||
text = str(args.get("key") or "")
|
||||
content = "add\n" + str(text)
|
||||
if args.get("category"):
|
||||
content += "\n" + args["category"]
|
||||
elif args.get("key"):
|
||||
content += "\n" + str(args["key"])
|
||||
elif action == "edit":
|
||||
content = "edit\n" + args.get("memory_id", "") + "\n" + args.get("text", "")
|
||||
elif action == "delete":
|
||||
content = "delete\n" + args.get("memory_id", "")
|
||||
elif action == "search":
|
||||
content = "search\n" + args.get("text", "")
|
||||
content = "search\n" + (args.get("text") or args.get("tex") or args.get("query") or "")
|
||||
elif action == "list":
|
||||
content = "list"
|
||||
if args.get("category"):
|
||||
|
||||
+23
-10
@@ -108,16 +108,28 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
if action == "add":
|
||||
email = (args.get("email") or "").strip()
|
||||
if not email:
|
||||
return {"error": "email is required for add", "exit_code": 1}
|
||||
name = (args.get("name") or "").strip() or email.split("@")[0]
|
||||
# Dedupe by email (same as the /add route).
|
||||
phones = [str(p or "").strip() for p in (args.get("phones") or []) if str(p or "").strip()]
|
||||
phone = (args.get("phone") or "").strip()
|
||||
if phone and phone not in phones:
|
||||
phones.insert(0, phone)
|
||||
address = (args.get("address") or "").strip()
|
||||
name = (args.get("name") or "").strip()
|
||||
if not name and email:
|
||||
name = email.split("@")[0]
|
||||
if not name and not email and not phones and not address:
|
||||
return {"error": "name plus email, phone, or address is required for add", "exit_code": 1}
|
||||
if not name:
|
||||
name = email.split("@")[0] if email else (phones[0] if phones else "Contact")
|
||||
# Dedupe by email or phone (same as the /add route).
|
||||
existing = await asyncio.to_thread(cc._fetch_contacts)
|
||||
for c in existing:
|
||||
if email.lower() in [e.lower() for e in c.get("emails", [])]:
|
||||
if email and email.lower() in [e.lower() for e in c.get("emails", [])]:
|
||||
return {"output": f"{email} is already a contact ({c.get('name','')}).", "exit_code": 0}
|
||||
ok = await asyncio.to_thread(cc._create_contact, name, email)
|
||||
return {"output": f"{'Added' if ok else 'Failed to add'} {name} <{email}>.", "exit_code": 0 if ok else 1}
|
||||
if phones and any(p in (c.get("phones") or []) for p in phones):
|
||||
return {"output": f"{phones[0]} is already a contact ({c.get('name','')}).", "exit_code": 0}
|
||||
ok = await asyncio.to_thread(cc._create_contact, name, email, address, phones)
|
||||
detail = email or ", ".join(phones) or address
|
||||
return {"output": f"{'Added' if ok else 'Failed to add'} {name} ({detail}).", "exit_code": 0 if ok else 1}
|
||||
|
||||
if action in ("update", "edit"):
|
||||
uid = (args.get("uid") or "").strip()
|
||||
@@ -129,11 +141,12 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
|
||||
emails = [args["email"]]
|
||||
emails = [e.strip() for e in (emails or []) if e and e.strip()]
|
||||
phones = [p.strip() for p in (args.get("phones") or []) if p and p.strip()]
|
||||
if not name and not emails:
|
||||
return {"error": "Provide a name or emails to update", "exit_code": 1}
|
||||
address = (args.get("address") or "").strip()
|
||||
if not name and not emails and not phones and not address:
|
||||
return {"error": "Provide a name, emails, phones, or address to update", "exit_code": 1}
|
||||
if not name and emails:
|
||||
name = emails[0].split("@")[0]
|
||||
ok = await asyncio.to_thread(cc._update_contact, uid, name, emails, phones)
|
||||
ok = await asyncio.to_thread(cc._update_contact, uid, name, emails, phones, address)
|
||||
return {"output": "Contact updated." if ok else "Update failed.", "exit_code": 0 if ok else 1}
|
||||
|
||||
if action == "delete":
|
||||
|
||||
@@ -315,6 +315,7 @@ async def _cookbook_register_task(
|
||||
_MODEL_PROCESS_PATTERNS = [
|
||||
("vLLM", ["vllm.entrypoints", "vllm serve", "/vllm/", "vllm-openai"]),
|
||||
("SGLang", ["sglang.launch_server", "sglang/launch_server"]),
|
||||
("MLX", ["mlx_lm.server", "mlx-lm"]),
|
||||
("llama.cpp", ["llama-server", "llama_cpp_server", "llamacppserver"]),
|
||||
("Ollama", ["ollama serve", "ollama runner", "/ollama "]),
|
||||
("ComfyUI", ["comfyui/main.py", "/ComfyUI/main.py", "ComfyUI"]),
|
||||
@@ -590,7 +591,7 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
|
||||
hint = ""
|
||||
if isinstance(err_msg, str) and "cmd" in err_msg.lower():
|
||||
hint = (" — the cmd must START with an allowlisted binary "
|
||||
"(vllm, python3, llama-server, ollama, sglang, lmdeploy, node, npx). "
|
||||
"(vllm, python3, llama-server, ollama, sglang, mlx_lm, lmdeploy, node, npx). "
|
||||
"Do NOT prefix with `cd …`, `source …`, or chain with `&&`. "
|
||||
"env_prefix (e.g. `source ~/qwen35-env/bin/activate`) is added "
|
||||
"automatically from the host's saved venv settings.")
|
||||
@@ -635,7 +636,7 @@ async def do_list_served_models(content: str, owner: Optional[str] = None) -> Di
|
||||
|
||||
if not merged:
|
||||
return {
|
||||
"output": "No model servers currently running (cookbook task tracker empty; /proc scan found no vLLM / sglang / llama.cpp / Ollama / ComfyUI / A1111 / Fooocus / InvokeAI / TGI / Aphrodite / Triton / Diffusers processes).",
|
||||
"output": "No model servers currently running (cookbook task tracker empty; /proc scan found no vLLM / sglang / MLX / llama.cpp / Ollama / ComfyUI / A1111 / Fooocus / InvokeAI / TGI / Aphrodite / Triton / Diffusers processes).",
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
|
||||
+76
-25
@@ -27,7 +27,8 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
# Action aliases — match what models actually emit. `create` is the most
|
||||
# common alternative to `add`. Hyphenated forms also accepted.
|
||||
action = (args.get("action") or "").replace("-", "_").strip().lower()
|
||||
raw_action = (args.get("action") or "").replace("-", "_").strip().lower()
|
||||
action = raw_action
|
||||
_NOTE_ACTION_ALIASES = {
|
||||
"create": "add",
|
||||
"new": "add",
|
||||
@@ -60,37 +61,68 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
q = q.filter(Note.owner == owner)
|
||||
return q.first()
|
||||
|
||||
def _format_note_list(notes) -> str:
|
||||
lines = []
|
||||
for n in notes:
|
||||
pin = " [PINNED]" if n.pinned else ""
|
||||
typ = " [checklist]" if n.note_type == "checklist" else ""
|
||||
lbl = f" #{n.label}" if n.label else ""
|
||||
title = n.title or "(untitled)"
|
||||
lines.append(f"- [{n.id[:8]}] **{title}**{pin}{typ}{lbl}")
|
||||
if n.note_type == "checklist" and n.items:
|
||||
try:
|
||||
items = json.loads(n.items)
|
||||
for i, item in enumerate(items):
|
||||
mark = "x" if item.get("done") else " "
|
||||
lines.append(f" [{mark}] {i}: {item.get('text', '')}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
elif n.content:
|
||||
snippet = n.content[:80].replace("\n", " ")
|
||||
lines.append(f" {snippet}")
|
||||
return "\n".join(lines)
|
||||
|
||||
try:
|
||||
if action == "list":
|
||||
if action in ("list", "search", "find"):
|
||||
q = db.query(Note)
|
||||
if owner is not None:
|
||||
q = q.filter(Note.owner == owner)
|
||||
if args.get("label"):
|
||||
q = q.filter(Note.label == args["label"])
|
||||
label_filter = str(args.get("label") or "").strip()
|
||||
if label_filter and label_filter.lower() != "default":
|
||||
q = q.filter(Note.label == label_filter)
|
||||
show_archived = args.get("archived", False)
|
||||
q = q.filter(Note.archived == show_archived)
|
||||
notes = q.order_by(Note.pinned.desc(), Note.updated_at.desc()).all()
|
||||
if action in ("search", "find"):
|
||||
query = str(
|
||||
args.get("query")
|
||||
or args.get("text")
|
||||
or args.get("title")
|
||||
or args.get("content")
|
||||
or ""
|
||||
).strip().lower()
|
||||
if query:
|
||||
filtered = []
|
||||
for n in notes:
|
||||
haystack = " ".join(
|
||||
str(part or "")
|
||||
for part in (n.title, n.content, n.label, n.items)
|
||||
).lower()
|
||||
if query in haystack:
|
||||
filtered.append(n)
|
||||
notes = filtered
|
||||
if not notes:
|
||||
return {"response": "No notes found.", "exit_code": 0}
|
||||
lines = []
|
||||
for n in notes:
|
||||
pin = " [PINNED]" if n.pinned else ""
|
||||
typ = " [checklist]" if n.note_type == "checklist" else ""
|
||||
lbl = f" #{n.label}" if n.label else ""
|
||||
title = n.title or "(untitled)"
|
||||
lines.append(f"- [{n.id[:8]}] **{title}**{pin}{typ}{lbl}")
|
||||
if n.note_type == "checklist" and n.items:
|
||||
try:
|
||||
items = json.loads(n.items)
|
||||
for i, item in enumerate(items):
|
||||
mark = "x" if item.get("done") else " "
|
||||
lines.append(f" [{mark}] {i}: {item.get('text', '')}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
elif n.content:
|
||||
snippet = n.content[:80].replace("\n", " ")
|
||||
lines.append(f" {snippet}")
|
||||
return {"results": "\n".join(lines)}
|
||||
return {"results": _format_note_list(notes), "exit_code": 0}
|
||||
|
||||
elif action == "view":
|
||||
note_id = args.get("id", "")
|
||||
note = _note_by_prefix(note_id)
|
||||
if not note:
|
||||
return {"error": f"Note '{note_id}' not found", "exit_code": 1}
|
||||
if not _note_visible_to_owner(note, owner):
|
||||
return {"error": "Note not found", "exit_code": 1}
|
||||
return {"results": _format_note_list([note]), "exit_code": 0}
|
||||
|
||||
elif action == "add":
|
||||
# Accept the various field names models emit: `text` is the most
|
||||
@@ -120,6 +152,25 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
# `new Date()` resolves the right absolute moment regardless of
|
||||
# where the user is.
|
||||
due_raw = args.get("due_date")
|
||||
if not due_raw:
|
||||
combined_text = " ".join(
|
||||
str(v or "")
|
||||
for v in (title, content_raw, text_raw)
|
||||
).strip()
|
||||
lower_combined = combined_text.lower()
|
||||
looks_like_reminder = (
|
||||
raw_action in {"remind", "reminder"}
|
||||
or re.search(r"\bremind(?:er)?\b", lower_combined)
|
||||
)
|
||||
if looks_like_reminder:
|
||||
temporal = re.search(
|
||||
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",
|
||||
lower_combined,
|
||||
)
|
||||
if temporal:
|
||||
due_raw = temporal.group(0)
|
||||
due_iso = None
|
||||
if due_raw:
|
||||
try:
|
||||
@@ -170,7 +221,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
# link with no target, leaving the user with a click that
|
||||
# did nothing and uncertainty about whether the note was made.
|
||||
return {
|
||||
"response": f"Note created: \"{title or '(untitled)'}\" (id: {note.id[:8]})",
|
||||
"response": f"{'Reminder' if due_iso else 'Note'} created: \"{title or '(untitled)'}\" (id: {note.id[:8]})",
|
||||
"note_id": note.id,
|
||||
"note_title": title or "",
|
||||
"open_url": f"/#open=notes¬e={note.id}",
|
||||
@@ -246,7 +297,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return {"response": f"Item '{items[index].get('text', '')}' marked {mark}", "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}. Use list/add/update/delete/toggle_item", "exit_code": 1}
|
||||
return {"error": f"Unknown action: {action}. Use list/search/view/add/update/delete/toggle_item", "exit_code": 1}
|
||||
except Exception as e:
|
||||
logger.error(f"manage_notes error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@ async def do_search_chats(query: str, limit: int = 20, owner: str | None = None)
|
||||
|
||||
lines = [f"Found {len(seen_sessions)} session(s) matching \"{query}\":\n"]
|
||||
for sid, result in seen_sessions.items():
|
||||
lines.append(f"- **{result.session_name}** (#{sid})")
|
||||
lines.append(f" Link: [Open chat](#{sid})")
|
||||
lines.append(f"- [**{result.session_name}**](#session-{sid})")
|
||||
lines.append(f" Open: [Open chat](#session-{sid})")
|
||||
lines.append(f" Match ({result.role}): {result.content_snippet}")
|
||||
if result.context_before:
|
||||
before = result.context_before[-1]
|
||||
|
||||
@@ -538,6 +538,11 @@ _APP_API_BLOCKLIST_METHOD_PATH = (
|
||||
# sidebar surfaces the session. Raw start works but the agent
|
||||
# fumbles the payload + the session doesn't reliably show up.
|
||||
("POST", "/api/research/start"),
|
||||
# Use web_search — the HTTP search route is UI-shaped and generic
|
||||
# app_api calls can return empty/poorly formatted results compared with the
|
||||
# named tool's source-aware output.
|
||||
("GET", "/api/search"),
|
||||
("POST", "/api/search"),
|
||||
# Use the named tools — they handle owner attribution, natural-
|
||||
# language due_date parsing, timezone, dedup, and tag/category
|
||||
# normalization. Hitting the raw endpoint via app_api saves a
|
||||
@@ -653,6 +658,8 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return {"error": "Don't POST /api/model/serve directly — use the `serve_model` or `serve_preset` tool (handles host resolution, env_prefix, and cookbook tracking).", "exit_code": 1}
|
||||
if "/api/research/start" in path:
|
||||
return {"error": "Don't POST /api/research/start directly — use the `trigger_research` tool (it surfaces the session in the Deep Research sidebar).", "exit_code": 1}
|
||||
if "/api/search" in path:
|
||||
return {"error": "Don't hit /api/search via app_api — use the `web_search` tool for online lookups, or `web_fetch` for a specific URL.", "exit_code": 1}
|
||||
if "/api/notes" in path:
|
||||
return {"error": "Don't hit /api/notes via app_api — use the `manage_notes` tool. It accepts natural-language due_date ('11pm today', 'tomorrow at 9am'), fires reminders from the due_date itself (no separate calendar event), and uses the caller's timezone. The raw endpoint requires ISO-UTC + a separate calendar event, both of which the agent tends to get wrong.", "exit_code": 1}
|
||||
if "/api/calendar/events" in path:
|
||||
|
||||
Reference in New Issue
Block a user