mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 16:51:53 +03:00
445 lines
18 KiB
Python
445 lines
18 KiB
Python
"""Direct and interactive agent CLI command."""
|
|
|
|
import asyncio
|
|
import importlib
|
|
import signal
|
|
import sys
|
|
from collections.abc import Awaitable, Callable
|
|
from types import FrameType
|
|
from typing import Any
|
|
|
|
import typer
|
|
from rich.console import Console
|
|
|
|
from nanobot import __logo__
|
|
from nanobot.cli.log_control import _set_nanobot_logs
|
|
from nanobot.cli.runtime_config import (
|
|
_load_runtime_config,
|
|
_migrate_cron_store,
|
|
_model_display,
|
|
_print_agent_start_error,
|
|
)
|
|
|
|
console = Console()
|
|
|
|
_CLASSIC_DEPENDENCIES = {
|
|
"AgentLoop": ("nanobot.agent.loop", "AgentLoop"),
|
|
"StreamRenderer": ("nanobot.cli.stream", "StreamRenderer"),
|
|
"consume_restart_notice_from_env": (
|
|
"nanobot.utils.restart",
|
|
"consume_restart_notice_from_env",
|
|
),
|
|
"is_default_workspace": ("nanobot.config.paths", "is_default_workspace"),
|
|
"sync_workspace_templates": ("nanobot.utils.helpers", "sync_workspace_templates"),
|
|
}
|
|
|
|
|
|
def __getattr__(name: str) -> Any:
|
|
"""Preserve patchable classic-agent symbols without loading them for the TUI."""
|
|
dependency = _CLASSIC_DEPENDENCIES.get(name)
|
|
if dependency is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
module_name, attribute = dependency
|
|
value = getattr(importlib.import_module(module_name), attribute)
|
|
globals()[name] = value
|
|
return value
|
|
|
|
|
|
def _classic_dependency(name: str) -> Any:
|
|
if name in globals():
|
|
return globals()[name]
|
|
return __getattr__(name)
|
|
|
|
|
|
def agent(
|
|
message: str | None = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
|
|
session_id: str | None = typer.Option(None, "--session", "-s", help="Session ID"),
|
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
|
markdown: bool = typer.Option(
|
|
True,
|
|
"--markdown/--no-markdown",
|
|
help="Render assistant output as Markdown",
|
|
),
|
|
logs: bool = typer.Option(
|
|
False,
|
|
"--logs/--no-logs",
|
|
help="Show nanobot runtime logs during chat",
|
|
),
|
|
classic: bool = typer.Option(
|
|
False,
|
|
"--classic",
|
|
"--no-tui",
|
|
help="Use the classic Python prompt instead of the native terminal UI",
|
|
),
|
|
theme: str = typer.Option(
|
|
"auto",
|
|
"--theme",
|
|
help="Terminal UI appearance: auto, dark, or light",
|
|
),
|
|
):
|
|
"""Chat in the terminal or send one message non-interactively."""
|
|
runtime_config = _load_runtime_config(config, workspace)
|
|
theme = theme.strip().lower()
|
|
if theme not in {"auto", "dark", "light"}:
|
|
raise typer.BadParameter("must be auto, dark, or light", param_hint="--theme")
|
|
native_tui = message is None and not classic
|
|
if native_tui:
|
|
from nanobot.cli.tui_launcher import TuiSessionError, TuiUnavailableError, launch_tui
|
|
from nanobot.config.loader import get_config_path
|
|
|
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
raise typer.BadParameter(
|
|
"the native TUI requires an interactive terminal; use --message for "
|
|
"one-shot input or --classic for the legacy prompt",
|
|
param_hint="terminal",
|
|
)
|
|
if not markdown:
|
|
raise typer.BadParameter("--no-markdown requires --classic", param_hint="--no-markdown")
|
|
if logs:
|
|
raise typer.BadParameter("--logs requires --classic", param_hint="--logs")
|
|
try:
|
|
exit_code = launch_tui(
|
|
runtime_config,
|
|
config_path=get_config_path().resolve(strict=False),
|
|
workspace_override=workspace,
|
|
session_id=session_id,
|
|
theme=theme,
|
|
)
|
|
except TuiSessionError as exc:
|
|
raise typer.BadParameter(str(exc), param_hint="--session") from exc
|
|
except TuiUnavailableError as exc:
|
|
console.print(f"[red]Native TUI unavailable: {exc}[/red]")
|
|
console.print("[dim]Use `nanobot agent --classic` only if you want the old prompt.[/dim]")
|
|
raise typer.Exit(1) from exc
|
|
else:
|
|
if exit_code:
|
|
raise typer.Exit(exit_code)
|
|
return
|
|
|
|
from nanobot.agent.hooks import create_file_edit_activity_hook
|
|
from nanobot.agent.tools.mcp import MCPProvider
|
|
from nanobot.agent.tools.registry import ToolRegistry
|
|
from nanobot.bus.outbound_events import (
|
|
StreamDeltaEvent,
|
|
StreamedResponseEvent,
|
|
StreamEndEvent,
|
|
outbound_event_from_message,
|
|
)
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.cli import terminal as cli_terminal
|
|
from nanobot.cli.stream import ThinkingSpinner
|
|
from nanobot.cron.service import CronService
|
|
from nanobot.providers.factory import make_provider
|
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
|
from nanobot.utils.helpers import sanitize_surrogates as _sanitize_surrogates
|
|
from nanobot.utils.restart import (
|
|
format_restart_completed_message,
|
|
should_show_cli_restart_notice,
|
|
)
|
|
|
|
agent_loop_class = _classic_dependency("AgentLoop")
|
|
stream_renderer_class = _classic_dependency("StreamRenderer")
|
|
consume_restart_notice_from_env = _classic_dependency("consume_restart_notice_from_env")
|
|
is_default_workspace = _classic_dependency("is_default_workspace")
|
|
sync_workspace_templates = _classic_dependency("sync_workspace_templates")
|
|
|
|
session_id = session_id or "cli:direct"
|
|
|
|
try:
|
|
provider = make_provider(runtime_config)
|
|
except ValueError as exc:
|
|
_print_agent_start_error(exc)
|
|
raise typer.Exit(1) from exc
|
|
|
|
sync_workspace_templates(runtime_config.workspace_path)
|
|
|
|
bus = MessageBus()
|
|
|
|
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
|
if is_default_workspace(runtime_config.workspace_path):
|
|
_migrate_cron_store(runtime_config)
|
|
|
|
# Create cron service with workspace-scoped store
|
|
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
|
|
cron = CronService(cron_store_path)
|
|
tools = ToolRegistry()
|
|
mcp_provider = MCPProvider.from_config(runtime_config, tools)
|
|
|
|
_set_nanobot_logs(logs)
|
|
|
|
try:
|
|
agent_loop = agent_loop_class.from_config(
|
|
runtime_config,
|
|
bus,
|
|
provider=provider,
|
|
cron_service=cron,
|
|
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
|
hook_factories=[create_file_edit_activity_hook],
|
|
tool_registry=tools,
|
|
)
|
|
except ValueError as exc:
|
|
_print_agent_start_error(exc)
|
|
raise typer.Exit(1) from exc
|
|
restart_notice = consume_restart_notice_from_env()
|
|
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
|
cli_terminal._print_agent_response(
|
|
format_restart_completed_message(restart_notice.started_at_raw),
|
|
render_markdown=False,
|
|
)
|
|
|
|
async def _close_runtime() -> None:
|
|
try:
|
|
await agent_loop.aclose()
|
|
finally:
|
|
await mcp_provider.aclose()
|
|
|
|
# Shared reference for progress callbacks
|
|
_thinking: ThinkingSpinner | None = None
|
|
|
|
def _make_progress(
|
|
renderer: Any | None = None,
|
|
) -> Callable[..., Awaitable[None]]:
|
|
reasoning_buffer = cli_terminal._ReasoningBuffer()
|
|
|
|
async def _cli_progress(
|
|
content: str,
|
|
*,
|
|
tool_hint: bool = False,
|
|
reasoning: bool = False,
|
|
**_kwargs: Any,
|
|
) -> None:
|
|
ch = agent_loop.channels_config
|
|
|
|
if _kwargs.get("reasoning_end"):
|
|
if ch and not ch.show_reasoning:
|
|
reasoning_buffer.clear()
|
|
else:
|
|
cli_terminal._flush_cli_reasoning(reasoning_buffer, _thinking, renderer)
|
|
return
|
|
|
|
if reasoning:
|
|
if ch and not ch.show_reasoning:
|
|
reasoning_buffer.clear()
|
|
return
|
|
text = reasoning_buffer.add(content)
|
|
if text:
|
|
cli_terminal._print_cli_reasoning(text, _thinking, renderer)
|
|
return
|
|
if ch and tool_hint and not ch.send_tool_hints:
|
|
return
|
|
if ch and not tool_hint and not ch.send_progress:
|
|
return
|
|
cli_terminal._print_cli_progress_line(content, _thinking, renderer)
|
|
|
|
return _cli_progress
|
|
|
|
if message is not None:
|
|
# Single message mode — direct call, no bus needed
|
|
async def run_once() -> None:
|
|
try:
|
|
await mcp_provider.connect()
|
|
renderer = stream_renderer_class(
|
|
render_markdown=markdown,
|
|
bot_name=runtime_config.agents.defaults.bot_name,
|
|
bot_icon=runtime_config.agents.defaults.bot_icon,
|
|
)
|
|
response = await agent_loop.process_direct(
|
|
message,
|
|
session_id,
|
|
on_progress=_make_progress(renderer),
|
|
on_stream=renderer.on_delta,
|
|
on_stream_end=renderer.on_end,
|
|
)
|
|
if not renderer.streamed:
|
|
await renderer.close()
|
|
print_kwargs: dict[str, Any] = {}
|
|
if renderer.header_printed:
|
|
print_kwargs["show_header"] = False
|
|
cli_terminal._print_agent_response(
|
|
response.content if response else "",
|
|
render_markdown=markdown,
|
|
metadata=response.metadata if response else None,
|
|
**print_kwargs,
|
|
)
|
|
finally:
|
|
await _close_runtime()
|
|
|
|
asyncio.run(run_once())
|
|
else:
|
|
# Interactive mode — route through bus like other channels
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
cli_terminal._init_prompt_session()
|
|
_model, _preset_tag = _model_display(runtime_config)
|
|
_icon = runtime_config.agents.defaults.bot_icon or __logo__
|
|
console.print(
|
|
f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} "
|
|
"— type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n"
|
|
)
|
|
|
|
if ":" in session_id:
|
|
cli_channel, cli_chat_id = session_id.split(":", 1)
|
|
else:
|
|
cli_channel, cli_chat_id = "cli", session_id
|
|
|
|
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
|
|
sig_name = signal.Signals(signum).name
|
|
cli_terminal._restore_terminal()
|
|
console.print(f"\nReceived {sig_name}, goodbye!")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, _handle_signal)
|
|
signal.signal(signal.SIGTERM, _handle_signal)
|
|
# SIGHUP is not available on Windows
|
|
if hasattr(signal, "SIGHUP"):
|
|
signal.signal(signal.SIGHUP, _handle_signal)
|
|
# Ignore SIGPIPE to prevent silent process termination when writing to closed pipes
|
|
# SIGPIPE is not available on Windows
|
|
if hasattr(signal, "SIGPIPE"):
|
|
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
|
|
|
async def run_interactive() -> None:
|
|
await mcp_provider.connect()
|
|
bus_task = asyncio.create_task(agent_loop.run())
|
|
turn_done = asyncio.Event()
|
|
turn_done.set()
|
|
turn_response: list[Any] = []
|
|
renderer: Any | None = None
|
|
reasoning_buffer = cli_terminal._ReasoningBuffer()
|
|
|
|
async def _consume_outbound() -> None:
|
|
while True:
|
|
try:
|
|
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
|
event = outbound_event_from_message(msg)
|
|
|
|
if isinstance(event, StreamDeltaEvent):
|
|
if renderer:
|
|
await renderer.on_delta(msg.content)
|
|
continue
|
|
if isinstance(event, StreamEndEvent):
|
|
if renderer:
|
|
await renderer.on_end(
|
|
resuming=event.resuming,
|
|
)
|
|
continue
|
|
if isinstance(event, StreamedResponseEvent):
|
|
if msg.content and renderer and not renderer.streamed:
|
|
await renderer.close()
|
|
print_kwargs: dict[str, Any] = {}
|
|
if renderer.header_printed:
|
|
print_kwargs["show_header"] = False
|
|
cli_terminal._print_agent_response(
|
|
msg.content,
|
|
render_markdown=markdown,
|
|
metadata=msg.metadata,
|
|
**print_kwargs,
|
|
)
|
|
turn_done.set()
|
|
continue
|
|
|
|
if await cli_terminal._maybe_print_interactive_progress(
|
|
msg,
|
|
None,
|
|
agent_loop.channels_config,
|
|
renderer,
|
|
reasoning_buffer,
|
|
):
|
|
continue
|
|
|
|
if not turn_done.is_set():
|
|
if msg.content:
|
|
turn_response.append(msg)
|
|
turn_done.set()
|
|
elif msg.content:
|
|
await cli_terminal._print_interactive_response(
|
|
msg.content,
|
|
render_markdown=markdown,
|
|
metadata=msg.metadata,
|
|
)
|
|
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
except asyncio.CancelledError:
|
|
break
|
|
|
|
outbound_task = asyncio.create_task(_consume_outbound())
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
cli_terminal._flush_pending_tty_input()
|
|
# Stop spinner before user input to avoid prompt_toolkit conflicts
|
|
if renderer:
|
|
renderer.stop_for_input()
|
|
user_input = _sanitize_surrogates(
|
|
await cli_terminal._read_interactive_input_async()
|
|
)
|
|
command = user_input.strip()
|
|
if not command:
|
|
continue
|
|
|
|
if cli_terminal._is_exit_command(command):
|
|
cli_terminal._restore_terminal()
|
|
console.print("\nGoodbye!")
|
|
break
|
|
|
|
turn_done.clear()
|
|
turn_response.clear()
|
|
reasoning_buffer.clear()
|
|
renderer = stream_renderer_class(
|
|
render_markdown=markdown,
|
|
bot_name=runtime_config.agents.defaults.bot_name,
|
|
bot_icon=runtime_config.agents.defaults.bot_icon,
|
|
)
|
|
|
|
await bus.publish_inbound(
|
|
InboundMessage(
|
|
channel=cli_channel,
|
|
sender_id="user",
|
|
chat_id=cli_chat_id,
|
|
content=user_input,
|
|
metadata={"_wants_stream": True},
|
|
)
|
|
)
|
|
|
|
await turn_done.wait()
|
|
|
|
if turn_response:
|
|
response_msg = turn_response[0]
|
|
content = response_msg.content
|
|
meta = response_msg.metadata
|
|
if content and not isinstance(
|
|
response_msg.event,
|
|
StreamedResponseEvent,
|
|
):
|
|
if renderer:
|
|
await renderer.close()
|
|
print_kwargs: dict[str, Any] = {}
|
|
if renderer and renderer.header_printed:
|
|
print_kwargs["show_header"] = False
|
|
cli_terminal._print_agent_response(
|
|
content,
|
|
render_markdown=markdown,
|
|
metadata=meta,
|
|
**print_kwargs,
|
|
)
|
|
elif renderer and not renderer.streamed:
|
|
await renderer.close()
|
|
except KeyboardInterrupt:
|
|
cli_terminal._restore_terminal()
|
|
console.print("\nGoodbye!")
|
|
break
|
|
except EOFError:
|
|
cli_terminal._restore_terminal()
|
|
console.print("\nGoodbye!")
|
|
break
|
|
finally:
|
|
agent_loop.stop()
|
|
outbound_task.cancel()
|
|
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
|
await _close_runtime()
|
|
|
|
asyncio.run(run_interactive())
|