From d756beb71c01111fde326bed4b6db3430534d0a9 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Tue, 20 Oct 2026 10:28:23 -0700 Subject: [PATCH] fix(AIC-3577): send object tool results to the model as JSON Handlers stringified tool results with str(), so a dict reached the model as its Python repr. Add tool_result_string to the client (string unchanged, None to empty, else json.dumps with ensure_ascii=False) and use it at every tool-result site, matching Go ToolResultString. Co-Authored-By: Claude Opus 5.5 --- .../launchdarkly_ai_claude_agents/handler.py | 3 ++- .../handler.py | 5 +++-- .../src/launchdarkly_ai_server/__init__.py | 2 ++ .../src/launchdarkly_ai_server/content.py | 16 ++++++++++++++ packages/client/tests/test_content.py | 21 +++++++++++++++++++ packages/client/tests/test_public_surface.py | 1 + .../handler.py | 3 ++- .../native_graph.py | 3 ++- .../handler.py | 6 ++++-- .../launchdarkly_ai_openai_agents/handler.py | 7 +++++-- .../native_graph.py | 3 ++- packages/openai-agents/tests/test_handler.py | 2 +- .../handler.py | 11 ++++++---- .../openai-messages/tests/test_handler.py | 20 ++++++++++++++++++ 14 files changed, 88 insertions(+), 15 deletions(-) diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index c12b3b65..7376e7f5 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -49,6 +49,7 @@ set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, + tool_result_string, ) from launchdarkly_ai_server.agui import TurnEmitter from launchdarkly_ai_server.lifecycle import effective_capture_content @@ -102,7 +103,7 @@ async def _execute(args: Any, _name: str = tool_name) -> dict[str, Any]: if not handler: raise ValueError(f'No handler registered for tool "{_name}"') result = await handler(args) if _is_coroutine(handler) else handler(args) - return {"content": [{"type": "text", "text": str(result)}]} + return {"content": [{"type": "text", "text": tool_result_string(result)}]} mcp_tool = tool_fn( tool_name, diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index 69e82fdd..ebf8181d 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -24,6 +24,7 @@ set_output_content_attributes, set_tool_call_content_attributes, to_semconv_finish_reason, + tool_result_string, ) from launchdarkly_ai_server.agui import TurnEmitter from launchdarkly_ai_server.lifecycle import effective_capture_content @@ -332,7 +333,7 @@ async def _run_tool_loop( { "type": "tool_result", "tool_use_id": block.id, - "content": str(result), + "content": tool_result_string(result), } ) conversation.append({"role": "user", "content": tool_results}) @@ -714,7 +715,7 @@ async def _stream_gen( { "type": "tool_result", "tool_use_id": block.id, - "content": str(result), + "content": tool_result_string(result), } ) for rich in emitter.fail_open(): diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index 44163c5c..4dadc164 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -20,6 +20,7 @@ set_tool_definition_attributes, text_message, to_semconv_finish_reason, + tool_result_string, ) from .conversation import ( ConversationIdSpanProcessor, @@ -177,6 +178,7 @@ "to_usage_dict", "text_message", "to_semconv_finish_reason", + "tool_result_string", "VariationMeta", # utils "create_handler", diff --git a/packages/client/src/launchdarkly_ai_server/content.py b/packages/client/src/launchdarkly_ai_server/content.py index 1c51bf34..078ac998 100644 --- a/packages/client/src/launchdarkly_ai_server/content.py +++ b/packages/client/src/launchdarkly_ai_server/content.py @@ -545,6 +545,22 @@ def set_tool_call_content_attributes( span.set_attribute("gen_ai.tool.call.result", _stringify_tool_value(result)) +def tool_result_string(value: Any) -> str: + """Render a tool handler's return value as the text sent back to the provider. + + A string passes through unchanged, ``None`` becomes ``""``, and anything else becomes + JSON (non-ASCII kept as is). A value JSON cannot encode falls back to ``str(value)``. + """ + if isinstance(value, str): + return value + if value is None: + return "" + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return str(value) + + def _stringify_tool_value(value: Any) -> str: """A string passes through unchanged; anything else becomes JSON.""" return value if isinstance(value, str) else json.dumps(value) diff --git a/packages/client/tests/test_content.py b/packages/client/tests/test_content.py index 9a89e6cc..959c5ce2 100644 --- a/packages/client/tests/test_content.py +++ b/packages/client/tests/test_content.py @@ -23,6 +23,7 @@ set_tool_definition_attributes, text_message, to_semconv_finish_reason, + tool_result_string, ) @@ -613,3 +614,23 @@ def test_an_empty_tool_catalog_writes_nothing(self) -> None: span = FakeSpan() set_tool_definition_attributes(span, True, []) assert span.attributes == {} + + +def test_tool_result_string_passes_strings_maps_none_to_empty_and_json_encodes_the_rest() -> ( + None +): + assert tool_result_string("plain") == "plain" + assert tool_result_string(None) == "" + assert ( + tool_result_string({"temp": 72, "city": "Zürich"}) + == '{"temp": 72, "city": "Zürich"}' + ) + assert tool_result_string([1, "two"]) == '[1, "two"]' + assert tool_result_string(3) == "3" + + +def test_tool_result_string_falls_back_to_str_for_a_value_json_cannot_encode() -> None: + cyclic: dict[str, Any] = {} + cyclic["self"] = cyclic + assert tool_result_string(cyclic) == str(cyclic) + assert tool_result_string({1, 2}) == str({1, 2}) diff --git a/packages/client/tests/test_public_surface.py b/packages/client/tests/test_public_surface.py index e77b854e..ef8c06d7 100644 --- a/packages/client/tests/test_public_surface.py +++ b/packages/client/tests/test_public_surface.py @@ -114,6 +114,7 @@ "to_ld_context", "to_semconv_finish_reason", "to_usage_dict", + "tool_result_string", "wrap_tool_handlers", } ) diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py index 4f14b1ae..2eed441f 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -30,6 +30,7 @@ report_usage, set_input_content_attributes, set_output_content_attributes, + tool_result_string, ) from launchdarkly_ai_server.agui import TurnEmitter from launchdarkly_ai_server.lifecycle import effective_capture_content @@ -71,7 +72,7 @@ async def _handler(_name: str = name, **kwargs: Any) -> str: result = fn(kwargs) if inspect.isawaitable(result): result = await result - return str(result) + return tool_result_string(result) t = tool_fn( name, diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py index 7edf8f34..5bd21b22 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py @@ -23,6 +23,7 @@ report_usage, set_ld_span_attributes, to_ld_context, + tool_result_string, ) from launchdarkly_ai_server.utils import make_graph_track_data @@ -89,7 +90,7 @@ async def _handler(_name: str = name, **kwargs: Any) -> str: result = fn(kwargs) if inspect.isawaitable(result): result = await result - return str(result) + return tool_result_string(result) t = tool_fn( name, diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py index 47997a32..bd5ec9fe 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -31,6 +31,7 @@ set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, + tool_result_string, ) from launchdarkly_ai_server.agui import TurnEmitter from launchdarkly_ai_server.lifecycle import effective_capture_content @@ -651,7 +652,7 @@ async def _call_impl( tool_results.append( ToolMessage( tool_call_id=tc.get("id") or tc["name"], - content=str(result_val), + content=tool_result_string(result_val), ) ) conversation_messages.extend(tool_results) @@ -1039,7 +1040,8 @@ async def _stream_gen( yield rich tool_results.append( ToolMessage( - tool_call_id=tc.get("id") or tc["name"], content=str(result_val) + tool_call_id=tc.get("id") or tc["name"], + content=tool_result_string(result_val), ) ) # Chunks can start a call the gathered message then omits. The diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index 4a74a9eb..7d7445d4 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -44,6 +44,7 @@ set_output_content_attributes, set_tool_call_content_attributes, text_message, + tool_result_string, ) from launchdarkly_ai_server.agui import TurnEmitter from launchdarkly_ai_server.lifecycle import effective_capture_content @@ -106,7 +107,7 @@ async def _execute(_ctx: Any, args_str: str, _name: str = name) -> str: res = handler(args) if inspect.isawaitable(res): res = await res - return str(res) + return tool_result_string(res) t = FunctionTool( name=name, @@ -365,7 +366,9 @@ async def on_tool_end( arguments=getattr(context, "tool_arguments", None), ) ) - self.pending.extend(self.emitter.tool_result(str(call_id), str(result))) + self.pending.extend( + self.emitter.tool_result(str(call_id), tool_result_string(result)) + ) span = self.open_tool_spans.pop(call_id, None) if span is None: return diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index e7225936..a44467e4 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -23,6 +23,7 @@ report_usage, set_ld_span_attributes, to_ld_context, + tool_result_string, ) from launchdarkly_ai_server.utils import make_graph_track_data @@ -77,7 +78,7 @@ async def _execute(args: Any, _name: str = name) -> str: result = handler(args) if inspect.isawaitable(result): result = await result - return str(result) + return tool_result_string(result) t = tool_fn( name=name, diff --git a/packages/openai-agents/tests/test_handler.py b/packages/openai-agents/tests/test_handler.py index f6d83bf2..8712801e 100644 --- a/packages/openai-agents/tests/test_handler.py +++ b/packages/openai-agents/tests/test_handler.py @@ -580,7 +580,7 @@ def _sync(args: Any) -> dict[str, Any]: {"my-tool": {"description": "d"}}, {"my-tool": _sync} ) out = await tools[0].on_invoke_tool(MagicMock(), '{"order_id": "A19"}') - assert out == str({"order_id": "A19", "status": "shipped"}) + assert out == json.dumps({"order_id": "A19", "status": "shipped"}) async def test_async_tool_handler_result_is_returned(self) -> None: async def _async(args: Any) -> str: diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index 7dcaf91d..4d3f22cc 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -26,6 +26,7 @@ set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, + tool_result_string, ) from launchdarkly_ai_server.agui import TurnEmitter from launchdarkly_ai_server.lifecycle import effective_capture_content @@ -353,7 +354,7 @@ async def _call_impl( { "type": "function_call_output", "call_id": tc.call_id, - "output": str(result), + "output": tool_result_string(result), } ) @@ -612,14 +613,16 @@ async def _stream_gen( # Cleared on both paths that end the span, and deliberately not in a `finally`: # a `finally` would also clear it for a BaseException, which is the one case where # the span is still open and the outer `finally` is the only thing left to close it. - # The model is sent str(result). The stream reports that same text. - for rich in emitter.tool_result(str(tc.call_id), str(result)): + # The model is sent tool_result_string(result). The stream reports that same text. + for rich in emitter.tool_result( + str(tc.call_id), tool_result_string(result) + ): yield rich tool_outputs.append( { "type": "function_call_output", "call_id": tc.call_id, - "output": str(result), + "output": tool_result_string(result), } ) current_input = tool_outputs diff --git a/packages/openai-messages/tests/test_handler.py b/packages/openai-messages/tests/test_handler.py index 6ae392de..49d43465 100644 --- a/packages/openai-messages/tests/test_handler.py +++ b/packages/openai-messages/tests/test_handler.py @@ -405,6 +405,26 @@ async def test_multiple_consecutive_tool_calls( fn2.assert_called_once() assert result["output"] == "final" + async def test_object_tool_result_reaches_the_model_as_json( + self, mock_openai: MagicMock + ) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + tool_resp = _make_response(tool_calls=[{"name": "weather", "call_id": "c1"}]) + final_resp = _make_response(output_text="done") + mock_openai.responses.create = AsyncMock(side_effect=[tool_resp, final_resp]) + tool_fn = AsyncMock(return_value={"temp": 72, "city": "Zürich"}) + cfg = { + **CONFIG, + "tools": { + "weather": {"name": "weather", "type": "function", "parameters": {}} + }, + } + await create_openai_messages_handler()(cfg, "q", {"weather": tool_fn}, {}) + second_input = mock_openai.responses.create.call_args_list[1].kwargs["input"] + outputs = [i for i in second_input if i.get("type") == "function_call_output"] + assert outputs[0]["output"] == '{"temp": 72, "city": "Zürich"}' + # --------------------------------------------------------------------------- # §1.5 Telemetry — span recording