Summary
ToolResult and ToolProgress arrive with no way to tell which call they
answer. ToolResult exposes tool_name, content and is_error; the tool
name is an empty string on every result I have captured, and there is no
correlation id.
The information exists on the wire. The tool_result notification carries
toolUseId, which matches the id of the tool_use frame, and the SDK drops
it during mapping.
Tested on 2026-08-02 with:
- Droid CLI
0.186.0
droid-sdk-python==0.1.2
- Factory protocol
1.147.0
What the SDK exposes
ToolUse -> ['tool_name', 'tool_input', 'tool_use_id']
ToolResult -> ['tool_name', 'content', 'is_error']
ToolProgress -> ['tool_name', 'content']
ToolUse has tool_use_id. Neither of the other two does.
What the wire carries
Frames from a single turn, captured by teeing ProcessTransport.read_messages:
{"type":"tool_call","toolUse":{"type":"tool_use","id":"chatcmpl-tool-a83c4a92","name":"ToolSearch","input":{"query":"weather"}}}
{"type":"tool_execution_phase_changed","toolUseId":"chatcmpl-tool-a83c4a92","toolName":"ToolSearch","phase":"queued"}
{"type":"tool_execution_phase_changed","toolUseId":"chatcmpl-tool-a83c4a92","toolName":"ToolSearch","phase":"executing"}
{"type":"tool_result","toolUseId":"chatcmpl-tool-a83c4a92","messageId":"6a6b09d2","content":"Error: No tools matched","isError":true}
The result frame carries toolUseId. The SDK turns it into
ToolResult(tool_name="", content="Error: No tools matched", is_error=True).
Reproduction
class TeeTransport(ProcessTransport):
async def read_messages(self):
async for message in super().read_messages():
print("WIRE:", json.dumps(message, ensure_ascii=False))
yield message
transport = TeeTransport(
exec_path="droid",
cwd="/tmp",
exec_args=["exec", "--input-format", "stream-jsonrpc",
"--output-format", "stream-jsonrpc"],
)
client = DroidClient(transport=transport)
async with client:
await client.initialize_session(
machine_id="probe",
cwd="/tmp",
mcp_servers=[],
model_id="glm-5.2",
interaction_mode=DroidInteractionMode.Auto,
autonomy_level=AutonomyLevel.Off,
skip_permissions_unsafe=False,
enabled_tool_ids=[],
)
await client.add_user_message(
text="Search your tool catalog for a weather tool, then call it for Gdansk."
)
async for event in client.receive_response():
if isinstance(event, (ToolUse, ToolResult, ToolProgress)):
print(type(event).__name__, repr(event.tool_name))
Output:
ToolUse 'ToolSearch'
ToolResult ''
Impact
I maintain an OpenAI-compatible bridge on top of the SDK. It disables every
native tool and applies a policy per tool: two Droid meta tools that cannot be
disabled are tolerated, everything else fails the turn closed. With no
attribution on the result, the policy cannot be applied to results at all.
The workaround is to remember that a tolerated call happened earlier in the
turn and assume a nameless result belongs to it. That falls apart as soon as two
different tools are in flight, which is exactly when the policy matters.
The empty string is a second, smaller problem: "" is indistinguishable from a
tool that reported no name, so there is no way to detect the missing data other
than knowing the SDK substitutes it.
Suggested fix
- Add
tool_use_id to ToolResult and ToolProgress, populated from
toolUseId on the notification.
- Report
tool_name as None when the frame does not carry one, instead of
substituting "".
The CLI could also include toolName on the tool_result frame the way it
already does on tool_execution_phase_changed, but the correlation id alone is
enough to solve this in the SDK.
Summary
ToolResultandToolProgressarrive with no way to tell which call theyanswer.
ToolResultexposestool_name,contentandis_error; the toolname is an empty string on every result I have captured, and there is no
correlation id.
The information exists on the wire. The
tool_resultnotification carriestoolUseId, which matches theidof thetool_useframe, and the SDK dropsit during mapping.
Tested on 2026-08-02 with:
0.186.0droid-sdk-python==0.1.21.147.0What the SDK exposes
ToolUsehastool_use_id. Neither of the other two does.What the wire carries
Frames from a single turn, captured by teeing
ProcessTransport.read_messages:{"type":"tool_call","toolUse":{"type":"tool_use","id":"chatcmpl-tool-a83c4a92","name":"ToolSearch","input":{"query":"weather"}}} {"type":"tool_execution_phase_changed","toolUseId":"chatcmpl-tool-a83c4a92","toolName":"ToolSearch","phase":"queued"} {"type":"tool_execution_phase_changed","toolUseId":"chatcmpl-tool-a83c4a92","toolName":"ToolSearch","phase":"executing"} {"type":"tool_result","toolUseId":"chatcmpl-tool-a83c4a92","messageId":"6a6b09d2","content":"Error: No tools matched","isError":true}The result frame carries
toolUseId. The SDK turns it intoToolResult(tool_name="", content="Error: No tools matched", is_error=True).Reproduction
Output:
Impact
I maintain an OpenAI-compatible bridge on top of the SDK. It disables every
native tool and applies a policy per tool: two Droid meta tools that cannot be
disabled are tolerated, everything else fails the turn closed. With no
attribution on the result, the policy cannot be applied to results at all.
The workaround is to remember that a tolerated call happened earlier in the
turn and assume a nameless result belongs to it. That falls apart as soon as two
different tools are in flight, which is exactly when the policy matters.
The empty string is a second, smaller problem:
""is indistinguishable from atool that reported no name, so there is no way to detect the missing data other
than knowing the SDK substitutes it.
Suggested fix
tool_use_idtoToolResultandToolProgress, populated fromtoolUseIdon the notification.tool_nameasNonewhen the frame does not carry one, instead ofsubstituting
"".The CLI could also include
toolNameon thetool_resultframe the way italready does on
tool_execution_phase_changed, but the correlation id alone isenough to solve this in the SDK.