Compare commits

...
34 Commits
Author SHA1 Message Date
chengyongruandchengyongru 584072cf63 refactor: restrict fallback_models to preset-only and clean up provider factory
- Restrict fallback_models to only reference preset names in model_presets.
- Add schema validation to reject unknown preset names in fallback_models.
- Remove build_provider_for_model() since bare model fallback is no longer supported.
- Simplify make_provider_factory() to only look up presets by name.
- Update onboard UI to remove "Add custom model" option from fallback chain.
- Update tests to use preset names instead of bare model strings in fallback chains.
- Fix test imports referencing deleted _make_provider function.
2026-05-08 20:16:06 +08:00
hanyuanlingandchengyongru 7c270577e1 Refine fallback routing on model presets 2026-05-08 20:16:06 +08:00
LeftXandchengyongru 2e5930e355 feat: add fallback_models support for automatic model failover
When the primary model fails (finish_reason="error" after exhausting
provider-level retries), automatically try each model in the configured
fallback_models list. Supports cross-provider fallback via a cached
provider_factory that resolves the correct provider for each model string.

Config:
  agents.defaults.fallback_models: ["model-b", "provider/model-c"]

Changes:
- AgentDefaults: add fallback_models field
- AgentRunSpec: add fallback_models field
- AgentRunner: add provider_factory, _call_provider, _resolve_fallback_provider
- AgentLoop: accept and forward fallback_models + provider_factory
- nanobot.py: extract _make_provider_for_model, add _make_provider_factory
- cli/commands.py: add _make_cli_provider_factory, wire all AgentLoop sites
- tests/agent/test_runner_fallback.py: 8 test cases covering primary success,
  single/multi fallback, cross-provider, no-factory reuse, caching

Made-with: Cursor
2026-05-08 20:16:06 +08:00
chengyongruandchengyongru 83f437a088 feat(config): add model preset support for runtime model switching
Add ModelPresetConfig schema and model_presets dictionary to config,
enabling named bundles of model parameters (model, temperature,
max_tokens, reasoning_effort, context_window_tokens) that can be
switched atomically at runtime via the self tool.
2026-05-08 20:16:06 +08:00
chengyongruandchengyongru e34b7fd086 fix(onboard): allow empty strings and falsy values in input fields
Fixes two related input-handling bugs in the onboard wizard:

1. _input_text treated "" as None, preventing users from clearing
   optional string fields or entering empty strings intentionally.

2. _input_model_with_autocomplete used `if value else None`, which
   discarded falsy values such as empty strings or 0.

To support clearing optional string fields, add _is_str_or_none() and
normalize empty strings to None inside _configure_pydantic_model only
when the field annotation is `str | None`. Required str fields keep
"" as a valid value.

Also included:
- Remember last selected item in provider/channel/model menus for
  better UX when configuring multiple items.
- Rename _SIMPLE_TYPES and _MENU_DISPATCH to lowercase to follow
  Python naming conventions (they are local variables, not constants).
- Remove unused imports in test file.

Extracted from PR #3358.
2026-05-08 13:13:20 +08:00
chengyongru 12005c20f0 fix(weixin): distinguish stale session from rate limit on ret=-2
Reference hermes-agent#17228 / #18100 / PR#18105.

iLink returns 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")

Previously we swallowed all ret=-2 responses, which caused silent
message drops when the context_token was stale.

Changes:
- Add _is_stale_session_ret() to detect empty/"unknown error" errmsg
- _send_text/_send_media_file retry once without context_token on stale
  session signal, then raise on persistent failure so ChannelManager
  can retry with backoff
- Remove error-swallowing behavior
- Update tests to expect raises and add TestIsStaleSessionRet coverage
2026-05-08 09:41:12 +08:00
chengyongru 9fefb31344 fix(weixin): treat ret=-2 as non-fatal on sendmessage and align client_id format
The iLink sendmessage API frequently returns ret=-2 (parameter error / rate
limit / expired token) even when HTTP status is 200.  The openclaw reference
plugin ignores the JSON body for sendmessage entirely and only checks HTTP
status.  Our previous strict ret checking turned ret=-2 into RuntimeError,
causing ChannelManager retries which only made things worse.

Changes:
- _send_text: swallow ret=-2 after one retry without context_token.
  Log request body + response at warning level for diagnostics.
- _send_media_file: same ret=-2 swallowing.
- _generate_client_id: change format to ``nanobot:{timestamp}-{hex}`` to
  match openclaw-weixin ``{prefix}:{Date.now()}-{hex}``.
- Update tests to expect swallowing instead of raising for ret=-2.
2026-05-07 18:11:06 +08:00
chengyongru 28358980ed fix(weixin): retry send without expired context_token on ret=-2
When the iLink API returns ret=-2 (parameter error), it is often caused
by an expired context_token rather than a malformed payload. After a
gateway restart, the cached token can become stale within ~90 seconds if
no new inbound message refreshes it, causing all outbound replies to fail
silently.

Changes:
- _send_text: retry once without context_token when ret=-2 and a token
  was present; if the retry succeeds, clear the expired token from cache.
- Remove leftover @staticmethod on _check_response_error so self.logger
  and the body parameter work correctly.
- Bump WEIXIN_CHANNEL_VERSION from 2.1.1 -> 2.1.7 to match the reference
  openclaw-weixin plugin.
- Add tests covering the ret=-2 retry path, failure path, and no-token
  path.

References:
- openclaw/openclaw#61174 (context_token expiry after long agent turns)
- hermes-agent#21011 (ret=-2 rate limiting / parameter error)
2026-05-07 17:43:04 +08:00
chengyongru e9f4a868a8 fix(weixin): check both ret and errcode on send to avoid silent drops
The iLink API signals failures through either `ret` or `errcode`.
`_poll_once` already checked both, but `_send_text` and `_send_media_file`
only checked `errcode`. When the API returned `ret != 0` with
`errcode == 0`, the send appeared successful but the message was never
delivered, causing the "still losing messages" issue.

- Add `_check_response_error` helper that validates both fields
- Use it in `_send_text` and `_send_media_file`
- Add debug log after successful text send for observability
- Add test for nonzero ret with zero errcode

Refs: previous inbound fix (suppress -> explicit try/except)
2026-05-07 16:37:31 +08:00
chengyongru 2a318d6991 fix(weixin): log exceptions instead of silently dropping messages in poll loop
Replace `with suppress(Exception)` in `_poll_once` message processing
and the `start()` poll loop with explicit `try/except` blocks that
log errors via `logger.exception`. Previously, any exception during
message processing (e.g. in `_handle_message`) was swallowed silently,
causing inbound messages to disappear without a trace.

Also add tests verifying that:
- `_poll_once` logs and continues when `_process_message` fails
- the poll loop logs and continues when `_poll_once` fails
2026-05-07 15:23:36 +08:00
chengyongru 22b3010bd0 Merge remote-tracking branch 'origin/main' into nightly 2026-05-07 00:46:59 +08:00
chengyongruandchengyongru c4b2d9f53b fix(transcription): address review nits on PR #3253
- Correct api_key type hint to str | None in _post_transcription_with_retry
- Remove unreachable final return ""
- Fix test_openai_missing_api_key_short_circuits to actually test
  missing-key path (use audio_file fixture so file exists)
- Fix PermissionError patch for Windows (patch class method instead
  of instance attribute)
2026-05-06 15:51:13 +08:00
mohamed-elkholy95andchengyongru 84e8aed6b1 fix(transcription): retry Whisper calls and guard malformed responses
A single transient failure between the agent and an OpenAI/Groq Whisper
endpoint currently vanishes as `return ""` in transcribe(). The voice
message arrives as the empty string and there is no way to tell real
silence apart from a failed upload. A malformed but successful response
body is even worse: the JSON-decode error escapes the helper unhandled.

Add a shared `_post_transcription_with_retry` used by both providers.

Retry behaviour:
  - exponential backoff 1s -> 2s -> 4s, up to 3 retries (4 attempts)
  - retryable HTTP statuses: 408, 429, 500, 502, 503, 504
  - retryable exceptions: TimeoutException, ConnectError, ReadError,
    WriteError, RemoteProtocolError

Non-transient failures short-circuit to "" on the first attempt --
retrying a misconfigured key or a broken upload only burns rate-limit
quota. Branches that short-circuit:
  - missing API key, missing audio file
  - file-read errors (PermissionError, OSError) on the audio path,
    preserving the nightly contract for direct provider callers
  - HTTP auth/4xx body issues via raise_for_status()
  - response.json() parse failures
  - non-dict JSON payloads

Sharing one helper means OpenAI and Groq cannot drift apart silently.

Thread `language` through the helper. The multipart files dict is rebuilt
inside the per-attempt loop, so when a caller sets self.language the
`language` field is sent on every attempt -- not just the first.

Tests cover:
  - every advertised retryable status and exception, parameterized
  - language present on attempts 1 and 2 of a 503->200 sequence
  - language absent when unset; present when set (both providers)
  - malformed JSON body and non-dict JSON body short-circuit to ""
  - PermissionError on file read short-circuits with no HTTP attempt
  - max-attempts give-up, exponential-backoff schedule, auth no-retry,
    missing-key / missing-file short-circuit

Test stub fix: the _StubResponse in tests/channels/test_channel_plugins.py
declared no status_code, which the new helper reads for retry classification.
Set status_code = 200 so the stub advertises the successful response that
those tests already simulate. Also moved the two transcription-provider
imports to the top of that file (previously placed mid-file) so the file
is ruff-clean (E402).
2026-05-06 15:51:13 +08:00
Tim O'Brienandchengyongru fb313bd8d1 fix(tool_hints): pass max_length to abbreviate_path for is_path tools
The is_path branch in _fmt_known was not passing max_length to
abbreviate_path, so read_file, write_file, edit, list_dir, and
web_fetch always truncated paths at 40 chars regardless of config.

Now all three branches (is_path, is_command, fallback) honor the
configured toolHintMaxLength.
2026-05-06 13:45:47 +08:00
Tim O'Brienandchengyongru 7d3337a98e fix: wire toolHintMaxLength through AgentLoop constructors
The config field was added but never passed from config to AgentLoop.
The value was always falling back to the default (40) regardless of
what was set in config.json.

Now passes tool_hint_max_length through all AgentLoop() call sites:
- nanobot/nanobot.py (main bot)
- nanobot/cli/commands.py (CLI agent, dev, webui commands)

Also adds documentation in docs/configuration.md.
2026-05-06 13:45:47 +08:00
Tim O'Brienandchengyongru f256d7ab9b feat(config): add toolHintMaxLength to control tool hint truncation
Add  to  config (default: 40, range: 20-500).
Controls how many characters of tool hints are shown in progress updates
(e.g. '$ cd …/project && npm test').

Set to 120+ to see full commands instead of truncated hints:

```json
{
  "agents": {
    "defaults": {
      "toolHintMaxLength": 120
    }
  }
}
```

- Thread max_length through format_tool_hints → _fmt_known/_fmt_mcp/_fmt_fallback
- Make path abbreviation in _abbreviate_command proportional to max_length
- Add TestToolHintMaxLength test class with 5 tests
- All 41 existing tests pass
2026-05-06 13:45:47 +08:00
chengyongruandchengyongru 3baa869fdb refactor(agent): simplify subagent concurrency with rejection over semaphore
Replace the asyncio.Semaphore queueing approach with a simple count
check in SpawnTool.execute(). When the concurrency limit is reached,
the tool returns an error string so the agent can perceive the reason
and adjust its behavior instead of silently queueing.

- Remove max_concurrent_subagents parameter threading through
  AgentLoop, commands.py, and nanobot.py
- SubagentManager reads the limit directly from AgentDefaults
- SpawnTool checks get_running_count() before calling spawn()
- Simplify tests to verify rejection behavior
2026-05-05 21:17:15 +08:00
MrBobandchengyongru 2103cd5602 feat(agent): limit subagent concurrency 2026-05-05 21:17:15 +08:00
chengyongruandchengyongru 5b45191cd9 refactor(sdk): move SDKCaptureHook to agent/hook.py
Colocate the capture hook with the rest of the hook infrastructure
instead of inlining it in the top-level facade module.
2026-05-04 23:37:09 +08:00
Mohamed Elkholyandchengyongru a5fcf7786d fix(sdk): populate RunResult.tools_used and RunResult.messages
``Nanobot.run()`` has always documented ``RunResult.tools_used`` and
``RunResult.messages`` but actually returned ``[]`` for both, so SDK
consumers could never inspect which tools fired or what the final
message list looked like — the only useful field was ``content``.

This threads the data out via a tiny ``_SDKCaptureHook`` that installs
alongside any user-supplied hooks. The capture hook accumulates tool
names across iterations and snapshots the message list on each
``after_iteration`` call; the last snapshot reflects end-of-turn state.

Only the SDK facade is touched: ``AgentLoop.process_direct`` and
``AgentRunner`` signatures are unchanged, so channels / CLI / API paths
are unaffected.
2026-05-04 23:37:09 +08:00
chengyongruandchengyongru 2a67663fab feat(cli): support github-copilot in provider logout
Logout previously claimed to support github-copilot in --help text but had
no registered handler, so `provider logout github-copilot` failed with
"Logout not implemented". Add the handler, sharing token deletion with the
codex flow via `_delete_oauth_files`. Tighten handler-table types, fix the
codex test fixture filename, and cover github-copilot plus the unknown
provider path.
2026-05-04 00:49:38 +08:00
mikaku9944andchengyongru 059a265078 style(cli): use English for docstrings in oauth commands 2026-05-04 00:49:38 +08:00
mikaku9944andchengyongru 9bcb17abe1 feat(cli): add provider logout command
- Implement \
anobot provider logout <provider>\ to clear OAuth credentials.
- Add \_LOGOUT_HANDLERS\ registration mechanism mirroring login.
- Implement logout for \openai-codex\ by deleting local \oauth-cli-kit\ token and lock files.
- Fallback gracefully when attempting to logout from providers lacking local credentials or implementations.
- Fixes #2665
2026-05-04 00:49:38 +08:00
chengyongru 016fd15a00 Merge remote-tracking branch 'origin/main' into nightly 2026-05-03 00:50:42 +08:00
chengyongru 7988ce5b74 Merge remote-tracking branch 'origin/main' into nightly 2026-04-30 15:11:22 +08:00
chengyongru ce4ad50c7d Merge remote-tracking branch 'origin/main' into nightly 2026-04-29 11:31:57 +08:00
chengyongruandchengyongru 4d72e40d35 fix(olostep): address review issues
- Revert unrelated docs change (default provider description)
- Move olostep import from global scope to lazy import inside method
- Revert formatting-only changes (__init__ signature, or "brave" defaults)
- Update tests to mock via sys.modules instead of module-level globals
2026-04-28 18:22:15 +08:00
umerkayandchengyongru 4e314aff0c minor test change 2026-04-28 18:22:15 +08:00
umerkayandchengyongru 02cad2aa74 fix requested changes 2026-04-28 18:22:15 +08:00
umerkayandchengyongru bcfdd49fa4 requested changes complete 2026-04-28 18:22:15 +08:00
umerkayandchengyongru 9cf9272920 feat(web): add Olostep as a configurable web search provider 2026-04-28 18:22:15 +08:00
Celina Hanoutiandchengyongru 407314a672 feat(providers): add Hugging Face inference provider 2026-04-28 14:24:32 +08:00
chengyongru ee1365bcf1 fix(skills): improve create-instance for cross-platform and add channel reference
- Make SKILL.md platform-agnostic (remove Windows-only path rules)
- Add 14-channel quick-reference table with required fields
- Create references/channels.md with detailed per-channel config
- Inherit model from parent config when not explicitly specified
- Consolidate duplicate file reads in _patch_config
- Add email channel consent_granted field documentation
- Fix auto_reply_enabled default value (true, not false)
- Add troubleshooting section to SKILL.md
2026-04-27 11:36:07 +08:00
chengyongruandchengyongru ebd1891f45 feat(skills): add create-instance built-in skill
Add a skill that lets a running nanobot agent create new bot instances
through a helper script. The agent collects instance name, channel type,
and optional model from the user, then runs the script which:
- Calls nanobot onboard to create config + workspace skeleton
- Enables the target channel and sets workspace/model in config
- Auto-assigns gateway/API ports if defaults are occupied
- Validates config via Pydantic before saving
- Reports required fields the user needs to fill in (e.g. bot token)
2026-04-27 10:33:54 +08:00
28 changed files with 3429 additions and 294 deletions
+1
View File
@@ -123,6 +123,7 @@
- **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.
- **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.
## 📦 Install
+140
View File
@@ -656,6 +656,146 @@ That's it! Environment variables, model routing, config matching, and `nanobot s
</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
Global settings that apply to all channels. Configure under the `channels` section in `~/.nanobot/config.json`:
+29 -15
View File
@@ -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.
- **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
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
my(action="check")
# → max_iterations: 40
# context_window_tokens: 65536
# model: 'anthropic/claude-sonnet-4-20250514'
# model_preset: 'fast'
# workspace: PosixPath('/tmp/workspace')
# provider_retry_mode: 'standard'
# 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")
# → How many prompt tokens I've used so far
my(action="check", key="model")
# → What model I'm currently running on
my(action="check", key="model_preset")
# → 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")
# → Whether web search is enabled
@@ -66,7 +75,7 @@ my(action="check", key="web_config.enable")
| 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 tokens has this conversation used?" | `check("_last_usage")` — cumulative across all turns |
| "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)
# → Bump iteration limit from 40 to 80
my(action="set", key="model", value="fast-model")
# → Switch to a faster model
my(action="set", key="model_preset", value="fast")
# → 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)
# → Expand context window for long documents
@@ -101,15 +113,17 @@ my(action="set", key="task_complexity", value="high")
### 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 | 1100 | Max tool calls per conversation turn |
| `context_window_tokens` | int | 4,0961,000,000 | Context window size |
| `model` | str | non-empty | LLM model to use |
| `model_preset` | str | must exist in `model_presets` | Switch to a named preset bundle |
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"
```text
Agent: This is a straightforward question, let me switch to a faster model.
→ my(action="set", key="model", value="fast-model")
Agent: This is a straightforward question, let me switch to the fast preset.
→ my(action="set", key="model_preset", value="fast")
```
### "Remember user preferences across turns"
+2
View File
@@ -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**
```bash
+154 -14
View File
@@ -41,7 +41,7 @@ from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
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.factory import ProviderSnapshot
from nanobot.session.manager import Session, SessionManager
@@ -188,6 +188,50 @@ class AgentLoop:
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_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__(
self,
bus: MessageBus,
@@ -200,6 +244,8 @@ class AgentLoop:
max_tool_result_chars: int | None = None,
provider_retry_mode: str = "standard",
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,
exec_config: ExecToolConfig | None = None,
cron_service: CronService | None = None,
@@ -217,6 +263,8 @@ class AgentLoop:
tools_config: ToolsConfig | None = None,
provider_snapshot_loader: Callable[[], ProviderSnapshot] | 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
@@ -224,7 +272,12 @@ class AgentLoop:
defaults = AgentDefaults()
self.bus = bus
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_signature = provider_signature
self.workspace = workspace
@@ -262,9 +315,9 @@ class AgentLoop:
# One file-read/write tracker per logical session. The tool registry is
# shared by this loop, so tools resolve the active state via contextvars.
self._file_state_store = FileStateStore()
self.runner = AgentRunner(provider)
self.runner = AgentRunner(wrapped_provider)
self.subagents = SubagentManager(
provider=provider,
provider=wrapped_provider,
workspace=workspace,
bus=bus,
model=self.model,
@@ -296,13 +349,13 @@ class AgentLoop:
)
self.consolidator = Consolidator(
store=self.context.memory,
provider=provider,
provider=wrapped_provider,
model=self.model,
sessions=self.sessions,
context_window_tokens=self.context_window_tokens,
build_messages=self.context.build_messages,
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,
)
self.auto_compact = AutoCompact(
@@ -312,9 +365,13 @@ class AgentLoop:
)
self.dream = Dream(
store=self.context.memory,
provider=provider,
provider=wrapped_provider,
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()
if _tc.my.enable:
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."""
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:
"""Swap model/provider for future turns without disturbing an active one."""
provider = snapshot.provider
@@ -335,14 +424,13 @@ class AgentLoop:
if self.provider is provider and self.model == model:
return
old_model = self.model
self.provider = provider
self.model = model
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)
provider = self._wrap_with_failover(provider, model)
self._apply_provider_state(provider, model, context_window_tokens)
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)
def _refresh_provider_snapshot(self) -> None:
@@ -357,6 +445,58 @@ class AgentLoop:
return
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:
"""Register the default set of tools."""
allowed_dir = (
+16 -6
View File
@@ -76,8 +76,6 @@ class MyTool(Tool):
RESTRICTED: dict[str, dict[str, Any]] = {
"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
@@ -118,13 +116,14 @@ class MyTool(Tool):
"Scratchpad keys persist across turns but not restarts.\n"
"Key values: _current_iteration (current progress), "
"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"
"\n"
"When to use:\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"
"- 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:
base += "\nREAD-ONLY MODE: set is disabled."
@@ -132,7 +131,7 @@ class MyTool(Tool):
base += (
"\nIMPORTANT: Before setting state, predict the potential impact. "
"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
@@ -148,7 +147,7 @@ class MyTool(Tool):
},
"key": {
"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.",
},
"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
for k in self.RESTRICTED:
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
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"):
if _has_real_attr(loop, k):
@@ -386,6 +387,8 @@ class MyTool(Tool):
value = expected(value)
except (ValueError, TypeError):
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
# --- existing restricted key logic ---
old = getattr(self._loop, key)
if "min" in spec and value < 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__}",
)
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}")
return f"Set {key} = {value!r} (was {old!r})"
if callable(value):
+1 -1
View File
@@ -160,7 +160,7 @@ def _read_webui_model_name() -> str | None:
try:
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
except Exception as e:
logger.debug("webui bootstrap could not load model name: {}", e)
+128 -11
View File
@@ -11,13 +11,13 @@ from __future__ import annotations
import asyncio
import base64
import copy
import hashlib
import json
import os
import random
import re
import time
import uuid
from collections import OrderedDict
from contextlib import suppress
from pathlib import Path
@@ -54,7 +54,7 @@ MESSAGE_TYPE_BOT = 2
MESSAGE_STATE_FINISH = 2
WEIXIN_MAX_MESSAGE_LEN = 4000
WEIXIN_CHANNEL_VERSION = "2.1.1"
WEIXIN_CHANNEL_VERSION = "2.1.7"
ILINK_APP_ID = "bot"
@@ -80,6 +80,36 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
ERRCODE_SESSION_EXPIRED = -14
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)
MAX_CONSECUTIVE_FAILURES = 3
BACKOFF_DELAY_S = 30
@@ -486,6 +516,7 @@ class WeixinChannel(BaseChannel):
except Exception:
if not self._running:
break
self.logger.exception("WeChat poll loop error")
consecutive_failures += 1
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
consecutive_failures = 0
@@ -525,6 +556,22 @@ class WeixinChannel(BaseChannel):
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:
remaining = self._session_pause_remaining_s()
if remaining > 0:
@@ -575,8 +622,10 @@ class WeixinChannel(BaseChannel):
# Process messages (WeixinMessage[] from types.ts)
msgs: list[dict] = data.get("msgs", []) or []
for msg in msgs:
with suppress(Exception):
try:
await self._process_message(msg)
except Exception:
self.logger.exception("Failed to process WeChat message")
# ------------------------------------------------------------------
# Inbound message processing (matches inbound.ts + process-message.ts)
@@ -1089,6 +1138,14 @@ class WeixinChannel(BaseChannel):
except Exception as 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(
self,
to_user_id: str,
@@ -1096,7 +1153,7 @@ class WeixinChannel(BaseChannel):
context_token: str,
) -> None:
"""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] = []
if text:
@@ -1120,11 +1177,47 @@ class WeixinChannel(BaseChannel):
}
data = await self._api_post("ilink/bot/sendmessage", body)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
if errcode and errcode != 0:
raise RuntimeError(
f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}"
errmsg = 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(
self,
@@ -1250,7 +1343,7 @@ class WeixinChannel(BaseChannel):
media_item["len"] = str(raw_size)
# 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}]
weixin_msg: dict[str, Any] = {
@@ -1270,11 +1363,35 @@ class WeixinChannel(BaseChannel):
}
data = await self._api_post("ilink/bot/sendmessage", body)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
if errcode and errcode != 0:
raise RuntimeError(
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
errmsg = 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
View File
@@ -48,6 +48,7 @@ from rich.table import Table
from rich.text import Text
from nanobot import __logo__, __version__
from nanobot.agent.loop import AgentLoop
class SafeFileHistory(FileHistory):
@@ -437,20 +438,6 @@ def _onboard_plugins(config_path: Path) -> None:
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:
"""Load config and optionally override the active workspace."""
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
@@ -528,7 +515,6 @@ def serve(
raise typer.Exit(1)
from loguru import logger
from nanobot.agent.loop import AgentLoop
from nanobot.api.server import create_app
from nanobot.bus.queue import MessageBus
from nanobot.session.manager import SessionManager
@@ -545,38 +531,20 @@ def serve(
timeout = timeout if timeout is not None else api_cfg.timeout
sync_workspace_templates(runtime_config.workspace_path)
bus = MessageBus()
provider = _make_provider(runtime_config)
defaults = runtime_config.agents.defaults
session_manager = SessionManager(runtime_config.workspace_path)
agent_loop = AgentLoop(
bus=bus,
provider=provider,
workspace=runtime_config.workspace_path,
model=runtime_config.agents.defaults.model,
max_iterations=runtime_config.agents.defaults.max_tool_iterations,
context_window_tokens=runtime_config.agents.defaults.context_window_tokens,
context_block_limit=runtime_config.agents.defaults.context_block_limit,
max_tool_result_chars=runtime_config.agents.defaults.max_tool_result_chars,
provider_retry_mode=runtime_config.agents.defaults.provider_retry_mode,
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,
resolved_preset = runtime_config.resolve_preset()
agent_loop = AgentLoop.from_config(
runtime_config, bus,
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" [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(f" [cyan]Timeout[/cyan] : {timeout}s")
if host in {"0.0.0.0", "::"}:
@@ -638,7 +606,6 @@ def _run_gateway(
open_browser_url: str | None = None,
) -> None:
"""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.message import MessageTool
from nanobot.bus.queue import MessageBus
@@ -659,7 +626,6 @@ def _run_gateway(
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
provider = provider_snapshot.provider
session_manager = SessionManager(config.workspace_path)
# Preserve existing single-workspace installs, but keep custom workspaces clean.
@@ -671,31 +637,10 @@ def _run_gateway(
cron = CronService(cron_store_path)
# Create agent with cron service
agent = AgentLoop(
bus=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,
agent = AgentLoop.from_config(
config, bus,
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager,
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
timezone=config.agents.defaults.timezone,
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_signature=provider_snapshot.signature,
)
@@ -798,7 +743,7 @@ def _run_gateway(
if job.payload.deliver and job.payload.to and response:
should_notify = await evaluate_response(
response, reminder_note, provider, agent.model,
response, reminder_note, agent.provider, agent.model,
)
if should_notify:
await _deliver_to_channel(
@@ -888,7 +833,7 @@ def _run_gateway(
hb_cfg = config.gateway.heartbeat
heartbeat = HeartbeatService(
workspace=config.workspace_path,
provider=provider,
provider=agent.provider,
model=agent.model,
on_execute=on_heartbeat_execute,
on_notify=on_heartbeat_notify,
@@ -1041,7 +986,6 @@ def agent(
"""Interact with the agent directly."""
from loguru import logger
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
@@ -1049,8 +993,6 @@ def agent(
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
provider = _make_provider(config)
# Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(config.workspace_path):
_migrate_cron_store(config)
@@ -1064,30 +1006,10 @@ def agent(
else:
logger.disable("nanobot")
agent_loop = AgentLoop(
bus=bus,
provider=provider,
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,
resolved_preset = config.resolve_preset()
agent_loop = AgentLoop.from_config(
config, bus,
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()
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
from nanobot.bus.events import InboundMessage
_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:
cli_channel, cli_chat_id = session_id.split(":", 1)
@@ -1489,7 +1411,10 @@ def status():
if config_path.exists():
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
for spec in PROVIDERS:
+270 -10
View File
@@ -22,7 +22,7 @@ from nanobot.cli.models import (
get_model_suggestions,
)
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()
@@ -49,6 +49,16 @@ _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
_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():
"""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)
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"):
return FieldTypeInfo("list", args[0] if args else str)
if origin is dict or (hasattr(origin, "__name__") and origin.__name__ == "Dict"):
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:
return FieldTypeInfo(name, None)
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()
if value is None or value == "":
if value is None:
return None
if field_type == "int":
@@ -507,7 +517,7 @@ def _input_model_with_autocomplete(
qmark=">",
).ask()
return value if value else None
return value if value is not None else None
def _input_context_window_with_recommendation(
@@ -588,12 +598,112 @@ def _handle_context_window_field(
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] = {
"model": _handle_model_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(
model: BaseModel,
display_name: str,
@@ -626,11 +736,20 @@ def _configure_pydantic_model(
items.append(f"{display}: {formatted}")
return items + ["[Done]"]
last_field_name: str | None = None
while True:
console.clear()
_show_config_panel(display_name, working_model, fields)
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:
return None
@@ -641,6 +760,8 @@ def _configure_pydantic_model(
if field_idx < 0 or field_idx >= len(fields):
return None
last_field_name = fields[field_idx][0]
field_name, field_info = fields[field_idx]
current_value = getattr(working_model, field_name, None)
ftype = _get_field_type_info(field_info)
@@ -697,6 +818,10 @@ def _configure_pydantic_model(
else:
new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
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)
@@ -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]")
# --- 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 ---
@@ -795,12 +1027,23 @@ def _configure_providers(config: Config) -> None:
choices.append(display)
return choices + ["<- Back"]
last_provider_key: str | None = None
while True:
try:
console.clear()
_show_section_header("LLM Providers", "Select a provider to configure API key and endpoint")
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":
break
@@ -812,6 +1055,7 @@ def _configure_providers(config: Config) -> None:
# Find the actual provider key from display names
for name, display in _get_provider_names().items():
if display == provider_name:
last_provider_key = name
_configure_provider(config, name)
break
@@ -885,17 +1129,21 @@ def _configure_channels(config: Config) -> None:
channel_names = list(_get_channel_names().keys())
choices = channel_names + ["<- Back"]
last_choice: str | None = None
while True:
try:
console.clear()
_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":
break
# Type guard: answer is now guaranteed to be a string
assert isinstance(answer, str)
last_choice = answer
_configure_channel(config, answer)
except KeyboardInterrupt:
console.print("\n[dim]Returning to main menu...[/dim]")
@@ -1003,6 +1251,12 @@ def _show_summary(config: Config) -> None:
channel_rows.append((display, status))
_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
for title, model in [
("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)
config = base_config.model_copy(deep=True)
_sync_preset_cache(config)
last_main_choice: str | None = None
while True:
console.clear()
_show_main_menu_header()
@@ -1082,6 +1338,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
"What would you like to configure?",
choices=[
"[P] LLM Provider",
"[M] Model Presets",
"[C] Chat Channel",
"[H] Channel Common",
"[A] Agent Settings",
@@ -1092,6 +1349,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
"[S] Save and Exit",
"[X] Exit Without Saving",
],
default=last_main_choice,
qmark=">",
).ask()
except KeyboardInterrupt:
@@ -1105,8 +1363,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
return OnboardResult(config=original_config, should_save=False)
continue
_MENU_DISPATCH = {
_menu_dispatch = {
"[P] LLM Provider": lambda: _configure_providers(config),
"[M] Model Presets": lambda: _configure_model_presets(config),
"[C] Chat Channel": lambda: _configure_channels(config),
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
"[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":
return OnboardResult(config=original_config, should_save=False)
action_fn = _MENU_DISPATCH.get(answer)
action_fn = _menu_dispatch.get(answer)
if action_fn:
last_main_choice = answer
action_fn()
+74 -7
View File
@@ -3,7 +3,7 @@
from pathlib import Path
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_settings import BaseSettings
@@ -65,18 +65,34 @@ class DreamConfig(Base):
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):
"""Default agent configuration."""
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"
provider: str = (
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
)
max_tokens: int = 8192
context_window_tokens: int = 65_536
context_block_limit: int | None = None
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_concurrent_subagents: int = Field(default=1, ge=1)
max_tool_result_chars: int = 16_000
@@ -88,7 +104,9 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("toolHintMaxLength"),
serialization_alias="toolHintMaxLength",
) # 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"
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"])
@@ -273,6 +291,54 @@ class Config(BaseSettings):
api: ApiConfig = Field(default_factory=ApiConfig)
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
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
def workspace_path(self) -> Path:
@@ -285,15 +351,16 @@ class Config(BaseSettings):
"""Match provider config and its registry name. Returns (config, spec_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":
spec = find_by_name(forced)
if spec:
p = getattr(self.providers, spec.name, None)
return (p, spec.name) if p else (None, None)
provider_cfg = getattr(self.providers, spec.name, None)
return (provider_cfg, spec.name) if provider_cfg else (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_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
normalized_prefix = model_prefix.replace("-", "_")
+1 -32
View File
@@ -8,7 +8,6 @@ from typing import Any
from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
@dataclass(slots=True)
@@ -62,32 +61,7 @@ class Nanobot:
Path(workspace).expanduser().resolve()
)
provider = _make_provider(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,
)
loop = AgentLoop.from_config(config)
return cls(loop)
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)
+2
View File
@@ -137,7 +137,9 @@ class LLMProvider(ABC):
"insufficient_quota",
"insufficient quota",
"quota exceeded",
"quota_exceeded",
"quota exhausted",
"quota_exhausted",
"billing hard limit",
"billing_hard_limit_reached",
"billing not active",
+127 -49
View File
@@ -4,11 +4,16 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from nanobot.config.schema import Config
from nanobot.providers.base import GenerationSettings, LLMProvider
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)
class ProviderSnapshot:
@@ -18,22 +23,62 @@ class ProviderSnapshot:
signature: tuple[object, ...]
def make_provider(config: Config) -> LLMProvider:
"""Create the LLM provider implied by config."""
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
p = config.get_provider(model)
spec = find_by_name(provider_name) if provider_name else None
@dataclass(frozen=True)
class _ProviderInfo:
"""Resolved metadata needed to build and validate an LLM provider."""
name: str | 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"
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 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.")
elif backend == "openai_compat" and not model.startswith("bedrock/"):
needs_key = not (p and p.api_key)
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
needs_key = not (cfg and cfg.api_key)
exempt = info.spec and (info.spec.is_oauth or info.spec.is_local or info.spec.is_direct)
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":
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
provider = AzureOpenAIProvider(
api_key=p.api_key,
api_base=p.api_base,
api_key=cfg.api_key if cfg else None,
api_base=info.api_base,
default_model=model,
)
elif backend == "github_copilot":
@@ -55,70 +100,103 @@ def make_provider(config: Config) -> LLMProvider:
from nanobot.providers.anthropic_provider import AnthropicProvider
provider = AnthropicProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
api_key=cfg.api_key if cfg else None,
api_base=info.api_base,
default_model=model,
extra_headers=p.extra_headers if p else None,
extra_headers=cfg.extra_headers if cfg else None,
)
elif backend == "bedrock":
from nanobot.providers.bedrock_provider import BedrockProvider
provider = BedrockProvider(
api_key=p.api_key if p else None,
api_base=p.api_base if p else None,
api_key=cfg.api_key if cfg else None,
api_base=info.api_base if cfg else None,
default_model=model,
region=getattr(p, "region", None) if p else None,
profile=getattr(p, "profile", None) if p else None,
extra_body=p.extra_body if p else None,
region=getattr(cfg, "region", None) if cfg else None,
profile=getattr(cfg, "profile", None) if cfg else None,
extra_body=cfg.extra_body if cfg else None,
)
else:
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
provider = OpenAICompatProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
api_key=cfg.api_key if cfg else None,
api_base=info.api_base,
default_model=model,
extra_headers=p.extra_headers if p else None,
spec=spec,
extra_body=p.extra_body if p else None,
extra_headers=cfg.extra_headers if cfg else None,
spec=info.spec,
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
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, ...]:
"""Return the config fields that affect the primary LLM provider."""
model = config.agents.defaults.model
resolved = config.resolve_preset()
defaults = config.agents.defaults
p = config.get_provider(model)
return (
model,
defaults.provider,
config.get_provider_name(model),
config.get_api_key(model),
config.get_api_base(model),
p.extra_headers if p else None,
p.extra_body if p else None,
getattr(p, "region", None) if p else None,
getattr(p, "profile", None) if p else None,
defaults.max_tokens,
defaults.temperature,
defaults.reasoning_effort,
defaults.context_window_tokens,
resolved.model,
resolved.provider,
config.get_provider_name(resolved.model),
config.get_api_key(resolved.model),
config.get_api_base(resolved.model),
resolved.max_tokens,
resolved.temperature,
resolved.reasoning_effort,
resolved.context_window_tokens,
tuple(defaults.fallback_presets),
)
def build_provider_snapshot(config: Config) -> ProviderSnapshot:
resolved = config.resolve_preset()
return ProviderSnapshot(
provider=make_provider(config),
model=config.agents.defaults.model,
context_window_tokens=config.agents.defaults.context_window_tokens,
model=resolved.model,
context_window_tokens=resolved.context_window_tokens,
signature=provider_signature(config),
)
+183
View File
@@ -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)
+64
View File
@@ -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()
+465 -10
View File
@@ -4,27 +4,22 @@ These tests focus on the business logic behind the onboard wizard,
without testing the interactive UI components.
"""
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from pydantic import BaseModel, Field
from nanobot.cli import onboard as onboard_wizard
# Import functions to test
from nanobot.cli.commands import _merge_missing_defaults
from nanobot.cli.onboard import (
_BACK_PRESSED,
_configure_pydantic_model,
_format_value,
_get_constraint_hint,
_get_field_display_name,
_get_field_type_info,
_get_constraint_hint,
_input_text,
_validate_field_constraint,
run_onboard,
)
from nanobot.config.schema import Config
@@ -640,8 +635,8 @@ class TestValidateFieldConstraint:
def test_real_send_max_retries_field(self):
"""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.config.schema import ChannelsConfig
field_info = ChannelsConfig.model_fields["send_max_retries"]
assert _validate_field_constraint(3, field_info) is None
@@ -833,12 +828,11 @@ class TestMainMenuUpdate:
def test_main_menu_dispatch_includes_channel_common(self):
"""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
# The menu items are defined inline in run_onboard, so we test
# 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_GETTER
@@ -846,7 +840,7 @@ class TestMainMenuUpdate:
def test_main_menu_dispatch_includes_api_server(self):
"""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_GETTER
@@ -960,3 +954,464 @@ class TestMainMenuUpdate:
assert result.should_save is True
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 == []
+89
View File
@@ -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
+31 -12
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import MagicMock
import pytest
from pydantic import BaseModel
@@ -35,6 +35,7 @@ def _make_mock_loop(**overrides):
loop._concurrency_gate = None
loop._unified_session = False
loop._extra_hooks = []
loop.model_preset = None
# web_config mock — needed for check tests
loop.web_config = MagicMock()
@@ -76,7 +77,7 @@ class TestInspectSummary:
tool = _make_tool()
result = await tool.execute(action="check")
assert "max_iterations: 40" in result
assert "context_window_tokens: 65536" in result
assert "model_preset" in result
@pytest.mark.asyncio
async def test_inspect_includes_runtime_vars(self):
@@ -92,8 +93,7 @@ class TestInspectSummary:
tool = _make_tool()
result = await tool.execute(action="check")
assert "max_iterations" in result
assert "context_window_tokens" in result
assert "model" in result
assert "model_preset" in result
assert "workspace" in result
assert "provider_retry_mode" in result
assert "max_tool_result_chars" in result
@@ -231,13 +231,13 @@ class TestModifyRestricted:
@pytest.mark.asyncio
async def test_modify_string_int_coerced(self):
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
@pytest.mark.asyncio
async def test_modify_context_window_valid(self):
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
@pytest.mark.asyncio
@@ -337,13 +337,13 @@ class TestModifyFree:
@pytest.mark.asyncio
async def test_modify_allows_list(self):
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]
@pytest.mark.asyncio
async def test_modify_allows_dict(self):
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}
@pytest.mark.asyncio
@@ -392,6 +392,26 @@ class TestModifyFree:
assert "Error" in result
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
@@ -689,8 +709,8 @@ class TestSubagentHookStatus:
@pytest.mark.asyncio
async def test_after_iteration_updates_status(self):
"""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.subagent import SubagentStatus, _SubagentHook
status = SubagentStatus(
task_id="test",
@@ -716,8 +736,8 @@ class TestSubagentHookStatus:
@pytest.mark.asyncio
async def test_after_iteration_with_error(self):
"""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.subagent import SubagentStatus, _SubagentHook
status = SubagentStatus(
task_id="test",
@@ -739,8 +759,8 @@ class TestSubagentHookStatus:
@pytest.mark.asyncio
async def test_after_iteration_no_status_is_noop(self):
"""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.subagent import _SubagentHook
hook = _SubagentHook("test")
context = AgentHookContext(iteration=1, messages=[])
@@ -757,7 +777,6 @@ class TestCheckpointCallback:
async def test_checkpoint_updates_phase_and_iteration(self):
"""The _on_checkpoint callback should update status.phase and iteration."""
from nanobot.agent.subagent import SubagentStatus
import asyncio
status = SubagentStatus(
task_id="cp",
+221 -2
View File
@@ -48,11 +48,11 @@ def test_make_headers_includes_route_tag_when_configured() -> None:
assert headers["Authorization"] == "Bearer token"
assert headers["SKRouteTag"] == "123"
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:
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:
@@ -1250,3 +1250,222 @@ async def test_send_text_succeeds_on_zero_errcode() -> None:
await channel._send_text("wx-user", "hello", "ctx-ok")
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
View File
@@ -9,7 +9,7 @@ import pytest
from typer.testing import CliRunner
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.cron.types import CronJob, CronPayload
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():
from nanobot.cli.commands import _make_provider
from nanobot.config.schema import Config
from nanobot.providers.factory import build_provider_for_preset
config = Config.model_validate(
{
@@ -503,7 +503,7 @@ def test_make_provider_uses_github_copilot_backend():
)
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"
@@ -562,6 +562,8 @@ def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
def test_make_provider_passes_extra_headers_to_custom_provider():
from nanobot.providers.factory import build_provider_for_preset
config = Config.model_validate(
{
"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:
_make_provider(config)
build_provider_for_preset(config, config.resolve_preset())
kwargs = mock_async_openai.call_args.kwargs
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, \
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._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.bus.queue.MessageBus"), \
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.channels_config = None
agent_loop.process_direct = AsyncMock(
@@ -609,6 +611,7 @@ def mock_agent_runtime(tmp_path):
)
agent_loop.close_mcp = AsyncMock(return_value=None)
mock_agent_loop_cls.return_value = agent_loop
mock_agent_loop_cls.from_config.return_value = agent_loop
yield {
"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 == (
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["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.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.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:
pass
@classmethod
def from_config(cls, *args, **kwargs):
return cls(*args, **kwargs)
async def process_direct(self, *_args, **_kwargs):
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
async def close_mcp(self) -> 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)
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.load_config", lambda _path=None: config)
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())
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:
pass
@classmethod
def from_config(cls, *args, **kwargs):
return cls(*args, **kwargs)
async def process_direct(self, *_args, **_kwargs):
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
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)
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.load_config", lambda _path=None: config)
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.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:
pass
@classmethod
def from_config(cls, *args, **kwargs):
return cls(*args, **kwargs)
async def process_direct(self, *_args, **_kwargs):
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
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)
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.load_config", lambda _path=None: config)
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.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:
pass
@classmethod
def from_config(cls, *args, **kwargs):
return cls(*args, **kwargs)
async def process_direct(self, *_args, **_kwargs):
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
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
)
@@ -846,7 +865,7 @@ def test_agent_overrides_workspace_path(mock_agent_runtime):
assert result.exit_code == 0
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["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):
@@ -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["config"].agents.defaults.workspace == str(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):
@@ -928,8 +947,8 @@ def _patch_cli_command_runtime(
sync_templates or (lambda _path: None),
)
monkeypatch.setattr(
"nanobot.cli.commands._make_provider",
provider_factory,
"nanobot.providers.factory.build_provider_for_preset",
lambda *_a, **_k: provider_factory(Config()),
)
monkeypatch.setattr(
"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:
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:
return None
@@ -985,7 +1008,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
message_bus=lambda: 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("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.load_config", lambda _path=None: config)
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(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(provider, _config),
@@ -1117,8 +1140,13 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
class _FakeAgentLoop:
def __init__(self, *args, **kwargs) -> None:
self.model = "test-model"
self.provider = object()
self.tools = {}
@classmethod
def from_config(cls, *args, **kwargs):
return cls(*args, **kwargs)
async def process_direct(self, *_args, **_kwargs):
return OutboundMessage(
channel="telegram",
@@ -1152,7 +1180,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
return True
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.utils.evaluator.evaluate_response",
@@ -1181,7 +1209,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
assert 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["task_context"] == (
"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.load_config", lambda _path=None: config)
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.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(object(), _config),
@@ -1248,8 +1276,13 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
class _FakeAgentLoop:
def __init__(self, *args, **kwargs) -> None:
self.model = "test-model"
self.provider = object()
self.tools = {}
@classmethod
def from_config(cls, *args, **kwargs):
return cls(*args, **kwargs)
async def process_direct(self, *_args, on_progress=None, **_kwargs):
seen["on_progress"] = on_progress
return OutboundMessage(
@@ -1275,7 +1308,7 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
return False
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.utils.evaluator.evaluate_response",
@@ -1480,9 +1513,14 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
class _FakeAgentLoop:
def __init__(self, **_kwargs) -> None:
self.model = "test-model"
self.provider = object()
self.dream = _FakeDream()
self.sessions = _FakeSessionManager()
@classmethod
def from_config(cls, *args, **kwargs):
return cls(**kwargs)
async def run(self) -> None:
await asyncio.Event().wait()
@@ -1571,7 +1609,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
message_bus=lambda: 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.cron.service.CronService", _FakeCronService)
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)
+264
View File
@@ -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")
+168
View File
@@ -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
+3 -3
View File
@@ -39,7 +39,7 @@ def test_from_config_default_path():
from nanobot.config.schema import Config
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_prov.return_value = MagicMock()
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():
from nanobot.config.schema import Config
from nanobot.nanobot import _make_provider
from nanobot.providers.factory import make_provider
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"):
provider = _make_provider(config)
provider = make_provider(config)
assert provider.__class__.__name__ == "GitHubCopilotProvider"
+467
View File
@@ -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()