Maintainer edit: make explicit --defaults refresh existing configs without prompting when no TTY is available, preserving user values for CI and Docker runs.
Document that SDK callers always go through run() → process_direct(ephemeral=False),
so per_call hooks are intentionally excluded from ephemeral turns per chengyongru
review feedback.
The test_run_populates_tools_used_across_iterations,
test_run_populates_final_messages, and
test_run_user_hooks_still_fire_alongside_capture tests were reading
bot._loop._extra_hooks directly in their fake_process_direct mocks,
but the contextvar change moved per-call hooks to _per_call_hooks.
Also fixes the ephemeral guard: per_call hooks should not be used in
ephemeral mode (the original code incorrectly applied them regardless).
Nanobot.run() previously mutated the shared self._loop._extra_hooks
attribute under a try/finally. When two run() calls with different
session_keys execute concurrently, they overwrite each other's hook
lists — the second call saves the first call's hooks as 'prev', and
the finally block restores stale state.
Use a contextvars.ContextVar instead, which is per-task in asyncio.
This gives each concurrent run() call its own hook list without
changing any function signatures. Falls back to self._extra_hooks
when no per-call hooks are set (non-SDK usage).
Match the _BOCHA_SEARCH_API_URL / _VOLCENGINE_SEARCH_API_URL convention
instead of hardcoding the URL inline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keenable's public endpoint serves the free tier (1000 req/hour) without
auth. Route to /v1/search/public with the X-Keenable-Title header when no
key is configured, instead of falling back to DuckDuckGo; keep the
authenticated /v1/search path when an apiKey or KEENABLE_API_KEY is set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Remove overly broad 'not found' match that could trigger on transient
errors like 'chat not found' or 'message to reply not found'
- Keep only 'method not found' and 'unknown method' which indicate
the server genuinely doesn't support sendRichMessage
- Demote timeout log from warning to debug since it falls back to
legacy path successfully
- Try sendRichMessage for all non-blockquote messages (tables, task lists,
math, collapsible, etc.) with graceful fallback to legacy HTML
- Fix payload format to use top-level 'markdown' field per Bot API 10.1 spec
- Add rich path in send_delta() stream end (delete preview + send rich)
- Latch off permanently if server returns capability error
- Removes _needs_rich_rendering() gate and 4 regex patterns
WhatsApp can deliver a sender LID instead of a phone number. The channel
already learns the LID->phone mapping at runtime, but only after a message
that carries both values, so the first message from a contact can't be
resolved to a phone number.
Seed the mapping on startup from two sources:
- reverse mapping files the bridge persists in the auth directory
(lid-mapping-<lid>_reverse.json), resolved via get_runtime_subdir so it
respects a custom runtime dir
- a new optional channels.whatsapp.lidMappings config dict for static
mappings (takes precedence over the on-disk files)
Malformed/empty mapping files are ignored rather than failing startup.
Adds tests for both sources, precedence, malformed files and the
no-auth-dir case, plus docs for the new config field.
timeout=None causes the httpx client to wait indefinitely when connecting
to MCP servers via streamableHttp transport, blocking the entire event loop.
This prevents Telegram polling and other async operations from running.
Set timeout=httpx.Timeout(30.0, connect=10.0) to match the pattern used
in the SSE transport's httpx_client_factory.
SessionManager._load() migrates sessions from the legacy directory
(~/.nanobot/sessions/) to the workspace path, but delete_session only
checked the workspace path. A user deleting a session could therefore
see its history come back the next time the session was loaded.
- delete_session now attempts to unlink both paths
- returns True if at least one file was removed
- added regression tests: legacy-only, both-paths, and no-revival
Maintainer edit: parse Feishu card table columns and rows so forwarded table cards do not fall back to [interactive], and add parser regression coverage.
Feishu cards arriving via WebSocket have a different structure than
expected: elements are nested lists with tag:text/text fields instead
of flat lists with tag:markdown/content. Also extract user_dsl for
richer card data and support body.elements (schema 2.0).
The _strip_image_content methods now use a fixed non-descriptive
placeholder instead of path-derived text. Update the 3 existing
test_provider_retry assertions to match the new placeholder format.
The image-strip fallback (triggered when a model errors on image input)
replaced image_url blocks with [image: <path>] or [image omitted]. Both
read like a live, available image to the LLM, causing it to:
1. hallucinate about image contents it never received
2. attempt read_file on the leaked server path
3. expose internal file paths to the model
Replace with an explicit '[Image not delivered to model — do not describe
or reference it]' placeholder that tells the LLM the image was stripped.
Fixes#4345
When the primary model (e.g. DeepSeek during peak hours) returns an empty
choices response with HTTP 200, the error carries no status code or
structured error metadata. The existing _FALLBACK_ERROR_TOKENS had no
matching token, so _should_fallback() returned False and fallback models
were never tried.
Changes:
- Add 'empty' token to _FALLBACK_ERROR_TOKENS so 'Error: API returned
empty choices.' text matches the fallback path
- Set error_kind='empty' in openai_compat_provider when returning
the empty-choices error, making the classification explicit
- Add test coverage for both text-only and error_kind matching paths
Fixes: glebov reported primary never falls back when DeepSeek returns
empty responses