fix(python): harden zeroclaw-tools CLI and integration ergonomics

This commit is contained in:
Chummy 2026-02-17 15:49:01 +08:00
parent e5ef8a3b62
commit f01d38be35
10 changed files with 110 additions and 27 deletions

View file

@ -447,7 +447,7 @@ print(result["messages"][-1].content)
- **Consistent tool calling** across all providers (even those with poor native support) - **Consistent tool calling** across all providers (even those with poor native support)
- **Automatic tool loop** — keeps calling tools until the task is complete - **Automatic tool loop** — keeps calling tools until the task is complete
- **Easy extensibility** — add custom tools with `@tool` decorator - **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. See [`python/README.md`](python/README.md) for full documentation.

View file

@ -60,6 +60,9 @@ export API_BASE="https://api.z.ai/api/coding/paas/v4"
# Run the CLI # Run the CLI
zeroclaw-tools "List files in the current directory" zeroclaw-tools "List files in the current directory"
# Interactive mode (no message required)
zeroclaw-tools -i
``` ```
### Discord Bot ### Discord Bot

View file

@ -64,3 +64,4 @@ target-version = "py310"
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

View file

@ -27,6 +27,18 @@ def test_import_tool_decorator():
assert hasattr(test_func, "invoke") 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(): def test_agent_creation():
"""Test that agent can be created with default tools.""" """Test that agent can be created with default tools."""
from zeroclaw_tools import create_agent, shell, file_read, file_write from zeroclaw_tools import create_agent, shell, file_read, file_write
@ -39,6 +51,35 @@ def test_agent_creation():
assert agent.model == "test-model" 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 @pytest.mark.asyncio
async def test_shell_tool(): async def test_shell_tool():
"""Test shell tool execution.""" """Test shell tool execution."""

View file

@ -6,6 +6,7 @@ import argparse
import asyncio import asyncio
import os import os
import sys import sys
from typing import Optional
from langchain_core.messages import HumanMessage 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.""" 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.""" """Run a single chat message through the agent."""
agent = create_agent( agent = create_agent(
tools=[shell, file_read, file_write, web_search, http_request, memory_store, memory_recall], 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." return result["messages"][-1].content or "Done."
def main(): def _build_parser() -> argparse.ArgumentParser:
"""CLI main entry point.""" """Build CLI argument parser."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="ZeroClaw Tools - LangGraph-based tool calling for LLMs" 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("--model", "-m", default="glm-5", help="Model to use")
parser.add_argument("--api-key", "-k", default=None, help="API key") 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("--base-url", "-u", default=None, help="API base URL")
parser.add_argument("--interactive", "-i", action="store_true", help="Interactive mode") 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") 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: if not api_key:
print("Error: API key required. Set API_KEY env var or use --api-key", file=sys.stderr) print("Error: API key required. Set API_KEY env var or use --api-key", file=sys.stderr)

View file

@ -3,7 +3,7 @@ LangGraph-based agent factory for consistent tool calling.
""" """
import os import os
from typing import Any, Callable, Optional from typing import Any, Optional
from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.tools import BaseTool 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. 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.""" 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: class ZeroclawAgent:
@ -40,7 +41,10 @@ class ZeroclawAgent:
self.system_prompt = system_prompt or SYSTEM_PROMPT 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") 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: if not api_key:
raise ValueError( raise ValueError(
@ -105,8 +109,16 @@ class ZeroclawAgent:
""" """
import asyncio import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(self.ainvoke(input, config)) 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( def create_agent(
tools: Optional[list[BaseTool]] = None, tools: Optional[list[BaseTool]] = None,

View file

@ -1,5 +1,5 @@
""" """
Integrations for various platforms (Discord, Telegram, etc.) Integrations for supported external platforms.
""" """
from .discord_bot import DiscordBot from .discord_bot import DiscordBot

View file

@ -2,20 +2,18 @@
Discord bot integration for ZeroClaw. Discord bot integration for ZeroClaw.
""" """
import asyncio
import os import os
from typing import Optional, Set from typing import Optional, Set
try: try:
import discord import discord
from discord.ext import commands
DISCORD_AVAILABLE = True DISCORD_AVAILABLE = True
except ImportError: except ImportError:
DISCORD_AVAILABLE = False DISCORD_AVAILABLE = False
discord = None discord = None
from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.messages import HumanMessage
from ..agent import create_agent from ..agent import create_agent
from ..tools import shell, file_read, file_write, web_search from ..tools import shell, file_read, file_write, web_search
@ -65,6 +63,18 @@ class DiscordBot:
self.model = model self.model = model
self.prefix = prefix 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._histories: dict[str, list] = {}
self._max_history = 20 self._max_history = 20
@ -117,13 +127,6 @@ class DiscordBot:
async def _process_message(self, content: str, user_id: str) -> str: async def _process_message(self, content: str, user_id: str) -> str:
"""Process a message and return the response.""" """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 = [] messages = []
if user_id in self._histories: if user_id in self._histories:
@ -132,7 +135,7 @@ class DiscordBot:
messages.append(HumanMessage(content=content)) 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: if user_id not in self._histories:
self._histories[user_id] = [] self._histories[user_id] = []

View file

@ -38,9 +38,13 @@ def tool(
``` ```
""" """
if func is not None: 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: 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 return decorator

View file

@ -3,7 +3,6 @@ Memory storage tools for persisting data between conversations.
""" """
import json import json
import os
from pathlib import Path from pathlib import Path
from langchain_core.tools import tool from langchain_core.tools import tool
@ -20,7 +19,7 @@ def _load_memory() -> dict:
if not path.exists(): if not path.exists():
return {} return {}
try: try:
with open(path, "r") as f: with open(path, "r", encoding="utf-8") as f:
return json.load(f) return json.load(f)
except Exception: except Exception:
return {} return {}
@ -30,7 +29,7 @@ def _save_memory(data: dict) -> None:
"""Save memory to disk.""" """Save memory to disk."""
path = _get_memory_path() path = _get_memory_path()
path.parent.mkdir(parents=True, exist_ok=True) 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) json.dump(data, f, indent=2)