@@ -89,6 +89,7 @@ def my_tool(x: int) -> str:
8989 from collections .abc import Sequence
9090
9191 from langgraph .runtime import Runtime
92+ from pydantic_core import ErrorDetails
9293
9394# right now we use a dict as the default, can change this to AgentState, but depends
9495# on if this lives in LangChain or LangGraph... ideally would have some typed
@@ -303,21 +304,40 @@ class ToolInvocationError(ToolException):
303304 """
304305
305306 def __init__ (
306- self , tool_name : str , source : ValidationError , tool_kwargs : dict [str , Any ]
307+ self ,
308+ tool_name : str ,
309+ source : ValidationError ,
310+ tool_kwargs : dict [str , Any ],
311+ filtered_errors : list [ErrorDetails ] | None = None ,
307312 ) -> None :
308313 """Initialize the ToolInvocationError.
309314
310315 Args:
311316 tool_name: The name of the tool that failed.
312317 source: The exception that occurred.
313318 tool_kwargs: The keyword arguments that were passed to the tool.
319+ filtered_errors: Optional list of filtered validation errors excluding
320+ injected arguments.
314321 """
322+ # Format error display based on filtered errors if provided
323+ if filtered_errors is not None :
324+ # Manually format the filtered errors without URLs or fancy formatting
325+ error_str_parts = []
326+ for error in filtered_errors :
327+ loc_str = "." .join (str (loc ) for loc in error .get ("loc" , ()))
328+ msg = error .get ("msg" , "Unknown error" )
329+ error_str_parts .append (f"{ loc_str } : { msg } " )
330+ error_display_str = "\n " .join (error_str_parts )
331+ else :
332+ error_display_str = str (source )
333+
315334 self .message = TOOL_INVOCATION_ERROR_TEMPLATE .format (
316- tool_name = tool_name , tool_kwargs = tool_kwargs , error = source
335+ tool_name = tool_name , tool_kwargs = tool_kwargs , error = error_display_str
317336 )
318337 self .tool_name = tool_name
319338 self .tool_kwargs = tool_kwargs
320339 self .source = source
340+ self .filtered_errors = filtered_errors
321341 super ().__init__ (self .message )
322342
323343
@@ -442,6 +462,59 @@ def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception],
442462 return (Exception ,)
443463
444464
465+ def _filter_validation_errors (
466+ validation_error : ValidationError ,
467+ tool_to_state_args : dict [str , str | None ],
468+ tool_to_store_arg : str | None ,
469+ tool_to_runtime_arg : str | None ,
470+ ) -> list [ErrorDetails ]:
471+ """Filter validation errors to only include LLM-controlled arguments.
472+
473+ When a tool invocation fails validation, only errors for arguments that the LLM
474+ controls should be included in error messages. This ensures the LLM receives
475+ focused, actionable feedback about parameters it can actually fix. System-injected
476+ arguments (state, store, runtime) are filtered out since the LLM has no control
477+ over them.
478+
479+ This function also removes injected argument values from the `input` field in error
480+ details, ensuring that only LLM-provided arguments appear in error messages.
481+
482+ Args:
483+ validation_error: The Pydantic ValidationError raised during tool invocation.
484+ tool_to_state_args: Mapping of state argument names to state field names.
485+ tool_to_store_arg: Name of the store argument, if any.
486+ tool_to_runtime_arg: Name of the runtime argument, if any.
487+
488+ Returns:
489+ List of ErrorDetails containing only errors for LLM-controlled arguments,
490+ with system-injected argument values removed from the input field.
491+ """
492+ injected_args = set (tool_to_state_args .keys ())
493+ if tool_to_store_arg :
494+ injected_args .add (tool_to_store_arg )
495+ if tool_to_runtime_arg :
496+ injected_args .add (tool_to_runtime_arg )
497+
498+ filtered_errors : list [ErrorDetails ] = []
499+ for error in validation_error .errors ():
500+ # Check if error location contains any injected argument
501+ # error['loc'] is a tuple like ('field_name',) or ('field_name', 'nested_field')
502+ if error ["loc" ] and error ["loc" ][0 ] not in injected_args :
503+ # Create a copy of the error dict to avoid mutating the original
504+ error_copy : dict [str , Any ] = {** error }
505+
506+ # Remove injected arguments from input_value if it's a dict
507+ if isinstance (error_copy .get ("input" ), dict ):
508+ input_dict = error_copy ["input" ]
509+ input_copy = {k : v for k , v in input_dict .items () if k not in injected_args }
510+ error_copy ["input" ] = input_copy
511+
512+ # Cast is safe because ErrorDetails is a TypedDict compatible with this structure
513+ filtered_errors .append (error_copy ) # type: ignore[arg-type]
514+
515+ return filtered_errors
516+
517+
445518class _ToolNode (RunnableCallable ):
446519 """A node for executing tools in LangGraph workflows.
447520
@@ -623,17 +696,10 @@ def _func(
623696 )
624697 tool_runtimes .append (tool_runtime )
625698
626- # Inject tool arguments (including runtime)
627-
628- injected_tool_calls = []
699+ # Pass original tool calls without injection
629700 input_types = [input_type ] * len (tool_calls )
630- for call , tool_runtime in zip (tool_calls , tool_runtimes , strict = False ):
631- injected_call = self ._inject_tool_args (call , tool_runtime ) # type: ignore[arg-type]
632- injected_tool_calls .append (injected_call )
633701 with get_executor_for_config (config ) as executor :
634- outputs = list (
635- executor .map (self ._run_one , injected_tool_calls , input_types , tool_runtimes )
636- )
702+ outputs = list (executor .map (self ._run_one , tool_calls , input_types , tool_runtimes ))
637703
638704 return self ._combine_tool_outputs (outputs , input_type )
639705
@@ -660,12 +726,10 @@ async def _afunc(
660726 )
661727 tool_runtimes .append (tool_runtime )
662728
663- injected_tool_calls = []
729+ # Pass original tool calls without injection
664730 coros = []
665731 for call , tool_runtime in zip (tool_calls , tool_runtimes , strict = False ):
666- injected_call = self ._inject_tool_args (call , tool_runtime ) # type: ignore[arg-type]
667- injected_tool_calls .append (injected_call )
668- coros .append (self ._arun_one (injected_call , input_type , tool_runtime )) # type: ignore[arg-type]
732+ coros .append (self ._arun_one (call , input_type , tool_runtime )) # type: ignore[arg-type]
669733 outputs = await asyncio .gather (* coros )
670734
671735 return self ._combine_tool_outputs (outputs , input_type )
@@ -742,13 +806,23 @@ def _execute_tool_sync(
742806 msg = f"Tool { call ['name' ]} is not registered with ToolNode"
743807 raise TypeError (msg )
744808
745- call_args = {** call , "type" : "tool_call" }
809+ # Inject state, store, and runtime right before invocation
810+ injected_call = self ._inject_tool_args (call , request .runtime )
811+ call_args = {** injected_call , "type" : "tool_call" }
746812
747813 try :
748814 try :
749815 response = tool .invoke (call_args , config )
750816 except ValidationError as exc :
751- raise ToolInvocationError (call ["name" ], exc , call ["args" ]) from exc
817+ # Filter out errors for injected arguments
818+ filtered_errors = _filter_validation_errors (
819+ exc ,
820+ self ._tool_to_state_args .get (call ["name" ], {}),
821+ self ._tool_to_store_arg .get (call ["name" ]),
822+ self ._tool_to_runtime_arg .get (call ["name" ]),
823+ )
824+ # Use original call["args"] without injected values for error reporting
825+ raise ToolInvocationError (call ["name" ], exc , call ["args" ], filtered_errors ) from exc
752826
753827 # GraphInterrupt is a special exception that will always be raised.
754828 # It can be triggered in the following scenarios,
@@ -887,13 +961,23 @@ async def _execute_tool_async(
887961 msg = f"Tool { call ['name' ]} is not registered with ToolNode"
888962 raise TypeError (msg )
889963
890- call_args = {** call , "type" : "tool_call" }
964+ # Inject state, store, and runtime right before invocation
965+ injected_call = self ._inject_tool_args (call , request .runtime )
966+ call_args = {** injected_call , "type" : "tool_call" }
891967
892968 try :
893969 try :
894970 response = await tool .ainvoke (call_args , config )
895971 except ValidationError as exc :
896- raise ToolInvocationError (call ["name" ], exc , call ["args" ]) from exc
972+ # Filter out errors for injected arguments
973+ filtered_errors = _filter_validation_errors (
974+ exc ,
975+ self ._tool_to_state_args .get (call ["name" ], {}),
976+ self ._tool_to_store_arg .get (call ["name" ]),
977+ self ._tool_to_runtime_arg .get (call ["name" ]),
978+ )
979+ # Use original call["args"] without injected values for error reporting
980+ raise ToolInvocationError (call ["name" ], exc , call ["args" ], filtered_errors ) from exc
897981
898982 # GraphInterrupt is a special exception that will always be raised.
899983 # It can be triggered in the following scenarios,
0 commit comments