Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions src/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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]

Expand All @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions tests/test_disabled_tool_rules.py
Original file line number Diff line number Diff line change
@@ -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
Loading