diff --git a/docs/help.md b/docs/help.md index 1f3873e..c59f306 100644 --- a/docs/help.md +++ b/docs/help.md @@ -173,6 +173,13 @@ Options: -d, --database FILE Path to log database --no-stream Do not stream output --key TEXT API key to use + -T, --tool TEXT Name of a tool to make available to the model + --functions TEXT Python code block or file path defining functions + to register as tools + --td, --tools-debug Show full details of tool executions + --ta, --tools-approve Manually approve every tool execution + --cl, --chain-limit INTEGER How many chained tool responses to allow, default + 5, set 0 for unlimited --help Show this message and exit. ``` diff --git a/docs/usage.md b/docs/usage.md index 6f995d1..7a791a8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -162,6 +162,17 @@ Approve tool call? [y/N]: ``` The `--functions` option can be passed more than once, and can also point to the filename of a `.py` file containing one or more functions. +If you have any tools that have been made available via plugins you can add them to the prompt using `--tool/-T` option. For example, using [llm-tools-simpleeval](https://github.com/simonw/llm-tools-simpleeval) like this: + +```bash +llm install llm-tools-simpleeval +llm --tool simple_eval "4444 * 233423" --td +``` +Run this command to see a list of available tools from plugins: +```bash +llm tools +``` + (usage-extract-fenced-code)= ### Extracting fenced code blocks @@ -416,6 +427,8 @@ Type '!edit' to open your default editor and modify the prompt. > !edit ``` +`llm chat` takes the same `--tool/-T` and `--functions` options as `llm prompt`. You can use this to start a chat with the specified {ref}`tools ` enabled. + ## Listing available models The `llm models` command lists every model that can be used with LLM, along with their aliases. This includes models that have been installed using {ref}`plugins `. diff --git a/llm/cli.py b/llm/cli.py index 8ebe102..2a72ab3 100644 --- a/llm/cli.py +++ b/llm/cli.py @@ -909,6 +909,42 @@ def prompt( ) @click.option("--no-stream", is_flag=True, help="Do not stream output") @click.option("--key", help="API key to use") +@click.option( + "tools", + "-T", + "--tool", + multiple=True, + help="Name of a tool to make available to the model", +) +@click.option( + "python_tools", + "--functions", + help="Python code block or file path defining functions to register as tools", + multiple=True, +) +@click.option( + "tools_debug", + "--td", + "--tools-debug", + is_flag=True, + help="Show full details of tool executions", + envvar="LLM_TOOLS_DEBUG", +) +@click.option( + "tools_approve", + "--ta", + "--tools-approve", + is_flag=True, + help="Manually approve every tool execution", +) +@click.option( + "chain_limit", + "--cl", + "--chain-limit", + type=int, + default=5, + help="How many chained tool responses to allow, default 5, set 0 for unlimited", +) def chat( system, model_id, @@ -920,6 +956,11 @@ def chat( no_stream, key, database, + tools, + python_tools, + tools_debug, + tools_approve, + chain_limit, ): """ Hold an ongoing chat with a model. @@ -987,7 +1028,18 @@ def chat( raise click.ClickException(render_errors(ex.errors())) kwargs = {} - kwargs.update(validated_options) + if validated_options: + kwargs["options"] = validated_options + + tool_functions = _gather_tools(tools, python_tools) + + if tool_functions: + kwargs["chain_limit"] = chain_limit + if tools_debug: + kwargs["after_call"] = _debug_tool_call + if tools_approve: + kwargs["before_call"] = _approve_tool_call + kwargs["tools"] = tool_functions should_stream = model.can_stream and not no_stream if not should_stream: @@ -1042,7 +1094,7 @@ def chat( prompt = new_prompt if prompt.strip() in ("exit", "quit"): break - response = conversation.prompt(prompt, system=system, **kwargs) + response = conversation.chain(prompt, system=system, **kwargs) # System prompt only sent for the first message: system = None for chunk in response: diff --git a/llm/models.py b/llm/models.py index 68f9b39..cd7c1ec 100644 --- a/llm/models.py +++ b/llm/models.py @@ -332,7 +332,7 @@ class Conversation(_BaseConversation): details: bool = False, key: Optional[str] = None, options: Optional[dict] = None, - ): + ) -> "ChainResponse": self.model._validate_attachments(attachments) return ChainResponse( Prompt( diff --git a/pyproject.toml b/pyproject.toml index a732728..ad3a2ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ test = [ "types-click", "types-PyYAML", "types-setuptools", + "llm-echo==0.3a1", ] [build-system] diff --git a/tests/conftest.py b/tests/conftest.py index 21e1f53..62c7c95 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import pytest import sqlite_utils import json import llm +import llm_echo from llm.plugins import pm from pydantic import Field from pytest_httpx import IteratorStream @@ -233,19 +234,20 @@ def register_embed_demo_model(embed_demo, mock_model, async_mock_model): @pytest.fixture(autouse=True) -def register_echo_model(): - class SimpleEchoModelPlugin: - __name__ = "SimpleEchoModelPlugin" +def register_echo_models(): + class EchoModelsPlugin: + __name__ = "EchoModelsPlugin" @llm.hookimpl def register_models(self, register): register(SimpleEchoModel()) + register(llm_echo.Echo(), llm_echo.EchoAsync()) - pm.register(SimpleEchoModelPlugin(), name="undo-SimpleEchoModelPlugin") + pm.register(EchoModelsPlugin(), name="undo-EchoModelsPlugin") try: yield finally: - pm.unregister(name="undo-SimpleEchoModelPlugin") + pm.unregister(name="undo-EchoModelsPlugin") @pytest.fixture diff --git a/tests/test_chat.py b/tests/test_chat.py index 278a3f2..b34dbff 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1,9 +1,11 @@ from click.testing import CliRunner +import json import llm.cli from unittest.mock import ANY import pytest import sys import sqlite_utils +import textwrap @pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") @@ -265,3 +267,69 @@ def test_llm_chat_creates_log_database(tmpdir, monkeypatch, custom_database_path assert (user_path / "logs.db").exists() db_path = str(user_path / "logs.db") assert sqlite_utils.Database(db_path)["responses"].count == 2 + + +@pytest.mark.xfail(sys.platform == "win32", reason="Expected to fail on Windows") +def test_chat_tools(logs_db): + runner = CliRunner() + functions = textwrap.dedent( + """ + def upper(text: str) -> str: + "Convert text to upper case" + return text.upper() + """ + ) + result = runner.invoke( + llm.cli.cli, + ["chat", "-m", "echo", "--functions", functions], + input="\n".join( + [ + json.dumps( + { + "prompt": "Convert hello to uppercase", + "tool_calls": [ + {"name": "upper", "arguments": {"text": "hello"}} + ], + } + ), + "quit", + ] + ), + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert result.output == ( + "Chatting with echo\n" + "Type 'exit' or 'quit' to exit\n" + "Type '!multi' to enter multiple lines, then '!end' to finish\n" + "Type '!edit' to open your default editor and modify the prompt\n" + '> {"prompt": "Convert hello to uppercase", "tool_calls": [{"name": "upper", ' + '"arguments": {"text": "hello"}}]}\n' + "{\n" + ' "prompt": "Convert hello to uppercase",\n' + ' "system": "",\n' + ' "attachments": [],\n' + ' "stream": true,\n' + ' "previous": []\n' + "}{\n" + ' "prompt": "",\n' + ' "system": "",\n' + ' "attachments": [],\n' + ' "stream": true,\n' + ' "previous": [\n' + " {\n" + ' "prompt": "{\\"prompt\\": \\"Convert hello to uppercase\\", ' + '\\"tool_calls\\": [{\\"name\\": \\"upper\\", \\"arguments\\": {\\"text\\": ' + '\\"hello\\"}}]}"\n' + " }\n" + " ],\n" + ' "tool_results": [\n' + " {\n" + ' "name": "upper",\n' + ' "output": "HELLO",\n' + ' "tool_call_id": null\n' + " }\n" + " ]\n" + "}\n" + "> quit\n" + ) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index ead52bf..98f8d32 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -297,11 +297,11 @@ def test_plugins_command(): result = runner.invoke(cli.cli, ["plugins"]) assert result.exit_code == 0 expected = [ + {"name": "EchoModelsPlugin", "hooks": ["register_models"]}, { "name": "MockModelsPlugin", "hooks": ["register_embedding_models", "register_models"], }, - {"name": "SimpleEchoModelPlugin", "hooks": ["register_models"]}, ] actual = json.loads(result.output) actual.sort(key=lambda p: p["name"])