@@ -306,6 +306,84 @@ def _get_tool_origin(
306306 return "UNKNOWN"
307307
308308
309+ def _extract_tool_declarations (
310+ tools_dict : dict [str , "BaseTool" ],
311+ ) -> list [dict [str , Any ]]:
312+ """Extracts structured tool metadata for the ``LLM_REQUEST`` event.
313+
314+ Earlier versions logged only the tool names (``list(tools_dict.keys())``).
315+ Downstream consumers such as online evaluation need the tool *description* and
316+ *parameter schema* to judge whether the model selected and invoked the right
317+ tool, so this returns one structured entry per tool instead of a bare name.
318+
319+ Each entry always carries ``name`` and, when available, ``description`` and
320+ ``parameters`` (the OpenAPI parameter schema from the tool's
321+ ``FunctionDeclaration``). Extraction is best-effort and per-tool: a tool whose
322+ declaration cannot be resolved still contributes its name and description, so
323+ one misbehaving tool never drops the whole ``tools`` attribute.
324+
325+ Args:
326+ tools_dict: Mapping of tool name to ``BaseTool`` from ``LlmRequest``.
327+
328+ Returns:
329+ A list of ``{"name", "description"?, "parameters"?}`` dicts.
330+ """
331+ tools : list [dict [str , Any ]] = []
332+ for name , tool in tools_dict .items ():
333+ # Fall back to the dict key when the tool has no (or a falsy) name.
334+ entry : dict [str , Any ] = {"name" : getattr (tool , "name" , None ) or name }
335+ description = getattr (tool , "description" , None )
336+ if description :
337+ entry ["description" ] = description
338+
339+ # The parameter schema lives on the tool's FunctionDeclaration, which some
340+ # tools (e.g. built-in tools) do not provide. Resolve defensively so a
341+ # single failing tool does not discard the whole tools list.
342+ #
343+ # Note: FunctionTool._get_declaration() rebuilds the declaration from the
344+ # function signature on each call (no caching), so this repeats work the
345+ # framework already did when assembling the request. Acceptable for typical
346+ # toolsets; revisit with a cache if it shows up on the hot path.
347+ declaration = None
348+ try :
349+ get_declaration = getattr (tool , "_get_declaration" , None )
350+ if callable (get_declaration ):
351+ declaration = get_declaration ()
352+ except Exception : # pylint: disable=broad-except
353+ logger .debug ("Failed to get declaration for tool %s" , name , exc_info = True )
354+
355+ if declaration is not None :
356+ if "description" not in entry :
357+ decl_description = getattr (declaration , "description" , None )
358+ if decl_description :
359+ entry ["description" ] = decl_description
360+ # A declaration carries its parameter schema in one of two shapes: the
361+ # structured `parameters` Schema, or a raw JSON-schema dict in
362+ # `parameters_json_schema`. Several tools (MCP, OpenAPI, skill, node, and
363+ # environment tools) populate only the latter, and model adapters prefer
364+ # it, so prefer it here too and fall back to `parameters` otherwise.
365+ json_schema = getattr (declaration , "parameters_json_schema" , None )
366+ if json_schema is not None :
367+ entry ["parameters" ] = json_schema
368+ else :
369+ parameters = getattr (declaration , "parameters" , None )
370+ if parameters is not None :
371+ try :
372+ entry ["parameters" ] = parameters .model_dump (
373+ exclude_none = True , mode = "json"
374+ )
375+ except Exception : # pylint: disable=broad-except
376+ # Leave parameters off if the schema is not JSON-serializable.
377+ logger .debug (
378+ "Failed to serialize parameters for tool %s" ,
379+ name ,
380+ exc_info = True ,
381+ )
382+
383+ tools .append (entry )
384+ return tools
385+
386+
309387_SENSITIVE_KEYS = frozenset ({
310388 "client_secret" ,
311389 "access_token" ,
@@ -4028,7 +4106,8 @@ async def before_model_callback(
40284106 """
40294107
40304108 # 5. Attributes (Config & Tools)
4031- attributes = {}
4109+ attributes : dict [str , Any ] = {}
4110+ tools_truncated = False
40324111 if llm_request .config :
40334112 config_dict = {}
40344113 for field_name in [
@@ -4057,13 +4136,21 @@ async def before_model_callback(
40574136 attributes ["labels" ] = labels
40584137
40594138 if hasattr (llm_request , "tools_dict" ) and llm_request .tools_dict :
4060- attributes ["tools" ] = list (llm_request .tools_dict .keys ())
4139+ # Route tool declarations through the shared safety pipeline so unbounded
4140+ # descriptions / parameter schemas are size-capped and sensitive keys are
4141+ # redacted, consistent with every other captured attribute.
4142+ tools , tools_truncated = _recursive_smart_truncate (
4143+ _extract_tool_declarations (llm_request .tools_dict ),
4144+ self .config .max_content_length ,
4145+ )
4146+ attributes ["tools" ] = tools
40614147
40624148 TraceManager .push_span (callback_context , "llm_request" )
40634149 await self ._log_event (
40644150 "LLM_REQUEST" ,
40654151 callback_context ,
40664152 raw_content = llm_request ,
4153+ is_truncated = tools_truncated ,
40674154 event_data = EventData (
40684155 model = llm_request .model ,
40694156 extra_attributes = attributes ,
0 commit comments