mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-10 12:17:11 +00:00
Improve document agent streaming and chat metrics
This commit is contained in:
+265
-20
@@ -15,7 +15,11 @@ import logging
|
||||
from typing import AsyncGenerator, List, Dict, Optional, Set
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from src.llm_core import stream_llm, stream_llm_with_fallback, _is_ollama_native_url
|
||||
from src.llm_core import (
|
||||
stream_llm,
|
||||
stream_llm_with_fallback,
|
||||
_is_ollama_native_url,
|
||||
)
|
||||
from src.model_context import estimate_tokens
|
||||
from src.settings import get_setting
|
||||
from src.prompt_security import untrusted_context_message
|
||||
@@ -576,11 +580,13 @@ def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool
|
||||
tool_lines = []
|
||||
for name, _default_section in TOOL_SECTIONS.items():
|
||||
if name in included:
|
||||
tool_lines.append(_compact_tool_line(name, _section_text(name, _default_section)))
|
||||
tool_lines.append(f"- `{name}`")
|
||||
parts = [
|
||||
_AGENT_PREAMBLE,
|
||||
"You are an AI assistant with native tool/function calling. "
|
||||
"Only the tool schemas provided by the API are available for this turn. "
|
||||
"Use native tool calls when action is needed; do not write tool syntax or tool instructions in chat.",
|
||||
"## Available tools\n" + ("\n".join(tool_lines) if tool_lines else "none"),
|
||||
_AGENT_RULES,
|
||||
_API_AGENT_RULES,
|
||||
]
|
||||
parts.extend(_domain_rules_for_tools(included))
|
||||
return "\n\n".join(parts)
|
||||
@@ -973,6 +979,11 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
|
||||
domains.add("notes_calendar_tasks")
|
||||
if has(r"\b(calendar|event|meeting|appointment|schedule)\b"):
|
||||
domains.add("notes_calendar_tasks")
|
||||
_code_write_intent = has(
|
||||
r"\b(?:python|javascript|typescript|java|c\+\+|cpp|c#|csharp|rust|go|golang|"
|
||||
r"ruby|php|swift|kotlin|bash|shell|html|css|sql)\b",
|
||||
r"\b(?:code|script|program|game|function|class|module|app)\b",
|
||||
)
|
||||
if has(r"\b(documents?|docs?|draft|compose|poem|story|essay|outline|letter|edit|rewrite|proofread|suggest|feedback|review this|make a file)\b"):
|
||||
domains.add("documents")
|
||||
if "notes_calendar_tasks" not in domains and has(r"\bwrite\b"):
|
||||
@@ -991,7 +1002,18 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
|
||||
domains.add("ui")
|
||||
if has(r"\b(session|chat history|rename chat|delete chat|archive chat|fork chat|list chats)\b"):
|
||||
domains.add("sessions")
|
||||
if has(r"\b(file|folder|directory|repo|git|grep|find in files|read file|edit file|shell|terminal|bash|python)\b"):
|
||||
if has(r"\b(file|folder|directory|repo|git|grep|find in files|read file|edit file|shell|terminal|bash)\b"):
|
||||
domains.add("files")
|
||||
if has(
|
||||
r"\b(run|execute|test|debug|fix|save|create|edit|read|open)\b.{0,40}\b("
|
||||
r"python|javascript|typescript|java|c\+\+|cpp|c#|csharp|rust|go|golang|"
|
||||
r"ruby|php|swift|kotlin|bash|shell|html|css|sql|code|script|program|game"
|
||||
r")\b",
|
||||
r"\b("
|
||||
r"python|javascript|typescript|java|c\+\+|cpp|c#|csharp|rust|go|golang|"
|
||||
r"ruby|php|swift|kotlin|bash|shell|html|css|sql"
|
||||
r")\b.{0,40}\b(file|script|program|app)\b",
|
||||
):
|
||||
domains.add("files")
|
||||
# Managing detached bash jobs: "kill the background job", "stop the job",
|
||||
# "kill that job", "check the job output", "is the bg job done".
|
||||
@@ -1022,6 +1044,128 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
|
||||
}
|
||||
|
||||
|
||||
def _turn_targets_active_document(intent: Dict[str, object], last_user: str, active_document) -> bool:
|
||||
"""Return whether an open document should affect this turn.
|
||||
|
||||
The editor can stay open while the user asks unrelated things ("who am I?",
|
||||
"search news"). In those cases injecting document context/tools makes small
|
||||
models overfit to the visible document and call suggest/edit tools. Keep the
|
||||
active document only for explicit document domains or common document-edit
|
||||
continuations.
|
||||
"""
|
||||
if active_document is None:
|
||||
return False
|
||||
if "documents" in (intent.get("domains") or set()):
|
||||
return True
|
||||
text = str(last_user or "").strip().lower()
|
||||
if not text:
|
||||
return False
|
||||
return bool(re.search(
|
||||
r"\b("
|
||||
r"document|doc|draft|text|poem|story|essay|outline|letter|paragraph|"
|
||||
r"stanza|line|title|heading|section|sentence|word|caps|uppercase|"
|
||||
r"lowercase|rewrite|reword|style|tone|suggest|suggestions|feedback|"
|
||||
r"improve|edit|change|remove|delete|replace|add another|append|"
|
||||
r"original text|in the document|the document|this document"
|
||||
r")\b",
|
||||
text,
|
||||
))
|
||||
|
||||
|
||||
def _minimal_saved_memory_message(messages: List[Dict]) -> Optional[Dict]:
|
||||
facts: List[str] = []
|
||||
seen = set()
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
metadata = message.get("metadata") if isinstance(message, dict) else None
|
||||
source = str((metadata or {}).get("source") or "")
|
||||
if not source.startswith("saved memory:"):
|
||||
continue
|
||||
content = str(message.get("content") or "")
|
||||
content = re.sub(r"(?m)^\s*Source:\s*saved memory:[^\n]*\n?", "", content)
|
||||
content = content.replace("Core facts about the user:", "")
|
||||
content = re.sub(
|
||||
r"Memory context\. Do not reference unless the user asks about these topics\.\s*",
|
||||
"",
|
||||
content,
|
||||
)
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("- "):
|
||||
continue
|
||||
fact = line[2:].strip()
|
||||
if not fact or fact in seen:
|
||||
continue
|
||||
seen.add(fact)
|
||||
facts.append(fact)
|
||||
if len(facts) >= 12:
|
||||
break
|
||||
if len(facts) >= 12:
|
||||
break
|
||||
if not facts:
|
||||
return None
|
||||
logger.info("[agent-intent] odysseus doc minimal memory facts=%s", len(facts))
|
||||
return {
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Saved user memory facts from Odysseus Brain. These are the same "
|
||||
"user facts available in the normal prompt path. Use them when "
|
||||
"the user asks for personalization, identity, background, "
|
||||
"preferences, or anything about \"me\" or \"my\":\n"
|
||||
+ "\n".join(f"- {fact}" for fact in facts)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream_create: bool = False) -> List[Dict]:
|
||||
"""Tiny prompt path for the Odysseus document LoRA.
|
||||
|
||||
This model is trained on document tool behavior, so avoid the normal agent
|
||||
rule stack and send only the task plus the active document when editing.
|
||||
"""
|
||||
latest = _extract_last_user_message(messages)
|
||||
if stream_create:
|
||||
system = (
|
||||
"You are Odysseus. Create the requested document by streaming exactly one fenced block:\n"
|
||||
"```document\n"
|
||||
"Title\n"
|
||||
"markdown\n"
|
||||
"Document content\n"
|
||||
"```\n"
|
||||
"Do not use function calls or tool calls. Do not write anything before the fence. "
|
||||
"Use saved user memory facts when the user asks for something relating to them."
|
||||
)
|
||||
else:
|
||||
system = (
|
||||
"You are Odysseus. Use the provided function when it is needed. "
|
||||
"After a successful tool call, answer briefly."
|
||||
)
|
||||
out = [{"role": "system", "content": system}]
|
||||
memory_message = _minimal_saved_memory_message(messages)
|
||||
if memory_message:
|
||||
out.append(memory_message)
|
||||
if active_document is not None:
|
||||
content = active_document.current_content or ""
|
||||
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}"
|
||||
),
|
||||
})
|
||||
out.append({"role": "user", "content": latest})
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_stream_document_fences(text: str) -> str:
|
||||
"""Treat visible ```document blocks as create_document tool blocks."""
|
||||
return re.sub(r"```document(\s*\n)", r"```create_document\1", text or "")
|
||||
|
||||
|
||||
def _recent_context_for_retrieval(messages: List[Dict], max_user: int = 3, max_chars: int = 600) -> str:
|
||||
"""Build the tool-retrieval query from the last few USER turns, not just
|
||||
the latest one.
|
||||
@@ -1674,7 +1818,13 @@ def _build_base_prompt(
|
||||
|
||||
|
||||
|
||||
def _resolve_tool_blocks(round_response: str, native_tool_calls: list, round_num: int, is_api_model: bool = False):
|
||||
def _resolve_tool_blocks(
|
||||
round_response: str,
|
||||
native_tool_calls: list,
|
||||
round_num: int,
|
||||
is_api_model: bool = False,
|
||||
allow_fenced_for_api: bool = False,
|
||||
):
|
||||
"""Choose native function calls or fenced code block parsing. Returns (tool_blocks, used_native)."""
|
||||
used_native = False
|
||||
converted_calls = [] # native calls that converted, ALIGNED with tool_blocks
|
||||
@@ -1707,7 +1857,7 @@ def _resolve_tool_blocks(round_response: str, native_tool_calls: list, round_num
|
||||
# falling back to DSML). Dropping the whole parser would silently lose
|
||||
# those too. Non-native / textual-only models keep every pattern,
|
||||
# fenced blocks included, since that's their *only* tool channel.
|
||||
tool_blocks = parse_tool_blocks(round_response, skip_fenced=is_api_model)
|
||||
tool_blocks = parse_tool_blocks(round_response, skip_fenced=(is_api_model and not allow_fenced_for_api))
|
||||
if tool_blocks:
|
||||
logger.info(f"Agent round {round_num}: {len(tool_blocks)} fenced tool block(s) detected")
|
||||
|
||||
@@ -2104,13 +2254,15 @@ async def stream_agent_loop(
|
||||
_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)
|
||||
_active_document_relevant = _turn_targets_active_document(_intent, _last_user, active_document)
|
||||
_prompt_active_document = active_document if _active_document_relevant else None
|
||||
_direct_low_signal = (
|
||||
_low_signal_turn
|
||||
and not bool(_intent.get("continuation"))
|
||||
and not plan_mode
|
||||
and not approved_plan
|
||||
and not guide_only
|
||||
and (_casual_low_signal_turn or active_document is None)
|
||||
and (_casual_low_signal_turn or not _active_document_relevant)
|
||||
and (_casual_low_signal_turn or not active_email)
|
||||
and (_casual_low_signal_turn or not workspace)
|
||||
and not forced_tools
|
||||
@@ -2120,11 +2272,12 @@ async def stream_agent_loop(
|
||||
# user turns only for explicit continuations ("yes", "do it", "1").
|
||||
_retrieval_query = str(_intent.get("retrieval_query") or _last_user)
|
||||
logger.info(
|
||||
"[agent-intent] latest=%r continuation=%s low_signal=%s domains=%s retrieval_query=%r",
|
||||
"[agent-intent] latest=%r continuation=%s low_signal=%s domains=%s active_doc_relevant=%s retrieval_query=%r",
|
||||
_last_user[:120],
|
||||
bool(_intent.get("continuation")),
|
||||
_low_signal_turn,
|
||||
sorted(_intent.get("domains") or []),
|
||||
_active_document_relevant,
|
||||
_retrieval_query[:200],
|
||||
)
|
||||
_mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {}
|
||||
@@ -2303,10 +2456,11 @@ async def stream_agent_loop(
|
||||
if "ui" in (_intent.get("domains") or set()):
|
||||
_relevant_tools.add("ui_control")
|
||||
|
||||
# If a document is open the model needs the editing tools available
|
||||
# regardless of which selection path (RAG, keyword, caller-provided) ran
|
||||
# or what keywords were in the latest user message.
|
||||
if _relevant_tools is not None and active_document is not None:
|
||||
# If this turn targets the open document, keep editing tools available
|
||||
# regardless of which selection path (RAG, keyword, caller-provided) ran.
|
||||
# Do not leak document tools into unrelated turns just because the editor
|
||||
# panel is open.
|
||||
if _relevant_tools is not None and _active_document_relevant:
|
||||
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
|
||||
|
||||
# Current-turn chat uploads are real files under the upload/data root. Make
|
||||
@@ -2365,6 +2519,23 @@ async def stream_agent_loop(
|
||||
except Exception as _e:
|
||||
logger.debug(f"[tool-rag] skill-aware tool include skipped: {_e}")
|
||||
|
||||
_ody_doc_finetune_mode = (
|
||||
(model or "").lower().startswith("odysseus-qwen3")
|
||||
and "documents" in (_intent.get("domains") or set())
|
||||
and "files" not in (_intent.get("domains") or set())
|
||||
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:
|
||||
_relevant_tools = {
|
||||
"edit_document", "update_document", "suggest_document",
|
||||
"ask_user", "update_plan",
|
||||
}
|
||||
else:
|
||||
_relevant_tools = {"create_document", "ask_user", "update_plan"}
|
||||
logger.info("[agent-intent] odysseus doc finetune tool clamp=%s", sorted(_relevant_tools))
|
||||
|
||||
if _relevant_tools is not None:
|
||||
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
|
||||
|
||||
@@ -2443,7 +2614,7 @@ async def stream_agent_loop(
|
||||
_is_api_model = any(h in endpoint_url for h in _API_HOSTS) or _model_supports_tools
|
||||
_compact_agent_prompt = _is_api_model or _is_ollama_native or _ollama_openai_compat
|
||||
messages, mcp_schemas = _build_system_prompt(
|
||||
messages, model, active_document, mcp_mgr, disabled_tools,
|
||||
messages, model, _prompt_active_document, mcp_mgr, disabled_tools,
|
||||
needs_admin=_needs_admin, relevant_tools=_relevant_tools,
|
||||
mcp_disabled_map=_mcp_disabled_map,
|
||||
compact=_compact_agent_prompt,
|
||||
@@ -2452,6 +2623,19 @@ async def stream_agent_loop(
|
||||
suppress_skills=_low_signal_turn,
|
||||
active_email=active_email,
|
||||
)
|
||||
if _ody_doc_finetune_mode and not plan_mode and not approved_plan and not guide_only:
|
||||
messages = _minimal_odysseus_doc_messages(
|
||||
messages,
|
||||
_prompt_active_document,
|
||||
stream_create=_ody_doc_stream_create_mode,
|
||||
)
|
||||
mcp_schemas = []
|
||||
logger.info(
|
||||
"[agent-intent] odysseus doc minimal prompt active active_doc=%s stream_create=%s messages=%s",
|
||||
bool(_prompt_active_document),
|
||||
_ody_doc_stream_create_mode,
|
||||
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
|
||||
@@ -2607,6 +2791,7 @@ async def stream_agent_loop(
|
||||
_doc_acc = "" # accumulated tool-call JSON arguments
|
||||
_doc_opened = False # whether doc_stream_open was sent
|
||||
_doc_last_len = 0 # last content length sent
|
||||
_doc_stream_create_completed = False
|
||||
|
||||
# Set when the loop runs out of rounds while the agent was still actively
|
||||
# using tools — i.e. it was cut off, not finished. Drives a "Continue" event
|
||||
@@ -2661,6 +2846,15 @@ 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_doc_stream_create_mode:
|
||||
all_tool_schemas = []
|
||||
else:
|
||||
_doc_schema_names = {"edit_document", "update_document", "suggest_document"}
|
||||
all_tool_schemas = [
|
||||
t for t in all_tool_schemas
|
||||
if t.get("function", {}).get("name") in _doc_schema_names
|
||||
]
|
||||
if disabled_tools:
|
||||
all_tool_schemas = [
|
||||
t for t in all_tool_schemas
|
||||
@@ -2706,6 +2900,7 @@ async def stream_agent_loop(
|
||||
max_tokens=max_tokens,
|
||||
prompt_type=prompt_type if round_num == 1 else None,
|
||||
tools=all_tool_schemas if all_tool_schemas else None,
|
||||
tool_choice_none=_ody_doc_stream_create_mode,
|
||||
timeout=agent_stream_timeout,
|
||||
session_id=session_id,
|
||||
):
|
||||
@@ -2832,14 +3027,26 @@ async def stream_agent_loop(
|
||||
round_response += data["delta"]
|
||||
full_response += data["delta"]
|
||||
yield chunk # Stream all rounds
|
||||
# Detect text-fence doc streaming for rounds 2+
|
||||
# (round 1 is handled by frontend fence detection + server fenced block path)
|
||||
# Detect text-fence doc streaming. Normal agent prompts
|
||||
# use ```create_document; the doc LoRA streaming path
|
||||
# uses neutral ```document to avoid triggering learned
|
||||
# hidden native tool-call output.
|
||||
if (
|
||||
round_num > 1
|
||||
(round_num > 1 or _ody_doc_stream_create_mode)
|
||||
and not _doc_acc
|
||||
and not (tool_policy and tool_policy.blocks("create_document"))
|
||||
):
|
||||
_fence_marker = '```create_document\n'
|
||||
_fence_markers = (
|
||||
('```document\n', 'document')
|
||||
if _ody_doc_stream_create_mode
|
||||
else ('```create_document\n',)
|
||||
)
|
||||
_fence_marker = None
|
||||
for _mk in _fence_markers:
|
||||
_candidate = _mk[0] if isinstance(_mk, tuple) else _mk
|
||||
if _candidate in round_response[_doc_scan_from:]:
|
||||
_fence_marker = _candidate
|
||||
break
|
||||
# Open a new block if we're not currently inside one
|
||||
# and there's an unstreamed marker in the response.
|
||||
# The marker search starts at the byte after the
|
||||
@@ -2847,7 +3054,7 @@ async def stream_agent_loop(
|
||||
# `create_document` block in the same round gets
|
||||
# detected (previously only the first one was
|
||||
# streamed and the rest were silently dropped).
|
||||
if not _doc_opened and _fence_marker in round_response[_doc_scan_from:]:
|
||||
if not _doc_opened and _fence_marker:
|
||||
_fi = round_response.index(_fence_marker, _doc_scan_from)
|
||||
_fa = round_response[_fi + len(_fence_marker):]
|
||||
_fl = _fa.split('\n')
|
||||
@@ -2900,11 +3107,36 @@ async def stream_agent_loop(
|
||||
_round_first_token_logged,
|
||||
)
|
||||
tool_blocks, used_native, converted_calls = _resolve_tool_blocks(
|
||||
round_response,
|
||||
_normalize_stream_document_fences(round_response) if _ody_doc_stream_create_mode else round_response,
|
||||
native_tool_calls,
|
||||
round_num,
|
||||
is_api_model=(_is_api_model and not guide_only),
|
||||
allow_fenced_for_api=_ody_doc_stream_create_mode,
|
||||
)
|
||||
if _ody_doc_stream_create_mode and tool_blocks:
|
||||
create_idx = next(
|
||||
(idx for idx, block in enumerate(tool_blocks) if block.tool_type == "create_document"),
|
||||
None,
|
||||
)
|
||||
if create_idx is None:
|
||||
logger.info(
|
||||
"[agent] odysseus doc stream-create discarded non-create tool call(s): %s",
|
||||
[block.tool_type for block in tool_blocks],
|
||||
)
|
||||
tool_blocks = []
|
||||
converted_calls = []
|
||||
else:
|
||||
if len(tool_blocks) > 1 or create_idx != 0:
|
||||
logger.info(
|
||||
"[agent] odysseus doc stream-create keeping first create_document and dropping extras: %s",
|
||||
[block.tool_type for block in tool_blocks],
|
||||
)
|
||||
tool_blocks = [tool_blocks[create_idx]]
|
||||
converted_calls = (
|
||||
[converted_calls[create_idx]]
|
||||
if create_idx < len(converted_calls)
|
||||
else converted_calls[:1]
|
||||
)
|
||||
|
||||
# Force-answer round: we told the model to STOP calling tools and
|
||||
# answer. If it ignored that and emitted a (possibly DSML) tool
|
||||
@@ -3528,6 +3760,12 @@ async def stream_agent_loop(
|
||||
formatted = format_tool_result(desc, result)
|
||||
tool_results.append(formatted)
|
||||
tool_result_texts.append(formatted)
|
||||
if (
|
||||
_ody_doc_stream_create_mode
|
||||
and block.tool_type == "create_document"
|
||||
and result.get("action") == "create"
|
||||
):
|
||||
_doc_stream_create_completed = True
|
||||
|
||||
# If budget was hit, stop the loop
|
||||
if budget_hit:
|
||||
@@ -3540,6 +3778,13 @@ async def stream_agent_loop(
|
||||
if _awaiting_user:
|
||||
break
|
||||
|
||||
if _doc_stream_create_completed:
|
||||
if not full_response.strip():
|
||||
full_response = "Done."
|
||||
yield 'data: ' + json.dumps({"delta": "Done."}) + '\n\n'
|
||||
logger.info("[agent] odysseus doc stream-create completed after one create_document")
|
||||
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
|
||||
|
||||
+5
-1
@@ -1361,6 +1361,7 @@ def _sanitize_llm_messages(messages: List[Dict]) -> List[Dict]:
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _normalize_anthropic_url(url: str) -> str:
|
||||
"""Ensure Anthropic URL points to /v1/messages."""
|
||||
url = url.rstrip("/")
|
||||
@@ -1845,7 +1846,8 @@ async def llm_call_async(
|
||||
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):
|
||||
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:
|
||||
@@ -1905,6 +1907,8 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
payload[tok_key] = max_tokens
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
elif tool_choice_none:
|
||||
payload["tool_choice"] = "none"
|
||||
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
||||
# activates thinking mode and returns structured reasoning_content.
|
||||
# Effort level is configurable via ODYSSEUS_MISTRAL_REASONING_EFFORT
|
||||
|
||||
+3
-3
@@ -219,7 +219,7 @@
|
||||
}, { once: true });
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260630toolmetrics">
|
||||
<link rel="modulepreload" href="/static/app.js">
|
||||
<link rel="modulepreload" href="/static/js/chat.js">
|
||||
<link rel="modulepreload" href="/static/js/ui.js">
|
||||
@@ -2499,10 +2499,10 @@
|
||||
<script type="module" src="/static/js/tts-ai.js"></script>
|
||||
<script type="module" src="/static/js/document.js"></script>
|
||||
<script type="module" src="/static/js/gallery.js"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260630toolmetrics"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260609ws"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260630toolmetrics"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script src="/static/js/cookbookSchedule.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
|
||||
+27
-7
@@ -1048,6 +1048,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
let roundHolder = holder; // Current AI text bubble (changes per round)
|
||||
let roundText = ''; // Text accumulated for current round
|
||||
let currentToolBubble = null; // Current tool execution bubble
|
||||
let lastToolThread = null; // Visible tool timeline for tool-only turns
|
||||
let roundFinalized = false; // Whether current round's text is finalized
|
||||
let _sourcesHtml = ''; // Sources box HTML to prepend to body
|
||||
let _sourcesExpanded = false; // Track if user expanded sources during stream
|
||||
@@ -1055,6 +1056,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
let _sourcesType = ''; // 'web' or 'research'
|
||||
let _findingsData = null; // Raw findings data for collapsible box
|
||||
// _keepResearchOn removed — clarification state now persisted server-side via DB mode
|
||||
function _metricsTargetForTurn() {
|
||||
const visibleRound = (roundHolder && roundHolder.style.display !== 'none') ? roundHolder : null;
|
||||
const visibleText = visibleRound ? (visibleRound.querySelector('.body')?.textContent || '').trim() : '';
|
||||
if (lastToolThread && lastToolThread.isConnected && (!visibleRound || !visibleText || visibleText === 'Done.')) {
|
||||
return lastToolThread;
|
||||
}
|
||||
return visibleRound || holder;
|
||||
}
|
||||
// Insert sources box as a stable DOM node that won't be replaced during streaming.
|
||||
// Returns the content container to use for innerHTML updates.
|
||||
function _ensureStreamLayout(body) {
|
||||
@@ -1332,7 +1341,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
_streamSawDone = true;
|
||||
// Always update background map if entry exists (even if user switched back)
|
||||
var bgDone = _backgroundStreams.get(streamSessionId);
|
||||
if (bgDone) {
|
||||
if (bgDone && !_isBg) {
|
||||
_backgroundStreams.delete(streamSessionId);
|
||||
} else if (bgDone) {
|
||||
bgDone.status = 'completed';
|
||||
bgDone.accumulated = accumulated;
|
||||
if (_isBg) {
|
||||
@@ -1456,9 +1467,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
}
|
||||
|
||||
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
|
||||
if (!_docFenceOpened && documentModule && roundText.includes('```create_document\n')) {
|
||||
const fenceIdx = roundText.indexOf('```create_document\n');
|
||||
const afterFence = roundText.slice(fenceIdx + '```create_document\n'.length);
|
||||
if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n'))) {
|
||||
const fenceMarker = roundText.includes('```document\n') ? '```document\n' : '```create_document\n';
|
||||
const fenceIdx = roundText.indexOf(fenceMarker);
|
||||
const afterFence = roundText.slice(fenceIdx + fenceMarker.length);
|
||||
const fenceLines = afterFence.split('\n');
|
||||
if (fenceLines.length >= 1 && fenceLines[0].trim()) {
|
||||
_docFenceOpened = true;
|
||||
@@ -1467,7 +1479,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
const knownLangs = ['python','py','javascript','js','typescript','ts','html','css','json','yaml','bash','sql','rust','go','java','c','cpp','markdown','text','plain','ruby','swift','kotlin','php','email','csv','xml','toml','ini'];
|
||||
const isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase());
|
||||
const lang = isLang ? fenceLines[1].trim() : '';
|
||||
_docFenceContentStart = fenceIdx + '```create_document\n'.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0);
|
||||
_docFenceContentStart = fenceIdx + fenceMarker.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0);
|
||||
documentModule.streamDocOpen(title, lang);
|
||||
}
|
||||
}
|
||||
@@ -2033,6 +2045,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (bgM) bgM.metrics = json.data;
|
||||
continue;
|
||||
}
|
||||
if (metrics) {
|
||||
const metricsTarget = _metricsTargetForTurn();
|
||||
if (metricsTarget) displayMetrics(metricsTarget, metrics);
|
||||
}
|
||||
|
||||
} else if (json.type === 'message_saved') {
|
||||
// Wire the persisted DB id onto the just-streamed bubble so it
|
||||
@@ -2113,6 +2129,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
chatBox.appendChild(threadWrap);
|
||||
}
|
||||
threadWrap.classList.add('streaming');
|
||||
lastToolThread = threadWrap;
|
||||
const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool;
|
||||
const toolIcon = _toolIcons[json.tool.toLowerCase()] || '\u25B6';
|
||||
const node = document.createElement('div')
|
||||
@@ -2471,6 +2488,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
}
|
||||
|
||||
_renderStream();
|
||||
if (spinner && spinner.element) { try { spinner.destroy(); } catch (_) {} spinner = null; }
|
||||
_cancelThinkingTimer();
|
||||
_removeThinkingSpinner();
|
||||
// Stop any thread pulse animations
|
||||
@@ -2650,7 +2668,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
|
||||
// Attach footer to the last visible bubble (roundHolder for multi-round agent, holder for single)
|
||||
const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder;
|
||||
footerTarget.appendChild(createMsgFooter(footerTarget));
|
||||
if (!footerTarget.querySelector('.msg-footer')) {
|
||||
footerTarget.appendChild(createMsgFooter(footerTarget));
|
||||
}
|
||||
// Add "View Report" link for completed research
|
||||
if (_researchingStreamIds.has(streamSessionId)) {
|
||||
_appendViewReportLink(footerTarget, streamSessionId);
|
||||
@@ -2690,7 +2710,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
}
|
||||
}
|
||||
if (metrics) {
|
||||
displayMetrics(footerTarget, metrics);
|
||||
displayMetrics(_metricsTargetForTurn() || footerTarget, metrics);
|
||||
}
|
||||
// Attach variant navigation if this was a regeneration
|
||||
_attachVariantNav(footerTarget);
|
||||
|
||||
@@ -1753,7 +1753,7 @@ export function displayMetrics(messageElement, metrics) {
|
||||
const cost = _billableCost(model, inputTokens, outputTokens);
|
||||
|
||||
// Nothing useful to show — bail out (only if ALL metrics are missing)
|
||||
if (!responseTime && !outputTokens && tps == null && !ctxPct) return;
|
||||
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
|
||||
|
||||
// Accumulate session cost (only on fresh metrics, not history reload)
|
||||
if (!metrics._fromHistory) {
|
||||
@@ -1776,9 +1776,11 @@ export function displayMetrics(messageElement, metrics) {
|
||||
? `${outputTokens} tok · ${costStr0}`
|
||||
: outputTokens
|
||||
? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}`
|
||||
: responseTime != null
|
||||
? `${responseTime}s`
|
||||
: '';
|
||||
: inputTokens
|
||||
? `${inputTokens} in${responseTime != null ? ' · ' + responseTime + 's' : ''}`
|
||||
: responseTime != null
|
||||
? `${responseTime}s`
|
||||
: '';
|
||||
if (!metricsLabel) return;
|
||||
metricsContainer.textContent = metricsLabel;
|
||||
metricsContainer.style.cursor = 'pointer';
|
||||
@@ -1996,6 +1998,13 @@ export function displayMetrics(messageElement, metrics) {
|
||||
}
|
||||
|
||||
let footer = messageElement.querySelector('.msg-footer');
|
||||
if (!footer) {
|
||||
footer = createMsgFooter(messageElement);
|
||||
if (messageElement.classList?.contains('agent-thread')) {
|
||||
footer.classList.add('agent-thread-footer');
|
||||
}
|
||||
messageElement.appendChild(footer);
|
||||
}
|
||||
if (footer) {
|
||||
const actions = footer.querySelector('.msg-actions');
|
||||
if (actions) {
|
||||
|
||||
@@ -3363,6 +3363,10 @@ body.bg-pattern-sparkles {
|
||||
transition: color .15s;
|
||||
}
|
||||
.msg-footer .response-metrics:hover { color:var(--fg); }
|
||||
.agent-thread > .agent-thread-footer {
|
||||
margin-top: 4px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
/* Context usage ring — right side of footer */
|
||||
.ctx-ring {
|
||||
display: inline-flex;
|
||||
|
||||
Reference in New Issue
Block a user