Skip to content

Commit 9e8eefe

Browse files
Gh-Novelclaude
andcommitted
feat(agent): iteration-cap recovery and ablation support
- when the tool budget is exhausted, force one final tools-disabled call so the agent answers from gathered evidence instead of giving up - exclude_tools param on the agent/MCP client hides chosen tools - THINKMCP_DISABLE_REASONING=1 hides think/critique/plan at the server - correct search_code docs: GitHub code search requires auth Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7fb97e8 commit 9e8eefe

3 files changed

Lines changed: 81 additions & 39 deletions

File tree

‎agent/mcp_client.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,15 @@ class MCPToolClient:
3030
out = await mcp.call_tool("web_search_tool", {"query": "..."})
3131
"""
3232

33-
def __init__(self, server_path: str = SERVER_PATH, env: dict[str, str] | None = None):
33+
def __init__(
34+
self,
35+
server_path: str = SERVER_PATH,
36+
env: dict[str, str] | None = None,
37+
exclude_tools: set[str] | None = None,
38+
):
3439
self._server_path = server_path
3540
self._env = {**os.environ, **(env or {})}
41+
self._exclude_tools = exclude_tools or set()
3642
self._stack: AsyncExitStack | None = None
3743
self.session: ClientSession | None = None
3844
self.tools: list[Any] = []
@@ -50,7 +56,7 @@ async def __aenter__(self) -> "MCPToolClient":
5056
await self.session.initialize()
5157

5258
listing = await self.session.list_tools()
53-
self.tools = listing.tools
59+
self.tools = [t for t in listing.tools if t.name not in self._exclude_tools]
5460
self.ollama_tools = [
5561
{
5662
"type": "function",

‎agent/thinking_agent.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ async def run_thinking_agent_async(
8585
on_event: Callable[[str, Any], None] | None = None,
8686
model: str = DEFAULT_MODEL,
8787
ollama_host: str = DEFAULT_OLLAMA_HOST,
88+
exclude_tools: set[str] | None = None,
8889
) -> tuple[str, list[dict]]:
8990
"""
9091
Run the ThinkMCP agent on a query.
@@ -97,6 +98,7 @@ async def run_thinking_agent_async(
9798
event_type in {'thinking', 'tool_call', 'tool_result', 'text', 'done'}
9899
model: Ollama model name (any tool-capable Qwen 3 variant works).
99100
ollama_host: Base URL of the Ollama server.
101+
exclude_tools: Tool names to hide from the model (used for ablations).
100102
101103
Returns:
102104
(final_answer, thinking_trace) where thinking_trace is a list of dicts
@@ -119,7 +121,7 @@ def _emit(event_type: str, data: Any) -> None:
119121
except Exception:
120122
pass
121123

122-
async with MCPToolClient() as mcp:
124+
async with MCPToolClient(exclude_tools=exclude_tools) as mcp:
123125
messages: list[dict] = [
124126
{"role": "system", "content": system},
125127
{"role": "user", "content": query},
@@ -183,6 +185,36 @@ def _emit(event_type: str, data: Any) -> None:
183185
{"role": "tool", "tool_name": name, "content": result_str}
184186
)
185187

188+
# Iteration cap reached — don't discard the gathered evidence.
189+
# One final call with tools disabled forces an answer from context.
190+
messages.append({
191+
"role": "user",
192+
"content": (
193+
"You have reached the tool-call limit. Using only the "
194+
"information gathered above, give your best final answer "
195+
"now. Do not request any more tools."
196+
),
197+
})
198+
try:
199+
response = await client.chat(
200+
model=model,
201+
messages=messages,
202+
think=use_native_thinking,
203+
options={"num_ctx": NUM_CTX},
204+
)
205+
msg = response.message
206+
native_thought = getattr(msg, "thinking", None) or ""
207+
tag_thought, visible = _split_think_tags(msg.content or "")
208+
thought = native_thought or tag_thought
209+
if thought.strip():
210+
entry = _thinking_entry(step, thought.strip())
211+
thinking_trace.append(entry)
212+
_emit("thinking", entry)
213+
step += 1
214+
final_answer = visible.strip()
215+
except ResponseError:
216+
final_answer = ""
217+
186218
final_answer = (
187219
final_answer
188220
or "Reached maximum iterations before the model produced a final answer."
@@ -198,6 +230,7 @@ def run_thinking_agent(
198230
on_event: Callable[[str, Any], None] | None = None,
199231
model: str = DEFAULT_MODEL,
200232
ollama_host: str = DEFAULT_OLLAMA_HOST,
233+
exclude_tools: set[str] | None = None,
201234
) -> tuple[str, list[dict]]:
202235
"""Synchronous wrapper around run_thinking_agent_async."""
203236
return asyncio.run(
@@ -208,5 +241,6 @@ def run_thinking_agent(
208241
on_event=on_event,
209242
model=model,
210243
ollama_host=ollama_host,
244+
exclude_tools=exclude_tools,
211245
)
212246
)

‎server/mcp_server.py‎

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -65,48 +65,50 @@ def search_papers_tool(topic: str) -> dict:
6565
def search_code_tool(query: str) -> dict:
6666
"""
6767
Search GitHub for code matching a query.
68-
Optionally uses GITHUB_TOKEN env var for higher rate limits.
68+
Requires GITHUB_TOKEN (GitHub's code-search API needs auth; zero scopes suffice).
6969
Returns: {query, total_count, results: [{name, path, repo, url, score}]}
7070
"""
7171
return search_code(query)
7272

7373

7474
# ── REASONING TOOLS ───────────────────────────────────────────────────────────
75-
76-
@mcp.tool()
77-
def think_tool(thought: str) -> str:
78-
"""
79-
A scratchpad for mid-workflow reasoning. Use this between tool calls to:
80-
- Plan next steps
81-
- Evaluate quality of retrieved results
82-
- Detect contradictions between sources
83-
- Decide if more information is needed
84-
Does NOT call any external service. Returns the thought for traceability.
85-
"""
86-
return think(thought)
87-
88-
89-
@mcp.tool()
90-
def critique_tool(answer: str, question: str) -> dict:
91-
"""
92-
Self-evaluate a draft answer before returning it to the user.
93-
Returns a structured quality assessment with confidence score,
94-
missing aspects, and a recommendation to accept or revise.
95-
Returns: {answer_preview, word_count, completeness, confidence,
96-
missing_aspects, recommendation}
97-
"""
98-
return critique(answer, question)
99-
100-
101-
@mcp.tool()
102-
def plan_tool(goal: str) -> dict:
103-
"""
104-
Break a goal into an ordered list of actionable sub-steps.
105-
Detects goal type (research, comparison, implementation, summarization)
106-
and returns a tailored strategy.
107-
Returns: {goal, strategy, steps: [...], total_steps}
108-
"""
109-
return plan(goal)
75+
# THINKMCP_DISABLE_REASONING=1 hides these three tools entirely — used by the
76+
# benchmark's ablated arm so the model cannot see (not just cannot call) them.
77+
78+
if os.environ.get("THINKMCP_DISABLE_REASONING", "") != "1":
79+
80+
@mcp.tool()
81+
def think_tool(thought: str) -> str:
82+
"""
83+
A scratchpad for mid-workflow reasoning. Use this between tool calls to:
84+
- Plan next steps
85+
- Evaluate quality of retrieved results
86+
- Detect contradictions between sources
87+
- Decide if more information is needed
88+
Does NOT call any external service. Returns the thought for traceability.
89+
"""
90+
return think(thought)
91+
92+
@mcp.tool()
93+
def critique_tool(answer: str, question: str) -> dict:
94+
"""
95+
Self-evaluate a draft answer before returning it to the user.
96+
Returns a structured quality assessment with confidence score,
97+
missing aspects, and a recommendation to accept or revise.
98+
Returns: {answer_preview, word_count, completeness, confidence,
99+
missing_aspects, recommendation}
100+
"""
101+
return critique(answer, question)
102+
103+
@mcp.tool()
104+
def plan_tool(goal: str) -> dict:
105+
"""
106+
Break a goal into an ordered list of actionable sub-steps.
107+
Detects goal type (research, comparison, implementation, summarization)
108+
and returns a tailored strategy.
109+
Returns: {goal, strategy, steps: [...], total_steps}
110+
"""
111+
return plan(goal)
110112

111113

112114
# ── MEMORY TOOLS ──────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)