fix(core): preserve structured inputs on tool runs in tracers (#37108)

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.
This commit is contained in:
Mason Daugherty
2026-04-30 14:56:14 -04:00
committed by GitHub
parent ba56ac6f03
commit a1f336fdc7
3 changed files with 69 additions and 1 deletions

View File

@@ -449,7 +449,7 @@ class _TracerCore(ABC):
kwargs.update({"metadata": metadata}) kwargs.update({"metadata": metadata})
if self._schema_format in {"original", "original+chat"}: 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": elif self._schema_format == "streaming_events":
inputs = {"input": inputs} inputs = {"input": inputs}
else: else:

View File

@@ -214,6 +214,40 @@ async def test_tracer_tool_run() -> None:
assert tracer.runs == [compare_run] 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") @freeze_time("2023-01-01")
async def test_tracer_nested_run() -> None: async def test_tracer_nested_run() -> None:
"""Test tracer on a nested run.""" """Test tracer on a nested run."""

View File

@@ -217,6 +217,40 @@ def test_tracer_tool_run() -> None:
assert tracer.runs == [compare_run] 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") @freeze_time("2023-01-01")
def test_tracer_nested_run() -> None: def test_tracer_nested_run() -> None:
"""Test tracer on a nested run.""" """Test tracer on a nested run."""