Files
wehub-resource-sync c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

1161 lines
49 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import datetime
import json
import time
from typing import Any
from unittest.mock import patch
import pytest
from haystack import Document
from haystack.components.agents.state import State
from haystack.components.agents.tool_calling import (
ToolNotFoundException,
_build_tool_result_message,
_inject_state_args,
_merge_tool_outputs_into_state,
_process_tool_output,
_result_to_string,
_run_tool,
_run_tool_async,
_schedule_tool_calls,
_validate_and_prepare_tools,
)
from haystack.components.builders.prompt_builder import PromptBuilder
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.generators.utils import print_streaming_chunk
from haystack.dataclasses import (
ChatMessage,
ChatRole,
ImageContent,
StreamingChunk,
TextContent,
ToolCall,
ToolCallResult,
)
from haystack.tools import ComponentTool, Tool, Toolset
from haystack.tools.errors import ToolInvocationError
def weather_function(location):
weather_info = {
"Berlin": {"weather": "mostly sunny", "temperature": 7, "unit": "celsius"},
"Paris": {"weather": "mostly cloudy", "temperature": 8, "unit": "celsius"},
"Rome": {"weather": "sunny", "temperature": 14, "unit": "celsius"},
}
return weather_info.get(location, {"weather": "unknown", "temperature": 0, "unit": "celsius"})
async def async_weather_function(location):
return weather_function(location)
weather_parameters = {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}
@pytest.fixture
def weather_tool():
return Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
)
@pytest.fixture
def async_weather_tool():
return Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
async_function=async_weather_function,
)
@pytest.fixture
def weather_tool_with_outputs_to_state():
return Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
outputs_to_state={"weather": {"source": "weather"}},
)
@pytest.fixture
def faulty_tool():
def faulty_tool_func(location):
raise Exception("This tool always fails.")
faulty_tool_parameters = {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
}
return Tool(
name="faulty_tool",
description="A tool that always fails when invoked.",
parameters=faulty_tool_parameters,
function=faulty_tool_func,
)
def add_function(num1: int, num2: int):
return num1 + num2
@pytest.fixture
def tool_set():
return Toolset(
tools=[
Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
),
Tool(
name="addition_tool",
description="A tool that adds two numbers.",
parameters={
"type": "object",
"properties": {"num1": {"type": "integer"}, "num2": {"type": "integer"}},
"required": ["num1", "num2"],
},
function=add_function,
),
]
)
class TestCore:
def test_validate_and_prepare_tools(self, weather_tool, faulty_tool):
result = _validate_and_prepare_tools([weather_tool, faulty_tool])
assert result == {"weather_tool": weather_tool, "faulty_tool": faulty_tool}
toolset = Toolset([weather_tool, faulty_tool])
result = _validate_and_prepare_tools(toolset)
assert result == {"weather_tool": weather_tool, "faulty_tool": faulty_tool}
def test_validate_and_prepare_tools_validation_failures(self, weather_tool):
with pytest.raises(ValueError):
_validate_and_prepare_tools([])
with pytest.raises(ValueError):
_validate_and_prepare_tools([weather_tool, weather_tool])
def test_inject_state_args_no_tool_inputs_does_not_match_by_name(self):
# Without an explicit `inputs_from_state`, a matching State key name must NOT be injected.
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
)
state = State(schema={"location": {"type": str}}, data={"location": "Berlin"})
args = _inject_state_args(tool=weather_tool, llm_args={}, state=state)
assert args == {}
def test_inject_state_args_no_tool_inputs_component_tool_does_not_match_by_name(self):
# Without an explicit `inputs_from_state`, a matching State key name must NOT be injected.
comp = PromptBuilder(template="Hello, {{name}}!")
prompt_tool = ComponentTool(
component=comp, name="prompt_tool", description="Creates a personalized greeting prompt."
)
state = State(schema={"name": {"type": str}}, data={"name": "James"})
args = _inject_state_args(tool=prompt_tool, llm_args={}, state=state)
assert args == {}
def test_inject_state_args_with_tool_inputs(self):
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
inputs_from_state={"loc": "location"},
)
state = State(schema={"location": {"type": str}}, data={"loc": "Berlin"})
args = _inject_state_args(tool=weather_tool, llm_args={}, state=state)
assert args == {"location": "Berlin"}
def test_inject_state_args_param_in_state_and_llm(self):
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
)
state = State(schema={"location": {"type": str}}, data={"location": "Berlin"})
args = _inject_state_args(tool=weather_tool, llm_args={"location": "Paris"}, state=state)
assert args == {"location": "Paris"}
def test_inject_state_args_injects_state_object_for_state_annotated_param(self):
def function_with_state(city: str, state: State) -> str:
return f"Weather in {city}"
state_tool = Tool(
name="state_tool",
description="A tool that receives the live State object.",
parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
function=function_with_state,
)
state = State(schema={"city": {"type": str}}, data={"city": "Berlin"})
args = _inject_state_args(tool=state_tool, llm_args={"city": "Paris"}, state=state)
assert args["city"] == "Paris"
assert args["state"] is state
def test_inject_state_args_injects_state_object_for_optional_state_annotated_param(self):
def function_with_optional_state(city: str, state: State | None = None) -> str:
return f"Weather in {city}"
state_tool = Tool(
name="state_tool",
description="A tool that receives an optional State object.",
parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
function=function_with_optional_state,
)
state = State(schema={})
args = _inject_state_args(tool=state_tool, llm_args={"city": "Paris"}, state=state)
assert args["city"] == "Paris"
assert args["state"] is state
class TestRunTool:
def test_run_with_streaming_callback_finish_reason(self, weather_tool):
streaming_chunks = []
def streaming_callback(chunk: StreamingChunk) -> None:
streaming_chunks.append(chunk)
tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
message = ChatMessage.from_assistant(tool_calls=[tool_call])
tool_messages, _ = _run_tool(
messages=[message], state=State(schema={}), tools=[weather_tool], streaming_callback=streaming_callback
)
assert len(tool_messages) == 1
assert len(streaming_chunks) >= 2
final_chunk = streaming_chunks[-1]
assert final_chunk.finish_reason == "tool_call_results"
assert final_chunk.meta["finish_reason"] == "tool_call_results"
assert final_chunk.content == ""
@pytest.mark.asyncio
async def test_run_async_with_streaming_callback_finish_reason(self, weather_tool):
streaming_chunks = []
async def streaming_callback(chunk: StreamingChunk) -> None:
streaming_chunks.append(chunk)
tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
message = ChatMessage.from_assistant(tool_calls=[tool_call])
tool_messages, _ = await _run_tool_async(
messages=[message], state=State(schema={}), tools=[weather_tool], streaming_callback=streaming_callback
)
assert len(tool_messages) == 1
assert len(streaming_chunks) >= 2
final_chunk = streaming_chunks[-1]
assert final_chunk.finish_reason == "tool_call_results"
assert final_chunk.meta["finish_reason"] == "tool_call_results"
assert final_chunk.content == ""
def test_enable_streaming_callback_passthrough(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
llm_tool = ComponentTool(
component=OpenAIChatGenerator(),
name="chat_generator_tool",
description="A tool that generates chat messages using OpenAI's GPT model.",
)
with patch("haystack.components.generators.chat.OpenAIChatGenerator.run") as mock_run:
mock_run.return_value = {"replies": [ChatMessage.from_assistant("Hello! How can I help you?")]}
_run_tool(
messages=[
ChatMessage.from_assistant(
tool_calls=[
ToolCall(
tool_name="chat_generator_tool",
arguments={"messages": [{"role": "user", "content": [{"text": "Hello!"}]}]},
id="12345",
)
]
)
],
state=State(schema={}),
tools=[llm_tool],
streaming_callback=print_streaming_chunk,
enable_streaming_callback_passthrough=True,
)
mock_run.assert_called_once_with(
messages=[ChatMessage.from_user(text="Hello!")], streaming_callback=print_streaming_chunk
)
def test_run_tool_calls_across_list_of_toolsets(self, weather_tool):
"""Tool calls should resolve against tools spread across a list of multiple Toolsets."""
add_tool = Tool(
name="add",
description="A tool that adds two numbers.",
parameters={
"type": "object",
"properties": {"num1": {"type": "integer"}, "num2": {"type": "integer"}},
"required": ["num1", "num2"],
},
function=add_function,
)
toolset1 = Toolset([weather_tool])
toolset2 = Toolset([add_tool])
weather_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
add_call = ToolCall(tool_name="add", arguments={"num1": 5, "num2": 3})
message = ChatMessage.from_assistant(tool_calls=[weather_call, add_call])
tool_messages, _ = _run_tool(messages=[message], state=State(schema={}), tools=[toolset1, toolset2])
assert len(tool_messages) == 2
assert "mostly sunny" in tool_messages[0].tool_call_results[0].result
assert "8" in tool_messages[1].tool_call_results[0].result
def test_run_no_messages(self, weather_tool):
tool_messages, state = _run_tool(messages=[], state=State(schema={}), tools=[weather_tool])
assert tool_messages == []
def test_run_no_tool_calls(self, weather_tool):
user_message = ChatMessage.from_user(text="Hello!")
assistant_message = ChatMessage.from_assistant(text="How can I help you?")
tool_messages, _ = _run_tool(
messages=[user_message, assistant_message], state=State(schema={}), tools=[weather_tool]
)
assert tool_messages == []
def test_run_multiple_tool_calls(self, weather_tool):
tool_calls = [
ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}),
ToolCall(tool_name="weather_tool", arguments={"location": "Paris"}),
ToolCall(tool_name="weather_tool", arguments={"location": "Rome"}),
]
message = ChatMessage.from_assistant(tool_calls=tool_calls)
tool_messages, _ = _run_tool(messages=[message], state=State(schema={}), tools=[weather_tool])
assert len(tool_messages) == 3
for i, tool_message in enumerate(tool_messages):
assert isinstance(tool_message, ChatMessage)
assert tool_message.is_from(ChatRole.TOOL)
assert tool_message.tool_call_results
tool_call_result = tool_message.tool_call_result
assert isinstance(tool_call_result, ToolCallResult)
assert not tool_call_result.error
assert tool_call_result.origin == tool_calls[i]
def test_run_tool_calls_with_empty_args(self):
hello_world_tool = Tool(
name="hello_world",
description="A tool that returns a greeting.",
parameters={"type": "object", "properties": {}},
function=lambda: "Hello, world!",
)
tool_call = ToolCall(tool_name="hello_world", arguments={})
tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
tool_messages, _ = _run_tool(messages=[tool_call_message], state=State(schema={}), tools=[hello_world_tool])
assert len(tool_messages) == 1
tool_call_result = tool_messages[0].tool_call_result
assert isinstance(tool_call_result, ToolCallResult)
assert not tool_call_result.error
assert tool_call_result.result == "Hello, world!"
def test_run_with_tools(self, weather_tool):
tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
message = ChatMessage.from_assistant(tool_calls=[tool_call])
tool_messages, _ = _run_tool(messages=[message], state=State(schema={}), tools=[weather_tool])
tool_call_result = tool_messages[0].tool_call_result
assert not tool_call_result.error
assert tool_call_result.result == '{"weather": "mostly sunny", "temperature": 7, "unit": "celsius"}'
assert tool_call_result.origin == tool_call
@pytest.mark.asyncio
async def test_run_async_with_tools(self, weather_tool):
tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
message = ChatMessage.from_assistant(tool_calls=[tool_call])
tool_messages, _ = await _run_tool_async(messages=[message], state=State(schema={}), tools=[weather_tool])
tool_call_result = tool_messages[0].tool_call_result
assert not tool_call_result.error
assert tool_call_result.result == '{"weather": "mostly sunny", "temperature": 7, "unit": "celsius"}'
assert tool_call_result.origin == tool_call
def test_parallel_tool_calling_with_state_updates(self):
"""Test that parallel tool execution with state updates works correctly with the state lock."""
execution_log = []
def function_1():
time.sleep(0.01)
execution_log.append("tool_1_executed")
return {"counter": 1, "tool_name": "tool_1"}
def function_2():
time.sleep(0.01)
execution_log.append("tool_2_executed")
return {"counter": 2, "tool_name": "tool_2"}
def function_3():
time.sleep(0.01)
execution_log.append("tool_3_executed")
return {"counter": 3, "tool_name": "tool_3"}
tool_1 = Tool(
name="state_tool_1",
description="A tool that updates state counter",
parameters={"type": "object", "properties": {}},
function=function_1,
outputs_to_state={"counter": {"source": "counter"}, "last_tool": {"source": "tool_name"}},
)
tool_2 = Tool(
name="state_tool_2",
description="A tool that updates state counter",
parameters={"type": "object", "properties": {}},
function=function_2,
outputs_to_state={"counter": {"source": "counter"}, "last_tool": {"source": "tool_name"}},
)
tool_3 = Tool(
name="state_tool_3",
description="A tool that updates state counter",
parameters={"type": "object", "properties": {}},
function=function_3,
outputs_to_state={"counter": {"source": "counter"}, "last_tool": {"source": "tool_name"}},
)
state = State(schema={"counter": {"type": int}, "last_tool": {"type": str}})
tool_calls = [
ToolCall(tool_name="state_tool_1", arguments={}),
ToolCall(tool_name="state_tool_2", arguments={}),
ToolCall(tool_name="state_tool_3", arguments={}),
]
message = ChatMessage.from_assistant(tool_calls=tool_calls)
_run_tool(messages=[message], state=state, tools=[tool_1, tool_2, tool_3])
assert len(execution_log) == 3
assert "tool_1_executed" in execution_log
assert "tool_2_executed" in execution_log
assert "tool_3_executed" in execution_log
assert state.has("counter")
assert state.has("last_tool")
assert state.get("counter") in [1, 2, 3]
assert state.get("last_tool") in ["tool_1", "tool_2", "tool_3"]
@pytest.mark.asyncio
async def test_async_parallel_tool_calling_with_state_updates(self):
"""Test that parallel tool execution with state updates works correctly with the state lock."""
execution_log = []
def function_1():
time.sleep(0.01)
execution_log.append("tool_1_executed")
return {"counter": 1, "tool_name": "tool_1"}
def function_2():
time.sleep(0.01)
execution_log.append("tool_2_executed")
return {"counter": 2, "tool_name": "tool_2"}
def function_3():
time.sleep(0.01)
execution_log.append("tool_3_executed")
return {"counter": 3, "tool_name": "tool_3"}
tool_1 = Tool(
name="state_tool_1",
description="A tool that updates state counter",
parameters={"type": "object", "properties": {}},
function=function_1,
outputs_to_state={"counter": {"source": "counter"}, "last_tool": {"source": "tool_name"}},
)
tool_2 = Tool(
name="state_tool_2",
description="A tool that updates state counter",
parameters={"type": "object", "properties": {}},
function=function_2,
outputs_to_state={"counter": {"source": "counter"}, "last_tool": {"source": "tool_name"}},
)
tool_3 = Tool(
name="state_tool_3",
description="A tool that updates state counter",
parameters={"type": "object", "properties": {}},
function=function_3,
outputs_to_state={"counter": {"source": "counter"}, "last_tool": {"source": "tool_name"}},
)
state = State(schema={"counter": {"type": int}, "last_tool": {"type": str}})
tool_calls = [
ToolCall(tool_name="state_tool_1", arguments={}),
ToolCall(tool_name="state_tool_2", arguments={}),
ToolCall(tool_name="state_tool_3", arguments={}),
]
message = ChatMessage.from_assistant(tool_calls=tool_calls)
await _run_tool_async(messages=[message], state=state, tools=[tool_1, tool_2, tool_3])
assert len(execution_log) == 3
assert "tool_1_executed" in execution_log
assert "tool_2_executed" in execution_log
assert "tool_3_executed" in execution_log
assert state.has("counter")
assert state.has("last_tool")
assert state.get("counter") in [1, 2, 3]
assert state.get("last_tool") in ["tool_1", "tool_2", "tool_3"]
def test_two_subsequent_run_tool_calls(self, weather_tool):
tool_calls = [
ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}),
ToolCall(tool_name="weather_tool", arguments={"location": "Paris"}),
ToolCall(tool_name="weather_tool", arguments={"location": "Rome"}),
]
message = ChatMessage.from_assistant(tool_calls=tool_calls)
streaming_callback_called = False
def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
tool_messages_1, _ = _run_tool(
messages=[message], state=State(schema={}), tools=[weather_tool], streaming_callback=streaming_callback
)
assert len(tool_messages_1) == 3
tool_messages_2, _ = _run_tool(
messages=[message], state=State(schema={}), tools=[weather_tool], streaming_callback=streaming_callback
)
assert len(tool_messages_2) == 3
@pytest.mark.asyncio
async def test_two_subsequent_run_tool_async_calls(self, weather_tool):
tool_calls = [
ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}),
ToolCall(tool_name="weather_tool", arguments={"location": "Paris"}),
ToolCall(tool_name="weather_tool", arguments={"location": "Rome"}),
]
message = ChatMessage.from_assistant(tool_calls=tool_calls)
streaming_callback_called = False
async def streaming_callback(chunk: StreamingChunk) -> None:
nonlocal streaming_callback_called
streaming_callback_called = True
tool_messages_1, _ = await _run_tool_async(
messages=[message], state=State(schema={}), tools=[weather_tool], streaming_callback=streaming_callback
)
assert len(tool_messages_1) == 3
tool_messages_2, _ = await _run_tool_async(
messages=[message], state=State(schema={}), tools=[weather_tool], streaming_callback=streaming_callback
)
assert len(tool_messages_2) == 3
def test_run_injects_state_object_into_tool(self):
received_state = {}
def function_with_state(city: str, state: State) -> str:
received_state["state"] = state
return f"Weather in {city}: sunny"
state_tool = Tool(
name="state_tool",
description="A tool that receives the live State object.",
parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
function=function_with_state,
)
state = State(schema={"city": {"type": str}})
tool_call = ToolCall(tool_name="state_tool", arguments={"city": "Berlin"})
message = ChatMessage.from_assistant(tool_calls=[tool_call])
tool_messages, _ = _run_tool(messages=[message], state=state, tools=[state_tool])
assert len(tool_messages) == 1
assert not tool_messages[0].tool_call_results[0].error
assert received_state["state"] is state
@pytest.mark.asyncio
async def test_run_async_injects_state_object_into_tool(self):
received_state = {}
def function_with_state(city: str, state: State) -> str:
received_state["state"] = state
return f"Weather in {city}: sunny"
state_tool = Tool(
name="state_tool",
description="A tool that receives the live State object.",
parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
function=function_with_state,
)
state = State(schema={"city": {"type": str}})
tool_call = ToolCall(tool_name="state_tool", arguments={"city": "Berlin"})
message = ChatMessage.from_assistant(tool_calls=[tool_call])
tool_messages, _ = await _run_tool_async(messages=[message], state=state, tools=[state_tool])
assert len(tool_messages) == 1
assert not tool_messages[0].tool_call_results[0].error
assert received_state["state"] is state
def _make_retrieval_tool():
"""A retrieval-like tool that both reads and writes the `documents` state key.
Each invocation returns a single Document labelled with the number of documents it observed in state,
so the result reveals whether it saw writes from earlier tool calls in the same step.
"""
def retrieval_function(documents=None):
seen = len(documents) if documents else 0
return {"documents": [Document(content=f"doc-{seen}")]}
return Tool(
name="retrieval_tool",
description="Retrieves documents.",
parameters={"type": "object", "properties": {}},
function=retrieval_function,
inputs_from_state={"documents": "documents"},
outputs_to_state={"documents": {"source": "documents"}},
)
class TestRunToolErrorHandling:
def test_tool_not_found_error(self, weather_tool):
tool_call = ToolCall(tool_name="non_existent_tool", arguments={"location": "Berlin"})
tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
with pytest.raises(ToolNotFoundException):
_run_tool(messages=[tool_call_message], state=State(schema={}), tools=[weather_tool])
def test_tool_not_found_does_not_raise_exception(self, weather_tool):
tool_call = ToolCall(tool_name="non_existent_tool", arguments={"location": "Berlin"})
tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
tool_messages, _ = _run_tool(
messages=[tool_call_message], state=State(schema={}), tools=[weather_tool], raise_on_failure=False
)
tool_message = tool_messages[0]
assert tool_message.tool_call_results[0].error
assert "not found" in tool_message.tool_call_results[0].result
def test_tool_invocation_error(self, faulty_tool):
tool_call = ToolCall(tool_name="faulty_tool", arguments={"location": "Berlin"})
tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
with pytest.raises(ToolInvocationError):
_run_tool(messages=[tool_call_message], state=State(schema={}), tools=[faulty_tool])
def test_tool_invocation_error_does_not_raise_exception(self, faulty_tool):
tool_call = ToolCall(tool_name="faulty_tool", arguments={"location": "Berlin"})
tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
tool_messages, _ = _run_tool(
messages=[tool_call_message], state=State(schema={}), tools=[faulty_tool], raise_on_failure=False
)
tool_message = tool_messages[0]
assert tool_message.tool_call_results[0].error
assert "Failed to invoke" in tool_message.tool_call_results[0].result
def test_outputs_to_string_with_multiple_outputs(self):
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
outputs_to_string={"weather": {"source": "weather"}, "temp": {"source": "temperature"}},
)
tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
tool_result = {"weather": "sunny", "temperature": 25, "unit": "celsius"}
chat_message = _build_tool_result_message(tool_result, tool_call, weather_tool, raise_on_failure=False)
assert chat_message.tool_call_results[0].result == '{"weather": "sunny", "temp": "25"}'
def test_output_handler_failure_falls_back_to_string(self):
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
outputs_to_string={"handler": json.dumps},
)
tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
tool_result = datetime.datetime.now()
tool_message = _build_tool_result_message(tool_result, tool_call, weather_tool, raise_on_failure=False)
assert not tool_message.tool_call_results[0].error
assert isinstance(tool_message.tool_call_results[0].result, str)
def test_output_handler_failure_falls_back_to_string_raw_result(self):
def handler(result):
raise ValueError("Handler error")
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
outputs_to_string={"handler": handler, "raw_result": True},
)
tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
tool_message = _build_tool_result_message("something", tool_call, weather_tool, raise_on_failure=False)
assert not tool_message.tool_call_results[0].error
assert tool_message.tool_call_results[0].result == "something"
def test_output_handler_failure_raises_when_raise_on_failure(self):
def handler(result):
raise ValueError("Handler error")
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
outputs_to_string={"handler": handler},
)
tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
with pytest.raises(ValueError, match="Handler error"):
_build_tool_result_message("something", tool_call, weather_tool, raise_on_failure=True)
def test_run_state_merge_error_always_raises(self, weather_tool_with_outputs_to_state):
class ProblematicState(State):
def set(self, key: str, value: Any, handler_override=None):
raise ValueError("State set operation failed")
state = ProblematicState(schema={"test_key": {"type": str}})
tool_calls = [ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
message = ChatMessage.from_assistant(tool_calls=tool_calls)
with pytest.raises(RuntimeError, match="weather_tool"):
_run_tool(
messages=[message], state=state, tools=[weather_tool_with_outputs_to_state], raise_on_failure=False
)
@pytest.mark.asyncio
async def test_run_async_state_merge_error_always_raises(self, weather_tool_with_outputs_to_state):
class ProblematicState(State):
def set(self, key: str, value: Any, handler_override=None):
raise ValueError("State set operation failed")
state = ProblematicState(schema={"test_key": {"type": str}})
tool_calls = [ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
message = ChatMessage.from_assistant(tool_calls=tool_calls)
with pytest.raises(RuntimeError, match="weather_tool"):
await _run_tool_async(
messages=[message], state=state, tools=[weather_tool_with_outputs_to_state], raise_on_failure=False
)
class TestUtilities:
def test_result_to_string(self):
assert _result_to_string("hello") == "hello"
assert _result_to_string(42) == "42"
assert _result_to_string(3.14) == "3.14"
assert _result_to_string(True) == "true"
assert _result_to_string(None) == "null"
assert _result_to_string([1, 2, 3]) == "[1, 2, 3]"
assert _result_to_string({"key": "value"}) == '{"key": "value"}'
assert _result_to_string("Hello 🌍") == "Hello 🌍"
def test_result_to_string_with_serializable_objects(self):
class MockObject:
def __init__(self, value):
self.value = value
def to_dict(self):
return {"value": self.value}
mock_obj = MockObject("test_value")
result = _result_to_string(mock_obj)
assert "test_value" in result
assert "value" in result
def test_merge_tool_outputs_result_not_a_dict(self, weather_tool):
state = State(schema={"weather": {"type": str}})
_merge_tool_outputs_into_state(tool=weather_tool, result="test", state=state)
assert state.data == {}
def test_merge_tool_outputs_empty_dict(self, weather_tool):
state = State(schema={"weather": {"type": str}})
_merge_tool_outputs_into_state(tool=weather_tool, result={}, state=state)
assert state.data == {}
def test_merge_tool_outputs_no_output_mapping(self, weather_tool):
state = State(schema={"weather": {"type": str}})
_merge_tool_outputs_into_state(
tool=weather_tool, result={"weather": "sunny", "temperature": 14, "unit": "celsius"}, state=state
)
assert state.data == {}
def test_merge_tool_outputs_with_output_mapping(self):
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
outputs_to_state={"weather": {"source": "weather"}},
)
state = State(schema={"weather": {"type": str}})
_merge_tool_outputs_into_state(
tool=weather_tool, result={"weather": "sunny", "temperature": 14, "unit": "celsius"}, state=state
)
assert state.data == {"weather": "sunny"}
def test_merge_tool_outputs_with_output_mapping_2(self):
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
outputs_to_state={"all_weather_results": {}},
)
state = State(schema={"all_weather_results": {"type": str}})
_merge_tool_outputs_into_state(
tool=weather_tool, result={"weather": "sunny", "temperature": 14, "unit": "celsius"}, state=state
)
assert state.data == {"all_weather_results": {"weather": "sunny", "temperature": 14, "unit": "celsius"}}
def test_merge_tool_outputs_source_key_absent_does_not_corrupt_list_state(self):
"""
Simulates a PipelineTool wrapping a pipeline with a conditional branch that may not execute, resulting in the
source key being absent from the tool result. The test verifies that in this case, the existing list in state
is not corrupted by appending None.
"""
tool = Tool(
name="retrieval",
description="mock",
parameters={"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
function=lambda query: {},
outputs_to_state={"documents": {"source": "documents_output"}},
)
existing_doc = Document(content="from first call")
state = State(schema={"documents": {"type": list[Document]}})
state.set("documents", [existing_doc])
_merge_tool_outputs_into_state(tool=tool, result={"result": "no web results found"}, state=state)
assert state.data["documents"] == [existing_doc]
assert None not in state.data["documents"]
def test_merge_tool_outputs_with_output_mapping_and_handler(self):
handler = lambda _, new: f"{new}" # noqa: E731
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters=weather_parameters,
function=weather_function,
outputs_to_state={"temperature": {"source": "temperature", "handler": handler}},
)
state = State(schema={"temperature": {"type": str}})
_merge_tool_outputs_into_state(
tool=weather_tool, result={"weather": "sunny", "temperature": 14, "unit": "celsius"}, state=state
)
assert state.data == {"temperature": "14"}
def test_process_output_empty_config(self, base64_image_string):
image_content = ImageContent(base64_image=base64_image_string, mime_type="image/png")
result = _process_tool_output(
config={"raw_result": True},
result=[image_content],
tool_call=ToolCall(tool_name="retrieve_image", arguments={}),
raise_on_failure=False,
)
assert result == [image_content]
def test_process_output_source_only(self, base64_image_string):
image_content = ImageContent(base64_image=base64_image_string, mime_type="image/png")
result = _process_tool_output(
config={"source": "images", "raw_result": True},
result={"images": [image_content]},
tool_call=ToolCall(tool_name="retrieve_image", arguments={}),
raise_on_failure=False,
)
assert result == [image_content]
def test_process_output_handler_only(self, base64_image_string):
def handler(result: dict) -> list[ImageContent]:
return [ImageContent(base64_image=result["base64_image_string"], mime_type=result["mime_type"])]
result = _process_tool_output(
config={"handler": handler, "raw_result": True},
result={"base64_image_string": base64_image_string, "mime_type": "image/png"},
tool_call=ToolCall(tool_name="retrieve_image", arguments={}),
raise_on_failure=False,
)
assert result == [ImageContent(base64_image=base64_image_string, mime_type="image/png")]
def test_process_output_source_and_handler(self, base64_image_string):
def handler(result: dict) -> list[ImageContent]:
return [ImageContent(base64_image=result["base64_image_string"], mime_type=result["mime_type"])]
result = _process_tool_output(
config={"source": "images", "handler": handler, "raw_result": True},
result={
"images": {"base64_image_string": base64_image_string, "mime_type": "image/png"},
"other_key": "other_value",
},
tool_call=ToolCall(tool_name="retrieve_image", arguments={}),
raise_on_failure=False,
)
assert result == [ImageContent(base64_image=base64_image_string, mime_type="image/png")]
def test_output_to_result_e2e(self, weather_tool):
def handler(result):
return [
TextContent(text=f"weather: {result['weather']}"),
TextContent(text=f"temperature: {result['temperature']} {result['unit']}"),
]
weather_tool.outputs_to_string = {"handler": handler, "raw_result": True}
message = ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
)
tool_messages, _ = _run_tool(messages=[message], state=State(schema={}), tools=[weather_tool])
assert tool_messages[0].tool_call_results[0].result == [
TextContent(text="weather: mostly sunny"),
TextContent(text="temperature: 7 celsius"),
]
def _reader_tool(state_key: str = "documents", seen: list | None = None):
"""A tool that reads `state_key` from State into its `value` param (no writes).
If `seen` is provided, the value the tool received is appended to it, so behavioral tests can assert what the
reader observed.
"""
def reader_fn(value=None):
if seen is not None:
seen.append(value)
return {"ok": True}
return Tool(
name="reader_tool",
description="Reads from state.",
parameters={"type": "object", "properties": {}},
function=reader_fn,
inputs_from_state={state_key: "value"},
)
def _writer_tool(state_key: str = "documents", value: str = "written"):
"""A tool that writes `value` to `state_key` in State (no reads)."""
def writer_fn():
return {"out": value}
return Tool(
name="writer_tool",
description="Writes to state.",
parameters={"type": "object", "properties": {}},
function=writer_fn,
outputs_to_state={state_key: {"source": "out"}},
)
def _state_param_tool():
"""A tool that receives the live State object, so it may read/write any key."""
def fn(state: State) -> str:
return "done"
return Tool(
name="state_tool",
description="Receives the live State.",
parameters={"type": "object", "properties": {}},
function=fn,
)
class TestScheduleToolCalls:
def test_no_calls_returns_no_batches(self):
assert _schedule_tool_calls([], []) == []
def test_single_call_is_one_batch(self):
assert _schedule_tool_calls([ToolCall(tool_name="writer_tool", arguments={})], [_writer_tool()]) == [[0]]
def test_read_only_calls_run_in_one_batch(self):
# Two readers of the same key: no writer, so no dependency — they run together.
calls = [ToolCall(tool_name="reader_tool", arguments={}), ToolCall(tool_name="reader_tool", arguments={})]
tools = [_reader_tool(), _reader_tool()]
assert _schedule_tool_calls(calls, tools) == [[0, 1]]
def test_disjoint_keys_run_in_one_batch(self):
# A writer of "a" and a reader of "b" don't overlap, so they run in parallel.
calls = [ToolCall(tool_name="writer_tool", arguments={}), ToolCall(tool_name="reader_tool", arguments={})]
tools = [_writer_tool("a"), _reader_tool("b")]
assert _schedule_tool_calls(calls, tools) == [[0, 1]]
def test_writer_then_reader_serializes(self):
# Writer listed first, reader second: reader depends on writer -> two batches.
calls = [ToolCall(tool_name="writer_tool", arguments={}), ToolCall(tool_name="reader_tool", arguments={})]
tools = [_writer_tool("docs"), _reader_tool("docs")]
assert _schedule_tool_calls(calls, tools) == [[0], [1]]
def test_reader_before_writer_is_reordered(self):
# Reader listed first, writer second: the writer must still run first.
calls = [ToolCall(tool_name="reader_tool", arguments={}), ToolCall(tool_name="writer_tool", arguments={})]
tools = [_reader_tool("docs"), _writer_tool("docs")]
assert _schedule_tool_calls(calls, tools) == [[1], [0]]
def test_pure_write_write_runs_in_one_batch(self):
# Two writers of the same key, neither reads it: no read-after-write dependency, so they run together.
calls = [ToolCall(tool_name="writer_tool", arguments={}), ToolCall(tool_name="writer_tool", arguments={})]
tools = [_writer_tool("docs"), _writer_tool("docs")]
assert _schedule_tool_calls(calls, tools) == [[0, 1]]
def test_read_write_cycle_breaks_by_call_order(self):
# Two retrieval tools that both read and write "documents" form a cycle; broken by call order.
calls = [ToolCall(tool_name="retrieval_tool", arguments={}), ToolCall(tool_name="retrieval_tool", arguments={})]
tools = [_make_retrieval_tool(), _make_retrieval_tool()]
assert _schedule_tool_calls(calls, tools) == [[0], [1]]
def test_state_param_tool_is_a_barrier(self):
# A State-typed tool reads/writes every key, so it serializes everything around it even when the other
# calls touch disjoint keys ("a" and "b"): writer("b") must run before the state tool (which reads "b"),
# and reader("a") must run after it (the state tool may write "a").
calls = [
ToolCall(tool_name="reader_tool", arguments={}),
ToolCall(tool_name="state_tool", arguments={}),
ToolCall(tool_name="writer_tool", arguments={}),
]
tools = [_reader_tool("a"), _state_param_tool(), _writer_tool("b")]
assert _schedule_tool_calls(calls, tools) == [[2], [1], [0]]
def test_llm_supplied_arg_is_not_a_state_read(self):
# The reader's `value` param is supplied by the LLM, so it is not read from state -> no dep on the writer.
calls = [
ToolCall(tool_name="reader_tool", arguments={"value": "provided"}),
ToolCall(tool_name="writer_tool", arguments={}),
]
tools = [_reader_tool("docs"), _writer_tool("docs")]
assert _schedule_tool_calls(calls, tools) == [[0, 1]]
class TestStateDependencyScheduling:
def test_read_write_same_key_runs_sequentially(self):
"""Two calls to a tool that reads and writes the same key must serialize; the second sees the first's write."""
retrieval_tool = _make_retrieval_tool()
state = State(schema={"documents": {"type": list[Document]}})
message = ChatMessage.from_assistant(
tool_calls=[
ToolCall(id="1", tool_name="retrieval_tool", arguments={}),
ToolCall(id="2", tool_name="retrieval_tool", arguments={}),
]
)
_run_tool(messages=[message], state=state, tools=[retrieval_tool])
assert [doc.content for doc in state.get("documents")] == ["doc-0", "doc-1"]
def test_reader_requested_before_writer_still_sees_write(self):
"""A reader listed before a writer of the same key must still run after the writer (forced read-after-write)."""
seen = []
reader_tool = _reader_tool("value", seen=seen)
writer_tool = _writer_tool("value")
state = State(schema={"value": {"type": str}})
# Reader is listed *before* the writer; it must still observe the writer's output.
message = ChatMessage.from_assistant(
tool_calls=[
ToolCall(id="1", tool_name="reader_tool", arguments={}),
ToolCall(id="2", tool_name="writer_tool", arguments={}),
]
)
_run_tool(messages=[message], state=state, tools=[reader_tool, writer_tool])
assert seen == ["written"]
class TestRunToolAsync:
@pytest.mark.asyncio
async def test_async_tool_is_awaited_directly(self, async_weather_tool):
message = ChatMessage.from_assistant(
tool_calls=[ToolCall(id="1", tool_name="weather_tool", arguments={"location": "Berlin"})]
)
with patch.object(asyncio, "to_thread") as to_thread_mock:
tool_messages, _ = await _run_tool_async(
messages=[message], state=State(schema={}), tools=[async_weather_tool]
)
to_thread_mock.assert_not_called()
assert json.loads(tool_messages[0].tool_call_results[0].result)["weather"] == "mostly sunny"
@pytest.mark.asyncio
async def test_sync_tool_is_dispatched_to_thread(self, weather_tool):
message = ChatMessage.from_assistant(
tool_calls=[ToolCall(id="1", tool_name="weather_tool", arguments={"location": "Berlin"})]
)
with patch.object(asyncio, "to_thread", wraps=asyncio.to_thread) as to_thread_spy:
tool_messages, _ = await _run_tool_async(messages=[message], state=State(schema={}), tools=[weather_tool])
assert to_thread_spy.call_count == 1
assert tool_messages[0].tool_call_results[0].result is not None
@pytest.mark.asyncio
async def test_max_workers_serializes_async_tool_calls(self):
active = 0
max_active = 0
async def slow_async(location: str) -> str:
nonlocal active, max_active
active += 1
max_active = max(max_active, active)
await asyncio.sleep(0.01)
active -= 1
return location
slow_tool = Tool(
name="weather_tool",
description="Slow async tool.",
parameters=weather_parameters,
async_function=slow_async,
)
message = ChatMessage.from_assistant(
tool_calls=[
ToolCall(id=str(i), tool_name="weather_tool", arguments={"location": f"city-{i}"}) for i in range(4)
]
)
await _run_tool_async(messages=[message], state=State(schema={}), tools=[slow_tool], max_workers=1)
assert max_active == 1
@pytest.mark.asyncio
async def test_read_write_same_key_runs_sequentially(self):
"""Two calls to a tool that reads and writes the same key must serialize; the second sees the first's write."""
retrieval_tool = _make_retrieval_tool()
state = State(schema={"documents": {"type": list[Document]}})
message = ChatMessage.from_assistant(
tool_calls=[
ToolCall(id="1", tool_name="retrieval_tool", arguments={}),
ToolCall(id="2", tool_name="retrieval_tool", arguments={}),
]
)
await _run_tool_async(messages=[message], state=state, tools=[retrieval_tool])
assert [doc.content for doc in state.get("documents")] == ["doc-0", "doc-1"]