Merge branch 'main' into pr-2304
This commit is contained in:
@@ -120,12 +120,13 @@ class AgentLoop:
|
||||
self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
|
||||
for cls in (WriteFileTool, EditFileTool, ListDirTool):
|
||||
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||
self.tools.register(ExecTool(
|
||||
working_dir=str(self.workspace),
|
||||
timeout=self.exec_config.timeout,
|
||||
restrict_to_workspace=self.restrict_to_workspace,
|
||||
path_append=self.exec_config.path_append,
|
||||
))
|
||||
if self.exec_config.enable:
|
||||
self.tools.register(ExecTool(
|
||||
working_dir=str(self.workspace),
|
||||
timeout=self.exec_config.timeout,
|
||||
restrict_to_workspace=self.restrict_to_workspace,
|
||||
path_append=self.exec_config.path_append,
|
||||
))
|
||||
self.tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
|
||||
self.tools.register(WebFetchTool(proxy=self.web_proxy))
|
||||
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
|
||||
|
||||
@@ -32,7 +32,9 @@ class SpawnTool(Tool):
|
||||
return (
|
||||
"Spawn a subagent to handle a task in the background. "
|
||||
"Use this for complex or time-consuming tasks that can run independently. "
|
||||
"The subagent will complete the task and report back when done."
|
||||
"The subagent will complete the task and report back when done. "
|
||||
"For deliverables or existing projects, inspect the workspace first "
|
||||
"and use a dedicated subdirectory when helpful."
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -80,6 +80,21 @@ class EmailChannel(BaseChannel):
|
||||
"Nov",
|
||||
"Dec",
|
||||
)
|
||||
_IMAP_RECONNECT_MARKERS = (
|
||||
"disconnected for inactivity",
|
||||
"eof occurred in violation of protocol",
|
||||
"socket error",
|
||||
"connection reset",
|
||||
"broken pipe",
|
||||
"bye",
|
||||
)
|
||||
_IMAP_MISSING_MAILBOX_MARKERS = (
|
||||
"mailbox doesn't exist",
|
||||
"select failed",
|
||||
"no such mailbox",
|
||||
"can't open mailbox",
|
||||
"does not exist",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
@@ -267,8 +282,37 @@ class EmailChannel(BaseChannel):
|
||||
dedupe: bool,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch messages by arbitrary IMAP search criteria."""
|
||||
messages: list[dict[str, Any]] = []
|
||||
cycle_uids: set[str] = set()
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
self._fetch_messages_once(
|
||||
search_criteria,
|
||||
mark_seen,
|
||||
dedupe,
|
||||
limit,
|
||||
messages,
|
||||
cycle_uids,
|
||||
)
|
||||
return messages
|
||||
except Exception as exc:
|
||||
if attempt == 1 or not self._is_stale_imap_error(exc):
|
||||
raise
|
||||
logger.warning("Email IMAP connection went stale, retrying once: {}", exc)
|
||||
|
||||
return messages
|
||||
|
||||
def _fetch_messages_once(
|
||||
self,
|
||||
search_criteria: tuple[str, ...],
|
||||
mark_seen: bool,
|
||||
dedupe: bool,
|
||||
limit: int,
|
||||
messages: list[dict[str, Any]],
|
||||
cycle_uids: set[str],
|
||||
) -> None:
|
||||
"""Fetch messages by arbitrary IMAP search criteria."""
|
||||
mailbox = self.config.imap_mailbox or "INBOX"
|
||||
|
||||
if self.config.imap_use_ssl:
|
||||
@@ -278,8 +322,15 @@ class EmailChannel(BaseChannel):
|
||||
|
||||
try:
|
||||
client.login(self.config.imap_username, self.config.imap_password)
|
||||
status, _ = client.select(mailbox)
|
||||
try:
|
||||
status, _ = client.select(mailbox)
|
||||
except Exception as exc:
|
||||
if self._is_missing_mailbox_error(exc):
|
||||
logger.warning("Email mailbox unavailable, skipping poll for {}: {}", mailbox, exc)
|
||||
return messages
|
||||
raise
|
||||
if status != "OK":
|
||||
logger.warning("Email mailbox select returned {}, skipping poll for {}", status, mailbox)
|
||||
return messages
|
||||
|
||||
status, data = client.search(None, *search_criteria)
|
||||
@@ -299,6 +350,8 @@ class EmailChannel(BaseChannel):
|
||||
continue
|
||||
|
||||
uid = self._extract_uid(fetched)
|
||||
if uid and uid in cycle_uids:
|
||||
continue
|
||||
if dedupe and uid and uid in self._processed_uids:
|
||||
continue
|
||||
|
||||
@@ -341,6 +394,8 @@ class EmailChannel(BaseChannel):
|
||||
}
|
||||
)
|
||||
|
||||
if uid:
|
||||
cycle_uids.add(uid)
|
||||
if dedupe and uid:
|
||||
self._processed_uids.add(uid)
|
||||
# mark_seen is the primary dedup; this set is a safety net
|
||||
@@ -356,7 +411,15 @@ class EmailChannel(BaseChannel):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return messages
|
||||
@classmethod
|
||||
def _is_stale_imap_error(cls, exc: Exception) -> bool:
|
||||
message = str(exc).lower()
|
||||
return any(marker in message for marker in cls._IMAP_RECONNECT_MARKERS)
|
||||
|
||||
@classmethod
|
||||
def _is_missing_mailbox_error(cls, exc: Exception) -> bool:
|
||||
message = str(exc).lower()
|
||||
return any(marker in message for marker in cls._IMAP_MISSING_MAILBOX_MARKERS)
|
||||
|
||||
@classmethod
|
||||
def _format_imap_date(cls, value: date) -> str:
|
||||
|
||||
@@ -191,6 +191,10 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
|
||||
texts.append(el.get("text", ""))
|
||||
elif tag == "at":
|
||||
texts.append(f"@{el.get('user_name', 'user')}")
|
||||
elif tag == "code_block":
|
||||
lang = el.get("language", "")
|
||||
code_text = el.get("text", "")
|
||||
texts.append(f"\n```{lang}\n{code_text}\n```\n")
|
||||
elif tag == "img" and (key := el.get("image_key")):
|
||||
images.append(key)
|
||||
return (" ".join(texts).strip() or None), images
|
||||
@@ -1039,7 +1043,7 @@ class FeishuChannel(BaseChannel):
|
||||
event = data.event
|
||||
message = event.message
|
||||
sender = event.sender
|
||||
|
||||
|
||||
# Deduplication check
|
||||
message_id = message.message_id
|
||||
if message_id in self._processed_message_ids:
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, Literal
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
from telegram import BotCommand, ReplyParameters, Update
|
||||
from telegram.error import TimedOut
|
||||
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
|
||||
from telegram.request import HTTPXRequest
|
||||
|
||||
@@ -151,6 +152,10 @@ def _markdown_to_telegram_html(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
_SEND_MAX_RETRIES = 3
|
||||
_SEND_RETRY_BASE_DELAY = 0.5 # seconds, doubled each retry
|
||||
|
||||
|
||||
class TelegramConfig(Base):
|
||||
"""Telegram channel configuration."""
|
||||
|
||||
@@ -160,6 +165,8 @@ class TelegramConfig(Base):
|
||||
proxy: str | None = None
|
||||
reply_to_message: bool = False
|
||||
group_policy: Literal["open", "mention"] = "mention"
|
||||
connection_pool_size: int = 32
|
||||
pool_timeout: float = 5.0
|
||||
|
||||
|
||||
class TelegramChannel(BaseChannel):
|
||||
@@ -226,15 +233,29 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
self._running = True
|
||||
|
||||
# Build the application with larger connection pool to avoid pool-timeout on long runs
|
||||
req = HTTPXRequest(
|
||||
connection_pool_size=16,
|
||||
pool_timeout=5.0,
|
||||
proxy = self.config.proxy or None
|
||||
|
||||
# Separate pools so long-polling (getUpdates) never starves outbound sends.
|
||||
api_request = HTTPXRequest(
|
||||
connection_pool_size=self.config.connection_pool_size,
|
||||
pool_timeout=self.config.pool_timeout,
|
||||
connect_timeout=30.0,
|
||||
read_timeout=30.0,
|
||||
proxy=self.config.proxy if self.config.proxy else None,
|
||||
proxy=proxy,
|
||||
)
|
||||
poll_request = HTTPXRequest(
|
||||
connection_pool_size=4,
|
||||
pool_timeout=self.config.pool_timeout,
|
||||
connect_timeout=30.0,
|
||||
read_timeout=30.0,
|
||||
proxy=proxy,
|
||||
)
|
||||
builder = (
|
||||
Application.builder()
|
||||
.token(self.config.token)
|
||||
.request(api_request)
|
||||
.get_updates_request(poll_request)
|
||||
)
|
||||
builder = Application.builder().token(self.config.token).request(req).get_updates_request(req)
|
||||
self._app = builder.build()
|
||||
self._app.add_error_handler(self._on_error)
|
||||
|
||||
@@ -365,7 +386,8 @@ class TelegramChannel(BaseChannel):
|
||||
ok, error = validate_url_target(media_path)
|
||||
if not ok:
|
||||
raise ValueError(f"unsafe media URL: {error}")
|
||||
await sender(
|
||||
await self._call_with_retry(
|
||||
sender,
|
||||
chat_id=chat_id,
|
||||
**{param: media_path},
|
||||
reply_parameters=reply_params,
|
||||
@@ -401,6 +423,21 @@ class TelegramChannel(BaseChannel):
|
||||
else:
|
||||
await self._send_text(chat_id, chunk, reply_params, thread_kwargs)
|
||||
|
||||
async def _call_with_retry(self, fn, *args, **kwargs):
|
||||
"""Call an async Telegram API function with retry on pool/network timeout."""
|
||||
for attempt in range(1, _SEND_MAX_RETRIES + 1):
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
except TimedOut:
|
||||
if attempt == _SEND_MAX_RETRIES:
|
||||
raise
|
||||
delay = _SEND_RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
"Telegram timeout (attempt {}/{}), retrying in {:.1f}s",
|
||||
attempt, _SEND_MAX_RETRIES, delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def _send_text(
|
||||
self,
|
||||
chat_id: int,
|
||||
@@ -411,7 +448,8 @@ class TelegramChannel(BaseChannel):
|
||||
"""Send a plain text message with HTML fallback."""
|
||||
try:
|
||||
html = _markdown_to_telegram_html(text)
|
||||
await self._app.bot.send_message(
|
||||
await self._call_with_retry(
|
||||
self._app.bot.send_message,
|
||||
chat_id=chat_id, text=html, parse_mode="HTML",
|
||||
reply_parameters=reply_params,
|
||||
**(thread_kwargs or {}),
|
||||
@@ -419,7 +457,8 @@ class TelegramChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
||||
try:
|
||||
await self._app.bot.send_message(
|
||||
await self._call_with_retry(
|
||||
self._app.bot.send_message,
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_parameters=reply_params,
|
||||
|
||||
@@ -21,12 +21,11 @@ if sys.platform == "win32":
|
||||
pass
|
||||
|
||||
import typer
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.application import run_in_terminal
|
||||
from prompt_toolkit.formatted_text import ANSI, HTML
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from prompt_toolkit.application import run_in_terminal
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.table import Table
|
||||
@@ -39,6 +38,7 @@ from nanobot.utils.helpers import sync_workspace_templates
|
||||
|
||||
app = typer.Typer(
|
||||
name="nanobot",
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
help=f"{__logo__} nanobot - Personal AI Assistant",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
@@ -265,6 +265,7 @@ def main(
|
||||
def onboard(
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
wizard: bool = typer.Option(False, "--wizard", help="Use interactive wizard"),
|
||||
):
|
||||
"""Initialize nanobot configuration and workspace."""
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
|
||||
@@ -284,42 +285,69 @@ def onboard(
|
||||
|
||||
# Create or update config
|
||||
if config_path.exists():
|
||||
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
||||
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
|
||||
console.print(" [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)
|
||||
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
|
||||
else:
|
||||
if wizard:
|
||||
config = _apply_workspace_override(load_config(config_path))
|
||||
save_config(config, config_path)
|
||||
console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
|
||||
else:
|
||||
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
||||
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
|
||||
console.print(" [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)
|
||||
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
|
||||
else:
|
||||
config = _apply_workspace_override(load_config(config_path))
|
||||
save_config(config, config_path)
|
||||
console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
|
||||
else:
|
||||
config = _apply_workspace_override(Config())
|
||||
save_config(config, config_path)
|
||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||
console.print("[dim]Config template now uses `maxTokens` + `contextWindowTokens`; `memoryWindow` is no longer a runtime setting.[/dim]")
|
||||
# In wizard mode, don't save yet - the wizard will handle saving if should_save=True
|
||||
if not wizard:
|
||||
save_config(config, config_path)
|
||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||
|
||||
# Run interactive wizard if enabled
|
||||
if wizard:
|
||||
from nanobot.cli.onboard_wizard import run_onboard
|
||||
|
||||
try:
|
||||
result = run_onboard(initial_config=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)
|
||||
console.print(f"[green]✓[/green] Config saved at {config_path}")
|
||||
except Exception as e:
|
||||
console.print(f"[red]✗[/red] Error during configuration: {e}")
|
||||
console.print("[yellow]Please run 'nanobot onboard' again to complete setup.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
_onboard_plugins(config_path)
|
||||
|
||||
# Create workspace, preferring the configured workspace path.
|
||||
workspace = get_workspace_path(config.workspace_path)
|
||||
if not workspace.exists():
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
||||
workspace_path = get_workspace_path(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}")
|
||||
|
||||
sync_workspace_templates(workspace)
|
||||
sync_workspace_templates(workspace_path)
|
||||
|
||||
agent_cmd = 'nanobot agent -m "Hello!"'
|
||||
gateway_cmd = "nanobot gateway"
|
||||
if config:
|
||||
agent_cmd += f" --config {config_path}"
|
||||
gateway_cmd += f" --config {config_path}"
|
||||
|
||||
console.print(f"\n{__logo__} nanobot is ready!")
|
||||
console.print("\nNext steps:")
|
||||
console.print(f" 1. Add your API key to [cyan]{config_path}[/cyan]")
|
||||
console.print(" Get one at: https://openrouter.ai/keys")
|
||||
console.print(f" 2. Chat: [cyan]{agent_cmd}[/cyan]")
|
||||
if wizard:
|
||||
console.print(f" 1. Chat: [cyan]{agent_cmd}[/cyan]")
|
||||
console.print(f" 2. Start gateway: [cyan]{gateway_cmd}[/cyan]")
|
||||
else:
|
||||
console.print(f" 1. Add your API key to [cyan]{config_path}[/cyan]")
|
||||
console.print(" Get one at: https://openrouter.ai/keys")
|
||||
console.print(f" 2. Chat: [cyan]{agent_cmd}[/cyan]")
|
||||
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
|
||||
|
||||
|
||||
@@ -363,9 +391,9 @@ def _onboard_plugins(config_path: Path) -> None:
|
||||
|
||||
def _make_provider(config: Config):
|
||||
"""Create the appropriate LLM provider from config."""
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
|
||||
model = config.agents.defaults.model
|
||||
provider_name = config.get_provider_name(model)
|
||||
@@ -434,21 +462,30 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
|
||||
console.print(f"[dim]Using config: {config_path}[/dim]")
|
||||
|
||||
loaded = load_config(config_path)
|
||||
_warn_deprecated_config_keys(config_path)
|
||||
if workspace:
|
||||
loaded.agents.defaults.workspace = workspace
|
||||
return loaded
|
||||
|
||||
|
||||
def _print_deprecated_memory_window_notice(config: Config) -> None:
|
||||
"""Warn when running with old memoryWindow-only config."""
|
||||
if config.agents.defaults.should_warn_deprecated_memory_window:
|
||||
def _warn_deprecated_config_keys(config_path: Path | None) -> None:
|
||||
"""Hint users to remove obsolete keys from their config file."""
|
||||
import json
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
path = config_path or get_config_path()
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
if "memoryWindow" in raw.get("agents", {}).get("defaults", {}):
|
||||
console.print(
|
||||
"[yellow]Hint:[/yellow] Detected deprecated `memoryWindow` without "
|
||||
"`contextWindowTokens`. `memoryWindow` is ignored; run "
|
||||
"[cyan]nanobot onboard[/cyan] to refresh your config template."
|
||||
"[dim]Hint: `memoryWindow` in your config is no longer used "
|
||||
"and can be safely removed.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Gateway / Server
|
||||
# ============================================================================
|
||||
@@ -476,7 +513,6 @@ def gateway(
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
config = _load_runtime_config(config, workspace)
|
||||
_print_deprecated_memory_window_notice(config)
|
||||
port = port if port is not None else config.gateway.port
|
||||
|
||||
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
|
||||
@@ -667,7 +703,6 @@ def agent(
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
config = _load_runtime_config(config, workspace)
|
||||
_print_deprecated_memory_window_notice(config)
|
||||
sync_workspace_templates(config.workspace_path)
|
||||
|
||||
bus = MessageBus()
|
||||
|
||||
231
nanobot/cli/model_info.py
Normal file
231
nanobot/cli/model_info.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Model information helpers for the onboard wizard.
|
||||
|
||||
Provides model context window lookup and autocomplete suggestions using litellm.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _litellm():
|
||||
"""Lazy accessor for litellm (heavy import deferred until actually needed)."""
|
||||
import litellm as _ll
|
||||
|
||||
return _ll
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_model_cost_map() -> dict[str, Any]:
|
||||
"""Get litellm's model cost map (cached)."""
|
||||
return getattr(_litellm(), "model_cost", {})
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_all_models() -> list[str]:
|
||||
"""Get all known model names from litellm.
|
||||
"""
|
||||
models = set()
|
||||
|
||||
# From model_cost (has pricing info)
|
||||
cost_map = _get_model_cost_map()
|
||||
for k in cost_map.keys():
|
||||
if k != "sample_spec":
|
||||
models.add(k)
|
||||
|
||||
# From models_by_provider (more complete provider coverage)
|
||||
for provider_models in getattr(_litellm(), "models_by_provider", {}).values():
|
||||
if isinstance(provider_models, (set, list)):
|
||||
models.update(provider_models)
|
||||
|
||||
return sorted(models)
|
||||
|
||||
|
||||
def _normalize_model_name(model: str) -> str:
|
||||
"""Normalize model name for comparison."""
|
||||
return model.lower().replace("-", "_").replace(".", "")
|
||||
|
||||
|
||||
def find_model_info(model_name: str) -> dict[str, Any] | None:
|
||||
"""Find model info with fuzzy matching.
|
||||
|
||||
Args:
|
||||
model_name: Model name in any common format
|
||||
|
||||
Returns:
|
||||
Model info dict or None if not found
|
||||
"""
|
||||
cost_map = _get_model_cost_map()
|
||||
if not cost_map:
|
||||
return None
|
||||
|
||||
# Direct match
|
||||
if model_name in cost_map:
|
||||
return cost_map[model_name]
|
||||
|
||||
# Extract base name (without provider prefix)
|
||||
base_name = model_name.split("/")[-1] if "/" in model_name else model_name
|
||||
base_normalized = _normalize_model_name(base_name)
|
||||
|
||||
candidates = []
|
||||
|
||||
for key, info in cost_map.items():
|
||||
if key == "sample_spec":
|
||||
continue
|
||||
|
||||
key_base = key.split("/")[-1] if "/" in key else key
|
||||
key_base_normalized = _normalize_model_name(key_base)
|
||||
|
||||
# Score the match
|
||||
score = 0
|
||||
|
||||
# Exact base name match (highest priority)
|
||||
if base_normalized == key_base_normalized:
|
||||
score = 100
|
||||
# Base name contains model
|
||||
elif base_normalized in key_base_normalized:
|
||||
score = 80
|
||||
# Model contains base name
|
||||
elif key_base_normalized in base_normalized:
|
||||
score = 70
|
||||
# Partial match
|
||||
elif base_normalized[:10] in key_base_normalized:
|
||||
score = 50
|
||||
|
||||
if score > 0:
|
||||
# Prefer models with max_input_tokens
|
||||
if info.get("max_input_tokens"):
|
||||
score += 10
|
||||
candidates.append((score, key, info))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Return the best match
|
||||
candidates.sort(key=lambda x: (-x[0], x[1]))
|
||||
return candidates[0][2]
|
||||
|
||||
|
||||
def get_model_context_limit(model: str, provider: str = "auto") -> int | None:
|
||||
"""Get the maximum input context tokens for a model.
|
||||
|
||||
Args:
|
||||
model: Model name (e.g., "claude-3.5-sonnet", "gpt-4o")
|
||||
provider: Provider name for informational purposes (not yet used for filtering)
|
||||
|
||||
Returns:
|
||||
Maximum input tokens, or None if unknown
|
||||
|
||||
Note:
|
||||
The provider parameter is currently informational only. Future versions may
|
||||
use it to prefer provider-specific model variants in the lookup.
|
||||
"""
|
||||
# First try fuzzy search in model_cost (has more accurate max_input_tokens)
|
||||
info = find_model_info(model)
|
||||
if info:
|
||||
# Prefer max_input_tokens (this is what we want for context window)
|
||||
max_input = info.get("max_input_tokens")
|
||||
if max_input and isinstance(max_input, int):
|
||||
return max_input
|
||||
|
||||
# Fall back to litellm's get_max_tokens (returns max_output_tokens typically)
|
||||
try:
|
||||
result = _litellm().get_max_tokens(model)
|
||||
if result and result > 0:
|
||||
return result
|
||||
except (KeyError, ValueError, AttributeError):
|
||||
# Model not found in litellm's database or invalid response
|
||||
pass
|
||||
|
||||
# Last resort: use max_tokens from model_cost
|
||||
if info:
|
||||
max_tokens = info.get("max_tokens")
|
||||
if max_tokens and isinstance(max_tokens, int):
|
||||
return max_tokens
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_provider_keywords() -> dict[str, list[str]]:
|
||||
"""Build provider keywords mapping from nanobot's provider registry.
|
||||
|
||||
Returns:
|
||||
Dict mapping provider name to list of keywords for model filtering.
|
||||
"""
|
||||
try:
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
mapping = {}
|
||||
for spec in PROVIDERS:
|
||||
if spec.keywords:
|
||||
mapping[spec.name] = list(spec.keywords)
|
||||
return mapping
|
||||
except ImportError:
|
||||
return {}
|
||||
|
||||
|
||||
def get_model_suggestions(partial: str, provider: str = "auto", limit: int = 20) -> list[str]:
|
||||
"""Get autocomplete suggestions for model names.
|
||||
|
||||
Args:
|
||||
partial: Partial model name typed by user
|
||||
provider: Provider name for filtering (e.g., "openrouter", "minimax")
|
||||
limit: Maximum number of suggestions to return
|
||||
|
||||
Returns:
|
||||
List of matching model names
|
||||
"""
|
||||
all_models = get_all_models()
|
||||
if not all_models:
|
||||
return []
|
||||
|
||||
partial_lower = partial.lower()
|
||||
partial_normalized = _normalize_model_name(partial)
|
||||
|
||||
# Get provider keywords from registry
|
||||
provider_keywords = _get_provider_keywords()
|
||||
|
||||
# Filter by provider if specified
|
||||
allowed_keywords = None
|
||||
if provider and provider != "auto":
|
||||
allowed_keywords = provider_keywords.get(provider.lower())
|
||||
|
||||
matches = []
|
||||
|
||||
for model in all_models:
|
||||
model_lower = model.lower()
|
||||
|
||||
# Apply provider filter
|
||||
if allowed_keywords:
|
||||
if not any(kw in model_lower for kw in allowed_keywords):
|
||||
continue
|
||||
|
||||
# Match against partial input
|
||||
if not partial:
|
||||
matches.append(model)
|
||||
continue
|
||||
|
||||
if partial_lower in model_lower:
|
||||
# Score by position of match (earlier = better)
|
||||
pos = model_lower.find(partial_lower)
|
||||
score = 100 - pos
|
||||
matches.append((score, model))
|
||||
elif partial_normalized in _normalize_model_name(model):
|
||||
score = 50
|
||||
matches.append((score, model))
|
||||
|
||||
# Sort by score if we have scored matches
|
||||
if matches and isinstance(matches[0], tuple):
|
||||
matches.sort(key=lambda x: (-x[0], x[1]))
|
||||
matches = [m[1] for m in matches]
|
||||
else:
|
||||
matches.sort()
|
||||
|
||||
return matches[:limit]
|
||||
|
||||
|
||||
def format_token_count(tokens: int) -> str:
|
||||
"""Format token count for display (e.g., 200000 -> '200,000')."""
|
||||
return f"{tokens:,}"
|
||||
1023
nanobot/cli/onboard_wizard.py
Normal file
1023
nanobot/cli/onboard_wizard.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,10 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
import pydantic
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
# Global variable to store current config path (for multi-instance support)
|
||||
_current_config_path: Path | None = None
|
||||
@@ -41,9 +43,9 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
data = json.load(f)
|
||||
data = _migrate_config(data)
|
||||
return Config.model_validate(data)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
print(f"Warning: Failed to load config from {path}: {e}")
|
||||
print("Using default configuration.")
|
||||
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
|
||||
logger.warning(f"Failed to load config from {path}: {e}")
|
||||
logger.warning("Using default configuration.")
|
||||
|
||||
return Config()
|
||||
|
||||
@@ -59,7 +61,7 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||
path = config_path or get_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = config.model_dump(by_alias=True)
|
||||
data = config.model_dump(mode="json", by_alias=True)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
@@ -38,14 +38,7 @@ class AgentDefaults(Base):
|
||||
context_window_tokens: int = 65_536
|
||||
temperature: float = 0.1
|
||||
max_tool_iterations: int = 40
|
||||
# Deprecated compatibility field: accepted from old configs but ignored at runtime.
|
||||
memory_window: int | None = Field(default=None, exclude=True)
|
||||
reasoning_effort: str | None = None # low / medium / high — enables LLM thinking mode
|
||||
|
||||
@property
|
||||
def should_warn_deprecated_memory_window(self) -> bool:
|
||||
"""Return True when old memoryWindow is present without contextWindowTokens."""
|
||||
return self.memory_window is not None and "context_window_tokens" not in self.model_fields_set
|
||||
reasoning_effort: str | None = None # low / medium / high - enables LLM thinking mode
|
||||
|
||||
|
||||
class AgentsConfig(Base):
|
||||
@@ -85,8 +78,8 @@ class ProvidersConfig(Base):
|
||||
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
|
||||
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
|
||||
byteplus_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan
|
||||
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
|
||||
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
|
||||
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # OpenAI Codex (OAuth)
|
||||
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # Github Copilot (OAuth)
|
||||
|
||||
|
||||
class HeartbeatConfig(Base):
|
||||
@@ -125,10 +118,10 @@ class WebToolsConfig(Base):
|
||||
class ExecToolConfig(Base):
|
||||
"""Shell exec tool configuration."""
|
||||
|
||||
enable: bool = True
|
||||
timeout: int = 60
|
||||
path_append: str = ""
|
||||
|
||||
|
||||
class MCPServerConfig(Base):
|
||||
"""MCP server connection configuration (stdio or HTTP)."""
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Any, Callable, Coroutine
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule, CronStore
|
||||
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
@@ -63,10 +63,12 @@ def _validate_schedule_for_add(schedule: CronSchedule) -> None:
|
||||
class CronService:
|
||||
"""Service for managing and executing scheduled jobs."""
|
||||
|
||||
_MAX_RUN_HISTORY = 20
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store_path: Path,
|
||||
on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None
|
||||
on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None,
|
||||
):
|
||||
self.store_path = store_path
|
||||
self.on_job = on_job
|
||||
@@ -113,6 +115,15 @@ class CronService:
|
||||
last_run_at_ms=j.get("state", {}).get("lastRunAtMs"),
|
||||
last_status=j.get("state", {}).get("lastStatus"),
|
||||
last_error=j.get("state", {}).get("lastError"),
|
||||
run_history=[
|
||||
CronRunRecord(
|
||||
run_at_ms=r["runAtMs"],
|
||||
status=r["status"],
|
||||
duration_ms=r.get("durationMs", 0),
|
||||
error=r.get("error"),
|
||||
)
|
||||
for r in j.get("state", {}).get("runHistory", [])
|
||||
],
|
||||
),
|
||||
created_at_ms=j.get("createdAtMs", 0),
|
||||
updated_at_ms=j.get("updatedAtMs", 0),
|
||||
@@ -160,6 +171,15 @@ class CronService:
|
||||
"lastRunAtMs": j.state.last_run_at_ms,
|
||||
"lastStatus": j.state.last_status,
|
||||
"lastError": j.state.last_error,
|
||||
"runHistory": [
|
||||
{
|
||||
"runAtMs": r.run_at_ms,
|
||||
"status": r.status,
|
||||
"durationMs": r.duration_ms,
|
||||
"error": r.error,
|
||||
}
|
||||
for r in j.state.run_history
|
||||
],
|
||||
},
|
||||
"createdAtMs": j.created_at_ms,
|
||||
"updatedAtMs": j.updated_at_ms,
|
||||
@@ -248,9 +268,8 @@ class CronService:
|
||||
logger.info("Cron: executing job '{}' ({})", job.name, job.id)
|
||||
|
||||
try:
|
||||
response = None
|
||||
if self.on_job:
|
||||
response = await self.on_job(job)
|
||||
await self.on_job(job)
|
||||
|
||||
job.state.last_status = "ok"
|
||||
job.state.last_error = None
|
||||
@@ -261,8 +280,17 @@ class CronService:
|
||||
job.state.last_error = str(e)
|
||||
logger.error("Cron: job '{}' failed: {}", job.name, e)
|
||||
|
||||
end_ms = _now_ms()
|
||||
job.state.last_run_at_ms = start_ms
|
||||
job.updated_at_ms = _now_ms()
|
||||
job.updated_at_ms = end_ms
|
||||
|
||||
job.state.run_history.append(CronRunRecord(
|
||||
run_at_ms=start_ms,
|
||||
status=job.state.last_status,
|
||||
duration_ms=end_ms - start_ms,
|
||||
error=job.state.last_error,
|
||||
))
|
||||
job.state.run_history = job.state.run_history[-self._MAX_RUN_HISTORY:]
|
||||
|
||||
# Handle one-shot jobs
|
||||
if job.schedule.kind == "at":
|
||||
@@ -366,6 +394,11 @@ class CronService:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_job(self, job_id: str) -> CronJob | None:
|
||||
"""Get a job by ID."""
|
||||
store = self._load_store()
|
||||
return next((j for j in store.jobs if j.id == job_id), None)
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Get service status."""
|
||||
store = self._load_store()
|
||||
|
||||
@@ -29,6 +29,15 @@ class CronPayload:
|
||||
to: str | None = None # e.g. phone number
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronRunRecord:
|
||||
"""A single execution record for a cron job."""
|
||||
run_at_ms: int
|
||||
status: Literal["ok", "error", "skipped"]
|
||||
duration_ms: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronJobState:
|
||||
"""Runtime state of a job."""
|
||||
@@ -36,6 +45,7 @@ class CronJobState:
|
||||
last_run_at_ms: int | None = None
|
||||
last_status: Literal["ok", "error", "skipped"] | None = None
|
||||
last_error: str | None = None
|
||||
run_history: list[CronRunRecord] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -51,6 +51,12 @@ class CustomProvider(LLMProvider):
|
||||
try:
|
||||
return self._parse(await self._client.chat.completions.create(**kwargs))
|
||||
except Exception as e:
|
||||
# JSONDecodeError.doc / APIError.response.text may carry the raw body
|
||||
# (e.g. "unsupported model: xxx") which is far more useful than the
|
||||
# generic "Expecting value …" message. Truncate to avoid huge HTML pages.
|
||||
body = getattr(e, "doc", None) or getattr(getattr(e, "response", None), "text", None)
|
||||
if body and body.strip():
|
||||
return LLMResponse(content=f"Error: {body.strip()[:500]}", finish_reason="error")
|
||||
return LLMResponse(content=f"Error: {e}", finish_reason="error")
|
||||
|
||||
def _parse(self, response: Any) -> LLMResponse:
|
||||
|
||||
Reference in New Issue
Block a user