Skip to content

Commit 63c2856

Browse files
authored
feat: pre-call sanitization and post-call tool guardrails (NousResearch#1732)
Salvage of PR NousResearch#1321 by @alireza78a (cherry-picked concept, reimplemented against current main). Phase 1 — Pre-call message sanitization: _sanitize_api_messages() now runs unconditionally before every LLM call. Previously gated on context_compressor being present, so sessions loaded from disk or running without compression could accumulate dangling tool_call/tool_result pairs causing API errors. Phase 2a — Delegate task cap: _cap_delegate_task_calls() truncates excess delegate_task calls per turn to MAX_CONCURRENT_CHILDREN. The existing cap in delegate_tool.py only limits the task array within a single call; this catches multiple separate delegate_task tool_calls in one turn. Phase 2b — Tool call deduplication: _deduplicate_tool_calls() drops duplicate (tool_name, arguments) pairs within a single turn when models stutter. All three are static methods on AIAgent, independently testable. 29 tests covering happy paths and edge cases.
1 parent 438abe6 commit 63c2856

2 files changed

Lines changed: 394 additions & 7 deletions

File tree

run_agent.py

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1957,7 +1957,124 @@ def _build_system_prompt(self, system_message: str = None) -> str:
19571957
prompt_parts.append(PLATFORM_HINTS[platform_key])
19581958

19591959
return "\n\n".join(prompt_parts)
1960-
1960+
1961+
# =========================================================================
1962+
# Pre/post-call guardrails (inspired by PR #1321 — @alireza78a)
1963+
# =========================================================================
1964+
1965+
@staticmethod
1966+
def _get_tool_call_id_static(tc) -> str:
1967+
"""Extract call ID from a tool_call entry (dict or object)."""
1968+
if isinstance(tc, dict):
1969+
return tc.get("id", "") or ""
1970+
return getattr(tc, "id", "") or ""
1971+
1972+
@staticmethod
1973+
def _sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1974+
"""Fix orphaned tool_call / tool_result pairs before every LLM call.
1975+
1976+
Runs unconditionally — not gated on whether the context compressor
1977+
is present — so orphans from session loading or manual message
1978+
manipulation are always caught.
1979+
"""
1980+
surviving_call_ids: set = set()
1981+
for msg in messages:
1982+
if msg.get("role") == "assistant":
1983+
for tc in msg.get("tool_calls") or []:
1984+
cid = AIAgent._get_tool_call_id_static(tc)
1985+
if cid:
1986+
surviving_call_ids.add(cid)
1987+
1988+
result_call_ids: set = set()
1989+
for msg in messages:
1990+
if msg.get("role") == "tool":
1991+
cid = msg.get("tool_call_id")
1992+
if cid:
1993+
result_call_ids.add(cid)
1994+
1995+
# 1. Drop tool results with no matching assistant call
1996+
orphaned_results = result_call_ids - surviving_call_ids
1997+
if orphaned_results:
1998+
messages = [
1999+
m for m in messages
2000+
if not (m.get("role") == "tool" and m.get("tool_call_id") in orphaned_results)
2001+
]
2002+
logger.debug(
2003+
"Pre-call sanitizer: removed %d orphaned tool result(s)",
2004+
len(orphaned_results),
2005+
)
2006+
2007+
# 2. Inject stub results for calls whose result was dropped
2008+
missing_results = surviving_call_ids - result_call_ids
2009+
if missing_results:
2010+
patched: List[Dict[str, Any]] = []
2011+
for msg in messages:
2012+
patched.append(msg)
2013+
if msg.get("role") == "assistant":
2014+
for tc in msg.get("tool_calls") or []:
2015+
cid = AIAgent._get_tool_call_id_static(tc)
2016+
if cid in missing_results:
2017+
patched.append({
2018+
"role": "tool",
2019+
"content": "[Result unavailable — see context summary above]",
2020+
"tool_call_id": cid,
2021+
})
2022+
messages = patched
2023+
logger.debug(
2024+
"Pre-call sanitizer: added %d stub tool result(s)",
2025+
len(missing_results),
2026+
)
2027+
2028+
return messages
2029+
2030+
@staticmethod
2031+
def _cap_delegate_task_calls(tool_calls: list) -> list:
2032+
"""Truncate excess delegate_task calls to MAX_CONCURRENT_CHILDREN.
2033+
2034+
The delegate_tool caps the task list inside a single call, but the
2035+
model can emit multiple separate delegate_task tool_calls in one
2036+
turn. This truncates the excess, preserving all non-delegate calls.
2037+
2038+
Returns the original list if no truncation was needed.
2039+
"""
2040+
from tools.delegate_tool import MAX_CONCURRENT_CHILDREN
2041+
delegate_count = sum(1 for tc in tool_calls if tc.function.name == "delegate_task")
2042+
if delegate_count <= MAX_CONCURRENT_CHILDREN:
2043+
return tool_calls
2044+
kept_delegates = 0
2045+
truncated = []
2046+
for tc in tool_calls:
2047+
if tc.function.name == "delegate_task":
2048+
if kept_delegates < MAX_CONCURRENT_CHILDREN:
2049+
truncated.append(tc)
2050+
kept_delegates += 1
2051+
else:
2052+
truncated.append(tc)
2053+
logger.warning(
2054+
"Truncated %d excess delegate_task call(s) to enforce "
2055+
"MAX_CONCURRENT_CHILDREN=%d limit",
2056+
delegate_count - MAX_CONCURRENT_CHILDREN, MAX_CONCURRENT_CHILDREN,
2057+
)
2058+
return truncated
2059+
2060+
@staticmethod
2061+
def _deduplicate_tool_calls(tool_calls: list) -> list:
2062+
"""Remove duplicate (tool_name, arguments) pairs within a single turn.
2063+
2064+
Only the first occurrence of each unique pair is kept.
2065+
Returns the original list if no duplicates were found.
2066+
"""
2067+
seen: set = set()
2068+
unique: list = []
2069+
for tc in tool_calls:
2070+
key = (tc.function.name, tc.function.arguments)
2071+
if key not in seen:
2072+
seen.add(key)
2073+
unique.append(tc)
2074+
else:
2075+
logger.warning("Removed duplicate tool call: %s", tc.function.name)
2076+
return unique if len(unique) < len(tool_calls) else tool_calls
2077+
19612078
def _repair_tool_call(self, tool_name: str) -> str | None:
19622079
"""Attempt to repair a mismatched tool name before aborting.
19632080
@@ -4992,11 +5109,10 @@ def run_conversation(
49925109
api_messages = apply_anthropic_cache_control(api_messages, cache_ttl=self._cache_ttl)
49935110

49945111
# Safety net: strip orphaned tool results / add stubs for missing
4995-
# results before sending to the API. The compressor handles this
4996-
# during compression, but orphans can also sneak in from session
4997-
# loading or manual message manipulation.
4998-
if hasattr(self, 'context_compressor') and self.context_compressor:
4999-
api_messages = self.context_compressor._sanitize_tool_pairs(api_messages)
5112+
# results before sending to the API. Runs unconditionally — not
5113+
# gated on context_compressor — so orphans from session loading or
5114+
# manual message manipulation are always caught.
5115+
api_messages = self._sanitize_api_messages(api_messages)
50005116

50015117
# Calculate approximate request size for logging
50025118
total_chars = sum(len(str(msg)) for msg in api_messages)
@@ -6026,7 +6142,15 @@ def _stop_spinner():
60266142

60276143
# Reset retry counter on successful JSON validation
60286144
self._invalid_json_retries = 0
6029-
6145+
6146+
# ── Post-call guardrails ──────────────────────────
6147+
assistant_message.tool_calls = self._cap_delegate_task_calls(
6148+
assistant_message.tool_calls
6149+
)
6150+
assistant_message.tool_calls = self._deduplicate_tool_calls(
6151+
assistant_message.tool_calls
6152+
)
6153+
60306154
assistant_msg = self._build_assistant_message(assistant_message, finish_reason)
60316155

60326156
# If this turn has both content AND tool_calls, capture the content

0 commit comments

Comments
 (0)