diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..b6172a29 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,135 @@ +name: Bug Report +description: Report a bug or unexpected behavior +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug! Please fill out the sections below to help us diagnose the issue. + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear description of what went wrong. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: How can we reproduce this behavior? + placeholder: | + 1. Configure nanobot with ... + 2. Send message ... + 3. See error ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant Logs + description: | + Paste any relevant log output. You can run nanobot with `--log-level DEBUG` for more verbose logs. + **Remember to redact any sensitive information (tokens, API keys, passwords, etc.)** + render: shell + + - type: input + id: version + attributes: + label: nanobot Version + description: Run `nanobot --version` or `pip show nanobot-ai` + placeholder: e.g., 0.1.5 + validations: + required: true + + - type: dropdown + id: python_version + attributes: + label: Python Version + description: What Python version are you using? + options: + - "3.11" + - "3.12" + - "3.13" + - Other (specify below) + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - Windows + - macOS + - Linux + - Docker + - Other (specify below) + validations: + required: true + + - type: dropdown + id: channel + attributes: + label: Channel / Platform + description: Which messaging platform are you using? + options: + - Weixin (Personal WeChat) + - WeCom (Enterprise WeChat) + - Feishu (Lark) + - DingTalk + - Telegram + - Discord + - Slack + - QQ + - WhatsApp + - Email + - MS Teams + - Matrix + - WebSocket + - API Server + - Other (specify below) + validations: + required: true + + - type: dropdown + id: llm_provider + attributes: + label: LLM Provider + description: Which LLM provider are you using? + options: + - OpenAI + - Anthropic (Claude) + - DeepSeek + - Google (Gemini) + - Ollama (Local) + - OpenRouter + - Azure OpenAI + - Other (specify below) + validations: + required: true + + - type: textarea + id: config + attributes: + label: Configuration (Optional) + description: | + Relevant parts of your nanobot configuration. **Remember to redact any sensitive information.** + render: yaml + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context, screenshots, or information that might help. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..8057511b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Question / Support + url: https://github.com/HKUDS/nanobot/discussions + about: Ask questions and get help from the community in Discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..0a43df33 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,55 @@ +name: Feature Request +description: Suggest a new feature or enhancement +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a feature! Please describe your idea clearly. + + - type: textarea + id: problem + attributes: + label: Problem / Motivation + description: What problem does this feature solve? What are you trying to accomplish? + placeholder: I'm always frustrated when ... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: How would you like this to work? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: What other approaches have you considered? + + - type: dropdown + id: component + attributes: + label: Related Component + description: Which part of nanobot does this relate to? + options: + - Channel (WeChat, Feishu, Telegram, etc.) + - LLM Provider + - Agent / Prompts + - Skills / Plugins + - Configuration + - CLI + - API Server + - Documentation + - Other + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context, examples from other projects, screenshots, etc. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fac9be66..22d38e95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,11 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.11", "3.12", "3.13"] + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 @@ -24,10 +25,11 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v4 - - name: Install system dependencies + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential - - name: Install all dependencies + - name: Install dependencies run: uv sync --all-extras - name: Lint with ruff diff --git a/.gitignore b/.gitignore index 68a879ba..054e5ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,14 @@ .docs .env .web +.orion + +# webui (monorepo frontend) +webui/node_modules/ +webui/dist/ +webui/coverage/ +webui/.vite/ +*.tsbuildinfo # Python bytecode & caches *.pyc diff --git a/README.md b/README.md index b376d099..140846ac 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,20 @@ ## 📢 News +- **2026-04-14** 🚀 Released **v0.1.5.post1** — Dream skill discovery, mid-turn follow-up injection, WebSocket channel, and deeper channel integrations. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post1) for details. +- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks. +- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened. +- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media. +- **2026-04-10** 📓 Notebook editing tool, multiple MCP servers, Feishu streaming & done-emoji. +- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config. +- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback. +- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools. +- **2026-04-06** 🛰️ Langfuse observability, unified Whisper transcription, email attachments. - **2026-04-05** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details. + +
+Earlier news + - **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling. - **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish. - **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening. @@ -31,11 +44,6 @@ - **2026-03-29** 💬 WeChat voice, typing, QR/media resilience; fixed-session OpenAI-compatible API. - **2026-03-28** 📚 Provider docs refresh; skill template wording fix. - **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details. - - -
-Earlier news - - **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries. - **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures. - **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured. @@ -276,6 +284,7 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the | **Email** | IMAP/SMTP credentials | | **QQ** | App ID + App Secret | | **Wecom** | Bot ID + Bot Secret | +| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint | | **Mochat** | Claw token (auto-setup available) |
@@ -394,6 +403,7 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso "enabled": true, "token": "YOUR_BOT_TOKEN", "allowFrom": ["YOUR_USER_ID"], + "allowChannels": [], "groupPolicy": "mention", "streaming": true } @@ -406,6 +416,7 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso > - `"open"` — Respond to all messages > DMs always respond when the sender is in `allowFrom`. > - If you set group policy to open create new threads as private threads and then @ the bot into it. Otherwise the thread itself and the channel in which you spawned it will spawn a bot session. +> `allowChannels` restricts the bot to specific Discord channel IDs. Empty (default) means respond in every channel the bot can see. Example: `["1234567890", "0987654321"]`. The filter applies after `allowFrom`, so both must pass. > `streaming` defaults to `true`. Disable it only if you explicitly want non-streaming replies. **5. Invite the bot** @@ -431,6 +442,13 @@ Install Matrix dependencies first: pip install nanobot-ai[matrix] ``` +> [!NOTE] +> Matrix is not supported on Windows. `matrix-nio[e2e]` depends on +> `python-olm`, which has no pre-built Windows wheel and is skipped by the +> `matrix` extra on `sys_platform == 'win32'`. The command above will still +> succeed on Windows but without `matrix-nio` installed, so enabling the +> Matrix channel will fail at startup. Use macOS, Linux, or WSL2. + **1. Create/choose a Matrix account** - Create or reuse a Matrix account on your homeserver (for example `matrix.org`). @@ -861,6 +879,56 @@ nanobot gateway
+
+Microsoft Teams (MVP — DM only) + +> Direct-message text in/out, tenant-aware OAuth, conversation reference persistence. +> Uses a public HTTPS webhook — no WebSocket; you need a tunnel or reverse proxy. + +**1. Install the optional dependency** + +```bash +pip install nanobot-ai[msteams] +``` + +**2. Create a Teams / Azure bot app registration** + +Create or reuse a Microsoft Teams / Azure bot app registration. Set the bot messaging endpoint to a public HTTPS URL ending in `/api/messages`. + +**3. Configure** + +```json +{ + "channels": { + "msteams": { + "enabled": true, + "appId": "YOUR_APP_ID", + "appPassword": "YOUR_APP_SECRET", + "tenantId": "YOUR_TENANT_ID", + "host": "0.0.0.0", + "port": 3978, + "path": "/api/messages", + "allowFrom": ["*"], + "replyInThread": true, + "mentionOnlyResponse": "Hi — what can I help with?", + "validateInboundAuth": true + } + } +} +``` + +> - `replyInThread: true` replies to the triggering Teams activity when a stored `activity_id` is available. +> - `mentionOnlyResponse` controls what Nanobot receives when a user sends only a bot mention (`Nanobot`). Set to `""` to ignore mention-only messages. +> - `validateInboundAuth: true` enables inbound Bot Framework bearer-token validation (signature, issuer, audience, lifetime, `serviceUrl`). This is the safe default for public deployments. Only set it to `false` for local development or tightly controlled testing. + +**4. Run** + +```bash +nanobot gateway +``` + +
+ ## 🌐 Agent Social Network 🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!** @@ -922,6 +990,7 @@ IMAP_PASSWORD=your-password-here > - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead — the API key is picked from the matching provider config. > - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link) > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. +> - **MiniMax thinking mode**: Use `providers.minimaxAnthropic` when you want `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`. > - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers. > - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. > - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config. @@ -939,6 +1008,7 @@ IMAP_PASSWORD=your-password-here | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `groq` | LLM + Voice transcription (Whisper, default) | [console.groq.com](https://console.groq.com) | | `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) | +| `minimax_anthropic` | LLM (MiniMax Anthropic-compatible endpoint, thinking mode) | [platform.minimaxi.com](https://platform.minimaxi.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | | `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) | @@ -947,6 +1017,7 @@ IMAP_PASSWORD=your-password-here | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | | `ollama` | LLM (local, Ollama) | — | +| `lm_studio` | LLM (local, LM Studio) | — | | `mistral` | LLM | [docs.mistral.ai](https://docs.mistral.ai/) | | `stepfun` | LLM (Step Fun/阶跃星辰) | [platform.stepfun.com](https://platform.stepfun.com) | | `ovms` | LLM (local, OpenVINO Model Server) | [docs.openvino.ai](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) | @@ -1034,7 +1105,7 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
Custom Provider (Any OpenAI-compatible API) -Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Model name is passed as-is. +Connects directly to any OpenAI-compatible endpoint — llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Model name is passed as-is. ```json { @@ -1052,7 +1123,31 @@ Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, To } ``` -> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`). +> For local servers that don't require authentication, set `apiKey` to `null`. +> +> `custom` is the right choice for providers that expose an OpenAI-compatible **chat completions** API. It does **not** force third-party endpoints onto the OpenAI/Azure **Responses API**. +> +> If your proxy or gateway is specifically Responses-API-compatible, use the `azure_openai` provider shape instead and point `apiBase` at that endpoint: +> +> ```json +> { +> "providers": { +> "azure_openai": { +> "apiKey": "your-api-key", +> "apiBase": "https://api.your-provider.com", +> "defaultModel": "your-model-name" +> } +> }, +> "agents": { +> "defaults": { +> "provider": "azure_openai", +> "model": "your-model-name" +> } +> } +> } +> ``` +> +> In short: **chat-completions-compatible endpoint → `custom`**; **Responses-compatible endpoint → `azure_openai`**.
@@ -1087,6 +1182,40 @@ ollama run llama3.2
+
+LM Studio (local) + +[LM Studio](https://lmstudio.ai/) provides a local OpenAI-compatible server for running LLMs. Download models through the LM Studio UI, then start the local server. + +**1. Start LM Studio server:** +- Launch LM Studio +- Go to the "Local Server" tab +- Load a model (e.g., Llama, Mistral, Qwen) +- Click "Start Server" (default port: 1234) + +**2. Add to config** (partial — merge into `~/.nanobot/config.json`): +```json +{ + "providers": { + "lm_studio": { + "apiKey": null, + "apiBase": "http://localhost:1234/v1" + } + }, + "agents": { + "defaults": { + "provider": "lm_studio", + "model": "local-model" + } + } +} +``` + +> **Note:** Set `apiKey` to `null` for LM Studio since it runs locally and doesn't require authentication. The model name should match what's shown in the LM Studio UI. +> `provider: "auto"` also works when `providers.lm_studio.apiBase` is configured, but setting `"provider": "lm_studio"` is the clearest option. + +
+
OpenVINO Model Server (local / OpenAI-compatible) @@ -1174,12 +1303,12 @@ vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000 **2. Add to config** (partial — merge into `~/.nanobot/config.json`): -*Provider (key can be any non-empty string for local):* +*Provider (set API key to null for local servers):* ```json { "providers": { "vllm": { - "apiKey": "dummy", + "apiKey": null, "apiBase": "http://localhost:8000/v1" } } @@ -1546,8 +1675,12 @@ How it works: 3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix. 4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart. -> [!TIP] -> Think of auto compact as "summarize older context, keep the freshest live turns." It is not a hard session reset. +> [!NOTE] +> Mental model: "summarize older context, keep the freshest live turns, **and overwrite the session file with the compact form.**" It is not a full `session.clear()`, but it is a write — not a soft cursor move. +> +> Concretely, auto compact rewrites `sessions/.jsonl` in place: older messages (including their structured `tool_calls` / `tool_call_id` / `reasoning_content`) are replaced by just the retained recent suffix (currently 8 messages), while the archived prefix is preserved only as a plain-text summary appended to `memory/history.jsonl` (or a `[RAW] ...` flattened dump if LLM summarization fails). The original structured JSON of those turns is no longer recoverable from the session file. +> +> This differs from the **token-driven soft consolidation** that fires when a prompt exceeds the context budget: that path only advances an internal `last_consolidated` cursor and leaves the session file untouched, so the raw tool-call trail stays on disk and can still be replayed or audited. If you rely on that trail for debugging or auditing, leave `idleCompactAfterMinutes` at the default `0` and let only the token-driven path run. ### Timezone @@ -1703,6 +1836,7 @@ Example config: } }, "gateway": { + "host": "127.0.0.1", "port": 18790 } } @@ -1715,6 +1849,14 @@ nanobot gateway --config ~/.nanobot-telegram/config.json nanobot gateway --config ~/.nanobot-discord/config.json ``` +Each gateway instance also exposes a lightweight HTTP health endpoint on +`gateway.host:gateway.port`. By default, the gateway binds to `127.0.0.1`, +so the endpoint stays local unless you explicitly set `gateway.host` to a +public or LAN-facing address. + +- `GET /health` returns `{"status":"ok"}` +- Other paths return `404` + Override workspace for one-off runs when needed: ```bash @@ -1857,7 +1999,21 @@ By default, the API binds to `127.0.0.1:8900`. You can change this in `config.js - Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`) - Single-message input: each request must contain exactly one `user` message - Fixed model: omit `model`, or pass the same model shown by `/v1/models` -- No streaming: `stream=true` is not supported +- Streaming: set `stream=true` to receive Server-Sent Events (`text/event-stream`) with OpenAI-compatible delta chunks, terminated by `data: [DONE]`; omit or set `stream=false` for a single JSON response +- **File uploads**: supports images, PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx) via JSON base64 or `multipart/form-data` (max 10MB per file) +- API requests run in the synthetic `api` channel, so the `message` tool does **not** automatically deliver to Telegram/Discord/etc. To proactively send to another chat, call `message` with an explicit `channel` and `chat_id` for an enabled channel. + +Example tool call for cross-channel delivery from an API session: + +```json +{ + "content": "Build finished successfully.", + "channel": "telegram", + "chat_id": "123456789" +} +``` + +If `channel` points to a channel that is not enabled in your config, nanobot will queue the outbound event but no platform delivery will occur. ### Endpoints @@ -1876,6 +2032,44 @@ curl http://127.0.0.1:8900/v1/chat/completions \ }' ``` +### File Upload (JSON base64) + +Send images inline using the OpenAI multimodal content format: + +```bash +curl http://127.0.0.1:8900/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [{"role": "user", "content": [ + {"type": "text", "text": "Describe this image"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBOR..."}} + ]}] + }' +``` + +### File Upload (multipart/form-data) + +Upload any supported file type (images, PDF, Word, Excel, PPT) via multipart: + +```bash +# Single file +curl http://127.0.0.1:8900/v1/chat/completions \ + -F "message=Summarize this report" \ + -F "files=@report.docx" + +# Multiple files with session isolation +curl http://127.0.0.1:8900/v1/chat/completions \ + -F "message=Compare these files" \ + -F "files=@chart.png" \ + -F "files=@data.xlsx" \ + -F "session_id=my-session" +``` + +Supported file types: +- **Images**: PNG, JPEG, GIF, WebP (sent to AI as base64 for vision analysis) +- **Documents**: PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx) (text extracted and sent to AI) +- **Text**: TXT, Markdown, CSV, JSON, etc. (read directly) + ### Python (`requests`) ```python diff --git a/docs/CHANNEL_PLUGIN_GUIDE.md b/docs/CHANNEL_PLUGIN_GUIDE.md index 86e06bf6..12a41685 100644 --- a/docs/CHANNEL_PLUGIN_GUIDE.md +++ b/docs/CHANNEL_PLUGIN_GUIDE.md @@ -135,14 +135,17 @@ class WebhookChannel(BaseChannel): [project] name = "nanobot-channel-webhook" version = "0.1.0" -dependencies = ["nanobot", "aiohttp"] +dependencies = ["nanobot-ai", "aiohttp"] [project.entry-points."nanobot.channels"] webhook = "nanobot_channel_webhook:WebhookChannel" [build-system] -requires = ["setuptools"] -build-backend = "setuptools.backends._legacy:_Backend" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["nanobot_channel_webhook"] ``` The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass. @@ -290,7 +293,6 @@ async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | |------|---------| | `_stream_delta: True` | A content chunk (delta contains the new text) | | `_stream_end: True` | Streaming finished (delta is empty) | -| `_resuming: True` | More streaming rounds coming (e.g. tool call then another response) | ### Example: Webhook with Streaming diff --git a/docs/MY_TOOL.md b/docs/MY_TOOL.md new file mode 100644 index 00000000..a8a273d1 --- /dev/null +++ b/docs/MY_TOOL.md @@ -0,0 +1,207 @@ +# My Tool + +Let the agent sense and adjust its own runtime state — like asking a coworker "are you busy? can you switch to a bigger monitor?" + +## Why You Need It + +Normal tools let the agent operate on the outside world (read/write files, search code). But the agent knows nothing about itself — it doesn't know which model it's running on, how many iterations are left, or how many tokens it has consumed. + +My tool fills this gap. With it, the agent can: + +- **Know who it is**: What model am I using? Where is my workspace? How many iterations remain? +- **Adapt on the fly**: Complex task? Expand the context window. Simple chat? Switch to a faster model. +- **Remember across turns**: Store notes in your scratchpad that persist into the next conversation turn. + +## Configuration + +Enabled by default (read-only mode). The agent can check its state but not set it. + +```yaml +tools: + my: + enable: true # default: true + allow_set: false # default: false (read-only) +``` + +To allow the agent to set its configuration (e.g. switch models, adjust parameters), set `tools.my.allow_set: true`. + +Legacy `tools.myEnabled` / `tools.mySet` keys are auto-migrated on load, and +rewritten in-place the next time `nanobot onboard` refreshes the config. + +All modifications are held in memory only — restart restores defaults. + +--- + +## check — Check "my" current state + +Without parameters, returns a key config overview: + +``` +my(action="check") +# → max_iterations: 40 +# context_window_tokens: 65536 +# model: 'anthropic/claude-sonnet-4-20250514' +# workspace: PosixPath('/tmp/workspace') +# provider_retry_mode: 'standard' +# max_tool_result_chars: 16000 +# _current_iteration: 3 +# _last_usage: {'prompt_tokens': 45000, 'completion_tokens': 8000} +# Note: prompt_tokens is cumulative across all turns, not current context window occupancy. +``` + +With a key parameter, drill into a specific config: + +``` +my(action="check", key="_last_usage.prompt_tokens") +# → How many prompt tokens I've used so far + +my(action="check", key="model") +# → What model I'm currently running on + +my(action="check", key="web_config.enable") +# → Whether web search is enabled +``` + +### What you can do with it + +| Scenario | How | +|----------|-----| +| "What model are you using?" | `check("model")` | +| "How many more tool calls can you make?" | `check("max_iterations")` minus `check("_current_iteration")` | +| "How many tokens has this conversation used?" | `check("_last_usage")` — cumulative across all turns | +| "Where is your working directory?" | `check("workspace")` | +| "Show me your full config" | `check()` | +| "Are there any subagents running?" | `check("subagents")` — shows phase, iteration, elapsed time, tool events | + +--- + +## set — Runtime tuning + +Changes take effect immediately, no restart required. + +``` +my(action="set", key="max_iterations", value=80) +# → Bump iteration limit from 40 to 80 + +my(action="set", key="model", value="fast-model") +# → Switch to a faster model + +my(action="set", key="context_window_tokens", value=131072) +# → Expand context window for long documents +``` + +You can also store custom state in your scratchpad: + +``` +my(action="set", key="current_project", value="nanobot") +my(action="set", key="user_style_preference", value="concise") +my(action="set", key="task_complexity", value="high") +# → These values persist into the next conversation turn +``` + +### Protected parameters + +These parameters have type and range validation — invalid values are rejected: + +| Parameter | Type | Range | Purpose | +|-----------|------|-------|---------| +| `max_iterations` | int | 1–100 | Max tool calls per conversation turn | +| `context_window_tokens` | int | 4,096–1,000,000 | Context window size | +| `model` | str | non-empty | LLM model to use | + +Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe. + +--- + +## Practical Scenarios + +### "This task is complex, I need more room" + +``` +Agent: This codebase is large, let me expand my context window to handle it. +→ my(action="set", key="context_window_tokens", value=131072) +``` + +### "Simple question, don't waste compute" + +``` +Agent: This is a straightforward question, let me switch to a faster model. +→ my(action="set", key="model", value="fast-model") +``` + +### "Remember user preferences across turns" + +``` +Turn 1: my(action="set", key="user_prefers_concise", value=True) +Turn 2: my(action="check", key="user_prefers_concise") +# → True (still remembers the user likes concise replies) +``` + +### "Self-diagnosis" + +``` +User: "Why aren't you searching the web?" +Agent: Let me check my web config. +→ my(action="check", key="web_config.enable") +# → False +Agent: Web search is disabled — please set web.enable: true in your config. +``` + +### "Token budget management" + +``` +Agent: Let me check how much budget I have left. +→ my(action="check", key="_last_usage") +# → {"prompt_tokens": 45000, "completion_tokens": 8000} +Agent: I've used ~53k tokens total so far. I'll keep my remaining replies concise. +``` + +### "Subagent monitoring" + +``` +Agent: Let me check on the background tasks. +→ my(action="check", key="subagents") +# → 2 subagent(s): +# [task-1] 'Code review' +# phase: running, iteration: 5, elapsed: 12.3s +# tools: read(✓), grep(✓) +# usage: {'prompt_tokens': 8000, 'completion_tokens': 1200} +# [task-2] 'Write tests' +# phase: pending, iteration: 0, elapsed: 0.2s +# tools: none +Agent: The code review is progressing well. The test task hasn't started yet. +``` + +--- + +## Safety Mechanisms + +Core design principle: **All modifications live in memory only. Restart restores defaults.** The agent cannot cause persistent damage. + +### Off-limits (BLOCKED) + +Cannot be checked or modified — fully hidden: + +| Category | Attributes | Reason | +|----------|-----------|--------| +| Core infrastructure | `bus`, `provider`, `_running` | Changes would crash the system | +| Tool registry | `tools` | Must not remove its own tools | +| Subsystems | `runner`, `sessions`, `consolidator`, etc. | Affects other users/sessions | +| Sensitive data | `_mcp_servers`, `_pending_queues`, etc. | Contains credentials and message routing | +| Security boundaries | `restrict_to_workspace`, `channels_config` | Bypassing would violate isolation | +| Python internals | `__class__`, `__dict__`, etc. | Prevents sandbox escape | + +### Read-only (check only) + +Can be checked but not set: + +| Category | Attributes | Reason | +|----------|-----------|--------| +| Subagent manager | `subagents` | Observable, but replacing breaks the system | +| Execution config | `exec_config` | Can check sandbox/enable status, cannot change it | +| Web config | `web_config` | Can check enable status, cannot change it | +| Iteration counter | `_current_iteration` | Updated by runner only | + +### Sensitive field protection + +Sub-fields matching sensitive names (`api_key`, `password`, `secret`, `token`, etc.) are blocked from both check and set, regardless of parent path. This prevents credential leaks via dot-path traversal (e.g. `web_config.search.api_key`). diff --git a/docs/WEBSOCKET.md b/docs/WEBSOCKET.md index 5cc867b4..1e0ddbbb 100644 --- a/docs/WEBSOCKET.md +++ b/docs/WEBSOCKET.md @@ -7,7 +7,7 @@ Nanobot can act as a WebSocket server, allowing external clients (web apps, CLIs - Bidirectional real-time communication over WebSocket - Streaming support — receive agent responses token by token - Token-based authentication (static tokens and short-lived issued tokens) -- Per-connection sessions — each connection gets a unique `chat_id` +- Multi-chat multiplexing — one connection can run many concurrent `chat_id`s - TLS/SSL support (WSS) with enforced TLSv1.2 minimum - Client allow-list via `allowFrom` - Auto-cleanup of dead connections @@ -98,6 +98,7 @@ All frames are JSON text. Each message has an `event` field. ```json { "event": "message", + "chat_id": "uuid-v4", "text": "Hello! How can I help?", "media": ["/tmp/image.png"], "reply_to": "msg-id" @@ -111,6 +112,7 @@ All frames are JSON text. Each message has an `event` field. ```json { "event": "delta", + "chat_id": "uuid-v4", "text": "Hello", "stream_id": "s1" } @@ -121,25 +123,46 @@ All frames are JSON text. Each message has an `event` field. ```json { "event": "stream_end", + "chat_id": "uuid-v4", "stream_id": "s1" } ``` +**`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)): + +```json +{"event": "attached", "chat_id": "uuid-v4"} +``` + +**`error`** — soft error for malformed inbound envelopes. The connection stays open: + +```json +{"event": "error", "detail": "invalid chat_id"} +``` + ### Client → Server -Send plain text: +**Legacy (default chat):** send a plain string, or a JSON object with a recognized text field: ```json "Hello nanobot!" ``` -Or send a JSON object with a recognized text field: - ```json {"content": "Hello nanobot!"} ``` -Recognized fields: `content`, `text`, `message` (checked in that order). Invalid JSON is treated as plain text. +Recognized fields: `content`, `text`, `message` (checked in that order). Invalid JSON is treated as plain text. These frames route to the connection's default `chat_id` (the one announced in `ready`). + +**Typed envelopes (multi-chat):** any JSON object with a string `type` field is a typed envelope: + +| `type` | Fields | Effect | +|--------|--------|--------| +| `new_chat` | — | Server mints a new `chat_id`, subscribes this connection, replies with `attached`. | +| `attach` | `chat_id` | Subscribe to an existing `chat_id` (e.g. after a page reload). Replies with `attached`. | +| `message` | `chat_id`, `content` | Send `content` on `chat_id`. First use auto-attaches; no explicit `attach` needed. | + +See [Multi-chat multiplexing](#multi-chat-multiplexing) for the full flow. ## Configuration Reference @@ -243,11 +266,53 @@ websocat "ws://127.0.0.1:8765/ws?client_id=alice&token=nbwt_aBcDeFg..." - Outstanding tokens are capped at 10,000. Requests beyond this return HTTP 429. - Expired tokens are purged lazily on each issue or validation request. +## Multi-chat multiplexing + +A single WebSocket can carry many concurrent chats. The server tracks `chat_id -> {connections}` as a fan-out set, so the same chat can also be mirrored across multiple connections (e.g. two browser tabs). + +### Typical flow (web UI with a sidebar) + +```text +client server + | --- connect --------------------> | + | <-- {"event":"ready", | + | "chat_id":"d3..."} (default)| + | | + | --- {"type":"new_chat"} ---------> | + | <-- {"event":"attached", | + | "chat_id":"a1..."} | + | | + | --- {"type":"message", | + | "chat_id":"a1...", | + | "content":"hi"} ------------> | + | <-- {"event":"delta", ...} | + | <-- {"event":"stream_end", ...} | + | | + | --- {"type":"attach", | # after page reload + | "chat_id":"a1..."} ---------> | + | <-- {"event":"attached", ...} | +``` + +### Rules + +- Every outbound event carries `chat_id`. Clients must dispatch by that field. +- `chat_id` format: `^[A-Za-z0-9_:-]{1,64}$`. Non-matching values return `error`. +- `message` auto-attaches on first use — no separate `attach` is required for chats the server minted (`new_chat`) on the same connection. +- Errors (invalid envelope, unknown `type`, bad `chat_id`) are soft: the server replies with `{"event":"error","detail":"..."}` and keeps the connection open. + +### Backward compatibility + +Legacy clients that only send plain text or `{"content": ...}` keep working unchanged: those frames route to the connection's default `chat_id` (the one from `ready`). No config flag is needed. + +### Security boundary + +`chat_id` is a *capability*: anyone holding a valid WebSocket auth credential and the chat_id can attach to that conversation and see its output. This is safe for nanobot's local, single-user model. Multi-tenant deployments should namespace chat_ids per user (or introduce a per-tenant auth gate) — nanobot does not do this today. + ## Security Notes - **Timing-safe comparison**: Static token validation uses `hmac.compare_digest` to prevent timing attacks. - **Defense in depth**: `allowFrom` is checked at both the HTTP handshake level and the message level. -- **Token isolation**: Each WebSocket connection gets a unique `chat_id`. Clients cannot access other sessions. +- **chat_id as capability**: see [Multi-chat multiplexing](#multi-chat-multiplexing). Auth on the WebSocket handshake is the single line of defense; callers who pass it can attach to any chat_id they know. - **TLS enforcement**: When SSL is enabled, TLSv1.2 is the minimum allowed version. - **Default-secure**: `websocketRequiresToken` defaults to `true`. Explicitly set it to `false` only on trusted networks. diff --git a/nanobot/__init__.py b/nanobot/__init__.py index 0bce848d..5e6954d9 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -21,7 +21,7 @@ def _resolve_version() -> str: return _pkg_version("nanobot-ai") except PackageNotFoundError: # Source checkouts often import nanobot without installed dist-info. - return _read_pyproject_version() or "0.1.5" + return _read_pyproject_version() or "0.1.5.post1" __version__ = _resolve_version() diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 23b59719..f58baf0a 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -3,15 +3,14 @@ import base64 import mimetypes import platform +from importlib.resources import files as pkg_files from pathlib import Path from typing import Any -from nanobot.utils.helpers import current_time_str - from nanobot.agent.memory import MemoryStore -from nanobot.utils.prompt_templates import render_template from nanobot.agent.skills import SkillsLoader -from nanobot.utils.helpers import build_assistant_message, detect_image_mime +from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime +from nanobot.utils.prompt_templates import render_template class ContextBuilder: @@ -41,7 +40,7 @@ class ContextBuilder: parts.append(bootstrap) memory = self.memory.get_memory_context() - if memory: + if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"): parts.append(f"# Memory\n\n{memory}") always_skills = self.skills.get_always_skills() @@ -50,7 +49,7 @@ class ContextBuilder: if always_content: parts.append(f"# Active Skills\n\n{always_content}") - skills_summary = self.skills.build_skills_summary() + skills_summary = self.skills.build_skills_summary(exclude=set(always_skills)) if skills_summary: parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary)) @@ -116,6 +115,17 @@ class ContextBuilder: return "\n\n".join(parts) if parts else "" + @staticmethod + def _is_template_content(content: str, template_path: str) -> bool: + """Check if *content* is identical to the bundled template (user hasn't customized it).""" + try: + tpl = pkg_files("nanobot") / "templates" / template_path + if tpl.is_file(): + return content.strip() == tpl.read_text(encoding="utf-8").strip() + except Exception: + pass + return False + def build_messages( self, history: list[dict[str, Any]], @@ -160,7 +170,6 @@ class ContextBuilder: if not p.is_file(): continue raw = p.read_bytes() - # Detect real MIME type from magic bytes; fallback to filename guess mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0] if not mime or not mime.startswith("image/"): continue diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5c4fbfc4..3350c447 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -17,29 +17,32 @@ from nanobot.agent.autocompact import AutoCompact from nanobot.agent.context import ContextBuilder from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook from nanobot.agent.memory import Consolidator, Dream -from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunSpec, AgentRunner +from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec +from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.subagent import SubagentManager from nanobot.agent.tools.cron import CronTool -from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.notebook import NotebookEditTool from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.search import GlobTool, GrepTool from nanobot.agent.tools.shell import ExecTool +from nanobot.agent.tools.self import MyTool from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage, OutboundMessage -from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.bus.queue import MessageBus +from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMProvider from nanobot.session.manager import Session, SessionManager -from nanobot.utils.helpers import image_placeholder_text, truncate_text as truncate_text_fn +from nanobot.utils.document import extract_documents +from nanobot.utils.helpers import image_placeholder_text +from nanobot.utils.helpers import truncate_text as truncate_text_fn from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE if TYPE_CHECKING: - from nanobot.config.schema import ChannelsConfig, ExecToolConfig, WebToolsConfig + from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig from nanobot.cron.service import CronService @@ -88,6 +91,9 @@ class _LoopHook(AgentHook): await self._on_stream_end(resuming=resuming) self._stream_buf = "" + async def before_iteration(self, context: AgentHookContext) -> None: + self._loop._current_iteration = context.iteration + async def before_execute_tools(self, context: AgentHookContext) -> None: if self._on_progress: if not self._on_stream: @@ -129,6 +135,7 @@ class AgentLoop: """ _RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint" + _PENDING_USER_TURN_KEY = "pending_user_turn" def __init__( self, @@ -153,9 +160,11 @@ class AgentLoop: hooks: list[AgentHook] | None = None, unified_session: bool = False, disabled_skills: list[str] | None = None, + tools_config: ToolsConfig | None = None, ): - from nanobot.config.schema import ExecToolConfig, WebToolsConfig + from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig + _tc = tools_config or ToolsConfig() defaults = AgentDefaults() self.bus = bus self.channels_config = channels_config @@ -223,7 +232,7 @@ class AgentLoop: provider=provider, model=self.model, sessions=self.sessions, - context_window_tokens=context_window_tokens, + context_window_tokens=self.context_window_tokens, build_messages=self.context.build_messages, get_tool_definitions=self.tools.get_definitions, max_completion_tokens=provider.generation.max_tokens, @@ -239,6 +248,10 @@ class AgentLoop: model=self.model, ) self._register_default_tools() + if _tc.my.enable: + self.tools.register(MyTool(loop=self, modify_allowed=_tc.my.allow_set)) + self._runtime_vars: dict[str, Any] = {} + self._current_iteration: int = 0 self.commands = CommandRouter() register_builtin_commands(self.commands) @@ -305,7 +318,7 @@ class AgentLoop: def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Update context for all tools that need routing info.""" - for name in ("message", "spawn", "cron"): + for name in ("message", "spawn", "cron", "my"): if tool := self.tools.get(name): if hasattr(tool, "set_context"): tool.set_context(channel, chat_id, *([message_id] if name == "message" else [])) @@ -338,6 +351,7 @@ class AgentLoop: on_progress: Callable[..., Awaitable[None]] | None = None, on_stream: Callable[[str], Awaitable[None]] | None = None, on_stream_end: Callable[..., Awaitable[None]] | None = None, + on_retry_wait: Callable[[str], Awaitable[None]] | None = None, *, session: Session | None = None, channel: str = "cli", @@ -382,10 +396,12 @@ class AgentLoop: pending_msg = pending_queue.get_nowait() except asyncio.QueueEmpty: break - user_content = self.context._build_user_content( - pending_msg.content, - pending_msg.media if pending_msg.media else None, - ) + content = pending_msg.content + media = pending_msg.media if pending_msg.media else None + if media: + content, media = extract_documents(content, media) + media = media or None + user_content = self.context._build_user_content(content, media) runtime_ctx = self.context._build_runtime_context( pending_msg.channel, pending_msg.chat_id, @@ -413,12 +429,18 @@ class AgentLoop: context_block_limit=self.context_block_limit, provider_retry_mode=self.provider_retry_mode, progress_callback=on_progress, + retry_wait_callback=on_retry_wait, checkpoint_callback=_checkpoint, injection_callback=_drain_pending, )) self._last_usage = result.usage if result.stop_reason == "max_iterations": logger.warning("Max iterations ({}) reached", self.max_iterations) + # Push final content through stream so streaming channels (e.g. Feishu) + # update the card instead of leaving it empty. + if on_stream and on_stream_end: + await on_stream(result.final_content or "") + await on_stream_end(resuming=False) elif result.stop_reason == "error": logger.error("LLM returned error: {}", (result.final_content or "")[:200]) return result.final_content, result.tools_used, result.messages, result.stop_reason, result.had_injections @@ -621,17 +643,31 @@ class AgentLoop: session = self.sessions.get_or_create(key) if self._restore_runtime_checkpoint(session): self.sessions.save(session) + if self._restore_pending_user_turn(session): + self.sessions.save(session) session, pending = self.auto_compact.prepare_session(session, key) await self.consolidator.maybe_consolidate_by_tokens(session) + # Persist subagent follow-ups into durable history BEFORE prompt + # assembly. ContextBuilder merges adjacent same-role messages for + # provider compatibility, which previously caused the follow-up to + # disappear from session.messages while still being visible to the + # LLM via the merged prompt. See _persist_subagent_followup. + is_subagent = msg.sender_id == "subagent" + if is_subagent and self._persist_subagent_followup(session, msg): + self.sessions.save(session) self._set_tool_context(channel, chat_id, msg.metadata.get("message_id")) history = session.get_history(max_messages=0) - current_role = "assistant" if msg.sender_id == "subagent" else "user" + current_role = "assistant" if is_subagent else "user" + # Subagent content is already in `history` above; passing it again + # as current_message would double-project it into the prompt. messages = self.context.build_messages( history=history, - current_message=msg.content, channel=channel, chat_id=chat_id, + current_message="" if is_subagent else msg.content, + channel=channel, + chat_id=chat_id, session_summary=pending, current_role=current_role, ) @@ -649,6 +685,12 @@ class AgentLoop: content=final_content or "Background task completed.", ) + # Extract document text from media at the processing boundary so all + # channels benefit without format-specific logic in ContextBuilder. + if msg.media: + new_content, image_only = extract_documents(msg.content, msg.media) + msg = dataclasses.replace(msg, content=new_content, media=image_only) + preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) @@ -656,6 +698,8 @@ class AgentLoop: session = self.sessions.get_or_create(key) if self._restore_runtime_checkpoint(session): self.sessions.save(session) + if self._restore_pending_user_turn(session): + self.sessions.save(session) session, pending = self.auto_compact.prepare_session(session, key) @@ -696,11 +740,37 @@ class AgentLoop: ) ) + async def _on_retry_wait(content: str) -> None: + meta = dict(msg.metadata or {}) + meta["_retry_wait"] = True + await self.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=content, + metadata=meta, + ) + ) + + # Persist the triggering user message immediately, before running the + # agent loop. If the process is killed mid-turn (OOM, SIGKILL, self- + # restart, etc.), the existing runtime_checkpoint preserves the + # in-flight assistant/tool state but NOT the user message itself, so + # the user's prompt is silently lost on recovery. Saving it up front + # makes recovery possible from the session log alone. + user_persisted_early = False + if isinstance(msg.content, str) and msg.content.strip(): + session.add_message("user", msg.content) + self._mark_pending_user_turn(session) + self.sessions.save(session) + user_persisted_early = True + final_content, _, all_msgs, stop_reason, had_injections = await self._run_agent_loop( initial_messages, on_progress=on_progress or _bus_progress, on_stream=on_stream, on_stream_end=on_stream_end, + on_retry_wait=_on_retry_wait, session=session, channel=msg.channel, chat_id=msg.chat_id, @@ -711,7 +781,10 @@ class AgentLoop: if final_content is None or not final_content.strip(): final_content = EMPTY_FINAL_RESPONSE_MESSAGE - self._save_turn(session, all_msgs, 1 + len(history)) + # Skip the already-persisted user message when saving the turn + save_skip = 1 + len(history) + (1 if user_persisted_early else 0) + self._save_turn(session, all_msgs, save_skip) + self._clear_pending_user_turn(session) self._clear_runtime_checkpoint(session) self.sessions.save(session) self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session)) @@ -824,11 +897,41 @@ class AgentLoop: session.messages.append(entry) session.updated_at = datetime.now() + def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool: + """Persist subagent follow-ups before prompt assembly so history stays durable. + + Returns True if a new entry was appended; False if the follow-up was + deduped (same ``subagent_task_id`` already in session) or carries no + content worth persisting. + """ + if not msg.content: + return False + task_id = msg.metadata.get("subagent_task_id") if isinstance(msg.metadata, dict) else None + if task_id and any( + m.get("injected_event") == "subagent_result" and m.get("subagent_task_id") == task_id + for m in session.messages + ): + return False + session.add_message( + "assistant", + msg.content, + sender_id=msg.sender_id, + injected_event="subagent_result", + subagent_task_id=task_id, + ) + return True + def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None: """Persist the latest in-flight turn state into session metadata.""" session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload self.sessions.save(session) + def _mark_pending_user_turn(self, session: Session) -> None: + session.metadata[self._PENDING_USER_TURN_KEY] = True + + def _clear_pending_user_turn(self, session: Session) -> None: + session.metadata.pop(self._PENDING_USER_TURN_KEY, None) + def _clear_runtime_checkpoint(self, session: Session) -> None: if self._RUNTIME_CHECKPOINT_KEY in session.metadata: session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None) @@ -895,22 +998,47 @@ class AgentLoop: break session.messages.extend(restored_messages[overlap:]) + self._clear_pending_user_turn(session) self._clear_runtime_checkpoint(session) return True + def _restore_pending_user_turn(self, session: Session) -> bool: + """Close a turn that only persisted the user message before crashing.""" + from datetime import datetime + + if not session.metadata.get(self._PENDING_USER_TURN_KEY): + return False + + if session.messages and session.messages[-1].get("role") == "user": + session.messages.append( + { + "role": "assistant", + "content": "Error: Task interrupted before a response was generated.", + "timestamp": datetime.now().isoformat(), + } + ) + session.updated_at = datetime.now() + + self._clear_pending_user_turn(session) + return True + async def process_direct( self, content: str, session_key: str = "cli:direct", channel: str = "cli", chat_id: str = "direct", + media: list[str] | None = None, on_progress: Callable[[str], Awaitable[None]] | None = None, on_stream: Callable[[str], Awaitable[None]] | None = None, on_stream_end: Callable[..., Awaitable[None]] | None = None, ) -> OutboundMessage | None: """Process a message directly and return the outbound payload.""" await self._connect_mcp() - msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) + msg = InboundMessage( + channel=channel, sender_id="user", chat_id=chat_id, + content=content, media=media or [], + ) return await self._process_message( msg, session_key=session_key, diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 3f8b2431..5f235e3a 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -239,13 +239,13 @@ class MemoryStore: pass # Fallback: read last line's cursor from the JSONL file. last = self._read_last_entry() - if last: + if last and last.get("cursor"): return last["cursor"] + 1 return 1 def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]: """Return history entries with cursor > *since_cursor*.""" - return [e for e in self._read_entries() if e["cursor"] > since_cursor] + return [e for e in self._read_entries() if e.get("cursor", 0) > since_cursor] def compact_history(self) -> None: """Drop oldest entries if the file exceeds *max_history_entries*.""" @@ -457,6 +457,8 @@ class Consolidator: tools=None, tool_choice=None, ) + if response.finish_reason == "error": + raise RuntimeError(f"LLM returned error: {response.content}") summary = response.content or "[no summary]" self.store.append_history(summary) return summary @@ -552,6 +554,13 @@ class Consolidator: # --------------------------------------------------------------------------- +# Single source of truth for the staleness threshold used in _annotate_with_ages +# *and* in the Phase 1 prompt template (passed as `stale_threshold_days`). +# Keep code and prompt aligned — if you bump this, the LLM's instruction string +# updates automatically. +_STALE_THRESHOLD_DAYS = 14 + + class Dream: """Two-phase memory processor: analyze history.jsonl, then edit files via AgentRunner. @@ -568,6 +577,7 @@ class Dream: max_batch_size: int = 20, max_iterations: int = 10, max_tool_result_chars: int = 16_000, + annotate_line_ages: bool = True, ): self.store = store self.provider = provider @@ -575,6 +585,10 @@ class Dream: self.max_batch_size = max_batch_size self.max_iterations = max_iterations self.max_tool_result_chars = max_tool_result_chars + # Kill switch for the git-blame-based per-line age annotation in Phase 1. + # Default True keeps the #3212 behavior; set False to feed MEMORY.md raw + # (e.g. if a specific LLM reacts poorly to the `← Nd` suffix). + self.annotate_line_ages = annotate_line_ages self._runner = AgentRunner(provider) self._tools = self._build_tools() @@ -632,6 +646,52 @@ class Dream: # -- main entry ---------------------------------------------------------- + def _annotate_with_ages(self, content: str) -> str: + """Append per-line age suffixes to MEMORY.md content. + + Each non-blank line whose age exceeds ``_STALE_THRESHOLD_DAYS`` gets a + suffix like ``← 30d`` indicating days since last modification. + Returns the original content unchanged if git is unavailable, + annotate fails, or the line count doesn't match the age count + (which can happen with an uncommitted working-tree edit — better to + skip annotation than to tag the wrong line). + SOUL.md and USER.md are never annotated. + """ + file_path = "memory/MEMORY.md" + try: + ages = self.store.git.line_ages(file_path) + except Exception: + logger.debug("line_ages failed for {}", file_path) + return content + if not ages: + return content + + had_trailing = content.endswith("\n") + lines = content.splitlines() + # If HEAD-blob line count disagrees with the working-tree content we + # received, ages would be assigned to the wrong lines — skip entirely + # and feed the LLM un-annotated content rather than misleading data. + if len(lines) != len(ages): + logger.debug( + "line_ages length mismatch for {} (lines={}, ages={}); skipping annotation", + file_path, len(lines), len(ages), + ) + return content + + annotated: list[str] = [] + for line, age in zip(lines, ages): + if not line.strip(): + annotated.append(line) + continue + if age.age_days > _STALE_THRESHOLD_DAYS: + annotated.append(f"{line} \u2190 {age.age_days}d") + else: + annotated.append(line) + result = "\n".join(annotated) + if had_trailing: + result += "\n" + return result + async def run(self) -> bool: """Process unprocessed history entries. Returns True if work was done.""" from nanobot.agent.skills import BUILTIN_SKILLS_DIR @@ -652,9 +712,14 @@ class Dream: f"[{e['timestamp']}] {e['content']}" for e in batch ) - # Current file contents + # Current file contents + per-line age annotations (MEMORY.md only) current_date = datetime.now().strftime("%Y-%m-%d") - current_memory = self.store.read_memory() or "(empty)" + raw_memory = self.store.read_memory() or "(empty)" + current_memory = ( + self._annotate_with_ages(raw_memory) + if self.annotate_line_ages + else raw_memory + ) current_soul = self.store.read_soul() or "(empty)" current_user = self.store.read_user() or "(empty)" @@ -676,7 +741,11 @@ class Dream: messages=[ { "role": "system", - "content": render_template("agent/dream_phase1.md", strip=True), + "content": render_template( + "agent/dream_phase1.md", + strip=True, + stale_threshold_days=_STALE_THRESHOLD_DAYS, + ), }, {"role": "user", "content": phase1_prompt}, ], @@ -759,7 +828,9 @@ class Dream: # Git auto-commit (only when there are actual changes) if changelog and self.store.git.is_initialized(): ts = batch[-1]["timestamp"] - sha = self.store.git.auto_commit(f"dream: {ts}, {len(changelog)} change(s)") + summary = f"dream: {ts}, {len(changelog)} change(s)" + commit_msg = f"{summary}\n\n{analysis.strip()}" + sha = self.store.git.auto_commit(commit_msg) if sha: logger.info("Dream commit: {}", sha) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index e92d864f..d90c79fe 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -71,6 +71,7 @@ class AgentRunSpec: context_block_limit: int | None = None provider_retry_mode: str = "standard" progress_callback: Any | None = None + retry_wait_callback: Any | None = None checkpoint_callback: Any | None = None injection_callback: Any | None = None @@ -134,6 +135,50 @@ class AgentRunner: continue messages.append(injection) + async def _try_drain_injections( + self, + spec: AgentRunSpec, + messages: list[dict[str, Any]], + assistant_message: dict[str, Any] | None, + injection_cycles: int, + *, + phase: str = "after error", + iteration: int | None = None, + ) -> tuple[bool, int]: + """Drain pending injections. Returns (should_continue, updated_cycles). + + If injections are found and we haven't exceeded _MAX_INJECTION_CYCLES, + append them to *messages* (and emit a checkpoint if *assistant_message* + and *iteration* are both provided) and return (True, cycles+1) so the + caller continues the iteration loop. Otherwise return (False, cycles). + """ + if injection_cycles >= _MAX_INJECTION_CYCLES: + return False, injection_cycles + injections = await self._drain_injections(spec) + if not injections: + return False, injection_cycles + injection_cycles += 1 + if assistant_message is not None: + messages.append(assistant_message) + if iteration is not None: + await self._emit_checkpoint( + spec, + { + "phase": "final_response", + "iteration": iteration, + "model": spec.model, + "assistant_message": assistant_message, + "completed_tool_results": [], + "pending_tool_calls": [], + }, + ) + self._append_injected_messages(messages, injections) + logger.info( + "Injected {} follow-up message(s) {} ({}/{})", + len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES, + ) + return True, injection_cycles + async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]: """Drain pending user messages via the injection callback. @@ -229,7 +274,7 @@ class AgentRunner: context.tool_calls = list(response.tool_calls) self._accumulate_usage(usage, raw_usage) - if response.has_tool_calls: + if response.should_execute_tools: if hook.wants_streaming(): await hook.on_stream_end(context, resuming=True) @@ -287,6 +332,13 @@ class AgentRunner: context.error = error context.stop_reason = stop_reason await hook.after_iteration(context) + should_continue, injection_cycles = await self._try_drain_injections( + spec, messages, None, injection_cycles, + phase="after tool error", + ) + if should_continue: + had_injections = True + continue break await self._emit_checkpoint( spec, @@ -302,19 +354,22 @@ class AgentRunner: empty_content_retries = 0 length_recovery_count = 0 # Checkpoint 1: drain injections after tools, before next LLM call - if injection_cycles < _MAX_INJECTION_CYCLES: - injections = await self._drain_injections(spec) - if injections: - had_injections = True - injection_cycles += 1 - self._append_injected_messages(messages, injections) - logger.info( - "Injected {} follow-up message(s) after tool execution ({}/{})", - len(injections), injection_cycles, _MAX_INJECTION_CYCLES, - ) + _drained, injection_cycles = await self._try_drain_injections( + spec, messages, None, injection_cycles, + phase="after tool execution", + ) + if _drained: + had_injections = True await hook.after_iteration(context) continue + if response.has_tool_calls: + logger.warning( + "Ignoring tool calls under finish_reason='{}' for {}", + response.finish_reason, + spec.session_key or "default", + ) + clean = hook.finalize_content(context, response.content) if response.finish_reason != "error" and is_blank_text(clean): empty_content_retries += 1 @@ -379,36 +434,18 @@ class AgentRunner: # Check for mid-turn injections BEFORE signaling stream end. # If injections are found we keep the stream alive (resuming=True) # so streaming channels don't prematurely finalize the card. - _injected_after_final = False - if injection_cycles < _MAX_INJECTION_CYCLES: - injections = await self._drain_injections(spec) - if injections: - had_injections = True - injection_cycles += 1 - _injected_after_final = True - if assistant_message is not None: - messages.append(assistant_message) - await self._emit_checkpoint( - spec, - { - "phase": "final_response", - "iteration": iteration, - "model": spec.model, - "assistant_message": assistant_message, - "completed_tool_results": [], - "pending_tool_calls": [], - }, - ) - self._append_injected_messages(messages, injections) - logger.info( - "Injected {} follow-up message(s) after final response ({}/{})", - len(injections), injection_cycles, _MAX_INJECTION_CYCLES, - ) + should_continue, injection_cycles = await self._try_drain_injections( + spec, messages, assistant_message, injection_cycles, + phase="after final response", + iteration=iteration, + ) + if should_continue: + had_injections = True if hook.wants_streaming(): - await hook.on_stream_end(context, resuming=_injected_after_final) + await hook.on_stream_end(context, resuming=should_continue) - if _injected_after_final: + if should_continue: await hook.after_iteration(context) continue @@ -421,6 +458,13 @@ class AgentRunner: context.error = error context.stop_reason = stop_reason await hook.after_iteration(context) + should_continue, injection_cycles = await self._try_drain_injections( + spec, messages, None, injection_cycles, + phase="after LLM error", + ) + if should_continue: + had_injections = True + continue break if is_blank_text(clean): final_content = EMPTY_FINAL_RESPONSE_MESSAGE @@ -431,6 +475,13 @@ class AgentRunner: context.error = error context.stop_reason = stop_reason await hook.after_iteration(context) + should_continue, injection_cycles = await self._try_drain_injections( + spec, messages, None, injection_cycles, + phase="after empty response", + ) + if should_continue: + had_injections = True + continue break messages.append(assistant_message or build_assistant_message( @@ -467,6 +518,17 @@ class AgentRunner: max_iterations=spec.max_iterations, ) self._append_final_message(messages, final_content) + # Drain any remaining injections so they are appended to the + # conversation history instead of being re-published as + # independent inbound messages by _dispatch's finally block. + # We ignore should_continue here because the for-loop has already + # exhausted all iterations. + drained_after_max_iterations, injection_cycles = await self._try_drain_injections( + spec, messages, None, injection_cycles, + phase="after max_iterations", + ) + if drained_after_max_iterations: + had_injections = True return AgentRunResult( final_content=final_content, @@ -491,7 +553,7 @@ class AgentRunner: "tools": tools, "model": spec.model, "retry_mode": spec.provider_retry_mode, - "on_retry_wait": spec.progress_callback, + "on_retry_wait": spec.retry_wait_callback, } if spec.temperature is not None: kwargs["temperature"] = spec.temperature @@ -878,6 +940,16 @@ class AgentRunner: 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:] diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index e9ef1986..b01ca74e 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -6,6 +6,8 @@ import re import shutil from pathlib import Path +import yaml + # Default builtin skills directory (relative to this file) BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills" @@ -16,10 +18,6 @@ _STRIP_SKILL_FRONTMATTER = re.compile( ) -def _escape_xml(text: str) -> str: - return text.replace("&", "&").replace("<", "<").replace(">", ">") - - class SkillsLoader: """ Loader for agent skills. @@ -110,39 +108,37 @@ class SkillsLoader: ] return "\n\n---\n\n".join(parts) - def build_skills_summary(self) -> str: + def build_skills_summary(self, exclude: set[str] | None = None) -> str: """ Build a summary of all skills (name, description, path, availability). This is used for progressive loading - the agent can read the full skill content using read_file when needed. + Args: + exclude: Set of skill names to omit from the summary. + Returns: - XML-formatted skills summary. + Markdown-formatted skills summary. """ all_skills = self.list_skills(filter_unavailable=False) if not all_skills: return "" - lines: list[str] = [""] + lines: list[str] = [] for entry in all_skills: skill_name = entry["name"] + if exclude and skill_name in exclude: + continue meta = self._get_skill_meta(skill_name) available = self._check_requirements(meta) - lines.extend( - [ - f' ', - f" {_escape_xml(skill_name)}", - f" {_escape_xml(self._get_skill_description(skill_name))}", - f" {entry['path']}", - ] - ) - if not available: + desc = self._get_skill_description(skill_name) + if available: + lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`") + else: missing = self._get_missing_requirements(meta) - if missing: - lines.append(f" {_escape_xml(missing)}") - lines.append(" ") - lines.append("") + suffix = f" (unavailable: {missing})" if missing else " (unavailable)" + lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`") return "\n".join(lines) def _get_missing_requirements(self, skill_meta: dict) -> str: @@ -171,11 +167,19 @@ class SkillsLoader: return content[match.end():].strip() return content - def _parse_nanobot_metadata(self, raw: str) -> dict: - """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys).""" - try: - data = json.loads(raw) - except (json.JSONDecodeError, TypeError): + def _parse_nanobot_metadata(self, raw: object) -> dict: + """Extract nanobot/openclaw metadata from a frontmatter field. + + ``raw`` may be a dict (already parsed by yaml.safe_load) or a JSON str. + """ + if isinstance(raw, dict): + data = raw + elif isinstance(raw, str): + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return {} + else: return {} if not isinstance(data, dict): return {} @@ -193,8 +197,8 @@ class SkillsLoader: def _get_skill_meta(self, name: str) -> dict: """Get nanobot metadata for a skill (cached in frontmatter).""" - meta = self.get_skill_metadata(name) or {} - return self._parse_nanobot_metadata(meta.get("metadata", "")) + raw_meta = self.get_skill_metadata(name) or {} + return self._parse_nanobot_metadata(raw_meta.get("metadata")) def get_always_skills(self) -> list[str]: """Get skills marked as always=true that meet requirements.""" @@ -203,7 +207,7 @@ class SkillsLoader: for entry in self.list_skills(filter_unavailable=True) if (meta := self.get_skill_metadata(entry["name"]) or {}) and ( - self._parse_nanobot_metadata(meta.get("metadata", "")).get("always") + self._parse_nanobot_metadata(meta.get("metadata")).get("always") or meta.get("always") ) ] @@ -224,10 +228,15 @@ class SkillsLoader: match = _STRIP_SKILL_FRONTMATTER.match(content) if not match: return None - metadata: dict[str, str] = {} - for line in match.group(1).splitlines(): - if ":" not in line: - continue - key, value = line.split(":", 1) - metadata[key.strip()] = value.strip().strip('"\'') + try: + parsed = yaml.safe_load(match.group(1)) + except yaml.YAMLError: + return None + if not isinstance(parsed, dict): + return None + # yaml.safe_load returns native types (int, bool, list, etc.); + # keep values as-is so downstream consumers get correct types. + metadata: dict[str, object] = {} + for key, value in parsed.items(): + metadata[str(key)] = value return metadata diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 571bcc79..388a634c 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -2,7 +2,9 @@ import asyncio import json +import time import uuid +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -23,12 +25,29 @@ from nanobot.config.schema import ExecToolConfig, WebToolsConfig from nanobot.providers.base import LLMProvider -class _SubagentHook(AgentHook): - """Logging-only hook for subagent execution.""" +@dataclass(slots=True) +class SubagentStatus: + """Real-time status of a running subagent.""" - def __init__(self, task_id: str) -> None: + task_id: str + label: str + task_description: str + started_at: float # time.monotonic() + phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error + iteration: int = 0 + tool_events: list = field(default_factory=list) # [{name, status, detail}, ...] + usage: dict = field(default_factory=dict) # token usage + stop_reason: str | None = None + error: str | None = None + + +class _SubagentHook(AgentHook): + """Hook for subagent execution — logs tool calls and updates status.""" + + def __init__(self, task_id: str, status: SubagentStatus | None = None) -> None: super().__init__() self._task_id = task_id + self._status = status async def before_execute_tools(self, context: AgentHookContext) -> None: for tool_call in context.tool_calls: @@ -38,6 +57,15 @@ class _SubagentHook(AgentHook): self._task_id, tool_call.name, args_str, ) + async def after_iteration(self, context: AgentHookContext) -> None: + if self._status is None: + return + self._status.iteration = context.iteration + self._status.tool_events = list(context.tool_events) + self._status.usage = dict(context.usage) + if context.error: + self._status.error = str(context.error) + class SubagentManager: """Manages background subagent execution.""" @@ -54,8 +82,6 @@ class SubagentManager: restrict_to_workspace: bool = False, disabled_skills: list[str] | None = None, ): - from nanobot.config.schema import ExecToolConfig - self.provider = provider self.workspace = workspace self.bus = bus @@ -67,6 +93,7 @@ class SubagentManager: self.disabled_skills = set(disabled_skills or []) self.runner = AgentRunner(provider) self._running_tasks: dict[str, asyncio.Task[None]] = {} + self._task_statuses: dict[str, SubagentStatus] = {} self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} async def spawn( @@ -82,8 +109,16 @@ class SubagentManager: display_label = label or task[:30] + ("..." if len(task) > 30 else "") origin = {"channel": origin_channel, "chat_id": origin_chat_id} + status = SubagentStatus( + task_id=task_id, + label=display_label, + task_description=task, + started_at=time.monotonic(), + ) + self._task_statuses[task_id] = status + bg_task = asyncio.create_task( - self._run_subagent(task_id, task, display_label, origin) + self._run_subagent(task_id, task, display_label, origin, status) ) self._running_tasks[task_id] = bg_task if session_key: @@ -91,6 +126,7 @@ class SubagentManager: def _cleanup(_: asyncio.Task) -> None: self._running_tasks.pop(task_id, None) + self._task_statuses.pop(task_id, None) if session_key and (ids := self._session_tasks.get(session_key)): ids.discard(task_id) if not ids: @@ -107,10 +143,15 @@ class SubagentManager: task: str, label: str, origin: dict[str, str], + status: SubagentStatus, ) -> None: """Execute the subagent task and announce the result.""" logger.info("Subagent [{}] starting task: {}", task_id, label) + async def _on_checkpoint(payload: dict) -> None: + status.phase = payload.get("phase", status.phase) + status.iteration = payload.get("iteration", status.iteration) + try: # Build subagent tools (no message tool, no spawn tool) tools = ToolRegistry() @@ -129,6 +170,7 @@ class SubagentManager: restrict_to_workspace=self.restrict_to_workspace, sandbox=self.exec_config.sandbox, path_append=self.exec_config.path_append, + allowed_env_keys=self.exec_config.allowed_env_keys, )) if self.web_config.enable: tools.register(WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy)) @@ -145,40 +187,38 @@ class SubagentManager: model=self.model, max_iterations=15, max_tool_result_chars=self.max_tool_result_chars, - hook=_SubagentHook(task_id), + hook=_SubagentHook(task_id, status), max_iterations_message="Task completed but no final response was generated.", error_message=None, fail_on_tool_error=True, + checkpoint_callback=_on_checkpoint, )) - if result.stop_reason == "tool_error": - await self._announce_result( - task_id, - label, - task, - self._format_partial_progress(result), - origin, - "error", - ) - return - if result.stop_reason == "error": - await self._announce_result( - task_id, - label, - task, - result.error or "Error: subagent execution failed.", - origin, - "error", - ) - return - final_result = result.final_content or "Task completed but no final response was generated." + status.phase = "done" + status.stop_reason = result.stop_reason - logger.info("Subagent [{}] completed successfully", task_id) - await self._announce_result(task_id, label, task, final_result, origin, "ok") + if result.stop_reason == "tool_error": + status.tool_events = list(result.tool_events) + await self._announce_result( + task_id, label, task, + self._format_partial_progress(result), + origin, "error", + ) + elif result.stop_reason == "error": + await self._announce_result( + task_id, label, task, + result.error or "Error: subagent execution failed.", + origin, "error", + ) + else: + final_result = result.final_content or "Task completed but no final response was generated." + logger.info("Subagent [{}] completed successfully", task_id) + await self._announce_result(task_id, label, task, final_result, origin, "ok") except Exception as e: - error_msg = f"Error: {str(e)}" + status.phase = "error" + status.error = str(e) logger.error("Subagent [{}] failed: {}", task_id, e) - await self._announce_result(task_id, label, task, error_msg, origin, "error") + await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error") async def _announce_result( self, @@ -206,6 +246,10 @@ class SubagentManager: sender_id="subagent", chat_id=f"{origin['channel']}:{origin['chat_id']}", content=announce_content, + metadata={ + "injected_event": "subagent_result", + "subagent_task_id": task_id, + }, ) await self.bus.publish_inbound(msg) @@ -262,3 +306,11 @@ class SubagentManager: def get_running_count(self) -> int: """Return the number of currently running subagents.""" return len(self._running_tasks) + + def get_running_count_by_session(self, session_key: str) -> int: + """Return the number of currently running subagents for a session.""" + tids = self._session_tasks.get(session_key, set()) + return sum( + 1 for tid in tids + if tid in self._running_tasks and not self._running_tasks[tid].done() + ) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index f0d3ddab..111bb1c8 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -5,40 +5,71 @@ from datetime import datetime from typing import Any from nanobot.agent.tools.base import Tool, tool_parameters -from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema +from nanobot.agent.tools.schema import ( + BooleanSchema, + IntegerSchema, + StringSchema, + tool_parameters_schema, +) from nanobot.cron.service import CronService from nanobot.cron.types import CronJob, CronJobState, CronSchedule - -@tool_parameters( - tool_parameters_schema( - action=StringSchema("Action to perform", enum=["add", "list", "remove"]), - name=StringSchema( - "Optional short human-readable label for the job " - "(e.g., 'weather-monitor', 'daily-standup'). Defaults to first 30 chars of message." - ), - message=StringSchema( - "Instruction for the agent to execute when the job triggers " - "(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report')" - ), - every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"), - cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"), - tz=StringSchema( - "Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). " - "When omitted with cron_expr, the tool's default timezone applies." - ), - at=StringSchema( - "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). " - "Naive values use the tool's default timezone." - ), - deliver=BooleanSchema( - description="Whether to deliver the execution result to the user channel (default true)", - default=True, - ), - job_id=StringSchema("Job ID (for remove)"), - required=["action"], - ) +_CRON_PARAMETERS = tool_parameters_schema( + action=StringSchema("Action to perform", enum=["add", "list", "remove"]), + name=StringSchema( + "Optional short human-readable label for the job " + "(e.g., 'weather-monitor', 'daily-standup'). Defaults to first 30 chars of message." + ), + message=StringSchema( + "REQUIRED when action='add'. Instruction for the agent to execute when the job triggers " + "(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). " + "Not used for action='list' or action='remove'." + ), + every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"), + cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"), + tz=StringSchema( + "Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). " + "When omitted with cron_expr, the tool's default timezone applies." + ), + at=StringSchema( + "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). " + "Naive values use the tool's default timezone." + ), + deliver=BooleanSchema( + description="Whether to deliver the execution result to the user channel (default true)", + default=True, + ), + job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."), + required=["action"], + description=( + "Action-specific parameters: add requires a non-empty message plus one schedule " + "(every_seconds, cron_expr, or at); remove requires job_id; list only needs action." + ), ) +_CRON_PARAMETERS["oneOf"] = [ + { + "properties": { + "action": {"enum": ["add"]}, + "message": {"type": "string", "minLength": 1}, + }, + "required": ["action", "message"], + }, + { + "properties": { + "action": {"enum": ["list"]}, + }, + "required": ["action"], + }, + { + "properties": { + "action": {"enum": ["remove"]}, + }, + "required": ["action", "job_id"], + }, +] + + +@tool_parameters(_CRON_PARAMETERS) class CronTool(Tool): """Tool to schedule reminders and recurring tasks.""" @@ -94,6 +125,15 @@ class CronTool(Tool): f"If tz is omitted, cron expressions and naive ISO times default to {self._default_timezone}." ) + def validate_params(self, params: dict[str, Any]) -> list[str]: + errors = super().validate_params(params) + action = params.get("action") + if action == "add" and not str(params.get("message") or "").strip(): + errors.append("message is required when action='add'") + if action == "remove" and not str(params.get("job_id") or "").strip(): + errors.append("job_id is required when action='remove'") + return errors + async def execute( self, action: str, @@ -128,7 +168,11 @@ class CronTool(Tool): deliver: bool = True, ) -> str: if not message: - return "Error: message is required for add" + return ( + "Error: cron action='add' requires a non-empty 'message' parameter " + "describing what to do when the job triggers " + "(e.g. the reminder text). Retry including message=\"...\"." + ) if not self._channel or not self._chat_id: return "Error: no session context (channel/chat_id)" if tz and not cron_expr: diff --git a/nanobot/agent/tools/file_state.py b/nanobot/agent/tools/file_state.py index 81b1d448..74bb9bf6 100644 --- a/nanobot/agent/tools/file_state.py +++ b/nanobot/agent/tools/file_state.py @@ -80,11 +80,14 @@ def check_read(path: str | Path) -> str | None: entry.mtime = current_mtime return None return "Warning: file has been modified since last read. Re-read to verify content before editing." + # mtime unchanged - still check content hash to detect quick modifications + if entry.content_hash and _hash_file(p) != entry.content_hash: + return "Warning: file has been modified since last read. Re-read to verify content before editing." return None def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) -> bool: - """Return True if file was previously read with same params and mtime is unchanged.""" + """Return True if file was previously read with same params and content is unchanged.""" p = str(Path(path).resolve()) entry = _state.get(p) if entry is None: @@ -97,7 +100,18 @@ def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) -> current_mtime = os.path.getmtime(p) except OSError: return False - return current_mtime == entry.mtime + if current_mtime != entry.mtime: + # mtime changed - check if content also changed + current_hash = _hash_file(p) + if current_hash != entry.content_hash: + # Content actually changed - don't dedup + entry.can_dedup = False + return False + # Content identical despite mtime change (e.g. touch) - mark as not dedupable to force full read next time + entry.can_dedup = False + return True + # mtime unchanged - content must be identical + return True def clear() -> None: diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index e131a2e6..1f3afd34 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -2,6 +2,7 @@ import difflib import mimetypes +import os from dataclasses import dataclass from pathlib import Path from typing import Any @@ -74,10 +75,23 @@ def _is_blocked_device(path: str | Path) -> bool: """Check if path is a blocked device that could hang or produce infinite output.""" import re raw = str(path) - if raw in _BLOCKED_DEVICE_PATHS: + + # Resolve symlinks to check the actual target + try: + resolved = str(Path(raw).resolve()) + except (OSError, ValueError): + resolved = raw + + if raw in _BLOCKED_DEVICE_PATHS or resolved in _BLOCKED_DEVICE_PATHS: return True if re.match(r"/proc/\d+/fd/[012]$", raw) or re.match(r"/proc/self/fd/[012]$", raw): return True + if re.match(r"/proc/\d+/fd/[012]$", resolved) or re.match(r"/proc/self/fd/[012]$", resolved): + return True + + # Check if resolved path starts with /dev/ (covers symlinks to devices) + if resolved.startswith("/dev/"): + return True return False @@ -164,14 +178,52 @@ class ReadFileTool(_FsTool): return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})") # Read dedup: same path + offset + limit + unchanged mtime → stub - if file_state.is_unchanged(fp, offset=offset, limit=limit): - return f"[File unchanged since last read: {path}]" + # Always check for external modifications before dedup + entry = file_state._state.get(str(fp.resolve())) + try: + current_mtime = os.path.getmtime(fp) + except OSError: + current_mtime = 0.0 + if entry and entry.can_dedup and entry.offset == offset and entry.limit == limit: + if current_mtime != entry.mtime: + # File was modified externally - force full read and mark as not dedupable + entry.can_dedup = False + file_state.record_read(fp, offset=offset, limit=limit) # Update state with new mtime + # Continue to read full content (don't return dedup message) + else: + # File unchanged - return dedup message + # But only if content is actually unchanged (not just mtime) + current_hash = file_state._hash_file(str(fp)) + if current_hash == entry.content_hash: + return f"[File unchanged since last read: {path}]" + else: + # Content changed despite same mtime - force full read + entry.can_dedup = False + file_state.record_read(fp, offset=offset, limit=limit) + else: + # No previous state or marked as not dedupable - read full content + file_state.record_read(fp, offset=offset, limit=limit) + # Force full read by setting can_dedup to False for this read + if entry: + entry.can_dedup = False + # Read the file content after dedup check + raw = fp.read_bytes() try: text_content = raw.decode("utf-8") except UnicodeDecodeError: + # Binary file - return error message + mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0] + if mime and mime.startswith("image/"): + return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})") return f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported." + # Normalize CRLF -> LF before line-splitting. Primarily a Windows + # concern (git checkouts with autocrlf, editors saving CRLF) but + # applied on all platforms so downstream StrReplace/Grep behavior + # is consistent regardless of where the file was written. + text_content = text_content.replace("\r\n", "\n") + all_lines = text_content.splitlines() total = len(all_lines) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 1b5a7132..2aea1927 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -454,7 +454,23 @@ async def connect_mcp_servers( return name, server_stack except Exception as e: - logger.error("MCP server '{}': failed to connect: {}", name, e) + hint = "" + text = str(e).lower() + if any( + marker in text + for marker in ( + "parse error", + "invalid json", + "unexpected token", + "jsonrpc", + "content-length", + ) + ): + hint = ( + " Hint: this looks like stdio protocol pollution. Make sure the MCP server writes " + "only JSON-RPC to stdout and sends logs/debug output to stderr instead." + ) + logger.error("MCP server '{}': failed to connect: {}{}", name, e, hint) try: await server_stack.aclose() except Exception: diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index 99d3ec63..3d185d57 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -14,14 +14,17 @@ class ToolRegistry: def __init__(self): self._tools: dict[str, Tool] = {} + self._cached_definitions: list[dict[str, Any]] | None = None def register(self, tool: Tool) -> None: """Register a tool.""" self._tools[tool.name] = tool + self._cached_definitions = None def unregister(self, name: str) -> None: """Unregister a tool by name.""" self._tools.pop(name, None) + self._cached_definitions = None def get(self, name: str) -> Tool | None: """Get a tool by name.""" @@ -46,8 +49,12 @@ class ToolRegistry: """Get tool definitions with stable ordering for cache-friendly prompts. Built-in tools are sorted first as a stable prefix, then MCP tools are - sorted and appended. + sorted and appended. The result is cached until the next + register/unregister call. """ + if self._cached_definitions is not None: + return self._cached_definitions + definitions = [tool.to_schema() for tool in self._tools.values()] builtins: list[dict[str, Any]] = [] mcp_tools: list[dict[str, Any]] = [] @@ -60,7 +67,8 @@ class ToolRegistry: builtins.sort(key=self._schema_name) mcp_tools.sort(key=self._schema_name) - return builtins + mcp_tools + self._cached_definitions = builtins + mcp_tools + return self._cached_definitions def prepare_call( self, @@ -68,6 +76,13 @@ class ToolRegistry: params: dict[str, Any], ) -> tuple[Tool | None, dict[str, Any], str | None]: """Resolve, cast, and validate one tool call.""" + # Guard against invalid parameter types (e.g., list instead of dict) + if not isinstance(params, dict) and name in ('write_file', 'read_file'): + return None, params, ( + f"Error: Tool '{name}' parameters must be a JSON object, got {type(params).__name__}. " + "Use named parameters: tool_name(param1=\"value1\", param2=\"value2\")" + ) + tool = self._tools.get(name) if not tool: return None, params, ( diff --git a/nanobot/agent/tools/self.py b/nanobot/agent/tools/self.py new file mode 100644 index 00000000..f05dbf21 --- /dev/null +++ b/nanobot/agent/tools/self.py @@ -0,0 +1,449 @@ +"""MyTool: runtime state inspection and configuration for the agent loop.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +from loguru import logger + +from nanobot.agent.subagent import SubagentStatus +from nanobot.agent.tools.base import Tool + +if TYPE_CHECKING: + from nanobot.agent.loop import AgentLoop + + +def _has_real_attr(obj: Any, key: str) -> bool: + """Check if obj has a real (explicitly set) attribute, not auto-generated by mock.""" + if isinstance(obj, dict): + return key in obj + d = getattr(obj, "__dict__", None) + if d is not None and key in d: + return True + for cls in type(obj).__mro__: + if key in cls.__dict__: + return True + return False + + +class MyTool(Tool): + """Check and set the agent loop's runtime configuration.""" + + BLOCKED = frozenset({ + # Core infrastructure + "bus", "provider", "_running", "tools", + # Config management + "_runtime_vars", + # Subsystems + "runner", "sessions", "consolidator", + "dream", "auto_compact", "context", "commands", + # Sensitive runtime state (credentials, message routing, task tracking) + "_mcp_servers", "_mcp_stacks", "_pending_queues", + "_session_locks", "_active_tasks", "_background_tasks", + # Security boundaries (inspect + modify both blocked) + "restrict_to_workspace", "channels_config", + "_concurrency_gate", "_unified_session", "_extra_hooks", + }) + + READ_ONLY = frozenset({ + "subagents", # observable but replacing it would break the system + "_current_iteration", # updated by runner only + "exec_config", # inspect allowed (e.g. check sandbox), modify blocked + "web_config", # inspect allowed (e.g. check enable), modify blocked + }) + + _DENIED_ATTRS = frozenset({ + "__class__", "__dict__", "__bases__", "__subclasses__", "__mro__", + "__init__", "__new__", "__reduce__", "__getstate__", "__setstate__", + "__del__", "__call__", "__getattr__", "__setattr__", "__delattr__", + "__code__", "__globals__", "func_globals", "func_code", + "__wrapped__", "__closure__", + }) + + # Sub-field names that are sensitive regardless of parent path + _SENSITIVE_NAMES = frozenset({ + "api_key", "secret", "password", "token", "credential", + "private_key", "access_token", "refresh_token", "auth", + }) + + @classmethod + def _is_sensitive_field_name(cls, name: str) -> bool: + lowered = name.lower() + return lowered in cls._SENSITIVE_NAMES or any( + part in cls._SENSITIVE_NAMES for part in lowered.split("_") + ) + + RESTRICTED: dict[str, dict[str, Any]] = { + "max_iterations": {"type": int, "min": 1, "max": 100}, + "context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000}, + "model": {"type": str, "min_len": 1}, + } + + _MAX_RUNTIME_KEYS = 64 + + def __init__(self, loop: AgentLoop, modify_allowed: bool = True) -> None: + self._loop = loop + self._modify_allowed = modify_allowed + self._channel = "" + self._chat_id = "" + + def __deepcopy__(self, memo: dict[int, Any]) -> MyTool: + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + result._loop = self._loop + result._modify_allowed = self._modify_allowed + result._channel = self._channel + result._chat_id = self._chat_id + return result + + def set_context(self, channel: str, chat_id: str) -> None: + self._channel = channel + self._chat_id = chat_id + + @property + def name(self) -> str: + return "my" + + @property + def description(self) -> str: + base = ( + "Check and set your own runtime state.\n" + "Actions: check, set.\n" + "- check (no key): full config overview — start here.\n" + "- check (key): drill into a value. Dot-paths allowed " + "(e.g. '_last_usage.prompt_tokens', 'web_config.enable').\n" + "- set (key, value): change config or store notes in your scratchpad. " + "Scratchpad keys persist across turns but not restarts.\n" + "Key values: _current_iteration (current progress), " + "max_iterations - _current_iteration = remaining iterations.\n" + "Note: web_config and exec_config are readable but read-only.\n" + "\n" + "When to use:\n" + "- User asks about your model, settings, or token usage → check that key.\n" + "- A tool fails or behaves unexpectedly → check the related config to diagnose.\n" + "- User asks you to remember a preference for this session → set to store it in your scratchpad.\n" + "- About to start a large task → check context_window_tokens and max_iterations first." + ) + if not self._modify_allowed: + base += "\nREAD-ONLY MODE: set is disabled." + else: + base += ( + "\nIMPORTANT: Before setting state, predict the potential impact. " + "If the operation could cause crashes or instability " + "(e.g. changing model), warn the user first." + ) + return base + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["check", "set"], + "description": "Action to perform", + }, + "key": { + "type": "string", + "description": "Dot-path for check/set. Examples: 'max_iterations', 'workspace', 'provider_retry_mode'. " + "For check without key, shows all config values.", + }, + "value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model)."}, + }, + "required": ["action"], + } + + def _audit(self, action: str, detail: str) -> None: + session = f"{self._channel}:{self._chat_id}" if self._channel else "unknown" + logger.info("self.{} | {} | session:{}", action, detail, session) + + # ------------------------------------------------------------------ + # Path resolution + # ------------------------------------------------------------------ + + def _resolve_path(self, path: str) -> tuple[Any, str | None]: + parts = path.split(".") + obj = self._loop + for part in parts: + if part in self._DENIED_ATTRS or part.startswith("__"): + return None, f"'{part}' is not accessible" + if part in self.BLOCKED: + return None, f"'{part}' is not accessible" + if part.lower() in self._SENSITIVE_NAMES: + return None, f"'{part}' is not accessible" + try: + if isinstance(obj, dict): + if part in obj: + obj = obj[part] + else: + return None, f"'{part}' not found in dict" + else: + obj = getattr(obj, part) + except (KeyError, AttributeError) as e: + return None, f"'{part}' not found: {e}" + return obj, None + + @staticmethod + def _validate_key(key: str | None, label: str = "key") -> str | None: + if not key or not key.strip(): + return f"Error: '{label}' cannot be empty or whitespace" + return None + + # ------------------------------------------------------------------ + # Smart formatting + # ------------------------------------------------------------------ + + @staticmethod + def _format_status(st: SubagentStatus, indent: str = " ") -> str: + elapsed = time.monotonic() - st.started_at + tool_summary = ", ".join( + f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:] + ) or "none" + lines = [ + f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s", + f"{indent}tools: {tool_summary}", + f"{indent}usage: {st.usage or 'n/a'}", + ] + if st.error: + lines.append(f"{indent}error: {st.error}") + if st.stop_reason: + lines.append(f"{indent}stop_reason: {st.stop_reason}") + return "\n".join(lines) + + @staticmethod + def _format_value(val: Any, key: str = "") -> str: + if isinstance(val, SubagentStatus): + header = f"Subagent [{val.task_id}] '{val.label}'" + detail = MyTool._format_status(val, " ") + return f"{header}\n task: {val.task_description}\n{detail}" + # SubagentManager: delegate to its _task_statuses dict + if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict): + return MyTool._format_value(val._task_statuses, key) + if isinstance(val, dict) and val and isinstance(next(iter(val.values())), SubagentStatus): + prefix = f"{key}: " if key else "" + lines = [f"{prefix}{len(val)} subagent(s):"] + for tid, st in val.items(): + detail = MyTool._format_status(st, " ") + lines.append(f" [{tid}] '{st.label}'\n{detail}") + return "\n".join(lines) + if hasattr(val, "tool_names"): + return f"tools: {len(val.tool_names)} registered — {val.tool_names}" + # Scalar types — repr is fine + if isinstance(val, (str, int, float, bool, type(None))): + r = repr(val) + return f"{key}: {r}" if key else r + # Dict — small: show content; large: show keys for dot-path navigation + if isinstance(val, dict): + ks = list(val.keys()) + if not ks: + return f"{key}: {{}}" if key else "{}" + if len(ks) <= 5: + r = repr(val) + if len(r) <= 200: + return f"{key}: {r}" if key else r + preview = ", ".join(str(k) for k in ks[:15]) + suffix = ", ..." if len(ks) > 15 else "" + return f"{key}: {{{preview}{suffix}}}" if key else f"{{{preview}{suffix}}}" + # List/tuple — count for large, repr for small + if isinstance(val, (list, tuple)): + if len(val) > 20: + return f"{key}: [{len(val)} items]" if key else f"[{len(val)} items]" + r = repr(val) + return f"{key}: {r}" if key else r + # Complex object — small Pydantic models: show values; others: show field names for navigation + cls_name = type(val).__name__ + model_fields = getattr(type(val), "model_fields", None) + if model_fields: + fields = list(model_fields.keys()) + if len(fields) <= 8: + # Small config objects: show field=value pairs + pairs = [] + for f in fields: + fv = getattr(val, f, "?") + if MyTool._is_sensitive_field_name(f): + continue + if isinstance(fv, (str, int, float, bool, type(None))): + pairs.append(f"{f}={fv!r}") + else: + pairs.append(f"{f}=<{type(fv).__name__}>") + preview = ", ".join(pairs) + return f"{key}: {preview}" if key else preview + else: + fields = [a for a in getattr(val, "__dict__", {}) if not a.startswith("__")] + if fields: + preview = ", ".join(str(f) for f in fields[:20]) + suffix = ", ..." if len(fields) > 20 else "" + return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]" + r = repr(val) + return f"{key}: {r}" if key else r + + # ------------------------------------------------------------------ + # Action dispatch + # ------------------------------------------------------------------ + + async def execute( + self, + action: str, + key: str | None = None, + value: Any = None, + **_kwargs: Any, + ) -> str: + if action in ("inspect", "check"): + return self._inspect(key) + if not self._modify_allowed: + return "Error: set is disabled (tools.my.allow_set is false)" + if action in ("modify", "set"): + return self._modify(key, value) + return f"Unknown action: {action}" + + # -- inspect -- + + def _inspect(self, key: str | None) -> str: + if not key: + return self._inspect_all() + top = key.split(".")[0] + if top in self._DENIED_ATTRS or top.startswith("__"): + return f"Error: '{top}' is not accessible" + obj, err = self._resolve_path(key) + if err: + # "scratchpad" alias for _runtime_vars + if key == "scratchpad": + rv = self._loop._runtime_vars + return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty" + # Fallback: check _runtime_vars for simple keys stored by modify + if "." not in key and key in self._loop._runtime_vars: + return self._format_value(self._loop._runtime_vars[key], key) + return f"Error: {err}" + # Guard against mock auto-generated attributes + if "." not in key and not _has_real_attr(self._loop, key): + if key in self._loop._runtime_vars: + return self._format_value(self._loop._runtime_vars[key], key) + return f"Error: '{key}' not found" + return self._format_value(obj, key) + + def _inspect_all(self) -> str: + loop = self._loop + parts: list[str] = [] + # RESTRICTED keys + for k in self.RESTRICTED: + parts.append(self._format_value(getattr(loop, k, None), k)) + # Other useful top-level keys shown in description + for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"): + if _has_real_attr(loop, k): + parts.append(self._format_value(getattr(loop, k, None), k)) + # Token usage + usage = loop._last_usage + if usage: + parts.append(self._format_value(usage, "_last_usage")) + rv = loop._runtime_vars + if rv: + parts.append(self._format_value(rv, "scratchpad")) + return "\n".join(parts) + + # -- modify -- + + def _modify(self, key: str | None, value: Any) -> str: + if err := self._validate_key(key): + return err + top = key.split(".")[0] + if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES: + self._audit("modify", f"BLOCKED {key}") + return f"Error: '{key}' is protected and cannot be modified" + if top in self.READ_ONLY: + self._audit("modify", f"READ_ONLY {key}") + return f"Error: '{key}' is read-only and cannot be modified" + if "." in key: + parent_path, leaf = key.rsplit(".", 1) + if leaf in self._DENIED_ATTRS or leaf.startswith("__"): + self._audit("modify", f"BLOCKED leaf '{leaf}'") + return f"Error: '{leaf}' is not accessible" + if leaf.lower() in self._SENSITIVE_NAMES: + self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'") + return f"Error: '{leaf}' is not accessible" + parent, err = self._resolve_path(parent_path) + if err: + return f"Error: {err}" + if isinstance(parent, dict): + parent[leaf] = value + else: + setattr(parent, leaf, value) + self._audit("modify", f"{key} = {value!r}") + return f"Set {key} = {value!r}" + if key in self.RESTRICTED: + return self._modify_restricted(key, value) + return self._modify_free(key, value) + + def _modify_restricted(self, key: str, value: Any) -> str: + spec = self.RESTRICTED[key] + expected = spec["type"] + if expected is int and isinstance(value, bool): + return f"Error: '{key}' must be {expected.__name__}, got bool" + if not isinstance(value, expected): + try: + value = expected(value) + except (ValueError, TypeError): + return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}" + old = getattr(self._loop, key) + if "min" in spec and value < spec["min"]: + return f"Error: '{key}' must be >= {spec['min']}" + if "max" in spec and value > spec["max"]: + return f"Error: '{key}' must be <= {spec['max']}" + if "min_len" in spec and len(str(value)) < spec["min_len"]: + return f"Error: '{key}' must be at least {spec['min_len']} characters" + setattr(self._loop, key, value) + self._audit("modify", f"{key}: {old!r} -> {value!r}") + return f"Set {key} = {value!r} (was {old!r})" + + def _modify_free(self, key: str, value: Any) -> str: + if _has_real_attr(self._loop, key): + old = getattr(self._loop, key) + if isinstance(old, (str, int, float, bool)): + old_t, new_t = type(old), type(value) + if old_t is float and new_t is int: + pass # int → float coercion allowed + elif old_t is not new_t: + self._audit( + "modify", + f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}", + ) + return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}" + setattr(self._loop, key, value) + self._audit("modify", f"{key}: {old!r} -> {value!r}") + return f"Set {key} = {value!r} (was {old!r})" + if callable(value): + self._audit("modify", f"REJECTED callable {key}") + return "Error: cannot store callable values" + err = self._validate_json_safe(value) + if err: + self._audit("modify", f"REJECTED {key}: {err}") + return f"Error: {err}" + if key not in self._loop._runtime_vars and len(self._loop._runtime_vars) >= self._MAX_RUNTIME_KEYS: + self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached") + return f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first." + old = self._loop._runtime_vars.get(key) + self._loop._runtime_vars[key] = value + self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}") + return f"Set scratchpad.{key} = {value!r}" + + @classmethod + def _validate_json_safe(cls, value: Any, depth: int = 0) -> str | None: + if depth > 10: + return "value nesting too deep (max 10 levels)" + if isinstance(value, (str, int, float, bool, type(None))): + return None + if isinstance(value, list): + for i, item in enumerate(value): + if err := cls._validate_json_safe(item, depth + 1): + return f"list[{i}] contains {err}" + return None + if isinstance(value, dict): + for k, v in value.items(): + if not isinstance(k, str): + return f"dict key must be str, got {type(k).__name__}" + if err := cls._validate_json_safe(v, depth + 1): + return f"dict key '{k}' contains {err}" + return None + return f"unsupported type {type(value).__name__}" diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 38fc33d7..31d4cdef 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -96,10 +96,37 @@ class WebSearchTool(Tool): self.config = config if config is not None else WebSearchConfig() self.proxy = proxy + def _effective_provider(self) -> str: + """Resolve the backend that execute() will actually use.""" + provider = self.config.provider.strip().lower() or "brave" + if provider == "duckduckgo": + return "duckduckgo" + if provider == "brave": + api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "") + return "brave" if api_key else "duckduckgo" + if provider == "tavily": + api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "") + return "tavily" if api_key else "duckduckgo" + if provider == "searxng": + base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip() + return "searxng" if base_url else "duckduckgo" + if provider == "jina": + api_key = self.config.api_key or os.environ.get("JINA_API_KEY", "") + return "jina" if api_key else "duckduckgo" + if provider == "kagi": + api_key = self.config.api_key or os.environ.get("KAGI_API_KEY", "") + return "kagi" if api_key else "duckduckgo" + return provider + @property def read_only(self) -> bool: return True + @property + def exclusive(self) -> bool: + """DuckDuckGo searches are serialized because ddgs is not concurrency-safe.""" + return self._effective_provider() == "duckduckgo" + async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: provider = self.config.provider.strip().lower() or "brave" n = min(max(count or self.config.max_results, 1), 10) diff --git a/nanobot/api/server.py b/nanobot/api/server.py index 2bfeddd0..ebdee557 100644 --- a/nanobot/api/server.py +++ b/nanobot/api/server.py @@ -7,15 +7,30 @@ All requests route to a single persistent API session. from __future__ import annotations import asyncio +import base64 +import json as _json +import mimetypes +import re import time import uuid +from pathlib import Path from typing import Any from aiohttp import web from loguru import logger +from nanobot.config.paths import get_media_dir +from nanobot.utils.helpers import safe_filename from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB +_DATA_URL_RE = re.compile(r"^data:([^;]+);base64,(.+)$", re.DOTALL) + + +class _FileSizeExceeded(Exception): + """Raised when an uploaded file exceeds the size limit.""" + + API_SESSION_KEY = "api:default" API_CHAT_ID = "default" @@ -24,6 +39,7 @@ API_CHAT_ID = "default" # Response helpers # --------------------------------------------------------------------------- + def _error_json(status: int, message: str, err_type: str = "invalid_request_error") -> web.Response: return web.json_response( {"error": {"message": message, "type": err_type, "code": status}}, @@ -56,50 +72,235 @@ def _response_text(value: Any) -> str: return str(getattr(value, "content") or "") return str(value) +# --------------------------------------------------------------------------- +# SSE helpers +# --------------------------------------------------------------------------- + + +def _sse_chunk(delta: str, model: str, chunk_id: str, finish_reason: str | None = None) -> bytes: + """Format a single OpenAI-compatible SSE chunk.""" + payload = { + "id": chunk_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "delta": {"content": delta} if delta else {}, + "finish_reason": finish_reason, + } + ], + } + return f"data: {_json.dumps(payload)}\n\n".encode() + + +_SSE_DONE = b"data: [DONE]\n\n" + +# --------------------------------------------------------------------------- +# Upload helpers +# --------------------------------------------------------------------------- + + +def _save_base64_data_url(data_url: str, media_dir: Path) -> str | None: + """Decode a data:...;base64,... URL and save to disk.""" + m = _DATA_URL_RE.match(data_url) + if not m: + return None + mime_type, b64_payload = m.group(1), m.group(2) + try: + raw = base64.b64decode(b64_payload) + except Exception: + return None + if len(raw) > MAX_FILE_SIZE: + raise _FileSizeExceeded(f"File exceeds {MAX_FILE_SIZE // (1024 * 1024)}MB limit") + ext = mimetypes.guess_extension(mime_type) or ".bin" + filename = f"{uuid.uuid4().hex[:12]}{ext}" + dest = media_dir / safe_filename(filename) + dest.write_bytes(raw) + return str(dest) + + +def _parse_json_content(body: dict) -> tuple[str, list[str]]: + """Parse JSON request body. Returns (text, media_paths).""" + messages = body.get("messages") + if not isinstance(messages, list) or len(messages) != 1: + raise ValueError("Only a single user message is supported") + message = messages[0] + if not isinstance(message, dict) or message.get("role") != "user": + raise ValueError("Only a single user message is supported") + + user_content = message.get("content", "") + media_dir = get_media_dir("api") + media_paths: list[str] = [] + + if isinstance(user_content, list): + text_parts: list[str] = [] + for part in user_content: + if not isinstance(part, dict): + continue + if part.get("type") == "text": + text_parts.append(part.get("text", "")) + elif part.get("type") == "image_url": + url = part.get("image_url", {}).get("url", "") + if url.startswith("data:"): + saved = _save_base64_data_url(url, media_dir) + if saved: + media_paths.append(saved) + elif url: + raise ValueError( + "Remote image URLs are not supported. " + "Use base64 data URLs or upload files via multipart/form-data." + ) + text = " ".join(text_parts) + elif isinstance(user_content, str): + text = user_content + else: + raise ValueError("Invalid content format") + + return text, media_paths + + +async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str | None, str | None]: + """Parse multipart/form-data. Returns (text, media_paths, session_id, model).""" + media_dir = get_media_dir("api") + reader = await request.multipart() + text = "" + session_id = None + model = None + media_paths: list[str] = [] + + while True: + part = await reader.next() + if part is None: + break + if part.name == "message": + text = (await part.read()).decode("utf-8") + elif part.name == "session_id": + session_id = (await part.read()).decode("utf-8").strip() + elif part.name == "model": + model = (await part.read()).decode("utf-8").strip() + elif part.name == "files": + raw = await part.read() + if len(raw) > MAX_FILE_SIZE: + raise _FileSizeExceeded( + f"File '{part.filename}' exceeds {MAX_FILE_SIZE // (1024 * 1024)}MB limit" + ) + base = safe_filename(part.filename or "upload.bin") + filename = f"{uuid.uuid4().hex[:12]}_{base}" + dest = media_dir / filename + dest.write_bytes(raw) + media_paths.append(str(dest)) + + if not text: + text = "请分析上传的文件" + + return text, media_paths, session_id, model + # --------------------------------------------------------------------------- # Route handlers # --------------------------------------------------------------------------- + async def handle_chat_completions(request: web.Request) -> web.Response: - """POST /v1/chat/completions""" - - # --- Parse body --- - try: - body = await request.json() - except Exception: - return _error_json(400, "Invalid JSON body") - - messages = body.get("messages") - if not isinstance(messages, list) or len(messages) != 1: - return _error_json(400, "Only a single user message is supported") - - # Stream not yet supported - if body.get("stream", False): - return _error_json(400, "stream=true is not supported yet. Set stream=false or omit it.") - - message = messages[0] - if not isinstance(message, dict) or message.get("role") != "user": - return _error_json(400, "Only a single user message is supported") - user_content = message.get("content", "") - if isinstance(user_content, list): - # Multi-modal content array — extract text parts - user_content = " ".join( - part.get("text", "") for part in user_content if part.get("type") == "text" - ) + """POST /v1/chat/completions — supports JSON and multipart/form-data.""" + content_type = request.content_type or "" + if not isinstance(content_type, str): + content_type = "" agent_loop = request.app["agent_loop"] timeout_s: float = request.app.get("request_timeout", 120.0) model_name: str = request.app.get("model_name", "nanobot") - if (requested_model := body.get("model")) and requested_model != model_name: + + stream = False + try: + if content_type.startswith("multipart/"): + text, media_paths, session_id, requested_model = await _parse_multipart(request) + else: + try: + body = await request.json() + except Exception: + return _error_json(400, "Invalid JSON body") + stream = body.get("stream", False) + requested_model = body.get("model") + text, media_paths = _parse_json_content(body) + session_id = body.get("session_id") + except ValueError as e: + return _error_json(400, str(e)) + except _FileSizeExceeded as e: + return _error_json(413, str(e), err_type="invalid_request_error") + except Exception: + logger.exception("Error parsing upload") + return _error_json(413, "File too large or invalid upload") + + if requested_model and requested_model != model_name: return _error_json(400, f"Only configured model '{model_name}' is available") - session_key = f"api:{body['session_id']}" if body.get("session_id") else API_SESSION_KEY + session_key = f"api:{session_id}" if session_id else API_SESSION_KEY session_locks: dict[str, asyncio.Lock] = request.app["session_locks"] session_lock = session_locks.setdefault(session_key, asyncio.Lock()) - logger.info("API request session_key={} content={}", session_key, user_content[:80]) + logger.info( + "API request session_key={} media={} text={} stream={}", + session_key, len(media_paths), text[:80], stream, + ) + # -- streaming path -- + if stream: + resp = web.StreamResponse() + resp.content_type = "text/event-stream" + resp.headers["Cache-Control"] = "no-cache" + resp.headers["Connection"] = "keep-alive" + resp.enable_compression() + await resp.prepare(request) + chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + queue: asyncio.Queue[str | None] = asyncio.Queue() + stream_failed = False + + async def _on_stream(token: str) -> None: + await queue.put(token) + + async def _on_stream_end(*_a: Any, **_kw: Any) -> None: + await queue.put(None) + + async def _run() -> None: + nonlocal stream_failed + try: + async with session_lock: + await asyncio.wait_for( + agent_loop.process_direct( + content=text, + media=media_paths if media_paths else None, + session_key=session_key, + channel="api", + chat_id=API_CHAT_ID, + on_stream=_on_stream, + on_stream_end=_on_stream_end, + ), + timeout=timeout_s, + ) + except Exception: + stream_failed = True + logger.exception("Streaming error for session {}", session_key) + await queue.put(None) + + task = asyncio.create_task(_run()) + try: + while True: + token = await queue.get() + if token is None: + break + await resp.write(_sse_chunk(token, model_name, chunk_id)) + finally: + task.cancel() + + if not stream_failed: + await resp.write(_sse_chunk("", model_name, chunk_id, finish_reason="stop")) + await resp.write(_SSE_DONE) + return resp + + # -- non-streaming path (original logic) -- _FALLBACK = EMPTY_FINAL_RESPONSE_MESSAGE try: @@ -107,7 +308,8 @@ async def handle_chat_completions(request: web.Request) -> web.Response: try: response = await asyncio.wait_for( agent_loop.process_direct( - content=user_content, + content=text, + media=media_paths if media_paths else None, session_key=session_key, channel="api", chat_id=API_CHAT_ID, @@ -117,13 +319,11 @@ async def handle_chat_completions(request: web.Request) -> web.Response: response_text = _response_text(response) if not response_text or not response_text.strip(): - logger.warning( - "Empty response for session {}, retrying", - session_key, - ) + logger.warning("Empty response for session {}, retrying", session_key) retry_response = await asyncio.wait_for( agent_loop.process_direct( - content=user_content, + content=text, + media=media_paths if media_paths else None, session_key=session_key, channel="api", chat_id=API_CHAT_ID, @@ -132,10 +332,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response: ) response_text = _response_text(retry_response) if not response_text or not response_text.strip(): - logger.warning( - "Empty response after retry for session {}, using fallback", - session_key, - ) + logger.warning("Empty response after retry, using fallback") response_text = _FALLBACK except asyncio.TimeoutError: @@ -153,17 +350,19 @@ async def handle_chat_completions(request: web.Request) -> web.Response: async def handle_models(request: web.Request) -> web.Response: """GET /v1/models""" model_name = request.app.get("model_name", "nanobot") - return web.json_response({ - "object": "list", - "data": [ - { - "id": model_name, - "object": "model", - "created": 0, - "owned_by": "nanobot", - } - ], - }) + return web.json_response( + { + "object": "list", + "data": [ + { + "id": model_name, + "object": "model", + "created": 0, + "owned_by": "nanobot", + } + ], + } + ) async def handle_health(request: web.Request) -> web.Response: @@ -175,7 +374,10 @@ async def handle_health(request: web.Request) -> web.Response: # App factory # --------------------------------------------------------------------------- -def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0) -> web.Application: + +def create_app( + agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0 +) -> web.Application: """Create the aiohttp application. Args: @@ -183,7 +385,7 @@ def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float = model_name: Model name reported in responses. request_timeout: Per-request timeout in seconds. """ - app = web.Application() + app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images app["agent_loop"] = agent_loop app["model_name"] = model_name app["request_timeout"] = request_timeout diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index dd29c085..a59b31e2 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -24,6 +24,7 @@ class BaseChannel(ABC): display_name: str = "Base" transcription_provider: str = "groq" transcription_api_key: str = "" + transcription_api_base: str = "" def __init__(self, config: Any, bus: MessageBus): """ @@ -44,10 +45,16 @@ class BaseChannel(ABC): try: if self.transcription_provider == "openai": from nanobot.providers.transcription import OpenAITranscriptionProvider - provider = OpenAITranscriptionProvider(api_key=self.transcription_api_key) + provider = OpenAITranscriptionProvider( + api_key=self.transcription_api_key, + api_base=self.transcription_api_base or None, + ) else: from nanobot.providers.transcription import GroqTranscriptionProvider - provider = GroqTranscriptionProvider(api_key=self.transcription_api_key) + provider = GroqTranscriptionProvider( + api_key=self.transcription_api_key, + api_base=self.transcription_api_base or None, + ) return await provider.transcribe(file_path) except Exception as e: logger.warning("{}: audio transcription failed: {}", self.name, e) @@ -116,7 +123,13 @@ class BaseChannel(ABC): def is_allowed(self, sender_id: str) -> bool: """Check if *sender_id* is permitted. Empty list → deny all; ``"*"`` → allow all.""" - allow_list = getattr(self.config, "allow_from", []) + if isinstance(self.config, dict): + if "allow_from" in self.config: + allow_list = self.config.get("allow_from") + else: + allow_list = self.config.get("allowFrom", []) + else: + allow_list = getattr(self.config, "allow_from", []) if not allow_list: logger.warning("{}: allow_from is empty — all access denied", self.name) return False diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 39b5818b..a863ba0d 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -337,6 +337,9 @@ class DingTalkChannel(BaseChannel): content_type = (resp.headers.get("content-type") or "").split(";")[0].strip() filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref)) return resp.content, filename, content_type or None + except httpx.TransportError as e: + logger.error("DingTalk media download network error ref={} err={}", media_ref, e) + raise except Exception as e: logger.error("DingTalk media download error ref={} err={}", media_ref, e) return None, None, None @@ -388,6 +391,9 @@ class DingTalkChannel(BaseChannel): logger.error("DingTalk media upload missing media_id body={}", text[:500]) return None return str(media_id) + except httpx.TransportError as e: + logger.error("DingTalk media upload network error type={} err={}", media_type, e) + raise except Exception as e: logger.error("DingTalk media upload error type={} err={}", media_type, e) return None @@ -437,6 +443,9 @@ class DingTalkChannel(BaseChannel): return False logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key) return True + except httpx.TransportError as e: + logger.error("DingTalk network error sending message msgKey={} err={}", msg_key, e) + raise except Exception as e: logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e) return False diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 6e8c673a..60ca0698 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -53,6 +53,7 @@ class DiscordConfig(Base): enabled: bool = False token: str = "" allow_from: list[str] = Field(default_factory=list) + allow_channels: list[str] = Field(default_factory=list) # Allowed channel IDs (empty = all) intents: int = 37377 group_policy: Literal["mention", "open"] = "mention" read_receipt_emoji: str = "👀" @@ -366,6 +367,7 @@ class DiscordChannel(BaseChannel): await client.send_outbound(msg) except Exception as e: logger.error("Error sending Discord message: {}", e) + raise finally: if not is_progress: await self._stop_typing(msg.chat_id) @@ -449,7 +451,6 @@ class DiscordChannel(BaseChannel): await self._start_typing(message.channel) # Add read receipt reaction immediately, working emoji after delay - channel_id = self._channel_key(message.channel) try: await message.add_reaction(self.config.read_receipt_emoji) self._pending_reactions[channel_id] = message @@ -533,6 +534,12 @@ class DiscordChannel(BaseChannel): """Check if inbound Discord message should be processed.""" if not self.is_allowed(sender_id): return False + # Channel-based filtering: only respond in allowed channels + allow_channels = self.config.allow_channels + if allow_channels: + channel_id = self._channel_key(message.channel) + if channel_id not in allow_channels: + return False if message.guild is not None and not self._should_respond_in_group(message, content): return False return True diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index f0fcdf9a..681e71ef 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -118,6 +118,7 @@ class EmailChannel(BaseChannel): config = EmailConfig.model_validate(config) super().__init__(config, bus) self.config: EmailConfig = config + self._self_addresses = self._collect_self_addresses() self._last_subject_by_chat: dict[str, str] = {} self._last_message_id_by_chat: dict[str, str] = {} self._processed_uids: set[str] = set() # Capped to prevent unbounded growth @@ -379,6 +380,12 @@ class EmailChannel(BaseChannel): sender = parseaddr(parsed.get("From", ""))[1].strip().lower() if not sender: continue + if self._is_self_address(sender): + logger.info("Email from {} ignored: matches bot-owned address", sender) + self._remember_processed_uid(uid, dedupe, cycle_uids) + if mark_seen: + client.store(imap_id, "+FLAGS", "\\Seen") + continue # --- Anti-spoofing: verify Authentication-Results --- spf_pass, dkim_pass = self._check_authentication_results(parsed) @@ -446,14 +453,7 @@ class EmailChannel(BaseChannel): } ) - if uid: - cycle_uids.add(uid) - if dedupe and uid: - self._processed_uids.add(uid) - # mark_seen is the primary dedup; this set is a safety net - if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: - # Evict a random half to cap memory; mark_seen is the primary dedup - self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:]) + self._remember_processed_uid(uid, dedupe, cycle_uids) if mark_seen: client.store(imap_id, "+FLAGS", "\\Seen") @@ -463,6 +463,50 @@ class EmailChannel(BaseChannel): except Exception: pass + def _collect_self_addresses(self) -> set[str]: + """Return normalized email addresses owned by this channel instance.""" + candidates = ( + self.config.from_address, + self.config.smtp_username, + self.config.imap_username, + ) + normalized = { + addr + for candidate in candidates + if (addr := self._normalize_address(candidate)) + } + return normalized + + @staticmethod + def _normalize_address(value: str) -> str: + """Normalize an address or mailbox-like identifier for comparisons.""" + raw = (value or "").strip() + if not raw: + return "" + parsed = parseaddr(raw)[1].strip().lower() + if parsed: + return parsed + if "@" in raw: + return raw.lower() + return "" + + def _is_self_address(self, sender: str) -> bool: + """Return True when an inbound sender belongs to the bot itself.""" + normalized_sender = self._normalize_address(sender) + return bool(normalized_sender) and normalized_sender in self._self_addresses + + def _remember_processed_uid(self, uid: str, dedupe: bool, cycle_uids: set[str]) -> None: + """Track a fetched UID so skipped messages are not reprocessed forever.""" + if not uid: + return + cycle_uids.add(uid) + if dedupe: + self._processed_uids.add(uid) + # mark_seen is the primary dedup; this set is a safety net + if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: + # Evict a random half to cap memory; mark_seen is the primary dedup + self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:]) + @classmethod def _is_stale_imap_error(cls, exc: Exception) -> bool: message = str(exc).lower() diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 5afeca35..1442c363 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1290,7 +1290,6 @@ class FeishuChannel(BaseChannel): Supported metadata keys: _stream_end: Finalize the streaming card. - _resuming: Mid-turn pause – flush but keep the buffer alive. _tool_hint: Delta is a formatted tool hint (for display only). message_id: Original message id (used with _stream_end for reaction cleanup). reaction_id: Reaction id to remove on stream end. @@ -1309,50 +1308,44 @@ class FeishuChannel(BaseChannel): if self.config.done_emoji and message_id: await self._add_reaction(message_id, self.config.done_emoji) - resuming = meta.get("_resuming", False) - if resuming: - # Mid-turn pause (e.g. tool call between streaming segments). - # Flush current text to card but keep the buffer alive so the - # next segment appends to the same card. - buf = self._stream_bufs.get(chat_id) - if buf and buf.card_id and buf.text: - buf.sequence += 1 - await loop.run_in_executor( - None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence, - ) - return - buf = self._stream_bufs.pop(chat_id, None) if not buf or not buf.text: return + # Try to finalize via streaming card; if that fails (e.g. + # streaming mode was closed by Feishu due to timeout), fall + # back to sending a regular interactive card. if buf.card_id: buf.sequence += 1 - await loop.run_in_executor( + ok = await loop.run_in_executor( None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence, ) - # Required so the chat list preview exits the streaming placeholder (Feishu streaming card docs). - buf.sequence += 1 - await loop.run_in_executor( - None, - self._close_streaming_mode_sync, - buf.card_id, - buf.sequence, - ) - else: - for chunk in self._split_elements_by_table_limit( - self._build_card_elements(buf.text) - ): - card = json.dumps( - {"config": {"wide_screen_mode": True}, "elements": chunk}, - ensure_ascii=False, - ) + if ok: + buf.sequence += 1 await loop.run_in_executor( - None, self._send_message_sync, rid_type, chat_id, "interactive", card + None, + self._close_streaming_mode_sync, + buf.card_id, + buf.sequence, ) + return + logger.warning( + "Streaming card {} final update failed, falling back to regular card", + buf.card_id, + ) + for chunk in self._split_elements_by_table_limit( + self._build_card_elements(buf.text) + ): + card = json.dumps( + {"config": {"wide_screen_mode": True}, "elements": chunk}, + ensure_ascii=False, + ) + await loop.run_in_executor( + None, self._send_message_sync, rid_type, chat_id, "interactive", card + ) return # --- accumulate delta --- @@ -1404,14 +1397,21 @@ class FeishuChannel(BaseChannel): if buf and buf.card_id: # Delegate to send_delta so tool hints get the same # throttling (and card creation) as regular text deltas. - lines = self.__class__._format_tool_hint_lines(hint).split("\n") - delta = "\n\n" + "\n".join( - f"{self.config.tool_hint_prefix} {ln}" for ln in lines if ln.strip() - ) + "\n\n" - await self.send_delta(msg.chat_id, delta) + await self.send_delta( + msg.chat_id, + "\n\n" + self._format_tool_hint_delta(hint) + "\n\n", + ) return - await self._send_tool_hint_card( - receive_id_type, msg.chat_id, hint + # No active streaming card — send as a regular + # interactive card with the same 🔧 prefix style. + card = json.dumps( + {"config": {"wide_screen_mode": True}, "elements": [ + {"tag": "markdown", "content": self._format_tool_hint_delta(hint)}, + ]}, + ensure_ascii=False, + ) + await loop.run_in_executor( + None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card ) return @@ -1708,33 +1708,9 @@ class FeishuChannel(BaseChannel): return "\n".join(part for part in parts if part) - async def _send_tool_hint_card( - self, receive_id_type: str, receive_id: str, tool_hint: str - ) -> None: - """Send tool hint as an interactive card with formatted code block. - - Args: - receive_id_type: "chat_id" or "open_id" - receive_id: The target chat or user ID - tool_hint: Formatted tool hint string (e.g., 'web_search("q"), read_file("path")') - """ - loop = asyncio.get_running_loop() - - # Put each top-level tool call on its own line without altering commas inside arguments. - formatted_code = self.__class__._format_tool_hint_lines(tool_hint) - - card = { - "config": {"wide_screen_mode": True}, - "elements": [ - {"tag": "markdown", "content": f"**Tool Calls**\n\n```text\n{formatted_code}\n```"} - ], - } - - await loop.run_in_executor( - None, - self._send_message_sync, - receive_id_type, - receive_id, - "interactive", - json.dumps(card, ensure_ascii=False), + def _format_tool_hint_delta(self, tool_hint: str) -> str: + """Format a tool hint string with the 🔧 prefix for each line.""" + lines = self.__class__._format_tool_hint_lines(tool_hint).split("\n") + return "\n".join( + f"{self.config.tool_hint_prefix} {ln}" for ln in lines if ln.strip() ) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index aaec5e33..c0622a27 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -41,6 +41,7 @@ class ChannelManager: transcription_provider = self.config.channels.transcription_provider transcription_key = self._resolve_transcription_key(transcription_provider) + transcription_base = self._resolve_transcription_base(transcription_provider) for name, cls in discover_all().items(): section = getattr(self.config.channels, name, None) @@ -57,6 +58,7 @@ class ChannelManager: channel = cls(section, self.bus) channel.transcription_provider = transcription_provider channel.transcription_api_key = transcription_key + channel.transcription_api_base = transcription_base self.channels[name] = channel logger.info("{} channel enabled", cls.display_name) except Exception as e: @@ -73,9 +75,26 @@ class ChannelManager: except AttributeError: return "" + def _resolve_transcription_base(self, provider: str) -> str: + """Pick the API base URL for the configured transcription provider.""" + try: + if provider == "openai": + return self.config.providers.openai.api_base or "" + return self.config.providers.groq.api_base or "" + except AttributeError: + return "" + def _validate_allow_from(self) -> None: for name, ch in self.channels.items(): - if getattr(ch.config, "allow_from", None) == []: + cfg = ch.config + if isinstance(cfg, dict): + if "allow_from" in cfg: + allow = cfg.get("allow_from") + else: + allow = cfg.get("allowFrom") + else: + allow = getattr(cfg, "allow_from", None) + if allow == []: raise SystemExit( f'Error: "{name}" has empty allowFrom (denies all). ' f'Set ["*"] to allow everyone, or add specific user IDs.' @@ -170,6 +189,9 @@ class ChannelManager: if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress: continue + if msg.metadata.get("_retry_wait"): + continue + # Coalesce consecutive _stream_delta messages for the same (channel, chat_id) # to reduce API calls and improve streaming latency if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"): diff --git a/nanobot/channels/msteams.py b/nanobot/channels/msteams.py new file mode 100644 index 00000000..427b35f8 --- /dev/null +++ b/nanobot/channels/msteams.py @@ -0,0 +1,535 @@ +"""Microsoft Teams channel MVP using a tiny built-in HTTP webhook server. + +Scope: +- DM-focused MVP +- text inbound/outbound +- conversation reference persistence +- sender allowlist support +- optional inbound Bot Framework bearer-token validation +- no attachments/cards/polls yet +""" + +from __future__ import annotations + +import asyncio +import html +import importlib.util +import json +import re +import threading +import time +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import TYPE_CHECKING, Any + +import httpx +from loguru import logger +from pydantic import Field + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_workspace_path +from nanobot.config.schema import Base + +MSTEAMS_AVAILABLE = ( + importlib.util.find_spec("jwt") is not None + and importlib.util.find_spec("cryptography") is not None +) + +if TYPE_CHECKING: + import jwt + +if MSTEAMS_AVAILABLE: + import jwt + + +class MSTeamsConfig(Base): + """Microsoft Teams channel configuration.""" + + enabled: bool = False + app_id: str = "" + app_password: str = "" + tenant_id: str = "" + host: str = "0.0.0.0" + port: int = 3978 + path: str = "/api/messages" + allow_from: list[str] = Field(default_factory=list) + reply_in_thread: bool = True + mention_only_response: str = "Hi — what can I help with?" + validate_inbound_auth: bool = True + + +@dataclass +class ConversationRef: + """Minimal stored conversation reference for replies.""" + + service_url: str + conversation_id: str + bot_id: str | None = None + activity_id: str | None = None + conversation_type: str | None = None + tenant_id: str | None = None + + +class MSTeamsChannel(BaseChannel): + """Microsoft Teams channel (DM-first MVP).""" + + name = "msteams" + display_name = "Microsoft Teams" + + @classmethod + def default_config(cls) -> dict[str, Any]: + return MSTeamsConfig().model_dump(by_alias=True) + + def __init__(self, config: Any, bus: MessageBus): + if isinstance(config, dict): + config = MSTeamsConfig.model_validate(config) + super().__init__(config, bus) + self.config: MSTeamsConfig = config + self._loop: asyncio.AbstractEventLoop | None = None + self._server: ThreadingHTTPServer | None = None + self._server_thread: threading.Thread | None = None + self._http: httpx.AsyncClient | None = None + self._token: str | None = None + self._token_expires_at: float = 0.0 + self._botframework_openid_config_url = ( + "https://login.botframework.com/v1/.well-known/openidconfiguration" + ) + self._botframework_openid_config: dict[str, Any] | None = None + self._botframework_openid_config_expires_at: float = 0.0 + self._botframework_jwks: dict[str, Any] | None = None + self._botframework_jwks_expires_at: float = 0.0 + self._refs_path = get_workspace_path() / "state" / "msteams_conversations.json" + self._refs_path.parent.mkdir(parents=True, exist_ok=True) + self._conversation_refs: dict[str, ConversationRef] = self._load_refs() + + async def start(self) -> None: + """Start the Teams webhook listener.""" + if not MSTEAMS_AVAILABLE: + logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]") + return + + if not self.config.app_id or not self.config.app_password: + logger.error("MSTeams app_id/app_password not configured") + return + + if not self.config.validate_inbound_auth: + logger.warning( + "MSTeams inbound auth validation was explicitly DISABLED in config. " + "Anyone who knows the webhook URL can send messages as any user. " + "Only disable this for local development or controlled testing." + ) + + self._loop = asyncio.get_running_loop() + self._http = httpx.AsyncClient(timeout=30.0) + self._running = True + + channel = self + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + if self.path != channel.config.path: + self.send_response(404) + self.end_headers() + return + + try: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) if length > 0 else b"{}" + payload = json.loads(raw.decode("utf-8")) + except Exception as e: + logger.warning("MSTeams invalid request body: {}", e) + self.send_response(400) + self.end_headers() + return + + auth_header = self.headers.get("Authorization", "") + if channel.config.validate_inbound_auth: + try: + fut = asyncio.run_coroutine_threadsafe( + channel._validate_inbound_auth(auth_header, payload), + channel._loop, + ) + fut.result(timeout=15) + except Exception as e: + logger.warning("MSTeams inbound auth validation failed: {}", e) + self.send_response(401) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error":"unauthorized"}') + return + try: + fut = asyncio.run_coroutine_threadsafe( + channel._handle_activity(payload), + channel._loop, + ) + fut.result(timeout=15) + except Exception as e: + logger.warning("MSTeams activity handling failed: {}", e) + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b"{}") + + def log_message(self, format: str, *args: Any) -> None: + return + + self._server = ThreadingHTTPServer((self.config.host, self.config.port), Handler) + self._server_thread = threading.Thread( + target=self._server.serve_forever, + name="nanobot-msteams", + daemon=True, + ) + self._server_thread.start() + + logger.info( + "MSTeams webhook listening on http://{}:{}{}", + self.config.host, + self.config.port, + self.config.path, + ) + + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop the channel.""" + self._running = False + if self._server: + self._server.shutdown() + self._server.server_close() + self._server = None + if self._server_thread and self._server_thread.is_alive(): + self._server_thread.join(timeout=2) + self._server_thread = None + if self._http: + await self._http.aclose() + self._http = None + + async def send(self, msg: OutboundMessage) -> None: + """Send a plain text reply into an existing Teams conversation.""" + if not self._http: + raise RuntimeError("MSTeams HTTP client not initialized") + + ref = self._conversation_refs.get(str(msg.chat_id)) + if not ref: + raise RuntimeError(f"MSTeams conversation ref not found for chat_id={msg.chat_id}") + + token = await self._get_access_token() + base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities" + use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id) + url = f"{base_url}/{ref.activity_id}" if use_thread_reply else base_url + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + payload = { + "type": "message", + "text": msg.content or " ", + } + if use_thread_reply: + payload["replyToId"] = ref.activity_id + + try: + resp = await self._http.post(url, headers=headers, json=payload) + resp.raise_for_status() + logger.info("MSTeams message sent to {}", ref.conversation_id) + except Exception as e: + logger.error("MSTeams send failed: {}", e) + raise + + async def _handle_activity(self, activity: dict[str, Any]) -> None: + """Handle inbound Teams/Bot Framework activity.""" + if activity.get("type") != "message": + return + + conversation = activity.get("conversation") or {} + from_user = activity.get("from") or {} + recipient = activity.get("recipient") or {} + channel_data = activity.get("channelData") or {} + + sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip() + conversation_id = str(conversation.get("id") or "").strip() + service_url = str(activity.get("serviceUrl") or "").strip() + activity_id = str(activity.get("id") or "").strip() + conversation_type = str(conversation.get("conversationType") or "").strip() + + if not sender_id or not conversation_id or not service_url: + return + + if recipient.get("id") and from_user.get("id") == recipient.get("id"): + return + + # DM-only MVP: ignore group/channel traffic for now + if conversation_type and conversation_type not in ("personal", ""): + logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type) + return + + text = self._sanitize_inbound_text(activity) + if not text: + text = self.config.mention_only_response.strip() + if not text: + logger.debug("MSTeams ignoring empty message after Teams text sanitization") + return + + if not self.is_allowed(sender_id): + logger.warning( + "Access denied for sender {} on channel {}. " + "Add them to allowFrom list in config to grant access.", + sender_id, self.name, + ) + return + + self._conversation_refs[conversation_id] = ConversationRef( + service_url=service_url, + conversation_id=conversation_id, + bot_id=str(recipient.get("id") or "") or None, + activity_id=activity_id or None, + conversation_type=conversation_type or None, + tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None, + ) + self._save_refs() + + await self._handle_message( + sender_id=sender_id, + chat_id=conversation_id, + content=text, + metadata={ + "msteams": { + "activity_id": activity_id, + "conversation_id": conversation_id, + "conversation_type": conversation_type or "personal", + "from_name": from_user.get("name"), + } + }, + ) + + def _sanitize_inbound_text(self, activity: dict[str, Any]) -> str: + """Extract the user-authored text from a Teams activity.""" + text = str(activity.get("text") or "") + text = self._strip_possible_bot_mention(text) + + channel_data = activity.get("channelData") or {} + reply_to_id = str(activity.get("replyToId") or "").strip() + normalized_preview = html.unescape(text).replace("&rsquo", "’").strip() + normalized_preview = normalized_preview.replace("\r\n", "\n").replace("\r", "\n") + preview_lines = [line.strip() for line in normalized_preview.split("\n")] + while preview_lines and not preview_lines[0]: + preview_lines.pop(0) + first_line = preview_lines[0] if preview_lines else "" + looks_like_quote_wrapper = first_line.lower().startswith("replying to ") or first_line.startswith("Reply wrapper") + + if reply_to_id or channel_data.get("messageType") == "reply" or looks_like_quote_wrapper: + text = self._normalize_teams_reply_quote(text) + + return text.strip() + + def _strip_possible_bot_mention(self, text: str) -> str: + """Remove simple Teams mention markup from message text.""" + cleaned = re.sub(r"]*>.*?", " ", text, flags=re.IGNORECASE | re.DOTALL) + cleaned = re.sub(r"[^\S\r\n]+", " ", cleaned) + cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned) + return cleaned.strip() + + def _normalize_teams_reply_quote(self, text: str) -> str: + """Normalize Teams quoted replies into a compact structured form.""" + cleaned = html.unescape(text).replace("&rsquo", "’").strip() + if not cleaned: + return "" + + normalized_newlines = cleaned.replace("\r\n", "\n").replace("\r", "\n") + lines = [line.strip() for line in normalized_newlines.split("\n")] + while lines and not lines[0]: + lines.pop(0) + + # Observed native Teams reply wrapper: + # Replying to Bob Smith + # actual reply text + if len(lines) >= 2 and lines[0].lower().startswith("replying to "): + quoted = lines[0][len("replying to ") :].strip(" :") + reply = "\n".join(lines[1:]).strip() + return self._format_reply_with_quote(quoted, reply) + + # Observed reply wrapper where the quoted content is surfaced after a + # synthetic "Reply wrapper" header, sometimes with a blank line separating quote + # and reply, and sometimes as a compact line-based fallback shape. + if lines and lines[0].strip().startswith("Reply wrapper"): + body = normalized_newlines.split("\n", 1)[1] if "\n" in normalized_newlines else "" + body = body.lstrip() + parts = re.split(r"\n\s*\n", body, maxsplit=1) + if len(parts) == 2: + quoted = re.sub(r"\s+", " ", parts[0]).strip() + reply = re.sub(r"\s+", " ", parts[1]).strip() + if quoted or reply: + return self._format_reply_with_quote(quoted, reply) + + body_lines = [line.strip() for line in body.split("\n") if line.strip()] + if body_lines: + quoted = " ".join(body_lines[:-1]).strip() + reply = body_lines[-1].strip() + if quoted and reply: + return self._format_reply_with_quote(quoted, reply) + + # Observed compact fallback where the relay flattens quote and reply into + # a single line after the synthetic Reply wrapper prefix. + compact = re.sub(r"\s+", " ", normalized_newlines).strip() + if compact.startswith("Reply wrapper "): + compact = compact[len("Reply wrapper ") :].strip() + for boundary in (". ", "! ", "? ", "… "): + idx = compact.rfind(boundary) + if idx == -1: + continue + quoted = compact[: idx + 1].strip() + reply = compact[idx + len(boundary) :].strip() + if quoted and reply and len(reply) <= 160: + return self._format_reply_with_quote(quoted, reply) + + return cleaned + + def _format_reply_with_quote(self, quoted: str, reply: str) -> str: + """Format a reply-with-context message for the model without Teams wrapper noise.""" + quoted = quoted.strip() + reply = reply.strip() + if quoted and reply: + return f"User is replying to: {quoted}\nUser reply: {reply}" + if reply: + return reply + return quoted + + async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None: + """Validate inbound Bot Framework bearer token.""" + if not MSTEAMS_AVAILABLE: + raise RuntimeError("PyJWT not installed. Run: pip install nanobot-ai[msteams]") + + if not auth_header.lower().startswith("bearer "): + raise ValueError("missing bearer token") + + token = auth_header.split(" ", 1)[1].strip() + if not token: + raise ValueError("empty bearer token") + + header = jwt.get_unverified_header(token) + kid = str(header.get("kid") or "").strip() + if not kid: + raise ValueError("missing token kid") + + jwks = await self._get_botframework_jwks() + keys = jwks.get("keys") or [] + jwk = next((key for key in keys if key.get("kid") == kid), None) + if not jwk: + raise ValueError(f"signing key not found for kid={kid}") + + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)) + claims = jwt.decode( + token, + key=public_key, + algorithms=["RS256"], + audience=self.config.app_id, + issuer="https://api.botframework.com", + options={ + "require": ["exp", "nbf", "iss", "aud"], + }, + ) + + claim_service_url = str( + claims.get("serviceurl") or claims.get("serviceUrl") or "", + ).strip() + activity_service_url = str(activity.get("serviceUrl") or "").strip() + if claim_service_url and activity_service_url and claim_service_url != activity_service_url: + raise ValueError("serviceUrl claim mismatch") + + async def _get_botframework_openid_config(self) -> dict[str, Any]: + """Fetch and cache Bot Framework OpenID configuration.""" + + now = time.time() + if self._botframework_openid_config and now < self._botframework_openid_config_expires_at: + return self._botframework_openid_config + + if not self._http: + raise RuntimeError("MSTeams HTTP client not initialized") + + resp = await self._http.get(self._botframework_openid_config_url) + resp.raise_for_status() + self._botframework_openid_config = resp.json() + self._botframework_openid_config_expires_at = now + 3600 + return self._botframework_openid_config + + async def _get_botframework_jwks(self) -> dict[str, Any]: + """Fetch and cache Bot Framework JWKS.""" + + now = time.time() + if self._botframework_jwks and now < self._botframework_jwks_expires_at: + return self._botframework_jwks + + if not self._http: + raise RuntimeError("MSTeams HTTP client not initialized") + + openid_config = await self._get_botframework_openid_config() + jwks_uri = str(openid_config.get("jwks_uri") or "").strip() + if not jwks_uri: + raise RuntimeError("Bot Framework OpenID config missing jwks_uri") + + resp = await self._http.get(jwks_uri) + resp.raise_for_status() + self._botframework_jwks = resp.json() + self._botframework_jwks_expires_at = now + 3600 + return self._botframework_jwks + + def _load_refs(self) -> dict[str, ConversationRef]: + """Load stored conversation references.""" + if not self._refs_path.exists(): + return {} + try: + data = json.loads(self._refs_path.read_text(encoding="utf-8")) + out: dict[str, ConversationRef] = {} + for key, value in data.items(): + out[key] = ConversationRef(**value) + return out + except Exception as e: + logger.warning("Failed to load MSTeams conversation refs: {}", e) + return {} + + def _save_refs(self) -> None: + """Persist conversation references.""" + try: + data = { + key: { + "service_url": ref.service_url, + "conversation_id": ref.conversation_id, + "bot_id": ref.bot_id, + "activity_id": ref.activity_id, + "conversation_type": ref.conversation_type, + "tenant_id": ref.tenant_id, + } + for key, ref in self._conversation_refs.items() + } + self._refs_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + except Exception as e: + logger.warning("Failed to save MSTeams conversation refs: {}", e) + + async def _get_access_token(self) -> str: + """Fetch an access token for Bot Framework / Azure Bot auth.""" + + now = time.time() + if self._token and now < self._token_expires_at - 60: + return self._token + + if not self._http: + raise RuntimeError("MSTeams HTTP client not initialized") + + tenant = (self.config.tenant_id or "").strip() or "botframework.com" + token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" + data = { + "grant_type": "client_credentials", + "client_id": self.config.app_id, + "client_secret": self.config.app_password, + "scope": "https://api.botframework.com/.default", + } + resp = await self._http.post(token_url, data=data) + resp.raise_for_status() + payload = resp.json() + self._token = payload["access_token"] + self._token_expires_at = now + int(payload.get("expires_in", 3600)) + return self._token diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 484eed6e..f109f6da 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -280,6 +280,9 @@ class QQChannel(BaseChannel): msg_id=msg_id, content=msg.content.strip(), ) + except (aiohttp.ClientError, OSError): + # Network / transport errors — propagate so ChannelManager can retry + raise except Exception: logger.exception("Error sending QQ message to chat_id={}", msg.chat_id) @@ -362,7 +365,12 @@ class QQChannel(BaseChannel): logger.info("QQ media sent: {}", filename) return True + except (aiohttp.ClientError, OSError) as e: + # Network / transport errors — propagate for retry by caller + logger.warning("QQ send media network error filename={} err={}", filename, e) + raise except Exception as e: + # API-level or other non-network errors — return False so send() can fallback logger.error("QQ send media failed filename={} err={}", filename, e) return False diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 2503f6a2..c68020ce 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -5,6 +5,7 @@ import re from typing import Any from loguru import logger +from pydantic import Field from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse from slack_sdk.socket_mode.websockets import SocketModeClient @@ -13,8 +14,6 @@ from slackify_markdown import slackify_markdown from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from pydantic import Field - from nanobot.channels.base import BaseChannel from nanobot.config.schema import Base @@ -50,6 +49,9 @@ class SlackChannel(BaseChannel): name = "slack" display_name = "Slack" + _SLACK_ID_RE = re.compile(r"^[CDGUW][A-Z0-9]{2,}$") + _SLACK_CHANNEL_REF_RE = re.compile(r"^<#([A-Z0-9]+)(?:\|[^>]+)?>$") + _SLACK_USER_REF_RE = re.compile(r"^<@([A-Z0-9]+)(?:\|[^>]+)?>$") @classmethod def default_config(cls) -> dict[str, Any]: @@ -63,6 +65,7 @@ class SlackChannel(BaseChannel): self._web_client: AsyncWebClient | None = None self._socket_client: SocketModeClient | None = None self._bot_user_id: str | None = None + self._target_cache: dict[str, str] = {} async def start(self) -> None: """Start the Slack Socket Mode client.""" @@ -113,17 +116,23 @@ class SlackChannel(BaseChannel): logger.warning("Slack client not running") return try: + target_chat_id = await self._resolve_target_chat_id(msg.chat_id) slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {} thread_ts = slack_meta.get("thread_ts") channel_type = slack_meta.get("channel_type") + origin_chat_id = str((slack_meta.get("event", {}) or {}).get("channel") or msg.chat_id) # Slack DMs don't use threads; channel/group replies may keep thread_ts. - thread_ts_param = thread_ts if thread_ts and channel_type != "im" else None + thread_ts_param = ( + thread_ts + if thread_ts and channel_type != "im" and target_chat_id == origin_chat_id + else None + ) # Slack rejects empty text payloads. Keep media-only messages media-only, # but send a single blank message when the bot has no text or files to send. if msg.content or not (msg.media or []): await self._web_client.chat_postMessage( - channel=msg.chat_id, + channel=target_chat_id, text=self._to_mrkdwn(msg.content) if msg.content else " ", thread_ts=thread_ts_param, ) @@ -131,7 +140,7 @@ class SlackChannel(BaseChannel): for media_path in msg.media or []: try: await self._web_client.files_upload_v2( - channel=msg.chat_id, + channel=target_chat_id, file=media_path, thread_ts=thread_ts_param, ) @@ -141,12 +150,123 @@ class SlackChannel(BaseChannel): # Update reaction emoji when the final (non-progress) response is sent if not (msg.metadata or {}).get("_progress"): event = slack_meta.get("event", {}) - await self._update_react_emoji(msg.chat_id, event.get("ts")) + await self._update_react_emoji(origin_chat_id, event.get("ts")) except Exception as e: logger.error("Error sending Slack message: {}", e) raise + async def _resolve_target_chat_id(self, target: str) -> str: + """Resolve human-friendly Slack targets to concrete IDs when needed.""" + if not self._web_client: + return target + + target = target.strip() + if not target: + return target + + if match := self._SLACK_CHANNEL_REF_RE.fullmatch(target): + return match.group(1) + if match := self._SLACK_USER_REF_RE.fullmatch(target): + return await self._open_dm_for_user(match.group(1)) + if self._SLACK_ID_RE.fullmatch(target): + if target.startswith(("U", "W")): + return await self._open_dm_for_user(target) + return target + + if target.startswith("#"): + return await self._resolve_channel_name(target[1:]) + if target.startswith("@"): + return await self._resolve_user_handle(target[1:]) + + try: + return await self._resolve_channel_name(target) + except ValueError: + return await self._resolve_user_handle(target) + + async def _resolve_channel_name(self, name: str) -> str: + normalized = self._normalize_target_name(name) + if not normalized: + raise ValueError("Slack target channel name is empty") + + cache_key = f"channel:{normalized}" + if cache_key in self._target_cache: + return self._target_cache[cache_key] + + cursor: str | None = None + while True: + response = await self._web_client.conversations_list( + types="public_channel,private_channel", + exclude_archived=True, + limit=200, + cursor=cursor, + ) + for channel in response.get("channels", []): + if self._normalize_target_name(str(channel.get("name") or "")) == normalized: + channel_id = str(channel.get("id") or "") + if channel_id: + self._target_cache[cache_key] = channel_id + return channel_id + cursor = ((response.get("response_metadata") or {}).get("next_cursor") or "").strip() + if not cursor: + break + + raise ValueError( + f"Slack channel '{name}' was not found. Use a joined channel name like " + f"'#general' or a concrete channel ID." + ) + + async def _resolve_user_handle(self, handle: str) -> str: + normalized = self._normalize_target_name(handle) + if not normalized: + raise ValueError("Slack target user handle is empty") + + cache_key = f"user:{normalized}" + if cache_key in self._target_cache: + return self._target_cache[cache_key] + + cursor: str | None = None + while True: + response = await self._web_client.users_list(limit=200, cursor=cursor) + for member in response.get("members", []): + if self._member_matches_handle(member, normalized): + user_id = str(member.get("id") or "") + if not user_id: + continue + dm_id = await self._open_dm_for_user(user_id) + self._target_cache[cache_key] = dm_id + return dm_id + cursor = ((response.get("response_metadata") or {}).get("next_cursor") or "").strip() + if not cursor: + break + + raise ValueError( + f"Slack user '{handle}' was not found. Use '@name' or a concrete DM/channel ID." + ) + + async def _open_dm_for_user(self, user_id: str) -> str: + response = await self._web_client.conversations_open(users=user_id) + channel_id = str(((response.get("channel") or {}).get("id")) or "") + if not channel_id: + raise ValueError(f"Slack DM target for user '{user_id}' could not be opened.") + return channel_id + + @staticmethod + def _normalize_target_name(value: str) -> str: + return value.strip().lstrip("#@").lower() + + @classmethod + def _member_matches_handle(cls, member: dict[str, Any], normalized: str) -> bool: + profile = member.get("profile") or {} + candidates = { + str(member.get("name") or ""), + str(profile.get("display_name") or ""), + str(profile.get("display_name_normalized") or ""), + str(profile.get("real_name") or ""), + str(profile.get("real_name_normalized") or ""), + } + return normalized in {cls._normalize_target_name(candidate) for candidate in candidates if candidate} + async def _on_socket_request( self, client: SocketModeClient, diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 2dde232b..f63704aa 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -520,7 +520,10 @@ class TelegramChannel(BaseChannel): reply_parameters=reply_params, **(thread_kwargs or {}), ) - except Exception as e: + except BadRequest as e: + # Only fall back to plain text on actual HTML parse/format errors. + # Network errors (TimedOut, NetworkError) should propagate immediately + # to avoid doubling connection demand during pool exhaustion. logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._call_with_retry( @@ -567,7 +570,10 @@ class TelegramChannel(BaseChannel): chat_id=int_chat_id, message_id=buf.message_id, text=html, parse_mode="HTML", ) - except Exception as e: + except BadRequest as e: + # Only fall back to plain text on actual HTML parse/format errors. + # Network errors (TimedOut, NetworkError) should propagate immediately + # to avoid doubling connection demand during pool exhaustion. if self._is_not_modified_error(e): logger.debug("Final stream edit already applied for {}", chat_id) self._stream_bufs.pop(chat_id, None) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 2af61d6f..882793ae 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -7,6 +7,7 @@ import email.utils import hmac import http import json +import re import secrets import ssl import time @@ -19,7 +20,8 @@ from pydantic import Field, field_validator, model_validator from websockets.asyncio.server import ServerConnection, serve from websockets.datastructures import Headers from websockets.exceptions import ConnectionClosed -from websockets.http11 import Request as WsRequest, Response +from websockets.http11 import Request as WsRequest +from websockets.http11 import Response from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus @@ -156,6 +158,37 @@ def _parse_inbound_payload(raw: str) -> str | None: return text +# Accept UUIDs and short scoped keys like "unified:default". Keeps the capability +# namespace small enough to rule out path traversal / quote injection tricks. +_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$") + + +def _is_valid_chat_id(value: Any) -> bool: + return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None + + +def _parse_envelope(raw: str) -> dict[str, Any] | None: + """Return a typed envelope dict if the frame is a new-style JSON envelope, else None. + + A frame qualifies when it parses as a JSON object with a string ``type`` field. + Legacy frames (plain text, or ``{"content": ...}`` without ``type``) return None; + callers should fall back to :func:`_parse_inbound_payload` for those. + """ + text = raw.strip() + if not text.startswith("{"): + return None + try: + data = json.loads(text) + except json.JSONDecodeError: + return None + if not isinstance(data, dict): + return None + t = data.get("type") + if not isinstance(t, str): + return None + return data + + def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool: """Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``.""" if not configured_secret: @@ -181,11 +214,47 @@ class WebSocketChannel(BaseChannel): config = WebSocketConfig.model_validate(config) super().__init__(config, bus) self.config: WebSocketConfig = config - self._connections: dict[str, Any] = {} + # chat_id -> connections subscribed to it (fan-out target). + self._subs: dict[str, set[Any]] = {} + # connection -> chat_ids it is subscribed to (O(1) cleanup on disconnect). + self._conn_chats: dict[Any, set[str]] = {} + # connection -> default chat_id for legacy frames that omit routing. + self._conn_default: dict[Any, str] = {} self._issued_tokens: dict[str, float] = {} self._stop_event: asyncio.Event | None = None self._server_task: asyncio.Task[None] | None = None + # -- Subscription bookkeeping ------------------------------------------- + + def _attach(self, connection: Any, chat_id: str) -> None: + """Idempotently subscribe *connection* to *chat_id*.""" + self._subs.setdefault(chat_id, set()).add(connection) + self._conn_chats.setdefault(connection, set()).add(chat_id) + + def _cleanup_connection(self, connection: Any) -> None: + """Remove *connection* from every subscription set; safe to call multiple times.""" + chat_ids = self._conn_chats.pop(connection, set()) + for cid in chat_ids: + subs = self._subs.get(cid) + if subs is None: + continue + subs.discard(connection) + if not subs: + self._subs.pop(cid, None) + self._conn_default.pop(connection, None) + + async def _send_event(self, connection: Any, event: str, **fields: Any) -> None: + """Send a control event (attached, error, ...) to a single connection.""" + payload: dict[str, Any] = {"event": event} + payload.update(fields) + raw = json.dumps(payload, ensure_ascii=False) + try: + await connection.send(raw) + except ConnectionClosed: + self._cleanup_connection(connection) + except Exception as e: + logger.warning("websocket: failed to send {} event: {}", event, e) + @classmethod def default_config(cls) -> dict[str, Any]: return WebSocketConfig().model_dump(by_alias=True) @@ -353,21 +422,22 @@ class WebSocketChannel(BaseChannel): logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id)) client_id = client_id[:128] - chat_id = str(uuid.uuid4()) + default_chat_id = str(uuid.uuid4()) try: await connection.send( json.dumps( { "event": "ready", - "chat_id": chat_id, + "chat_id": default_chat_id, "client_id": client_id, }, ensure_ascii=False, ) ) # Register only after ready is successfully sent to avoid out-of-order sends - self._connections[chat_id] = connection + self._conn_default[connection] = default_chat_id + self._attach(connection, default_chat_id) async for raw in connection: if isinstance(raw, bytes): @@ -376,19 +446,66 @@ class WebSocketChannel(BaseChannel): except UnicodeDecodeError: logger.warning("websocket: ignoring non-utf8 binary frame") continue + + envelope = _parse_envelope(raw) + if envelope is not None: + await self._dispatch_envelope(connection, client_id, envelope) + continue + content = _parse_inbound_payload(raw) if content is None: continue await self._handle_message( sender_id=client_id, - chat_id=chat_id, + chat_id=default_chat_id, content=content, metadata={"remote": getattr(connection, "remote_address", None)}, ) except Exception as e: logger.debug("websocket connection ended: {}", e) finally: - self._connections.pop(chat_id, None) + self._cleanup_connection(connection) + + async def _dispatch_envelope( + self, + connection: Any, + client_id: str, + envelope: dict[str, Any], + ) -> None: + """Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``).""" + t = envelope.get("type") + if t == "new_chat": + new_id = str(uuid.uuid4()) + self._attach(connection, new_id) + await self._send_event(connection, "attached", chat_id=new_id) + return + if t == "attach": + cid = envelope.get("chat_id") + if not _is_valid_chat_id(cid): + await self._send_event(connection, "error", detail="invalid chat_id") + return + self._attach(connection, cid) + await self._send_event(connection, "attached", chat_id=cid) + return + if t == "message": + cid = envelope.get("chat_id") + content = envelope.get("content") + if not _is_valid_chat_id(cid): + await self._send_event(connection, "error", detail="invalid chat_id") + return + if not isinstance(content, str) or not content.strip(): + await self._send_event(connection, "error", detail="missing content") + return + # Auto-attach on first use so clients can one-shot without a separate attach. + self._attach(connection, cid) + await self._handle_message( + sender_id=client_id, + chat_id=cid, + content=content, + metadata={"remote": getattr(connection, "remote_address", None)}, + ) + return + await self._send_event(connection, "error", detail=f"unknown type: {t!r}") async def stop(self) -> None: if not self._running: @@ -402,30 +519,31 @@ class WebSocketChannel(BaseChannel): except Exception as e: logger.warning("websocket: server task error during shutdown: {}", e) self._server_task = None - self._connections.clear() + self._subs.clear() + self._conn_chats.clear() + self._conn_default.clear() self._issued_tokens.clear() - async def _safe_send(self, chat_id: str, raw: str, *, label: str = "") -> None: - """Send a raw frame, cleaning up dead connections on ConnectionClosed.""" - connection = self._connections.get(chat_id) - if connection is None: - return + async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None: + """Send a raw frame to one connection, cleaning up on ConnectionClosed.""" try: await connection.send(raw) except ConnectionClosed: - self._connections.pop(chat_id, None) - logger.warning("websocket{}connection gone for chat_id={}", label, chat_id) + self._cleanup_connection(connection) + logger.warning("websocket{}connection gone", label) except Exception as e: logger.error("websocket{}send failed: {}", label, e) raise async def send(self, msg: OutboundMessage) -> None: - connection = self._connections.get(msg.chat_id) - if connection is None: - logger.warning("websocket: no active connection for chat_id={}", msg.chat_id) + # Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe. + conns = list(self._subs.get(msg.chat_id, ())) + if not conns: + logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id) return payload: dict[str, Any] = { "event": "message", + "chat_id": msg.chat_id, "text": msg.content, } if msg.media: @@ -433,7 +551,8 @@ class WebSocketChannel(BaseChannel): if msg.reply_to: payload["reply_to"] = msg.reply_to raw = json.dumps(payload, ensure_ascii=False) - await self._safe_send(msg.chat_id, raw, label=" ") + for connection in conns: + await self._safe_send_to(connection, raw, label=" ") async def send_delta( self, @@ -441,17 +560,20 @@ class WebSocketChannel(BaseChannel): delta: str, metadata: dict[str, Any] | None = None, ) -> None: - if self._connections.get(chat_id) is None: + conns = list(self._subs.get(chat_id, ())) + if not conns: return meta = metadata or {} if meta.get("_stream_end"): - body: dict[str, Any] = {"event": "stream_end"} + body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id} else: body = { "event": "delta", + "chat_id": chat_id, "text": delta, } if meta.get("_stream_id") is not None: body["stream_id"] = meta["_stream_id"] raw = json.dumps(body, ensure_ascii=False) - await self._safe_send(chat_id, raw, label=" stream ") + for connection in conns: + await self._safe_send_to(connection, raw, label=" stream ") diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py index a7d7f1fe..69bdf3f0 100644 --- a/nanobot/channels/wecom.py +++ b/nanobot/channels/wecom.py @@ -302,13 +302,22 @@ class WecomChannel(BaseChannel): elif msg_type == "mixed": # Mixed content contains multiple message items - msg_items = body.get("mixed", {}).get("item", []) + msg_items = body.get("mixed", {}).get("msg_item", []) for item in msg_items: - item_type = item.get("type", "") + item_type = item.get("msgtype", "") if item_type == "text": text = item.get("text", {}).get("content", "") if text: content_parts.append(text) + elif item_type == "image": + file_url = item.get("image", {}).get("url", "") + aes_key = item.get("image", {}).get("aeskey", "") + if file_url and aes_key: + file_path = await self._download_and_save_media(file_url, aes_key, "image") + if file_path: + filename = os.path.basename(file_path) + content_parts.append(f"[image: {filename}]") + media_paths.append(file_path) else: content_parts.append(MSG_TYPE_MAP.get(item_type, f"[{item_type}]")) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 3f87e220..fbe84bcf 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -985,7 +985,43 @@ class WeixinChannel(BaseChannel): for media_path in (msg.media or []): try: await self._send_media_file(msg.chat_id, media_path, ctx_token) + except (httpx.TimeoutException, httpx.TransportError) as net_err: + # Network/transport errors: do NOT fall back to text — + # the text send would also likely fail, and the outer + # except will re-raise so ChannelManager retries properly. + logger.error( + "Network error sending WeChat media {}: {}", + media_path, + net_err, + ) + raise + except httpx.HTTPStatusError as http_err: + status_code = ( + http_err.response.status_code + if http_err.response is not None + else 0 + ) + if status_code >= 500: + # Server-side / retryable HTTP error — same as network. + logger.error( + "Server error ({} {}) sending WeChat media {}: {}", + status_code, + http_err.response.reason_phrase + if http_err.response is not None + else "", + media_path, + http_err, + ) + raise + # 4xx client errors are NOT retryable — fall back to text. + filename = Path(media_path).name + logger.error("Failed to send WeChat media {}: {}", media_path, http_err) + await self._send_text( + msg.chat_id, f"[Failed to send: {filename}]", ctx_token, + ) except Exception as e: + # Non-network errors (format, file-not-found, etc.): + # notify the user via text fallback. filename = Path(media_path).name logger.error("Failed to send WeChat media {}: {}", media_path, e) # Notify user about failure via text diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 48400bbd..5f043050 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -593,6 +593,7 @@ def serve( unified_session=runtime_config.agents.defaults.unified_session, disabled_skills=runtime_config.agents.defaults.disabled_skills, session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes, + tools_config=runtime_config.tools, ) model_name = runtime_config.agents.defaults.model @@ -687,6 +688,7 @@ def gateway( unified_session=config.agents.defaults.unified_session, disabled_skills=config.agents.defaults.disabled_skills, session_ttl_minutes=config.agents.defaults.session_ttl_minutes, + tools_config=config.tools, ) # Set cron callback (needs agent) @@ -729,7 +731,7 @@ def gateway( response = resp.content if resp else "" message_tool = agent.tools.get("message") - if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: + if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: return response if job.payload.deliver and job.payload.to and response: @@ -821,12 +823,55 @@ def gateway( console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s") + async def _health_server(host: str, health_port: int): + """Lightweight HTTP health endpoint on the gateway port.""" + import json as _json + + async def handle(reader, writer): + try: + data = await asyncio.wait_for(reader.read(4096), timeout=5) + except (asyncio.TimeoutError, ConnectionError): + writer.close() + return + + request_line = data.split(b"\r\n", 1)[0].decode("utf-8", errors="replace") + method, path = "", "" + parts = request_line.split(" ") + if len(parts) >= 2: + method, path = parts[0], parts[1] + + if method == "GET" and path == "/health": + body = _json.dumps({"status": "ok"}) + resp = ( + f"HTTP/1.0 200 OK\r\n" + f"Content-Type: application/json\r\n" + f"Content-Length: {len(body)}\r\n" + f"\r\n{body}" + ) + else: + body = "Not Found" + resp = ( + f"HTTP/1.0 404 Not Found\r\n" + f"Content-Type: text/plain\r\n" + f"Content-Length: {len(body)}\r\n" + f"\r\n{body}" + ) + + writer.write(resp.encode()) + await writer.drain() + writer.close() + + server = await asyncio.start_server(handle, host, health_port) + console.print(f"[green]✓[/green] Health endpoint: http://{host}:{health_port}/health") + async with server: + await server.serve_forever() # Register Dream system job (always-on, idempotent on restart) dream_cfg = config.agents.defaults.dream if dream_cfg.model_override: agent.dream.model = dream_cfg.model_override agent.dream.max_batch_size = dream_cfg.max_batch_size agent.dream.max_iterations = dream_cfg.max_iterations + agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages from nanobot.cron.types import CronJob, CronPayload cron.register_system_job(CronJob( id="dream", @@ -843,6 +888,7 @@ def gateway( await asyncio.gather( agent.run(), channels.start_all(), + _health_server(config.gateway.host, port), ) except KeyboardInterrupt: console.print("\nShutting down...") @@ -921,6 +967,7 @@ def agent( unified_session=config.agents.defaults.unified_session, disabled_skills=config.agents.defaults.disabled_skills, session_ttl_minutes=config.agents.defaults.session_ttl_minutes, + tools_config=config.tools, ) restart_notice = consume_restart_notice_from_env() if restart_notice and should_show_cli_restart_notice(restart_notice, session_id): @@ -964,7 +1011,7 @@ def agent( # Interactive mode — route through bus like other channels from nanobot.bus.events import InboundMessage _init_prompt_session() - console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") + console.print(f"{__logo__} Interactive mode [bold blue]({config.agents.defaults.model})[/bold blue] — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n") if ":" in session_id: cli_channel, cli_chat_id = session_id.split(":", 1) diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 4e3b6e56..4c570089 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -4,7 +4,7 @@ import json import types from dataclasses import dataclass from functools import lru_cache -from typing import Any, NamedTuple, get_args, get_origin +from typing import Any, Literal, NamedTuple, get_args, get_origin try: import questionary @@ -202,6 +202,8 @@ def _get_field_type_info(field_info) -> FieldTypeInfo: return FieldTypeInfo(name, None) if isinstance(annotation, type) and issubclass(annotation, BaseModel): return FieldTypeInfo("model", annotation) + if origin is Literal: + return FieldTypeInfo("literal", list(args)) return FieldTypeInfo("str", None) @@ -264,7 +266,12 @@ def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str: if isinstance(value, list): return ", ".join(str(v) for v in value) if isinstance(value, dict): - return json.dumps(value) + # Handle dicts containing BaseModel instances + parts = [] + for k, v in value.items(): + formatted = _format_value(v, rich=False, field_name=str(k)) + parts.append(f"{k}: {formatted}") + return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]") return str(value) @@ -279,6 +286,63 @@ def _format_value_for_input(value: Any, field_type: str) -> str: return str(value) +def _validate_field_constraint(value: Any, field_info) -> str | None: + """Validate a value against Pydantic Field constraints. + + Returns an error message string if validation fails, None if valid. + Uses attribute-based detection to handle Pydantic v2 internal types. + """ + if field_info is None or not hasattr(field_info, "metadata"): + return None + + for m in field_info.metadata: + if hasattr(m, "ge") and isinstance(value, (int, float)): + if value < m.ge: + return f"Value must be >= {m.ge}" + if hasattr(m, "gt") and isinstance(value, (int, float)): + if value <= m.gt: + return f"Value must be > {m.gt}" + if hasattr(m, "le") and isinstance(value, (int, float)): + if value > m.le: + return f"Value must be <= {m.le}" + if hasattr(m, "lt") and isinstance(value, (int, float)): + if value >= m.lt: + return f"Value must be < {m.lt}" + if hasattr(m, "min_length") and hasattr(value, "__len__"): + if len(value) < m.min_length: + return f"Length must be >= {m.min_length}" + if hasattr(m, "max_length") and hasattr(value, "__len__"): + if len(value) > m.max_length: + return f"Length must be <= {m.max_length}" + + return None + + +def _get_constraint_hint(field_info) -> str: + """Derive a human-readable constraint hint from field metadata. + + Returns a string like "(0-10)" or "(>= 0)" to append to field display names. + """ + if field_info is None or not hasattr(field_info, "metadata"): + return "" + + ge_val = None + le_val = None + for m in field_info.metadata: + if hasattr(m, "ge"): + ge_val = m.ge + if hasattr(m, "le"): + le_val = m.le + + if ge_val is not None and le_val is not None: + return f" ({ge_val}-{le_val})" + if ge_val is not None: + return f" (>= {ge_val})" + if le_val is not None: + return f" (<= {le_val})" + return "" + + # --- Rich UI Components --- @@ -333,7 +397,7 @@ def _input_bool(display_name: str, current: bool | None) -> bool | None: ).ask() -def _input_text(display_name: str, current: Any, field_type: str) -> Any: +def _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any: """Get text input and parse based on field type.""" default = _format_value_for_input(current, field_type) @@ -344,16 +408,28 @@ def _input_text(display_name: str, current: Any, field_type: str) -> Any: if field_type == "int": try: - return int(value) + parsed = int(value) except ValueError: console.print("[yellow]! Invalid number format, value not saved[/yellow]") return None + if field_info: + error = _validate_field_constraint(parsed, field_info) + if error: + console.print(f"[yellow]! {error}, value not saved[/yellow]") + return None + return parsed elif field_type == "float": try: - return float(value) + parsed = float(value) except ValueError: console.print("[yellow]! Invalid number format, value not saved[/yellow]") return None + if field_info: + error = _validate_field_constraint(parsed, field_info) + if error: + console.print(f"[yellow]! {error}, value not saved[/yellow]") + return None + return parsed elif field_type == "list": return [v.strip() for v in value.split(",") if v.strip()] elif field_type == "dict": @@ -367,7 +443,7 @@ def _input_text(display_name: str, current: Any, field_type: str) -> Any: def _input_with_existing( - display_name: str, current: Any, field_type: str + display_name: str, current: Any, field_type: str, field_info=None ) -> Any: """Handle input with 'keep existing' option for non-empty values.""" has_existing = current is not None and current != "" and current != {} and current != [] @@ -381,7 +457,7 @@ def _input_with_existing( if choice == "Keep existing value" or choice is None: return None - return _input_text(display_name, current, field_type) + return _input_text(display_name, current, field_type, field_info=field_info) # --- Pydantic Model Configuration --- @@ -568,7 +644,7 @@ def _configure_pydantic_model( field_name, field_info = fields[field_idx] current_value = getattr(working_model, field_name, None) ftype = _get_field_type_info(field_info) - field_display = _get_field_display_name(field_name, field_info) + field_display = _get_field_display_name(field_name, field_info) + _get_constraint_hint(field_info) # Nested Pydantic model - recurse if ftype.type_name == "model": @@ -607,10 +683,19 @@ def _configure_pydantic_model( continue # Generic field input + if ftype.type_name == "literal" and ftype.inner_type: + select_choices = [str(v) for v in ftype.inner_type] + default_choice = str(current_value) if current_value in ftype.inner_type else select_choices[0] + new_value = _select_with_back(field_display, select_choices, default=default_choice) + if new_value is _BACK_PRESSED: + continue + if new_value is not None: + setattr(working_model, field_name, new_value) + continue if ftype.type_name == "bool": new_value = _input_bool(field_display, current_value) else: - new_value = _input_with_existing(field_display, current_value, ftype.type_name) + new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info) if new_value is not None: setattr(working_model, field_name, new_value) @@ -821,18 +906,24 @@ def _configure_channels(config: Config) -> None: _SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = { "Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None), + "Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None), + "API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None), "Gateway": ("Gateway Settings", "Configure server host, port, and heartbeat", None), "Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}), } _SETTINGS_GETTER = { "Agent Settings": lambda c: c.agents.defaults, + "Channel Common": lambda c: c.channels, + "API Server": lambda c: c.api, "Gateway": lambda c: c.gateway, "Tools": lambda c: c.tools, } _SETTINGS_SETTER = { "Agent Settings": lambda c, v: setattr(c.agents, "defaults", v), + "Channel Common": lambda c, v: setattr(c, "channels", v), + "API Server": lambda c, v: setattr(c, "api", v), "Gateway": lambda c, v: setattr(c, "gateway", v), "Tools": lambda c, v: setattr(c, "tools", v), } @@ -915,12 +1006,20 @@ def _show_summary(config: Config) -> None: # Settings sections for title, model in [ ("Agent Settings", config.agents.defaults), + ("Channel Common", config.channels), + ("API Server", config.api), ("Gateway", config.gateway), ("Tools", config.tools), - ("Channel Common", config.channels), ]: _print_summary_panel(_summarize_model(model), title) + _pause() + + +def _pause() -> None: + """Pause for user acknowledgement before clearing the screen.""" + _get_questionary().text("Press Enter to continue...", default="").ask() + # --- Main Entry Point --- @@ -984,7 +1083,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: choices=[ "[P] LLM Provider", "[C] Chat Channel", + "[H] Channel Common", "[A] Agent Settings", + "[I] API Server", "[G] Gateway", "[T] Tools", "[V] View Configuration Summary", @@ -1007,7 +1108,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: _MENU_DISPATCH = { "[P] LLM Provider": lambda: _configure_providers(config), "[C] Chat Channel": lambda: _configure_channels(config), + "[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"), "[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"), + "[I] API Server": lambda: _configure_general_settings(config, "API Server"), "[G] Gateway": lambda: _configure_general_settings(config, "Gateway"), "[T] Tools": lambda: _configure_general_settings(config, "Tools"), "[V] View Configuration Summary": lambda: _show_summary(config), diff --git a/nanobot/cli/stream.py b/nanobot/cli/stream.py index 8151e3dd..9454edac 100644 --- a/nanobot/cli/stream.py +++ b/nanobot/cli/stream.py @@ -102,7 +102,7 @@ class StreamRenderer: self._live = Live(self._render(), console=c, auto_refresh=False) self._live.start() now = time.monotonic() - if "\n" in delta or (now - self._t) > 0.05: + if (now - self._t) > 0.15: self._live.update(self._render()) self._live.refresh() self._t = now diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 94e46320..94ee0646 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -74,6 +74,12 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage: search_usage_text = usage.format() except Exception: pass # Never let usage fetch break /status + active_tasks = loop._active_tasks.get(ctx.key, []) + task_count = sum(1 for t in active_tasks if not t.done()) + try: + task_count += loop.subagents.get_running_count_by_session(ctx.key) + except Exception: + pass return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, @@ -84,6 +90,10 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage: session_msg_count=len(session.get_history(max_messages=0)), context_tokens_estimate=ctx_est, search_usage_text=search_usage_text, + active_task_count=task_count, + max_completion_tokens=getattr( + getattr(loop.provider, "generation", None), "max_tokens", 8192 + ), ), metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 618334c1..4281b931 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -117,4 +117,19 @@ def _migrate_config(data: dict) -> dict: exec_cfg = tools.get("exec", {}) if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools: tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace") + + # Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}. + # The old flat keys shipped in the initial MyTool landing; wrapping them in a + # sub-config keeps `web` / `exec` / `my` symmetric and gives room to grow. + if "myEnabled" in tools or "mySet" in tools: + my_cfg = tools.setdefault("my", {}) + if "myEnabled" in tools and "enable" not in my_cfg: + my_cfg["enable"] = tools.pop("myEnabled") + else: + tools.pop("myEnabled", None) + if "mySet" in tools and "allowSet" not in my_cfg: + my_cfg["allowSet"] = tools.pop("mySet") + else: + tools.pop("mySet", None) + return data diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fb891515..ac48619f 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -43,7 +43,12 @@ class DreamConfig(Base): validation_alias=AliasChoices("modelOverride", "model", "model_override"), ) # Optional Dream-specific model override max_batch_size: int = Field(default=20, ge=1) # Max history entries per run - max_iterations: int = Field(default=10, ge=1) # Max tool calls per Phase 2 + # Bumped from 10 to 15 in #3212 (exp002: +30% dedup, no accuracy loss; >15 plateaus). + max_iterations: int = Field(default=15, ge=1) # Max tool calls per Phase 2 + # Per-line git-blame age annotation in Phase 1 prompt (see #3212). Default + # on — set to False to feed MEMORY.md raw if a specific LLM reacts poorly + # to the `← Nd` suffix or you want deterministic, git-independent prompts. + annotate_line_ages: bool = True def build_schedule(self, timezone: str) -> CronSchedule: """Build the runtime schedule, preferring the legacy cron override if present.""" @@ -96,7 +101,7 @@ class AgentsConfig(Base): class ProviderConfig(Base): """LLM provider configuration.""" - api_key: str = "" + api_key: str | None = None api_base: str | None = None extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) @@ -115,10 +120,12 @@ class ProvidersConfig(Base): dashscope: ProviderConfig = Field(default_factory=ProviderConfig) vllm: ProviderConfig = Field(default_factory=ProviderConfig) ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models + lm_studio: ProviderConfig = Field(default_factory=ProviderConfig) # LM Studio local models ovms: ProviderConfig = Field(default_factory=ProviderConfig) # OpenVINO Model Server (OVMS) gemini: ProviderConfig = Field(default_factory=ProviderConfig) moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) + minimax_anthropic: ProviderConfig = Field(default_factory=ProviderConfig) # MiniMax Anthropic endpoint (thinking) mistral: ProviderConfig = Field(default_factory=ProviderConfig) stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰) xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米) @@ -152,7 +159,7 @@ class ApiConfig(Base): class GatewayConfig(Base): """Gateway/server configuration.""" - host: str = "0.0.0.0" + host: str = "127.0.0.1" # Safer default: local-only bind. port: int = 18790 heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) @@ -198,11 +205,19 @@ class MCPServerConfig(Base): 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__ names; ["*"] = all tools; [] = no tools +class MyToolConfig(Base): + """Self-inspection tool configuration.""" + + enable: bool = True # register the `my` tool (agent runtime state inspection) + allow_set: bool = False # let `my` modify loop state (read-only if False) + + class ToolsConfig(Base): """Tools configuration.""" web: WebToolsConfig = Field(default_factory=WebToolsConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig) + my: MyToolConfig = Field(default_factory=MyToolConfig) restrict_to_workspace: bool = False # restrict all tool access to workspace directory mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 00f6b17e..6312308a 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -104,7 +104,12 @@ class HeartbeatService: model=self.model, ) - if not response.has_tool_calls: + if not response.should_execute_tools: + if response.has_tool_calls: + logger.warning( + "Ignoring heartbeat tool calls under finish_reason='{}'", + response.finish_reason, + ) return "skip", "" args = response.tool_calls[0].arguments diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 44560a58..96102e3d 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -84,6 +84,7 @@ class Nanobot: unified_session=defaults.unified_session, disabled_skills=defaults.disabled_skills, session_ttl_minutes=defaults.session_ttl_minutes, + tools_config=config.tools, ) return cls(loop) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 759d880a..121052ef 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -67,6 +67,14 @@ class LLMResponse: """Check if response contains tool calls.""" return len(self.tool_calls) > 0 + @property + def should_execute_tools(self) -> bool: + """Tools execute only when has_tool_calls AND finish_reason is ``tool_calls`` / ``stop``. + Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220).""" + if not self.has_tool_calls: + return False + return self.finish_reason in ("tool_calls", "stop") + @dataclass(frozen=True) class GenerationSettings: @@ -77,6 +85,9 @@ class GenerationSettings: reasoning_effort: str | None = None +_SYNTHETIC_USER_CONTENT = "(conversation continued)" + + class LLMProvider(ABC): """Base class for LLM providers.""" @@ -409,6 +420,17 @@ class LLMProvider(ABC): recovered["role"] = "user" merged.append(recovered) + # Safety net: ensure the first non-system message is not a bare + # ``assistant`` message. Providers like GLM reject system→assistant + # with error 1214. This can happen when upstream truncation (e.g. + # _snip_history) drops the only user message. Insert a synthetic + # user message to keep the sequence valid. + for i, msg in enumerate(merged): + if msg.get("role") != "system": + if msg.get("role") == "assistant" and not msg.get("tool_calls"): + merged.insert(i, {"role": "user", "content": _SYNTHETIC_USER_CONTENT}) + break + return merged @staticmethod @@ -512,9 +534,9 @@ class LLMProvider(ABC): on_retry_wait: Callable[[str], Awaitable[None]] | None = None, ) -> LLMResponse: """Call chat_stream() with retry on transient provider failures.""" - if max_tokens is self._SENTINEL: + if max_tokens is self._SENTINEL or max_tokens is None: max_tokens = self.generation.max_tokens - if temperature is self._SENTINEL: + if temperature is self._SENTINEL or temperature is None: temperature = self.generation.temperature if reasoning_effort is self._SENTINEL: reasoning_effort = self.generation.reasoning_effort @@ -549,11 +571,14 @@ class LLMProvider(ABC): Parameters default to ``self.generation`` when not explicitly passed, so callers no longer need to thread temperature / max_tokens / - reasoning_effort through every layer. + reasoning_effort through every layer. Explicit ``None`` is also + normalized to the provider's generation defaults so that downstream + ``_build_kwargs`` never sees ``None`` for ``max_tokens`` / ``temperature`` + (which would crash ``max(1, max_tokens)``). """ - if max_tokens is self._SENTINEL: + if max_tokens is self._SENTINEL or max_tokens is None: max_tokens = self.generation.max_tokens - if temperature is self._SENTINEL: + if temperature is self._SENTINEL or temperature is None: temperature = self.generation.temperature if reasoning_effort is self._SENTINEL: reasoning_effort = self.generation.reasoning_effort @@ -718,9 +743,22 @@ class LLMProvider(ABC): identical_error_count, (response.content or "")[:120].lower(), ) + if on_retry_wait: + await on_retry_wait( + f"Persistent retry stopped after {identical_error_count} identical errors." + ) return response if not persistent and attempt > len(delays): + logger.warning( + "LLM request failed after {} retries, giving up: {}", + attempt, + (response.content or "")[:120].lower(), + ) + if on_retry_wait: + await on_retry_wait( + f"Model request failed after {attempt} retries, giving up." + ) break base_delay = delays[min(attempt - 1, len(delays) - 1)] diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 101ee6c3..1a9f295a 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import hashlib import importlib.util import os @@ -49,6 +50,29 @@ _DEFAULT_OPENROUTER_HEADERS = { "X-OpenRouter-Title": "nanobot", "X-OpenRouter-Categories": "cli-agent,personal-agent", } +_KIMI_THINKING_MODELS: frozenset[str] = frozenset({ + "kimi-k2.5", + "k2.6-code-preview", +}) + + +def _is_kimi_thinking_model(model_name: str) -> bool: + """Return True if model_name refers to a Kimi thinking-capable model. + + Supports two forms: + - Exact match: kimi-k2.5 in _KIMI_THINKING_MODELS + - Slug match: moonshotai/kimi-k2.5 -> the part after the last "/" + is checked against _KIMI_THINKING_MODELS + + This covers both the native Moonshot provider (bare slug) and + OpenRouter-style names (``"publisher/slug"``). + """ + name = model_name.lower() + if name in _KIMI_THINKING_MODELS: + return True + if "/" in name and name.rsplit("/", 1)[1] in _KIMI_THINKING_MODELS: + return True + return False def _short_tool_id() -> str: @@ -222,6 +246,24 @@ class OpenAICompatProvider(LLMProvider): return tool_call_id return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9] + @staticmethod + def _normalize_tool_call_arguments(arguments: Any) -> str: + """Force function.arguments into a valid JSON object string.""" + if isinstance(arguments, str): + stripped = arguments.strip() + if not stripped: + return "{}" + try: + parsed = json_repair.loads(stripped) + except Exception: + return "{}" + if isinstance(parsed, dict): + return json.dumps(parsed, ensure_ascii=False) + return "{}" + if isinstance(arguments, dict): + return json.dumps(arguments, ensure_ascii=False) + return "{}" + def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """Strip non-standard keys, normalize tool_call IDs.""" sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS) @@ -241,6 +283,16 @@ class OpenAICompatProvider(LLMProvider): continue tc_clean = dict(tc) tc_clean["id"] = map_id(tc_clean.get("id")) + function = tc_clean.get("function") + if isinstance(function, dict): + function_clean = dict(function) + if "arguments" in function_clean: + function_clean["arguments"] = self._normalize_tool_call_arguments( + function_clean.get("arguments") + ) + else: + function_clean["arguments"] = "{}" + tc_clean["function"] = function_clean normalized.append(tc_clean) clean["tool_calls"] = normalized if clean.get("role") == "assistant": @@ -334,6 +386,16 @@ class OpenAICompatProvider(LLMProvider): if extra: kwargs.setdefault("extra_body", {}).update(extra) + # Model-level thinking injection for Kimi thinking-capable models. + # Strip any provider prefix (e.g. "moonshotai/") before the set lookup + # so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled + # identically to bare names like "kimi-k2.5". + if reasoning_effort is not None and _is_kimi_thinking_model(model_name): + thinking_enabled = reasoning_effort.lower() != "minimal" + kwargs.setdefault("extra_body", {}).update( + {"thinking": {"type": "enabled" if thinking_enabled else "disabled"}} + ) + if tools: kwargs["tools"] = tools kwargs["tool_choice"] = tool_choice or "auto" @@ -799,7 +861,12 @@ class OpenAICompatProvider(LLMProvider): } @staticmethod - def _handle_error(e: Exception) -> LLMResponse: + def _handle_error( + e: Exception, + *, + spec: ProviderSpec | None = None, + api_base: str | None = None, + ) -> LLMResponse: body = ( getattr(e, "doc", None) or getattr(e, "body", None) @@ -807,6 +874,15 @@ class OpenAICompatProvider(LLMProvider): ) body_text = body if isinstance(body, str) else str(body) if body is not None else "" msg = f"Error: {body_text.strip()[:500]}" if body_text.strip() else f"Error calling LLM: {e}" + + text = f"{body_text} {e}".lower() + if spec and spec.is_local and ("502" in text or "connection" in text or "refused" in text): + msg += ( + "\nHint: this is a local model endpoint. Check that the local server is reachable at " + f"{api_base or spec.default_api_base}, and if you are using a proxy/tunnel, make sure it " + "can reach your local Ollama/vLLM service instead of routing localhost through the remote host." + ) + response = getattr(e, "response", None) retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None)) if retry_after is None: @@ -850,7 +926,7 @@ class OpenAICompatProvider(LLMProvider): ) return self._parse(await self._client.chat.completions.create(**kwargs)) except Exception as e: - return self._handle_error(e) + return self._handle_error(e, spec=self._spec, api_base=self.api_base) async def chat_stream( self, @@ -933,7 +1009,7 @@ class OpenAICompatProvider(LLMProvider): error_kind="timeout", ) except Exception as e: - return self._handle_error(e) + return self._handle_error(e, spec=self._spec, api_base=self.api_base) def get_default_model(self) -> str: return self.default_model diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 693d6048..be098731 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -280,6 +280,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( backend="openai_compat", default_api_base="https://api.minimax.io/v1", ), + # MiniMax Anthropic-compatible endpoint: supports thinking mode + ProviderSpec( + name="minimax_anthropic", + keywords=("minimax_anthropic",), + env_key="MINIMAX_API_KEY", + display_name="MiniMax (Anthropic)", + backend="anthropic", + default_api_base="https://api.minimax.io/anthropic", + ), # Mistral AI: OpenAI-compatible API ProviderSpec( name="mistral", @@ -328,6 +337,17 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( detect_by_base_keyword="11434", default_api_base="http://localhost:11434/v1", ), + # LM Studio (local, OpenAI-compatible) + ProviderSpec( + name="lm_studio", + keywords=("lm-studio", "lmstudio", "lm_studio"), + env_key="LM_STUDIO_API_KEY", + display_name="LM Studio", + backend="openai_compat", + is_local=True, + detect_by_base_keyword="1234", + default_api_base="http://localhost:1234/v1", + ), # === OpenVINO Model Server (direct, local, OpenAI-compatible at /v3) === ProviderSpec( name="ovms", diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index aca9693e..617fd3eb 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -10,9 +10,13 @@ from loguru import logger class OpenAITranscriptionProvider: """Voice transcription provider using OpenAI's Whisper API.""" - def __init__(self, api_key: str | None = None): + def __init__(self, api_key: str | None = None, api_base: str | None = None): self.api_key = api_key or os.environ.get("OPENAI_API_KEY") - self.api_url = "https://api.openai.com/v1/audio/transcriptions" + self.api_url = ( + api_base + or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL") + or "https://api.openai.com/v1/audio/transcriptions" + ) async def transcribe(self, file_path: str | Path) -> str: if not self.api_key: @@ -44,9 +48,9 @@ class GroqTranscriptionProvider: Groq offers extremely fast transcription with a generous free tier. """ - def __init__(self, api_key: str | None = None): + def __init__(self, api_key: str | None = None, api_base: str | None = None): self.api_key = api_key or os.environ.get("GROQ_API_KEY") - self.api_url = "https://api.groq.com/openai/v1/audio/transcriptions" + self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions" async def transcribe(self, file_path: str | Path) -> str: """ diff --git a/nanobot/skills/my/SKILL.md b/nanobot/skills/my/SKILL.md new file mode 100644 index 00000000..2c06566d --- /dev/null +++ b/nanobot/skills/my/SKILL.md @@ -0,0 +1,72 @@ +--- +name: my +description: Check and set the agent's own runtime state (model, iterations, context window, token usage, web config). Use when diagnosing why something doesn't work ("why can't you search the web?", "why did you stop?"), checking resource limits before complex tasks, adapting configuration for long or simple tasks, or remembering user preferences across turns. Also use when the user asks what model you are running, how many tokens you've used, or what your settings are. +always: true +--- + +# Self-Awareness + +## How to use + +1. **Identify the situation** from the categories below +2. **Call the my tool** with the appropriate action +3. **If set**, warn the user before changing impactful settings (model, iterations) +4. **For detailed examples**, read [references/examples.md](references/examples.md) + +## When to check + + +**Diagnose before explaining.** When something doesn't work, check your state first. + + + +**Check budget before complex tasks.** Know your limits before committing. + + + +**Recall across turns.** Store preferences in your scratchpad, read them back later. + + +## When to set + + +**Only set when benefit is clear and user is informed.** Warn before changing model. + + +| Situation | Command | +|-----------|---------| +| Large codebase analysis | `my(action="set", key="context_window_tokens", value=131072)` | +| Repetitive simple tasks | `my(action="set", key="model", value="")` | +| Long multi-step task | `my(action="set", key="max_iterations", value=80)` | + +**Tradeoff:** Bias toward stability. Only set when defaults are genuinely insufficient. + +## Anti-patterns + + +**Don't check every turn.** Costs a tool call. Use when you need information, not reflexively. + + + +**Don't store sensitive data.** No API keys, passwords, or tokens in scratchpad. + + + +**Don't set workspace.** Does not update file tool boundaries — won't work. + + +## Constraints + +- All modifications in-memory only — restart resets everything +- Protected params have type/range validation: `max_iterations` (1–100), `context_window_tokens` (4096–1M), `model` (non-empty str) +- If `tools.my.allow_set` is false, check only + +## Related tools + +| Need | Use | Persists? | +|------|-----|-----------| +| Per-session temp state | `my(action="set", key="...", value=...)` | No | +| Long-term facts | Memory skill (`MEMORY.md`, `USER.md`) | Yes | +| Permanent config change | Edit config file | Yes | + +**Rule of thumb:** Tomorrow? Memory. This turn only? My. diff --git a/nanobot/skills/my/references/examples.md b/nanobot/skills/my/references/examples.md new file mode 100644 index 00000000..9f8e8d08 --- /dev/null +++ b/nanobot/skills/my/references/examples.md @@ -0,0 +1,75 @@ +# My Tool — Practical Examples + +Concrete scenarios showing when and how to use the my tool effectively. + +## Diagnosis + +### "Why can't you search the web?" +``` +→ my(action="check", key="web_config.enable") + → False +→ "Web search is disabled. Add web.enable: true to your config to enable it." +``` + +### "Why did you stop?" +``` +→ my(action="check", key="max_iterations") + → 40 +→ my(action="check", key="_last_usage") + → {"prompt_tokens": 62000, "completion_tokens": 3000} +→ "I hit the iteration limit (40). The task was complex. I can ask the user if they want to increase it." +``` + +### "What model are you running?" +``` +→ my(action="check", key="model") + → 'anthropic/claude-sonnet-4-20250514' +``` + +## Adaptive Behavior + +### Large codebase analysis +``` +→ my(action="check") + → context_window_tokens: 65536 +→ my(action="set", key="context_window_tokens", value=131072) + → "Set context_window_tokens = 131072 (was 65536)" +→ "I've expanded my context window to handle this large codebase." +``` + +### Switching to a faster model for repetitive tasks +``` +→ my(action="set", key="model", value="anthropic/claude-haiku-4-5-20251001") + → "Set model = 'anthropic/claude-haiku-4-5-20251001' (was 'anthropic/claude-sonnet-4-20250514')" +→ "Switched to a faster model for these batch tasks." +``` + +## Cross-Turn Memory + +### Remembering user preferences +``` +# Turn 1: user says "keep it brief" +→ my(action="set", key="user_style", value="concise") + → "Set scratchpad.user_style = 'concise'" + +# Turn 3: new topic +→ my(action="check", key="user_style") + → 'concise' + (adjusts response style accordingly) +``` + +### Tracking project context +``` +→ my(action="set", key="active_branch", value="feat/auth") +→ my(action="set", key="test_framework", value="pytest") +→ my(action="set", key="has_docker", value=true) +``` + +## Budget Awareness + +### Token-conscious behavior +``` +→ my(action="check", key="_last_usage") + → {"prompt_tokens": 58000, "completion_tokens": 12000} +→ "I've consumed ~70k tokens. I'll keep my remaining responses focused." +``` diff --git a/nanobot/templates/SOUL.md b/nanobot/templates/SOUL.md index db2de7b0..27b60971 100644 --- a/nanobot/templates/SOUL.md +++ b/nanobot/templates/SOUL.md @@ -2,8 +2,19 @@ I am nanobot 🐈, a personal AI assistant. -I solve problems by doing, not by describing what I would do. -I keep responses short unless depth is asked for. -I say what I know, flag what I don't, and never fake confidence. -I stay friendly and curious — I'd rather ask a good question than guess wrong. -I treat the user's time as the scarcest resource, and their trust as the most valuable. +## Core Principles + +- Solve by doing, not by describing what I would do. +- Keep responses short unless depth is asked for. +- Say what I know, flag what I don't, and never fake confidence. +- Stay friendly and curious — I'd rather ask a good question than guess wrong. +- Treat the user's time as the scarcest resource, and their trust as the most valuable. + +## Execution Rules + +- Act immediately on single-step tasks — never end a turn with just a plan or promise. +- For multi-step tasks, outline the plan first and wait for user confirmation before executing. +- Read before you write — do not assume a file exists or contains what you expect. +- If a tool call fails, diagnose the error and retry with a different approach before reporting failure. +- When information is missing, look it up with tools first. Only ask the user when tools cannot answer. +- After multi-step changes, verify the result (re-read the file, run the test, check the output). diff --git a/nanobot/templates/agent/dream_phase1.md b/nanobot/templates/agent/dream_phase1.md index 3cc19b18..114db38c 100644 --- a/nanobot/templates/agent/dream_phase1.md +++ b/nanobot/templates/agent/dream_phase1.md @@ -1,4 +1,6 @@ -Compare conversation history against current memory files. Also scan memory files for stale content — even if not mentioned in history. +You have TWO equally important tasks: +1. Extract new facts from conversation history +2. Deduplicate existing memory files — find and flag redundant, overlapping, or stale content even if NOT mentioned in history Output one line per finding: [FILE] atomic fact (not already in memory) @@ -12,12 +14,20 @@ Rules: - Corrections: [USER] location is Tokyo, not Osaka - Capture confirmed approaches the user validated -Staleness — flag for [FILE-REMOVE]: -- Time-sensitive data older than 14 days: weather, daily status, one-time meetings, passed events -- Completed one-time tasks: triage, one-time reviews, finished research, resolved incidents -- Resolved tracking: merged/closed PRs, fixed issues, completed migrations -- Detailed incident info after 14 days — reduce to one-line summary -- Superseded: approaches replaced by newer solutions, deprecated dependencies +Deduplication — scan ALL memory files for these redundancy patterns: +- Same fact stated in multiple places (e.g., "communicates in Chinese" in both USER.md and multiple MEMORY.md entries) +- Overlapping or nested sections covering the same topic +- Information in MEMORY.md that is already captured in USER.md or SOUL.md (MEMORY.md should not duplicate permanent-file content) +- Verbose entries that can be condensed without losing information +For each duplicate found, output [FILE-REMOVE] for the less authoritative copy (prefer keeping facts in their canonical location) + +Staleness — MEMORY.md lines may have a ``← Nd`` suffix showing days since last modification: +- SOUL.md and USER.md have no age annotations — they are permanent, only update with corrections +- Age only indicates when content was last touched, not whether it should be removed +- Use content judgment: user habits/preferences/personality traits are permanent regardless of age +- Only prune content that is objectively outdated: passed events, resolved tracking, superseded approaches +- Lines with ``← Nd`` (N>{{ stale_threshold_days }}) deserve closer review but are NOT automatically removable +- When removing: prefer deleting individual items over entire sections Skill discovery — flag [SKILL] when ALL of these are true: - A specific, repeatable workflow appeared 2+ times in the conversation history diff --git a/nanobot/templates/agent/identity.md b/nanobot/templates/agent/identity.md index 74ed7027..31f3d0d2 100644 --- a/nanobot/templates/agent/identity.md +++ b/nanobot/templates/agent/identity.md @@ -1,7 +1,3 @@ -# nanobot 🐈 - -You are nanobot, a helpful AI assistant. - ## Runtime {{ runtime }} @@ -26,14 +22,6 @@ This conversation is via email. Structure with clear sections. Markdown may not Output is rendered in a terminal. Avoid markdown headings and tables. Use plain text with minimal formatting. {% endif %} -## Execution Rules - -- Act, don't narrate. If you can do it with a tool, do it now — never end a turn with just a plan or promise. -- Read before you write. Do not assume a file exists or contains what you expect. -- If a tool call fails, diagnose the error and retry with a different approach before reporting failure. -- When information is missing, look it up with tools first. Only ask the user when tools cannot answer. -- After multi-step changes, verify the result (re-read the file, run the test, check the output). - ## Search & Discovery - Prefer built-in `grep` / `glob` over `exec` for workspace search. diff --git a/nanobot/templates/agent/skills_section.md b/nanobot/templates/agent/skills_section.md index b495c9ef..300c5679 100644 --- a/nanobot/templates/agent/skills_section.md +++ b/nanobot/templates/agent/skills_section.md @@ -1,6 +1,6 @@ # Skills The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. -Skills with available="false" need dependencies installed first - you can try installing them with apt/brew. +Unavailable skills need dependencies installed first — you can try installing them with apt/brew. {{ skills_summary }} diff --git a/nanobot/utils/document.py b/nanobot/utils/document.py new file mode 100644 index 00000000..89ffcca9 --- /dev/null +++ b/nanobot/utils/document.py @@ -0,0 +1,291 @@ +"""Document text extraction utilities for nanobot.""" + +import mimetypes +from pathlib import Path + +from loguru import logger + +from nanobot.utils.helpers import detect_image_mime + +try: + from pypdf import PdfReader +except ImportError: + PdfReader = None # type: ignore + +try: + from docx import Document as DocxDocument +except ImportError: + DocxDocument = None # type: ignore + +try: + from openpyxl import load_workbook +except ImportError: + load_workbook = None # type: ignore + +try: + from pptx import Presentation as PptxPresentation +except ImportError: + PptxPresentation = None # type: ignore + + +# Supported file extensions for text extraction +SUPPORTED_EXTENSIONS: set[str] = { + # Document formats + ".pdf", + ".docx", + ".xlsx", + ".pptx", + # Text formats + ".txt", + ".md", + ".csv", + ".json", + ".xml", + ".html", + ".htm", + ".log", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + # Image formats (for future OCR support) + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", +} + +_MAX_TEXT_LENGTH = 200_000 + + +def extract_text(path: Path) -> str | None: + """Extract text from a file. + + Args: + path: Path to the file. + + Returns: + Extracted text as string, None for unsupported types, + or error string for failures. + """ + if not isinstance(path, Path): + path = Path(path) + + if not path.exists(): + return f"[error: file not found: {path}]" + + ext = path.suffix.lower() + + # Document formats + if ext == ".pdf": + if PdfReader is None: + return "[error: pypdf not installed]" + return _extract_pdf(path) + elif ext == ".docx": + if DocxDocument is None: + return "[error: python-docx not installed]" + return _extract_docx(path) + elif ext == ".xlsx": + if load_workbook is None: + return "[error: openpyxl not installed]" + return _extract_xlsx(path) + elif ext == ".pptx": + if PptxPresentation is None: + return "[error: python-pptx not installed]" + return _extract_pptx(path) + elif _is_text_extension(ext): + return _extract_text_file(path) + elif ext in {".png", ".jpg", ".jpeg", ".gif", ".webp"}: + # Image files - for future OCR support + return f"[image: {path.name}]" + else: + # Unsupported extension + return None + + +def _extract_pdf(path: Path) -> str: + """Extract text from PDF using pypdf.""" + try: + reader = PdfReader(path) + pages: list[str] = [] + for i, page in enumerate(reader.pages, 1): + text = page.extract_text() or "" + pages.append(f"--- Page {i} ---\n{text}") + return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH) + except Exception as e: + logger.error("Failed to extract PDF {}: {}", path, e) + return f"[error: failed to extract PDF: {e!s}]" + + +def _extract_docx(path: Path) -> str: + """Extract text from DOCX using python-docx.""" + try: + doc = DocxDocument(path) + paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()] + return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH) + except Exception as e: + logger.error("Failed to extract DOCX {}: {}", path, e) + return f"[error: failed to extract DOCX: {e!s}]" + + +def _extract_xlsx(path: Path) -> str: + """Extract text from XLSX using openpyxl.""" + try: + wb = load_workbook(path, read_only=True, data_only=True) + sheets: list[str] = [] + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + rows: list[str] = [] + for row in ws.iter_rows(values_only=True): + row_text = "\t".join(str(cell) if cell is not None else "" for cell in row) + if row_text.strip(): + rows.append(row_text) + if rows: + sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows)) + wb.close() + return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH) + except Exception as e: + logger.error("Failed to extract XLSX {}: {}", path, e) + return f"[error: failed to extract XLSX: {e!s}]" + + +def _extract_pptx(path: Path) -> str: + """Extract text from PPTX using python-pptx.""" + try: + prs = PptxPresentation(path) + slides: list[str] = [] + for i, slide in enumerate(prs.slides, 1): + slide_text: list[str] = [] + for shape in slide.shapes: + _collect_pptx_shape_text(shape, slide_text) + if slide_text: + slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text)) + return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH) + except Exception as e: + logger.error("Failed to extract PPTX {}: {}", path, e) + return f"[error: failed to extract PPTX: {e!s}]" + + +def _collect_pptx_shape_text(shape, out: list[str]) -> None: + """Collect text from a PPTX shape, recursing into groups and tables. + + Groups have ``has_text_frame=False`` and must be walked via ``.shapes``; + tables are GraphicFrame objects whose cell text lives under ``.table``. + """ + sub_shapes = getattr(shape, "shapes", None) + if sub_shapes is not None: + for sub in sub_shapes: + _collect_pptx_shape_text(sub, out) + return + + if getattr(shape, "has_table", False): + for row in shape.table.rows: + cells = [cell.text.strip() for cell in row.cells] + line = "\t".join(cell for cell in cells if cell) + if line: + out.append(line) + return + + text = getattr(shape, "text", "") + if text: + out.append(text) + + +def _extract_text_file(path: Path) -> str: + """Extract text from a plain text file.""" + try: + # Try UTF-8 first, then latin-1 fallback + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + content = path.read_text(encoding="latin-1") + return _truncate(content, _MAX_TEXT_LENGTH) + except Exception as e: + logger.error("Failed to read text file {}: {}", path, e) + return f"[error: failed to read file: {e!s}]" + + +def _truncate(text: str, max_length: int) -> str: + """Truncate text with a suffix indicating truncation.""" + if len(text) <= max_length: + return text + return text[:max_length] + f"... (truncated, {len(text)} chars total)" + + +def _is_text_extension(ext: str) -> bool: + """Check if extension is a text format.""" + return ext in { + ".txt", + ".md", + ".csv", + ".json", + ".xml", + ".html", + ".htm", + ".log", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + } + + +# --------------------------------------------------------------------------- +# High-level helper: split media into images + extracted document text +# --------------------------------------------------------------------------- + +_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB + + +def extract_documents( + text: str, + media_paths: list[str], + *, + max_file_size: int = _MAX_EXTRACT_FILE_SIZE, +) -> tuple[str, list[str]]: + """Separate images from documents in *media_paths*. + + Documents (PDF, DOCX, XLSX, PPTX, plain-text, …) have their text + extracted and appended to *text*. Only image paths are kept in the + returned list so that downstream layers only need to handle vision + blocks. + + Files larger than *max_file_size* bytes are skipped with a warning + to avoid unbounded memory / CPU usage. + """ + image_paths: list[str] = [] + doc_texts: list[str] = [] + + for path_str in media_paths: + p = Path(path_str) + if not p.is_file(): + continue + + try: + size = p.stat().st_size + except OSError: + continue + if size > max_file_size: + logger.warning( + "Skipping oversized file for extraction: {} ({:.1f} MB > {} MB limit)", + p.name, size / (1024 * 1024), max_file_size // (1024 * 1024), + ) + continue + + with open(p, "rb") as f: + header = f.read(16) + mime = detect_image_mime(header) or mimetypes.guess_type(path_str)[0] + if mime and mime.startswith("image/"): + image_paths.append(path_str) + else: + extracted = extract_text(p) + if extracted and not extracted.startswith("[error:"): + doc_texts.append(f"[File: {p.name}]\n{extracted}") + + if doc_texts: + text = text + "\n\n" + "\n\n".join(doc_texts) + + return text, image_paths diff --git a/nanobot/utils/evaluator.py b/nanobot/utils/evaluator.py index 90537c3f..fb9e2267 100644 --- a/nanobot/utils/evaluator.py +++ b/nanobot/utils/evaluator.py @@ -68,8 +68,14 @@ async def evaluate_response( temperature=0.0, ) - if not llm_response.has_tool_calls: - logger.warning("evaluate_response: no tool call returned, defaulting to notify") + if not llm_response.should_execute_tools: + if llm_response.has_tool_calls: + logger.warning( + "evaluate_response: ignoring tool calls under finish_reason='{}', defaulting to notify", + llm_response.finish_reason, + ) + else: + logger.warning("evaluate_response: no tool call returned, defaulting to notify") return True args = llm_response.tool_calls[0].arguments diff --git a/nanobot/utils/gitstore.py b/nanobot/utils/gitstore.py index c2f7d237..d9b528c9 100644 --- a/nanobot/utils/gitstore.py +++ b/nanobot/utils/gitstore.py @@ -5,6 +5,7 @@ from __future__ import annotations import io import time from dataclasses import dataclass +from datetime import datetime, timezone from pathlib import Path from loguru import logger @@ -24,6 +25,23 @@ class CommitInfo: return f"{header}\n(no file changes)" +@dataclass +class LineAge: + """Age of a single line based on git blame.""" + + age_days: int # days since last modification + + +def _compute_line_ages(annotated) -> list[LineAge]: + """Convert annotate results to per-line ages.""" + now = datetime.now(tz=timezone.utc).date() + ages: list[LineAge] = [] + for (commit, _tree_entry), _line_bytes in annotated: + dt = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc).date() + ages.append(LineAge(age_days=(now - dt).days)) + return ages + + class GitStore: """Git-backed version control for memory files.""" @@ -46,14 +64,35 @@ class GitStore: if self.is_initialized(): return False + if self._is_inside_git_repo(): + logger.warning( + "Workspace {} is already inside a git repo; " + "skipping nested repo initialization", + self._workspace, + ) + return False + try: from dulwich import porcelain porcelain.init(str(self._workspace)) - # Write .gitignore + # Write .gitignore (merge with existing if present) gitignore = self._workspace / ".gitignore" - gitignore.write_text(self._build_gitignore(), encoding="utf-8") + dream_entries = self._build_gitignore() + if gitignore.exists(): + existing = gitignore.read_text(encoding="utf-8") + existing_lines = set(existing.splitlines()) + new_lines = [ + line + for line in dream_entries.splitlines() + if line not in existing_lines + ] + if new_lines: + merged = existing.rstrip("\n") + "\n" + "\n".join(new_lines) + "\n" + gitignore.write_text(merged, encoding="utf-8") + else: + gitignore.write_text(dream_entries, encoding="utf-8") # Ensure tracked files exist (touch them if missing) so the initial # commit has something to track. @@ -137,6 +176,22 @@ class GitStore: except Exception: return None + def _is_inside_git_repo(self) -> bool: + """Check if self._workspace is already inside a git repository. + + Walks up from self._workspace to the filesystem root, returning True + if any parent directory contains a .git entry. + + Git worktrees and submodules can use a ``.git`` file instead of a + directory, so we must treat either form as "already inside a repo". + """ + current = self._workspace.resolve() + while current != current.parent: + if (current / ".git").exists(): + return True + current = current.parent + return False + def _build_gitignore(self) -> str: """Generate .gitignore content from tracked files.""" dirs: set[str] = set() @@ -191,6 +246,34 @@ class GitStore: logger.warning("Git log failed") return [] + def line_ages(self, file_path: str) -> list[LineAge]: + """Compute the age of each line in a tracked file via git blame. + + Returns one LineAge per line, in order. + Returns an empty list if the repo is not initialized, the file is + empty, or annotation fails. + """ + + if not self.is_initialized(): + return [] + + target = self._workspace / file_path + if not target.exists() or target.stat().st_size == 0: + return [] + + try: + from dulwich import porcelain + + annotated = porcelain.annotate(str(self._workspace), file_path) + except Exception: + logger.warning("Git line_ages annotate failed for {}", file_path) + return [] + + if not annotated: + return [] + + return _compute_line_ages(annotated) + def diff_commits(self, sha1: str, sha2: str) -> str: """Show diff between two commits.""" if not self.is_initialized(): diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 1bfd9f18..6c3849ef 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -400,6 +400,8 @@ def build_status_content( session_msg_count: int, context_tokens_estimate: int, search_usage_text: str | None = None, + active_task_count: int = 0, + max_completion_tokens: int = 8192, ) -> str: """Build a human-readable runtime status snapshot. @@ -418,7 +420,9 @@ def build_status_content( last_out = last_usage.get("completion_tokens", 0) cached = last_usage.get("cached_tokens", 0) ctx_total = max(context_window_tokens, 0) - ctx_pct = int((context_tokens_estimate / ctx_total) * 100) if ctx_total > 0 else 0 + # Budget mirrors Consolidator formula: ctx_window - max_completion - _SAFETY_BUFFER + ctx_budget = max(ctx_total - int(max_completion_tokens) - 1024, 1) + ctx_pct = min(int((context_tokens_estimate / ctx_budget) * 100), 999) if ctx_budget > 0 else 0 ctx_used_str = f"{context_tokens_estimate // 1000}k" if context_tokens_estimate >= 1000 else str(context_tokens_estimate) ctx_total_str = f"{ctx_total // 1000}k" if ctx_total > 0 else "n/a" token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out" @@ -428,9 +432,10 @@ def build_status_content( f"\U0001f408 nanobot v{version}", f"\U0001f9e0 Model: {model}", token_line, - f"\U0001f4da Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}%)", + f"\U0001f4da Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}% of input budget)", f"\U0001f4ac Session: {session_msg_count} messages", f"\u23f1 Uptime: {uptime}", + f"\u26a1 Tasks: {active_task_count} active", ] if search_usage_text: lines.append(search_usage_text) diff --git a/pyproject.toml b/pyproject.toml index b2a25bfa..1e55fed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.5" +version = "0.1.5.post1" description = "A lightweight personal AI assistant framework" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -40,7 +40,7 @@ dependencies = [ "slack-sdk>=3.39.0,<4.0.0", "slackify-markdown>=0.2.0,<1.0.0", "qq-botpy>=1.2.0,<2.0.0", - "python-socks[asyncio]>=2.8.0,<3.0.0", + "python-socks[asyncio]>=2.8.0,<3.0.0; sys_platform != 'win32'", "prompt-toolkit>=3.0.50,<4.0.0", "questionary>=2.0.0,<3.0.0", "mcp>=1.26.0,<2.0.0", @@ -50,6 +50,11 @@ dependencies = [ "tiktoken>=0.12.0,<1.0.0", "jinja2>=3.1.0,<4.0.0", "dulwich>=0.22.0,<1.0.0", + "pyyaml>=6.0,<7.0.0", + "pypdf>=5.0.0,<6.0.0", + "python-docx>=1.1.0,<2.0.0", + "openpyxl>=3.1.0,<4.0.0", + "python-pptx>=1.0.0,<2.0.0", "filelock>=3.25.2", ] @@ -64,9 +69,13 @@ weixin = [ "qrcode[pil]>=8.0", "pycryptodome>=3.20.0", ] +msteams = [ + "PyJWT>=2.0,<3.0", + "cryptography>=41.0", +] matrix = [ - "matrix-nio[e2e]>=0.25.2", + "matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'", "mistune>=3.0.0,<4.0.0", "nh3>=0.2.17,<1.0.0", ] diff --git a/tests/agent/__init__.py b/tests/agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/agent/test_consolidator.py b/tests/agent/test_consolidator.py index 28587e1b..985bc6ad 100644 --- a/tests/agent/test_consolidator.py +++ b/tests/agent/test_consolidator.py @@ -65,6 +65,46 @@ class TestConsolidatorSummarize: assert result is None +class TestConsolidatorArchiveErrorHandling: + """archive() must fall back to raw_archive when the LLM returns an error + response (finish_reason == 'error'), e.g. overloaded / quota exceeded. + See https://github.com/HKUDS/nanobot/issues/3244 + """ + + async def test_archive_falls_back_on_error_finish_reason(self, consolidator, mock_provider, store): + """LLM returning finish_reason='error' should trigger raw_archive, not write error text.""" + mock_provider.chat_with_retry.return_value = MagicMock( + content="Error: {'type': 'error', 'error': {'type': 'overloaded_error', 'message': 'overloaded_error (529)'}}", + finish_reason="error", + ) + messages = [ + {"role": "user", "content": "fix the auth bug"}, + {"role": "assistant", "content": "Done, fixed the race condition."}, + ] + result = await consolidator.archive(messages) + assert result is None + entries = store.read_unprocessed_history(since_cursor=0) + assert len(entries) == 1 + assert "[RAW]" in entries[0]["content"] + assert "Error:" not in entries[0]["content"] + + async def test_archive_preserves_summary_on_success(self, consolidator, mock_provider, store): + """Normal LLM response should still produce a proper summary entry.""" + mock_provider.chat_with_retry.return_value = MagicMock( + content="User fixed a bug in the auth module.", + finish_reason="stop", + ) + messages = [ + {"role": "user", "content": "fix the auth bug"}, + {"role": "assistant", "content": "Done."}, + ] + result = await consolidator.archive(messages) + assert result == "User fixed a bug in the auth module." + entries = store.read_unprocessed_history(since_cursor=0) + assert len(entries) == 1 + assert "[RAW]" not in entries[0]["content"] + + class TestConsolidatorTokenBudget: async def test_prompt_below_threshold_does_not_consolidate(self, consolidator): """No consolidation when tokens are within budget.""" diff --git a/tests/agent/test_context_prompt_cache.py b/tests/agent/test_context_prompt_cache.py index 26f73027..ad132e83 100644 --- a/tests/agent/test_context_prompt_cache.py +++ b/tests/agent/test_context_prompt_cache.py @@ -149,16 +149,39 @@ def test_partial_dream_processing_shows_only_remainder(tmp_path) -> None: def test_execution_rules_in_system_prompt(tmp_path) -> None: - """New execution rules should appear in the system prompt.""" + """Execution rules should appear in the system prompt via default SOUL.md.""" + from nanobot.utils.helpers import sync_workspace_templates + workspace = _make_workspace(tmp_path) + sync_workspace_templates(workspace, silent=True) builder = ContextBuilder(workspace) prompt = builder.build_system_prompt() - assert "Act, don't narrate" in prompt + assert "single-step tasks" in prompt + assert "multi-step tasks" in prompt assert "Read before you write" in prompt assert "verify the result" in prompt +def test_identity_has_no_behavioral_instructions(tmp_path) -> None: + """Identity template should not contain behavioral rules or hardcoded name.""" + workspace = _make_workspace(tmp_path) + builder = ContextBuilder(workspace) + + identity = builder._get_identity(channel=None) + assert "You are nanobot" not in identity + assert "Act, don't narrate" not in identity + assert "Execution Rules" not in identity + + +def test_default_soul_template_contains_execution_rules() -> None: + """Default SOUL.md template must contain execution rules with act/plan layering.""" + soul = (pkg_files("nanobot") / "templates" / "SOUL.md").read_text(encoding="utf-8") + assert "## Execution Rules" in soul + assert "single-step tasks" in soul + assert "multi-step tasks" in soul + + def test_channel_format_hint_telegram(tmp_path) -> None: """Telegram channel should get messaging-app format hint.""" workspace = _make_workspace(tmp_path) @@ -219,3 +242,55 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path for left, right in zip(messages, messages[1:]): assert not (left.get("role") == right.get("role") == "assistant") + + +def test_always_skills_excluded_from_skills_index(tmp_path) -> None: + """Always skills should appear in Active Skills but NOT in the skills index.""" + workspace = _make_workspace(tmp_path) + builder = ContextBuilder(workspace) + + prompt = builder.build_system_prompt() + + # memory skill should be in Active Skills section + assert "# Active Skills" in prompt + assert "### Skill: memory" in prompt + + # memory skill should NOT appear in the skills index + skills_section = prompt.split("# Skills\n", 1) + if len(skills_section) > 1: + index_text = skills_section[1].split("\n\n---")[0] + assert "**memory**" not in index_text + + +def test_template_memory_md_is_skipped(tmp_path) -> None: + """MEMORY.md matching the bundled template should not inject the Memory section.""" + workspace = _make_workspace(tmp_path) + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(workspace, silent=True) + + builder = ContextBuilder(workspace) + prompt = builder.build_system_prompt() + + # The "# Memory\n\n## Long-term Memory" block is produced only by + # build_system_prompt() when MEMORY.md is injected. The memory skill + # also contains "# Memory" but is followed by "## Structure", not + # "## Long-term Memory". + assert "# Memory\n\n## Long-term Memory" not in prompt + assert "This file is automatically updated by nanobot" not in prompt + + +def test_customized_memory_md_is_injected(tmp_path) -> None: + """A Dream-populated MEMORY.md should be injected normally.""" + workspace = _make_workspace(tmp_path) + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(workspace, silent=True) + + (workspace / "memory" / "MEMORY.md").write_text( + "# Long-term Memory\n\nUser prefers dark mode.\n", encoding="utf-8" + ) + + builder = ContextBuilder(workspace) + prompt = builder.build_system_prompt() + + assert "# Memory\n\n## Long-term Memory" in prompt + assert "User prefers dark mode" in prompt diff --git a/tests/agent/test_dream.py b/tests/agent/test_dream.py index eece79ed..cb6c8de7 100644 --- a/tests/agent/test_dream.py +++ b/tests/agent/test_dream.py @@ -2,11 +2,12 @@ import pytest -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from nanobot.agent.memory import Dream, MemoryStore from nanobot.agent.runner import AgentRunResult from nanobot.agent.skills import BUILTIN_SKILLS_DIR +from nanobot.utils.gitstore import LineAge @pytest.fixture @@ -123,3 +124,135 @@ class TestDreamRun: assert "Successfully wrote" in result assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists() + async def test_phase1_prompt_includes_line_age_annotations(self, dream, mock_provider, mock_runner, store): + """Phase 1 prompt should have per-line age suffixes in MEMORY.md when git is available.""" + store.append_history("some event") + mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]") + mock_runner.run = AsyncMock(return_value=_make_run_result()) + + # Init git so line_ages works + store.git.init() + store.git.auto_commit("initial memory state") + + await dream.run() + + # The MEMORY.md section should not crash and should contain the memory content + call_args = mock_provider.chat_with_retry.call_args + user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"] + assert "## Current MEMORY.md" in user_msg + + async def test_phase1_annotates_only_memory_not_soul_or_user(self, dream, mock_provider, mock_runner, store): + """SOUL.md and USER.md should never have age annotations — they are permanent.""" + store.append_history("some event") + mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]") + mock_runner.run = AsyncMock(return_value=_make_run_result()) + + store.git.init() + store.git.auto_commit("initial state") + + await dream.run() + + call_args = mock_provider.chat_with_retry.call_args + user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"] + # The ← suffix should only appear in MEMORY.md section + memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0] + soul_section = user_msg.split("## Current SOUL.md")[1].split("## Current USER.md")[0] + user_section = user_msg.split("## Current USER.md")[1] + # SOUL and USER should not contain age arrows + assert "\u2190" not in soul_section + assert "\u2190" not in user_section + + async def test_phase1_prompt_works_without_git(self, dream, mock_provider, mock_runner, store): + """Phase 1 should work fine even if git is not initialized (no age annotations).""" + store.append_history("some event") + mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]") + mock_runner.run = AsyncMock(return_value=_make_run_result()) + + await dream.run() + + # Should still succeed — just without age annotations + mock_provider.chat_with_retry.assert_called_once() + call_args = mock_provider.chat_with_retry.call_args + user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"] + assert "## Current MEMORY.md" in user_msg + + async def test_phase1_prompt_carries_age_suffix_for_stale_lines( + self, dream, mock_provider, mock_runner, store, + ): + """End-to-end: ages >14d must appear verbatim in the LLM prompt, ages ≤14d must not.""" + # MEMORY.md fixture has 2 non-blank lines ("# Memory" and "- Project X active"). + # Inject four ages to cover threshold boundaries: >14 suffix, ==14 no suffix, <14 no suffix. + store.write_memory("# Memory\n- Project X active\n- fresh item\n- edge case line") + store.append_history("some event") + mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]") + mock_runner.run = AsyncMock(return_value=_make_run_result()) + + fake_ages = [ + LineAge(age_days=30), # "# Memory" → should get ← 30d + LineAge(age_days=20), # "- Project X..." → should get ← 20d + LineAge(age_days=14), # "- fresh item" → ==14, threshold is strictly >14, no suffix + LineAge(age_days=5), # "- edge case..." → no suffix + ] + with patch.object(store.git, "line_ages", return_value=fake_ages): + await dream.run() + + call_args = mock_provider.chat_with_retry.call_args + user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"] + memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0] + assert "\u2190 30d" in memory_section + assert "\u2190 20d" in memory_section + assert "\u2190 14d" not in memory_section + assert "\u2190 5d" not in memory_section + + async def test_phase1_skips_annotation_when_disabled( + self, dream, mock_provider, mock_runner, store, + ): + """`annotate_line_ages=False` must bypass the git lookup entirely and keep MEMORY.md raw.""" + store.append_history("some event") + mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]") + mock_runner.run = AsyncMock(return_value=_make_run_result()) + + dream.annotate_line_ages = False + # line_ages must be bypassed entirely — verify with a spy rather than a + # raising side_effect, because _annotate_with_ages catches Exception + # (which swallows AssertionError) and would hide an accidental call. + with patch.object(store.git, "line_ages") as mock_line_ages: + await dream.run() + mock_line_ages.assert_not_called() + + call_args = mock_provider.chat_with_retry.call_args + user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"] + assert "\u2190" not in user_msg + + async def test_phase1_skips_annotation_on_line_ages_length_mismatch( + self, dream, mock_provider, mock_runner, store, + ): + """If ages length != lines length (dirty working tree), skip annotation instead of mis-tagging.""" + # MEMORY.md has 2 non-blank lines but we hand back only 1 age → mismatch. + store.append_history("some event") + mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]") + mock_runner.run = AsyncMock(return_value=_make_run_result()) + + with patch.object(store.git, "line_ages", return_value=[LineAge(age_days=999)]): + await dream.run() + + call_args = mock_provider.chat_with_retry.call_args + user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"] + memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0] + # No age arrow at all — we refused to annotate rather than tag the wrong line. + assert "\u2190" not in memory_section + + async def test_phase1_prompt_uses_threshold_from_template_var( + self, dream, mock_provider, mock_runner, store, + ): + """System prompt should reference the stale-threshold constant, not a hardcoded 14.""" + store.append_history("some event") + mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]") + mock_runner.run = AsyncMock(return_value=_make_run_result()) + + await dream.run() + + system_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][0]["content"] + # The template renders with stale_threshold_days=14 → LLM must see "N>14" + assert "N>14" in system_msg + diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 8a0b54b8..4f1c1f35 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -1,5 +1,13 @@ +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + from nanobot.agent.context import ContextBuilder from nanobot.agent.loop import AgentLoop +from nanobot.bus.events import InboundMessage +from nanobot.bus.queue import MessageBus from nanobot.session.manager import Session @@ -11,6 +19,12 @@ def _mk_loop() -> AgentLoop: return loop +def _make_full_loop(tmp_path: Path) -> AgentLoop: + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model") + + def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None: loop = _mk_loop() session = Session(key="test:runtime-only") @@ -200,3 +214,365 @@ def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None: assert session.messages[0]["role"] == "assistant" assert session.messages[1]["tool_call_id"] == "call_done" assert session.messages[2]["tool_call_id"] == "call_pending" + + +@pytest.mark.asyncio +async def test_process_message_persists_user_message_before_turn_completes(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] + + msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="persist me") + with pytest.raises(RuntimeError, match="boom"): + await loop._process_message(msg) + + loop.sessions.invalidate("feishu:c1") + persisted = loop.sessions.get_or_create("feishu:c1") + assert [m["role"] for m in persisted.messages] == ["user"] + assert persisted.messages[0]["content"] == "persist me" + assert persisted.metadata.get(AgentLoop._PENDING_USER_TURN_KEY) is True + assert persisted.updated_at >= persisted.created_at + + +@pytest.mark.asyncio +async def test_process_message_does_not_duplicate_early_persisted_user_message(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + loop._run_agent_loop = AsyncMock(return_value=( + "done", + None, + [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "done"}, + ], + "stop", + False, + )) # type: ignore[method-assign] + + result = await loop._process_message( + InboundMessage(channel="feishu", sender_id="u1", chat_id="c2", content="hello") + ) + + assert result is not None + assert result.content == "done" + session = loop.sessions.get_or_create("feishu:c2") + assert [ + {k: v for k, v in m.items() if k in {"role", "content"}} + for m in session.messages + ] == [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "done"}, + ] + assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata + + +@pytest.mark.asyncio +async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + loop.provider.chat_with_retry = AsyncMock(return_value=MagicMock()) # unused because _run_agent_loop is stubbed + + session = loop.sessions.get_or_create("feishu:c3") + session.add_message("user", "old question") + session.metadata[AgentLoop._PENDING_USER_TURN_KEY] = True + loop.sessions.save(session) + + loop._run_agent_loop = AsyncMock(return_value=( + "new answer", + None, + [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "old question"}, + {"role": "assistant", "content": "Error: Task interrupted before a response was generated."}, + {"role": "user", "content": "new question"}, + {"role": "assistant", "content": "new answer"}, + ], + "stop", + False, + )) # type: ignore[method-assign] + + result = await loop._process_message( + InboundMessage(channel="feishu", sender_id="u1", chat_id="c3", content="new question") + ) + + assert result is not None + assert result.content == "new answer" + session = loop.sessions.get_or_create("feishu:c3") + assert [ + {k: v for k, v in m.items() if k in {"role", "content"}} + for m in session.messages + ] == [ + {"role": "user", "content": "old question"}, + {"role": "assistant", "content": "Error: Task interrupted before a response was generated."}, + {"role": "user", "content": "new question"}, + {"role": "assistant", "content": "new answer"}, + ] + assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata + + +@pytest.mark.asyncio +async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -> None: + from nanobot.command.builtin import cmd_stop + from nanobot.command.router import CommandContext + + loop = _make_full_loop(tmp_path) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + checkpoint_saved = asyncio.Event() + + async def interrupted_run_agent_loop(_initial_messages, *, session=None, **_kwargs): + assert session is not None + loop._set_runtime_checkpoint( + session, + { + "assistant_message": { + "role": "assistant", + "content": "working", + "tool_calls": [ + { + "id": "call_done", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + }, + { + "id": "call_pending", + "type": "function", + "function": {"name": "exec", "arguments": "{}"}, + }, + ], + }, + "completed_tool_results": [ + { + "role": "tool", + "tool_call_id": "call_done", + "name": "read_file", + "content": "ok", + } + ], + "pending_tool_calls": [ + { + "id": "call_pending", + "type": "function", + "function": {"name": "exec", "arguments": "{}"}, + } + ], + }, + ) + checkpoint_saved.set() + await asyncio.Event().wait() + + loop._run_agent_loop = interrupted_run_agent_loop # type: ignore[method-assign] + + first_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="keep progress") + task = asyncio.create_task(loop._process_message(first_msg)) + loop._active_tasks[first_msg.session_key] = [task] + await asyncio.wait_for(checkpoint_saved.wait(), timeout=1.0) + + stop_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="/stop") + stop_ctx = CommandContext(msg=stop_msg, session=None, key=stop_msg.session_key, raw="/stop", loop=loop) + stop_result = await cmd_stop(stop_ctx) + + assert "Stopped 1 task" in stop_result.content + assert task.done() + + loop.sessions.invalidate("feishu:c4") + interrupted = loop.sessions.get_or_create("feishu:c4") + assert interrupted.metadata.get(AgentLoop._PENDING_USER_TURN_KEY) is True + assert interrupted.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is not None + + async def resumed_run_agent_loop(initial_messages, **_kwargs): + return ( + "next answer", + None, + [*initial_messages, {"role": "assistant", "content": "next answer"}], + "stop", + False, + ) + + loop._run_agent_loop = resumed_run_agent_loop # type: ignore[method-assign] + result = await loop._process_message( + InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="continue here") + ) + + assert result is not None + assert result.content == "next answer" + + session = loop.sessions.get_or_create("feishu:c4") + assert [ + {k: v for k, v in m.items() if k in {"role", "content", "tool_call_id", "name"}} + for m in session.messages + ] == [ + {"role": "user", "content": "keep progress"}, + {"role": "assistant", "content": "working"}, + {"role": "tool", "tool_call_id": "call_done", "name": "read_file", "content": "ok"}, + { + "role": "tool", + "tool_call_id": "call_pending", + "name": "exec", + "content": "Error: Task interrupted before this tool finished.", + }, + {"role": "user", "content": "continue here"}, + {"role": "assistant", "content": "next answer"}, + ] + assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata + assert AgentLoop._RUNTIME_CHECKPOINT_KEY not in session.metadata + + +@pytest.mark.asyncio +async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + session = loop.sessions.get_or_create("cli:test") + session.add_message("user", "question") + session.add_message("assistant", "working") + loop.sessions.save(session) + + seen: dict[str, list[dict]] = {} + + async def fake_run_agent_loop(initial_messages, **_kwargs): + seen["initial_messages"] = initial_messages + return ( + "done", + [], + [*initial_messages, {"role": "assistant", "content": "done"}], + "stop", + False, + ) + + loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign] + + await loop._process_message( + InboundMessage( + channel="system", + sender_id="subagent", + chat_id="cli:test", + content="subagent result", + metadata={"subagent_task_id": "sub-1"}, + ) + ) + + non_system = [m for m in seen["initial_messages"] if m.get("role") != "system"] + assert [m["content"] for m in non_system[:2]] == ["question", "working"] + assert non_system[2]["content"].count("subagent result") == 1 + assert "Current Time:" in non_system[2]["content"] + + loop.sessions.invalidate("cli:test") + persisted = loop.sessions.get_or_create("cli:test") + assert [ + {k: v for k, v in m.items() if k in {"role", "content", "injected_event", "subagent_task_id"}} + for m in persisted.messages + ] == [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": "working"}, + { + "role": "assistant", + "content": "subagent result", + "injected_event": "subagent_result", + "subagent_task_id": "sub-1", + }, + {"role": "assistant", "content": "done"}, + ] + + +@pytest.mark.asyncio +async def test_multiple_subagent_followups_all_persist_as_standalone_history(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + async def fake_run_agent_loop(initial_messages, **_kwargs): + return ( + "ack", + [], + [*initial_messages, {"role": "assistant", "content": "ack"}], + "stop", + False, + ) + + loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign] + + for idx in range(3): + await loop._process_message( + InboundMessage( + channel="system", + sender_id="subagent", + chat_id="cli:multi", + content=f"subagent result {idx}", + metadata={"subagent_task_id": f"sub-{idx}"}, + ) + ) + + loop.sessions.invalidate("cli:multi") + persisted = loop.sessions.get_or_create("cli:multi") + followups = [m for m in persisted.messages if m.get("injected_event") == "subagent_result"] + assert [m["content"] for m in followups] == [ + "subagent result 0", + "subagent result 1", + "subagent result 2", + ] + + +def test_prompt_merge_does_not_replace_standalone_subagent_history_entry(tmp_path: Path) -> None: + loop = _mk_loop() + session = Session(key="cli:merge") + session.add_message("assistant", "previous assistant") + + inserted = loop._persist_subagent_followup( + session, + InboundMessage( + channel="system", + sender_id="subagent", + chat_id="cli:merge", + content="subagent result", + metadata={"subagent_task_id": "sub-1"}, + ), + ) + + assert inserted is True + + builder = ContextBuilder(tmp_path) + projected = builder.build_messages( + history=session.get_history(max_messages=0), + current_message="", + current_role="assistant", + channel="cli", + chat_id="merge", + ) + + non_system = [m for m in projected if m.get("role") != "system"] + assert len(non_system) == 2 + assert "subagent result" in non_system[-1]["content"] + assert session.messages[-1]["content"] == "subagent result" + assert session.messages[-1]["injected_event"] == "subagent_result" + + +def test_subagent_followup_dedupes_by_task_id() -> None: + loop = _mk_loop() + session = Session(key="cli:dedupe") + msg = InboundMessage( + channel="system", + sender_id="subagent", + chat_id="cli:dedupe", + content="subagent result", + metadata={"subagent_task_id": "sub-1"}, + ) + + assert loop._persist_subagent_followup(session, msg) is True + assert loop._persist_subagent_followup(session, msg) is False + assert len(session.messages) == 1 + + +def test_subagent_followup_skips_empty_content() -> None: + loop = _mk_loop() + session = Session(key="cli:empty") + msg = InboundMessage( + channel="system", + sender_id="subagent", + chat_id="cli:empty", + content="", + metadata={"subagent_task_id": "sub-empty"}, + ) + + assert loop._persist_subagent_followup(session, msg) is False + assert session.messages == [] diff --git a/tests/agent/test_memory_store.py b/tests/agent/test_memory_store.py index efe7d198..7bb23fc6 100644 --- a/tests/agent/test_memory_store.py +++ b/tests/agent/test_memory_store.py @@ -79,6 +79,29 @@ class TestHistoryWithCursor: entries = store.read_unprocessed_history(since_cursor=0) assert len(entries) == 2 + def test_read_unprocessed_skips_entries_without_cursor(self, store): + """Regression: entries missing the cursor key should be silently skipped.""" + store.history_file.write_text( + '{"timestamp": "2026-04-01 10:00", "content": "no cursor"}\n' + '{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "valid"}\n' + '{"cursor": 3, "timestamp": "2026-04-01 10:02", "content": "also valid"}\n', + encoding="utf-8", + ) + entries = store.read_unprocessed_history(since_cursor=0) + assert [e["cursor"] for e in entries] == [2, 3] + + def test_next_cursor_falls_back_when_last_entry_has_no_cursor(self, store): + """Regression: _next_cursor should not KeyError on entries without cursor.""" + store.history_file.write_text( + '{"timestamp": "2026-04-01 10:01", "content": "no cursor"}\n', + encoding="utf-8", + ) + # Delete .cursor file so _next_cursor falls back to reading JSONL + store._cursor_file.unlink(missing_ok=True) + # Last entry has no cursor — should safely return 1, not KeyError + cursor = store.append_history("new event") + assert cursor == 1 + def test_compact_history_drops_oldest(self, tmp_path): store = MemoryStore(tmp_path, max_history_entries=2) store.append_history("event 1") diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index 43999f93..32927cfc 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -22,6 +22,9 @@ from nanobot.cli.onboard import ( _format_value, _get_field_display_name, _get_field_type_info, + _get_constraint_hint, + _input_text, + _validate_field_constraint, run_onboard, ) from nanobot.config.schema import Config @@ -207,6 +210,25 @@ class TestGetFieldTypeInfo: assert type_name == "str" assert inner is None + def test_literal_type_returns_literal_with_choices(self): + """Literal["a", "b"] should return ("literal", ["a", "b"]).""" + from typing import Literal + + class Model(BaseModel): + mode: Literal["standard", "persistent"] = "standard" + + type_name, inner = _get_field_type_info(Model.model_fields["mode"]) + assert type_name == "literal" + assert inner == ["standard", "persistent"] + + def test_real_provider_retry_mode_field(self): + """Validate against actual AgentDefaults.provider_retry_mode field.""" + from nanobot.config.schema import AgentDefaults + + type_name, inner = _get_field_type_info(AgentDefaults.model_fields["provider_retry_mode"]) + assert type_name == "literal" + assert inner == ["standard", "persistent"] + class TestGetFieldDisplayName: """Tests for _get_field_display_name human-readable name generation.""" @@ -493,3 +515,448 @@ class TestRunOnboardExitBehavior: assert result.should_save is False assert result.config.model_dump(by_alias=True) == initial_config.model_dump(by_alias=True) + + +class TestValidateFieldConstraint: + """Tests for _validate_field_constraint schema-aware input validation.""" + + def test_returns_none_when_no_constraints(self): + """Fields without constraints should pass validation.""" + from pydantic import BaseModel + + class M(BaseModel): + name: str = "hello" + + field_info = M.model_fields["name"] + from nanobot.cli.onboard import _validate_field_constraint + + assert _validate_field_constraint("anything", field_info) is None + + def test_rejects_value_below_ge_bound(self): + """Value below ge (>=) bound should return error.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + count: int = Field(default=3, ge=0) + + field_info = M.model_fields["count"] + from nanobot.cli.onboard import _validate_field_constraint + + result = _validate_field_constraint(-1, field_info) + assert result is not None + assert "0" in result + + def test_accepts_value_at_ge_bound(self): + """Value exactly at ge (>=) bound should pass.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + count: int = Field(default=3, ge=0) + + field_info = M.model_fields["count"] + from nanobot.cli.onboard import _validate_field_constraint + + assert _validate_field_constraint(0, field_info) is None + + def test_rejects_value_above_le_bound(self): + """Value above le (<=) bound should return error.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + retries: int = Field(default=3, le=10) + + field_info = M.model_fields["retries"] + from nanobot.cli.onboard import _validate_field_constraint + + result = _validate_field_constraint(11, field_info) + assert result is not None + assert "10" in result + + def test_accepts_value_at_le_bound(self): + """Value exactly at le (<=) bound should pass.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + retries: int = Field(default=3, le=10) + + field_info = M.model_fields["retries"] + from nanobot.cli.onboard import _validate_field_constraint + + assert _validate_field_constraint(10, field_info) is None + + def test_combined_ge_and_le_bounds(self): + """Field with both ge and le should validate both.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + retries: int = Field(default=3, ge=0, le=10) + + field_info = M.model_fields["retries"] + from nanobot.cli.onboard import _validate_field_constraint + + assert _validate_field_constraint(5, field_info) is None + assert _validate_field_constraint(-1, field_info) is not None + assert _validate_field_constraint(11, field_info) is not None + + def test_gt_and_lt_bounds(self): + """Strict inequality bounds (gt, lt) should exclude boundary.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + ratio: float = Field(default=0.5, gt=0.0, lt=1.0) + + field_info = M.model_fields["ratio"] + from nanobot.cli.onboard import _validate_field_constraint + + assert _validate_field_constraint(0.5, field_info) is None + assert _validate_field_constraint(0.0, field_info) is not None + assert _validate_field_constraint(1.0, field_info) is not None + + def test_min_length_constraint(self): + """min_length should validate string/list length.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + name: str = Field(default="x", min_length=1) + + field_info = M.model_fields["name"] + from nanobot.cli.onboard import _validate_field_constraint + + assert _validate_field_constraint("a", field_info) is None + assert _validate_field_constraint("", field_info) is not None + + def test_max_length_constraint(self): + """max_length should validate string/list length.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + tag: str = Field(default="x", max_length=5) + + field_info = M.model_fields["tag"] + from nanobot.cli.onboard import _validate_field_constraint + + assert _validate_field_constraint("abc", field_info) is None + assert _validate_field_constraint("abcdef", field_info) is not None + + def test_real_send_max_retries_field(self): + """Validate against the actual ChannelsConfig.send_max_retries field.""" + from nanobot.config.schema import ChannelsConfig + from nanobot.cli.onboard import _validate_field_constraint + + field_info = ChannelsConfig.model_fields["send_max_retries"] + assert _validate_field_constraint(3, field_info) is None + assert _validate_field_constraint(0, field_info) is None + assert _validate_field_constraint(10, field_info) is None + assert _validate_field_constraint(-1, field_info) is not None + assert _validate_field_constraint(11, field_info) is not None + + +class TestGetConstraintHint: + """Tests for _get_constraint_hint field display suffix.""" + + def test_no_constraints_returns_empty(self): + """Fields without constraints should return empty string.""" + from pydantic import BaseModel + + class M(BaseModel): + name: str = "hello" + + field_info = M.model_fields["name"] + assert _get_constraint_hint(field_info) == "" + + def test_ge_le_range(self): + """Field with ge+le should show '(min-max)'.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + retries: int = Field(default=3, ge=0, le=10) + + field_info = M.model_fields["retries"] + hint = _get_constraint_hint(field_info) + assert "0" in hint + assert "10" in hint + + def test_ge_only(self): + """Field with only ge should show '(>= N)'.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + count: int = Field(default=1, ge=0) + + field_info = M.model_fields["count"] + hint = _get_constraint_hint(field_info) + assert "0" in hint + assert ">=" in hint + + def test_le_only(self): + """Field with only le should show '(<= N)'.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + ratio: float = Field(default=1.0, le=100.0) + + field_info = M.model_fields["ratio"] + hint = _get_constraint_hint(field_info) + assert "100" in hint + assert "<=" in hint + + def test_real_send_max_retries_hint(self): + """Actual ChannelsConfig.send_max_retries should show '(0-10)'.""" + from nanobot.config.schema import ChannelsConfig + + field_info = ChannelsConfig.model_fields["send_max_retries"] + hint = _get_constraint_hint(field_info) + assert "0" in hint + assert "10" in hint + + +class TestInputTextWithValidation: + """Tests for _input_text integration with constraint validation.""" + + def test_rejects_out_of_range_int(self, monkeypatch): + """_input_text with field_info should reject values violating ge/le constraints.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + retries: int = Field(default=3, ge=0, le=10) + + field_info = M.model_fields["retries"] + monkeypatch.setattr( + onboard_wizard, + "_get_questionary", + lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "15")), + ) + + result = _input_text("Retries", 3, "int", field_info=field_info) + assert result is None + + def test_accepts_valid_int(self, monkeypatch): + """_input_text with field_info should accept valid constrained values.""" + from pydantic import BaseModel, Field + + class M(BaseModel): + retries: int = Field(default=3, ge=0, le=10) + + field_info = M.model_fields["retries"] + monkeypatch.setattr( + onboard_wizard, + "_get_questionary", + lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "5")), + ) + + result = _input_text("Retries", 3, "int", field_info=field_info) + assert result == 5 + + def test_works_without_field_info(self, monkeypatch): + """_input_text without field_info should work as before (no validation).""" + monkeypatch.setattr( + onboard_wizard, + "_get_questionary", + lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "42")), + ) + + result = _input_text("Count", 0, "int") + assert result == 42 + + +class TestChannelCommonRegistration: + """Tests for Channel Common menu registration.""" + + def test_channel_common_in_settings_sections(self): + """Channel Common should be registered in _SETTINGS_SECTIONS.""" + from nanobot.cli.onboard import _SETTINGS_SECTIONS + + assert "Channel Common" in _SETTINGS_SECTIONS + + def test_channel_common_getter_returns_channels(self): + """Channel Common getter should return config.channels.""" + from nanobot.cli.onboard import _SETTINGS_GETTER + + config = Config() + result = _SETTINGS_GETTER["Channel Common"](config) + assert result is config.channels + + def test_channel_common_setter_writes_channels(self): + """Channel Common setter should update config.channels.""" + from nanobot.cli.onboard import _SETTINGS_SETTER + + config = Config() + original = config.channels + new_channels = original.model_copy(deep=True) + new_channels.send_tool_hints = True + _SETTINGS_SETTER["Channel Common"](config, new_channels) + assert config.channels.send_tool_hints is True + + def test_channel_common_edit_preserves_extras(self): + """Editing Channel Common should not lose per-channel extras.""" + config = Config() + config.channels.feishu = {"enabled": True, "appId": "test123"} + channels = config.channels.model_copy(deep=True) + channels.send_tool_hints = True + config.channels = channels + assert config.channels.send_tool_hints is True + assert config.channels.feishu["appId"] == "test123" + + +class TestApiServerRegistration: + """Tests for API Server menu registration.""" + + def test_api_server_in_settings_sections(self): + """API Server should be registered in _SETTINGS_SECTIONS.""" + from nanobot.cli.onboard import _SETTINGS_SECTIONS + + assert "API Server" in _SETTINGS_SECTIONS + + def test_api_server_getter_returns_api(self): + """API Server getter should return config.api.""" + from nanobot.cli.onboard import _SETTINGS_GETTER + + config = Config() + result = _SETTINGS_GETTER["API Server"](config) + assert result is config.api + + def test_api_server_setter_writes_api(self): + """API Server setter should update config.api.""" + from nanobot.cli.onboard import _SETTINGS_SETTER + + config = Config() + from nanobot.config.schema import ApiConfig + + new_api = ApiConfig(host="0.0.0.0", port=9999) + _SETTINGS_SETTER["API Server"](config, new_api) + assert config.api.host == "0.0.0.0" + assert config.api.port == 9999 + + +class TestMainMenuUpdate: + """Tests for main menu including new Channel Common and API Server items.""" + + def test_main_menu_dispatch_includes_channel_common(self): + """Main menu dispatch should route [H] to Channel Common.""" + from nanobot.cli.onboard import run_onboard + + # We verify by checking the dispatch table is set up correctly + # The menu items are defined inline in run_onboard, so we test + # that _configure_general_settings handles the new sections. + from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER + + assert "Channel Common" in _SETTINGS_SECTIONS + assert "Channel Common" in _SETTINGS_GETTER + assert "Channel Common" in _SETTINGS_SETTER + + def test_main_menu_dispatch_includes_api_server(self): + """Main menu dispatch should route [I] to API Server.""" + from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER + + assert "API Server" in _SETTINGS_SECTIONS + assert "API Server" in _SETTINGS_GETTER + assert "API Server" in _SETTINGS_SETTER + + def test_run_onboard_channel_common_edit(self, monkeypatch): + """run_onboard should handle [H] Channel Common correctly.""" + initial_config = Config() + + responses = iter([ + "[H] Channel Common", + KeyboardInterrupt(), + "[S] Save and Exit", + ]) + + class FakePrompt: + def __init__(self, response): + self.response = response + + def ask(self): + if isinstance(self.response, BaseException): + raise self.response + return self.response + + def fake_select(*_args, **_kwargs): + return FakePrompt(next(responses)) + + def fake_configure_general_settings(config, section): + if section == "Channel Common": + config.channels.send_tool_hints = True + + monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) + monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select)) + monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings) + + result = run_onboard(initial_config=initial_config) + + assert result.should_save is True + assert result.config.channels.send_tool_hints is True + + def test_run_onboard_api_server_edit(self, monkeypatch): + """run_onboard should handle [I] API Server correctly.""" + initial_config = Config() + + responses = iter([ + "[I] API Server", + KeyboardInterrupt(), + "[S] Save and Exit", + ]) + + class FakePrompt: + def __init__(self, response): + self.response = response + + def ask(self): + if isinstance(self.response, BaseException): + raise self.response + return self.response + + def fake_select(*_args, **_kwargs): + return FakePrompt(next(responses)) + + def fake_configure_general_settings(config, section): + if section == "API Server": + config.api.port = 9999 + + monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) + monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select)) + monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings) + + result = run_onboard(initial_config=initial_config) + + assert result.should_save is True + assert result.config.api.port == 9999 + + def test_view_summary_calls_pause(self, monkeypatch): + """[V] View Summary should pause before returning to main menu.""" + initial_config = Config() + pause_called = {"n": 0} + + responses = iter([ + "[V] View Configuration Summary", + "[S] Save and Exit", + ]) + + class FakePrompt: + def __init__(self, response): + self.response = response + + def ask(self): + if isinstance(self.response, BaseException): + raise self.response + return self.response + + def fake_select(*_args, **_kwargs): + return FakePrompt(next(responses)) + + def fake_pause(): + pause_called["n"] += 1 + + monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) + monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select)) + # _pause is called inside _show_summary, so we patch it there + monkeypatch.setattr(onboard_wizard, "_pause", fake_pause) + # Suppress summary output but still call _pause + monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None) + monkeypatch.setattr(onboard_wizard, "_get_provider_names", lambda: {}) + monkeypatch.setattr(onboard_wizard, "_get_channel_names", lambda: {}) + + result = run_onboard(initial_config=initial_config) + + assert result.should_save is True + assert pause_called["n"] == 1 diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index a62457aa..b47db948 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -18,6 +18,16 @@ from nanobot.providers.base import LLMResponse, ToolCallRequest _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars +def _make_injection_callback(queue: asyncio.Queue): + """Return an async callback that drains *queue* into a list of dicts.""" + async def inject_cb(): + items = [] + while not queue.empty(): + items.append(await queue.get()) + return items + return inject_cb + + def _make_loop(tmp_path): from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus @@ -633,10 +643,11 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch trimmed = runner._snip_history(spec, messages) - assert trimmed == [ - {"role": "system", "content": "system"}, - {"role": "assistant", "content": "after tool"}, - ] + # After the fix, the user message is recovered so the sequence is valid + # for providers that require system → user (e.g. GLM error 1214). + assert trimmed[0]["role"] == "system" + non_system = [m for m in trimmed if m["role"] != "system"] + assert non_system[0]["role"] == "user", f"Expected user after system, got {non_system[0]['role']}" @pytest.mark.asyncio @@ -679,11 +690,20 @@ async def test_runner_keeps_going_when_tool_result_persistence_fails(): class _DelayTool(Tool): - def __init__(self, name: str, *, delay: float, read_only: bool, shared_events: list[str]): + def __init__( + self, + name: str, + *, + delay: float, + read_only: bool, + shared_events: list[str], + exclusive: bool = False, + ): self._name = name self._delay = delay self._read_only = read_only self._shared_events = shared_events + self._exclusive = exclusive @property def name(self) -> str: @@ -701,6 +721,10 @@ class _DelayTool(Tool): def read_only(self) -> bool: return self._read_only + @property + def exclusive(self) -> bool: + return self._exclusive + async def execute(self, **kwargs): self._shared_events.append(f"start:{self._name}") await asyncio.sleep(self._delay) @@ -746,6 +770,48 @@ async def test_runner_batches_read_only_tools_before_exclusive_work(): assert shared_events[-2:] == ["start:write_a", "end:write_a"] +@pytest.mark.asyncio +async def test_runner_does_not_batch_exclusive_read_only_tools(): + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + tools = ToolRegistry() + shared_events: list[str] = [] + read_a = _DelayTool("read_a", delay=0.03, read_only=True, shared_events=shared_events) + read_b = _DelayTool("read_b", delay=0.03, read_only=True, shared_events=shared_events) + ddg_like = _DelayTool( + "ddg_like", + delay=0.01, + read_only=True, + shared_events=shared_events, + exclusive=True, + ) + tools.register(read_a) + tools.register(ddg_like) + tools.register(read_b) + + runner = AgentRunner(MagicMock()) + await runner._execute_tools( + AgentRunSpec( + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + concurrent_tools=True, + ), + [ + ToolCallRequest(id="ro1", name="read_a", arguments={}), + ToolCallRequest(id="ddg1", name="ddg_like", arguments={}), + ToolCallRequest(id="ro2", name="read_b", arguments={}), + ], + {}, + ) + + assert shared_events[0] == "start:read_a" + assert shared_events.index("end:read_a") < shared_events.index("start:ddg_like") + assert shared_events.index("end:ddg_like") < shared_events.index("start:read_b") + + @pytest.mark.asyncio async def test_runner_blocks_repeated_external_fetches(): from nanobot.agent.runner import AgentRunSpec, AgentRunner @@ -1008,7 +1074,7 @@ async def test_runner_tool_error_sets_final_content(): @pytest.mark.asyncio async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, monkeypatch): - from nanobot.agent.subagent import SubagentManager + from nanobot.agent.subagent import SubagentManager, SubagentStatus from nanobot.bus.queue import MessageBus bus = MessageBus() @@ -1031,7 +1097,8 @@ async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, mon monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute) - await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}) + status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()) + await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status) mgr._announce_result.assert_awaited_once() args = mgr._announce_result.await_args.args @@ -1888,12 +1955,7 @@ async def test_checkpoint1_injects_after_tool_execution(): tools.execute = AsyncMock(return_value="file content") injection_queue = asyncio.Queue() - - async def inject_cb(): - items = [] - while not injection_queue.empty(): - items.append(await injection_queue.get()) - return items + inject_cb = _make_injection_callback(injection_queue) # Put a follow-up message in the queue before the run starts await injection_queue.put( @@ -1951,12 +2013,7 @@ async def test_checkpoint2_injects_after_final_response_with_resuming_stream(): tools.get_definitions.return_value = [] injection_queue = asyncio.Queue() - - async def inject_cb(): - items = [] - while not injection_queue.empty(): - items.append(await injection_queue.get()) - return items + inject_cb = _make_injection_callback(injection_queue) # Inject a follow-up that arrives during the first response await injection_queue.put( @@ -2005,12 +2062,7 @@ async def test_checkpoint2_preserves_final_response_in_history_before_followup() tools.get_definitions.return_value = [] injection_queue = asyncio.Queue() - - async def inject_cb(): - items = [] - while not injection_queue.empty(): - items.append(await injection_queue.get()) - return items + inject_cb = _make_injection_callback(injection_queue) await injection_queue.put( InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question") @@ -2410,3 +2462,501 @@ async def test_dispatch_republishes_leftover_queue_messages(tmp_path): contents = [m.content for m in msgs] assert "leftover-1" in contents assert "leftover-2" in contents + + +@pytest.mark.asyncio +async def test_drain_injections_on_fatal_tool_error(): + """Pending injections should be drained even when a fatal tool error occurs.""" + from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.bus.events import InboundMessage + + provider = MagicMock() + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + return LLMResponse( + content="", + tool_calls=[ToolCallRequest(id="c1", name="exec", arguments={"cmd": "bad"})], + usage={}, + ) + # Second call: respond normally to the injected follow-up + return LLMResponse(content="reply to follow-up", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(side_effect=RuntimeError("tool exploded")) + + injection_queue = asyncio.Queue() + inject_cb = _make_injection_callback(injection_queue) + + await injection_queue.put( + InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after error") + ) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "hello"}], + tools=tools, + model="test-model", + max_iterations=5, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + fail_on_tool_error=True, + injection_callback=inject_cb, + )) + + assert result.had_injections is True + assert result.final_content == "reply to follow-up" + # The injection should be in the messages history + injected = [ + m for m in result.messages + if m.get("role") == "user" and m.get("content") == "follow-up after error" + ] + assert len(injected) == 1 + + +@pytest.mark.asyncio +async def test_drain_injections_on_llm_error(): + """Pending injections should be drained when the LLM returns an error finish_reason.""" + from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.bus.events import InboundMessage + + provider = MagicMock() + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + return LLMResponse( + content=None, + tool_calls=[], + finish_reason="error", + usage={}, + ) + # Second call: respond normally to the injected follow-up + return LLMResponse(content="recovered answer", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + + injection_queue = asyncio.Queue() + inject_cb = _make_injection_callback(injection_queue) + + await injection_queue.put( + InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after LLM error") + ) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "previous response"}, + {"role": "user", "content": "trigger error"}, + ], + tools=tools, + model="test-model", + max_iterations=5, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + injection_callback=inject_cb, + )) + + assert result.had_injections is True + assert result.final_content == "recovered answer" + injected = [ + m for m in result.messages + if m.get("role") == "user" and "follow-up after LLM error" in str(m.get("content", "")) + ] + assert len(injected) == 1 + + +@pytest.mark.asyncio +async def test_drain_injections_on_empty_final_response(): + """Pending injections should be drained when the runner exits due to empty response.""" + from nanobot.agent.runner import AgentRunSpec, AgentRunner, _MAX_EMPTY_RETRIES + from nanobot.bus.events import InboundMessage + + provider = MagicMock() + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + if call_count["n"] <= _MAX_EMPTY_RETRIES + 1: + return LLMResponse(content="", tool_calls=[], usage={}) + # After retries exhausted + injection drain, respond normally + return LLMResponse(content="answer after empty", tool_calls=[], usage={}) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + + injection_queue = asyncio.Queue() + inject_cb = _make_injection_callback(injection_queue) + + await injection_queue.put( + InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after empty") + ) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "previous response"}, + {"role": "user", "content": "trigger empty"}, + ], + tools=tools, + model="test-model", + max_iterations=10, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + injection_callback=inject_cb, + )) + + assert result.had_injections is True + assert result.final_content == "answer after empty" + injected = [ + m for m in result.messages + if m.get("role") == "user" and "follow-up after empty" in str(m.get("content", "")) + ] + assert len(injected) == 1 + + +@pytest.mark.asyncio +async def test_drain_injections_on_max_iterations(): + """Pending injections should be drained when the runner hits max_iterations. + + Unlike other error paths, max_iterations cannot continue the loop, so + injections are appended to messages but not processed by the LLM. + The key point is they are consumed from the queue to prevent re-publish. + """ + from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.bus.events import InboundMessage + + provider = MagicMock() + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + return LLMResponse( + content="", + tool_calls=[ToolCallRequest(id=f"c{call_count['n']}", name="read_file", arguments={"path": "x"})], + usage={}, + ) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value="file content") + + injection_queue = asyncio.Queue() + inject_cb = _make_injection_callback(injection_queue) + + await injection_queue.put( + InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after max iters") + ) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "hello"}], + tools=tools, + model="test-model", + max_iterations=2, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + injection_callback=inject_cb, + )) + + assert result.stop_reason == "max_iterations" + assert result.had_injections is True + # The injection was consumed from the queue (preventing re-publish) + assert injection_queue.empty() + # The injection message is appended to conversation history + injected = [ + m for m in result.messages + if m.get("role") == "user" and m.get("content") == "follow-up after max iters" + ] + assert len(injected) == 1 + + +@pytest.mark.asyncio +async def test_drain_injections_set_flag_when_followup_arrives_after_last_iteration(): + """Late follow-ups drained in max_iterations should still flip had_injections.""" + from nanobot.agent.hook import AgentHook + from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.bus.events import InboundMessage + + provider = MagicMock() + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + return LLMResponse( + content="", + tool_calls=[ToolCallRequest(id=f"c{call_count['n']}", name="read_file", arguments={"path": "x"})], + usage={}, + ) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value="file content") + + injection_queue = asyncio.Queue() + inject_cb = _make_injection_callback(injection_queue) + + class InjectOnLastAfterIterationHook(AgentHook): + def __init__(self) -> None: + self.after_iteration_calls = 0 + + async def after_iteration(self, context) -> None: + self.after_iteration_calls += 1 + if self.after_iteration_calls == 2: + await injection_queue.put( + InboundMessage( + channel="cli", + sender_id="u", + chat_id="c", + content="late follow-up after max iters", + ) + ) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "hello"}], + tools=tools, + model="test-model", + max_iterations=2, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + injection_callback=inject_cb, + hook=InjectOnLastAfterIterationHook(), + )) + + assert result.stop_reason == "max_iterations" + assert result.had_injections is True + assert injection_queue.empty() + injected = [ + m for m in result.messages + if m.get("role") == "user" and m.get("content") == "late follow-up after max iters" + ] + assert len(injected) == 1 + + +@pytest.mark.asyncio +async def test_injection_cycle_cap_on_error_path(): + """Injection cycles should be capped even when every iteration hits an LLM error.""" + from nanobot.agent.runner import AgentRunSpec, AgentRunner, _MAX_INJECTION_CYCLES + from nanobot.bus.events import InboundMessage + + provider = MagicMock() + call_count = {"n": 0} + + async def chat_with_retry(*, messages, **kwargs): + call_count["n"] += 1 + return LLMResponse( + content=None, + tool_calls=[], + finish_reason="error", + usage={}, + ) + + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + + drain_count = {"n": 0} + + async def inject_cb(): + drain_count["n"] += 1 + if drain_count["n"] <= _MAX_INJECTION_CYCLES: + return [InboundMessage(channel="cli", sender_id="u", chat_id="c", content=f"msg-{drain_count['n']}")] + return [] + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "previous"}, + {"role": "user", "content": "trigger error"}, + ], + tools=tools, + model="test-model", + max_iterations=20, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + injection_callback=inject_cb, + )) + + assert result.had_injections is True + # Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks + assert call_count["n"] == _MAX_INJECTION_CYCLES + 1 + + +# --------------------------------------------------------------------------- +# Regression tests for GLM-1214: _snip_history must preserve a user message +# --------------------------------------------------------------------------- + + +def test_snip_history_preserves_user_message_after_truncation(monkeypatch): + """When _snip_history truncates messages and the only user message ends up + outside the kept window, the method must recover the nearest user message + so the resulting sequence is valid for providers like GLM (which reject + system→assistant with error 1214). + + This reproduces the exact scenario from the bug report: + - Normal interaction: user asks, assistant calls tool, tool returns, + assistant replies. + - Injection adds a phantom user message, triggering more tool calls. + - _snip_history activates, keeping only recent assistant/tool pairs. + - The injected user message is in the truncated prefix and gets lost. + """ + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + tools = MagicMock() + tools.get_definitions.return_value = [] + runner = AgentRunner(provider) + + messages = [ + {"role": "system", "content": "system"}, + {"role": "assistant", "content": "previous reply"}, + {"role": "user", "content": ".nanobot的同目录"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "tc_1", "type": "function", "function": {"name": "exec", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "tc_1", "content": "tool output 1"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "tc_2", "type": "function", "function": {"name": "exec", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "tc_2", "content": "tool output 2"}, + ] + + spec = AgentRunSpec( + initial_messages=messages, + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + context_window_tokens=2000, + context_block_limit=100, + ) + + # 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)) + # Make kept window small: only the last 2 messages fit the budget. + token_sizes = { + "system": 0, + "previous reply": 200, + ".nanobot的同目录": 80, + "tool output 1": 80, + "tool output 2": 80, + } + monkeypatch.setattr( + "nanobot.agent.runner.estimate_message_tokens", + lambda msg: token_sizes.get(str(msg.get("content")), 100), + ) + + trimmed = runner._snip_history(spec, messages) + + # The first non-system message MUST be user (not assistant). + non_system = [m for m in trimmed if m.get("role") != "system"] + assert non_system, "trimmed should contain at least one non-system message" + assert non_system[0]["role"] == "user", ( + f"First non-system message must be 'user', got '{non_system[0]['role']}'. " + f"Roles: {[m['role'] for m in trimmed]}" + ) + + +def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch): + """Edge case: if non_system has zero user messages, _snip_history should + still return a valid sequence (not crash or produce system→assistant).""" + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + tools = MagicMock() + tools.get_definitions.return_value = [] + runner = AgentRunner(provider) + + messages = [ + {"role": "system", "content": "system"}, + {"role": "assistant", "content": "reply"}, + {"role": "tool", "tool_call_id": "tc_1", "content": "result"}, + {"role": "assistant", "content": "reply 2"}, + {"role": "tool", "tool_call_id": "tc_2", "content": "result 2"}, + ] + + spec = AgentRunSpec( + initial_messages=messages, + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + context_window_tokens=2000, + context_block_limit=100, + ) + + monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None)) + monkeypatch.setattr( + "nanobot.agent.runner.estimate_message_tokens", + lambda msg: 100, + ) + + trimmed = runner._snip_history(spec, messages) + + # Should not crash. The result should still be a valid list. + assert isinstance(trimmed, list) + # Must have at least system. + assert any(m.get("role") == "system" for m in trimmed) + # The _enforce_role_alternation safety net must be able to fix whatever + # _snip_history returns here — verify it produces a valid sequence. + from nanobot.providers.base import LLMProvider + fixed = LLMProvider._enforce_role_alternation(trimmed) + non_system = [m for m in fixed if m["role"] != "system"] + if non_system: + assert non_system[0]["role"] in ("user", "tool"), ( + f"Safety net should ensure first non-system is user/tool, got {non_system[0]['role']}" + ) + + +@pytest.mark.asyncio +async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress(): + """Regression: provider retry heartbeats must route through + ``retry_wait_callback``, not ``progress_callback``. Binding them to + the progress callback (as an earlier runtime refactor did) caused + internal retry diagnostics like "Model request failed, retry in 1s" + to leak to end-user channels as normal progress updates. + """ + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + captured: dict = {} + + async def chat_with_retry(**kwargs): + captured.update(kwargs) + return LLMResponse(content="done", tool_calls=[], usage={}) + + provider = MagicMock() + provider.chat_with_retry = chat_with_retry + tools = MagicMock() + tools.get_definitions.return_value = [] + + progress_cb = AsyncMock() + retry_wait_cb = AsyncMock() + + runner = AgentRunner(provider) + await runner.run(AgentRunSpec( + initial_messages=[ + {"role": "system", "content": "system"}, + {"role": "user", "content": "hi"}, + ], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + progress_callback=progress_cb, + retry_wait_callback=retry_wait_cb, + )) + + assert captured["on_retry_wait"] is retry_wait_cb + assert captured["on_retry_wait"] is not progress_cb diff --git a/tests/agent/test_skills_loader.py b/tests/agent/test_skills_loader.py index 4284fa0c..d9cfa6d1 100644 --- a/tests/agent/test_skills_loader.py +++ b/tests/agent/test_skills_loader.py @@ -310,3 +310,90 @@ def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None always = loader.get_always_skills() assert "alpha" not in always assert "beta" in always + + +# -- multiline description tests (YAML folded > and literal |) ----------------- + + +def test_build_skills_summary_folded_description(tmp_path: Path) -> None: + """description: > (YAML folded scalar) should be parsed correctly.""" + workspace = tmp_path / "ws" + ws_skills = workspace / "skills" + ws_skills.mkdir(parents=True) + skill_dir = ws_skills / "pdf" + skill_dir.mkdir(parents=True) + skill_path = skill_dir / "SKILL.md" + skill_path.write_text( + "---\n" + "name: pdf\n" + "description: >\n" + " Use this skill when visual quality and design identity matter for a PDF.\n" + " CREATE (generate from scratch): \"make a PDF\".\n" + "---\n\n# PDF Skill\n", + encoding="utf-8", + ) + builtin = tmp_path / "builtin" + builtin.mkdir() + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + summary = loader.build_skills_summary() + assert "pdf" in summary + assert "visual quality" in summary + + +def test_build_skills_summary_literal_description(tmp_path: Path) -> None: + """description: | (YAML literal scalar) should be parsed correctly.""" + workspace = tmp_path / "ws" + ws_skills = workspace / "skills" + ws_skills.mkdir(parents=True) + skill_dir = ws_skills / "multi" + skill_dir.mkdir(parents=True) + skill_path = skill_dir / "SKILL.md" + skill_path.write_text( + "---\n" + "name: multi\n" + "description: |\n" + " Line one of description.\n" + " Line two of description.\n" + "---\n\n# Multi\n", + encoding="utf-8", + ) + builtin = tmp_path / "builtin" + builtin.mkdir() + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + meta = loader.get_skill_metadata("multi") + assert meta is not None + desc = meta.get("description") + assert isinstance(desc, str) + assert "Line one" in desc + assert "Line two" in desc + + +def test_get_skill_metadata_handles_yaml_types(tmp_path: Path) -> None: + """yaml.safe_load returns native types; always should be True, not 'true'.""" + workspace = tmp_path / "ws" + ws_skills = workspace / "skills" + ws_skills.mkdir(parents=True) + skill_dir = ws_skills / "typed" + skill_dir.mkdir(parents=True) + payload = json.dumps({"nanobot": {"requires": {"bins": ["gh"]}, "always": True}}, separators=(",", ":")) + skill_path = skill_dir / "SKILL.md" + skill_path.write_text( + "---\n" + "name: typed\n" + f"metadata: {payload}\n" + "always: true\n" + "---\n\n# Typed\n", + encoding="utf-8", + ) + builtin = tmp_path / "builtin" + builtin.mkdir() + + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + meta = loader.get_skill_metadata("typed") + assert meta is not None + # YAML parsed 'true' to Python True + assert meta.get("always") is True + # metadata is a parsed dict, not a JSON string + assert isinstance(meta.get("metadata"), dict) diff --git a/tests/agent/test_task_cancel.py b/tests/agent/test_task_cancel.py index 7e84e57d..c1c36ca8 100644 --- a/tests/agent/test_task_cancel.py +++ b/tests/agent/test_task_cancel.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import time from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -269,7 +270,9 @@ class TestSubagentCancellation: monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute) - await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}) + from nanobot.agent.subagent import SubagentStatus + status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()) + await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status) assistant_messages = [ msg for msg in captured_second_call @@ -308,7 +311,9 @@ class TestSubagentCancellation: mgr.runner.run = AsyncMock(side_effect=fake_run) - await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}) + from nanobot.agent.subagent import SubagentStatus + status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()) + await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status) mgr.runner.run.assert_awaited_once() mgr._announce_result.assert_awaited_once() @@ -344,7 +349,9 @@ class TestSubagentCancellation: monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute) - await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}) + from nanobot.agent.subagent import SubagentStatus + status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()) + await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status) mgr._announce_result.assert_awaited_once() args = mgr._announce_result.await_args.args @@ -356,7 +363,7 @@ class TestSubagentCancellation: @pytest.mark.asyncio async def test_cancel_by_session_cancels_running_subagent_tool(self, monkeypatch, tmp_path): - from nanobot.agent.subagent import SubagentManager + from nanobot.agent.subagent import SubagentManager, SubagentStatus from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -389,7 +396,10 @@ class TestSubagentCancellation: monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute) task = asyncio.create_task( - mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}) + mgr._run_subagent( + "sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, + SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()), + ) ) mgr._running_tasks["sub-1"] = task mgr._session_tasks["test:c1"] = {"sub-1"} diff --git a/tests/agent/tools/__init__.py b/tests/agent/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/agent/tools/test_self_tool.py b/tests/agent/tools/test_self_tool.py new file mode 100644 index 00000000..19b1639d --- /dev/null +++ b/tests/agent/tools/test_self_tool.py @@ -0,0 +1,1125 @@ +"""Tests for MyTool — runtime state inspection and configuration.""" + +from __future__ import annotations + +import time +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import BaseModel + +from nanobot.agent.tools.self import MyTool + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_mock_loop(**overrides): + """Build a lightweight mock AgentLoop with the attributes MyTool reads.""" + loop = MagicMock() + loop.model = "anthropic/claude-sonnet-4-20250514" + loop.max_iterations = 40 + loop.context_window_tokens = 65_536 + loop.workspace = Path("/tmp/workspace") + loop.restrict_to_workspace = False + loop._start_time = 1000.0 + loop.exec_config = MagicMock() + loop.channels_config = MagicMock() + loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50} + loop._runtime_vars = {} + loop._current_iteration = 0 + loop.provider_retry_mode = "standard" + loop.max_tool_result_chars = 16000 + loop._concurrency_gate = None + loop._unified_session = False + loop._extra_hooks = [] + + # web_config mock — needed for check tests + loop.web_config = MagicMock() + loop.web_config.enable = True + loop.web_config.search = MagicMock() + loop.web_config.search.api_key = "sk-secret-key-12345" + + # Tools registry mock + loop.tools = MagicMock() + loop.tools.tool_names = ["read_file", "write_file", "exec", "web_search", "self"] + loop.tools.has.side_effect = lambda n: n in loop.tools.tool_names + loop.tools.get.return_value = None + + # SubagentManager mock + loop.subagents = MagicMock() + loop.subagents._running_tasks = {"abc123": MagicMock(done=MagicMock(return_value=False))} + loop.subagents.get_running_count = MagicMock(return_value=1) + + for k, v in overrides.items(): + setattr(loop, k, v) + + return loop + + +def _make_tool(loop=None): + if loop is None: + loop = _make_mock_loop() + return MyTool(loop=loop) + + +# --------------------------------------------------------------------------- +# check — no key (summary) +# --------------------------------------------------------------------------- + +class TestInspectSummary: + + @pytest.mark.asyncio + async def test_inspect_returns_current_state(self): + tool = _make_tool() + result = await tool.execute(action="check") + assert "max_iterations: 40" in result + assert "context_window_tokens: 65536" in result + + @pytest.mark.asyncio + async def test_inspect_includes_runtime_vars(self): + loop = _make_mock_loop() + loop._runtime_vars = {"task": "review"} + tool = _make_tool(loop) + result = await tool.execute(action="check") + assert "task" in result + + @pytest.mark.asyncio + async def test_inspect_summary_shows_all_description_keys(self): + """check without key should show all top-level keys listed in description.""" + tool = _make_tool() + result = await tool.execute(action="check") + assert "max_iterations" in result + assert "context_window_tokens" in result + assert "model" in result + assert "workspace" in result + assert "provider_retry_mode" in result + assert "max_tool_result_chars" in result + assert "_last_usage" in result + assert "_current_iteration" in result + + +# --------------------------------------------------------------------------- +# check — single key (direct) +# --------------------------------------------------------------------------- + +class TestInspectSingleKey: + + @pytest.mark.asyncio + async def test_inspect_simple_value(self): + tool = _make_tool() + result = await tool.execute(action="check", key="max_iterations") + assert "40" in result + + @pytest.mark.asyncio + async def test_inspect_blocked_returns_error(self): + tool = _make_tool() + result = await tool.execute(action="check", key="bus") + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_inspect_dunder_blocked(self): + tool = _make_tool() + for attr in ("__class__", "__dict__", "__bases__", "__subclasses__", "__mro__"): + result = await tool.execute(action="check", key=attr) + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_inspect_nonexistent_returns_not_found(self): + tool = _make_tool() + result = await tool.execute(action="check", key="nonexistent_attr_xyz") + assert "not found" in result + + +# --------------------------------------------------------------------------- +# check — dot-path navigation +# --------------------------------------------------------------------------- + +class TestInspectPathNavigation: + + @pytest.mark.asyncio + async def test_inspect_config_subfield(self): + loop = _make_mock_loop() + loop.web_config = MagicMock() + loop.web_config.enable = True + tool = _make_tool(loop) + result = await tool.execute(action="check", key="web_config.enable") + assert "True" in result + + @pytest.mark.asyncio + async def test_inspect_dict_key_via_dotpath(self): + loop = _make_mock_loop() + loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50} + tool = _make_tool(loop) + result = await tool.execute(action="check", key="_last_usage.prompt_tokens") + assert "100" in result + + @pytest.mark.asyncio + async def test_inspect_blocked_in_path(self): + tool = _make_tool() + result = await tool.execute(action="check", key="bus.foo") + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_inspect_tools_returns_blocked(self): + """tools is BLOCKED — check should return access error.""" + tool = _make_tool() + result = await tool.execute(action="check", key="tools") + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self): + class SearchConfig(BaseModel): + provider: str = "tavily" + api_key: str = "sk-test-secret" + base_url: str = "" + max_results: int = 5 + + loop = _make_mock_loop() + loop.web_config = MagicMock() + loop.web_config.search = SearchConfig() + tool = _make_tool(loop) + + result = await tool.execute(action="check", key="web_config.search") + + assert "provider='tavily'" in result + assert "sk-test-secret" not in result + assert "api_key" not in result.lower() + + + +# --------------------------------------------------------------------------- +# set — restricted (with validation) +# --------------------------------------------------------------------------- + +class TestModifyRestricted: + + @pytest.mark.asyncio + async def test_modify_restricted_valid(self): + tool = _make_tool() + result = await tool.execute(action="set", key="max_iterations", value=80) + assert "Set max_iterations = 80" in result + assert tool._loop.max_iterations == 80 + + @pytest.mark.asyncio + async def test_modify_restricted_out_of_range(self): + tool = _make_tool() + result = await tool.execute(action="set", key="max_iterations", value=0) + assert "Error" in result + assert tool._loop.max_iterations == 40 + + @pytest.mark.asyncio + async def test_modify_restricted_max_exceeded(self): + tool = _make_tool() + result = await tool.execute(action="set", key="max_iterations", value=999) + assert "Error" in result + + @pytest.mark.asyncio + async def test_modify_restricted_wrong_type(self): + tool = _make_tool() + result = await tool.execute(action="set", key="max_iterations", value="not_an_int") + assert "Error" in result + + @pytest.mark.asyncio + async def test_modify_restricted_bool_rejected(self): + tool = _make_tool() + result = await tool.execute(action="set", key="max_iterations", value=True) + assert "Error" in result + + @pytest.mark.asyncio + async def test_modify_string_int_coerced(self): + tool = _make_tool() + result = await tool.execute(action="set", key="max_iterations", value="80") + assert tool._loop.max_iterations == 80 + + @pytest.mark.asyncio + async def test_modify_context_window_valid(self): + tool = _make_tool() + result = await tool.execute(action="set", key="context_window_tokens", value=131072) + assert tool._loop.context_window_tokens == 131072 + + @pytest.mark.asyncio + async def test_modify_none_value_for_restricted_int(self): + tool = _make_tool() + result = await tool.execute(action="set", key="max_iterations", value=None) + assert "Error" in result + + +# --------------------------------------------------------------------------- +# set — blocked (minimal set) +# --------------------------------------------------------------------------- + +class TestModifyBlocked: + + @pytest.mark.asyncio + async def test_modify_bus_blocked(self): + tool = _make_tool() + result = await tool.execute(action="set", key="bus", value="hacked") + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_provider_blocked(self): + tool = _make_tool() + result = await tool.execute(action="set", key="provider", value=None) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_running_blocked(self): + tool = _make_tool() + result = await tool.execute(action="set", key="_running", value=True) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_dunder_blocked(self): + tool = _make_tool() + result = await tool.execute(action="set", key="__class__", value="evil") + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_dotpath_leaf_dunder_blocked(self): + """Fix 3.1: leaf segment of dot-path must also be validated.""" + tool = _make_tool() + result = await tool.execute( + action="set", + key="provider_retry_mode.__class__", + value="evil", + ) + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_modify_dotpath_leaf_denied_attr_blocked(self): + """Fix 3.1: leaf segment matching _DENIED_ATTRS must be rejected.""" + tool = _make_tool() + result = await tool.execute( + action="set", + key="provider_retry_mode.__globals__", + value={}, + ) + assert "not accessible" in result + + +# --------------------------------------------------------------------------- +# set — free tier (setattr priority) +# --------------------------------------------------------------------------- + +class TestModifyFree: + + @pytest.mark.asyncio + async def test_modify_existing_attr_setattr(self): + """Modifying an existing loop attribute should use setattr.""" + tool = _make_tool() + result = await tool.execute(action="set", key="provider_retry_mode", value="persistent") + assert "Set provider_retry_mode" in result + assert tool._loop.provider_retry_mode == "persistent" + + @pytest.mark.asyncio + async def test_modify_new_key_stores_in_runtime_vars(self): + """Modifying a non-existing attribute should store in _runtime_vars.""" + tool = _make_tool() + result = await tool.execute(action="set", key="my_custom_var", value="hello") + assert "my_custom_var" in result + assert tool._loop._runtime_vars["my_custom_var"] == "hello" + + @pytest.mark.asyncio + async def test_modify_rejects_callable(self): + tool = _make_tool() + result = await tool.execute(action="set", key="evil", value=lambda: None) + assert "callable" in result + + @pytest.mark.asyncio + async def test_modify_rejects_complex_objects(self): + tool = _make_tool() + result = await tool.execute(action="set", key="obj", value=Path("/tmp")) + assert "Error" in result + + @pytest.mark.asyncio + async def test_modify_allows_list(self): + tool = _make_tool() + result = await tool.execute(action="set", key="items", value=[1, 2, 3]) + assert tool._loop._runtime_vars["items"] == [1, 2, 3] + + @pytest.mark.asyncio + async def test_modify_allows_dict(self): + tool = _make_tool() + result = await tool.execute(action="set", key="data", value={"a": 1}) + assert tool._loop._runtime_vars["data"] == {"a": 1} + + @pytest.mark.asyncio + async def test_modify_whitespace_key_rejected(self): + tool = _make_tool() + result = await tool.execute(action="set", key=" ", value="test") + assert "cannot be empty or whitespace" in result + + @pytest.mark.asyncio + async def test_modify_nested_dict_with_object_rejected(self): + tool = _make_tool() + result = await tool.execute(action="set", key="evil", value={"nested": object()}) + assert "Error" in result + + @pytest.mark.asyncio + async def test_modify_deep_nesting_rejected(self): + tool = _make_tool() + deep = {"level": 0} + current = deep + for i in range(1, 15): + current["child"] = {"level": i} + current = current["child"] + result = await tool.execute(action="set", key="deep", value=deep) + assert "nesting too deep" in result + + @pytest.mark.asyncio + async def test_modify_dict_with_non_str_key_rejected(self): + tool = _make_tool() + result = await tool.execute(action="set", key="evil", value={42: "value"}) + assert "key must be str" in result + + @pytest.mark.asyncio + async def test_modify_existing_attr_type_mismatch_rejected(self): + """Setting a string attr to int should be rejected.""" + tool = _make_tool() + result = await tool.execute(action="set", key="provider_retry_mode", value=42) + assert "Error" in result + assert "str" in result + assert tool._loop.provider_retry_mode == "standard" + + @pytest.mark.asyncio + async def test_modify_existing_int_attr_wrong_type_rejected(self): + """Setting an int attr to string should be rejected.""" + tool = _make_tool() + result = await tool.execute(action="set", key="max_tool_result_chars", value="big") + assert "Error" in result + assert tool._loop.max_tool_result_chars == 16000 + + +# --------------------------------------------------------------------------- +# set — previously BLOCKED/READONLY now open +# --------------------------------------------------------------------------- + +class TestModifyOpen: + + @pytest.mark.asyncio + async def test_modify_tools_blocked(self): + """tools is BLOCKED — cannot be replaced.""" + tool = _make_tool() + new_registry = MagicMock() + result = await tool.execute(action="set", key="tools", value=new_registry) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_subagents_blocked(self): + """subagents is READ_ONLY — cannot be replaced.""" + tool = _make_tool() + new_subagents = MagicMock() + result = await tool.execute(action="set", key="subagents", value=new_subagents) + assert "read-only" in result + + @pytest.mark.asyncio + async def test_modify_runner_blocked(self): + """runner is BLOCKED — cannot be replaced.""" + tool = _make_tool() + new_runner = MagicMock() + result = await tool.execute(action="set", key="runner", value=new_runner) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_sessions_blocked(self): + """sessions is BLOCKED — cannot be replaced.""" + tool = _make_tool() + new_sessions = MagicMock() + result = await tool.execute(action="set", key="sessions", value=new_sessions) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_consolidator_blocked(self): + """consolidator is BLOCKED — cannot be replaced.""" + tool = _make_tool() + new_consolidator = MagicMock() + result = await tool.execute(action="set", key="consolidator", value=new_consolidator) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_dream_blocked(self): + """dream is BLOCKED — cannot be replaced.""" + tool = _make_tool() + new_dream = MagicMock() + result = await tool.execute(action="set", key="dream", value=new_dream) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_auto_compact_blocked(self): + """auto_compact is BLOCKED — cannot be replaced.""" + tool = _make_tool() + new_auto_compact = MagicMock() + result = await tool.execute(action="set", key="auto_compact", value=new_auto_compact) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_context_blocked(self): + """context is BLOCKED — cannot be replaced.""" + tool = _make_tool() + new_context = MagicMock() + result = await tool.execute(action="set", key="context", value=new_context) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_commands_blocked(self): + """commands is BLOCKED — cannot be replaced.""" + tool = _make_tool() + new_commands = MagicMock() + result = await tool.execute(action="set", key="commands", value=new_commands) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_workspace_allowed(self): + """workspace was READONLY in v1, now freely modifiable.""" + tool = _make_tool() + result = await tool.execute(action="set", key="workspace", value="/new/path") + assert "Set workspace" in result + + @pytest.mark.asyncio + async def test_modify_mcp_servers_blocked(self): + """_mcp_servers contains API credentials — must be blocked.""" + tool = _make_tool() + result = await tool.execute(action="set", key="_mcp_servers", value={"evil": "leaked"}) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_mcp_stacks_blocked(self): + """_mcp_stacks holds connection handles — must be blocked.""" + tool = _make_tool() + result = await tool.execute(action="set", key="_mcp_stacks", value={}) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_pending_queues_blocked(self): + """_pending_queues controls message routing — must be blocked.""" + tool = _make_tool() + result = await tool.execute(action="set", key="_pending_queues", value={}) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_session_locks_blocked(self): + """_session_locks controls session isolation — must be blocked.""" + tool = _make_tool() + result = await tool.execute(action="set", key="_session_locks", value={}) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_active_tasks_blocked(self): + """_active_tasks tracks running tasks — must be blocked.""" + tool = _make_tool() + result = await tool.execute(action="set", key="_active_tasks", value={}) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_background_tasks_blocked(self): + """_background_tasks tracks background tasks — must be blocked.""" + tool = _make_tool() + result = await tool.execute(action="set", key="_background_tasks", value=[]) + assert "protected" in result + + @pytest.mark.asyncio + async def test_inspect_mcp_servers_blocked(self): + """_mcp_servers contains credentials — check must be blocked too.""" + tool = _make_tool() + result = await tool.execute(action="check", key="_mcp_servers") + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_modify_wrapped_denied(self): + """__wrapped__ allows decorator bypass — must be denied.""" + tool = _make_tool() + result = await tool.execute(action="set", key="__wrapped__", value="evil") + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_closure_denied(self): + """__closure__ exposes function internals — must be denied.""" + tool = _make_tool() + result = await tool.execute(action="set", key="__closure__", value="evil") + assert "protected" in result + + +# --------------------------------------------------------------------------- +# validate_json_safe — element counting +# --------------------------------------------------------------------------- + +class TestValidateJsonSafe: + + def test_single_list_passes(self): + assert MyTool._validate_json_safe(list(range(500))) is None + + def test_deeply_nested_within_limit(self): + value = {"level1": {"level2": {"level3": list(range(100))}}} + assert MyTool._validate_json_safe(value) is None + + +# --------------------------------------------------------------------------- +# unknown action +# --------------------------------------------------------------------------- + +class TestUnknownAction: + + @pytest.mark.asyncio + async def test_unknown_action(self): + tool = _make_tool() + result = await tool.execute(action="explode") + assert "Unknown action" in result + + +# --------------------------------------------------------------------------- +# runtime_vars limits (from code review) +# --------------------------------------------------------------------------- + +class TestRuntimeVarsLimits: + + @pytest.mark.asyncio + async def test_runtime_vars_rejects_at_max_keys(self): + loop = _make_mock_loop() + loop._runtime_vars = {f"key_{i}": i for i in range(64)} + tool = _make_tool(loop) + result = await tool.execute(action="set", key="overflow", value="data") + assert "full" in result + assert "overflow" not in loop._runtime_vars + + @pytest.mark.asyncio + async def test_runtime_vars_allows_update_existing_key_at_max(self): + loop = _make_mock_loop() + loop._runtime_vars = {f"key_{i}": i for i in range(64)} + tool = _make_tool(loop) + result = await tool.execute(action="set", key="key_0", value="updated") + assert "Error" not in result + assert loop._runtime_vars["key_0"] == "updated" + + +# --------------------------------------------------------------------------- +# denied attrs (non-dunder) +# --------------------------------------------------------------------------- + +class TestDeniedAttrs: + + @pytest.mark.asyncio + async def test_modify_denied_non_dunder_blocked(self): + tool = _make_tool() + for attr in ("func_globals", "func_code"): + result = await tool.execute(action="set", key=attr, value="evil") + assert "protected" in result, f"{attr} should be blocked" + + +# --------------------------------------------------------------------------- +# SubagentStatus formatting +# --------------------------------------------------------------------------- + +class TestSubagentStatusFormatting: + + def test_format_single_status(self): + """_format_value should produce a rich multi-line display for a SubagentStatus.""" + from nanobot.agent.subagent import SubagentStatus + + status = SubagentStatus( + task_id="abc12345", + label="read logs and summarize", + task_description="Read the log files and produce a summary", + started_at=time.monotonic() - 12.4, + phase="awaiting_tools", + iteration=3, + tool_events=[ + {"name": "read_file", "status": "ok", "detail": "read app.log"}, + {"name": "grep", "status": "ok", "detail": "searched ERROR"}, + {"name": "exec", "status": "error", "detail": "timeout"}, + ], + usage={"prompt_tokens": 4500, "completion_tokens": 1200}, + ) + result = MyTool._format_value(status) + assert "abc12345" in result + assert "read logs and summarize" in result + assert "awaiting_tools" in result + assert "iteration: 3" in result + assert "read_file(ok)" in result + assert "exec(error)" in result + assert "4500" in result + + def test_format_status_dict(self): + """_format_value should handle dict[str, SubagentStatus] with rich display.""" + from nanobot.agent.subagent import SubagentStatus + + statuses = { + "abc12345": SubagentStatus( + task_id="abc12345", + label="task A", + task_description="Do task A", + started_at=time.monotonic() - 5.0, + phase="awaiting_tools", + iteration=1, + ), + } + result = MyTool._format_value(statuses) + assert "1 subagent(s)" in result + assert "abc12345" in result + assert "task A" in result + + def test_format_empty_status_dict(self): + """Empty dict[str, SubagentStatus] should show 'no running subagents'.""" + result = MyTool._format_value({}) + assert "{}" in result + + def test_format_status_with_error(self): + """Status with error should include the error message.""" + from nanobot.agent.subagent import SubagentStatus + + status = SubagentStatus( + task_id="err00001", + label="failing task", + task_description="A task that fails", + started_at=time.monotonic() - 1.0, + phase="error", + error="Connection refused", + ) + result = MyTool._format_value(status) + assert "error: Connection refused" in result + +# --------------------------------------------------------------------------- +# _SubagentHook after_iteration updates status +# --------------------------------------------------------------------------- + +class TestSubagentHookStatus: + + @pytest.mark.asyncio + async def test_after_iteration_updates_status(self): + """after_iteration should copy iteration, tool_events, usage to status.""" + from nanobot.agent.subagent import SubagentStatus, _SubagentHook + from nanobot.agent.hook import AgentHookContext + + status = SubagentStatus( + task_id="test", + label="test", + task_description="test", + started_at=time.monotonic(), + ) + hook = _SubagentHook("test", status) + + context = AgentHookContext( + iteration=5, + messages=[], + tool_events=[{"name": "read_file", "status": "ok", "detail": "ok"}], + usage={"prompt_tokens": 100, "completion_tokens": 50}, + ) + await hook.after_iteration(context) + + assert status.iteration == 5 + assert len(status.tool_events) == 1 + assert status.tool_events[0]["name"] == "read_file" + assert status.usage == {"prompt_tokens": 100, "completion_tokens": 50} + + @pytest.mark.asyncio + async def test_after_iteration_with_error(self): + """after_iteration should set status.error when context has an error.""" + from nanobot.agent.subagent import SubagentStatus, _SubagentHook + from nanobot.agent.hook import AgentHookContext + + status = SubagentStatus( + task_id="test", + label="test", + task_description="test", + started_at=time.monotonic(), + ) + hook = _SubagentHook("test", status) + + context = AgentHookContext( + iteration=1, + messages=[], + error="something went wrong", + ) + await hook.after_iteration(context) + + assert status.error == "something went wrong" + + @pytest.mark.asyncio + async def test_after_iteration_no_status_is_noop(self): + """after_iteration with no status should be a no-op.""" + from nanobot.agent.subagent import _SubagentHook + from nanobot.agent.hook import AgentHookContext + + hook = _SubagentHook("test") + context = AgentHookContext(iteration=1, messages=[]) + await hook.after_iteration(context) # should not raise + + +# --------------------------------------------------------------------------- +# Checkpoint callback updates status +# --------------------------------------------------------------------------- + +class TestCheckpointCallback: + + @pytest.mark.asyncio + async def test_checkpoint_updates_phase_and_iteration(self): + """The _on_checkpoint callback should update status.phase and iteration.""" + from nanobot.agent.subagent import SubagentStatus + import asyncio + + status = SubagentStatus( + task_id="cp", + label="test", + task_description="test", + started_at=time.monotonic(), + ) + + # Simulate the checkpoint callback as defined in _run_subagent + async def _on_checkpoint(payload: dict) -> None: + status.phase = payload.get("phase", status.phase) + status.iteration = payload.get("iteration", status.iteration) + + await _on_checkpoint({"phase": "awaiting_tools", "iteration": 2}) + assert status.phase == "awaiting_tools" + assert status.iteration == 2 + + await _on_checkpoint({"phase": "tools_completed", "iteration": 3}) + assert status.phase == "tools_completed" + assert status.iteration == 3 + + @pytest.mark.asyncio + async def test_checkpoint_preserves_phase_on_missing_key(self): + """If payload doesn't have 'phase', status.phase should stay unchanged.""" + from nanobot.agent.subagent import SubagentStatus + + status = SubagentStatus( + task_id="cp", + label="test", + task_description="test", + started_at=time.monotonic(), + phase="initializing", + ) + + async def _on_checkpoint(payload: dict) -> None: + status.phase = payload.get("phase", status.phase) + status.iteration = payload.get("iteration", status.iteration) + + await _on_checkpoint({"iteration": 1}) + assert status.phase == "initializing" + assert status.iteration == 1 + + +# --------------------------------------------------------------------------- +# check subagents._task_statuses via dot-path +# NOTE: subagents is now BLOCKED for security, so these tests verify +# that access is properly rejected. +# --------------------------------------------------------------------------- + +class TestInspectTaskStatuses: + + @pytest.mark.asyncio + async def test_inspect_task_statuses_accessible(self): + """subagents is READ_ONLY — check should show subagent statuses.""" + from nanobot.agent.subagent import SubagentStatus + + loop = _make_mock_loop() + loop.subagents._task_statuses = { + "abc12345": SubagentStatus( + task_id="abc12345", + label="read logs", + task_description="Read the log files", + started_at=time.monotonic() - 8.0, + phase="awaiting_tools", + iteration=2, + tool_events=[{"name": "read_file", "status": "ok", "detail": "ok"}], + usage={"prompt_tokens": 500, "completion_tokens": 100}, + ), + } + tool = _make_tool(loop) + result = await tool.execute(action="check", key="subagents._task_statuses") + assert "abc12345" in result + assert "read logs" in result + + @pytest.mark.asyncio + async def test_inspect_single_subagent_status_accessible(self): + """subagents._task_statuses. should return individual SubagentStatus.""" + from nanobot.agent.subagent import SubagentStatus + + loop = _make_mock_loop() + status = SubagentStatus( + task_id="xyz", + label="search code", + task_description="Search the codebase", + started_at=time.monotonic() - 3.0, + phase="done", + iteration=4, + stop_reason="completed", + ) + loop.subagents._task_statuses = {"xyz": status} + tool = _make_tool(loop) + result = await tool.execute(action="check", key="subagents._task_statuses.xyz") + assert "search code" in result + assert "completed" in result + + +# --------------------------------------------------------------------------- +# read-only mode (tools.my.allow_set=False) +# --------------------------------------------------------------------------- + +class TestReadOnlyMode: + + def _make_readonly_tool(self): + loop = _make_mock_loop() + return MyTool(loop=loop, modify_allowed=False) + + @pytest.mark.asyncio + async def test_inspect_allowed_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="check", key="max_iterations") + assert "40" in result + + @pytest.mark.asyncio + async def test_modify_blocked_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="set", key="max_iterations", value=80) + assert "disabled" in result + + def test_description_shows_readonly(self): + tool = self._make_readonly_tool() + assert "READ-ONLY MODE" in tool.description + + def test_description_shows_warning_when_modify_allowed(self): + tool = _make_tool() + assert "IMPORTANT" in tool.description + assert "READ-ONLY" not in tool.description + + +# --------------------------------------------------------------------------- +# runtime vars check fallback (Fix #1: cross-turn memory) +# --------------------------------------------------------------------------- + +class TestRuntimeVarsInspectFallback: + + @pytest.mark.asyncio + async def test_inspect_runtime_var_after_modify(self): + """Design doc scenario: set then check should return the value.""" + tool = _make_tool() + await tool.execute(action="set", key="user_prefers_concise", value=True) + result = await tool.execute(action="check", key="user_prefers_concise") + assert "True" in result + + @pytest.mark.asyncio + async def test_inspect_runtime_var_string(self): + tool = _make_tool() + await tool.execute(action="set", key="current_project", value="nanobot") + result = await tool.execute(action="check", key="current_project") + assert "nanobot" in result + + @pytest.mark.asyncio + async def test_inspect_runtime_var_dict(self): + tool = _make_tool() + await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5}) + result = await tool.execute(action="check", key="task_meta") + assert "step" in result + assert "2" in result + + @pytest.mark.asyncio + async def test_inspect_nonexistent_still_returns_not_found(self): + tool = _make_tool() + result = await tool.execute(action="check", key="never_set_key_xyz") + assert "not found" in result + + +# --------------------------------------------------------------------------- +# sensitive sub-field blocking (Fix #3: API key leak prevention) +# --------------------------------------------------------------------------- + +class TestSensitiveSubFieldBlocking: + + @pytest.mark.asyncio + async def test_inspect_api_key_blocked(self): + """web_config.search.api_key must not be accessible.""" + tool = _make_tool() + result = await tool.execute(action="check", key="web_config.search.api_key") + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_inspect_password_blocked(self): + """Any field named 'password' must be blocked.""" + loop = _make_mock_loop() + loop.some_config = MagicMock() + loop.some_config.password = "hunter2" + tool = _make_tool(loop) + result = await tool.execute(action="check", key="some_config.password") + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_inspect_secret_blocked(self): + loop = _make_mock_loop() + loop.vault = MagicMock() + loop.vault.secret = "classified" + tool = _make_tool(loop) + result = await tool.execute(action="check", key="vault.secret") + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_inspect_token_blocked(self): + loop = _make_mock_loop() + loop.auth_data = MagicMock() + loop.auth_data.token = "jwt-payload" + tool = _make_tool(loop) + result = await tool.execute(action="check", key="auth_data.token") + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_modify_api_key_blocked(self): + """web_config is READ_ONLY, so any set under it is blocked.""" + tool = _make_tool() + result = await tool.execute(action="set", key="web_config.search.api_key", value="evil") + # Blocked either by READ_ONLY (web_config) or sensitive name (api_key) + assert "read-only" in result or "not accessible" in result + + @pytest.mark.asyncio + async def test_modify_password_blocked(self): + loop = _make_mock_loop() + loop.some_config = MagicMock() + tool = _make_tool(loop) + result = await tool.execute(action="set", key="some_config.password", value="evil") + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_inspect_non_sensitive_subfield_allowed(self): + """web_config.enable should still be inspectable.""" + tool = _make_tool() + result = await tool.execute(action="check", key="web_config.enable") + assert "True" in result + + @pytest.mark.asyncio + async def test_modify_sensitive_top_level_blocked(self): + """Top-level key matching sensitive name must be blocked for set.""" + tool = _make_tool() + result = await tool.execute(action="set", key="api_key", value="evil") + assert "protected" in result + + +# --------------------------------------------------------------------------- +# security-sensitive attribute protection (Fix #4) +# --------------------------------------------------------------------------- + +class TestSecurityAttributeProtection: + + @pytest.mark.asyncio + async def test_modify_restrict_to_workspace_blocked(self): + """restrict_to_workspace is BLOCKED — cannot be toggled.""" + tool = _make_tool() + result = await tool.execute(action="set", key="restrict_to_workspace", value=True) + assert "protected" in result + + @pytest.mark.asyncio + async def test_modify_exec_config_blocked(self): + """exec_config is READ_ONLY — cannot be modified.""" + tool = _make_tool() + result = await tool.execute(action="set", key="exec_config", value=MagicMock()) + assert "read-only" in result + + @pytest.mark.asyncio + async def test_modify_web_config_blocked(self): + """web_config is READ_ONLY — cannot be modified.""" + tool = _make_tool() + result = await tool.execute(action="set", key="web_config", value=MagicMock()) + assert "read-only" in result + + @pytest.mark.asyncio + async def test_modify_channels_config_blocked(self): + """channels_config is BLOCKED — cannot be modified.""" + tool = _make_tool() + result = await tool.execute(action="set", key="channels_config", value={}) + assert "protected" in result + + @pytest.mark.asyncio + async def test_inspect_restrict_to_workspace_blocked(self): + """restrict_to_workspace is BLOCKED — cannot be inspected.""" + tool = _make_tool() + result = await tool.execute(action="check", key="restrict_to_workspace") + assert "not accessible" in result + + @pytest.mark.asyncio + async def test_inspect_exec_config_allowed(self): + """exec_config is READ_ONLY — check should work.""" + tool = _make_tool() + result = await tool.execute(action="check", key="exec_config") + assert "Error" not in result + + @pytest.mark.asyncio + async def test_inspect_web_config_allowed(self): + """web_config is READ_ONLY — check should work.""" + tool = _make_tool() + result = await tool.execute(action="check", key="web_config") + assert "Error" not in result + + @pytest.mark.asyncio + async def test_modify_exec_config_dotpath_blocked(self): + """exec_config.enable = False should be blocked because exec_config is READ_ONLY.""" + tool = _make_tool() + result = await tool.execute(action="set", key="exec_config.enable", value=False) + assert "read-only" in result + + @pytest.mark.asyncio + async def test_modify_web_config_dotpath_blocked(self): + """web_config.enable = False should be blocked because web_config is READ_ONLY.""" + tool = _make_tool() + result = await tool.execute(action="set", key="web_config.enable", value=False) + assert "read-only" in result + + +# --------------------------------------------------------------------------- +# current iteration count (Fix #2) +# --------------------------------------------------------------------------- + +class TestCurrentIteration: + + @pytest.mark.asyncio + async def test_inspect_current_iteration(self): + tool = _make_tool() + result = await tool.execute(action="check", key="_current_iteration") + assert "0" in result + + @pytest.mark.asyncio + async def test_current_iteration_in_summary(self): + tool = _make_tool() + result = await tool.execute(action="check") + assert "_current_iteration" in result + + @pytest.mark.asyncio + async def test_modify_current_iteration_blocked(self): + """_current_iteration is READ_ONLY — cannot be set manually.""" + tool = _make_tool() + result = await tool.execute(action="set", key="_current_iteration", value=5) + assert "read-only" in result + + +# --------------------------------------------------------------------------- +# _last_usage in check summary (Fix #5) +# --------------------------------------------------------------------------- + +class TestLastUsageInSummary: + + @pytest.mark.asyncio + async def test_last_usage_shown_in_summary(self): + tool = _make_tool() + result = await tool.execute(action="check") + assert "_last_usage" in result + assert "prompt_tokens" in result + + @pytest.mark.asyncio + async def test_last_usage_not_shown_when_empty(self): + loop = _make_mock_loop() + loop._last_usage = {} + tool = _make_tool(loop) + result = await tool.execute(action="check") + assert "_last_usage" not in result + + +# --------------------------------------------------------------------------- +# set_context (audit session tracking) +# --------------------------------------------------------------------------- + +class TestSetContext: + + def test_set_context_stores_channel_and_chat_id(self): + tool = _make_tool() + tool.set_context("feishu", "oc_abc123") + assert tool._channel == "feishu" + assert tool._chat_id == "oc_abc123" diff --git a/tests/agent/tools/test_subagent_tools.py b/tests/agent/tools/test_subagent_tools.py new file mode 100644 index 00000000..045d5a45 --- /dev/null +++ b/tests/agent/tools/test_subagent_tools.py @@ -0,0 +1,53 @@ +"""Tests for subagent tool registration and wiring.""" + +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.config.schema import AgentDefaults + +_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars + + +@pytest.mark.asyncio +async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path): + """allowed_env_keys from ExecToolConfig must be forwarded to the subagent's ExecTool.""" + from nanobot.agent.subagent import SubagentManager, SubagentStatus + from nanobot.bus.queue import MessageBus + from nanobot.config.schema import ExecToolConfig + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + exec_config=ExecToolConfig(allowed_env_keys=["GOPATH", "JAVA_HOME"]), + ) + mgr._announce_result = AsyncMock() + + async def fake_run(spec): + exec_tool = spec.tools.get("exec") + assert exec_tool is not None + assert exec_tool.allowed_env_keys == ["GOPATH", "JAVA_HOME"] + return SimpleNamespace( + stop_reason="done", + final_content="done", + error=None, + tool_events=[], + ) + + mgr.runner.run = AsyncMock(side_effect=fake_run) + + status = SubagentStatus( + task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic() + ) + await mgr._run_subagent( + "sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status + ) + + mgr.runner.run.assert_awaited_once() diff --git a/tests/channels/test_base_channel.py b/tests/channels/test_base_channel.py index 5d10d4e1..660aff60 100644 --- a/tests/channels/test_base_channel.py +++ b/tests/channels/test_base_channel.py @@ -23,3 +23,15 @@ def test_is_allowed_requires_exact_match() -> None: assert channel.is_allowed("allow@email.com") is True assert channel.is_allowed("attacker|allow@email.com") is False + + +def test_is_allowed_supports_dict_allow_from_alias() -> None: + channel = _DummyChannel({"allowFrom": ["alice"]}, MessageBus()) + + assert channel.is_allowed("alice") is True + + +def test_is_allowed_denies_empty_dict_allow_from() -> None: + channel = _DummyChannel({"allow_from": []}, MessageBus()) + + assert channel.is_allowed("alice") is False diff --git a/tests/channels/test_channel_manager_delta_coalescing.py b/tests/channels/test_channel_manager_delta_coalescing.py index 0fa97f5b..3c150f90 100644 --- a/tests/channels/test_channel_manager_delta_coalescing.py +++ b/tests/channels/test_channel_manager_delta_coalescing.py @@ -296,3 +296,50 @@ class TestDispatchOutboundWithCoalescing: # Should have pending regular message assert len(pending) == 1 assert pending[0].content == "Final" + + +class TestRetryWaitFiltering: + """Internal provider retry heartbeats must never reach channels.""" + + @pytest.mark.asyncio + async def test_retry_wait_message_dropped(self, manager, bus): + """A ``_retry_wait`` message must be filtered before channel dispatch. + + Regression: provider retry diagnostics like + ``Model request failed, retry in 1s (attempt 1).`` were being + delivered to end-user channels because the runner bound + ``on_retry_wait`` to the progress callback. + """ + retry_msg = OutboundMessage( + channel="mock", + chat_id="chat1", + content="Model request failed, retry in 1s (attempt 1).", + metadata={"_retry_wait": True}, + ) + real_msg = OutboundMessage( + channel="mock", + chat_id="chat1", + content="final answer", + metadata={}, + ) + await bus.publish_outbound(retry_msg) + await bus.publish_outbound(real_msg) + + task = asyncio.create_task(manager._dispatch_outbound()) + try: + for _ in range(30): + if manager.channels["mock"]._send_mock.await_count >= 1: + break + await asyncio.sleep(0.05) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + send_mock = manager.channels["mock"]._send_mock + assert send_mock.await_count == 1 + sent = send_mock.await_args_list[0].args[0] + assert sent.content == "final answer" + assert not sent.metadata.get("_retry_wait") diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index 8bb95b53..a6959f93 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -175,7 +175,7 @@ async def test_manager_loads_plugin_from_dict_config(): channels=ChannelsConfig.model_validate({ "fakeplugin": {"enabled": True, "allowFrom": ["*"]}, }), - providers=SimpleNamespace(groq=SimpleNamespace(api_key="")), + providers=SimpleNamespace(groq=SimpleNamespace(api_key="", api_base="")), ) with patch( @@ -193,6 +193,113 @@ async def test_manager_loads_plugin_from_dict_config(): assert isinstance(mgr.channels["fakeplugin"], _FakePlugin) +@pytest.mark.asyncio +async def test_manager_propagates_groq_transcription_api_base_to_channels(): + from nanobot.channels.manager import ChannelManager + + fake_config = SimpleNamespace( + channels=ChannelsConfig.model_validate({ + "fakeplugin": {"enabled": True, "allowFrom": ["*"]}, + }), + transcription_provider="groq", + providers=SimpleNamespace( + groq=SimpleNamespace(api_key="groq-key", api_base="http://proxy.local/v1/audio/transcriptions"), + openai=SimpleNamespace(api_key="openai-key", api_base="https://api.openai.com/v1/audio/transcriptions"), + ), + ) + + with patch( + "nanobot.channels.registry.discover_all", + return_value={"fakeplugin": _FakePlugin}, + ): + mgr = ChannelManager.__new__(ChannelManager) + mgr.config = fake_config + mgr.bus = MessageBus() + mgr.channels = {} + mgr._dispatch_task = None + mgr._init_channels() + + channel = mgr.channels["fakeplugin"] + assert channel.transcription_provider == "groq" + assert channel.transcription_api_key == "groq-key" + assert channel.transcription_api_base == "http://proxy.local/v1/audio/transcriptions" + + +@pytest.mark.asyncio +async def test_manager_propagates_openai_transcription_api_base_to_channels(): + from nanobot.channels.manager import ChannelManager + + fake_config = SimpleNamespace( + channels=ChannelsConfig.model_validate({ + "fakeplugin": {"enabled": True, "allowFrom": ["*"]}, + "transcriptionProvider": "openai", + }), + providers=SimpleNamespace( + openai=SimpleNamespace( + api_key="openai-key", + api_base="http://proxy.local/v1/audio/transcriptions", + ), + groq=SimpleNamespace(api_key="groq-key", api_base=""), + ), + ) + + with patch( + "nanobot.channels.registry.discover_all", + return_value={"fakeplugin": _FakePlugin}, + ): + mgr = ChannelManager.__new__(ChannelManager) + mgr.config = fake_config + mgr.bus = MessageBus() + mgr.channels = {} + mgr._dispatch_task = None + mgr._init_channels() + + channel = mgr.channels["fakeplugin"] + assert channel.transcription_provider == "openai" + assert channel.transcription_api_key == "openai-key" + assert channel.transcription_api_base == "http://proxy.local/v1/audio/transcriptions" + + +@pytest.mark.asyncio +async def test_base_channel_passes_api_base_to_openai_transcription_provider(): + """BaseChannel.transcribe_audio must forward transcription_api_base to OpenAI.""" + from nanobot.providers import transcription as transcription_mod + + channel = _FakePlugin({"enabled": True, "allowFrom": ["*"]}, MessageBus()) + channel.transcription_provider = "openai" + channel.transcription_api_key = "k" + channel.transcription_api_base = "http://override/v1/audio/transcriptions" + + captured: dict[str, object] = {} + + class _StubOpenAI: + def __init__(self, api_key=None, api_base=None): + captured["api_key"] = api_key + captured["api_base"] = api_base + + async def transcribe(self, file_path): + return "ok" + + with patch.object(transcription_mod, "OpenAITranscriptionProvider", _StubOpenAI): + result = await channel.transcribe_audio("/tmp/does-not-matter.wav") + + assert result == "ok" + assert captured["api_key"] == "k" + assert captured["api_base"] == "http://override/v1/audio/transcriptions" + + +def test_openai_transcription_provider_honors_api_base_argument(): + from nanobot.providers.transcription import OpenAITranscriptionProvider + + default = OpenAITranscriptionProvider(api_key="k") + assert default.api_url == "https://api.openai.com/v1/audio/transcriptions" + + custom = OpenAITranscriptionProvider( + api_key="k", api_base="http://override/v1/audio/transcriptions" + ) + assert custom.api_url == "http://override/v1/audio/transcriptions" + + def test_channels_login_uses_discovered_plugin_class(monkeypatch): from nanobot.cli.commands import app from nanobot.config.schema import Config @@ -646,7 +753,10 @@ class _ChannelWithAllowFrom(BaseChannel): def __init__(self, config, bus, allow_from): super().__init__(config, bus) - self.config.allow_from = allow_from + if isinstance(self.config, dict): + self.config["allow_from"] = allow_from + else: + self.config.allow_from = allow_from async def start(self) -> None: pass @@ -714,6 +824,25 @@ async def test_validate_allow_from_passes_with_asterisk(): mgr._validate_allow_from() +@pytest.mark.asyncio +async def test_validate_allow_from_raises_on_empty_dict_allow_from(): + """_validate_allow_from should reject empty dict-backed allow_from lists.""" + fake_config = SimpleNamespace( + channels=ChannelsConfig(), + providers=SimpleNamespace(groq=SimpleNamespace(api_key="")), + ) + + mgr = ChannelManager.__new__(ChannelManager) + mgr.config = fake_config + mgr.channels = {"test": _ChannelWithAllowFrom({"enabled": True}, None, [])} + mgr._dispatch_task = None + + with pytest.raises(SystemExit) as exc_info: + mgr._validate_allow_from() + + assert "empty allowFrom" in str(exc_info.value) + + @pytest.mark.asyncio async def test_get_channel_returns_channel_if_exists(): """get_channel should return the channel if it exists.""" diff --git a/tests/channels/test_dingtalk_channel.py b/tests/channels/test_dingtalk_channel.py index f743c4e6..86de99bb 100644 --- a/tests/channels/test_dingtalk_channel.py +++ b/tests/channels/test_dingtalk_channel.py @@ -2,7 +2,9 @@ import asyncio import zipfile from io import BytesIO from types import SimpleNamespace +from unittest.mock import AsyncMock +import httpx import pytest # Check optional dingtalk dependencies before running tests @@ -52,6 +54,21 @@ class _FakeHttp: return self._next_response() +class _NetworkErrorHttp: + """HTTP client stub that raises httpx.TransportError on every request.""" + + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def post(self, url: str, json=None, headers=None, **kwargs): + self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers}) + raise httpx.ConnectError("Connection refused") + + async def get(self, url: str, **kwargs): + self.calls.append({"method": "GET", "url": url}) + raise httpx.ConnectError("Connection refused") + + @pytest.mark.asyncio async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None: config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]) @@ -298,3 +315,141 @@ async def test_send_media_ref_zips_html_before_upload(tmp_path, monkeypatch) -> archive = zipfile.ZipFile(BytesIO(captured["data"])) assert archive.namelist() == ["report.html"] + + +# ── Exception handling tests ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_send_batch_message_propagates_transport_error() -> None: + """Network/transport errors must re-raise so callers can retry.""" + config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]) + channel = DingTalkChannel(config, MessageBus()) + channel._http = _NetworkErrorHttp() + + with pytest.raises(httpx.ConnectError, match="Connection refused"): + await channel._send_batch_message( + "token", + "user123", + "sampleMarkdown", + {"text": "hello", "title": "Nanobot Reply"}, + ) + + # The POST was attempted exactly once + assert len(channel._http.calls) == 1 + assert channel._http.calls[0]["method"] == "POST" + + +@pytest.mark.asyncio +async def test_send_batch_message_returns_false_on_api_error() -> None: + """DingTalk API-level errors (non-200 status, errcode != 0) should return False.""" + config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]) + channel = DingTalkChannel(config, MessageBus()) + + # Non-200 status code → API error → return False + channel._http = _FakeHttp(responses=[_FakeResponse(400, {"errcode": 400})]) + result = await channel._send_batch_message( + "token", "user123", "sampleMarkdown", {"text": "hello"} + ) + assert result is False + + # 200 with non-zero errcode → API error → return False + channel._http = _FakeHttp(responses=[_FakeResponse(200, {"errcode": 100})]) + result = await channel._send_batch_message( + "token", "user123", "sampleMarkdown", {"text": "hello"} + ) + assert result is False + + # 200 with errcode=0 → success → return True + channel._http = _FakeHttp(responses=[_FakeResponse(200, {"errcode": 0})]) + result = await channel._send_batch_message( + "token", "user123", "sampleMarkdown", {"text": "hello"} + ) + assert result is True + + +@pytest.mark.asyncio +async def test_send_media_ref_short_circuits_on_transport_error() -> None: + """When the first send fails with a transport error, _send_media_ref must + re-raise immediately instead of trying download+upload+fallback.""" + config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]) + channel = DingTalkChannel(config, MessageBus()) + channel._http = _NetworkErrorHttp() + + # An image URL triggers the sampleImageMsg path first + with pytest.raises(httpx.ConnectError, match="Connection refused"): + await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg") + + # Only one POST should have been attempted — no download/upload/fallback + assert len(channel._http.calls) == 1 + assert channel._http.calls[0]["method"] == "POST" + + +@pytest.mark.asyncio +async def test_send_media_ref_short_circuits_on_download_transport_error() -> None: + """When the image URL send returns an API error (False) but the download + for the fallback hits a transport error, it must re-raise rather than + silently returning False.""" + config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]) + channel = DingTalkChannel(config, MessageBus()) + + # First POST (sampleImageMsg) returns API error → False, then GET (download) raises transport error + class _MixedHttp: + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def post(self, url, json=None, headers=None, **kwargs): + self.calls.append({"method": "POST", "url": url}) + # API-level failure: 200 with errcode != 0 + return _FakeResponse(200, {"errcode": 100}) + + async def get(self, url, **kwargs): + self.calls.append({"method": "GET", "url": url}) + raise httpx.ConnectError("Connection refused") + + channel._http = _MixedHttp() + + with pytest.raises(httpx.ConnectError, match="Connection refused"): + await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg") + + # Should have attempted POST (image URL) and GET (download), but NOT upload + assert len(channel._http.calls) == 2 + assert channel._http.calls[0]["method"] == "POST" + assert channel._http.calls[1]["method"] == "GET" + + +@pytest.mark.asyncio +async def test_send_media_ref_short_circuits_on_upload_transport_error() -> None: + """When download succeeds but upload hits a transport error, must re-raise.""" + config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]) + channel = DingTalkChannel(config, MessageBus()) + + image_bytes = b"\xff\xd8\xff\xe0" + b"\x00" * 100 # minimal JPEG-ish data + + class _UploadFailsHttp: + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def post(self, url, json=None, headers=None, files=None, **kwargs): + self.calls.append({"method": "POST", "url": url}) + # If it's the upload endpoint, raise transport error + if "media/upload" in url: + raise httpx.ConnectError("Connection refused") + # Otherwise (sampleImageMsg), return API error to trigger fallback + return _FakeResponse(200, {"errcode": 100}) + + async def get(self, url, **kwargs): + self.calls.append({"method": "GET", "url": url}) + resp = _FakeResponse(200) + resp.content = image_bytes + resp.headers = {"content-type": "image/jpeg"} + return resp + + channel._http = _UploadFailsHttp() + + with pytest.raises(httpx.ConnectError, match="Connection refused"): + await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg") + + # POST (image URL), GET (download), POST (upload) attempted — no further sends + methods = [c["method"] for c in channel._http.calls] + assert methods == ["POST", "GET", "POST"] diff --git a/tests/channels/test_discord_channel.py b/tests/channels/test_discord_channel.py index 3a31a591..82ef6c51 100644 --- a/tests/channels/test_discord_channel.py +++ b/tests/channels/test_discord_channel.py @@ -313,6 +313,45 @@ async def test_on_message_accepts_allowlisted_dm() -> None: assert handled[0]["metadata"] == {"message_id": "789", "guild_id": None, "reply_to": None} +@pytest.mark.asyncio +async def test_on_message_accepts_when_channel_in_allow_channels() -> None: + # When allow_channels is set, messages from listed channels should be forwarded. + channel = DiscordChannel( + DiscordConfig(enabled=True, allow_from=["*"], allow_channels=["456"]), + MessageBus(), + ) + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + + await channel._on_message(_make_message(author_id=123, channel_id=456)) + + assert len(handled) == 1 + assert handled[0]["chat_id"] == "456" + + +@pytest.mark.asyncio +async def test_on_message_drops_when_channel_not_in_allow_channels() -> None: + # When allow_channels is set and incoming channel is not listed, drop silently. + channel = DiscordChannel( + DiscordConfig(enabled=True, allow_from=["*"], allow_channels=["999"]), + MessageBus(), + ) + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + + await channel._on_message(_make_message(author_id=123, channel_id=456)) + + assert handled == [] + + @pytest.mark.asyncio async def test_on_message_ignores_unmentioned_guild_message() -> None: # With mention-only group policy, guild messages without a bot mention are dropped. @@ -867,3 +906,100 @@ async def test_start_no_proxy_auth_when_only_password(monkeypatch) -> None: assert channel.is_running is False assert _FakeDiscordClient.instances[0].proxy == "http://127.0.0.1:7890" assert _FakeDiscordClient.instances[0].proxy_auth is None + + +# --------------------------------------------------------------------------- +# Tests for the send() exception propagation fix +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_send_re_raises_network_error() -> None: + """Network errors during send must propagate so ChannelManager can retry.""" + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + client = _FakeDiscordClient(channel, intents=None) + channel._client = client + channel._running = True + + async def _failing_send_outbound(msg: OutboundMessage) -> None: + raise ConnectionError("network unreachable") + + client.send_outbound = _failing_send_outbound # type: ignore[method-assign] + + with pytest.raises(ConnectionError, match="network unreachable"): + await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello")) + + +@pytest.mark.asyncio +async def test_send_re_raises_generic_exception() -> None: + """Any exception from send_outbound must propagate, not be swallowed.""" + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + client = _FakeDiscordClient(channel, intents=None) + channel._client = client + channel._running = True + + async def _failing_send_outbound(msg: OutboundMessage) -> None: + raise RuntimeError("discord API failure") + + client.send_outbound = _failing_send_outbound # type: ignore[method-assign] + + with pytest.raises(RuntimeError, match="discord API failure"): + await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello")) + + +@pytest.mark.asyncio +async def test_send_still_stops_typing_on_error() -> None: + """Typing cleanup must still run in the finally block even when send raises.""" + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + client = _FakeDiscordClient(channel, intents=None) + channel._client = client + channel._running = True + + # Start a typing task so we can verify it gets cleaned up + start = asyncio.Event() + release = asyncio.Event() + + async def slow_typing() -> None: + start.set() + await release.wait() + + typing_channel = _FakeChannel(channel_id=123) + typing_channel.typing_enter_hook = slow_typing + await channel._start_typing(typing_channel) + await asyncio.wait_for(start.wait(), timeout=1.0) + + async def _failing_send_outbound(msg: OutboundMessage) -> None: + raise ConnectionError("timeout") + + client.send_outbound = _failing_send_outbound # type: ignore[method-assign] + + with pytest.raises(ConnectionError, match="timeout"): + await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello")) + + release.set() + await asyncio.sleep(0) + + # Typing should have been cleaned up by the finally block + assert channel._typing_tasks == {} + + +@pytest.mark.asyncio +async def test_send_succeeds_normally() -> None: + """Successful sends should work without raising.""" + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + client = _FakeDiscordClient(channel, intents=None) + channel._client = client + channel._running = True + + sent_messages: list[OutboundMessage] = [] + + async def _capture_send_outbound(msg: OutboundMessage) -> None: + sent_messages.append(msg) + + client.send_outbound = _capture_send_outbound # type: ignore[method-assign] + + msg = OutboundMessage(channel="discord", chat_id="123", content="hello world") + await channel.send(msg) + + assert len(sent_messages) == 1 + assert sent_messages[0].content == "hello world" + assert sent_messages[0].chat_id == "123" diff --git a/tests/channels/test_email_channel.py b/tests/channels/test_email_channel.py index 6d6d2f74..98343522 100644 --- a/tests/channels/test_email_channel.py +++ b/tests/channels/test_email_channel.py @@ -92,6 +92,109 @@ def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None: assert items_again == [] +def test_fetch_new_messages_skips_self_sent_email_and_marks_seen(monkeypatch) -> None: + raw = _make_raw_email(from_addr="Nanobot ", subject="Loop test") + + class FakeIMAP: + def __init__(self) -> None: + self.store_calls: list[tuple[bytes, str, str]] = [] + + def login(self, _user: str, _pw: str): + return "OK", [b"logged in"] + + def select(self, _mailbox: str): + return "OK", [b"1"] + + def search(self, *_args): + return "OK", [b"1"] + + def fetch(self, _imap_id: bytes, _parts: str): + return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"] + + def store(self, imap_id: bytes, op: str, flags: str): + self.store_calls.append((imap_id, op, flags)) + return "OK", [b""] + + def logout(self): + return "BYE", [b""] + + fake = FakeIMAP() + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + channel = EmailChannel(_make_config(from_address="bot@example.com"), MessageBus()) + items = channel._fetch_new_messages() + + assert items == [] + assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")] + + # Same UID should still be deduped after being ignored. + items_again = channel._fetch_new_messages() + assert items_again == [] + + +@pytest.mark.parametrize( + "config_override,from_header", + [ + # Only smtp_username matches — simulates an SMTP relay where + # outbound From gets rewritten to the SMTP login identity. + ( + {"from_address": "", "smtp_username": "bot@example.com", "imap_username": "other@imap.com"}, + "bot@example.com", + ), + # Only imap_username matches — simulates mailbox-based identity + # with no explicit from_address set. + ( + {"from_address": "", "smtp_username": "other@smtp.com", "imap_username": "bot@example.com"}, + "bot@example.com", + ), + # Case-insensitive: inbound From arrives upper-cased. + ( + {"from_address": "bot@example.com", "smtp_username": "other@smtp.com", "imap_username": "other@imap.com"}, + "BOT@EXAMPLE.COM", + ), + ], + ids=["smtp_username_only", "imap_username_only", "case_insensitive"], +) +def test_fetch_new_messages_skips_self_sent_across_identity_sources( + monkeypatch, config_override, from_header +) -> None: + """Self-address detection must fire when any of from_address / smtp_username / + imap_username matches, and must be case-insensitive.""" + raw = _make_raw_email(from_addr=from_header, subject="Loop test") + + class FakeIMAP: + def __init__(self) -> None: + self.store_calls: list[tuple[bytes, str, str]] = [] + + def login(self, _user: str, _pw: str): + return "OK", [b"logged in"] + + def select(self, _mailbox: str): + return "OK", [b"1"] + + def search(self, *_args): + return "OK", [b"1"] + + def fetch(self, _imap_id: bytes, _parts: str): + return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"] + + def store(self, imap_id: bytes, op: str, flags: str): + self.store_calls.append((imap_id, op, flags)) + return "OK", [b""] + + def logout(self): + return "BYE", [b""] + + fake = FakeIMAP() + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + channel = EmailChannel(_make_config(**config_override), MessageBus()) + items = channel._fetch_new_messages() + + assert items == [] + assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")] + + def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeypatch) -> None: raw = _make_raw_email(subject="Invoice", body="Please pay") fail_once = {"pending": True} diff --git a/tests/channels/test_feishu_streaming.py b/tests/channels/test_feishu_streaming.py index a047c8c5..4bef8354 100644 --- a/tests/channels/test_feishu_streaming.py +++ b/tests/channels/test_feishu_streaming.py @@ -205,53 +205,22 @@ class TestSendDelta: ch._client.im.v1.message.create.assert_called_once() @pytest.mark.asyncio - async def test_stream_end_resuming_keeps_buffer(self): - """_resuming=True flushes text to card but keeps the buffer for the next segment.""" + async def test_stream_end_fallback_when_final_update_fails(self): + """If streaming mode was closed (e.g. Feishu timeout), fall back to a regular card.""" ch = _make_channel() ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( - text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0, + text="Lost content", card_id="card_1", sequence=3, last_edit=0.0, ) - ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() + ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(success=False) + ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb") - await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True}) - - assert "oc_chat1" in ch._stream_bufs - buf = ch._stream_bufs["oc_chat1"] - assert buf.card_id == "card_1" - assert buf.sequence == 3 - ch._client.cardkit.v1.card_element.content.assert_called_once() - ch._client.cardkit.v1.card.settings.assert_not_called() - - @pytest.mark.asyncio - async def test_stream_end_resuming_then_final_end(self): - """Full multi-segment flow: resuming mid-turn, then final end closes the card.""" - ch = _make_channel() - ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( - text="Seg1", card_id="card_1", sequence=1, last_edit=0.0, - ) - ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() - ch._client.cardkit.v1.card.settings.return_value = _mock_content_response() - - await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True}) - assert "oc_chat1" in ch._stream_bufs - - ch._stream_bufs["oc_chat1"].text += " Seg2" await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True}) assert "oc_chat1" not in ch._stream_bufs - ch._client.cardkit.v1.card.settings.assert_called_once() - - @pytest.mark.asyncio - async def test_stream_end_resuming_no_card_is_noop(self): - """_resuming with no card_id (card creation failed) is a safe no-op.""" - ch = _make_channel() - ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( - text="text", card_id=None, sequence=0, last_edit=0.0, - ) - await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True}) - - assert "oc_chat1" in ch._stream_bufs - ch._client.cardkit.v1.card_element.content.assert_not_called() + # Should NOT attempt to close streaming mode since update failed + ch._client.cardkit.v1.card.settings.assert_not_called() + # Should fall back to sending a regular interactive card + ch._client.im.v1.message.create.assert_called_once() @pytest.mark.asyncio async def test_stream_end_without_buf_is_noop(self): @@ -375,22 +344,6 @@ class TestToolHintInlineStreaming: assert "🔧 $ cd /project" in buf.text assert "🔧 $ git status" in buf.text - @pytest.mark.asyncio - async def test_tool_hint_preserved_on_resuming_flush(self): - """When _resuming flushes the buffer, tool hint is kept as permanent content.""" - ch = _make_channel() - ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( - text="Partial answer\n\n🔧 $ cd /project\n\n", - card_id="card_1", sequence=2, last_edit=0.0, - ) - ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() - - await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True}) - - buf = ch._stream_bufs["oc_chat1"] - assert "Partial answer" in buf.text - assert "🔧 $ cd /project" in buf.text - @pytest.mark.asyncio async def test_tool_hint_preserved_on_final_stream_end(self): """When final _stream_end closes the card, tool hint is kept in the final text.""" diff --git a/tests/channels/test_feishu_tool_hint_code_block.py b/tests/channels/test_feishu_tool_hint_code_block.py index a5db5ad6..4f9d214c 100644 --- a/tests/channels/test_feishu_tool_hint_code_block.py +++ b/tests/channels/test_feishu_tool_hint_code_block.py @@ -1,6 +1,7 @@ -"""Tests for FeishuChannel tool hint code block formatting.""" +"""Tests for FeishuChannel tool hint formatting.""" import json +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -28,15 +29,24 @@ def mock_feishu_channel(): config.app_secret = "test_app_secret" config.encrypt_key = None config.verification_token = None + config.tool_hint_prefix = "\U0001f527" # 🔧 bus = MagicMock() channel = FeishuChannel(config, bus) - channel._client = MagicMock() # Simulate initialized client + channel._client = MagicMock() return channel +def _get_tool_hint_card(mock_send): + """Extract the interactive card from _send_message_sync calls.""" + call_args = mock_send.call_args[0] + _, _, msg_type, content = call_args + assert msg_type == "interactive" + return json.loads(content) + + @mark.asyncio -async def test_tool_hint_sends_code_message(mock_feishu_channel): - """Tool hint messages should be sent as interactive cards with code blocks.""" +async def test_tool_hint_sends_interactive_card(mock_feishu_channel): + """Tool hint without active buffer sends an interactive card with 🔧 style.""" msg = OutboundMessage( channel="feishu", chat_id="oc_123456", @@ -47,23 +57,12 @@ async def test_tool_hint_sends_code_message(mock_feishu_channel): with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: await mock_feishu_channel.send(msg) - # Verify interactive message with card was sent assert mock_send.call_count == 1 - call_args = mock_send.call_args[0] - receive_id_type, receive_id, msg_type, content = call_args - - assert receive_id_type == "chat_id" - assert receive_id == "oc_123456" - assert msg_type == "interactive" - - # Parse content to verify card structure - card = json.loads(content) + card = _get_tool_hint_card(mock_send) assert card["config"]["wide_screen_mode"] is True - assert len(card["elements"]) == 1 - assert card["elements"][0]["tag"] == "markdown" - # Check that code block is properly formatted with language hint - expected_md = "**Tool Calls**\n\n```text\nweb_search(\"test query\")\n```" - assert card["elements"][0]["content"] == expected_md + md = card["elements"][0]["content"] + assert "\U0001f527" in md + assert "web_search" in md @mark.asyncio @@ -78,8 +77,6 @@ async def test_tool_hint_empty_content_does_not_send(mock_feishu_channel): with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: await mock_feishu_channel.send(msg) - - # Should not send any message mock_send.assert_not_called() @@ -96,7 +93,6 @@ async def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel): with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: await mock_feishu_channel.send(msg) - # Should send as text message (detected format) assert mock_send.call_count == 1 call_args = mock_send.call_args[0] _, _, msg_type, content = call_args @@ -106,7 +102,7 @@ async def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel): @mark.asyncio async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel): - """Multiple tool calls should be displayed each on its own line in a code block.""" + """Multiple tool calls should each get the 🔧 prefix.""" msg = OutboundMessage( channel="feishu", chat_id="oc_123456", @@ -117,13 +113,11 @@ async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel): with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: await mock_feishu_channel.send(msg) - call_args = mock_send.call_args[0] - msg_type = call_args[2] - content = json.loads(call_args[3]) - assert msg_type == "interactive" - # Each tool call should be on its own line - expected_md = "**Tool Calls**\n\n```text\nweb_search(\"query\"),\nread_file(\"/path/to/file\")\n```" - assert content["elements"][0]["content"] == expected_md + card = _get_tool_hint_card(mock_send) + md = card["elements"][0]["content"] + assert "web_search" in md + assert "read_file" in md + assert "\U0001f527" in md @mark.asyncio @@ -139,8 +133,8 @@ async def test_tool_hint_new_format_basic(mock_feishu_channel): with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: await mock_feishu_channel.send(msg) - content = json.loads(mock_send.call_args[0][3]) - md = content["elements"][0]["content"] + card = _get_tool_hint_card(mock_send) + md = card["elements"][0]["content"] assert "read src/main.py" in md assert 'grep "TODO"' in md @@ -158,16 +152,15 @@ async def test_tool_hint_new_format_with_comma_in_quotes(mock_feishu_channel): with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: await mock_feishu_channel.send(msg) - content = json.loads(mock_send.call_args[0][3]) - md = content["elements"][0]["content"] - # The comma inside quotes should NOT cause a line break + card = _get_tool_hint_card(mock_send) + md = card["elements"][0]["content"] assert 'grep "hello, world"' in md assert "$ echo test" in md @mark.asyncio async def test_tool_hint_new_format_with_folding(mock_feishu_channel): - """Folded calls (× N) should display on separate lines.""" + """Folded calls (× N) should display correctly.""" msg = OutboundMessage( channel="feishu", chat_id="oc_123456", @@ -178,8 +171,8 @@ async def test_tool_hint_new_format_with_folding(mock_feishu_channel): with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: await mock_feishu_channel.send(msg) - content = json.loads(mock_send.call_args[0][3]) - md = content["elements"][0]["content"] + card = _get_tool_hint_card(mock_send) + md = card["elements"][0]["content"] assert "\u00d7 3" in md assert 'grep "pattern"' in md @@ -197,9 +190,12 @@ async def test_tool_hint_new_format_mcp(mock_feishu_channel): with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: await mock_feishu_channel.send(msg) - content = json.loads(mock_send.call_args[0][3]) - md = content["elements"][0]["content"] + card = _get_tool_hint_card(mock_send) + md = card["elements"][0]["content"] assert "4_5v::analyze_image" in md + + +@mark.asyncio async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel): """Commas inside a single tool argument must not be split onto a new line.""" msg = OutboundMessage( @@ -212,10 +208,7 @@ async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel): with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: await mock_feishu_channel.send(msg) - content = json.loads(mock_send.call_args[0][3]) - expected_md = ( - "**Tool Calls**\n\n```text\n" - "web_search(\"foo, bar\"),\n" - "read_file(\"/path/to/file\")\n```" - ) - assert content["elements"][0]["content"] == expected_md + card = _get_tool_hint_card(mock_send) + md = card["elements"][0]["content"] + assert 'web_search("foo, bar")' in md + assert 'read_file("/path/to/file")' in md diff --git a/tests/channels/test_qq_channel.py b/tests/channels/test_qq_channel.py index 729442a1..417648ad 100644 --- a/tests/channels/test_qq_channel.py +++ b/tests/channels/test_qq_channel.py @@ -1,6 +1,7 @@ import tempfile from pathlib import Path from types import SimpleNamespace +from unittest.mock import AsyncMock, patch import pytest @@ -14,6 +15,8 @@ except ImportError: if not QQ_AVAILABLE: pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True) +import aiohttp + from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.qq import QQChannel, QQConfig @@ -170,3 +173,221 @@ async def test_read_media_bytes_missing_file() -> None: data, filename = await channel._read_media_bytes("/nonexistent/path/image.png") assert data is None assert filename is None + + +# ------------------------------------------------------- +# Tests for _send_media exception handling +# ------------------------------------------------------- + +def _make_channel_with_local_file(suffix: str = ".png", content: bytes = b"\x89PNG\r\n"): + """Create a QQChannel with a fake client and a temp file for media.""" + channel = QQChannel( + QQConfig(app_id="app", secret="secret", allow_from=["*"]), + MessageBus(), + ) + channel._client = _FakeClient() + channel._chat_type_cache["user1"] = "c2c" + + tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + tmp.write(content) + tmp.close() + return channel, tmp.name + + +@pytest.mark.asyncio +async def test_send_media_network_error_propagates() -> None: + """aiohttp.ClientError (network/transport) should re-raise, not return False.""" + channel, tmp_path = _make_channel_with_local_file() + + # Make the base64 upload raise a network error + channel._client.api._http = SimpleNamespace() + channel._client.api._http.request = AsyncMock( + side_effect=aiohttp.ServerDisconnectedError("connection lost"), + ) + + with pytest.raises(aiohttp.ServerDisconnectedError): + await channel._send_media( + chat_id="user1", + media_ref=tmp_path, + msg_id="msg1", + is_group=False, + ) + + +@pytest.mark.asyncio +async def test_send_media_client_connector_error_propagates() -> None: + """aiohttp.ClientConnectorError (DNS/connection refused) should re-raise.""" + channel, tmp_path = _make_channel_with_local_file() + + from aiohttp.client_reqrep import ConnectionKey + conn_key = ConnectionKey("api.qq.com", 443, True, None, None, None, None) + connector_error = aiohttp.ClientConnectorError( + connection_key=conn_key, + os_error=OSError("Connection refused"), + ) + + channel._client.api._http = SimpleNamespace() + channel._client.api._http.request = AsyncMock( + side_effect=connector_error, + ) + + with pytest.raises(aiohttp.ClientConnectorError): + await channel._send_media( + chat_id="user1", + media_ref=tmp_path, + msg_id="msg1", + is_group=False, + ) + + +@pytest.mark.asyncio +async def test_send_media_oserror_propagates() -> None: + """OSError (low-level I/O) should re-raise for retry.""" + channel, tmp_path = _make_channel_with_local_file() + + channel._client.api._http = SimpleNamespace() + channel._client.api._http.request = AsyncMock( + side_effect=OSError("Network is unreachable"), + ) + + with pytest.raises(OSError): + await channel._send_media( + chat_id="user1", + media_ref=tmp_path, + msg_id="msg1", + is_group=False, + ) + + +@pytest.mark.asyncio +async def test_send_media_api_error_returns_false() -> None: + """API-level errors (botpy RuntimeError subclasses) should return False, not raise.""" + channel, tmp_path = _make_channel_with_local_file() + + # Simulate a botpy API error (e.g. ServerError is a RuntimeError subclass) + from botpy.errors import ServerError + + channel._client.api._http = SimpleNamespace() + channel._client.api._http.request = AsyncMock( + side_effect=ServerError("internal server error"), + ) + + result = await channel._send_media( + chat_id="user1", + media_ref=tmp_path, + msg_id="msg1", + is_group=False, + ) + assert result is False + + +@pytest.mark.asyncio +async def test_send_media_generic_runtime_error_returns_false() -> None: + """Generic RuntimeError (not network) should return False.""" + channel, tmp_path = _make_channel_with_local_file() + + channel._client.api._http = SimpleNamespace() + channel._client.api._http.request = AsyncMock( + side_effect=RuntimeError("some API error"), + ) + + result = await channel._send_media( + chat_id="user1", + media_ref=tmp_path, + msg_id="msg1", + is_group=False, + ) + assert result is False + + +@pytest.mark.asyncio +async def test_send_media_value_error_returns_false() -> None: + """ValueError (bad API response data) should return False.""" + channel, tmp_path = _make_channel_with_local_file() + + channel._client.api._http = SimpleNamespace() + channel._client.api._http.request = AsyncMock( + side_effect=ValueError("bad response data"), + ) + + result = await channel._send_media( + chat_id="user1", + media_ref=tmp_path, + msg_id="msg1", + is_group=False, + ) + assert result is False + + +@pytest.mark.asyncio +async def test_send_media_timeout_error_propagates() -> None: + """asyncio.TimeoutError inherits from Exception but not ClientError/OSError. + However, aiohttp.ServerTimeoutError IS a ClientError subclass, so that propagates. + For a plain TimeoutError (which is also OSError in Python 3.11+), it should propagate.""" + channel, tmp_path = _make_channel_with_local_file() + + channel._client.api._http = SimpleNamespace() + channel._client.api._http.request = AsyncMock( + side_effect=aiohttp.ServerTimeoutError("request timed out"), + ) + + with pytest.raises(aiohttp.ServerTimeoutError): + await channel._send_media( + chat_id="user1", + media_ref=tmp_path, + msg_id="msg1", + is_group=False, + ) + + +@pytest.mark.asyncio +async def test_send_fallback_text_on_api_error() -> None: + """When _send_media returns False (API error), send() should emit fallback text.""" + channel, tmp_path = _make_channel_with_local_file() + + from botpy.errors import ServerError + + channel._client.api._http = SimpleNamespace() + channel._client.api._http.request = AsyncMock( + side_effect=ServerError("internal server error"), + ) + + await channel.send( + OutboundMessage( + channel="qq", + chat_id="user1", + content="", + media=[tmp_path], + metadata={"message_id": "msg1"}, + ) + ) + + # Should have sent a fallback text message + assert len(channel._client.api.c2c_calls) == 1 + fallback_content = channel._client.api.c2c_calls[0]["content"] + assert "Attachment send failed" in fallback_content + + +@pytest.mark.asyncio +async def test_send_propagates_network_error_no_fallback() -> None: + """When _send_media raises a network error, send() should NOT silently fallback.""" + channel, tmp_path = _make_channel_with_local_file() + + channel._client.api._http = SimpleNamespace() + channel._client.api._http.request = AsyncMock( + side_effect=aiohttp.ServerDisconnectedError("connection lost"), + ) + + with pytest.raises(aiohttp.ServerDisconnectedError): + await channel.send( + OutboundMessage( + channel="qq", + chat_id="user1", + content="hello", + media=[tmp_path], + metadata={"message_id": "msg1"}, + ) + ) + + # No fallback text should have been sent + assert len(channel._client.api.c2c_calls) == 0 diff --git a/tests/channels/test_slack_channel.py b/tests/channels/test_slack_channel.py index f7eec95c..2e72c4e6 100644 --- a/tests/channels/test_slack_channel.py +++ b/tests/channels/test_slack_channel.py @@ -10,8 +10,7 @@ except ImportError: from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.slack import SlackChannel -from nanobot.channels.slack import SlackConfig +from nanobot.channels.slack import SlackChannel, SlackConfig class _FakeAsyncWebClient: @@ -20,6 +19,12 @@ class _FakeAsyncWebClient: self.file_upload_calls: list[dict[str, object | None]] = [] self.reactions_add_calls: list[dict[str, object | None]] = [] self.reactions_remove_calls: list[dict[str, object | None]] = [] + self.conversations_list_calls: list[dict[str, object | None]] = [] + self.users_list_calls: list[dict[str, object | None]] = [] + self.conversations_open_calls: list[dict[str, object | None]] = [] + self._conversations_pages: list[dict[str, object]] = [] + self._users_pages: list[dict[str, object]] = [] + self._open_dm_response: dict[str, object] = {"channel": {"id": "D_OPENED"}} async def chat_postMessage( self, @@ -81,6 +86,22 @@ class _FakeAsyncWebClient: } ) + async def conversations_list(self, **kwargs): + self.conversations_list_calls.append(kwargs) + if self._conversations_pages: + return self._conversations_pages.pop(0) + return {"channels": [], "response_metadata": {"next_cursor": ""}} + + async def users_list(self, **kwargs): + self.users_list_calls.append(kwargs) + if self._users_pages: + return self._users_pages.pop(0) + return {"members": [], "response_metadata": {"next_cursor": ""}} + + async def conversations_open(self, **kwargs): + self.conversations_open_calls.append(kwargs) + return self._open_dm_response + @pytest.mark.asyncio async def test_send_uses_thread_for_channel_messages() -> None: @@ -151,3 +172,147 @@ async def test_send_updates_reaction_when_final_response_sent() -> None: assert fake_web.reactions_add_calls == [ {"channel": "C123", "name": "white_check_mark", "timestamp": "1700000000.000100"} ] + + +@pytest.mark.asyncio +async def test_send_resolves_channel_name_to_channel_id() -> None: + channel = SlackChannel(SlackConfig(enabled=True), MessageBus()) + fake_web = _FakeAsyncWebClient() + fake_web._conversations_pages = [ + { + "channels": [{"id": "C999", "name": "channel_x"}], + "response_metadata": {"next_cursor": ""}, + } + ] + channel._web_client = fake_web + + await channel.send( + OutboundMessage( + channel="slack", + chat_id="#channel_x", + content="hello", + ) + ) + + assert fake_web.chat_post_calls == [ + {"channel": "C999", "text": "hello\n", "thread_ts": None} + ] + assert len(fake_web.conversations_list_calls) == 1 + + +@pytest.mark.asyncio +async def test_send_resolves_user_handle_to_dm_channel() -> None: + channel = SlackChannel(SlackConfig(enabled=True), MessageBus()) + fake_web = _FakeAsyncWebClient() + fake_web._users_pages = [ + { + "members": [ + { + "id": "U234", + "name": "alice", + "profile": {"display_name": "Alice"}, + } + ], + "response_metadata": {"next_cursor": ""}, + } + ] + fake_web._open_dm_response = {"channel": {"id": "D234"}} + channel._web_client = fake_web + + await channel.send( + OutboundMessage( + channel="slack", + chat_id="@alice", + content="hello", + ) + ) + + assert fake_web.conversations_open_calls == [{"users": "U234"}] + assert fake_web.chat_post_calls == [ + {"channel": "D234", "text": "hello\n", "thread_ts": None} + ] + + +@pytest.mark.asyncio +async def test_send_updates_reaction_on_origin_channel_for_cross_channel_send() -> None: + channel = SlackChannel(SlackConfig(enabled=True, react_emoji="eyes"), MessageBus()) + fake_web = _FakeAsyncWebClient() + fake_web._conversations_pages = [ + { + "channels": [{"id": "C999", "name": "channel_x"}], + "response_metadata": {"next_cursor": ""}, + } + ] + channel._web_client = fake_web + + await channel.send( + OutboundMessage( + channel="slack", + chat_id="channel_x", + content="done", + metadata={ + "slack": { + "event": {"ts": "1700000000.000100", "channel": "D_ORIGIN"}, + "channel_type": "im", + }, + }, + ) + ) + + assert fake_web.chat_post_calls == [ + {"channel": "C999", "text": "done\n", "thread_ts": None} + ] + assert fake_web.reactions_remove_calls == [ + {"channel": "D_ORIGIN", "name": "eyes", "timestamp": "1700000000.000100"} + ] + assert fake_web.reactions_add_calls == [ + {"channel": "D_ORIGIN", "name": "white_check_mark", "timestamp": "1700000000.000100"} + ] + + +@pytest.mark.asyncio +async def test_send_does_not_reuse_origin_thread_ts_for_cross_channel_send() -> None: + channel = SlackChannel(SlackConfig(enabled=True), MessageBus()) + fake_web = _FakeAsyncWebClient() + fake_web._conversations_pages = [ + { + "channels": [{"id": "C999", "name": "channel_x"}], + "response_metadata": {"next_cursor": ""}, + } + ] + channel._web_client = fake_web + + await channel.send( + OutboundMessage( + channel="slack", + chat_id="channel_x", + content="done", + metadata={ + "slack": { + "event": {"ts": "1700000000.000100", "channel": "C_ORIGIN"}, + "thread_ts": "1700000000.000200", + "channel_type": "channel", + }, + }, + ) + ) + + assert fake_web.chat_post_calls == [ + {"channel": "C999", "text": "done\n", "thread_ts": None} + ] + + +@pytest.mark.asyncio +async def test_send_raises_when_named_target_cannot_be_resolved() -> None: + channel = SlackChannel(SlackConfig(enabled=True), MessageBus()) + fake_web = _FakeAsyncWebClient() + channel._web_client = fake_web + + with pytest.raises(ValueError, match="was not found"): + await channel.send( + OutboundMessage( + channel="slack", + chat_id="#missing-channel", + content="hello", + ) + ) diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 5a196412..8d9431ba 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -387,6 +387,84 @@ async def test_send_delta_stream_end_treats_not_modified_as_success() -> None: assert "123" not in channel._stream_bufs +@pytest.mark.asyncio +async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> None: + """TimedOut during HTML edit should propagate, never fall back to plain text.""" + from telegram.error import TimedOut + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + # _call_with_retry retries TimedOut up to 3 times, so the mock will be called + # multiple times – but all calls must be with parse_mode="HTML" (no plain fallback). + channel._app.bot.edit_message_text = AsyncMock(side_effect=TimedOut("network timeout")) + channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0) + + with pytest.raises(TimedOut, match="network timeout"): + await channel.send_delta("123", "", {"_stream_end": True}) + + # Every call to edit_message_text must have used parse_mode="HTML" — + # no plain-text fallback call should have been made. + for call in channel._app.bot.edit_message_text.call_args_list: + assert call.kwargs.get("parse_mode") == "HTML" + # Buffer should still be present (not cleaned up on error) + assert "123" in channel._stream_bufs + + +@pytest.mark.asyncio +async def test_send_delta_stream_end_does_not_fallback_on_network_error() -> None: + """NetworkError during HTML edit should propagate, never fall back to plain text.""" + from telegram.error import NetworkError + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + channel._app.bot.edit_message_text = AsyncMock(side_effect=NetworkError("connection reset")) + channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0) + + with pytest.raises(NetworkError, match="connection reset"): + await channel.send_delta("123", "", {"_stream_end": True}) + + # Every call to edit_message_text must have used parse_mode="HTML" — + # no plain-text fallback call should have been made. + for call in channel._app.bot.edit_message_text.call_args_list: + assert call.kwargs.get("parse_mode") == "HTML" + # Buffer should still be present (not cleaned up on error) + assert "123" in channel._stream_bufs + + +@pytest.mark.asyncio +async def test_send_delta_stream_end_falls_back_on_bad_request() -> None: + """BadRequest (HTML parse error) should still trigger plain-text fallback.""" + from telegram.error import BadRequest + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + # First call (HTML) raises BadRequest, second call (plain) succeeds + channel._app.bot.edit_message_text = AsyncMock( + side_effect=[BadRequest("Can't parse entities"), None] + ) + channel._stream_bufs["123"] = _StreamBuf(text="hello ", message_id=7, last_edit=0.0) + + await channel.send_delta("123", "", {"_stream_end": True}) + + # edit_message_text should have been called twice: once for HTML, once for plain fallback + assert channel._app.bot.edit_message_text.call_count == 2 + # Second call should not use parse_mode="HTML" + second_call_kwargs = channel._app.bot.edit_message_text.call_args_list[1].kwargs + assert "parse_mode" not in second_call_kwargs or second_call_kwargs.get("parse_mode") is None + # Buffer should be cleaned up on success + assert "123" not in channel._stream_bufs + + @pytest.mark.asyncio async def test_send_delta_stream_end_splits_oversized_reply() -> None: """Final streamed reply exceeding Telegram limit is split into chunks.""" @@ -1159,3 +1237,159 @@ async def test_on_message_location_with_text() -> None: assert len(handled) == 1 assert "meet me here" in handled[0]["content"] assert "[location: 51.5074, -0.1278]" in handled[0]["content"] + + +# --------------------------------------------------------------------------- +# Tests for retry amplification fix (issue #3050) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_send_text_does_not_fallback_on_network_timeout() -> None: + """TimedOut should propagate immediately, NOT trigger plain-text fallback. + + Before the fix, _send_text caught ALL exceptions (including TimedOut) + and retried as plain text, doubling connection demand during pool + exhaustion — see issue #3050. + """ + from telegram.error import TimedOut + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + call_count = 0 + + async def always_timeout(**kwargs): + nonlocal call_count + call_count += 1 + raise TimedOut() + + channel._app.bot.send_message = always_timeout + + import nanobot.channels.telegram as tg_mod + orig_delay = tg_mod._SEND_RETRY_BASE_DELAY + tg_mod._SEND_RETRY_BASE_DELAY = 0.01 + try: + with pytest.raises(TimedOut): + await channel._send_text(123, "hello", None, {}) + finally: + tg_mod._SEND_RETRY_BASE_DELAY = orig_delay + + # With the fix: only _call_with_retry's 3 HTML attempts (no plain fallback). + # Before the fix: 3 HTML + 3 plain = 6 attempts. + assert call_count == 3, ( + f"Expected 3 calls (HTML retries only), got {call_count} " + "(plain-text fallback should not trigger on TimedOut)" + ) + + +@pytest.mark.asyncio +async def test_send_text_does_not_fallback_on_network_error() -> None: + """NetworkError should propagate immediately, NOT trigger plain-text fallback.""" + from telegram.error import NetworkError + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + call_count = 0 + + async def always_network_error(**kwargs): + nonlocal call_count + call_count += 1 + raise NetworkError("Connection reset") + + channel._app.bot.send_message = always_network_error + + import nanobot.channels.telegram as tg_mod + orig_delay = tg_mod._SEND_RETRY_BASE_DELAY + tg_mod._SEND_RETRY_BASE_DELAY = 0.01 + try: + with pytest.raises(NetworkError): + await channel._send_text(123, "hello", None, {}) + finally: + tg_mod._SEND_RETRY_BASE_DELAY = orig_delay + + # _call_with_retry does NOT retry NetworkError (only TimedOut/RetryAfter), + # so it raises after 1 attempt. The fix prevents plain-text fallback. + # Before the fix: 1 HTML + 1 plain = 2. After the fix: 1 HTML only. + assert call_count == 1, ( + f"Expected 1 call (HTML only, no plain fallback), got {call_count}" + ) + + +@pytest.mark.asyncio +async def test_send_text_falls_back_on_bad_request() -> None: + """BadRequest (HTML parse error) should still trigger plain-text fallback.""" + from telegram.error import BadRequest + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + original_send = channel._app.bot.send_message + html_call_count = 0 + + async def html_fails(**kwargs): + nonlocal html_call_count + if kwargs.get("parse_mode") == "HTML": + html_call_count += 1 + raise BadRequest("Can't parse entities") + return await original_send(**kwargs) + + channel._app.bot.send_message = html_fails + + import nanobot.channels.telegram as tg_mod + orig_delay = tg_mod._SEND_RETRY_BASE_DELAY + tg_mod._SEND_RETRY_BASE_DELAY = 0.01 + try: + await channel._send_text(123, "hello **world**", None, {}) + finally: + tg_mod._SEND_RETRY_BASE_DELAY = orig_delay + + # HTML attempt failed with BadRequest → fallback to plain text succeeds. + assert html_call_count == 1, f"Expected 1 HTML attempt, got {html_call_count}" + assert len(channel._app.bot.sent_messages) == 1 + # Plain text send should NOT have parse_mode + assert channel._app.bot.sent_messages[0].get("parse_mode") is None + + +@pytest.mark.asyncio +async def test_send_text_bad_request_plain_fallback_exhausted() -> None: + """When both HTML and plain-text fallback fail with BadRequest, the error propagates.""" + from telegram.error import BadRequest + + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + call_count = 0 + + async def always_bad_request(**kwargs): + nonlocal call_count + call_count += 1 + raise BadRequest("Bad request") + + channel._app.bot.send_message = always_bad_request + + import nanobot.channels.telegram as tg_mod + orig_delay = tg_mod._SEND_RETRY_BASE_DELAY + tg_mod._SEND_RETRY_BASE_DELAY = 0.01 + try: + with pytest.raises(BadRequest): + await channel._send_text(123, "hello", None, {}) + finally: + tg_mod._SEND_RETRY_BASE_DELAY = orig_delay + + # _call_with_retry does NOT retry BadRequest (only TimedOut/RetryAfter), + # so HTML fails after 1 attempt → fallback to plain also fails after 1 attempt. + # Before the fix: 2 total. After the fix: still 2 (BadRequest SHOULD fallback). + assert call_count == 2, f"Expected 2 calls (1 HTML + 1 plain), got {call_count}" diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 89a330a1..c7d4923f 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -17,9 +17,11 @@ from nanobot.bus.events import OutboundMessage from nanobot.channels.websocket import ( WebSocketChannel, WebSocketConfig, + _is_valid_chat_id, _issue_route_secret_matches, _normalize_config_path, _normalize_http_path, + _parse_envelope, _parse_inbound_payload, _parse_query, _parse_request_path, @@ -168,7 +170,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None: bus = MagicMock() channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) mock_ws = AsyncMock() - channel._connections["chat-1"] = mock_ws + channel._attach(mock_ws, "chat-1") msg = OutboundMessage( channel="websocket", @@ -182,6 +184,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None: mock_ws.send.assert_awaited_once() payload = json.loads(mock_ws.send.call_args[0][0]) assert payload["event"] == "message" + assert payload["chat_id"] == "chat-1" assert payload["text"] == "hello" assert payload["reply_to"] == "m1" assert payload["media"] == ["/tmp/a.png"] @@ -201,12 +204,13 @@ async def test_send_removes_connection_on_connection_closed() -> None: channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) mock_ws = AsyncMock() mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True) - channel._connections["chat-1"] = mock_ws + channel._attach(mock_ws, "chat-1") msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello") await channel.send(msg) - assert "chat-1" not in channel._connections + assert "chat-1" not in channel._subs + assert mock_ws not in channel._conn_chats @pytest.mark.asyncio @@ -215,11 +219,12 @@ async def test_send_delta_removes_connection_on_connection_closed() -> None: channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus) mock_ws = AsyncMock() mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True) - channel._connections["chat-1"] = mock_ws + channel._attach(mock_ws, "chat-1") await channel.send_delta("chat-1", "chunk", {"_stream_delta": True, "_stream_id": "s1"}) - assert "chat-1" not in channel._connections + assert "chat-1" not in channel._subs + assert mock_ws not in channel._conn_chats @pytest.mark.asyncio @@ -227,7 +232,7 @@ async def test_send_delta_emits_delta_and_stream_end() -> None: bus = MagicMock() channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus) mock_ws = AsyncMock() - channel._connections["chat-1"] = mock_ws + channel._attach(mock_ws, "chat-1") await channel.send_delta("chat-1", "part", {"_stream_delta": True, "_stream_id": "sid"}) await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"}) @@ -236,9 +241,11 @@ async def test_send_delta_emits_delta_and_stream_end() -> None: first = json.loads(mock_ws.send.call_args_list[0][0][0]) second = json.loads(mock_ws.send.call_args_list[1][0][0]) assert first["event"] == "delta" + assert first["chat_id"] == "chat-1" assert first["text"] == "part" assert first["stream_id"] == "sid" assert second["event"] == "stream_end" + assert second["chat_id"] == "chat-1" assert second["stream_id"] == "sid" @@ -248,7 +255,7 @@ async def test_send_non_connection_closed_exception_is_raised() -> None: channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) mock_ws = AsyncMock() mock_ws.send.side_effect = RuntimeError("unexpected") - channel._connections["chat-1"] = mock_ws + channel._attach(mock_ws, "chat-1") msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello") with pytest.raises(RuntimeError, match="unexpected"): @@ -596,3 +603,223 @@ async def test_websocket_requires_token_without_issue_path(bus: MagicMock) -> No finally: await channel.stop() await server_task + + +# -- Multi-chat multiplexing ------------------------------------------------- +# +# The multiplex protocol lets one WS connection route N logical chats over +# typed envelopes (`new_chat` / `attach` / `message`). Legacy frames must keep +# working on the connection's default chat_id. + + +@pytest.mark.asyncio +async def test_multiplex_legacy_still_works(bus: MagicMock) -> None: + port = 29930 + channel = _ch(bus, port=port) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + + try: + async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=legacy") as client: + ready = json.loads(await client.recv()) + default_chat = ready["chat_id"] + + # Plain text frame routes to default chat_id + await client.send("hello from legacy") + await asyncio.sleep(0.1) + inbound = bus.publish_inbound.call_args[0][0] + assert inbound.chat_id == default_chat + assert inbound.content == "hello from legacy" + + # {"content": ...} frame routes to default chat_id + await client.send(json.dumps({"content": "structured legacy"})) + await asyncio.sleep(0.1) + assert bus.publish_inbound.call_args[0][0].chat_id == default_chat + assert bus.publish_inbound.call_args[0][0].content == "structured legacy" + + # Outbound still reaches the legacy client, with chat_id annotated + await channel.send( + OutboundMessage(channel="websocket", chat_id=default_chat, content="reply") + ) + reply = json.loads(await client.recv()) + assert reply["event"] == "message" + assert reply["chat_id"] == default_chat + assert reply["text"] == "reply" + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_multiplex_new_chat_roundtrip(bus: MagicMock) -> None: + port = 29931 + channel = _ch(bus, port=port) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + + try: + async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=mp") as client: + ready = json.loads(await client.recv()) + default_chat = ready["chat_id"] + + await client.send(json.dumps({"type": "new_chat"})) + attached = json.loads(await client.recv()) + assert attached["event"] == "attached" + new_chat = attached["chat_id"] + assert new_chat and new_chat != default_chat + + # Send on the new chat via typed envelope + await client.send( + json.dumps({"type": "message", "chat_id": new_chat, "content": "hi on new"}) + ) + await asyncio.sleep(0.1) + inbound = bus.publish_inbound.call_args[0][0] + assert inbound.chat_id == new_chat + assert inbound.content == "hi on new" + + # Server pushes a message back; chat_id must match + await channel.send( + OutboundMessage(channel="websocket", chat_id=new_chat, content="ok") + ) + reply = json.loads(await client.recv()) + assert reply["event"] == "message" + assert reply["chat_id"] == new_chat + assert reply["text"] == "ok" + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None: + port = 29932 + channel = _ch(bus, port=port) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + + try: + async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=two") as client: + await client.recv() # ready + + await client.send(json.dumps({"type": "new_chat"})) + chat_a = json.loads(await client.recv())["chat_id"] + await client.send(json.dumps({"type": "new_chat"})) + chat_b = json.loads(await client.recv())["chat_id"] + assert chat_a != chat_b + + # Push A → client sees A only (FIFO over the single WS). + await channel.send( + OutboundMessage(channel="websocket", chat_id=chat_a, content="for-A") + ) + msg_a = json.loads(await client.recv()) + assert msg_a["chat_id"] == chat_a + assert msg_a["text"] == "for-A" + + # Push B → client sees B only. + await channel.send( + OutboundMessage(channel="websocket", chat_id=chat_b, content="for-B") + ) + msg_b = json.loads(await client.recv()) + assert msg_b["chat_id"] == chat_b + assert msg_b["text"] == "for-B" + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_multiplex_invalid_frames_return_error(bus: MagicMock) -> None: + port = 29933 + channel = _ch(bus, port=port) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + + try: + async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=bad") as client: + await client.recv() # ready + + # attach with bad chat_id + await client.send(json.dumps({"type": "attach", "chat_id": "has space"})) + err1 = json.loads(await client.recv()) + assert err1["event"] == "error" + + # message with missing content + await client.send(json.dumps({"type": "message", "chat_id": "abc", "content": ""})) + err2 = json.loads(await client.recv()) + assert err2["event"] == "error" + + # unknown type + await client.send(json.dumps({"type": "nope"})) + err3 = json.loads(await client.recv()) + assert err3["event"] == "error" + + # Connection survives: legacy frame still works. + await client.send("still-alive") + await asyncio.sleep(0.1) + bus.publish_inbound.assert_awaited() + assert bus.publish_inbound.call_args[0][0].content == "still-alive" + finally: + await channel.stop() + await server_task + + +@pytest.mark.asyncio +async def test_multiplex_cleanup_on_disconnect(bus: MagicMock) -> None: + port = 29934 + channel = _ch(bus, port=port) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + + try: + async with websockets.connect(f"ws://127.0.0.1:{port}/ws?client_id=dc") as client: + ready = json.loads(await client.recv()) + default_chat = ready["chat_id"] + await client.send(json.dumps({"type": "new_chat"})) + extra_chat = json.loads(await client.recv())["chat_id"] + assert default_chat in channel._subs + assert extra_chat in channel._subs + # Client gone. Server-side tracking must be empty. + await asyncio.sleep(0.2) + assert default_chat not in channel._subs + assert extra_chat not in channel._subs + assert not channel._conn_chats + assert not channel._conn_default + finally: + await channel.stop() + await server_task + + +def test_parse_envelope_detects_typed_frames() -> None: + assert _parse_envelope('{"type":"new_chat"}') == {"type": "new_chat"} + env = _parse_envelope('{"type":"message","chat_id":"abc","content":"hi"}') + assert env == {"type": "message", "chat_id": "abc", "content": "hi"} + + +def test_parse_envelope_rejects_legacy_and_garbage() -> None: + # No `type` field → legacy, caller falls back to _parse_inbound_payload. + assert _parse_envelope('{"content":"hi"}') is None + assert _parse_envelope("plain text") is None + assert _parse_envelope("{broken") is None + assert _parse_envelope("[1,2,3]") is None + # Non-string `type` is not a valid envelope. + assert _parse_envelope('{"type":123}') is None + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("abc", True), + ("a1b2_c:d-e", True), + ("x" * 64, True), + ("unified:default", True), + ("", False), + ("x" * 65, False), + ("has space", False), + ("a/b", False), + ("a.b", False), + (None, False), + (123, False), + ], +) +def test_is_valid_chat_id(value: Any, expected: bool) -> None: + assert _is_valid_chat_id(value) is expected diff --git a/tests/channels/test_websocket_integration.py b/tests/channels/test_websocket_integration.py index 8ff15866..ff0d5085 100644 --- a/tests/channels/test_websocket_integration.py +++ b/tests/channels/test_websocket_integration.py @@ -290,10 +290,11 @@ async def test_disconnected_client_cleanup(bus: MagicMock) -> None: async with WsTestClient("ws://127.0.0.1:29914/", client_id="tmp") as c: chat_id = (await c.recv_ready()).chat_id # disconnected + await asyncio.sleep(0.1) await ch.send(OutboundMessage( channel="websocket", chat_id=chat_id, content="orphan", )) - assert chat_id not in ch._connections + assert chat_id not in ch._subs finally: await ch.stop(); await t diff --git a/tests/channels/test_wecom_channel.py b/tests/channels/test_wecom_channel.py index b79c023b..a8ed3c0e 100644 --- a/tests/channels/test_wecom_channel.py +++ b/tests/channels/test_wecom_channel.py @@ -541,6 +541,50 @@ async def test_process_voice_message() -> None: assert "[voice]" in msg.content +@pytest.mark.asyncio +async def test_process_mixed_message() -> None: + """Mixed message: contains picture and text message types.""" + channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus()) + client = _FakeWeComClient() + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + f.write(b"\x89PNG\r\n") + saved = f.name + + client.download_file.return_value = (b"\x89PNG\r\n", "photo.png") + channel._client = client + + try: + with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(os.path.dirname(saved))): + frame = _FakeFrame(body={ + "msgid": "msg_mixed_1", + "chatid": "chat1", + "msgtype": "mixed", + "from": {"userid": "user1"}, + "mixed": { + "msg_item": [ + {"msgtype": "text", "text": {"content": "hello wecom"}}, + {"msgtype": "image", "image": {"url": "https://example.com/img.png", "aeskey": "key123"}} + ] + } + }) + await channel._process_message(frame, "mixed") + + msg = await channel.bus.consume_inbound() + assert msg.sender_id == "user1" + assert msg.chat_id == "chat1" + assert msg.content.startswith("hello wecom") + assert msg.metadata["msg_type"] == "mixed" + assert len(msg.media) == 1 + assert msg.media[0].endswith("photo.png") + assert "[image:" in msg.content + finally: + # Clean up any photo.png in tempdir + p = os.path.join(os.path.dirname(saved), "photo.png") + if os.path.exists(p): + os.unlink(p) + + @pytest.mark.asyncio async def test_process_message_deduplication() -> None: """Same msg_id is not processed twice.""" diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 3a847411..2b455fca 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -1003,3 +1003,185 @@ async def test_download_media_item_non_image_requires_aes_key_even_with_full_url assert saved_path is None channel._client.get.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Tests for media-send error classification (network vs non-network errors) +# --------------------------------------------------------------------------- + + +def _make_outbound_msg(chat_id: str = "wx-user", content: str = "", media: list | None = None): + """Build a minimal OutboundMessage-like object for send() tests.""" + from nanobot.bus.events import OutboundMessage + + return OutboundMessage( + channel="weixin", + chat_id=chat_id, + content=content, + media=media or [], + metadata={}, + ) + + +@pytest.mark.asyncio +async def test_send_media_timeout_error_propagates_without_text_fallback() -> None: + """httpx.TimeoutException during media send must re-raise immediately, + NOT fall back to _send_text (which would also fail during network issues).""" + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-1" + channel._send_media_file = AsyncMock(side_effect=httpx.TimeoutException("timed out")) + channel._send_text = AsyncMock() + + msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"]) + + with pytest.raises(httpx.TimeoutException, match="timed out"): + await channel.send(msg) + + # _send_text must NOT have been called as a fallback + channel._send_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_media_transport_error_propagates_without_text_fallback() -> None: + """httpx.TransportError during media send must re-raise immediately.""" + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-1" + channel._send_media_file = AsyncMock( + side_effect=httpx.TransportError("connection reset") + ) + channel._send_text = AsyncMock() + + msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"]) + + with pytest.raises(httpx.TransportError, match="connection reset"): + await channel.send(msg) + + channel._send_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_media_5xx_http_status_error_propagates_without_text_fallback() -> None: + """httpx.HTTPStatusError with a 5xx status must re-raise immediately.""" + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-1" + + fake_response = httpx.Response( + status_code=503, + request=httpx.Request("POST", "https://example.test/upload"), + ) + channel._send_media_file = AsyncMock( + side_effect=httpx.HTTPStatusError( + "Service Unavailable", request=fake_response.request, response=fake_response + ) + ) + channel._send_text = AsyncMock() + + msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"]) + + with pytest.raises(httpx.HTTPStatusError, match="Service Unavailable"): + await channel.send(msg) + + channel._send_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_media_4xx_http_status_error_falls_back_to_text() -> None: + """httpx.HTTPStatusError with a 4xx status should fall back to text, not re-raise.""" + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-1" + + fake_response = httpx.Response( + status_code=400, + request=httpx.Request("POST", "https://example.test/upload"), + ) + channel._send_media_file = AsyncMock( + side_effect=httpx.HTTPStatusError( + "Bad Request", request=fake_response.request, response=fake_response + ) + ) + channel._send_text = AsyncMock() + + msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"]) + + # Should NOT raise — 4xx is a client error, non-retryable + await channel.send(msg) + + # _send_text should have been called with the fallback message + channel._send_text.assert_awaited_once_with( + "wx-user", "[Failed to send: photo.jpg]", "ctx-1" + ) + + +@pytest.mark.asyncio +async def test_send_media_file_not_found_falls_back_to_text() -> None: + """FileNotFoundError (a non-network error) should fall back to text.""" + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-1" + channel._send_media_file = AsyncMock( + side_effect=FileNotFoundError("Media file not found: /tmp/missing.jpg") + ) + channel._send_text = AsyncMock() + + msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/missing.jpg"]) + + # Should NOT raise + await channel.send(msg) + + channel._send_text.assert_awaited_once_with( + "wx-user", "[Failed to send: missing.jpg]", "ctx-1" + ) + + +@pytest.mark.asyncio +async def test_send_media_value_error_falls_back_to_text() -> None: + """ValueError (e.g. unsupported format) should fall back to text.""" + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-1" + channel._send_media_file = AsyncMock( + side_effect=ValueError("Unsupported media format") + ) + channel._send_text = AsyncMock() + + msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/file.xyz"]) + + # Should NOT raise + await channel.send(msg) + + channel._send_text.assert_awaited_once_with( + "wx-user", "[Failed to send: file.xyz]", "ctx-1" + ) + + +@pytest.mark.asyncio +async def test_send_media_network_error_does_not_double_api_calls() -> None: + """During network issues, media send should make exactly 1 API call attempt, + not 2 (media + text fallback). Verify total call count.""" + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._context_tokens["wx-user"] = "ctx-1" + channel._send_media_file = AsyncMock( + side_effect=httpx.ConnectError("connection refused") + ) + channel._send_text = AsyncMock() + + msg = _make_outbound_msg(chat_id="wx-user", content="hello", media=["/tmp/img.png"]) + + with pytest.raises(httpx.ConnectError): + await channel.send(msg) + + # _send_media_file called once, _send_text never called + channel._send_media_file.assert_awaited_once() + channel._send_text.assert_not_awaited() diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index fd5429d8..5966d520 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -257,6 +257,28 @@ def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan(): assert config.get_api_base() == "https://ark.cn-beijing.volces.com/api/coding/v3" +def test_config_accepts_lm_studio_without_api_key_and_uses_default_localhost_api_base(): + config = Config.model_validate( + { + "agents": { + "defaults": { + "provider": "lm_studio", + "model": "local-model", + } + }, + "providers": { + "lmStudio": { + "apiKey": None, + } + }, + } + ) + + assert config.get_provider_name() == "lm_studio" + assert config.get_api_key() is None + assert config.get_api_base() == "http://localhost:1234/v1" + + def test_find_by_name_accepts_camel_case_and_hyphen_aliases(): assert find_by_name("volcengineCodingPlan") is not None assert find_by_name("volcengineCodingPlan").name == "volcengine_coding_plan" @@ -1159,6 +1181,153 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) assert "port 18792" in result.stdout +def test_gateway_health_endpoint_binds_and_serves_expected_responses( + monkeypatch, tmp_path: Path +) -> None: + config_file = _write_instance_config(tmp_path) + config = Config() + config.gateway.port = 18791 + captured: dict[str, object] = {} + + class _FakeDream: + model = None + max_batch_size = 0 + max_iterations = 0 + + async def run(self) -> None: + return None + + class _FakeAgentLoop: + def __init__(self, **_kwargs) -> None: + self.model = "test-model" + self.dream = _FakeDream() + + async def run(self) -> None: + await asyncio.Event().wait() + + async def close_mcp(self) -> None: + return None + + def stop(self) -> None: + return None + + class _FakeChannelManager: + def __init__(self, _config, _bus) -> None: + self.enabled_channels = ["telegram", "discord"] + + async def start_all(self) -> None: + await asyncio.Event().wait() + + async def stop_all(self) -> None: + return None + + class _FakeCronService: + def __init__(self, _store_path: Path) -> None: + self.on_job = None + + async def start(self) -> None: + return None + + def stop(self) -> None: + return None + + def status(self) -> dict[str, int]: + return {"jobs": 0} + + def register_system_job(self, _job) -> None: + return None + + class _FakeHeartbeatService: + def __init__(self, **_kwargs) -> None: + return None + + async def start(self) -> None: + return None + + def stop(self) -> None: + return None + + class _FakeServer: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False + + async def serve_forever(self) -> None: + raise _StopGatewayError("stop") + + async def _fake_start_server(handler, host: str, port: int): + captured["handler"] = handler + captured["host"] = host + captured["port"] = port + return _FakeServer() + + class _FakeReader: + def __init__(self, payload: bytes) -> None: + self.payload = payload + + async def read(self, _size: int) -> bytes: + return self.payload + + class _FakeWriter: + def __init__(self) -> None: + self.output = b"" + self.closed = False + + def write(self, data: bytes) -> None: + self.output += data + + async def drain(self) -> None: + return None + + def close(self) -> None: + self.closed = True + + _patch_cli_command_runtime( + monkeypatch, + config, + message_bus=lambda: object(), + session_manager=lambda _workspace: object(), + ) + monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager) + monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService) + monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService) + monkeypatch.setattr("asyncio.start_server", _fake_start_server) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert result.exit_code == 0 + assert captured["host"] == "127.0.0.1" + assert captured["port"] == 18791 + assert "Health endpoint: http://127.0.0.1:18791/health" in result.stdout + + def _call_handler(path: str) -> tuple[str, _FakeWriter]: + request = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode() + writer = _FakeWriter() + handler = captured["handler"] + assert callable(handler) + asyncio.run(handler(_FakeReader(request), writer)) + return writer.output.decode(), writer + + root_response, root_writer = _call_handler("/") + assert root_writer.closed is True + assert "HTTP/1.0 404 Not Found" in root_response + assert root_response.endswith("\r\n\r\nNot Found") + + health_response, health_writer = _call_handler("/health") + assert health_writer.closed is True + assert "HTTP/1.0 200 OK" in health_response + health_body = json.loads(health_response.split("\r\n\r\n", 1)[1]) + assert health_body == {"status": "ok"} + + missing_response, missing_writer = _call_handler("/missing") + assert missing_writer.closed is True + assert "HTTP/1.0 404 Not Found" in missing_response + assert missing_response.endswith("\r\n\r\nNot Found") + + def test_serve_uses_api_config_defaults_and_workspace_override( monkeypatch, tmp_path: Path ) -> None: diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py index 697d5fc1..bc714790 100644 --- a/tests/cli/test_restart_command.py +++ b/tests/cli/test_restart_command.py @@ -140,6 +140,7 @@ class TestRestartCommand: loop.consolidator.estimate_session_prompt_tokens = MagicMock( return_value=(20500, "tiktoken") ) + loop.subagents.get_running_count_by_session.return_value = 0 msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status") @@ -148,11 +149,36 @@ class TestRestartCommand: assert response is not None assert "Model: test-model" in response.content assert "Tokens: 0 in / 0 out" in response.content - assert "Context: 20k/65k (31%)" in response.content + assert "Context: 20k/65k (31% of input budget)" in response.content assert "Session: 3 messages" in response.content assert "Uptime: 2m 5s" in response.content + assert "Tasks: 0 active" in response.content assert response.metadata == {"render_as": "text"} + @pytest.mark.asyncio + async def test_status_counts_running_dispatch_and_subagent_tasks(self): + loop, _bus = _make_loop() + session = MagicMock() + session.get_history.return_value = [{"role": "user"}] + loop.sessions.get_or_create.return_value = session + loop.consolidator.estimate_session_prompt_tokens = MagicMock( + return_value=(1000, "tiktoken") + ) + + running_task = MagicMock() + running_task.done.return_value = False + finished_task = MagicMock() + finished_task.done.return_value = True + + msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status") + loop._active_tasks[msg.session_key] = [running_task, finished_task] + loop.subagents.get_running_count_by_session.return_value = 2 + + response = await loop._process_message(msg) + + assert response is not None + assert "Tasks: 3 active" in response.content + @pytest.mark.asyncio async def test_run_agent_loop_resets_usage_when_provider_omits_it(self): loop, _bus = _make_loop() @@ -179,6 +205,7 @@ class TestRestartCommand: loop.consolidator.estimate_session_prompt_tokens = MagicMock( return_value=(0, "none") ) + loop.subagents.get_running_count_by_session.return_value = 0 response = await loop._process_message( InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status") @@ -186,7 +213,8 @@ class TestRestartCommand: assert response is not None assert "Tokens: 1200 in / 34 out" in response.content - assert "Context: 1k/65k (1%)" in response.content + assert "Context: 1k/65k (1% of input budget)" in response.content + assert "Tasks: 0 active" in response.content @pytest.mark.asyncio async def test_process_direct_preserves_render_metadata(self): @@ -195,6 +223,7 @@ class TestRestartCommand: session.get_history.return_value = [] loop.sessions.get_or_create.return_value = session loop.subagents.get_running_count.return_value = 0 + loop.subagents.get_running_count_by_session.return_value = 0 response = await loop.process_direct("/status", session_key="cli:test") diff --git a/tests/config/test_config_migration.py b/tests/config/test_config_migration.py index add602c5..b27926ec 100644 --- a/tests/config/test_config_migration.py +++ b/tests/config/test_config_migration.py @@ -140,6 +140,71 @@ def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) assert saved["channels"]["qq"]["msgFormat"] == "plain" +def test_load_config_migrates_legacy_my_tool_keys(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "tools": { + "myEnabled": False, + "mySet": True, + } + } + ), + encoding="utf-8", + ) + + config = load_config(config_path) + + assert config.tools.my.enable is False + assert config.tools.my.allow_set is True + + +def test_save_config_rewrites_legacy_my_tool_keys(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "tools": { + "myEnabled": False, + "mySet": True, + } + } + ), + encoding="utf-8", + ) + + config = load_config(config_path) + save_config(config, config_path) + saved = json.loads(config_path.read_text(encoding="utf-8")) + + tools = saved["tools"] + assert "myEnabled" not in tools + assert "mySet" not in tools + assert tools["my"] == {"enable": False, "allowSet": True} + + +def test_new_my_tool_keys_take_precedence_over_legacy(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "tools": { + "myEnabled": False, + "mySet": False, + "my": {"enable": True, "allowSet": True}, + } + } + ), + encoding="utf-8", + ) + + config = load_config(config_path) + + assert config.tools.my.enable is True + assert config.tools.my.allow_set is True + + def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -> None: whitelisted = tmp_path / "whitelisted.json" whitelisted.write_text( diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py index 86f3055c..a3ee9b1a 100644 --- a/tests/cron/test_cron_tool_list.py +++ b/tests/cron/test_cron_tool_list.py @@ -7,7 +7,6 @@ import pytest from nanobot.agent.tools.cron import CronTool from nanobot.cron.service import CronService from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule -from tests.test_openai_api import pytest_plugins def _make_tool(tmp_path) -> CronTool: @@ -346,6 +345,47 @@ def test_add_job_can_disable_delivery(tmp_path) -> None: assert job.payload.deliver is False +def test_cron_schema_advertises_action_specific_requirements(tmp_path) -> None: + tool = _make_tool(tmp_path) + + assert tool.parameters["required"] == ["action"] + assert tool.parameters["oneOf"] == [ + { + "properties": { + "action": {"enum": ["add"]}, + "message": {"type": "string", "minLength": 1}, + }, + "required": ["action", "message"], + }, + { + "properties": {"action": {"enum": ["list"]}}, + "required": ["action"], + }, + { + "properties": {"action": {"enum": ["remove"]}}, + "required": ["action", "job_id"], + }, + ] + + +def test_validate_params_requires_message_only_for_add(tmp_path) -> None: + tool = _make_tool(tmp_path) + + assert "message is required when action='add'" in tool.validate_params({"action": "add"}) + assert tool.validate_params({"action": "list"}) == [] + assert "job_id is required when action='remove'" in tool.validate_params({"action": "remove"}) + + +def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None: + tool = _make_tool(tmp_path) + tool.set_context("telegram", "chat-1") + + result = tool._add_job(None, "", 60, None, None, None) + + assert "action='add' requires a non-empty 'message'" in result + assert "Retry including message=" in result + + def test_list_excludes_disabled_jobs(tmp_path) -> None: tool = _make_tool(tmp_path) job = tool._cron.add_job( diff --git a/tests/cron/test_cron_tool_schema_contract.py b/tests/cron/test_cron_tool_schema_contract.py new file mode 100644 index 00000000..681cde3c --- /dev/null +++ b/tests/cron/test_cron_tool_schema_contract.py @@ -0,0 +1,99 @@ +"""Regression tests for the cron tool's JSON-schema / runtime contract (#3113). + +The schema advertised ``required=["action"]`` while ``_add_job`` rejected empty +``message``; LLMs rationally omitted ``message`` and looped on the runtime +error. The fix keeps ``required=["action"]`` (so ``list``/``remove`` stay +callable) but states the per-action requirement in each field's description +and tightens the runtime error for ``add`` without ``message``. +""" + +from __future__ import annotations + +import pytest + +from nanobot.agent.tools.cron import CronTool +from nanobot.agent.tools.registry import ToolRegistry + + +class _SvcStub: + """Minimal CronService stand-in; we only exercise schema/dispatch paths.""" + + def list_jobs(self): + return [] + + def get_job(self, _job_id): + return None + + def remove_job(self, _job_id): + return "not-found" + + def add_job(self, **kwargs): + class _J: + pass + + j = _J() + j.id = "id1" + j.name = kwargs.get("name", "x") + return j + + +@pytest.fixture +def registry() -> ToolRegistry: + tool = CronTool(_SvcStub(), default_timezone="UTC") + tool.set_context("channel", "chat-id") + reg = ToolRegistry() + reg.register(tool) + return reg + + +class TestSchemaContract: + def test_list_accepted_without_message(self, registry: ToolRegistry) -> None: + # action='list' must pass schema validation with nothing but 'action'. + _, _, err = registry.prepare_call("cron", {"action": "list"}) + assert err is None + + def test_remove_accepted_without_message(self, registry: ToolRegistry) -> None: + # action='remove' must pass schema validation with just 'action' + 'job_id'. + _, _, err = registry.prepare_call("cron", {"action": "remove", "job_id": "abc"}) + assert err is None + + def test_add_with_message_accepted(self, registry: ToolRegistry) -> None: + _, _, err = registry.prepare_call( + "cron", {"action": "add", "message": "ping", "at": "2030-01-01T00:00:00"} + ) + assert err is None + + def test_add_without_message_surfaces_actionable_runtime_error( + self, registry: ToolRegistry + ) -> None: + # Schema permits omitting message; the runtime must return a message + # that tells the LLM exactly what's missing and how to retry, so it + # doesn't loop like #3113 reports. + import asyncio + + tool = registry._tools["cron"] # type: ignore[attr-defined] + out = asyncio.run(tool.execute(action="add", at="2030-01-01T00:00:00")) + assert "message" in out + assert "add" in out + assert "Retry" in out or "retry" in out + + +class TestSchemaSelfDescribesRequirements: + def test_message_description_flags_add_requirement(self) -> None: + # LLMs rely on field descriptions to infer when something is actually + # needed. Without this hint, #3113's loop returns. + tool = CronTool(_SvcStub()) + desc = tool.parameters["properties"]["message"]["description"] + assert "REQUIRED" in desc and "action='add'" in desc + + def test_job_id_description_flags_remove_requirement(self) -> None: + tool = CronTool(_SvcStub()) + desc = tool.parameters["properties"]["job_id"]["description"] + assert "REQUIRED" in desc and "action='remove'" in desc + + def test_top_level_required_stays_narrow(self) -> None: + # If 'message' or 'job_id' ever creep back into top-level required, + # list/remove start failing schema validation (the bug PR #3163 v1 + # accidentally introduced). + tool = CronTool(_SvcStub()) + assert tool.parameters["required"] == ["action"] diff --git a/tests/providers/test_custom_provider.py b/tests/providers/test_custom_provider.py index d2a9f424..85314dc7 100644 --- a/tests/providers/test_custom_provider.py +++ b/tests/providers/test_custom_provider.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from unittest.mock import patch from nanobot.providers.openai_compat_provider import OpenAICompatProvider +from nanobot.providers.registry import find_by_name def test_custom_provider_parse_handles_empty_choices() -> None: @@ -53,3 +54,20 @@ def test_custom_provider_parse_chunks_accepts_plain_text_chunks() -> None: assert result.finish_reason == "stop" assert result.content == "hello world" + + +def test_local_provider_502_error_includes_reachability_hint() -> None: + spec = find_by_name("ollama") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider(api_base="http://localhost:11434/v1", spec=spec) + + result = provider._handle_error( + Exception("Error code: 502"), + spec=spec, + api_base="http://localhost:11434/v1", + ) + + assert result.finish_reason == "error" + assert "local model endpoint" in result.content + assert "http://localhost:11434/v1" in result.content + assert "proxy/tunnel" in result.content diff --git a/tests/providers/test_enforce_role_alternation.py b/tests/providers/test_enforce_role_alternation.py index 333c5d04..1195c258 100644 --- a/tests/providers/test_enforce_role_alternation.py +++ b/tests/providers/test_enforce_role_alternation.py @@ -1,6 +1,6 @@ """Tests for LLMProvider._enforce_role_alternation.""" -from nanobot.providers.base import LLMProvider +from nanobot.providers.base import LLMProvider, _SYNTHETIC_USER_CONTENT class TestEnforceRoleAlternation: @@ -195,3 +195,46 @@ class TestEnforceRoleAlternation: assert result[3]["role"] == "user" assert "And 3+3?" in result[3]["content"] assert "(please be quick)" in result[3]["content"] + + def test_leading_assistant_after_system_inserts_synthetic_user(self): + """When the first non-system message is assistant (no tool_calls), a + synthetic user message is inserted to prevent GLM error 1214.""" + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "assistant", "content": "previous reply"}, + {"role": "tool", "tool_call_id": "tc_1", "content": "result"}, + {"role": "assistant", "content": "after tool"}, + ] + result = LLMProvider._enforce_role_alternation(msgs) + non_system = [m for m in result if m["role"] != "system"] + assert non_system[0]["role"] == "user" + assert non_system[0]["content"] == _SYNTHETIC_USER_CONTENT + # The original assistant should follow. + assert non_system[1]["role"] == "assistant" + + def test_leading_assistant_with_tool_calls_not_patched(self): + """An assistant message with tool_calls at the start is left as-is + because tool messages will follow and some providers accept this.""" + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "tc_1", "type": "function", "function": {"name": "ls", "arguments": "{}"}} + ]}, + {"role": "tool", "tool_call_id": "tc_1", "content": "result"}, + ] + result = LLMProvider._enforce_role_alternation(msgs) + non_system = [m for m in result if m["role"] != "system"] + # The assistant has tool_calls so it should NOT be patched. + assert non_system[0]["role"] == "assistant" + assert non_system[0].get("tool_calls") is not None + + def test_user_after_system_not_patched(self): + """Normal system→user sequence is not modified.""" + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + result = LLMProvider._enforce_role_alternation(msgs) + assert result[1]["role"] == "user" + assert result[1]["content"] == "hello" diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index ec2581cd..8304aae8 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -584,6 +584,78 @@ def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() - assert sanitized[2]["tool_call_id"] == "3ec83c30d" +def test_openai_compat_stringifies_dict_tool_arguments() -> None: + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + sanitized = provider._sanitize_messages([ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "exec", "arguments": {"cmd": "ls -la"}}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "name": "exec", "content": "ok"}, + {"role": "user", "content": "done"}, + ]) + + assert sanitized[1]["tool_calls"][0]["function"]["arguments"] == '{"cmd": "ls -la"}' + + +def test_openai_compat_repairs_non_json_tool_arguments_string() -> None: + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + sanitized = provider._sanitize_messages([ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "exec", "arguments": "{'cmd': 'pwd'}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "name": "exec", "content": "ok"}, + {"role": "user", "content": "done"}, + ]) + + assert sanitized[1]["tool_calls"][0]["function"]["arguments"] == '{"cmd": "pwd"}' + + +def test_openai_compat_defaults_missing_tool_arguments_to_empty_object() -> None: + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + sanitized = provider._sanitize_messages([ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "exec"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "name": "exec", "content": "ok"}, + {"role": "user", "content": "done"}, + ]) + + assert sanitized[1]["tool_calls"][0]["function"]["arguments"] == "{}" + + @pytest.mark.asyncio async def test_openai_compat_stream_watchdog_returns_error_on_stall(monkeypatch) -> None: monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0") @@ -658,3 +730,50 @@ def test_openai_no_thinking_extra_body() -> None: """Non-thinking providers should never get extra_body for thinking.""" kw = _build_kwargs_for("openai", "gpt-4o", reasoning_effort="medium") assert "extra_body" not in kw + + +def test_kimi_k25_thinking_enabled() -> None: + """kimi-k2.5 with reasoning_effort set should opt in to thinking.""" + kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="medium") + assert kw.get("extra_body") == {"thinking": {"type": "enabled"}} + + +def test_kimi_k25_thinking_disabled_for_minimal() -> None: + """reasoning_effort='minimal' maps to thinking disabled for kimi-k2.5.""" + kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="minimal") + assert kw.get("extra_body") == {"thinking": {"type": "disabled"}} + + +def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None: + """Without reasoning_effort the thinking param must not be injected.""" + kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort=None) + assert "extra_body" not in kw + + +def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None: + """OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking.""" + kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium") + assert kw.get("extra_body") == {"thinking": {"type": "enabled"}} + +def test_kimi_k25_thinking_disabled_with_openrouter_prefix() -> None: + """OpenRouter names must NOT trigger thinking without reasoning_effort.""" + kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort=None) + assert "extra_body" not in kw + + +def test_kimi_k26_code_preview_thinking_enabled() -> None: + """k2.6-code-preview also supports thinking; should behave like k2.5.""" + kw = _build_kwargs_for("moonshot", "k2.6-code-preview", reasoning_effort="high") + assert kw.get("extra_body") == {"thinking": {"type": "enabled"}} + + +def test_kimi_k2_series_no_thinking_injection() -> None: + """kimi-k2 (non-thinking) models must NOT receive extra_body.thinking.""" + kw = _build_kwargs_for("moonshot", "kimi-k2", reasoning_effort="high") + assert "extra_body" not in kw + + +def test_kimi_k2_thinking_series_no_thinking_injection() -> None: + """kimi-k2-thinking series models must NOT receive extra_body.thinking.""" + kw = _build_kwargs_for("moonshot", "kimi-k2-thinking", reasoning_effort="high") + assert "extra_body" not in kw diff --git a/tests/providers/test_llm_response.py b/tests/providers/test_llm_response.py new file mode 100644 index 00000000..ca9644dc --- /dev/null +++ b/tests/providers/test_llm_response.py @@ -0,0 +1,57 @@ +"""Regression tests for ``LLMResponse.should_execute_tools`` (#3220). + +The agent used to execute tool calls whenever ``has_tool_calls`` was true, regardless +of ``finish_reason``. Non-compliant API gateways that inject empty / bogus tool calls +under ``refusal`` / ``content_filter`` / ``error`` pushed the agent into a tight loop +until ``max_iterations`` fired. ``should_execute_tools`` is the single guard that +every tool-execution site now funnels through. +""" + +from __future__ import annotations + +import pytest + +from nanobot.providers.base import LLMResponse, ToolCallRequest + + +def _response(finish_reason: str, *, with_tool_call: bool = True) -> LLMResponse: + tool_calls = ( + [ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})] + if with_tool_call + else [] + ) + return LLMResponse(content=None, tool_calls=tool_calls, finish_reason=finish_reason) + + +class TestShouldExecuteTools: + def test_no_tool_calls_never_executes(self) -> None: + # No tool calls present -> guard must reject regardless of finish_reason. + for reason in ("tool_calls", "stop", "length", "error", "refusal", "content_filter"): + resp = _response(reason, with_tool_call=False) + assert resp.should_execute_tools is False, f"rejected for finish_reason={reason!r}" + + def test_tool_calls_with_tool_calls_reason_executes(self) -> None: + # The canonical case: provider explicitly signals tool intent. + resp = _response("tool_calls") + assert resp.has_tool_calls is True + assert resp.should_execute_tools is True + + def test_tool_calls_with_stop_reason_executes(self) -> None: + # Some compliant providers emit "stop" together with tool_calls; the + # guard must accept this to avoid breaking real tool-calling flows. + # See openai_compat_provider.py:~633,678 where ("tool_calls", "stop") + # are both treated as terminal tool-call states. + resp = _response("stop") + assert resp.should_execute_tools is True + + @pytest.mark.parametrize( + "anomalous_reason", + ["refusal", "content_filter", "error", "length", "function_call", ""], + ) + def test_tool_calls_under_anomalous_reason_blocked(self, anomalous_reason: str) -> None: + # This is the #3220 bug: gateways injecting tool_calls under any of these + # finish_reasons must not cause execution. Blocking here is what prevents + # the infinite empty tool-call loop. + resp = _response(anomalous_reason) + assert resp.has_tool_calls is True + assert resp.should_execute_tools is False diff --git a/tests/providers/test_minimax_anthropic_provider.py b/tests/providers/test_minimax_anthropic_provider.py new file mode 100644 index 00000000..286b8901 --- /dev/null +++ b/tests/providers/test_minimax_anthropic_provider.py @@ -0,0 +1,21 @@ +"""Tests for the MiniMax Anthropic provider registration.""" + +from nanobot.config.schema import ProvidersConfig +from nanobot.providers.registry import PROVIDERS + + +def test_minimax_anthropic_config_field_exists(): + """ProvidersConfig should expose a minimax_anthropic field.""" + config = ProvidersConfig() + assert hasattr(config, "minimax_anthropic") + + +def test_minimax_anthropic_provider_in_registry(): + """MiniMax Anthropic endpoint should be registered with Anthropic backend.""" + specs = {s.name: s for s in PROVIDERS} + assert "minimax_anthropic" in specs + + minimax_anthropic = specs["minimax_anthropic"] + assert minimax_anthropic.env_key == "MINIMAX_API_KEY" + assert minimax_anthropic.backend == "anthropic" + assert minimax_anthropic.default_api_base == "https://api.minimax.io/anthropic" diff --git a/tests/providers/test_provider_retry.py b/tests/providers/test_provider_retry.py index 2ef784a3..add5e224 100644 --- a/tests/providers/test_provider_retry.py +++ b/tests/providers/test_provider_retry.py @@ -87,6 +87,33 @@ async def test_chat_with_retry_returns_final_error_after_retries(monkeypatch) -> assert delays == [1, 2, 4] +@pytest.mark.asyncio +async def test_chat_with_retry_emits_terminal_progress_when_standard_retries_exhaust(monkeypatch) -> None: + provider = ScriptedProvider([ + LLMResponse(content="429 rate limit a", finish_reason="error"), + LLMResponse(content="429 rate limit b", finish_reason="error"), + LLMResponse(content="429 rate limit c", finish_reason="error"), + LLMResponse(content="503 final server error", finish_reason="error"), + ]) + progress: list[str] = [] + + async def _fake_sleep(delay: int) -> None: + return None + + async def _progress(msg: str) -> None: + progress.append(msg) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + response = await provider.chat_with_retry( + messages=[{"role": "user", "content": "hello"}], + on_retry_wait=_progress, + ) + + assert response.content == "503 final server error" + assert progress[-1] == "Model request failed after 4 retries, giving up." + + @pytest.mark.asyncio async def test_chat_with_retry_preserves_cancelled_error() -> None: provider = ScriptedProvider([asyncio.CancelledError()]) @@ -469,3 +496,67 @@ async def test_persistent_retry_aborts_after_ten_identical_transient_errors(monk assert response.content == "429 rate limit" assert provider.calls == 10 assert delays == [1, 2, 4, 4, 4, 4, 4, 4, 4] + + +@pytest.mark.asyncio +async def test_persistent_retry_emits_terminal_progress_on_identical_error_limit(monkeypatch) -> None: + provider = ScriptedProvider([ + *[LLMResponse(content="429 rate limit", finish_reason="error") for _ in range(10)], + ]) + progress: list[str] = [] + + async def _fake_sleep(delay: float) -> None: + return None + + async def _progress(msg: str) -> None: + progress.append(msg) + + monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep) + + response = await provider.chat_with_retry( + messages=[{"role": "user", "content": "hello"}], + retry_mode="persistent", + on_retry_wait=_progress, + ) + + assert response.finish_reason == "error" + assert progress[-1] == "Persistent retry stopped after 10 identical errors." + + +@pytest.mark.asyncio +async def test_chat_with_retry_normalizes_explicit_none_max_tokens() -> None: + """Explicit max_tokens=None must fall back to generation defaults. + + Regression for #3102: callers that construct AgentRunSpec with + max_tokens=None propagate None into chat_with_retry, which used to + reach ``_build_kwargs`` and crash on ``max(1, None)``. + """ + provider = ScriptedProvider([LLMResponse(content="ok")]) + + response = await provider.chat_with_retry( + messages=[{"role": "user", "content": "hi"}], + max_tokens=None, + temperature=None, + ) + + assert response.content == "ok" + # Generation settings default to 4096 / 0.7; explicit None should + # have been replaced before reaching chat(). + assert provider.last_kwargs["max_tokens"] == 4096 + assert provider.last_kwargs["temperature"] == 0.7 + + +@pytest.mark.asyncio +async def test_chat_stream_with_retry_normalizes_explicit_none_max_tokens() -> None: + """chat_stream_with_retry must apply the same None-guard as chat_with_retry.""" + provider = ScriptedProvider([LLMResponse(content="ok")]) + + response = await provider.chat_stream_with_retry( + messages=[{"role": "user", "content": "hi"}], + max_tokens=None, + temperature=None, + ) + + assert response.content == "ok" + assert provider.last_kwargs["max_tokens"] == 4096 + assert provider.last_kwargs["temperature"] == 0.7 diff --git a/tests/test_api_attachment.py b/tests/test_api_attachment.py new file mode 100644 index 00000000..92e09ef8 --- /dev/null +++ b/tests/test_api_attachment.py @@ -0,0 +1,496 @@ +"""Tests for API file upload functionality (JSON base64 + multipart).""" + +from __future__ import annotations + +import base64 +from io import BytesIO +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio + +from nanobot.api.server import ( + _FileSizeExceeded, + _parse_json_content, + _save_base64_data_url, + create_app, +) +from nanobot.utils.document import extract_documents + +try: + from aiohttp.test_utils import TestClient, TestServer + + HAS_AIOHTTP = True +except ImportError: + HAS_AIOHTTP = False + +pytest_plugins = ("pytest_asyncio",) + + +def _make_mock_agent(response_text: str = "mock response") -> MagicMock: + agent = MagicMock() + agent.process_direct = AsyncMock(return_value=response_text) + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + return agent + + +@pytest.fixture +def mock_agent(): + return _make_mock_agent() + + +@pytest.fixture +def app(mock_agent): + return create_app(mock_agent, model_name="test-model", request_timeout=10.0) + + +@pytest_asyncio.fixture +async def aiohttp_client(): + clients: list[TestClient] = [] + + async def _make_client(app): + client = TestClient(TestServer(app)) + await client.start_server() + clients.append(client) + return client + + try: + yield _make_client + finally: + for client in clients: + await client.close() + + +# --------------------------------------------------------------------------- +# Helper function tests +# --------------------------------------------------------------------------- + +def test_save_base64_data_url_saves_png(tmp_path) -> None: + """Saving a base64 data URL creates a file with correct extension.""" + b64_data = base64.b64encode(b"fake png data").decode() + data_url = f"data:image/png;base64,{b64_data}" + result = _save_base64_data_url(data_url, tmp_path) + assert result is not None + assert result.endswith(".png") + assert (tmp_path / result.replace(str(tmp_path) + "/", "")).read_bytes() == b"fake png data" + + +def test_save_base64_data_url_handles_invalid_b64(tmp_path) -> None: + """Invalid base64 returns None.""" + result = _save_base64_data_url("data:image/png;base64,not-valid-base64!!!", tmp_path) + assert result is None + + +def test_save_base64_data_url_handles_unknown_mime(tmp_path) -> None: + """Unknown MIME type defaults to .bin.""" + b64_data = base64.b64encode(b"some data").decode() + data_url = f"data:unknown/type;base64,{b64_data}" + result = _save_base64_data_url(data_url, tmp_path) + assert result is not None + assert result.endswith(".bin") + + +def test_save_base64_data_url_rejects_oversized_payload(tmp_path) -> None: + """Base64 uploads should respect the same per-file limit as multipart.""" + large_payload = base64.b64encode(b"x" * (11 * 1024 * 1024)).decode() + data_url = f"data:image/png;base64,{large_payload}" + + with pytest.raises(_FileSizeExceeded, match="10MB limit"): + _save_base64_data_url(data_url, tmp_path) + + +def test_parse_json_content_extracts_text_and_media(tmp_path) -> None: + """Parse JSON with text + base64 image saves image and returns paths.""" + b64_data = base64.b64encode(b"img").decode() + body = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe this"}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_data}"}}, + ], + } + ] + } + import os + original_cwd = os.getcwd() + os.chdir(tmp_path) + + try: + text, media_paths = _parse_json_content(body) + assert text == "describe this" + assert len(media_paths) == 1 + finally: + os.chdir(original_cwd) + + +def test_parse_json_content_plain_text_only() -> None: + """Plain text string content returns no media.""" + body = {"messages": [{"role": "user", "content": "hello"}]} + text, media_paths = _parse_json_content(body) + assert text == "hello" + assert media_paths == [] + + +def test_parse_json_content_validates_single_message() -> None: + """Multiple messages raise ValueError.""" + body = { + "messages": [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "second"}, + ] + } + with pytest.raises(ValueError, match="single user message"): + _parse_json_content(body) + + +def test_parse_json_content_validates_user_role() -> None: + """Non-user role raises ValueError.""" + body = {"messages": [{"role": "system", "content": "you are a bot"}]} + with pytest.raises(ValueError, match="single user message"): + _parse_json_content(body) + + +def test_parse_json_content_rejects_oversized_base64_file(tmp_path) -> None: + """Oversized JSON data URLs should fail before writing to disk.""" + large_payload = base64.b64encode(b"x" * (11 * 1024 * 1024)).decode() + body = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe"}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{large_payload}"}}, + ], + } + ] + } + import os + original_cwd = os.getcwd() + os.chdir(tmp_path) + + try: + with pytest.raises(_FileSizeExceeded, match="10MB limit"): + _parse_json_content(body) + finally: + os.chdir(original_cwd) + + +# --------------------------------------------------------------------------- +# Multipart upload tests +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_multipart_upload_saves_file(aiohttp_client, mock_agent, tmp_path) -> None: + """Multipart upload saves file to media dir and passes path to process_direct.""" + import os + original_cwd = os.getcwd() + os.chdir(tmp_path) + + try: + app = create_app(mock_agent, model_name="m") + client = await aiohttp_client(app) + + file_data = b"test file content" + data = BytesIO(file_data) + + resp = await client.post( + "/v1/chat/completions", + data={"message": "analyze this", "files": data}, + ) + assert resp.status == 200 + call_kwargs = mock_agent.process_direct.call_args.kwargs + assert call_kwargs["content"] == "analyze this" + assert len(call_kwargs.get("media") or []) == 1 + finally: + os.chdir(original_cwd) + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_multipart_multiple_files(aiohttp_client, mock_agent, tmp_path) -> None: + """Multipart upload with multiple files saves all and passes paths.""" + import os + original_cwd = os.getcwd() + os.chdir(tmp_path) + + try: + app = create_app(mock_agent, model_name="m") + client = await aiohttp_client(app) + + # Note: aiohttp test client has limited multipart support + # This test verifies the basic flow + file_data = b"test content" + data = BytesIO(file_data) + + resp = await client.post( + "/v1/chat/completions", + data={"message": "analyze", "files": data}, + ) + assert resp.status == 200 + finally: + os.chdir(original_cwd) + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_multipart_file_size_limit(aiohttp_client, mock_agent, tmp_path) -> None: + """File exceeding MAX_FILE_SIZE returns 413.""" + import os + original_cwd = os.getcwd() + os.chdir(tmp_path) + + try: + app = create_app(mock_agent, model_name="m") + client = await aiohttp_client(app) + + # Create a file larger than 10MB + large_data = b"x" * (11 * 1024 * 1024) + data = BytesIO(large_data) + + resp = await client.post( + "/v1/chat/completions", + data={"message": "analyze", "files": data}, + ) + assert resp.status == 413 + finally: + os.chdir(original_cwd) + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_multipart_defaults_text_when_missing(aiohttp_client, mock_agent, tmp_path) -> None: + """Multipart without message field uses default text.""" + import os + original_cwd = os.getcwd() + os.chdir(tmp_path) + + try: + app = create_app(mock_agent, model_name="m") + client = await aiohttp_client(app) + + file_data = b"content" + data = BytesIO(file_data) + + resp = await client.post( + "/v1/chat/completions", + data={"files": data}, + ) + assert resp.status == 200 + call_kwargs = mock_agent.process_direct.call_args.kwargs + assert call_kwargs["content"] == "请分析上传的文件" + finally: + os.chdir(original_cwd) + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_multipart_with_session_id(aiohttp_client, mock_agent, tmp_path) -> None: + """Multipart upload with session_id uses custom session key.""" + import os + original_cwd = os.getcwd() + os.chdir(tmp_path) + + try: + app = create_app(mock_agent, model_name="m") + client = await aiohttp_client(app) + + file_data = b"content" + data = BytesIO(file_data) + + resp = await client.post( + "/v1/chat/completions", + data={"message": "hello", "session_id": "my-session", "files": data}, + ) + assert resp.status == 200 + call_kwargs = mock_agent.process_direct.call_args.kwargs + assert call_kwargs["session_key"] == "api:my-session" + finally: + os.chdir(original_cwd) + + +# --------------------------------------------------------------------------- +# Backward compatibility tests +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_plain_text_backward_compat(aiohttp_client, mock_agent) -> None: + """Plain text JSON request (no media) works as before.""" + app = create_app(mock_agent, model_name="m") + client = await aiohttp_client(app) + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hello world"}]}, + ) + assert resp.status == 200 + body = await resp.json() + assert body["choices"][0]["message"]["content"] == "mock response" + call_kwargs = mock_agent.process_direct.call_args.kwargs + assert call_kwargs["content"] == "hello world" + assert call_kwargs.get("media") is None + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_json_base64_image_upload(aiohttp_client, mock_agent, tmp_path) -> None: + """JSON request with base64 data URL saves file and passes path.""" + import os + original_cwd = os.getcwd() + os.chdir(tmp_path) + + try: + app = create_app(mock_agent, model_name="m") + client = await aiohttp_client(app) + + # Use valid base64 for a tiny PNG (1x1 transparent pixel) + tiny_png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" + + resp = await client.post( + "/v1/chat/completions", + json={ + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this"}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{tiny_png_b64}"}}, + ], + } + ] + }, + ) + assert resp.status == 200 + call_kwargs = mock_agent.process_direct.call_args.kwargs + assert call_kwargs["content"] == "what is this" + assert len(call_kwargs.get("media", [])) == 1 + finally: + os.chdir(original_cwd) + + +# --------------------------------------------------------------------------- +# extract_documents tests (now in nanobot.utils.document) +# --------------------------------------------------------------------------- + +def test_extract_documents_separates_images_from_docs(tmp_path) -> None: + """Images stay in media; document text is appended to content.""" + from docx import Document + + png = tmp_path / "chart.png" + png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + + doc = Document() + doc.add_paragraph("Quarterly revenue is $5M") + docx_path = tmp_path / "report.docx" + doc.save(docx_path) + + text, image_paths = extract_documents("summarize", [str(png), str(docx_path)]) + assert len(image_paths) == 1 + assert image_paths[0] == str(png) + assert "Quarterly revenue" in text + assert "summarize" in text + + +def test_extract_documents_skips_extraction_errors(tmp_path, monkeypatch) -> None: + """Document extraction errors should not leak into user text.""" + bad_file = tmp_path / "broken.docx" + bad_file.write_text("not a docx", encoding="utf-8") + + import nanobot.utils.document as _doc + monkeypatch.setattr( + _doc, "extract_text", + lambda _path: "[error: failed to extract DOCX: boom]", + ) + + text, image_paths = extract_documents("hello", [str(bad_file)]) + assert text == "hello" + assert image_paths == [] + + +def test_extract_documents_images_only(tmp_path) -> None: + """When all files are images, text is unchanged and all paths kept.""" + png = tmp_path / "a.png" + png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + text, image_paths = extract_documents("describe", [str(png)]) + assert text == "describe" + assert len(image_paths) == 1 + + +def test_extract_documents_skips_oversized_files(tmp_path) -> None: + """Files exceeding the size limit should be silently skipped.""" + big = tmp_path / "huge.txt" + big.write_bytes(b"x" * 200) + + text, image_paths = extract_documents("hello", [str(big)], max_file_size=100) + assert text == "hello" + assert image_paths == [] + + +def test_extract_documents_does_not_read_full_file_for_mime(tmp_path) -> None: + """MIME detection should only read header bytes, not the entire file.""" + from pathlib import Path as _Path + + big_txt = tmp_path / "big.txt" + big_txt.write_bytes(b"hello world " * 100_000) # ~1.2 MB + + original_read_bytes = _Path.read_bytes + read_sizes: list[int] = [] + + def _tracking_read_bytes(self): + data = original_read_bytes(self) + read_sizes.append(len(data)) + return data + + import unittest.mock + with unittest.mock.patch.object(_Path, "read_bytes", _tracking_read_bytes): + extract_documents("test", [str(big_txt)]) + + # If the full file was read for MIME detection, read_sizes would + # contain a >1MB entry. After the fix, only a small header is read. + assert all(size <= 4096 for size in read_sizes), ( + f"extract_documents read full file for MIME detection: sizes={read_sizes}" + ) + + +# --------------------------------------------------------------------------- +# DOCX upload test — API saves file, loop layer extracts text +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_docx_upload_passes_media_path(aiohttp_client, tmp_path) -> None: + """Uploaded DOCX is saved to disk and its path passed as media. + (Text extraction happens later in AgentLoop._process_message.)""" + agent = _make_mock_agent("report summary") + import os + original_cwd = os.getcwd() + os.chdir(tmp_path) + + try: + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + from docx import Document + doc = Document() + doc.add_paragraph("Total revenue: $5,000,000") + buf = BytesIO() + doc.save(buf) + + import aiohttp + data = aiohttp.FormData() + data.add_field("message", "summarize the report") + data.add_field("files", buf.getvalue(), filename="report.docx", + content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document") + + resp = await client.post("/v1/chat/completions", data=data) + assert resp.status == 200 + call_kwargs = agent.process_direct.call_args.kwargs + assert call_kwargs["content"] == "summarize the report" + media = call_kwargs.get("media", []) + assert len(media) == 1 + assert "report.docx" in media[0] + finally: + os.chdir(original_cwd) diff --git a/tests/test_api_stream.py b/tests/test_api_stream.py new file mode 100644 index 00000000..75d86652 --- /dev/null +++ b/tests/test_api_stream.py @@ -0,0 +1,280 @@ +"""Tests for SSE streaming support in /v1/chat/completions.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio + +from nanobot.api.server import ( + _sse_chunk, + _SSE_DONE, + create_app, +) + +try: + from aiohttp.test_utils import TestClient, TestServer + + HAS_AIOHTTP = True +except ImportError: + HAS_AIOHTTP = False + +pytest_plugins = ("pytest_asyncio",) + + +# --------------------------------------------------------------------------- +# Unit tests for SSE helpers +# --------------------------------------------------------------------------- + + +def test_sse_chunk_with_delta() -> None: + raw = _sse_chunk("hello", "test-model", "chatcmpl-abc123") + line = raw.decode() + assert line.startswith("data: ") + payload = json.loads(line[len("data: "):]) + assert payload["id"] == "chatcmpl-abc123" + assert payload["object"] == "chat.completion.chunk" + assert payload["model"] == "test-model" + assert payload["choices"][0]["delta"]["content"] == "hello" + assert payload["choices"][0]["finish_reason"] is None + + +def test_sse_chunk_finish_reason() -> None: + raw = _sse_chunk("", "m", "id1", finish_reason="stop") + payload = json.loads(raw.decode().split("data: ", 1)[1]) + assert payload["choices"][0]["delta"] == {} + assert payload["choices"][0]["finish_reason"] == "stop" + + +def test_sse_done_format() -> None: + assert _SSE_DONE == b"data: [DONE]\n\n" + + +# --------------------------------------------------------------------------- +# Integration tests with aiohttp TestClient +# --------------------------------------------------------------------------- + + +def _make_streaming_agent(tokens: list[str]) -> MagicMock: + """Create a mock agent that streams tokens via on_stream callback.""" + agent = MagicMock() + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + async def fake_process_direct(*, content="", media=None, session_key="", + channel="", chat_id="", on_stream=None, + on_stream_end=None, **kwargs): + if on_stream: + for token in tokens: + await on_stream(token) + if on_stream_end: + await on_stream_end() + return " ".join(tokens) + + agent.process_direct = fake_process_direct + return agent + + +@pytest_asyncio.fixture +async def aiohttp_client(): + clients: list[TestClient] = [] + + async def _make_client(app): + client = TestClient(TestServer(app)) + await client.start_server() + clients.append(client) + return client + + try: + yield _make_client + finally: + for client in clients: + await client.close() + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_stream_true_returns_sse(aiohttp_client) -> None: + """stream=true should return text/event-stream with SSE chunks.""" + agent = _make_streaming_agent(["Hello", " world"]) + app = create_app(agent, model_name="test-model") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}], "stream": True}, + ) + assert resp.status == 200 + assert resp.content_type == "text/event-stream" + + body = await resp.text() + lines = [l for l in body.split("\n") if l.startswith("data: ")] + + # Should have: 2 token chunks + 1 finish chunk + [DONE] + data_lines = [l[len("data: "):] for l in lines] + assert data_lines[-1] == "[DONE]" + + chunks = [json.loads(l) for l in data_lines[:-1]] + assert chunks[0]["choices"][0]["delta"]["content"] == "Hello" + assert chunks[1]["choices"][0]["delta"]["content"] == " world" + # Last chunk before [DONE] should have finish_reason=stop + assert chunks[-1]["choices"][0]["finish_reason"] == "stop" + assert chunks[-1]["choices"][0]["delta"] == {} + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_stream_false_returns_json(aiohttp_client) -> None: + """stream=false should still return regular JSON response.""" + agent = MagicMock() + agent.process_direct = AsyncMock(return_value="normal reply") + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}], "stream": False}, + ) + assert resp.status == 200 + body = await resp.json() + assert body["object"] == "chat.completion" + assert body["choices"][0]["message"]["content"] == "normal reply" + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_stream_default_is_false(aiohttp_client) -> None: + """Omitting stream should behave like stream=false.""" + agent = MagicMock() + agent.process_direct = AsyncMock(return_value="default reply") + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 200 + body = await resp.json() + assert body["object"] == "chat.completion" + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_stream_sse_chunk_ids_are_consistent(aiohttp_client) -> None: + """All SSE chunks in a single stream should share the same id.""" + agent = _make_streaming_agent(["A", "B", "C"]) + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "go"}], "stream": True}, + ) + body = await resp.text() + data_lines = [l[len("data: "):] for l in body.split("\n") if l.startswith("data: ") and l != "data: [DONE]"] + chunks = [json.loads(l) for l in data_lines] + + chunk_ids = {c["id"] for c in chunks} + assert len(chunk_ids) == 1, f"Expected single chunk id, got {chunk_ids}" + assert chunk_ids.pop().startswith("chatcmpl-") + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None: + """process_direct should be called with on_stream and on_stream_end when streaming.""" + captured_kwargs: dict = {} + + async def fake_process_direct(**kwargs): + captured_kwargs.update(kwargs) + if kwargs.get("on_stream_end"): + await kwargs["on_stream_end"]() + return "done" + + agent = MagicMock() + agent.process_direct = fake_process_direct + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}], "stream": True}, + ) + assert resp.status == 200 + assert captured_kwargs.get("on_stream") is not None + assert captured_kwargs.get("on_stream_end") is not None + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_stream_with_session_id(aiohttp_client) -> None: + """Streaming should respect session_id for session key routing.""" + captured_key: str = "" + + async def fake_process_direct(*, session_key="", on_stream=None, on_stream_end=None, **kwargs): + nonlocal captured_key + captured_key = session_key + if on_stream: + await on_stream("ok") + if on_stream_end: + await on_stream_end() + return "ok" + + agent = MagicMock() + agent.process_direct = fake_process_direct + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={ + "messages": [{"role": "user", "content": "hi"}], + "stream": True, + "session_id": "my-session", + }, + ) + assert resp.status == 200 + assert captured_key == "api:my-session" + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_streaming_backend_failure_does_not_emit_success_terminator(aiohttp_client) -> None: + """Backend exceptions should not surface as a normal stop+[DONE] stream.""" + agent = MagicMock() + + async def boom(**kwargs): + raise RuntimeError("backend blew up") + + agent.process_direct = boom + agent._connect_mcp = AsyncMock() + agent.close_mcp = AsyncMock() + + app = create_app(agent, model_name="m") + client = await aiohttp_client(app) + + resp = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}], "stream": True}, + ) + + assert resp.status == 200 + body = await resp.text() + assert '"finish_reason": "stop"' not in body + assert "[DONE]" not in body diff --git a/tests/test_build_status.py b/tests/test_build_status.py index d98301cf..922243d5 100644 --- a/tests/test_build_status.py +++ b/tests/test_build_status.py @@ -15,6 +15,7 @@ def test_status_shows_cache_hit_rate(): ) assert "60% cached" in content assert "2000 in / 300 out" in content + assert "Tasks: 0 active" in content def test_status_no_cache_info(): @@ -30,6 +31,7 @@ def test_status_no_cache_info(): ) assert "cached" not in content.lower() assert "2000 in / 300 out" in content + assert "Tasks: 0 active" in content def test_status_zero_cached_tokens(): @@ -57,3 +59,34 @@ def test_status_100_percent_cached(): context_tokens_estimate=3000, ) assert "100% cached" in content + + +def test_status_context_pct_uses_budget_not_total(): + """Percentage should be calculated against input budget, not raw context window.""" + content = build_status_content( + version="0.1.0", + model="test", + start_time=1000000.0, + last_usage={"prompt_tokens": 2000, "completion_tokens": 300}, + context_window_tokens=128000, + session_msg_count=10, + context_tokens_estimate=120000, + max_completion_tokens=8192, + ) + # budget = 128000 - 8192 - 1024 = 118784; pct = 120000/118784*100 ≈ 101% + assert "(101% of input budget)" in content + + +def test_status_context_pct_capped_at_999(): + """Extreme overflow should be capped at 999.""" + content = build_status_content( + version="0.1.0", + model="test", + start_time=1000000.0, + last_usage={"prompt_tokens": 2000, "completion_tokens": 300}, + context_window_tokens=10000, + session_msg_count=10, + context_tokens_estimate=100000, + max_completion_tokens=4096, + ) + assert "(999% of input budget)" in content diff --git a/tests/test_context_documents.py b/tests/test_context_documents.py new file mode 100644 index 00000000..7d9ac908 --- /dev/null +++ b/tests/test_context_documents.py @@ -0,0 +1,113 @@ +"""Tests for context builder media handling. + +The ContextBuilder._build_user_content method should ONLY handle images. +Document text extraction is the responsibility of the processing layer +(AgentLoop._process_message and _drain_pending). +""" + +from __future__ import annotations + +from pathlib import Path + +from nanobot.agent.context import ContextBuilder +from nanobot.utils.document import extract_documents + + +def _make_builder(tmp_path: Path) -> ContextBuilder: + """Create a minimal ContextBuilder for testing.""" + return ContextBuilder(workspace=tmp_path, timezone="UTC") + + +def test_build_user_content_with_no_media_returns_string(tmp_path: Path) -> None: + builder = _make_builder(tmp_path) + result = builder._build_user_content("hello", None) + assert result == "hello" + + +def test_build_user_content_with_image_returns_list(tmp_path: Path) -> None: + """Image files should produce base64 content blocks.""" + builder = _make_builder(tmp_path) + png = tmp_path / "test.png" + png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + result = builder._build_user_content("describe this", [str(png)]) + assert isinstance(result, list) + types = [b["type"] for b in result] + assert "image_url" in types + assert "text" in types + + +def test_build_user_content_ignores_non_image_files(tmp_path: Path) -> None: + """Non-image files should be silently skipped — extraction is not context builder's job.""" + builder = _make_builder(tmp_path) + txt = tmp_path / "notes.txt" + txt.write_text("some text", encoding="utf-8") + result = builder._build_user_content("summarize", [str(txt)]) + assert result == "summarize" + + +def test_build_user_content_mixed_image_and_non_image(tmp_path: Path) -> None: + """Only images should be included; non-image files are skipped.""" + builder = _make_builder(tmp_path) + png = tmp_path / "chart.png" + png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + txt = tmp_path / "report.txt" + txt.write_text("report text", encoding="utf-8") + + result = builder._build_user_content("analyze", [str(png), str(txt)]) + assert isinstance(result, list) + assert any(b["type"] == "image_url" for b in result) + text_parts = [b.get("text", "") for b in result if b.get("type") == "text"] + assert all("report text" not in t for t in text_parts) + + +# --------------------------------------------------------------------------- +# Bug detection: extract_documents must be called BEFORE _build_user_content +# to prevent document media from being silently dropped. +# This simulates the _drain_pending code path. +# --------------------------------------------------------------------------- + +def test_drain_pending_path_preserves_document_text(tmp_path: Path) -> None: + """Simulates the _drain_pending path: a pending follow-up message + with a document attachment must have its text extracted before being + passed to _build_user_content. Without extract_documents, the + document is silently dropped.""" + from docx import Document + + doc = Document() + doc.add_paragraph("Quarterly revenue is $5M") + docx_path = tmp_path / "report.docx" + doc.save(docx_path) + + content = "summarize" + media = [str(docx_path)] + + # Step 1: extract_documents separates docs from images + new_content, image_only = extract_documents(content, media) + + # Step 2: _build_user_content handles only images (none left here) + builder = _make_builder(tmp_path) + result = builder._build_user_content(new_content, image_only if image_only else None) + + # The document text should be present in the final content + assert "Quarterly revenue" in result + assert "summarize" in result + + +def test_drain_pending_path_without_extract_loses_document(tmp_path: Path) -> None: + """Demonstrates the BUG: if _drain_pending calls _build_user_content + directly without extract_documents, document content is lost.""" + from docx import Document + + doc = Document() + doc.add_paragraph("Secret data in document") + docx_path = tmp_path / "report.docx" + doc.save(docx_path) + + builder = _make_builder(tmp_path) + + # Bug path: call _build_user_content directly with document media + result = builder._build_user_content("summarize", [str(docx_path)]) + + # The document text is LOST — _build_user_content ignores non-images + assert result == "summarize" # only the original text, no doc content + assert "Secret data" not in result diff --git a/tests/test_document_parsing.py b/tests/test_document_parsing.py new file mode 100644 index 00000000..9b424998 --- /dev/null +++ b/tests/test_document_parsing.py @@ -0,0 +1,316 @@ +"""Tests for document text extraction utilities.""" + +from pathlib import Path + +from nanobot.utils.document import ( + SUPPORTED_EXTENSIONS, + _is_text_extension, + extract_text, +) + + +class TestSupportedExtensions: + """Test the SUPPORTED_EXTENSIONS constant.""" + + def test_supported_extensions_include_common_formats(self): + """Test that common document formats are included.""" + # Document formats + assert ".pdf" in SUPPORTED_EXTENSIONS + assert ".docx" in SUPPORTED_EXTENSIONS + assert ".xlsx" in SUPPORTED_EXTENSIONS + assert ".pptx" in SUPPORTED_EXTENSIONS + + # Text formats + assert ".txt" in SUPPORTED_EXTENSIONS + assert ".md" in SUPPORTED_EXTENSIONS + assert ".csv" in SUPPORTED_EXTENSIONS + assert ".json" in SUPPORTED_EXTENSIONS + assert ".yaml" in SUPPORTED_EXTENSIONS + assert ".yml" in SUPPORTED_EXTENSIONS + + # Image formats + assert ".png" in SUPPORTED_EXTENSIONS + assert ".jpg" in SUPPORTED_EXTENSIONS + assert ".jpeg" in SUPPORTED_EXTENSIONS + + +class TestExtractText: + """Test the extract_text function.""" + + def test_extract_text_unsupported_returns_none(self, tmp_path: Path): + """Test that unsupported file types return None.""" + unsupported_file = tmp_path / "file.xyz" + unsupported_file.write_text("content") + + result = extract_text(unsupported_file) + assert result is None + + def test_extract_text_file_not_found(self, tmp_path: Path): + """Test that non-existent files return error string.""" + missing_file = tmp_path / "nonexistent.txt" + + result = extract_text(missing_file) + assert result is not None + assert "[error: file not found:" in result + + def test_extract_text_txt_file(self, tmp_path: Path): + """Test extracting text from a .txt file.""" + txt_file = tmp_path / "test.txt" + content = "Hello, world!\nThis is a test." + txt_file.write_text(content, encoding="utf-8") + + result = extract_text(txt_file) + assert result == content + + def test_extract_text_txt_file_with_truncation(self, tmp_path: Path): + """Test that large text files are truncated.""" + txt_file = tmp_path / "large.txt" + # Create content larger than _MAX_TEXT_LENGTH + content = "x" * 300_000 + txt_file.write_text(content, encoding="utf-8") + + result = extract_text(txt_file) + assert len(result) < 300_000 + assert "(truncated," in result + assert "chars total)" in result + + def test_extract_text_md_file(self, tmp_path: Path): + """Test extracting text from a .md file.""" + md_file = tmp_path / "test.md" + content = "# Header\n\nSome markdown content." + md_file.write_text(content, encoding="utf-8") + + result = extract_text(md_file) + assert result == content + + def test_extract_text_csv_file(self, tmp_path: Path): + """Test extracting text from a .csv file.""" + csv_file = tmp_path / "test.csv" + content = "name,age\nAlice,30\nBob,25" + csv_file.write_text(content, encoding="utf-8") + + result = extract_text(csv_file) + assert result == content + + def test_extract_text_json_file(self, tmp_path: Path): + """Test extracting text from a .json file.""" + json_file = tmp_path / "test.json" + content = '{"key": "value", "number": 42}' + json_file.write_text(content, encoding="utf-8") + + result = extract_text(json_file) + assert result == content + + def test_extract_text_xlsx(self, tmp_path: Path): + """Test extracting text from an .xlsx file.""" + from openpyxl import Workbook + + xlsx_file = tmp_path / "test.xlsx" + wb = Workbook() + ws = wb.active + ws.title = "Sheet1" + ws["A1"] = "Name" + ws["B1"] = "Age" + ws["A2"] = "Alice" + ws["B2"] = 30 + ws["A3"] = "Bob" + ws["B3"] = 25 + + # Add a second sheet + ws2 = wb.create_sheet("Sheet2") + ws2["A1"] = "Product" + ws2["B1"] = "Price" + ws2["A2"] = "Widget" + ws2["B2"] = 9.99 + + wb.save(xlsx_file) + wb.close() + + result = extract_text(xlsx_file) + assert result is not None + assert "--- Sheet: Sheet1 ---" in result + assert "--- Sheet: Sheet2 ---" in result + assert "Alice" in result + assert "Bob" in result + assert "Widget" in result + assert "9.99" in result + + def test_extract_text_xlsx_empty_sheet(self, tmp_path: Path): + """Test extracting text from an .xlsx file with empty sheets.""" + from openpyxl import Workbook + + xlsx_file = tmp_path / "empty.xlsx" + wb = Workbook() + # Clear the default sheet + wb.remove(wb.active) + # Add an empty sheet + wb.create_sheet("EmptySheet") + wb.save(xlsx_file) + wb.close() + + result = extract_text(xlsx_file) + # Empty sheets should return empty string or header only + assert result == "--- Sheet: EmptySheet ---" or result == "" + + def test_extract_text_docx(self, tmp_path: Path): + """Test extracting text from a .docx file.""" + from docx import Document + + docx_file = tmp_path / "test.docx" + doc = Document() + doc.add_heading("Test Document", 0) + doc.add_paragraph("This is paragraph one.") + doc.add_paragraph("This is paragraph two.") + doc.save(docx_file) + + result = extract_text(docx_file) + assert result is not None + assert "Test Document" in result + assert "This is paragraph one." in result + assert "This is paragraph two." in result + + def test_extract_text_docx_empty(self, tmp_path: Path): + """Test extracting text from an empty .docx file.""" + from docx import Document + + docx_file = tmp_path / "empty.docx" + doc = Document() + doc.save(docx_file) + + result = extract_text(docx_file) + assert result == "" + + def test_extract_text_pptx(self, tmp_path: Path): + """Test extracting text from a .pptx file.""" + from pptx import Presentation + + pptx_file = tmp_path / "test.pptx" + prs = Presentation() + + # Slide 1 + slide1 = prs.slides.add_slide(prs.slide_layouts[0]) + for shape in slide1.shapes: + if hasattr(shape, "text"): + shape.text = "First Slide Title" + + # Slide 2 + slide2 = prs.slides.add_slide(prs.slide_layouts[5]) + left = top = width = height = 1000000 + textbox = slide2.shapes.add_textbox(left, top, width, height) + text_frame = textbox.text_frame + text_frame.text = "Bullet point content" + + prs.save(pptx_file) + + result = extract_text(pptx_file) + assert result is not None + assert "--- Slide 1 ---" in result + assert "--- Slide 2 ---" in result + # Text content may vary depending on PowerPoint layout defaults + assert len(result) > 0 + + def test_extract_text_pptx_table(self, tmp_path: Path): + """Table cells should be extracted, not silently dropped.""" + from pptx import Presentation + from pptx.util import Inches + + pptx_file = tmp_path / "table.pptx" + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[5]) + table = slide.shapes.add_table( + 2, 2, Inches(1), Inches(1), Inches(4), Inches(1) + ).table + table.cell(0, 0).text = "Header A" + table.cell(0, 1).text = "Header B" + table.cell(1, 0).text = "Alice" + table.cell(1, 1).text = "Bob" + prs.save(pptx_file) + + result = extract_text(pptx_file) + assert result is not None + assert "Header A" in result + assert "Header B" in result + assert "Alice" in result + assert "Bob" in result + + def test_extract_text_pptx_grouped_shapes(self, tmp_path: Path): + """Text inside grouped shapes must be extracted recursively.""" + from pptx import Presentation + from pptx.util import Inches + + pptx_file = tmp_path / "grouped.pptx" + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[5]) + group = slide.shapes.add_group_shape() + inner = group.shapes.add_textbox( + Inches(1), Inches(1), Inches(3), Inches(1) + ) + inner.text_frame.text = "Inside group" + prs.save(pptx_file) + + result = extract_text(pptx_file) + assert result is not None + assert "Inside group" in result + + def test_extract_text_pdf_not_found(self, tmp_path: Path): + """Test that missing PDF files return error string.""" + missing_pdf = tmp_path / "nonexistent.pdf" + + result = extract_text(missing_pdf) + assert result is not None + assert "[error: file not found:" in result + + def test_extract_text_image_files(self, tmp_path: Path): + """Test that image files return placeholder text.""" + # Create a minimal PNG file (1x1 pixel) + png_file = tmp_path / "test.png" + # Minimal valid PNG: 8-byte signature + IHDR + IDAT + IEND + png_data = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x02\x00\x00\x00\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01" + b"\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" + ) + png_file.write_bytes(png_data) + + result = extract_text(png_file) + assert result is not None + assert "[image:" in result + assert "test.png" in result + + +class TestIsTextExtension: + """Test the _is_text_extension helper.""" + + def test_text_extensions_return_true(self): + """Test that known text extensions return True.""" + assert _is_text_extension(".txt") is True + assert _is_text_extension(".md") is True + assert _is_text_extension(".csv") is True + assert _is_text_extension(".json") is True + assert _is_text_extension(".yaml") is True + assert _is_text_extension(".yml") is True + assert _is_text_extension(".xml") is True + assert _is_text_extension(".html") is True + assert _is_text_extension(".htm") is True + + def test_non_text_extensions_return_false(self): + """Test that non-text extensions return False.""" + assert _is_text_extension(".pdf") is False + assert _is_text_extension(".docx") is False + assert _is_text_extension(".xlsx") is False + assert _is_text_extension(".pptx") is False + assert _is_text_extension(".png") is False + assert _is_text_extension(".xyz") is False + + def test_case_sensitivity(self): + """Test that _is_text_extension requires lowercase extension. + + Note: The main extract_text function handles case-insensitivity by + converting extensions to lowercase before calling _is_text_extension. + """ + # _is_text_extension itself is case-sensitive (lowercase only) + assert _is_text_extension(".txt") is True + assert _is_text_extension(".TXT") is False + assert _is_text_extension(".pdf") is False diff --git a/tests/test_msteams.py b/tests/test_msteams.py new file mode 100644 index 00000000..f5597c38 --- /dev/null +++ b/tests/test_msteams.py @@ -0,0 +1,562 @@ +import json + +import pytest + +# Check optional msteams dependencies before running tests +try: + from nanobot.channels import msteams + MSTEAMS_AVAILABLE = getattr(msteams, "MSTEAMS_AVAILABLE", False) +except ImportError: + MSTEAMS_AVAILABLE = False + +if not MSTEAMS_AVAILABLE: + pytest.skip("MSTeams dependencies not installed (PyJWT, cryptography). Run: pip install nanobot-ai[msteams]", allow_module_level=True) + +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa + +import nanobot.channels.msteams as msteams_module +from nanobot.bus.events import OutboundMessage +from nanobot.channels.msteams import ConversationRef, MSTeamsChannel, MSTeamsConfig + + +class DummyBus: + def __init__(self): + self.inbound = [] + + async def publish_inbound(self, msg): + self.inbound.append(msg) + + +class FakeResponse: + def __init__(self, payload=None, *, should_raise=False): + self._payload = payload or {} + self._should_raise = should_raise + + def raise_for_status(self): + if self._should_raise: + raise RuntimeError("boom") + return None + + def json(self): + return self._payload + + +class FakeHttpClient: + def __init__(self, payload=None, *, should_raise=False): + self.payload = payload or {"access_token": "tok", "expires_in": 3600} + self.should_raise = should_raise + self.calls = [] + + async def post(self, url, **kwargs): + self.calls.append((url, kwargs)) + return FakeResponse(self.payload, should_raise=self.should_raise) + + async def aclose(self): + pass + + +@pytest.fixture +def make_channel(tmp_path, monkeypatch): + monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) + + def _make_channel(**config_overrides): + config = { + "enabled": True, + "appId": "app-id", + "appPassword": "secret", + "tenantId": "tenant-id", + "allowFrom": ["*"], + } + config.update(config_overrides) + return MSTeamsChannel(config, DummyBus()) + + return _make_channel + + +@pytest.mark.asyncio +async def test_handle_activity_personal_message_publishes_and_stores_ref(make_channel, tmp_path): + ch = make_channel() + + activity = { + "type": "message", + "id": "activity-1", + "text": "Hello from Teams", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "conversation": { + "id": "conv-123", + "conversationType": "personal", + }, + "from": { + "id": "29:user-id", + "aadObjectId": "aad-user-1", + "name": "Bob", + }, + "recipient": { + "id": "28:bot-id", + "name": "nanobot", + }, + "channelData": { + "tenant": {"id": "tenant-id"}, + }, + } + + await ch._handle_activity(activity) + + assert len(ch.bus.inbound) == 1 + msg = ch.bus.inbound[0] + assert msg.channel == "msteams" + assert msg.sender_id == "aad-user-1" + assert msg.chat_id == "conv-123" + assert msg.content == "Hello from Teams" + assert msg.metadata["msteams"]["conversation_id"] == "conv-123" + assert "conv-123" in ch._conversation_refs + + saved = json.loads((tmp_path / "state" / "msteams_conversations.json").read_text(encoding="utf-8")) + assert saved["conv-123"]["conversation_id"] == "conv-123" + assert saved["conv-123"]["tenant_id"] == "tenant-id" + + +@pytest.mark.asyncio +async def test_handle_activity_ignores_group_messages(make_channel): + ch = make_channel() + + activity = { + "type": "message", + "id": "activity-2", + "text": "Hello group", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "conversation": { + "id": "conv-group", + "conversationType": "channel", + }, + "from": { + "id": "29:user-id", + "aadObjectId": "aad-user-1", + "name": "Bob", + }, + "recipient": { + "id": "28:bot-id", + "name": "nanobot", + }, + } + + await ch._handle_activity(activity) + + assert ch.bus.inbound == [] + assert ch._conversation_refs == {} + + +@pytest.mark.asyncio +async def test_handle_activity_denied_sender_does_not_store_ref(make_channel, tmp_path): + ch = make_channel(allowFrom=["allowed-user"]) + + activity = { + "type": "message", + "id": "activity-denied", + "text": "Hello from denied user", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "conversation": { + "id": "conv-denied", + "conversationType": "personal", + }, + "from": { + "id": "29:user-id", + "aadObjectId": "aad-user-1", + "name": "Bob", + }, + "recipient": { + "id": "28:bot-id", + "name": "nanobot", + }, + "channelData": { + "tenant": {"id": "tenant-id"}, + }, + } + + await ch._handle_activity(activity) + + assert ch.bus.inbound == [] + assert ch._conversation_refs == {} + assert not (tmp_path / "state" / "msteams_conversations.json").exists() + + +@pytest.mark.asyncio +async def test_handle_activity_mention_only_uses_default_response(make_channel): + ch = make_channel() + + activity = { + "type": "message", + "id": "activity-3", + "text": "Nanobot", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "conversation": { + "id": "conv-empty", + "conversationType": "personal", + }, + "from": { + "id": "29:user-id", + "aadObjectId": "aad-user-1", + "name": "Bob", + }, + "recipient": { + "id": "28:bot-id", + "name": "nanobot", + }, + } + + await ch._handle_activity(activity) + + assert len(ch.bus.inbound) == 1 + assert ch.bus.inbound[0].content == "Hi — what can I help with?" + assert "conv-empty" in ch._conversation_refs + + +@pytest.mark.asyncio +async def test_handle_activity_mention_only_ignores_when_response_disabled(make_channel): + ch = make_channel(mentionOnlyResponse=" ") + + activity = { + "type": "message", + "id": "activity-4", + "text": "Nanobot", + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "conversation": { + "id": "conv-empty-disabled", + "conversationType": "personal", + }, + "from": { + "id": "29:user-id", + "aadObjectId": "aad-user-1", + "name": "Bob", + }, + "recipient": { + "id": "28:bot-id", + "name": "nanobot", + }, + } + + await ch._handle_activity(activity) + + assert ch.bus.inbound == [] + assert ch._conversation_refs == {} + + +def test_strip_possible_bot_mention_removes_generic_at_tags(make_channel): + ch = make_channel() + + assert ch._strip_possible_bot_mention("Nanobot hello") == "hello" + assert ch._strip_possible_bot_mention("hi Some Bot there") == "hi there" + + +def test_sanitize_inbound_text_keeps_normal_inline_message(make_channel): + ch = make_channel() + + activity = { + "text": "Nanobot normal inline message", + "channelData": {}, + } + + assert ch._sanitize_inbound_text(activity) == "normal inline message" + + +def test_sanitize_inbound_text_normalizes_reply_wrapper_without_reply_metadata(make_channel): + ch = make_channel() + + activity = { + "text": "Reply wrapper \r\nQuoted prior message\r\n\r\nThis is a reply with quote test", + "channelData": {}, + } + + assert ch._sanitize_inbound_text(activity) == ( + "User is replying to: Quoted prior message\n" + "User reply: This is a reply with quote test" + ) + + +def test_sanitize_inbound_text_structures_reply_quote_prefix(make_channel): + ch = make_channel() + + activity = { + "text": "Replying to Bob Smith\nactual reply text", + "replyToId": "parent-activity", + "channelData": {"messageType": "reply"}, + } + + assert ch._sanitize_inbound_text(activity) == "User is replying to: Bob Smith\nUser reply: actual reply text" + + +def test_sanitize_inbound_text_structures_live_reply_wrapper_shape(make_channel): + ch = make_channel() + + activity = { + "text": "Reply wrapper Got it. I’ll watch for the exact text reply with quote test and then inspect that turn specifically. Reply with quote test", + "replyToId": "parent-activity", + "channelData": {"messageType": "reply"}, + } + + assert ch._sanitize_inbound_text(activity) == ( + "User is replying to: Got it. I’ll watch for the exact text reply with quote test and then inspect that turn specifically.\n" + "User reply: Reply with quote test" + ) + + +def test_normalize_teams_reply_quote_leaves_plain_text_test_phrase_untouched(make_channel): + ch = make_channel() + + text = "Normal message ending with Reply with quote test" + + assert ch._normalize_teams_reply_quote(text) == text + + +def test_sanitize_inbound_text_structures_multiline_reply_wrapper_shape(make_channel): + ch = make_channel() + + activity = { + "text": ( + "Reply wrapper\r\n" + "Understood — then the restart already happened, and the new Teams quote normalization should now be live. " + "Next best step: • send one more real reply-with-quote message in Teams • I&rsquo…\r\n" + "\r\n" + "This is a reply with quote" + ), + "replyToId": "parent-activity", + "channelData": {"messageType": "reply"}, + } + + assert ch._sanitize_inbound_text(activity) == ( + "User is replying to: Understood — then the restart already happened, and the new Teams quote normalization should now be live. " + "Next best step: • send one more real reply-with-quote message in Teams • I’…\n" + "User reply: This is a reply with quote" + ) + + +def test_sanitize_inbound_text_structures_exact_live_crlf_reply_wrapper_shape(make_channel): + ch = make_channel() + + activity = { + "text": ( + "Reply wrapper \r\n" + "Please send one real reply-with-quote message in Teams. That single test should be enough now: " + "• I’ll check the new MSTeams sanitized inbound text ... log • and compare it to the prompt…\r\n" + "\r\n" + "This is a reply with quote test" + ), + "replyToId": "parent-activity", + "channelData": {"messageType": "reply"}, + } + + assert ch._sanitize_inbound_text(activity) == ( + "User is replying to: Please send one real reply-with-quote message in Teams. That single test should be enough now: " + "• I’ll check the new MSTeams sanitized inbound text ... log • and compare it to the prompt…\n" + "User reply: This is a reply with quote test" + ) + + +@pytest.mark.asyncio +async def test_get_access_token_uses_configured_tenant(make_channel): + ch = make_channel(tenantId="tenant-123") + fake_http = FakeHttpClient() + ch._http = fake_http + + token = await ch._get_access_token() + + assert token == "tok" + assert len(fake_http.calls) == 1 + url, kwargs = fake_http.calls[0] + assert url == "https://login.microsoftonline.com/tenant-123/oauth2/v2.0/token" + assert kwargs["data"]["client_id"] == "app-id" + assert kwargs["data"]["client_secret"] == "secret" + assert kwargs["data"]["scope"] == "https://api.botframework.com/.default" + + +@pytest.mark.asyncio +async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channel): + ch = make_channel(replyInThread=True) + fake_http = FakeHttpClient() + ch._http = fake_http + ch._token = "tok" + ch._token_expires_at = 9999999999 + ch._conversation_refs["conv-123"] = ConversationRef( + service_url="https://smba.trafficmanager.net/amer/", + conversation_id="conv-123", + activity_id="activity-1", + ) + + await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text")) + + assert len(fake_http.calls) == 1 + url, kwargs = fake_http.calls[0] + assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities/activity-1" + assert kwargs["headers"]["Authorization"] == "Bearer tok" + assert kwargs["json"]["text"] == "Reply text" + assert kwargs["json"]["replyToId"] == "activity-1" + + +@pytest.mark.asyncio +async def test_send_posts_to_conversation_when_thread_reply_disabled(make_channel): + ch = make_channel(replyInThread=False) + fake_http = FakeHttpClient() + ch._http = fake_http + ch._token = "tok" + ch._token_expires_at = 9999999999 + ch._conversation_refs["conv-123"] = ConversationRef( + service_url="https://smba.trafficmanager.net/amer/", + conversation_id="conv-123", + activity_id="activity-1", + ) + + await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text")) + + assert len(fake_http.calls) == 1 + url, kwargs = fake_http.calls[0] + assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities" + assert kwargs["headers"]["Authorization"] == "Bearer tok" + assert kwargs["json"]["text"] == "Reply text" + assert "replyToId" not in kwargs["json"] + + +@pytest.mark.asyncio +async def test_send_posts_to_conversation_when_thread_reply_enabled_but_no_activity_id(make_channel): + ch = make_channel(replyInThread=True) + fake_http = FakeHttpClient() + ch._http = fake_http + ch._token = "tok" + ch._token_expires_at = 9999999999 + ch._conversation_refs["conv-123"] = ConversationRef( + service_url="https://smba.trafficmanager.net/amer/", + conversation_id="conv-123", + activity_id=None, + ) + + await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text")) + + assert len(fake_http.calls) == 1 + url, kwargs = fake_http.calls[0] + assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities" + assert kwargs["headers"]["Authorization"] == "Bearer tok" + assert kwargs["json"]["text"] == "Reply text" + assert "replyToId" not in kwargs["json"] + + +@pytest.mark.asyncio +async def test_send_raises_when_conversation_ref_missing(make_channel): + ch = make_channel() + ch._http = FakeHttpClient() + + with pytest.raises(RuntimeError, match="conversation ref not found"): + await ch.send(OutboundMessage(channel="msteams", chat_id="missing", content="Reply text")) + + +@pytest.mark.asyncio +async def test_send_raises_delivery_failures_for_retry(make_channel): + ch = make_channel() + ch._http = FakeHttpClient(should_raise=True) + ch._token = "tok" + ch._token_expires_at = 9999999999 + ch._conversation_refs["conv-123"] = ConversationRef( + service_url="https://smba.trafficmanager.net/amer/", + conversation_id="conv-123", + activity_id="activity-1", + ) + + with pytest.raises(RuntimeError, match="boom"): + await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text")) + + +def _make_test_rsa_jwk(kid: str = "test-kid"): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_key = private_key.public_key() + jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(public_key)) + jwk["kid"] = kid + jwk["use"] = "sig" + jwk["kty"] = "RSA" + jwk["alg"] = "RS256" + return private_key, jwk + + +@pytest.mark.asyncio +async def test_validate_inbound_auth_accepts_observed_botframework_shape(make_channel): + ch = make_channel(validateInboundAuth=True) + + private_key, jwk = _make_test_rsa_jwk() + ch._botframework_jwks = {"keys": [jwk]} + ch._botframework_jwks_expires_at = 9999999999 + + service_url = "https://smba.trafficmanager.net/amer/tenant/" + token = jwt.encode( + { + "iss": "https://api.botframework.com", + "aud": "app-id", + "serviceurl": service_url, + "nbf": 1700000000, + "exp": 4100000000, + }, + private_key, + algorithm="RS256", + headers={"kid": jwk["kid"]}, + ) + + await ch._validate_inbound_auth( + f"Bearer {token}", + {"serviceUrl": service_url}, + ) + + +@pytest.mark.asyncio +async def test_validate_inbound_auth_rejects_service_url_mismatch(make_channel): + ch = make_channel(validateInboundAuth=True) + + private_key, jwk = _make_test_rsa_jwk() + ch._botframework_jwks = {"keys": [jwk]} + ch._botframework_jwks_expires_at = 9999999999 + + token = jwt.encode( + { + "iss": "https://api.botframework.com", + "aud": "app-id", + "serviceurl": "https://smba.trafficmanager.net/amer/tenant-a/", + "nbf": 1700000000, + "exp": 4100000000, + }, + private_key, + algorithm="RS256", + headers={"kid": jwk["kid"]}, + ) + + with pytest.raises(ValueError, match="serviceUrl claim mismatch"): + await ch._validate_inbound_auth( + f"Bearer {token}", + {"serviceUrl": "https://smba.trafficmanager.net/amer/tenant-b/"}, + ) + + +@pytest.mark.asyncio +async def test_validate_inbound_auth_rejects_missing_bearer_token(make_channel): + ch = make_channel(validateInboundAuth=True) + + with pytest.raises(ValueError, match="missing bearer token"): + await ch._validate_inbound_auth("", {"serviceUrl": "https://smba.trafficmanager.net/amer/tenant/"}) + + +@pytest.mark.asyncio +async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypatch): + ch = make_channel() + errors = [] + monkeypatch.setattr(msteams_module, "MSTEAMS_AVAILABLE", False) + monkeypatch.setattr(msteams_module.logger, "error", lambda message, *args: errors.append(message.format(*args))) + + await ch.start() + + assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"] + + +def test_msteams_default_config_includes_restart_notify_fields(): + cfg = MSTeamsChannel.default_config() + + assert cfg["validateInboundAuth"] is True + assert "restartNotifyEnabled" not in cfg + assert "restartNotifyPreMessage" not in cfg + assert "restartNotifyPostMessage" not in cfg + + diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py index 2d4ae858..59b52b19 100644 --- a/tests/test_openai_api.py +++ b/tests/test_openai_api.py @@ -101,15 +101,14 @@ async def test_no_user_message_returns_400(aiohttp_client, app) -> None: @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @pytest.mark.asyncio -async def test_stream_true_returns_400(aiohttp_client, app) -> None: +async def test_stream_true_returns_sse(aiohttp_client, app) -> None: client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", json={"messages": [{"role": "user", "content": "hello"}], "stream": True}, ) - assert resp.status == 400 - body = await resp.json() - assert "stream" in body["error"]["message"].lower() + assert resp.status == 200 + assert resp.content_type == "text/event-stream" @pytest.mark.asyncio @@ -194,6 +193,7 @@ async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_ag assert body["model"] == "test-model" mock_agent.process_direct.assert_called_once_with( content="hello", + media=None, session_key=API_SESSION_KEY, channel="api", chat_id=API_CHAT_ID, @@ -205,7 +205,7 @@ async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_ag async def test_followup_requests_share_same_session_key(aiohttp_client) -> None: call_log: list[str] = [] - async def fake_process(content, session_key="", channel="", chat_id=""): + async def fake_process(content, session_key="", channel="", chat_id="", **kwargs): call_log.append(session_key) return f"reply to {content}" @@ -236,7 +236,7 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None: async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None: order: list[str] = [] - async def slow_process(content, session_key="", channel="", chat_id=""): + async def slow_process(content, session_key="", channel="", chat_id="", **kwargs): order.append(f"start:{content}") await asyncio.sleep(0.1) order.append(f"end:{content}") @@ -307,20 +307,46 @@ async def test_multimodal_content_extracts_text(aiohttp_client, mock_agent) -> N }, ) assert resp.status == 200 - mock_agent.process_direct.assert_called_once_with( - content="describe this", - session_key=API_SESSION_KEY, - channel="api", - chat_id=API_CHAT_ID, + call_kwargs = mock_agent.process_direct.call_args.kwargs + assert call_kwargs["content"] == "describe this" + assert call_kwargs["session_key"] == API_SESSION_KEY + assert call_kwargs["channel"] == "api" + assert call_kwargs["chat_id"] == API_CHAT_ID + assert len(call_kwargs.get("media") or []) >= 0 # base64 images saved to disk + + +@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") +@pytest.mark.asyncio +async def test_multimodal_remote_image_url_returns_400(aiohttp_client, mock_agent) -> None: + app = create_app(mock_agent, model_name="m") + client = await aiohttp_client(app) + resp = await client.post( + "/v1/chat/completions", + json={ + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe this"}, + {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}, + ], + } + ] + }, ) + assert resp.status == 400 + body = await resp.json() + assert "remote image urls are not supported" in body["error"]["message"].lower() + mock_agent.process_direct.assert_not_called() + @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @pytest.mark.asyncio async def test_empty_response_retry_then_success(aiohttp_client) -> None: call_count = 0 - async def sometimes_empty(content, session_key="", channel="", chat_id=""): + async def sometimes_empty(content, session_key="", channel="", chat_id="", **kwargs): nonlocal call_count call_count += 1 if call_count == 1: @@ -351,7 +377,7 @@ async def test_empty_response_falls_back(aiohttp_client) -> None: call_count = 0 - async def always_empty(content, session_key="", channel="", chat_id=""): + async def always_empty(content, session_key="", channel="", chat_id="", **kwargs): nonlocal call_count call_count += 1 return "" @@ -371,3 +397,31 @@ async def test_empty_response_falls_back(aiohttp_client) -> None: body = await resp.json() assert body["choices"][0]["message"]["content"] == EMPTY_FINAL_RESPONSE_MESSAGE assert call_count == 2 + + +@pytest.mark.asyncio +async def test_process_direct_accepts_media() -> None: + """process_direct should forward media paths to _process_message.""" + from nanobot.agent.loop import AgentLoop + + loop = AgentLoop.__new__(AgentLoop) + loop._connect_mcp = AsyncMock() + + captured_msg = None + + async def fake_process(msg, *, session_key="", on_progress=None, on_stream=None, on_stream_end=None): + nonlocal captured_msg + captured_msg = msg + return None + + loop._process_message = fake_process + + await loop.process_direct( + content="analyze this", + media=["/tmp/image.png", "/tmp/report.pdf"], + session_key="test:1", + ) + + assert captured_msg is not None + assert captured_msg.media == ["/tmp/image.png", "/tmp/report.pdf"] + assert captured_msg.content == "analyze this" diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index da90c4d0..a133f53d 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -356,6 +356,33 @@ async def test_connect_mcp_servers_enabled_tools_warns_on_unknown_entries( assert "Available wrapped names: mcp_test_demo" in warnings[-1] +@pytest.mark.asyncio +async def test_connect_mcp_servers_logs_stdio_pollution_hint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + messages: list[str] = [] + + def _error(message: str, *args: object) -> None: + messages.append(message.format(*args)) + + @asynccontextmanager + async def _broken_stdio_client(_params: object): + raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers") + yield # pragma: no cover + + monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client) + monkeypatch.setattr("nanobot.agent.tools.mcp.logger.error", _error) + + registry = ToolRegistry() + stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry) + + assert stacks == {} + assert messages + assert "stdio protocol pollution" in messages[-1] + assert "stdout" in messages[-1] + assert "stderr" in messages[-1] + + @pytest.mark.asyncio async def test_connect_mcp_servers_one_failure_does_not_block_others( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/tools/test_read_enhancements.py b/tests/tools/test_read_enhancements.py index a703ba6e..0be12370 100644 --- a/tests/tools/test_read_enhancements.py +++ b/tests/tools/test_read_enhancements.py @@ -1,5 +1,8 @@ """Tests for ReadFileTool enhancements: description fix, read dedup, PDF support, device blacklist.""" +import os +import sys + import pytest from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool @@ -143,6 +146,7 @@ class TestReadPdf: # Device path blacklist # --------------------------------------------------------------------------- +@pytest.mark.skipif(sys.platform == "win32", reason="/dev directory doesn't exist on Windows") class TestReadDeviceBlacklist: @pytest.fixture() @@ -178,3 +182,67 @@ class TestReadDeviceBlacklist: result = await tool.execute(path=str(link)) assert "Error" in result assert "blocked" in result.lower() or "device" in result.lower() + + +# --------------------------------------------------------------------------- +# file_state: mtime-unchanged / content-changed fallback +# --------------------------------------------------------------------------- +# On filesystems with coarse mtime resolution (NTFS ~100ms, FAT 2s) a fast +# write-after-read can leave mtime unchanged. The content-hash fallback is +# what protects against stale-read warnings being false-negative on those +# platforms. Lock that behavior down here so nobody reverts it silently. + +class TestFileStateHashFallback: + + def test_check_read_warns_when_content_changed_but_mtime_same(self, tmp_path): + f = tmp_path / "data.txt" + f.write_text("original", encoding="utf-8") + file_state.record_read(f) + original_mtime = os.path.getmtime(f) + + f.write_text("modified", encoding="utf-8") + os.utime(f, (original_mtime, original_mtime)) + assert os.path.getmtime(f) == original_mtime + + warning = file_state.check_read(f) + assert warning is not None + assert "modified" in warning.lower() + + def test_check_read_passes_when_content_and_mtime_unchanged(self, tmp_path): + f = tmp_path / "data.txt" + f.write_text("stable", encoding="utf-8") + file_state.record_read(f) + + assert file_state.check_read(f) is None + + +# --------------------------------------------------------------------------- +# Line-ending normalization +# --------------------------------------------------------------------------- +# ReadFileTool normalizes CRLF -> LF before line-splitting. This primarily +# helps Windows users whose checkouts carry CRLF line endings and whose +# subsequent StrReplace edits would otherwise miss on `\r` boundaries. The +# normalization applies on all platforms; these tests lock that in so the +# behavior is intentional and discoverable, not accidental. + +class TestReadFileLineEndingNormalization: + + @pytest.fixture() + def tool(self, tmp_path): + return ReadFileTool(workspace=tmp_path) + + @pytest.mark.asyncio + async def test_crlf_is_normalized_to_lf(self, tool, tmp_path): + f = tmp_path / "crlf.txt" + f.write_bytes(b"alpha\r\nbeta\r\ngamma\r\n") + result = await tool.execute(path=str(f)) + assert "\r" not in result + assert "alpha" in result and "beta" in result and "gamma" in result + + @pytest.mark.asyncio + async def test_lf_only_is_preserved(self, tool, tmp_path): + f = tmp_path / "lf.txt" + f.write_bytes(b"alpha\nbeta\ngamma\n") + result = await tool.execute(path=str(f)) + assert "\r" not in result + assert "alpha" in result and "beta" in result and "gamma" in result diff --git a/tests/tools/test_search_tools.py b/tests/tools/test_search_tools.py index 3153caa4..fac033ac 100644 --- a/tests/tools/test_search_tools.py +++ b/tests/tools/test_search_tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import time from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -10,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest from nanobot.agent.loop import AgentLoop -from nanobot.agent.subagent import SubagentManager +from nanobot.agent.subagent import SubagentManager, SubagentStatus from nanobot.agent.tools.search import GlobTool, GrepTool from nanobot.bus.queue import MessageBus @@ -179,9 +180,13 @@ async def test_grep_files_with_matches_supports_head_limit_and_offset(tmp_path: offset=1, ) - lines = result.splitlines() - assert lines[0] == "src/b.py" + # Filesystem order is not deterministic across platforms, so just verify: + # 1. Only one file path is returned (head_limit=1 after offset=1) + # 2. The pagination info is correct assert "pagination: limit=1, offset=1" in result + # Count non-empty lines that start with src/ (file paths) + file_lines = [l for l in result.splitlines() if l.startswith("src/")] + assert len(file_lines) == 1 @pytest.mark.asyncio @@ -319,7 +324,8 @@ async def test_subagent_registers_grep_and_glob(tmp_path: Path) -> None: mgr.runner.run = fake_run mgr._announce_result = AsyncMock() - await mgr._run_subagent("sub-1", "search task", "label", {"channel": "cli", "chat_id": "direct"}) + status = SubagentStatus(task_id="sub-1", label="label", task_description="search task", started_at=time.monotonic()) + await mgr._run_subagent("sub-1", "search task", "label", {"channel": "cli", "chat_id": "direct"}, status) assert "grep" in captured["tool_names"] assert "glob" in captured["tool_names"] diff --git a/tests/tools/test_tool_registry.py b/tests/tools/test_tool_registry.py index 5b259119..ca60f30e 100644 --- a/tests/tools/test_tool_registry.py +++ b/tests/tools/test_tool_registry.py @@ -47,3 +47,57 @@ def test_get_definitions_orders_builtins_then_mcp_tools() -> None: "mcp_fs_list", "mcp_git_status", ] + + +def test_prepare_call_read_file_rejects_non_object_params_with_actionable_hint() -> None: + registry = ToolRegistry() + registry.register(_FakeTool("read_file")) + + tool, params, error = registry.prepare_call("read_file", ["foo.txt"]) + + assert tool is None + assert params == ["foo.txt"] + assert error is not None + assert "must be a JSON object" in error + assert "Use named parameters" in error + + +def test_prepare_call_other_tools_keep_generic_object_validation() -> None: + registry = ToolRegistry() + registry.register(_FakeTool("grep")) + + tool, params, error = registry.prepare_call("grep", ["TODO"]) + + assert tool is not None + assert params == ["TODO"] + assert error == "Error: Invalid parameters for tool 'grep': parameters must be an object, got list" + + +def test_get_definitions_returns_cached_result() -> None: + registry = ToolRegistry() + registry.register(_FakeTool("read_file")) + first = registry.get_definitions() + assert registry._cached_definitions is not None + second = registry.get_definitions() + assert first == second + + +def test_register_invalidates_cache() -> None: + registry = ToolRegistry() + registry.register(_FakeTool("read_file")) + first = registry.get_definitions() + registry.register(_FakeTool("write_file")) + second = registry.get_definitions() + assert first is not second + assert len(second) == 2 + + +def test_unregister_invalidates_cache() -> None: + registry = ToolRegistry() + registry.register(_FakeTool("read_file")) + registry.register(_FakeTool("write_file")) + first = registry.get_definitions() + registry.unregister("write_file") + second = registry.get_definitions() + assert first is not second + assert len(second) == 1 diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index 072623db..73e3b4f2 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -545,18 +545,23 @@ async def test_exec_always_returns_exit_code() -> None: assert "hello" in result -async def test_exec_head_tail_truncation() -> None: +async def test_exec_head_tail_truncation(tmp_path) -> None: """Long output should preserve both head and tail.""" tool = ExecTool() - # Generate output that exceeds _MAX_OUTPUT (10_000 chars) - # Use current interpreter (PATH may not have `python`). ExecTool uses - # create_subprocess_shell: POSIX needs shlex.quote; Windows uses cmd.exe - # rules, so list2cmdline is appropriate there. - script = "print('A' * 6000 + '\\n' + 'B' * 6000)" + # Generate output that exceeds _MAX_OUTPUT (10_000 chars). + # Use a temp script file so the output-generating logic lives in a file + # (Windows cmd.exe has finicky rules for quoting `-c` payloads with + # embedded newlines). ExecTool runs via create_subprocess_shell, so we + # must quote *both* the interpreter path and the script path — tmp_path + # on some CI runners and on many local Windows installs contains spaces + # (e.g. C:\Users\John Doe\AppData\...) which would otherwise break the + # shell's argv split. + script_file = tmp_path / "gen_output.py" + script_file.write_text("print('A' * 6000 + chr(10) + 'B' * 6000)", encoding="utf-8") if sys.platform == "win32": - command = subprocess.list2cmdline([sys.executable, "-c", script]) + command = subprocess.list2cmdline([sys.executable, str(script_file)]) else: - command = f"{shlex.quote(sys.executable)} -c {shlex.quote(script)}" + command = f"{shlex.quote(sys.executable)} {shlex.quote(str(script_file))}" result = await tool.execute(command=command) assert "chars truncated" in result # Head portion should start with As diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 790d8adc..a42e51e1 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -1,7 +1,5 @@ """Tests for multi-provider web search.""" -import asyncio - import httpx import pytest @@ -20,6 +18,25 @@ def _response(status: int = 200, json: dict | None = None) -> httpx.Response: return r +def test_duckduckgo_search_is_exclusive(): + tool = _tool(provider="duckduckgo") + assert tool.exclusive is True + assert tool.concurrency_safe is False + + +def test_brave_with_api_key_remains_concurrency_safe(): + tool = _tool(provider="brave", api_key="brave-key") + assert tool.exclusive is False + assert tool.concurrency_safe is True + + +def test_brave_without_api_key_is_treated_as_duckduckgo_for_concurrency(monkeypatch): + monkeypatch.delenv("BRAVE_API_KEY", raising=False) + tool = _tool(provider="brave", api_key="") + assert tool.exclusive is True + assert tool.concurrency_safe is False + + @pytest.mark.asyncio async def test_brave_search(monkeypatch): async def mock_get(self, url, **kw): @@ -79,7 +96,6 @@ async def test_duckduckgo_search(monkeypatch): import nanobot.agent.tools.web as web_mod monkeypatch.setattr(web_mod, "DDGS", MockDDGS, raising=False) - from ddgs import DDGS monkeypatch.setattr("ddgs.DDGS", MockDDGS) tool = _tool(provider="duckduckgo") @@ -265,5 +281,3 @@ async def test_duckduckgo_timeout_returns_error(monkeypatch): result = await tool.execute(query="test") gate.set() assert "Error" in result - - diff --git a/tests/utils/test_gitstore.py b/tests/utils/test_gitstore.py new file mode 100644 index 00000000..b431bf71 --- /dev/null +++ b/tests/utils/test_gitstore.py @@ -0,0 +1,216 @@ +"""Tests for GitStore — line_ages() and core git operations.""" + +import subprocess +import time +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest + +from nanobot.utils.gitstore import GitStore + + +@pytest.fixture +def git(tmp_path): + """Create an initialized GitStore with tracked MEMORY.md.""" + g = GitStore(tmp_path, tracked_files=["MEMORY.md", "SOUL.md"]) + g.init() + return g + + +class TestLineAges: + def test_returns_empty_when_not_initialized(self, tmp_path): + """line_ages should return [] if the git repo is not initialized.""" + git = GitStore(tmp_path, tracked_files=["MEMORY.md"]) + assert git.line_ages("MEMORY.md") == [] + + def test_returns_empty_for_missing_file(self, git): + """line_ages should return [] for a file that doesn't exist.""" + assert git.line_ages("SOUL.md") == [] + + def test_returns_empty_for_empty_file(self, git, tmp_path): + """line_ages should return [] for an empty tracked file.""" + (tmp_path / "SOUL.md").write_text("", encoding="utf-8") + git.auto_commit("empty soul") + assert git.line_ages("SOUL.md") == [] + + def test_one_age_per_line(self, git, tmp_path): + """line_ages should return one entry per line in the file.""" + content = "# Memory\n\n## Section A\n- item 1\n" + (tmp_path / "MEMORY.md").write_text(content, encoding="utf-8") + git.auto_commit("initial") + ages = git.line_ages("MEMORY.md") + assert len(ages) == len(content.splitlines()) + + def test_fresh_lines_have_age_zero(self, git, tmp_path): + """Lines committed today should have age_days=0.""" + (tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8") + git.auto_commit("initial") + ages = git.line_ages("MEMORY.md") + assert all(a.age_days == 0 for a in ages) + + def test_age_differentiates_across_days(self, git, tmp_path): + """Lines committed today should show correct age when 'now' is mocked forward.""" + (tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8") + git.auto_commit("initial") + + future_now = datetime.now(tz=timezone.utc) + timedelta(days=30) + with patch("nanobot.utils.gitstore.datetime") as mock_dt: + mock_dt.now.return_value = future_now + mock_dt.fromtimestamp = datetime.fromtimestamp + ages = git.line_ages("MEMORY.md") + + assert len(ages) == 2 + assert all(a.age_days == 30 for a in ages) + + def test_annotate_failure_returns_empty(self, tmp_path): + """If annotate fails, line_ages should return [] gracefully.""" + git = GitStore(tmp_path, tracked_files=["MEMORY.md"]) + # Don't init — annotate will fail + assert git.line_ages("MEMORY.md") == [] + + def test_partial_edit_only_updates_changed_lines(self, git, tmp_path): + """Only modified lines should reflect the new commit's timestamp.""" + (tmp_path / "MEMORY.md").write_text( + "# Memory\n\n## A\n- old\n\n## B\n- keep\n", encoding="utf-8" + ) + git.auto_commit("commit1") + time.sleep(1.1) + + # Only modify section A + (tmp_path / "MEMORY.md").write_text( + "# Memory\n\n## A\n- new\n\n## B\n- keep\n", encoding="utf-8" + ) + git.auto_commit("commit2") + + ages = git.line_ages("MEMORY.md") + lines = (tmp_path / "MEMORY.md").read_text(encoding="utf-8").splitlines() + # All lines are from today, but verify line-level tracking works + assert len(ages) == len(lines) + # "- new" line and "- keep" line both age=0 (same day), but + # the key point is we get per-line results + assert len(ages) == 7 + + +class TestNestedRepoProtection: + """Regression tests for GitHub issue #2980: nested repo protection.""" + + def test_init_refuses_inside_git_repo(self, tmp_path): + """init() should detect it's inside an existing git repo and refuse.""" + project = tmp_path / "project" + project.mkdir() + (project / ".git").mkdir() + + workspace = project / "workspace" + workspace.mkdir() + + g = GitStore(workspace, tracked_files=["MEMORY.md"]) + result = g.init() + + assert result is False + assert not (workspace / ".git").is_dir() + + def test_init_preserves_existing_gitignore(self, tmp_path): + """init() should preserve existing .gitignore entries and append new ones.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + + existing = "*.pyc\n__pycache__/\n" + (workspace / ".gitignore").write_text(existing, encoding="utf-8") + + g = GitStore(workspace, tracked_files=["MEMORY.md"]) + result = g.init() + + assert result is True + gitignore = (workspace / ".gitignore").read_text(encoding="utf-8") + assert "*.pyc" in gitignore + assert "__pycache__/" in gitignore + assert "!MEMORY.md" in gitignore + assert "!.gitignore" in gitignore + + def test_init_no_gitignore_creates_new(self, tmp_path): + """init() should create .gitignore with Dream content when none exists.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + + g = GitStore(workspace, tracked_files=["MEMORY.md"]) + result = g.init() + + assert result is True + gitignore = (workspace / ".gitignore").read_text(encoding="utf-8") + expected = g._build_gitignore() + assert gitignore == expected + + def test_init_gitignore_merge_idempotent(self, tmp_path): + """init() should not duplicate Dream entries already in .gitignore.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + + # Pre-existing .gitignore that already has some Dream entries + existing = "*.pyc\n/*\n!MEMORY.md\n" + (workspace / ".gitignore").write_text(existing, encoding="utf-8") + + g = GitStore(workspace, tracked_files=["MEMORY.md"]) + result = g.init() + + assert result is True + gitignore = (workspace / ".gitignore").read_text(encoding="utf-8") + # No duplicate lines + lines = gitignore.splitlines() + assert lines.count("/*") == 1 + assert lines.count("!MEMORY.md") == 1 + # Existing entry preserved, new Dream entries appended + assert "*.pyc" in gitignore + assert "!.gitignore" in gitignore + + def test_init_outside_git_repo_works_normally(self, tmp_path): + """init() should succeed and create .git when not inside a git repo.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + + g = GitStore(workspace, tracked_files=["MEMORY.md"]) + result = g.init() + + assert result is True + assert (workspace / ".git").is_dir() + + def test_init_refuses_inside_git_worktree(self, tmp_path): + """init() should refuse when the parent checkout is a git worktree.""" + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + (repo / "README.md").write_text("x\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "README.md"], check=True) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=test", + "-c", + "user.email=test@example.com", + "commit", + "-q", + "-m", + "init", + ], + check=True, + ) + subprocess.run(["git", "-C", str(repo), "branch", "wt-branch"], check=True) + + worktree = tmp_path / "worktree" + subprocess.run( + ["git", "-C", str(repo), "worktree", "add", "-q", str(worktree), "wt-branch"], + check=True, + ) + assert (worktree / ".git").is_file() + + workspace = worktree / "workspace" + workspace.mkdir() + + g = GitStore(workspace, tracked_files=["MEMORY.md"]) + result = g.init() + + assert result is False + assert not (workspace / ".git").exists()