901 lines
26 KiB
Python
901 lines
26 KiB
Python
import asyncio
|
|
import re
|
|
from click.testing import CliRunner
|
|
from importlib.metadata import version
|
|
import json
|
|
import llm
|
|
from llm import cli, CancelToolCall
|
|
from llm.migrations import migrate
|
|
from llm.tools import llm_time
|
|
import os
|
|
import pytest
|
|
import sqlite_utils
|
|
import time
|
|
|
|
API_KEY = os.environ.get("PYTEST_OPENAI_API_KEY", None) or "badkey"
|
|
|
|
|
|
@pytest.mark.vcr
|
|
def test_tool_use_basic(vcr):
|
|
model = llm.get_model("gpt-4o-mini")
|
|
|
|
def multiply(a: int, b: int) -> int:
|
|
"""Multiply two numbers."""
|
|
return a * b
|
|
|
|
chain_response = model.chain("What is 1231 * 2331?", tools=[multiply], key=API_KEY)
|
|
|
|
output = "".join(chain_response)
|
|
|
|
assert output == "The result of \\( 1231 \\times 2331 \\) is \\( 2,869,461 \\)."
|
|
|
|
first, second = chain_response._responses
|
|
|
|
assert first.prompt.prompt == "What is 1231 * 2331?"
|
|
assert first.prompt.tools[0].name == "multiply"
|
|
|
|
assert len(second.prompt.tool_results) == 1
|
|
assert second.prompt.tool_results[0].name == "multiply"
|
|
assert second.prompt.tool_results[0].output == "2869461"
|
|
|
|
# Test writing to the database
|
|
db = sqlite_utils.Database(memory=True)
|
|
migrate(db)
|
|
chain_response.log_to_db(db)
|
|
assert set(db.table_names()).issuperset(
|
|
{"tools", "tool_responses", "tool_calls", "tool_results"}
|
|
)
|
|
|
|
responses = list(db["responses"].rows)
|
|
assert len(responses) == 2
|
|
first_response, second_response = responses
|
|
|
|
tools = list(db["tools"].rows)
|
|
assert len(tools) == 1
|
|
assert tools[0]["name"] == "multiply"
|
|
assert tools[0]["description"] == "Multiply two numbers."
|
|
assert tools[0]["plugin"] is None
|
|
|
|
tool_results = list(db["tool_results"].rows)
|
|
tool_calls = list(db["tool_calls"].rows)
|
|
|
|
assert len(tool_calls) == 1
|
|
assert tool_calls[0]["response_id"] == first_response["id"]
|
|
assert tool_calls[0]["name"] == "multiply"
|
|
assert tool_calls[0]["arguments"] == '{"a": 1231, "b": 2331}'
|
|
|
|
assert len(tool_results) == 1
|
|
assert tool_results[0]["response_id"] == second_response["id"]
|
|
assert tool_results[0]["output"] == "2869461"
|
|
assert tool_results[0]["tool_call_id"] == tool_calls[0]["tool_call_id"]
|
|
|
|
|
|
@pytest.mark.vcr
|
|
def test_tool_use_chain_of_two_calls(vcr):
|
|
model = llm.get_model("gpt-4o-mini")
|
|
|
|
def lookup_population(country: str) -> int:
|
|
"Returns the current population of the specified fictional country"
|
|
return 123124
|
|
|
|
def can_have_dragons(population: int) -> bool:
|
|
"Returns True if the specified population can have dragons, False otherwise"
|
|
return population > 10000
|
|
|
|
chain_response = model.chain(
|
|
"Can the country of Crumpet have dragons? Answer with only YES or NO",
|
|
tools=[lookup_population, can_have_dragons],
|
|
stream=False,
|
|
key=API_KEY,
|
|
)
|
|
|
|
output = chain_response.text()
|
|
assert output == "YES"
|
|
assert len(chain_response._responses) == 3
|
|
|
|
first, second, third = chain_response._responses
|
|
assert first.tool_calls()[0].arguments == {"country": "Crumpet"}
|
|
assert first.prompt.tool_results == []
|
|
assert second.prompt.tool_results[0].output == "123124"
|
|
assert second.tool_calls()[0].arguments == {"population": 123124}
|
|
assert third.prompt.tool_results[0].output == "true"
|
|
assert third.tool_calls() == []
|
|
|
|
|
|
def test_tool_use_async_tool_function():
|
|
async def hello():
|
|
return "world"
|
|
|
|
model = llm.get_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "hello"}]}), tools=[hello]
|
|
)
|
|
output = chain_response.text()
|
|
# That's two JSON objects separated by '\n}{\n'
|
|
bits = output.split("\n}{\n")
|
|
assert len(bits) == 2
|
|
objects = [json.loads(bits[0] + "}"), json.loads("{" + bits[1])]
|
|
tool_call_id = objects[1]["tool_results"][0]["tool_call_id"]
|
|
assert tool_call_id.startswith("tc_")
|
|
objects[1]["tool_results"][0]["tool_call_id"] = None
|
|
assert objects == [
|
|
{"prompt": "", "system": "", "attachments": [], "stream": True, "previous": []},
|
|
{
|
|
"prompt": "",
|
|
"system": "",
|
|
"attachments": [],
|
|
"stream": True,
|
|
"previous": [{"prompt": '{"tool_calls": [{"name": "hello"}]}'}],
|
|
"tool_results": [
|
|
{"name": "hello", "output": "world", "tool_call_id": None}
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_tools_run_tools_in_parallel():
|
|
start_timestamps = []
|
|
|
|
start_ns = time.monotonic_ns()
|
|
|
|
async def hello():
|
|
start_timestamps.append(("hello", time.monotonic_ns() - start_ns))
|
|
await asyncio.sleep(0.2)
|
|
return "world"
|
|
|
|
async def hello2():
|
|
start_timestamps.append(("hello2", time.monotonic_ns() - start_ns))
|
|
await asyncio.sleep(0.2)
|
|
return "world2"
|
|
|
|
model = llm.get_async_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "hello"}, {"name": "hello2"}]}),
|
|
tools=[hello, hello2],
|
|
)
|
|
output = await chain_response.text()
|
|
# That's two JSON objects separated by '\n}{\n'
|
|
bits = output.split("\n}{\n")
|
|
assert len(bits) == 2
|
|
objects = [json.loads(bits[0] + "}"), json.loads("{" + bits[1])]
|
|
ids = [r["tool_call_id"] for r in objects[1]["tool_results"]]
|
|
assert all(i.startswith("tc_") for i in ids)
|
|
assert len(set(ids)) == 2
|
|
for r in objects[1]["tool_results"]:
|
|
r["tool_call_id"] = None
|
|
assert objects == [
|
|
{"prompt": "", "system": "", "attachments": [], "stream": True, "previous": []},
|
|
{
|
|
"prompt": "",
|
|
"system": "",
|
|
"attachments": [],
|
|
"stream": True,
|
|
"previous": [
|
|
{"prompt": '{"tool_calls": [{"name": "hello"}, {"name": "hello2"}]}'}
|
|
],
|
|
"tool_results": [
|
|
{"name": "hello", "output": "world", "tool_call_id": None},
|
|
{"name": "hello2", "output": "world2", "tool_call_id": None},
|
|
],
|
|
},
|
|
]
|
|
delta_ns = start_timestamps[1][1] - start_timestamps[0][1]
|
|
# They should have run in parallel so it should be less than 0.02s difference
|
|
assert delta_ns < (100_000_000 * 0.2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_toolbox():
|
|
class Tools(llm.Toolbox):
|
|
def __init__(self):
|
|
self.prepared = False
|
|
|
|
async def go(self):
|
|
await asyncio.sleep(0)
|
|
return "This was async"
|
|
|
|
async def prepare_async(self):
|
|
await asyncio.sleep(0)
|
|
self.prepared = True
|
|
|
|
instance = Tools()
|
|
assert instance.prepared is False
|
|
|
|
model = llm.get_async_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "Tools_go"}]}),
|
|
tools=[instance],
|
|
)
|
|
output = await chain_response.text()
|
|
assert '"output": "This was async"' in output
|
|
assert instance.prepared is True
|
|
|
|
|
|
def test_toolbox_add_tool():
|
|
model = llm.get_model("echo")
|
|
|
|
class Tools(llm.Toolbox):
|
|
def __init__(self):
|
|
self.prepared = False
|
|
|
|
def original(self):
|
|
return "Original method"
|
|
|
|
def prepare(self):
|
|
self.prepared = True
|
|
|
|
def new_method():
|
|
return "New method"
|
|
|
|
tools = Tools()
|
|
tools.add_tool(new_method)
|
|
assert not tools.prepared
|
|
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "new_method"}]}),
|
|
tools=[tools],
|
|
)
|
|
output = chain_response.text()
|
|
assert '"output": "New method"' in output
|
|
assert tools.prepared
|
|
|
|
|
|
def test_toolbox_add_tool_with_pass_self():
|
|
model = llm.get_model("echo")
|
|
|
|
class Tools(llm.Toolbox):
|
|
def __init__(self, hotdog):
|
|
self.hotdog = hotdog
|
|
|
|
def original(self):
|
|
return "Original method"
|
|
|
|
def new_method(self):
|
|
return self.hotdog
|
|
|
|
tools = Tools("doghot")
|
|
tools.add_tool(new_method, pass_self=True)
|
|
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "new_method"}]}),
|
|
tools=[tools],
|
|
)
|
|
output = chain_response.text()
|
|
assert '"output": "doghot"' in output
|
|
|
|
|
|
@pytest.mark.vcr
|
|
def test_conversation_with_tools(vcr):
|
|
import llm
|
|
|
|
def add(a: int, b: int) -> int:
|
|
return a + b
|
|
|
|
def multiply(a: int, b: int) -> int:
|
|
return a * b
|
|
|
|
model = llm.get_model("echo")
|
|
conversation = model.conversation(tools=[add, multiply])
|
|
|
|
output1 = conversation.chain(
|
|
json.dumps(
|
|
{"tool_calls": [{"name": "multiply", "arguments": {"a": 5324, "b": 23233}}]}
|
|
)
|
|
).text()
|
|
assert "123692492" in output1
|
|
output2 = conversation.chain(
|
|
json.dumps(
|
|
{
|
|
"tool_calls": [
|
|
{"name": "add", "arguments": {"a": 841758375, "b": 123123}}
|
|
]
|
|
}
|
|
)
|
|
).text()
|
|
assert "841881498" in output2
|
|
|
|
|
|
def test_default_tool_llm_version():
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
cli.cli,
|
|
[
|
|
"-m",
|
|
"echo",
|
|
"-T",
|
|
"llm_version",
|
|
json.dumps({"tool_calls": [{"name": "llm_version"}]}),
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert '"output": "{}"'.format(version("llm")) in result.output
|
|
|
|
|
|
def test_cli_tools_with_options():
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
cli.cli,
|
|
[
|
|
"-m",
|
|
"mock",
|
|
"-o",
|
|
"max_tokens",
|
|
"10",
|
|
"-T",
|
|
"llm_version",
|
|
json.dumps({"tool_calls": [{"name": "llm_version"}]}),
|
|
],
|
|
catch_exceptions=False,
|
|
)
|
|
assert result.exit_code == 0
|
|
# It just needs not to crash
|
|
# https://github.com/simonw/llm/issues/1233
|
|
|
|
|
|
def test_functions_tool_locals():
|
|
# https://github.com/simonw/llm/issues/1107
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
cli.cli,
|
|
[
|
|
"-m",
|
|
"echo",
|
|
"--functions",
|
|
"my_locals = locals",
|
|
"-T",
|
|
"llm_version",
|
|
json.dumps({"tool_calls": [{"name": "locals"}]}),
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
|
|
|
|
def test_default_tool_llm_time():
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
cli.cli,
|
|
[
|
|
"-m",
|
|
"echo",
|
|
"-T",
|
|
"llm_time",
|
|
json.dumps({"tool_calls": [{"name": "llm_time"}]}),
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "timezone_offset" in result.output
|
|
|
|
# Test it by calling it directly
|
|
info = llm_time()
|
|
assert set(info.keys()) == {
|
|
"timezone_offset",
|
|
"utc_time_iso",
|
|
"local_time",
|
|
"local_timezone",
|
|
"utc_time",
|
|
"is_dst",
|
|
}
|
|
|
|
|
|
def test_incorrect_tool_usage():
|
|
model = llm.get_model("echo")
|
|
|
|
def simple(name: str):
|
|
return name
|
|
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "bad_tool"}]}),
|
|
tools=[simple],
|
|
)
|
|
output = chain_response.text()
|
|
assert 'Error: tool \\"bad_tool\\" does not exist' in output
|
|
|
|
|
|
def test_tool_returning_attachment():
|
|
model = llm.get_model("echo")
|
|
|
|
def return_attachment() -> llm.Attachment:
|
|
return llm.ToolOutput(
|
|
"Output",
|
|
attachments=[
|
|
llm.Attachment(
|
|
content=b"This is a test attachment",
|
|
type="image/png",
|
|
)
|
|
],
|
|
)
|
|
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "return_attachment"}]}),
|
|
tools=[return_attachment],
|
|
)
|
|
output = chain_response.text()
|
|
assert '"type": "image/png"' in output
|
|
assert '"output": "Output"' in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_tool_returning_attachment():
|
|
model = llm.get_async_model("echo")
|
|
|
|
async def return_attachment() -> llm.Attachment:
|
|
return llm.ToolOutput(
|
|
"Output",
|
|
attachments=[
|
|
llm.Attachment(
|
|
content=b"This is a test attachment",
|
|
type="image/png",
|
|
)
|
|
],
|
|
)
|
|
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "return_attachment"}]}),
|
|
tools=[return_attachment],
|
|
)
|
|
output = await chain_response.text()
|
|
assert '"type": "image/png"' in output
|
|
assert '"output": "Output"' in output
|
|
|
|
|
|
def test_tool_conversation_settings():
|
|
model = llm.get_model("echo")
|
|
before_collected = []
|
|
after_collected = []
|
|
|
|
def before(*args):
|
|
before_collected.append(args)
|
|
|
|
def after(*args):
|
|
after_collected.append(args)
|
|
|
|
conversation = model.conversation(
|
|
tools=[llm_time], before_call=before, after_call=after
|
|
)
|
|
# Run two things
|
|
conversation.chain(json.dumps({"tool_calls": [{"name": "llm_time"}]})).text()
|
|
conversation.chain(json.dumps({"tool_calls": [{"name": "llm_time"}]})).text()
|
|
assert len(before_collected) == 2
|
|
assert len(after_collected) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_conversation_settings_async():
|
|
model = llm.get_async_model("echo")
|
|
before_collected = []
|
|
after_collected = []
|
|
|
|
async def before(*args):
|
|
before_collected.append(args)
|
|
|
|
async def after(*args):
|
|
after_collected.append(args)
|
|
|
|
conversation = model.conversation(
|
|
tools=[llm_time], before_call=before, after_call=after
|
|
)
|
|
await conversation.chain(json.dumps({"tool_calls": [{"name": "llm_time"}]})).text()
|
|
await conversation.chain(json.dumps({"tool_calls": [{"name": "llm_time"}]})).text()
|
|
assert len(before_collected) == 2
|
|
assert len(after_collected) == 2
|
|
|
|
|
|
ERROR_FUNCTION = """
|
|
def trigger_error(msg: str):
|
|
raise Exception(msg)
|
|
"""
|
|
|
|
|
|
@pytest.mark.parametrize("async_", (False, True))
|
|
def test_tool_errors(async_):
|
|
# https://github.com/simonw/llm/issues/1107
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
cli.cli,
|
|
(
|
|
[
|
|
"-m",
|
|
"echo",
|
|
"--functions",
|
|
ERROR_FUNCTION,
|
|
json.dumps(
|
|
{
|
|
"tool_calls": [
|
|
{"name": "trigger_error", "arguments": {"msg": "Error!"}}
|
|
]
|
|
}
|
|
),
|
|
]
|
|
+ (["--async"] if async_ else [])
|
|
),
|
|
)
|
|
assert result.exit_code == 0
|
|
assert '"output": "Error: Error!"' in result.output
|
|
# llm logs --json output
|
|
log_json_result = runner.invoke(cli.cli, ["logs", "--json", "-c"])
|
|
assert log_json_result.exit_code == 0
|
|
log_data = json.loads(log_json_result.output)
|
|
assert len(log_data) == 2
|
|
assert log_data[1]["tool_results"][0]["exception"] == "Exception: Error!"
|
|
# llm logs -c output
|
|
log_text_result = runner.invoke(cli.cli, ["logs", "-c"])
|
|
assert log_text_result.exit_code == 0
|
|
normalized_log_text = re.sub(r"tc_[0-9a-z]{26}", "tc_TCID", log_text_result.output)
|
|
assert (
|
|
"- **trigger_error**: `tc_TCID`<br>\n"
|
|
" ```\n"
|
|
" Error: Error!\n"
|
|
" ```<br>\n"
|
|
" **Error**: Exception: Error!\n"
|
|
) in normalized_log_text
|
|
|
|
|
|
def test_chain_sync_cancel_only_first_of_two():
|
|
model = llm.get_model("echo")
|
|
|
|
def t1() -> str:
|
|
return "ran1"
|
|
|
|
def t2() -> str:
|
|
return "ran2"
|
|
|
|
def before(tool, tool_call):
|
|
if tool.name == "t1":
|
|
raise CancelToolCall("skip1")
|
|
# allow t2
|
|
return None
|
|
|
|
calls = [
|
|
{"name": "t1"},
|
|
{"name": "t2"},
|
|
]
|
|
payload = json.dumps({"tool_calls": calls})
|
|
chain = model.chain(payload, tools=[t1, t2], before_call=before)
|
|
_ = chain.text()
|
|
|
|
# second response has two results
|
|
second = chain._responses[1]
|
|
results = second.prompt.tool_results
|
|
assert len(results) == 2
|
|
|
|
# first cancelled, second executed
|
|
assert results[0].name == "t1"
|
|
assert results[0].output == "Cancelled: skip1"
|
|
assert isinstance(results[0].exception, CancelToolCall)
|
|
|
|
assert results[1].name == "t2"
|
|
assert results[1].output == "ran2"
|
|
assert results[1].exception is None
|
|
|
|
|
|
# 2c async equivalent
|
|
@pytest.mark.asyncio
|
|
async def test_chain_async_cancel_only_first_of_two():
|
|
async_model = llm.get_async_model("echo")
|
|
|
|
def t1() -> str:
|
|
return "ran1"
|
|
|
|
async def t2() -> str:
|
|
return "ran2"
|
|
|
|
async def before(tool, tool_call):
|
|
if tool.name == "t1":
|
|
raise CancelToolCall("skip1")
|
|
return None
|
|
|
|
calls = [
|
|
{"name": "t1"},
|
|
{"name": "t2"},
|
|
]
|
|
payload = json.dumps({"tool_calls": calls})
|
|
chain = async_model.chain(payload, tools=[t1, t2], before_call=before)
|
|
_ = await chain.text()
|
|
|
|
second = chain._responses[1]
|
|
results = second.prompt.tool_results
|
|
assert len(results) == 2
|
|
|
|
assert results[0].name == "t1"
|
|
assert results[0].output == "Cancelled: skip1"
|
|
assert isinstance(results[0].exception, CancelToolCall)
|
|
|
|
assert results[1].name == "t2"
|
|
assert results[1].output == "ran2"
|
|
assert results[1].exception is None
|
|
|
|
|
|
def test_tool_function_receives_llm_tool_call():
|
|
captured = {}
|
|
|
|
def lookup(name: str, llm_tool_call) -> str:
|
|
"Look up a name"
|
|
captured["tool_call"] = llm_tool_call
|
|
return "result for " + name
|
|
|
|
model = llm.get_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps(
|
|
{"tool_calls": [{"name": "lookup", "arguments": {"name": "simon"}}]}
|
|
),
|
|
tools=[lookup],
|
|
)
|
|
chain_response.text()
|
|
|
|
tool_call = captured["tool_call"]
|
|
assert isinstance(tool_call, llm.ToolCall)
|
|
assert tool_call.name == "lookup"
|
|
assert tool_call.arguments == {"name": "simon"}
|
|
second = chain_response._responses[1]
|
|
assert second.prompt.tool_results[0].output == "result for simon"
|
|
|
|
|
|
def test_async_tool_function_receives_llm_tool_call_with_sync_model():
|
|
captured = {}
|
|
|
|
async def lookup(name: str, llm_tool_call: llm.ToolCall) -> str:
|
|
"Look up a name"
|
|
captured["tool_call"] = llm_tool_call
|
|
return "result for " + name
|
|
|
|
model = llm.get_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps(
|
|
{"tool_calls": [{"name": "lookup", "arguments": {"name": "simon"}}]}
|
|
),
|
|
tools=[lookup],
|
|
)
|
|
chain_response.text()
|
|
|
|
tool_call = captured["tool_call"]
|
|
assert isinstance(tool_call, llm.ToolCall)
|
|
assert tool_call.name == "lookup"
|
|
assert tool_call.arguments == {"name": "simon"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("async_tool", (False, True))
|
|
async def test_tool_function_receives_llm_tool_call_async_model(async_tool):
|
|
captured = {}
|
|
|
|
def lookup(name: str, llm_tool_call) -> str:
|
|
"Look up a name"
|
|
captured["tool_call"] = llm_tool_call
|
|
return "result for " + name
|
|
|
|
async def async_lookup(name: str, llm_tool_call) -> str:
|
|
"Look up a name"
|
|
captured["tool_call"] = llm_tool_call
|
|
return "result for " + name
|
|
|
|
fn = async_lookup if async_tool else lookup
|
|
model = llm.get_async_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps(
|
|
{"tool_calls": [{"name": fn.__name__, "arguments": {"name": "simon"}}]}
|
|
),
|
|
tools=[fn],
|
|
)
|
|
output = await chain_response.text()
|
|
assert '"output": "result for simon"' in output
|
|
|
|
tool_call = captured["tool_call"]
|
|
assert isinstance(tool_call, llm.ToolCall)
|
|
assert tool_call.name == fn.__name__
|
|
assert tool_call.arguments == {"name": "simon"}
|
|
|
|
|
|
def test_llm_tool_call_excluded_from_input_schema():
|
|
def lookup(name: str, llm_tool_call) -> str:
|
|
"Look up a name"
|
|
return name
|
|
|
|
tool = llm.Tool.function(lookup)
|
|
assert "llm_tool_call" not in tool.input_schema.get("properties", {})
|
|
assert "llm_tool_call" not in tool.input_schema.get("required", [])
|
|
assert "name" in tool.input_schema["properties"]
|
|
|
|
|
|
def test_kwargs_only_function_does_not_receive_llm_tool_call():
|
|
# A tool that accepts **kwargs but does not name llm_tool_call
|
|
# explicitly should NOT have it injected.
|
|
captured = {}
|
|
|
|
async def impl(**kwargs):
|
|
captured.update(kwargs)
|
|
return "ok"
|
|
|
|
tool = llm.Tool(
|
|
name="t",
|
|
description="A tool",
|
|
input_schema={"type": "object", "properties": {"name": {"type": "string"}}},
|
|
implementation=impl,
|
|
)
|
|
model = llm.get_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "t", "arguments": {"name": "x"}}]}),
|
|
tools=[tool],
|
|
)
|
|
chain_response.text()
|
|
assert captured == {"name": "x"}
|
|
|
|
|
|
def test_toolbox_method_receives_llm_tool_call():
|
|
captured = {}
|
|
|
|
class Tools(llm.Toolbox):
|
|
def lookup(self, name: str, llm_tool_call) -> str:
|
|
captured["tool_call"] = llm_tool_call
|
|
return "hi " + name
|
|
|
|
model = llm.get_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps(
|
|
{"tool_calls": [{"name": "Tools_lookup", "arguments": {"name": "simon"}}]}
|
|
),
|
|
tools=[Tools()],
|
|
)
|
|
output = chain_response.text()
|
|
assert '"output": "hi simon"' in output
|
|
|
|
tool_call = captured["tool_call"]
|
|
assert isinstance(tool_call, llm.ToolCall)
|
|
assert tool_call.arguments == {"name": "simon"}
|
|
|
|
|
|
def test_add_tool_call_synthesizes_missing_tool_call_id():
|
|
model = llm.get_model("echo")
|
|
response = model.prompt("hello")
|
|
response.add_tool_call(llm.ToolCall(name="a", arguments={}))
|
|
response.add_tool_call(llm.ToolCall(name="b", arguments={}, tool_call_id="given"))
|
|
response.add_tool_call(llm.ToolCall(name="c", arguments={}))
|
|
ids = [tc.tool_call_id for tc in response._tool_calls]
|
|
assert ids[0] is not None and ids[0].startswith("tc_")
|
|
assert ids[1] == "given"
|
|
assert ids[2] is not None and ids[2].startswith("tc_")
|
|
assert ids[0] != ids[2]
|
|
|
|
|
|
def test_tool_call_ids_guaranteed_through_chain():
|
|
seen_before_call = []
|
|
captured = {}
|
|
|
|
def first(llm_tool_call) -> str:
|
|
captured["first_id"] = llm_tool_call.tool_call_id
|
|
return "one"
|
|
|
|
def second() -> str:
|
|
return "two"
|
|
|
|
def before(tool, tool_call):
|
|
seen_before_call.append(tool_call.tool_call_id)
|
|
|
|
model = llm.get_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "first"}, {"name": "second"}]}),
|
|
tools=[first, second],
|
|
before_call=before,
|
|
)
|
|
chain_response.text()
|
|
|
|
assert len(seen_before_call) == 2
|
|
assert all(i is not None and i.startswith("tc_") for i in seen_before_call)
|
|
assert seen_before_call[0] != seen_before_call[1]
|
|
# The implementation saw the same id via llm_tool_call
|
|
assert captured["first_id"] == seen_before_call[0]
|
|
|
|
# ToolResults and the next prompt's tool message carry the same ids
|
|
second_response = chain_response._responses[1]
|
|
result_ids = [r.tool_call_id for r in second_response.prompt.tool_results]
|
|
assert result_ids == seen_before_call
|
|
|
|
# The assistant message parts carry the synthesized ids too, so a
|
|
# persisted-and-replayed history stays correlated
|
|
from llm.parts import ToolCallPart
|
|
|
|
first_response = chain_response._responses[0]
|
|
part_ids = [
|
|
p.tool_call_id
|
|
for p in first_response._messages_now()[0].parts
|
|
if isinstance(p, ToolCallPart)
|
|
]
|
|
assert part_ids == seen_before_call
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_call_ids_guaranteed_async_model():
|
|
seen = []
|
|
|
|
async def hello() -> str:
|
|
return "world"
|
|
|
|
async def before(tool, tool_call):
|
|
seen.append(tool_call.tool_call_id)
|
|
|
|
model = llm.get_async_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "hello"}]}),
|
|
tools=[hello],
|
|
before_call=before,
|
|
)
|
|
await chain_response.text()
|
|
assert len(seen) == 1
|
|
assert seen[0] is not None and seen[0].startswith("tc_")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_missing_tool_produces_error_result():
|
|
# Async executor parity with sync: a call to a tool that is not in
|
|
# tools= must produce an error ToolResult, not silently vanish -
|
|
# otherwise the next provider call has a tool_call with no result.
|
|
before_calls = []
|
|
|
|
async def real_tool() -> str:
|
|
return "ok"
|
|
|
|
async def before(tool, tool_call):
|
|
# before_call fires even when tool is None, like the sync path
|
|
before_calls.append((tool.name if tool else None, tool_call.name))
|
|
|
|
model = llm.get_async_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "missing_tool"}, {"name": "real_tool"}]}),
|
|
tools=[real_tool],
|
|
before_call=before,
|
|
)
|
|
await chain_response.text()
|
|
|
|
second = chain_response._responses[1]
|
|
results = [(r.name, r.output) for r in second.prompt.tool_results]
|
|
assert results == [
|
|
("missing_tool", 'Error: tool "missing_tool" does not exist'),
|
|
("real_tool", "ok"),
|
|
]
|
|
assert isinstance(second.prompt.tool_results[0].exception, KeyError)
|
|
assert (None, "missing_tool") in before_calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_missing_tool_can_be_cancelled_by_before_call():
|
|
async def real_tool() -> str:
|
|
return "ok"
|
|
|
|
async def before(tool, tool_call):
|
|
if tool is None:
|
|
raise CancelToolCall("no such tool")
|
|
|
|
model = llm.get_async_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "missing_tool"}, {"name": "real_tool"}]}),
|
|
tools=[real_tool],
|
|
before_call=before,
|
|
)
|
|
await chain_response.text()
|
|
second = chain_response._responses[1]
|
|
results = [(r.name, r.output) for r in second.prompt.tool_results]
|
|
assert results == [
|
|
("missing_tool", "Cancelled: no such tool"),
|
|
("real_tool", "ok"),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_tool_without_implementation_produces_error_result():
|
|
tool = llm.Tool(
|
|
name="no_impl",
|
|
description="A tool with no implementation",
|
|
input_schema={"type": "object", "properties": {}},
|
|
implementation=None,
|
|
)
|
|
model = llm.get_async_model("echo")
|
|
chain_response = model.chain(
|
|
json.dumps({"tool_calls": [{"name": "no_impl"}]}),
|
|
tools=[tool],
|
|
)
|
|
await chain_response.text()
|
|
second = chain_response._responses[1]
|
|
assert [(r.name, r.output) for r in second.prompt.tool_results] == [
|
|
("no_impl", 'Error: tool "no_impl" has no implementation'),
|
|
]
|