@@ -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