Improve document agent streaming and chat metrics
This commit is contained in:
parent
699dbc6ae3
commit
b8338b2399
6 changed files with 317 additions and 35 deletions
|
|
@ -15,7 +15,11 @@ import logging
|
||||||
from typing import AsyncGenerator, List, Dict, Optional, Set
|
from typing import AsyncGenerator, List, Dict, Optional, Set
|
||||||
from urllib.parse import urlparse
|
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.model_context import estimate_tokens
|
||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
from src.prompt_security import untrusted_context_message
|
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 = []
|
tool_lines = []
|
||||||
for name, _default_section in TOOL_SECTIONS.items():
|
for name, _default_section in TOOL_SECTIONS.items():
|
||||||
if name in included:
|
if name in included:
|
||||||
tool_lines.append(_compact_tool_line(name, _section_text(name, _default_section)))
|
tool_lines.append(f"- `{name}`")
|
||||||
parts = [
|
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"),
|
"## Available tools\n" + ("\n".join(tool_lines) if tool_lines else "none"),
|
||||||
_AGENT_RULES,
|
_API_AGENT_RULES,
|
||||||
]
|
]
|
||||||
parts.extend(_domain_rules_for_tools(included))
|
parts.extend(_domain_rules_for_tools(included))
|
||||||
return "\n\n".join(parts)
|
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")
|
domains.add("notes_calendar_tasks")
|
||||||
if has(r"\b(calendar|event|meeting|appointment|schedule)\b"):
|
if has(r"\b(calendar|event|meeting|appointment|schedule)\b"):
|
||||||
domains.add("notes_calendar_tasks")
|
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"):
|
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")
|
domains.add("documents")
|
||||||
if "notes_calendar_tasks" not in domains and has(r"\bwrite\b"):
|
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")
|
domains.add("ui")
|
||||||
if has(r"\b(session|chat history|rename chat|delete chat|archive chat|fork chat|list chats)\b"):
|
if has(r"\b(session|chat history|rename chat|delete chat|archive chat|fork chat|list chats)\b"):
|
||||||
domains.add("sessions")
|
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")
|
domains.add("files")
|
||||||
# Managing detached bash jobs: "kill the background job", "stop the job",
|
# Managing detached bash jobs: "kill the background job", "stop the job",
|
||||||
# "kill that job", "check the job output", "is the bg job done".
|
# "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:
|
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
|
"""Build the tool-retrieval query from the last few USER turns, not just
|
||||||
the latest one.
|
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)."""
|
"""Choose native function calls or fenced code block parsing. Returns (tool_blocks, used_native)."""
|
||||||
used_native = False
|
used_native = False
|
||||||
converted_calls = [] # native calls that converted, ALIGNED with tool_blocks
|
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
|
# falling back to DSML). Dropping the whole parser would silently lose
|
||||||
# those too. Non-native / textual-only models keep every pattern,
|
# those too. Non-native / textual-only models keep every pattern,
|
||||||
# fenced blocks included, since that's their *only* tool channel.
|
# 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:
|
if tool_blocks:
|
||||||
logger.info(f"Agent round {round_num}: {len(tool_blocks)} fenced tool block(s) detected")
|
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)
|
_intent = _classify_agent_request(messages, _last_user)
|
||||||
_low_signal_turn = bool(_intent.get("low_signal"))
|
_low_signal_turn = bool(_intent.get("low_signal"))
|
||||||
_casual_low_signal_turn = _is_casual_low_signal(_last_user)
|
_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 = (
|
_direct_low_signal = (
|
||||||
_low_signal_turn
|
_low_signal_turn
|
||||||
and not bool(_intent.get("continuation"))
|
and not bool(_intent.get("continuation"))
|
||||||
and not plan_mode
|
and not plan_mode
|
||||||
and not approved_plan
|
and not approved_plan
|
||||||
and not guide_only
|
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 active_email)
|
||||||
and (_casual_low_signal_turn or not workspace)
|
and (_casual_low_signal_turn or not workspace)
|
||||||
and not forced_tools
|
and not forced_tools
|
||||||
|
|
@ -2120,11 +2272,12 @@ async def stream_agent_loop(
|
||||||
# user turns only for explicit continuations ("yes", "do it", "1").
|
# user turns only for explicit continuations ("yes", "do it", "1").
|
||||||
_retrieval_query = str(_intent.get("retrieval_query") or _last_user)
|
_retrieval_query = str(_intent.get("retrieval_query") or _last_user)
|
||||||
logger.info(
|
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],
|
_last_user[:120],
|
||||||
bool(_intent.get("continuation")),
|
bool(_intent.get("continuation")),
|
||||||
_low_signal_turn,
|
_low_signal_turn,
|
||||||
sorted(_intent.get("domains") or []),
|
sorted(_intent.get("domains") or []),
|
||||||
|
_active_document_relevant,
|
||||||
_retrieval_query[:200],
|
_retrieval_query[:200],
|
||||||
)
|
)
|
||||||
_mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {}
|
_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()):
|
if "ui" in (_intent.get("domains") or set()):
|
||||||
_relevant_tools.add("ui_control")
|
_relevant_tools.add("ui_control")
|
||||||
|
|
||||||
# If a document is open the model needs the editing tools available
|
# If this turn targets the open document, keep editing tools available
|
||||||
# regardless of which selection path (RAG, keyword, caller-provided) ran
|
# regardless of which selection path (RAG, keyword, caller-provided) ran.
|
||||||
# or what keywords were in the latest user message.
|
# Do not leak document tools into unrelated turns just because the editor
|
||||||
if _relevant_tools is not None and active_document is not None:
|
# panel is open.
|
||||||
|
if _relevant_tools is not None and _active_document_relevant:
|
||||||
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
|
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
|
||||||
|
|
||||||
# Current-turn chat uploads are real files under the upload/data root. Make
|
# 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:
|
except Exception as _e:
|
||||||
logger.debug(f"[tool-rag] skill-aware tool include skipped: {_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:
|
if _relevant_tools is not None:
|
||||||
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
|
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
|
_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
|
_compact_agent_prompt = _is_api_model or _is_ollama_native or _ollama_openai_compat
|
||||||
messages, mcp_schemas = _build_system_prompt(
|
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,
|
needs_admin=_needs_admin, relevant_tools=_relevant_tools,
|
||||||
mcp_disabled_map=_mcp_disabled_map,
|
mcp_disabled_map=_mcp_disabled_map,
|
||||||
compact=_compact_agent_prompt,
|
compact=_compact_agent_prompt,
|
||||||
|
|
@ -2452,6 +2623,19 @@ async def stream_agent_loop(
|
||||||
suppress_skills=_low_signal_turn,
|
suppress_skills=_low_signal_turn,
|
||||||
active_email=active_email,
|
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:
|
if plan_mode and not guide_only:
|
||||||
# Steer the model to investigate-then-propose. Hard tool gating handles
|
# Steer the model to investigate-then-propose. Hard tool gating handles
|
||||||
# every write path except shell; this directive is what keeps the
|
# 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_acc = "" # accumulated tool-call JSON arguments
|
||||||
_doc_opened = False # whether doc_stream_open was sent
|
_doc_opened = False # whether doc_stream_open was sent
|
||||||
_doc_last_len = 0 # last content length 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
|
# 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
|
# 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
|
if s.get("function", {}).get("name") not in _ADMIN_SCHEMA_NAMES
|
||||||
]
|
]
|
||||||
all_tool_schemas = base_schemas + mcp_schemas
|
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:
|
if disabled_tools:
|
||||||
all_tool_schemas = [
|
all_tool_schemas = [
|
||||||
t for t in all_tool_schemas
|
t for t in all_tool_schemas
|
||||||
|
|
@ -2706,6 +2900,7 @@ async def stream_agent_loop(
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
prompt_type=prompt_type if round_num == 1 else None,
|
prompt_type=prompt_type if round_num == 1 else None,
|
||||||
tools=all_tool_schemas if all_tool_schemas else None,
|
tools=all_tool_schemas if all_tool_schemas else None,
|
||||||
|
tool_choice_none=_ody_doc_stream_create_mode,
|
||||||
timeout=agent_stream_timeout,
|
timeout=agent_stream_timeout,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
):
|
):
|
||||||
|
|
@ -2832,14 +3027,26 @@ async def stream_agent_loop(
|
||||||
round_response += data["delta"]
|
round_response += data["delta"]
|
||||||
full_response += data["delta"]
|
full_response += data["delta"]
|
||||||
yield chunk # Stream all rounds
|
yield chunk # Stream all rounds
|
||||||
# Detect text-fence doc streaming for rounds 2+
|
# Detect text-fence doc streaming. Normal agent prompts
|
||||||
# (round 1 is handled by frontend fence detection + server fenced block path)
|
# use ```create_document; the doc LoRA streaming path
|
||||||
|
# uses neutral ```document to avoid triggering learned
|
||||||
|
# hidden native tool-call output.
|
||||||
if (
|
if (
|
||||||
round_num > 1
|
(round_num > 1 or _ody_doc_stream_create_mode)
|
||||||
and not _doc_acc
|
and not _doc_acc
|
||||||
and not (tool_policy and tool_policy.blocks("create_document"))
|
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
|
# Open a new block if we're not currently inside one
|
||||||
# and there's an unstreamed marker in the response.
|
# and there's an unstreamed marker in the response.
|
||||||
# The marker search starts at the byte after the
|
# 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
|
# `create_document` block in the same round gets
|
||||||
# detected (previously only the first one was
|
# detected (previously only the first one was
|
||||||
# streamed and the rest were silently dropped).
|
# 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)
|
_fi = round_response.index(_fence_marker, _doc_scan_from)
|
||||||
_fa = round_response[_fi + len(_fence_marker):]
|
_fa = round_response[_fi + len(_fence_marker):]
|
||||||
_fl = _fa.split('\n')
|
_fl = _fa.split('\n')
|
||||||
|
|
@ -2900,10 +3107,35 @@ async def stream_agent_loop(
|
||||||
_round_first_token_logged,
|
_round_first_token_logged,
|
||||||
)
|
)
|
||||||
tool_blocks, used_native, converted_calls = _resolve_tool_blocks(
|
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,
|
native_tool_calls,
|
||||||
round_num,
|
round_num,
|
||||||
is_api_model=(_is_api_model and not guide_only),
|
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
|
# Force-answer round: we told the model to STOP calling tools and
|
||||||
|
|
@ -3528,6 +3760,12 @@ async def stream_agent_loop(
|
||||||
formatted = format_tool_result(desc, result)
|
formatted = format_tool_result(desc, result)
|
||||||
tool_results.append(formatted)
|
tool_results.append(formatted)
|
||||||
tool_result_texts.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 was hit, stop the loop
|
||||||
if budget_hit:
|
if budget_hit:
|
||||||
|
|
@ -3540,6 +3778,13 @@ async def stream_agent_loop(
|
||||||
if _awaiting_user:
|
if _awaiting_user:
|
||||||
break
|
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
|
# Feed results back to LLM for next round
|
||||||
# Pass the CONVERTED calls (aligned 1:1 with tool_result_texts), not the
|
# 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
|
# raw native_tool_calls: a call that failed to convert is dropped from
|
||||||
|
|
|
||||||
|
|
@ -1361,6 +1361,7 @@ def _sanitize_llm_messages(messages: List[Dict]) -> List[Dict]:
|
||||||
|
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _normalize_anthropic_url(url: str) -> str:
|
def _normalize_anthropic_url(url: str) -> str:
|
||||||
"""Ensure Anthropic URL points to /v1/messages."""
|
"""Ensure Anthropic URL points to /v1/messages."""
|
||||||
url = url.rstrip("/")
|
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,
|
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,
|
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||||
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = 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.
|
"""Stream LLM responses with improved error handling.
|
||||||
|
|
||||||
Yields SSE chunks:
|
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
|
payload[tok_key] = max_tokens
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
|
elif tool_choice_none:
|
||||||
|
payload["tool_choice"] = "none"
|
||||||
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
||||||
# activates thinking mode and returns structured reasoning_content.
|
# activates thinking mode and returns structured reasoning_content.
|
||||||
# Effort level is configurable via ODYSSEUS_MISTRAL_REASONING_EFFORT
|
# Effort level is configurable via ODYSSEUS_MISTRAL_REASONING_EFFORT
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
})();
|
})();
|
||||||
</script>
|
</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/app.js">
|
||||||
<link rel="modulepreload" href="/static/js/chat.js">
|
<link rel="modulepreload" href="/static/js/chat.js">
|
||||||
<link rel="modulepreload" href="/static/js/ui.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/tts-ai.js"></script>
|
||||||
<script type="module" src="/static/js/document.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/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/codeRunner.js"></script>
|
||||||
<script type="module" src="/static/js/chatStream.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 type="module" src="/static/js/cookbook.js"></script>
|
||||||
<script src="/static/js/cookbookSchedule.js"></script>
|
<script src="/static/js/cookbookSchedule.js"></script>
|
||||||
<script type="module" src="/static/js/search-chat.js"></script>
|
<script type="module" src="/static/js/search-chat.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1048,6 +1048,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||||
let roundHolder = holder; // Current AI text bubble (changes per round)
|
let roundHolder = holder; // Current AI text bubble (changes per round)
|
||||||
let roundText = ''; // Text accumulated for current round
|
let roundText = ''; // Text accumulated for current round
|
||||||
let currentToolBubble = null; // Current tool execution bubble
|
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 roundFinalized = false; // Whether current round's text is finalized
|
||||||
let _sourcesHtml = ''; // Sources box HTML to prepend to body
|
let _sourcesHtml = ''; // Sources box HTML to prepend to body
|
||||||
let _sourcesExpanded = false; // Track if user expanded sources during stream
|
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 _sourcesType = ''; // 'web' or 'research'
|
||||||
let _findingsData = null; // Raw findings data for collapsible box
|
let _findingsData = null; // Raw findings data for collapsible box
|
||||||
// _keepResearchOn removed — clarification state now persisted server-side via DB mode
|
// _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.
|
// Insert sources box as a stable DOM node that won't be replaced during streaming.
|
||||||
// Returns the content container to use for innerHTML updates.
|
// Returns the content container to use for innerHTML updates.
|
||||||
function _ensureStreamLayout(body) {
|
function _ensureStreamLayout(body) {
|
||||||
|
|
@ -1332,7 +1341,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||||
_streamSawDone = true;
|
_streamSawDone = true;
|
||||||
// Always update background map if entry exists (even if user switched back)
|
// Always update background map if entry exists (even if user switched back)
|
||||||
var bgDone = _backgroundStreams.get(streamSessionId);
|
var bgDone = _backgroundStreams.get(streamSessionId);
|
||||||
if (bgDone) {
|
if (bgDone && !_isBg) {
|
||||||
|
_backgroundStreams.delete(streamSessionId);
|
||||||
|
} else if (bgDone) {
|
||||||
bgDone.status = 'completed';
|
bgDone.status = 'completed';
|
||||||
bgDone.accumulated = accumulated;
|
bgDone.accumulated = accumulated;
|
||||||
if (_isBg) {
|
if (_isBg) {
|
||||||
|
|
@ -1456,9 +1467,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
|
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
|
||||||
if (!_docFenceOpened && documentModule && roundText.includes('```create_document\n')) {
|
if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n'))) {
|
||||||
const fenceIdx = roundText.indexOf('```create_document\n');
|
const fenceMarker = roundText.includes('```document\n') ? '```document\n' : '```create_document\n';
|
||||||
const afterFence = roundText.slice(fenceIdx + '```create_document\n'.length);
|
const fenceIdx = roundText.indexOf(fenceMarker);
|
||||||
|
const afterFence = roundText.slice(fenceIdx + fenceMarker.length);
|
||||||
const fenceLines = afterFence.split('\n');
|
const fenceLines = afterFence.split('\n');
|
||||||
if (fenceLines.length >= 1 && fenceLines[0].trim()) {
|
if (fenceLines.length >= 1 && fenceLines[0].trim()) {
|
||||||
_docFenceOpened = true;
|
_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 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 isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase());
|
||||||
const lang = isLang ? fenceLines[1].trim() : '';
|
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);
|
documentModule.streamDocOpen(title, lang);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2033,6 +2045,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||||
if (bgM) bgM.metrics = json.data;
|
if (bgM) bgM.metrics = json.data;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (metrics) {
|
||||||
|
const metricsTarget = _metricsTargetForTurn();
|
||||||
|
if (metricsTarget) displayMetrics(metricsTarget, metrics);
|
||||||
|
}
|
||||||
|
|
||||||
} else if (json.type === 'message_saved') {
|
} else if (json.type === 'message_saved') {
|
||||||
// Wire the persisted DB id onto the just-streamed bubble so it
|
// Wire the persisted DB id onto the just-streamed bubble so it
|
||||||
|
|
@ -2113,6 +2129,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||||
chatBox.appendChild(threadWrap);
|
chatBox.appendChild(threadWrap);
|
||||||
}
|
}
|
||||||
threadWrap.classList.add('streaming');
|
threadWrap.classList.add('streaming');
|
||||||
|
lastToolThread = threadWrap;
|
||||||
const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool;
|
const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool;
|
||||||
const toolIcon = _toolIcons[json.tool.toLowerCase()] || '\u25B6';
|
const toolIcon = _toolIcons[json.tool.toLowerCase()] || '\u25B6';
|
||||||
const node = document.createElement('div')
|
const node = document.createElement('div')
|
||||||
|
|
@ -2471,6 +2488,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderStream();
|
_renderStream();
|
||||||
|
if (spinner && spinner.element) { try { spinner.destroy(); } catch (_) {} spinner = null; }
|
||||||
_cancelThinkingTimer();
|
_cancelThinkingTimer();
|
||||||
_removeThinkingSpinner();
|
_removeThinkingSpinner();
|
||||||
// Stop any thread pulse animations
|
// 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)
|
// 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;
|
const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder;
|
||||||
|
if (!footerTarget.querySelector('.msg-footer')) {
|
||||||
footerTarget.appendChild(createMsgFooter(footerTarget));
|
footerTarget.appendChild(createMsgFooter(footerTarget));
|
||||||
|
}
|
||||||
// Add "View Report" link for completed research
|
// Add "View Report" link for completed research
|
||||||
if (_researchingStreamIds.has(streamSessionId)) {
|
if (_researchingStreamIds.has(streamSessionId)) {
|
||||||
_appendViewReportLink(footerTarget, streamSessionId);
|
_appendViewReportLink(footerTarget, streamSessionId);
|
||||||
|
|
@ -2690,7 +2710,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (metrics) {
|
if (metrics) {
|
||||||
displayMetrics(footerTarget, metrics);
|
displayMetrics(_metricsTargetForTurn() || footerTarget, metrics);
|
||||||
}
|
}
|
||||||
// Attach variant navigation if this was a regeneration
|
// Attach variant navigation if this was a regeneration
|
||||||
_attachVariantNav(footerTarget);
|
_attachVariantNav(footerTarget);
|
||||||
|
|
|
||||||
|
|
@ -1753,7 +1753,7 @@ export function displayMetrics(messageElement, metrics) {
|
||||||
const cost = _billableCost(model, inputTokens, outputTokens);
|
const cost = _billableCost(model, inputTokens, outputTokens);
|
||||||
|
|
||||||
// Nothing useful to show — bail out (only if ALL metrics are missing)
|
// 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)
|
// Accumulate session cost (only on fresh metrics, not history reload)
|
||||||
if (!metrics._fromHistory) {
|
if (!metrics._fromHistory) {
|
||||||
|
|
@ -1776,6 +1776,8 @@ export function displayMetrics(messageElement, metrics) {
|
||||||
? `${outputTokens} tok · ${costStr0}`
|
? `${outputTokens} tok · ${costStr0}`
|
||||||
: outputTokens
|
: outputTokens
|
||||||
? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}`
|
? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}`
|
||||||
|
: inputTokens
|
||||||
|
? `${inputTokens} in${responseTime != null ? ' · ' + responseTime + 's' : ''}`
|
||||||
: responseTime != null
|
: responseTime != null
|
||||||
? `${responseTime}s`
|
? `${responseTime}s`
|
||||||
: '';
|
: '';
|
||||||
|
|
@ -1996,6 +1998,13 @@ export function displayMetrics(messageElement, metrics) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let footer = messageElement.querySelector('.msg-footer');
|
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) {
|
if (footer) {
|
||||||
const actions = footer.querySelector('.msg-actions');
|
const actions = footer.querySelector('.msg-actions');
|
||||||
if (actions) {
|
if (actions) {
|
||||||
|
|
|
||||||
|
|
@ -3363,6 +3363,10 @@ body.bg-pattern-sparkles {
|
||||||
transition: color .15s;
|
transition: color .15s;
|
||||||
}
|
}
|
||||||
.msg-footer .response-metrics:hover { color:var(--fg); }
|
.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 */
|
/* Context usage ring — right side of footer */
|
||||||
.ctx-ring {
|
.ctx-ring {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue