Skip to content

Commit fa8e295

Browse files
fix(langchain): record tool span input as structured data
LangChain's BaseTool.run/arun call on_tool_start with the tool input twice: positionally as str(tool_input), and as the original dict under the `inputs` keyword. The handler stored the positional value, so dict inputs landed in $ai_input_state as a Python repr with single quotes, which no JSON parser can read. Prefer the `inputs` dict when LangChain supplies one, matching on_chain_start. Tools invoked with a plain string keep recording that string. Generated-By: PostHog Desktop Task-Id: 75cf9ec9-fd9e-49fa-af56-ac505bc09cbe
1 parent 2e7c73b commit fa8e295

3 files changed

Lines changed: 55 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
Capture LangChain tool inputs on `$ai_span` as structured data rather than a Python `repr` string. `BaseTool.run`/`arun` pass the tool input to `on_tool_start` twice — positionally as `str(tool_input)`, and as the original dict under the `inputs` keyword. The handler was storing the positional value, so a dict input landed in `$ai_input_state` as `{'query': 'SELECT 1'}` (single quotes), which no JSON parser can read: `JSONExtract*` in ClickHouse returns empty, and any downstream consumer has to fall back to substring matching. Tool spans now record the `inputs` dict when LangChain supplies one, matching what `on_chain_start` already does; tools invoked with a plain string are unchanged.

‎posthog/ai/langchain/callbacks.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,15 @@ def on_tool_start(
283283
"on_tool_start", run_id, parent_run_id, input_str=input_str
284284
)
285285
self._set_parent_of_run(run_id, parent_run_id)
286+
# LangChain hands us the tool input twice: `input_str` is `str(tool_input)`, which
287+
# renders a dict as a Python repr (single quotes) rather than JSON, and the `inputs`
288+
# kwarg carries the original dict. Prefer the structured value so `$ai_input_state`
289+
# stays machine-readable and consistent with `on_chain_start`; tools invoked with a
290+
# plain string have no `inputs` and keep using `input_str`.
291+
inputs = kwargs.get("inputs")
292+
tool_input = inputs if isinstance(inputs, dict) else input_str
286293
self._set_trace_or_span_metadata(
287-
serialized, input_str, run_id, parent_run_id, **kwargs
294+
serialized, tool_input, run_id, parent_run_id, **kwargs
288295
)
289296

290297
def on_tool_end(

‎posthog/test/ai/langchain/test_callbacks.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2442,6 +2442,48 @@ def test_agent_action_and_finish_imports():
24422442
assert call_args["event"] == "$ai_span"
24432443

24442444

2445+
def test_tool_span_input_state_prefers_structured_inputs(mock_client):
2446+
"""A dict tool input is captured as a dict, not LangChain's `str(tool_input)` repr."""
2447+
callbacks = CallbackHandler(mock_client)
2448+
run_id = uuid.uuid4()
2449+
parent_run_id = uuid.uuid4()
2450+
tool_input = {"query": "SELECT 1", "truncate": True}
2451+
2452+
# Mirrors how langchain_core's BaseTool.run/arun calls on_tool_start: the positional
2453+
# argument is str(tool_input), while the original dict comes through `inputs`.
2454+
callbacks.on_tool_start(
2455+
{"name": "execute_sql"},
2456+
str(tool_input),
2457+
run_id=run_id,
2458+
parent_run_id=parent_run_id,
2459+
inputs=tool_input,
2460+
)
2461+
callbacks.on_tool_end("1", run_id=run_id, parent_run_id=parent_run_id)
2462+
2463+
props = mock_client.capture.call_args[1]["properties"]
2464+
assert props["$ai_input_state"] == tool_input
2465+
2466+
2467+
def test_tool_span_input_state_falls_back_to_string(mock_client):
2468+
"""A tool invoked with a plain string still records that string."""
2469+
callbacks = CallbackHandler(mock_client)
2470+
run_id = uuid.uuid4()
2471+
parent_run_id = uuid.uuid4()
2472+
2473+
# langchain_core passes inputs=None when tool_input isn't a dict.
2474+
callbacks.on_tool_start(
2475+
{"name": "get_weather"},
2476+
"sf",
2477+
run_id=run_id,
2478+
parent_run_id=parent_run_id,
2479+
inputs=None,
2480+
)
2481+
callbacks.on_tool_end("sunny", run_id=run_id, parent_run_id=parent_run_id)
2482+
2483+
props = mock_client.capture.call_args[1]["properties"]
2484+
assert props["$ai_input_state"] == "sf"
2485+
2486+
24452487
def test_posthog_properties_field_in_generation_metadata(mock_client):
24462488
"""Test that posthog_properties is properly stored in GenerationMetadata."""
24472489
callbacks = CallbackHandler(mock_client)

0 commit comments

Comments
 (0)