diff --git a/docs/improvement-tasks.md b/docs/improvement-tasks.md index b5db9ad0..e989f660 100644 --- a/docs/improvement-tasks.md +++ b/docs/improvement-tasks.md @@ -1,9 +1,15 @@ # Ulysses Improvement Tasks -Last updated: 2026-07-07 +Last updated: 2026-07-08 ## Priority: HIGH +- [ ] **Agent toolcalling loop: model narrates actions but never emits fenced blocks** + Gemma-4-12b repeatedly narrates intent ("I'm calling list_vault now") without emitting the ```tool fenced block. The model clearly understands what it should do but the fenced block never arrives. This blocks agent workflows entirely when it occurs (~25% of tool calls). The anti-hallucination system prompt rule drafted yesterday is staged but not live (container not restarted). Need to investigate whether this is: (a) the missing system prompt rule, (b) a regex/parsing issue in parse_tool_blocks(), (c) the compact API-model prompt confusing non-tool-native models, or (d) a context/truncation issue preventing the fenced block from reaching the parser. + +- [ ] **Chat deletion dropdown broken — does not appear** + Clicking the arrow on a chat in the left drawer no longer shows the dropdown menu (rename/delete/etc.). Cannot delete chats. Likely a JS event listener or DOM positioning issue — possibly zoom-related getBoundingClientRect() skew or a regression from recent dropdown/popup fixes. + - [ ] **AUTO_START_LOCAL: persist & auto-restore last running model on restart** New `AUTO_START_LOCAL` env var (bool, default false). On graceful shutdown/restart, persist the currently running model + all launch parameters (engine, quantization, context length, GPU layers, etc.) to a file in `/app/data/`. On startup, if the file exists and AUTO_START_LOCAL is true, automate the full launch pipeline: resolve the saved preset, spin up the engine, and register the endpoint — skipping the manual cookbook → Launch → pick model → verify params → launch → wait → refresh cycle the user currently endures on every restart. diff --git a/src/agent_loop.py b/src/agent_loop.py index 79efb7e3..2709c99b 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -2912,6 +2912,54 @@ async def stream_agent_loop( r"\b[^.\n]{0,140}", re.IGNORECASE, ) + # Map intent verbs → likely tool names, filtered against available tools. + # Used by the intent-without-action supervisor to give the model a concrete + # tool name instead of a vague "DO IT NOW." + _INTENT_TOOL_HINTS: Dict[str, List[str]] = { + "list": ["ls", "list_vault", "list_served_models", "list_sessions", "manage_documents"], + "scan": ["ls", "list_vault", "grep"], + "read": ["read_file", "manage_documents", "view_document"], + "view": ["read_file", "manage_documents", "view_document"], + "see": ["read_file", "manage_documents", "view_document"], + "look": ["read_file", "grep", "web_search"], + "show": ["read_file", "manage_documents"], + "search": ["web_search", "grep", "search_documents"], + "find": ["grep", "web_search", "search_documents"], + "query": ["web_search", "grep"], + "grep": ["grep"], + "write": ["write_file", "create_document"], + "create": ["create_document", "write_file"], + "edit": ["edit_document", "write_file"], + "tail": ["tail_serve_output", "list_served_models"], + "check": ["read_file", "grep", "web_search", "list_served_models"], + "investigate": ["read_file", "grep", "web_search", "bash"], + "examine": ["read_file", "grep", "bash"], + "debug": ["bash", "python", "read_file", "grep"], + "run": ["bash", "python"], + "execute": ["bash", "python"], + "start": ["launch_model", "serve_model"], + "launch": ["launch_model", "serve_model"], + "call": [], # too generic — model usually names the tool explicitly + "fetch": ["web_fetch", "read_file"], + "pull": ["web_fetch", "read_file"], + "inspect": ["read_file", "grep", "bash"], + "verify": ["read_file", "grep", "bash"], + "diagnose": ["bash", "read_file", "grep"], + "capture": ["read_file", "grep", "bash"], + "grab": ["read_file", "web_fetch"], + "test": ["bash", "python"], + "use": [], # too generic + "do": [], # too generic + "perform": [], # too generic + "serve": ["serve_model", "list_served_models"], + "register": ["register_endpoint"], + "adopt": ["register_endpoint"], + "kill": ["bash"], + "stop": ["bash"], + "restart": ["bash"], + "kick": ["bash"], + "trigger": ["bash", "python"], + } _awaiting_user = False # set by ask_user → end the turn and wait for a choice # Document streaming state (persists across rounds) @@ -3433,24 +3481,94 @@ async def stream_agent_loop( "session_id from the serve/list result. Never answer with " "\"check logs\" when those tools are available." ) + # Find the most specific tool hint from the intent phrase. + _hint_tools: List[str] = [] + _avail = _relevant_tools or set() + for _verb, _tools in _INTENT_TOOL_HINTS.items(): + if f" {_verb}" in f" {_lower_phrase}" and _tools: + _hint_tools = [t for t in _tools if t in _avail][:3] + if _hint_tools: + break + # Also check if the model named a specific tool + _named_match = re.search( + r'(?:call|run|use|trigger)\s+(?:the\s+)?`?(\w[\w_-]+)`?', + _matched_phrase, re.IGNORECASE, + ) + if _named_match: + _named_tool = _named_match.group(1).lower() + if _named_tool in _avail: + _hint_tools.insert(0, _named_tool) + _tool_hint = "" + if _hint_tools: + _tool_list = "`, `".join(_hint_tools[:3]) + _tool_hint = f" Call `{_tool_list}`. " + messages.append({ "role": "system", "content": ( f"You just wrote: \"{_matched_phrase}\" — but ended the " - "turn without making the actual tool call. The user can " - "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}" + "turn without making the actual tool call." + _tool_hint + + " The user can see you announced the action but didn't run " + "it, which is the most frustrating thing you can do. " + "DO IT NOW: emit the function call this turn. " + + _cookbook_log_hint + "If you decided not to do it after all, say so plainly in " "one sentence instead of restating the plan." ), }) - # Visible signal in the stream so the user knows we caught it. yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n' continue break # no tools — done + # ── Wrong-tool detector ─────────────────────────────────────── + # When the model DID emit a tool call but the prose describes a + # DIFFERENT action. Common on smaller models: says "I'll list the + # vault" but emits rename_session. Compare intent against actual + # tool blocks and flag mismatches. + _intent_text_wt = _strip_think_blocks(cleaned_round).strip() + _intent_match_wt = _INTENT_RE.search(_intent_text_wt) if _intent_text_wt else None + if _intent_match_wt and tool_blocks and not guide_only and not _force_answer: + _matched_wt = _intent_match_wt.group(0).strip().lower() + _actual_tools = {b.tool_type for b in tool_blocks} + _suggested: Set[str] = set() + for _verb, _tools in _INTENT_TOOL_HINTS.items(): + if f" {_verb}" in f" {_matched_wt}": + _suggested.update(_tools) + _named_wt = re.search( + r'(?:call|run|use|trigger)\s+(?:the\s+)?`?(\w[\w_-]+)`?', + _matched_wt, re.IGNORECASE, + ) + if _named_wt: + _suggested.add(_named_wt.group(1).lower()) + if _suggested and not (_suggested & _actual_tools): + logger.info( + f"[agent] wrong-tool-detect round {round_num}: " + f"intent suggests {sorted(_suggested)[:5]}, actual={sorted(_actual_tools)}" + ) + _avail_wt = _relevant_tools or set() + _candidates = sorted(_suggested & _avail_wt)[:3] + if _candidates: + _cand_list = "`, `".join(_candidates) + logger.info( + f"[agent] wrong-tool: {sorted(_actual_tools)} ≠ {_candidates} — " + f"skipping execution, injecting correction" + ) + messages.append({ + "role": "system", + "content": ( + f"You just called `{'`, `'.join(sorted(_actual_tools))}` " + f"but your description said you wanted to: \"{_matched_wt}\". " + f"These are not the same thing. The tool you likely need is " + f"`{_cand_list}`. Call that instead." + ), + }) + _empty_round = round_response.strip() or "(model attempted wrong tool call)" + _append_tool_results( + messages, _empty_round, [], [], [], False, round_num, round_reasoning, + ) + yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n' + continue + # ── Loop-breaker (Terminus-style stall detector) ────────────── # Stall detector for repeated no-progress tool loops. # A round is "useless" ONLY when it re-issues a recent tool call AND