diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6fe2cfd..1d2b070 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -189,7 +189,8 @@ class AgentLoop: # Execute tools for tool_call in response.tool_calls: - logger.debug(f"Executing tool: {tool_call.name}") + args_str = json.dumps(tool_call.arguments) + logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}") result = await self.tools.execute(tool_call.name, tool_call.arguments) messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result @@ -281,7 +282,8 @@ class AgentLoop: ) for tool_call in response.tool_calls: - logger.debug(f"Executing tool: {tool_call.name}") + args_str = json.dumps(tool_call.arguments) + logger.debug(f"Executing tool: {tool_call.name} with arguments: {args_str}") result = await self.tools.execute(tool_call.name, tool_call.arguments) messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 8dcc460..a4053e7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -68,7 +68,7 @@ def onboard(): console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") - console.print(" Get one at: https://openrouter.ai/keys") + console.print(" Get one at: https://openrouter.ai/keys or https://bigmodel.cn/ (Zhipu AI)") console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]") console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") @@ -328,307 +328,74 @@ def agent( response = await agent_loop.process_direct(user_input, session_id) console.print(f"\n{__logo__} {response}\n") - except KeyboardInterrupt: - console.print("\nGoodbye!") + except (KeyboardInterrupt, EOFError): + console.print("\nExiting...") break asyncio.run(run_interactive()) # ============================================================================ -# Channel Commands -# ============================================================================ - - -channels_app = typer.Typer(help="Manage channels") -app.add_typer(channels_app, name="channels") - - -@channels_app.command("status") -def channels_status(): - """Show channel status.""" - from nanobot.config.loader import load_config - - config = load_config() - - table = Table(title="Channel Status") - table.add_column("Channel", style="cyan") - table.add_column("Enabled", style="green") - table.add_column("Bridge URL", style="yellow") - - wa = config.channels.whatsapp - table.add_row( - "WhatsApp", - "✓" if wa.enabled else "✗", - wa.bridge_url - ) - - console.print(table) - - -def _get_bridge_dir() -> Path: - """Get the bridge directory, setting it up if needed.""" - import shutil - import subprocess - - # User's bridge location - user_bridge = Path.home() / ".nanobot" / "bridge" - - # Check if already built - if (user_bridge / "dist" / "index.js").exists(): - return user_bridge - - # Check for npm - if not shutil.which("npm"): - console.print("[red]npm not found. Please install Node.js >= 18.[/red]") - raise typer.Exit(1) - - # Find source bridge: first check package data, then source dir - pkg_bridge = Path(__file__).parent / "bridge" # nanobot/bridge (installed) - src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) - - source = None - if (pkg_bridge / "package.json").exists(): - source = pkg_bridge - elif (src_bridge / "package.json").exists(): - source = src_bridge - - if not source: - console.print("[red]Bridge source not found.[/red]") - console.print("Try reinstalling: pip install --force-reinstall nanobot") - raise typer.Exit(1) - - console.print(f"{__logo__} Setting up bridge...") - - # Copy to user directory - user_bridge.parent.mkdir(parents=True, exist_ok=True) - if user_bridge.exists(): - shutil.rmtree(user_bridge) - shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) - - # Install and build - try: - console.print(" Installing dependencies...") - subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) - - console.print(" Building...") - subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) - - console.print("[green]✓[/green] Bridge ready\n") - except subprocess.CalledProcessError as e: - console.print(f"[red]Build failed: {e}[/red]") - if e.stderr: - console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") - raise typer.Exit(1) - - return user_bridge - - -@channels_app.command("login") -def channels_login(): - """Link device via QR code.""" - import subprocess - - bridge_dir = _get_bridge_dir() - - console.print(f"{__logo__} Starting bridge...") - console.print("Scan the QR code to connect.\n") - - try: - subprocess.run(["npm", "start"], cwd=bridge_dir, check=True) - except subprocess.CalledProcessError as e: - console.print(f"[red]Bridge failed: {e}[/red]") - except FileNotFoundError: - console.print("[red]npm not found. Please install Node.js.[/red]") - - -# ============================================================================ -# Cron Commands -# ============================================================================ - -cron_app = typer.Typer(help="Manage scheduled tasks") -app.add_typer(cron_app, name="cron") - - -@cron_app.command("list") -def cron_list( - all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"), -): - """List scheduled jobs.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - jobs = service.list_jobs(include_disabled=all) - - if not jobs: - console.print("No scheduled jobs.") - return - - table = Table(title="Scheduled Jobs") - table.add_column("ID", style="cyan") - table.add_column("Name") - table.add_column("Schedule") - table.add_column("Status") - table.add_column("Next Run") - - import time - for job in jobs: - # Format schedule - if job.schedule.kind == "every": - sched = f"every {(job.schedule.every_ms or 0) // 1000}s" - elif job.schedule.kind == "cron": - sched = job.schedule.expr or "" - else: - sched = "one-time" - - # Format next run - next_run = "" - if job.state.next_run_at_ms: - next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000)) - next_run = next_time - - status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" - - table.add_row(job.id, job.name, sched, status, next_run) - - console.print(table) - - -@cron_app.command("add") -def cron_add( - name: str = typer.Option(..., "--name", "-n", help="Job name"), - message: str = typer.Option(..., "--message", "-m", help="Message for agent"), - every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), - cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"), - at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), - deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), - to: str = typer.Option(None, "--to", help="Recipient for delivery"), -): - """Add a scheduled job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - from nanobot.cron.types import CronSchedule - - # Determine schedule type - if every: - schedule = CronSchedule(kind="every", every_ms=every * 1000) - elif cron_expr: - schedule = CronSchedule(kind="cron", expr=cron_expr) - elif at: - import datetime - dt = datetime.datetime.fromisoformat(at) - schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) - else: - console.print("[red]Error: Must specify --every, --cron, or --at[/red]") - raise typer.Exit(1) - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - job = service.add_job( - name=name, - schedule=schedule, - message=message, - deliver=deliver, - to=to, - ) - - console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") - - -@cron_app.command("remove") -def cron_remove( - job_id: str = typer.Argument(..., help="Job ID to remove"), -): - """Remove a scheduled job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - if service.remove_job(job_id): - console.print(f"[green]✓[/green] Removed job {job_id}") - else: - console.print(f"[red]Job {job_id} not found[/red]") - - -@cron_app.command("enable") -def cron_enable( - job_id: str = typer.Argument(..., help="Job ID"), - disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"), -): - """Enable or disable a job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - job = service.enable_job(job_id, enabled=not disable) - if job: - status = "disabled" if disable else "enabled" - console.print(f"[green]✓[/green] Job '{job.name}' {status}") - else: - console.print(f"[red]Job {job_id} not found[/red]") - - -@cron_app.command("run") -def cron_run( - job_id: str = typer.Argument(..., help="Job ID to run"), - force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"), -): - """Manually run a job.""" - from nanobot.config.loader import get_data_dir - from nanobot.cron.service import CronService - - store_path = get_data_dir() / "cron" / "jobs.json" - service = CronService(store_path) - - async def run(): - return await service.run_job(job_id, force=force) - - if asyncio.run(run()): - console.print(f"[green]✓[/green] Job executed") - else: - console.print(f"[red]Failed to run job {job_id}[/red]") - - -# ============================================================================ -# Status Commands +# System Commands # ============================================================================ @app.command() def status(): - """Show nanobot status.""" + """Check nanobot status and configuration.""" from nanobot.config.loader import load_config, get_config_path - from nanobot.utils.helpers import get_workspace_path config_path = get_config_path() - workspace = get_workspace_path() + if not config_path.exists(): + console.print("[red]Error: nanobot is not initialized.[/red]") + console.print("Run [cyan]nanobot onboard[/cyan] first.") + raise typer.Exit(1) - console.print(f"{__logo__} nanobot Status\n") + config = load_config() - console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}") - console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}") + console.print(f"{__logo__} [bold]nanobot status[/bold]") + console.print(f"Version: {__version__}") + console.print(f"Config: {config_path}") + console.print(f"Workspace: {config.workspace_path}") - if config_path.exists(): - config = load_config() + table = Table(title="Configuration Summary") + table.add_column("Category", style="cyan") + table.add_column("Status", style="green") + + # Channels + enabled_channels = [] + if config.channels.whatsapp.enabled: + enabled_channels.append("WhatsApp") + if config.channels.telegram.enabled: + enabled_channels.append("Telegram") + + table.add_row("Channels", ", ".join(enabled_channels) if enabled_channels else "[dim]none[/dim]") + + # Agent + table.add_row("Default Model", config.agents.defaults.model) + + # Tools + has_brave = bool(config.tools.web.search.api_key) + table.add_row("Web Search", "[green]enabled[/green]" if has_brave else "[dim]disabled[/dim]") + + console.print(table) + + # Detailed API check + with console.status("[bold blue]Checking API providers..."): + console.print(f"\n[bold]API Providers:[/bold]") console.print(f"Model: {config.agents.defaults.model}") # Check API keys has_openrouter = bool(config.providers.openrouter.api_key) has_anthropic = bool(config.providers.anthropic.api_key) has_openai = bool(config.providers.openai.api_key) + has_zhipu = bool(config.providers.zhipu.api_key) has_vllm = bool(config.providers.vllm.api_base) console.print(f"OpenRouter API: {'[green]✓[/green]' if has_openrouter else '[dim]not set[/dim]'}") console.print(f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}") console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}") + console.print(f"Zhipu AI API: {'[green]✓[/green]' if has_zhipu else '[dim]not set[/dim]'}") vllm_status = f"[green]✓ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]" console.print(f"vLLM/Local: {vllm_status}") diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index e30fbb2..df0fc5e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -28,7 +28,7 @@ class ChannelsConfig(BaseModel): class AgentDefaults(BaseModel): """Default agent configuration.""" workspace: str = "~/.nanobot/workspace" - model: str = "anthropic/claude-opus-4-5" + model: str = "glm-4.7-flash" max_tokens: int = 8192 temperature: float = 0.7 max_tool_iterations: int = 20 @@ -50,6 +50,7 @@ class ProvidersConfig(BaseModel): anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) + zhipu: ProviderConfig = Field(default_factory=ProviderConfig) vllm: ProviderConfig = Field(default_factory=ProviderConfig) @@ -89,19 +90,22 @@ class Config(BaseSettings): return Path(self.agents.defaults.workspace).expanduser() def get_api_key(self) -> str | None: - """Get API key in priority order: OpenRouter > Anthropic > OpenAI > vLLM.""" + """Get API key in priority order: OpenRouter > Anthropic > OpenAI > Zhipu > vLLM.""" return ( self.providers.openrouter.api_key or self.providers.anthropic.api_key or self.providers.openai.api_key or + self.providers.zhipu.api_key or self.providers.vllm.api_key or None ) def get_api_base(self) -> str | None: - """Get API base URL if using OpenRouter or vLLM.""" + """Get API base URL if using OpenRouter, Zhipu or vLLM.""" if self.providers.openrouter.api_key: return self.providers.openrouter.api_base or "https://openrouter.ai/api/v1" + if self.providers.zhipu.api_key: + return self.providers.zhipu.api_base if self.providers.vllm.api_base: return self.providers.vllm.api_base return None diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 4e7305b..07e84cd 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -21,7 +21,7 @@ class LiteLLMProvider(LLMProvider): self, api_key: str | None = None, api_base: str | None = None, - default_model: str = "anthropic/claude-opus-4-5" + default_model: str = "glm-4.7-flash" ): super().__init__(api_key, api_base) self.default_model = default_model @@ -47,6 +47,8 @@ class LiteLLMProvider(LLMProvider): os.environ.setdefault("ANTHROPIC_API_KEY", api_key) elif "openai" in default_model or "gpt" in default_model: os.environ.setdefault("OPENAI_API_KEY", api_key) + elif "zhipu" in default_model or "glm" in default_model or "zai" in default_model: + os.environ.setdefault("ZHIPUAI_API_KEY", api_key) if api_base: litellm.api_base = api_base @@ -81,6 +83,15 @@ class LiteLLMProvider(LLMProvider): if self.is_openrouter and not model.startswith("openrouter/"): model = f"openrouter/{model}" + # For Zhipu/Z.ai, ensure prefix is present + # Handle cases like "glm-4.7-flash" -> "zhipu/glm-4.7-flash" + if ("glm" in model.lower() or "zhipu" in model.lower()) and not ( + model.startswith("zhipu/") or + model.startswith("zai/") or + model.startswith("openrouter/") + ): + model = f"zhipu/{model}" + # For vLLM, use hosted_vllm/ prefix per LiteLLM docs # Convert openai/ prefix to hosted_vllm/ if user specified it if self.is_vllm: