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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -64,3 +64,4 @@ target-version = "py310"
|
|||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Integrations for various platforms (Discord, Telegram, etc.)
|
||||
Integrations for supported external platforms.
|
||||
"""
|
||||
|
||||
from .discord_bot import DiscordBot
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue