Skip to content

fix(tools): sanitize the ToolCallEnded message for handoff results - #7925

Open
BlueX888 wants to merge 2 commits into
livekit:mainfrom
BlueX888:fix/tool-call-ended-message-repr
Open

BlueX888 wants to merge 2 commits into
livekit:mainfrom
BlueX888:fix/tool-call-ended-message-repr

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

fix(tools): sanitize the ToolCallEnded message for handoff results

What breaks

For the documented handoff form return (agent, ToolResult(...)), the public ToolCallEnded.message event exposed a raw Python repr — including the non-deterministic Agent memory address:

message="(<livekit.agents.voice.agent.Agent object at 0x000001EA71398B60>, ToolResult(output='Transferred', reply_required=False))"

_ToolExecutor._on_done() (livekit-agents/livekit/agents/voice/tool_executor.py) unwrapped only a bare ToolResult and mapped a bare Agent/None/StopResponse to message=None; every other result — handoff tuples included — fell through to str(output).

Dispatch sanitizes the same result through generation.make_tool_output() (output.agent_task + fnc_call_out.output), so chat history and FunctionToolsExecutedEvent correctly recorded "Transferred" while the public tool_execution_updated event — forwarded verbatim to remote clients — carried the repr and the address. Two views of the same call disagreed, and the address changed every run.

Fix

Derive the success-path message through the same sanitizer dispatch uses (run_ctx._make_update_output(output), i.e. make_tool_output), taking fnc_call_out.output (or None). The error, cancellation, StopResponse, bare-Agent and None branches are untouched.

Behavior

Every success result shape now yields the same message as dispatch/chat history: str/int/dict outputs, bare ToolResult, (agent, ToolResult(...)), (agent, str), list handoffs, tuples/sets, Agent objects, and invalid/multi-Agent outputs. Empty-string output maps to None (consistent with the event docs and remote_session.py).

Tests

New test in tests/test_tools.py returns (Agent(...), ToolResult("Transferred", reply_required=False)) through a real _ToolExecutor and asserts ToolCallEnded(message="Transferred", status="done"), with parity against make_tool_output(...).fnc_call_out.output.

  • On base: fails with the exact repr leak above.
  • With the fix: tests/test_tools.py 142 passed, plus the tool-output/lifecycle/session-host neighbors (72) — 227 passed total across the suite set, no deviations at base apart from the one new test. Ruff clean.

_on_done() stringified the raw task result, so the documented
`return (agent, ToolResult(...))` handoff surfaced a Python repr
(including the Agent's memory address) in the public
tool_execution_updated event, while dispatch recorded the sanitized
text in chat history.

Derive the terminal message through the same make_tool_output sanitizer
used by dispatch, so container results report their recorded output
instead of a repr.
@BlueX888
BlueX888 requested a review from a team as a code owner October 18, 2026 14:19
devin-ai-integration[bot]

This comment was marked as resolved.

_on_done() unwrapped ToolResult before deriving the terminal text, so a
ToolResult(("a", "b")) payload reached the sanitizer as a bare tuple
and was reported as ['a', 'b'], while dispatch recorded ('a', 'b').
Derive the message from the raw return value, matching the shape dispatch
gives make_tool_output, and add a regression test for the tuple case.
@BlueX888

Copy link
Copy Markdown
Contributor Author

Addressed the Devin finding about ToolResult containers.

Confirmed: _on_done() unwrapped ToolResult before handing the value to make_tool_output, while dispatch passes the raw return value. For ToolResult(("a", "b")) the unwrapped tuple was then classified as a bare container and re-serialized as a list, so the public ToolCallEnded.message read ['a', 'b'] while make_tool_output recorded ('a', 'b') for chat history. ToolResult(("a",)) and ToolResult({"a"}) diverged the same way.

The terminal message is now derived from the raw return value, so the sanitizer sees the same shape dispatch sees on every path. The earlier handoff case still reports Transferred.

Added test_tuple_tool_result_terminal_message_matches_dispatch, which asserts the event message equals make_tool_output's recorded output for ToolResult(("a", "b")), and verified it fails on the previous head.

Verification:

  • uv run pytest tests/test_tools.py -q -> 143 passed
  • uv run pytest --unit -q --continue-on-collection-errors -> 5280 passed, 5 skipped; 10 unrelated collection errors from provider extras and the livekit server binary not installed here
  • ruff check / ruff format --check clean; mypy -p livekit.agents clean

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant