diff --git a/src/agent_loop.py b/src/agent_loop.py index 178443bf36..db7c2e42c9 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -842,6 +842,39 @@ def _compact_tool_line(name: str, section: str) -> str: return f"- `{name}` — " + lines[0][:160] +def _strip_disabled_tool_rules(text: str, disabled: set) -> str: + """Drop rule lines that tell the model to use a tool it does not have. + + `_assemble_prompt` filters TOOL_SECTIONS through `included`, but the static + rule blocks (_AGENT_RULES, _API_AGENT_RULES, _DOMAIN_RULES) are appended + whole. A disabled tool therefore loses its schema and its description while + keeping its behavioural rule. With `manage_memory` disabled, for example, + the prompt still carries + + - User identity facts/preferences ("my name is X", ...) use + `manage_memory`, not contacts. + + Observed with local models: pointed at a tool that is absent from the + toolset, the model does nothing at all for that case rather than reaching + for a tool it does have. A rule naming an unavailable tool is worse than no + rule, so drop those lines and leave everything else untouched. + + No-op when nothing is disabled. + """ + if not disabled or not text: + return text + kept, dropped = [], [] + for line in text.split("\n"): + stripped = line.lstrip() + if stripped.startswith("- ") and any(f"`{name}`" in line for name in disabled): + dropped.append(stripped[:70]) + continue + kept.append(line) + if dropped: + logger.debug("[prompt] dropped %d rule line(s) for disabled tools: %s", len(dropped), dropped) + return "\n".join(kept) + + 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() @@ -860,7 +893,7 @@ def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool _API_AGENT_RULES, ] parts.extend(_domain_rules_for_tools(included)) - return "\n\n".join(parts) + return _strip_disabled_tool_rules("\n\n".join(parts), disabled) parts = [_AGENT_PREAMBLE] @@ -887,7 +920,7 @@ def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool parts.append(_AGENT_RULES) parts.extend(_domain_rules_for_tools(included)) - return "\n\n".join(parts) + return _strip_disabled_tool_rules("\n\n".join(parts), disabled) # Legacy: full prompt with all tools (fallback when RAG unavailable) diff --git a/tests/test_disabled_tool_rules.py b/tests/test_disabled_tool_rules.py new file mode 100644 index 0000000000..1f53e27f37 --- /dev/null +++ b/tests/test_disabled_tool_rules.py @@ -0,0 +1,48 @@ +"""Tests for _strip_disabled_tool_rules — see issue: rules for disabled tools +stay in the system prompt after the tool itself has been filtered out.""" + +from src.agent_loop import _strip_disabled_tool_rules + + +RULES = ( + "## Base rules\n" + "- Prefer native tool/function calling when tools are needed.\n" + '- User identity facts/preferences ("my name is X") use `manage_memory`, not contacts.\n' + "- Notes/todos/reminders use `manage_notes`, not memory.\n" +) + + +def test_drops_only_lines_naming_a_disabled_tool(): + out = _strip_disabled_tool_rules(RULES, {"manage_memory"}) + assert "`manage_memory`" not in out + assert "`manage_notes`" in out + assert "Prefer native tool/function calling" in out + assert out.startswith("## Base rules") + + +def test_unrelated_disabled_tool_changes_nothing(): + assert _strip_disabled_tool_rules(RULES, {"web_search"}) == RULES + + +def test_no_op_without_disabled_tools(): + assert _strip_disabled_tool_rules(RULES, set()) == RULES + assert _strip_disabled_tool_rules("", {"manage_memory"}) == "" + + +def test_prose_is_not_a_rule_line(): + text = "Memory lives in `manage_memory` for this deployment.\n- unrelated rule\n" + assert _strip_disabled_tool_rules(text, {"manage_memory"}) == text + + +def test_indented_rule_lines_are_dropped(): + text = "## Rules\n - nested rule using `manage_memory`\n - keep this one\n" + out = _strip_disabled_tool_rules(text, {"manage_memory"}) + assert "manage_memory" not in out + assert "keep this one" in out + + +def test_multiple_disabled_tools(): + out = _strip_disabled_tool_rules(RULES, {"manage_memory", "manage_notes"}) + assert "manage_memory" not in out + assert "manage_notes" not in out + assert "Prefer native tool/function calling" in out