mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
584072cf63 | ||
|
|
7c270577e1 | ||
|
|
2e5930e355 | ||
|
|
83f437a088 | ||
|
|
e34b7fd086 | ||
|
|
12005c20f0 | ||
|
|
9fefb31344 | ||
|
|
28358980ed | ||
|
|
e9f4a868a8 | ||
|
|
2a318d6991 | ||
|
|
22b3010bd0 | ||
|
|
c4b2d9f53b | ||
|
|
84e8aed6b1 | ||
|
|
fb313bd8d1 | ||
|
|
7d3337a98e | ||
|
|
f256d7ab9b | ||
|
|
3baa869fdb | ||
|
|
2103cd5602 | ||
|
|
5b45191cd9 | ||
|
|
a5fcf7786d | ||
|
|
2a67663fab | ||
|
|
059a265078 | ||
|
|
9bcb17abe1 | ||
|
|
016fd15a00 | ||
|
|
7988ce5b74 | ||
|
|
ce4ad50c7d | ||
|
|
4d72e40d35 | ||
|
|
4e314aff0c | ||
|
|
02cad2aa74 | ||
|
|
bcfdd49fa4 | ||
|
|
9cf9272920 | ||
|
|
407314a672 | ||
|
|
ee1365bcf1 | ||
|
|
ebd1891f45 |
@@ -123,6 +123,7 @@
|
|||||||
- **Ultra-lightweight**: stable long-running agent behavior with a small, readable core.
|
- **Ultra-lightweight**: stable long-running agent behavior with a small, readable core.
|
||||||
- **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend.
|
- **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend.
|
||||||
- **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in.
|
- **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in.
|
||||||
|
- **Runtime model switching**: define [model presets](docs/configuration.md#model-presets) and switch between cheap/fast and powerful models mid-conversation — no restart required.
|
||||||
- **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page.
|
- **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page.
|
||||||
|
|
||||||
## 📦 Install
|
## 📦 Install
|
||||||
|
|||||||
@@ -656,6 +656,146 @@ That's it! Environment variables, model routing, config matching, and `nanobot s
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Agent Settings
|
||||||
|
|
||||||
|
### Model Presets
|
||||||
|
|
||||||
|
Model presets let you define **named bundles** of model + generation parameters and switch between them instantly — no restart required.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Config fields in `config.json` use **camelCase** (`modelPreset`, `contextWindowTokens`).
|
||||||
|
> The [`my` tool](./my-tool.md) uses **snake_case** (`model_preset`, `context_window_tokens`).
|
||||||
|
> Both refer to the same thing — just different naming conventions for config vs. runtime API.
|
||||||
|
|
||||||
|
**Why use presets?**
|
||||||
|
- Switch between a cheap/fast model and a powerful model mid-conversation.
|
||||||
|
- Share the same config across different tasks without manually editing `model`, `provider`, `temperature`, etc.
|
||||||
|
- Runtime switching via the [`my` tool](./my-tool.md).
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> The easiest way to set up presets and fallback models is through the interactive wizard:
|
||||||
|
> ```bash
|
||||||
|
> nanobot onboard --wizard
|
||||||
|
> ```
|
||||||
|
> Choose **"[M] Model Presets"** to create, edit, or delete presets interactively.
|
||||||
|
|
||||||
|
**Configuration example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modelPresets": {
|
||||||
|
"fast": {
|
||||||
|
"model": "gpt-4.1-mini",
|
||||||
|
"provider": "openai",
|
||||||
|
"maxTokens": 4096,
|
||||||
|
"contextWindowTokens": 128000,
|
||||||
|
"temperature": 0.3
|
||||||
|
},
|
||||||
|
"deep": {
|
||||||
|
"model": "claude-opus-4-7",
|
||||||
|
"provider": "anthropic",
|
||||||
|
"maxTokens": 8192,
|
||||||
|
"contextWindowTokens": 200000,
|
||||||
|
"temperature": 0.1,
|
||||||
|
"reasoningEffort": "high"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"modelPreset": "fast"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Preset fields:**
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `model` | string | *(required)* | Model identifier, e.g. `anthropic/claude-opus-4-7` or `gpt-4.1` |
|
||||||
|
| `provider` | string | `"auto"` | Provider name or `"auto"` to infer from the model string |
|
||||||
|
| `maxTokens` | integer | `8192` | Max completion tokens per turn |
|
||||||
|
| `contextWindowTokens` | integer | `65536` | Context window size for token budgeting |
|
||||||
|
| `temperature` | float | `0.1` | Sampling temperature |
|
||||||
|
| `reasoningEffort` | string or null | `null` | Thinking mode: `low`, `medium`, `high`, `adaptive` |
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- When `modelPreset` is set, the preset **completely overrides** all model-specific fields in `agents.defaults`.
|
||||||
|
- When `modelPreset` is omitted, nanobot automatically creates an implicit `"default"` preset from your existing `agents.defaults.model`, `provider`, `temperature`, etc. — **zero migration required** for existing configs.
|
||||||
|
|
||||||
|
**Runtime switching** (requires `tools.my.allowSet: true`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
my(action="set", key="model_preset", value="deep")
|
||||||
|
```
|
||||||
|
|
||||||
|
This atomically swaps the model, provider, generation parameters, and context window for the next turn.
|
||||||
|
|
||||||
|
If the preset name does not exist, the agent receives an error such as `model_preset 'unknown' not found. Available: fast, deep`.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Directly modifying `model` or `contextWindowTokens` via `my(action="set", key="model", ...)` still works, but it automatically clears the active preset because the live state no longer matches the preset bundle. Use `model_preset` for atomic switches instead.
|
||||||
|
|
||||||
|
See [`my-tool.md`](./my-tool.md) for more runtime examples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fallback Models
|
||||||
|
|
||||||
|
When the primary model returns a transient error (rate limit, server overload, quota exhausted), nanobot can automatically fail over to a chain of backup models.
|
||||||
|
|
||||||
|
**Configuration example:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"modelPreset": "fast",
|
||||||
|
"fallbackModels": ["deep", "backup"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. nanobot tries the primary model first (the one from the active preset).
|
||||||
|
2. The provider retries transient errors internally (e.g. 3 attempts with exponential backoff for 503/429).
|
||||||
|
3. Only after the provider's own retries are exhausted and the final response still has `finish_reason == "error"` with a retryable error kind, nanobot moves to the next candidate in `fallbackModels`.
|
||||||
|
4. Each candidate must be a preset name defined in `modelPresets`. The preset's full config (model, provider, generation params) is used.
|
||||||
|
5. If all candidates are exhausted, the final error is returned to the user.
|
||||||
|
|
||||||
|
**Failover triggers on:**
|
||||||
|
- `server_error` (503, 502, 500)
|
||||||
|
- `rate_limit` (429)
|
||||||
|
- `insufficient_quota` / `quota_exhausted` (429)
|
||||||
|
|
||||||
|
**Failover does NOT trigger on:**
|
||||||
|
- Authentication errors (401) — rotating to another model with the same key won't help
|
||||||
|
- Invalid request errors (400) — the request itself is malformed
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Fallback models must reference preset names defined in `modelPresets`. Define a preset for each fallback model you want to use: `["cheap-preset", "backup", "emergency"]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Other Agent Defaults
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `agents.defaults.model` | string | `"anthropic/claude-opus-4-5"` | Default model when no preset is active |
|
||||||
|
| `agents.defaults.provider` | string | `"auto"` | Default provider when no preset is active |
|
||||||
|
| `agents.defaults.maxTokens` | integer | `8192` | Max completion tokens when no preset is active |
|
||||||
|
| `agents.defaults.temperature` | float | `0.1` | Sampling temperature when no preset is active |
|
||||||
|
| `agents.defaults.reasoningEffort` | string or null | `null` | Thinking mode when no preset is active |
|
||||||
|
| `agents.defaults.maxToolIterations` | integer | `200` | Max tool calls per conversation turn |
|
||||||
|
| `agents.defaults.maxToolResultChars` | integer | `16000` | Max characters per tool result |
|
||||||
|
| `agents.defaults.providerRetryMode` | string | `"standard"` | `"standard"` or `"persistent"` — how aggressively to retry provider-level errors |
|
||||||
|
| `agents.defaults.timezone` | string | `"UTC"` | IANA timezone for runtime context |
|
||||||
|
| `agents.defaults.unifiedSession` | boolean | `false` | Share one session across all channels |
|
||||||
|
| `agents.defaults.sessionTtlMinutes` | integer | `0` | Auto-compact idle threshold (0 = disabled) |
|
||||||
|
| `agents.defaults.maxMessages` | integer | `120` | Max messages to replay from session history |
|
||||||
|
| `agents.defaults.consolidationRatio` | float | `0.5` | Target ratio retained after context compression |
|
||||||
|
|
||||||
## Channel Settings
|
## Channel Settings
|
||||||
|
|
||||||
Global settings that apply to all channels. Configure under the `channels` section in `~/.nanobot/config.json`:
|
Global settings that apply to all channels. Configure under the `channels` section in `~/.nanobot/config.json`:
|
||||||
|
|||||||
+29
-15
@@ -12,6 +12,11 @@ My tool fills this gap. With it, the agent can:
|
|||||||
- **Adapt on the fly**: Complex task? Expand the context window. Simple chat? Switch to a faster model.
|
- **Adapt on the fly**: Complex task? Expand the context window. Simple chat? Switch to a faster model.
|
||||||
- **Remember across turns**: Store notes in your scratchpad that persist into the next conversation turn.
|
- **Remember across turns**: Store notes in your scratchpad that persist into the next conversation turn.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This tool uses **snake_case** keys (`model_preset`, `context_window_tokens`).
|
||||||
|
> The matching config fields in `config.json` are **camelCase** (`modelPreset`, `contextWindowTokens`).
|
||||||
|
> See [`configuration.md`](./configuration.md#model-presets) for how to define presets in your config.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Enabled by default (read-only mode). The agent can check its state but not set it.
|
Enabled by default (read-only mode). The agent can check its state but not set it.
|
||||||
@@ -39,8 +44,7 @@ Without parameters, returns a key config overview:
|
|||||||
```text
|
```text
|
||||||
my(action="check")
|
my(action="check")
|
||||||
# → max_iterations: 40
|
# → max_iterations: 40
|
||||||
# context_window_tokens: 65536
|
# model_preset: 'fast'
|
||||||
# model: 'anthropic/claude-sonnet-4-20250514'
|
|
||||||
# workspace: PosixPath('/tmp/workspace')
|
# workspace: PosixPath('/tmp/workspace')
|
||||||
# provider_retry_mode: 'standard'
|
# provider_retry_mode: 'standard'
|
||||||
# max_tool_result_chars: 16000
|
# max_tool_result_chars: 16000
|
||||||
@@ -55,8 +59,13 @@ With a key parameter, drill into a specific config:
|
|||||||
my(action="check", key="_last_usage.prompt_tokens")
|
my(action="check", key="_last_usage.prompt_tokens")
|
||||||
# → How many prompt tokens I've used so far
|
# → How many prompt tokens I've used so far
|
||||||
|
|
||||||
my(action="check", key="model")
|
my(action="check", key="model_preset")
|
||||||
# → What model I'm currently running on
|
# → Current active preset name (e.g. 'fast')
|
||||||
|
|
||||||
|
my(action="check", key="model_presets")
|
||||||
|
# → Lists all preset names and their models, e.g.:
|
||||||
|
# fast → gpt-4.1-mini (openai)
|
||||||
|
# deep → claude-opus-4-7 (anthropic)
|
||||||
|
|
||||||
my(action="check", key="web_config.enable")
|
my(action="check", key="web_config.enable")
|
||||||
# → Whether web search is enabled
|
# → Whether web search is enabled
|
||||||
@@ -66,7 +75,7 @@ my(action="check", key="web_config.enable")
|
|||||||
|
|
||||||
| Scenario | How |
|
| Scenario | How |
|
||||||
|----------|-----|
|
|----------|-----|
|
||||||
| "What model are you using?" | `check("model")` |
|
| "What model are you using?" | `check("model_preset")` |
|
||||||
| "How many more tool calls can you make?" | `check("max_iterations")` minus `check("_current_iteration")` |
|
| "How many more tool calls can you make?" | `check("max_iterations")` minus `check("_current_iteration")` |
|
||||||
| "How many tokens has this conversation used?" | `check("_last_usage")` — cumulative across all turns |
|
| "How many tokens has this conversation used?" | `check("_last_usage")` — cumulative across all turns |
|
||||||
| "Where is your working directory?" | `check("workspace")` |
|
| "Where is your working directory?" | `check("workspace")` |
|
||||||
@@ -83,8 +92,11 @@ Changes take effect immediately, no restart required.
|
|||||||
my(action="set", key="max_iterations", value=80)
|
my(action="set", key="max_iterations", value=80)
|
||||||
# → Bump iteration limit from 40 to 80
|
# → Bump iteration limit from 40 to 80
|
||||||
|
|
||||||
my(action="set", key="model", value="fast-model")
|
my(action="set", key="model_preset", value="fast")
|
||||||
# → Switch to a faster model
|
# → Switch to the 'fast' preset (model, provider, temperature, etc. all at once)
|
||||||
|
#
|
||||||
|
# If the preset name does not exist:
|
||||||
|
# → Error: model_preset 'unknown' not found. Available: fast, deep
|
||||||
|
|
||||||
my(action="set", key="context_window_tokens", value=131072)
|
my(action="set", key="context_window_tokens", value=131072)
|
||||||
# → Expand context window for long documents
|
# → Expand context window for long documents
|
||||||
@@ -101,15 +113,17 @@ my(action="set", key="task_complexity", value="high")
|
|||||||
|
|
||||||
### Protected parameters
|
### Protected parameters
|
||||||
|
|
||||||
These parameters have type and range validation — invalid values are rejected:
|
These parameters have validation — invalid values are rejected:
|
||||||
|
|
||||||
| Parameter | Type | Range | Purpose |
|
| Parameter | Type | Range / Constraint | Purpose |
|
||||||
|-----------|------|-------|---------|
|
|-----------|------|-------------------|---------|
|
||||||
| `max_iterations` | int | 1–100 | Max tool calls per conversation turn |
|
| `max_iterations` | int | 1–100 | Max tool calls per conversation turn |
|
||||||
| `context_window_tokens` | int | 4,096–1,000,000 | Context window size |
|
| `model_preset` | str | must exist in `model_presets` | Switch to a named preset bundle |
|
||||||
| `model` | str | non-empty | LLM model to use |
|
|
||||||
|
|
||||||
Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe.
|
Other parameters (e.g. `model`, `context_window_tokens`, `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Setting `model` or `context_window_tokens` directly automatically clears the active `model_preset`, because the live state no longer matches the preset bundle. Use `model_preset` for atomic switches instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,8 +139,8 @@ Agent: This codebase is large, let me expand my context window to handle it.
|
|||||||
### "Simple question, don't waste compute"
|
### "Simple question, don't waste compute"
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Agent: This is a straightforward question, let me switch to a faster model.
|
Agent: This is a straightforward question, let me switch to the fast preset.
|
||||||
→ my(action="set", key="model", value="fast-model")
|
→ my(action="set", key="model_preset", value="fast")
|
||||||
```
|
```
|
||||||
|
|
||||||
### "Remember user preferences across turns"
|
### "Remember user preferences across turns"
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ Configure these **two parts** in your config (other options have defaults).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
*Want to switch models mid-conversation?* Define [`modelPresets`](./configuration.md#model-presets) and switch instantly with `my(action="set", key="model_preset", value="fast")`.
|
||||||
|
|
||||||
**3. Chat**
|
**3. Chat**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+154
-14
@@ -41,7 +41,7 @@ from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
|||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
@@ -188,6 +188,50 @@ class AgentLoop:
|
|||||||
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(
|
||||||
|
cls,
|
||||||
|
config: Any,
|
||||||
|
bus: MessageBus | None = None,
|
||||||
|
**extra: Any,
|
||||||
|
) -> AgentLoop:
|
||||||
|
"""Create an AgentLoop from config with the common parameter set."""
|
||||||
|
from nanobot.providers.factory import build_provider_for_preset, make_provider_factory
|
||||||
|
|
||||||
|
if bus is None:
|
||||||
|
bus = MessageBus()
|
||||||
|
defaults = config.agents.defaults
|
||||||
|
resolved_preset = config.resolve_preset()
|
||||||
|
provider = build_provider_for_preset(config, resolved_preset)
|
||||||
|
return cls(
|
||||||
|
bus=bus,
|
||||||
|
provider=provider,
|
||||||
|
workspace=config.workspace_path,
|
||||||
|
model=resolved_preset.model,
|
||||||
|
max_iterations=defaults.max_tool_iterations,
|
||||||
|
context_window_tokens=resolved_preset.context_window_tokens,
|
||||||
|
context_block_limit=defaults.context_block_limit,
|
||||||
|
max_tool_result_chars=defaults.max_tool_result_chars,
|
||||||
|
provider_retry_mode=defaults.provider_retry_mode,
|
||||||
|
fallback_presets=defaults.fallback_presets,
|
||||||
|
provider_factory=make_provider_factory(config),
|
||||||
|
web_config=config.tools.web,
|
||||||
|
exec_config=config.tools.exec,
|
||||||
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
|
mcp_servers=config.tools.mcp_servers,
|
||||||
|
channels_config=config.channels,
|
||||||
|
timezone=defaults.timezone,
|
||||||
|
unified_session=defaults.unified_session,
|
||||||
|
disabled_skills=defaults.disabled_skills,
|
||||||
|
session_ttl_minutes=defaults.session_ttl_minutes,
|
||||||
|
consolidation_ratio=defaults.consolidation_ratio,
|
||||||
|
max_messages=defaults.max_messages,
|
||||||
|
tools_config=config.tools,
|
||||||
|
model_presets=config.model_presets,
|
||||||
|
model_preset=defaults.model_preset,
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
@@ -200,6 +244,8 @@ class AgentLoop:
|
|||||||
max_tool_result_chars: int | None = None,
|
max_tool_result_chars: int | None = None,
|
||||||
provider_retry_mode: str = "standard",
|
provider_retry_mode: str = "standard",
|
||||||
tool_hint_max_length: int | None = None,
|
tool_hint_max_length: int | None = None,
|
||||||
|
fallback_presets: list[str] | None = None,
|
||||||
|
provider_factory: Callable[[str], LLMProvider] | None = None,
|
||||||
web_config: WebToolsConfig | None = None,
|
web_config: WebToolsConfig | None = None,
|
||||||
exec_config: ExecToolConfig | None = None,
|
exec_config: ExecToolConfig | None = None,
|
||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
@@ -217,6 +263,8 @@ class AgentLoop:
|
|||||||
tools_config: ToolsConfig | None = None,
|
tools_config: ToolsConfig | None = None,
|
||||||
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
|
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
|
||||||
provider_signature: tuple[object, ...] | None = None,
|
provider_signature: tuple[object, ...] | None = None,
|
||||||
|
model_presets: dict[str, ModelPresetConfig] | None = None,
|
||||||
|
model_preset: str | None = None,
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig
|
from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig
|
||||||
|
|
||||||
@@ -224,7 +272,12 @@ class AgentLoop:
|
|||||||
defaults = AgentDefaults()
|
defaults = AgentDefaults()
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.channels_config = channels_config
|
self.channels_config = channels_config
|
||||||
self.provider = provider
|
self.provider_factory = provider_factory
|
||||||
|
self.fallback_presets = fallback_presets or []
|
||||||
|
wrapped_provider = self._wrap_with_failover(
|
||||||
|
provider, model or provider.get_default_model()
|
||||||
|
)
|
||||||
|
self.provider = wrapped_provider
|
||||||
self._provider_snapshot_loader = provider_snapshot_loader
|
self._provider_snapshot_loader = provider_snapshot_loader
|
||||||
self._provider_signature = provider_signature
|
self._provider_signature = provider_signature
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
@@ -262,9 +315,9 @@ class AgentLoop:
|
|||||||
# One file-read/write tracker per logical session. The tool registry is
|
# One file-read/write tracker per logical session. The tool registry is
|
||||||
# shared by this loop, so tools resolve the active state via contextvars.
|
# shared by this loop, so tools resolve the active state via contextvars.
|
||||||
self._file_state_store = FileStateStore()
|
self._file_state_store = FileStateStore()
|
||||||
self.runner = AgentRunner(provider)
|
self.runner = AgentRunner(wrapped_provider)
|
||||||
self.subagents = SubagentManager(
|
self.subagents = SubagentManager(
|
||||||
provider=provider,
|
provider=wrapped_provider,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
@@ -296,13 +349,13 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
self.consolidator = Consolidator(
|
self.consolidator = Consolidator(
|
||||||
store=self.context.memory,
|
store=self.context.memory,
|
||||||
provider=provider,
|
provider=wrapped_provider,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
sessions=self.sessions,
|
sessions=self.sessions,
|
||||||
context_window_tokens=self.context_window_tokens,
|
context_window_tokens=self.context_window_tokens,
|
||||||
build_messages=self.context.build_messages,
|
build_messages=self.context.build_messages,
|
||||||
get_tool_definitions=self.tools.get_definitions,
|
get_tool_definitions=self.tools.get_definitions,
|
||||||
max_completion_tokens=provider.generation.max_tokens,
|
max_completion_tokens=wrapped_provider.generation.max_tokens,
|
||||||
consolidation_ratio=consolidation_ratio,
|
consolidation_ratio=consolidation_ratio,
|
||||||
)
|
)
|
||||||
self.auto_compact = AutoCompact(
|
self.auto_compact = AutoCompact(
|
||||||
@@ -312,9 +365,13 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
self.dream = Dream(
|
self.dream = Dream(
|
||||||
store=self.context.memory,
|
store=self.context.memory,
|
||||||
provider=provider,
|
provider=wrapped_provider,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
)
|
)
|
||||||
|
self.model_presets: dict[str, ModelPresetConfig] = model_presets or {}
|
||||||
|
self._active_preset: str | None = (
|
||||||
|
model_preset if model_preset in self.model_presets else None
|
||||||
|
)
|
||||||
self._register_default_tools()
|
self._register_default_tools()
|
||||||
if _tc.my.enable:
|
if _tc.my.enable:
|
||||||
self.tools.register(MyTool(loop=self, modify_allowed=_tc.my.allow_set))
|
self.tools.register(MyTool(loop=self, modify_allowed=_tc.my.allow_set))
|
||||||
@@ -327,6 +384,38 @@ class AgentLoop:
|
|||||||
"""Keep subagent runtime limits aligned with mutable loop settings."""
|
"""Keep subagent runtime limits aligned with mutable loop settings."""
|
||||||
self.subagents.max_iterations = self.max_iterations
|
self.subagents.max_iterations = self.max_iterations
|
||||||
|
|
||||||
|
def _wrap_with_failover(self, provider: LLMProvider, model: str) -> LLMProvider:
|
||||||
|
"""Wrap provider with failover router when fallback_presets are configured."""
|
||||||
|
if not self.fallback_presets or not self.provider_factory:
|
||||||
|
return provider
|
||||||
|
from nanobot.providers.failover import ModelRouter
|
||||||
|
|
||||||
|
if isinstance(provider, ModelRouter):
|
||||||
|
return provider
|
||||||
|
|
||||||
|
return ModelRouter(
|
||||||
|
primary_provider=provider,
|
||||||
|
primary_model=model,
|
||||||
|
fallback_presets=self.fallback_presets,
|
||||||
|
provider_factory=self.provider_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_provider_state(
|
||||||
|
self,
|
||||||
|
provider: LLMProvider,
|
||||||
|
model: str,
|
||||||
|
context_window_tokens: int,
|
||||||
|
) -> None:
|
||||||
|
"""Push provider/model/context_window to all LLM-consuming subsystems."""
|
||||||
|
self.provider = provider
|
||||||
|
# Bypass property setters so internal updates don't clear _active_preset.
|
||||||
|
object.__setattr__(self, "_model", model)
|
||||||
|
object.__setattr__(self, "_context_window_tokens", context_window_tokens)
|
||||||
|
self.runner.provider = provider
|
||||||
|
self.subagents.set_provider(provider, model)
|
||||||
|
self.consolidator.set_provider(provider, model, context_window_tokens)
|
||||||
|
self.dream.set_provider(provider, model)
|
||||||
|
|
||||||
def _apply_provider_snapshot(self, snapshot: ProviderSnapshot) -> None:
|
def _apply_provider_snapshot(self, snapshot: ProviderSnapshot) -> None:
|
||||||
"""Swap model/provider for future turns without disturbing an active one."""
|
"""Swap model/provider for future turns without disturbing an active one."""
|
||||||
provider = snapshot.provider
|
provider = snapshot.provider
|
||||||
@@ -335,14 +424,13 @@ class AgentLoop:
|
|||||||
if self.provider is provider and self.model == model:
|
if self.provider is provider and self.model == model:
|
||||||
return
|
return
|
||||||
old_model = self.model
|
old_model = self.model
|
||||||
self.provider = provider
|
provider = self._wrap_with_failover(provider, model)
|
||||||
self.model = model
|
self._apply_provider_state(provider, model, context_window_tokens)
|
||||||
self.context_window_tokens = context_window_tokens
|
|
||||||
self.runner.provider = provider
|
|
||||||
self.subagents.set_provider(provider, model)
|
|
||||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
|
||||||
self.dream.set_provider(provider, model)
|
|
||||||
self._provider_signature = snapshot.signature
|
self._provider_signature = snapshot.signature
|
||||||
|
if self._active_preset:
|
||||||
|
preset = self.model_presets.get(self._active_preset)
|
||||||
|
if preset and preset.model != model:
|
||||||
|
self._active_preset = None
|
||||||
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
||||||
|
|
||||||
def _refresh_provider_snapshot(self) -> None:
|
def _refresh_provider_snapshot(self) -> None:
|
||||||
@@ -357,6 +445,58 @@ class AgentLoop:
|
|||||||
return
|
return
|
||||||
self._apply_provider_snapshot(snapshot)
|
self._apply_provider_snapshot(snapshot)
|
||||||
|
|
||||||
|
# -- model / context_window_tokens properties with preset invalidation --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> str:
|
||||||
|
return self._model
|
||||||
|
|
||||||
|
@model.setter
|
||||||
|
def model(self, value: str) -> None:
|
||||||
|
self._model = value
|
||||||
|
if hasattr(self, "_active_preset"):
|
||||||
|
self._active_preset = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def context_window_tokens(self) -> int:
|
||||||
|
return self._context_window_tokens
|
||||||
|
|
||||||
|
@context_window_tokens.setter
|
||||||
|
def context_window_tokens(self, value: int) -> None:
|
||||||
|
self._context_window_tokens = value
|
||||||
|
if hasattr(self, "_active_preset"):
|
||||||
|
self._active_preset = None
|
||||||
|
|
||||||
|
# -- model_preset property --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_preset(self) -> str | None:
|
||||||
|
return self._active_preset
|
||||||
|
|
||||||
|
@model_preset.setter
|
||||||
|
def model_preset(self, name: str) -> None:
|
||||||
|
"""Resolve a preset by name and apply all fields."""
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
raise ValueError("model_preset must be a non-empty string")
|
||||||
|
if name not in self.model_presets:
|
||||||
|
raise KeyError(
|
||||||
|
f"model_preset {name!r} not found. Available: {', '.join(self.model_presets) or '(none)'}"
|
||||||
|
)
|
||||||
|
if self.provider_factory is None:
|
||||||
|
raise ValueError("provider_factory is not configured; cannot switch model preset")
|
||||||
|
|
||||||
|
p = self.model_presets[name]
|
||||||
|
new_provider = self._wrap_with_failover(self.provider_factory(name), p.model)
|
||||||
|
|
||||||
|
# Preserve dream model_override if it differs from the current loop model.
|
||||||
|
old_dream_model = self.dream.model
|
||||||
|
dream_had_override = old_dream_model != self.model
|
||||||
|
|
||||||
|
self._apply_provider_state(new_provider, p.model, p.context_window_tokens)
|
||||||
|
if dream_had_override:
|
||||||
|
self.dream.model = old_dream_model
|
||||||
|
self._active_preset = name
|
||||||
|
|
||||||
def _register_default_tools(self) -> None:
|
def _register_default_tools(self) -> None:
|
||||||
"""Register the default set of tools."""
|
"""Register the default set of tools."""
|
||||||
allowed_dir = (
|
allowed_dir = (
|
||||||
|
|||||||
@@ -76,8 +76,6 @@ class MyTool(Tool):
|
|||||||
|
|
||||||
RESTRICTED: dict[str, dict[str, Any]] = {
|
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||||
"max_iterations": {"type": int, "min": 1, "max": 100},
|
"max_iterations": {"type": int, "min": 1, "max": 100},
|
||||||
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
|
||||||
"model": {"type": str, "min_len": 1},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_MAX_RUNTIME_KEYS = 64
|
_MAX_RUNTIME_KEYS = 64
|
||||||
@@ -118,13 +116,14 @@ class MyTool(Tool):
|
|||||||
"Scratchpad keys persist across turns but not restarts.\n"
|
"Scratchpad keys persist across turns but not restarts.\n"
|
||||||
"Key values: _current_iteration (current progress), "
|
"Key values: _current_iteration (current progress), "
|
||||||
"max_iterations - _current_iteration = remaining iterations.\n"
|
"max_iterations - _current_iteration = remaining iterations.\n"
|
||||||
|
"Use 'model_preset' to switch the active model preset.\n"
|
||||||
"Note: web_config and exec_config are readable but read-only.\n"
|
"Note: web_config and exec_config are readable but read-only.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"When to use:\n"
|
"When to use:\n"
|
||||||
"- User asks about your model, settings, or token usage → check that key.\n"
|
"- User asks about your model, settings, or token usage → check that key.\n"
|
||||||
"- A tool fails or behaves unexpectedly → check the related config to diagnose.\n"
|
"- A tool fails or behaves unexpectedly → check the related config to diagnose.\n"
|
||||||
"- User asks you to remember a preference for this session → set to store it in your scratchpad.\n"
|
"- User asks you to remember a preference for this session → set to store it in your scratchpad.\n"
|
||||||
"- About to start a large task → check context_window_tokens and max_iterations first."
|
"- About to start a large task → check max_iterations and model_preset first."
|
||||||
)
|
)
|
||||||
if not self._modify_allowed:
|
if not self._modify_allowed:
|
||||||
base += "\nREAD-ONLY MODE: set is disabled."
|
base += "\nREAD-ONLY MODE: set is disabled."
|
||||||
@@ -132,7 +131,7 @@ class MyTool(Tool):
|
|||||||
base += (
|
base += (
|
||||||
"\nIMPORTANT: Before setting state, predict the potential impact. "
|
"\nIMPORTANT: Before setting state, predict the potential impact. "
|
||||||
"If the operation could cause crashes or instability "
|
"If the operation could cause crashes or instability "
|
||||||
"(e.g. changing model), warn the user first."
|
"(e.g. changing model_preset), warn the user first."
|
||||||
)
|
)
|
||||||
return base
|
return base
|
||||||
|
|
||||||
@@ -148,7 +147,7 @@ class MyTool(Tool):
|
|||||||
},
|
},
|
||||||
"key": {
|
"key": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Dot-path for check/set. Examples: 'max_iterations', 'workspace', 'provider_retry_mode'. "
|
"description": "Dot-path for check/set. Examples: 'max_iterations', 'model_preset', 'provider_retry_mode'. "
|
||||||
"For check without key, shows all config values.",
|
"For check without key, shows all config values.",
|
||||||
},
|
},
|
||||||
"value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model)."},
|
"value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model)."},
|
||||||
@@ -330,6 +329,8 @@ class MyTool(Tool):
|
|||||||
# RESTRICTED keys
|
# RESTRICTED keys
|
||||||
for k in self.RESTRICTED:
|
for k in self.RESTRICTED:
|
||||||
parts.append(self._format_value(getattr(loop, k, None), k))
|
parts.append(self._format_value(getattr(loop, k, None), k))
|
||||||
|
# model_preset (property on AgentLoop)
|
||||||
|
parts.append(self._format_value(loop.model_preset, "model_preset"))
|
||||||
# Other useful top-level keys shown in description
|
# Other useful top-level keys shown in description
|
||||||
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"):
|
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"):
|
||||||
if _has_real_attr(loop, k):
|
if _has_real_attr(loop, k):
|
||||||
@@ -386,6 +387,8 @@ class MyTool(Tool):
|
|||||||
value = expected(value)
|
value = expected(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
|
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
|
||||||
|
|
||||||
|
# --- existing restricted key logic ---
|
||||||
old = getattr(self._loop, key)
|
old = getattr(self._loop, key)
|
||||||
if "min" in spec and value < spec["min"]:
|
if "min" in spec and value < spec["min"]:
|
||||||
return f"Error: '{key}' must be >= {spec['min']}"
|
return f"Error: '{key}' must be >= {spec['min']}"
|
||||||
@@ -412,7 +415,14 @@ class MyTool(Tool):
|
|||||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||||
)
|
)
|
||||||
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
||||||
setattr(self._loop, key, value)
|
# When a model-specific field is set directly, it no longer matches any preset
|
||||||
|
if key in ("model", "context_window_tokens"):
|
||||||
|
self._loop._active_preset = None
|
||||||
|
try:
|
||||||
|
setattr(self._loop, key, value)
|
||||||
|
except (AttributeError, TypeError, ValueError, KeyError) as e:
|
||||||
|
self._audit("modify", f"REJECTED {key}: {e}")
|
||||||
|
return f"Error: {e}"
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
if callable(value):
|
if callable(value):
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ def _read_webui_model_name() -> str | None:
|
|||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
model = load_config().agents.defaults.model.strip()
|
model = load_config().resolve_preset().model.strip()
|
||||||
return model or None
|
return model or None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("webui bootstrap could not load model name: {}", e)
|
logger.debug("webui bootstrap could not load model name: {}", e)
|
||||||
|
|||||||
+128
-11
@@ -11,13 +11,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -54,7 +54,7 @@ MESSAGE_TYPE_BOT = 2
|
|||||||
MESSAGE_STATE_FINISH = 2
|
MESSAGE_STATE_FINISH = 2
|
||||||
|
|
||||||
WEIXIN_MAX_MESSAGE_LEN = 4000
|
WEIXIN_MAX_MESSAGE_LEN = 4000
|
||||||
WEIXIN_CHANNEL_VERSION = "2.1.1"
|
WEIXIN_CHANNEL_VERSION = "2.1.7"
|
||||||
ILINK_APP_ID = "bot"
|
ILINK_APP_ID = "bot"
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +80,36 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
|
|||||||
ERRCODE_SESSION_EXPIRED = -14
|
ERRCODE_SESSION_EXPIRED = -14
|
||||||
SESSION_PAUSE_DURATION_S = 60 * 60
|
SESSION_PAUSE_DURATION_S = 60 * 60
|
||||||
|
|
||||||
|
# iLink rate-limit / stale-session errcode
|
||||||
|
RATE_LIMIT_ERRCODE = -2
|
||||||
|
|
||||||
|
|
||||||
|
def _is_stale_session_ret(
|
||||||
|
ret: int | None,
|
||||||
|
errcode: int | None,
|
||||||
|
errmsg: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when iLink returns ret=-2 / errcode=-2 that is likely a stale
|
||||||
|
context_token rather than a genuine rate limit.
|
||||||
|
|
||||||
|
Empirically iLink signals these two scenarios weakly:
|
||||||
|
- stale session: ret=-2, errmsg="unknown error" OR errmsg empty/None
|
||||||
|
- genuine rate limit: ret=-2 with a populated errmsg such as
|
||||||
|
"frequency limit" / "too frequently" / similar
|
||||||
|
|
||||||
|
Treating "unknown error" and empty/None errmsg as stale-session signals
|
||||||
|
lets the caller attempt one tokenless retry. A true rate limit still
|
||||||
|
falls through to the existing retry/backoff path if the tokenless
|
||||||
|
attempt also fails.
|
||||||
|
"""
|
||||||
|
if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE:
|
||||||
|
return False
|
||||||
|
msg = (errmsg or "").strip().lower()
|
||||||
|
if not msg:
|
||||||
|
return True
|
||||||
|
return msg == "unknown error"
|
||||||
|
|
||||||
|
|
||||||
# Retry constants (matching the reference plugin's monitor.ts)
|
# Retry constants (matching the reference plugin's monitor.ts)
|
||||||
MAX_CONSECUTIVE_FAILURES = 3
|
MAX_CONSECUTIVE_FAILURES = 3
|
||||||
BACKOFF_DELAY_S = 30
|
BACKOFF_DELAY_S = 30
|
||||||
@@ -486,6 +516,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
except Exception:
|
except Exception:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
break
|
break
|
||||||
|
self.logger.exception("WeChat poll loop error")
|
||||||
consecutive_failures += 1
|
consecutive_failures += 1
|
||||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
@@ -525,6 +556,22 @@ class WeixinChannel(BaseChannel):
|
|||||||
f"WeChat session paused, {remaining_min} min remaining (errcode {ERRCODE_SESSION_EXPIRED})"
|
f"WeChat session paused, {remaining_min} min remaining (errcode {ERRCODE_SESSION_EXPIRED})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_response_error(self, data: dict, operation: str, *, body: dict | None = None) -> None:
|
||||||
|
"""Check both ``ret`` and ``errcode`` like the reference TS code.
|
||||||
|
|
||||||
|
The iLink API may signal failure through either field (or both).
|
||||||
|
``_poll_once`` already checks both; outbound send helpers must do
|
||||||
|
the same to avoid silent drops.
|
||||||
|
"""
|
||||||
|
ret = data.get("ret", 0)
|
||||||
|
errcode = data.get("errcode", 0)
|
||||||
|
is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0)
|
||||||
|
if not is_error:
|
||||||
|
return
|
||||||
|
raise RuntimeError(
|
||||||
|
f"WeChat {operation} error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
|
||||||
|
)
|
||||||
|
|
||||||
async def _poll_once(self) -> None:
|
async def _poll_once(self) -> None:
|
||||||
remaining = self._session_pause_remaining_s()
|
remaining = self._session_pause_remaining_s()
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
@@ -575,8 +622,10 @@ class WeixinChannel(BaseChannel):
|
|||||||
# Process messages (WeixinMessage[] from types.ts)
|
# Process messages (WeixinMessage[] from types.ts)
|
||||||
msgs: list[dict] = data.get("msgs", []) or []
|
msgs: list[dict] = data.get("msgs", []) or []
|
||||||
for msg in msgs:
|
for msg in msgs:
|
||||||
with suppress(Exception):
|
try:
|
||||||
await self._process_message(msg)
|
await self._process_message(msg)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Failed to process WeChat message")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Inbound message processing (matches inbound.ts + process-message.ts)
|
# Inbound message processing (matches inbound.ts + process-message.ts)
|
||||||
@@ -1089,6 +1138,14 @@ class WeixinChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug("typing clear failed for {}: {}", chat_id, e)
|
self.logger.debug("typing clear failed for {}: {}", chat_id, e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_client_id() -> str:
|
||||||
|
"""Generate a client_id matching the reference plugin format.
|
||||||
|
|
||||||
|
openclaw-weixin uses ``{prefix}:{timestamp}-{8-char hex}``.
|
||||||
|
"""
|
||||||
|
return f"nanobot:{int(time.time() * 1000)}-{os.urandom(4).hex()}"
|
||||||
|
|
||||||
async def _send_text(
|
async def _send_text(
|
||||||
self,
|
self,
|
||||||
to_user_id: str,
|
to_user_id: str,
|
||||||
@@ -1096,7 +1153,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
context_token: str,
|
context_token: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a text message matching the exact protocol from send.ts."""
|
"""Send a text message matching the exact protocol from send.ts."""
|
||||||
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
|
client_id = self._generate_client_id()
|
||||||
|
|
||||||
item_list: list[dict] = []
|
item_list: list[dict] = []
|
||||||
if text:
|
if text:
|
||||||
@@ -1120,11 +1177,47 @@ class WeixinChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
errcode = data.get("errcode", 0)
|
errcode = data.get("errcode", 0)
|
||||||
if errcode and errcode != 0:
|
errmsg = data.get("errmsg", "")
|
||||||
raise RuntimeError(
|
|
||||||
f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}"
|
# The iLink sendmessage API may return ret=-2 / errcode=-2 for two
|
||||||
|
# different reasons:
|
||||||
|
# - stale context_token: errmsg is empty/None or "unknown error"
|
||||||
|
# - genuine rate limit: errmsg is populated (e.g. "frequency limit")
|
||||||
|
# Per hermes-agent#17228 / #18100, the empty/None variant is a stale
|
||||||
|
# session signal. Retry once without context_token (iLink accepts
|
||||||
|
# tokenless sends as a degraded fallback). If the tokenless attempt
|
||||||
|
# also fails, let _check_response_error raise so ChannelManager can
|
||||||
|
# retry with backoff — do NOT swallow the error.
|
||||||
|
if _is_stale_session_ret(ret, errcode, errmsg) and context_token:
|
||||||
|
self.logger.warning(
|
||||||
|
"WeChat send text returned stale-session signal for {} (client_id={}); "
|
||||||
|
"retrying without context_token",
|
||||||
|
to_user_id,
|
||||||
|
client_id,
|
||||||
)
|
)
|
||||||
|
body_no_ctx = copy.deepcopy(body)
|
||||||
|
body_no_ctx["msg"].pop("context_token", None)
|
||||||
|
data = await self._api_post("ilink/bot/sendmessage", body_no_ctx)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
|
errcode = data.get("errcode", 0)
|
||||||
|
errmsg = data.get("errmsg", "")
|
||||||
|
if ret == 0 and (errcode == 0 or errcode is None):
|
||||||
|
self.logger.warning(
|
||||||
|
"WeChat send text succeeded WITHOUT context_token for {}; "
|
||||||
|
"clearing expired token from cache",
|
||||||
|
to_user_id,
|
||||||
|
)
|
||||||
|
self._context_tokens.pop(to_user_id, None)
|
||||||
|
self._save_state()
|
||||||
|
self.logger.debug(
|
||||||
|
"WeChat text sent to {} (client_id={})", to_user_id, client_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._check_response_error(data, "send text", body=body)
|
||||||
|
self.logger.debug("WeChat text sent to {} (client_id={})", to_user_id, client_id)
|
||||||
|
|
||||||
async def _send_media_file(
|
async def _send_media_file(
|
||||||
self,
|
self,
|
||||||
@@ -1250,7 +1343,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
media_item["len"] = str(raw_size)
|
media_item["len"] = str(raw_size)
|
||||||
|
|
||||||
# Send each media item as its own message (matching reference plugin)
|
# Send each media item as its own message (matching reference plugin)
|
||||||
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
|
client_id = self._generate_client_id()
|
||||||
item_list: list[dict] = [{"type": item_type, item_key: media_item}]
|
item_list: list[dict] = [{"type": item_type, item_key: media_item}]
|
||||||
|
|
||||||
weixin_msg: dict[str, Any] = {
|
weixin_msg: dict[str, Any] = {
|
||||||
@@ -1270,11 +1363,35 @@ class WeixinChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = await self._api_post("ilink/bot/sendmessage", body)
|
data = await self._api_post("ilink/bot/sendmessage", body)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
errcode = data.get("errcode", 0)
|
errcode = data.get("errcode", 0)
|
||||||
if errcode and errcode != 0:
|
errmsg = data.get("errmsg", "")
|
||||||
raise RuntimeError(
|
|
||||||
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
|
# Same stale-session handling as _send_text (hermes-agent#17228 / #18100).
|
||||||
|
if _is_stale_session_ret(ret, errcode, errmsg) and context_token:
|
||||||
|
self.logger.warning(
|
||||||
|
"WeChat send media returned stale-session signal for {} (client_id={}); "
|
||||||
|
"retrying without context_token",
|
||||||
|
to_user_id,
|
||||||
|
client_id,
|
||||||
)
|
)
|
||||||
|
body_no_ctx = copy.deepcopy(body)
|
||||||
|
body_no_ctx["msg"].pop("context_token", None)
|
||||||
|
data = await self._api_post("ilink/bot/sendmessage", body_no_ctx)
|
||||||
|
ret = data.get("ret", 0)
|
||||||
|
errcode = data.get("errcode", 0)
|
||||||
|
errmsg = data.get("errmsg", "")
|
||||||
|
if ret == 0 and (errcode == 0 or errcode is None):
|
||||||
|
self.logger.warning(
|
||||||
|
"WeChat send media succeeded WITHOUT context_token for {}; "
|
||||||
|
"clearing expired token from cache",
|
||||||
|
to_user_id,
|
||||||
|
)
|
||||||
|
self._context_tokens.pop(to_user_id, None)
|
||||||
|
self._save_state()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._check_response_error(data, "send media", body=body)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+21
-96
@@ -48,6 +48,7 @@ from rich.table import Table
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from nanobot import __logo__, __version__
|
from nanobot import __logo__, __version__
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
|
||||||
|
|
||||||
class SafeFileHistory(FileHistory):
|
class SafeFileHistory(FileHistory):
|
||||||
@@ -437,20 +438,6 @@ def _onboard_plugins(config_path: Path) -> None:
|
|||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(config: Config):
|
|
||||||
"""Create the appropriate LLM provider from config.
|
|
||||||
|
|
||||||
Routing is driven by ``ProviderSpec.backend`` in the registry.
|
|
||||||
"""
|
|
||||||
from nanobot.providers.factory import make_provider
|
|
||||||
|
|
||||||
try:
|
|
||||||
return make_provider(config)
|
|
||||||
except ValueError as exc:
|
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
|
||||||
raise typer.Exit(1) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
||||||
"""Load config and optionally override the active workspace."""
|
"""Load config and optionally override the active workspace."""
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
|
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
|
||||||
@@ -528,7 +515,6 @@ def serve(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.api.server import create_app
|
from nanobot.api.server import create_app
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@@ -545,38 +531,20 @@ def serve(
|
|||||||
timeout = timeout if timeout is not None else api_cfg.timeout
|
timeout = timeout if timeout is not None else api_cfg.timeout
|
||||||
sync_workspace_templates(runtime_config.workspace_path)
|
sync_workspace_templates(runtime_config.workspace_path)
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(runtime_config)
|
defaults = runtime_config.agents.defaults
|
||||||
session_manager = SessionManager(runtime_config.workspace_path)
|
session_manager = SessionManager(runtime_config.workspace_path)
|
||||||
agent_loop = AgentLoop(
|
resolved_preset = runtime_config.resolve_preset()
|
||||||
bus=bus,
|
agent_loop = AgentLoop.from_config(
|
||||||
provider=provider,
|
runtime_config, bus,
|
||||||
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,
|
|
||||||
tool_hint_max_length=runtime_config.agents.defaults.tool_hint_max_length,
|
|
||||||
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,
|
session_manager=session_manager,
|
||||||
mcp_servers=runtime_config.tools.mcp_servers,
|
|
||||||
channels_config=runtime_config.channels,
|
|
||||||
timezone=runtime_config.agents.defaults.timezone,
|
|
||||||
unified_session=runtime_config.agents.defaults.unified_session,
|
|
||||||
disabled_skills=runtime_config.agents.defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
|
|
||||||
consolidation_ratio=runtime_config.agents.defaults.consolidation_ratio,
|
|
||||||
max_messages=runtime_config.agents.defaults.max_messages,
|
|
||||||
tools_config=runtime_config.tools,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
model_name = runtime_config.agents.defaults.model
|
model_name = resolved_preset.model
|
||||||
|
preset_name = defaults.model_preset
|
||||||
|
preset_tag = f" (preset: {preset_name})" if preset_name else ""
|
||||||
console.print(f"{__logo__} Starting OpenAI-compatible API server")
|
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]Endpoint[/cyan] : http://{host}:{port}/v1/chat/completions")
|
||||||
console.print(f" [cyan]Model[/cyan] : {model_name}")
|
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
|
||||||
console.print(" [cyan]Session[/cyan] : api:default")
|
console.print(" [cyan]Session[/cyan] : api:default")
|
||||||
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
|
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
|
||||||
if host in {"0.0.0.0", "::"}:
|
if host in {"0.0.0.0", "::"}:
|
||||||
@@ -638,7 +606,6 @@ def _run_gateway(
|
|||||||
open_browser_url: str | None = None,
|
open_browser_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.agent.tools.cron import CronTool
|
from nanobot.agent.tools.cron import CronTool
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
@@ -659,7 +626,6 @@ def _run_gateway(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
provider = provider_snapshot.provider
|
|
||||||
session_manager = SessionManager(config.workspace_path)
|
session_manager = SessionManager(config.workspace_path)
|
||||||
|
|
||||||
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||||
@@ -671,31 +637,10 @@ def _run_gateway(
|
|||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop(
|
agent = AgentLoop.from_config(
|
||||||
bus=bus,
|
config, bus,
|
||||||
provider=provider,
|
|
||||||
workspace=config.workspace_path,
|
|
||||||
model=provider_snapshot.model,
|
|
||||||
max_iterations=config.agents.defaults.max_tool_iterations,
|
|
||||||
context_window_tokens=provider_snapshot.context_window_tokens,
|
|
||||||
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,
|
|
||||||
tool_hint_max_length=config.agents.defaults.tool_hint_max_length,
|
|
||||||
exec_config=config.tools.exec,
|
|
||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
|
||||||
channels_config=config.channels,
|
|
||||||
timezone=config.agents.defaults.timezone,
|
|
||||||
unified_session=config.agents.defaults.unified_session,
|
|
||||||
disabled_skills=config.agents.defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
|
||||||
consolidation_ratio=config.agents.defaults.consolidation_ratio,
|
|
||||||
max_messages=config.agents.defaults.max_messages,
|
|
||||||
tools_config=config.tools,
|
|
||||||
provider_snapshot_loader=load_provider_snapshot,
|
provider_snapshot_loader=load_provider_snapshot,
|
||||||
provider_signature=provider_snapshot.signature,
|
provider_signature=provider_snapshot.signature,
|
||||||
)
|
)
|
||||||
@@ -798,7 +743,7 @@ def _run_gateway(
|
|||||||
|
|
||||||
if job.payload.deliver and job.payload.to and response:
|
if job.payload.deliver and job.payload.to and response:
|
||||||
should_notify = await evaluate_response(
|
should_notify = await evaluate_response(
|
||||||
response, reminder_note, provider, agent.model,
|
response, reminder_note, agent.provider, agent.model,
|
||||||
)
|
)
|
||||||
if should_notify:
|
if should_notify:
|
||||||
await _deliver_to_channel(
|
await _deliver_to_channel(
|
||||||
@@ -888,7 +833,7 @@ def _run_gateway(
|
|||||||
hb_cfg = config.gateway.heartbeat
|
hb_cfg = config.gateway.heartbeat
|
||||||
heartbeat = HeartbeatService(
|
heartbeat = HeartbeatService(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
provider=provider,
|
provider=agent.provider,
|
||||||
model=agent.model,
|
model=agent.model,
|
||||||
on_execute=on_heartbeat_execute,
|
on_execute=on_heartbeat_execute,
|
||||||
on_notify=on_heartbeat_notify,
|
on_notify=on_heartbeat_notify,
|
||||||
@@ -1041,7 +986,6 @@ def agent(
|
|||||||
"""Interact with the agent directly."""
|
"""Interact with the agent directly."""
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
@@ -1049,8 +993,6 @@ def agent(
|
|||||||
sync_workspace_templates(config.workspace_path)
|
sync_workspace_templates(config.workspace_path)
|
||||||
|
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
|
||||||
|
|
||||||
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||||
if is_default_workspace(config.workspace_path):
|
if is_default_workspace(config.workspace_path):
|
||||||
_migrate_cron_store(config)
|
_migrate_cron_store(config)
|
||||||
@@ -1064,30 +1006,10 @@ def agent(
|
|||||||
else:
|
else:
|
||||||
logger.disable("nanobot")
|
logger.disable("nanobot")
|
||||||
|
|
||||||
agent_loop = AgentLoop(
|
resolved_preset = config.resolve_preset()
|
||||||
bus=bus,
|
agent_loop = AgentLoop.from_config(
|
||||||
provider=provider,
|
config, bus,
|
||||||
workspace=config.workspace_path,
|
|
||||||
model=config.agents.defaults.model,
|
|
||||||
max_iterations=config.agents.defaults.max_tool_iterations,
|
|
||||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
|
||||||
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,
|
|
||||||
tool_hint_max_length=config.agents.defaults.tool_hint_max_length,
|
|
||||||
exec_config=config.tools.exec,
|
|
||||||
cron_service=cron,
|
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,
|
|
||||||
unified_session=config.agents.defaults.unified_session,
|
|
||||||
disabled_skills=config.agents.defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
|
||||||
consolidation_ratio=config.agents.defaults.consolidation_ratio,
|
|
||||||
max_messages=config.agents.defaults.max_messages,
|
|
||||||
tools_config=config.tools,
|
|
||||||
)
|
)
|
||||||
restart_notice = consume_restart_notice_from_env()
|
restart_notice = consume_restart_notice_from_env()
|
||||||
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
||||||
@@ -1131,7 +1053,7 @@ def agent(
|
|||||||
# Interactive mode — route through bus like other channels
|
# Interactive mode — route through bus like other channels
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
_init_prompt_session()
|
_init_prompt_session()
|
||||||
console.print(f"{__logo__} Interactive mode [bold blue]({config.agents.defaults.model})[/bold blue] — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
|
console.print(f"{__logo__} Interactive mode [bold blue]({resolved_preset.model})[/bold blue] — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
|
||||||
|
|
||||||
if ":" in session_id:
|
if ":" in session_id:
|
||||||
cli_channel, cli_chat_id = session_id.split(":", 1)
|
cli_channel, cli_chat_id = session_id.split(":", 1)
|
||||||
@@ -1489,7 +1411,10 @@ def status():
|
|||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
console.print(f"Model: {config.agents.defaults.model}")
|
resolved_preset = config.resolve_preset()
|
||||||
|
preset = config.agents.defaults.model_preset
|
||||||
|
preset_tag = f" (preset: {preset})" if preset else ""
|
||||||
|
console.print(f"Model: {resolved_preset.model}{preset_tag}")
|
||||||
|
|
||||||
# Check API keys from registry
|
# Check API keys from registry
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
|
|||||||
+270
-10
@@ -22,7 +22,7 @@ from nanobot.cli.models import (
|
|||||||
get_model_suggestions,
|
get_model_suggestions,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import get_config_path, load_config
|
from nanobot.config.loader import get_config_path, load_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -49,6 +49,16 @@ _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
|
|||||||
|
|
||||||
_BACK_PRESSED = object() # Sentinel value for back navigation
|
_BACK_PRESSED = object() # Sentinel value for back navigation
|
||||||
|
|
||||||
|
# Cache of model-preset names populated at runtime so that field handlers can
|
||||||
|
# offer existing presets as choices (e.g. AgentDefaults.model_preset).
|
||||||
|
#
|
||||||
|
# Lifecycle: populated by _sync_preset_cache(config), which must be called
|
||||||
|
# after every config mutation that changes model_presets (add, delete, edit).
|
||||||
|
# Cleared between tests via _MODEL_PRESET_CACHE.clear(). In long-running
|
||||||
|
# processes (gateway) the cache is refreshed each time the preset management
|
||||||
|
# screen is entered, so staleness is bounded by user interaction.
|
||||||
|
_MODEL_PRESET_CACHE: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
def _get_questionary():
|
def _get_questionary():
|
||||||
"""Return questionary or raise a clear error when wizard deps are unavailable."""
|
"""Return questionary or raise a clear error when wizard deps are unavailable."""
|
||||||
@@ -191,13 +201,13 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
|
|||||||
origin = get_origin(annotation)
|
origin = get_origin(annotation)
|
||||||
args = get_args(annotation)
|
args = get_args(annotation)
|
||||||
|
|
||||||
_SIMPLE_TYPES: dict[type, str] = {bool: "bool", int: "int", float: "float"}
|
_simple_types: dict[type, str] = {bool: "bool", int: "int", float: "float"}
|
||||||
|
|
||||||
if origin is list or (hasattr(origin, "__name__") and origin.__name__ == "List"):
|
if origin is list or (hasattr(origin, "__name__") and origin.__name__ == "List"):
|
||||||
return FieldTypeInfo("list", args[0] if args else str)
|
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 (hasattr(origin, "__name__") and origin.__name__ == "Dict"):
|
||||||
return FieldTypeInfo("dict", None)
|
return FieldTypeInfo("dict", None)
|
||||||
for py_type, name in _SIMPLE_TYPES.items():
|
for py_type, name in _simple_types.items():
|
||||||
if annotation is py_type:
|
if annotation is py_type:
|
||||||
return FieldTypeInfo(name, None)
|
return FieldTypeInfo(name, None)
|
||||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||||
@@ -403,7 +413,7 @@ def _input_text(display_name: str, current: Any, field_type: str, field_info=Non
|
|||||||
|
|
||||||
value = _get_questionary().text(f"{display_name}:", default=default).ask()
|
value = _get_questionary().text(f"{display_name}:", default=default).ask()
|
||||||
|
|
||||||
if value is None or value == "":
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if field_type == "int":
|
if field_type == "int":
|
||||||
@@ -507,7 +517,7 @@ def _input_model_with_autocomplete(
|
|||||||
qmark=">",
|
qmark=">",
|
||||||
).ask()
|
).ask()
|
||||||
|
|
||||||
return value if value else None
|
return value if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _input_context_window_with_recommendation(
|
def _input_context_window_with_recommendation(
|
||||||
@@ -588,12 +598,112 @@ def _handle_context_window_field(
|
|||||||
setattr(working_model, field_name, new_value)
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_model_preset_field(
|
||||||
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle the 'model_preset' field with a list of existing presets."""
|
||||||
|
# model_preset lives on AgentDefaults, but the preset list is on Config.
|
||||||
|
# We can't easily access Config here, so we read from the global config
|
||||||
|
# via a module-level cache set by _configure_model_presets / run_onboard.
|
||||||
|
preset_names = sorted(_MODEL_PRESET_CACHE)
|
||||||
|
choices = ["(clear/unset)"] + preset_names
|
||||||
|
default_choice = str(current_value) if current_value else "(clear/unset)"
|
||||||
|
new_value = _select_with_back(field_display, choices, default=default_choice)
|
||||||
|
if new_value is _BACK_PRESSED:
|
||||||
|
return
|
||||||
|
if new_value == "(clear/unset)":
|
||||||
|
setattr(working_model, field_name, None)
|
||||||
|
elif new_value is not None:
|
||||||
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_provider_field(
|
||||||
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle the 'provider' field with a list of registered providers."""
|
||||||
|
provider_names = sorted(_get_provider_names().keys())
|
||||||
|
choices = ["auto"] + provider_names
|
||||||
|
default_choice = str(current_value) if current_value else "auto"
|
||||||
|
new_value = _select_with_back(field_display, choices, default=default_choice)
|
||||||
|
if new_value is _BACK_PRESSED:
|
||||||
|
return
|
||||||
|
if new_value is not None:
|
||||||
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_fallback_presets_field(
|
||||||
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle the 'fallback_presets' field with preset-aware multi-select."""
|
||||||
|
items: list[str] = list(current_value) if isinstance(current_value, list) else []
|
||||||
|
preset_names = sorted(_MODEL_PRESET_CACHE)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
console.clear()
|
||||||
|
console.print(f"[bold]{field_display}[/bold]")
|
||||||
|
if items:
|
||||||
|
for idx, item in enumerate(items, 1):
|
||||||
|
console.print(f" {idx}. {item}")
|
||||||
|
else:
|
||||||
|
console.print(" [dim](empty)[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
choices = ["[+] Add preset"]
|
||||||
|
if items:
|
||||||
|
choices.append("[-] Remove last")
|
||||||
|
choices.append("[X] Clear all")
|
||||||
|
choices.append("[Done]")
|
||||||
|
choices.append("<- Back")
|
||||||
|
|
||||||
|
answer = _get_questionary().select(
|
||||||
|
"Manage fallback chain:",
|
||||||
|
choices=choices,
|
||||||
|
qmark=">",
|
||||||
|
).ask()
|
||||||
|
|
||||||
|
if answer is None or answer == "<- Back":
|
||||||
|
return
|
||||||
|
if answer == "[Done]":
|
||||||
|
setattr(working_model, field_name, items)
|
||||||
|
return
|
||||||
|
if answer == "[+] Add preset":
|
||||||
|
if not preset_names:
|
||||||
|
console.print("[yellow]! No presets defined yet.[/yellow]")
|
||||||
|
_get_questionary().press_any_key_to_continue().ask()
|
||||||
|
continue
|
||||||
|
add_choices = [p for p in preset_names if p not in items]
|
||||||
|
if not add_choices:
|
||||||
|
console.print("[yellow]! All presets already added.[/yellow]")
|
||||||
|
_get_questionary().press_any_key_to_continue().ask()
|
||||||
|
continue
|
||||||
|
picked = _select_with_back("Select preset:", add_choices)
|
||||||
|
if picked is _BACK_PRESSED or picked is None:
|
||||||
|
continue
|
||||||
|
items.append(picked)
|
||||||
|
elif answer == "[-] Remove last" and items:
|
||||||
|
items.pop()
|
||||||
|
elif answer == "[X] Clear all" and items:
|
||||||
|
items.clear()
|
||||||
|
|
||||||
|
|
||||||
_FIELD_HANDLERS: dict[str, Any] = {
|
_FIELD_HANDLERS: dict[str, Any] = {
|
||||||
"model": _handle_model_field,
|
"model": _handle_model_field,
|
||||||
"context_window_tokens": _handle_context_window_field,
|
"context_window_tokens": _handle_context_window_field,
|
||||||
|
"model_preset": _handle_model_preset_field,
|
||||||
|
"provider": _handle_provider_field,
|
||||||
|
"fallback_presets": _handle_fallback_presets_field,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_str_or_none(annotation: Any) -> bool:
|
||||||
|
"""Check whether a field annotation is ``str | None`` (or ``Optional[str]``)."""
|
||||||
|
origin = get_origin(annotation)
|
||||||
|
if origin is None:
|
||||||
|
return False
|
||||||
|
args = get_args(annotation)
|
||||||
|
return str in args and type(None) in args
|
||||||
|
|
||||||
|
|
||||||
def _configure_pydantic_model(
|
def _configure_pydantic_model(
|
||||||
model: BaseModel,
|
model: BaseModel,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
@@ -626,11 +736,20 @@ def _configure_pydantic_model(
|
|||||||
items.append(f"{display}: {formatted}")
|
items.append(f"{display}: {formatted}")
|
||||||
return items + ["[Done]"]
|
return items + ["[Done]"]
|
||||||
|
|
||||||
|
last_field_name: str | None = None
|
||||||
while True:
|
while True:
|
||||||
console.clear()
|
console.clear()
|
||||||
_show_config_panel(display_name, working_model, fields)
|
_show_config_panel(display_name, working_model, fields)
|
||||||
choices = get_choices()
|
choices = get_choices()
|
||||||
answer = _select_with_back("Select field to configure:", choices)
|
default_choice = None
|
||||||
|
if last_field_name:
|
||||||
|
for idx, (fname, _) in enumerate(fields):
|
||||||
|
if fname == last_field_name:
|
||||||
|
default_choice = choices[idx]
|
||||||
|
break
|
||||||
|
answer = _select_with_back(
|
||||||
|
"Select field to configure:", choices, default=default_choice
|
||||||
|
)
|
||||||
|
|
||||||
if answer is _BACK_PRESSED or answer is None:
|
if answer is _BACK_PRESSED or answer is None:
|
||||||
return None
|
return None
|
||||||
@@ -641,6 +760,8 @@ def _configure_pydantic_model(
|
|||||||
if field_idx < 0 or field_idx >= len(fields):
|
if field_idx < 0 or field_idx >= len(fields):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
last_field_name = fields[field_idx][0]
|
||||||
|
|
||||||
field_name, field_info = fields[field_idx]
|
field_name, field_info = fields[field_idx]
|
||||||
current_value = getattr(working_model, field_name, None)
|
current_value = getattr(working_model, field_name, None)
|
||||||
ftype = _get_field_type_info(field_info)
|
ftype = _get_field_type_info(field_info)
|
||||||
@@ -697,6 +818,10 @@ def _configure_pydantic_model(
|
|||||||
else:
|
else:
|
||||||
new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
|
new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
|
||||||
if new_value is not None:
|
if new_value is not None:
|
||||||
|
# Normalize empty string to None for optional string fields so that
|
||||||
|
# clearing an api_key / api_base actually removes the value.
|
||||||
|
if new_value == "" and _is_str_or_none(field_info.annotation):
|
||||||
|
new_value = None
|
||||||
setattr(working_model, field_name, new_value)
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
@@ -733,6 +858,113 @@ def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None
|
|||||||
console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
|
console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Model Preset Configuration ---
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_preset_cache(config: Config) -> None:
|
||||||
|
"""Synchronise the module-level preset name cache from config."""
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.update(config.model_presets.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_model_presets(config: Config) -> None:
|
||||||
|
"""Configure model presets (CRUD)."""
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
|
||||||
|
def get_preset_choices() -> list[str]:
|
||||||
|
choices: list[str] = []
|
||||||
|
for name, preset in config.model_presets.items():
|
||||||
|
choices.append(f"{name} ({preset.model})")
|
||||||
|
choices.append("[+] Add new preset")
|
||||||
|
choices.append("<- Back")
|
||||||
|
return choices
|
||||||
|
|
||||||
|
last_preset_name: str | None = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
console.clear()
|
||||||
|
_show_section_header(
|
||||||
|
"Model Presets",
|
||||||
|
"Create, edit or delete named model presets for quick switching",
|
||||||
|
)
|
||||||
|
choices = get_preset_choices()
|
||||||
|
default_choice = None
|
||||||
|
if last_preset_name:
|
||||||
|
for c in choices:
|
||||||
|
if c.startswith(last_preset_name + " ("):
|
||||||
|
default_choice = c
|
||||||
|
break
|
||||||
|
answer = _select_with_back(
|
||||||
|
"Select preset:", choices, default=default_choice
|
||||||
|
)
|
||||||
|
|
||||||
|
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
||||||
|
break
|
||||||
|
|
||||||
|
assert isinstance(answer, str)
|
||||||
|
|
||||||
|
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",
|
||||||
|
).ask()
|
||||||
|
if not name_input:
|
||||||
|
continue
|
||||||
|
name = name_input.strip()
|
||||||
|
if name in config.model_presets:
|
||||||
|
console.print(f"[yellow]! Preset '{name}' already exists[/yellow]")
|
||||||
|
_pause()
|
||||||
|
continue
|
||||||
|
new_preset = ModelPresetConfig(model="")
|
||||||
|
updated = _configure_pydantic_model(new_preset, f"New Preset: {name}")
|
||||||
|
if updated is not None:
|
||||||
|
config.model_presets[name] = updated
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
last_preset_name = name
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Editing / deleting an existing preset
|
||||||
|
# Extract preset name from "name (model)" format
|
||||||
|
preset_name = answer.split(" (", 1)[0]
|
||||||
|
preset = config.model_presets.get(preset_name)
|
||||||
|
if preset is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_preset_name = preset_name
|
||||||
|
|
||||||
|
choices = ["Edit", "Cancel"]
|
||||||
|
if preset_name != "default":
|
||||||
|
choices.insert(1, "Delete")
|
||||||
|
action = _select_with_back(
|
||||||
|
f"Preset: {preset_name}",
|
||||||
|
choices,
|
||||||
|
default="Edit",
|
||||||
|
)
|
||||||
|
if action is _BACK_PRESSED or action == "Cancel" or action is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if action == "Delete":
|
||||||
|
confirm = _get_questionary().confirm(
|
||||||
|
f"Delete preset '{preset_name}'?",
|
||||||
|
default=False,
|
||||||
|
).ask()
|
||||||
|
if confirm:
|
||||||
|
del config.model_presets[preset_name]
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
last_preset_name = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if action == "Edit":
|
||||||
|
updated = _configure_pydantic_model(preset, f"Edit Preset: {preset_name}")
|
||||||
|
if updated is not None:
|
||||||
|
config.model_presets[preset_name] = updated
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n[dim]Returning to main menu...[/dim]")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
# --- Provider Configuration ---
|
# --- Provider Configuration ---
|
||||||
|
|
||||||
|
|
||||||
@@ -795,12 +1027,23 @@ def _configure_providers(config: Config) -> None:
|
|||||||
choices.append(display)
|
choices.append(display)
|
||||||
return choices + ["<- Back"]
|
return choices + ["<- Back"]
|
||||||
|
|
||||||
|
last_provider_key: str | None = None
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
console.clear()
|
console.clear()
|
||||||
_show_section_header("LLM Providers", "Select a provider to configure API key and endpoint")
|
_show_section_header("LLM Providers", "Select a provider to configure API key and endpoint")
|
||||||
choices = get_provider_choices()
|
choices = get_provider_choices()
|
||||||
answer = _select_with_back("Select provider:", choices)
|
default_choice = None
|
||||||
|
if last_provider_key:
|
||||||
|
display = _get_provider_names().get(last_provider_key)
|
||||||
|
if display:
|
||||||
|
for c in choices:
|
||||||
|
if c.replace(" *", "") == display:
|
||||||
|
default_choice = c
|
||||||
|
break
|
||||||
|
answer = _select_with_back(
|
||||||
|
"Select provider:", choices, default=default_choice
|
||||||
|
)
|
||||||
|
|
||||||
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
||||||
break
|
break
|
||||||
@@ -812,6 +1055,7 @@ def _configure_providers(config: Config) -> None:
|
|||||||
# Find the actual provider key from display names
|
# Find the actual provider key from display names
|
||||||
for name, display in _get_provider_names().items():
|
for name, display in _get_provider_names().items():
|
||||||
if display == provider_name:
|
if display == provider_name:
|
||||||
|
last_provider_key = name
|
||||||
_configure_provider(config, name)
|
_configure_provider(config, name)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -885,17 +1129,21 @@ def _configure_channels(config: Config) -> None:
|
|||||||
channel_names = list(_get_channel_names().keys())
|
channel_names = list(_get_channel_names().keys())
|
||||||
choices = channel_names + ["<- Back"]
|
choices = channel_names + ["<- Back"]
|
||||||
|
|
||||||
|
last_choice: str | None = None
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
console.clear()
|
console.clear()
|
||||||
_show_section_header("Chat Channels", "Select a channel to configure connection settings")
|
_show_section_header("Chat Channels", "Select a channel to configure connection settings")
|
||||||
answer = _select_with_back("Select channel:", choices)
|
answer = _select_with_back(
|
||||||
|
"Select channel:", choices, default=last_choice
|
||||||
|
)
|
||||||
|
|
||||||
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
|
||||||
break
|
break
|
||||||
|
|
||||||
# Type guard: answer is now guaranteed to be a string
|
# Type guard: answer is now guaranteed to be a string
|
||||||
assert isinstance(answer, str)
|
assert isinstance(answer, str)
|
||||||
|
last_choice = answer
|
||||||
_configure_channel(config, answer)
|
_configure_channel(config, answer)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[dim]Returning to main menu...[/dim]")
|
console.print("\n[dim]Returning to main menu...[/dim]")
|
||||||
@@ -1003,6 +1251,12 @@ def _show_summary(config: Config) -> None:
|
|||||||
channel_rows.append((display, status))
|
channel_rows.append((display, status))
|
||||||
_print_summary_panel(channel_rows, "Chat Channels")
|
_print_summary_panel(channel_rows, "Chat Channels")
|
||||||
|
|
||||||
|
# Model Presets
|
||||||
|
preset_rows = []
|
||||||
|
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")
|
||||||
|
|
||||||
# Settings sections
|
# Settings sections
|
||||||
for title, model in [
|
for title, model in [
|
||||||
("Agent Settings", config.agents.defaults),
|
("Agent Settings", config.agents.defaults),
|
||||||
@@ -1072,7 +1326,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
|
|
||||||
original_config = base_config.model_copy(deep=True)
|
original_config = base_config.model_copy(deep=True)
|
||||||
config = base_config.model_copy(deep=True)
|
config = base_config.model_copy(deep=True)
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
|
||||||
|
last_main_choice: str | None = None
|
||||||
while True:
|
while True:
|
||||||
console.clear()
|
console.clear()
|
||||||
_show_main_menu_header()
|
_show_main_menu_header()
|
||||||
@@ -1082,6 +1338,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
"What would you like to configure?",
|
"What would you like to configure?",
|
||||||
choices=[
|
choices=[
|
||||||
"[P] LLM Provider",
|
"[P] LLM Provider",
|
||||||
|
"[M] Model Presets",
|
||||||
"[C] Chat Channel",
|
"[C] Chat Channel",
|
||||||
"[H] Channel Common",
|
"[H] Channel Common",
|
||||||
"[A] Agent Settings",
|
"[A] Agent Settings",
|
||||||
@@ -1092,6 +1349,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
"[S] Save and Exit",
|
"[S] Save and Exit",
|
||||||
"[X] Exit Without Saving",
|
"[X] Exit Without Saving",
|
||||||
],
|
],
|
||||||
|
default=last_main_choice,
|
||||||
qmark=">",
|
qmark=">",
|
||||||
).ask()
|
).ask()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -1105,8 +1363,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
return OnboardResult(config=original_config, should_save=False)
|
return OnboardResult(config=original_config, should_save=False)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_MENU_DISPATCH = {
|
_menu_dispatch = {
|
||||||
"[P] LLM Provider": lambda: _configure_providers(config),
|
"[P] LLM Provider": lambda: _configure_providers(config),
|
||||||
|
"[M] Model Presets": lambda: _configure_model_presets(config),
|
||||||
"[C] Chat Channel": lambda: _configure_channels(config),
|
"[C] Chat Channel": lambda: _configure_channels(config),
|
||||||
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
||||||
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
||||||
@@ -1121,6 +1380,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
if answer == "[X] Exit Without Saving":
|
if answer == "[X] Exit Without Saving":
|
||||||
return OnboardResult(config=original_config, should_save=False)
|
return OnboardResult(config=original_config, should_save=False)
|
||||||
|
|
||||||
action_fn = _MENU_DISPATCH.get(answer)
|
action_fn = _menu_dispatch.get(answer)
|
||||||
if action_fn:
|
if action_fn:
|
||||||
|
last_main_choice = answer
|
||||||
action_fn()
|
action_fn()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
|
||||||
from pydantic.alias_generators import to_camel
|
from pydantic.alias_generators import to_camel
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
@@ -65,18 +65,34 @@ class DreamConfig(Base):
|
|||||||
return f"every {hours}h"
|
return f"every {hours}h"
|
||||||
|
|
||||||
|
|
||||||
|
class ModelPresetConfig(Base):
|
||||||
|
"""A named set of model + generation parameters for quick switching."""
|
||||||
|
|
||||||
|
model: str
|
||||||
|
provider: str = "auto"
|
||||||
|
max_tokens: int = 8192
|
||||||
|
context_window_tokens: int = 65_536
|
||||||
|
temperature: float = 0.1
|
||||||
|
reasoning_effort: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentDefaults(Base):
|
class AgentDefaults(Base):
|
||||||
"""Default agent configuration."""
|
"""Default agent configuration."""
|
||||||
|
|
||||||
workspace: str = "~/.nanobot/workspace"
|
workspace: str = "~/.nanobot/workspace"
|
||||||
|
model_preset: str | None = None # Active preset name — takes precedence over fields below
|
||||||
|
# Fallback fields (used when model_preset is not set):
|
||||||
model: str = "anthropic/claude-opus-4-5"
|
model: str = "anthropic/claude-opus-4-5"
|
||||||
provider: str = (
|
provider: str = (
|
||||||
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
||||||
)
|
)
|
||||||
max_tokens: int = 8192
|
max_tokens: int = 8192
|
||||||
context_window_tokens: int = 65_536
|
context_window_tokens: int = 65_536
|
||||||
context_block_limit: int | None = None
|
|
||||||
temperature: float = 0.1
|
temperature: float = 0.1
|
||||||
|
reasoning_effort: str | None = None # low / medium / high / adaptive - enables LLM thinking mode
|
||||||
|
# End fallback fields
|
||||||
|
|
||||||
|
context_block_limit: int | None = None
|
||||||
max_tool_iterations: int = 200
|
max_tool_iterations: int = 200
|
||||||
max_concurrent_subagents: int = Field(default=1, ge=1)
|
max_concurrent_subagents: int = Field(default=1, ge=1)
|
||||||
max_tool_result_chars: int = 16_000
|
max_tool_result_chars: int = 16_000
|
||||||
@@ -88,7 +104,9 @@ class AgentDefaults(Base):
|
|||||||
validation_alias=AliasChoices("toolHintMaxLength"),
|
validation_alias=AliasChoices("toolHintMaxLength"),
|
||||||
serialization_alias="toolHintMaxLength",
|
serialization_alias="toolHintMaxLength",
|
||||||
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
|
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
|
||||||
reasoning_effort: str | None = None # low / medium / high / adaptive - enables LLM thinking mode
|
fallback_presets: list[str] = Field(
|
||||||
|
default_factory=list
|
||||||
|
) # Ordered fallback chain. Each item must be a preset name defined in model_presets.
|
||||||
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
|
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
|
||||||
unified_session: bool = False # Share one session across all channels (single-user multi-device)
|
unified_session: bool = False # Share one session across all channels (single-user multi-device)
|
||||||
disabled_skills: list[str] = Field(default_factory=list) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"])
|
disabled_skills: list[str] = Field(default_factory=list) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"])
|
||||||
@@ -273,6 +291,54 @@ class Config(BaseSettings):
|
|||||||
api: ApiConfig = Field(default_factory=ApiConfig)
|
api: ApiConfig = Field(default_factory=ApiConfig)
|
||||||
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
||||||
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
||||||
|
model_presets: dict[str, ModelPresetConfig] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _sync_and_validate_preset(self) -> "Config":
|
||||||
|
"""Expose agents.defaults model fields as the implicit 'default' preset
|
||||||
|
and validate the active preset reference.
|
||||||
|
|
||||||
|
This guarantees that ``model_presets`` is never empty and that legacy
|
||||||
|
configs (which only set ``agents.defaults.model`` etc.) continue to work
|
||||||
|
without explicitly declaring a preset.
|
||||||
|
"""
|
||||||
|
self._refresh_default_preset()
|
||||||
|
defaults = self.agents.defaults
|
||||||
|
if defaults.model_preset is None:
|
||||||
|
defaults.model_preset = "default"
|
||||||
|
if defaults.model_preset not in self.model_presets:
|
||||||
|
raise ValueError(f"model_preset {defaults.model_preset!r} not found in model_presets")
|
||||||
|
for fb in defaults.fallback_presets:
|
||||||
|
if fb not in self.model_presets:
|
||||||
|
raise ValueError(f"fallback_presets entry {fb!r} not found in model_presets")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _refresh_default_preset(self) -> None:
|
||||||
|
"""Rebuild the implicit 'default' preset from current agents.defaults.
|
||||||
|
|
||||||
|
Called inside ``_sync_and_validate_preset`` (model validator) and
|
||||||
|
``resolve_preset()`` so that runtime mutations (e.g. tests directly
|
||||||
|
setting ``defaults.model``) are reflected.
|
||||||
|
"""
|
||||||
|
d = self.agents.defaults
|
||||||
|
self.model_presets["default"] = ModelPresetConfig(
|
||||||
|
model=d.model,
|
||||||
|
provider=d.provider,
|
||||||
|
max_tokens=d.max_tokens,
|
||||||
|
context_window_tokens=d.context_window_tokens,
|
||||||
|
temperature=d.temperature,
|
||||||
|
reasoning_effort=d.reasoning_effort,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_preset(self) -> ModelPresetConfig:
|
||||||
|
"""Return the active preset.
|
||||||
|
|
||||||
|
The implicit ``"default"`` preset is rebuilt from current defaults every
|
||||||
|
time so that runtime mutations (e.g. tests setting ``defaults.model``)
|
||||||
|
are always reflected.
|
||||||
|
"""
|
||||||
|
self._refresh_default_preset()
|
||||||
|
return self.model_presets[self.agents.defaults.model_preset]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def workspace_path(self) -> Path:
|
def workspace_path(self) -> Path:
|
||||||
@@ -285,15 +351,16 @@ class Config(BaseSettings):
|
|||||||
"""Match provider config and its registry name. Returns (config, spec_name)."""
|
"""Match provider config and its registry name. Returns (config, spec_name)."""
|
||||||
from nanobot.providers.registry import PROVIDERS, find_by_name
|
from nanobot.providers.registry import PROVIDERS, find_by_name
|
||||||
|
|
||||||
forced = self.agents.defaults.provider
|
resolved = self.resolve_preset()
|
||||||
|
forced = resolved.provider
|
||||||
if forced != "auto":
|
if forced != "auto":
|
||||||
spec = find_by_name(forced)
|
spec = find_by_name(forced)
|
||||||
if spec:
|
if spec:
|
||||||
p = getattr(self.providers, spec.name, None)
|
provider_cfg = getattr(self.providers, spec.name, None)
|
||||||
return (p, spec.name) if p else (None, None)
|
return (provider_cfg, spec.name) if provider_cfg else (None, None)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
model_lower = (model or self.agents.defaults.model).lower()
|
model_lower = (model or resolved.model).lower()
|
||||||
model_normalized = model_lower.replace("-", "_")
|
model_normalized = model_lower.replace("-", "_")
|
||||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||||
normalized_prefix = model_prefix.replace("-", "_")
|
normalized_prefix = model_prefix.replace("-", "_")
|
||||||
|
|||||||
+1
-32
@@ -8,7 +8,6 @@ from typing import Any
|
|||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -62,32 +61,7 @@ class Nanobot:
|
|||||||
Path(workspace).expanduser().resolve()
|
Path(workspace).expanduser().resolve()
|
||||||
)
|
)
|
||||||
|
|
||||||
provider = _make_provider(config)
|
loop = AgentLoop.from_config(config)
|
||||||
bus = MessageBus()
|
|
||||||
defaults = config.agents.defaults
|
|
||||||
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=bus,
|
|
||||||
provider=provider,
|
|
||||||
workspace=config.workspace_path,
|
|
||||||
model=defaults.model,
|
|
||||||
max_iterations=defaults.max_tool_iterations,
|
|
||||||
context_window_tokens=defaults.context_window_tokens,
|
|
||||||
context_block_limit=defaults.context_block_limit,
|
|
||||||
max_tool_result_chars=defaults.max_tool_result_chars,
|
|
||||||
provider_retry_mode=defaults.provider_retry_mode,
|
|
||||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
|
||||||
web_config=config.tools.web,
|
|
||||||
exec_config=config.tools.exec,
|
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
|
||||||
mcp_servers=config.tools.mcp_servers,
|
|
||||||
timezone=defaults.timezone,
|
|
||||||
unified_session=defaults.unified_session,
|
|
||||||
disabled_skills=defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=defaults.session_ttl_minutes,
|
|
||||||
consolidation_ratio=defaults.consolidation_ratio,
|
|
||||||
tools_config=config.tools,
|
|
||||||
)
|
|
||||||
return cls(loop)
|
return cls(loop)
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
@@ -124,8 +98,3 @@ class Nanobot:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(config: Any) -> Any:
|
|
||||||
"""Create the LLM provider from config (extracted from CLI)."""
|
|
||||||
from nanobot.providers.factory import make_provider
|
|
||||||
|
|
||||||
return make_provider(config)
|
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ class LLMProvider(ABC):
|
|||||||
"insufficient_quota",
|
"insufficient_quota",
|
||||||
"insufficient quota",
|
"insufficient quota",
|
||||||
"quota exceeded",
|
"quota exceeded",
|
||||||
|
"quota_exceeded",
|
||||||
"quota exhausted",
|
"quota exhausted",
|
||||||
|
"quota_exhausted",
|
||||||
"billing hard limit",
|
"billing hard limit",
|
||||||
"billing_hard_limit_reached",
|
"billing_hard_limit_reached",
|
||||||
"billing not active",
|
"billing not active",
|
||||||
|
|||||||
+127
-49
@@ -4,11 +4,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.config.schema import ModelPresetConfig, ProviderConfig
|
||||||
|
from nanobot.providers.registry import ProviderSpec
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProviderSnapshot:
|
class ProviderSnapshot:
|
||||||
@@ -18,22 +23,62 @@ class ProviderSnapshot:
|
|||||||
signature: tuple[object, ...]
|
signature: tuple[object, ...]
|
||||||
|
|
||||||
|
|
||||||
def make_provider(config: Config) -> LLMProvider:
|
@dataclass(frozen=True)
|
||||||
"""Create the LLM provider implied by config."""
|
class _ProviderInfo:
|
||||||
model = config.agents.defaults.model
|
"""Resolved metadata needed to build and validate an LLM provider."""
|
||||||
provider_name = config.get_provider_name(model)
|
|
||||||
p = config.get_provider(model)
|
name: str | None
|
||||||
spec = find_by_name(provider_name) if provider_name else None
|
cfg: ProviderConfig | None
|
||||||
|
spec: ProviderSpec | None
|
||||||
|
api_base: str | None
|
||||||
|
backend: str
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_provider_info(
|
||||||
|
config: Config,
|
||||||
|
model: str,
|
||||||
|
preset: ModelPresetConfig,
|
||||||
|
) -> _ProviderInfo:
|
||||||
|
"""Derive provider name, config, spec and api_base from preset or auto-detection."""
|
||||||
|
if preset.provider != "auto":
|
||||||
|
name = preset.provider
|
||||||
|
cfg = getattr(config.providers, name, None)
|
||||||
|
spec = find_by_name(name)
|
||||||
|
api_base = (
|
||||||
|
cfg.api_base
|
||||||
|
if cfg and cfg.api_base
|
||||||
|
else (spec.default_api_base if spec and spec.default_api_base else None)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
name = config.get_provider_name(model)
|
||||||
|
cfg = config.get_provider(model)
|
||||||
|
spec = find_by_name(name) if name else None
|
||||||
|
api_base = config.get_api_base(model)
|
||||||
|
|
||||||
backend = spec.backend if spec else "openai_compat"
|
backend = spec.backend if spec else "openai_compat"
|
||||||
|
return _ProviderInfo(name=name, cfg=cfg, spec=spec, api_base=api_base, backend=backend)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_provider(info: _ProviderInfo, model: str) -> None:
|
||||||
|
"""Ensure credentials / endpoints are present before instantiation."""
|
||||||
|
cfg = info.cfg
|
||||||
|
backend = info.backend
|
||||||
|
name = info.name
|
||||||
|
|
||||||
if backend == "azure_openai":
|
if backend == "azure_openai":
|
||||||
if not p or not p.api_key or not p.api_base:
|
if not cfg or not cfg.api_key or not cfg.api_base:
|
||||||
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
|
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
|
||||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
||||||
needs_key = not (p and p.api_key)
|
needs_key = not (cfg and cfg.api_key)
|
||||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
exempt = info.spec and (info.spec.is_oauth or info.spec.is_local or info.spec.is_direct)
|
||||||
if needs_key and not exempt:
|
if needs_key and not exempt:
|
||||||
raise ValueError(f"No API key configured for provider '{provider_name}'.")
|
raise ValueError(f"No API key configured for provider '{name}'.")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_provider(model: str, info: _ProviderInfo) -> LLMProvider:
|
||||||
|
"""Instantiate the concrete provider class for *backend*."""
|
||||||
|
cfg = info.cfg
|
||||||
|
backend = info.backend
|
||||||
|
|
||||||
if backend == "openai_codex":
|
if backend == "openai_codex":
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||||
@@ -43,8 +88,8 @@ def make_provider(config: Config) -> LLMProvider:
|
|||||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||||
|
|
||||||
provider = AzureOpenAIProvider(
|
provider = AzureOpenAIProvider(
|
||||||
api_key=p.api_key,
|
api_key=cfg.api_key if cfg else None,
|
||||||
api_base=p.api_base,
|
api_base=info.api_base,
|
||||||
default_model=model,
|
default_model=model,
|
||||||
)
|
)
|
||||||
elif backend == "github_copilot":
|
elif backend == "github_copilot":
|
||||||
@@ -55,70 +100,103 @@ def make_provider(config: Config) -> LLMProvider:
|
|||||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||||
|
|
||||||
provider = AnthropicProvider(
|
provider = AnthropicProvider(
|
||||||
api_key=p.api_key if p else None,
|
api_key=cfg.api_key if cfg else None,
|
||||||
api_base=config.get_api_base(model),
|
api_base=info.api_base,
|
||||||
default_model=model,
|
default_model=model,
|
||||||
extra_headers=p.extra_headers if p else None,
|
extra_headers=cfg.extra_headers if cfg else None,
|
||||||
)
|
)
|
||||||
elif backend == "bedrock":
|
elif backend == "bedrock":
|
||||||
from nanobot.providers.bedrock_provider import BedrockProvider
|
from nanobot.providers.bedrock_provider import BedrockProvider
|
||||||
|
|
||||||
provider = BedrockProvider(
|
provider = BedrockProvider(
|
||||||
api_key=p.api_key if p else None,
|
api_key=cfg.api_key if cfg else None,
|
||||||
api_base=p.api_base if p else None,
|
api_base=info.api_base if cfg else None,
|
||||||
default_model=model,
|
default_model=model,
|
||||||
region=getattr(p, "region", None) if p else None,
|
region=getattr(cfg, "region", None) if cfg else None,
|
||||||
profile=getattr(p, "profile", None) if p else None,
|
profile=getattr(cfg, "profile", None) if cfg else None,
|
||||||
extra_body=p.extra_body if p else None,
|
extra_body=cfg.extra_body if cfg else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
provider = OpenAICompatProvider(
|
||||||
api_key=p.api_key if p else None,
|
api_key=cfg.api_key if cfg else None,
|
||||||
api_base=config.get_api_base(model),
|
api_base=info.api_base,
|
||||||
default_model=model,
|
default_model=model,
|
||||||
extra_headers=p.extra_headers if p else None,
|
extra_headers=cfg.extra_headers if cfg else None,
|
||||||
spec=spec,
|
spec=info.spec,
|
||||||
extra_body=p.extra_body if p else None,
|
extra_body=cfg.extra_body if cfg else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
defaults = config.agents.defaults
|
|
||||||
provider.generation = GenerationSettings(
|
|
||||||
temperature=defaults.temperature,
|
|
||||||
max_tokens=defaults.max_tokens,
|
|
||||||
reasoning_effort=defaults.reasoning_effort,
|
|
||||||
)
|
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_generation(provider: LLMProvider, preset: ModelPresetConfig) -> None:
|
||||||
|
provider.generation = GenerationSettings(
|
||||||
|
temperature=preset.temperature,
|
||||||
|
max_tokens=preset.max_tokens,
|
||||||
|
reasoning_effort=preset.reasoning_effort,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_provider_for_preset(config: Config, preset: ModelPresetConfig) -> LLMProvider:
|
||||||
|
"""Create an LLM provider from a full *preset* (model + provider + generation)."""
|
||||||
|
info = _resolve_provider_info(config, preset.model, preset)
|
||||||
|
_validate_provider(info, preset.model)
|
||||||
|
provider = _create_provider(preset.model, info)
|
||||||
|
_apply_generation(provider, preset)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def make_provider(config: Config) -> LLMProvider:
|
||||||
|
"""Create the LLM provider implied by config (legacy entrypoint)."""
|
||||||
|
resolved = config.resolve_preset()
|
||||||
|
return build_provider_for_preset(config, resolved)
|
||||||
|
|
||||||
|
|
||||||
|
def make_provider_factory(config: Config):
|
||||||
|
"""Build a cached factory that creates providers for preset names.
|
||||||
|
|
||||||
|
The factory looks up *preset_name* in ``config.model_presets`` and builds
|
||||||
|
the provider from the preset's full configuration.
|
||||||
|
"""
|
||||||
|
cache: dict[str, LLMProvider] = {}
|
||||||
|
presets = config.model_presets
|
||||||
|
|
||||||
|
def factory(preset_name: str) -> LLMProvider:
|
||||||
|
preset = presets.get(preset_name)
|
||||||
|
if preset is None:
|
||||||
|
raise ValueError(f"Preset {preset_name!r} not found in model_presets")
|
||||||
|
if preset_name not in cache:
|
||||||
|
cache[preset_name] = build_provider_for_preset(config, preset)
|
||||||
|
return cache[preset_name]
|
||||||
|
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
def provider_signature(config: Config) -> tuple[object, ...]:
|
def provider_signature(config: Config) -> tuple[object, ...]:
|
||||||
"""Return the config fields that affect the primary LLM provider."""
|
"""Return the config fields that affect the primary LLM provider."""
|
||||||
model = config.agents.defaults.model
|
resolved = config.resolve_preset()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
p = config.get_provider(model)
|
|
||||||
return (
|
return (
|
||||||
model,
|
resolved.model,
|
||||||
defaults.provider,
|
resolved.provider,
|
||||||
config.get_provider_name(model),
|
config.get_provider_name(resolved.model),
|
||||||
config.get_api_key(model),
|
config.get_api_key(resolved.model),
|
||||||
config.get_api_base(model),
|
config.get_api_base(resolved.model),
|
||||||
p.extra_headers if p else None,
|
resolved.max_tokens,
|
||||||
p.extra_body if p else None,
|
resolved.temperature,
|
||||||
getattr(p, "region", None) if p else None,
|
resolved.reasoning_effort,
|
||||||
getattr(p, "profile", None) if p else None,
|
resolved.context_window_tokens,
|
||||||
defaults.max_tokens,
|
tuple(defaults.fallback_presets),
|
||||||
defaults.temperature,
|
|
||||||
defaults.reasoning_effort,
|
|
||||||
defaults.context_window_tokens,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_provider_snapshot(config: Config) -> ProviderSnapshot:
|
def build_provider_snapshot(config: Config) -> ProviderSnapshot:
|
||||||
|
resolved = config.resolve_preset()
|
||||||
return ProviderSnapshot(
|
return ProviderSnapshot(
|
||||||
provider=make_provider(config),
|
provider=make_provider(config),
|
||||||
model=config.agents.defaults.model,
|
model=resolved.model,
|
||||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
context_window_tokens=resolved.context_window_tokens,
|
||||||
signature=provider_signature(config),
|
signature=provider_signature(config),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Provider-like failover router used after provider-local retry is exhausted."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRouter(LLMProvider):
|
||||||
|
"""Try fallback model candidates for eligible transient final errors."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
primary_provider: LLMProvider,
|
||||||
|
primary_model: str,
|
||||||
|
fallback_presets: list[str],
|
||||||
|
provider_factory: Callable[[str], LLMProvider] | None = None,
|
||||||
|
per_candidate_timeout_s: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
api_key=getattr(primary_provider, "api_key", None),
|
||||||
|
api_base=getattr(primary_provider, "api_base", None),
|
||||||
|
)
|
||||||
|
self.primary_provider = primary_provider
|
||||||
|
self.primary_model = primary_model
|
||||||
|
self.fallback_presets = list(fallback_presets)
|
||||||
|
self._provider_factory = provider_factory
|
||||||
|
self._provider_cache: dict[str, LLMProvider] = {}
|
||||||
|
self.per_candidate_timeout_s = per_candidate_timeout_s
|
||||||
|
self.generation = getattr(primary_provider, "generation", GenerationSettings())
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return self.primary_model
|
||||||
|
|
||||||
|
async def chat(self, **kwargs: Any) -> LLMResponse:
|
||||||
|
async def call(provider: LLMProvider, candidate_model: str, _unused_delta: Any) -> LLMResponse:
|
||||||
|
return await provider.chat(**{**kwargs, "model": candidate_model})
|
||||||
|
return await self._route(call)
|
||||||
|
|
||||||
|
async def chat_stream(self, **kwargs: Any) -> LLMResponse:
|
||||||
|
async def call(provider: LLMProvider, candidate_model: str, content_delta: Any) -> LLMResponse:
|
||||||
|
return await provider.chat_stream(
|
||||||
|
**{**kwargs, "model": candidate_model, "on_content_delta": content_delta}
|
||||||
|
)
|
||||||
|
return await self._route(call, on_content_delta=kwargs.get("on_content_delta"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_progress_deltas(self) -> bool: # type: ignore[override]
|
||||||
|
return getattr(self.primary_provider, "supports_progress_deltas", False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _should_failover(cls, response: LLMResponse) -> bool:
|
||||||
|
if response.finish_reason != "error":
|
||||||
|
return False
|
||||||
|
if response.error_should_retry is False:
|
||||||
|
return False
|
||||||
|
if response.error_kind == "configuration":
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _resolve(self, model: str) -> tuple[LLMProvider, str]:
|
||||||
|
"""Return (provider, actual_model_name) for a preset name.
|
||||||
|
|
||||||
|
Caches results so factory is only invoked once per unique name.
|
||||||
|
"""
|
||||||
|
if model in self._provider_cache:
|
||||||
|
cached_provider = self._provider_cache[model]
|
||||||
|
return cached_provider, cached_provider.get_default_model()
|
||||||
|
if self._provider_factory is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot resolve fallback model {model!r}: no provider_factory configured"
|
||||||
|
)
|
||||||
|
provider = self._provider_factory(model)
|
||||||
|
self._provider_cache[model] = provider
|
||||||
|
return provider, provider.get_default_model()
|
||||||
|
|
||||||
|
async def _with_timeout(self, coro: Awaitable[LLMResponse]) -> LLMResponse:
|
||||||
|
timeout_s = self.per_candidate_timeout_s
|
||||||
|
if timeout_s is None:
|
||||||
|
return await coro
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(coro, timeout=timeout_s)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return LLMResponse(
|
||||||
|
content=f"Error calling LLM: timed out after {timeout_s:g}s",
|
||||||
|
finish_reason="error",
|
||||||
|
error_kind="timeout",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolver_error(label: str, exc: Exception) -> LLMResponse:
|
||||||
|
logger.warning("Failed to resolve fallback model {}: {}", label, exc)
|
||||||
|
return LLMResponse(
|
||||||
|
content=f"Error configuring fallback model {label}: {exc}",
|
||||||
|
finish_reason="error",
|
||||||
|
error_kind="configuration",
|
||||||
|
error_should_retry=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _route(
|
||||||
|
self,
|
||||||
|
call: Callable[[LLMProvider, str, Callable[[str], Awaitable[None]] | None], Awaitable[LLMResponse]],
|
||||||
|
*,
|
||||||
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
"""Try primary then each fallback candidate, lazily resolving providers."""
|
||||||
|
|
||||||
|
async def _try_one(label: str, provider: LLMProvider, model: str) -> LLMResponse:
|
||||||
|
try:
|
||||||
|
return await self._with_timeout(call(provider, model, on_content_delta))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
return self._resolver_error(label, exc)
|
||||||
|
|
||||||
|
# Primary
|
||||||
|
response = await _try_one("primary", self.primary_provider, self.primary_model)
|
||||||
|
if response.finish_reason != "error":
|
||||||
|
return response
|
||||||
|
if not self._should_failover(response):
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Fallbacks
|
||||||
|
for name in self.fallback_presets:
|
||||||
|
try:
|
||||||
|
provider, model = self._resolve(name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to resolve fallback model {}: {}", name, exc)
|
||||||
|
return self._resolver_error(name, exc)
|
||||||
|
|
||||||
|
response = await _try_one(name, provider, model)
|
||||||
|
if response.finish_reason != "error":
|
||||||
|
logger.info("LLM failover selected model={}", name)
|
||||||
|
return response
|
||||||
|
if not self._should_failover(response):
|
||||||
|
return response
|
||||||
|
|
||||||
|
logger.warning("LLM failover exhausted after all candidates")
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def chat_with_retry(self, **kwargs: Any) -> LLMResponse:
|
||||||
|
async def call(
|
||||||
|
provider: LLMProvider, candidate_model: str, _unused_delta: Any
|
||||||
|
) -> LLMResponse:
|
||||||
|
return await provider.chat_with_retry(
|
||||||
|
**{**kwargs, "model": candidate_model}
|
||||||
|
)
|
||||||
|
return await self._route(call)
|
||||||
|
|
||||||
|
async def chat_stream_with_retry(self, **kwargs: Any) -> LLMResponse:
|
||||||
|
on_content_delta = kwargs.pop("on_content_delta", None)
|
||||||
|
|
||||||
|
async def call(
|
||||||
|
provider: LLMProvider,
|
||||||
|
candidate_model: str,
|
||||||
|
content_delta: Callable[[str], Awaitable[None]] | None,
|
||||||
|
) -> LLMResponse:
|
||||||
|
buffered: list[str] = []
|
||||||
|
|
||||||
|
async def buffer_delta(delta: str) -> None:
|
||||||
|
buffered.append(delta)
|
||||||
|
|
||||||
|
kwargs["on_content_delta"] = buffer_delta if content_delta else None
|
||||||
|
response = await provider.chat_stream_with_retry(
|
||||||
|
**{**kwargs, "model": candidate_model}
|
||||||
|
)
|
||||||
|
if response.finish_reason != "error" and content_delta:
|
||||||
|
try:
|
||||||
|
for delta in buffered:
|
||||||
|
await content_delta(delta)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failover delta callback failed for model={}", candidate_model)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return await self._route(call, on_content_delta=on_content_delta)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: create-instance
|
||||||
|
description: "Create a new nanobot instance with separate config and workspace. Use when the user wants to set up a new bot, create a new instance for a different channel, persona, or purpose. Triggers on: create instance, new bot, set up bot, add bot, create telegram/discord/feishu/slack/wechat/wecom/dingtalk/qq/email/matrix/msteams/whatsapp bot, multi-instance setup."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create Instance
|
||||||
|
|
||||||
|
Set up a new nanobot instance with its own config and workspace.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Collect information** (ask one at a time if not already provided):
|
||||||
|
- **Instance name** (required): short identifier, e.g. `telegram-bot`, `work-slack`
|
||||||
|
- **Channel type** (required): see table below
|
||||||
|
- **Model** (optional): LLM model, defaults to current instance
|
||||||
|
|
||||||
|
2. **Do NOT collect secrets** in the chat (API keys, bot tokens). API keys are automatically inherited from the current instance via `--inherit-config`. Channel-specific tokens must be filled in manually after creation.
|
||||||
|
|
||||||
|
3. **Run the creation script**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python <skill-dir>/scripts/create_instance.py --name <name> --channel <channel> --inherit-config <current-config>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<skill-dir>` — the directory containing this SKILL.md
|
||||||
|
- `<current-config>` — current instance's config path, typically `~/.nanobot/config.json`
|
||||||
|
- Optional: `--model <model>`, `--config-dir <path>`
|
||||||
|
|
||||||
|
**Exec tool constraints:**
|
||||||
|
- Use forward-slash paths (works on all platforms)
|
||||||
|
- Do not wrap paths in quotes
|
||||||
|
- Do not use `cd`; pass the full script path directly
|
||||||
|
|
||||||
|
4. **Report results** to the user:
|
||||||
|
- Config and workspace paths (script outputs them)
|
||||||
|
- Required fields to fill in (script lists them)
|
||||||
|
- Start command: `nanobot gateway --config <config-path>`
|
||||||
|
|
||||||
|
## Available Channels
|
||||||
|
|
||||||
|
| Channel | Key | Required Fields |
|
||||||
|
|---------|-----|-----------------|
|
||||||
|
| Telegram | `telegram` | token |
|
||||||
|
| Discord | `discord` | token |
|
||||||
|
| Feishu / Lark | `feishu` | app_id, app_secret |
|
||||||
|
| DingTalk | `dingtalk` | client_id, client_secret |
|
||||||
|
| Slack | `slack` | bot_token, app_token |
|
||||||
|
| WeCom | `wecom` | bot_id, secret |
|
||||||
|
| WeChat OA | `weixin` | token |
|
||||||
|
| WhatsApp | `whatsapp` | bridge_token |
|
||||||
|
| QQ | `qq` | app_id, secret |
|
||||||
|
| Email | `email` | imap_host, imap_username, imap_password, smtp_host, smtp_username, smtp_password, from_address |
|
||||||
|
| Matrix | `matrix` | user_id, password or access_token |
|
||||||
|
| MS Teams | `msteams` | app_id, app_password, tenant_id |
|
||||||
|
| MoChat | `mochat` | claw_token |
|
||||||
|
| WebSocket | `websocket` | token |
|
||||||
|
|
||||||
|
For detailed channel configuration including optional fields, see `references/channels.md`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **"Unknown channel"**: Channel name must match the Key column exactly. Run the script without arguments to see usage.
|
||||||
|
- **"Config already exists"**: Use a different `--name` or `--config-dir` to create in a new location.
|
||||||
|
- **Port conflicts**: The script auto-assigns free ports for gateway and API if defaults are in use.
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Channel Configuration Reference
|
||||||
|
|
||||||
|
Detailed configuration for each supported channel.
|
||||||
|
|
||||||
|
## Field Types
|
||||||
|
|
||||||
|
- **Required**: defaults to empty string `""`, must be filled in before the instance can start
|
||||||
|
- **Optional**: has a sensible default, can be customized
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## telegram
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `token` — Bot token from @BotFather
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `proxy` — HTTP proxy URL
|
||||||
|
- `group_policy` — `"open"` (all messages) or `"mention"` (default, only when @mentioned)
|
||||||
|
- `streaming` — Enable streaming responses (default: true)
|
||||||
|
- `reply_to_message` — Reply to the triggering message (default: false)
|
||||||
|
- `react_emoji` — Emoji for "thinking" reaction (default: `"eyes"`)
|
||||||
|
- `inline_keyboards` — Enable inline keyboard buttons (default: false)
|
||||||
|
|
||||||
|
## discord
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `token` — Bot token from Discord Developer Portal
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `allow_channels` — Restrict to specific channel IDs
|
||||||
|
- `group_policy` — `"mention"` (default) or `"open"`
|
||||||
|
- `streaming` — Enable streaming (default: true)
|
||||||
|
- `proxy` — HTTP proxy URL
|
||||||
|
- `intents` — Discord gateway intents (default: 37377)
|
||||||
|
- `read_receipt_emoji` — Emoji for read receipt
|
||||||
|
- `working_emoji` — Emoji for "working" indicator
|
||||||
|
|
||||||
|
## feishu
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `app_id` — Feishu app ID
|
||||||
|
- `app_secret` — Feishu app secret
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `encrypt_key` — Event encryption key
|
||||||
|
- `verification_token` — Event verification token
|
||||||
|
- `domain` — `"feishu"` (default) or `"lark"`
|
||||||
|
- `group_policy` — `"mention"` (default) or `"open"`
|
||||||
|
- `streaming` — Enable streaming (default: true)
|
||||||
|
|
||||||
|
## dingtalk
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `client_id` — DingTalk app client ID
|
||||||
|
- `client_secret` — DingTalk app client secret
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `allow_from` — Allowed user IDs
|
||||||
|
|
||||||
|
## slack
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `bot_token` — Bot OAuth token (`xoxb-...`)
|
||||||
|
- `app_token` — App-level token (`xapp-...`)
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `mode` — `"socket"` (default, Socket Mode) or `"webhook"`
|
||||||
|
- `reply_in_thread` — Reply in thread (default: true)
|
||||||
|
- `react_emoji` — "thinking" emoji (default: `"eyes"`)
|
||||||
|
- `done_emoji` — "done" emoji (default: `"white_check_mark"`)
|
||||||
|
- `group_policy` — `"mention"` (default) or `"open"`
|
||||||
|
- `dm.enabled` — Enable DM support
|
||||||
|
- `dm.policy` — DM policy
|
||||||
|
- `dm.allow_from` — Allowed DM users
|
||||||
|
|
||||||
|
## wecom
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `bot_id` — WeCom bot ID
|
||||||
|
- `secret` — WeCom bot secret
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `allow_from` — Allowed users
|
||||||
|
- `welcome_message` — Welcome message for new chats
|
||||||
|
|
||||||
|
## weixin
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `token` — WeChat Official Account token
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `base_url` — API base URL
|
||||||
|
- `cdn_base_url` — CDN base URL
|
||||||
|
- `state_dir` — State persistence directory
|
||||||
|
- `poll_timeout` — Long polling timeout
|
||||||
|
|
||||||
|
## whatsapp
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `bridge_token` — WhatsApp bridge token (auto-generated if absent)
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `bridge_url` — Bridge WebSocket URL (default: `"ws://localhost:3001"`)
|
||||||
|
- `group_policy` — `"open"` (default) or `"mention"`
|
||||||
|
|
||||||
|
## qq
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `app_id` — QQ bot app ID
|
||||||
|
- `secret` — QQ bot secret
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `msg_format` — `"plain"` or `"markdown"`
|
||||||
|
- `ack_message` — Acknowledgment message text
|
||||||
|
- `media_dir` — Media file directory
|
||||||
|
|
||||||
|
## email
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `imap_host` — IMAP server hostname
|
||||||
|
- `imap_username` — IMAP login username
|
||||||
|
- `imap_password` — IMAP login password
|
||||||
|
- `smtp_host` — SMTP server hostname
|
||||||
|
- `smtp_username` — SMTP login username
|
||||||
|
- `smtp_password` — SMTP login password
|
||||||
|
- `from_address` — Sender email address
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `imap_port` — IMAP port (default: 993)
|
||||||
|
- `smtp_port` — SMTP port (default: 587)
|
||||||
|
- `imap_use_ssl` — Use SSL for IMAP (default: true)
|
||||||
|
- `smtp_use_tls` — Use TLS for SMTP (default: true)
|
||||||
|
- `poll_interval_seconds` — Polling interval (default: 30)
|
||||||
|
- `mark_seen` — Mark emails as read (default: true)
|
||||||
|
- `max_body_chars` — Max email body length (default: 12000)
|
||||||
|
- `subject_prefix` — Reply subject prefix (default: `"Re: "`)
|
||||||
|
- `verify_dkim` — Verify DKIM signatures (default: true)
|
||||||
|
- `verify_spf` — Verify SPF records (default: true)
|
||||||
|
- `allowed_attachment_types` — Allowed file extensions
|
||||||
|
- `max_attachment_size` — Max attachment size in bytes
|
||||||
|
- `consent_granted` — Must be set to `true` for the channel to start (default: false)
|
||||||
|
- `auto_reply_enabled` — Enable auto-reply (default: true)
|
||||||
|
|
||||||
|
## matrix
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `user_id` — Matrix user ID (e.g. `@bot:matrix.org`)
|
||||||
|
- `password` or `access_token` — Login password OR access token
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `homeserver` — Homeserver URL (default: `"https://matrix.org"`)
|
||||||
|
- `device_id` — Device ID
|
||||||
|
- `e2eeEnabled` — Enable end-to-end encryption (default: true)
|
||||||
|
- `group_policy` — `"open"`, `"mention"`, or `"allowlist"`
|
||||||
|
- `streaming` — Enable streaming (default: false)
|
||||||
|
- `max_media_bytes` — Max media file size (default: 20MB)
|
||||||
|
|
||||||
|
## msteams
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `app_id` — Azure AD app ID
|
||||||
|
- `app_password` — Azure AD app password/secret
|
||||||
|
- `tenant_id` — Azure AD tenant ID
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `host` — Listen host (default: `"0.0.0.0"`)
|
||||||
|
- `port` — Listen port (default: 3978)
|
||||||
|
- `reply_in_thread` — Reply in thread (default: true)
|
||||||
|
- `validate_inbound_auth` — Validate incoming auth (default: true)
|
||||||
|
|
||||||
|
## mochat
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `claw_token` — MoChat Claw token
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `base_url` — API base URL
|
||||||
|
- `socket_url` — WebSocket URL
|
||||||
|
- `refresh_interval_ms` — Refresh interval in ms
|
||||||
|
- `watch_timeout_ms` — Watch timeout in ms
|
||||||
|
|
||||||
|
## websocket
|
||||||
|
|
||||||
|
Built-in WebSocket channel for programmatic access.
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `token` — Authentication token (enabled by default; set `websocket_requires_token: false` to disable)
|
||||||
|
|
||||||
|
**Notable optional:**
|
||||||
|
- `host` — Listen host (default: `"127.0.0.1"`)
|
||||||
|
- `port` — Listen port (default: 8765)
|
||||||
|
- `allow_from` — Allowed origins (default: `["*"]`)
|
||||||
|
- `streaming` — Enable streaming (default: true)
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a new nanobot instance with a dedicated config and workspace.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
create_instance.py --name <name> --channel <channel> [--model <model>] [--config-dir <dir>]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
create_instance.py --name telegram-bot --channel telegram
|
||||||
|
create_instance.py --name discord-bot --channel discord --model deepseek/deepseek-chat
|
||||||
|
create_instance.py --name my-bot --channel telegram --config-dir ~/.nanobot-custom
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_name(name: str) -> str:
|
||||||
|
"""Normalize and validate instance name."""
|
||||||
|
name = name.strip().lower()
|
||||||
|
name = re.sub(r"[^a-z0-9-]", "-", name)
|
||||||
|
name = re.sub(r"-{2,}", "-", name)
|
||||||
|
name = name.strip("-")
|
||||||
|
if not name:
|
||||||
|
print("[ERROR] Instance name must contain at least one letter or digit.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if len(name) > 64:
|
||||||
|
print(f"[ERROR] Instance name too long ({len(name)} chars, max 64).", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _get_available_channels() -> list[str]:
|
||||||
|
"""Get list of available channel names without importing channel classes."""
|
||||||
|
from nanobot.channels.registry import discover_channel_names
|
||||||
|
|
||||||
|
return discover_channel_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_onboard(config_path: Path, workspace: Path) -> None:
|
||||||
|
"""Create skeleton config + workspace using nanobot's programmatic API."""
|
||||||
|
from nanobot.cli.commands import _onboard_plugins
|
||||||
|
from nanobot.config.loader import save_config, set_config_path
|
||||||
|
from nanobot.config.paths import get_workspace_path
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.workspace = str(workspace)
|
||||||
|
set_config_path(config_path)
|
||||||
|
save_config(config, config_path)
|
||||||
|
_onboard_plugins(config_path)
|
||||||
|
|
||||||
|
workspace_path = get_workspace_path(config.workspace_path)
|
||||||
|
if not workspace_path.exists():
|
||||||
|
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
sync_workspace_templates(workspace_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_config(
|
||||||
|
config_path: Path,
|
||||||
|
*,
|
||||||
|
channel: str,
|
||||||
|
workspace: Path,
|
||||||
|
model: str | None,
|
||||||
|
inherit_config_path: Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Patch the generated config: enable channel, set workspace, optionally set model."""
|
||||||
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
# Inherit providers and model from current instance
|
||||||
|
if inherit_config_path and inherit_config_path.exists():
|
||||||
|
try:
|
||||||
|
src = json.loads(inherit_config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
# Inherit providers (API keys, api_base, etc.)
|
||||||
|
src_providers = src.get("providers", {})
|
||||||
|
if src_providers:
|
||||||
|
data.setdefault("providers", {})
|
||||||
|
for key, val in src_providers.items():
|
||||||
|
if isinstance(val, dict) and val.get("apiKey"):
|
||||||
|
data["providers"][key] = val
|
||||||
|
|
||||||
|
# Inherit model if not explicitly overridden
|
||||||
|
if not model:
|
||||||
|
parent_model = src.get("agents", {}).get("defaults", {}).get("model")
|
||||||
|
if parent_model:
|
||||||
|
model = parent_model
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[WARN] Could not inherit from {inherit_config_path}: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Set workspace and model
|
||||||
|
data.setdefault("agents", {}).setdefault("defaults", {})
|
||||||
|
data["agents"]["defaults"]["workspace"] = str(workspace)
|
||||||
|
if model:
|
||||||
|
data["agents"]["defaults"]["model"] = model
|
||||||
|
|
||||||
|
# Enable the target channel
|
||||||
|
channels = data.setdefault("channels", {})
|
||||||
|
if channel in channels and isinstance(channels[channel], dict):
|
||||||
|
channels[channel]["enabled"] = True
|
||||||
|
else:
|
||||||
|
channels[channel] = {"enabled": True}
|
||||||
|
|
||||||
|
# Auto-assign ports if defaults are already in use
|
||||||
|
_assign_free_ports(data)
|
||||||
|
|
||||||
|
# Validate with Pydantic, then save
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
Config.model_validate(data)
|
||||||
|
config_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
||||||
|
"""Check if a port is already in use."""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
try:
|
||||||
|
s.bind((host, port))
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _find_free_port(start: int, host: str = "127.0.0.1", max_tries: int = 100) -> int:
|
||||||
|
"""Find the first free port starting from `start`."""
|
||||||
|
for port in range(start, start + max_tries):
|
||||||
|
if not _is_port_in_use(port, host):
|
||||||
|
return port
|
||||||
|
# OS-level fallback: ask the kernel for an ephemeral port
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind((host, 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_free_ports(data: dict) -> None:
|
||||||
|
"""If default gateway or API ports are in use, assign free ones."""
|
||||||
|
from nanobot.config.schema import ApiConfig, GatewayConfig
|
||||||
|
|
||||||
|
defaults = [
|
||||||
|
("gateway", GatewayConfig()),
|
||||||
|
("api", ApiConfig()),
|
||||||
|
]
|
||||||
|
for key, default_cfg in defaults:
|
||||||
|
section = data.setdefault(key, {})
|
||||||
|
port = section.get("port", default_cfg.port)
|
||||||
|
host = section.get("host", default_cfg.host)
|
||||||
|
if _is_port_in_use(port, host):
|
||||||
|
section["port"] = _find_free_port(port + 1, host)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_channel_required_fields(channel: str) -> list[str]:
|
||||||
|
"""Inspect a channel's default config and list fields that are empty strings."""
|
||||||
|
try:
|
||||||
|
from nanobot.channels.registry import load_channel_class
|
||||||
|
|
||||||
|
cls = load_channel_class(channel)
|
||||||
|
default = cls.default_config()
|
||||||
|
return sorted(k for k, v in default.items() if isinstance(v, str) and v == "" and k != "enabled")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[WARN] Could not inspect channel '{channel}' defaults: {exc}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Create a new nanobot instance.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--name", required=True, help="Instance name (e.g. telegram-bot)")
|
||||||
|
parser.add_argument("--channel", required=True, help="Channel type (e.g. telegram, discord)")
|
||||||
|
parser.add_argument("--model", default=None, help="LLM model (default: same as current instance)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--config-dir",
|
||||||
|
default=None,
|
||||||
|
help="Config directory (default: ~/.nanobot-{name})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--inherit-config",
|
||||||
|
default=None,
|
||||||
|
help="Path to current instance's config.json to copy API keys from",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Validate name
|
||||||
|
name = _validate_name(args.name)
|
||||||
|
|
||||||
|
# Validate channel
|
||||||
|
available = _get_available_channels()
|
||||||
|
if args.channel not in available:
|
||||||
|
print(f"[ERROR] Unknown channel: {args.channel}", file=sys.stderr)
|
||||||
|
print(f"Available channels: {', '.join(sorted(available))}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Resolve paths
|
||||||
|
home = Path.home()
|
||||||
|
config_dir = Path(args.config_dir).expanduser().resolve() if args.config_dir else home / f".nanobot-{name}"
|
||||||
|
config_path = config_dir / "config.json"
|
||||||
|
workspace = config_dir / "workspace"
|
||||||
|
|
||||||
|
# Check for duplicate
|
||||||
|
if config_path.exists():
|
||||||
|
print(f"[ERROR] Config already exists at {config_path}", file=sys.stderr)
|
||||||
|
print("Delete it first or use a different --config-dir.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Creating instance '{name}'...")
|
||||||
|
print(f" Config dir: {config_dir}")
|
||||||
|
print(f" Workspace: {workspace}")
|
||||||
|
print(f" Channel: {args.channel}")
|
||||||
|
if args.model:
|
||||||
|
print(f" Model: {args.model}")
|
||||||
|
|
||||||
|
# Run onboard
|
||||||
|
_run_onboard(config_path, workspace)
|
||||||
|
|
||||||
|
# Patch config
|
||||||
|
inherit_path = Path(args.inherit_config).expanduser().resolve() if args.inherit_config else None
|
||||||
|
_patch_config(
|
||||||
|
config_path,
|
||||||
|
channel=args.channel,
|
||||||
|
workspace=workspace,
|
||||||
|
model=args.model,
|
||||||
|
inherit_config_path=inherit_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Report
|
||||||
|
print(f"\n[OK] Instance '{name}' created successfully.")
|
||||||
|
print(f" Config: {config_path}")
|
||||||
|
print(f" Workspace: {workspace}")
|
||||||
|
|
||||||
|
# List fields the user needs to fill in
|
||||||
|
required_fields = _get_channel_required_fields(args.channel)
|
||||||
|
if required_fields:
|
||||||
|
print(f"\n[IMPORTANT] Edit {config_path} and fill in these fields:")
|
||||||
|
for field in required_fields:
|
||||||
|
print(f" - channels.{args.channel}.{field}")
|
||||||
|
|
||||||
|
print(f"\nTo start the instance:")
|
||||||
|
print(f" nanobot gateway --config {config_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,27 +4,22 @@ These tests focus on the business logic behind the onboard wizard,
|
|||||||
without testing the interactive UI components.
|
without testing the interactive UI components.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from nanobot.cli import onboard as onboard_wizard
|
from nanobot.cli import onboard as onboard_wizard
|
||||||
|
|
||||||
# Import functions to test
|
|
||||||
from nanobot.cli.commands import _merge_missing_defaults
|
from nanobot.cli.commands import _merge_missing_defaults
|
||||||
from nanobot.cli.onboard import (
|
from nanobot.cli.onboard import (
|
||||||
_BACK_PRESSED,
|
_BACK_PRESSED,
|
||||||
_configure_pydantic_model,
|
_configure_pydantic_model,
|
||||||
_format_value,
|
_format_value,
|
||||||
|
_get_constraint_hint,
|
||||||
_get_field_display_name,
|
_get_field_display_name,
|
||||||
_get_field_type_info,
|
_get_field_type_info,
|
||||||
_get_constraint_hint,
|
|
||||||
_input_text,
|
_input_text,
|
||||||
_validate_field_constraint,
|
|
||||||
run_onboard,
|
run_onboard,
|
||||||
)
|
)
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
@@ -640,8 +635,8 @@ class TestValidateFieldConstraint:
|
|||||||
|
|
||||||
def test_real_send_max_retries_field(self):
|
def test_real_send_max_retries_field(self):
|
||||||
"""Validate against the actual ChannelsConfig.send_max_retries field."""
|
"""Validate against the actual ChannelsConfig.send_max_retries field."""
|
||||||
from nanobot.config.schema import ChannelsConfig
|
|
||||||
from nanobot.cli.onboard import _validate_field_constraint
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
from nanobot.config.schema import ChannelsConfig
|
||||||
|
|
||||||
field_info = ChannelsConfig.model_fields["send_max_retries"]
|
field_info = ChannelsConfig.model_fields["send_max_retries"]
|
||||||
assert _validate_field_constraint(3, field_info) is None
|
assert _validate_field_constraint(3, field_info) is None
|
||||||
@@ -833,12 +828,11 @@ class TestMainMenuUpdate:
|
|||||||
|
|
||||||
def test_main_menu_dispatch_includes_channel_common(self):
|
def test_main_menu_dispatch_includes_channel_common(self):
|
||||||
"""Main menu dispatch should route [H] to Channel Common."""
|
"""Main menu dispatch should route [H] to Channel Common."""
|
||||||
from nanobot.cli.onboard import run_onboard
|
|
||||||
|
|
||||||
# We verify by checking the dispatch table is set up correctly
|
# We verify by checking the dispatch table is set up correctly
|
||||||
# The menu items are defined inline in run_onboard, so we test
|
# The menu items are defined inline in run_onboard, so we test
|
||||||
# that _configure_general_settings handles the new sections.
|
# that _configure_general_settings handles the new sections.
|
||||||
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
|
from nanobot.cli.onboard import _SETTINGS_GETTER, _SETTINGS_SECTIONS, _SETTINGS_SETTER
|
||||||
|
|
||||||
assert "Channel Common" in _SETTINGS_SECTIONS
|
assert "Channel Common" in _SETTINGS_SECTIONS
|
||||||
assert "Channel Common" in _SETTINGS_GETTER
|
assert "Channel Common" in _SETTINGS_GETTER
|
||||||
@@ -846,7 +840,7 @@ class TestMainMenuUpdate:
|
|||||||
|
|
||||||
def test_main_menu_dispatch_includes_api_server(self):
|
def test_main_menu_dispatch_includes_api_server(self):
|
||||||
"""Main menu dispatch should route [I] to API Server."""
|
"""Main menu dispatch should route [I] to API Server."""
|
||||||
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
|
from nanobot.cli.onboard import _SETTINGS_GETTER, _SETTINGS_SECTIONS, _SETTINGS_SETTER
|
||||||
|
|
||||||
assert "API Server" in _SETTINGS_SECTIONS
|
assert "API Server" in _SETTINGS_SECTIONS
|
||||||
assert "API Server" in _SETTINGS_GETTER
|
assert "API Server" in _SETTINGS_GETTER
|
||||||
@@ -960,3 +954,464 @@ class TestMainMenuUpdate:
|
|||||||
|
|
||||||
assert result.should_save is True
|
assert result.should_save is True
|
||||||
assert pause_called["n"] == 1
|
assert pause_called["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputTextEmptyString:
|
||||||
|
"""Tests for _input_text empty-string handling bug fix."""
|
||||||
|
|
||||||
|
def test_empty_string_returned_not_none(self, monkeypatch):
|
||||||
|
"""_input_text should return empty string, not None, when user enters ''."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_get_questionary",
|
||||||
|
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _input_text("Name", "old", "str")
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_none_still_returns_none(self, monkeypatch):
|
||||||
|
"""_input_text should return None when questionary returns None."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_get_questionary",
|
||||||
|
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: None)),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _input_text("Name", "old", "str")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsStrOrNone:
|
||||||
|
"""Tests for _is_str_or_none helper."""
|
||||||
|
|
||||||
|
def test_str_or_none_true(self):
|
||||||
|
from nanobot.cli.onboard import _is_str_or_none
|
||||||
|
|
||||||
|
assert _is_str_or_none(str | None) is True
|
||||||
|
|
||||||
|
def test_optional_str_true(self):
|
||||||
|
from typing import Optional
|
||||||
|
from nanobot.cli.onboard import _is_str_or_none
|
||||||
|
|
||||||
|
assert _is_str_or_none(Optional[str]) is True
|
||||||
|
|
||||||
|
def test_str_only_false(self):
|
||||||
|
from nanobot.cli.onboard import _is_str_or_none
|
||||||
|
|
||||||
|
assert _is_str_or_none(str) is False
|
||||||
|
|
||||||
|
def test_int_or_none_false(self):
|
||||||
|
from nanobot.cli.onboard import _is_str_or_none
|
||||||
|
|
||||||
|
assert _is_str_or_none(int | None) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigurePydanticModelEmptyString:
|
||||||
|
"""Tests that optional string fields are cleared when empty string is entered."""
|
||||||
|
|
||||||
|
def test_optional_str_empty_string_becomes_none(self, monkeypatch):
|
||||||
|
"""Entering '' for an optional str field should set it to None."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from nanobot.cli.onboard import _is_str_or_none
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
api_key: str | None = None
|
||||||
|
|
||||||
|
model = M(api_key="secret")
|
||||||
|
|
||||||
|
call_count = {"select": 0}
|
||||||
|
|
||||||
|
def fake_select(_prompt, choices, default=None):
|
||||||
|
call_count["select"] += 1
|
||||||
|
# First call: select the api_key field, then Done
|
||||||
|
if call_count["select"] == 1:
|
||||||
|
for c in choices:
|
||||||
|
if "Api Key" in c:
|
||||||
|
return c
|
||||||
|
return choices[0]
|
||||||
|
return "[Done]"
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_config_panel", lambda *a, **kw: None)
|
||||||
|
# Simulate user entering empty string
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard, "_input_with_existing", lambda *a, **kw: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _configure_pydantic_model(model, "Test")
|
||||||
|
assert result is not None
|
||||||
|
assert result.api_key is None
|
||||||
|
|
||||||
|
def test_required_str_empty_string_kept(self, monkeypatch):
|
||||||
|
"""Entering '' for a required str field should keep the empty string."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
api_key: str = ""
|
||||||
|
|
||||||
|
model = M(api_key="secret")
|
||||||
|
|
||||||
|
call_count = {"select": 0}
|
||||||
|
|
||||||
|
def fake_select(_prompt, choices, default=None):
|
||||||
|
call_count["select"] += 1
|
||||||
|
if call_count["select"] == 1:
|
||||||
|
for c in choices:
|
||||||
|
if "Api Key" in c:
|
||||||
|
return c
|
||||||
|
return choices[0]
|
||||||
|
return "[Done]"
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_config_panel", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard, "_input_with_existing", lambda *a, **kw: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _configure_pydantic_model(model, "Test")
|
||||||
|
assert result is not None
|
||||||
|
assert result.api_key == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelPresetWizard:
|
||||||
|
"""Tests for model preset CRUD in the onboard wizard."""
|
||||||
|
|
||||||
|
def test_sync_preset_cache(self):
|
||||||
|
"""_sync_preset_cache should populate the module-level cache."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _sync_preset_cache
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.model_presets = {
|
||||||
|
"fast": ModelPresetConfig(model="gpt-4.1-mini"),
|
||||||
|
"power": ModelPresetConfig(model="gpt-4.1"),
|
||||||
|
}
|
||||||
|
_sync_preset_cache(config)
|
||||||
|
assert _MODEL_PRESET_CACHE == {"fast", "power"}
|
||||||
|
|
||||||
|
def test_model_preset_add(self, monkeypatch):
|
||||||
|
"""_configure_model_presets should add a new preset."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _configure_model_presets
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
|
responses = iter([
|
||||||
|
"[+] Add new preset",
|
||||||
|
"my-preset",
|
||||||
|
"<- Back",
|
||||||
|
])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_text(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_configure(*_model, **_kwargs):
|
||||||
|
return ModelPresetConfig(model="gpt-test", temperature=0.5)
|
||||||
|
|
||||||
|
# _select_with_back returns a string/sentinel directly (not a prompt object)
|
||||||
|
def fake_select_with_back(*_args, **_kwargs):
|
||||||
|
return next(responses)
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select, text=fake_text))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fake_configure)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None))
|
||||||
|
|
||||||
|
_configure_model_presets(config)
|
||||||
|
|
||||||
|
assert "my-preset" in config.model_presets
|
||||||
|
assert config.model_presets["my-preset"].model == "gpt-test"
|
||||||
|
assert config.model_presets["my-preset"].temperature == 0.5
|
||||||
|
|
||||||
|
def test_model_preset_delete(self, monkeypatch):
|
||||||
|
"""_configure_model_presets should delete an existing preset."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _configure_model_presets
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.model_presets = {"old": ModelPresetConfig(model="x")}
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.add("old")
|
||||||
|
|
||||||
|
responses = iter([
|
||||||
|
"old (x)",
|
||||||
|
"Delete",
|
||||||
|
True,
|
||||||
|
"<- Back",
|
||||||
|
])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_confirm(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_select_with_back(*_args, **_kwargs):
|
||||||
|
return next(responses)
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select, confirm=fake_confirm))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None))
|
||||||
|
|
||||||
|
_configure_model_presets(config)
|
||||||
|
|
||||||
|
assert "old" not in config.model_presets
|
||||||
|
assert "old" not in _MODEL_PRESET_CACHE
|
||||||
|
|
||||||
|
def test_model_preset_field_handler(self, monkeypatch):
|
||||||
|
"""_handle_model_preset_field should set a preset name from choices."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.update({"fast", "power"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "fast")
|
||||||
|
|
||||||
|
defaults = AgentDefaults()
|
||||||
|
_handle_model_preset_field(defaults, "model_preset", "Model Preset", None)
|
||||||
|
assert defaults.model_preset == "fast"
|
||||||
|
|
||||||
|
def test_model_preset_field_handler_clear(self, monkeypatch):
|
||||||
|
"""_handle_model_preset_field should clear preset when (clear/unset) chosen."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.add("fast")
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "(clear/unset)")
|
||||||
|
|
||||||
|
defaults = AgentDefaults(model_preset="fast")
|
||||||
|
_handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast")
|
||||||
|
assert defaults.model_preset is None
|
||||||
|
|
||||||
|
def test_main_menu_dispatch_includes_model_presets(self):
|
||||||
|
"""run_onboard dispatch should route [M] to Model Presets."""
|
||||||
|
from nanobot.cli.onboard import _configure_model_presets
|
||||||
|
|
||||||
|
# The function should be importable and callable
|
||||||
|
assert callable(_configure_model_presets)
|
||||||
|
|
||||||
|
def test_run_onboard_model_presets_edit(self, monkeypatch):
|
||||||
|
"""run_onboard should handle [M] Model Presets correctly."""
|
||||||
|
initial_config = Config()
|
||||||
|
|
||||||
|
responses = iter([
|
||||||
|
"[M] Model Presets",
|
||||||
|
KeyboardInterrupt(),
|
||||||
|
"[S] Save and Exit",
|
||||||
|
])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
preset_mutated = {"n": 0}
|
||||||
|
|
||||||
|
def fake_configure_model_presets(config):
|
||||||
|
preset_mutated["n"] += 1
|
||||||
|
# Mutate config so unsaved changes are detected
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
config.model_presets["test"] = ModelPresetConfig(model="x")
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_configure_model_presets", fake_configure_model_presets)
|
||||||
|
|
||||||
|
result = run_onboard(initial_config=initial_config)
|
||||||
|
|
||||||
|
assert result.should_save is True
|
||||||
|
assert preset_mutated["n"] == 1
|
||||||
|
|
||||||
|
def test_summary_shows_model_presets(self, monkeypatch):
|
||||||
|
"""_show_summary should include model presets panel."""
|
||||||
|
from nanobot.cli.onboard import _show_summary
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.model_presets = {
|
||||||
|
"fast": ModelPresetConfig(model="gpt-4.1-mini"),
|
||||||
|
}
|
||||||
|
|
||||||
|
panels = []
|
||||||
|
|
||||||
|
def fake_print_summary(rows, title):
|
||||||
|
panels.append(title)
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", fake_print_summary)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_get_provider_names", lambda: {})
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_get_channel_names", lambda: {})
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_pause", lambda: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(print=lambda *a, **kw: None))
|
||||||
|
|
||||||
|
_show_summary(config)
|
||||||
|
|
||||||
|
assert "Model Presets" in panels
|
||||||
|
|
||||||
|
def test_provider_field_handler(self, monkeypatch):
|
||||||
|
"""_handle_provider_field should set a provider from the registry list."""
|
||||||
|
from nanobot.cli.onboard import _handle_provider_field
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard, "_get_provider_names", lambda: {"moonshot": "Moonshot", "openai": "OpenAI"}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "moonshot")
|
||||||
|
|
||||||
|
preset = ModelPresetConfig(model="x")
|
||||||
|
_handle_provider_field(preset, "provider", "Provider", "auto")
|
||||||
|
assert preset.provider == "moonshot"
|
||||||
|
|
||||||
|
def test_provider_field_handler_back_pressed(self, monkeypatch):
|
||||||
|
"""_handle_provider_field should not modify value when back is pressed."""
|
||||||
|
from nanobot.cli.onboard import _BACK_PRESSED, _handle_provider_field
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard, "_get_provider_names", lambda: {"moonshot": "Moonshot"}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: _BACK_PRESSED)
|
||||||
|
|
||||||
|
preset = ModelPresetConfig(model="x", provider="auto")
|
||||||
|
_handle_provider_field(preset, "provider", "Provider", "auto")
|
||||||
|
assert preset.provider == "auto"
|
||||||
|
|
||||||
|
def test_fallback_presets_add_preset_and_done(self, monkeypatch):
|
||||||
|
"""_handle_fallback_presets_field should add a preset and save on Done."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_presets_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.update({"fast", "power"})
|
||||||
|
|
||||||
|
responses = iter(["[+] Add preset", "[Done]"])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "fast")
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None))
|
||||||
|
|
||||||
|
defaults = AgentDefaults()
|
||||||
|
_handle_fallback_presets_field(defaults, "fallback_presets", "Fallback Presets", [])
|
||||||
|
assert defaults.fallback_presets == ["fast"]
|
||||||
|
|
||||||
|
def test_fallback_presets_back_preserves_existing(self, monkeypatch):
|
||||||
|
"""_handle_fallback_presets_field should not modify value on Back."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_presets_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
_MODEL_PRESET_CACHE.add("fast")
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt("<- Back")
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None))
|
||||||
|
|
||||||
|
defaults = AgentDefaults(fallback_presets=["existing"])
|
||||||
|
_handle_fallback_presets_field(defaults, "fallback_presets", "Fallback Presets", ["existing"])
|
||||||
|
assert defaults.fallback_presets == ["existing"]
|
||||||
|
|
||||||
|
def test_fallback_presets_remove_last(self, monkeypatch):
|
||||||
|
"""_handle_fallback_presets_field should remove last item."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_presets_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
|
responses = iter(["[-] Remove last", "[Done]"])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None))
|
||||||
|
|
||||||
|
defaults = AgentDefaults(fallback_presets=["a", "b"])
|
||||||
|
_handle_fallback_presets_field(defaults, "fallback_presets", "Fallback Presets", ["a", "b"])
|
||||||
|
assert defaults.fallback_presets == ["a"]
|
||||||
|
|
||||||
|
def test_fallback_presets_no_presets_shows_warning(self, monkeypatch):
|
||||||
|
"""_handle_fallback_presets_field should warn when no presets exist."""
|
||||||
|
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_presets_field
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
|
responses = iter(["[+] Add preset", "[Done]"])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select, press_any_key_to_continue=lambda: FakePrompt(None)))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None))
|
||||||
|
|
||||||
|
defaults = AgentDefaults()
|
||||||
|
_handle_fallback_presets_field(defaults, "fallback_presets", "Fallback Presets", [])
|
||||||
|
assert defaults.fallback_presets == []
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# tests/agent/test_self_model_preset.py
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.config.schema import ModelPresetConfig, MyToolConfig, ToolsConfig
|
||||||
|
from nanobot.providers.base import GenerationSettings
|
||||||
|
|
||||||
|
|
||||||
|
def _make_loop(presets: dict | None = None) -> tuple[AgentLoop, Any]:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.generation = GenerationSettings(temperature=0.1, max_tokens=8192)
|
||||||
|
|
||||||
|
def _factory(name: str):
|
||||||
|
preset = (presets or {}).get(name)
|
||||||
|
if preset:
|
||||||
|
new_provider = MagicMock()
|
||||||
|
new_provider.generation = GenerationSettings(
|
||||||
|
temperature=preset.temperature,
|
||||||
|
max_tokens=preset.max_tokens,
|
||||||
|
reasoning_effort=preset.reasoning_effort,
|
||||||
|
)
|
||||||
|
return new_provider
|
||||||
|
return provider
|
||||||
|
|
||||||
|
loop = AgentLoop(
|
||||||
|
bus=MagicMock(),
|
||||||
|
provider=provider,
|
||||||
|
workspace=Path("/tmp/test"),
|
||||||
|
model="test-model",
|
||||||
|
context_window_tokens=65536,
|
||||||
|
model_presets=presets or {},
|
||||||
|
provider_factory=_factory,
|
||||||
|
tools_config=ToolsConfig(my=MyToolConfig(allow_set=True)),
|
||||||
|
)
|
||||||
|
tool = loop.tools.get("my")
|
||||||
|
return loop, tool
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_model_preset_updates_all_fields() -> None:
|
||||||
|
presets = {
|
||||||
|
"gpt5": ModelPresetConfig(
|
||||||
|
model="gpt-5",
|
||||||
|
provider="openai",
|
||||||
|
max_tokens=16384,
|
||||||
|
context_window_tokens=128000,
|
||||||
|
temperature=0.2,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
loop, tool = _make_loop(presets)
|
||||||
|
await tool.execute(action="set", key="model_preset", value="gpt5")
|
||||||
|
|
||||||
|
assert loop.model == "gpt-5"
|
||||||
|
assert loop.context_window_tokens == 128000
|
||||||
|
assert loop.provider.generation.temperature == 0.2
|
||||||
|
assert loop.provider.generation.max_tokens == 16384
|
||||||
|
assert loop._active_preset == "gpt5"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_model_preset_unknown_returns_error() -> None:
|
||||||
|
loop, tool = _make_loop({})
|
||||||
|
result = await tool.execute(action="set", key="model_preset", value="nope")
|
||||||
|
|
||||||
|
assert "Error" in result or "not found" in result
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_model_preset_shows_current() -> None:
|
||||||
|
presets = {"gpt5": ModelPresetConfig(model="gpt-5", provider="openai")}
|
||||||
|
loop, tool = _make_loop(presets)
|
||||||
|
await tool.execute(action="set", key="model_preset", value="gpt5")
|
||||||
|
result = await tool.execute(action="check", key="model_preset")
|
||||||
|
|
||||||
|
assert "gpt5" in result
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_model_presets_shows_available() -> None:
|
||||||
|
presets = {
|
||||||
|
"gpt5": ModelPresetConfig(model="gpt-5", provider="openai"),
|
||||||
|
"ds": ModelPresetConfig(model="deepseek-chat", provider="deepseek"),
|
||||||
|
}
|
||||||
|
loop, tool = _make_loop(presets)
|
||||||
|
result = await tool.execute(action="check", key="model_presets")
|
||||||
|
|
||||||
|
assert "gpt5" in result
|
||||||
|
assert "ds" in result
|
||||||
|
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -35,6 +35,7 @@ def _make_mock_loop(**overrides):
|
|||||||
loop._concurrency_gate = None
|
loop._concurrency_gate = None
|
||||||
loop._unified_session = False
|
loop._unified_session = False
|
||||||
loop._extra_hooks = []
|
loop._extra_hooks = []
|
||||||
|
loop.model_preset = None
|
||||||
|
|
||||||
# web_config mock — needed for check tests
|
# web_config mock — needed for check tests
|
||||||
loop.web_config = MagicMock()
|
loop.web_config = MagicMock()
|
||||||
@@ -76,7 +77,7 @@ class TestInspectSummary:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="check")
|
result = await tool.execute(action="check")
|
||||||
assert "max_iterations: 40" in result
|
assert "max_iterations: 40" in result
|
||||||
assert "context_window_tokens: 65536" in result
|
assert "model_preset" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_includes_runtime_vars(self):
|
async def test_inspect_includes_runtime_vars(self):
|
||||||
@@ -92,8 +93,7 @@ class TestInspectSummary:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="check")
|
result = await tool.execute(action="check")
|
||||||
assert "max_iterations" in result
|
assert "max_iterations" in result
|
||||||
assert "context_window_tokens" in result
|
assert "model_preset" in result
|
||||||
assert "model" in result
|
|
||||||
assert "workspace" in result
|
assert "workspace" in result
|
||||||
assert "provider_retry_mode" in result
|
assert "provider_retry_mode" in result
|
||||||
assert "max_tool_result_chars" in result
|
assert "max_tool_result_chars" in result
|
||||||
@@ -231,13 +231,13 @@ class TestModifyRestricted:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_string_int_coerced(self):
|
async def test_modify_string_int_coerced(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value="80")
|
await tool.execute(action="set", key="max_iterations", value="80")
|
||||||
assert tool._loop.max_iterations == 80
|
assert tool._loop.max_iterations == 80
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_context_window_valid(self):
|
async def test_modify_context_window_valid(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||||
assert tool._loop.context_window_tokens == 131072
|
assert tool._loop.context_window_tokens == 131072
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -337,13 +337,13 @@ class TestModifyFree:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_allows_list(self):
|
async def test_modify_allows_list(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
await tool.execute(action="set", key="items", value=[1, 2, 3])
|
||||||
assert tool._loop._runtime_vars["items"] == [1, 2, 3]
|
assert tool._loop._runtime_vars["items"] == [1, 2, 3]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_allows_dict(self):
|
async def test_modify_allows_dict(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="data", value={"a": 1})
|
await tool.execute(action="set", key="data", value={"a": 1})
|
||||||
assert tool._loop._runtime_vars["data"] == {"a": 1}
|
assert tool._loop._runtime_vars["data"] == {"a": 1}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -392,6 +392,26 @@ class TestModifyFree:
|
|||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert tool._loop.max_tool_result_chars == 16000
|
assert tool._loop.max_tool_result_chars == 16000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modify_model_clears_active_preset(self):
|
||||||
|
"""Directly modifying model must clear _active_preset so state stays consistent."""
|
||||||
|
tool = _make_tool()
|
||||||
|
tool._loop._active_preset = "gpt5"
|
||||||
|
result = await tool.execute(action="set", key="model", value="other-model")
|
||||||
|
assert "Set model" in result
|
||||||
|
assert tool._loop.model == "other-model"
|
||||||
|
assert tool._loop._active_preset is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modify_context_window_tokens_clears_active_preset(self):
|
||||||
|
"""Directly modifying context_window_tokens must clear _active_preset."""
|
||||||
|
tool = _make_tool()
|
||||||
|
tool._loop._active_preset = "gpt5"
|
||||||
|
result = await tool.execute(action="set", key="context_window_tokens", value=32768)
|
||||||
|
assert "Set context_window_tokens" in result
|
||||||
|
assert tool._loop.context_window_tokens == 32768
|
||||||
|
assert tool._loop._active_preset is None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# set — previously BLOCKED/READONLY now open
|
# set — previously BLOCKED/READONLY now open
|
||||||
@@ -689,8 +709,8 @@ class TestSubagentHookStatus:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_after_iteration_updates_status(self):
|
async def test_after_iteration_updates_status(self):
|
||||||
"""after_iteration should copy iteration, tool_events, usage to status."""
|
"""after_iteration should copy iteration, tool_events, usage to status."""
|
||||||
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
|
||||||
from nanobot.agent.hook import AgentHookContext
|
from nanobot.agent.hook import AgentHookContext
|
||||||
|
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
||||||
|
|
||||||
status = SubagentStatus(
|
status = SubagentStatus(
|
||||||
task_id="test",
|
task_id="test",
|
||||||
@@ -716,8 +736,8 @@ class TestSubagentHookStatus:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_after_iteration_with_error(self):
|
async def test_after_iteration_with_error(self):
|
||||||
"""after_iteration should set status.error when context has an error."""
|
"""after_iteration should set status.error when context has an error."""
|
||||||
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
|
||||||
from nanobot.agent.hook import AgentHookContext
|
from nanobot.agent.hook import AgentHookContext
|
||||||
|
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
||||||
|
|
||||||
status = SubagentStatus(
|
status = SubagentStatus(
|
||||||
task_id="test",
|
task_id="test",
|
||||||
@@ -739,8 +759,8 @@ class TestSubagentHookStatus:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_after_iteration_no_status_is_noop(self):
|
async def test_after_iteration_no_status_is_noop(self):
|
||||||
"""after_iteration with no status should be a no-op."""
|
"""after_iteration with no status should be a no-op."""
|
||||||
from nanobot.agent.subagent import _SubagentHook
|
|
||||||
from nanobot.agent.hook import AgentHookContext
|
from nanobot.agent.hook import AgentHookContext
|
||||||
|
from nanobot.agent.subagent import _SubagentHook
|
||||||
|
|
||||||
hook = _SubagentHook("test")
|
hook = _SubagentHook("test")
|
||||||
context = AgentHookContext(iteration=1, messages=[])
|
context = AgentHookContext(iteration=1, messages=[])
|
||||||
@@ -757,7 +777,6 @@ class TestCheckpointCallback:
|
|||||||
async def test_checkpoint_updates_phase_and_iteration(self):
|
async def test_checkpoint_updates_phase_and_iteration(self):
|
||||||
"""The _on_checkpoint callback should update status.phase and iteration."""
|
"""The _on_checkpoint callback should update status.phase and iteration."""
|
||||||
from nanobot.agent.subagent import SubagentStatus
|
from nanobot.agent.subagent import SubagentStatus
|
||||||
import asyncio
|
|
||||||
|
|
||||||
status = SubagentStatus(
|
status = SubagentStatus(
|
||||||
task_id="cp",
|
task_id="cp",
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ def test_make_headers_includes_route_tag_when_configured() -> None:
|
|||||||
assert headers["Authorization"] == "Bearer token"
|
assert headers["Authorization"] == "Bearer token"
|
||||||
assert headers["SKRouteTag"] == "123"
|
assert headers["SKRouteTag"] == "123"
|
||||||
assert headers["iLink-App-Id"] == "bot"
|
assert headers["iLink-App-Id"] == "bot"
|
||||||
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 1)
|
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 7)
|
||||||
|
|
||||||
|
|
||||||
def test_channel_version_matches_reference_plugin_version() -> None:
|
def test_channel_version_matches_reference_plugin_version() -> None:
|
||||||
assert WEIXIN_CHANNEL_VERSION == "2.1.1"
|
assert WEIXIN_CHANNEL_VERSION == "2.1.7"
|
||||||
|
|
||||||
|
|
||||||
def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
||||||
@@ -1250,3 +1250,222 @@ async def test_send_text_succeeds_on_zero_errcode() -> None:
|
|||||||
await channel._send_text("wx-user", "hello", "ctx-ok")
|
await channel._send_text("wx-user", "hello", "ctx-ok")
|
||||||
|
|
||||||
channel._api_post.assert_awaited_once()
|
channel._api_post.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None:
|
||||||
|
"""_send_text must raise when the API returns ret != 0, even if errcode is 0.
|
||||||
|
|
||||||
|
The iLink API signals failure through either field. Checking only errcode
|
||||||
|
caused silent message drops (responses generated but never delivered).
|
||||||
|
"""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel._api_post = AsyncMock(
|
||||||
|
return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="WeChat send text error.*ret=-100.*errcode=0"):
|
||||||
|
await channel._send_text("wx-user", "hello", "ctx-ok")
|
||||||
|
|
||||||
|
channel._api_post.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests for _poll_once not silently dropping messages on processing errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_once_logs_exception_on_process_message_failure(monkeypatch) -> None:
|
||||||
|
"""When _process_message raises, _poll_once must log the error and continue
|
||||||
|
processing remaining messages instead of silently swallowing the exception."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = SimpleNamespace(timeout=None)
|
||||||
|
channel._token = "token"
|
||||||
|
channel._get_updates_buf = "old-buf"
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
logged_messages: list[str] = []
|
||||||
|
|
||||||
|
async def _failing_process(msg: dict) -> None:
|
||||||
|
calls.append(msg.get("message_id"))
|
||||||
|
if msg.get("message_id") == "msg-1":
|
||||||
|
raise RuntimeError("processing failed")
|
||||||
|
|
||||||
|
channel._process_message = _failing_process # type: ignore[method-assign]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
channel.logger,
|
||||||
|
"exception",
|
||||||
|
lambda message, *args, **kwargs: logged_messages.append(str(message)),
|
||||||
|
)
|
||||||
|
|
||||||
|
channel._api_post = AsyncMock( # type: ignore[method-assign]
|
||||||
|
return_value={
|
||||||
|
"ret": 0,
|
||||||
|
"errcode": 0,
|
||||||
|
"get_updates_buf": "new-buf",
|
||||||
|
"msgs": [
|
||||||
|
{"message_id": "msg-1", "message_type": 1},
|
||||||
|
{"message_id": "msg-2", "message_type": 1},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._poll_once()
|
||||||
|
|
||||||
|
# Both messages should have been attempted
|
||||||
|
assert calls == ["msg-1", "msg-2"]
|
||||||
|
# Buffer should still advance (already updated before processing)
|
||||||
|
assert channel._get_updates_buf == "new-buf"
|
||||||
|
# Error should be logged
|
||||||
|
assert any("Failed to process WeChat message" in m for m in logged_messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_loop_logs_exception_and_continues_on_poll_failure(monkeypatch) -> None:
|
||||||
|
"""When _poll_once raises a non-timeout exception, the start() loop must log
|
||||||
|
the error and continue polling instead of exiting silently."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel.config.token = "token" # skip QR login in start()
|
||||||
|
channel._running = True
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
logged_messages: list[str] = []
|
||||||
|
|
||||||
|
async def _failing_poll() -> None:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
raise RuntimeError("poll exploded")
|
||||||
|
channel._running = False # Stop after second call
|
||||||
|
|
||||||
|
channel._poll_once = _failing_poll # type: ignore[method-assign]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
channel.logger,
|
||||||
|
"exception",
|
||||||
|
lambda message, *args, **kwargs: logged_messages.append(str(message)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use a tiny retry delay so the test finishes quickly
|
||||||
|
original_retry = weixin_mod.RETRY_DELAY_S
|
||||||
|
weixin_mod.RETRY_DELAY_S = 0.01
|
||||||
|
try:
|
||||||
|
await channel.start()
|
||||||
|
finally:
|
||||||
|
weixin_mod.RETRY_DELAY_S = original_retry
|
||||||
|
|
||||||
|
assert call_count == 2
|
||||||
|
assert any("WeChat poll loop error" in m for m in logged_messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_retries_without_context_token_on_ret_minus_two() -> None:
|
||||||
|
"""If sendmessage returns ret=-2 with a context_token, retry without it."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel._context_tokens["wx-user"] = "expired-token"
|
||||||
|
|
||||||
|
channel._api_post = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"ret": -2}, # first attempt with token fails
|
||||||
|
{"ret": 0}, # retry without token succeeds
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._send_text("wx-user", "hello", "expired-token")
|
||||||
|
|
||||||
|
# Should have called API twice
|
||||||
|
assert channel._api_post.await_count == 2
|
||||||
|
# First call includes context_token
|
||||||
|
first_body = channel._api_post.await_args_list[0].args[1]
|
||||||
|
assert first_body["msg"]["context_token"] == "expired-token"
|
||||||
|
# Second call does NOT include context_token
|
||||||
|
second_body = channel._api_post.await_args_list[1].args[1]
|
||||||
|
assert "context_token" not in second_body["msg"]
|
||||||
|
# Expired token should be cleared from cache
|
||||||
|
assert "wx-user" not in channel._context_tokens
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_raises_when_retry_also_fails_with_stale_session() -> None:
|
||||||
|
"""If both attempts return stale-session ret=-2, raise so ChannelManager retries."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
channel._context_tokens["wx-user"] = "bad-token"
|
||||||
|
|
||||||
|
channel._api_post = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"ret": -2}, # with token
|
||||||
|
{"ret": -2}, # without token
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="WeChat send text error"):
|
||||||
|
await channel._send_text("wx-user", "hello", "bad-token")
|
||||||
|
|
||||||
|
assert channel._api_post.await_count == 2
|
||||||
|
# Token is NOT cleared because retry also failed
|
||||||
|
assert channel._context_tokens.get("wx-user") == "bad-token"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_text_raises_on_ret_minus_two_when_no_context_token() -> None:
|
||||||
|
"""If no context_token was provided, ret=-2 stale session is raised."""
|
||||||
|
channel, _bus = _make_channel()
|
||||||
|
channel._client = object()
|
||||||
|
channel._token = "token"
|
||||||
|
|
||||||
|
channel._api_post = AsyncMock(return_value={"ret": -2})
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="WeChat send text error"):
|
||||||
|
await channel._send_text("wx-user", "hello", "")
|
||||||
|
|
||||||
|
# Only one API call (no retry possible without token)
|
||||||
|
channel._api_post.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests for _is_stale_session_ret (hermes-agent#17228 / #18105)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsStaleSessionRet:
|
||||||
|
"""Verify stale-session detection for iLink ret=-2 / errcode=-2 responses."""
|
||||||
|
|
||||||
|
def test_ret_minus_2_with_empty_errmsg_is_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, "") is True
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, None) is True
|
||||||
|
|
||||||
|
def test_errcode_minus_2_with_empty_errmsg_is_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, -2, "") is True
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, -2, None) is True
|
||||||
|
|
||||||
|
def test_ret_minus_2_with_unknown_error_is_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, "unknown error") is True
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, "UNKNOWN ERROR") is True
|
||||||
|
|
||||||
|
def test_errcode_minus_2_with_unknown_error_is_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, -2, "unknown error") is True
|
||||||
|
|
||||||
|
def test_ret_minus_2_with_frequency_limit_is_not_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, "frequency limit") is False
|
||||||
|
assert weixin_mod._is_stale_session_ret(-2, 0, "too frequently") is False
|
||||||
|
|
||||||
|
def test_errcode_minus_2_with_frequency_limit_is_not_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, -2, "freq limit") is False
|
||||||
|
|
||||||
|
def test_success_codes_are_not_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, 0, "") is False
|
||||||
|
assert weixin_mod._is_stale_session_ret(0, 0, None) is False
|
||||||
|
|
||||||
|
def test_other_errors_are_not_stale(self):
|
||||||
|
assert weixin_mod._is_stale_session_ret(-14, -14, "session timeout") is False
|
||||||
|
assert weixin_mod._is_stale_session_ret(-100, 0, "internal error") is False
|
||||||
|
|||||||
+64
-26
@@ -9,7 +9,7 @@ import pytest
|
|||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.cli.commands import _make_provider, app
|
from nanobot.cli.commands import app
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.cron.types import CronJob, CronPayload
|
from nanobot.cron.types import CronJob, CronPayload
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
@@ -488,8 +488,8 @@ def test_openai_compat_provider_passes_model_through():
|
|||||||
|
|
||||||
|
|
||||||
def test_make_provider_uses_github_copilot_backend():
|
def test_make_provider_uses_github_copilot_backend():
|
||||||
from nanobot.cli.commands import _make_provider
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.providers.factory import build_provider_for_preset
|
||||||
|
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
@@ -503,7 +503,7 @@ def test_make_provider_uses_github_copilot_backend():
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
provider = _make_provider(config)
|
provider = build_provider_for_preset(config, config.resolve_preset())
|
||||||
|
|
||||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
||||||
|
|
||||||
@@ -562,6 +562,8 @@ def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
|||||||
|
|
||||||
|
|
||||||
def test_make_provider_passes_extra_headers_to_custom_provider():
|
def test_make_provider_passes_extra_headers_to_custom_provider():
|
||||||
|
from nanobot.providers.factory import build_provider_for_preset
|
||||||
|
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "custom", "model": "gpt-4o-mini"}},
|
"agents": {"defaults": {"provider": "custom", "model": "gpt-4o-mini"}},
|
||||||
@@ -579,7 +581,7 @@ def test_make_provider_passes_extra_headers_to_custom_provider():
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
|
||||||
_make_provider(config)
|
build_provider_for_preset(config, config.resolve_preset())
|
||||||
|
|
||||||
kwargs = mock_async_openai.call_args.kwargs
|
kwargs = mock_async_openai.call_args.kwargs
|
||||||
assert kwargs["api_key"] == "test-key"
|
assert kwargs["api_key"] == "test-key"
|
||||||
@@ -597,11 +599,11 @@ def mock_agent_runtime(tmp_path):
|
|||||||
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
||||||
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
||||||
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
||||||
patch("nanobot.cli.commands._make_provider", return_value=object()), \
|
patch("nanobot.providers.factory.build_provider_for_preset", return_value=MagicMock(generation=MagicMock(max_tokens=8192))), \
|
||||||
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
||||||
patch("nanobot.bus.queue.MessageBus"), \
|
patch("nanobot.bus.queue.MessageBus"), \
|
||||||
patch("nanobot.cron.service.CronService"), \
|
patch("nanobot.cron.service.CronService"), \
|
||||||
patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls:
|
patch("nanobot.cli.commands.AgentLoop") as mock_agent_loop_cls:
|
||||||
agent_loop = MagicMock()
|
agent_loop = MagicMock()
|
||||||
agent_loop.channels_config = None
|
agent_loop.channels_config = None
|
||||||
agent_loop.process_direct = AsyncMock(
|
agent_loop.process_direct = AsyncMock(
|
||||||
@@ -609,6 +611,7 @@ def mock_agent_runtime(tmp_path):
|
|||||||
)
|
)
|
||||||
agent_loop.close_mcp = AsyncMock(return_value=None)
|
agent_loop.close_mcp = AsyncMock(return_value=None)
|
||||||
mock_agent_loop_cls.return_value = agent_loop
|
mock_agent_loop_cls.return_value = agent_loop
|
||||||
|
mock_agent_loop_cls.from_config.return_value = agent_loop
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"config": config,
|
"config": config,
|
||||||
@@ -639,7 +642,7 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_
|
|||||||
assert mock_agent_runtime["sync_templates"].call_args.args == (
|
assert mock_agent_runtime["sync_templates"].call_args.args == (
|
||||||
mock_agent_runtime["config"].workspace_path,
|
mock_agent_runtime["config"].workspace_path,
|
||||||
)
|
)
|
||||||
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == (
|
assert mock_agent_runtime["agent_loop_cls"].from_config.call_args.args[0].workspace_path == (
|
||||||
mock_agent_runtime["config"].workspace_path
|
mock_agent_runtime["config"].workspace_path
|
||||||
)
|
)
|
||||||
mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
|
mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
|
||||||
@@ -672,7 +675,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *a, **k: MagicMock(generation=MagicMock(max_tokens=8192)))
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
|
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
|
||||||
|
|
||||||
@@ -680,13 +683,17 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||||
@@ -707,7 +714,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *a, **k: MagicMock(generation=MagicMock(max_tokens=8192)))
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
|
|
||||||
class _FakeCron:
|
class _FakeCron:
|
||||||
@@ -718,6 +725,10 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
@@ -725,7 +736,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||||
@@ -753,7 +764,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *a, **k: MagicMock(generation=MagicMock(max_tokens=8192)))
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||||
|
|
||||||
@@ -765,6 +776,10 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
@@ -772,7 +787,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
@@ -806,7 +821,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *a, **k: MagicMock(generation=MagicMock(max_tokens=8192)))
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||||
|
|
||||||
@@ -818,6 +833,10 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
@@ -825,7 +844,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
|
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
|
||||||
)
|
)
|
||||||
@@ -846,7 +865,7 @@ def test_agent_overrides_workspace_path(mock_agent_runtime):
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
||||||
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
||||||
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
|
assert mock_agent_runtime["agent_loop_cls"].from_config.call_args.args[0].workspace_path == workspace_path
|
||||||
|
|
||||||
|
|
||||||
def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path):
|
def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path):
|
||||||
@@ -863,7 +882,7 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
|
|||||||
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
|
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
|
||||||
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
||||||
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
||||||
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
|
assert mock_agent_runtime["agent_loop_cls"].from_config.call_args.args[0].workspace_path == workspace_path
|
||||||
|
|
||||||
|
|
||||||
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
|
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
|
||||||
@@ -928,8 +947,8 @@ def _patch_cli_command_runtime(
|
|||||||
sync_templates or (lambda _path: None),
|
sync_templates or (lambda _path: None),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._make_provider",
|
"nanobot.providers.factory.build_provider_for_preset",
|
||||||
provider_factory,
|
lambda *_a, **_k: provider_factory(Config()),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.build_provider_snapshot",
|
"nanobot.providers.factory.build_provider_snapshot",
|
||||||
@@ -962,6 +981,10 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, **kwargs) -> None:
|
||||||
seen["workspace"] = kwargs["workspace"]
|
seen["workspace"] = kwargs["workspace"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, config, bus=None, **kwargs):
|
||||||
|
return cls(workspace=config.workspace_path, **kwargs)
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
async def _connect_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -985,7 +1008,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
||||||
monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app)
|
monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app)
|
||||||
|
|
||||||
@@ -1077,7 +1100,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: provider)
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *_a, **_k: provider)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.build_provider_snapshot",
|
"nanobot.providers.factory.build_provider_snapshot",
|
||||||
lambda _config: _test_provider_snapshot(provider, _config),
|
lambda _config: _test_provider_snapshot(provider, _config),
|
||||||
@@ -1117,8 +1140,13 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
class _FakeAgentLoop:
|
class _FakeAgentLoop:
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
self.model = "test-model"
|
self.model = "test-model"
|
||||||
|
self.provider = object()
|
||||||
self.tools = {}
|
self.tools = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel="telegram",
|
channel="telegram",
|
||||||
@@ -1152,7 +1180,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.utils.evaluator.evaluate_response",
|
"nanobot.utils.evaluator.evaluate_response",
|
||||||
@@ -1181,7 +1209,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
|||||||
|
|
||||||
assert response == "Time to stretch."
|
assert response == "Time to stretch."
|
||||||
assert seen["response"] == "Time to stretch."
|
assert seen["response"] == "Time to stretch."
|
||||||
assert seen["provider"] is provider
|
assert seen["provider"] is not None # provider resolved inside AgentLoop
|
||||||
assert seen["model"] == "test-model"
|
assert seen["model"] == "test-model"
|
||||||
assert seen["task_context"] == (
|
assert seen["task_context"] == (
|
||||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
||||||
@@ -1228,7 +1256,7 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
|||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.providers.factory.build_provider_for_preset", lambda *a, **k: MagicMock(generation=MagicMock(max_tokens=8192)))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.providers.factory.build_provider_snapshot",
|
"nanobot.providers.factory.build_provider_snapshot",
|
||||||
lambda _config: _test_provider_snapshot(object(), _config),
|
lambda _config: _test_provider_snapshot(object(), _config),
|
||||||
@@ -1248,8 +1276,13 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
|||||||
class _FakeAgentLoop:
|
class _FakeAgentLoop:
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
self.model = "test-model"
|
self.model = "test-model"
|
||||||
|
self.provider = object()
|
||||||
self.tools = {}
|
self.tools = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
async def process_direct(self, *_args, on_progress=None, **_kwargs):
|
async def process_direct(self, *_args, on_progress=None, **_kwargs):
|
||||||
seen["on_progress"] = on_progress
|
seen["on_progress"] = on_progress
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@@ -1275,7 +1308,7 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.utils.evaluator.evaluate_response",
|
"nanobot.utils.evaluator.evaluate_response",
|
||||||
@@ -1480,9 +1513,14 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
class _FakeAgentLoop:
|
class _FakeAgentLoop:
|
||||||
def __init__(self, **_kwargs) -> None:
|
def __init__(self, **_kwargs) -> None:
|
||||||
self.model = "test-model"
|
self.model = "test-model"
|
||||||
|
self.provider = object()
|
||||||
self.dream = _FakeDream()
|
self.dream = _FakeDream()
|
||||||
self.sessions = _FakeSessionManager()
|
self.sessions = _FakeSessionManager()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(cls, *args, **kwargs):
|
||||||
|
return cls(**kwargs)
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
@@ -1571,7 +1609,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: object(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
||||||
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)
|
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_preset_config_accepts_model_and_provider_separately() -> None:
|
||||||
|
preset = ModelPresetConfig(model="gpt-5", provider="openai")
|
||||||
|
assert preset.model == "gpt-5"
|
||||||
|
assert preset.provider == "openai"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_preset_config_defaults() -> None:
|
||||||
|
preset = ModelPresetConfig(model="test-model")
|
||||||
|
assert preset.provider == "auto"
|
||||||
|
assert preset.max_tokens == 8192
|
||||||
|
assert preset.context_window_tokens == 65_536
|
||||||
|
assert preset.temperature == 0.1
|
||||||
|
assert preset.reasoning_effort is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_preset_config_all_fields() -> None:
|
||||||
|
preset = ModelPresetConfig(
|
||||||
|
model="deepseek-r1",
|
||||||
|
provider="deepseek",
|
||||||
|
max_tokens=16384,
|
||||||
|
context_window_tokens=131072,
|
||||||
|
temperature=0.2,
|
||||||
|
reasoning_effort="high",
|
||||||
|
)
|
||||||
|
assert preset.model == "deepseek-r1"
|
||||||
|
assert preset.provider == "deepseek"
|
||||||
|
assert preset.max_tokens == 16384
|
||||||
|
assert preset.context_window_tokens == 131072
|
||||||
|
assert preset.temperature == 0.2
|
||||||
|
assert preset.reasoning_effort == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_accepts_model_presets_dict() -> None:
|
||||||
|
cfg = Config(model_presets={
|
||||||
|
"gpt5": ModelPresetConfig(model="gpt-5", provider="openai", max_tokens=16384),
|
||||||
|
"ds": ModelPresetConfig(model="deepseek-chat", provider="deepseek"),
|
||||||
|
})
|
||||||
|
assert "gpt5" in cfg.model_presets
|
||||||
|
assert cfg.model_presets["gpt5"].max_tokens == 16384
|
||||||
|
assert cfg.model_presets["ds"].model == "deepseek-chat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_preset_returns_preset_values() -> None:
|
||||||
|
cfg = Config.model_validate({
|
||||||
|
"model_presets": {
|
||||||
|
"gpt5": {
|
||||||
|
"model": "gpt-5",
|
||||||
|
"provider": "openai",
|
||||||
|
"max_tokens": 16384,
|
||||||
|
"context_window_tokens": 128000,
|
||||||
|
"temperature": 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {"defaults": {"model_preset": "gpt5"}},
|
||||||
|
})
|
||||||
|
r = cfg.resolve_preset()
|
||||||
|
assert r.model == "gpt-5"
|
||||||
|
assert r.provider == "openai"
|
||||||
|
assert r.max_tokens == 16384
|
||||||
|
assert r.context_window_tokens == 128000
|
||||||
|
assert r.temperature == 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_preset_ignores_old_config_fields() -> None:
|
||||||
|
"""Preset wins completely — old config remnants are ignored."""
|
||||||
|
cfg = Config.model_validate({
|
||||||
|
"model_presets": {
|
||||||
|
"gpt5": {
|
||||||
|
"model": "gpt-5",
|
||||||
|
"provider": "openai",
|
||||||
|
"max_tokens": 16384,
|
||||||
|
"context_window_tokens": 128000,
|
||||||
|
"temperature": 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model_preset": "gpt5",
|
||||||
|
"model": "old-model",
|
||||||
|
"temperature": 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
r = cfg.resolve_preset()
|
||||||
|
assert r.model == "gpt-5"
|
||||||
|
assert r.temperature == 0.2
|
||||||
|
assert r.max_tokens == 16384
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_not_found_raises_error() -> None:
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(Exception, match="model_preset.*not found"):
|
||||||
|
Config.model_validate({
|
||||||
|
"model_presets": {},
|
||||||
|
"agents": {"defaults": {"model_preset": "nonexistent"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_presets_invalid_preset_raises_error() -> None:
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(Exception, match="fallback_presets.*not found"):
|
||||||
|
Config.model_validate({
|
||||||
|
"model_presets": {
|
||||||
|
"valid": {"model": "gpt-4"},
|
||||||
|
},
|
||||||
|
"agents": {"defaults": {"fallback_presets": ["invalid_preset"]}},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_preset_without_preset_returns_defaults() -> None:
|
||||||
|
"""Backward compat: no explicit preset → resolve_preset returns the auto-created 'default' preset."""
|
||||||
|
cfg = Config.model_validate({
|
||||||
|
"agents": {"defaults": {"model": "deepseek-chat"}},
|
||||||
|
})
|
||||||
|
assert cfg.agents.defaults.model_preset == "default"
|
||||||
|
r = cfg.resolve_preset()
|
||||||
|
assert r.model == "deepseek-chat"
|
||||||
|
assert r.max_tokens == 8192
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_loop_stores_model_presets() -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
|
||||||
|
presets = {
|
||||||
|
"gpt5": ModelPresetConfig(model="gpt-5", provider="openai"),
|
||||||
|
}
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test"
|
||||||
|
|
||||||
|
loop = AgentLoop(
|
||||||
|
bus=MagicMock(),
|
||||||
|
provider=provider,
|
||||||
|
workspace=Path("/tmp/test"),
|
||||||
|
model_presets=presets,
|
||||||
|
)
|
||||||
|
assert loop.model_presets == presets
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_preset_with_reasoning_effort() -> None:
|
||||||
|
cfg = Config.model_validate({
|
||||||
|
"model_presets": {
|
||||||
|
"ds-r1": {
|
||||||
|
"model": "deepseek-r1",
|
||||||
|
"provider": "deepseek",
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"agents": {"defaults": {"model_preset": "ds-r1"}},
|
||||||
|
})
|
||||||
|
assert cfg.resolve_preset().reasoning_effort == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_routes_to_correct_provider() -> None:
|
||||||
|
"""resolve_preset + _match_provider uses the preset's model+provider."""
|
||||||
|
cfg = Config.model_validate({
|
||||||
|
"model_presets": {
|
||||||
|
"ds": {"model": "deepseek-chat", "provider": "deepseek"},
|
||||||
|
},
|
||||||
|
"providers": {"deepseek": {"api_key": "test-key"}},
|
||||||
|
"agents": {"defaults": {"model_preset": "ds"}},
|
||||||
|
})
|
||||||
|
provider_name = cfg.get_provider_name()
|
||||||
|
assert provider_name == "deepseek"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_with_auto_provider_uses_keyword_matching() -> None:
|
||||||
|
cfg = Config.model_validate({
|
||||||
|
"model_presets": {
|
||||||
|
"auto-ds": {"model": "deepseek-chat", "provider": "auto"},
|
||||||
|
},
|
||||||
|
"providers": {"deepseek": {"api_key": "test-key"}},
|
||||||
|
"agents": {"defaults": {"model_preset": "auto-ds"}},
|
||||||
|
})
|
||||||
|
provider_name = cfg.get_provider_name()
|
||||||
|
assert provider_name == "deepseek"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backward_compat_no_preset() -> None:
|
||||||
|
"""Existing configs without model_presets are automatically promoted to the 'default' preset."""
|
||||||
|
cfg = Config.model_validate({
|
||||||
|
"providers": {"anthropic": {"api_key": "test-key"}},
|
||||||
|
"agents": {"defaults": {"model": "anthropic/claude-opus-4-5"}},
|
||||||
|
})
|
||||||
|
assert cfg.resolve_preset().model == "anthropic/claude-opus-4-5"
|
||||||
|
assert cfg.agents.defaults.model_preset == "default"
|
||||||
|
assert "default" in cfg.model_presets
|
||||||
|
assert cfg.get_provider_name() == "anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_preset_overrides_all_model_fields() -> None:
|
||||||
|
"""When model_preset is set, resolve_preset returns preset values, not individual fields."""
|
||||||
|
cfg = Config.model_validate({
|
||||||
|
"model_presets": {
|
||||||
|
"gpt5": {"model": "gpt-5", "provider": "openai", "max_tokens": 16384},
|
||||||
|
},
|
||||||
|
"providers": {"openai": {"api_key": "test-key"}},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model_preset": "gpt5",
|
||||||
|
"model": "legacy-model",
|
||||||
|
"max_tokens": 4096,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
r = cfg.resolve_preset()
|
||||||
|
assert r.model == "gpt-5"
|
||||||
|
assert r.provider == "openai"
|
||||||
|
assert r.max_tokens == 16384
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_model_presets_dict_is_harmless() -> None:
|
||||||
|
cfg = Config.model_validate({"model_presets": {}})
|
||||||
|
assert cfg.resolve_preset().model == "anthropic/claude-opus-4-5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_uses_preset_provider_not_defaults() -> None:
|
||||||
|
"""When creating a provider for a non-active preset, the preset's own provider must be used."""
|
||||||
|
from nanobot.providers.factory import make_provider_factory
|
||||||
|
|
||||||
|
cfg = Config.model_validate({
|
||||||
|
"model_presets": {
|
||||||
|
"kimi": {"model": "kimi-k2.6", "provider": "moonshot"},
|
||||||
|
"zhipu": {"model": "glm-5.1", "provider": "zhipu"},
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"moonshot": {"api_key": "moonshot-key", "api_base": "https://api.moonshot.ai/v1"},
|
||||||
|
"zhipu": {"api_key": "zhipu-key", "api_base": "https://open.bigmodel.cn/api/paas/v4"},
|
||||||
|
},
|
||||||
|
"agents": {"defaults": {"model_preset": "kimi"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
factory = make_provider_factory(cfg)
|
||||||
|
zhipu_provider = factory("zhipu")
|
||||||
|
|
||||||
|
assert zhipu_provider.api_base == "https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
assert getattr(zhipu_provider, "api_key", None) == "zhipu-key"
|
||||||
|
|
||||||
|
# Also verify the active preset provider is still correct
|
||||||
|
moonshot_provider = factory("kimi")
|
||||||
|
assert moonshot_provider.api_base == "https://api.moonshot.ai/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_factory_rejects_unknown_preset_name() -> None:
|
||||||
|
"""Factory must raise ValueError when asked for a preset not in model_presets."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.providers.factory import make_provider_factory
|
||||||
|
|
||||||
|
cfg = Config.model_validate({
|
||||||
|
"model_presets": {
|
||||||
|
"known": {"model": "gpt-4", "provider": "openai"},
|
||||||
|
},
|
||||||
|
"providers": {"openai": {"api_key": "test-key"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
factory = make_provider_factory(cfg)
|
||||||
|
with pytest.raises(ValueError, match="Preset 'unknown' not found"):
|
||||||
|
factory("unknown")
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Tests for nanobot/skills/create-instance/scripts/create_instance.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).parent.parent.parent / "nanobot" / "skills" / "create-instance" / "scripts" / "create_instance.py"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
"""Point HOME at a temp dir so nanobot writes configs there."""
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.delenv("NANOBOT_CONFIG", raising=False)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _run_script(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess:
|
||||||
|
"""Run create_instance.py as a subprocess."""
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation:
|
||||||
|
"""Argument validation tests."""
|
||||||
|
|
||||||
|
def test_missing_required_args_exits_with_error(self) -> None:
|
||||||
|
result = _run_script()
|
||||||
|
assert result.returncode != 0
|
||||||
|
|
||||||
|
def test_invalid_channel_exits_with_error(self, tmp_home: Path) -> None:
|
||||||
|
result = _run_script("--name", "test", "--channel", "nonexistent_channel")
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "nonexistent_channel" in result.stderr or "nonexistent_channel" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateInstance:
|
||||||
|
"""End-to-end instance creation tests."""
|
||||||
|
|
||||||
|
def test_creates_config_and_workspace(self, tmp_home: Path) -> None:
|
||||||
|
config_dir = tmp_home / ".nanobot-test"
|
||||||
|
result = _run_script(
|
||||||
|
"--name", "test-bot",
|
||||||
|
"--channel", "telegram",
|
||||||
|
"--config-dir", str(config_dir),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
config_path = config_dir / "config.json"
|
||||||
|
assert config_path.exists(), f"Config not created at {config_path}"
|
||||||
|
|
||||||
|
workspace = config_dir / "workspace"
|
||||||
|
assert workspace.exists(), f"Workspace not created at {workspace}"
|
||||||
|
|
||||||
|
def test_config_has_channel_enabled(self, tmp_home: Path) -> None:
|
||||||
|
config_dir = tmp_home / ".nanobot-test"
|
||||||
|
result = _run_script(
|
||||||
|
"--name", "test-bot",
|
||||||
|
"--channel", "telegram",
|
||||||
|
"--config-dir", str(config_dir),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
||||||
|
assert data["channels"]["telegram"]["enabled"] is True
|
||||||
|
|
||||||
|
def test_config_workspace_path_set(self, tmp_home: Path) -> None:
|
||||||
|
config_dir = tmp_home / ".nanobot-test"
|
||||||
|
result = _run_script(
|
||||||
|
"--name", "test-bot",
|
||||||
|
"--channel", "telegram",
|
||||||
|
"--config-dir", str(config_dir),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
||||||
|
ws = data["agents"]["defaults"]["workspace"]
|
||||||
|
assert str(config_dir / "workspace") in ws or "workspace" in ws
|
||||||
|
|
||||||
|
def test_model_override(self, tmp_home: Path) -> None:
|
||||||
|
config_dir = tmp_home / ".nanobot-test"
|
||||||
|
result = _run_script(
|
||||||
|
"--name", "test-bot",
|
||||||
|
"--channel", "telegram",
|
||||||
|
"--model", "deepseek/deepseek-chat",
|
||||||
|
"--config-dir", str(config_dir),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
||||||
|
assert data["agents"]["defaults"]["model"] == "deepseek/deepseek-chat"
|
||||||
|
|
||||||
|
def test_rejects_duplicate_instance(self, tmp_home: Path) -> None:
|
||||||
|
config_dir = tmp_home / ".nanobot-test"
|
||||||
|
result1 = _run_script(
|
||||||
|
"--name", "test-bot",
|
||||||
|
"--channel", "telegram",
|
||||||
|
"--config-dir", str(config_dir),
|
||||||
|
)
|
||||||
|
assert result1.returncode == 0
|
||||||
|
|
||||||
|
result2 = _run_script(
|
||||||
|
"--name", "test-bot",
|
||||||
|
"--channel", "telegram",
|
||||||
|
"--config-dir", str(config_dir),
|
||||||
|
)
|
||||||
|
assert result2.returncode != 0
|
||||||
|
|
||||||
|
def test_port_reassigned_when_default_in_use(self, tmp_home: Path) -> None:
|
||||||
|
"""When default gateway port is occupied, script should pick a different one."""
|
||||||
|
config_dir = tmp_home / ".nanobot-test"
|
||||||
|
|
||||||
|
# Bind to the default gateway port to simulate a running instance
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as blocker:
|
||||||
|
blocker.bind(("127.0.0.1", 18790))
|
||||||
|
blocker.listen(1)
|
||||||
|
|
||||||
|
result = _run_script(
|
||||||
|
"--name", "test-bot",
|
||||||
|
"--channel", "telegram",
|
||||||
|
"--config-dir", str(config_dir),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
||||||
|
assert data["gateway"]["port"] != 18790
|
||||||
|
|
||||||
|
def test_inherits_api_key_from_current_instance(self, tmp_home: Path) -> None:
|
||||||
|
"""API keys from --inherit-config should be copied to new instance."""
|
||||||
|
# Create a fake "current instance" config with an API key
|
||||||
|
src_dir = tmp_home / ".nanobot-current"
|
||||||
|
src_dir.mkdir()
|
||||||
|
src_config = src_dir / "config.json"
|
||||||
|
src_config.write_text(json.dumps({
|
||||||
|
"providers": {
|
||||||
|
"anthropic": {"apiKey": "sk-test-key-12345"},
|
||||||
|
"deepseek": {"apiKey": "dsk-another-key"},
|
||||||
|
"openai": {}, # no key, should not be copied
|
||||||
|
},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
config_dir = tmp_home / ".nanobot-new"
|
||||||
|
result = _run_script(
|
||||||
|
"--name", "new-bot",
|
||||||
|
"--channel", "telegram",
|
||||||
|
"--config-dir", str(config_dir),
|
||||||
|
"--inherit-config", str(src_config),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
|
||||||
|
providers = data.get("providers", {})
|
||||||
|
assert providers.get("anthropic", {}).get("apiKey") == "sk-test-key-12345"
|
||||||
|
assert providers.get("deepseek", {}).get("apiKey") == "dsk-another-key"
|
||||||
|
# openai had no key, so it should not be in the new config's providers
|
||||||
|
assert providers.get("openai", {}).get("apiKey") is None
|
||||||
@@ -39,7 +39,7 @@ def test_from_config_default_path():
|
|||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
with patch("nanobot.config.loader.load_config") as mock_load, \
|
with patch("nanobot.config.loader.load_config") as mock_load, \
|
||||||
patch("nanobot.nanobot._make_provider") as mock_prov:
|
patch("nanobot.providers.factory.build_provider_for_preset") as mock_prov:
|
||||||
mock_load.return_value = Config()
|
mock_load.return_value = Config()
|
||||||
mock_prov.return_value = MagicMock()
|
mock_prov.return_value = MagicMock()
|
||||||
mock_prov.return_value.get_default_model.return_value = "test"
|
mock_prov.return_value.get_default_model.return_value = "test"
|
||||||
@@ -127,7 +127,7 @@ def test_workspace_override(tmp_path):
|
|||||||
|
|
||||||
def test_sdk_make_provider_uses_github_copilot_backend():
|
def test_sdk_make_provider_uses_github_copilot_backend():
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.nanobot import _make_provider
|
from nanobot.providers.factory import make_provider
|
||||||
|
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
@@ -141,7 +141,7 @@ def test_sdk_make_provider_uses_github_copilot_backend():
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
provider = _make_provider(config)
|
provider = make_provider(config)
|
||||||
|
|
||||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
"""End-to-end smoke tests for model presets + failover.
|
||||||
|
|
||||||
|
Uses a local aiohttp fake OpenAI server so requests are real HTTP,
|
||||||
|
not mocked at the provider level.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.nanobot import Nanobot
|
||||||
|
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||||
|
from nanobot.providers.failover import ModelRouter
|
||||||
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
|
||||||
|
try:
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.test_utils import TestServer
|
||||||
|
|
||||||
|
HAS_AIOHTTP = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_AIOHTTP = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _disable_proxy_for_localhost_tests(monkeypatch):
|
||||||
|
"""Prevent httpx from routing localhost requests through a system proxy."""
|
||||||
|
monkeypatch.delenv("ALL_PROXY", raising=False)
|
||||||
|
monkeypatch.delenv("HTTP_PROXY", raising=False)
|
||||||
|
monkeypatch.delenv("HTTPS_PROXY", raising=False)
|
||||||
|
monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers (mock-level preset tests)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _write_config(tmp_path: Path, **overrides) -> Path:
|
||||||
|
data = {
|
||||||
|
"providers": {
|
||||||
|
"openrouter": {"apiKey": "sk-test-key"},
|
||||||
|
"openai": {"apiKey": "sk-openai-test"},
|
||||||
|
},
|
||||||
|
"agents": {"defaults": {"model": "openai/gpt-4.1"}},
|
||||||
|
"tools": {"my": {"allowSet": True}},
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(json.dumps(data))
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Model Preset Mock Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preset_loaded_at_startup(tmp_path: Path) -> None:
|
||||||
|
config_path = _write_config(
|
||||||
|
tmp_path,
|
||||||
|
model_presets={
|
||||||
|
"fast": {
|
||||||
|
"model": "gpt-4.1-mini",
|
||||||
|
"provider": "openai",
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"context_window_tokens": 128000,
|
||||||
|
"temperature": 0.3,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
agents={"defaults": {"model_preset": "fast", "model": "ignored-model"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
|
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||||
|
|
||||||
|
loop = bot._loop
|
||||||
|
assert loop.model == "gpt-4.1-mini"
|
||||||
|
assert loop.context_window_tokens == 128000
|
||||||
|
assert loop.provider.generation.temperature == 0.3
|
||||||
|
assert loop.provider.generation.max_tokens == 4096
|
||||||
|
assert loop.model_preset == "fast"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preset_runtime_switch_updates_all_fields(tmp_path: Path) -> None:
|
||||||
|
config_path = _write_config(
|
||||||
|
tmp_path,
|
||||||
|
model_presets={
|
||||||
|
"cheap": {
|
||||||
|
"model": "gpt-4.1-mini",
|
||||||
|
"provider": "openai",
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"context_window_tokens": 64000,
|
||||||
|
"temperature": 0.5,
|
||||||
|
},
|
||||||
|
"power": {
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"provider": "openai",
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"context_window_tokens": 256000,
|
||||||
|
"temperature": 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents={"defaults": {"model_preset": "cheap"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
|
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||||
|
|
||||||
|
loop = bot._loop
|
||||||
|
assert loop.model == "gpt-4.1-mini"
|
||||||
|
|
||||||
|
my_tool = loop.tools.get("my")
|
||||||
|
result = await my_tool.execute(action="set", key="model_preset", value="power")
|
||||||
|
assert "Error" not in result
|
||||||
|
|
||||||
|
assert loop.model == "gpt-4.1"
|
||||||
|
assert loop.context_window_tokens == 256000
|
||||||
|
assert loop.provider.generation.temperature == 0.1
|
||||||
|
assert loop.provider.generation.max_tokens == 8192
|
||||||
|
assert loop.model_preset == "power"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preset_switch_unknown_returns_error(tmp_path: Path) -> None:
|
||||||
|
config_path = _write_config(
|
||||||
|
tmp_path,
|
||||||
|
model_presets={"a": {"model": "model-a"}},
|
||||||
|
agents={"defaults": {"model_preset": "a"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
|
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||||
|
|
||||||
|
loop = bot._loop
|
||||||
|
original_model = loop.model
|
||||||
|
|
||||||
|
my_tool = loop.tools.get("my")
|
||||||
|
result = await my_tool.execute(action="set", key="model_preset", value="nonexistent")
|
||||||
|
assert "not found" in result.lower()
|
||||||
|
|
||||||
|
assert loop.model == original_model
|
||||||
|
assert loop.model_preset == "a"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preset_model_with_fallback_presets_in_config(tmp_path: Path) -> None:
|
||||||
|
config_path = _write_config(
|
||||||
|
tmp_path,
|
||||||
|
model_presets={
|
||||||
|
"prod": {
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"provider": "openai",
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"temperature": 0.1,
|
||||||
|
},
|
||||||
|
"fallback": {
|
||||||
|
"model": "gpt-4.1-mini",
|
||||||
|
"provider": "openai",
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"temperature": 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents={
|
||||||
|
"defaults": {
|
||||||
|
"model_preset": "prod",
|
||||||
|
"fallback_presets": ["fallback"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
|
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||||
|
|
||||||
|
loop = bot._loop
|
||||||
|
assert loop.model == "gpt-4.1"
|
||||||
|
assert isinstance(loop.provider, ModelRouter)
|
||||||
|
assert loop.provider.fallback_presets == ["fallback"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fallback_presets_wired_to_all_subsystems(tmp_path: Path) -> None:
|
||||||
|
"""When fallback_presets is configured, every subsystem that calls the LLM
|
||||||
|
must use the same ModelRouter instance, not the raw primary provider."""
|
||||||
|
config_path = _write_config(
|
||||||
|
tmp_path,
|
||||||
|
model_presets={
|
||||||
|
"prod": {
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"provider": "openai",
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"temperature": 0.1,
|
||||||
|
},
|
||||||
|
"fallback": {
|
||||||
|
"model": "gpt-4.1-mini",
|
||||||
|
"provider": "openai",
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"temperature": 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents={
|
||||||
|
"defaults": {
|
||||||
|
"model_preset": "prod",
|
||||||
|
"fallback_presets": ["fallback"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
|
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||||
|
|
||||||
|
loop = bot._loop
|
||||||
|
router = loop.provider
|
||||||
|
assert isinstance(router, ModelRouter)
|
||||||
|
|
||||||
|
# Every LLM-consuming subsystem must share the same router
|
||||||
|
assert loop.runner.provider is router, "AgentRunner must use ModelRouter"
|
||||||
|
assert loop.subagents.provider is router, "SubagentManager must use ModelRouter"
|
||||||
|
assert loop.consolidator.provider is router, "Consolidator must use ModelRouter"
|
||||||
|
assert loop.dream.provider is router, "Dream must use ModelRouter"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Real HTTP Smoke Tests (aiohttp fake OpenAI server)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preset_generation_params_reach_http_request() -> None:
|
||||||
|
"""Provider.generation settings must appear in the actual HTTP request body."""
|
||||||
|
requests_log: list[dict] = []
|
||||||
|
|
||||||
|
async def handler(request: web.Request) -> web.Response:
|
||||||
|
body = await request.json()
|
||||||
|
requests_log.append(body)
|
||||||
|
return web.json_response({
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"model": body.get("model"),
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "pong"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_post("/chat/completions", handler)
|
||||||
|
server = TestServer(app)
|
||||||
|
await server.start_server()
|
||||||
|
try:
|
||||||
|
base_url = str(server.make_url("/"))
|
||||||
|
provider = OpenAICompatProvider(
|
||||||
|
api_key="test",
|
||||||
|
api_base=base_url,
|
||||||
|
default_model="test-model",
|
||||||
|
)
|
||||||
|
provider.generation = GenerationSettings(temperature=0.42, max_tokens=1024)
|
||||||
|
|
||||||
|
with patch.object(LLMProvider, "_CHAT_RETRY_DELAYS", (0,)):
|
||||||
|
response = await provider.chat_with_retry(
|
||||||
|
messages=[{"role": "user", "content": "ping"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.finish_reason != "error"
|
||||||
|
assert len(requests_log) >= 1
|
||||||
|
req = requests_log[0]
|
||||||
|
assert req["model"] == "test-model"
|
||||||
|
assert req["temperature"] == 0.42
|
||||||
|
assert req["max_tokens"] == 1024
|
||||||
|
finally:
|
||||||
|
await server.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failover_sends_second_request_to_fallback_model() -> None:
|
||||||
|
"""Primary returns 503; after retry exhaustion ModelRouter hits fallback."""
|
||||||
|
requests_log: list[dict] = []
|
||||||
|
|
||||||
|
async def handler(request: web.Request) -> web.Response:
|
||||||
|
body = await request.json()
|
||||||
|
requests_log.append(body)
|
||||||
|
model = body.get("model")
|
||||||
|
|
||||||
|
if model == "primary-model":
|
||||||
|
return web.Response(
|
||||||
|
status=503,
|
||||||
|
body=json.dumps({"error": {"message": "overloaded", "type": "server_error"}}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"model": model,
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "fallback-ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_post("/chat/completions", handler)
|
||||||
|
server = TestServer(app)
|
||||||
|
await server.start_server()
|
||||||
|
try:
|
||||||
|
base_url = str(server.make_url("/"))
|
||||||
|
primary = OpenAICompatProvider(
|
||||||
|
api_key="test", api_base=base_url, default_model="primary-model"
|
||||||
|
)
|
||||||
|
fallback = OpenAICompatProvider(
|
||||||
|
api_key="test", api_base=base_url, default_model="fallback-model"
|
||||||
|
)
|
||||||
|
|
||||||
|
factory = MagicMock(return_value=fallback)
|
||||||
|
|
||||||
|
router = ModelRouter(
|
||||||
|
primary_provider=primary,
|
||||||
|
primary_model="primary-model",
|
||||||
|
fallback_presets=["fallback-model"],
|
||||||
|
provider_factory=factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(LLMProvider, "_CHAT_RETRY_DELAYS", (0,)):
|
||||||
|
response = await router.chat_with_retry(
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.finish_reason != "error"
|
||||||
|
assert response.content == "fallback-ok"
|
||||||
|
|
||||||
|
models_requested = [r["model"] for r in requests_log]
|
||||||
|
assert "primary-model" in models_requested
|
||||||
|
assert "fallback-model" in models_requested
|
||||||
|
factory.assert_called_once_with("fallback-model")
|
||||||
|
finally:
|
||||||
|
await server.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failover_on_quota_429() -> None:
|
||||||
|
"""Quota 429 on one provider may still work on a different provider."""
|
||||||
|
requests_log: list[dict] = []
|
||||||
|
|
||||||
|
async def handler(request: web.Request) -> web.Response:
|
||||||
|
body = await request.json()
|
||||||
|
requests_log.append(body)
|
||||||
|
return web.Response(
|
||||||
|
status=429,
|
||||||
|
body=json.dumps({
|
||||||
|
"error": {
|
||||||
|
"message": "insufficient quota",
|
||||||
|
"type": "insufficient_quota",
|
||||||
|
"code": "insufficient_quota",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_post("/chat/completions", handler)
|
||||||
|
server = TestServer(app)
|
||||||
|
await server.start_server()
|
||||||
|
try:
|
||||||
|
base_url = str(server.make_url("/"))
|
||||||
|
primary = OpenAICompatProvider(
|
||||||
|
api_key="test", api_base=base_url, default_model="primary-model"
|
||||||
|
)
|
||||||
|
fallback = OpenAICompatProvider(
|
||||||
|
api_key="test", api_base=base_url, default_model="fallback-model"
|
||||||
|
)
|
||||||
|
|
||||||
|
factory = MagicMock(return_value=fallback)
|
||||||
|
|
||||||
|
router = ModelRouter(
|
||||||
|
primary_provider=primary,
|
||||||
|
primary_model="primary-model",
|
||||||
|
fallback_presets=["fallback-model"],
|
||||||
|
provider_factory=factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(LLMProvider, "_CHAT_RETRY_DELAYS", (0,)):
|
||||||
|
response = await router.chat_with_retry(
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Quota 429 SHOULD trigger failover — another provider may still work.
|
||||||
|
factory.assert_called_once_with("fallback-model")
|
||||||
|
assert response.finish_reason == "error"
|
||||||
|
# Both primary and fallback should have been requested.
|
||||||
|
assert len(requests_log) == 2
|
||||||
|
finally:
|
||||||
|
await server.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_router_failover_integration() -> None:
|
||||||
|
"""ModelRouter -> real HTTP failover chain (primary 503, fallback 200)."""
|
||||||
|
requests_log: list[dict] = []
|
||||||
|
|
||||||
|
async def handler(request: web.Request) -> web.Response:
|
||||||
|
body = await request.json()
|
||||||
|
requests_log.append(body)
|
||||||
|
model = body.get("model")
|
||||||
|
|
||||||
|
if model == "primary-model":
|
||||||
|
return web.Response(
|
||||||
|
status=503,
|
||||||
|
body=json.dumps({"error": {"message": "overloaded", "type": "server_error"}}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"model": model,
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "fallback-ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_post("/chat/completions", handler)
|
||||||
|
server = TestServer(app)
|
||||||
|
await server.start_server()
|
||||||
|
try:
|
||||||
|
base_url = str(server.make_url("/"))
|
||||||
|
primary = OpenAICompatProvider(
|
||||||
|
api_key="test", api_base=base_url, default_model="primary-model"
|
||||||
|
)
|
||||||
|
fallback = OpenAICompatProvider(
|
||||||
|
api_key="test", api_base=base_url, default_model="fallback-model"
|
||||||
|
)
|
||||||
|
|
||||||
|
factory = MagicMock(return_value=fallback)
|
||||||
|
|
||||||
|
router = ModelRouter(
|
||||||
|
primary_provider=primary,
|
||||||
|
primary_model="primary-model",
|
||||||
|
fallback_presets=["fallback-model"],
|
||||||
|
provider_factory=factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(LLMProvider, "_CHAT_RETRY_DELAYS", (0,)):
|
||||||
|
response = await router.chat_with_retry(
|
||||||
|
messages=[{"role": "user", "content": "hello"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.finish_reason != "error"
|
||||||
|
assert response.content == "fallback-ok"
|
||||||
|
models_requested = [r["model"] for r in requests_log]
|
||||||
|
assert "primary-model" in models_requested
|
||||||
|
assert "fallback-model" in models_requested
|
||||||
|
factory.assert_called_once_with("fallback-model")
|
||||||
|
finally:
|
||||||
|
await server.close()
|
||||||
Reference in New Issue
Block a user