fix(python): harden zeroclaw-tools CLI and integration ergonomics
This commit is contained in:
parent
e5ef8a3b62
commit
f01d38be35
10 changed files with 110 additions and 27 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,7 +109,15 @@ class ZeroclawAgent:
|
||||||
"""
|
"""
|
||||||
import asyncio
|
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(
|
def create_agent(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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] = []
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue