Merge remote-tracking branch 'origin/main' into pr-2449
Made-with: Cursor # Conflicts: # nanobot/utils/evaluator.py
This commit is contained in:
+171
-30
@@ -21,6 +21,7 @@ if sys.platform == "win32":
|
||||
pass
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.application import run_in_terminal
|
||||
from prompt_toolkit.formatted_text import ANSI, HTML
|
||||
@@ -36,6 +37,11 @@ from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
||||
from nanobot.config.paths import get_workspace_path, is_default_workspace
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
from nanobot.utils.restart import (
|
||||
consume_restart_notice_from_env,
|
||||
format_restart_completed_message,
|
||||
should_show_cli_restart_notice,
|
||||
)
|
||||
|
||||
app = typer.Typer(
|
||||
name="nanobot",
|
||||
@@ -426,6 +432,9 @@ def _make_provider(config: Config):
|
||||
api_base=p.api_base,
|
||||
default_model=model,
|
||||
)
|
||||
elif backend == "github_copilot":
|
||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
||||
provider = GitHubCopilotProvider(default_model=model)
|
||||
elif backend == "anthropic":
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
@@ -457,7 +466,7 @@ def _make_provider(config: Config):
|
||||
|
||||
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
||||
"""Load config and optionally override the active workspace."""
|
||||
from nanobot.config.loader import load_config, set_config_path
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
|
||||
|
||||
config_path = None
|
||||
if config:
|
||||
@@ -468,7 +477,11 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
|
||||
set_config_path(config_path)
|
||||
console.print(f"[dim]Using config: {config_path}[/dim]")
|
||||
|
||||
loaded = load_config(config_path)
|
||||
try:
|
||||
loaded = resolve_config_env_vars(load_config(config_path))
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
_warn_deprecated_config_keys(config_path)
|
||||
if workspace:
|
||||
loaded.agents.defaults.workspace = workspace
|
||||
@@ -506,6 +519,93 @@ def _migrate_cron_store(config: "Config") -> None:
|
||||
shutil.move(str(legacy_path), str(new_path))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OpenAI-Compatible API Server
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
port: int | None = typer.Option(None, "--port", "-p", help="API server port"),
|
||||
host: str | None = typer.Option(None, "--host", "-H", help="Bind address"),
|
||||
timeout: float | None = typer.Option(None, "--timeout", "-t", help="Per-request timeout (seconds)"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show nanobot runtime logs"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
):
|
||||
"""Start the OpenAI-compatible API server (/v1/chat/completions)."""
|
||||
try:
|
||||
from aiohttp import web # noqa: F401
|
||||
except ImportError:
|
||||
console.print("[red]aiohttp is required. Install with: pip install 'nanobot-ai[api]'[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
from loguru import logger
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.api.server import create_app
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
if verbose:
|
||||
logger.enable("nanobot")
|
||||
else:
|
||||
logger.disable("nanobot")
|
||||
|
||||
runtime_config = _load_runtime_config(config, workspace)
|
||||
api_cfg = runtime_config.api
|
||||
host = host if host is not None else api_cfg.host
|
||||
port = port if port is not None else api_cfg.port
|
||||
timeout = timeout if timeout is not None else api_cfg.timeout
|
||||
sync_workspace_templates(runtime_config.workspace_path)
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(runtime_config)
|
||||
session_manager = SessionManager(runtime_config.workspace_path)
|
||||
agent_loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=runtime_config.workspace_path,
|
||||
model=runtime_config.agents.defaults.model,
|
||||
max_iterations=runtime_config.agents.defaults.max_tool_iterations,
|
||||
context_window_tokens=runtime_config.agents.defaults.context_window_tokens,
|
||||
context_block_limit=runtime_config.agents.defaults.context_block_limit,
|
||||
max_tool_result_chars=runtime_config.agents.defaults.max_tool_result_chars,
|
||||
provider_retry_mode=runtime_config.agents.defaults.provider_retry_mode,
|
||||
web_config=runtime_config.tools.web,
|
||||
exec_config=runtime_config.tools.exec,
|
||||
restrict_to_workspace=runtime_config.tools.restrict_to_workspace,
|
||||
session_manager=session_manager,
|
||||
mcp_servers=runtime_config.tools.mcp_servers,
|
||||
channels_config=runtime_config.channels,
|
||||
timezone=runtime_config.agents.defaults.timezone,
|
||||
)
|
||||
|
||||
model_name = runtime_config.agents.defaults.model
|
||||
console.print(f"{__logo__} Starting OpenAI-compatible API server")
|
||||
console.print(f" [cyan]Endpoint[/cyan] : http://{host}:{port}/v1/chat/completions")
|
||||
console.print(f" [cyan]Model[/cyan] : {model_name}")
|
||||
console.print(" [cyan]Session[/cyan] : api:default")
|
||||
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
|
||||
if host in {"0.0.0.0", "::"}:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] API is bound to all interfaces. "
|
||||
"Only do this behind a trusted network boundary, firewall, or reverse proxy."
|
||||
)
|
||||
console.print()
|
||||
|
||||
api_app = create_app(agent_loop, model_name=model_name, request_timeout=timeout)
|
||||
|
||||
async def on_startup(_app):
|
||||
await agent_loop._connect_mcp()
|
||||
|
||||
async def on_cleanup(_app):
|
||||
await agent_loop.close_mcp()
|
||||
|
||||
api_app.on_startup.append(on_startup)
|
||||
api_app.on_cleanup.append(on_cleanup)
|
||||
|
||||
web.run_app(api_app, host=host, port=port, print=lambda msg: logger.info(msg))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Gateway / Server
|
||||
# ============================================================================
|
||||
@@ -557,19 +657,31 @@ def gateway(
|
||||
model=config.agents.defaults.model,
|
||||
max_iterations=config.agents.defaults.max_tool_iterations,
|
||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
||||
web_search_config=config.tools.web.search,
|
||||
web_proxy=config.tools.web.proxy or None,
|
||||
web_config=config.tools.web,
|
||||
context_block_limit=config.agents.defaults.context_block_limit,
|
||||
max_tool_result_chars=config.agents.defaults.max_tool_result_chars,
|
||||
provider_retry_mode=config.agents.defaults.provider_retry_mode,
|
||||
exec_config=config.tools.exec,
|
||||
cron_service=cron,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
session_manager=session_manager,
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
channels_config=config.channels,
|
||||
timezone=config.agents.defaults.timezone,
|
||||
)
|
||||
|
||||
# Set cron callback (needs agent)
|
||||
async def on_cron_job(job: CronJob) -> str | None:
|
||||
"""Execute a cron job through the agent."""
|
||||
# Dream is an internal job — run directly, not through the agent loop.
|
||||
if job.name == "dream":
|
||||
try:
|
||||
await agent.dream.run()
|
||||
logger.info("Dream cron job completed")
|
||||
except Exception:
|
||||
logger.exception("Dream cron job failed")
|
||||
return None
|
||||
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
@@ -676,6 +788,7 @@ def gateway(
|
||||
on_notify=on_heartbeat_notify,
|
||||
interval_s=hb_cfg.interval_s,
|
||||
enabled=hb_cfg.enabled,
|
||||
timezone=config.agents.defaults.timezone,
|
||||
)
|
||||
|
||||
if channels.enabled_channels:
|
||||
@@ -689,6 +802,21 @@ def gateway(
|
||||
|
||||
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
||||
|
||||
# Register Dream system job (always-on, idempotent on restart)
|
||||
dream_cfg = config.agents.defaults.dream
|
||||
if dream_cfg.model_override:
|
||||
agent.dream.model = dream_cfg.model_override
|
||||
agent.dream.max_batch_size = dream_cfg.max_batch_size
|
||||
agent.dream.max_iterations = dream_cfg.max_iterations
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
cron.register_system_job(CronJob(
|
||||
id="dream",
|
||||
name="dream",
|
||||
schedule=dream_cfg.build_schedule(config.agents.defaults.timezone),
|
||||
payload=CronPayload(kind="system_event"),
|
||||
))
|
||||
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
|
||||
|
||||
async def run():
|
||||
try:
|
||||
await cron.start()
|
||||
@@ -761,14 +889,23 @@ def agent(
|
||||
model=config.agents.defaults.model,
|
||||
max_iterations=config.agents.defaults.max_tool_iterations,
|
||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
||||
web_search_config=config.tools.web.search,
|
||||
web_proxy=config.tools.web.proxy or None,
|
||||
web_config=config.tools.web,
|
||||
context_block_limit=config.agents.defaults.context_block_limit,
|
||||
max_tool_result_chars=config.agents.defaults.max_tool_result_chars,
|
||||
provider_retry_mode=config.agents.defaults.provider_retry_mode,
|
||||
exec_config=config.tools.exec,
|
||||
cron_service=cron,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
channels_config=config.channels,
|
||||
timezone=config.agents.defaults.timezone,
|
||||
)
|
||||
restart_notice = consume_restart_notice_from_env()
|
||||
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
||||
_print_agent_response(
|
||||
format_restart_completed_message(restart_notice.started_at_raw),
|
||||
render_markdown=False,
|
||||
)
|
||||
|
||||
# Shared reference for progress callbacks
|
||||
_thinking: ThinkingSpinner | None = None
|
||||
@@ -887,6 +1024,9 @@ def agent(
|
||||
while True:
|
||||
try:
|
||||
_flush_pending_tty_input()
|
||||
# Stop spinner before user input to avoid prompt_toolkit conflicts
|
||||
if renderer:
|
||||
renderer.stop_for_input()
|
||||
user_input = await _read_interactive_input_async()
|
||||
command = user_input.strip()
|
||||
if not command:
|
||||
@@ -948,12 +1088,18 @@ app.add_typer(channels_app, name="channels")
|
||||
|
||||
|
||||
@channels_app.command("status")
|
||||
def channels_status():
|
||||
def channels_status(
|
||||
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
):
|
||||
"""Show channel status."""
|
||||
from nanobot.channels.registry import discover_all
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.config.loader import load_config, set_config_path
|
||||
|
||||
config = load_config()
|
||||
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
|
||||
if resolved_config_path is not None:
|
||||
set_config_path(resolved_config_path)
|
||||
|
||||
config = load_config(resolved_config_path)
|
||||
|
||||
table = Table(title="Channel Status")
|
||||
table.add_column("Channel", style="cyan")
|
||||
@@ -1040,12 +1186,17 @@ def _get_bridge_dir() -> Path:
|
||||
def channels_login(
|
||||
channel_name: str = typer.Argument(..., help="Channel name (e.g. weixin, whatsapp)"),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Force re-authentication even if already logged in"),
|
||||
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
):
|
||||
"""Authenticate with a channel via QR code or other interactive login."""
|
||||
from nanobot.channels.registry import discover_all
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.config.loader import load_config, set_config_path
|
||||
|
||||
config = load_config()
|
||||
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
|
||||
if resolved_config_path is not None:
|
||||
set_config_path(resolved_config_path)
|
||||
|
||||
config = load_config(resolved_config_path)
|
||||
channel_cfg = getattr(config.channels, channel_name, None) or {}
|
||||
|
||||
# Validate channel exists
|
||||
@@ -1219,26 +1370,16 @@ def _login_openai_codex() -> None:
|
||||
|
||||
@_register_login("github_copilot")
|
||||
def _login_github_copilot() -> None:
|
||||
import asyncio
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
|
||||
|
||||
async def _trigger():
|
||||
client = AsyncOpenAI(
|
||||
api_key="dummy",
|
||||
base_url="https://api.githubcopilot.com",
|
||||
)
|
||||
await client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=1,
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(_trigger())
|
||||
console.print("[green]✓ Authenticated with GitHub Copilot[/green]")
|
||||
from nanobot.providers.github_copilot_provider import login_github_copilot
|
||||
|
||||
console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
|
||||
token = login_github_copilot(
|
||||
print_fn=lambda s: console.print(s),
|
||||
prompt_fn=lambda s: typer.prompt(s),
|
||||
)
|
||||
account = token.account_id or "GitHub"
|
||||
console.print(f"[green]✓ Authenticated with GitHub Copilot[/green] [dim]{account}[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Authentication error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -18,7 +18,7 @@ from nanobot import __logo__
|
||||
|
||||
|
||||
def _make_console() -> Console:
|
||||
return Console(file=sys.stdout)
|
||||
return Console(file=sys.stdout, force_terminal=True)
|
||||
|
||||
|
||||
class ThinkingSpinner:
|
||||
@@ -120,6 +120,10 @@ class StreamRenderer:
|
||||
else:
|
||||
_make_console().print()
|
||||
|
||||
def stop_for_input(self) -> None:
|
||||
"""Stop spinner before user input to avoid prompt_toolkit conflicts."""
|
||||
self._stop_spinner()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Stop spinner/live without rendering a final streamed round."""
|
||||
if self._live:
|
||||
|
||||
Reference in New Issue
Block a user