Skip to content

Commit 64be596

Browse files
nsgdsclaude
andcommitted
fix(agent): re-arm the domain of recently-used tools for anaphoric follow-ups
A vague reply right after a real tool turn ('do another', 'add a couple more to it', 'try again') references the prior action only anaphorically: no domain keyword, and not a terse continuation. _classify_agent_request returns domains=[], the just-used tool is stripped from selection, and a small model confabulates the action instead — observed live as a fabricated image link, and as a wrong-tool cascade that built a phantom document while claiming to update a note. When a turn matches NO domain (and isn't a continuation), re-arm the domain of any tool a recent assistant turn actually used, reusing _DOMAIN_TOOL_MAP as the tool->domain reverse map — no per-domain special casing. Deliberately narrow: keyword-matched turns are untouched, casual/greeting turns exit before the re-arm, explicit continuations already inherit context, and unmapped (user-registered MCP) tool names are inert. Tool use is read from persisted tool_events metadata (survives session reload) with a generated-image URL fallback. Disabled tools stay disabled: domain seeding feeds the same schema filter as every other selection path. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c1d6287 commit 64be596

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

src/agent_loop.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,39 @@ def _is_contextual_retry_continuation(messages: List[Dict], text: str) -> bool:
952952
return bool(_COOKBOOK_CONTEXT_RE.search(recent))
953953

954954

955+
def _assistant_recently_used_tools(messages: List[Dict], lookback: int = 4) -> set:
956+
"""Tool names used by the last ``lookback`` assistant turns.
957+
958+
An anaphoric follow-up ("do another", "reply to the next one too", "add a
959+
couple more") refers to a just-completed tool action but contains no domain
960+
keyword, so it matches nothing and hits the low_signal short-circuit that
961+
strips every tool — leaving a small model to confabulate a fake action
962+
instead of repeating the real one. Re-arming the domain of a recently-used
963+
tool keeps that tool offered. Read from recorded ``tool_events`` metadata,
964+
with a generated-image URL fallback (image tools don't always thread the
965+
metadata into the message list).
966+
"""
967+
used: set = set()
968+
checked = 0
969+
for msg in reversed(messages):
970+
if msg.get("role") != "assistant":
971+
continue
972+
checked += 1
973+
if checked > lookback:
974+
break
975+
events = (msg.get("metadata") or {}).get("tool_events") or []
976+
if isinstance(events, list):
977+
for e in events:
978+
if isinstance(e, dict) and e.get("tool"):
979+
used.add(e["tool"])
980+
content = msg.get("content", "")
981+
if isinstance(content, list):
982+
content = " ".join(b.get("text", "") for b in content if isinstance(b, dict))
983+
if "/api/generated-image/" in str(content or ""):
984+
used.update(("generate_image", "edit_image"))
985+
return used
986+
987+
955988
def _assistant_requested_followup(messages: List[Dict]) -> bool:
956989
"""True when the previous assistant turn asked for missing task details.
957990
@@ -1077,6 +1110,25 @@ def has(*patterns: str) -> bool:
10771110
r"\b(?:home ?assistant|miniflux|gitea|linkding|jellyfin)\b"):
10781111
domains.add("integrations")
10791112

1113+
# Anaphoric-follow-up re-arm (domain-agnostic). A vague reply right after a
1114+
# real tool turn ("do another", "reply to the next one too", "add a couple
1115+
# more") references the prior action only anaphorically — no keyword — so it
1116+
# matches no domain and would be stripped of every tool, leaving a small
1117+
# model to confabulate a fake action. If a recent assistant turn actually
1118+
# used a tool, re-arm that tool's domain so it stays offered, reusing the
1119+
# existing _DOMAIN_TOOL_MAP as the tool->domain map (no per-domain special
1120+
# casing). Deliberately narrow: only when THIS turn matched nothing (the
1121+
# keyword classifier already handled turns with their own intent, and
1122+
# widening every keyword-matched turn with historical domains would bloat
1123+
# tool selection), and not for explicit continuations, which already
1124+
# inherit recent context for both retrieval and domain matching.
1125+
if not continuation and not domains:
1126+
_recent_tools = _assistant_recently_used_tools(messages)
1127+
if _recent_tools:
1128+
for _dom, _dom_tools in _DOMAIN_TOOL_MAP.items():
1129+
if _recent_tools & _dom_tools:
1130+
domains.add(_dom)
1131+
10801132
low_signal = not continuation and not domains
10811133
return {
10821134
"low_signal": low_signal,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""A vague follow-up right after a real tool turn ("do another", "add a couple
2+
more to it", "try again") references the prior action only anaphorically — no
3+
domain keyword and not a terse continuation — so `_classify_agent_request`
4+
returns domains=[] and the just-used tool is stripped from selection. A small
5+
model, asked to repeat an action it no longer has, confabulates: observed live
6+
as a fabricated image link (images) and a wrong-tool cascade that built a
7+
phantom document while claiming to update a note (notes).
8+
9+
Fix: when a turn matches NO domain (and isn't a continuation), re-arm the
10+
domain of any tool a recent assistant turn actually used, reusing
11+
_DOMAIN_TOOL_MAP as the tool->domain reverse map. Deliberately narrow — turns
12+
that matched a domain of their own are untouched, casual/greeting turns exit
13+
before the re-arm, and explicit continuations already inherit context.
14+
15+
The classifier is deterministic (no embeddings/DB), so it is exercised directly.
16+
"""
17+
18+
from src.agent_loop import (
19+
_assistant_recently_used_tools,
20+
_classify_agent_request,
21+
_DOMAIN_TOOL_MAP,
22+
)
23+
24+
25+
def _tool_turn(tool, text="Done."):
26+
return {"role": "assistant", "content": text,
27+
"metadata": {"tool_events": [{"round": 1, "tool": tool, "exit_code": 0}]}}
28+
29+
30+
def _plain(role, text):
31+
return {"role": role, "content": text}
32+
33+
34+
def _classify(msgs):
35+
return _classify_agent_request(msgs, msgs[-1]["content"])
36+
37+
38+
# --- _assistant_recently_used_tools -----------------------------------------
39+
40+
def test_collects_tools_from_tool_events():
41+
msgs = [_plain("user", "make a note to buy milk"), _tool_turn("manage_notes"),
42+
_plain("user", "add a couple more to it")]
43+
assert "manage_notes" in _assistant_recently_used_tools(msgs)
44+
45+
46+
def test_generated_image_url_fallback_maps_to_image_tools():
47+
msgs = [_plain("user", "make an image"),
48+
{"role": "assistant", "content": "Direct link: https://x/api/generated-image/abc.png"},
49+
_plain("user", "do another one")]
50+
used = _assistant_recently_used_tools(msgs)
51+
assert {"generate_image", "edit_image"} <= used
52+
53+
54+
def test_lookback_bound():
55+
"""Tool turns older than the lookback window are not collected."""
56+
msgs = [_plain("user", "note please"), _tool_turn("manage_notes")]
57+
for i in range(5): # 5 newer assistant turns push the tool turn out (lookback=4)
58+
msgs += [_plain("user", f"q{i}"), _plain("assistant", f"a{i}")]
59+
msgs += [_plain("user", "add more to it")]
60+
assert "manage_notes" not in _assistant_recently_used_tools(msgs)
61+
62+
63+
# --- the classifier re-arm ---------------------------------------------------
64+
65+
def test_anaphoric_followup_rearms_used_tools_domain():
66+
"""The live repro: notes turn, then a keyword-free follow-up."""
67+
msgs = [_plain("user", 'Make a note titled "Grocery run" to buy milk and eggs.'),
68+
_tool_turn("manage_notes"),
69+
_plain("user", "actually add a couple more to it — bread and coffee")]
70+
intent = _classify(msgs)
71+
assert "notes_calendar_tasks" in intent["domains"]
72+
assert intent["low_signal"] is False
73+
74+
75+
def test_rearm_covers_every_mapped_domain():
76+
"""The reverse map must work for each domain's tools, not just one."""
77+
for domain, tools in _DOMAIN_TOOL_MAP.items():
78+
tool = sorted(tools)[0]
79+
msgs = [_plain("user", "please handle this"), _tool_turn(tool),
80+
_plain("user", "hmm, now do the other one as well")]
81+
intent = _classify(msgs)
82+
assert domain in intent["domains"], f"{tool} should re-arm {domain}"
83+
84+
85+
def test_turn_with_its_own_domain_is_untouched():
86+
"""A follow-up that matched a domain keeps ONLY its own match — recent tool
87+
use must not piggyback extra domains onto keyword-matched turns."""
88+
msgs = [_plain("user", "make a note to buy milk"), _tool_turn("manage_notes"),
89+
_plain("user", "now reply to the latest email in my inbox")]
90+
intent = _classify(msgs)
91+
assert "email" in intent["domains"]
92+
assert "notes_calendar_tasks" not in intent["domains"]
93+
94+
95+
def test_casual_turn_does_not_rearm():
96+
"""Greetings/thanks exit on the casual path before the re-arm."""
97+
msgs = [_plain("user", "make a note to buy milk"), _tool_turn("manage_notes"),
98+
_plain("user", "thanks!")]
99+
intent = _classify(msgs)
100+
assert intent["domains"] == set()
101+
assert intent["low_signal"] is True
102+
103+
104+
def test_unmapped_mcp_tool_is_inert():
105+
"""A user-registered qualified MCP tool maps to no domain — no re-arm."""
106+
msgs = [_plain("user", "check the weather"), _tool_turn("mcp__weather__get_forecast"),
107+
_plain("user", "and tomorrow as well?")]
108+
intent = _classify(msgs)
109+
assert intent["domains"] == set()
110+
111+
112+
def test_fresh_chat_unaffected():
113+
"""No assistant history -> nothing to re-arm; fresh-turn behavior unchanged."""
114+
msgs = [_plain("user", "hello there, what can you do?")]
115+
intent = _classify(msgs)
116+
assert intent["domains"] == set()

0 commit comments

Comments
 (0)