mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-21 07:32:19 +02:00
Merge dev into fix/native-agent-loop-guard-signals
This commit is contained in:
+361
-38
@@ -428,6 +428,10 @@ _DOMAIN_RULES = {
|
||||
- Use `resolve_contact` to look up a contact's email or phone number by name. Searches the CardDAV address book and sent email history.
|
||||
- Use `manage_contact` to list, add, update, or delete contacts in the address book.
|
||||
- Do NOT use `manage_memory` for contact lookups — contact details live in the address book, not memory.""",
|
||||
"integrations": """\
|
||||
## Integration/API rules
|
||||
- To query or control a configured service integration (Home Assistant, Miniflux, Gitea, Linkding, Jellyfin, or any other registered service), use `api_call` with the integration name, HTTP method, path, and optional JSON body.
|
||||
- Do not use shell, curl, or `app_api` to reach a user's connected integration when `api_call` is available.""",
|
||||
}
|
||||
|
||||
_DOMAIN_TOOL_MAP = {
|
||||
@@ -438,9 +442,10 @@ _DOMAIN_TOOL_MAP = {
|
||||
"notes_calendar_tasks": {"manage_notes", "manage_calendar", "manage_tasks"},
|
||||
"ui": {"ui_control"},
|
||||
"sessions": {"create_session", "list_sessions", "manage_session", "send_to_session", "search_chats"},
|
||||
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace"},
|
||||
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace", "manage_bg_jobs"},
|
||||
"settings": {"manage_settings", "manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "app_api"},
|
||||
"contacts": {"resolve_contact", "manage_contact"},
|
||||
"integrations": {"api_call"},
|
||||
}
|
||||
|
||||
def _domain_rules_for_tools(tool_names: set) -> list[str]:
|
||||
@@ -685,7 +690,7 @@ def get_builtin_overrides() -> dict:
|
||||
ov = get_setting("builtin_tool_overrides", {})
|
||||
return ov if isinstance(ov, dict) else {}
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load builtin tool overrides: %s', e)
|
||||
logger.warning("Failed to load builtin tool overrides, using defaults", exc_info=e)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -697,17 +702,44 @@ def _section_text(name: str, default: str) -> str:
|
||||
return val if isinstance(val, str) and val.strip() else default
|
||||
|
||||
|
||||
def _compact_tool_line(name: str, section: str) -> str:
|
||||
"""One-line fenced-tool usage hint for compact/local prompts."""
|
||||
text = (section or "").strip()
|
||||
if not text:
|
||||
return f"- `{name}`"
|
||||
if text.startswith("- "):
|
||||
return text
|
||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
usage = []
|
||||
in_fence = False
|
||||
for ln in lines:
|
||||
if ln.startswith("```"):
|
||||
usage.append(ln)
|
||||
in_fence = not in_fence
|
||||
if len(usage) >= 3:
|
||||
break
|
||||
continue
|
||||
if in_fence and len(usage) < 3:
|
||||
usage.append(ln)
|
||||
if usage:
|
||||
return f"- `{name}` — " + " ".join(usage)
|
||||
return f"- `{name}` — " + lines[0][:160]
|
||||
|
||||
|
||||
def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool = False) -> str:
|
||||
"""Build the system prompt with only the specified tools included."""
|
||||
disabled = disabled_tools or set()
|
||||
included = tool_names - disabled
|
||||
|
||||
if compact:
|
||||
tool_list = ", ".join(sorted(included)) if included else "none"
|
||||
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)))
|
||||
parts = [
|
||||
"You are an AI assistant with tool access.",
|
||||
f"Available tools: {tool_list}.",
|
||||
_API_AGENT_RULES,
|
||||
_AGENT_PREAMBLE,
|
||||
"## Available tools\n" + ("\n".join(tool_lines) if tool_lines else "none"),
|
||||
_AGENT_RULES,
|
||||
]
|
||||
parts.extend(_domain_rules_for_tools(included))
|
||||
return "\n\n".join(parts)
|
||||
@@ -773,11 +805,6 @@ _API_HOSTS = frozenset([
|
||||
"api.perplexity.ai", "api.x.ai",
|
||||
"ollama.com", "api.venice.ai", "api.kimi.com",
|
||||
"api.githubcopilot.com",
|
||||
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
|
||||
# Without these, `_is_api_model` falls back to keyword sniffing on the
|
||||
# model name, so well-behaved local servers don't get native tool
|
||||
# schemas and the agent silently degrades to fenced-block parsing.
|
||||
"localhost", "127.0.0.1", "host.docker.internal",
|
||||
])
|
||||
_MCP_KEYWORDS = frozenset(["mcp", "browse", "browser", "website", "calendar", "event", "email",
|
||||
"gmail", "screenshot", "navigate", "click", "miniflux", "rss", "feed"])
|
||||
@@ -805,6 +832,28 @@ def _is_ollama_openai_compat_url(endpoint_url: str) -> bool:
|
||||
return parsed.port == 11434 and (path == "/v1" or path.startswith("/v1/"))
|
||||
|
||||
|
||||
def _is_local_openai_compat_url(endpoint_url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(endpoint_url or "")
|
||||
except Exception:
|
||||
return False
|
||||
host = (parsed.hostname or "").lower()
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
if not (path == "/v1" or path.startswith("/v1/")):
|
||||
return False
|
||||
if host in {"localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal"}:
|
||||
return True
|
||||
if host.startswith("192.168.") or host.startswith("10."):
|
||||
return True
|
||||
if host.startswith("172."):
|
||||
try:
|
||||
second = int(host.split(".")[1])
|
||||
return 16 <= second <= 31
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _endpoint_lookup_keys(endpoint_url: str) -> List[str]:
|
||||
"""Candidate ModelEndpoint.base_url keys for a runtime chat URL."""
|
||||
raw = (endpoint_url or "").strip()
|
||||
@@ -868,6 +917,17 @@ def _extract_last_user_message(messages: List[Dict]) -> str:
|
||||
|
||||
|
||||
_LOW_SIGNAL_RE = re.compile(r"^[\W_]*$", re.UNICODE)
|
||||
_CASUAL_OPENING_RE = re.compile(
|
||||
r"^\s*(?:h+i+|hey+|hello+|yo+|sup+|what'?s up|wass?up|hiya|howdy|"
|
||||
r"lol|lmao|haha+|hehe+|thanks?|thank you|ty|idk|dunno|meh|bruh|bro)\b(?P<tail>.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CASUAL_BLOCKLIST_RE = re.compile(
|
||||
r"\b(?:cookbook|serve|serving|launch|start|vllm|sglang|llama\.?cpp|ollama|"
|
||||
r"download|model|email|document|doc|note|calendar|task|search|web|research|"
|
||||
r"file|folder|repo|git|settings?|endpoint|api|token|mcp)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXPLICIT_CONTINUATION_RE = re.compile(
|
||||
r"^\s*(?:"
|
||||
r"yes|y|yeah|yep|ok|okay|sure|do it|go ahead|continue|carry on|"
|
||||
@@ -877,6 +937,17 @@ _EXPLICIT_CONTINUATION_RE = re.compile(
|
||||
r")\s*[.!?]*\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RETRY_CONTINUATION_RE = re.compile(
|
||||
r"\b(?:try again|retry|again|rerun|re-run|run it again|launch it again|"
|
||||
r"start it again|failed|fails?|died|crashed|broke|insta|instantly)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_COOKBOOK_CONTEXT_RE = re.compile(
|
||||
r"\b(?:cookbook|serve|serving|served|launch|start|preset|vllm|sglang|"
|
||||
r"llama\.?cpp|ollama|download|cached models?|model servers?|running models?|"
|
||||
r"gpu box|ajax|qwen|gemma|llama|mistral|minimax)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_explicit_continuation(text: str) -> bool:
|
||||
@@ -884,6 +955,37 @@ def _is_explicit_continuation(text: str) -> bool:
|
||||
return bool(_EXPLICIT_CONTINUATION_RE.match(str(text or "").strip()))
|
||||
|
||||
|
||||
def _is_casual_low_signal(text: str) -> bool:
|
||||
"""True for short greetings/slang that should not inherit stale context."""
|
||||
s = str(text or "").strip()
|
||||
m = _CASUAL_OPENING_RE.match(s)
|
||||
if not m:
|
||||
return False
|
||||
tail = m.group("tail") or ""
|
||||
if _CASUAL_BLOCKLIST_RE.search(tail):
|
||||
return False
|
||||
# Allow a short vocative/address after the opener without hardcoding the
|
||||
# address term itself: "hey man", "yo dude", "sup <name>". Longer tails are
|
||||
# more likely to be an actual request and should get normal context/tooling.
|
||||
tail_words = re.findall(r"[A-Za-z0-9_'-]+", tail)
|
||||
return len(tail_words) <= 2
|
||||
|
||||
|
||||
def _is_contextual_retry_continuation(messages: List[Dict], text: str) -> bool:
|
||||
"""Treat "try again / it failed" as a continuation only for active tool work.
|
||||
|
||||
These follow-ups are common after Cookbook launches: the latest user turn
|
||||
says only "try again it failed", while the actionable model/host/command
|
||||
details live one or two turns back. Keep this intentionally narrow so
|
||||
ordinary chat does not inherit stale Cookbook context.
|
||||
"""
|
||||
latest = str(text or "").strip()
|
||||
if not latest or not _RETRY_CONTINUATION_RE.search(latest):
|
||||
return False
|
||||
recent = _recent_context_for_retrieval(messages, max_user=5, max_chars=1200)
|
||||
return bool(_COOKBOOK_CONTEXT_RE.search(recent))
|
||||
|
||||
|
||||
def _assistant_requested_followup(messages: List[Dict]) -> bool:
|
||||
"""True when the previous assistant turn asked for missing task details.
|
||||
|
||||
@@ -925,11 +1027,12 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
|
||||
which domain rule packs get appended to the system prompt.
|
||||
"""
|
||||
text = str(last_user or "").strip()
|
||||
continuation = _is_explicit_continuation(text) or _assistant_requested_followup(messages)
|
||||
retry_continuation = _is_contextual_retry_continuation(messages, text)
|
||||
continuation = _is_explicit_continuation(text) or _assistant_requested_followup(messages) or retry_continuation
|
||||
retrieval_query = _recent_context_for_retrieval(messages) if continuation else text
|
||||
q = retrieval_query.lower()
|
||||
|
||||
if not text or bool(_LOW_SIGNAL_RE.match(text)):
|
||||
if not text or bool(_LOW_SIGNAL_RE.match(text)) or _is_casual_low_signal(text):
|
||||
return {
|
||||
"low_signal": True,
|
||||
"continuation": False,
|
||||
@@ -972,10 +1075,25 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
|
||||
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"):
|
||||
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".
|
||||
if (has(r"\b(background|bg)\s+(jobs?|task)\b")
|
||||
or has(r"\b(kill|stop|cancel|terminate|check|tail|show|list)\b.{0,16}\bjobs?\b")
|
||||
or has(r"\bjobs?\b.{0,16}\b(output|status|done|finished|running)\b")):
|
||||
domains.add("files")
|
||||
if has(r"\b(endpoint|api token|mcp|webhook|preference|configure|config|setting)\b"):
|
||||
domains.add("settings")
|
||||
if has(r"\b(contact|contacts|phone|phone number|address book|vcard)\b"):
|
||||
domains.add("contacts")
|
||||
# API-integration intent — calling a configured service via the api_call
|
||||
# tool. Without this the #3794 repro ("Use the api_call tool to call Home
|
||||
# Assistant GET /api/states") matched no domain, classified as low-signal,
|
||||
# and the tool never reached the schema filter. Detect it explicitly so the
|
||||
# "integrations" domain seeds api_call deterministically (see
|
||||
# _DOMAIN_TOOL_MAP), independent of embedding retrieval.
|
||||
if has(r"\bapi[ _]call\b", r"\bintegrations?\b",
|
||||
r"\b(?:home ?assistant|miniflux|gitea|linkding|jellyfin)\b"):
|
||||
domains.add("integrations")
|
||||
|
||||
low_signal = not continuation and not domains
|
||||
return {
|
||||
@@ -1004,8 +1122,11 @@ def _recent_context_for_retrieval(messages: List[Dict], max_user: int = 3, max_c
|
||||
if isinstance(content, list):
|
||||
content = " ".join(b.get("text", "") for b in content if isinstance(b, dict))
|
||||
content = (content or "").strip()
|
||||
# Skip injected tool-result envelopes — role=user but not human intent.
|
||||
if not content or content.startswith("[Tool execution results]"):
|
||||
# Skip injected envelopes — role=user but not human intent. Tool results
|
||||
# are now wrapped via untrusted_context_message (metadata.trusted=False);
|
||||
# keep the legacy "[Tool execution results]" prefix for older histories.
|
||||
meta = msg.get("metadata") or {}
|
||||
if not content or meta.get("trusted") is False or content.startswith("[Tool execution results]"):
|
||||
continue
|
||||
collected.append(content)
|
||||
if len(collected) >= max_user:
|
||||
@@ -1024,6 +1145,7 @@ def _build_system_prompt(
|
||||
compact: bool = False,
|
||||
owner: Optional[str] = None,
|
||||
suppress_local_context: bool = False,
|
||||
suppress_skills: bool = False,
|
||||
active_email: Optional[Dict[str, str]] = None,
|
||||
) -> List[Dict]:
|
||||
"""Build agent system prompt, inject MCP/document context, merge consecutive system msgs."""
|
||||
@@ -1041,7 +1163,7 @@ def _build_system_prompt(
|
||||
_ov_sig = _hl.sha256(_json.dumps(get_builtin_overrides() or {}, sort_keys=True).encode()).hexdigest()
|
||||
except Exception:
|
||||
_ov_sig = ""
|
||||
cache_key = (frozenset(disabled_tools or []), bool(mcp_mgr), needs_admin, _rt_key, compact, _ov_sig, owner, suppress_local_context)
|
||||
cache_key = (frozenset(disabled_tools or []), bool(mcp_mgr), needs_admin, _rt_key, compact, _ov_sig, owner, suppress_local_context, suppress_skills)
|
||||
if _cached_base_prompt and _cached_base_prompt_key == cache_key and not active_document:
|
||||
agent_prompt = _cached_base_prompt
|
||||
# Skill index is user-editable (name + description), so it must never
|
||||
@@ -1051,6 +1173,7 @@ def _build_system_prompt(
|
||||
disabled_tools, mcp_mgr, needs_admin, relevant_tools,
|
||||
mcp_disabled_map=mcp_disabled_map, compact=compact, owner=owner,
|
||||
suppress_local_context=suppress_local_context,
|
||||
suppress_skills=suppress_skills,
|
||||
)
|
||||
else:
|
||||
agent_prompt, _skill_index_block = _build_base_prompt(
|
||||
@@ -1062,6 +1185,7 @@ def _build_system_prompt(
|
||||
compact=compact,
|
||||
owner=owner,
|
||||
suppress_local_context=suppress_local_context,
|
||||
suppress_skills=suppress_skills,
|
||||
)
|
||||
if not active_document:
|
||||
_cached_base_prompt = agent_prompt
|
||||
@@ -1090,8 +1214,8 @@ def _build_system_prompt(
|
||||
try:
|
||||
from src.user_time import current_datetime_context_message
|
||||
_datetime_message = current_datetime_context_message()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Failed to build datetime context message", exc_info=e)
|
||||
|
||||
# Document context is kept as a SEPARATE message (not merged into the tool
|
||||
# prompt) so the context trimmer doesn't destroy it when truncating the
|
||||
@@ -1134,8 +1258,8 @@ def _build_system_prompt(
|
||||
try:
|
||||
from src.pdf_form_doc import find_source_upload_id
|
||||
_is_form_backed = bool(find_source_upload_id(active_document.current_content or ""))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Failed to detect if document is form-backed, assuming plain", exc_info=e)
|
||||
|
||||
if _is_form_backed:
|
||||
doc_ctx = (
|
||||
@@ -1345,7 +1469,7 @@ def _build_system_prompt(
|
||||
# few. If the teacher wrote a procedure for "open my X chat" last
|
||||
# time the student failed, this is where the student finds it
|
||||
# before deciding which tool to call.
|
||||
if not suppress_local_context:
|
||||
if not suppress_local_context and not suppress_skills:
|
||||
try:
|
||||
last_user = _extract_last_user_message(messages)
|
||||
# Respect the user's skills-enabled toggle (mirrors memory_enabled).
|
||||
@@ -1512,6 +1636,7 @@ def _build_base_prompt(
|
||||
compact: bool = False,
|
||||
owner: Optional[str] = None,
|
||||
suppress_local_context: bool = False,
|
||||
suppress_skills: bool = False,
|
||||
):
|
||||
"""Build the agent prompt with only relevant tools included.
|
||||
|
||||
@@ -1564,7 +1689,7 @@ def _build_base_prompt(
|
||||
# The caller wraps it in untrusted_context_message and ships it as a
|
||||
# user-role message — same treatment as the matched-skills block.
|
||||
skill_index_block = ""
|
||||
if not suppress_local_context:
|
||||
if not suppress_local_context and not suppress_skills:
|
||||
try:
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.constants import DATA_DIR
|
||||
@@ -1723,8 +1848,14 @@ def _append_tool_results(
|
||||
if round_reasoning:
|
||||
msg["reasoning_content"] = round_reasoning
|
||||
messages.append(msg)
|
||||
# Tool output (shell/python stdout, file reads, fetched pages, email
|
||||
# bodies, MCP results) is sourced from outside the server. Wrap it as
|
||||
# untrusted data so prompt-injection inside a tool result is treated as
|
||||
# data, not instructions — same hardening as skills (#788) and the
|
||||
# web/RAG context. THREAT_MODEL.md lists tool output as a surface that
|
||||
# must go through untrusted_context_message.
|
||||
messages.append(
|
||||
{"role": "user", "content": f"[Tool execution results]\n\n{tool_output_text}"}
|
||||
untrusted_context_message("tool execution results", tool_output_text)
|
||||
)
|
||||
|
||||
|
||||
@@ -1983,6 +2114,7 @@ async def stream_agent_loop(
|
||||
approved_plan: Optional[str] = None,
|
||||
tool_policy: Optional[ToolPolicy] = None,
|
||||
workspace: Optional[str] = None,
|
||||
forced_tools: Optional[Set[str]] = None,
|
||||
_is_teacher_run: bool = False,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Streaming agent loop generator.
|
||||
@@ -2022,6 +2154,20 @@ async def stream_agent_loop(
|
||||
_needs_admin = _detect_admin_intent(messages)
|
||||
_last_user = _extract_last_user_message(messages)
|
||||
_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)
|
||||
_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_email)
|
||||
and (_casual_low_signal_turn or not workspace)
|
||||
and not forced_tools
|
||||
and not relevant_tools
|
||||
)
|
||||
# Tool retrieval uses the latest message by default. It may inherit recent
|
||||
# user turns only for explicit continuations ("yes", "do it", "1").
|
||||
_retrieval_query = str(_intent.get("retrieval_query") or _last_user)
|
||||
@@ -2029,11 +2175,86 @@ async def stream_agent_loop(
|
||||
"[agent-intent] latest=%r continuation=%s low_signal=%s domains=%s retrieval_query=%r",
|
||||
_last_user[:120],
|
||||
bool(_intent.get("continuation")),
|
||||
bool(_intent.get("low_signal")),
|
||||
_low_signal_turn,
|
||||
sorted(_intent.get("domains") or []),
|
||||
_retrieval_query[:200],
|
||||
)
|
||||
_mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {}
|
||||
if _direct_low_signal:
|
||||
logger.info("[agent] direct low-signal reply path for latest=%r", _last_user[:80])
|
||||
direct_messages = [{"role": "user", "content": _last_user}]
|
||||
direct_response = ""
|
||||
direct_start = time.time()
|
||||
direct_actual_model = model
|
||||
real_input_tokens = 0
|
||||
real_output_tokens = 0
|
||||
try:
|
||||
async for chunk in stream_llm_with_fallback(
|
||||
[(endpoint_url, model, headers)] + list(fallbacks or []),
|
||||
direct_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=min(max_tokens or 128, 128),
|
||||
prompt_type=None,
|
||||
tools=None,
|
||||
timeout=int(get_setting("agent_stream_timeout_seconds", 300) or 300),
|
||||
session_id=session_id,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
data = json.loads(chunk[6:])
|
||||
except json.JSONDecodeError:
|
||||
yield chunk
|
||||
continue
|
||||
if data.get("type") == "usage":
|
||||
usage = data.get("data", {}) or {}
|
||||
direct_actual_model = usage.get("model") or direct_actual_model
|
||||
real_input_tokens += usage.get("input_tokens", 0) or 0
|
||||
real_output_tokens += usage.get("output_tokens", 0) or 0
|
||||
continue
|
||||
if data.get("type") == "model_actual":
|
||||
direct_actual_model = data.get("model") or direct_actual_model
|
||||
data["requested_model"] = model
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
continue
|
||||
if data.get("type") == "fallback":
|
||||
direct_actual_model = data.get("answered_by") or direct_actual_model
|
||||
yield chunk
|
||||
continue
|
||||
if "delta" in data:
|
||||
if not data.get("thinking"):
|
||||
direct_response += data.get("delta", "")
|
||||
yield chunk
|
||||
continue
|
||||
yield chunk
|
||||
elif chunk.startswith("event: "):
|
||||
yield chunk
|
||||
except Exception as _direct_err:
|
||||
logger.warning("[agent] direct low-signal path failed: %s", _direct_err)
|
||||
fallback = "Hey."
|
||||
direct_response += fallback
|
||||
yield f"data: {json.dumps({'delta': fallback})}\n\n"
|
||||
|
||||
if not direct_response.strip():
|
||||
fallback = "Hey."
|
||||
direct_response = fallback
|
||||
yield f"data: {json.dumps({'delta': fallback})}\n\n"
|
||||
|
||||
duration = time.time() - direct_start
|
||||
metrics = {
|
||||
"model": direct_actual_model,
|
||||
"requested_model": model,
|
||||
"input_tokens": real_input_tokens or estimate_tokens(direct_messages),
|
||||
"output_tokens": real_output_tokens or max(len(direct_response) // 4, 1),
|
||||
"total_time": round(duration, 2),
|
||||
"response_time": round(duration, 2),
|
||||
"agent_rounds": 0,
|
||||
"tool_calls": 0,
|
||||
"direct_low_signal": True,
|
||||
}
|
||||
yield f"data: {json.dumps({'type': 'metrics', 'data': metrics})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
if plan_mode and mcp_mgr:
|
||||
# Allow read-only MCP tools to investigate, block write/unknown ones:
|
||||
# hide them from the schemas AND reject them at runtime by qualified name.
|
||||
@@ -2045,11 +2266,11 @@ async def stream_agent_loop(
|
||||
|
||||
# RAG-based tool selection: retrieve relevant tools for this query.
|
||||
# If caller provided a pre-computed set (e.g. task_scheduler), use that.
|
||||
_relevant_tools = set() if guide_only else relevant_tools
|
||||
_relevant_tools = relevant_tools
|
||||
_t1 = time.time()
|
||||
if _relevant_tools:
|
||||
logger.info(f"[tool-rag] Using caller-provided relevant_tools ({len(_relevant_tools)} tools)")
|
||||
if not guide_only and not _relevant_tools and bool(_intent.get("low_signal")):
|
||||
if not guide_only and not _relevant_tools and _low_signal_turn:
|
||||
from src.tool_index import ALWAYS_AVAILABLE
|
||||
if workspace:
|
||||
# An active workspace IS the file-work signal: a vague "look at the
|
||||
@@ -2140,6 +2361,15 @@ async def stream_agent_loop(
|
||||
if _relevant_tools is not None and active_document is not None:
|
||||
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
|
||||
|
||||
# Per-request UI toggles are stronger than retrieval. If the user turns on
|
||||
# Search, the model must see the search tools even when the latest text is a
|
||||
# typo or otherwise low-signal for tool RAG.
|
||||
if not guide_only and forced_tools:
|
||||
if _relevant_tools is None:
|
||||
from src.tool_index import ALWAYS_AVAILABLE
|
||||
_relevant_tools = set(ALWAYS_AVAILABLE)
|
||||
_relevant_tools.update(t for t in forced_tools if t not in disabled_tools)
|
||||
|
||||
# The skill index injected by _build_system_prompt tells the model to
|
||||
# call `manage_skills action=view`, and Jaccard-matched skills are pasted
|
||||
# into the prompt as procedures to follow — but neither path goes through
|
||||
@@ -2147,7 +2377,7 @@ async def stream_agent_loop(
|
||||
# (grep, read_file, ...) that aren't in its schema list. Keep the schemas
|
||||
# in lockstep: manage_skills is callable whenever any skill is indexed,
|
||||
# and a matched skill's declared requires_toolsets ride along with it.
|
||||
if not guide_only and _relevant_tools is not None:
|
||||
if not guide_only and _relevant_tools is not None and not _low_signal_turn:
|
||||
try:
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.constants import DATA_DIR
|
||||
@@ -2212,7 +2442,7 @@ async def stream_agent_loop(
|
||||
_model_supports_tools = any(kw in _model_lc for kw in (
|
||||
"gpt-4", "gpt-5", "gpt-o", "claude", "gemini", "gemma",
|
||||
"qwen3", "qwen2.5", "mixtral", "mistral", "llama-3.1", "llama-3.2",
|
||||
"llama-3.3", "llama-4",
|
||||
"llama-3.3", "llama-4", "llama3.1", "llama3.2", "llama3.3", "llama4",
|
||||
# Local-served models that follow OpenAI-style function calling
|
||||
# via vLLM's `--enable-auto-tool-choice`. Belt-and-suspenders
|
||||
# with the per-endpoint flag above.
|
||||
@@ -2254,13 +2484,15 @@ async def stream_agent_loop(
|
||||
_is_api_model = False
|
||||
else:
|
||||
_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,
|
||||
needs_admin=_needs_admin, relevant_tools=_relevant_tools,
|
||||
mcp_disabled_map=_mcp_disabled_map,
|
||||
compact=_is_api_model,
|
||||
compact=_compact_agent_prompt,
|
||||
owner=owner,
|
||||
suppress_local_context=guide_only,
|
||||
suppress_skills=_low_signal_turn,
|
||||
active_email=active_email,
|
||||
)
|
||||
if plan_mode and not guide_only:
|
||||
@@ -2346,6 +2578,14 @@ async def stream_agent_loop(
|
||||
# Strip internal metadata keys before sending to the LLM API
|
||||
messages = [{k: v for k, v in msg.items() if k != "_protected"} for msg in messages]
|
||||
|
||||
agent_prompt_tokens = estimate_tokens(messages)
|
||||
logger.info(
|
||||
"[agent-timing] prep_done model=%s prompt_tokens=%s context_length=%s prep=%s",
|
||||
model,
|
||||
agent_prompt_tokens,
|
||||
context_length,
|
||||
{k: round(v, 3) for k, v in prep_timings.items()},
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'agent_prep', 'data': {k: round(v, 3) for k, v in prep_timings.items()}})}\n\n"
|
||||
|
||||
full_response = ""
|
||||
@@ -2491,6 +2731,19 @@ async def stream_agent_loop(
|
||||
# complementary cap for the rare stream that trickles bytes forever and
|
||||
# so never trips the inactivity timeout. Generous — only catches runaway.
|
||||
_round_deadline = time.time() + max(agent_stream_timeout * 4, 1200)
|
||||
_round_start = time.time()
|
||||
_round_first_event_logged = False
|
||||
_round_first_token_logged = False
|
||||
logger.info(
|
||||
"[agent-timing] round_start round=%s model=%s endpoint=%s prompt_tokens=%s tools=%s native_tools=%s timeout=%s",
|
||||
round_num,
|
||||
model,
|
||||
endpoint_url,
|
||||
estimate_tokens(messages),
|
||||
len(_tool_names_sent),
|
||||
bool(all_tool_schemas),
|
||||
agent_stream_timeout,
|
||||
)
|
||||
async for chunk in stream_llm_with_fallback(
|
||||
_candidates,
|
||||
messages,
|
||||
@@ -2501,11 +2754,30 @@ async def stream_agent_loop(
|
||||
timeout=agent_stream_timeout,
|
||||
session_id=session_id,
|
||||
):
|
||||
if not _round_first_event_logged:
|
||||
_round_first_event_logged = True
|
||||
logger.info(
|
||||
"[agent-timing] first_event round=%s elapsed=%.3fs kind=%s",
|
||||
round_num,
|
||||
time.time() - _round_start,
|
||||
"error" if chunk.startswith("event: error") else "data",
|
||||
)
|
||||
if time.time() > _round_deadline:
|
||||
logger.warning(f"[agent] round {round_num} stream exceeded wall-clock deadline; cutting off")
|
||||
logger.warning(
|
||||
"[agent-timing] round_deadline round=%s elapsed=%.3fs deadline_s=%s",
|
||||
round_num,
|
||||
time.time() - _round_start,
|
||||
max(agent_stream_timeout * 4, 1200),
|
||||
)
|
||||
break
|
||||
# Forward error events from stream_llm to the frontend
|
||||
if chunk.startswith("event: error"):
|
||||
logger.warning(
|
||||
"[agent-timing] stream_error round=%s elapsed=%.3fs chunk=%r",
|
||||
round_num,
|
||||
time.time() - _round_start,
|
||||
chunk[:500],
|
||||
)
|
||||
yield chunk
|
||||
continue
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
@@ -2585,6 +2857,15 @@ async def stream_agent_loop(
|
||||
if not first_token_received:
|
||||
time_to_first_token = time.time() - total_start
|
||||
first_token_received = True
|
||||
if not _round_first_token_logged:
|
||||
_round_first_token_logged = True
|
||||
logger.info(
|
||||
"[agent-timing] first_visible_token round=%s elapsed=%.3fs total_elapsed=%.3fs thinking=%s",
|
||||
round_num,
|
||||
time.time() - _round_start,
|
||||
time.time() - total_start,
|
||||
bool(data.get("thinking")),
|
||||
)
|
||||
# Keep reasoning deltas in a separate accumulator so
|
||||
# we can echo them back via `reasoning_content` on the
|
||||
# next request (DeepSeek requires this; harmless for
|
||||
@@ -2654,7 +2935,21 @@ async def stream_agent_loop(
|
||||
yield chunk
|
||||
# Intercept [DONE] — don't forward until all rounds finish
|
||||
|
||||
tool_blocks, used_native = _resolve_tool_blocks(round_response, native_tool_calls, round_num, is_api_model=_is_api_model)
|
||||
logger.info(
|
||||
"[agent-timing] round_stream_done round=%s elapsed=%.3fs text_chars=%s tool_calls=%s first_event=%s first_token=%s",
|
||||
round_num,
|
||||
time.time() - _round_start,
|
||||
len(round_response),
|
||||
len(native_tool_calls),
|
||||
_round_first_event_logged,
|
||||
_round_first_token_logged,
|
||||
)
|
||||
tool_blocks, used_native = _resolve_tool_blocks(
|
||||
round_response,
|
||||
native_tool_calls,
|
||||
round_num,
|
||||
is_api_model=(_is_api_model and not guide_only),
|
||||
)
|
||||
|
||||
# Force-answer round: we told the model to STOP calling tools and
|
||||
# answer. If it ignored that and emitted a (possibly DSML) tool
|
||||
@@ -2738,7 +3033,7 @@ async def stream_agent_loop(
|
||||
# model with no real native_tool_calls) must not be stripped from the
|
||||
# persisted text either — otherwise it streams once and then disappears
|
||||
# on reload (#3222 follow-up).
|
||||
cleaned_round = strip_tool_blocks(round_response, skip_fenced=(_is_api_model and not used_native)).strip()
|
||||
cleaned_round = strip_tool_blocks(round_response, skip_fenced=(_is_api_model and not used_native and not guide_only)).strip()
|
||||
round_texts.append(cleaned_round)
|
||||
|
||||
if not tool_blocks:
|
||||
@@ -2815,6 +3110,15 @@ async def stream_agent_loop(
|
||||
"[agent] intent-without-action nudge #%d on round %d",
|
||||
_intent_nudge_count, round_num,
|
||||
)
|
||||
_lower_phrase = _matched_phrase.lower()
|
||||
_cookbook_log_hint = ""
|
||||
if any(_word in _lower_phrase for _word in ("log", "logs", "output", "tail", "status")):
|
||||
_cookbook_log_hint = (
|
||||
" If this is about a Cookbook/model serve, the concrete calls are: "
|
||||
"`list_served_models` first, then `tail_serve_output` with the "
|
||||
"session_id from the serve/list result. Never answer with "
|
||||
"\"check logs\" when those tools are available."
|
||||
)
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": (
|
||||
@@ -2823,6 +3127,7 @@ async def stream_agent_loop(
|
||||
"see you announced the action but didn't run it, which "
|
||||
"is the most frustrating thing you can do. "
|
||||
"DO IT NOW: emit the actual function call this turn. "
|
||||
f"{_cookbook_log_hint}"
|
||||
"If you decided not to do it after all, say so plainly in "
|
||||
"one sentence instead of restating the plan."
|
||||
),
|
||||
@@ -3119,9 +3424,12 @@ async def stream_agent_loop(
|
||||
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
|
||||
)
|
||||
|
||||
# ask_user: the agent posed a multiple-choice question. Emit it so the
|
||||
# frontend renders clickable options, then end the turn (below) and
|
||||
# wait — the user's pick becomes the next message.
|
||||
# ask_user: remember the payload now, but emit the interactive event
|
||||
# only *after* tool_output below. Emitting it before tool_output let
|
||||
# the subsequent tool-card rewrite/scroll push the choices out of
|
||||
# view. The payload is also copied into the persisted tool event so
|
||||
# history reload can reconstruct an unanswered card.
|
||||
_pending_ask_user_event = None
|
||||
if "ask_user" in result:
|
||||
# The question lives in the tool args. ChatMessage.to_dict()
|
||||
# replays only role+content to the model next turn — tool_event
|
||||
@@ -3136,9 +3444,7 @@ async def stream_agent_loop(
|
||||
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
|
||||
full_response += _auq_delta
|
||||
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
|
||||
yield (
|
||||
f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n'
|
||||
)
|
||||
_pending_ask_user_event = _auq
|
||||
_awaiting_user = True
|
||||
|
||||
# update_plan: agent wrote back to the plan (ticked a step / revised).
|
||||
@@ -3193,6 +3499,10 @@ async def stream_agent_loop(
|
||||
|
||||
# Emit tool_output (include ui_event data if present)
|
||||
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
|
||||
if _pending_ask_user_event:
|
||||
# Keep enough state in the streamed tool result for alternate
|
||||
# clients to render the prompt without depending on event order.
|
||||
tool_output_data["ask_user"] = _pending_ask_user_event
|
||||
if "ui_event" in result:
|
||||
tool_output_data["ui_event"] = result["ui_event"]
|
||||
for k in (
|
||||
@@ -3223,6 +3533,14 @@ async def stream_agent_loop(
|
||||
tool_output_data["diff"] = result["diff"]
|
||||
yield f'data: {json.dumps(tool_output_data)}\n\n'
|
||||
|
||||
# This must be the final UI event for ask_user: the frontend appends
|
||||
# the card below the now-settled tool node and cancels any between-
|
||||
# round spinner. The turn ends after the current tool batch.
|
||||
if _pending_ask_user_event:
|
||||
yield (
|
||||
f'data: {json.dumps({"type": "ask_user", "data": _pending_ask_user_event})}\n\n'
|
||||
)
|
||||
|
||||
# Native document tools open in the editor + carry the REAL doc id.
|
||||
# Emit a doc_update so the frontend opens/activates it and sends it
|
||||
# back as active_doc_id next turn (otherwise the agent can't "see"
|
||||
@@ -3280,6 +3598,11 @@ async def stream_agent_loop(
|
||||
# this the diff shows live but vanishes from saved history.
|
||||
if result.get("diff"):
|
||||
tool_event["diff"] = result["diff"]
|
||||
if _pending_ask_user_event:
|
||||
# Persist the structured question with the tool event. On a
|
||||
# reload, chatRenderer can restore the card; a later user
|
||||
# message removes it as answered.
|
||||
tool_event["ask_user"] = _pending_ask_user_event
|
||||
tool_events.append(tool_event)
|
||||
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
|
||||
_effectful_used = True
|
||||
|
||||
Reference in New Issue
Block a user