Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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():
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/launchdarkly_ai_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
set_tool_definition_attributes,
text_message,
to_semconv_finish_reason,
tool_result_string,
)
from .conversation import (
ConversationIdSpanProcessor,
Expand Down Expand Up @@ -177,6 +178,7 @@
"to_usage_dict",
"text_message",
"to_semconv_finish_reason",
"tool_result_string",
"VariationMeta",
# utils
"create_handler",
Expand Down
16 changes: 16 additions & 0 deletions packages/client/src/launchdarkly_ai_server/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
21 changes: 21 additions & 0 deletions packages/client/tests/test_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
set_tool_definition_attributes,
text_message,
to_semconv_finish_reason,
tool_result_string,
)


Expand Down Expand Up @@ -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})
1 change: 1 addition & 0 deletions packages/client/tests/test_public_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"to_ld_context",
"to_semconv_finish_reason",
"to_usage_dict",
"tool_result_string",
"wrap_tool_handlers",
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/openai-agents/tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
}
)

Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/openai-messages/tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading