Compare commits

...
Author SHA1 Message Date
Ubuntu de9c5f03ae fix(agent): make detached exec portable on Windows 2026-06-28 11:05:10 +00:00
Ubuntu 1cd3431639 feat(agent): add runtime budget convergence notices 2026-06-28 05:11:04 +00:00
Ubuntu c62d0d5fa7 feat(agent): support detached exec services 2026-06-28 05:09:39 +00:00
Ubuntu 9474498e3e fix(agent): flag missing verification commands 2026-06-28 05:09:39 +00:00
Ubuntu abf930a381 feat(agent): structure long tool output summaries 2026-06-28 05:09:39 +00:00
Ubuntu c2b1453b2e fix(agent): harden persisted output handling 2026-06-28 05:09:39 +00:00
Ubuntu 3fccd9ab9a Revert "docs(agent): capture eval reliability lessons"
This reverts commit f56e0bea2ee0fa3dc533be1b4d4b33a5f302de10.
2026-06-28 05:09:39 +00:00
Ubuntu 12610138af docs(agent): capture eval reliability lessons 2026-06-28 05:09:39 +00:00
Ubuntu 5b9eba4318 feat(agent): add verification gates and provider recovery 2026-06-28 05:09:39 +00:00
Xubin Ren c90e433057 fix(session): guard lossy migration by stored key 2026-06-27 16:52:54 +08:00
axelray-devandXubin Ren 3ce77633c0 fix(session): add _decode_storage_key for corrupt-file repair in list_sessions 2026-06-27 16:52:54 +08:00
axelray-devandXubin Ren 00a907c493 fix(session): split safe_key and _storage_key to fix WebUI coupling (#4533) 2026-06-27 16:52:54 +08:00
axelray-devandXubin Ren cf2f589615 fix(session): prevent save from writing to legacy lossy path, add collision tests (#4533) 2026-06-27 16:52:54 +08:00
axelray-devandXubin Ren 463f536750 fix: prevent session key collision on disk (#4057)
safe_key() replaces ':' with '_', causing collisions between distinct
keys (e.g. telegram:a_b vs telegram:a:b both become telegram_a_b).

Use base64url (no padding) for collision-resistant encoding while
maintaining backward compatibility: _get_session_path and
_get_legacy_session_path check the new path first, then fall back
to the old lossy encoding for existing session files.
2026-06-27 16:52:54 +08:00
Xubin Ren 00a7de0171 fix: stringify Anthropic typeless blocks as JSON 2026-06-27 16:47:59 +08:00
efb792ff24 fix: validate content block type in Anthropic assistant blocks (#4060)
_assistant_blocks appends dict items from content lists directly
without checking for the required 'type' field. A block like
{'text': 'hi'} reaches the Anthropic payload without a 'type',
causing a 400 rejection.

Add the same missing-type check that _convert_user_content already has,
so bare dicts in assistant content lists are coerced to text blocks
instead of triggering API validation errors.

Co-authored-by: nanobot-issues <issues@nanobot.dev>
2026-06-27 16:47:59 +08:00
Xubin Ren d8601478db test: cover stream-id delta coalescing 2026-06-27 16:47:53 +08:00
axelray-devandXubin Ren 66fc54421c fix: include _stream_id in stream delta coalescing key (#4063)
ChannelManager coalesces _stream_delta messages by (channel, chat_id)
only. Overlapping streams in the same chat can be merged incorrectly
because deltas from distinct _stream_id values share one buffer.

Include _stream_id in the coalescing key so distinct streams in the
same channel and chat are delivered separately.
2026-06-27 16:47:53 +08:00
Xubin Ren 6a27c26257 test: cover non-stream duplicate tool call ids 2026-06-27 16:47:48 +08:00
axelray-devandXubin Ren 3ca82ea880 fix: deduplicate tool call IDs in non-stream parser (#4059)
Duplicate tool call ID normalization exists in the streaming parser path
but is not shared with the non-stream parser. Non-stream parsing appends
raw provider IDs into ToolCallRequest objects without deduplication.

Some OpenAI-compatible providers reuse the same tool_call_id for parallel
tool calls in non-streaming responses. Without dedup, runner executes
both tools with the same ID, producing duplicate tool results with the
same tool_call_id, which can fail strict provider validation.

Add the same _seen_tc_ids dedup pattern used in _parse_chunks to the
_parse method so both paths handle duplicate IDs consistently.
2026-06-27 16:47:48 +08:00
r4sk1nandXubin Ren 47dcc61e9b test(agent): fix flaky test_keeps_n_most_recent by ensuring sequential mtimes 2026-06-27 16:29:31 +08:00
axelray-devandXubin Ren 2bf111f456 fix(exec): remove ad-hoc shell comment stripping from _guard_command
- Removes match_text regex that stripped # comments before pattern matching
(broke on quoted # inside strings)
- allow_patterns now run re.fullmatch against the full lowercased command
- deny_patterns search the original lowercased command
- Replaces comment-stripping test with comment-tail bypass regression
(touch canary # echo allowlisted must be blocked)
- Adds Re-bin regression for quoted hash + blocked command
(echo "#" followed by blocked command must be caught)
- All 10 tests pass

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-27 11:11:46 +08:00
axelray-devandXubin Ren aa6c1bf300 fix(exec): prevent allowPatterns bypass via chained commands and shell comments 2026-06-27 11:11:46 +08:00
chengyongruandXubin Ren 5281e67222 fix(docker): repair whatsapp image build 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren dbb53109f4 build(docker): remove node from runtime image 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren b015515f30 docs(security): remove whatsapp bridge wording 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren be88e14424 docs(security): trim whatsapp migration note 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren 9e490ef473 docs(readme): restore changelog wording 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren bfb4246659 fix(docker): limit whatsapp bridge removal 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren fbf96a3502 fix(whatsapp): add bridge migration compatibility 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren 2a9e288dfe refactor(whatsapp): replace bridge with neonize 2026-06-27 11:05:03 +08:00
chengyongruandXubin Ren 3460ca3cb9 fix(agent): gate microcompaction on context pressure
Extract model-facing context governance from AgentRunner.

Only compact in-flight tool results when the model request is over budget, keep compacted IDs stable within a turn, and allow the newest result to be compacted as a last resort when it is the remaining source of overflow.
2026-06-27 11:04:41 +08:00
chengyongruandXubin Ren 9b45fc1172 fix(session): remove message time replay prefixes 2026-06-27 11:04:36 +08:00
chengyongruandXubin Ren 8656549129 test: cover exec login default public path
maintainer edit: add a regression test for the public ExecTool.execute path so omitted login stays non-login by default, and update the Unix environment docstring to match the new explicit login behavior.
2026-06-27 11:04:31 +08:00
axelray-devandXubin Ren 4c1f127549 test: update login-shell assertion for new default=False 2026-06-27 11:04:31 +08:00
axelray-devandXubin Ren 13c951aa41 fix: change exec login-shell default from true to false (#4518)
The exec tool defaults login=True for bash/zsh, which causes the shell
to source ~/.bash_profile and similar startup files. This reintroduces
secrets from shell startup files into the exec environment, even though
_build_env() intentionally starts with a curated environment.

Change the default to login=False in both _prepare_command() and _spawn(),
and update the schema default accordingly.
2026-06-27 11:04:31 +08:00
chengyongruandXubin Ren cd1fb61eb5 ci: relax webui install lock check 2026-06-27 11:04:11 +08:00
chengyongruandXubin Ren e79cb816e3 ci: pin bun for webui job 2026-06-27 11:04:11 +08:00
chengyongruandXubin Ren 64901be67f test: harden webui and gateway checks 2026-06-27 11:04:11 +08:00
chengyongruandXubin Ren 9ce9d2235a docs: clarify heartbeat versus cron delivery 2026-06-27 11:04:08 +08:00
Xubin Ren 06d5495b60 docs: document subagent tool error behavior 2026-06-25 22:53:36 +08:00
axelray-devandXubin Ren 851a0ff50c fix: make subagent fail_on_tool_error configurable (#4198)
Add fail_on_tool_error to AgentDefaults and wire it through
AgentLoop -> SubagentManager -> AgentRunSpec.

Previously hardcoded to True in SubagentManager._run_subagent.
Now configurable via config.json with default True for backward
compatibility. When set to False, subagents can retry on minor
tool errors instead of immediately failing.

Changes:
- nanobot/config/schema.py: add fail_on_tool_error field (default True)
- nanobot/agent/subagent.py: accept and forward fail_on_tool_error
- nanobot/agent/loop.py: pass config through to SubagentManager
- tests/agent/test_subagent.py: add regression test

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-25 22:53:36 +08:00
Xubin Ren 3596ccf828 docs: explain custom provider thinking style 2026-06-25 22:53:15 +08:00
axelray-devandXubin Ren d1ae73a8a8 fix: add clear error message for invalid thinking_style values
Widen thinking_style from Literal to str | None and add a
@field_validator that produces a helpful error message listing
valid options when an invalid value is provided.

Addresses the review feedback on #4482.
2026-06-25 22:53:15 +08:00
axelray-devandXubin Ren c661012754 fix: coalesce None thinking_style to empty string in provider creation
ProviderConfig.thinking_style defaults to None (Optional field), but
create_dynamic_spec expects a string. Coalesce None to "" at all call
sites (factory.py, settings_api.py) and fix the test assertion to
expect None from the config default.
2026-06-25 22:53:15 +08:00
axelray-devandXubin Ren 0e19ea3062 fix: validate thinking_style against known values at config load time 2026-06-25 22:53:15 +08:00
axelray-devandXubin Ren ceae6d7b61 fix: allow custom provider to configure thinking style (#4429) 2026-06-25 22:53:15 +08:00
Xubin Ren 34f776b48b test(cli): lock disabled dream cursor advancement 2026-06-25 22:53:10 +08:00
axelray-devandXubin Ren f7b027a295 fix: only advance dream cursor when behind latest (#4242)
Address review: avoid overwriting cursor on every restart when Dream
is disabled. Now only advances if current cursor is behind the latest
position, so repeated restarts don't permanently skip entries.
2026-06-25 22:53:10 +08:00
axelray-devandXubin Ren 6c880a6691 fix: advance dream cursor when Dream is disabled to prevent prompt bloat (#4242)
When dream.enabled is false, the Dream cron job never runs, so the
dream cursor (.dream_cursor) stays at its initial value (0). This
causes read_recent_history_for_prompt() to treat every history entry
as unprocessed, injecting the full chat history into every system
prompt and growing without bound.

Fix: fast-forward the dream cursor to the latest history entry at
gateway startup when Dream is disabled.
2026-06-25 22:53:10 +08:00
Xubin Ren 4636c78100 test(webui): cover xiaomi mimo wav recording path 2026-06-25 22:52:55 +08:00
zpljd258andXubin Ren 28c8c89a42 fix(webui): convert WebM to WAV for Xiaomi MiMo ASR transcription
MiMo ASR (mimo-v2.5-asr) only accepts audio/wav, audio/mp3, and
audio/mpeg formats. Web browsers record in WebM/Opus by default,
causing the API to reject the payload with a transcription error.

This change adds a frontend WebM→WAV converter using the Web Audio API
(DecodeAudioData + PCM encoding) that activates only when the
configured transcription provider is 'xiaomi_mimo'. Other providers
are unaffected — they continue to receive the original browser format.

Tested and confirmed working on WebUI.
2026-06-25 22:52:55 +08:00
7899857201 refactor(cli): simplify onboard search-provider dispatch
- Extract _set_field_from_choices for the shared pick-and-set tail used by
  both the LLM and search provider handlers.
- Replace the single-entry _TYPED_FIELD_HANDLERS registry with a direct
  isinstance check in _resolve_field_handler (robust to renames, no
  class-name strings).
- Drop a redundant str() in the search default computation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:52:50 +08:00
9354b80a6e fix(cli): show search engines (incl. Keenable) in onboard wizard
The onboard wizard dispatched field handlers by bare field name, so
WebSearchConfig.provider was hijacked by the LLM-provider handler and
showed LLM providers instead of search engines. Keenable was also never
wired into the CLI wizard when it landed in the WebUI.

- Add a single source of truth for selectable search providers
  (SEARCH_PROVIDER_OPTIONS in web.py); WebUI settings now import it.
- Add a WebSearchConfig-aware search-provider picker to the wizard and
  resolve handlers by (model type, field name) so the LLM and search
  provider fields no longer collide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:52:50 +08:00
Max HsuandXubin Ren 638af123ba fix(dingtalk): preserve richText formatting and set HTTP client timeout
richText messages only kept rich_text_list items whose type was
"text", so bold/italic/inlineCode/pre segments were silently dropped;
a message made entirely of formatted segments produced empty content
and fell through to the "unsupported message type: richText" warning.
Now any item carrying text is kept (matching the SDK's own
get_text_list, which keys off the "text" field) and its type is mapped
to Markdown so the formatting survives into the agent's context.

Text and downloadCode within a rich-text item are handled
independently (the SDK treats them as separate via get_text_list /
get_image_list), so an item carrying both a caption and an attachment
no longer drops the file.

The shared httpx.AsyncClient was created without a timeout, so all
requests (including large file/image downloads) used httpx's 5s
default and hit ConnectTimeout/ReadTimeout on uploads. Set an explicit
httpx.Timeout (connect=10s, read/write=30s).

Adds regression tests for formatted-segment preservation, the
all-formatted no-drop case, the text+downloadCode item, and the client
timeout configuration.

Closes #4497
2026-06-25 22:52:45 +08:00
michaelxerandXubin Ren 42aa37cfc0 docs: update enabledTools docs and schema comment to reflect resource/prompt gating
The enabledTools gate now also controls MCP resource and prompt
registration (not just tools). Update the configuration docs and
schema field comment to document this behavioral change.

Refs: #4435, #4436
2026-06-25 16:10:37 +08:00
michaelxerandXubin Ren 03302c751f log info when resources/prompts skipped due to enabledTools gate
Address chengyongru review: bump skip message from silent to logger.info
so operators get a visible trace when resources/prompts are not registered.
2026-06-25 16:10:37 +08:00
michaelxerandXubin Ren 246ea8ef61 fix(tools): gate MCP resource and prompt registration behind enabledTools
The enabledTools allowlist was only enforced for MCP tools returned by
session.list_tools(). Resources and prompts from session.list_resources()
and session.list_prompts() were registered unconditionally, allowing a
deny-all or restrictive enabledTools config to leak resource and prompt
capabilities to the model.

Now resources and prompts are only registered when allow_all_tools is
true (default ["*"] wildcard). Any explicit tool restriction — including
enabledTools: [] (deny-all) or a list of specific tool names — also
blocks resource and prompt registration from that server.

Fixes #4435
2026-06-25 16:10:37 +08:00
chengyongruandXubin Ren f60b3c7920 docs: explain Telegram rich messages opt-in
maintainer edit: document that richMessages defaults to false, when to enable it, and why Telegram Web users should leave it disabled.
2026-06-25 16:10:33 +08:00
chengyongruandXubin Ren e92899607a fix: make Telegram rich messages opt in
maintainer edit: Telegram Web cannot render sendRichMessage payloads, so keep the rich path available only for explicit opt-in instead of enabling it by default.
2026-06-25 16:10:33 +08:00
axelray-devandXubin Ren c930aa3713 fix: add rich_messages config to disable sendRichMessage for Telegram Web (#4488) 2026-06-25 16:10:33 +08:00
chengyongruandXubin Ren 4378944459 test: speed up test suite 2026-06-25 16:10:28 +08:00
chengyongruandXubin Ren 123384975e fix(webui): restore code block copy fallback 2026-06-25 16:10:23 +08:00
103 changed files with 4779 additions and 2185 deletions
+30 -2
View File
@@ -44,10 +44,38 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential
- name: Install dependencies - name: Install dependencies
run: uv sync --all-extras run: uv sync --all-extras --dev
- name: Lint with ruff - name: Lint with ruff
run: uv run ruff check nanobot --select F run: uv run ruff check nanobot --select F
- name: Run tests - name: Run tests
run: uv run pytest tests/ run: uv run python -m pytest tests/ --cov=nanobot --cov-report=term-missing:skip-covered
webui:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.6
- name: Install WebUI dependencies
working-directory: webui
run: bun install
- name: Lint WebUI
working-directory: webui
run: bun run lint
- name: Test WebUI
working-directory: webui
run: bun run test
- name: Build WebUI
working-directory: webui
run: bun run build
-1
View File
@@ -41,7 +41,6 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability. - **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`). - **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility. - **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway. - **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access. - **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers. - **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
+15 -22
View File
@@ -1,15 +1,16 @@
FROM node:24-bookworm-slim AS webui-builder
WORKDIR /app
COPY webui/package.json webui/package-lock.json ./webui/
WORKDIR /app/webui
RUN npm ci
COPY webui/ ./
RUN mkdir -p /app/nanobot/web && npm run build
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# Install Node.js for the WhatsApp bridge
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg git bubblewrap openssh-client && \ apt-get install -y --no-install-recommends ca-certificates git bubblewrap openssh-client libmagic1 && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
apt-get update && \
apt-get install -y --no-install-recommends nodejs && \
apt-get purge -y gnupg && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -17,22 +18,14 @@ WORKDIR /app
# Install Python dependencies first (cached layer). Hatch reads the custom build # Install Python dependencies first (cached layer). Hatch reads the custom build
# hook from hatch_build.py even for this metadata-only install. # hook from hatch_build.py even for this metadata-only install.
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./ COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \ RUN mkdir -p nanobot && touch nanobot/__init__.py && \
uv pip install --system --no-cache . && \ NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[whatsapp]" && \
rm -rf nanobot bridge rm -rf nanobot
# Copy the full source and install # Copy the full source and install
COPY nanobot/ nanobot/ COPY nanobot/ nanobot/
COPY bridge/ bridge/ COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
COPY webui/ webui/ RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[whatsapp]"
RUN NANOBOT_FORCE_WEBUI_BUILD=1 uv pip install --system --no-cache .
# Build the WhatsApp bridge
WORKDIR /app/bridge
RUN git config --global --add url."https://github.com/".insteadOf ssh://git@github.com/ && \
git config --global --add url."https://github.com/".insteadOf git@github.com: && \
npm install && npm run build
WORKDIR /app
# Create non-root user and config directory # Create non-root user and config directory
RUN useradd -m -u 1000 -s /bin/bash nanobot && \ RUN useradd -m -u 1000 -s /bin/bash nanobot && \
+7 -16
View File
@@ -48,7 +48,7 @@ chmod 600 ~/.nanobot/config.json
}, },
"whatsapp": { "whatsapp": {
"enabled": true, "enabled": true,
"allowFrom": ["+1234567890"] "allowFrom": ["1234567890"]
} }
} }
} }
@@ -57,7 +57,7 @@ chmod 600 ~/.nanobot/config.json
**Security Notes:** **Security Notes:**
- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone. - In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone.
- Get your Telegram user ID from `@userinfobot` - Get your Telegram user ID from `@userinfobot`
- Use full phone numbers with country code for WhatsApp - Use WhatsApp sender IDs as full phone numbers with country code and no leading `+`
- Review access logs regularly for unauthorized access attempts - Review access logs regularly for unauthorized access attempts
### 3. Shell Command Execution ### 3. Shell Command Execution
@@ -109,10 +109,9 @@ File operations have path traversal protection, but:
- Timeouts are configured to prevent hanging requests - Timeouts are configured to prevent hanging requests
- Consider using a firewall to restrict outbound connections if needed - Consider using a firewall to restrict outbound connections if needed
**WhatsApp Bridge:** **WhatsApp:**
- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network) - Keep the neonize session database under `~/.nanobot/whatsapp-auth` secure (mode 0700).
- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js - Use `nanobot channels login whatsapp --force` to remove and recreate the local session database when rotating linked devices.
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
### 6. Dependency Security ### 6. Dependency Security
@@ -127,17 +126,9 @@ pip-audit
pip install --upgrade nanobot-ai pip install --upgrade nanobot-ai
``` ```
For Node.js dependencies (WhatsApp bridge):
```bash
cd bridge
npm audit
npm audit fix
```
**Important Notes:** **Important Notes:**
- Keep `litellm` updated to the latest version for security fixes - Keep `litellm` updated to the latest version for security fixes
- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability - Run `pip-audit` regularly, including optional channel dependencies such as `nanobot-ai[whatsapp]`
- Run `pip-audit` or `npm audit` regularly
- Subscribe to security advisories for nanobot and its dependencies - Subscribe to security advisories for nanobot and its dependencies
### 7. Production Deployment ### 7. Production Deployment
@@ -238,7 +229,7 @@ If you suspect a security breach:
✅ **Secure Communication** ✅ **Secure Communication**
- HTTPS for all external API calls - HTTPS for all external API calls
- TLS for Telegram API - TLS for Telegram API
- WhatsApp bridge: localhost-only binding + optional token auth - WhatsApp session secrets stay in the local session database
## Known Limitations ## Known Limitations
-26
View File
@@ -1,26 +0,0 @@
{
"name": "nanobot-whatsapp-bridge",
"version": "0.1.0",
"description": "WhatsApp bridge for nanobot using Baileys",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc && node dist/index.js"
},
"dependencies": {
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ws": "^8.17.1",
"qrcode-terminal": "^0.12.0",
"pino": "^9.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/ws": "^8.5.10",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=20.0.0"
}
}
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env node
/**
* nanobot WhatsApp Bridge
*
* This bridge connects WhatsApp Web to nanobot's Python backend
* via WebSocket. It handles authentication, message forwarding,
* and reconnection logic.
*
* Usage:
* npm run build && npm start
*
* Or with custom settings:
* BRIDGE_PORT=3001 AUTH_DIR=~/.nanobot/whatsapp npm start
*/
// Polyfill crypto for Baileys in ESM
import { webcrypto } from 'crypto';
if (!globalThis.crypto) {
(globalThis as any).crypto = webcrypto;
}
import { BridgeServer } from './server.js';
import { homedir } from 'os';
import { join } from 'path';
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
const TOKEN = process.env.BRIDGE_TOKEN?.trim();
if (!TOKEN) {
console.error('BRIDGE_TOKEN is required. Start the bridge via nanobot so it can provision a local secret automatically.');
process.exit(1);
}
console.log('🐈 nanobot WhatsApp Bridge');
console.log('========================\n');
const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('\n\nShutting down...');
await server.stop();
process.exit(0);
});
process.on('SIGTERM', async () => {
await server.stop();
process.exit(0);
});
// Start the server
server.start().catch((error) => {
console.error('Failed to start bridge:', error);
process.exit(1);
});
-155
View File
@@ -1,155 +0,0 @@
/**
* WebSocket server for Python-Node.js bridge communication.
* Security: binds to 127.0.0.1 only; requires BRIDGE_TOKEN auth; rejects browser Origin headers.
*/
import { WebSocketServer, WebSocket } from 'ws';
import { WhatsAppClient, InboundMessage } from './whatsapp.js';
interface SendCommand {
type: 'send';
to: string;
text: string;
}
interface SendMediaCommand {
type: 'send_media';
to: string;
filePath: string;
mimetype: string;
caption?: string;
fileName?: string;
}
type BridgeCommand = SendCommand | SendMediaCommand;
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
}
export class BridgeServer {
private wss: WebSocketServer | null = null;
private wa: WhatsAppClient | null = null;
private clients: Set<WebSocket> = new Set();
constructor(private port: number, private authDir: string, private token: string) {}
async start(): Promise<void> {
if (!this.token.trim()) {
throw new Error('BRIDGE_TOKEN is required');
}
// Bind to localhost only — never expose to external network
this.wss = new WebSocketServer({
host: '127.0.0.1',
port: this.port,
verifyClient: (info, done) => {
const origin = info.origin || info.req.headers.origin;
if (origin) {
console.warn(`Rejected WebSocket connection with Origin header: ${origin}`);
done(false, 403, 'Browser-originated WebSocket connections are not allowed');
return;
}
done(true);
},
});
console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
console.log('🔒 Token authentication enabled');
// Initialize WhatsApp client
this.wa = new WhatsAppClient({
authDir: this.authDir,
onMessage: (msg) => this.broadcast({ type: 'message', ...msg }),
onQR: (qr) => this.broadcast({ type: 'qr', qr }),
onStatus: (status) => this.broadcast({ type: 'status', status }),
});
// Handle WebSocket connections
this.wss.on('connection', (ws) => {
// Require auth handshake as first message
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
ws.once('message', (data) => {
clearTimeout(timeout);
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth' && msg.token === this.token) {
console.log('🔗 Python client authenticated');
this.setupClient(ws);
} else {
ws.close(4003, 'Invalid token');
}
} catch {
ws.close(4003, 'Invalid auth message');
}
});
});
// Connect to WhatsApp
await this.wa.connect();
}
private setupClient(ws: WebSocket): void {
this.clients.add(ws);
ws.on('message', async (data) => {
try {
const cmd = JSON.parse(data.toString()) as BridgeCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
console.error('Error handling command:', error);
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
}
});
ws.on('close', () => {
console.log('🔌 Python client disconnected');
this.clients.delete(ws);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
this.clients.delete(ws);
});
}
private async handleCommand(cmd: BridgeCommand): Promise<void> {
if (!this.wa) return;
if (cmd.type === 'send') {
await this.wa.sendMessage(cmd.to, cmd.text);
} else if (cmd.type === 'send_media') {
await this.wa.sendMedia(cmd.to, cmd.filePath, cmd.mimetype, cmd.caption, cmd.fileName);
}
}
private broadcast(msg: BridgeMessage): void {
const data = JSON.stringify(msg);
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
async stop(): Promise<void> {
// Close all client connections
for (const client of this.clients) {
client.close();
}
this.clients.clear();
// Close WebSocket server
if (this.wss) {
this.wss.close();
this.wss = null;
}
// Disconnect WhatsApp
if (this.wa) {
await this.wa.disconnect();
this.wa = null;
}
}
}
-3
View File
@@ -1,3 +0,0 @@
declare module 'qrcode-terminal' {
export function generate(text: string, options?: { small?: boolean }): void;
}
-360
View File
@@ -1,360 +0,0 @@
/**
* WhatsApp client wrapper using Baileys.
* Based on OpenClaw's working implementation.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
downloadMediaMessage,
extractMessageContent as baileysExtractMessageContent,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join, basename, resolve, sep } from 'path';
import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
export interface InboundMessage {
id: string;
sender: string;
pn: string;
participant?: string;
content: string;
timestamp: number;
isGroup: boolean;
isForwarded?: boolean;
wasMentioned?: boolean;
isReplyToBot?: boolean;
media?: string[];
}
export interface WhatsAppClientOptions {
authDir: string;
onMessage: (msg: InboundMessage) => void;
onQR: (qr: string) => void;
onStatus: (status: string) => void;
}
export class WhatsAppClient {
private sock: any = null;
private options: WhatsAppClientOptions;
private reconnecting = false;
constructor(options: WhatsAppClientOptions) {
this.options = options;
}
private normalizeJid(jid: string | undefined | null): string {
return (jid || '').trim().toLowerCase().replace(/:\d+(?=@)/g, '');
}
private selfJids(): Set<string> {
return new Set(
[this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid]
.map((jid) => this.normalizeJid(jid))
.filter(Boolean),
);
}
private messageContextInfos(msg: any): any[] {
const unwrapped = baileysExtractMessageContent(msg?.message);
const containers = [msg?.message, unwrapped];
const infos = containers.flatMap((message) => [
message?.extendedTextMessage?.contextInfo,
message?.imageMessage?.contextInfo,
message?.videoMessage?.contextInfo,
message?.documentMessage?.contextInfo,
message?.audioMessage?.contextInfo,
]);
return infos.filter(Boolean);
}
private botAddressing(msg: any): { wasMentioned: boolean; isReplyToBot: boolean } {
if (!msg?.key?.remoteJid?.endsWith('@g.us')) {
return { wasMentioned: false, isReplyToBot: false };
}
const selfIds = this.selfJids();
const contextInfos = this.messageContextInfos(msg);
const mentioned = contextInfos.flatMap((info) => (
Array.isArray(info?.mentionedJid) ? info.mentionedJid : []
));
const wasMentioned = mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid)));
const isReplyToBot = contextInfos.some((info) => {
const quotedParticipant = this.normalizeJid(info?.participant);
return Boolean(info?.stanzaId && quotedParticipant && selfIds.has(quotedParticipant));
});
return { wasMentioned, isReplyToBot };
}
private isForwarded(msg: any): boolean {
return this.messageContextInfos(msg).some((info) => Boolean(info?.isForwarded));
}
async connect(): Promise<void> {
const logger = pino({ level: 'silent' });
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
const { version } = await fetchLatestBaileysVersion();
console.log(`Using Baileys version: ${version.join('.')}`);
// Record startup time — messages older than this will be ignored
// to avoid replaying history on reconnect
const startupTimestamp = Math.floor(Date.now() / 1000);
// Create socket following OpenClaw's pattern
this.sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
version,
logger,
printQRInTerminal: false,
browser: ['nanobot', 'cli', VERSION],
syncFullHistory: false,
markOnlineOnConnect: false,
});
// Handle WebSocket errors
if (this.sock.ws && typeof this.sock.ws.on === 'function') {
this.sock.ws.on('error', (err: Error) => {
console.error('WebSocket error:', err.message);
});
}
// Handle connection updates
this.sock.ev.on('connection.update', async (update: any) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
// Display QR code in terminal
console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
qrcode.generate(qr, { small: true });
this.options.onQR(qr);
}
if (connection === 'close') {
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
this.options.onStatus('disconnected');
if (shouldReconnect && !this.reconnecting) {
this.reconnecting = true;
console.log('Reconnecting in 5 seconds...');
setTimeout(() => {
this.reconnecting = false;
this.connect();
}, 5000);
}
} else if (connection === 'open') {
console.log('✅ Connected to WhatsApp');
this.options.onStatus('connected');
}
});
// Save credentials on update
this.sock.ev.on('creds.update', saveCreds);
// Handle incoming messages
this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue;
if (msg.key.remoteJid === 'status@broadcast') continue;
// Drop messages older than startup time (avoid replaying history on reconnect)
const msgTimestamp = msg.messageTimestamp as number;
if (msgTimestamp && msgTimestamp < startupTimestamp) continue;
// Send read receipt (blue check) immediately
try {
await this.sock!.readMessages([msg.key]);
} catch (e) {
// Non-fatal: log but don't block message processing
console.error('Failed to send read receipt:', (e as Error).message);
}
const unwrapped = baileysExtractMessageContent(msg.message);
if (!unwrapped) continue;
const content = this.getTextContent(unwrapped);
let fallbackContent: string | null = null;
const mediaPaths: string[] = [];
if (unwrapped.imageMessage) {
fallbackContent = '[Image]';
const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.documentMessage) {
fallbackContent = '[Document]';
const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
unwrapped.documentMessage.fileName ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.videoMessage) {
fallbackContent = '[Video]';
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.audioMessage) {
fallbackContent = '[Voice Message]';
const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.contactMessage) {
// Single shared contact
const displayName = unwrapped.contactMessage.displayName || '';
const vcard = unwrapped.contactMessage.vcard || '';
fallbackContent = `[Contact: ${displayName}]\n${vcard}`;
} else if (unwrapped.contactsArrayMessage) {
// Multiple shared contacts
const vcards = unwrapped.contactsArrayMessage.contacts || [];
const parts = vcards.map((c: any) => {
const name = c.displayName || '';
const vc = c.vcard || '';
return `[Contact: ${name}]\n${vc}`;
});
fallbackContent = parts.join('\n\n');
}
const isForwarded = this.isForwarded(msg);
const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
if (!finalContent && mediaPaths.length === 0) continue;
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
const { wasMentioned, isReplyToBot } = this.botAddressing(msg);
this.options.onMessage({
id: msg.key.id || '',
sender: msg.key.remoteJid || '',
pn: msg.key.remoteJidAlt || '',
...(isGroup && msg.key.participant ? { participant: msg.key.participant } : {}),
content: finalContent,
timestamp: msg.messageTimestamp as number,
isGroup,
...(isForwarded ? { isForwarded } : {}),
...(isGroup ? { wasMentioned: wasMentioned || isReplyToBot, isReplyToBot } : {}),
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
});
}
});
}
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> {
try {
const mediaDir = join(this.options.authDir, '..', 'media');
await mkdir(mediaDir, { recursive: true });
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
let outFilename: string;
if (fileName) {
const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`;
} else {
const mime = mimetype || 'application/octet-stream';
const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
}
const filepath = resolve(mediaDir, outFilename);
if (!filepath.startsWith(resolve(mediaDir) + sep)) {
throw new Error(`Path traversal blocked: ${outFilename}`);
}
await writeFile(filepath, buffer);
return filepath;
} catch (err) {
console.error('Failed to download media:', err);
return null;
}
}
private getTextContent(message: any): string | null {
// Text message
if (message.conversation) {
return message.conversation;
}
// Extended text (reply, link preview)
if (message.extendedTextMessage?.text) {
return message.extendedTextMessage.text;
}
// Image with optional caption
if (message.imageMessage) {
return message.imageMessage.caption || '';
}
// Video with optional caption
if (message.videoMessage) {
return message.videoMessage.caption || '';
}
// Document with optional caption
if (message.documentMessage) {
return message.documentMessage.caption || '';
}
// Voice/Audio message
if (message.audioMessage) {
return `[Voice Message]`;
}
return null;
}
async sendMessage(to: string, text: string): Promise<void> {
if (!this.sock) {
throw new Error('Not connected');
}
await this.sock.sendMessage(to, { text });
}
async sendMedia(
to: string,
filePath: string,
mimetype: string,
caption?: string,
fileName?: string,
): Promise<void> {
if (!this.sock) {
throw new Error('Not connected');
}
const buffer = await readFile(filePath);
const category = mimetype.split('/')[0];
if (category === 'image') {
await this.sock.sendMessage(to, { image: buffer, caption: caption || undefined, mimetype });
} else if (category === 'video') {
await this.sock.sendMessage(to, { video: buffer, caption: caption || undefined, mimetype });
} else if (category === 'audio') {
await this.sock.sendMessage(to, { audio: buffer, mimetype });
} else {
const name = fileName || basename(filePath);
await this.sock.sendMessage(to, { document: buffer, mimetype, fileName: name });
}
}
async disconnect(): Promise<void> {
if (this.sock) {
this.sock.end(undefined);
this.sock = null;
}
}
}
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+33 -14
View File
@@ -79,6 +79,8 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
``` ```
> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file. > You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file.
>
> `richMessages` defaults to `false`. Set it to `true` only if your Telegram client supports Bot API 10.1 rich messages and you want richer markdown rendering; keep it disabled for Telegram Web, which may show unsupported-message errors for rich messages.
**3. Run** **3. Run**
@@ -301,9 +303,15 @@ nanobot gateway
<details> <details>
<summary><b>WhatsApp</b></summary> <summary><b>WhatsApp</b></summary>
Requires **Node.js ≥18**. Requires the WhatsApp optional dependencies:
**1. Link device** ```bash
pip install "nanobot-ai[whatsapp]"
# Source checkout:
python -m pip install -e ".[whatsapp]"
```
**1. Link device with QR**
```bash ```bash
nanobot channels login whatsapp nanobot channels login whatsapp
@@ -317,30 +325,41 @@ nanobot channels login whatsapp
"channels": { "channels": {
"whatsapp": { "whatsapp": {
"enabled": true, "enabled": true,
"allowFrom": ["+1234567890"] "allowFrom": ["1234567890"]
} }
} }
} }
``` ```
**3. Run** (two terminals) Optional session database path:
```json
{
"channels": {
"whatsapp": {
"databasePath": "~/.nanobot/whatsapp-auth/neonize.db"
}
}
}
```
**Migrating from the old bridge**
- Remove `bridgeUrl` and `bridgeToken`; WhatsApp no longer runs a local Node.js bridge.
- Re-run `nanobot channels login whatsapp`; old Baileys bridge auth data is not reused by neonize.
- Update `allowFrom` entries to the WhatsApp sender ID without a leading `+`.
**3. Run**
```bash ```bash
# Terminal 1
nanobot channels login whatsapp
# Terminal 2
nanobot gateway nanobot gateway
``` ```
> WhatsApp bridge updates are not applied automatically for existing installations. After upgrading nanobot, rebuild the local bridge with:
> `rm -rf ~/.nanobot/bridge && nanobot channels login whatsapp`
**Optional: static LID mappings** **Optional: static LID mappings**
Modern WhatsApp can deliver a sender's LID instead of their phone number. nanobot Modern WhatsApp can deliver a sender's LID instead of their phone number. nanobot
learns the LID→phone mapping at runtime (and reuses the ones the bridge persists on learns LID to phone mappings at runtime when both identifiers are present, but you
disk), but you can also seed mappings up front so the phone number resolves from the can also seed mappings up front so the phone number resolves from the
very first message: very first message:
```json ```json
@@ -348,7 +367,7 @@ very first message:
"channels": { "channels": {
"whatsapp": { "whatsapp": {
"enabled": true, "enabled": true,
"allowFrom": ["+1234567890"], "allowFrom": ["1234567890"],
"lidMappings": { "123456789012345": "1234567890" } "lidMappings": { "123456789012345": "1234567890" }
} }
} }
+6 -4
View File
@@ -57,18 +57,20 @@ Preset names come from the top-level `modelPresets` config. Switching is runtime
## Periodic Tasks ## Periodic Tasks
Periodic tasks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers results to your most recently active chat channel. If there are no active tasks, the heartbeat is skipped silently. Periodic background checks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers only results that pass the notification gate to your most recently active chat channel. If there are no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
Use heartbeat for recurring checks that should usually stay quiet. User-created cron jobs are different: they run as scheduled turns in the chat/session where they were created and normally deliver the result back to that channel.
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`): **Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
```markdown ```markdown
## Active Tasks ## Active Tasks
- Check weather forecast and send a summary - Check weather forecast and notify me only if storms are expected
- Scan inbox for urgent emails - Scan inbox for urgent emails and notify me if any are found
``` ```
The agent can also manage this file itself ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section. The agent can also manage this file itself - ask it to "add a periodic background check" or "check this periodically but only notify me if something changes" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section.
You can change the interval or disable the built-in heartbeat in `~/.nanobot/config.json`: You can change the interval or disable the built-in heartbeat in `~/.nanobot/config.json`:
+2 -2
View File
@@ -136,9 +136,9 @@ When `nanobot gateway` starts, it creates workspace-scoped cron storage at `<wor
- `dream`, when `agents.defaults.dream.enabled` is true; - `dream`, when `agents.defaults.dream.enabled` is true;
- `heartbeat`, when `gateway.heartbeat.enabled` is true. - `heartbeat`, when `gateway.heartbeat.enabled` is true.
Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends useful results to the most recently active chat target. Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends only useful/actionable results to the most recently active chat target. Routine "nothing changed" results are suppressed.
User-created reminders use the same cron service but are not the same as the protected heartbeat system job. User-created reminders use the same cron service but are not the same as the protected heartbeat system job. They run as scheduled turns in their origin chat/session and normally deliver the result back to that channel.
## Where to Go Next ## Where to Go Next
+44 -4
View File
@@ -985,6 +985,29 @@ Some OpenAI-compatible gateways expose request-body extensions such as vLLM guid
} }
``` ```
If a custom OpenAI-compatible endpoint exposes a provider-specific thinking toggle, set `thinkingStyle` so nanobot can translate `reasoningEffort` into the right request body. Supported styles are `thinking_type` (`{"thinking":{"type":"enabled"}}`), `enable_thinking` (`{"enable_thinking": true}`), and `reasoning_split` (`{"reasoning_split": true}`):
```json
{
"providers": {
"companyProxy": {
"apiKey": "${COMPANY_PROXY_API_KEY}",
"apiBase": "https://api.your-provider.com/v1",
"thinkingStyle": "enable_thinking"
}
},
"modelPresets": {
"company": {
"provider": "companyProxy",
"model": "served-model-name",
"reasoningEffort": "high"
}
}
}
```
Leave `thinkingStyle` unset unless the endpoint explicitly documents one of those wire formats. `extraBody` is still applied last, so advanced users can override the generated value.
</details> </details>
<a id="local-providers"></a> <a id="local-providers"></a>
@@ -1482,6 +1505,8 @@ Global settings that apply to all channels. Configure under the `channels` secti
} }
``` ```
Telegram `richMessages` defaults to `false`. Enable it only to opt in to Bot API 10.1 `sendRichMessage` rendering; leave it disabled for Telegram Web clients that show unsupported-message errors for rich messages.
### Retry Behavior ### Retry Behavior
Retry is intentionally simple. Retry is intentionally simple.
@@ -1827,9 +1852,9 @@ Use `enabledTools` to register only a subset of tools from an MCP server:
`enabledTools` accepts either the raw MCP tool name (for example `read_file`) or the wrapped nanobot tool name (for example `mcp_filesystem_write_file`). `enabledTools` accepts either the raw MCP tool name (for example `read_file`) or the wrapped nanobot tool name (for example `mcp_filesystem_write_file`).
- Omit `enabledTools`, or set it to `["*"]`, to register all tools. - Omit `enabledTools`, or set it to `["*"]`, to register all capabilities (tools, resources, and prompts).
- Set `enabledTools` to `[]` to register no tools from that server. - Set `enabledTools` to `[]` to register no tools from that server. Resources and prompts are also skipped, since they have no per-name filter.
- Set `enabledTools` to a non-empty list of names to register only that subset. - Set `enabledTools` to a non-empty list of names to register only those tools — resources and prompts are not registered.
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed. MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
@@ -1938,7 +1963,9 @@ The gateway can run a protected heartbeat cron job that periodically checks `HEA
} }
``` ```
If `HEARTBEAT.md` has tasks under `## Active Tasks`, the agent executes them and delivers useful results to the most recently active chat target. If the file has no active tasks, the heartbeat is skipped silently. If `HEARTBEAT.md` has tasks under `## Active Tasks`, the agent executes them and sends only useful/actionable results to the most recently active chat target. If the file has no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
This is intentionally different from user-created cron jobs. A cron job created with the `cron` tool runs as a scheduled turn in its origin chat/session and normally delivers the result back to that channel. Use `HEARTBEAT.md` for recurring background checks that should not notify the user on every run.
The heartbeat job is backed by the same cron service as user-created reminders. It is stored under the active workspace (`<workspace>/cron/jobs.json`) and shows up in `cron(action="list")` as `heartbeat`, but it is system-managed and cannot be removed with the `cron` tool. Disable it through config and restart the gateway if you do not want periodic heartbeat checks. The heartbeat job is backed by the same cron service as user-created reminders. It is stored under the active workspace (`<workspace>/cron/jobs.json`) and shows up in `cron(action="list")` as `heartbeat`, but it is system-managed and cannot be removed with the `cron` tool. Disable it through config and restart the gateway if you do not want periodic heartbeat checks.
@@ -1963,9 +1990,22 @@ By default, nanobot only allows one spawned subagent at a time. When the limit i
} }
``` ```
Subagents also stop immediately when one of their tools returns an execution error. That default keeps failures visible to the parent agent. If your subagent workflows use tools that can fail transiently and should be retried or worked around by the model, disable hard-stop behavior:
```json
{
"agents": {
"defaults": {
"failOnToolError": false
}
}
}
```
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `agents.defaults.maxConcurrentSubagents` | `1` | Maximum number of spawned subagents that may run at the same time. Attempts to spawn beyond this limit return an error. | | `agents.defaults.maxConcurrentSubagents` | `1` | Maximum number of spawned subagents that may run at the same time. Attempts to spawn beyond this limit return an error. |
| `agents.defaults.failOnToolError` | `true` | Stop a spawned subagent when a tool execution fails. Set to `false` to return tool errors to the subagent model so it can recover within the same run. |
## Auto Compact ## Auto Compact
+2
View File
@@ -293,6 +293,8 @@ If you have more than one custom OpenAI-compatible endpoint, give each endpoint
Custom provider keys are treated as direct OpenAI-compatible providers. `apiBase` is required because nanobot cannot know the endpoint URL. `apiKey` is optional for local servers or private proxies that do not require one. Choose a name that does not conflict with a built-in provider name or alias, such as `openai`, `openai-codex`, `github-copilot`, or `lm-studio`. Do not set `apiType` on custom provider keys; `apiType` is only for `providers.openai`. Custom provider keys are treated as direct OpenAI-compatible providers. `apiBase` is required because nanobot cannot know the endpoint URL. `apiKey` is optional for local servers or private proxies that do not require one. Choose a name that does not conflict with a built-in provider name or alias, such as `openai`, `openai-codex`, `github-copilot`, or `lm-studio`. Do not set `apiType` on custom provider keys; `apiType` is only for `providers.openai`.
If your custom endpoint documents a nonstandard thinking toggle, set `providers.<name>.thinkingStyle` to `thinking_type`, `enable_thinking`, or `reasoning_split`; nanobot then maps `reasoningEffort` onto that provider-specific request body. Leave it unset for ordinary OpenAI-compatible endpoints.
This named custom provider path is not for Anthropic-compatible endpoints. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` and set the preset provider to `anthropic`. This named custom provider path is not for Anthropic-compatible endpoints. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` and set the preset provider to `anthropic`.
### Ollama ### Ollama
+2 -3
View File
@@ -326,11 +326,10 @@ python -m pip install -e .
nanobot --version nanobot --version
``` ```
If you use WhatsApp, rebuild the local bridge after upgrading: If you use WhatsApp from a source checkout, keep the optional dependencies installed:
```bash ```bash
rm -rf ~/.nanobot/bridge python -m pip install -e ".[whatsapp]"
nanobot channels login whatsapp
``` ```
## First-Run Troubleshooting ## First-Run Troubleshooting
+6 -1
View File
@@ -118,7 +118,12 @@ to perform that task.
Automations are scheduled agent turns. They should be created from the chat, Automations are scheduled agent turns. They should be created from the chat,
channel, or session where they are supposed to run so nanobot keeps the correct channel, or session where they are supposed to run so nanobot keeps the correct
target context. target context. When an automation runs, it normally delivers the result back to
that linked chat.
For recurring background checks that should stay quiet unless there is something
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
instead of creating a chat automation.
Use the Automations view to: Use the Automations view to:
+391
View File
@@ -0,0 +1,391 @@
"""Model-message governance for agent runner requests.
This module owns model-facing message shaping and tool-result content normalization.
It may return copied messages or persisted-result placeholders, but it must not
mutate an existing session history list in place.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.utils.helpers import (
estimate_message_tokens,
estimate_prompt_tokens_chain,
find_legal_message_start,
maybe_persist_tool_result,
truncate_text,
)
from nanobot.utils.runtime import ensure_nonempty_tool_result
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
SNIP_SAFETY_BUFFER = 1024
MICROCOMPACT_KEEP_RECENT = 10
MICROCOMPACT_MIN_CHARS = 500
INFLIGHT_COMPACT_TARGET_RATIO = 0.85
COMPACTABLE_TOOLS = frozenset({
"read_file", "exec", "grep", "find_files",
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
})
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
@dataclass(slots=True)
class ContextGovernanceConfig:
provider: LLMProvider
model: str
tools: Any
workspace: Path | None
session_key: str | None
max_tool_result_chars: int
context_window_tokens: int | None = None
context_block_limit: int | None = None
max_tokens: int | None = None
inflight_start_index: int = 0
class ContextGovernor:
"""Prepare model-copy messages while preserving persisted history."""
def prepare_for_model(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[dict[str, Any]]:
updated = self.drop_orphan_tool_results(messages)
updated = self.backfill_missing_tool_results(updated)
updated = self.apply_tool_result_budget(config, updated)
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
updated = self.snip_history(config, updated)
updated = self.drop_orphan_tool_results(updated)
return self.backfill_missing_tool_results(updated)
@staticmethod
def input_budget(config: ContextGovernanceConfig) -> int:
if not config.context_window_tokens:
return 0
provider_max_tokens = getattr(
getattr(config.provider, "generation", None),
"max_tokens",
4096,
)
max_output = config.max_tokens if isinstance(config.max_tokens, int) else (
provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
)
budget = config.context_block_limit or (
config.context_window_tokens - max_output - SNIP_SAFETY_BUFFER
)
return budget if budget > 0 else 0
@staticmethod
def normalize_tool_result(
config: ContextGovernanceConfig,
tool_call_id: str,
tool_name: str,
result: Any,
) -> Any:
result = ensure_nonempty_tool_result(tool_name, result)
if tool_name in TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS:
return result
try:
content = maybe_persist_tool_result(
config.workspace,
config.session_key,
tool_call_id,
result,
max_chars=config.max_tool_result_chars,
)
except Exception:
logger.exception(
"Tool result persist failed for {} in {}; using raw result",
tool_call_id,
config.session_key or "default",
)
content = result
if isinstance(content, str) and len(content) > config.max_tool_result_chars:
return truncate_text(content, config.max_tool_result_chars)
return content
@staticmethod
def drop_orphan_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Drop tool results that have no matching assistant tool_call earlier in history."""
declared: set[str] = set()
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
declared.add(str(tc["id"]))
if role == "tool":
tid = msg.get("tool_call_id")
if tid and str(tid) not in declared:
if updated is None:
updated = [dict(m) for m in messages[:idx]]
continue
if updated is not None:
updated.append(dict(msg))
if updated is None:
return messages
return updated
@staticmethod
def backfill_missing_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Insert synthetic error results for assistant tool_calls with missing tool outputs."""
declared: list[tuple[int, str, str]] = []
fulfilled: set[str] = set()
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
name = ""
func = tc.get("function")
if isinstance(func, dict):
name = func.get("name", "")
declared.append((idx, str(tc["id"]), name))
elif role == "tool":
tid = msg.get("tool_call_id")
if tid:
fulfilled.add(str(tid))
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
if not missing:
return messages
updated = list(messages)
offset = 0
for assistant_idx, call_id, name in missing:
insert_at = assistant_idx + 1 + offset
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
insert_at += 1
updated.insert(insert_at, {
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": BACKFILL_CONTENT,
})
offset += 1
return updated
def apply_tool_result_budget(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
updated = messages
for idx, message in enumerate(messages):
if message.get("role") != "tool":
continue
normalized = self.normalize_tool_result(
config,
str(message.get("tool_call_id") or f"tool_{idx}"),
str(message.get("name") or "tool"),
message.get("content"),
)
if normalized != message.get("content"):
if updated is messages:
updated = [dict(m) for m in messages]
updated[idx]["content"] = normalized
return updated
def compact_inflight_overflow(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[dict[str, Any]]:
"""Compact in-flight tool results only when the request would overflow."""
budget = self.input_budget(config)
if budget <= 0:
return messages
tools = config.tools.get_definitions()
updated = self._apply_recorded_compactions(messages, compacted_tool_call_ids)
estimate, source = estimate_prompt_tokens_chain(
config.provider,
config.model,
updated,
tools,
)
if estimate <= budget:
return updated
target = int(budget * INFLIGHT_COMPACT_TARGET_RATIO)
candidates = self._inflight_compaction_candidates(
config,
updated,
compacted_tool_call_ids,
)
if not candidates:
return updated
for candidate_idx, (idx, tool_call_id) in enumerate(candidates):
is_newest_candidate = candidate_idx == len(candidates) - 1
if is_newest_candidate and estimate <= budget:
break
if tool_call_id in compacted_tool_call_ids:
continue
if updated is messages:
updated = [dict(m) for m in messages]
compacted_tool_call_ids.add(tool_call_id)
self._compact_tool_result_at(updated, idx)
estimate, source = estimate_prompt_tokens_chain(
config.provider,
config.model,
updated,
tools,
)
if estimate <= target:
break
logger.debug(
"In-flight context compaction for {}: prompt={} budget={} target={} via {}, ids={}",
config.session_key or "default",
estimate,
budget,
target,
source,
len(compacted_tool_call_ids),
)
return updated
def snip_history(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if not messages or not config.context_window_tokens:
return messages
budget = self.input_budget(config)
if budget <= 0:
return messages
tools = config.tools.get_definitions()
estimate, _ = estimate_prompt_tokens_chain(
config.provider,
config.model,
messages,
tools,
)
if estimate <= budget:
return messages
system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
if not non_system:
return messages
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
fixed_tokens, _ = estimate_prompt_tokens_chain(
config.provider,
config.model,
system_messages,
tools,
)
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
kept: list[dict[str, Any]] = []
kept_tokens = 0
for message in reversed(non_system):
msg_tokens = estimate_message_tokens(message)
if kept and kept_tokens + msg_tokens > remaining_budget:
break
kept.append(message)
kept_tokens += msg_tokens
kept.reverse()
return system_messages + self._legal_history_tail(kept, non_system)
@staticmethod
def _summary_for(message: dict[str, Any]) -> str:
name = message.get("name", "tool")
return f"[{name} result omitted from context]"
def _legal_history_tail(
self,
kept: list[dict[str, Any]],
non_system: list[dict[str, Any]],
) -> list[dict[str, Any]]:
fallback = kept if kept else (non_system[-1:] if non_system else [])
kept = self._user_tail(kept) or self._user_tail(non_system, last=True) or fallback
start = find_legal_message_start(kept)
return kept[start:] if start else kept
@staticmethod
def _user_tail(messages: list[dict[str, Any]], *, last: bool = False) -> list[dict[str, Any]]:
indexes = range(len(messages) - 1, -1, -1) if last else range(len(messages))
for idx in indexes:
if messages[idx].get("role") == "user":
return messages[idx:]
return []
def _apply_recorded_compactions(
self,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[dict[str, Any]]:
if not compacted_tool_call_ids:
return messages
updated = messages
for idx, msg in enumerate(messages):
if msg.get("role") != "tool":
continue
tool_call_id = msg.get("tool_call_id")
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
continue
summary = self._summary_for(msg)
if msg.get("content") == summary:
continue
if updated is messages:
updated = [dict(m) for m in messages]
updated[idx]["content"] = summary
return updated
def _inflight_compaction_candidates(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[tuple[int, str]]:
compactable: list[tuple[int, str]] = []
for idx, msg in enumerate(messages):
if idx < config.inflight_start_index:
continue
if msg.get("role") != "tool" or msg.get("name") not in COMPACTABLE_TOOLS:
continue
tool_call_id = msg.get("tool_call_id")
if not tool_call_id or str(tool_call_id) in compacted_tool_call_ids:
continue
content = msg.get("content")
if not isinstance(content, str) or len(content) < MICROCOMPACT_MIN_CHARS:
continue
compactable.append((idx, str(tool_call_id)))
if not compactable:
return []
primary_count = max(0, len(compactable) - MICROCOMPACT_KEEP_RECENT)
primary = compactable[:primary_count]
# Hard overflow beats the keep-recent preference. Return recent results
# after stale ones so the newest result is naturally last.
fallback = compactable[primary_count:]
return primary + fallback
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
messages[idx]["content"] = self._summary_for(messages[idx])
+3 -2
View File
@@ -190,6 +190,7 @@ class AgentLoop:
context_window_tokens: int | None = None, context_window_tokens: int | None = None,
context_block_limit: int | None = None, context_block_limit: int | None = None,
max_tool_result_chars: int | None = None, max_tool_result_chars: int | None = None,
fail_on_tool_error: bool | None = None,
provider_retry_mode: str = "standard", provider_retry_mode: str = "standard",
tool_hint_max_length: int | None = None, tool_hint_max_length: int | None = None,
cron_service: CronService | None = None, cron_service: CronService | None = None,
@@ -287,6 +288,7 @@ class AgentLoop:
disabled_skills=disabled_skills, disabled_skills=disabled_skills,
max_iterations=self.max_iterations, max_iterations=self.max_iterations,
max_concurrent_subagents=max_concurrent_subagents, max_concurrent_subagents=max_concurrent_subagents,
fail_on_tool_error=fail_on_tool_error,
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk), llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
) )
self._unified_session = unified_session self._unified_session = unified_session
@@ -377,6 +379,7 @@ class AgentLoop:
context_window_tokens=context_window_tokens, context_window_tokens=context_window_tokens,
context_block_limit=defaults.context_block_limit, context_block_limit=defaults.context_block_limit,
max_tool_result_chars=defaults.max_tool_result_chars, max_tool_result_chars=defaults.max_tool_result_chars,
fail_on_tool_error=defaults.fail_on_tool_error,
provider_retry_mode=defaults.provider_retry_mode, provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length, tool_hint_max_length=defaults.tool_hint_max_length,
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
@@ -1180,7 +1183,6 @@ class AgentLoop:
_hist_kwargs: dict[str, Any] = { _hist_kwargs: dict[str, Any] = {
"max_messages": self._max_messages, "max_messages": self._max_messages,
"max_tokens": self._replay_token_budget(), "max_tokens": self._replay_token_budget(),
"include_timestamps": True,
"extend_to_user": is_subagent, "extend_to_user": is_subagent,
} }
history = session.get_history(**_hist_kwargs) history = session.get_history(**_hist_kwargs)
@@ -1459,7 +1461,6 @@ class AgentLoop:
_hist_kwargs: dict[str, Any] = { _hist_kwargs: dict[str, Any] = {
"max_messages": self._max_messages, "max_messages": self._max_messages,
"max_tokens": self._replay_token_budget(), "max_tokens": self._replay_token_budget(),
"include_timestamps": True,
"extend_to_user": False, "extend_to_user": False,
} }
ctx.history = ctx.session.get_history(**_hist_kwargs) ctx.history = ctx.session.get_history(**_hist_kwargs)
+5 -7
View File
@@ -479,6 +479,9 @@ class MemoryStore:
def set_last_dream_cursor(self, cursor: int) -> None: def set_last_dream_cursor(self, cursor: int) -> None:
self._dream_cursor_file.write_text(str(cursor), encoding="utf-8") self._dream_cursor_file.write_text(str(cursor), encoding="utf-8")
def get_latest_cursor(self) -> int:
return max(self._next_cursor() - 1, 0)
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None: def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
"""Build the Dream prompt with unprocessed history context. """Build the Dream prompt with unprocessed history context.
@@ -709,17 +712,12 @@ class Consolidator:
@staticmethod @staticmethod
def _full_unconsolidated_history( def _full_unconsolidated_history(
session: Session, session: Session,
*,
include_timestamps: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Return the whole unconsolidated tail for consolidation decisions.""" """Return the whole unconsolidated tail for consolidation decisions."""
unconsolidated_count = len(session.messages) - session.last_consolidated unconsolidated_count = len(session.messages) - session.last_consolidated
if unconsolidated_count <= 0: if unconsolidated_count <= 0:
return [] return []
return session.get_history( return session.get_history(max_messages=unconsolidated_count)
max_messages=unconsolidated_count,
include_timestamps=include_timestamps,
)
@staticmethod @staticmethod
def _replay_overflow_boundary( def _replay_overflow_boundary(
@@ -794,7 +792,7 @@ class Consolidator:
session: Session, session: Session,
) -> tuple[int, str]: ) -> tuple[int, str]:
"""Estimate prompt size from the full unconsolidated session tail.""" """Estimate prompt size from the full unconsolidated session tail."""
history = self._full_unconsolidated_history(session, include_timestamps=True) history = self._full_unconsolidated_history(session)
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None)) channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
# Include archived summary in estimation so the budget accounts for it. # Include archived summary in estimation so the budget accounts for it.
meta = session.metadata.get("_last_summary") meta = session.metadata.get("_last_summary")
+85 -246
View File
@@ -13,6 +13,10 @@ from typing import Any, Callable
from loguru import logger from loguru import logger
from nanobot.agent.context_governance import (
ContextGovernanceConfig,
ContextGovernor,
)
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
@@ -32,11 +36,8 @@ from nanobot.utils.helpers import (
estimate_message_tokens, estimate_message_tokens,
estimate_prompt_tokens_chain, estimate_prompt_tokens_chain,
extract_reasoning, extract_reasoning,
find_legal_message_start,
maybe_persist_tool_result,
strip_reasoning_tags, strip_reasoning_tags,
strip_think, strip_think,
truncate_text,
) )
from nanobot.utils.progress_events import ( from nanobot.utils.progress_events import (
invoke_file_edit_progress, invoke_file_edit_progress,
@@ -49,7 +50,7 @@ from nanobot.utils.runtime import (
build_finalization_retry_message, build_finalization_retry_message,
build_goal_continue_message, build_goal_continue_message,
build_length_recovery_message, build_length_recovery_message,
ensure_nonempty_tool_result, build_runtime_budget_notice_message,
is_blank_text, is_blank_text,
repeated_external_lookup_error, repeated_external_lookup_error,
repeated_workspace_violation_error, repeated_workspace_violation_error,
@@ -67,17 +68,7 @@ _MAX_EMPTY_RETRIES = 2
_MAX_LENGTH_RECOVERIES = 3 _MAX_LENGTH_RECOVERIES = 3
_MAX_INJECTIONS_PER_TURN = 3 _MAX_INJECTIONS_PER_TURN = 3
_MAX_INJECTION_CYCLES = 5 _MAX_INJECTION_CYCLES = 5
_SNIP_SAFETY_BUFFER = 1024 _BUDGET_NOTICE_MIN_ITERATIONS = 20
_MICROCOMPACT_KEEP_RECENT = 10
_MICROCOMPACT_MIN_CHARS = 500
_COMPACTABLE_TOOLS = frozenset({
"read_file", "exec", "grep", "find_files",
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
})
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
_TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
# Backward-compatible module attribute for tests/extensions that monkeypatch # Backward-compatible module attribute for tests/extensions that monkeypatch
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers. # the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
prepare_file_edit_tracker = _prepare_file_edit_tracker prepare_file_edit_tracker = _prepare_file_edit_tracker
@@ -135,6 +126,7 @@ class AgentRunner:
def __init__(self, provider: LLMProvider): def __init__(self, provider: LLMProvider):
self.provider = provider self.provider = provider
self.context_governor = ContextGovernor()
@staticmethod @staticmethod
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]: def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
@@ -367,6 +359,20 @@ class AgentRunner:
length_recovery_count = 0 length_recovery_count = 0
had_injections = False had_injections = False
injection_cycles = 0 injection_cycles = 0
budget_notice_level_sent = 0
compacted_tool_call_ids: set[str] = set()
governance_config = ContextGovernanceConfig(
provider=self.provider,
model=spec.model,
tools=spec.tools,
workspace=spec.workspace,
session_key=spec.session_key,
max_tool_result_chars=spec.max_tool_result_chars,
context_window_tokens=spec.context_window_tokens,
context_block_limit=spec.context_block_limit,
max_tokens=spec.max_tokens,
inflight_start_index=len(spec.initial_messages),
)
for iteration in range(spec.max_iterations): for iteration in range(spec.max_iterations):
try: try:
@@ -374,14 +380,11 @@ class AgentRunner:
# may repair or compact historical messages for the model, but # may repair or compact historical messages for the model, but
# those synthetic edits must not shift the append boundary used # those synthetic edits must not shift the append boundary used
# later when the caller saves only the new turn. # later when the caller saves only the new turn.
messages_for_model = self._drop_orphan_tool_results(messages) messages_for_model = self.context_governor.prepare_for_model(
messages_for_model = self._backfill_missing_tool_results(messages_for_model) governance_config,
messages_for_model = self._microcompact(messages_for_model) messages,
messages_for_model = self._apply_tool_result_budget(spec, messages_for_model) compacted_tool_call_ids,
messages_for_model = self._snip_history(spec, messages_for_model) )
# Snipping may have created new orphans; clean them up.
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
except Exception: except Exception:
logger.exception( logger.exception(
"Context governance failed on turn {} for {}; applying minimal repair", "Context governance failed on turn {} for {}; applying minimal repair",
@@ -389,8 +392,10 @@ class AgentRunner:
spec.session_key or "default", spec.session_key or "default",
) )
try: try:
messages_for_model = self._drop_orphan_tool_results(messages) messages_for_model = ContextGovernor.drop_orphan_tool_results(messages)
messages_for_model = self._backfill_missing_tool_results(messages_for_model) messages_for_model = ContextGovernor.backfill_missing_tool_results(
messages_for_model
)
except Exception: except Exception:
messages_for_model = messages messages_for_model = messages
context = AgentHookContext( context = AgentHookContext(
@@ -463,8 +468,8 @@ class AgentRunner:
"role": "tool", "role": "tool",
"tool_call_id": tool_call.id, "tool_call_id": tool_call.id,
"name": tool_call.name, "name": tool_call.name,
"content": self._normalize_tool_result( "content": self.context_governor.normalize_tool_result(
spec, governance_config,
tool_call.id, tool_call.id,
tool_call.name, tool_call.name,
result, result,
@@ -509,6 +514,12 @@ class AgentRunner:
) )
if _drained: if _drained:
had_injections = True had_injections = True
budget_notice_level_sent = self._append_runtime_budget_notice_if_needed(
spec,
messages,
completed_iterations=iteration + 1,
sent_level=budget_notice_level_sent,
)
await hook.after_iteration(context) await hook.after_iteration(context)
continue continue
@@ -938,6 +949,53 @@ class AgentRunner:
retry_messages.append(build_budget_exhausted_finalization_message()) retry_messages.append(build_budget_exhausted_finalization_message())
return retry_messages return retry_messages
@classmethod
def _append_runtime_budget_notice_if_needed(
cls,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
*,
completed_iterations: int,
sent_level: int,
) -> int:
level = cls._runtime_budget_notice_level(
max_iterations=spec.max_iterations,
completed_iterations=completed_iterations,
)
if level <= sent_level:
return sent_level
remaining_iterations = max(0, spec.max_iterations - completed_iterations)
messages.append(build_runtime_budget_notice_message(
level=level,
max_iterations=spec.max_iterations,
used_iterations=completed_iterations,
remaining_iterations=remaining_iterations,
))
return level
@staticmethod
def _runtime_budget_notice_level(
*,
max_iterations: int,
completed_iterations: int,
) -> int:
"""Return the convergence-warning level for a long tool loop."""
if max_iterations < _BUDGET_NOTICE_MIN_ITERATIONS:
return 0
remaining_iterations = max_iterations - completed_iterations
if remaining_iterations <= 0:
return 0
convergence_threshold = max(5, (max_iterations + 9) // 10)
final_threshold = max(3, (max_iterations + 32) // 33)
if remaining_iterations <= final_threshold:
return 2
if remaining_iterations <= convergence_threshold:
return 1
return 0
@staticmethod @staticmethod
def _max_iterations_fallback(spec: AgentRunSpec) -> str: def _max_iterations_fallback(spec: AgentRunSpec) -> str:
if spec.max_iterations_message: if spec.max_iterations_message:
@@ -1334,225 +1392,6 @@ class AgentRunner:
return return
messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER)) messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER))
def _normalize_tool_result(
self,
spec: AgentRunSpec,
tool_call_id: str,
tool_name: str,
result: Any,
) -> Any:
result = ensure_nonempty_tool_result(tool_name, result)
if tool_name in _TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS:
# Exempt tools bound their own output; skip generic offload and truncation.
return result
try:
content = maybe_persist_tool_result(
spec.workspace,
spec.session_key,
tool_call_id,
result,
max_chars=spec.max_tool_result_chars,
)
except Exception:
logger.exception(
"Tool result persist failed for {} in {}; using raw result",
tool_call_id,
spec.session_key or "default",
)
content = result
if isinstance(content, str) and len(content) > spec.max_tool_result_chars:
return truncate_text(content, spec.max_tool_result_chars)
return content
@staticmethod
def _drop_orphan_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Drop tool results that have no matching assistant tool_call earlier in the history."""
declared: set[str] = set()
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
declared.add(str(tc["id"]))
if role == "tool":
tid = msg.get("tool_call_id")
if tid and str(tid) not in declared:
if updated is None:
updated = [dict(m) for m in messages[:idx]]
continue
if updated is not None:
updated.append(dict(msg))
if updated is None:
return messages
return updated
@staticmethod
def _backfill_missing_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Insert synthetic error results for orphaned tool_use blocks."""
declared: list[tuple[int, str, str]] = [] # (assistant_idx, call_id, name)
fulfilled: set[str] = set()
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
name = ""
func = tc.get("function")
if isinstance(func, dict):
name = func.get("name", "")
declared.append((idx, str(tc["id"]), name))
elif role == "tool":
tid = msg.get("tool_call_id")
if tid:
fulfilled.add(str(tid))
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
if not missing:
return messages
updated = list(messages)
offset = 0
for assistant_idx, call_id, name in missing:
insert_at = assistant_idx + 1 + offset
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
insert_at += 1
updated.insert(insert_at, {
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": _BACKFILL_CONTENT,
})
offset += 1
return updated
@staticmethod
def _microcompact(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Replace old compactable tool results with one-line summaries."""
compactable_indices: list[int] = []
for idx, msg in enumerate(messages):
if msg.get("role") == "tool" and msg.get("name") in _COMPACTABLE_TOOLS:
compactable_indices.append(idx)
if len(compactable_indices) <= _MICROCOMPACT_KEEP_RECENT:
return messages
stale = compactable_indices[: len(compactable_indices) - _MICROCOMPACT_KEEP_RECENT]
updated: list[dict[str, Any]] | None = None
for idx in stale:
msg = messages[idx]
content = msg.get("content")
if not isinstance(content, str) or len(content) < _MICROCOMPACT_MIN_CHARS:
continue
name = msg.get("name", "tool")
summary = f"[{name} result omitted from context]"
if updated is None:
updated = [dict(m) for m in messages]
updated[idx]["content"] = summary
return updated if updated is not None else messages
def _apply_tool_result_budget(
self,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
updated = messages
for idx, message in enumerate(messages):
if message.get("role") != "tool":
continue
normalized = self._normalize_tool_result(
spec,
str(message.get("tool_call_id") or f"tool_{idx}"),
str(message.get("name") or "tool"),
message.get("content"),
)
if normalized != message.get("content"):
if updated is messages:
updated = [dict(m) for m in messages]
updated[idx]["content"] = normalized
return updated
def _snip_history(
self,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if not messages or not spec.context_window_tokens:
return messages
provider_max_tokens = getattr(getattr(self.provider, "generation", None), "max_tokens", 4096)
max_output = spec.max_tokens if isinstance(spec.max_tokens, int) else (
provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
)
budget = spec.context_block_limit or (
spec.context_window_tokens - max_output - _SNIP_SAFETY_BUFFER
)
if budget <= 0:
return messages
estimate, _ = estimate_prompt_tokens_chain(
self.provider,
spec.model,
messages,
spec.tools.get_definitions(),
)
if estimate <= budget:
return messages
system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
if not non_system:
return messages
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
fixed_tokens, _ = estimate_prompt_tokens_chain(
self.provider,
spec.model,
system_messages,
spec.tools.get_definitions(),
)
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
kept: list[dict[str, Any]] = []
kept_tokens = 0
for message in reversed(non_system):
msg_tokens = estimate_message_tokens(message)
if kept and kept_tokens + msg_tokens > remaining_budget:
break
kept.append(message)
kept_tokens += msg_tokens
kept.reverse()
if kept:
for i, message in enumerate(kept):
if message.get("role") == "user":
kept = kept[i:]
break
else:
# Recover nearest user message from outside the kept window;
# GLM rejects system→assistant (error 1214). Budget is
# intentionally exceeded — oversized beats invalid.
for idx in range(len(non_system) - 1, -1, -1):
if non_system[idx].get("role") == "user":
kept = non_system[idx:]
break
# If no user exists at all, _enforce_role_alternation
# will insert a synthetic one as a safety net.
start = find_legal_message_start(kept)
if start:
kept = kept[start:]
if not kept:
kept = non_system[-min(len(non_system), 4) :]
start = find_legal_message_start(kept)
if start:
kept = kept[start:]
return system_messages + kept
def _partition_tool_batches( def _partition_tool_batches(
self, self,
spec: AgentRunSpec, spec: AgentRunSpec,
+7 -1
View File
@@ -86,6 +86,7 @@ class SubagentManager:
disabled_skills: list[str] | None = None, disabled_skills: list[str] | None = None,
max_iterations: int | None = None, max_iterations: int | None = None,
max_concurrent_subagents: int | None = None, max_concurrent_subagents: int | None = None,
fail_on_tool_error: bool | None = None,
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None, llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
): ):
defaults = AgentDefaults() defaults = AgentDefaults()
@@ -107,6 +108,11 @@ class SubagentManager:
if max_concurrent_subagents is not None if max_concurrent_subagents is not None
else defaults.max_concurrent_subagents else defaults.max_concurrent_subagents
) )
self.fail_on_tool_error = (
fail_on_tool_error
if fail_on_tool_error is not None
else defaults.fail_on_tool_error
)
self.runner = AgentRunner(provider) self.runner = AgentRunner(provider)
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
self._running_tasks: dict[str, asyncio.Task[None]] = {} self._running_tasks: dict[str, asyncio.Task[None]] = {}
@@ -251,7 +257,7 @@ class SubagentManager:
max_iterations_message="Task completed but no final response was generated.", max_iterations_message="Task completed but no final response was generated.",
finalize_on_max_iterations=False, finalize_on_max_iterations=False,
error_message=None, error_message=None,
fail_on_tool_error=True, fail_on_tool_error=self.fail_on_tool_error,
checkpoint_callback=_on_checkpoint, checkpoint_callback=_on_checkpoint,
session_key=sess_key, session_key=sess_key,
workspace=root, workspace=root,
+62 -9
View File
@@ -17,6 +17,13 @@ from nanobot.agent.tools.schema import (
StringSchema, StringSchema,
tool_parameters_schema, tool_parameters_schema,
) )
from nanobot.agent.verification_state import (
VerificationAnalysis,
analyze_verification_result,
append_verification_feedback,
record_verification_observation,
)
from nanobot.utils.helpers import build_structured_output_summary
DEFAULT_YIELD_MS = 1000 DEFAULT_YIELD_MS = 1000
MAX_YIELD_MS = 30_000 MAX_YIELD_MS = 30_000
@@ -37,6 +44,7 @@ class _SessionPoll:
terminated: bool = False terminated: bool = False
stdin_closed: bool = False stdin_closed: bool = False
truncated_chars: int = 0 truncated_chars: int = 0
analysis: VerificationAnalysis | None = None
@dataclass(slots=True) @dataclass(slots=True)
@@ -147,7 +155,19 @@ class _ExecSession:
output = "".join(self._chunks) output = "".join(self._chunks)
self._chunks.clear() self._chunks.clear()
output, truncated = _truncate_output(output, max_output_chars) analysis = analyze_verification_result(
command=self.command,
output=output,
exit_code=self.process.returncode,
timed_out=self._timed_out,
)
output, truncated = _truncate_output(
output,
max_output_chars,
analysis=analysis,
exit_code=self.process.returncode,
elapsed_s=max(0.0, time.monotonic() - self.started_at),
)
return _SessionPoll( return _SessionPoll(
output=output, output=output,
done=self.process.returncode is not None, done=self.process.returncode is not None,
@@ -157,6 +177,7 @@ class _ExecSession:
terminated=terminated, terminated=terminated,
stdin_closed=stdin_closed, stdin_closed=stdin_closed,
truncated_chars=truncated, truncated_chars=truncated,
analysis=analysis,
) )
async def kill(self) -> None: async def kill(self) -> None:
@@ -320,15 +341,33 @@ def clamp_session_int(value: int | None, default: int, minimum: int, maximum: in
return min(max(value, minimum), maximum) return min(max(value, minimum), maximum)
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]: def _truncate_output(
output: str,
max_output_chars: int,
*,
analysis: VerificationAnalysis | None = None,
exit_code: int | None = None,
elapsed_s: float | None = None,
) -> tuple[str, int]:
if len(output) <= max_output_chars: if len(output) <= max_output_chars:
return output, 0 return output, 0
half = max_output_chars // 2
omitted = len(output) - max_output_chars omitted = len(output) - max_output_chars
return ( return (
output[:half] build_structured_output_summary(
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n" "[tool output truncated]",
+ output[-half:], output,
max_chars=max_output_chars,
metadata=[
("original_size_chars", len(output)),
("exit_code", exit_code if exit_code is not None else "running"),
("elapsed_s", f"{elapsed_s:.1f}" if elapsed_s is not None else "unknown"),
],
analysis=analysis,
guidance=(
"Use the structured summary first. Poll again for new output "
"or rerun a narrower command instead of reading broad logs."
),
),
omitted, omitted,
) )
@@ -351,6 +390,20 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
return "\n".join(parts) if parts else "(no output yet)" return "\n".join(parts) if parts else "(no output yet)"
def _format_poll_with_verification(session_id: str, poll: _SessionPoll) -> str:
result = format_session_poll(session_id, poll)
if not poll.done:
return result
analysis = poll.analysis or analyze_verification_result(
command="",
output=result,
exit_code=poll.exit_code,
timed_out=poll.timed_out,
)
record_verification_observation(current_request_session_key(), analysis)
return append_verification_feedback(result, analysis)
@tool_parameters( @tool_parameters(
tool_parameters_schema( tool_parameters_schema(
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."), session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
@@ -492,7 +545,7 @@ class WriteStdinTool(Tool):
max_output_chars=output_limit, max_output_chars=output_limit,
owner_session_key=current_request_session_key(), owner_session_key=current_request_session_key(),
) )
return format_session_poll(session_id, poll) return _format_poll_with_verification(session_id, poll)
except KeyError: except KeyError:
return f"Error: exec session not found: {session_id}" return f"Error: exec session not found: {session_id}"
except Exception as exc: except Exception as exc:
@@ -532,10 +585,10 @@ class WriteStdinTool(Tool):
joined = "".join(aggregate) joined = "".join(aggregate)
if wait_for in joined: if wait_for in joined:
poll.output = joined poll.output = joined
return format_session_poll(session_id, poll) return _format_poll_with_verification(session_id, poll)
if poll.done or remaining_ms <= 0: if poll.done or remaining_ms <= 0:
poll.output = "".join(aggregate) poll.output = "".join(aggregate)
result = format_session_poll(session_id, poll) result = _format_poll_with_verification(session_id, poll)
if wait_for not in poll.output: if wait_for not in poll.output:
result += f"\nWait target not observed: {wait_for!r}" result += f"\nWait target not observed: {wait_for!r}"
return result return result
+67 -2
View File
@@ -23,6 +23,11 @@ from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.agent.verification_state import (
clear_verification_observation,
format_completion_gate_message,
latest_verification_observation,
)
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
from nanobot.session.goal_state import ( from nanobot.session.goal_state import (
GOAL_STATE_KEY, GOAL_STATE_KEY,
@@ -187,6 +192,29 @@ class LongTaskTool(Tool, _GoalToolsMixin):
max_length=8000, max_length=8000,
nullable=True, nullable=True,
), ),
verification_summary=StringSchema(
"For coding or file-producing tasks, summarize how the work was verified. "
"Mention the most relevant test/check command and whether it passed. "
"If no verification was possible, say why.",
max_length=4000,
nullable=True,
),
commands_run=StringSchema(
"Optional concise list of verification/build commands run before completion.",
max_length=4000,
nullable=True,
),
artifacts_created=StringSchema(
"Optional concise list of files, outputs, or artifacts created.",
max_length=4000,
nullable=True,
),
remaining_failures=StringSchema(
"Known unresolved failures, if intentionally stopping before success. "
"Leave empty when verification passes.",
max_length=4000,
nullable=True,
),
required=[], required=[],
) )
) )
@@ -222,30 +250,67 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
return ( return (
"End bookkeeping for the active sustained goal. " "End bookkeeping for the active sustained goal. "
"Use when the objective is fully achieved and verified—recap what was delivered. " "Use when the objective is fully achieved and verified—recap what was delivered. "
"For coding/file-producing tasks, run the smallest reliable verification first and include "
"verification_summary / commands_run / artifacts_created. "
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect " "Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
"what actually happened (not necessarily success). " "what actually happened (not necessarily success). "
"If recent verification failed and no later verification passed, this tool will ask you to "
"continue fixing unless remaining_failures describes an intentional incomplete stop. "
"If no goal is active, the tool reports that and leaves metadata unchanged." "If no goal is active, the tool reports that and leaves metadata unchanged."
) )
async def execute(self, recap: str | None = None, **kwargs: Any) -> str: async def execute(
self,
recap: str | None = None,
verification_summary: str | None = None,
commands_run: str | None = None,
artifacts_created: str | None = None,
remaining_failures: str | None = None,
**kwargs: Any,
) -> str:
sess = self._session() sess = self._session()
if sess is None: if sess is None:
return "Error: complete_goal requires an active chat session." return "Error: complete_goal requires an active chat session."
session_key = self._request_ctx.get().session_key if self._request_ctx.get() else None
observation = latest_verification_observation(session_key)
if (
observation is not None
and observation.analysis.status == "failed"
and not _has_meaningful_remaining_failures(remaining_failures)
):
return format_completion_gate_message(observation)
prior = parse_goal_state(goal_state_raw(sess.metadata)) prior = parse_goal_state(goal_state_raw(sess.metadata))
if not isinstance(prior, dict) or prior.get("status") != "active": if not isinstance(prior, dict) or prior.get("status") != "active":
return "No active goal to complete." return "No active goal to complete."
ended = _iso_now() ended = _iso_now()
sess.metadata[GOAL_STATE_KEY] = { completed = {
**prior, **prior,
"status": "completed", "status": "completed",
"completed_at": ended, "completed_at": ended,
"recap": (recap or "").strip(), "recap": (recap or "").strip(),
} }
if verification_summary:
completed["verification_summary"] = verification_summary.strip()
if commands_run:
completed["commands_run"] = commands_run.strip()
if artifacts_created:
completed["artifacts_created"] = artifacts_created.strip()
if remaining_failures:
completed["remaining_failures"] = remaining_failures.strip()
sess.metadata[GOAL_STATE_KEY] = completed
discard_legacy_goal_state_key(sess.metadata) discard_legacy_goal_state_key(sess.metadata)
self._sessions.save(sess) self._sessions.save(sess)
clear_verification_observation(session_key)
await self._publish_goal_state_changed(sess.metadata) await self._publish_goal_state_changed(sess.metadata)
tail = (recap or "").strip() tail = (recap or "").strip()
if tail: if tail:
return f"Goal marked complete ({ended}). Recap:\n{tail}" return f"Goal marked complete ({ended}). Recap:\n{tail}"
return f"Goal marked complete ({ended})." return f"Goal marked complete ({ended})."
def _has_meaningful_remaining_failures(value: str | None) -> bool:
text = (value or "").strip().lower()
return bool(text and text not in {"none", "no", "n/a", "na", "no remaining failures"})
+30 -4
View File
@@ -797,6 +797,16 @@ async def connect_mcp_servers(
", ".join(available_wrapped_names) or "(none)", ", ".join(available_wrapped_names) or "(none)",
) )
# Only register resources and prompts when no tool restriction is
# active. enabledTools is a per-*tool* allowlist; resources and
# prompts have no equivalent name filter, so they must be skipped
# whenever the operator specified a tool subset. An empty list
# (deny-all) or a list of specific tool names both indicate that
# the operator intended to restrict capabilities — registering
# unrestricted resource/prompt wrappers would violate that intent.
# The default ["*"] (allow-all) means no restriction was intended.
register_extras = allow_all_tools
if register_extras:
try: try:
resources_result = await session.list_resources() resources_result = await session.list_resources()
for resource in resources_result.resources: for resource in resources_result.resources:
@@ -806,10 +816,14 @@ async def connect_mcp_servers(
registry.register(wrapper) registry.register(wrapper)
registered_count += 1 registered_count += 1
logger.debug( logger.debug(
"MCP: registered resource '{}' from server '{}'", wrapper.name, name "MCP: registered resource '{}' from server '{}'",
wrapper.name,
name,
) )
except Exception as e: except Exception as e:
logger.debug("MCP server '{}': resources not supported or failed: {}", name, e) logger.debug(
"MCP server '{}': resources not supported or failed: {}", name, e
)
try: try:
prompts_result = await session.list_prompts() prompts_result = await session.list_prompts()
@@ -819,9 +833,21 @@ async def connect_mcp_servers(
) )
registry.register(wrapper) registry.register(wrapper)
registered_count += 1 registered_count += 1
logger.debug("MCP: registered prompt '{}' from server '{}'", wrapper.name, name) logger.debug(
"MCP: registered prompt '{}' from server '{}'",
wrapper.name,
name,
)
except Exception as e: except Exception as e:
logger.debug("MCP server '{}': prompts not supported or failed: {}", name, e) logger.debug(
"MCP server '{}': prompts not supported or failed: {}", name, e
)
else:
logger.info(
"MCP server '{}': skipping resource/prompt registration "
"(enabledTools does not include '*' — only tools allowed)",
name,
)
logger.info( logger.info(
"MCP server '{}': connected, {} capabilities registered", name, registered_count "MCP server '{}': connected, {} capabilities registered", name, registered_count
+161 -27
View File
@@ -6,14 +6,17 @@ import asyncio
import os import os
import re import re
import shutil import shutil
import subprocess
import sys import sys
import time
import uuid
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from loguru import logger from loguru import logger
from pydantic import Field from pydantic import AliasChoices, Field
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import current_request_session_key from nanobot.agent.tools.context import current_request_session_key
@@ -33,12 +36,19 @@ from nanobot.agent.tools.schema import (
StringSchema, StringSchema,
tool_parameters_schema, tool_parameters_schema,
) )
from nanobot.agent.verification_state import (
analyze_verification_result,
append_verification_feedback,
record_verification_observation,
)
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config_base import Base from nanobot.config_base import Base
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
from nanobot.security.workspace_policy import is_path_within from nanobot.security.workspace_policy import is_path_within
from nanobot.utils.helpers import build_structured_output_summary
_IS_WINDOWS = sys.platform == "win32" _IS_WINDOWS = sys.platform == "win32"
_DETACHED_EXIT_GRACE_S = 1.0 if _IS_WINDOWS else 0.2
# Policy note appended to recoverable workspace-boundary guard errors. # Policy note appended to recoverable workspace-boundary guard errors.
@@ -55,6 +65,13 @@ class ExecToolConfig(Base):
"""Shell exec tool configuration.""" """Shell exec tool configuration."""
enable: bool = True enable: bool = True
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max. timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
allow_local_service_access: bool = Field(
default=False,
validation_alias=AliasChoices(
"allowLocalServiceAccess",
"allow_local_service_access",
),
) # allow shell commands to reach literal localhost/loopback services
path_prepend: str = "" path_prepend: str = ""
path_append: str = "" path_append: str = ""
sandbox: str = "" sandbox: str = ""
@@ -93,8 +110,8 @@ class _PreparedCommand:
nullable=True, nullable=True,
), ),
login=BooleanSchema( login=BooleanSchema(
description="Whether to run bash/zsh with login shell semantics (default true).", description="Whether to run bash/zsh with login shell semantics (default false).",
default=True, default=False,
nullable=True, nullable=True,
), ),
yield_time_ms=IntegerSchema( yield_time_ms=IntegerSchema(
@@ -126,6 +143,16 @@ class _PreparedCommand:
maximum=MAX_OUTPUT_CHARS, maximum=MAX_OUTPUT_CHARS,
nullable=True, nullable=True,
), ),
detach=BooleanSchema(
description=(
"Run the command as a detached background process that can "
"survive after the agent finishes. Use for local servers, "
"dev servers, mock APIs, or other services that must remain "
"available for later commands or external verification."
),
default=False,
nullable=True,
),
) )
) )
class ExecTool(Tool): class ExecTool(Tool):
@@ -149,6 +176,7 @@ class ExecTool(Tool):
working_dir=ctx.workspace, working_dir=ctx.workspace,
timeout=cfg.timeout, timeout=cfg.timeout,
restrict_to_workspace=ctx.config.restrict_to_workspace, restrict_to_workspace=ctx.config.restrict_to_workspace,
allow_local_service_access=cfg.allow_local_service_access,
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access, webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
sandbox=cfg.sandbox, sandbox=cfg.sandbox,
path_prepend=cfg.path_prepend, path_prepend=cfg.path_prepend,
@@ -165,6 +193,7 @@ class ExecTool(Tool):
deny_patterns: list[str] | None = None, deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None, allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
allow_local_service_access: bool = False,
webui_allow_local_service_access: bool = True, webui_allow_local_service_access: bool = True,
allow_local_preview_access: bool | None = None, allow_local_preview_access: bool | None = None,
sandbox: str = "", sandbox: str = "",
@@ -197,6 +226,7 @@ class ExecTool(Tool):
] ]
self.allow_patterns = allow_patterns or [] self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self.allow_local_service_access = allow_local_service_access
if allow_local_preview_access is not None: if allow_local_preview_access is not None:
webui_allow_local_service_access = allow_local_preview_access webui_allow_local_service_access = allow_local_preview_access
self.webui_allow_local_service_access = webui_allow_local_service_access self.webui_allow_local_service_access = webui_allow_local_service_access
@@ -236,8 +266,11 @@ class ExecTool(Tool):
"Use -y or --yes flags to avoid interactive prompts. " "Use -y or --yes flags to avoid interactive prompts. "
"For long-running or interactive commands, pass yield_time_ms; " "For long-running or interactive commands, pass yield_time_ms; "
"if the command keeps running, exec returns a session_id that can " "if the command keeps running, exec returns a session_id that can "
"be polled or written to with write_stdin. Output is truncated at " "be polled or written to with write_stdin. For services that "
"10 000 chars; timeout defaults to 60s." "must remain available after you finish, pass detach=true instead "
"of yield_time_ms; detached output is written to a log file and "
"the tool returns a pid. Output is truncated at 10 000 chars; "
"timeout defaults to 60s."
) )
@property @property
@@ -251,6 +284,7 @@ class ExecTool(Tool):
login: bool | None = None, yield_time_ms: int | None = None, login: bool | None = None, yield_time_ms: int | None = None,
max_output_chars: int | None = None, max_output_chars: int | None = None,
max_output_tokens: int | None = None, max_output_tokens: int | None = None,
detach: bool | None = False,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str:
command = command or cmd command = command or cmd
@@ -264,10 +298,14 @@ class ExecTool(Tool):
if isinstance(prepared, str): if isinstance(prepared, str):
return prepared return prepared
if detach:
return await self._execute_detached(prepared)
if yield_time_ms is not None: if yield_time_ms is not None:
return await self._execute_session(prepared, yield_time_ms, max_output_chars) return await self._execute_session(prepared, yield_time_ms, max_output_chars)
try: try:
started_at = time.monotonic()
process = await self._spawn( process = await self._spawn(
prepared.command, prepared.command,
prepared.cwd, prepared.cwd,
@@ -283,7 +321,15 @@ class ExecTool(Tool):
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
await self._kill_process(process) await self._kill_process(process)
return f"Error: Command timed out after {prepared.timeout} seconds" result = f"Error: Command timed out after {prepared.timeout} seconds"
analysis = analyze_verification_result(
command=prepared.command,
output=result,
exit_code=None,
timed_out=True,
)
record_verification_observation(current_request_session_key(), analysis)
return append_verification_feedback(result, analysis)
except asyncio.CancelledError: except asyncio.CancelledError:
await self._kill_process(process) await self._kill_process(process)
raise raise
@@ -301,17 +347,35 @@ class ExecTool(Tool):
output_parts.append(f"\nExit code: {process.returncode}") output_parts.append(f"\nExit code: {process.returncode}")
result = "\n".join(output_parts) if output_parts else "(no output)" result = "\n".join(output_parts) if output_parts else "(no output)"
elapsed_s = max(0.0, time.monotonic() - started_at)
analysis = analyze_verification_result(
command=prepared.command,
output=result,
exit_code=process.returncode,
)
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS) max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
if len(result) > max_len: if len(result) > max_len:
half = max_len // 2 result = build_structured_output_summary(
result = ( "[tool output truncated]",
result[:half] result,
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n" max_chars=max_len,
+ result[-half:] metadata=[
("original_size_chars", len(result)),
("exit_code", process.returncode),
("duration_s", f"{elapsed_s:.1f}"),
],
analysis=analysis,
guidance=(
"Use the structured summary first. Rerun a narrower "
"command, grep a specific failure, or inspect the "
"named artifact instead of rerunning broad noisy logs."
),
) )
return result record_verification_observation(current_request_session_key(), analysis)
return append_verification_feedback(result, analysis)
except Exception as e: except Exception as e:
return f"Error executing command: {str(e)}" return f"Error executing command: {str(e)}"
@@ -339,10 +403,71 @@ class ExecTool(Tool):
MAX_OUTPUT_CHARS, MAX_OUTPUT_CHARS,
), ),
) )
return format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
if poll.done:
analysis = analyze_verification_result(
command=prepared.command,
output=result,
exit_code=poll.exit_code,
timed_out=poll.timed_out,
)
record_verification_observation(current_request_session_key(), analysis)
return append_verification_feedback(result, analysis)
return result
except Exception as exc: except Exception as exc:
return f"Error executing command: {exc}" return f"Error executing command: {exc}"
async def _execute_detached(self, prepared: _PreparedCommand) -> str:
log_dir = Path(prepared.cwd) / ".nanobot" / "exec-logs"
try:
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"detached-{uuid.uuid4().hex[:12]}.log"
except Exception as exc:
return f"Error preparing detached command log directory: {exc}"
log_handle = None
try:
log_handle = open(log_path, "ab", buffering=0)
process = await self._spawn(
prepared.command,
prepared.cwd,
prepared.env,
prepared.shell_program,
prepared.login,
stdout=log_handle,
stderr=log_handle,
start_new_session=not _IS_WINDOWS,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if _IS_WINDOWS else 0,
)
except Exception as exc:
return f"Error starting detached command: {exc}"
finally:
if log_handle is not None:
with suppress(Exception):
log_handle.close()
try:
exit_code = await asyncio.wait_for(process.wait(), timeout=_DETACHED_EXIT_GRACE_S)
except asyncio.TimeoutError:
return (
"Detached process started.\n"
f"pid: {process.pid}\n"
f"cwd: {prepared.cwd}\n"
f"log: {log_path}\n"
"Poll the log or run a health check to verify the service is ready."
)
log_text = ""
with suppress(Exception):
log_text = log_path.read_text(encoding="utf-8", errors="replace")
if len(log_text) > 4000:
log_text = log_text[-4000:]
return (
f"Detached process exited immediately with code {exit_code}.\n"
f"log: {log_path}\n"
f"{log_text}"
)
def _resolve_timeout(self, timeout: int | None) -> int | None: def _resolve_timeout(self, timeout: int | None) -> int | None:
"""Resolve the effective hard timeout in seconds (None = no limit). """Resolve the effective hard timeout in seconds (None = no limit).
@@ -432,7 +557,7 @@ class ExecTool(Tool):
env=env, env=env,
timeout=effective_timeout, timeout=effective_timeout,
shell_program=shell_program, shell_program=shell_program,
login=True if login is None else login, login=False if login is None else login,
) )
def _compose_path(self, current_path: str) -> str: def _compose_path(self, current_path: str) -> str:
@@ -461,9 +586,13 @@ class ExecTool(Tool):
async def _spawn( async def _spawn(
command: str, cwd: str, env: dict[str, str], command: str, cwd: str, env: dict[str, str],
shell_program: str | None = None, shell_program: str | None = None,
login: bool = True, login: bool = False,
*, *,
stdin: int = asyncio.subprocess.DEVNULL, stdin: int = asyncio.subprocess.DEVNULL,
stdout: Any = asyncio.subprocess.PIPE,
stderr: Any = asyncio.subprocess.PIPE,
start_new_session: bool = False,
creationflags: int = 0,
) -> asyncio.subprocess.Process: ) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell.""" """Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS: if _IS_WINDOWS:
@@ -471,18 +600,20 @@ class ExecTool(Tool):
return await asyncio.create_subprocess_exec( return await asyncio.create_subprocess_exec(
"powershell", "-NoProfile", "-Command", command, "powershell", "-NoProfile", "-Command", command,
stdin=stdin, stdin=stdin,
stdout=asyncio.subprocess.PIPE, stdout=stdout,
stderr=asyncio.subprocess.PIPE, stderr=stderr,
cwd=cwd, cwd=cwd,
env=env, env=env,
creationflags=creationflags,
) )
return await asyncio.create_subprocess_shell( return await asyncio.create_subprocess_shell(
command, command,
stdin=stdin, stdin=stdin,
stdout=asyncio.subprocess.PIPE, stdout=stdout,
stderr=asyncio.subprocess.PIPE, stderr=stderr,
cwd=cwd, cwd=cwd,
env=env, env=env,
creationflags=creationflags,
) )
shell_program = shell_program or shutil.which("bash") or "/bin/bash" shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args = [shell_program] args = [shell_program]
@@ -493,10 +624,11 @@ class ExecTool(Tool):
return await asyncio.create_subprocess_exec( return await asyncio.create_subprocess_exec(
*args, *args,
stdin=stdin, stdin=stdin,
stdout=asyncio.subprocess.PIPE, stdout=stdout,
stderr=asyncio.subprocess.PIPE, stderr=stderr,
cwd=cwd, cwd=cwd,
env=env, env=env,
start_new_session=start_new_session,
) )
@staticmethod @staticmethod
@@ -541,8 +673,9 @@ class ExecTool(Tool):
def _build_env(self) -> dict[str, str]: def _build_env(self) -> dict[str, str]:
"""Build a minimal environment for subprocess execution. """Build a minimal environment for subprocess execution.
On Unix, only HOME/LANG/TERM are passed; ``bash -l`` sources the On Unix, only HOME/LANG/TERM are passed by default. If callers request
user's profile which sets PATH and other essentials. ``login=True``, bash/zsh may source the user's profile and add PATH or
other variables.
On Windows, ``cmd.exe`` has no login-profile mechanism, so a curated On Windows, ``cmd.exe`` has no login-profile mechanism, so a curated
set of system variables (including PATH) is forwarded. API keys and set of system variables (including PATH) is forwarded. API keys and
@@ -602,7 +735,7 @@ class ExecTool(Tool):
# exempt specific commands (e.g. "rm -rf" inside a build directory) # exempt specific commands (e.g. "rm -rf" inside a build directory)
# from the hardcoded deny list via configuration. # from the hardcoded deny list via configuration.
explicitly_allowed = bool(self.allow_patterns) and any( explicitly_allowed = bool(self.allow_patterns) and any(
re.search(p, lower) for p in self.allow_patterns re.fullmatch(p, lower) for p in self.allow_patterns
) )
if not explicitly_allowed: if not explicitly_allowed:
for pattern in self.deny_patterns: for pattern in self.deny_patterns:
@@ -613,11 +746,12 @@ class ExecTool(Tool):
return "Error: Command blocked by allowlist filter (not in allowlist)" return "Error: Command blocked by allowlist filter (not in allowlist)"
from nanobot.security.network import contains_internal_url from nanobot.security.network import contains_internal_url
allow_loopback = self.allow_local_service_access or current_scope_allows_loopback(
enabled=self.webui_allow_local_service_access,
)
if contains_internal_url( if contains_internal_url(
cmd, cmd,
allow_loopback=current_scope_allows_loopback( allow_loopback=allow_loopback,
enabled=self.webui_allow_local_service_access,
),
): ):
# The runner turns this marker into a non-retryable security hint. # The runner turns this marker into a non-retryable security hint.
return "Error: Command blocked by safety guard (internal/private URL detected)" return "Error: Command blocked by safety guard (internal/private URL detected)"
+18
View File
@@ -36,6 +36,24 @@ _VOLCENGINE_TIME_RANGES = {"OneDay", "OneWeek", "OneMonth", "OneYear"}
_VOLCENGINE_DATE_RANGE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$") _VOLCENGINE_DATE_RANGE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$")
# Single source of truth for selectable search providers (CLI wizard + WebUI).
# "credential" describes what each provider needs: none / api_key / base_url /
# optional_api_key.
SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
{"name": "tavily", "label": "Tavily", "credential": "api_key"},
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
{"name": "jina", "label": "Jina", "credential": "api_key"},
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
{"name": "exa", "label": "Exa", "credential": "api_key"},
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
{"name": "bocha", "label": "Bocha", "credential": "api_key"},
{"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"},
{"name": "keenable", "label": "Keenable", "credential": "optional_api_key"},
)
class WebSearchConfig(Base): class WebSearchConfig(Base):
"""Web search configuration.""" """Web search configuration."""
provider: str = "duckduckgo" provider: str = "duckduckgo"
+292
View File
@@ -0,0 +1,292 @@
"""Lightweight verification-result detection for coding workflows."""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Literal
VerificationStatus = Literal["passed", "failed"]
@dataclass(frozen=True, slots=True)
class VerificationAnalysis:
"""Structured summary of a command that appears to be verification."""
status: VerificationStatus
command: str
exit_code: int | None
failed_tests: tuple[str, ...] = ()
primary_errors: tuple[str, ...] = ()
missing_artifacts: tuple[str, ...] = ()
timed_out: bool = False
@dataclass(frozen=True, slots=True)
class VerificationObservation:
"""Latest verification signal observed for a session."""
analysis: VerificationAnalysis
sequence: int
_OBSERVATIONS: dict[str, VerificationObservation] = {}
_SEQUENCE = 0
_TEST_COMMAND_RE = re.compile(
r"(?ix)"
r"("
r"\bpytest\b|\bpy\.test\b|\bunittest\b|\bnosetests\b|"
r"\btest_outputs\.py\b|\brun_tests?(?:\.sh|\.py)?\b|"
r"\bnpm\s+(?:run\s+)?test\b|\byarn\s+test\b|\bpnpm\s+test\b|"
r"\bcargo\s+test\b|\bgo\s+test\b|\bctest\b|"
r"\bmake\s+(?:[^;&|]*\s+)?test\b"
r")"
)
_ARTIFACT_CHECK_COMMAND_RE = re.compile(
r"(?ix)"
r"("
r"\bcmp\b|"
r"\bdiff\b|"
r"\bsha(?:1|224|256|384|512)?sum\b|"
r"\bmd5sum\b|"
r"\bgcc\b.*(?:&&|;).*\./|"
r"\bclang\b.*(?:&&|;).*\./|"
r"\bpython3?\b.*<<['\"]?PY\b.*\bassert\b"
r")"
)
_COMPARISON_COMMAND_RE = re.compile(r"(?i)\b(?:cmp|diff)\b")
_FAILURE_RE = re.compile(
r"(?im)"
r"("
r"^FAILED\s+|"
r"\b\d+\s+failed\b|"
r"\bAssertionError\b|"
r"\bFileNotFoundError\b|"
r"\bTimeoutError\b|"
r"\bcommand not found\b|"
r"\bError:\s+Command timed out\b|"
r"\bFAILURES?\b|"
r"\bTEST FAILED\b"
r")"
)
_SUCCESS_RE = re.compile(
r"(?im)"
r"("
r"\b\d+\s+passed\b|"
r"\bOK\b|"
r"\bTEST PASSED\b|"
r"\bExit code:\s*0\b"
r")"
)
_ARTIFACT_SUCCESS_RE = re.compile(
r"(?im)"
r"("
r"\b(?:cmp|diff|test|verify)_exit:\s*0\b|"
r"^\s*(?:cmp|diff|match|same|image|ppm|stdout|stderr|out|err)[\w.-]*:\s*0\s*$"
r")"
)
_ARTIFACT_FAILURE_RE = re.compile(
r"(?im)"
r"("
r"\b(?:cmp|diff|test|verify)_exit:\s*[1-9]\d*\b|"
r"^\s*(?:cmp|diff|match|same|image|ppm|stdout|stderr|out|err)[\w.-]*:\s*[1-9]\d*\s*$"
r")"
)
_FAILED_TEST_RE = re.compile(r"(?m)^FAILED\s+([^\s]+)")
_PYTEST_SHORT_RE = re.compile(r"(?m)^_{3,}\s+([A-Za-z0-9_./:-]+)\s+_{3,}$")
_ERROR_LINE_RE = re.compile(
r"(?m)"
r"^\s*(?:E\s+)?("
r"(?:AssertionError|FileNotFoundError|TimeoutError|ValueError|TypeError|RuntimeError)"
r"(?::[^\n]*)?|"
r"assert\s+[^\n]+|"
r"[^:\n]+:\s+line\s+\d+:\s+[^:\n]+:\s+command not found|"
r"Error:\s+[^\n]+|"
r"TEST FAILED[^\n]*"
r")"
)
_MISSING_PATH_RE = re.compile(
r"(?i)"
r"(?:No such file or directory:\s*['\"]([^'\"]+)['\"]|"
r"(?:file|path)\s+([^\s'\"]+)\s+does not exist|"
r"cannot open file\s+['\"]([^'\"]+)['\"])"
)
def analyze_verification_result(
*,
command: str,
output: str,
exit_code: int | None,
timed_out: bool = False,
) -> VerificationAnalysis | None:
"""Return a verification summary when a command/output looks like a test."""
command = " ".join((command or "").split())
looks_like_test_command = bool(_TEST_COMMAND_RE.search(command))
looks_like_artifact_check = bool(_ARTIFACT_CHECK_COMMAND_RE.search(command))
looks_like_comparison_command = bool(_COMPARISON_COMMAND_RE.search(command))
looks_like_verification = looks_like_test_command or looks_like_artifact_check
failure_seen = bool(_FAILURE_RE.search(output))
success_seen = bool(_SUCCESS_RE.search(output))
artifact_success_seen = bool(_ARTIFACT_SUCCESS_RE.search(output)) and (
looks_like_comparison_command or bool(re.search(r"\b(?:test|verify)_exit:\s*0\b", output, flags=re.I))
)
artifact_failure_seen = bool(_ARTIFACT_FAILURE_RE.search(output)) and (
looks_like_comparison_command or bool(re.search(r"\b(?:test|verify)_exit:\s*[1-9]\d*\b", output, flags=re.I))
)
if not looks_like_test_command and not failure_seen:
if not (looks_like_artifact_check and artifact_success_seen and exit_code == 0):
return None
if (
(timed_out and looks_like_verification)
or (exit_code not in (None, 0) and (looks_like_verification or failure_seen))
or failure_seen
or artifact_failure_seen
):
return VerificationAnalysis(
status="failed",
command=command,
exit_code=exit_code,
failed_tests=_unique(_FAILED_TEST_RE.findall(output), limit=8),
primary_errors=_extract_primary_errors(output),
missing_artifacts=_extract_missing_artifacts(output),
timed_out=timed_out,
)
if looks_like_test_command and exit_code == 0 and success_seen:
return VerificationAnalysis(
status="passed",
command=command,
exit_code=exit_code,
)
if looks_like_artifact_check and exit_code == 0 and artifact_success_seen:
return VerificationAnalysis(
status="passed",
command=command,
exit_code=exit_code,
)
return None
def append_verification_feedback(output: str, analysis: VerificationAnalysis | None) -> str:
"""Append model-facing feedback for failed verification results."""
if analysis is None or analysis.status != "failed":
return output
lines = [
"",
"[Verification Feedback]",
"Verification status: failed.",
"Do not call complete_goal or present the task as finished until this is fixed and a verification passes.",
]
if analysis.command:
lines.append(f"Command: {analysis.command[:240]}")
if analysis.exit_code is not None:
lines.append(f"Exit code: {analysis.exit_code}")
if analysis.timed_out:
lines.append("Failure type: command timeout")
if analysis.failed_tests:
lines.append("Failed tests:")
lines.extend(f"- {item}" for item in analysis.failed_tests)
if analysis.primary_errors:
lines.append("Primary errors:")
lines.extend(f"- {item}" for item in analysis.primary_errors)
if analysis.missing_artifacts:
lines.append("Missing artifacts:")
lines.extend(f"- {item}" for item in analysis.missing_artifacts)
lines.append("Next action: inspect the failing assertion, fix the implementation or artifact, then rerun the most specific verification command.")
lines.append("[/Verification Feedback]")
return output.rstrip() + "\n" + "\n".join(lines)
def record_verification_observation(session_key: str | None, analysis: VerificationAnalysis | None) -> None:
"""Remember the latest verification signal for a session."""
if not session_key or analysis is None:
return
global _SEQUENCE
_SEQUENCE += 1
_OBSERVATIONS[session_key] = VerificationObservation(
analysis=analysis,
sequence=_SEQUENCE,
)
def latest_verification_observation(session_key: str | None) -> VerificationObservation | None:
if not session_key:
return None
return _OBSERVATIONS.get(session_key)
def clear_verification_observation(session_key: str | None) -> None:
if session_key:
_OBSERVATIONS.pop(session_key, None)
def format_completion_gate_message(observation: VerificationObservation) -> str:
"""Build the complete_goal soft-gate message for unresolved failures."""
analysis = observation.analysis
lines = [
"Recent verification appears to have failed, so the goal is not marked complete yet.",
"Continue fixing the task and rerun verification before completing.",
]
if analysis.command:
lines.append(f"Last failed verification command: {analysis.command[:240]}")
if analysis.failed_tests:
lines.append("Failed tests: " + ", ".join(analysis.failed_tests[:5]))
if analysis.primary_errors:
lines.append("Primary error: " + analysis.primary_errors[0])
if analysis.missing_artifacts:
lines.append("Missing artifact: " + analysis.missing_artifacts[0])
lines.append(
"If you are intentionally stopping with known failures, call complete_goal again with remaining_failures describing them honestly."
)
return "\n".join(lines)
def _extract_primary_errors(output: str) -> tuple[str, ...]:
candidates: list[str] = []
for match in _ERROR_LINE_RE.findall(output):
text = " ".join(match.split())
if text and text not in candidates:
candidates.append(text[:240])
if len(candidates) >= 8:
break
if not candidates:
for match in _PYTEST_SHORT_RE.findall(output):
text = " ".join(match.split())
if text and text not in candidates:
candidates.append(text[:240])
if len(candidates) >= 4:
break
return tuple(candidates)
def _extract_missing_artifacts(output: str) -> tuple[str, ...]:
paths: list[str] = []
for groups in _MISSING_PATH_RE.findall(output):
path = next((item for item in groups if item), "")
if path and path not in paths:
paths.append(path[:240])
if len(paths) >= 8:
break
return tuple(paths)
def _unique(items: list[str], *, limit: int) -> tuple[str, ...]:
out: list[str] = []
for item in items:
text = " ".join(item.split())
if text and text not in out:
out.append(text[:240])
if len(out) >= limit:
break
return tuple(out)
+18 -4
View File
@@ -94,11 +94,23 @@ class NanobotDingTalkHandler(CallbackHandler):
for item in rich_list: for item in rich_list:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
if item.get("type") == "text": # A rich-text item may carry text and/or a downloadCode; the
# DingTalk SDK treats them independently, so handle both.
t = item.get("text", "").strip() t = item.get("text", "").strip()
if t: if t:
content = (content + " " + t).strip() if content else t fmt = item.get("type", "")
elif item.get("downloadCode"): if fmt == "bold":
formatted = f"**{t}**"
elif fmt == "italic":
formatted = f"*{t}*"
elif fmt == "inlineCode":
formatted = f"`{t}`"
elif fmt == "pre":
formatted = f"```\n{t}\n```"
else:
formatted = t
content = (content + " " + formatted).strip() if content else formatted
if item.get("downloadCode"):
dc = item["downloadCode"] dc = item["downloadCode"]
fname = item.get("fileName") or "file" fname = item.get("fileName") or "file"
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown" sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
@@ -214,7 +226,9 @@ class DingTalkChannel(BaseChannel):
return return
self._running = True self._running = True
self._http = httpx.AsyncClient() self._http = httpx.AsyncClient(
timeout=httpx.Timeout(10.0, connect=10.0, read=30.0, write=30.0, pool=10.0)
)
self.logger.info( self.logger.info(
"Initializing Stream Client with Client ID: {}...", "Initializing Stream Client with Client ID: {}...",
+2
View File
@@ -199,6 +199,8 @@ class EmailChannel(BaseChannel):
except Exception: except Exception:
self.logger.exception("Polling error") self.logger.exception("Polling error")
if not self._running:
break
await asyncio.sleep(poll_seconds) await asyncio.sleep(poll_seconds)
async def stop(self) -> None: async def stop(self) -> None:
+11 -5
View File
@@ -396,7 +396,7 @@ class ChannelManager:
def _coalesce_stream_deltas( def _coalesce_stream_deltas(
self, first_msg: OutboundMessage self, first_msg: OutboundMessage
) -> tuple[OutboundMessage, list[OutboundMessage]]: ) -> tuple[OutboundMessage, list[OutboundMessage]]:
"""Merge consecutive _stream_delta messages for the same (channel, chat_id). """Merge consecutive _stream_delta messages for the same (channel, chat_id, _stream_id).
This reduces the number of API calls when the queue has accumulated multiple This reduces the number of API calls when the queue has accumulated multiple
deltas, which happens when LLM generates faster than the channel can process. deltas, which happens when LLM generates faster than the channel can process.
@@ -404,7 +404,8 @@ class ChannelManager:
Returns: Returns:
tuple of (merged_message, list_of_non_matching_messages) tuple of (merged_message, list_of_non_matching_messages)
""" """
target_key = (first_msg.channel, first_msg.chat_id) first_metadata = first_msg.metadata or {}
target_key = (first_msg.channel, first_msg.chat_id, first_metadata.get("_stream_id"))
combined_content = first_msg.content combined_content = first_msg.content
final_metadata = dict(first_msg.metadata or {}) final_metadata = dict(first_msg.metadata or {})
non_matching: list[OutboundMessage] = [] non_matching: list[OutboundMessage] = []
@@ -418,9 +419,14 @@ class ChannelManager:
break break
# Check if this message belongs to the same stream # Check if this message belongs to the same stream
same_target = (next_msg.channel, next_msg.chat_id) == target_key next_metadata = next_msg.metadata or {}
is_delta = next_msg.metadata and next_msg.metadata.get("_stream_delta") same_target = (
is_end = next_msg.metadata and next_msg.metadata.get("_stream_end") next_msg.channel,
next_msg.chat_id,
next_metadata.get("_stream_id"),
) == target_key
is_delta = next_metadata.get("_stream_delta")
is_end = next_metadata.get("_stream_end")
if same_target and is_delta and not final_metadata.get("_stream_end"): if same_target and is_delta and not final_metadata.get("_stream_end"):
# Accumulate content # Accumulate content
+4 -1
View File
@@ -351,6 +351,8 @@ class TelegramConfig(Base):
streaming: bool = True streaming: bool = True
# Enable inline keyboard buttons in Telegram messages. # Enable inline keyboard buttons in Telegram messages.
inline_keyboards: bool = False inline_keyboards: bool = False
# Opt in to Bot API 10.1 sendRichMessage for richer markdown rendering.
rich_messages: bool = False
stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1) stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1)
webhook_url: str = "" webhook_url: str = ""
webhook_listen_host: str = "127.0.0.1" webhook_listen_host: str = "127.0.0.1"
@@ -803,6 +805,7 @@ class TelegramChannel(BaseChannel):
# latches off permanently if the server doesn't support it. # latches off permanently if the server doesn't support it.
if ( if (
not render_as_blockquote not render_as_blockquote
and self.config.rich_messages
and not getattr(self, "_rich_send_disabled", False) and not getattr(self, "_rich_send_disabled", False)
): ):
rich_ok = await self._try_send_rich( rich_ok = await self._try_send_rich(
@@ -911,7 +914,7 @@ class TelegramChannel(BaseChannel):
# Skip when a streaming preview already exists to avoid the # Skip when a streaming preview already exists to avoid the
# delete-and-resend pattern that causes flickering and drops # delete-and-resend pattern that causes flickering and drops
# line breaks (issue #4470). # line breaks (issue #4470).
if not buf.message_id and not getattr(self, "_rich_send_disabled", False): if not buf.message_id and self.config.rich_messages and not getattr(self, "_rich_send_disabled", False):
reply_params = None reply_params = None
if reply_to_message_id := meta.get("message_id"): if reply_to_message_id := meta.get("message_id"):
reply_params = {"message_id": int(reply_to_message_id), "allow_sending_without_reply": True} reply_params = {"message_id": int(reply_to_message_id), "allow_sending_without_reply": True}
+574 -314
View File
@@ -1,24 +1,23 @@
"""WhatsApp channel implementation using Node.js bridge.""" """WhatsApp channel implementation using neonize."""
from __future__ import annotations
import asyncio import asyncio
import hashlib
import json
import mimetypes import mimetypes
import os import re
import secrets import secrets
import shutil import time
import subprocess
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal, NamedTuple
from loguru import logger
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir, get_runtime_subdir
from nanobot.config.schema import Base from nanobot.config.schema import Base
@@ -26,45 +25,249 @@ class WhatsAppConfig(Base):
"""WhatsApp channel configuration.""" """WhatsApp channel configuration."""
enabled: bool = False enabled: bool = False
bridge_url: str = "ws://localhost:3001"
bridge_token: str = ""
allow_from: list[str] = Field(default_factory=list) allow_from: list[str] = Field(default_factory=list)
group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned group_policy: Literal["open", "mention"] = "open"
# Optional static LID->phone mappings, e.g. {"123456789012345": "15551234567"}. database_path: str = ""
# Useful to resolve a sender's phone number from the very first message instead of
# only after a message that carries both phone and LID. Merged with mappings the
# bridge persists on disk (lid-mapping-*_reverse.json) under the auth directory.
lid_mappings: dict[str, str] = Field(default_factory=dict) lid_mappings: dict[str, str] = Field(default_factory=dict)
def _bridge_token_path() -> Path: class _NeonizeAPI(NamedTuple):
from nanobot.config.paths import get_runtime_subdir NewAClient: Any
ConnectedEv: Any
return get_runtime_subdir("whatsapp-auth") / "bridge-token" DisconnectedEv: Any
MessageEv: Any
PairStatusEv: Any
build_jid: Any
def _load_or_create_bridge_token(path: Path) -> str: class _MediaInfo(NamedTuple):
"""Load a persisted bridge token or create one on first use.""" kind: str
if path.exists(): message: Any
token = path.read_text(encoding="utf-8").strip() mimetype: str
if token: filename: str
return token is_voice: bool = False
path.parent.mkdir(parents=True, exist_ok=True)
token = secrets.token_urlsafe(32) _NEONIZE_API: _NeonizeAPI | None = None
path.write_text(token, encoding="utf-8") _JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
with suppress(OSError): _LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token")
path.chmod(0o600)
return token
def _default_database_path() -> Path:
return get_runtime_subdir("whatsapp-auth") / "neonize.db"
def _legacy_bridge_config_fields(config: dict[str, Any]) -> list[str]:
return [field for field in _LEGACY_BRIDGE_CONFIG_FIELDS if field in config]
def _load_neonize() -> _NeonizeAPI:
global _NEONIZE_API
if _NEONIZE_API is not None:
return _NEONIZE_API
try:
from neonize.aioze.client import NewAClient
from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv
from neonize.utils.jid import build_jid
except ImportError as exc:
raise RuntimeError(
'WhatsApp dependencies not installed. Run: pip install "nanobot-ai[whatsapp]"'
) from exc
_NEONIZE_API = _NeonizeAPI(
NewAClient=NewAClient,
ConnectedEv=ConnectedEv,
DisconnectedEv=DisconnectedEv,
MessageEv=MessageEv,
PairStatusEv=PairStatusEv,
build_jid=build_jid,
)
return _NEONIZE_API
def _has_field(message: Any, name: str) -> bool:
if message is None:
return False
has_field = getattr(message, "HasField", None)
if callable(has_field):
try:
return bool(has_field(name))
except ValueError:
pass
list_fields = getattr(message, "ListFields", None)
if callable(list_fields):
try:
return any(getattr(field, "name", "") == name for field, _ in list_fields())
except Exception:
pass
value = getattr(message, name, None)
return value is not None and value != "" and value != b""
def _message_field(message: Any, *names: str) -> Any:
for name in names:
if _has_field(message, name):
return getattr(message, name)
return None
def _safe_attr(obj: Any, name: str, default: Any = None) -> Any:
if obj is None:
return default
return getattr(obj, name, default)
def _jid_to_string(jid: Any) -> str:
if jid is None:
return ""
if isinstance(jid, str):
return jid.strip()
if bool(_safe_attr(jid, "IsEmpty", False)):
return ""
user = str(_safe_attr(jid, "User", "") or "").strip()
server = str(_safe_attr(jid, "Server", "") or "").strip()
if user and server:
return f"{user}@{server}"
return server or user
def _normalize_jid(raw: Any) -> str:
jid = _jid_to_string(raw).strip()
if not jid:
return ""
if jid.endswith("@lid.whatsapp.net"):
return jid[: -len(".whatsapp.net")]
return jid
def _bare_jid(raw: Any) -> str:
jid = _normalize_jid(raw)
if "@" not in jid:
return jid
return jid.split("@", 1)[0].split(":", 1)[0]
def _classify_sender_ids(jids: list[Any]) -> tuple[str, str]:
phone_id = ""
lid_id = ""
for raw in jids:
jid = _normalize_jid(raw)
if not jid:
continue
match = _JID_RE.match(jid)
if match:
user = match.group("user").split(":", 1)[0]
server = match.group("server")
if server in {"s.whatsapp.net", "c.us"}:
phone_id = phone_id or user
elif server in {"lid", "lid.whatsapp.net"}:
lid_id = lid_id or user
continue
if not phone_id:
phone_id = jid
return phone_id, lid_id
def _context_infos(message: Any) -> list[Any]:
infos: list[Any] = []
for container in (
message,
_message_field(message, "extendedTextMessage"),
_message_field(message, "imageMessage"),
_message_field(message, "videoMessage"),
_message_field(message, "audioMessage"),
_message_field(message, "documentMessage"),
_message_field(message, "stickerMessage"),
):
context = _message_field(container, "contextInfo")
if context is not None:
infos.append(context)
return infos
def _message_text(message: Any) -> str:
conversation = str(_safe_attr(message, "conversation", "") or "").strip()
if conversation:
return conversation
extended = _message_field(message, "extendedTextMessage")
text = str(_safe_attr(extended, "text", "") or "").strip()
if text:
return text
for field_name in ("imageMessage", "videoMessage", "documentMessage", "stickerMessage"):
media_message = _message_field(message, field_name)
caption = str(_safe_attr(media_message, "caption", "") or "").strip()
if caption:
return caption
return ""
def _media_message(message: Any) -> _MediaInfo | None:
image = _message_field(message, "imageMessage")
if image is not None:
return _MediaInfo(
kind="image",
message=image,
mimetype=str(_safe_attr(image, "mimetype", "") or "image/jpeg"),
filename=str(_safe_attr(image, "fileName", "") or ""),
)
video = _message_field(message, "videoMessage")
if video is not None:
return _MediaInfo(
kind="video",
message=video,
mimetype=str(_safe_attr(video, "mimetype", "") or "video/mp4"),
filename=str(_safe_attr(video, "fileName", "") or ""),
)
audio = _message_field(message, "audioMessage")
if audio is not None:
return _MediaInfo(
kind="audio",
message=audio,
mimetype=str(_safe_attr(audio, "mimetype", "") or "audio/ogg"),
filename=str(_safe_attr(audio, "fileName", "") or ""),
is_voice=bool(_safe_attr(audio, "PTT", False) or _safe_attr(audio, "ptt", False)),
)
document = _message_field(message, "documentMessage")
if document is not None:
return _MediaInfo(
kind="file",
message=document,
mimetype=str(_safe_attr(document, "mimetype", "") or "application/octet-stream"),
filename=str(
_safe_attr(document, "fileName", "")
or _safe_attr(document, "title", "")
or ""
),
)
sticker = _message_field(message, "stickerMessage")
if sticker is not None:
return _MediaInfo(
kind="sticker",
message=sticker,
mimetype=str(_safe_attr(sticker, "mimetype", "") or "image/webp"),
filename=str(_safe_attr(sticker, "fileName", "") or ""),
)
return None
class WhatsAppChannel(BaseChannel): class WhatsAppChannel(BaseChannel):
""" """WhatsApp channel using neonize's async WhatsApp client."""
WhatsApp channel that connects to a Node.js bridge.
The bridge uses @whiskeysockets/baileys to handle the WhatsApp Web protocol.
Communication between Python and Node.js is via WebSocket.
"""
name = "whatsapp" name = "whatsapp"
display_name = "WhatsApp" display_name = "WhatsApp"
@@ -74,211 +277,254 @@ class WhatsAppChannel(BaseChannel):
return WhatsAppConfig().model_dump(by_alias=True) return WhatsAppConfig().model_dump(by_alias=True)
def __init__(self, config: Any, bus: MessageBus): def __init__(self, config: Any, bus: MessageBus):
legacy_bridge_fields = _legacy_bridge_config_fields(config) if isinstance(config, dict) else []
if isinstance(config, dict): if isinstance(config, dict):
config = WhatsAppConfig.model_validate(config) config = WhatsAppConfig.model_validate(config)
super().__init__(config, bus) super().__init__(config, bus)
self._ws = None if legacy_bridge_fields:
self.logger.warning(
"Ignoring deprecated WhatsApp bridge config fields: {}. "
"Run 'nanobot channels login whatsapp' to create a neonize session.",
", ".join(legacy_bridge_fields),
)
self._client: Any | None = None
self._connected = False self._connected = False
self._processed_message_ids: OrderedDict[str, None] = OrderedDict() self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
self._lid_to_phone: dict[str, str] = self._load_lid_mappings() self._lid_to_phone = self._load_lid_mappings()
self._bridge_token: str | None = None self._self_jids: set[str] = set()
self._started_at = 0.0
def _database_path(self) -> Path:
configured = self.config.database_path.strip()
return Path(configured).expanduser() if configured else _default_database_path()
def _load_lid_mappings(self) -> dict[str, str]: def _load_lid_mappings(self) -> dict[str, str]:
"""Seed LID->phone mappings on startup.
Combines two sources so the sender's phone number can be resolved from the
very first message (instead of only after one that carries both phone and LID):
1. Reverse mapping files the bridge persists in the auth directory, named
``lid-mapping-<lid>_reverse.json`` and containing the phone number string.
2. Static ``lid_mappings`` from the channel config (takes precedence).
"""
from nanobot.config.paths import get_runtime_subdir
mapping: dict[str, str] = {} mapping: dict[str, str] = {}
auth_dir = get_runtime_subdir("whatsapp-auth") for lid, phone in self.config.lid_mappings.items():
if auth_dir.is_dir(): phone_text = str(phone).strip()
for path in auth_dir.glob("lid-mapping-*_reverse.json"): if phone_text:
lid = path.name[len("lid-mapping-"):-len("_reverse.json")] mapping[str(lid).strip()] = phone_text
try:
phone = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if isinstance(phone, str) and phone.strip():
mapping[lid] = phone.strip()
for lid, phone in getattr(self.config, "lid_mappings", {}).items():
if isinstance(phone, str) and phone.strip():
mapping[str(lid)] = phone.strip()
return mapping return mapping
def _effective_bridge_token(self) -> str: def _new_client(self) -> Any:
"""Resolve the bridge token, generating a local secret when needed.""" api = _load_neonize()
if self._bridge_token is not None: db_path = self._database_path()
return self._bridge_token db_path.parent.mkdir(parents=True, exist_ok=True)
configured = self.config.bridge_token.strip() return api.NewAClient(str(db_path))
if configured:
self._bridge_token = configured
else:
self._bridge_token = _load_or_create_bridge_token(_bridge_token_path())
return self._bridge_token
async def login(self, force: bool = False) -> bool: async def login(self, force: bool = False) -> bool:
""" db_path = self._database_path()
Set up and run the WhatsApp bridge for QR code login. if force:
self._reset_database(db_path)
client = self._new_client()
login_result = asyncio.get_running_loop().create_future()
self._register_handlers(client, login_result=login_result, handle_messages=False)
This spawns the Node.js bridge process which handles the WhatsApp
authentication flow. The process blocks until the user scans the QR code
or interrupts with Ctrl+C.
"""
try: try:
bridge_dir = _ensure_bridge_setup() self.logger.info("Starting WhatsApp login with neonize...")
except RuntimeError: connect_task = await client.connect()
self.logger.exception("bridge setup failed") self._fail_login_on_connect_task_done(connect_task, login_result)
return False await login_result
self.logger.info("WhatsApp login complete")
env = {**os.environ}
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
env["AUTH_DIR"] = str(_bridge_token_path().parent)
self.logger.info("Starting WhatsApp bridge for QR login...")
try:
subprocess.run(
[shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env
)
except subprocess.CalledProcessError:
return False
return True return True
except Exception as exc:
self.logger.error("WhatsApp login failed: {}", exc)
return False
finally:
with suppress(Exception):
await client.stop()
async def start(self) -> None: async def start(self) -> None:
"""Start the WhatsApp channel by connecting to the bridge."""
import websockets
bridge_url = self.config.bridge_url
self.logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
self._running = True self._running = True
self._started_at = time.time()
client = self._new_client()
self._client = client
self._register_handlers(client, handle_messages=True)
while self._running:
try: try:
async with websockets.connect(bridge_url) as ws: self.logger.info("Connecting WhatsApp channel with neonize...")
self._ws = ws await client.connect()
await ws.send( await client.idle()
json.dumps({"type": "auth", "token": self._effective_bridge_token()})
)
self._connected = True
self.logger.info("Connected to WhatsApp bridge")
# Listen for messages
async for message in ws:
try:
await self._handle_bridge_message(message)
except Exception:
self.logger.exception("Error handling bridge message")
except asyncio.CancelledError: except asyncio.CancelledError:
break raise
except Exception as e: finally:
self._connected = False
self._ws = None
self.logger.warning("WhatsApp bridge connection error: {}", e)
if self._running:
self.logger.info("Reconnecting in 5 seconds...")
await asyncio.sleep(5)
async def stop(self) -> None:
"""Stop the WhatsApp channel."""
self._running = False self._running = False
self._connected = False self._connected = False
if self._client is client:
self._client = None
with suppress(Exception):
await client.stop()
if self._ws: async def stop(self) -> None:
await self._ws.close() self._running = False
self._ws = None self._connected = False
client = self._client
self._client = None
if client is not None:
await client.stop()
@staticmethod
def _fail_login_on_connect_task_done(
connect_task: asyncio.Task[Any] | None,
login_result: asyncio.Future[None],
) -> None:
if connect_task is None:
return
def _on_done(task: asyncio.Task[Any]) -> None:
try:
exc = task.exception()
except asyncio.CancelledError:
return
if login_result.done():
return
if exc is not None:
login_result.set_exception(exc)
else:
login_result.set_exception(
RuntimeError("WhatsApp connection ended before login completed")
)
connect_task.add_done_callback(_on_done)
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through WhatsApp.""" client = self._client
if not self._ws or not self._connected: if client is None or not self._connected:
self.logger.warning("WhatsApp bridge not connected") raise RuntimeError("WhatsApp channel is not connected")
return
chat_id = msg.chat_id
to = self._build_jid(msg.chat_id)
if msg.content: if msg.content:
try: await client.send_message(to, msg.content)
payload = {"type": "send", "to": chat_id, "text": msg.content}
await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception:
self.logger.exception("Error sending message")
raise
for media_path in msg.media or []: for media_path in msg.media or []:
await self._send_media(client, to, media_path)
def _build_jid(self, raw: str) -> Any:
api = _load_neonize()
target = raw.strip()
match = _JID_RE.match(_normalize_jid(target))
if not match:
return api.build_jid(target)
user = match.group("user").split(":", 1)[0]
server = match.group("server")
return api.build_jid(user, server)
async def _send_media(self, client: Any, to: Any, media_path: str) -> None:
path = str(Path(media_path).expanduser())
mime, _ = mimetypes.guess_type(path)
mimetype = mime or "application/octet-stream"
if mimetype.startswith("image/"):
await client.send_image(to, path)
elif mimetype.startswith("video/"):
await client.send_video(to, path)
elif mimetype.startswith("audio/"):
await client.send_audio(to, path)
else:
await client.send_document(
to,
path,
filename=Path(path).name,
mimetype=mimetype,
)
def _register_handlers(
self,
client: Any,
*,
login_result: asyncio.Future[None] | None = None,
handle_messages: bool,
) -> None:
api = _load_neonize()
@client.qr
async def _on_qr(_: Any, qr_data: bytes) -> None:
import segno
self.logger.info("Scan the WhatsApp QR code with Linked Devices")
segno.make_qr(qr_data).terminal(compact=True)
@client.event(api.ConnectedEv)
async def _on_connected(current_client: Any, _: Any) -> None:
self._connected = True
try: try:
mime, _ = mimetypes.guess_type(media_path) await self._remember_self_jids(current_client)
payload = { except Exception as exc:
"type": "send_media", if login_result is not None and not login_result.done():
"to": chat_id, login_result.set_exception(exc)
"filePath": media_path, raise
"mimetype": mime or "application/octet-stream", if login_result is not None and not login_result.done():
"fileName": media_path.rsplit("/", 1)[-1], login_result.set_result(None)
} self.logger.info("WhatsApp connected")
await self._ws.send(json.dumps(payload, ensure_ascii=False))
@client.event(api.DisconnectedEv)
async def _on_disconnected(_: Any, event: Any) -> None:
self._connected = False
if login_result is not None and not login_result.done():
login_result.set_exception(
RuntimeError(f"WhatsApp disconnected before login completed: {event}")
)
self.logger.warning("WhatsApp disconnected: {}", event)
@client.event(api.PairStatusEv)
async def _on_pair_status(_: Any, event: Any) -> None:
error = str(_safe_attr(event, "Error", "") or "")
if error:
exc = RuntimeError(f"WhatsApp pair status error: {error}")
if login_result is not None and not login_result.done():
login_result.set_exception(exc)
raise exc
self.logger.info("WhatsApp pair status: {}", event)
if not handle_messages:
return
@client.event(api.MessageEv)
async def _on_message(current_client: Any, event: Any) -> None:
try:
await self._handle_neonize_message(current_client, event)
except Exception: except Exception:
self.logger.exception("Error sending media {}", media_path) self.logger.exception("Error handling WhatsApp message")
raise raise
async def _handle_bridge_message(self, raw: str) -> None: async def _remember_self_jids(self, client: Any) -> None:
"""Handle a message from the bridge.""" device = _safe_attr(client, "me")
try: if device is None:
data = json.loads(raw) device = await client.get_me()
except json.JSONDecodeError:
self.logger.warning("Invalid JSON from bridge: {}", raw[:100]) for attr in ("JID", "LID"):
jid = _normalize_jid(_safe_attr(device, attr))
if jid:
self._self_jids.add(jid)
self._self_jids.add(_bare_jid(jid))
async def _handle_neonize_message(self, client: Any, event: Any) -> None:
info = _safe_attr(event, "Info")
message = _safe_attr(event, "Message")
source = _safe_attr(info, "MessageSource")
if info is None or message is None or source is None:
raise ValueError("WhatsApp MessageEv is missing Info, Message, or MessageSource")
if bool(_safe_attr(source, "IsFromMe", False)):
return return
msg_type = data.get("type") chat_jid = _normalize_jid(_safe_attr(source, "Chat"))
if not chat_jid:
if msg_type == "message": raise ValueError("WhatsApp message has no chat JID")
# Incoming message from WhatsApp if chat_jid == "status@broadcast":
# Deprecated by whatsapp: old phone number style typically: <phone>@s.whatspp.net
pn = data.get("pn", "")
# New LID sytle typically:
sender = data.get("sender", "")
content = data.get("content", "")
message_id = data.get("id", "")
# Extract just the phone number or lid as chat_id
is_group = data.get("isGroup", False)
was_mentioned = bool(data.get("wasMentioned", False) or data.get("isReplyToBot", False))
if is_group and getattr(self.config, "group_policy", "open") == "mention":
if not was_mentioned:
return return
# Classify by JID suffix: @s.whatsapp.net = phone, @lid.whatsapp.net = LID timestamp = float(_safe_attr(info, "Timestamp", 0) or 0)
# The bridge's pn/sender fields don't consistently map to phone/LID across versions. if self._started_at and timestamp and timestamp < self._started_at:
raw_a = pn or ""
participant = data.get("participant", "")
raw_b = participant or sender or ""
id_a = raw_a.split("@")[0] if "@" in raw_a else raw_a
id_b = raw_b.split("@")[0] if "@" in raw_b else raw_b
phone_id = ""
lid_id = ""
for raw, extracted in [(raw_a, id_a), (raw_b, id_b)]:
if "@s.whatsapp.net" in raw:
phone_id = extracted
elif "@lid.whatsapp.net" in raw:
lid_id = extracted
elif extracted and not phone_id:
phone_id = extracted # best guess for bare values
sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b
if not self.is_allowed(sender_id):
return return
is_group = bool(_safe_attr(source, "IsGroup", False))
if is_group and self.config.group_policy == "mention":
if not self._is_addressed_to_bot(message):
return
message_id = str(_safe_attr(info, "ID", "") or "")
if message_id: if message_id:
if message_id in self._processed_message_ids: if message_id in self._processed_message_ids:
return return
@@ -286,137 +532,151 @@ class WhatsAppChannel(BaseChannel):
while len(self._processed_message_ids) > 1000: while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False) self._processed_message_ids.popitem(last=False)
participant_jid = _normalize_jid(_safe_attr(source, "Sender"))
sender_alt_jid = _normalize_jid(_safe_attr(source, "SenderAlt"))
sender_candidates = [sender_alt_jid, participant_jid]
if not is_group:
sender_candidates.append(chat_jid)
phone_id, lid_id = _classify_sender_ids(sender_candidates)
if phone_id and lid_id: if phone_id and lid_id:
self._lid_to_phone[lid_id] = phone_id self._lid_to_phone[lid_id] = phone_id
self.logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id) sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id
if not sender_id:
raise ValueError("WhatsApp message has no resolvable sender ID")
metadata = {
"message_id": message_id or None,
"timestamp": int(timestamp) if timestamp else None,
"is_group": is_group,
"is_forwarded": self._is_forwarded(message),
"participant": participant_jid or None,
"sender_alt": sender_alt_jid or None,
"lid": lid_id or None,
"phone": phone_id or None,
"is_reply_to_bot": self._is_reply_to_bot(message),
}
if not self.is_allowed(sender_id):
self.logger.info(
"Passing unauthorized WhatsApp sender {} to pairing flow "
"(phone={}, lid={}, chat={})",
sender_id,
phone_id or "",
lid_id or "",
chat_jid,
)
await self._handle_message(
sender_id=sender_id,
chat_id=chat_jid,
content=_message_text(message),
media=[],
metadata=metadata,
is_dm=not is_group,
)
return
# Extract media paths (images/documents/videos downloaded by the bridge) text = _message_text(message)
media_paths = data.get("media") or [] media_paths: list[str] = []
media = _media_message(message)
# Handle voice transcription if it's a voice message if media is not None:
if content == "[Voice Message]": path = await self._download_media(client, event, media)
if media_paths: if media.kind == "audio" and media.is_voice:
self.logger.info("Transcribing voice message from {}...", sender_id) transcription = await self.transcribe_audio(path)
transcription = await self.transcribe_audio(media_paths[0])
if transcription: if transcription:
content = transcription text = transcription
media_paths = []
self.logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50])
else: else:
content = "[Voice Message: Transcription failed]" media_paths.append(path)
text = self._append_media_tag(text, "audio", path)
else: else:
content = "[Voice Message: Audio not available]" media_paths.append(path)
text = self._append_media_tag(text, media.kind, path)
# Build content tags matching Telegram's pattern: [image: /path] or [file: /path] if not text and not media_paths:
if media_paths: return
for p in media_paths:
mime, _ = mimetypes.guess_type(p)
media_type = "image" if mime and mime.startswith("image/") else "file"
media_tag = f"[{media_type}: {p}]"
content = f"{content}\n{media_tag}" if content else media_tag
await self._handle_message( await self._handle_message(
sender_id=sender_id, sender_id=sender_id,
chat_id=sender, # Use full LID for replies chat_id=chat_jid,
content=content, content=text,
media=media_paths, media=media_paths,
metadata={ metadata=metadata,
"message_id": message_id, is_dm=not is_group,
"timestamp": data.get("timestamp"),
"is_group": data.get("isGroup", False),
"is_forwarded": bool(data.get("isForwarded", False)),
"participant": participant or None,
"is_reply_to_bot": data.get("isReplyToBot", False),
},
) )
elif msg_type == "status": def _is_addressed_to_bot(self, message: Any) -> bool:
# Connection status update return self._was_mentioned(message) or self._is_reply_to_bot(message)
status = data.get("status")
self.logger.info("Status: {}", status)
if status == "connected": def _was_mentioned(self, message: Any) -> bool:
self._connected = True if not self._self_jids:
elif status == "disconnected": return False
self._connected = False for context in _context_infos(message):
mentioned = (
elif msg_type == "qr": _safe_attr(context, "mentionedJID")
# QR code for authentication or _safe_attr(context, "mentionedJid")
self.logger.info("Scan QR code in the bridge terminal to connect WhatsApp") or _safe_attr(context, "mentioned_jid")
or []
elif msg_type == "error":
self.logger.error("Bridge error: {}", data.get("error"))
def _ensure_bridge_setup() -> Path:
"""
Ensure the WhatsApp bridge is set up and built.
Returns the bridge directory. Raises RuntimeError if npm is not found
or bridge cannot be built.
"""
from nanobot.config.paths import get_bridge_install_dir
user_bridge = get_bridge_install_dir()
stamp_file = user_bridge / ".nanobot-bridge-source-hash"
# Find source bridge
current_file = Path(__file__)
pkg_bridge = current_file.parent.parent / "bridge"
src_bridge = current_file.parent.parent.parent / "bridge"
source = None
if (pkg_bridge / "package.json").exists():
source = pkg_bridge
elif (src_bridge / "package.json").exists():
source = src_bridge
if not source:
raise RuntimeError(
"WhatsApp bridge source not found. "
"Try reinstalling: pip install --force-reinstall nanobot"
) )
for jid in mentioned:
normalized = _normalize_jid(jid)
if normalized in self._self_jids or _bare_jid(normalized) in self._self_jids:
return True
return False
def source_hash(root: Path) -> str: def _is_reply_to_bot(self, message: Any) -> bool:
digest = hashlib.sha256() if not self._self_jids:
for path in sorted(root.rglob("*")): return False
if not path.is_file(): for context in _context_infos(message):
continue participant = _normalize_jid(
rel = path.relative_to(root) _safe_attr(context, "participant")
if rel.parts and rel.parts[0] in {"node_modules", "dist"}: or _safe_attr(context, "Participant")
continue or ""
digest.update(rel.as_posix().encode("utf-8")) )
digest.update(b"\0") if participant in self._self_jids or _bare_jid(participant) in self._self_jids:
digest.update(path.read_bytes()) return True
digest.update(b"\0") return False
return digest.hexdigest()
expected_hash = source_hash(source) @staticmethod
current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None def _is_forwarded(message: Any) -> bool:
for context in _context_infos(message):
if bool(_safe_attr(context, "isForwarded", False)):
return True
if int(_safe_attr(context, "forwardingScore", 0) or 0) > 0:
return True
return False
if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash: async def _download_media(self, client: Any, event: Any, media: _MediaInfo) -> str:
return user_bridge info = _safe_attr(event, "Info")
message_id = str(_safe_attr(info, "ID", "") or "")
path = self._media_path(message_id, media)
await client.download_any(_safe_attr(event, "Message"), str(path))
return str(path)
if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash: def _media_path(self, message_id: str, media: _MediaInfo) -> Path:
logger.info("WhatsApp bridge source changed; rebuilding bridge...") media_dir = get_media_dir("whatsapp")
safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", message_id or str(int(time.time())))
filename = Path(media.filename).name if media.filename else ""
suffix = Path(filename).suffix if filename else ""
if not suffix:
suffix = mimetypes.guess_extension(media.mimetype) or {
"image": ".jpg",
"video": ".mp4",
"audio": ".ogg",
"sticker": ".webp",
}.get(media.kind, ".bin")
return media_dir / f"wa_{safe_id}_{secrets.token_hex(4)}{suffix}"
npm_path = shutil.which("npm") @staticmethod
if not npm_path: def _append_media_tag(text: str, kind: str, path: str) -> str:
raise RuntimeError("npm not found. Please install Node.js >= 20.") label = kind if kind in {"image", "video", "audio", "sticker"} else "file"
tag = f"[{label}: {path}]"
return f"{text}\n{tag}" if text else tag
logger.info("Setting up WhatsApp bridge...") @staticmethod
user_bridge.parent.mkdir(parents=True, exist_ok=True) def _reset_database(path: Path) -> None:
if user_bridge.exists(): for candidate in (
shutil.rmtree(user_bridge) path,
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) path.with_suffix(path.suffix + "-shm"),
path.with_suffix(path.suffix + "-wal"),
logger.info(" Installing dependencies...") ):
subprocess.run([npm_path, "install"], cwd=user_bridge, check=True, capture_output=True) if candidate.exists():
candidate.unlink()
logger.info(" Building...")
subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
stamp_file.write_text(expected_hash + "\n")
logger.info("Bridge ready")
return user_bridge
+7
View File
@@ -154,6 +154,12 @@ def _install_gateway_shutdown_handlers(
return restore return restore
def _advance_dream_cursor_if_behind(memory: Any) -> None:
latest = memory.get_latest_cursor()
if memory.get_last_dream_cursor() < latest:
memory.set_last_dream_cursor(latest)
class SafeFileHistory(FileHistory): class SafeFileHistory(FileHistory):
"""FileHistory subclass that sanitizes surrogate characters on write. """FileHistory subclass that sanitizes surrogate characters on write.
@@ -1165,6 +1171,7 @@ def _run_gateway(
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}") console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
else: else:
console.print("[yellow]○[/yellow] Dream: disabled") console.print("[yellow]○[/yellow] Dream: disabled")
_advance_dream_cursor_if_behind(agent.context.memory)
# Register Heartbeat system job (idempotent on restart) # Register Heartbeat system job (idempotent on restart)
if hb_cfg.enabled: if hb_cfg.enabled:
+35 -7
View File
@@ -762,13 +762,11 @@ def _handle_model_preset_field(
setattr(working_model, field_name, new_value) setattr(working_model, field_name, new_value)
def _handle_provider_field( def _set_field_from_choices(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any working_model: BaseModel, field_name: str, field_display: str,
choices: list[str], default_choice: str
) -> None: ) -> None:
"""Handle the 'provider' field with a list of registered providers.""" """Prompt to pick one of ``choices`` and set the field (no-op on back/cancel)."""
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) new_value = _select_with_back(field_display, choices, default=default_choice)
if new_value is _BACK_PRESSED: if new_value is _BACK_PRESSED:
return return
@@ -776,6 +774,15 @@ def _handle_provider_field(
setattr(working_model, field_name, new_value) 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 LLM providers."""
choices = ["auto"] + sorted(_get_provider_names().keys())
default_choice = str(current_value) if current_value else "auto"
_set_field_from_choices(working_model, field_name, field_display, choices, default_choice)
def _handle_fallback_models_field( def _handle_fallback_models_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None: ) -> None:
@@ -836,6 +843,17 @@ def _handle_fallback_models_field(
items.clear() items.clear()
def _handle_search_provider_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the web-search 'provider' field with the search-engine list."""
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
choices = [opt["name"] for opt in SEARCH_PROVIDER_OPTIONS]
default_choice = current_value if current_value in choices else choices[0]
_set_field_from_choices(working_model, field_name, field_display, choices, default_choice)
_FIELD_HANDLERS: dict[str, Any] = { _FIELD_HANDLERS: dict[str, Any] = {
"model": _handle_model_field, "model": _handle_model_field,
"context_window_tokens": _handle_context_window_field, "context_window_tokens": _handle_context_window_field,
@@ -845,6 +863,16 @@ _FIELD_HANDLERS: dict[str, Any] = {
} }
def _resolve_field_handler(model: BaseModel, field_name: str) -> Any:
"""Resolve the handler for a field. WebSearchConfig shares the bare "provider"
name with LLM configs but needs the search-engine picker, not the LLM list."""
if field_name == "provider":
from nanobot.agent.tools.web import WebSearchConfig
if isinstance(model, WebSearchConfig):
return _handle_search_provider_field
return _FIELD_HANDLERS.get(field_name)
def _is_str_or_none(annotation: Any) -> bool: def _is_str_or_none(annotation: Any) -> bool:
"""Check whether a field annotation is ``str | None`` (or ``Optional[str]``).""" """Check whether a field annotation is ``str | None`` (or ``Optional[str]``)."""
origin = get_origin(annotation) origin = get_origin(annotation)
@@ -934,7 +962,7 @@ def _configure_pydantic_model(
continue continue
# Registered special-field handlers # Registered special-field handlers
handler = _FIELD_HANDLERS.get(field_name) handler = _resolve_field_handler(working_model, field_name)
if handler: if handler:
handler(working_model, field_name, field_display, current_value) handler(working_model, field_name, field_display, current_value)
continue continue
+1 -1
View File
@@ -626,7 +626,7 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread. _GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread.
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers. Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap plus verification_summary / commands_run / artifacts_created when applicable. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
Goal: Goal:
{goal} {goal}
-2
View File
@@ -2,7 +2,6 @@
from nanobot.config.loader import get_config_path, load_config from nanobot.config.loader import get_config_path, load_config
from nanobot.config.paths import ( from nanobot.config.paths import (
get_bridge_install_dir,
get_cli_history_path, get_cli_history_path,
get_cron_dir, get_cron_dir,
get_data_dir, get_data_dir,
@@ -29,6 +28,5 @@ __all__ = [
"get_workspace_path", "get_workspace_path",
"is_default_workspace", "is_default_workspace",
"get_cli_history_path", "get_cli_history_path",
"get_bridge_install_dir",
"get_legacy_sessions_dir", "get_legacy_sessions_dir",
] ]
-5
View File
@@ -66,11 +66,6 @@ def get_cli_history_path() -> Path:
return Path.home() / ".nanobot" / "history" / "cli_history" return Path.home() / ".nanobot" / "history" / "cli_history"
def get_bridge_install_dir() -> Path:
"""Return the shared WhatsApp bridge installation directory."""
return Path.home() / ".nanobot" / "bridge"
def get_legacy_sessions_dir() -> Path: def get_legacy_sessions_dir() -> Path:
"""Return the legacy global session directory used for migration fallback.""" """Return the legacy global session directory used for migration fallback."""
return Path.home() / ".nanobot" / "sessions" return Path.home() / ".nanobot" / "sessions"
+27 -3
View File
@@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any, ClassVar, Literal
from pydantic import AliasChoices, ConfigDict, Field, model_validator from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from nanobot.config_base import Base from nanobot.config_base import Base
@@ -132,6 +132,7 @@ class AgentDefaults(Base):
fallback_models: list[FallbackCandidate] = Field(default_factory=list) fallback_models: list[FallbackCandidate] = Field(default_factory=list)
max_tool_iterations: int = 200 max_tool_iterations: int = 200
max_concurrent_subagents: int = Field(default=1, ge=1) max_concurrent_subagents: int = Field(default=1, ge=1)
fail_on_tool_error: bool = True
max_tool_result_chars: int = 16_000 max_tool_result_chars: int = 16_000
provider_retry_mode: Literal["standard", "persistent"] = "standard" provider_retry_mode: Literal["standard", "persistent"] = "standard"
tool_hint_max_length: int = Field( tool_hint_max_length: int = Field(
@@ -182,6 +183,29 @@ class ProviderConfig(Base):
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
extra_query: dict[str, str] | None = None # Extra query params (e.g. api-version for Azure-style gateways) extra_query: dict[str, str] | None = None # Extra query params (e.g. api-version for Azure-style gateways)
thinking_style: str | None = None # Thinking/reasoning style for custom providers
# Valid values mirror the keys of _THINKING_STYLE_MAP in
# nanobot/providers/openai_compat_provider.py. Kept duplicated here to
# avoid an import cycle (schema.py must not import from providers/).
_VALID_THINKING_STYLES: ClassVar[tuple[str, ...]] = (
"thinking_type",
"enable_thinking",
"reasoning_split",
)
@field_validator("thinking_style")
@classmethod
def _validate_thinking_style(cls, v: str | None) -> str | None:
if not v: # None or "" -> no injection, valid (backwards compatible)
return v
if v not in cls._VALID_THINKING_STYLES:
raise ValueError(
f"Invalid thinking_style {v!r}. "
f"Must be one of: {', '.join(repr(s) for s in cls._VALID_THINKING_STYLES)} "
f"(or empty/omitted)."
)
return v
class BedrockProviderConfig(ProviderConfig): class BedrockProviderConfig(ProviderConfig):
@@ -307,7 +331,7 @@ class MCPServerConfig(Base):
url: str = "" # HTTP/SSE: endpoint URL url: str = "" # HTTP/SSE: endpoint URL
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
tool_timeout: int = 30 # seconds before a tool call is cancelled tool_timeout: int = 30 # seconds before a tool call is cancelled
enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all capabilities (tools, resources, prompts); any restriction = only listed tools, no resources/prompts
def _lazy_default(module_path: str, class_name: str) -> Any: def _lazy_default(module_path: str, class_name: str) -> Any:
+22 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import json
import re import re
import secrets import secrets
import string import string
@@ -275,7 +276,19 @@ class AnthropicProvider(LLMProvider):
blocks.append({"type": "text", "text": content}) blocks.append({"type": "text", "text": content})
elif isinstance(content, list): elif isinstance(content, list):
for item in content: for item in content:
blocks.append(item if isinstance(item, dict) else {"type": "text", "text": str(item)}) if isinstance(item, dict):
if not item.get("type"):
# Anthropic requires every content block to declare a "type".
# A tool that returned a bare dict lands here; coerce it to
# a text block instead of emitting one that the API rejects.
blocks.append({
"type": "text",
"text": AnthropicProvider._stringify_typeless_block(item),
})
else:
blocks.append(item)
else:
blocks.append({"type": "text", "text": str(item)})
for tc in msg.get("tool_calls") or []: for tc in msg.get("tool_calls") or []:
if not isinstance(tc, dict): if not isinstance(tc, dict):
@@ -315,11 +328,18 @@ class AnthropicProvider(LLMProvider):
# A tool that returned a bare dict (or a list of dicts) lands # A tool that returned a bare dict (or a list of dicts) lands
# here; coerce it to a text block instead of emitting a block # here; coerce it to a text block instead of emitting a block
# the API rejects with "content.0.type: Field required". # the API rejects with "content.0.type: Field required".
result.append({"type": "text", "text": str(item)}) result.append({
"type": "text",
"text": AnthropicProvider._stringify_typeless_block(item),
})
continue continue
result.append(item) result.append(item)
return result or "(empty)" return result or "(empty)"
@staticmethod
def _stringify_typeless_block(block: dict[str, Any]) -> str:
return json.dumps(block, ensure_ascii=False, sort_keys=True, default=str)
@staticmethod @staticmethod
def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None: def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None:
"""Convert OpenAI image_url block to Anthropic image block.""" """Convert OpenAI image_url block to Anthropic image block."""
+1 -1
View File
@@ -54,7 +54,7 @@ def _make_provider_core(
if provider_name and not spec and p: if provider_name and not spec and p:
if not p.api_base: if not p.api_base:
raise ValueError(f"Provider '{provider_name}' requires api_base in config.") raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
spec = create_dynamic_spec(provider_name) spec = create_dynamic_spec(provider_name, thinking_style=(p.thinking_style or "") if p else "")
if spec and spec.is_transcription_only: if spec and spec.is_transcription_only:
raise ValueError(f"Provider '{provider_name}' only supports transcription.") raise ValueError(f"Provider '{provider_name}' only supports transcription.")
backend = spec.backend if spec else "openai_compat" backend = spec.backend if spec else "openai_compat"
+77 -4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import ast
import asyncio import asyncio
import hashlib import hashlib
import json import json
@@ -26,6 +27,25 @@ from nanobot.providers.openai_responses import (
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_ORIGINATOR = "nanobot" DEFAULT_ORIGINATOR = "nanobot"
_RESPONSE_FAILED_PREFIX = "Response failed:"
_RETRYABLE_RESPONSE_FAILED_TOKENS = frozenset({
"overloaded",
"overloaded_error",
"rate_limit_exceeded",
"request_limit_exceeded",
"requests_limit_exceeded",
"server_error",
"server_is_overloaded",
"service_unavailable",
"temporarily_unavailable",
"too_many_requests",
})
_NON_RETRYABLE_RESPONSE_FAILED_TOKENS = frozenset({
"content_filter",
"content_policy_violation",
"cyber_policy",
"safety_violation",
})
class OpenAICodexProvider(LLMProvider): class OpenAICodexProvider(LLMProvider):
@@ -246,6 +266,8 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
status_code = getattr(exc, "status_code", None) status_code = getattr(exc, "status_code", None)
error_kind: str | None = None error_kind: str | None = None
error_type = getattr(exc, "error_type", None)
error_code = getattr(exc, "error_code", None)
default_detail: str | None = None default_detail: str | None = None
should_retry: bool | None = getattr(exc, "should_retry", None) should_retry: bool | None = getattr(exc, "should_retry", None)
@@ -265,12 +287,20 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
error_kind = "http" error_kind = "http"
default_detail = "HTTP request failed" default_detail = "HTTP request failed"
failed_type, failed_code = _extract_response_failed_error(detail)
if failed_type or failed_code:
error_kind = error_kind or "provider"
error_type = failed_type or error_type
error_code = failed_code or error_code
if should_retry is None:
should_retry = _should_retry_response_failed(error_type, error_code, detail)
if status_code is not None and should_retry is None: if status_code is not None and should_retry is None:
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
should_retry = _should_retry_status( should_retry = _should_retry_status(
int(status_code), int(status_code),
getattr(exc, "error_type", None), error_type,
getattr(exc, "error_code", None), error_code,
retry_content, retry_content,
) )
@@ -283,13 +313,56 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
retry_after=retry_after, retry_after=retry_after,
error_status_code=int(status_code) if status_code is not None else None, error_status_code=int(status_code) if status_code is not None else None,
error_kind=error_kind, error_kind=error_kind,
error_type=getattr(exc, "error_type", None), error_type=error_type,
error_code=getattr(exc, "error_code", None), error_code=error_code,
error_retry_after_s=retry_after, error_retry_after_s=retry_after,
error_should_retry=should_retry, error_should_retry=should_retry,
) )
def _extract_response_failed_error(detail: str) -> tuple[str | None, str | None]:
"""Extract provider semantic error fields from Responses SSE failures."""
if _RESPONSE_FAILED_PREFIX not in detail:
return None, None
payload = detail.split(_RESPONSE_FAILED_PREFIX, 1)[1].strip()
if not payload:
return None, None
parsed: Any = None
try:
parsed = json.loads(payload)
except Exception:
try:
parsed = ast.literal_eval(payload)
except Exception:
parsed = None
error_type, error_code = LLMProvider._extract_error_type_code(parsed or payload)
return error_type, error_code
def _should_retry_response_failed(
error_type: str | None,
error_code: str | None,
detail: str,
) -> bool | None:
semantic_tokens = {
token for token in (
LLMProvider._normalize_error_token(error_type),
LLMProvider._normalize_error_token(error_code),
)
if token is not None
}
if any(token in _NON_RETRYABLE_RESPONSE_FAILED_TOKENS for token in semantic_tokens):
return False
if any(token in _RETRYABLE_RESPONSE_FAILED_TOKENS for token in semantic_tokens):
return True
if LLMProvider._is_transient_error(detail):
return True
return None
def _codex_log_summary(exc_type: str, response: LLMResponse) -> str: def _codex_log_summary(exc_type: str, response: LLMResponse) -> str:
"""Return a bounded diagnostic summary without request body or raw upstream payload.""" """Return a bounded diagnostic summary without request body or raw upstream payload."""
if response.error_status_code is not None: if response.error_status_code is not None:
+8 -1
View File
@@ -1131,14 +1131,21 @@ class OpenAICompatProvider(LLMProvider):
if reasoning_content is None: if reasoning_content is None:
reasoning_content = m.get("reasoning_content") reasoning_content = m.get("reasoning_content")
# Deduplicate tool call IDs (same pattern as streaming path)
# Some providers reuse the same ID for parallel tool calls.
_seen_tc_ids: set[str] = set()
parsed_tool_calls = [] parsed_tool_calls = []
for tc in raw_tool_calls: for tc in raw_tool_calls:
tc_map = self._maybe_mapping(tc) or {} tc_map = self._maybe_mapping(tc) or {}
fn = self._maybe_mapping(tc_map.get("function")) or {} fn = self._maybe_mapping(tc_map.get("function")) or {}
args = parse_tool_arguments(fn.get("arguments", {})) args = parse_tool_arguments(fn.get("arguments", {}))
ec, prov, fn_prov = _extract_tc_extras(tc) ec, prov, fn_prov = _extract_tc_extras(tc)
raw_id = str(tc_map.get("id") or _short_tool_id())
if not raw_id or raw_id in _seen_tc_ids:
raw_id = _short_tool_id()
_seen_tc_ids.add(raw_id)
parsed_tool_calls.append(ToolCallRequest( parsed_tool_calls.append(ToolCallRequest(
id=str(tc_map.get("id") or _short_tool_id()), id=raw_id,
name=str(fn.get("name") or ""), name=str(fn.get("name") or ""),
arguments=args, arguments=args,
extra_content=ec, extra_content=ec,
+2 -1
View File
@@ -628,7 +628,7 @@ def find_by_name(name: str) -> ProviderSpec | None:
return None return None
def create_dynamic_spec(name: str) -> ProviderSpec: def create_dynamic_spec(name: str, *, thinking_style: str = "") -> ProviderSpec:
"""Create a dynamic ProviderSpec for custom user-defined providers.""" """Create a dynamic ProviderSpec for custom user-defined providers."""
normalized = to_snake(name.replace("-", "_")) normalized = to_snake(name.replace("-", "_"))
strip_prefixes = tuple(dict.fromkeys((name, normalized))) strip_prefixes = tuple(dict.fromkeys((name, normalized)))
@@ -640,4 +640,5 @@ def create_dynamic_spec(name: str) -> ProviderSpec:
backend="openai_compat", backend="openai_compat",
is_direct=True, is_direct=True,
strip_model_prefixes=strip_prefixes, strip_model_prefixes=strip_prefixes,
thinking_style=thinking_style,
) )
+70 -31
View File
@@ -1,5 +1,6 @@
"""Session management for conversation history.""" """Session management for conversation history."""
import base64
import json import json
import os import os
import re import re
@@ -118,25 +119,6 @@ class Session:
): ):
self.last_consolidated = 0 self.last_consolidated = 0
@staticmethod
def _annotate_message_time(message: dict[str, Any], content: Any) -> Any:
"""Expose persisted turn timestamps to the model for relative-date reasoning.
Annotating *every* assistant turn trains the model (via in-context
demonstrations) to start its own replies with the same
``[Message Time: ...]`` prefix, which leaks metadata back to the user.
We therefore only annotate user turns. User-side stamps are enough to
pin adjacent assistant replies for relative-time reasoning, including
proactive messages the user replies to later.
"""
timestamp = message.get("timestamp")
if not timestamp or not isinstance(content, str):
return content
role = message.get("role")
if role != "user":
return content
return f"[Message Time: {timestamp}]\n{content}"
def add_message(self, role: str, content: str, **kwargs: Any) -> None: def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the session.""" """Add a message to the session."""
msg = { msg = {
@@ -153,7 +135,6 @@ class Session:
max_messages: int = 120, max_messages: int = 120,
*, *,
max_tokens: int = 0, max_tokens: int = 0,
include_timestamps: bool = False,
extend_to_user: bool = False, extend_to_user: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Return unconsolidated messages for LLM input. """Return unconsolidated messages for LLM input.
@@ -243,8 +224,6 @@ class Session:
if mcp_lines: if mcp_lines:
breadcrumbs = "\n".join(mcp_lines) breadcrumbs = "\n".join(mcp_lines)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
if include_timestamps:
content = self._annotate_message_time(message, content)
if role == "assistant" and isinstance(content, str) and not content.strip(): if role == "assistant" and isinstance(content, str) and not content.strip():
if not any(key in message for key in ("tool_calls", "reasoning_content", "thinking_blocks")): if not any(key in message for key in ("tool_calls", "reasoning_content", "thinking_blocks")):
continue continue
@@ -425,14 +404,53 @@ class SessionManager:
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem.""" """Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
return safe_filename(key.replace(":", "_")) return safe_filename(key.replace(":", "_"))
@staticmethod
def _storage_key(key: str) -> str:
"""Collision-resistant encoding for internal session storage filenames."""
return base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
@staticmethod
def _decode_storage_key(stem: str) -> str | None:
"""Reverse _storage_key(): decode a base64url (no-padding) stem back to the original key."""
try:
# Restore padding stripped by rstrip("=")
padding = 4 - len(stem) % 4
if padding != 4:
stem += "=" * padding
return base64.urlsafe_b64decode(stem).decode("utf-8")
except Exception:
return None
def _get_session_path(self, key: str) -> Path: def _get_session_path(self, key: str) -> Path:
"""Get the file path for a session.""" """Get the collision-resistant workspace path for a session."""
return self.sessions_dir / f"{self.safe_key(key)}.jsonl" return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
def _get_legacy_lossy_path(self, key: str) -> Path:
"""Previous workspace session path using lossy ':' to '_' replacement."""
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
def _get_legacy_session_path(self, key: str) -> Path: def _get_legacy_session_path(self, key: str) -> Path:
"""Legacy global session path (~/.nanobot/sessions/).""" """Legacy global session path (~/.nanobot/sessions/)."""
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl" return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
@staticmethod
def _stored_key_for_path(path: Path) -> str | None:
"""Read the stored session key from a JSONL metadata row, if present."""
try:
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
data = json.loads(line)
if data.get("_type") == "metadata":
stored_key = data.get("key")
return stored_key if isinstance(stored_key, str) else None
return None
except Exception:
return None
return None
def get_or_create(self, key: str) -> Session: def get_or_create(self, key: str) -> Session:
""" """
Get an existing session or create a new one. Get an existing session or create a new one.
@@ -457,13 +475,28 @@ class SessionManager:
"""Load a session from disk.""" """Load a session from disk."""
path = self._get_session_path(key) path = self._get_session_path(key)
if not path.exists(): if not path.exists():
legacy_path = self._get_legacy_session_path(key) fallback_paths = [
if legacy_path.exists(): (self._get_legacy_lossy_path(key), "legacy lossy path"),
(self._get_legacy_session_path(key), "legacy path"),
]
for fallback_path, description in fallback_paths:
if not fallback_path.exists():
continue
stored_key = self._stored_key_for_path(fallback_path)
if stored_key and stored_key != key:
logger.info(
"Skipping migration for {} from {} because it belongs to {}",
key,
description,
stored_key,
)
continue
try: try:
shutil.move(str(legacy_path), str(path)) shutil.move(str(fallback_path), str(path))
logger.info("Migrated session {} from legacy path", key) logger.info("Migrated session {} from {}", key, description)
except Exception: except Exception:
logger.exception("Failed to migrate session {}", key) logger.exception("Failed to migrate session {}", key)
break
if not path.exists(): if not path.exists():
return None return None
@@ -582,6 +615,7 @@ class SessionManager:
the most recent writes. the most recent writes.
""" """
path = self._get_session_path(session.key) path = self._get_session_path(session.key)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(".jsonl.tmp") tmp_path = path.with_suffix(".jsonl.tmp")
try: try:
@@ -645,7 +679,11 @@ class SessionManager:
Returns True if at least one JSONL file was found and unlinked. Returns True if at least one JSONL file was found and unlinked.
""" """
paths = [self._get_session_path(key), self._get_legacy_session_path(key)] paths = [
self._get_session_path(key),
self._get_legacy_lossy_path(key),
self._get_legacy_session_path(key),
]
self.invalidate(key) self.invalidate(key)
deleted = False deleted = False
for path in paths: for path in paths:
@@ -806,7 +844,8 @@ class SessionManager:
sessions = [] sessions = []
for path in self.sessions_dir.glob("*.jsonl"): for path in self.sessions_dir.glob("*.jsonl"):
fallback_key = path.stem.replace("_", ":", 1) decoded = self._decode_storage_key(path.stem)
fallback_key = decoded or path.stem.replace("_", ":", 1)
try: try:
# Read the metadata line and a small preview for session lists. # Read the metadata line and a small preview for session lists.
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
@@ -814,7 +853,7 @@ class SessionManager:
if first_line: if first_line:
data = json.loads(first_line) data = json.loads(first_line)
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1) key = data.get("key") or fallback_key
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
title = _metadata_title(metadata) title = _metadata_title(metadata)
preview = "" preview = ""
+3 -1
View File
@@ -5,7 +5,9 @@ description: Schedule reminders and recurring tasks.
# Cron # Cron
Use the `cron` tool to schedule reminders or recurring tasks. Use the `cron` tool to schedule reminders or recurring tasks that should report back to the originating chat/session when they run.
Do not use `cron` for periodic background checks that should stay quiet when there is nothing useful to report. For those, update `HEARTBEAT.md`; the protected heartbeat job runs those checks and only delivers results that pass the notification gate.
## Three Modes ## Three Modes
+2 -2
View File
@@ -26,7 +26,7 @@ Those belong to the execution phase after the marker is set.
- **`long_task`** — Register **one** sustained objective per thread. Call it promptly once the user has asked for a sustained task. The `goal` should follow the idempotent-goal rules below, but it should be produced quickly from the user's request—not after a long hidden planning pass. - **`long_task`** — Register **one** sustained objective per thread. Call it promptly once the user has asked for a sustained task. The `goal` should follow the idempotent-goal rules below, but it should be produced quickly from the user's request—not after a long hidden planning pass.
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace). - **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). For coding or file-producing tasks, include **`verification_summary`**, **`commands_run`**, and **`artifacts_created`** when possible; if stopping with known unresolved issues, fill **`remaining_failures`** honestly. Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals. If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals.
@@ -68,7 +68,7 @@ Use this when the goal is to **build or reshape a codebase** (app, service, tool
1. **Modular layout** — Split into **meaningful modules** (directories + files with clear responsibilities: entrypoints, domain logic, config, infra, CLI/UI routes, etc.). **Do not** default to dumping an entire project into one giant source file unless the user explicitly wants a minimal single-file artifact. 1. **Modular layout** — Split into **meaningful modules** (directories + files with clear responsibilities: entrypoints, domain logic, config, infra, CLI/UI routes, etc.). **Do not** default to dumping an entire project into one giant source file unless the user explicitly wants a minimal single-file artifact.
2. **Conventional structure** — Follow normal practice for that stack (separation of concerns, sensible naming, config vs code, reusable helpers). Aim for reviewable increments, not unreadable blobs. 2. **Conventional structure** — Follow normal practice for that stack (separation of concerns, sensible naming, config vs code, reusable helpers). Aim for reviewable increments, not unreadable blobs.
3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter. 3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter. Before `complete_goal`, run the smallest reliable verification you can and summarize it in `verification_summary`.
## Look things up instead of guessing ## Look things up instead of guessing
+2 -1
View File
@@ -9,6 +9,7 @@ Use this file for project-specific preferences, recurring workflow conventions,
- Before scheduling reminders, check available skills and follow skill guidance first. - Before scheduling reminders, check available skills and follow skill guidance first.
- Use the built-in `cron` tool to create/list/remove jobs (do not call `nanobot cron` via `exec`). - Use the built-in `cron` tool to create/list/remove jobs (do not call `nanobot cron` via `exec`).
- Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`). - Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
- Cron jobs run as scheduled turns in the origin chat/session and normally deliver the result back to that channel. Do not use cron for background checks that should stay silent when there is nothing useful to report; use `HEARTBEAT.md` instead.
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. **Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
@@ -20,4 +21,4 @@ Use this file for project-specific preferences, recurring workflow conventions,
- Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`. - Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
- Use `write_file` for first creation or intentional full-file rewrites. - Use `write_file` for first creation or intentional full-file rewrites.
When the user asks for a recurring/periodic heartbeat task, update `HEARTBEAT.md` instead of creating a one-time reminder. Use the built-in `cron` tool for separate reminders or custom schedules that should not be part of the heartbeat task list. When the user asks for a recurring/periodic heartbeat task, or for a periodic background check that should only notify on actionable changes, update `HEARTBEAT.md` instead of creating a one-time reminder. Use the built-in `cron` tool for explicit reminders, scheduled tasks that should report every run, or custom schedules that should not be part of the heartbeat task list.
+3 -1
View File
@@ -3,7 +3,9 @@
<!-- <!--
This file is checked periodically by your nanobot agent. When nanobot gateway starts with gateway.heartbeat.enabled=true, it automatically registers a protected heartbeat cron job that reads this file. This file is checked periodically by your nanobot agent. When nanobot gateway starts with gateway.heartbeat.enabled=true, it automatically registers a protected heartbeat cron job that reads this file.
If this file has no tasks (only headers and comments), the agent will skip it. Completed tasks should be deleted, not kept — heartbeat only reads "Active Tasks". Use this file for recurring background checks that should stay quiet unless there is something useful to report. Regular cron jobs are different: they normally deliver each run's result back to the chat/session where they were created.
If this file has no tasks (only headers and comments), the agent will skip it. Completed tasks should be deleted, not kept - heartbeat only reads "Active Tasks".
--> -->
## Active Tasks ## Active Tasks
+100 -21
View File
@@ -290,7 +290,8 @@ def current_time_str(timezone: str | None = None) -> str:
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]') _UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
_TOOL_RESULT_PREVIEW_CHARS = 1200 _TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS = 800
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS = 80
_TOOL_RESULTS_DIR = ".nanobot/tool-results" _TOOL_RESULTS_DIR = ".nanobot/tool-results"
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60 _TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
_TOOL_RESULT_MAX_BUCKETS = 32 _TOOL_RESULT_MAX_BUCKETS = 32
@@ -404,22 +405,106 @@ def stringify_text_blocks(content: list[dict[str, Any]]) -> str | None:
return "\n".join(parts) return "\n".join(parts)
def _render_tool_result_reference( def build_structured_output_summary(
filepath: Path, title: str,
text: str,
*, *,
original_size: int, max_chars: int,
preview: str, metadata: list[tuple[str, Any]] | None = None,
truncated_preview: bool, analysis: Any | None = None,
guidance: str | None = None,
) -> str: ) -> str:
result = ( """Return a compact, structured head/tail summary for oversized tool output."""
f"[tool output persisted]\n"
f"Full output saved to: {filepath}\n" if max_chars <= 0:
f"Original size: {original_size} chars\n" return text
f"Preview:\n{preview}" edge_chars = min(
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS,
max(_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS, max_chars // 3),
)
while True:
head = text[:edge_chars]
if len(text) > edge_chars * 2:
tail: str | None = text[-edge_chars:]
omitted_middle_chars = len(text) - len(head) - len(tail)
else:
tail = None
omitted_middle_chars = 0
result = _render_structured_output_summary(
title,
metadata=metadata or [],
guidance=guidance,
analysis=analysis,
head=head,
tail=tail,
omitted_middle_chars=omitted_middle_chars,
)
if len(result) <= max_chars or edge_chars <= _TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS:
return truncate_text(result, max_chars)
overflow = len(result) - max_chars
edge_chars = max(
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS,
edge_chars - max(overflow // 2 + 1, 16),
)
def _render_structured_output_summary(
title: str,
*,
metadata: list[tuple[str, Any]],
guidance: str | None,
analysis: Any | None,
head: str,
tail: str | None,
omitted_middle_chars: int,
) -> str:
lines = [title]
lines.extend(f"{key}: {value}" for key, value in metadata)
if omitted_middle_chars:
lines.append(f"truncation: {omitted_middle_chars:,} chars truncated from the middle")
if guidance:
lines.append(f"guidance: {guidance}")
lines.extend(_verification_summary_lines(analysis))
lines.extend(["head:", head])
if tail is not None:
lines.extend(["tail:", tail])
return "\n".join(lines)
def _verification_summary_lines(analysis: Any | None) -> list[str]:
if analysis is None or getattr(analysis, "status", None) != "failed":
return []
lines = ["verification_status: failed"]
if getattr(analysis, "timed_out", False):
lines.append("failure_type: command timeout")
if getattr(analysis, "failed_tests", ()):
lines.append("failed_tests:")
lines.extend(f"- {item}" for item in analysis.failed_tests)
if getattr(analysis, "primary_errors", ()):
lines.append("primary_errors:")
lines.extend(f"- {item}" for item in analysis.primary_errors)
if getattr(analysis, "missing_artifacts", ()):
lines.append("missing_artifacts:")
lines.extend(f"- {item}" for item in analysis.missing_artifacts)
return lines
def _build_tool_result_reference(filepath: Path, text: str, *, max_chars: int) -> str:
return build_structured_output_summary(
"[tool output persisted]",
text,
max_chars=max_chars,
metadata=[
("tool_output_id", filepath.stem),
("original_size_chars", len(text)),
("storage", "internal audit artifact"),
],
guidance=(
"Use this head/tail summary first. Avoid reading persisted "
"tool-output files wholesale; rerun a narrower command when "
"more detail is needed."
),
) )
if truncated_preview:
result += "\n...\n(Read the saved file if you need the full output.)"
return result
def _bucket_mtime(path: Path) -> float: def _bucket_mtime(path: Path) -> float:
@@ -494,13 +579,7 @@ def maybe_persist_tool_result(
else: else:
_write_text_atomic(path, text_payload) _write_text_atomic(path, text_payload)
preview = text_payload[:_TOOL_RESULT_PREVIEW_CHARS] return _build_tool_result_reference(path, text_payload, max_chars=max_chars)
return _render_tool_result_reference(
path,
original_size=len(text_payload),
preview=preview,
truncated_preview=len(text_payload) > _TOOL_RESULT_PREVIEW_CHARS,
)
def split_message(content: str, max_len: int = 2000) -> list[str]: def split_message(content: str, max_len: int = 2000) -> list[str]:
+40
View File
@@ -42,6 +42,27 @@ SUSTAINED_GOAL_CONTINUE_PROMPT = (
"objective using your tools, or call complete_goal if the work is truly finished." "objective using your tools, or call complete_goal if the work is truly finished."
) )
RUNTIME_BUDGET_CONVERGENCE_PROMPT = """\
[Runtime Budget Notice]
You have used {used_iterations} of {max_iterations} model/tool iterations for this turn. \
{remaining_iterations} iteration(s) remain before NanoBot must finalize without more tools.
Switch to convergence mode: stop broad exploration, choose the smallest high-signal command or edit, \
verify the likely solution, and preserve enough budget for a final answer. For coding or \
file-producing tasks, do not mark the work complete until the smallest reliable verification passes, \
or clearly state remaining failures.
[/Runtime Budget Notice]"""
RUNTIME_BUDGET_FINAL_PROMPT = """\
[Runtime Budget Notice]
Only {remaining_iterations} of {max_iterations} model/tool iteration(s) remain before NanoBot must \
finalize without more tools.
Finalize the solution path now: avoid new broad searches or builds unless essential, make the \
smallest final fix or artifact, run one targeted verification if possible, then answer honestly with \
the evidence or remaining failures.
[/Runtime Budget Notice]"""
def empty_tool_result_message(tool_name: str) -> str: def empty_tool_result_message(tool_name: str) -> str:
"""Short prompt-safe marker for tools that completed without visible output.""" """Short prompt-safe marker for tools that completed without visible output."""
@@ -88,6 +109,25 @@ def build_goal_continue_message(custom: str | None = None) -> dict[str, str]:
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT} return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
def build_runtime_budget_notice_message(
*,
level: int,
max_iterations: int,
used_iterations: int,
remaining_iterations: int,
) -> dict[str, str]:
"""Prompt the model to converge as the generic tool-iteration budget runs low."""
template = RUNTIME_BUDGET_FINAL_PROMPT if level >= 2 else RUNTIME_BUDGET_CONVERGENCE_PROMPT
return {
"role": "user",
"content": template.format(
max_iterations=max_iterations,
used_iterations=used_iterations,
remaining_iterations=remaining_iterations,
),
}
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None: def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
"""Stable signature for repeated external lookups we want to throttle.""" """Stable signature for repeated external lookups we want to throttle."""
if not isinstance(arguments, dict): if not isinstance(arguments, dict):
+2 -1
View File
@@ -232,7 +232,8 @@ def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None: def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
fallback_key = path.stem.replace("_", ":", 1) storage_key = SessionManager._decode_storage_key(path.stem)
fallback_key = storage_key or path.stem.replace("_", ":", 1)
try: try:
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
first_line = f.readline().strip() first_line = f.readline().strip()
+4 -15
View File
@@ -16,6 +16,7 @@ from zoneinfo import ZoneInfo
import httpx import httpx
from nanobot import __version__ from nanobot import __version__
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
from nanobot.audio.transcription import resolve_transcription_config from nanobot.audio.transcription import resolve_transcription_config
from nanobot.audio.transcription_registry import ( from nanobot.audio.transcription_registry import (
resolve_transcription_provider, resolve_transcription_provider,
@@ -79,19 +80,7 @@ _NATIVE_RESTART_BEHAVIOR_BY_SECTION = {
"apps": "engineRestart", "apps": "engineRestart",
} }
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( _WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
{"name": "tavily", "label": "Tavily", "credential": "api_key"},
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
{"name": "jina", "label": "Jina", "credential": "api_key"},
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
{"name": "exa", "label": "Exa", "credential": "api_key"},
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
{"name": "bocha", "label": "Bocha", "credential": "api_key"},
{"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"},
{"name": "keenable", "label": "Keenable", "credential": "optional_api_key"},
)
_WEB_SEARCH_PROVIDER_BY_NAME = { _WEB_SEARCH_PROVIDER_BY_NAME = {
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
} }
@@ -370,7 +359,7 @@ def _resolve_settings_provider(
normalized = provider_name.replace("-", "_") normalized = provider_name.replace("-", "_")
for extra_name, provider_config in _dynamic_provider_items(config): for extra_name, provider_config in _dynamic_provider_items(config):
if provider_name == extra_name or normalized == extra_name.replace("-", "_"): if provider_name == extra_name or normalized == extra_name.replace("-", "_"):
return create_dynamic_spec(extra_name), extra_name, provider_config return create_dynamic_spec(extra_name, thinking_style=(provider_config.thinking_style or "")), extra_name, provider_config
return None return None
@@ -750,7 +739,7 @@ def settings_payload(
providers.append( providers.append(
_provider_settings_row( _provider_settings_row(
provider_key, provider_key,
create_dynamic_spec(provider_key), create_dynamic_spec(provider_key, thinking_style=(provider_config.thinking_style or "")),
provider_config, provider_config,
) )
) )
+5 -4
View File
@@ -93,6 +93,10 @@ matrix = [
discord = [ discord = [
"discord.py>=2.5.2,<3.0.0", "discord.py>=2.5.2,<3.0.0",
] ]
whatsapp = [
"neonize>=0.3.18.post0,<0.4.0",
"segno>=1.6.1,<2.0.0",
]
langsmith = [ langsmith = [
"langsmith>=0.1.0", "langsmith>=0.1.0",
] ]
@@ -150,14 +154,10 @@ packages = ["nanobot"]
[tool.hatch.build.targets.wheel.sources] [tool.hatch.build.targets.wheel.sources]
"nanobot" = "nanobot" "nanobot" = "nanobot"
[tool.hatch.build.targets.wheel.force-include]
"bridge" = "nanobot/bridge"
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
include = [ include = [
"nanobot/", "nanobot/",
"nanobot/web/dist/", "nanobot/web/dist/",
"bridge/",
"hatch_build.py", "hatch_build.py",
"README.md", "README.md",
"LICENSE", "LICENSE",
@@ -182,6 +182,7 @@ source = ["nanobot"]
omit = ["tests/*", "**/tests/*"] omit = ["tests/*", "**/tests/*"]
[tool.coverage.report] [tool.coverage.report]
fail_under = 75
exclude_lines = [ exclude_lines = [
"pragma: no cover", "pragma: no cover",
"def __repr__", "def __repr__",
+1 -1
View File
@@ -223,7 +223,7 @@ class TestAgentLoopTTLParam:
kwargs = session.get_history.call_args.kwargs kwargs = session.get_history.call_args.kwargs
assert isinstance(kwargs.get("max_tokens"), int) assert isinstance(kwargs.get("max_tokens"), int)
assert kwargs["max_tokens"] > 0 assert kwargs["max_tokens"] > 0
assert kwargs["include_timestamps"] is True assert set(kwargs) == {"max_messages", "max_tokens", "extend_to_user"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path): async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path):
+6
View File
@@ -24,9 +24,14 @@ class TestDreamSessionKey:
class TestPruneDreamSessions: class TestPruneDreamSessions:
def test_keeps_n_most_recent(self, tmp_path): def test_keeps_n_most_recent(self, tmp_path):
import os
import time
sessions_dir = tmp_path / "sessions" sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir() sessions_dir.mkdir()
base_time = time.time() - 100
for i in range(15): for i in range(15):
key = f"dream:20260528-{100000 + i:06d}" key = f"dream:20260528-{100000 + i:06d}"
safe_key = key.replace(":", "_") safe_key = key.replace(":", "_")
@@ -37,6 +42,7 @@ class TestPruneDreamSessions:
f'"updated_at": "2026-05-28T10:00:{i:02d}"}}\n', f'"updated_at": "2026-05-28T10:00:{i:02d}"}}\n',
encoding="utf-8", encoding="utf-8",
) )
os.utime(path, (base_time + i, base_time + i))
normal_path = sessions_dir / "telegram_123.jsonl" normal_path = sessions_dir / "telegram_123.jsonl"
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8") normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
+41 -1
View File
@@ -11,7 +11,6 @@ from unittest.mock import patch
from nanobot.providers.base import ToolCallRequest from nanobot.providers.base import ToolCallRequest
from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider
GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}} GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}}
@@ -125,6 +124,47 @@ def test_parse_dict_preserves_extra_content() -> None:
assert payload["extra_content"] == GEMINI_EXTRA assert payload["extra_content"] == GEMINI_EXTRA
def test_parse_dict_deduplicates_duplicate_tool_call_ids() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response_dict = {
"choices": [
{
"message": {
"content": None,
"tool_calls": [{
"id": "call_same",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
}],
},
"finish_reason": "tool_calls",
},
{
"message": {
"content": None,
"tool_calls": [{
"id": "call_same",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
}],
},
"finish_reason": "tool_calls",
},
],
}
result = provider._parse(response_dict)
ids = [tc.id for tc in result.tool_calls]
assert len(ids) == 2
assert ids[0] == "call_same"
assert ids[1] != "call_same"
assert len(set(ids)) == 2
assert [tc.arguments for tc in result.tool_calls] == [{"path": "a.txt"}, {"path": "b.txt"}]
# ── _parse_chunks: streaming round-trip ─────────────────────────────── # ── _parse_chunks: streaming round-trip ───────────────────────────────
def test_parse_chunks_sdk_preserves_extra_content() -> None: def test_parse_chunks_sdk_preserves_extra_content() -> None:
+3 -5
View File
@@ -1222,11 +1222,9 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
non_system = [m for m in seen["initial_messages"] if m.get("role") != "system"] non_system = [m for m in seen["initial_messages"] if m.get("role") != "system"]
assert "question" in non_system[0]["content"] assert "question" in non_system[0]["content"]
assert "working" in non_system[1]["content"] assert "working" in non_system[1]["content"]
# User turns carry the timestamp prefix so the model can reason about # Persisted timestamps stay in session records, but replay content is not
# relative time. Assistant turns do NOT, otherwise the model treats those # rewritten with volatile ``[Message Time: ...]`` prefixes.
# past replies as in-context examples and starts its own outputs with assert "[Message Time:" not in non_system[0]["content"]
# ``[Message Time: ...]`` (which then leaks back to the user).
assert "[Message Time:" in non_system[0]["content"]
assert "[Message Time:" not in non_system[1]["content"] assert "[Message Time:" not in non_system[1]["content"]
assert non_system[2]["content"].count("subagent result") == 1 assert non_system[2]["content"].count("subagent result") == 1
assert "Current Time:" in non_system[2]["content"] assert "Current Time:" in non_system[2]["content"]
+21
View File
@@ -330,6 +330,27 @@ class TestDreamCursor:
def test_initial_cursor_is_zero(self, store): def test_initial_cursor_is_zero(self, store):
assert store.get_last_dream_cursor() == 0 assert store.get_last_dream_cursor() == 0
def test_returns_zero_when_empty(self, store):
assert store.get_latest_cursor() == 0
def test_returns_cursor_of_last_entry(self, store):
store.append_history("event 1")
store.append_history("event 2")
store.append_history("event 3")
assert store.get_latest_cursor() == 3
def test_returns_zero_when_no_entries(self, store):
store.history_file.write_text("", encoding="utf-8")
assert store.get_latest_cursor() == 0
def test_matches_next_cursor_minus_one(self, store):
store.append_history("event 1")
store.append_history("event 2")
assert store.get_latest_cursor() == max(store._next_cursor() - 1, 0)
def test_set_and_get_cursor(self, store): def test_set_and_get_cursor(self, store):
store.set_last_dream_cursor(5) store.set_last_dream_cursor(5)
assert store.get_last_dream_cursor() == 5 assert store.get_last_dream_cursor() == 5
+24
View File
@@ -1998,3 +1998,27 @@ class TestModelPresetWizard:
defaults = AgentDefaults() defaults = AgentDefaults()
_handle_provider_field(defaults, "provider", "Provider", "auto") _handle_provider_field(defaults, "provider", "Provider", "auto")
assert defaults.provider == "anthropic" assert defaults.provider == "anthropic"
def test_search_provider_field_handler(self, monkeypatch):
"""_handle_search_provider_field should set the search engine from choices."""
from nanobot.agent.tools.web import WebSearchConfig
from nanobot.cli.onboard import _handle_search_provider_field
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "keenable")
cfg = WebSearchConfig()
_handle_search_provider_field(cfg, "provider", "Provider", "duckduckgo")
assert cfg.provider == "keenable"
def test_provider_field_dispatch_is_model_type_aware(self):
"""WebSearchConfig.provider must not be hijacked by the LLM provider handler."""
from nanobot.agent.tools.web import WebSearchConfig
from nanobot.cli.onboard import (
_handle_provider_field,
_handle_search_provider_field,
_resolve_field_handler,
)
from nanobot.config.schema import AgentDefaults
assert _resolve_field_handler(WebSearchConfig(), "provider") is _handle_search_provider_field
assert _resolve_field_handler(AgentDefaults(), "provider") is _handle_provider_field
+295 -113
View File
@@ -2,16 +2,45 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from nanobot.agent.context_governance import (
BACKFILL_CONTENT,
MICROCOMPACT_KEEP_RECENT,
ContextGovernanceConfig,
ContextGovernor,
)
from nanobot.agent.runner import AgentRunSpec
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
def _governance_config(
provider,
tools,
spec: AgentRunSpec,
*,
inflight_start_index: int = 0,
) -> ContextGovernanceConfig:
return ContextGovernanceConfig(
provider=provider,
model=spec.model,
tools=tools,
workspace=spec.workspace,
session_key=spec.session_key,
max_tool_result_chars=spec.max_tool_result_chars,
context_window_tokens=spec.context_window_tokens,
context_block_limit=spec.context_block_limit,
max_tokens=spec.max_tokens,
inflight_start_index=inflight_start_index,
)
def _make_loop(tmp_path): def _make_loop(tmp_path):
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@@ -22,13 +51,14 @@ def _make_loop(tmp_path):
with patch("nanobot.agent.loop.ContextBuilder"), \ with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \ patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path) loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
return loop return loop
async def test_runner_uses_raw_messages_when_context_governance_fails(): async def test_runner_uses_raw_messages_when_context_governance_fails():
from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.runner import AgentRunner
provider = MagicMock() provider = MagicMock()
captured_messages: list[dict] = [] captured_messages: list[dict] = []
@@ -46,7 +76,9 @@ async def test_runner_uses_raw_messages_when_context_governance_fails():
] ]
runner = AgentRunner(provider) runner = AgentRunner(provider)
runner._snip_history = MagicMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] runner.context_governor.prepare_for_model = MagicMock( # type: ignore[method-assign]
side_effect=RuntimeError("boom")
)
result = await runner.run(AgentRunSpec( result = await runner.run(AgentRunSpec(
initial_messages=initial_messages, initial_messages=initial_messages,
tools=tools, tools=tools,
@@ -57,13 +89,12 @@ async def test_runner_uses_raw_messages_when_context_governance_fails():
assert result.final_content == "done" assert result.final_content == "done"
assert captured_messages == initial_messages assert captured_messages == initial_messages
def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch):
from nanobot.agent.runner import AgentRunSpec, AgentRunner
def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch):
provider = MagicMock() provider = MagicMock()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider)
messages = [ messages = [
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
{"role": "user", "content": "old user"}, {"role": "user", "content": "old user"},
@@ -85,7 +116,10 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch
context_block_limit=100, context_block_limit=100,
) )
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_args, **_kwargs: (500, None)) monkeypatch.setattr(
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
lambda *_args, **_kwargs: (500, None),
)
token_sizes = { token_sizes = {
"old user": 120, "old user": 120,
"tool call": 120, "tool call": 120,
@@ -94,11 +128,11 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch
"system": 0, "system": 0,
} }
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.agent.runner.estimate_message_tokens", "nanobot.agent.context_governance.estimate_message_tokens",
lambda msg: token_sizes.get(str(msg.get("content")), 40), lambda msg: token_sizes.get(str(msg.get("content")), 40),
) )
trimmed = runner._snip_history(spec, messages) trimmed = ContextGovernor().snip_history(_governance_config(provider, tools, spec), messages)
# After the fix, the user message is recovered so the sequence is valid # After the fix, the user message is recovered so the sequence is valid
# for providers that require system → user (e.g. GLM error 1214). # for providers that require system → user (e.g. GLM error 1214).
@@ -108,12 +142,9 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch
def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch): def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch):
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [{"type": "function", "function": {"name": "large_tool"}}] tools.get_definitions.return_value = [{"type": "function", "function": {"name": "large_tool"}}]
runner = AgentRunner(provider)
messages = [ messages = [
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
{"role": "user", "content": "old user"}, {"role": "user", "content": "old user"},
@@ -139,7 +170,7 @@ def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch):
assert estimate_tools == tools.get_definitions.return_value assert estimate_tools == tools.get_definitions.return_value
return 350, None return 350, None
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", _estimate) monkeypatch.setattr("nanobot.agent.context_governance.estimate_prompt_tokens_chain", _estimate)
token_sizes = { token_sizes = {
"system": 50, "system": 50,
"old user": 200, "old user": 200,
@@ -149,11 +180,11 @@ def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch):
"recent two": 200, "recent two": 200,
} }
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.agent.runner.estimate_message_tokens", "nanobot.agent.context_governance.estimate_message_tokens",
lambda msg: token_sizes.get(str(msg.get("content")), 40), lambda msg: token_sizes.get(str(msg.get("content")), 40),
) )
trimmed = runner._snip_history(spec, messages) trimmed = ContextGovernor().snip_history(_governance_config(provider, tools, spec), messages)
contents = [message.get("content") for message in trimmed] contents = [message.get("content") for message in trimmed]
assert contents == ["system", "recent two"] assert contents == ["system", "recent two"]
@@ -161,7 +192,6 @@ def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch):
async def test_backfill_missing_tool_results_inserts_error(): async def test_backfill_missing_tool_results_inserts_error():
"""Orphaned tool_use (no matching tool_result) should get a synthetic error.""" """Orphaned tool_use (no matching tool_result) should get a synthetic error."""
from nanobot.agent.runner import AgentRunner, _BACKFILL_CONTENT
messages = [ messages = [
{"role": "user", "content": "hi"}, {"role": "user", "content": "hi"},
@@ -175,18 +205,16 @@ async def test_backfill_missing_tool_results_inserts_error():
}, },
{"role": "tool", "tool_call_id": "call_a", "name": "exec", "content": "ok"}, {"role": "tool", "tool_call_id": "call_a", "name": "exec", "content": "ok"},
] ]
result = AgentRunner._backfill_missing_tool_results(messages) result = ContextGovernor.backfill_missing_tool_results(messages)
tool_msgs = [m for m in result if m.get("role") == "tool"] tool_msgs = [m for m in result if m.get("role") == "tool"]
assert len(tool_msgs) == 2 assert len(tool_msgs) == 2
backfilled = [m for m in tool_msgs if m.get("tool_call_id") == "call_b"] backfilled = [m for m in tool_msgs if m.get("tool_call_id") == "call_b"]
assert len(backfilled) == 1 assert len(backfilled) == 1
assert backfilled[0]["content"] == _BACKFILL_CONTENT assert backfilled[0]["content"] == BACKFILL_CONTENT
assert backfilled[0]["name"] == "read_file" assert backfilled[0]["name"] == "read_file"
def test_drop_orphan_tool_results_removes_unmatched_tool_messages(): def test_drop_orphan_tool_results_removes_unmatched_tool_messages():
from nanobot.agent.runner import AgentRunner
messages = [ messages = [
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
{"role": "user", "content": "old user"}, {"role": "user", "content": "old user"},
@@ -202,7 +230,7 @@ def test_drop_orphan_tool_results_removes_unmatched_tool_messages():
{"role": "assistant", "content": "after tool"}, {"role": "assistant", "content": "after tool"},
] ]
cleaned = AgentRunner._drop_orphan_tool_results(messages) cleaned = ContextGovernor.drop_orphan_tool_results(messages)
assert cleaned == [ assert cleaned == [
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
@@ -222,8 +250,6 @@ def test_drop_orphan_tool_results_removes_unmatched_tool_messages():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_backfill_noop_when_complete(): async def test_backfill_noop_when_complete():
"""Complete message chains should not be modified.""" """Complete message chains should not be modified."""
from nanobot.agent.runner import AgentRunner
messages = [ messages = [
{"role": "user", "content": "hi"}, {"role": "user", "content": "hi"},
{ {
@@ -236,13 +262,13 @@ async def test_backfill_noop_when_complete():
{"role": "tool", "tool_call_id": "call_x", "name": "exec", "content": "done"}, {"role": "tool", "tool_call_id": "call_x", "name": "exec", "content": "done"},
{"role": "assistant", "content": "all good"}, {"role": "assistant", "content": "all good"},
] ]
result = AgentRunner._backfill_missing_tool_results(messages) result = ContextGovernor.backfill_missing_tool_results(messages)
assert result is messages # same object — no copy assert result is messages # same object — no copy
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_drops_orphan_tool_results_before_model_request(): async def test_runner_drops_orphan_tool_results_before_model_request():
from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.runner import AgentRunner
provider = MagicMock() provider = MagicMock()
captured_messages: list[dict] = [] captured_messages: list[dict] = []
@@ -283,7 +309,6 @@ async def test_runner_drops_orphan_tool_results_before_model_request():
async def test_backfill_repairs_model_context_without_shifting_save_turn_boundary(tmp_path): async def test_backfill_repairs_model_context_without_shifting_save_turn_boundary(tmp_path):
"""Historical backfill should not duplicate old tail messages on persist.""" """Historical backfill should not duplicate old tail messages on persist."""
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.runner import _BACKFILL_CONTENT
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@@ -335,7 +360,7 @@ async def test_backfill_repairs_model_context_without_shifting_save_turn_boundar
if message.get("role") == "tool" and message.get("tool_call_id") == "call_missing" if message.get("role") == "tool" and message.get("tool_call_id") == "call_missing"
] ]
assert len(synthetic) == 1 assert len(synthetic) == 1
assert synthetic[0]["content"] == _BACKFILL_CONTENT assert synthetic[0]["content"] == BACKFILL_CONTENT
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert [ assert [
@@ -367,7 +392,7 @@ async def test_backfill_repairs_model_context_without_shifting_save_turn_boundar
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_backfill_only_mutates_model_context_not_returned_messages(): async def test_runner_backfill_only_mutates_model_context_not_returned_messages():
"""Runner should repair orphaned tool calls for the model without rewriting result.messages.""" """Runner should repair orphaned tool calls for the model without rewriting result.messages."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner, _BACKFILL_CONTENT from nanobot.agent.runner import AgentRunner
provider = MagicMock() provider = MagicMock()
captured_messages: list[dict] = [] captured_messages: list[dict] = []
@@ -413,7 +438,7 @@ async def test_runner_backfill_only_mutates_model_context_not_returned_messages(
if message.get("role") == "tool" and message.get("tool_call_id") == "call_missing" if message.get("role") == "tool" and message.get("tool_call_id") == "call_missing"
] ]
assert len(synthetic) == 1 assert len(synthetic) == 1
assert synthetic[0]["content"] == _BACKFILL_CONTENT assert synthetic[0]["content"] == BACKFILL_CONTENT
assert [ assert [
{ {
@@ -447,96 +472,254 @@ async def test_runner_backfill_only_mutates_model_context_not_returned_messages(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio def _microcompact_messages(*, total: int, tool_name: str, content: str) -> list[dict]:
async def test_microcompact_replaces_old_tool_results():
"""Tool results beyond _MICROCOMPACT_KEEP_RECENT should be summarized."""
from nanobot.agent.runner import AgentRunner, _MICROCOMPACT_KEEP_RECENT
total = _MICROCOMPACT_KEEP_RECENT + 5
long_content = "x" * 600
messages: list[dict] = [{"role": "system", "content": "sys"}] messages: list[dict] = [{"role": "system", "content": "sys"}]
for i in range(total): for i in range(total):
messages.append({ messages.append({
"role": "assistant", "role": "assistant",
"content": "", "content": "",
"tool_calls": [{"id": f"c{i}", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}], "tool_calls": [{
"id": f"c{i}",
"type": "function",
"function": {"name": tool_name, "arguments": "{}"},
}],
}) })
messages.append({ messages.append({
"role": "tool", "tool_call_id": f"c{i}", "name": "read_file", "role": "tool",
"content": long_content, "tool_call_id": f"c{i}",
"name": tool_name,
"content": content,
}) })
return messages
result = AgentRunner._microcompact(messages)
def test_microcompact_skips_when_prompt_under_hard_budget(monkeypatch):
"""Cache-friendly path: in-flight tool results stay stable while prompt fits."""
provider = MagicMock()
provider.generation = SimpleNamespace(max_tokens=0)
tools = MagicMock()
tools.get_definitions.return_value = []
total = MICROCOMPACT_KEEP_RECENT + 5
long_content = "x" * 600
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
spec = AgentRunSpec(
initial_messages=messages,
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
max_tokens=0,
context_window_tokens=20_000,
)
monkeypatch.setattr(
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
lambda *_args, **_kwargs: (1000, "test"),
)
result = ContextGovernor().compact_inflight_overflow(
_governance_config(provider, tools, spec),
messages,
set(),
)
assert result is messages
def test_microcompact_overflow_compacts_to_low_watermark(monkeypatch):
"""Overflow path: compact in-flight stale results with headroom for later calls."""
provider = MagicMock()
provider.generation = SimpleNamespace(max_tokens=0)
tools = MagicMock()
tools.get_definitions.return_value = []
total = MICROCOMPACT_KEEP_RECENT + 8
long_content = "x" * 600
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
spec = AgentRunSpec(
initial_messages=messages,
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
max_tokens=0,
context_window_tokens=2224, # input budget 1200, low target 1020
)
def estimate(_provider, _model, msgs, _tools):
return sum(
100 if (content := msg.get("content")) == long_content
else 1 if isinstance(content, str) and "omitted from context" in content
else 0
for msg in msgs
if msg.get("role") == "tool"
), "test"
monkeypatch.setattr("nanobot.agent.context_governance.estimate_prompt_tokens_chain", estimate)
result = ContextGovernor().compact_inflight_overflow(
_governance_config(provider, tools, spec),
messages,
set(),
)
tool_msgs = [m for m in result if m.get("role") == "tool"] tool_msgs = [m for m in result if m.get("role") == "tool"]
stale_count = total - _MICROCOMPACT_KEEP_RECENT
compacted = [m for m in tool_msgs if "omitted from context" in str(m.get("content", ""))] compacted = [m for m in tool_msgs if "omitted from context" in str(m.get("content", ""))]
preserved = [m for m in tool_msgs if m.get("content") == long_content] preserved = [m for m in tool_msgs if m.get("content") == long_content]
assert len(compacted) == stale_count
assert len(preserved) == _MICROCOMPACT_KEEP_RECENT assert len(compacted) == 8
assert len(preserved) == total - 8
assert [m["tool_call_id"] for m in compacted] == [f"c{i}" for i in range(8)]
@pytest.mark.asyncio def test_microcompact_compacts_newest_when_it_alone_overflows(monkeypatch):
async def test_microcompact_preserves_short_results(): """The newest result is preserved only while the request can still fit."""
"""Short tool results (< _MICROCOMPACT_MIN_CHARS) should not be replaced.""" provider = MagicMock()
from nanobot.agent.runner import AgentRunner, _MICROCOMPACT_KEEP_RECENT provider.generation = SimpleNamespace(max_tokens=0)
tools = MagicMock()
tools.get_definitions.return_value = []
total = _MICROCOMPACT_KEEP_RECENT + 5 long_content = "x" * 600
messages: list[dict] = [] messages = _microcompact_messages(total=1, tool_name="read_file", content=long_content)
for i in range(total): spec = AgentRunSpec(
messages.append({ initial_messages=messages,
"role": "assistant", tools=tools,
"content": "", model="test-model",
"tool_calls": [{"id": f"c{i}", "type": "function", "function": {"name": "exec", "arguments": "{}"}}], max_iterations=1,
}) max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
messages.append({ max_tokens=0,
"role": "tool", "tool_call_id": f"c{i}", "name": "exec", context_window_tokens=2000,
"content": "short", context_block_limit=500,
}) )
result = AgentRunner._microcompact(messages) def estimate(_provider, _model, msgs, _tools):
return sum(
1000 if msg.get("content") == long_content else 1
for msg in msgs
if msg.get("role") == "tool"
), "test"
monkeypatch.setattr("nanobot.agent.context_governance.estimate_prompt_tokens_chain", estimate)
compacted_tool_call_ids: set[str] = set()
result = ContextGovernor().compact_inflight_overflow(
_governance_config(provider, tools, spec),
messages,
compacted_tool_call_ids,
)
tool_msg = next(m for m in result if m.get("role") == "tool")
assert "omitted from context" in tool_msg["content"]
assert compacted_tool_call_ids == {"c0"}
def test_context_governor_keeps_compaction_boundary_stable(monkeypatch):
provider = MagicMock()
provider.generation = SimpleNamespace(max_tokens=0)
tools = MagicMock()
tools.get_definitions.return_value = []
total = MICROCOMPACT_KEEP_RECENT + 8
long_content = "x" * 600
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
spec = AgentRunSpec(
initial_messages=messages,
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
max_tokens=0,
context_window_tokens=2224,
)
def estimate(_provider, _model, msgs, _tools):
return sum(
100 if msg.get("content") == long_content else 1
for msg in msgs
if msg.get("role") == "tool"
), "test"
monkeypatch.setattr("nanobot.agent.context_governance.estimate_prompt_tokens_chain", estimate)
governor = ContextGovernor()
compacted_tool_call_ids: set[str] = set()
config = _governance_config(provider, tools, spec, inflight_start_index=0)
first = governor.compact_inflight_overflow(config, messages, compacted_tool_call_ids)
first_ids = set(compacted_tool_call_ids)
second = governor.compact_inflight_overflow(config, messages, compacted_tool_call_ids)
assert compacted_tool_call_ids == first_ids
assert [m.get("content") for m in second] == [m.get("content") for m in first]
def test_microcompact_preserves_short_results(monkeypatch):
"""Short tool results below the compaction threshold should not be replaced."""
provider = MagicMock()
provider.generation = SimpleNamespace(max_tokens=0)
tools = MagicMock()
tools.get_definitions.return_value = []
total = MICROCOMPACT_KEEP_RECENT + 5
messages = _microcompact_messages(total=total, tool_name="exec", content="short")
spec = AgentRunSpec(
initial_messages=messages,
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
max_tokens=0,
context_window_tokens=2024,
)
monkeypatch.setattr(
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
lambda *_args, **_kwargs: (2000, "test"),
)
result = ContextGovernor().compact_inflight_overflow(
_governance_config(provider, tools, spec),
messages,
set(),
)
assert result is messages # no copy needed — all stale results are short assert result is messages # no copy needed — all stale results are short
@pytest.mark.asyncio def test_microcompact_skips_non_compactable_tools(monkeypatch):
async def test_microcompact_skips_non_compactable_tools():
"""Non-compactable tools (e.g. 'message') should never be replaced.""" """Non-compactable tools (e.g. 'message') should never be replaced."""
from nanobot.agent.runner import AgentRunner, _MICROCOMPACT_KEEP_RECENT provider = MagicMock()
provider.generation = SimpleNamespace(max_tokens=0)
tools = MagicMock()
tools.get_definitions.return_value = []
total = _MICROCOMPACT_KEEP_RECENT + 5 total = MICROCOMPACT_KEEP_RECENT + 5
long_content = "y" * 1000 long_content = "y" * 1000
messages: list[dict] = [] messages = _microcompact_messages(total=total, tool_name="message", content=long_content)
for i in range(total): spec = AgentRunSpec(
messages.append({ initial_messages=messages,
"role": "assistant", tools=tools,
"content": "", model="test-model",
"tool_calls": [{"id": f"c{i}", "type": "function", "function": {"name": "message", "arguments": "{}"}}], max_iterations=1,
}) max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
messages.append({ max_tokens=0,
"role": "tool", "tool_call_id": f"c{i}", "name": "message", context_window_tokens=2024,
"content": long_content, )
})
result = AgentRunner._microcompact(messages) monkeypatch.setattr(
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
lambda *_args, **_kwargs: (2000, "test"),
)
result = ContextGovernor().compact_inflight_overflow(
_governance_config(provider, tools, spec),
messages,
set(),
)
assert result is messages # no compactable tools found assert result is messages # no compactable tools found
def test_governance_repairs_orphans_after_snip(): def test_governance_repairs_orphans_after_snip():
"""After _snip_history clips an assistant+tool_calls, the second """After snipping clips an assistant+tool_calls, orphan repair cleans up the tail."""
_drop_orphan_tool_results pass must clean up the resulting orphans."""
from nanobot.agent.runner import AgentRunner
messages = [
{"role": "system", "content": "system"},
{"role": "user", "content": "old msg"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "tc_old", "type": "function",
"function": {"name": "search", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "tc_old", "name": "search",
"content": "old result"},
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "new msg"},
]
# Simulate snipping that keeps only the tail: drop the assistant with # Simulate snipping that keeps only the tail: drop the assistant with
# tool_calls but keep its tool result (orphan). # tool_calls but keep its tool result (orphan).
snipped = [ snipped = [
@@ -547,7 +730,7 @@ def test_governance_repairs_orphans_after_snip():
{"role": "user", "content": "new msg"}, {"role": "user", "content": "new msg"},
] ]
cleaned = AgentRunner._drop_orphan_tool_results(snipped) cleaned = ContextGovernor.drop_orphan_tool_results(snipped)
# The orphan tool result should be removed. # The orphan tool result should be removed.
assert not any( assert not any(
m.get("role") == "tool" and m.get("tool_call_id") == "tc_old" m.get("role") == "tool" and m.get("tool_call_id") == "tc_old"
@@ -556,10 +739,7 @@ def test_governance_repairs_orphans_after_snip():
def test_governance_fallback_still_repairs_orphans(): def test_governance_fallback_still_repairs_orphans():
"""When full governance fails, the fallback must still run """When full governance fails, the fallback must still repair orphans."""
_drop_orphan_tool_results and _backfill_missing_tool_results."""
from nanobot.agent.runner import AgentRunner
# Messages with an orphan tool result (no matching assistant tool_call). # Messages with an orphan tool result (no matching assistant tool_call).
messages = [ messages = [
{"role": "user", "content": "hello"}, {"role": "user", "content": "hello"},
@@ -568,10 +748,12 @@ def test_governance_fallback_still_repairs_orphans():
{"role": "assistant", "content": "hi"}, {"role": "assistant", "content": "hi"},
] ]
repaired = AgentRunner._drop_orphan_tool_results(messages) repaired = ContextGovernor.drop_orphan_tool_results(messages)
repaired = AgentRunner._backfill_missing_tool_results(repaired) repaired = ContextGovernor.backfill_missing_tool_results(repaired)
# Orphan tool result should be gone. # Orphan tool result should be gone.
assert not any(m.get("tool_call_id") == "orphan_tc" for m in repaired) assert not any(m.get("tool_call_id") == "orphan_tc" for m in repaired)
def test_snip_history_preserves_user_message_after_truncation(monkeypatch): def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
"""When _snip_history truncates messages and the only user message ends up """When _snip_history truncates messages and the only user message ends up
outside the kept window, the method must recover the nearest user message outside the kept window, the method must recover the nearest user message
@@ -585,12 +767,9 @@ def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
- _snip_history activates, keeping only recent assistant/tool pairs. - _snip_history activates, keeping only recent assistant/tool pairs.
- The injected user message is in the truncated prefix and gets lost. - The injected user message is in the truncated prefix and gets lost.
""" """
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider)
messages = [ messages = [
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
@@ -621,7 +800,10 @@ def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
) )
# Make estimate_prompt_tokens_chain report above budget so _snip_history activates. # Make estimate_prompt_tokens_chain report above budget so _snip_history activates.
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None)) monkeypatch.setattr(
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
lambda *_a, **_kw: (500, None),
)
# Make kept window small: only the last 2 messages fit the budget. # Make kept window small: only the last 2 messages fit the budget.
token_sizes = { token_sizes = {
"system": 0, "system": 0,
@@ -631,11 +813,11 @@ def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
"tool output 2": 80, "tool output 2": 80,
} }
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.agent.runner.estimate_message_tokens", "nanobot.agent.context_governance.estimate_message_tokens",
lambda msg: token_sizes.get(str(msg.get("content")), 100), lambda msg: token_sizes.get(str(msg.get("content")), 100),
) )
trimmed = runner._snip_history(spec, messages) trimmed = ContextGovernor().snip_history(_governance_config(provider, tools, spec), messages)
# The first non-system message MUST be user (not assistant). # The first non-system message MUST be user (not assistant).
non_system = [m for m in trimmed if m.get("role") != "system"] non_system = [m for m in trimmed if m.get("role") != "system"]
@@ -649,12 +831,9 @@ def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch): def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch):
"""Edge case: if non_system has zero user messages, _snip_history should """Edge case: if non_system has zero user messages, _snip_history should
still return a valid sequence (not crash or produce systemassistant).""" still return a valid sequence (not crash or produce systemassistant)."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
provider = MagicMock() provider = MagicMock()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider)
messages = [ messages = [
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
@@ -674,13 +853,16 @@ def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch):
context_block_limit=100, context_block_limit=100,
) )
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None))
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.agent.runner.estimate_message_tokens", "nanobot.agent.context_governance.estimate_prompt_tokens_chain",
lambda *_a, **_kw: (500, None),
)
monkeypatch.setattr(
"nanobot.agent.context_governance.estimate_message_tokens",
lambda msg: 100, lambda msg: 100,
) )
trimmed = runner._snip_history(spec, messages) trimmed = ContextGovernor().snip_history(_governance_config(provider, tools, spec), messages)
# Should not crash. The result should still be a valid list. # Should not crash. The result should still be a valid list.
assert isinstance(trimmed, list) assert isinstance(trimmed, list)
+15 -6
View File
@@ -6,15 +6,13 @@ import os
import time import time
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path): async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path):
from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock() provider = MagicMock()
captured_second_call: list[dict] = [] captured_second_call: list[dict] = []
@@ -50,7 +48,13 @@ async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path):
assert result.final_content == "done" assert result.final_content == "done"
tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool") tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool")
assert "[tool output persisted]" in tool_message["content"] assert "[tool output persisted]" in tool_message["content"]
assert "tool-results" in tool_message["content"] assert "tool_output_id: call_big" in tool_message["content"]
assert "original_size_chars: 20000" in tool_message["content"]
assert "head:" in tool_message["content"]
assert "tail:" in tool_message["content"]
assert "Read the saved file" not in tool_message["content"]
assert str(tmp_path) not in tool_message["content"]
assert len(tool_message["content"]) <= 2048
assert (tmp_path / ".nanobot" / "tool-results" / "test_runner" / "call_big.txt").exists() assert (tmp_path / ".nanobot" / "tool-results" / "test_runner" / "call_big.txt").exists()
@@ -78,6 +82,8 @@ def test_persist_tool_result_prunes_old_session_buckets(tmp_path):
) )
assert "[tool output persisted]" in persisted assert "[tool output persisted]" in persisted
assert "tool_output_id: call_big" in persisted
assert "tool-results" not in persisted
assert not old_bucket.exists() assert not old_bucket.exists()
assert recent_bucket.exists() assert recent_bucket.exists()
assert (root / "current_session" / "call_big.txt").exists() assert (root / "current_session" / "call_big.txt").exists()
@@ -172,7 +178,7 @@ async def test_read_file_result_is_not_offloaded(tmp_path):
async def test_runner_keeps_going_when_tool_result_persistence_fails(): async def test_runner_keeps_going_when_tool_result_persistence_fails():
from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock() provider = MagicMock()
captured_second_call: list[dict] = [] captured_second_call: list[dict] = []
@@ -195,7 +201,10 @@ async def test_runner_keeps_going_when_tool_result_persistence_fails():
tools.execute = AsyncMock(return_value="tool result") tools.execute = AsyncMock(return_value="tool result")
runner = AgentRunner(provider) runner = AgentRunner(provider)
with patch("nanobot.agent.runner.maybe_persist_tool_result", side_effect=RuntimeError("disk full")): with patch(
"nanobot.agent.context_governance.maybe_persist_tool_result",
side_effect=RuntimeError("disk full"),
):
result = await runner.run(AgentRunSpec( result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
+76
View File
@@ -358,3 +358,79 @@ async def test_runner_blocks_repeated_external_fetches():
if msg.get("role") == "tool" and msg.get("tool_call_id") == "call_3" if msg.get("role") == "tool" and msg.get("tool_call_id") == "call_3"
][0] ][0]
assert "repeated external lookup blocked" in blocked_tool_message["content"] assert "repeated external lookup blocked" in blocked_tool_message["content"]
@pytest.mark.asyncio
async def test_runner_adds_budget_notice_near_long_tool_budget():
provider = MagicMock()
captured_final_call: list[dict] = []
call_count = {"n": 0}
async def chat_with_retry(*, messages, **kwargs):
call_count["n"] += 1
if call_count["n"] <= 16:
return LLMResponse(
content="working",
tool_calls=[ToolCallRequest(id=f"call_{call_count['n']}", name="work", arguments={})],
usage={},
)
captured_final_call[:] = messages
return LLMResponse(content="done", tool_calls=[], usage={})
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="tool result")
result = await AgentRunner(provider).run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "finish a large task"}],
tools=tools,
model="test-model",
max_iterations=20,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert result.final_content == "done"
notices = [
msg["content"]
for msg in captured_final_call
if msg.get("role") == "user" and "[Runtime Budget Notice]" in str(msg.get("content"))
]
assert len(notices) == 1
assert "15 of 20 model/tool iterations" in notices[0]
assert "Switch to convergence mode" in notices[0]
assert tools.execute.await_count == 16
@pytest.mark.asyncio
async def test_runner_budget_notice_does_not_affect_short_runs():
provider = MagicMock()
captured_final_call: list[dict] = []
call_count = {"n": 0}
async def chat_with_retry(*, messages, **kwargs):
call_count["n"] += 1
if call_count["n"] <= 2:
return LLMResponse(
content="working",
tool_calls=[ToolCallRequest(id=f"call_{call_count['n']}", name="work", arguments={})],
usage={},
)
captured_final_call[:] = messages
return LLMResponse(content="done", tool_calls=[], usage={})
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="tool result")
result = await AgentRunner(provider).run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "small task"}],
tools=tools,
model="test-model",
max_iterations=4,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert result.final_content == "done"
assert all("[Runtime Budget Notice]" not in str(msg.get("content")) for msg in captured_final_call)
+12
View File
@@ -1,6 +1,7 @@
"""Tests for atomic session save and corrupt-file repair.""" """Tests for atomic session save and corrupt-file repair."""
import json import json
import shutil
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -36,6 +37,17 @@ class TestAtomicSave:
tmp_files = list(mgr.sessions_dir.glob("*.tmp")) tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
assert tmp_files == [] assert tmp_files == []
def test_save_recreates_deleted_sessions_dir(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
shutil.rmtree(mgr.sessions_dir)
session = Session(key="test:recreate")
session.add_message("user", "hello")
mgr.save(session)
path = mgr._get_session_path("test:recreate")
assert path.exists()
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path): def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
mgr = SessionManager(tmp_path) mgr = SessionManager(tmp_path)
session = Session(key="test:fail") session = Session(key="test:fail")
+170
View File
@@ -0,0 +1,170 @@
"""Regression tests for collision-resistant session filenames."""
import json
from datetime import datetime
from pathlib import Path
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.helpers import safe_filename
def _manager(tmp_path: Path, monkeypatch) -> SessionManager:
monkeypatch.setattr(
"nanobot.session.manager.get_legacy_sessions_dir",
lambda: tmp_path / "legacy_sessions",
)
return SessionManager(tmp_path / "workspace")
def _write_session_file(path: Path, key: str, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
metadata = {
"_type": "metadata",
"key": key,
"created_at": datetime(2025, 1, 1).isoformat(),
"updated_at": datetime(2025, 1, 1).isoformat(),
"metadata": {"source": "test"},
"last_consolidated": 0,
}
message = {"role": "user", "content": content}
path.write_text(
json.dumps(metadata) + "\n" + json.dumps(message) + "\n",
encoding="utf-8",
)
def test_distinct_keys_have_distinct_filenames(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
first = sm._get_session_path("telegram:a_b")
second = sm._get_session_path("telegram:a:b")
assert first.name != second.name
assert sm.safe_key("telegram:a_b") == sm.safe_key("telegram:a:b")
assert sm._storage_key("telegram:a_b") != sm._storage_key("telegram:a:b")
def test_save_uses_new_path_not_lossy(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
key = "telegram:a:b"
session = Session(key=key)
session.add_message("user", "first")
sm.save(session)
new_path = sm._get_session_path(key)
lossy_path = sm._get_legacy_lossy_path(key)
_write_session_file(lossy_path, key, "stale lossy content")
stale_lossy = lossy_path.read_text(encoding="utf-8")
session.add_message("assistant", "latest content")
sm.save(session)
assert new_path.exists()
assert lossy_path.exists()
assert "latest content" in new_path.read_text(encoding="utf-8")
assert lossy_path.read_text(encoding="utf-8") == stale_lossy
def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
key = "telegram:legacy:lossy"
lossy_path = sm._get_legacy_lossy_path(key)
_write_session_file(lossy_path, key, "loaded from lossy")
session = sm._load(key)
assert session is not None
assert session.metadata == {"source": "test"}
assert session.messages[0]["content"] == "loaded from lossy"
def test_load_migrates_lossy_to_new_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
key = "telegram:migrate:lossy"
new_path = sm._get_session_path(key)
lossy_path = sm._get_legacy_lossy_path(key)
_write_session_file(lossy_path, key, "migrate me")
session = sm._load(key)
assert session is not None
assert session.messages[0]["content"] == "migrate me"
assert new_path.exists()
assert not lossy_path.exists()
def test_load_does_not_migrate_lossy_path_for_different_stored_key(
tmp_path: Path,
monkeypatch,
) -> None:
sm = _manager(tmp_path, monkeypatch)
first_key = "telegram:a_b"
second_key = "telegram:a:b"
lossy_path = sm._get_legacy_lossy_path(first_key)
assert lossy_path == sm._get_legacy_lossy_path(second_key)
_write_session_file(lossy_path, first_key, "belongs to first")
loaded_second = sm._load(second_key)
assert loaded_second is None
assert lossy_path.exists()
assert not sm._get_session_path(second_key).exists()
loaded_first = sm._load(first_key)
assert loaded_first is not None
assert loaded_first.messages[0]["content"] == "belongs to first"
assert sm._get_session_path(first_key).exists()
assert not lossy_path.exists()
def test_safe_key_is_lossy() -> None:
assert SessionManager.safe_key("telegram:a_b") == SessionManager.safe_key("telegram:a:b")
def test_storage_key_is_collision_resistant() -> None:
encoded = {
SessionManager._storage_key("a:b"),
SessionManager._storage_key("a_b"),
SessionManager._storage_key("a:b:c"),
}
assert len(encoded) == 3
assert SessionManager._storage_key("telegram:a_b") != SessionManager._storage_key("telegram:a:b")
def test_lossy_path_helper_returns_expected_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch)
key = "telegram:a:b"
expected = sm.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
assert sm._get_legacy_lossy_path(key) == expected
def test_storage_paths_are_distinct_when_keys_collide_under_safe_key(
tmp_path: Path,
monkeypatch,
) -> None:
sm = _manager(tmp_path, monkeypatch)
first = Session(key="telegram:a_b")
first.add_message("user", "underscore history")
second = Session(key="telegram:a:b")
second.add_message("user", "colon history")
sm.save(first)
sm.save(second)
assert sm.safe_key(first.key) == sm.safe_key(second.key)
assert sm._get_session_path(first.key).exists()
assert sm._get_session_path(second.key).exists()
assert sm._get_session_path(first.key) != sm._get_session_path(second.key)
sm.invalidate(first.key)
sm.invalidate(second.key)
loaded_first = sm._load(first.key)
loaded_second = sm._load(second.key)
assert loaded_first is not None
assert loaded_second is not None
assert loaded_first.messages[0]["content"] == "underscore history"
assert loaded_second.messages[0]["content"] == "colon history"
+2 -2
View File
@@ -58,11 +58,11 @@ def test_read_session_file_missing(tmp_path: Path) -> None:
assert sm.read_session_file("nope:none") is None assert sm.read_session_file("nope:none") is None
def test_safe_key_matches_internal_path(tmp_path: Path) -> None: def test_storage_key_matches_internal_path(tmp_path: Path) -> None:
sm = SessionManager(tmp_path) sm = SessionManager(tmp_path)
key = "telegram:abc/def" key = "telegram:abc/def"
expected = sm._get_session_path(key).name expected = sm._get_session_path(key).name
assert SessionManager.safe_key(key) + ".jsonl" == expected assert SessionManager._storage_key(key) + ".jsonl" == expected
def _write_legacy_session(legacy_dir: Path, key: str, roles: list[str]) -> Path: def _write_legacy_session(legacy_dir: Path, key: str, roles: list[str]) -> Path:
+15 -16
View File
@@ -266,13 +266,8 @@ def test_get_history_preserves_reasoning_content():
] ]
def test_get_history_annotates_user_turns_but_not_assistant_turns(): def test_get_history_does_not_inject_persisted_timestamps_into_replay_content():
"""Only user turns carry the timestamp prefix. """Persisted timestamps are session metadata, not prompt content."""
Annotating assistant turns trains the model (via in-context examples) to
start its own replies with ``[Message Time: ...]``. User-side stamps are
enough to pin adjacent assistant replies for relative-time reasoning.
"""
session = Session(key="test:timestamps") session = Session(key="test:timestamps")
session.messages.append({ session.messages.append({
"role": "user", "role": "user",
@@ -285,12 +280,14 @@ def test_get_history_annotates_user_turns_but_not_assistant_turns():
"timestamp": "2026-04-26T22:00:05", "timestamp": "2026-04-26T22:00:05",
}) })
history = session.get_history(max_messages=500, include_timestamps=True) history = session.get_history(max_messages=500)
assert session.messages[0]["timestamp"] == "2026-04-26T22:00:00"
assert session.messages[1]["timestamp"] == "2026-04-26T22:00:05"
assert history == [ assert history == [
{ {
"role": "user", "role": "user",
"content": "[Message Time: 2026-04-26T22:00:00]\n10 点提醒是昨天发生的", "content": "10 点提醒是昨天发生的",
}, },
{ {
"role": "assistant", "role": "assistant",
@@ -299,8 +296,8 @@ def test_get_history_annotates_user_turns_but_not_assistant_turns():
] ]
def test_get_history_does_not_annotate_proactive_assistant_deliveries_with_timestamps(): def test_get_history_keeps_proactive_delivery_timestamps_out_of_replay_content():
"""Assistant-side timestamp examples can leak back into future replies.""" """Timestamp metadata remains persisted without becoming prompt text."""
session = Session(key="test:proactive-timestamps") session = Session(key="test:proactive-timestamps")
session.messages.append({ session.messages.append({
"role": "assistant", "role": "assistant",
@@ -314,8 +311,10 @@ def test_get_history_does_not_annotate_proactive_assistant_deliveries_with_times
"timestamp": "2026-04-26T18:00:00", "timestamp": "2026-04-26T18:00:00",
}) })
history = session.get_history(max_messages=500, include_timestamps=True) history = session.get_history(max_messages=500)
assert session.messages[0]["timestamp"] == "2026-04-26T15:00:00"
assert session.messages[1]["timestamp"] == "2026-04-26T18:00:00"
assert history == [ assert history == [
{ {
"role": "assistant", "role": "assistant",
@@ -323,18 +322,18 @@ def test_get_history_does_not_annotate_proactive_assistant_deliveries_with_times
}, },
{ {
"role": "user", "role": "user",
"content": "[Message Time: 2026-04-26T18:00:00]\n", "content": "",
}, },
] ]
def test_get_history_does_not_annotate_tool_results_with_timestamps(): def test_get_history_does_not_inject_tool_result_timestamps():
session = Session(key="test:tool-timestamps") session = Session(key="test:tool-timestamps")
session.messages.append({"role": "user", "content": "run tool"}) session.messages.append({"role": "user", "content": "run tool"})
session.messages.extend(_tool_turn("ts", 0)) session.messages.extend(_tool_turn("ts", 0))
session.messages[-1]["timestamp"] = "2026-04-26T22:00:10" session.messages[-1]["timestamp"] = "2026-04-26T22:00:10"
history = session.get_history(max_messages=500, include_timestamps=True) history = session.get_history(max_messages=500)
tool_result = history[-1] tool_result = history[-1]
assert tool_result["role"] == "tool" assert tool_result["role"] == "tool"
@@ -555,7 +554,7 @@ def test_get_history_sanitizes_existing_assistant_replay_artifacts():
} }
) )
history = session.get_history(max_messages=500, include_timestamps=True) history = session.get_history(max_messages=500)
assert history == [{"role": "assistant", "content": "来了 🎨"}] assert history == [{"role": "assistant", "content": "来了 🎨"}]
+33 -2
View File
@@ -1,11 +1,12 @@
"""Tests for SubagentManager.""" """Tests for SubagentManager."""
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.agent.subagent import SubagentManager from nanobot.agent.runner import AgentRunResult
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.agent.tools.filesystem import FileToolsConfig from nanobot.agent.tools.filesystem import FileToolsConfig
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ToolsConfig from nanobot.config.schema import ToolsConfig
@@ -79,3 +80,33 @@ def test_subagent_respects_file_tool_toggle(tmp_path):
"write_file", "write_file",
} }
assert file_tools.isdisjoint(tools.tool_names) assert file_tools.isdisjoint(tools.tool_names)
@pytest.mark.asyncio
async def test_subagent_forwards_fail_on_tool_error_to_runner(tmp_path):
provider = MagicMock(spec=LLMProvider)
provider.get_default_model.return_value = "test"
sm = SubagentManager(
provider=provider,
workspace=tmp_path,
bus=MessageBus(),
model="test",
max_tool_result_chars=16_000,
fail_on_tool_error=False,
)
sm.runner.run = AsyncMock(
return_value=AgentRunResult(final_content="ok", messages=[], stop_reason="completed")
)
sm._announce_result = AsyncMock()
status = SubagentStatus(
task_id="t1",
label="label",
task_description="task",
started_at=0.0,
)
await sm._run_subagent("t1", "task", "label", {"channel": "cli", "chat_id": "direct"}, status)
spec = sm.runner.run.call_args.args[0]
assert spec.fail_on_tool_error is False
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from nanobot.agent.verification_state import (
analyze_verification_result,
append_verification_feedback,
)
def test_analyze_pytest_failure_extracts_actionable_summary():
output = """\
FAILED ../tests/test_outputs.py::test_regex_matches_dates - AssertionError: Expected dates
E AssertionError: Expected ['2025-01-09'], but got ['bad']
E FileNotFoundError: [Errno 2] No such file or directory: '/app/out.txt'
============================== 1 failed in 0.05s ===============================
Exit code: 1
"""
analysis = analyze_verification_result(
command="pytest /tests/test_outputs.py",
output=output,
exit_code=1,
)
assert analysis is not None
assert analysis.status == "failed"
assert analysis.failed_tests == ("../tests/test_outputs.py::test_regex_matches_dates",)
assert any("AssertionError" in item for item in analysis.primary_errors)
assert "/app/out.txt" in analysis.missing_artifacts
def test_append_verification_feedback_tells_agent_not_to_finish():
analysis = analyze_verification_result(
command="python /app/test_outputs.py",
output="FAILED test_outputs.py::test_file\nAssertionError: missing\nExit code: 1",
exit_code=1,
)
feedback = append_verification_feedback("raw output\nExit code: 1", analysis)
assert "[Verification Feedback]" in feedback
assert "Do not call complete_goal" in feedback
assert "Next action" in feedback
def test_analyze_passing_test_records_success_without_feedback():
analysis = analyze_verification_result(
command="pytest",
output="============================== 3 passed in 0.10s ==============================\nExit code: 0",
exit_code=0,
)
assert analysis is not None
assert analysis.status == "passed"
assert append_verification_feedback("ok", analysis) == "ok"
def test_analyze_command_not_found_as_failed_check():
output = """\
STDERR:
/usr/bin/bash: line 1: python3: command not found
Exit code: 127
"""
analysis = analyze_verification_result(
command="python3 - <<'PY'\nprint('quick verification')\nPY",
output=output,
exit_code=127,
)
assert analysis is not None
assert analysis.status == "failed"
assert any("command not found" in item for item in analysis.primary_errors)
def test_analyze_artifact_comparison_success_records_pass():
output = """\
run_exit:0
0d115b98 /app/image.ppm
0d115b98 /tmp/orig.ppm
cmp_exit:0
7 21 1024
Exit code: 0
"""
analysis = analyze_verification_result(
command=(
"cd /usr/bin && gcc -static -o /app/reversed_final /app/mystery.c -lm "
"&& (cd /app && ./reversed_final >/tmp/final_out 2>/tmp/final_err); "
"sha256sum /app/image.ppm /tmp/orig.ppm; "
"cmp -s /app/image.ppm /tmp/orig.ppm; echo cmp_exit:$?"
),
output=output,
exit_code=0,
)
assert analysis is not None
assert analysis.status == "passed"
assert append_verification_feedback("ok", analysis) == "ok"
def test_analyze_plain_checksum_without_success_marker_is_ignored():
analysis = analyze_verification_result(
command="sha256sum /app/image.ppm /tmp/orig.ppm",
output="0d115b98 /app/image.ppm\n0d115b98 /tmp/orig.ppm\nExit code: 0",
exit_code=0,
)
assert analysis is None
def test_analyze_named_comparison_markers_record_pass():
output = """\
ppm:0
stderr:0
stdout:0
4 26 1011
1821 mystery.c
Exit code: 0
"""
analysis = analyze_verification_result(
command=(
"gcc -static -O2 -o reversed mystery.c -lm\n"
"./reversed > vrout.txt 2> vrerr.txt\n"
"cp image.ppm rev.ppm\n"
"./mystery > voout.txt 2> voerr.txt\n"
"cmp image.ppm rev.ppm\n"
"printf 'ppm:%s\\n' $?\n"
"cmp voerr.txt vrerr.txt\n"
"printf 'stderr:%s\\n' $?\n"
"cmp voout.txt vrout.txt\n"
"printf 'stdout:%s\\n' $?"
),
output=output,
exit_code=0,
)
assert analysis is not None
assert analysis.status == "passed"
def test_analyze_named_comparison_marker_failure_records_failed():
output = """\
ppm:0
stderr:1
stdout:0
Exit code: 0
"""
analysis = analyze_verification_result(
command=(
"cmp image.ppm rev.ppm; printf 'ppm:%s\\n' $?; "
"cmp voerr.txt vrerr.txt; printf 'stderr:%s\\n' $?; "
"cmp voout.txt vrout.txt; printf 'stdout:%s\\n' $?"
),
output=output,
exit_code=0,
)
assert analysis is not None
assert analysis.status == "failed"
def test_analyze_plain_run_status_marker_without_comparison_is_ignored():
analysis = analyze_verification_result(
command="gcc -static -O2 -o reversed mystery.c -lm && ./reversed",
output="rc:0\nExit code: 0",
exit_code=0,
)
assert analysis is None
+65
View File
@@ -13,6 +13,11 @@ from nanobot.agent.tools.long_task import (
CompleteGoalTool, CompleteGoalTool,
LongTaskTool, LongTaskTool,
) )
from nanobot.agent.verification_state import (
VerificationAnalysis,
clear_verification_observation,
record_verification_observation,
)
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.goal_state import GOAL_STATE_KEY
@@ -192,6 +197,66 @@ async def test_complete_goal_without_active_is_noop_message(tmp_path):
assert "No active" in out assert "No active" in out
@pytest.mark.asyncio
async def test_complete_goal_blocks_unresolved_verification_failure(tmp_path):
sm = SessionManager(tmp_path)
lt, cg = _tools(sm)
await lt.execute(goal="Fix the tests")
record_verification_observation(
"websocket:c1",
VerificationAnalysis(
status="failed",
command="pytest /tests/test_outputs.py",
exit_code=1,
failed_tests=("test_outputs.py::test_output",),
primary_errors=("AssertionError: wrong output",),
),
)
out = await cg.execute(recap="Done.")
assert "not marked complete" in out
assert "test_outputs.py::test_output" in out
assert sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]["status"] == "active"
clear_verification_observation("websocket:c1")
@pytest.mark.asyncio
async def test_complete_goal_allows_after_later_successful_verification(tmp_path):
sm = SessionManager(tmp_path)
lt, cg = _tools(sm)
await lt.execute(goal="Fix the tests")
record_verification_observation(
"websocket:c1",
VerificationAnalysis(
status="failed",
command="pytest /tests/test_outputs.py",
exit_code=1,
failed_tests=("test_outputs.py::test_output",),
),
)
record_verification_observation(
"websocket:c1",
VerificationAnalysis(
status="passed",
command="pytest /tests/test_outputs.py",
exit_code=0,
),
)
out = await cg.execute(
recap="Done.",
verification_summary="pytest /tests/test_outputs.py passed",
commands_run="pytest /tests/test_outputs.py",
artifacts_created="/app/out.txt",
)
assert "marked complete" in out
blob = sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]
assert blob["status"] == "completed"
assert blob["verification_summary"] == "pytest /tests/test_outputs.py passed"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_long_task_skips_ws_publish_without_bus(tmp_path): async def test_long_task_skips_ws_publish_without_bus(tmp_path):
sm = SessionManager(tmp_path) sm = SessionManager(tmp_path)
@@ -142,6 +142,31 @@ class TestDeltaCoalescing:
assert pending[0].chat_id == "chat2" assert pending[0].chat_id == "chat2"
assert pending[0].content == "World" assert pending[0].content == "World"
@pytest.mark.asyncio
async def test_deltas_different_stream_ids_not_coalesced(self, manager, bus):
"""Deltas for the same chat but different streams should not be merged."""
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="A1",
metadata={"_stream_delta": True, "_stream_id": "stream-a"},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="B1",
metadata={"_stream_delta": True, "_stream_id": "stream-b"},
))
first_msg = await bus.consume_outbound()
merged, pending = manager._coalesce_stream_deltas(first_msg)
assert merged.content == "A1"
assert merged.metadata.get("_stream_id") == "stream-a"
assert len(pending) == 1
assert pending[0].content == "B1"
assert pending[0].metadata.get("_stream_id") == "stream-b"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_end_terminates_coalescing(self, manager, bus): async def test_stream_end_terminates_coalescing(self, manager, bus):
"""_stream_end should stop coalescing and be included in final message.""" """_stream_end should stop coalescing and be included in final message."""
+144
View File
@@ -259,6 +259,150 @@ async def test_handler_processes_file_message(monkeypatch) -> None:
assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content
def _rich_text_message(rich_text_list):
class _FakeRichTextChatbotMessage:
text = None
extensions = {}
image_content = None
rich_text_content = SimpleNamespace(rich_text_list=rich_text_list)
sender_staff_id = "user1"
sender_id = "fallback-user"
sender_nick = "Alice"
message_type = "richText"
@staticmethod
def from_dict(_data):
return _FakeRichTextChatbotMessage()
return _FakeRichTextChatbotMessage
@pytest.mark.asyncio
async def test_handler_richtext_keeps_formatted_segments(monkeypatch) -> None:
"""richText segments with non-'text' types (bold/italic/code/pre) must be kept
and mapped to Markdown, not dropped (issue #4497)."""
bus = MessageBus()
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
bus,
)
handler = NanobotDingTalkHandler(channel)
fake_msg = _rich_text_message([
{"type": "bold", "text": "Title"},
{"type": "text", "text": "plain"},
{"type": "italic", "text": "em"},
{"type": "inlineCode", "text": "x = 1"},
{"type": "pre", "text": "block"},
])
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
status, body = await handler.process(
SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
)
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
assert (status, body) == ("OK", "OK")
assert msg.content == "**Title** plain *em* `x = 1` ```\nblock\n```"
@pytest.mark.asyncio
async def test_handler_richtext_all_formatted_not_dropped(monkeypatch) -> None:
"""A richText message made only of formatted segments must not end up with empty
content and fall through to the 'unsupported message type' path (issue #4497)."""
bus = MessageBus()
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
bus,
)
handler = NanobotDingTalkHandler(channel)
fake_msg = _rich_text_message([{"type": "bold", "text": "Important"}])
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
status, body = await handler.process(
SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
)
# Before the fix this message produced empty content and never reached the bus,
# so consume_inbound would block here.
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
assert (status, body) == ("OK", "OK")
assert msg.content == "**Important**"
@pytest.mark.asyncio
async def test_handler_richtext_item_with_text_and_download(monkeypatch) -> None:
"""A rich-text item carrying both text and a downloadCode must yield both the
text and the downloaded file, not drop the attachment (issue #4497)."""
bus = MessageBus()
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
bus,
)
handler = NanobotDingTalkHandler(channel)
fake_msg = _rich_text_message([
{"text": "see attached", "downloadCode": "abc123", "fileName": "report.xlsx"},
])
async def fake_download(download_code, filename, sender_id):
return f"/tmp/nanobot_dingtalk/{sender_id}/{filename}"
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
monkeypatch.setattr(channel, "_download_dingtalk_file", fake_download)
status, body = await handler.process(
SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
)
await asyncio.gather(*list(channel._background_tasks))
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
assert (status, body) == ("OK", "OK")
assert "see attached" in msg.content
assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content
@pytest.mark.asyncio
async def test_start_configures_http_timeout(monkeypatch) -> None:
"""The shared httpx client must be created with an explicit timeout so file/image
downloads don't hit httpx's 5s default and ConnectTimeout (issue #4497)."""
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
class _FakeStreamClient:
def __init__(self, _credential):
pass
def register_callback_handler(self, _topic, _handler):
pass
async def start(self):
# Exit the reconnect loop after one iteration.
channel._running = False
monkeypatch.setattr(dingtalk_module, "DINGTALK_AVAILABLE", True)
monkeypatch.setattr(dingtalk_module, "Credential", lambda *a, **k: object())
monkeypatch.setattr(dingtalk_module, "DingTalkStreamClient", _FakeStreamClient)
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", SimpleNamespace(TOPIC="topic"))
await channel.start()
assert channel._http is not None
timeout = channel._http.timeout
assert timeout.connect == 10.0
assert timeout.read == 30.0
assert timeout.write == 30.0
assert timeout.pool == 10.0
await channel.stop()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None: async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
"""Test the two-step file download flow (get URL then download content).""" """Test the two-step file download flow (get URL then download content)."""
+13
View File
@@ -35,7 +35,20 @@ def test_feishu_channel_constructor_does_not_import_lark_oapi():
def test_lark_runtime_thread_import_clears_sdk_import_loop(): def test_lark_runtime_thread_import_clears_sdk_import_loop():
out = _run_import_probe( out = _run_import_probe(
"import asyncio\n" "import asyncio\n"
"import sys\n"
"import tempfile\n"
"from pathlib import Path\n"
"from nanobot.channels.feishu import _load_lark_runtime\n" "from nanobot.channels.feishu import _load_lark_runtime\n"
"root = Path(tempfile.mkdtemp())\n"
"pkg = root / 'lark_oapi'\n"
"(pkg / 'ws').mkdir(parents=True)\n"
"(pkg / 'core').mkdir(parents=True)\n"
"(pkg / '__init__.py').write_text('class LogLevel:\\n INFO = 20\\n')\n"
"(pkg / 'ws' / '__init__.py').write_text('')\n"
"(pkg / 'ws' / 'client.py').write_text('import asyncio\\nloop = asyncio.new_event_loop()\\n')\n"
"(pkg / 'core' / '__init__.py').write_text('')\n"
"(pkg / 'core' / 'const.py').write_text(\"FEISHU_DOMAIN = 'feishu'\\nLARK_DOMAIN = 'lark'\\n\")\n"
"sys.path.insert(0, str(root))\n"
"async def main():\n" "async def main():\n"
" await asyncio.to_thread(_load_lark_runtime)\n" " await asyncio.to_thread(_load_lark_runtime)\n"
" import lark_oapi.ws.client as ws\n" " import lark_oapi.ws.client as ws\n"
+19 -2
View File
@@ -471,7 +471,7 @@ async def test_send_rich_capability_error_latches_and_falls_back() -> None:
from telegram.error import BadRequest from telegram.error import BadRequest
channel = TelegramChannel( channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
MessageBus(), MessageBus(),
) )
channel._app = _FakeApp(lambda: None) channel._app = _FakeApp(lambda: None)
@@ -490,7 +490,7 @@ async def test_send_rich_bad_request_does_not_latch_capability() -> None:
from telegram.error import BadRequest from telegram.error import BadRequest
channel = TelegramChannel( channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
MessageBus(), MessageBus(),
) )
channel._app = _FakeApp(lambda: None) channel._app = _FakeApp(lambda: None)
@@ -505,6 +505,23 @@ async def test_send_rich_bad_request_does_not_latch_capability() -> None:
assert len(channel._app.bot.sent_messages) == 1 assert len(channel._app.bot.sent_messages) == 1
@pytest.mark.asyncio
async def test_rich_messages_default_skips_send_rich_message() -> None:
"""By default, sendRichMessage should not be called."""
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
channel._app.bot.do_api_request = AsyncMock()
await channel.send(OutboundMessage(channel="telegram", chat_id="123", content="**hello**"))
channel._app.bot.do_api_request.assert_not_called()
assert len(channel._app.bot.sent_messages) == 1
assert channel._app.bot.sent_messages[0]["text"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_on_error_logs_network_issues_as_warning(monkeypatch) -> None: async def test_on_error_logs_network_issues_as_warning(monkeypatch) -> None:
from telegram.error import NetworkError from telegram.error import NetworkError
+1 -1
View File
@@ -136,7 +136,7 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Response: async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Response:
"""Run GET in a thread to avoid blocking the asyncio loop shared with websockets.""" """Run GET in a thread to avoid blocking the asyncio loop shared with websockets."""
return await asyncio.to_thread( return await asyncio.to_thread(
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0) functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
) )
+2 -3
View File
@@ -109,7 +109,7 @@ async def _http_get(
url: str, headers: dict[str, str] | None = None url: str, headers: dict[str, str] | None = None
) -> httpx.Response: ) -> httpx.Response:
return await asyncio.to_thread( return await asyncio.to_thread(
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0) functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
) )
@@ -506,12 +506,11 @@ async def test_cli_apps_catalog_does_not_block_other_webui_http_routes(
token = boot.json()["token"] token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
started = time.perf_counter()
catalog_task = asyncio.create_task( catalog_task = asyncio.create_task(
_http_get("http://127.0.0.1:29935/api/settings/cli-apps", headers=auth) _http_get("http://127.0.0.1:29935/api/settings/cli-apps", headers=auth)
) )
assert await asyncio.wait_for(entered.wait(), 2.0) assert await asyncio.wait_for(entered.wait(), 2.0)
assert time.perf_counter() - started < 1.0 assert not catalog_task.done()
workspaces_started = time.perf_counter() workspaces_started = time.perf_counter()
workspaces = await _http_get("http://127.0.0.1:29935/api/workspaces", headers=auth) workspaces = await _http_get("http://127.0.0.1:29935/api/workspaces", headers=auth)
+1 -1
View File
@@ -90,7 +90,7 @@ async def _http_get(
url: str, headers: dict[str, str] | None = None url: str, headers: dict[str, str] | None = None
) -> httpx.Response: ) -> httpx.Response:
return await asyncio.to_thread( return await asyncio.to_thread(
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0) functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
) )
@@ -0,0 +1,77 @@
"""Boundary tests for pure WebSocket protocol helpers."""
from __future__ import annotations
import pytest
from nanobot.channels.websocket import (
_extract_data_url_mime,
_is_valid_chat_id,
_parse_envelope,
)
def test_chat_id_validator_accepts_only_compact_capability_keys() -> None:
valid = [
"a",
"A-Z_09:chat-id",
"x" * 64,
]
invalid = [
"",
"x" * 65,
"../escape",
"chat/id",
"chat id",
"chat\nid",
None,
123,
]
for value in valid:
assert _is_valid_chat_id(value), value
for value in invalid:
assert not _is_valid_chat_id(value), repr(value)
@pytest.mark.parametrize(
("raw", "expected_type"),
[
("plain text", None),
("{not json", None),
("[]", None),
("{}", None),
('{"type": 42}', None),
('{"type": "message", "content": "hi"}', "message"),
(' {"type": "new_chat"} ', "new_chat"),
],
)
def test_parse_envelope_only_accepts_typed_json_objects(
raw: str,
expected_type: str | None,
) -> None:
parsed = _parse_envelope(raw)
if expected_type is None:
assert parsed is None
else:
assert parsed is not None
assert parsed["type"] == expected_type
@pytest.mark.parametrize(
("url", "expected"),
[
("data:image/png;base64,AAAA", "image/png"),
("data:IMAGE/JPEG;charset=utf-8;base64,AAAA", "image/jpeg"),
("data:video/webm;codecs=vp9;base64,AAAA", "video/webm"),
("data:image/svg+xml;base64,AAAA", "image/svg+xml"),
("data:image/png,AAAA", None),
("data:;base64,AAAA", None),
("https://example.invalid/image.png", None),
],
)
def test_extract_data_url_mime_normalizes_only_base64_data_urls(
url: str,
expected: str | None,
) -> None:
assert _extract_data_url_mime(url) == expected
+366 -418
View File
@@ -1,507 +1,455 @@
"""Tests for WhatsApp channel outbound media support.""" from __future__ import annotations
import json import asyncio
import os from types import SimpleNamespace
import sys
import types
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.channels.whatsapp import ( from nanobot.channels import whatsapp as whatsapp_module
WhatsAppChannel, from nanobot.channels.whatsapp import WhatsAppChannel, _legacy_bridge_config_fields, _NeonizeAPI
_load_or_create_bridge_token,
class _Proto:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def HasField(self, name: str) -> bool: # noqa: N802 - protobuf compatibility
return _is_set(getattr(self, name, None))
def ListFields(self): # noqa: N802 - protobuf compatibility
return [
(SimpleNamespace(name=name), value)
for name, value in self.__dict__.items()
if _is_set(value)
]
def _is_set(value) -> bool:
if value is None:
return False
if isinstance(value, (str, bytes, list, tuple, dict, set)):
return bool(value)
return True
def _jid(user: str, server: str) -> _Proto:
return _Proto(User=user, Server=server, IsEmpty=False)
def _event(
*,
message: _Proto,
message_id: str = "m1",
chat: _Proto | None = None,
sender: _Proto | None = None,
sender_alt: _Proto | None = None,
is_group: bool = False,
timestamp: int = 1,
is_from_me: bool = False,
) -> _Proto:
source = _Proto(
Chat=chat or _jid("15551234567", "s.whatsapp.net"),
Sender=sender,
SenderAlt=sender_alt,
IsGroup=is_group,
IsFromMe=is_from_me,
)
return _Proto(
Info=_Proto(ID=message_id, Timestamp=timestamp, MessageSource=source),
Message=message,
) )
def _make_channel() -> WhatsAppChannel: def _make_channel(config: dict | None = None) -> WhatsAppChannel:
bus = MagicMock() merged = {"enabled": True, "allowFrom": ["*"]}
ch = WhatsAppChannel({"enabled": True}, bus) if config:
ch._ws = AsyncMock() merged.update(config)
ch._connected = True ch = WhatsAppChannel(merged, MagicMock())
ch._started_at = 0
return ch return ch
@pytest.mark.asyncio def _patch_neonize_api(monkeypatch) -> None:
async def test_send_text_only(): monkeypatch.setattr(
ch = _make_channel() whatsapp_module,
msg = OutboundMessage(channel="whatsapp", chat_id="123@s.whatsapp.net", content="hello") "_NEONIZE_API",
_NeonizeAPI(
await ch.send(msg) NewAClient=object,
ConnectedEv=object(),
ch._ws.send.assert_called_once() DisconnectedEv=object(),
payload = json.loads(ch._ws.send.call_args[0][0]) MessageEv=object(),
assert payload["type"] == "send" PairStatusEv=object(),
assert payload["text"] == "hello" build_jid=lambda user, server="s.whatsapp.net": (user, server),
),
@pytest.mark.asyncio
async def test_send_media_dispatches_send_media_command():
ch = _make_channel()
msg = OutboundMessage(
channel="whatsapp",
chat_id="123@s.whatsapp.net",
content="check this out",
media=["/tmp/photo.jpg"],
) )
await ch.send(msg)
assert ch._ws.send.call_count == 2 class _FakeLoginClient:
text_payload = json.loads(ch._ws.send.call_args_list[0][0][0]) def __init__(self) -> None:
media_payload = json.loads(ch._ws.send.call_args_list[1][0][0]) self.handlers = {}
self.me = _Proto(JID=_jid("bot", "s.whatsapp.net"), LID=_jid("BOTLID", "lid"))
self.stop = AsyncMock()
assert text_payload["type"] == "send" def event(self, event_type):
assert text_payload["text"] == "check this out" def register(func):
self.handlers[event_type] = func
return func
assert media_payload["type"] == "send_media" return register
assert media_payload["filePath"] == "/tmp/photo.jpg"
assert media_payload["mimetype"] == "image/jpeg" def qr(self, func):
assert media_payload["fileName"] == "photo.jpg" self.qr_handler = func
return func
async def connect(self) -> None:
await self.handlers[whatsapp_module._NEONIZE_API.ConnectedEv](self, _Proto())
class _FailingConnectLoginClient(_FakeLoginClient):
async def connect(self) -> asyncio.Task[None]:
async def fail() -> None:
raise RuntimeError("dial failed")
return asyncio.create_task(fail())
def test_default_config_has_no_bridge_fields() -> None:
config = WhatsAppChannel.default_config()
assert "bridgeUrl" not in config
assert "bridgeToken" not in config
assert config["databasePath"] == ""
def test_legacy_bridge_config_fields_are_detected() -> None:
assert _legacy_bridge_config_fields({"bridgeUrl": "ws://localhost:3001"}) == ["bridgeUrl"]
assert _legacy_bridge_config_fields({"bridgeToken": "secret"}) == ["bridgeToken"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_media_only_no_text(): async def test_login_succeeds_when_connected(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = _FakeLoginClient()
ch = _make_channel() ch = _make_channel()
msg = OutboundMessage( ch._new_client = MagicMock(return_value=client)
assert await ch.login() is True
assert ch._self_jids == {"bot@s.whatsapp.net", "bot", "BOTLID@lid", "BOTLID"}
client.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_login_fails_when_connect_task_fails(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = _FailingConnectLoginClient()
ch = _make_channel()
ch._new_client = MagicMock(return_value=client)
assert await ch.login() is False
client.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = SimpleNamespace(
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(OutboundMessage(channel="whatsapp", chat_id="12345@s.whatsapp.net", content="hi"))
client.send_message.assert_awaited_once_with(("12345", "s.whatsapp.net"), "hi")
@pytest.mark.asyncio
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = SimpleNamespace(
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp", channel="whatsapp",
chat_id="123@s.whatsapp.net", chat_id="12345@s.whatsapp.net",
content="", content="",
media=["/tmp/doc.pdf"], media=["photo.jpg", "clip.mp4", "voice.ogg", "report.pdf"],
)
) )
await ch.send(msg) jid = ("12345", "s.whatsapp.net")
client.send_image.assert_awaited_once_with(jid, "photo.jpg")
ch._ws.send.assert_called_once() client.send_video.assert_awaited_once_with(jid, "clip.mp4")
payload = json.loads(ch._ws.send.call_args[0][0]) client.send_audio.assert_awaited_once_with(jid, "voice.ogg")
assert payload["type"] == "send_media" client.send_document.assert_awaited_once_with(
assert payload["mimetype"] == "application/pdf" jid,
"report.pdf",
filename="report.pdf",
mimetype="application/pdf",
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_multiple_media(): async def test_send_when_disconnected_raises() -> None:
ch = _make_channel() ch = _make_channel()
msg = OutboundMessage(
channel="whatsapp",
chat_id="123@s.whatsapp.net",
content="",
media=["/tmp/a.png", "/tmp/b.mp4"],
)
await ch.send(msg) with pytest.raises(RuntimeError, match="not connected"):
await ch.send(OutboundMessage(channel="whatsapp", chat_id="123", content="hi"))
assert ch._ws.send.call_count == 2
p1 = json.loads(ch._ws.send.call_args_list[0][0][0])
p2 = json.loads(ch._ws.send.call_args_list[1][0][0])
assert p1["mimetype"] == "image/png"
assert p2["mimetype"] == "video/mp4"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_when_disconnected_is_noop(): async def test_group_policy_mention_skips_unmentioned_group_message() -> None:
ch = _make_channel() ch = _make_channel({"groupPolicy": "mention"})
ch._connected = False ch._self_jids = {"bot@s.whatsapp.net", "bot"}
msg = OutboundMessage(
channel="whatsapp",
chat_id="123@s.whatsapp.net",
content="hello",
media=["/tmp/x.jpg"],
)
await ch.send(msg)
ch._ws.send.assert_not_called()
@pytest.mark.asyncio
async def test_group_policy_mention_skips_unmentioned_group_message():
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
await ch._handle_bridge_message( await ch._handle_neonize_message(
json.dumps( SimpleNamespace(download_any=AsyncMock()),
{ _event(
"type": "message", message=_Proto(conversation="hello group"),
"id": "m1", chat=_jid("120363000", "g.us"),
"sender": "12345@g.us", sender=_jid("SENDERLID", "lid"),
"pn": "user@s.whatsapp.net", is_group=True,
"content": "hello group", ),
"timestamp": 1,
"isGroup": True,
"wasMentioned": False,
}
)
) )
ch._handle_message.assert_not_called() ch._handle_message.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_group_policy_mention_accepts_mentioned_group_message(): async def test_group_policy_mention_accepts_mention_and_prefers_phone_sender() -> None:
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock()) ch = _make_channel({"groupPolicy": "mention"})
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
context = _Proto(mentionedJID=["bot@s.whatsapp.net"])
message = _Proto(extendedTextMessage=_Proto(text="hello @bot", contextInfo=context))
await ch._handle_bridge_message( await ch._handle_neonize_message(
json.dumps( SimpleNamespace(download_any=AsyncMock()),
{ _event(
"type": "message", message=message,
"id": "m1", chat=_jid("120363000", "g.us"),
"sender": "12345@g.us", sender=_jid("LID99", "lid"),
"pn": "user@s.whatsapp.net", sender_alt=_jid("15559998888", "s.whatsapp.net"),
"content": "hello @bot", is_group=True,
"timestamp": 1, ),
"isGroup": True,
"wasMentioned": True,
}
)
) )
ch._handle_message.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs kwargs = ch._handle_message.await_args.kwargs
assert kwargs["chat_id"] == "12345@g.us" assert kwargs["sender_id"] == "15559998888"
assert kwargs["sender_id"] == "user" assert kwargs["chat_id"] == "120363000@g.us"
assert kwargs["metadata"]["lid"] == "LID99"
assert kwargs["metadata"]["phone"] == "15559998888"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_group_policy_mention_accepts_reply_to_bot_message(): async def test_group_policy_mention_accepts_reply_to_bot() -> None:
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock()) ch = _make_channel({"groupPolicy": "mention"})
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
context = _Proto(participant="bot@s.whatsapp.net")
message = _Proto(extendedTextMessage=_Proto(text="reply", contextInfo=context))
await ch._handle_bridge_message( await ch._handle_neonize_message(
json.dumps( SimpleNamespace(download_any=AsyncMock()),
{ _event(
"type": "message", message=message,
"id": "m-reply", chat=_jid("120363000", "g.us"),
"sender": "12345@g.us", sender=_jid("SENDERLID", "lid"),
"pn": "user@s.whatsapp.net", is_group=True,
"content": "replying to bot", ),
"timestamp": 1,
"isGroup": True,
"wasMentioned": False,
"isReplyToBot": True,
}
)
) )
ch._handle_message.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs kwargs = ch._handle_message.await_args.kwargs
assert kwargs["metadata"]["is_reply_to_bot"] is True assert kwargs["metadata"]["is_reply_to_bot"] is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sender_id_prefers_phone_jid_over_lid(): async def test_group_sender_id_uses_participant_not_group_jid() -> None:
"""sender_id should resolve to phone number when @s.whatsapp.net JID is present."""
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "lid1",
"sender": "ABC123@lid.whatsapp.net",
"pn": "5551234@s.whatsapp.net",
"content": "hi",
"timestamp": 1,
})
)
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "5551234"
@pytest.mark.asyncio
async def test_group_sender_id_uses_participant_when_phone_jid_missing():
"""Group messages should identify the participant, not the group chat JID."""
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["SENDERLID"]}, MagicMock()) ch = WhatsAppChannel({"enabled": True, "allowFrom": ["SENDERLID"]}, MagicMock())
ch._started_at = 0
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
await ch._handle_bridge_message( await ch._handle_neonize_message(
json.dumps({ SimpleNamespace(download_any=AsyncMock()),
"type": "message", _event(
"id": "group-lid", message=_Proto(conversation="hi"),
"sender": "12345@g.us", chat=_jid("120363000", "g.us"),
"pn": "", sender=_jid("SENDERLID", "lid"),
"participant": "SENDERLID@lid.whatsapp.net", is_group=True,
"content": "hi", ),
"timestamp": 1,
"isGroup": True,
})
) )
kwargs = ch._handle_message.await_args.kwargs kwargs = ch._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "SENDERLID" assert kwargs["sender_id"] == "SENDERLID"
assert kwargs["metadata"]["participant"] == "SENDERLID@lid.whatsapp.net" assert kwargs["metadata"]["participant"] == "SENDERLID@lid"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lid_to_phone_cache_resolves_lid_only_messages(): async def test_lid_to_phone_cache_resolves_lid_only_messages() -> None:
"""When only LID is present, a cached LID→phone mapping should be used.""" ch = _make_channel()
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock() ch._handle_message = AsyncMock()
# First message: both phone and LID → builds cache await ch._handle_neonize_message(
await ch._handle_bridge_message( SimpleNamespace(download_any=AsyncMock()),
json.dumps({ _event(
"type": "message", message=_Proto(conversation="first"),
"id": "c1", message_id="c1",
"sender": "LID99@lid.whatsapp.net", chat=_jid("LID99", "lid"),
"pn": "5559999@s.whatsapp.net", sender=_jid("LID99", "lid"),
"content": "first", sender_alt=_jid("5559999", "s.whatsapp.net"),
"timestamp": 1, ),
})
) )
# Second message: only LID, no phone await ch._handle_neonize_message(
await ch._handle_bridge_message( SimpleNamespace(download_any=AsyncMock()),
json.dumps({ _event(
"type": "message", message=_Proto(conversation="second"),
"id": "c2", message_id="c2",
"sender": "LID99@lid.whatsapp.net", chat=_jid("LID99", "lid"),
"pn": "", sender=_jid("LID99", "lid"),
"content": "second", ),
"timestamp": 2,
})
) )
second_kwargs = ch._handle_message.await_args_list[1].kwargs assert ch._handle_message.await_args_list[1].kwargs["sender_id"] == "5559999"
assert second_kwargs["sender_id"] == "5559999"
@pytest.mark.asyncio def test_lid_mappings_from_config() -> None:
async def test_voice_message_transcription_uses_media_path():
"""Voice messages are transcribed when media path is available."""
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Hello world")
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "v1",
"sender": "12345@s.whatsapp.net",
"pn": "",
"content": "[Voice Message]",
"timestamp": 1,
"media": ["/tmp/voice.ogg"],
})
)
ch.transcribe_audio.assert_awaited_once_with("/tmp/voice.ogg")
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"].startswith("Hello world")
@pytest.mark.asyncio
async def test_forwarded_voice_message_preserves_metadata_after_transcription():
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Forwarded audio text")
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "v-forwarded",
"sender": "12345@s.whatsapp.net",
"pn": "",
"content": "[Voice Message]",
"timestamp": 1,
"media": ["/tmp/voice.ogg"],
"isForwarded": True,
})
)
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"] == "Forwarded audio text"
assert kwargs["metadata"]["is_forwarded"] is True
@pytest.mark.asyncio
async def test_unauthorized_voice_message_does_not_transcribe() -> None:
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Hello world")
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "v-blocked",
"sender": "blocked@s.whatsapp.net",
"pn": "",
"content": "[Voice Message]",
"timestamp": 1,
"media": ["/tmp/voice.ogg"],
})
)
ch.transcribe_audio.assert_not_awaited()
ch._handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_voice_message_no_media_shows_not_available():
"""Voice messages without media produce a fallback placeholder."""
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
ch._handle_message = AsyncMock()
await ch._handle_bridge_message(
json.dumps({
"type": "message",
"id": "v2",
"sender": "12345@s.whatsapp.net",
"pn": "",
"content": "[Voice Message]",
"timestamp": 1,
})
)
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"] == "[Voice Message: Audio not available]"
def test_load_or_create_bridge_token_persists_generated_secret(tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
first = _load_or_create_bridge_token(token_path)
second = _load_or_create_bridge_token(token_path)
assert first == second
assert token_path.read_text(encoding="utf-8") == first
assert len(first) >= 32
if os.name != "nt":
assert token_path.stat().st_mode & 0o777 == 0o600
def test_configured_bridge_token_skips_local_token_file(monkeypatch, tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
ch = WhatsAppChannel({"enabled": True, "bridgeToken": "manual-secret"}, MagicMock())
assert ch._effective_bridge_token() == "manual-secret"
assert not token_path.exists()
@pytest.mark.asyncio
async def test_login_exports_effective_bridge_token(monkeypatch, tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
calls = []
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
monkeypatch.setattr("nanobot.channels.whatsapp._ensure_bridge_setup", lambda: bridge_dir)
monkeypatch.setattr("nanobot.channels.whatsapp.shutil.which", lambda _: "/usr/bin/npm")
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return MagicMock()
monkeypatch.setattr("nanobot.channels.whatsapp.subprocess.run", fake_run)
ch = WhatsAppChannel({"enabled": True}, MagicMock())
assert await ch.login() is True
assert len(calls) == 1
_, kwargs = calls[0]
assert kwargs["cwd"] == bridge_dir
assert kwargs["env"]["AUTH_DIR"] == str(token_path.parent)
assert kwargs["env"]["BRIDGE_TOKEN"] == token_path.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_start_sends_auth_message_with_generated_token(monkeypatch, tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
sent_messages: list[str] = []
class FakeWS:
def __init__(self) -> None:
self.close = AsyncMock()
async def send(self, message: str) -> None:
sent_messages.append(message)
ch._running = False
def __aiter__(self):
return self
async def __anext__(self):
raise StopAsyncIteration
class FakeConnect:
def __init__(self, ws):
self.ws = ws
async def __aenter__(self):
return self.ws
async def __aexit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
monkeypatch.setitem(
sys.modules,
"websockets",
types.SimpleNamespace(connect=lambda url: FakeConnect(FakeWS())),
)
ch = WhatsAppChannel({"enabled": True, "bridgeUrl": "ws://localhost:3001"}, MagicMock())
await ch.start()
assert sent_messages == [
json.dumps({"type": "auth", "token": token_path.read_text(encoding="utf-8")})
]
# ---------------------------------------------------------------------------
# LID -> phone mapping seeding (startup): static config + bridge reverse files.
# ---------------------------------------------------------------------------
def test_lid_mappings_from_config():
ch = WhatsAppChannel( ch = WhatsAppChannel(
{"enabled": True, "lidMappings": {"123456789012345": "15551234567"}}, {"enabled": True, "lidMappings": {"123456789012345": "15551234567"}},
MagicMock(), MagicMock(),
) )
assert ch._lid_to_phone["123456789012345"] == "15551234567"
assert ch._lid_to_phone == {"123456789012345": "15551234567"}
def test_lid_mappings_from_bridge_reverse_files(tmp_path, monkeypatch): @pytest.mark.asyncio
auth_dir = tmp_path / "whatsapp-auth" async def test_image_media_is_downloaded_and_forwarded(monkeypatch, tmp_path) -> None:
auth_dir.mkdir() monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
(auth_dir / "lid-mapping-999888777666555_reverse.json").write_text( ch = _make_channel()
json.dumps("15559998888"), encoding="utf-8" ch._handle_message = AsyncMock()
client = SimpleNamespace(download_any=AsyncMock())
message = _Proto(
imageMessage=_Proto(
caption="look",
mimetype="image/jpeg",
) )
# malformed / empty files must be ignored, not crash startup
(auth_dir / "lid-mapping-broken_reverse.json").write_text("{not json", encoding="utf-8")
(auth_dir / "lid-mapping-empty_reverse.json").write_text(json.dumps(""), encoding="utf-8")
monkeypatch.setattr(
"nanobot.config.paths.get_runtime_subdir", lambda name: auth_dir
) )
ch = WhatsAppChannel({"enabled": True}, MagicMock()) await ch._handle_neonize_message(
assert ch._lid_to_phone == {"999888777666555": "15559998888"} client,
_event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
def test_lid_mappings_config_takes_precedence_over_files(tmp_path, monkeypatch):
auth_dir = tmp_path / "whatsapp-auth"
auth_dir.mkdir()
(auth_dir / "lid-mapping-555_reverse.json").write_text(
json.dumps("from-file"), encoding="utf-8"
)
monkeypatch.setattr(
"nanobot.config.paths.get_runtime_subdir", lambda name: auth_dir
) )
ch = WhatsAppChannel( client.download_any.assert_awaited_once()
{"enabled": True, "lidMappings": {"555": "from-config"}}, MagicMock() kwargs = ch._handle_message.await_args.kwargs
) assert kwargs["content"].startswith("look\n[image: ")
assert ch._lid_to_phone["555"] == "from-config" assert len(kwargs["media"]) == 1
assert kwargs["media"][0].endswith(".jpg")
def test_lid_mappings_empty_when_no_auth_dir(tmp_path, monkeypatch): @pytest.mark.asyncio
missing = tmp_path / "does-not-exist" async def test_voice_message_transcribes_and_drops_media_when_successful(
monkeypatch.setattr( monkeypatch, tmp_path
"nanobot.config.paths.get_runtime_subdir", lambda name: missing ) -> None:
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
ch = _make_channel()
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="Hello from audio")
client = SimpleNamespace(download_any=AsyncMock())
message = _Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True))
await ch._handle_neonize_message(
client,
_event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
) )
ch = WhatsAppChannel({"enabled": True}, MagicMock())
assert ch._lid_to_phone == {} ch.transcribe_audio.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["content"] == "Hello from audio"
assert kwargs["media"] == []
@pytest.mark.asyncio
async def test_unauthorized_voice_message_does_not_download_or_transcribe(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
ch._started_at = 0
ch._handle_message = AsyncMock()
ch.transcribe_audio = AsyncMock(return_value="blocked audio")
client = SimpleNamespace(download_any=AsyncMock())
await ch._handle_neonize_message(
client,
_event(
message=_Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True)),
chat=_jid("blocked", "s.whatsapp.net"),
sender=_jid("blocked", "s.whatsapp.net"),
),
)
client.download_any.assert_not_awaited()
ch.transcribe_audio.assert_not_awaited()
ch._handle_message.assert_awaited_once()
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["sender_id"] == "blocked"
assert kwargs["content"] == ""
assert kwargs["media"] == []
assert kwargs["is_dm"] is True
@pytest.mark.asyncio
async def test_unauthorized_dm_uses_base_pairing_flow(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
monkeypatch.setattr("nanobot.channels.base.generate_code", lambda _ch, _sid: "ABCD-EFGH")
monkeypatch.setattr("nanobot.channels.base.is_approved", lambda _ch, _sid: False)
client = SimpleNamespace(send_message=AsyncMock(), download_any=AsyncMock())
ch = WhatsAppChannel({"enabled": True, "allowFrom": []}, MagicMock())
ch._client = client
ch._connected = True
ch._started_at = 0
await ch._handle_neonize_message(
client,
_event(
message=_Proto(conversation="hello"),
chat=_jid("blocked", "s.whatsapp.net"),
sender=_jid("blocked", "s.whatsapp.net"),
),
)
client.download_any.assert_not_awaited()
client.send_message.assert_awaited_once()
assert client.send_message.await_args.args[0] == ("blocked", "s.whatsapp.net")
assert "ABCD-EFGH" in client.send_message.await_args.args[1]
def test_reset_database_removes_sqlite_sidecars(tmp_path) -> None:
db = tmp_path / "neonize.db"
wal = tmp_path / "neonize.db-wal"
shm = tmp_path / "neonize.db-shm"
for path in (db, wal, shm):
path.write_text("x", encoding="utf-8")
WhatsAppChannel._reset_database(db)
assert not db.exists()
assert not wal.exists()
assert not shm.exists()
+14
View File
@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from nanobot.agent.memory import MemoryStore
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.cli import commands as cli_commands from nanobot.cli import commands as cli_commands
from nanobot.cli.commands import app from nanobot.cli.commands import app
@@ -140,6 +141,19 @@ def test_gateway_tty_signal_mode_restores_ctrl_c(monkeypatch) -> None:
os.close(slave_fd) os.close(slave_fd)
def test_disabled_dream_cursor_only_advances_when_behind(tmp_path) -> None:
store = MemoryStore(tmp_path)
store.append_history("first")
store.append_history("second")
cli_commands._advance_dream_cursor_if_behind(store)
assert store.get_last_dream_cursor() == 2
store.set_last_dream_cursor(10)
cli_commands._advance_dream_cursor_if_behind(store)
assert store.get_last_dream_cursor() == 10
@pytest.fixture @pytest.fixture
def mock_paths(): def mock_paths():
"""Mock config/workspace paths for test isolation.""" """Mock config/workspace paths for test isolation."""
+13
View File
@@ -246,3 +246,16 @@ def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None:
config = load_config(config_path) config = load_config(config_path)
assert config.tools.webui_allow_local_service_access is False assert config.tools.webui_allow_local_service_access is False
def test_load_config_accepts_exec_local_service_access(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"tools": {"exec": {"allowLocalServiceAccess": True}}}),
encoding="utf-8",
)
config = load_config(config_path)
assert config.tools.exec.allow_local_service_access is True
assert not hasattr(config.tools, "allow_local_service_access")
-2
View File
@@ -1,7 +1,6 @@
from pathlib import Path from pathlib import Path
from nanobot.config.paths import ( from nanobot.config.paths import (
get_bridge_install_dir,
get_cli_history_path, get_cli_history_path,
get_cron_dir, get_cron_dir,
get_data_dir, get_data_dir,
@@ -34,7 +33,6 @@ def test_media_dir_supports_channel_namespace(monkeypatch, tmp_path: Path) -> No
def test_shared_and_legacy_paths_remain_global() -> None: def test_shared_and_legacy_paths_remain_global() -> None:
assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history" assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history"
assert get_bridge_install_dir() == Path.home() / ".nanobot" / "bridge"
assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions" assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions"
+11 -2
View File
@@ -70,7 +70,7 @@ def test_convert_user_content_coerces_typeless_dict():
{"foo": "bar"}, {"foo": "bar"},
{"type": "text", "text": "ok"}, {"type": "text", "text": "ok"},
]) ])
assert result[0] == {"type": "text", "text": str({"foo": "bar"})} assert result[0] == {"type": "text", "text": '{"foo": "bar"}'}
assert result[1] == {"type": "text", "text": "ok"} assert result[1] == {"type": "text", "text": "ok"}
@@ -81,7 +81,16 @@ def test_convert_user_content_coerces_mixed_typeless():
{"key": "val"}, {"key": "val"},
]) ])
assert result[0] == {"type": "text", "text": "42"} assert result[0] == {"type": "text", "text": "42"}
assert result[1] == {"type": "text", "text": str({"key": "val"})} assert result[1] == {"type": "text", "text": '{"key": "val"}'}
def test_assistant_blocks_coerce_typeless_dict_to_json_text():
blocks = AnthropicProvider._assistant_blocks({
"role": "assistant",
"content": [{"answer": "ok", "count": 2}],
})
assert blocks == [{"type": "text", "text": '{"answer": "ok", "count": 2}'}]
def test_convert_assistant_message_repairs_history_tool_arguments(): def test_convert_assistant_message_repairs_history_tool_arguments():
@@ -0,0 +1,62 @@
"""Tests for custom provider thinking_style config passthrough."""
from __future__ import annotations
from nanobot.config.schema import ProviderConfig, ProvidersConfig
from nanobot.providers.registry import create_dynamic_spec
class TestCustomProviderThinkingStyle:
"""Verify that thinking_style flows from config to ProviderSpec."""
def test_default_thinking_style_is_empty(self) -> None:
cfg = ProviderConfig()
assert cfg.thinking_style is None
def test_create_dynamic_spec_default(self) -> None:
spec = create_dynamic_spec("custom")
assert spec.thinking_style == ""
def test_create_dynamic_spec_with_thinking_type(self) -> None:
spec = create_dynamic_spec("custom", thinking_style="thinking_type")
assert spec.thinking_style == "thinking_type"
def test_create_dynamic_spec_with_enable_thinking(self) -> None:
spec = create_dynamic_spec("custom", thinking_style="enable_thinking")
assert spec.thinking_style == "enable_thinking"
def test_create_dynamic_spec_with_reasoning_split(self) -> None:
spec = create_dynamic_spec("custom", thinking_style="reasoning_split")
assert spec.thinking_style == "reasoning_split"
def test_provider_config_accepts_camel_case(self) -> None:
"""Config JSON uses camelCase: thinkingStyle."""
cfg = ProviderConfig.model_validate({"thinkingStyle": "thinking_type"})
assert cfg.thinking_style == "thinking_type"
def test_providers_config_custom_has_thinking_style(self) -> None:
"""Full providers config round-trip."""
data = {
"custom": {
"apiKey": "sk-test",
"apiBase": "https://example.com/v1",
"thinkingStyle": "enable_thinking",
}
}
pc = ProvidersConfig.model_validate(data)
assert pc.custom.thinking_style == "enable_thinking"
def test_invalid_thinking_style_raises_with_clear_message(self) -> None:
"""An invalid thinking_style must raise a ValidationError whose message
lists the valid options (not just Pydantic's generic Literal error)."""
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError) as exc_info:
ProviderConfig.model_validate({"thinkingStyle": "thinking_typ"})
message = str(exc_info.value)
assert "Invalid thinking_style" in message
assert "thinking_type" in message
assert "enable_thinking" in message
assert "reasoning_split" in message
+1 -1
View File
@@ -1228,7 +1228,7 @@ def test_openai_compat_defaults_missing_tool_arguments_to_empty_object() -> None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_openai_compat_stream_watchdog_returns_error_on_stall(monkeypatch) -> None: async def test_openai_compat_stream_watchdog_returns_error_on_stall(monkeypatch) -> None:
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0") monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0.01")
mock_create = AsyncMock(return_value=_StalledStream()) mock_create = AsyncMock(return_value=_StalledStream())
spec = find_by_name("openai") spec = find_by_name("openai")
@@ -303,6 +303,37 @@ async def test_codex_http_error_preserves_status_and_retry_after(monkeypatch) ->
assert response.error_should_retry is True assert response.error_should_retry is True
def test_codex_response_failed_server_error_is_retryable() -> None:
response = _codex_error_response(
RuntimeError(
"Response failed: {'type': 'server_error', 'code': 'server_error', "
"'message': 'The server had an error while processing your request.'}"
)
)
assert response.finish_reason == "error"
assert response.error_kind == "provider"
assert response.error_type == "server_error"
assert response.error_code == "server_error"
assert response.error_should_retry is True
assert provider_base.LLMProvider._is_transient_response(response) is True
def test_codex_response_failed_cyber_policy_is_not_retryable() -> None:
response = _codex_error_response(
RuntimeError(
"Response failed: {'type': 'invalid_request_error', 'code': 'cyber_policy', "
"'message': 'Request denied.'}"
)
)
assert response.error_kind == "provider"
assert response.error_type == "invalid_request_error"
assert response.error_code == "cyber_policy"
assert response.error_should_retry is False
assert provider_base.LLMProvider._is_transient_response(response) is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_codex_http_diagnostic_log_omits_raw_body(monkeypatch) -> None: async def test_codex_http_diagnostic_log_omits_raw_body(monkeypatch) -> None:
log_capture = _capture_codex_warnings(monkeypatch) log_capture = _capture_codex_warnings(monkeypatch)
+33
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import ipaddress
import socket import socket
from unittest.mock import patch from unittest.mock import patch
@@ -107,6 +108,38 @@ def test_blocks_ipv6_mapped_rfc1918():
assert not ok assert not ok
def test_blocks_sampled_addresses_from_internal_networks():
"""Property-style guard: sampled blocked CIDRs must all fail closed."""
configure_ssrf_whitelist([])
blocked_networks = [
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.168.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10",
]
samples: list[str] = []
for cidr in blocked_networks:
network = ipaddress.ip_network(cidr)
samples.append(str(network.network_address))
if network.num_addresses > 2:
samples.append(str(network.network_address + 1))
samples.append(str(network[-2]))
for idx, ip in enumerate(samples):
host = f"internal-{idx}.example"
resolver = _fake_resolve_v6 if ":" in ip else _fake_resolve
with patch("nanobot.security.network.socket.getaddrinfo", resolver(host, [ip])):
ok, err = validate_url_target(f"http://{host}/")
assert not ok, f"expected {ip} to be blocked"
assert "blocked" in err.lower() or "private" in err.lower()
def test_allows_public_ipv6(): def test_allows_public_ipv6():
"""Public IPv6 addresses must still be allowed.""" """Public IPv6 addresses must still be allowed."""
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_v6("example.com", ["2606:4700::6810:84e5"])): with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_v6("example.com", ["2606:4700::6810:84e5"])):
+32
View File
@@ -53,6 +53,38 @@ def test_resolve_allowed_path_blocks_parent_traversal(tmp_path: Path) -> None:
resolve_allowed_path("../secret.txt", workspace=workspace, allowed_root=workspace) resolve_allowed_path("../secret.txt", workspace=workspace, allowed_root=workspace)
def test_resolve_allowed_path_blocks_traversal_shapes(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "secret.txt"
outside.write_text("secret", encoding="utf-8")
traversal_shapes: list[str | Path] = [
"../secret.txt",
"src/../../secret.txt",
Path("..") / "secret.txt",
workspace / "src" / ".." / ".." / "secret.txt",
]
if os.name == "nt":
traversal_shapes.append("src\\..\\..\\secret.txt")
for candidate in traversal_shapes:
with pytest.raises(WorkspaceBoundaryError, match="outside allowed directory"):
resolve_allowed_path(candidate, workspace=workspace, allowed_root=workspace)
def test_resolve_allowed_path_blocks_prefix_sibling(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
sibling = tmp_path / "workspace-other"
sibling.mkdir()
secret = sibling / "secret.txt"
secret.write_text("secret", encoding="utf-8")
with pytest.raises(WorkspaceBoundaryError, match="outside allowed directory"):
resolve_allowed_path(secret, workspace=workspace, allowed_root=workspace)
def test_resolve_allowed_path_blocks_symlink_escape(tmp_path: Path) -> None: def test_resolve_allowed_path_blocks_symlink_escape(tmp_path: Path) -> None:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
+33 -2
View File
@@ -15,7 +15,7 @@ def test_deny_patterns_block_rm_rf():
def test_allow_patterns_bypass_deny(): def test_allow_patterns_bypass_deny():
"""allow_patterns take priority: matching command skips deny check.""" """allow_patterns take priority: matching command skips deny check."""
tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/tmp/"]) tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/tmp/.*"])
result = tool._guard_command("rm -rf /tmp/build", "/tmp") result = tool._guard_command("rm -rf /tmp/build", "/tmp")
assert result is None assert result is None
@@ -49,10 +49,41 @@ def test_allow_patterns_bypass_extra_deny():
def test_allow_patterns_is_whitelist_only(): def test_allow_patterns_is_whitelist_only():
"""When allow_patterns is set, non-matching non-denied commands are blocked.""" """When allow_patterns is set, non-matching non-denied commands are blocked."""
tool = ExecTool(allow_patterns=[r"\becho\b"]) tool = ExecTool(allow_patterns=[r"echo\s+hello"])
# echo matches allow → ok # echo matches allow → ok
assert tool._guard_command("echo hello", "/tmp") is None assert tool._guard_command("echo hello", "/tmp") is None
# ls does not match allow and is not in deny → blocked by allowlist # ls does not match allow and is not in deny → blocked by allowlist
result = tool._guard_command("ls /tmp", "/tmp") result = tool._guard_command("ls /tmp", "/tmp")
assert result is not None assert result is not None
assert "allowlist" in result.lower() assert "allowlist" in result.lower()
def test_allow_patterns_do_not_allow_chained_command_bypass():
"""A partial allowlist match must not bypass deny patterns in chained commands."""
tool = ExecTool(allow_patterns=[r"\becho\b"])
result = tool._guard_command("echo hello; rm -rf /", "/tmp")
assert result is not None
assert "deny pattern filter" in result.lower()
def test_allow_patterns_do_not_allow_comment_tail_bypass():
"""Comment tails must not make a non-allowlisted command match."""
tool = ExecTool(allow_patterns=[r"echo allowlisted"])
result = tool._guard_command("touch canary # echo allowlisted", "/tmp")
assert result is not None
assert "allowlist" in result.lower()
def test_deny_patterns_search_original_command_with_quoted_hash():
"""Deny checks must still inspect text after a quoted hash."""
tool = ExecTool(deny_patterns=[r"\brm\s+-rf\s+/"])
result = tool._guard_command('echo "#"; rm -rf /', "/tmp")
assert result is not None
assert "deny pattern filter" in result.lower()
def test_allow_patterns_fullmatch_allows_exact_command():
"""A full-command allow pattern can still exempt an exact denied command."""
tool = ExecTool(allow_patterns=[r"rm\s+-rf\s+/tmp/build"])
result = tool._guard_command("rm -rf /tmp/build", "/tmp")
assert result is None
+24 -1
View File
@@ -105,7 +105,7 @@ class TestSpawnUnix:
args = mock_exec.call_args[0] args = mock_exec.call_args[0]
assert "bash" in args[0] assert "bash" in args[0]
assert "-l" in args assert "-l" not in args
assert "-c" in args assert "-c" in args
assert "echo hi" in args assert "echo hi" in args
@@ -400,6 +400,29 @@ class TestExecuteEndToEnd:
assert "hello world" in result assert "hello world" in result
assert "Exit code: 0" in result assert "Exit code: 0" in result
@pytest.mark.asyncio
async def test_execute_defaults_to_non_login_shell(self):
"""The public execute path must not silently request a login shell."""
mock_proc = AsyncMock()
mock_proc.communicate.return_value = (b"ok\n", b"")
mock_proc.returncode = 0
captured_login = []
async def capture_spawn(cmd, cwd, env, shell_program=None, login=None, *, stdin=None):
captured_login.append(login)
return mock_proc
with (
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
patch.object(ExecTool, "_guard_command", return_value=None),
):
tool = ExecTool()
await tool.execute(command="echo ok")
await tool.execute(command="echo ok", login=True)
assert captured_login == [False, True]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _extract_absolute_paths - UNC path support # _extract_absolute_paths - UNC path support
+20 -1
View File
@@ -9,7 +9,11 @@ from unittest.mock import patch
import pytest import pytest
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
from nanobot.security.workspace_access import bind_workspace_scope, build_workspace_scope, reset_workspace_scope from nanobot.security.workspace_access import (
bind_workspace_scope,
build_workspace_scope,
reset_workspace_scope,
)
def _fake_resolve_private(hostname, port, family=0, type_=0): def _fake_resolve_private(hostname, port, family=0, type_=0):
@@ -68,6 +72,21 @@ def test_exec_core_full_workspace_scope_blocks_loopback(tmp_path):
assert "internal/private" in error assert "internal/private" in error
def test_exec_explicit_local_service_access_allows_loopback(tmp_path):
tool = ExecTool(working_dir=str(tmp_path), allow_local_service_access=True)
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
error = tool._guard_command("curl http://localhost:8765/", str(tmp_path))
assert error is None
def test_exec_explicit_local_service_access_still_blocks_metadata(tmp_path):
tool = ExecTool(working_dir=str(tmp_path), allow_local_service_access=True)
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_private):
error = tool._guard_command("curl http://169.254.169.254/latest/meta-data/", str(tmp_path))
assert error is not None
assert "internal/private" in error
def test_exec_full_workspace_scope_blocks_loopback_when_local_service_disabled(tmp_path): def test_exec_full_workspace_scope_blocks_loopback_when_local_service_disabled(tmp_path):
tool = ExecTool(working_dir=str(tmp_path), webui_allow_local_service_access=False) tool = ExecTool(working_dir=str(tmp_path), webui_allow_local_service_access=False)
scope = build_workspace_scope(tmp_path, "full", source_channel="websocket") scope = build_workspace_scope(tmp_path, "full", source_channel="websocket")
+107
View File
@@ -104,6 +104,84 @@ def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
assert "Exit code: 0" in result assert "Exit code: 0" in result
def test_exec_detach_starts_background_process(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
ready_path = tmp_path / "ready.txt"
command = _python_command(
"import pathlib, time; "
"pathlib.Path('ready.txt').write_text('ok'); "
"time.sleep(0.6)"
)
result = await tool.execute(command=command, detach=True)
for _ in range(20):
if ready_path.exists():
break
await asyncio.sleep(0.05)
return result
result = asyncio.run(run())
assert "Detached process started." in result
assert "pid:" in result
assert "log:" in result
assert (tmp_path / "ready.txt").read_text() == "ok"
def test_exec_detach_reports_immediate_exit(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
command = _python_command("print('boom'); raise SystemExit(7)")
return await tool.execute(command=command, detach=True)
result = asyncio.run(run())
assert "Detached process exited immediately with code 7" in result
assert "boom" in result
def test_exec_long_output_summary_includes_failure_signals(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
command = _python_command(
"print('A' * 3000); "
"print('FAILED ../tests/test_outputs.py::test_artifact - AssertionError: missing output'); "
"print(\"FileNotFoundError: [Errno 2] No such file or directory: '/app/out.txt'\"); "
"print('B' * 3000); "
"raise SystemExit(1)"
)
return await tool.execute(command=command, max_output_tokens=2500)
result = asyncio.run(run())
assert "[tool output truncated]" in result
assert "chars truncated" in result
assert "failed_tests:" in result
assert "../tests/test_outputs.py::test_artifact" in result
assert "missing_artifacts:" in result
assert "/app/out.txt" in result
assert "head:" in result
assert "tail:" in result
assert "[Verification Feedback]" in result
def test_exec_adds_verification_feedback_for_test_failures(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
command = _python_command(
"print('FAILED test_outputs.py::test_answer - AssertionError: wrong'); "
"print('AssertionError: wrong'); raise SystemExit(1)"
)
return await tool.execute(command=command)
result = asyncio.run(run())
assert "Exit code: 1" in result
assert "[Verification Feedback]" in result
assert "Do not call complete_goal" in result
assert "test_outputs.py::test_answer" in result
def test_exec_accepts_supported_shell_parameter(tmp_path): def test_exec_accepts_supported_shell_parameter(tmp_path):
async def run() -> str: async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5) tool = ExecTool(working_dir=str(tmp_path), timeout=5)
@@ -235,6 +313,35 @@ def test_write_stdin_accepts_max_output_tokens_alias(tmp_path):
assert "Session terminated." in cleanup assert "Session terminated." in cleanup
def test_write_stdin_long_output_summary_includes_failure_signals(tmp_path):
async def run() -> str:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
command = _python_command(
"print('A' * 3000); "
"print('FAILED test_outputs.py::test_file - AssertionError: bad'); "
"print(\"FileNotFoundError: [Errno 2] No such file or directory: '/app/missing.txt'\"); "
"print('B' * 3000); "
"raise SystemExit(1)"
)
return await exec_tool.execute(
command=command,
yield_time_ms=1000,
max_output_tokens=2500,
)
result = asyncio.run(run())
assert "[tool output truncated]" in result
assert "chars truncated" in result
assert "failed_tests:" in result
assert "test_outputs.py::test_file" in result
assert "missing_artifacts:" in result
assert "/app/missing.txt" in result
assert "Exit code: 1" in result
assert "[Verification Feedback]" in result
def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path): def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
async def run() -> tuple[str, str]: async def run() -> tuple[str, str]:
manager = ExecSessionManager() manager = ExecSessionManager()
+73
View File
@@ -433,6 +433,79 @@ async def test_connect_mcp_servers_enabled_tools_empty_list_registers_none(
assert registry.tool_names == [] assert registry.tool_names == []
@pytest.mark.asyncio
async def test_connect_mcp_servers_enabled_tools_empty_list_blocks_resources_and_prompts(
fake_mcp_runtime: dict[str, object | None],
) -> None:
"""enabledTools: [] (deny-all) must also block resource and prompt registration."""
fake_mcp_runtime["session"] = _make_fake_session_with_capabilities(
tool_names=["demo"],
resource_names=["secret_data"],
prompt_names=["admin_prompt"],
)
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{"test": MCPServerConfig(command="fake", enabled_tools=[])},
registry,
)
for stack in stacks.values():
await stack.aclose()
assert registry.tool_names == []
# Resources and prompts must also be blocked
assert not any("secret_data" in name for name in registry.tool_names)
assert not any("admin_prompt" in name for name in registry.tool_names)
@pytest.mark.asyncio
async def test_connect_mcp_servers_enabled_tools_specific_list_blocks_resources_and_prompts(
fake_mcp_runtime: dict[str, object | None],
) -> None:
"""enabledTools with specific tool names must not leak resources or prompts."""
fake_mcp_runtime["session"] = _make_fake_session_with_capabilities(
tool_names=["demo", "other"],
resource_names=["secret_data"],
prompt_names=["admin_prompt"],
)
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{"test": MCPServerConfig(command="fake", enabled_tools=["demo"])},
registry,
)
for stack in stacks.values():
await stack.aclose()
# Only the allowed tool should be registered
assert "mcp_test_demo" in registry.tool_names
assert "mcp_test_other" not in registry.tool_names
# Resources and prompts must not leak
assert not any("secret_data" in name for name in registry.tool_names)
assert not any("admin_prompt" in name for name in registry.tool_names)
@pytest.mark.asyncio
async def test_connect_mcp_servers_enabled_tools_wildcard_allows_resources_and_prompts(
fake_mcp_runtime: dict[str, object | None],
) -> None:
"""enabledTools: ['*'] should allow all tools, resources, and prompts."""
fake_mcp_runtime["session"] = _make_fake_session_with_capabilities(
tool_names=["demo"],
resource_names=["public_data"],
prompt_names=["help_prompt"],
)
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{"test": MCPServerConfig(command="fake", enabled_tools=["*"])},
registry,
)
for stack in stacks.values():
await stack.aclose()
assert "mcp_test_demo" in registry.tool_names
assert any("public_data" in name for name in registry.tool_names)
assert any("help_prompt" in name for name in registry.tool_names)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_connect_mcp_servers_enabled_tools_warns_on_unknown_entries( async def test_connect_mcp_servers_enabled_tools_warns_on_unknown_entries(
fake_mcp_runtime: dict[str, object | None], monkeypatch: pytest.MonkeyPatch fake_mcp_runtime: dict[str, object | None], monkeypatch: pytest.MonkeyPatch
+5 -3
View File
@@ -660,10 +660,12 @@ async def test_exec_head_tail_truncation(tmp_path) -> None:
else: else:
command = f"{shlex.quote(sys.executable)} {shlex.quote(str(script_file))}" command = f"{shlex.quote(sys.executable)} {shlex.quote(str(script_file))}"
result = await tool.execute(command=command) result = await tool.execute(command=command)
assert "[tool output truncated]" in result
assert "chars truncated" in result assert "chars truncated" in result
# Head portion should start with As assert "head:" in result
assert result.startswith("A") assert "tail:" in result
# Tail portion should end with the exit code which comes after Bs assert "A" * 80 in result
assert "B" * 80 in result
assert "Exit code:" in result assert "Exit code:" in result
+179
View File
@@ -0,0 +1,179 @@
"""Black-box smoke test for the real gateway WebUI transport."""
from __future__ import annotations
import asyncio
import json
import socket
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import quote
import httpx
import pytest
import websockets
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _write_smoke_config(path: Path, *, workspace: Path, ws_port: int, gateway_port: int) -> None:
config = {
"agents": {
"defaults": {
"workspace": str(workspace),
"provider": "custom",
"model": "custom/smoke-model",
"maxToolIterations": 1,
"dream": {"enabled": False},
}
},
"providers": {
"custom": {
"apiKey": "smoke-no-external-call",
"apiBase": "http://127.0.0.1:9/v1",
}
},
"channels": {
"websocket": {
"enabled": True,
"host": "127.0.0.1",
"port": ws_port,
"allowFrom": ["*"],
}
},
"gateway": {
"host": "127.0.0.1",
"port": gateway_port,
"heartbeat": {"enabled": False},
},
}
path.write_text(json.dumps(config), encoding="utf-8")
def _start_gateway(config_path: Path, log_path: Path) -> subprocess.Popen[bytes]:
log_file = log_path.open("wb")
try:
process = subprocess.Popen(
[
sys.executable,
"-m",
"nanobot",
"gateway",
"--config",
str(config_path),
],
cwd=Path(__file__).resolve().parents[2],
stdout=log_file,
stderr=subprocess.STDOUT,
)
finally:
log_file.close()
return process
def _stop_gateway(process: subprocess.Popen[bytes]) -> None:
if process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
def _get_json(url: str, *, token: str | None = None) -> dict:
headers = {"Authorization": f"Bearer {token}"} if token else {}
response = httpx.get(url, headers=headers, timeout=5.0, trust_env=False)
response.raise_for_status()
return response.json()
def _wait_for_bootstrap(base_url: str, process: subprocess.Popen[bytes], log_path: Path) -> dict:
deadline = time.monotonic() + 20
last_error: Exception | None = None
while time.monotonic() < deadline:
if process.poll() is not None:
break
try:
return _get_json(f"{base_url}/webui/bootstrap")
except (httpx.HTTPError, OSError) as exc:
last_error = exc
time.sleep(0.2)
logs = log_path.read_text(encoding="utf-8", errors="replace")
raise AssertionError(f"gateway did not start; last_error={last_error!r}\n{logs}")
async def _recv_until(ws: websockets.WebSocketClientProtocol, event: str) -> dict:
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
raw = await asyncio.wait_for(ws.recv(), timeout=5)
payload = json.loads(raw)
if payload.get("event") == event:
return payload
raise AssertionError(f"websocket event {event!r} was not received")
@pytest.mark.asyncio
async def test_gateway_webui_bootstrap_message_and_thread_hydration(tmp_path: Path) -> None:
ws_port = _free_port()
gateway_port = _free_port()
workspace = tmp_path / "workspace"
workspace.mkdir()
config_path = tmp_path / "config.json"
log_path = tmp_path / "gateway.log"
_write_smoke_config(
config_path,
workspace=workspace,
ws_port=ws_port,
gateway_port=gateway_port,
)
process = _start_gateway(config_path, log_path)
base_url = f"http://127.0.0.1:{ws_port}"
try:
bootstrap = _wait_for_bootstrap(base_url, process, log_path)
assert bootstrap["model_name"] == "custom/smoke-model"
ws_url = f'{bootstrap["ws_url"]}?token={bootstrap["token"]}&client_id=smoke'
async with websockets.connect(ws_url) as ws:
ready = await _recv_until(ws, "ready")
assert ready["client_id"] == "smoke"
await ws.send(json.dumps({"type": "new_chat"}))
attached = await _recv_until(ws, "attached")
chat_id = attached["chat_id"]
await _recv_until(ws, "session_updated")
await ws.send(json.dumps({
"type": "message",
"chat_id": chat_id,
"content": "/model",
"webui": True,
"turn_id": "smoke-turn",
}))
answer = await _recv_until(ws, "message")
assert "Current model: `custom/smoke-model`" in answer["text"]
await _recv_until(ws, "turn_end")
api_token = _wait_for_bootstrap(base_url, process, log_path)["token"]
sessions = _get_json(f"{base_url}/api/sessions", token=api_token)
key = f"websocket:{chat_id}"
assert key in {row["key"] for row in sessions["sessions"]}
encoded_key = quote(key, safe="")
thread = _get_json(
f"{base_url}/api/sessions/{encoded_key}/webui-thread",
token=api_token,
)
contents = [str(message.get("content") or "") for message in thread["messages"]]
assert "/model" in contents
assert any("Current model: `custom/smoke-model`" in text for text in contents)
finally:
_stop_gateway(process)
+8 -147
View File
@@ -9,10 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
@@ -37,7 +35,7 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.5", "@types/node": "^24.0.0",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
@@ -1009,10 +1007,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": { "node_modules/@radix-ui/primitive": {
"version": "1.1.3", "version": "1.1.3",
"license": "MIT" "license": "MIT"
@@ -1080,65 +1074,6 @@
} }
} }
}, },
"node_modules/@radix-ui/react-avatar": {
"version": "1.1.11",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.3",
"@radix-ui/react-primitive": "2.1.4",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-is-hydrated": "0.1.0",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": {
"version": "1.1.3",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.7", "version": "1.1.7",
"license": "MIT", "license": "MIT",
@@ -1566,35 +1501,6 @@
} }
} }
}, },
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.8", "version": "1.1.8",
"license": "MIT", "license": "MIT",
@@ -1763,22 +1669,6 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-is-hydrated": {
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": { "node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1", "version": "1.1.1",
"license": "MIT", "license": "MIT",
@@ -1946,9 +1836,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1963,9 +1850,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1980,9 +1864,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1997,9 +1878,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2014,9 +1892,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2031,9 +1906,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2048,9 +1920,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2065,9 +1934,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2082,9 +1948,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2099,9 +1962,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2116,9 +1976,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2432,11 +2289,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.17", "version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
@@ -6751,7 +6610,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
+3 -2
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useThemeValue } from "@/hooks/useTheme"; import { useThemeValue } from "@/hooks/useTheme";
import { hasAnsi, parseAnsiSegments, stripAnsi } from "@/lib/ansi"; import { hasAnsi, parseAnsiSegments, stripAnsi } from "@/lib/ansi";
import { copyTextToClipboard } from "@/lib/clipboard";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface CodeBlockProps { interface CodeBlockProps {
@@ -192,8 +193,8 @@ export function CodeBlock({
const renderAnsi = shouldRenderAnsi(language, code); const renderAnsi = shouldRenderAnsi(language, code);
const onCopy = useCallback(() => { const onCopy = useCallback(() => {
if (!navigator.clipboard) return; void copyTextToClipboard(renderAnsi ? stripAnsi(code) : code).then((ok) => {
navigator.clipboard.writeText(renderAnsi ? stripAnsi(code) : code).then(() => { if (!ok) return;
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1_500); setTimeout(() => setCopied(false), 1_500);
}); });
@@ -172,6 +172,7 @@ interface ThreadComposerProps {
workspaceError?: string | null; workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null; pendingQueueKey?: string | null;
transcriptionProvider?: string | null;
} }
const COMMAND_ICONS: Record<string, LucideIcon> = { const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -782,6 +783,7 @@ export function ThreadComposer({
workspaceError = null, workspaceError = null,
onWorkspaceScopeChange, onWorkspaceScopeChange,
pendingQueueKey = null, pendingQueueKey = null,
transcriptionProvider = null,
}: ThreadComposerProps) { }: ThreadComposerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState(""); const [value, setValue] = useState("");
@@ -1193,6 +1195,7 @@ export function ThreadComposer({
onError: setVoiceError, onError: setVoiceError,
onTranscript: appendTranscription, onTranscript: appendTranscription,
onTranscribeAudio, onTranscribeAudio,
wantsWav: transcriptionProvider === "xiaomi_mimo",
}); });
useEffect(() => { useEffect(() => {
@@ -494,6 +494,7 @@ export function ThreadShell({
return client.onSessionUpdate((updatedChatId, scope) => { return client.onSessionUpdate((updatedChatId, scope) => {
if (updatedChatId !== chatId) return; if (updatedChatId !== chatId) return;
if (scope === "metadata") return; if (scope === "metadata") return;
viewportRef.current?.cancelAutoScroll();
pendingCanonicalHydrateRef.current.add(chatId); pendingCanonicalHydrateRef.current.add(chatId);
refreshHistory(); refreshHistory();
}); });
@@ -736,6 +737,7 @@ export function ThreadShell({
workspaceError={workspaceError} workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId} pendingQueueKey={chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
/> />
) : ( ) : (
<ThreadComposer <ThreadComposer
@@ -765,6 +767,7 @@ export function ThreadShell({
workspaceScopeDisabled={workspaceScopeDisabled} workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError} workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
/> />
)} )}
</> </>
@@ -26,6 +26,7 @@ import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
export interface ThreadViewportHandle { export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void; jumpToUserPrompt: (promptId: string) => void;
cancelAutoScroll: () => void;
} }
interface ThreadViewportProps { interface ThreadViewportProps {
@@ -290,7 +291,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
setVisibleMessageCount((count) => Math.max(count, messages.length - index)); setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages]); }, [messages]);
useImperativeHandle(ref, () => ({ jumpToUserPrompt }), [jumpToUserPrompt]); useImperativeHandle(
ref,
() => ({
jumpToUserPrompt,
cancelAutoScroll: cancelScheduledBottomScroll,
}),
[cancelScheduledBottomScroll, jumpToUserPrompt],
);
const measureComposerDock = useCallback(() => { const measureComposerDock = useCallback(() => {
const el = composerDockRef.current; const el = composerDockRef.current;

Some files were not shown because too many files have changed in this diff Show More