diff --git a/src/agent_loop.py b/src/agent_loop.py
index aef0048a1..22e52aee8 100644
--- a/src/agent_loop.py
+++ b/src/agent_loop.py
@@ -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
diff --git a/src/llm_core.py b/src/llm_core.py
index e8331279f..aadb5bad5 100644
--- a/src/llm_core.py
+++ b/src/llm_core.py
@@ -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
diff --git a/static/index.html b/static/index.html
index ffeafa183..314e65b67 100644
--- a/static/index.html
+++ b/static/index.html
@@ -219,7 +219,7 @@
}, { once: true });
})();
-
+
@@ -2499,10 +2499,10 @@
-
+
-
+
diff --git a/static/js/chat.js b/static/js/chat.js
index 4b0cd08ba..09c22a997 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -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);
diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js
index a6ed56c96..936183c5d 100644
--- a/static/js/chatRenderer.js
+++ b/static/js/chatRenderer.js
@@ -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) {
diff --git a/static/style.css b/static/style.css
index 2f27131c5..88073ab48 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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;