refactor: enforce BasedPyright strict type checking (#5158)
This commit is contained in:
+135
-106
@@ -1,15 +1,23 @@
|
||||
"""CLI commands for nanobot."""
|
||||
|
||||
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportPrivateUsage=false, reportUnusedFunction=false
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Awaitable, Callable, Coroutine, Iterable
|
||||
from contextlib import nullcontext, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from types import FrameType
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.gateway.runtime import GatewayRuntime
|
||||
from nanobot.providers.registry import ProviderSpec
|
||||
|
||||
|
||||
# Force UTF-8 encoding for Windows console
|
||||
if sys.platform == "win32":
|
||||
@@ -17,8 +25,10 @@ if sys.platform == "win32":
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
# Re-open stdout/stderr with UTF-8 encoding
|
||||
with suppress(Exception):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Keep console encoding setup before importing CLI UI/logging libraries.
|
||||
import typer # noqa: E402
|
||||
@@ -52,6 +62,7 @@ from prompt_toolkit.application import run_in_terminal # noqa: E402
|
||||
from prompt_toolkit.formatted_text import ANSI, HTML # noqa: E402
|
||||
from prompt_toolkit.history import FileHistory # noqa: E402
|
||||
from prompt_toolkit.key_binding import KeyBindings # noqa: E402
|
||||
from prompt_toolkit.key_binding.key_processor import KeyPressEvent # noqa: E402
|
||||
from prompt_toolkit.keys import Keys # noqa: E402
|
||||
from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
|
||||
from pydantic import ValidationError # noqa: E402
|
||||
@@ -139,7 +150,7 @@ def _ensure_interactive_tty_mode() -> None:
|
||||
def _install_gateway_shutdown_handlers(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
shutdown_event: asyncio.Event,
|
||||
tasks: list[asyncio.Task],
|
||||
tasks: list[asyncio.Task[Any]],
|
||||
print_status: Callable[[str], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Install foreground gateway signal handlers and return a restore callback."""
|
||||
@@ -298,8 +309,8 @@ def _pick_heartbeat_target_from_sessions(
|
||||
# CLI input: prompt_toolkit for editing, paste, history, and display
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROMPT_SESSION: PromptSession | None = None
|
||||
_SAVED_TERM_ATTRS = None # original termios settings, restored on exit
|
||||
_PROMPT_SESSION: PromptSession[str] | None = None
|
||||
_saved_term_attrs: list[Any] | None = None # original termios settings, restored on exit
|
||||
|
||||
|
||||
def _flush_pending_tty_input() -> None:
|
||||
@@ -328,12 +339,12 @@ def _flush_pending_tty_input() -> None:
|
||||
|
||||
def _restore_terminal() -> None:
|
||||
"""Restore terminal to its original state (echo, line buffering, etc.)."""
|
||||
if _SAVED_TERM_ATTRS is None:
|
||||
if _saved_term_attrs is None:
|
||||
return
|
||||
with suppress(Exception):
|
||||
import termios
|
||||
|
||||
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
|
||||
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved_term_attrs)
|
||||
|
||||
|
||||
def _build_cli_key_bindings() -> KeyBindings:
|
||||
@@ -357,20 +368,20 @@ def _build_cli_key_bindings() -> KeyBindings:
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add("enter")
|
||||
def _(event):
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
event.current_buffer.validate_and_handle()
|
||||
|
||||
@kb.add("escape", "enter") # Alt+Enter / Meta+Enter (ESC + CR, "\x1b\r")
|
||||
def _(event):
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
event.current_buffer.insert_text("\n")
|
||||
|
||||
# LF-as-Enter terminals send Alt+Enter as ESC + LF rather than ESC + CR.
|
||||
@kb.add("escape", Keys.ControlJ) # Alt+Enter on LF-as-Enter terminals
|
||||
def _(event):
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
event.current_buffer.insert_text("\n")
|
||||
|
||||
@kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals
|
||||
def _(event):
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
event.current_buffer.insert_text("\n")
|
||||
|
||||
return kb
|
||||
@@ -378,13 +389,13 @@ def _build_cli_key_bindings() -> KeyBindings:
|
||||
|
||||
def _init_prompt_session() -> None:
|
||||
"""Create the prompt_toolkit session with persistent file history."""
|
||||
global _PROMPT_SESSION, _SAVED_TERM_ATTRS
|
||||
global _PROMPT_SESSION, _saved_term_attrs
|
||||
|
||||
# Save terminal state so we can restore it on exit
|
||||
with suppress(Exception):
|
||||
import termios
|
||||
|
||||
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
|
||||
_saved_term_attrs = termios.tcgetattr(sys.stdin.fileno())
|
||||
|
||||
from nanobot.config.paths import get_cli_history_path
|
||||
|
||||
@@ -405,11 +416,14 @@ def _make_console() -> Console:
|
||||
return Console(file=sys.stdout)
|
||||
|
||||
|
||||
def _render_interactive_ansi(render_fn) -> str:
|
||||
def _render_interactive_ansi(render_fn: Callable[[Console], None]) -> str:
|
||||
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
|
||||
ansi_console = Console(
|
||||
force_terminal=sys.stdout.isatty(),
|
||||
color_system=console.color_system or "standard",
|
||||
color_system=cast(
|
||||
Literal["auto", "standard", "256", "truecolor", "windows"],
|
||||
console.color_system or "standard",
|
||||
),
|
||||
width=console.width,
|
||||
)
|
||||
with ansi_console.capture() as capture:
|
||||
@@ -420,7 +434,7 @@ def _render_interactive_ansi(render_fn) -> str:
|
||||
def _print_agent_response(
|
||||
response: str,
|
||||
render_markdown: bool,
|
||||
metadata: dict | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
show_header: bool = True,
|
||||
) -> None:
|
||||
"""Render assistant response with consistent terminal styling."""
|
||||
@@ -434,7 +448,9 @@ def _print_agent_response(
|
||||
console.print()
|
||||
|
||||
|
||||
def _response_renderable(content: str, render_markdown: bool, metadata: dict | None = None):
|
||||
def _response_renderable(
|
||||
content: str, render_markdown: bool, metadata: dict[str, Any] | None = None
|
||||
) -> Text | Markdown:
|
||||
"""Render plain-text command output without markdown collapsing newlines."""
|
||||
if not render_markdown:
|
||||
return Text(content)
|
||||
@@ -457,19 +473,19 @@ async def _print_interactive_line(text: str) -> None:
|
||||
async def _print_interactive_response(
|
||||
response: str,
|
||||
render_markdown: bool,
|
||||
metadata: dict | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Print async interactive replies with prompt_toolkit-safe Rich styling."""
|
||||
def _write() -> None:
|
||||
content = response or ""
|
||||
ansi = _render_interactive_ansi(
|
||||
lambda c: (
|
||||
c.print(),
|
||||
c.print(f"[cyan]{__logo__} nanobot[/cyan]"),
|
||||
c.print(_response_renderable(content, render_markdown, metadata)),
|
||||
c.print(),
|
||||
)
|
||||
)
|
||||
|
||||
def _render(target: Console) -> None:
|
||||
target.print()
|
||||
target.print(f"[cyan]{__logo__} nanobot[/cyan]")
|
||||
target.print(_response_renderable(content, render_markdown, metadata))
|
||||
target.print()
|
||||
|
||||
ansi = _render_interactive_ansi(_render)
|
||||
print_formatted_text(ANSI(ansi), end="")
|
||||
|
||||
await run_in_terminal(_write)
|
||||
@@ -663,10 +679,11 @@ def onboard(
|
||||
loaded.agents.defaults.workspace = workspace
|
||||
return loaded
|
||||
|
||||
loaded_config: Config | None = None
|
||||
# Create or update config
|
||||
if config_path.exists():
|
||||
if wizard:
|
||||
config = _apply_workspace_override(load_config(config_path))
|
||||
loaded_config = _apply_workspace_override(load_config(config_path))
|
||||
else:
|
||||
should_refresh = non_interactive_refresh
|
||||
if not non_interactive_refresh:
|
||||
@@ -678,37 +695,39 @@ def onboard(
|
||||
" [bold]N[/bold] = refresh config, keeping existing values and adding new fields"
|
||||
)
|
||||
if typer.confirm("Overwrite?"):
|
||||
config = _apply_workspace_override(Config())
|
||||
save_config(config, config_path)
|
||||
loaded_config = _apply_workspace_override(Config())
|
||||
save_config(loaded_config, config_path)
|
||||
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
|
||||
else:
|
||||
should_refresh = True
|
||||
|
||||
if should_refresh:
|
||||
config = _apply_workspace_override(load_config(config_path))
|
||||
save_config(config, config_path)
|
||||
loaded_config = _apply_workspace_override(load_config(config_path))
|
||||
save_config(loaded_config, config_path)
|
||||
console.print(
|
||||
f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)"
|
||||
)
|
||||
else:
|
||||
config = _apply_workspace_override(Config())
|
||||
loaded_config = _apply_workspace_override(Config())
|
||||
# In wizard mode, don't save yet - the wizard will handle saving if should_save=True
|
||||
if not wizard:
|
||||
save_config(config, config_path)
|
||||
save_config(loaded_config, config_path)
|
||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||
|
||||
assert loaded_config is not None
|
||||
|
||||
# Run interactive wizard if enabled
|
||||
if wizard:
|
||||
from nanobot.cli.onboard import run_onboard
|
||||
|
||||
try:
|
||||
result = run_onboard(initial_config=config)
|
||||
result = run_onboard(initial_config=loaded_config)
|
||||
if not result.should_save:
|
||||
console.print("[yellow]Configuration discarded. No changes were saved.[/yellow]")
|
||||
return
|
||||
|
||||
config = result.config
|
||||
save_config(config, config_path)
|
||||
loaded_config = result.config
|
||||
save_config(loaded_config, config_path)
|
||||
console.print(f"[green]✓[/green] Config saved at {config_path}")
|
||||
except Exception as e:
|
||||
console.print(f"[red]✗[/red] Error during configuration: {e}")
|
||||
@@ -717,7 +736,7 @@ def onboard(
|
||||
_onboard_plugins(config_path)
|
||||
|
||||
# Create workspace, preferring the configured workspace path.
|
||||
workspace_path = get_workspace_path(config.workspace_path)
|
||||
workspace_path = get_workspace_path(loaded_config.workspace_path)
|
||||
if not workspace_path.exists():
|
||||
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||
console.print(f"[green]✓[/green] Created workspace at {workspace_path}")
|
||||
@@ -1000,7 +1019,7 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
|
||||
"""Return the current WebSocket config as a mutable alias-key dictionary."""
|
||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||
|
||||
current = getattr(config.channels, "websocket", None) or {}
|
||||
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||
model = WebSocketConfig.model_validate(current)
|
||||
return model.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
@@ -1008,7 +1027,7 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
|
||||
def _webui_channel_enabled(config: Config) -> bool:
|
||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||
|
||||
current = getattr(config.channels, "websocket", None) or {}
|
||||
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||
return bool(WebSocketConfig.model_validate(current).enabled)
|
||||
|
||||
|
||||
@@ -1167,7 +1186,7 @@ def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool)
|
||||
"""Enable the local WebUI channel with safe localhost defaults."""
|
||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||
|
||||
current = getattr(config.channels, "websocket", None) or {}
|
||||
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||
model = WebSocketConfig.model_validate(current)
|
||||
changed = False
|
||||
generated_secret = False
|
||||
@@ -1329,7 +1348,7 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
|
||||
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
|
||||
|
||||
|
||||
def _attach_to_background_gateway(runtime: Any) -> None:
|
||||
def _attach_to_background_gateway(runtime: "GatewayRuntime") -> None:
|
||||
"""Keep a foreground WebUI command attached to a managed gateway."""
|
||||
_print_webui_foreground_lifecycle(attached=True)
|
||||
try:
|
||||
@@ -1512,16 +1531,19 @@ def serve(
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
async def on_startup(_app):
|
||||
async def on_startup(_app: Any) -> None:
|
||||
await agent_loop._connect_mcp()
|
||||
|
||||
async def on_cleanup(_app):
|
||||
async def on_cleanup(_app: Any) -> None:
|
||||
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))
|
||||
def _log_aiohttp(message: object) -> None:
|
||||
logger.info("{}", message)
|
||||
|
||||
web.run_app(api_app, host=host, port=port, print=_log_aiohttp)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -1778,6 +1800,7 @@ def _run_gateway(
|
||||
from nanobot.cron.session_turns import is_bound_cron_job
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.providers.factory import (
|
||||
ProviderSnapshot,
|
||||
build_provider_snapshot,
|
||||
build_unconfigured_provider_snapshot,
|
||||
load_provider_snapshot,
|
||||
@@ -1823,12 +1846,15 @@ def _run_gateway(
|
||||
runtime_events = RuntimeEventBus()
|
||||
fallback_model_observer = build_webui_fallback_model_observer(bus)
|
||||
|
||||
def _observe_fallback_models(snapshot):
|
||||
def _observe_fallback_models(snapshot: ProviderSnapshot) -> ProviderSnapshot:
|
||||
if isinstance(snapshot.provider, FallbackProvider):
|
||||
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
|
||||
return snapshot
|
||||
|
||||
def _load_gateway_provider_snapshot(*args: Any, **kwargs: Any):
|
||||
def _load_gateway_provider_snapshot(
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> ProviderSnapshot:
|
||||
try:
|
||||
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
|
||||
except ValueError as exc:
|
||||
@@ -1896,10 +1922,13 @@ def _run_gateway(
|
||||
local_trigger_store=trigger_store,
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
)
|
||||
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
|
||||
agent._schedule_background(cast(Coroutine[Any, Any, None], awaitable))
|
||||
|
||||
webui_turn_coordinator = WebuiTurnCoordinator(
|
||||
bus=bus,
|
||||
sessions=session_manager,
|
||||
schedule_background=lambda coro: agent._schedule_background(coro),
|
||||
schedule_background=_schedule_webui_background,
|
||||
)
|
||||
webui_turn_coordinator.subscribe(runtime_events)
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
@@ -1944,14 +1973,14 @@ def _run_gateway(
|
||||
session_manager.save(session)
|
||||
await bus.publish_outbound(msg)
|
||||
|
||||
message_tool = getattr(agent, "tools", {}).get("message")
|
||||
message_tool = agent.tools.get("message")
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_tool.set_send_callback(_deliver_to_channel)
|
||||
|
||||
# Set cron callback (needs agent)
|
||||
async def on_cron_job(job: CronJob) -> str | None:
|
||||
"""Execute a cron job through the agent."""
|
||||
async def _silent(*_args, **_kwargs):
|
||||
async def _silent(*_args: Any, **_kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
# Dream is an internal job — run directly, not through the agent loop.
|
||||
@@ -1972,10 +2001,7 @@ def _run_gateway(
|
||||
return None
|
||||
prompt, last_cursor = result
|
||||
key = dream_session_key()
|
||||
resolve_dream_runtime = getattr(agent, "dream_runtime", None)
|
||||
dream_runtime = (
|
||||
resolve_dream_runtime() if callable(resolve_dream_runtime) else None
|
||||
)
|
||||
dream_runtime = agent.dream_runtime()
|
||||
resp = await agent.process_direct(
|
||||
prompt,
|
||||
session_key=key,
|
||||
@@ -2111,11 +2137,7 @@ def _run_gateway(
|
||||
cron.on_job = on_cron_job
|
||||
|
||||
def _webui_runtime_model_name() -> str | None:
|
||||
model = getattr(agent, "model", None)
|
||||
if isinstance(model, str):
|
||||
stripped = model.strip()
|
||||
return stripped or None
|
||||
return None
|
||||
return agent.model.strip() or None
|
||||
|
||||
# Create channel manager (forwards SessionManager so the WebSocket channel
|
||||
# can serve the embedded webui's REST surface).
|
||||
@@ -2126,12 +2148,8 @@ def _run_gateway(
|
||||
cron_service=cron,
|
||||
local_trigger_store=trigger_store,
|
||||
webui_runtime_model_name=_webui_runtime_model_name,
|
||||
webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None),
|
||||
webui_local_trigger_pending_ids=getattr(
|
||||
agent,
|
||||
"pending_local_trigger_ids_for_session",
|
||||
None,
|
||||
),
|
||||
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
|
||||
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
|
||||
webui_static_dist=webui_static_dist,
|
||||
webui_runtime_surface=webui_runtime_surface,
|
||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||
@@ -2158,8 +2176,9 @@ def _run_gateway(
|
||||
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
||||
|
||||
cron_status = cron.status()
|
||||
if cron_status["jobs"] > 0:
|
||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
||||
cron_job_count = cast(int, cron_status["jobs"])
|
||||
if cron_job_count > 0:
|
||||
console.print(f"[green]✓[/green] Cron: {cron_job_count} scheduled jobs")
|
||||
|
||||
hb_cfg = config.gateway.heartbeat
|
||||
if hb_cfg.enabled:
|
||||
@@ -2167,13 +2186,16 @@ def _run_gateway(
|
||||
else:
|
||||
console.print("[yellow]✗[/yellow] Heartbeat: disabled")
|
||||
|
||||
async def _health_server(host: str, health_port: int):
|
||||
async def _health_server(host: str, health_port: int) -> None:
|
||||
"""Lightweight HTTP health endpoint on the gateway port."""
|
||||
import json as _json
|
||||
|
||||
connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS)
|
||||
|
||||
async def handle(reader, writer):
|
||||
async def handle(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
if connection_slots.locked():
|
||||
writer.close()
|
||||
return
|
||||
@@ -2260,7 +2282,7 @@ def _run_gateway(
|
||||
# Channels start asynchronously; a short poll lets us avoid racing the bind.
|
||||
for _ in range(40): # ~4s max
|
||||
try:
|
||||
reader, writer = await asyncio.open_connection(
|
||||
_reader, writer = await asyncio.open_connection(
|
||||
target_host,
|
||||
target_port,
|
||||
)
|
||||
@@ -2276,10 +2298,10 @@ def _run_gateway(
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
|
||||
|
||||
async def run():
|
||||
tasks: list[asyncio.Task] = []
|
||||
shutdown_task: asyncio.Task | None = None
|
||||
runtime_tasks: asyncio.Future | None = None
|
||||
async def run() -> None:
|
||||
tasks: list[asyncio.Task[Any]] = []
|
||||
shutdown_task: asyncio.Task[Any] | None = None
|
||||
runtime_tasks: asyncio.Future[list[Any]] | None = None
|
||||
runtime_tasks_drained = False
|
||||
shutdown_event = asyncio.Event()
|
||||
_ensure_interactive_tty_mode()
|
||||
@@ -2306,7 +2328,7 @@ def _run_gateway(
|
||||
asyncio.create_task(
|
||||
run_local_trigger_queue(
|
||||
store=trigger_store,
|
||||
submit_turn=getattr(agent, "submit_local_trigger_turn", None),
|
||||
submit_turn=agent.submit_local_trigger_turn,
|
||||
is_channel_enabled=lambda name: channels.get_channel(name) is not None,
|
||||
),
|
||||
name="nanobot-local-triggers",
|
||||
@@ -2334,7 +2356,7 @@ def _run_gateway(
|
||||
if runtime_tasks in done:
|
||||
runtime_tasks_drained = True
|
||||
await runtime_tasks
|
||||
elif runtime_tasks is not None:
|
||||
else:
|
||||
runtime_tasks.cancel()
|
||||
except KeyboardInterrupt:
|
||||
console.print("\nShutting down...")
|
||||
@@ -2410,33 +2432,33 @@ def agent(
|
||||
from nanobot.providers.factory import make_provider
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
|
||||
config = _load_runtime_config(config, workspace)
|
||||
runtime_config = _load_runtime_config(config, workspace)
|
||||
try:
|
||||
provider = make_provider(config)
|
||||
provider = make_provider(runtime_config)
|
||||
except ValueError as exc:
|
||||
_print_agent_start_error(exc)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
sync_workspace_templates(config.workspace_path)
|
||||
sync_workspace_templates(runtime_config.workspace_path)
|
||||
|
||||
bus = MessageBus()
|
||||
|
||||
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||
if is_default_workspace(config.workspace_path):
|
||||
_migrate_cron_store(config)
|
||||
if is_default_workspace(runtime_config.workspace_path):
|
||||
_migrate_cron_store(runtime_config)
|
||||
|
||||
# Create cron service with workspace-scoped store
|
||||
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
||||
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
|
||||
cron = CronService(cron_store_path)
|
||||
|
||||
_set_nanobot_logs(logs)
|
||||
|
||||
try:
|
||||
agent_loop = AgentLoop.from_config(
|
||||
config, bus,
|
||||
runtime_config, bus,
|
||||
provider=provider,
|
||||
cron_service=cron,
|
||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
)
|
||||
except ValueError as exc:
|
||||
@@ -2452,7 +2474,9 @@ def agent(
|
||||
# Shared reference for progress callbacks
|
||||
_thinking: ThinkingSpinner | None = None
|
||||
|
||||
def _make_progress(renderer: StreamRenderer | None = None):
|
||||
def _make_progress(
|
||||
renderer: StreamRenderer | None = None,
|
||||
) -> Callable[..., Awaitable[None]]:
|
||||
reasoning_buffer = _ReasoningBuffer()
|
||||
|
||||
async def _cli_progress(content: str, *, tool_hint: bool = False, reasoning: bool = False, **_kwargs: Any) -> None:
|
||||
@@ -2482,11 +2506,11 @@ def agent(
|
||||
|
||||
if message:
|
||||
# Single message mode — direct call, no bus needed
|
||||
async def run_once():
|
||||
async def run_once() -> None:
|
||||
renderer = StreamRenderer(
|
||||
render_markdown=markdown,
|
||||
bot_name=config.agents.defaults.bot_name,
|
||||
bot_icon=config.agents.defaults.bot_icon,
|
||||
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,
|
||||
@@ -2512,8 +2536,8 @@ def agent(
|
||||
# Interactive mode — route through bus like other channels
|
||||
from nanobot.bus.events import InboundMessage
|
||||
_init_prompt_session()
|
||||
_model, _preset_tag = _model_display(config)
|
||||
_icon = config.agents.defaults.bot_icon or __logo__
|
||||
_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:
|
||||
@@ -2521,7 +2545,7 @@ def agent(
|
||||
else:
|
||||
cli_channel, cli_chat_id = "cli", session_id
|
||||
|
||||
def _handle_signal(signum, frame):
|
||||
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
|
||||
sig_name = signal.Signals(signum).name
|
||||
_restore_terminal()
|
||||
console.print(f"\nReceived {sig_name}, goodbye!")
|
||||
@@ -2537,7 +2561,7 @@ def agent(
|
||||
if hasattr(signal, 'SIGPIPE'):
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
||||
|
||||
async def run_interactive():
|
||||
async def run_interactive() -> None:
|
||||
bus_task = asyncio.create_task(agent_loop.run())
|
||||
turn_done = asyncio.Event()
|
||||
turn_done.set()
|
||||
@@ -2545,7 +2569,7 @@ def agent(
|
||||
renderer: StreamRenderer | None = None
|
||||
reasoning_buffer = _ReasoningBuffer()
|
||||
|
||||
async def _consume_outbound():
|
||||
async def _consume_outbound() -> None:
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||
@@ -2578,7 +2602,7 @@ def agent(
|
||||
|
||||
if await _maybe_print_interactive_progress(
|
||||
msg,
|
||||
renderer,
|
||||
None,
|
||||
agent_loop.channels_config,
|
||||
renderer,
|
||||
reasoning_buffer,
|
||||
@@ -2625,8 +2649,8 @@ def agent(
|
||||
reasoning_buffer.clear()
|
||||
renderer = StreamRenderer(
|
||||
render_markdown=markdown,
|
||||
bot_name=config.agents.defaults.bot_name,
|
||||
bot_icon=config.agents.defaults.bot_icon,
|
||||
bot_name=runtime_config.agents.defaults.bot_name,
|
||||
bot_icon=runtime_config.agents.defaults.bot_icon,
|
||||
)
|
||||
|
||||
await bus.publish_inbound(InboundMessage(
|
||||
@@ -2701,7 +2725,7 @@ def channels_status(
|
||||
if section is None:
|
||||
enabled = False
|
||||
elif isinstance(section, dict):
|
||||
enabled = section.get("enabled", False)
|
||||
enabled = cast(dict[str, Any], section).get("enabled", False)
|
||||
else:
|
||||
enabled = getattr(section, "enabled", False)
|
||||
table.add_row(
|
||||
@@ -2719,10 +2743,11 @@ def channels_login(
|
||||
config: 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.bus.queue import MessageBus
|
||||
from nanobot.channels.registry import discover_all
|
||||
|
||||
_, loaded = _load_inspection_config(config=config)
|
||||
channel_cfg = getattr(loaded.channels, channel_name, None) or {}
|
||||
channel_cfg: Any = getattr(loaded.channels, channel_name, None) or {}
|
||||
|
||||
# Validate channel exists
|
||||
all_channels = discover_all()
|
||||
@@ -2733,8 +2758,8 @@ def channels_login(
|
||||
|
||||
console.print(f"{__logo__} {all_channels[channel_name].display_name} Login\n")
|
||||
|
||||
channel_cls = all_channels[channel_name]
|
||||
channel = channel_cls(channel_cfg, bus=None)
|
||||
channel_factory = all_channels[channel_name]
|
||||
channel = channel_factory(channel_cfg, bus=MessageBus())
|
||||
|
||||
success = asyncio.run(channel.login(force=force))
|
||||
|
||||
@@ -2923,24 +2948,28 @@ _OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _register_login(name: str):
|
||||
def _register_login(
|
||||
name: str,
|
||||
) -> Callable[[Callable[[], None]], Callable[[], None]]:
|
||||
"""Register an OAuth login handler."""
|
||||
def decorator(fn):
|
||||
def decorator(fn: Callable[[], None]) -> Callable[[], None]:
|
||||
_LOGIN_HANDLERS[name] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _register_logout(name: str):
|
||||
def _register_logout(
|
||||
name: str,
|
||||
) -> Callable[[Callable[[], None]], Callable[[], None]]:
|
||||
"""Register an OAuth logout handler."""
|
||||
def decorator(fn):
|
||||
def decorator(fn: Callable[[], None]) -> Callable[[], None]:
|
||||
_LOGOUT_HANDLERS[name] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
|
||||
def _resolve_oauth_provider(provider: str):
|
||||
def _resolve_oauth_provider(provider: str) -> "ProviderSpec":
|
||||
"""Resolve and validate an OAuth provider configuration."""
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Typer commands for foreground and background gateway control."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
+116
-69
@@ -1,19 +1,27 @@
|
||||
"""Interactive onboarding questionnaire for nanobot."""
|
||||
|
||||
# pyright: reportMissingTypeStubs=false, reportUnusedFunction=false
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import types
|
||||
from collections.abc import Callable, Iterable, Sized
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Literal, NamedTuple, get_args, get_origin
|
||||
from typing import Any, Literal, NamedTuple, TypeVar, cast, get_args, get_origin
|
||||
|
||||
try:
|
||||
import questionary
|
||||
except ModuleNotFoundError: # pragma: no cover - exercised in environments without wizard deps
|
||||
questionary = None
|
||||
from loguru import logger
|
||||
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import FieldInfo
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.panel import Panel
|
||||
@@ -29,6 +37,8 @@ from nanobot.config.schema import Config, ModelPresetConfig
|
||||
|
||||
console = Console()
|
||||
|
||||
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OnboardResult:
|
||||
@@ -119,14 +129,14 @@ _CHANNEL_LOGIN_CHOICE = "Login with QR/link"
|
||||
_CHANNEL_ADVANCED_CHOICE = "Edit advanced settings"
|
||||
|
||||
|
||||
def _get_questionary():
|
||||
def _get_questionary() -> Any:
|
||||
"""Return questionary or raise a clear error when wizard deps are unavailable."""
|
||||
if questionary is None:
|
||||
raise RuntimeError(
|
||||
"Interactive onboarding requires the optional 'questionary' dependency. "
|
||||
"Install project dependencies and rerun with --wizard."
|
||||
)
|
||||
return questionary
|
||||
return cast(Any, questionary)
|
||||
|
||||
|
||||
def _select_with_back(
|
||||
@@ -147,7 +157,6 @@ def _select_with_back(
|
||||
import shutil
|
||||
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.keys import Keys
|
||||
from prompt_toolkit.layout import Layout
|
||||
from prompt_toolkit.layout.containers import HSplit, Window
|
||||
@@ -170,8 +179,8 @@ def _select_with_back(
|
||||
visible_count = min(len(choices), max(1, terminal_lines - 3))
|
||||
|
||||
# Build menu items (uses closure over selected_index)
|
||||
def get_menu_text():
|
||||
items = []
|
||||
def get_menu_text() -> list[tuple[str, str]]:
|
||||
items: list[tuple[str, str]] = []
|
||||
start, end = _choice_viewport(selected_index, len(choices), visible_count)
|
||||
for i in range(start, end):
|
||||
choice = choices[i]
|
||||
@@ -182,14 +191,14 @@ def _select_with_back(
|
||||
return items
|
||||
|
||||
# Create layout
|
||||
menu_control = FormattedTextControl(get_menu_text, show_cursor=False)
|
||||
menu_control = FormattedTextControl(cast(Any, get_menu_text), show_cursor=False)
|
||||
menu_window = Window(content=menu_control, height=visible_count, always_hide_cursor=True)
|
||||
|
||||
def get_prompt_text():
|
||||
def get_prompt_text() -> list[tuple[str, str]]:
|
||||
suffix = f" ({selected_index + 1}/{len(choices)})" if len(choices) > visible_count else ""
|
||||
return [("class:question", f"{prompt}{suffix}")]
|
||||
|
||||
prompt_control = FormattedTextControl(get_prompt_text, show_cursor=False)
|
||||
prompt_control = FormattedTextControl(cast(Any, get_prompt_text), show_cursor=False)
|
||||
prompt_window = Window(content=prompt_control, height=1, always_hide_cursor=True)
|
||||
|
||||
layout = Layout(HSplit([prompt_window, menu_window]))
|
||||
@@ -198,34 +207,34 @@ def _select_with_back(
|
||||
bindings = KeyBindings()
|
||||
|
||||
@bindings.add(Keys.Up)
|
||||
def _up(event):
|
||||
def _up(event: KeyPressEvent) -> None:
|
||||
nonlocal selected_index
|
||||
selected_index = (selected_index - 1) % len(choices)
|
||||
event.app.invalidate()
|
||||
|
||||
@bindings.add(Keys.Down)
|
||||
def _down(event):
|
||||
def _down(event: KeyPressEvent) -> None:
|
||||
nonlocal selected_index
|
||||
selected_index = (selected_index + 1) % len(choices)
|
||||
event.app.invalidate()
|
||||
|
||||
@bindings.add(Keys.Enter)
|
||||
def _enter(event):
|
||||
def _enter(event: KeyPressEvent) -> None:
|
||||
state["result"] = choices[selected_index]
|
||||
event.app.exit()
|
||||
|
||||
@bindings.add("escape")
|
||||
def _escape(event):
|
||||
def _escape(event: KeyPressEvent) -> None:
|
||||
state["result"] = _BACK_PRESSED
|
||||
event.app.exit()
|
||||
|
||||
@bindings.add(Keys.Left)
|
||||
def _left(event):
|
||||
def _left(event: KeyPressEvent) -> None:
|
||||
state["result"] = _BACK_PRESSED
|
||||
event.app.exit()
|
||||
|
||||
@bindings.add(Keys.ControlC)
|
||||
def _ctrl_c(event):
|
||||
def _ctrl_c(event: KeyPressEvent) -> None:
|
||||
state["result"] = None
|
||||
event.app.exit()
|
||||
|
||||
@@ -235,7 +244,7 @@ def _select_with_back(
|
||||
"question": f"fg:{_UI_TEXT}",
|
||||
})
|
||||
|
||||
app = Application(layout=layout, key_bindings=bindings, style=style)
|
||||
app = Application[object](layout=layout, key_bindings=bindings, style=style)
|
||||
app.ttimeoutlen = 0.05
|
||||
app.timeoutlen = 0.05
|
||||
try:
|
||||
@@ -268,7 +277,7 @@ class FieldTypeInfo(NamedTuple):
|
||||
inner_type: Any
|
||||
|
||||
|
||||
def _get_field_type_info(field_info) -> FieldTypeInfo:
|
||||
def _get_field_type_info(field_info: FieldInfo) -> FieldTypeInfo:
|
||||
"""Extract field type info from Pydantic field."""
|
||||
annotation = field_info.annotation
|
||||
if annotation is None:
|
||||
@@ -285,10 +294,11 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
|
||||
args = get_args(annotation)
|
||||
|
||||
_simple_types: dict[type, str] = {bool: "bool", int: "int", float: "float"}
|
||||
origin_name = getattr(origin, "__name__", None)
|
||||
|
||||
if origin is list or (hasattr(origin, "__name__") and origin.__name__ == "List"):
|
||||
if origin is list or origin_name == "List":
|
||||
return FieldTypeInfo("list", args[0] if args else str)
|
||||
if origin is dict or (hasattr(origin, "__name__") and origin.__name__ == "Dict"):
|
||||
if origin is dict or origin_name == "Dict":
|
||||
return FieldTypeInfo("dict", None)
|
||||
for py_type, name in _simple_types.items():
|
||||
if annotation is py_type:
|
||||
@@ -300,7 +310,7 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
|
||||
return FieldTypeInfo("str", None)
|
||||
|
||||
|
||||
def _get_field_display_name(field_key: str, field_info) -> str:
|
||||
def _get_field_display_name(field_key: str, field_info: FieldInfo | None) -> str:
|
||||
"""Get display name for a field."""
|
||||
if field_info and field_info.description:
|
||||
return field_info.description
|
||||
@@ -349,22 +359,30 @@ def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str:
|
||||
masked = _mask_value(value)
|
||||
return f"[dim]{masked}[/dim]" if rich else masked
|
||||
if isinstance(value, BaseModel):
|
||||
parts = []
|
||||
model_parts: list[str] = []
|
||||
for fname, _finfo in type(value).model_fields.items():
|
||||
fval = getattr(value, fname, None)
|
||||
formatted = _format_value(fval, rich=False, field_name=fname)
|
||||
if formatted != "[not set]":
|
||||
parts.append(f"{fname}={formatted}")
|
||||
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
|
||||
model_parts.append(f"{fname}={formatted}")
|
||||
return (
|
||||
", ".join(model_parts)
|
||||
if model_parts
|
||||
else ("[dim]not set[/dim]" if rich else "[not set]")
|
||||
)
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(v) for v in value)
|
||||
return ", ".join(str(v) for v in cast(list[Any], value))
|
||||
if isinstance(value, dict):
|
||||
# Handle dicts containing BaseModel instances
|
||||
parts = []
|
||||
for k, v in value.items():
|
||||
mapping_parts: list[str] = []
|
||||
for k, v in cast(dict[Any, Any], value).items():
|
||||
formatted = _format_value(v, rich=False, field_name=str(k))
|
||||
parts.append(f"{k}: {formatted}")
|
||||
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
|
||||
mapping_parts.append(f"{k}: {formatted}")
|
||||
return (
|
||||
", ".join(mapping_parts)
|
||||
if mapping_parts
|
||||
else ("[dim]not set[/dim]" if rich else "[not set]")
|
||||
)
|
||||
return str(value)
|
||||
|
||||
|
||||
@@ -373,13 +391,13 @@ def _format_value_for_input(value: Any, field_type: str) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
if field_type == "list" and isinstance(value, list):
|
||||
return ",".join(str(v) for v in value)
|
||||
return ",".join(str(v) for v in cast(list[Any], value))
|
||||
if field_type == "dict" and isinstance(value, dict):
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _validate_field_constraint(value: Any, field_info) -> str | None:
|
||||
def _validate_field_constraint(value: Any, field_info: FieldInfo | None) -> str | None:
|
||||
"""Validate a value against Pydantic Field constraints.
|
||||
|
||||
Returns an error message string if validation fails, None if valid.
|
||||
@@ -388,7 +406,8 @@ def _validate_field_constraint(value: Any, field_info) -> str | None:
|
||||
if field_info is None or not hasattr(field_info, "metadata"):
|
||||
return None
|
||||
|
||||
for m in field_info.metadata:
|
||||
for metadata in field_info.metadata:
|
||||
m = metadata
|
||||
if hasattr(m, "ge") and isinstance(value, (int, float)):
|
||||
if value < m.ge:
|
||||
return f"Value must be >= {m.ge}"
|
||||
@@ -402,16 +421,16 @@ def _validate_field_constraint(value: Any, field_info) -> str | None:
|
||||
if value >= m.lt:
|
||||
return f"Value must be < {m.lt}"
|
||||
if hasattr(m, "min_length") and hasattr(value, "__len__"):
|
||||
if len(value) < m.min_length:
|
||||
if len(cast(Sized, value)) < m.min_length:
|
||||
return f"Length must be >= {m.min_length}"
|
||||
if hasattr(m, "max_length") and hasattr(value, "__len__"):
|
||||
if len(value) > m.max_length:
|
||||
if len(cast(Sized, value)) > m.max_length:
|
||||
return f"Length must be <= {m.max_length}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_constraint_hint(field_info) -> str:
|
||||
def _get_constraint_hint(field_info: FieldInfo | None) -> str:
|
||||
"""Derive a human-readable constraint hint from field metadata.
|
||||
|
||||
Returns a string like " - 0-10" or " - >= 0" to append to field display names.
|
||||
@@ -421,7 +440,8 @@ def _get_constraint_hint(field_info) -> str:
|
||||
|
||||
ge_val = None
|
||||
le_val = None
|
||||
for m in field_info.metadata:
|
||||
for metadata in field_info.metadata:
|
||||
m = metadata
|
||||
if hasattr(m, "ge"):
|
||||
ge_val = m.ge
|
||||
if hasattr(m, "le"):
|
||||
@@ -439,7 +459,11 @@ def _get_constraint_hint(field_info) -> str:
|
||||
# --- Rich UI Components ---
|
||||
|
||||
|
||||
def _show_config_panel(display_name: str, model: BaseModel, fields: list) -> None:
|
||||
def _show_config_panel(
|
||||
display_name: str,
|
||||
model: BaseModel,
|
||||
fields: list[tuple[str, FieldInfo]],
|
||||
) -> None:
|
||||
"""Display current configuration as a rich table."""
|
||||
table = Table(show_header=False, box=None, padding=(0, 2))
|
||||
table.add_column("Field", style=_UI_ACCENT)
|
||||
@@ -504,20 +528,18 @@ def _input_bool(display_name: str, current: bool | None) -> bool | None:
|
||||
).ask()
|
||||
|
||||
|
||||
def _input_back_key_bindings():
|
||||
def _input_back_key_bindings() -> KeyBindings:
|
||||
"""Return key bindings that make Escape behave like a local back action."""
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
|
||||
bindings = KeyBindings()
|
||||
|
||||
@bindings.add("escape")
|
||||
def _escape(event):
|
||||
def _escape(event: KeyPressEvent) -> None:
|
||||
event.app.exit(result=_BACK_PRESSED)
|
||||
|
||||
return bindings
|
||||
|
||||
|
||||
def _ask_prompt(prompt):
|
||||
def _ask_prompt(prompt: Any) -> Any:
|
||||
"""Ask a questionary prompt with responsive Escape handling."""
|
||||
app = getattr(prompt, "application", None)
|
||||
if app is not None:
|
||||
@@ -528,7 +550,12 @@ def _ask_prompt(prompt):
|
||||
return prompt.ask()
|
||||
|
||||
|
||||
def _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any:
|
||||
def _input_text(
|
||||
display_name: str,
|
||||
current: Any,
|
||||
field_type: str,
|
||||
field_info: FieldInfo | None = None,
|
||||
) -> Any:
|
||||
"""Get text input and parse based on field type."""
|
||||
default = _format_value_for_input(current, field_type)
|
||||
|
||||
@@ -591,7 +618,10 @@ def _input_secret(display_name: str) -> str | None | object:
|
||||
|
||||
|
||||
def _input_with_existing(
|
||||
display_name: str, current: Any, field_type: str, field_info=None
|
||||
display_name: str,
|
||||
current: Any,
|
||||
field_type: str,
|
||||
field_info: FieldInfo | None = None,
|
||||
) -> Any:
|
||||
"""Handle input with 'keep existing' option for non-empty values."""
|
||||
has_existing = current is not None and current != "" and current != {} and current != []
|
||||
@@ -624,8 +654,6 @@ def _input_model_with_autocomplete(
|
||||
"""Get model input with autocomplete suggestions.
|
||||
|
||||
"""
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
|
||||
default = str(current) if current else ""
|
||||
|
||||
class DynamicModelCompleter(Completer):
|
||||
@@ -634,7 +662,12 @@ def _input_model_with_autocomplete(
|
||||
def __init__(self, provider_name: str):
|
||||
self.provider = provider_name
|
||||
|
||||
def get_completions(self, document, _complete_event):
|
||||
def get_completions(
|
||||
self,
|
||||
document: Document,
|
||||
complete_event: CompleteEvent,
|
||||
) -> Iterable[Completion]:
|
||||
_ = complete_event
|
||||
text = document.text_before_cursor
|
||||
suggestions = get_model_suggestions(text, provider=self.provider, limit=50)
|
||||
for model in suggestions:
|
||||
@@ -735,7 +768,7 @@ def _handle_model_field(
|
||||
return
|
||||
if new_value is not None and new_value != current_value:
|
||||
setattr(working_model, field_name, new_value)
|
||||
_try_auto_fill_context_window(working_model, new_value)
|
||||
_try_auto_fill_context_window(working_model, cast(str, new_value))
|
||||
|
||||
|
||||
def _handle_context_window_field(
|
||||
@@ -794,7 +827,11 @@ def _handle_fallback_models_field(
|
||||
"""Handle the 'fallback_models' field with preset-aware list management."""
|
||||
from nanobot.config.schema import InlineFallbackConfig
|
||||
|
||||
items: list[Any] = list(current_value) if isinstance(current_value, list) else []
|
||||
items: list[Any] = (
|
||||
list(cast(list[Any], current_value))
|
||||
if isinstance(current_value, list)
|
||||
else []
|
||||
)
|
||||
preset_names = sorted(_MODEL_PRESET_CACHE)
|
||||
|
||||
while True:
|
||||
@@ -888,11 +925,11 @@ def _is_str_or_none(annotation: Any) -> bool:
|
||||
|
||||
|
||||
def _configure_pydantic_model(
|
||||
model: BaseModel,
|
||||
model: _ModelT,
|
||||
display_name: str,
|
||||
*,
|
||||
skip_fields: set[str] | None = None,
|
||||
) -> BaseModel | None:
|
||||
) -> _ModelT | None:
|
||||
"""Configure a Pydantic model interactively.
|
||||
|
||||
Returns the updated model when the user selects "Done" or navigates back.
|
||||
@@ -901,7 +938,7 @@ def _configure_pydantic_model(
|
||||
skip_fields = skip_fields or set()
|
||||
working_model = model.model_copy(deep=True)
|
||||
|
||||
fields = [
|
||||
fields: list[tuple[str, FieldInfo]] = [
|
||||
(name, info)
|
||||
for name, info in type(working_model).model_fields.items()
|
||||
if name not in skip_fields
|
||||
@@ -911,7 +948,7 @@ def _configure_pydantic_model(
|
||||
return working_model
|
||||
|
||||
def get_choices() -> list[str]:
|
||||
items = []
|
||||
items: list[str] = []
|
||||
for fname, finfo in fields:
|
||||
value = getattr(working_model, fname, None)
|
||||
display = _get_field_display_name(fname, finfo)
|
||||
@@ -1057,6 +1094,10 @@ def _sync_preset_cache(config: Config) -> None:
|
||||
_MODEL_PRESET_CACHE.update(config.model_presets.keys())
|
||||
|
||||
|
||||
def _validate_nonempty_name(text: str) -> bool | str:
|
||||
return True if text and text.strip() else "Name cannot be empty"
|
||||
|
||||
|
||||
def _configure_model_presets(config: Config) -> None:
|
||||
"""Configure model presets (CRUD)."""
|
||||
_sync_preset_cache(config)
|
||||
@@ -1099,7 +1140,7 @@ def _configure_model_presets(config: Config) -> None:
|
||||
if answer == "[+] Add new preset":
|
||||
name_input = _get_questionary().text(
|
||||
"Preset name:",
|
||||
validate=lambda t: True if t and t.strip() else "Name cannot be empty",
|
||||
validate=_validate_nonempty_name,
|
||||
).ask()
|
||||
if not name_input:
|
||||
continue
|
||||
@@ -1218,7 +1259,7 @@ def _configure_providers(config: Config) -> None:
|
||||
|
||||
def get_provider_choices() -> list[str]:
|
||||
"""Build provider choices with config status indicators."""
|
||||
choices = []
|
||||
choices: list[str] = []
|
||||
for name, display in _get_provider_names().items():
|
||||
provider = getattr(config.providers, name, None)
|
||||
if provider and provider.api_key:
|
||||
@@ -1427,7 +1468,7 @@ _SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
|
||||
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
|
||||
}
|
||||
|
||||
_SETTINGS_GETTER = {
|
||||
_SETTINGS_GETTER: dict[str, Callable[[Config], BaseModel]] = {
|
||||
"Agent Settings": lambda c: c.agents.defaults,
|
||||
"Channel Common": lambda c: c.channels,
|
||||
"API Server": lambda c: c.api,
|
||||
@@ -1435,7 +1476,7 @@ _SETTINGS_GETTER = {
|
||||
"Tools": lambda c: c.tools,
|
||||
}
|
||||
|
||||
_SETTINGS_SETTER = {
|
||||
_SETTINGS_SETTER: dict[str, Callable[[Config, BaseModel], None]] = {
|
||||
"Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
|
||||
"Channel Common": lambda c, v: setattr(c, "channels", v),
|
||||
"API Server": lambda c, v: setattr(c, "api", v),
|
||||
@@ -1449,7 +1490,7 @@ def _configure_general_settings(config: Config, section: str) -> None:
|
||||
meta = _SETTINGS_SECTIONS.get(section)
|
||||
if not meta:
|
||||
return
|
||||
display_name, subtitle, skip = meta
|
||||
display_name, _subtitle, skip = meta
|
||||
model = _SETTINGS_GETTER[section](config)
|
||||
updated = _configure_pydantic_model(model, display_name, skip_fields=skip)
|
||||
if updated is not None:
|
||||
@@ -1495,7 +1536,7 @@ def _show_summary(config: Config) -> None:
|
||||
console.print()
|
||||
|
||||
# Providers
|
||||
provider_rows = []
|
||||
provider_rows: list[tuple[str, str]] = []
|
||||
for name, display in _get_provider_names().items():
|
||||
provider = getattr(config.providers, name, None)
|
||||
status = (
|
||||
@@ -1507,12 +1548,12 @@ def _show_summary(config: Config) -> None:
|
||||
_print_summary_panel(provider_rows, "LLM Providers")
|
||||
|
||||
# Channels
|
||||
channel_rows = []
|
||||
channel_rows: list[tuple[str, str]] = []
|
||||
for name, display in _get_channel_names().items():
|
||||
channel = getattr(config.channels, name, None)
|
||||
if channel:
|
||||
enabled = (
|
||||
channel.get("enabled", False)
|
||||
cast(dict[str, Any], channel).get("enabled", False)
|
||||
if isinstance(channel, dict)
|
||||
else getattr(channel, "enabled", False)
|
||||
)
|
||||
@@ -1523,7 +1564,7 @@ def _show_summary(config: Config) -> None:
|
||||
_print_summary_panel(channel_rows, "Chat Channels")
|
||||
|
||||
# Model Presets
|
||||
preset_rows = []
|
||||
preset_rows: list[tuple[str, str]] = []
|
||||
for name, preset in config.model_presets.items():
|
||||
preset_rows.append((name, f"{preset.model} - ctx {preset.context_window_tokens}"))
|
||||
_print_summary_panel(preset_rows, "Model Presets")
|
||||
@@ -1562,7 +1603,7 @@ def _set_primary_quick_start_preset(config: Config, provider_name: str, model: s
|
||||
|
||||
def _show_quick_start_progress(active_step: int) -> None:
|
||||
"""Render a compact step tracker for Quick Start."""
|
||||
parts = []
|
||||
parts: list[str] = []
|
||||
for idx, label in enumerate(_QUICK_START_STEPS, 1):
|
||||
if idx < active_step:
|
||||
parts.append(f"[{_UI_SUCCESS}]{idx}. {label}[/]")
|
||||
@@ -1755,7 +1796,10 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
|
||||
continue
|
||||
if api_base_result is None:
|
||||
return False
|
||||
api_base, base_was_prompted = api_base_result
|
||||
api_base, base_was_prompted = cast(
|
||||
tuple[str, bool],
|
||||
api_base_result,
|
||||
)
|
||||
|
||||
api_key: str | None = None
|
||||
if _quick_start_requires_api_key(provider_name, provider_info):
|
||||
@@ -1778,7 +1822,10 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
|
||||
continue
|
||||
if api_base_result is None:
|
||||
return False
|
||||
api_base, base_was_prompted = api_base_result
|
||||
api_base, base_was_prompted = cast(
|
||||
tuple[str, bool],
|
||||
api_base_result,
|
||||
)
|
||||
|
||||
provider_config = getattr(config.providers, provider_name, None)
|
||||
if provider_config is None:
|
||||
@@ -1792,7 +1839,7 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
|
||||
)
|
||||
if model is _BACK_PRESSED:
|
||||
continue
|
||||
model = (model or "").strip()
|
||||
model = cast(str, model or "").strip()
|
||||
if not model:
|
||||
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
|
||||
return False
|
||||
@@ -1850,7 +1897,7 @@ def _enable_quick_start_websocket_defaults(config: Config) -> bool:
|
||||
console.print("[red]No configuration class found for websocket[/red]")
|
||||
return False
|
||||
|
||||
current = getattr(config.channels, "websocket", None) or {}
|
||||
current: Any = getattr(config.channels, "websocket", None) or {}
|
||||
model = config_cls.model_validate(current)
|
||||
if hasattr(model, "enabled"):
|
||||
setattr(model, "enabled", True)
|
||||
@@ -1997,7 +2044,7 @@ def _configure_advanced_settings(config: Config) -> None:
|
||||
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
||||
break
|
||||
|
||||
_advanced_dispatch = {
|
||||
_advanced_dispatch: dict[str, Callable[[], None]] = {
|
||||
"[P] LLM Provider": lambda: _configure_providers(config),
|
||||
"[M] Model Presets": lambda: _configure_model_presets(config),
|
||||
"[C] Chat Channel": lambda: _configure_channels(config),
|
||||
@@ -2008,9 +2055,9 @@ def _configure_advanced_settings(config: Config) -> None:
|
||||
"[T] Tools": lambda: _configure_general_settings(config, "Tools"),
|
||||
"[V] View Configuration Summary": lambda: _show_summary(config),
|
||||
}
|
||||
action_fn = _advanced_dispatch.get(answer)
|
||||
action_fn = _advanced_dispatch.get(cast(str, answer))
|
||||
if action_fn:
|
||||
last_choice = answer
|
||||
last_choice = cast(str, answer)
|
||||
action_fn()
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from typing import Literal
|
||||
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
@@ -51,12 +52,12 @@ class ThinkingSpinner:
|
||||
self._spinner = c.status(f"[dim]{bot_name} is thinking...[/dim]", spinner="dots")
|
||||
self._active = False
|
||||
|
||||
def __enter__(self):
|
||||
def __enter__(self) -> ThinkingSpinner:
|
||||
self._spinner.start()
|
||||
self._active = True
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
def __exit__(self, *exc: object) -> Literal[False]:
|
||||
self._active = False
|
||||
self._spinner.stop()
|
||||
_clear_current_line(self._console)
|
||||
@@ -110,7 +111,7 @@ class StreamRenderer:
|
||||
self._header_printed = False
|
||||
self._start_spinner()
|
||||
|
||||
def _renderable(self):
|
||||
def _renderable(self) -> Markdown | Text:
|
||||
"""Create a renderable from the current buffer."""
|
||||
if self._md and self._buf:
|
||||
return Markdown(self._buf)
|
||||
|
||||
Reference in New Issue
Block a user