From f01d38be353483fd59c47c6f4aa289c77dde9e54 Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 15:49:01 +0800 Subject: [PATCH] fix(python): harden zeroclaw-tools CLI and integration ergonomics --- README.md | 2 +- python/README.md | 3 ++ python/pyproject.toml | 1 + python/tests/test_tools.py | 41 +++++++++++++++++++ python/zeroclaw_tools/__main__.py | 32 ++++++++++++--- python/zeroclaw_tools/agent.py | 18 ++++++-- .../zeroclaw_tools/integrations/__init__.py | 2 +- .../integrations/discord_bot.py | 25 ++++++----- python/zeroclaw_tools/tools/base.py | 8 +++- python/zeroclaw_tools/tools/memory.py | 5 +-- 10 files changed, 110 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index dc9882a..72b0a28 100644 --- a/README.md +++ b/README.md @@ -447,7 +447,7 @@ print(result["messages"][-1].content) - **Consistent tool calling** across all providers (even those with poor native support) - **Automatic tool loop** — keeps calling tools until the task is complete - **Easy extensibility** — add custom tools with `@tool` decorator -- **Discord/Telegram bots** included +- **Discord bot integration** included (Telegram planned) See [`python/README.md`](python/README.md) for full documentation. diff --git a/python/README.md b/python/README.md index 5ad7c7b..0f04f3e 100644 --- a/python/README.md +++ b/python/README.md @@ -60,6 +60,9 @@ export API_BASE="https://api.z.ai/api/coding/paas/v4" # Run the CLI zeroclaw-tools "List files in the current directory" + +# Interactive mode (no message required) +zeroclaw-tools -i ``` ### Discord Bot diff --git a/python/pyproject.toml b/python/pyproject.toml index 00a53b3..dea680b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -64,3 +64,4 @@ target-version = "py310" [tool.pytest.ini_options] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/python/tests/test_tools.py b/python/tests/test_tools.py index 14318fd..c5242c7 100644 --- a/python/tests/test_tools.py +++ b/python/tests/test_tools.py @@ -27,6 +27,18 @@ def test_import_tool_decorator(): assert hasattr(test_func, "invoke") +def test_tool_decorator_custom_metadata(): + """Test that custom tool metadata is preserved.""" + from zeroclaw_tools import tool + + @tool(name="echo_tool", description="Echo input back") + def echo(value: str) -> str: + return value + + assert echo.name == "echo_tool" + assert "Echo input back" in echo.description + + def test_agent_creation(): """Test that agent can be created with default tools.""" from zeroclaw_tools import create_agent, shell, file_read, file_write @@ -39,6 +51,35 @@ def test_agent_creation(): assert agent.model == "test-model" +def test_cli_allows_interactive_without_message(): + """Interactive mode should not require positional message.""" + from zeroclaw_tools.__main__ import parse_args + + args = parse_args(["-i"]) + + assert args.interactive is True + assert args.message == [] + + +def test_cli_requires_message_when_not_interactive(): + """Non-interactive mode requires at least one message token.""" + from zeroclaw_tools.__main__ import parse_args + + with pytest.raises(SystemExit): + parse_args([]) + + +@pytest.mark.asyncio +async def test_invoke_in_event_loop_raises(): + """invoke() should fail fast when called from an active event loop.""" + from zeroclaw_tools import create_agent, shell + + agent = create_agent(tools=[shell], model="test-model", api_key="test-key") + + with pytest.raises(RuntimeError, match="ainvoke"): + agent.invoke({"messages": []}) + + @pytest.mark.asyncio async def test_shell_tool(): """Test shell tool execution.""" diff --git a/python/zeroclaw_tools/__main__.py b/python/zeroclaw_tools/__main__.py index e6c9639..1d284a5 100644 --- a/python/zeroclaw_tools/__main__.py +++ b/python/zeroclaw_tools/__main__.py @@ -6,6 +6,7 @@ import argparse import asyncio import os import sys +from typing import Optional from langchain_core.messages import HumanMessage @@ -25,7 +26,7 @@ DEFAULT_SYSTEM_PROMPT = """You are ZeroClaw, an AI assistant with full system ac Be concise and helpful. Execute tools directly without excessive explanation.""" -async def chat(message: str, api_key: str, base_url: str, model: str) -> str: +async def chat(message: str, api_key: str, base_url: Optional[str], model: str) -> str: """Run a single chat message through the agent.""" agent = create_agent( tools=[shell, file_read, file_write, web_search, http_request, memory_store, memory_recall], @@ -39,21 +40,40 @@ async def chat(message: str, api_key: str, base_url: str, model: str) -> str: return result["messages"][-1].content or "Done." -def main(): - """CLI main entry point.""" +def _build_parser() -> argparse.ArgumentParser: + """Build CLI argument parser.""" parser = argparse.ArgumentParser( description="ZeroClaw Tools - LangGraph-based tool calling for LLMs" ) - parser.add_argument("message", nargs="+", help="Message to send to the agent") + parser.add_argument( + "message", + nargs="*", + help="Message to send to the agent (optional in interactive mode)", + ) parser.add_argument("--model", "-m", default="glm-5", help="Model to use") parser.add_argument("--api-key", "-k", default=None, help="API key") parser.add_argument("--base-url", "-u", default=None, help="API base URL") parser.add_argument("--interactive", "-i", action="store_true", help="Interactive mode") + return parser - args = parser.parse_args() + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse CLI arguments and enforce mode-specific requirements.""" + parser = _build_parser() + args = parser.parse_args(argv) + + if not args.interactive and not args.message: + parser.error("message is required unless --interactive is set") + + return args + + +def main(argv: list[str] | None = None): + """CLI main entry point.""" + args = parse_args(argv) api_key = args.api_key or os.environ.get("API_KEY") or os.environ.get("GLM_API_KEY") - base_url = args.base_url or os.environ.get("API_BASE", "https://api.z.ai/api/coding/paas/v4") + base_url = args.base_url or os.environ.get("API_BASE") if not api_key: print("Error: API key required. Set API_KEY env var or use --api-key", file=sys.stderr) diff --git a/python/zeroclaw_tools/agent.py b/python/zeroclaw_tools/agent.py index 35d0855..35e9ab2 100644 --- a/python/zeroclaw_tools/agent.py +++ b/python/zeroclaw_tools/agent.py @@ -3,7 +3,7 @@ LangGraph-based agent factory for consistent tool calling. """ import os -from typing import Any, Callable, Optional +from typing import Any, Optional from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.tools import BaseTool @@ -14,6 +14,7 @@ from langgraph.prebuilt import ToolNode SYSTEM_PROMPT = """You are ZeroClaw, an AI assistant with tool access. Use tools to accomplish tasks. Be concise and helpful. Execute tools directly when needed without excessive explanation.""" +GLM_DEFAULT_BASE_URL = "https://api.z.ai/api/coding/paas/v4" class ZeroclawAgent: @@ -40,7 +41,10 @@ class ZeroclawAgent: self.system_prompt = system_prompt or SYSTEM_PROMPT api_key = api_key or os.environ.get("API_KEY") or os.environ.get("GLM_API_KEY") - base_url = base_url or os.environ.get("API_BASE", "https://api.z.ai/api/coding/paas/v4") + base_url = base_url or os.environ.get("API_BASE") + + if base_url is None and model.lower().startswith(("glm", "zhipu")): + base_url = GLM_DEFAULT_BASE_URL if not api_key: raise ValueError( @@ -105,7 +109,15 @@ class ZeroclawAgent: """ import asyncio - return asyncio.run(self.ainvoke(input, config)) + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(self.ainvoke(input, config)) + + raise RuntimeError( + "ZeroclawAgent.invoke() cannot be called inside an active event loop. " + "Use 'await ZeroclawAgent.ainvoke(...)' instead." + ) def create_agent( diff --git a/python/zeroclaw_tools/integrations/__init__.py b/python/zeroclaw_tools/integrations/__init__.py index e26f400..ef58dbb 100644 --- a/python/zeroclaw_tools/integrations/__init__.py +++ b/python/zeroclaw_tools/integrations/__init__.py @@ -1,5 +1,5 @@ """ -Integrations for various platforms (Discord, Telegram, etc.) +Integrations for supported external platforms. """ from .discord_bot import DiscordBot diff --git a/python/zeroclaw_tools/integrations/discord_bot.py b/python/zeroclaw_tools/integrations/discord_bot.py index 45a9d7d..298f9f6 100644 --- a/python/zeroclaw_tools/integrations/discord_bot.py +++ b/python/zeroclaw_tools/integrations/discord_bot.py @@ -2,20 +2,18 @@ Discord bot integration for ZeroClaw. """ -import asyncio import os from typing import Optional, Set try: import discord - from discord.ext import commands DISCORD_AVAILABLE = True except ImportError: DISCORD_AVAILABLE = False discord = None -from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.messages import HumanMessage from ..agent import create_agent from ..tools import shell, file_read, file_write, web_search @@ -65,6 +63,18 @@ class DiscordBot: self.model = model self.prefix = prefix + if not self.api_key: + raise ValueError( + "API key required. Set API_KEY environment variable or pass api_key parameter." + ) + + self.agent = create_agent( + tools=[shell, file_read, file_write, web_search], + model=self.model, + api_key=self.api_key, + base_url=self.base_url, + ) + self._histories: dict[str, list] = {} self._max_history = 20 @@ -117,13 +127,6 @@ class DiscordBot: async def _process_message(self, content: str, user_id: str) -> str: """Process a message and return the response.""" - agent = create_agent( - tools=[shell, file_read, file_write, web_search], - model=self.model, - api_key=self.api_key, - base_url=self.base_url, - ) - messages = [] if user_id in self._histories: @@ -132,7 +135,7 @@ class DiscordBot: messages.append(HumanMessage(content=content)) - result = await agent.ainvoke({"messages": messages}) + result = await self.agent.ainvoke({"messages": messages}) if user_id not in self._histories: self._histories[user_id] = [] diff --git a/python/zeroclaw_tools/tools/base.py b/python/zeroclaw_tools/tools/base.py index e78a555..12fe337 100644 --- a/python/zeroclaw_tools/tools/base.py +++ b/python/zeroclaw_tools/tools/base.py @@ -38,9 +38,13 @@ def tool( ``` """ if func is not None: - return langchain_tool(func) + if name is not None: + return langchain_tool(name, func, description=description) + return langchain_tool(func, description=description) def decorator(f: Callable) -> Any: - return langchain_tool(f, name=name) + if name is not None: + return langchain_tool(name, f, description=description) + return langchain_tool(f, description=description) return decorator diff --git a/python/zeroclaw_tools/tools/memory.py b/python/zeroclaw_tools/tools/memory.py index ae4167d..f9586ce 100644 --- a/python/zeroclaw_tools/tools/memory.py +++ b/python/zeroclaw_tools/tools/memory.py @@ -3,7 +3,6 @@ Memory storage tools for persisting data between conversations. """ import json -import os from pathlib import Path from langchain_core.tools import tool @@ -20,7 +19,7 @@ def _load_memory() -> dict: if not path.exists(): return {} try: - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: return {} @@ -30,7 +29,7 @@ def _save_memory(data: dict) -> None: """Save memory to disk.""" path = _get_memory_path() path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2)