From a1f336fdc7c874df2943eeea1ebe5135bd175217 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Thu, 30 Apr 2026 14:56:14 -0400 Subject: [PATCH] fix(core): preserve structured `inputs` on tool runs in tracers (#37108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool runs in `_TracerCore._create_tool_run` were discarding the structured `inputs` dict that `BaseTool.run` passes to `on_tool_start`, replacing it with `{"input": str(filtered_tool_input)}`. Consequently, every multi-arg tool (e.g. ones in `deepagents` like `execute`, `edit_file`, `write_file`, `grep`, ...) appeared in LangSmith with a stringified, escaped dump of its arguments — multi-line bash commands rendered with `\n` and were effectively unreadable. Chain runs already preserved dicts via `_get_chain_inputs`; tool runs are now symmetric. ## Changes - Preserve `inputs` when it is already a `dict` in the `original` / `original+chat` branch of `_TracerCore._create_tool_run`, falling back to `{"input": input_str}` only when no structured payload was provided - Add regression tests in the sync and async base-tracer suites that pass a structured `inputs` to `on_tool_start` and assert the dict survives onto the resulting `Run` ## Breaking change Custom `BaseTracer` subclasses that parsed `Run.inputs["input"]` as a stringified dict for tool runs will need to read the structured fields directly. The shape now matches what `on_tool_start(inputs=...)` has always received — introduced alongside `_schema_format` in the `astream_events` work — and what `streaming_events` consumers already see. --- libs/core/langchain_core/tracers/core.py | 2 +- .../tracers/test_async_base_tracer.py | 34 +++++++++++++++++++ .../unit_tests/tracers/test_base_tracer.py | 34 +++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/libs/core/langchain_core/tracers/core.py b/libs/core/langchain_core/tracers/core.py index 4f5a325ecea..75614e3c881 100644 --- a/libs/core/langchain_core/tracers/core.py +++ b/libs/core/langchain_core/tracers/core.py @@ -449,7 +449,7 @@ class _TracerCore(ABC): kwargs.update({"metadata": metadata}) if self._schema_format in {"original", "original+chat"}: - inputs = {"input": input_str} + inputs = inputs if isinstance(inputs, dict) else {"input": input_str} elif self._schema_format == "streaming_events": inputs = {"input": inputs} else: diff --git a/libs/core/tests/unit_tests/tracers/test_async_base_tracer.py b/libs/core/tests/unit_tests/tracers/test_async_base_tracer.py index 6bb93ee8539..829a917bc28 100644 --- a/libs/core/tests/unit_tests/tracers/test_async_base_tracer.py +++ b/libs/core/tests/unit_tests/tracers/test_async_base_tracer.py @@ -214,6 +214,40 @@ async def test_tracer_tool_run() -> None: assert tracer.runs == [compare_run] +@freeze_time("2023-01-01") +async def test_tracer_tool_run_preserves_structured_inputs() -> None: + """Structured `inputs` from `BaseTool.run` should not be flattened to `str`.""" + uuid = uuid4() + structured_inputs = {"command": "echo 'hello\nworld'", "timeout": None} + compare_run = Run( + id=str(uuid), + name="tool", + start_time=datetime.now(timezone.utc), + end_time=datetime.now(timezone.utc), + events=[ + {"name": "start", "time": datetime.now(timezone.utc)}, + {"name": "end", "time": datetime.now(timezone.utc)}, + ], + extra={}, + serialized={"name": "tool"}, + inputs=structured_inputs, + outputs={"output": "ok"}, + error=None, + run_type="tool", + trace_id=uuid, + dotted_order=f"20230101T000000000000Z{uuid}", + ) + tracer = FakeAsyncTracer() + await tracer.on_tool_start( + serialized={"name": "tool"}, + input_str=str(structured_inputs), + run_id=uuid, + inputs=structured_inputs, + ) + await tracer.on_tool_end("ok", run_id=uuid) + assert tracer.runs == [compare_run] + + @freeze_time("2023-01-01") async def test_tracer_nested_run() -> None: """Test tracer on a nested run.""" diff --git a/libs/core/tests/unit_tests/tracers/test_base_tracer.py b/libs/core/tests/unit_tests/tracers/test_base_tracer.py index 84e1218c9d1..c830bfeecc5 100644 --- a/libs/core/tests/unit_tests/tracers/test_base_tracer.py +++ b/libs/core/tests/unit_tests/tracers/test_base_tracer.py @@ -217,6 +217,40 @@ def test_tracer_tool_run() -> None: assert tracer.runs == [compare_run] +@freeze_time("2023-01-01") +def test_tracer_tool_run_preserves_structured_inputs() -> None: + """Structured `inputs` from `BaseTool.run` should not be flattened to `str`.""" + uuid = uuid4() + structured_inputs = {"command": "echo 'hello\nworld'", "timeout": None} + compare_run = Run( + id=str(uuid), + name="tool", + start_time=datetime.now(timezone.utc), + end_time=datetime.now(timezone.utc), + events=[ + {"name": "start", "time": datetime.now(timezone.utc)}, + {"name": "end", "time": datetime.now(timezone.utc)}, + ], + extra={}, + serialized={"name": "tool"}, + inputs=structured_inputs, + outputs={"output": "ok"}, + error=None, + run_type="tool", + trace_id=uuid, + dotted_order=f"20230101T000000000000Z{uuid}", + ) + tracer = FakeTracer() + tracer.on_tool_start( + serialized={"name": "tool"}, + input_str=str(structured_inputs), + run_id=uuid, + inputs=structured_inputs, + ) + tracer.on_tool_end("ok", run_id=uuid) + assert tracer.runs == [compare_run] + + @freeze_time("2023-01-01") def test_tracer_nested_run() -> None: """Test tracer on a nested run."""