Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71bbefceb1 | ||
|
|
bdaa1b35e4 | ||
|
|
4c9ae63bcf | ||
|
|
c606ab9318 | ||
|
|
e0a1722fa4 | ||
|
|
9891aea1eb | ||
|
|
c2e70260c4 | ||
|
|
b4b5de889a | ||
|
|
d0b0284189 | ||
|
|
c0a87d77fc | ||
|
|
6e627bc2e0 | ||
|
|
ece660ae69 | ||
|
|
84268edf01 | ||
|
|
9136cca1ff | ||
|
|
6035b70ae5 | ||
|
|
e4c300bcfd | ||
|
|
0c65efee06 | ||
|
|
c12d234ee8 | ||
|
|
2954933a55 | ||
|
|
a71cce08c1 | ||
|
|
c10544fc19 | ||
|
|
7c659bc0fe | ||
|
|
ea5bf4cf5d | ||
|
|
9f3e4089c2 | ||
|
|
c9880d4267 | ||
|
|
af436f5e6c | ||
|
|
2d3c94e609 | ||
|
|
88d2abc6c5 | ||
|
|
112212d3cd | ||
|
|
2ab51cb80b | ||
|
|
f71b3b3fea | ||
|
|
9990e80d61 | ||
|
|
9a131cb0ed | ||
|
|
c5ab4098ca | ||
|
|
5a8f3f772c | ||
|
|
e1a98d68ff | ||
|
|
92065dbb74 | ||
|
|
55ad41265f | ||
|
|
6d0d995b1b | ||
|
|
f444e94ff7 | ||
|
|
5f9af317c4 | ||
|
|
4b3bc89d06 | ||
|
|
3323b9d909 | ||
|
|
96a7abcda4 | ||
|
|
534a8344bd | ||
|
|
7b710116a4 | ||
|
|
fc9545c36a |
+1
-2
@@ -15,9 +15,8 @@ docs/
|
||||
*.pyzz
|
||||
.venv/
|
||||
venv/
|
||||
.worktrees/
|
||||
__pycache__/
|
||||
poetry.lock
|
||||
.pytest_cache/
|
||||
botpy.log
|
||||
tests/
|
||||
botpy.log
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ ENV PATH="/root/.local/bin:${PATH}"
|
||||
|
||||
COPY pyproject.toml README.md LICENSE /app/
|
||||
COPY nanobot/ /app/nanobot/
|
||||
RUN uv pip install --system --no-cache --reinstall /app[mem0] psycopg2-binary
|
||||
RUN uv pip install --system --no-cache --reinstall /app psycopg2-binary
|
||||
|
||||
ENTRYPOINT ["nanobot"]
|
||||
CMD ["gateway"]
|
||||
|
||||
@@ -16,40 +16,22 @@
|
||||
|
||||
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
|
||||
|
||||
📏 Real-time line count: **3,922 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
||||
📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
||||
|
||||
## 📢 News
|
||||
|
||||
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
|
||||
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
|
||||
- **2026-02-22** 🛡️ Slack thread isolation, Discord typing fix, agent reliability improvements.
|
||||
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
|
||||
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
|
||||
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
|
||||
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
|
||||
- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
|
||||
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
|
||||
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
|
||||
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details.
|
||||
- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
|
||||
- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
|
||||
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
|
||||
- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
|
||||
|
||||
<details>
|
||||
<summary>Earlier news</summary>
|
||||
|
||||
- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
|
||||
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
|
||||
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
|
||||
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
|
||||
- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
|
||||
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
|
||||
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
|
||||
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
|
||||
- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
|
||||
- **2026-02-04** 🚀 Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
|
||||
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
|
||||
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
|
||||
|
||||
</details>
|
||||
|
||||
## Key Features of nanobot:
|
||||
|
||||
🪶 **Ultra-Lightweight**: Just ~4,000 lines of core agent code — 99% smaller than Clawdbot.
|
||||
@@ -125,26 +107,17 @@ nanobot onboard
|
||||
|
||||
**2. Configure** (`~/.nanobot/config.json`)
|
||||
|
||||
Add or merge these **two parts** into your config (other options have defaults).
|
||||
|
||||
*Set your API key* (e.g. OpenRouter, recommended for global users):
|
||||
For OpenRouter - recommended for global users:
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
*Set your model* (optionally pin a provider — defaults to auto-detection):
|
||||
```json
|
||||
{
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"provider": "openrouter"
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,26 +126,63 @@ Add or merge these **two parts** into your config (other options have defaults).
|
||||
**3. Chat**
|
||||
|
||||
```bash
|
||||
nanobot agent
|
||||
nanobot agent -m "What is 2+2?"
|
||||
```
|
||||
|
||||
That's it! You have a working AI assistant in 2 minutes.
|
||||
|
||||
## 🖥️ Local Models (vLLM)
|
||||
|
||||
Run nanobot with your own local models using vLLM or any OpenAI-compatible server.
|
||||
|
||||
**1. Start your vLLM server**
|
||||
|
||||
```bash
|
||||
vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
|
||||
```
|
||||
|
||||
**2. Configure** (`~/.nanobot/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"vllm": {
|
||||
"apiKey": "dummy",
|
||||
"apiBase": "http://localhost:8000/v1"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "meta-llama/Llama-3.1-8B-Instruct"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Chat**
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello from my local LLM!"
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> The `apiKey` can be any non-empty string for local servers that don't require authentication.
|
||||
|
||||
## 💬 Chat Apps
|
||||
|
||||
Connect nanobot to your favorite chat platform.
|
||||
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ — anytime, anywhere.
|
||||
|
||||
| Channel | What you need |
|
||||
|---------|---------------|
|
||||
| **Telegram** | Bot token from @BotFather |
|
||||
| **Discord** | Bot token + Message Content intent |
|
||||
| **WhatsApp** | QR code scan |
|
||||
| **Feishu** | App ID + App Secret |
|
||||
| **Mochat** | Claw token (auto-setup available) |
|
||||
| **DingTalk** | App Key + App Secret |
|
||||
| **Slack** | Bot token + App-Level token |
|
||||
| **Email** | IMAP/SMTP credentials |
|
||||
| **QQ** | App ID + App Secret |
|
||||
| Channel | Setup |
|
||||
|---------|-------|
|
||||
| **Telegram** | Easy (just a token) |
|
||||
| **Discord** | Easy (bot token + intents) |
|
||||
| **WhatsApp** | Medium (scan QR) |
|
||||
| **Feishu** | Medium (app credentials) |
|
||||
| **Mochat** | Medium (claw token + websocket) |
|
||||
| **DingTalk** | Medium (app credentials) |
|
||||
| **Slack** | Medium (bot + app tokens) |
|
||||
| **Email** | Medium (IMAP/SMTP credentials) |
|
||||
| **QQ** | Easy (app credentials) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommended)</summary>
|
||||
@@ -309,72 +319,6 @@ nanobot gateway
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Matrix (Element)</b></summary>
|
||||
|
||||
Install Matrix dependencies first:
|
||||
|
||||
```bash
|
||||
pip install nanobot-ai[matrix]
|
||||
```
|
||||
|
||||
**1. Create/choose a Matrix account**
|
||||
|
||||
- Create or reuse a Matrix account on your homeserver (for example `matrix.org`).
|
||||
- Confirm you can log in with Element.
|
||||
|
||||
**2. Get credentials**
|
||||
|
||||
- You need:
|
||||
- `userId` (example: `@nanobot:matrix.org`)
|
||||
- `accessToken`
|
||||
- `deviceId` (recommended so sync tokens can be restored across restarts)
|
||||
- You can obtain these from your homeserver login API (`/_matrix/client/v3/login`) or from your client's advanced session settings.
|
||||
|
||||
**3. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"matrix": {
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.org",
|
||||
"userId": "@nanobot:matrix.org",
|
||||
"accessToken": "syt_xxx",
|
||||
"deviceId": "NANOBOT01",
|
||||
"e2eeEnabled": true,
|
||||
"allowFrom": [],
|
||||
"groupPolicy": "open",
|
||||
"groupAllowFrom": [],
|
||||
"allowRoomMentions": false,
|
||||
"maxMediaBytes": 20971520
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Keep a persistent `matrix-store` and stable `deviceId` — encrypted session state is lost if these change across restarts.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `allowFrom` | User IDs allowed to interact. Empty = all senders. |
|
||||
| `groupPolicy` | `open` (default), `mention`, or `allowlist`. |
|
||||
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
|
||||
| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
|
||||
| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. |
|
||||
| `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |
|
||||
|
||||
|
||||
|
||||
|
||||
**4. Run**
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>WhatsApp</b></summary>
|
||||
|
||||
@@ -652,119 +596,21 @@ Config file: `~/.nanobot/config.json`
|
||||
> - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
||||
> - **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.
|
||||
> - **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.
|
||||
> - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
|----------|---------|-------------|
|
||||
| `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | — |
|
||||
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) |
|
||||
| `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) |
|
||||
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
|
||||
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
||||
| `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) |
|
||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
|
||||
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
|
||||
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
||||
|
||||
<details>
|
||||
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
||||
|
||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account.
|
||||
|
||||
**1. Login:**
|
||||
```bash
|
||||
nanobot provider login openai-codex
|
||||
```
|
||||
|
||||
**2. Set model** (merge into `~/.nanobot/config.json`):
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "openai-codex/gpt-5.1-codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Chat:**
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
> Docker users: use `docker run -it` for interactive OAuth login.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
|
||||
|
||||
Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Bypasses LiteLLM; model name is passed as-is.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "your-api-key",
|
||||
"apiBase": "https://api.your-provider.com/v1"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "your-model-name"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>vLLM (local / OpenAI-compatible)</b></summary>
|
||||
|
||||
Run your own model with vLLM or any OpenAI-compatible server, then add to config:
|
||||
|
||||
**1. Start the server** (example):
|
||||
```bash
|
||||
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):*
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"vllm": {
|
||||
"apiKey": "dummy",
|
||||
"apiBase": "http://localhost:8000/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
*Model:*
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "meta-llama/Llama-3.1-8B-Instruct"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Adding a New Provider (Developer Guide)</b></summary>
|
||||
@@ -811,70 +657,13 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
|
||||
</details>
|
||||
|
||||
|
||||
### MCP (Model Context Protocol)
|
||||
|
||||
> [!TIP]
|
||||
> The config format is compatible with Claude Desktop / Cursor. You can copy MCP server configs directly from any MCP server's README.
|
||||
|
||||
nanobot supports [MCP](https://modelcontextprotocol.io/) — connect external tool servers and use them as native agent tools.
|
||||
|
||||
Add MCP servers to your `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
|
||||
},
|
||||
"my-remote-mcp": {
|
||||
"url": "https://example.com/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Bearer xxxxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two transport modes are supported:
|
||||
|
||||
| Mode | Config | Example |
|
||||
|------|--------|---------|
|
||||
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
|
||||
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
|
||||
|
||||
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"my-slow-server": {
|
||||
"url": "https://example.com/mcp/",
|
||||
"toolTimeout": 120
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
|
||||
|
||||
|
||||
|
||||
|
||||
### Security
|
||||
|
||||
> [!TIP]
|
||||
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
|
||||
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
|
||||
| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. |
|
||||
|
||||
|
||||
@@ -889,7 +678,6 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
||||
| `nanobot agent --logs` | Show runtime logs during chat |
|
||||
| `nanobot gateway` | Start the gateway |
|
||||
| `nanobot status` | Show status |
|
||||
| `nanobot provider login openai-codex` | OAuth login for providers |
|
||||
| `nanobot channels login` | Link WhatsApp (scan QR) |
|
||||
| `nanobot channels status` | Show channel status |
|
||||
|
||||
@@ -912,46 +700,12 @@ nanobot cron remove <job_id>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
|
||||
|
||||
The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel.
|
||||
|
||||
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
|
||||
|
||||
```markdown
|
||||
## Periodic Tasks
|
||||
|
||||
- [ ] Check weather forecast and send a summary
|
||||
- [ ] Scan inbox for urgent emails
|
||||
```
|
||||
|
||||
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you.
|
||||
|
||||
> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to.
|
||||
|
||||
</details>
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
> [!TIP]
|
||||
> The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose run --rm nanobot-cli onboard # first-time setup
|
||||
vim ~/.nanobot/config.json # add API keys
|
||||
docker compose up -d nanobot-gateway # start gateway
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
|
||||
docker compose logs -f nanobot-gateway # view logs
|
||||
docker compose down # stop
|
||||
```
|
||||
|
||||
### Docker
|
||||
Build and run nanobot in a container:
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
@@ -971,59 +725,6 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot agent -m "Hello!"
|
||||
docker run -v ~/.nanobot:/root/.nanobot --rm nanobot status
|
||||
```
|
||||
|
||||
## 🐧 Linux Service
|
||||
|
||||
Run the gateway as a systemd user service so it starts automatically and restarts on failure.
|
||||
|
||||
**1. Find the nanobot binary path:**
|
||||
|
||||
```bash
|
||||
which nanobot # e.g. /home/user/.local/bin/nanobot
|
||||
```
|
||||
|
||||
**2. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service` (replace `ExecStart` path if needed):
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Nanobot Gateway
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/nanobot gateway
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=%h
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
**3. Enable and start:**
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now nanobot-gateway
|
||||
```
|
||||
|
||||
**Common operations:**
|
||||
|
||||
```bash
|
||||
systemctl --user status nanobot-gateway # check status
|
||||
systemctl --user restart nanobot-gateway # restart after config changes
|
||||
journalctl --user -u nanobot-gateway -f # follow logs
|
||||
```
|
||||
|
||||
If you edit the `.service` file itself, run `systemctl --user daemon-reload` before restarting.
|
||||
|
||||
> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
|
||||
>
|
||||
> ```bash
|
||||
> loginctl enable-linger $USER
|
||||
> ```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
@@ -1052,6 +753,7 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||
|
||||
**Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)!
|
||||
|
||||
- [x] **Voice Transcription** — Support for Groq Whisper (Issue #13)
|
||||
- [ ] **Multi-modal** — See and hear (images, voice, video)
|
||||
- [ ] **Long-term memory** — Never forget important context
|
||||
- [ ] **Better reasoning** — Multi-step planning and reflection
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
If you discover a security vulnerability in nanobot, please report it by:
|
||||
|
||||
1. **DO NOT** open a public GitHub issue
|
||||
2. Create a private security advisory on GitHub or contact the repository maintainers (xubinrencs@gmail.com)
|
||||
2. Create a private security advisory on GitHub or contact the repository maintainers
|
||||
3. Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
x-common-config: &common-config
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ~/.nanobot:/root/.nanobot
|
||||
|
||||
services:
|
||||
nanobot-gateway:
|
||||
container_name: nanobot-gateway
|
||||
<<: *common-config
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 18790:18790
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
nanobot-cli:
|
||||
<<: *common-config
|
||||
profiles:
|
||||
- cli
|
||||
command: ["status"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
@@ -1,265 +0,0 @@
|
||||
# Design: Native Anthropic Tools Integration
|
||||
|
||||
**Goal**: Integrate Anthropic's native trained tools (bash_20250124, text_editor_20250728, computer_20251124) into nanobot to leverage model's trained behaviors instead of custom function tools.
|
||||
|
||||
## Overview
|
||||
|
||||
Anthropic's native tools are version-coupled to model training. Unlike custom function tools (which the model learns via instruction-following at inference time), native tools have their behaviors baked into model weights during training. This provides more reliable tool execution.
|
||||
|
||||
**Key Insight**: The Anthropic API accepts BOTH tool formats in the same request:
|
||||
- Function tools: `{type: "function", function: {name, description, input_schema}}`
|
||||
- Native tools: `{type: "bash_20250124", name: "bash"}` (schema-less)
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Tool Addition Strategy
|
||||
|
||||
Add three native tool implementations from anthropic-quickstarts reference:
|
||||
- **BashTool20250124** - persistent bash session (replaces ExecTool)
|
||||
- **EditTool20250728** - file operations with view/create/str_replace/insert (replaces EditTool, possibly ReadFileTool/WriteFileTool)
|
||||
- **ComputerTool20251124** - VNC desktop control (new capability)
|
||||
|
||||
Location: `nanobot/agent/tools/anthropic/` (new subpackage)
|
||||
|
||||
Port from reference:
|
||||
- Base classes: `BaseAnthropicTool`, `ToolResult`, `CLIResult`, `ToolError`
|
||||
- Tool implementations with trained behaviors intact
|
||||
- Session management (_BashSession for bash tool)
|
||||
|
||||
### 2. Registry Changes
|
||||
|
||||
Make `ToolRegistry` format-agnostic via duck typing:
|
||||
|
||||
**Current**: Only calls `tool.to_schema()`, expects function format
|
||||
|
||||
**New**: Support both interfaces
|
||||
```python
|
||||
def get_definitions(self) -> list[dict[str, Any]]:
|
||||
definitions = []
|
||||
for tool in self._tools.values():
|
||||
if hasattr(tool, 'to_params'): # Native Anthropic tool
|
||||
definitions.append(tool.to_params())
|
||||
elif hasattr(tool, 'to_schema'): # Function tool
|
||||
definitions.append(tool.to_schema())
|
||||
else:
|
||||
raise ValueError(f"Tool {tool.name} has no schema method")
|
||||
return definitions
|
||||
```
|
||||
|
||||
**Execution**: No changes needed - `execute()` already looks up by name and calls the tool. Native tools implement `__call__(**kwargs)` which works with existing dispatch.
|
||||
|
||||
**Result**: Registry becomes thin coordination layer, doesn't enforce specific base class.
|
||||
|
||||
### 3. Tool Implementations
|
||||
|
||||
#### BashTool20250124
|
||||
- Maintains persistent bash session via `_BashSession` class
|
||||
- Sentinel-based output reading for reliable command capture
|
||||
- Timeout handling (120s default)
|
||||
- Restart capability
|
||||
- Returns: `ToolResult(output=..., error=...)`
|
||||
|
||||
#### EditTool20250728
|
||||
- Commands: `view`, `create`, `str_replace`, `insert`
|
||||
- Path validation (absolute paths required)
|
||||
- `str_replace`: uniqueness checking before replacement
|
||||
- `insert`: line number validation
|
||||
- File history tracking for potential undo
|
||||
- Returns: `CLIResult(output=...)` with formatted snippets
|
||||
|
||||
#### ComputerTool20251124
|
||||
- VNC desktop interaction (keyboard, mouse, screenshots)
|
||||
- Actions: `key`, `type`, `mouse_move`, `left_click`, `right_click`, `double_click`, `screenshot`, etc.
|
||||
- Screenshot returns `ToolResult(base64_image=...)`
|
||||
- Coordinate scaling support
|
||||
- Connects to VNC at 172.17.0.1:5900 (Windows VM from code-server)
|
||||
|
||||
### 4. API Integration
|
||||
|
||||
Update `anthropic_oauth.py._convert_tools_to_anthropic()` to pass through both formats:
|
||||
|
||||
**Current**: Only converts `type: "function"` tools
|
||||
```python
|
||||
if tool.get("type") == "function":
|
||||
# convert to Anthropic format
|
||||
```
|
||||
|
||||
**New**: Pass through ALL formats
|
||||
```python
|
||||
def _convert_tools_to_anthropic(self, tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
anthropic_tools = []
|
||||
for tool in tools:
|
||||
if tool.get("type") == "function":
|
||||
# Convert function tool format
|
||||
func = tool["function"]
|
||||
anthropic_tools.append({
|
||||
"name": func["name"],
|
||||
"description": func.get("description", ""),
|
||||
"input_schema": func.get("parameters", {"type": "object", "properties": {}})
|
||||
})
|
||||
else:
|
||||
# Pass through native tool format as-is
|
||||
# (bash_20250124, text_editor_20250728, computer_20251124)
|
||||
anthropic_tools.append(tool)
|
||||
|
||||
return anthropic_tools if anthropic_tools else None
|
||||
```
|
||||
|
||||
**Distinction**: Based on `type` field
|
||||
- `type == "function"` → function tool, needs conversion
|
||||
- `type == "bash_20250124"` (or other native type) → pass through as-is
|
||||
|
||||
### 5. Tool Result Handling
|
||||
|
||||
**Current**: Tools return plain strings
|
||||
|
||||
**New**: Native tools return `ToolResult` objects
|
||||
```python
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ToolResult:
|
||||
output: str | None = None
|
||||
error: str | None = None
|
||||
base64_image: str | None = None
|
||||
system: str | None = None
|
||||
```
|
||||
|
||||
**Agent loop changes** (`loop.py`): Handle both return types
|
||||
```python
|
||||
result = await self.tools.execute(tool_name, tool_input)
|
||||
|
||||
if isinstance(result, ToolResult):
|
||||
# Native tool result - build structured content
|
||||
tool_result_content = []
|
||||
if result.output:
|
||||
tool_result_content.append({"type": "text", "text": result.output})
|
||||
if result.error:
|
||||
tool_result_content.append({"type": "text", "text": f"Error: {result.error}"})
|
||||
if result.base64_image:
|
||||
# Image handling (see Section 6)
|
||||
pass
|
||||
if result.system:
|
||||
# System messages for next turn
|
||||
pass
|
||||
else:
|
||||
# Legacy string result from function tools
|
||||
tool_result_content = [{"type": "text", "text": str(result)}]
|
||||
```
|
||||
|
||||
### 6. Image Handling Flow
|
||||
|
||||
**Goal**: Both model and user see screenshots from computer tool
|
||||
|
||||
**Implementation**: Track media across tool iteration loop
|
||||
|
||||
```python
|
||||
# At start of agent turn
|
||||
media_paths_for_turn: list[str] = []
|
||||
|
||||
# During tool execution
|
||||
if isinstance(result, ToolResult) and result.base64_image:
|
||||
# 1. Save to disk for user
|
||||
media_dir = Path.home() / ".nanobot" / "media"
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
screenshot_path = media_dir / f"screenshot_{int(time.time())}.png"
|
||||
screenshot_path.write_bytes(base64.b64decode(result.base64_image))
|
||||
media_paths_for_turn.append(str(screenshot_path))
|
||||
|
||||
# 2. Include in tool_result for model to see
|
||||
tool_result_content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": result.base64_image
|
||||
}
|
||||
})
|
||||
|
||||
# After final LLM response
|
||||
await self.bus.publish(OutboundMessage(
|
||||
channel=inbound.channel,
|
||||
chat_id=inbound.chat_id,
|
||||
content=final_response,
|
||||
media=media_paths_for_turn # Include all screenshots
|
||||
))
|
||||
```
|
||||
|
||||
**Result**:
|
||||
- Model sees base64 in tool_result → analyzes and reasons about it
|
||||
- User receives file via Telegram's media sending (`_send_with_media()`)
|
||||
|
||||
### 7. Version Management & Beta Flags
|
||||
|
||||
**Problem**: Each native tool version requires specific API beta flag
|
||||
|
||||
**Solution**: Add beta flag tracking to native tools
|
||||
|
||||
Each native tool class specifies its required beta flag:
|
||||
```python
|
||||
class BashTool20250124(BaseAnthropicTool):
|
||||
api_type = "bash_20250124"
|
||||
name = "bash"
|
||||
beta_flag = "computer-use-2025-11-24" # Required for API
|
||||
```
|
||||
|
||||
In `anthropic_oauth.py._make_request()`, collect beta flags:
|
||||
```python
|
||||
# Collect unique beta flags from native tools
|
||||
beta_flags = set()
|
||||
for tool in tools or []:
|
||||
if hasattr(tool, 'beta_flag') and tool.beta_flag:
|
||||
beta_flags.add(tool.beta_flag)
|
||||
|
||||
# Add to API request headers
|
||||
if beta_flags:
|
||||
headers["anthropic-beta"] = ",".join(sorted(beta_flags))
|
||||
```
|
||||
|
||||
**Note**: All three tools (bash, text_editor, computer) currently use the same beta flag: `"computer-use-2025-11-24"` as of the 2025-11-24 tool version.
|
||||
|
||||
### 8. Removing Overlapping Tools
|
||||
|
||||
Once native tools are implemented and tested, remove overlapping custom tools:
|
||||
|
||||
**To Remove**:
|
||||
- `ExecTool` → replaced by `BashTool20250124` (persistent session, better output)
|
||||
- `EditFileTool` → replaced by `EditTool20250728` (str_replace command)
|
||||
- Possibly `ReadFileTool`, `WriteFileTool` → `EditTool20250728` has `view` and `create` commands
|
||||
|
||||
**To Keep**:
|
||||
- `ListDirTool` → no native equivalent
|
||||
- `WebSearchTool`, `WebFetchTool` → no native equivalent
|
||||
- `MessageTool`, `SpawnTool`, `WaitForSubagentsTool` → nanobot-specific
|
||||
- `CronTool` → nanobot-specific
|
||||
|
||||
**Migration Notes**:
|
||||
- `EditTool20250728` only supports absolute paths (enforced in validation)
|
||||
- `BashTool20250124` maintains session state across calls (different from ExecTool's one-shot)
|
||||
- Test native tools thoroughly before removing custom ones
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Trained Behaviors**: Model knows how to use these tools from training, not instruction-following
|
||||
2. **Better Reliability**: Persistent bash sessions, validated file operations
|
||||
3. **New Capabilities**: Desktop interaction via computer tool
|
||||
4. **Future-Proof**: Easy to add more native tools as Anthropic releases them (just port implementation)
|
||||
5. **Unified System**: Both function tools and native tools work together in same request
|
||||
|
||||
## Trade-offs
|
||||
|
||||
1. **Code Duplication**: Porting reference implementations means maintaining separate codebase
|
||||
- Mitigation: Keep close to reference implementation for easier updates
|
||||
2. **Version Management**: Need to track tool versions and beta flags
|
||||
- Mitigation: Simple beta_flag attribute on tool classes
|
||||
3. **Testing Complexity**: Need to test both tool systems
|
||||
- Mitigation: Gradual rollout, keep custom tools until native tools proven
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. All three native tools execute successfully
|
||||
2. Model can use bash, edit, and computer tools in same conversation
|
||||
3. Screenshots from computer tool visible to both model and user
|
||||
4. No regression in existing functionality (other tools still work)
|
||||
5. Performance comparable to custom tools
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
nanobot - A lightweight AI agent framework
|
||||
"""
|
||||
|
||||
__version__ = "0.1.4.post2"
|
||||
__version__ = "0.1.0"
|
||||
__logo__ = "🐈"
|
||||
|
||||
+15
-45
@@ -6,10 +6,7 @@ import platform
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.memory_mem0 import Mem0MemoryStore, HAS_MEM0
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
|
||||
|
||||
@@ -22,21 +19,10 @@ class ContextBuilder:
|
||||
"""
|
||||
|
||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
|
||||
|
||||
def __init__(self, workspace: Path, mem0_config: dict[str, Any] | None = None):
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
self.workspace = workspace
|
||||
|
||||
# Choose memory backend based on config
|
||||
if mem0_config and mem0_config.get("enabled") and HAS_MEM0:
|
||||
self.memory = Mem0MemoryStore(workspace, config=mem0_config)
|
||||
self.use_mem0 = True
|
||||
logger.info("ContextBuilder using mem0 for semantic memory")
|
||||
else:
|
||||
if mem0_config and mem0_config.get("enabled"):
|
||||
logger.warning("mem0 enabled but not installed, falling back to MEMORY.md")
|
||||
self.memory = MemoryStore(workspace)
|
||||
self.use_mem0 = False
|
||||
|
||||
self.memory = MemoryStore(workspace)
|
||||
self.skills = SkillsLoader(workspace)
|
||||
|
||||
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
|
||||
@@ -59,14 +45,10 @@ class ContextBuilder:
|
||||
if bootstrap:
|
||||
parts.append(bootstrap)
|
||||
|
||||
# Static knowledge context (KNOWLEDGE.md — manually curated, stable for caching)
|
||||
# MEMORY.md is excluded from system prompt as it changes frequently (consolidator),
|
||||
# but the agent can still read/grep it via tools.
|
||||
knowledge_file = self.memory.memory_dir / "KNOWLEDGE.md"
|
||||
if knowledge_file.exists():
|
||||
knowledge = knowledge_file.read_text(encoding="utf-8").strip()
|
||||
if knowledge:
|
||||
parts.append(f"# Knowledge\n\n{knowledge}")
|
||||
# Memory context
|
||||
memory = self.memory.get_memory_context()
|
||||
if memory:
|
||||
parts.append(f"# Memory\n\n{memory}")
|
||||
|
||||
# Skills - progressive loading
|
||||
# 1. Always-loaded skills: include full content
|
||||
@@ -90,6 +72,10 @@ Skills with available="false" need dependencies installed first - you can try in
|
||||
|
||||
def _get_identity(self) -> str:
|
||||
"""Get the core identity section with runtime context."""
|
||||
from datetime import datetime
|
||||
import time as _time
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||||
tz = _time.strftime("%Z") or "UTC"
|
||||
workspace_path = str(self.workspace.expanduser().resolve())
|
||||
system = platform.system()
|
||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||
@@ -101,6 +87,9 @@ Skills with available="false" need dependencies installed first - you can try in
|
||||
- Send messages to users on chat channels
|
||||
- Spawn subagents for complex background tasks
|
||||
|
||||
## Current Time
|
||||
{now} ({tz})
|
||||
|
||||
## Runtime
|
||||
{runtime}
|
||||
|
||||
@@ -116,14 +105,7 @@ For normal conversation, just respond with text - do not call the message tool.
|
||||
|
||||
Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
|
||||
When remembering something important, write to {workspace_path}/memory/MEMORY.md
|
||||
To recall past events, grep {workspace_path}/memory/HISTORY.md
|
||||
|
||||
## Visibility Markers
|
||||
|
||||
Messages marked with [HIDDEN:{{signature}}] were not sent to the user. These markers
|
||||
are cryptographically signed by the system to track internal reasoning and background
|
||||
tasks. Do NOT generate [HIDDEN:*] patterns yourself - outputs containing forged
|
||||
visibility markers will be rejected."""
|
||||
To recall past events, grep {workspace_path}/memory/HISTORY.md"""
|
||||
|
||||
def _load_bootstrap_files(self) -> str:
|
||||
"""Load all bootstrap files from workspace."""
|
||||
@@ -166,18 +148,6 @@ visibility markers will be rejected."""
|
||||
system_prompt = self.build_system_prompt(skill_names)
|
||||
if channel and chat_id:
|
||||
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
|
||||
|
||||
# Add mem0 semantic memory context (if enabled)
|
||||
if self.use_mem0 and channel and chat_id:
|
||||
user_id = f"{channel}_{chat_id}"
|
||||
memory_context = self.memory.get_memory_context(
|
||||
query=current_message,
|
||||
user_id=user_id,
|
||||
limit=5
|
||||
)
|
||||
if memory_context:
|
||||
system_prompt += f"\n\n{memory_context}"
|
||||
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
# History
|
||||
|
||||
+81
-382
@@ -21,17 +21,15 @@ from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.wait import WaitForSubagentsTool
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.anthropic.base import ToolResult, CLIResult
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.visibility import sign_content, has_forged_marker, strip_all_hidden_markers
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
class AgentLoop:
|
||||
"""
|
||||
The agent loop is the core processing engine.
|
||||
|
||||
|
||||
It:
|
||||
1. Receives messages from the bus
|
||||
2. Builds context with history, memory, skills
|
||||
@@ -39,22 +37,6 @@ class AgentLoop:
|
||||
4. Executes tool calls
|
||||
5. Sends responses back
|
||||
"""
|
||||
|
||||
# Server-side context management: Anthropic trims old tool results and preserves all
|
||||
# thinking blocks (keep="all" maximises cache hits). Client keeps full history.
|
||||
CONTEXT_MANAGEMENT = {
|
||||
"edits": [
|
||||
{
|
||||
"type": "clear_thinking_20251015",
|
||||
"keep": "all", # Preserve all thinking blocks for cache reuse
|
||||
},
|
||||
{
|
||||
"type": "clear_tool_uses_20250919",
|
||||
"trigger": {"type": "input_tokens", "value": 80000},
|
||||
"keep": {"type": "tool_uses", "value": 5},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -68,8 +50,6 @@ class AgentLoop:
|
||||
exec_config: "ExecToolConfig | None" = None,
|
||||
cron_service: "CronService | None" = None,
|
||||
restrict_to_workspace: bool = False,
|
||||
enable_memory_tool: bool = True,
|
||||
mem0_config: dict[str, Any] | None = None,
|
||||
session_manager: SessionManager | None = None,
|
||||
):
|
||||
from nanobot.config.schema import ExecToolConfig
|
||||
@@ -84,16 +64,15 @@ class AgentLoop:
|
||||
self.exec_config = exec_config or ExecToolConfig()
|
||||
self.cron_service = cron_service
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self.enable_memory_tool = enable_memory_tool
|
||||
self.mem0_config = mem0_config
|
||||
|
||||
self.context = ContextBuilder(workspace, mem0_config=mem0_config)
|
||||
|
||||
self.context = ContextBuilder(workspace)
|
||||
self.sessions = session_manager or SessionManager(workspace)
|
||||
self.tools = ToolRegistry()
|
||||
self.subagents = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=workspace,
|
||||
bus=bus,
|
||||
model=self.model,
|
||||
brave_api_key=brave_api_key,
|
||||
exec_config=self.exec_config,
|
||||
restrict_to_workspace=restrict_to_workspace,
|
||||
@@ -106,54 +85,36 @@ class AgentLoop:
|
||||
|
||||
def _register_default_tools(self) -> None:
|
||||
"""Register the default set of tools."""
|
||||
# Import native tools
|
||||
from nanobot.agent.tools.anthropic import (
|
||||
BashTool20250124,
|
||||
EditTool20250728,
|
||||
ComputerTool20251124,
|
||||
)
|
||||
|
||||
# File tools (restrict to workspace if configured)
|
||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||
self.tools.register(ReadFileTool(allowed_dir=allowed_dir))
|
||||
self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
|
||||
# Removed: replaced by EditTool20250728
|
||||
# self.tools.register(EditFileTool(allowed_dir=allowed_dir))
|
||||
self.tools.register(EditFileTool(allowed_dir=allowed_dir))
|
||||
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
|
||||
|
||||
# Removed: replaced by BashTool20250124
|
||||
# self.tools.register(ExecTool(
|
||||
# working_dir=str(self.workspace),
|
||||
# timeout=self.exec_config.timeout,
|
||||
# restrict_to_workspace=self.restrict_to_workspace,
|
||||
# ))
|
||||
|
||||
|
||||
# Shell tool
|
||||
self.tools.register(ExecTool(
|
||||
working_dir=str(self.workspace),
|
||||
timeout=self.exec_config.timeout,
|
||||
restrict_to_workspace=self.restrict_to_workspace,
|
||||
))
|
||||
|
||||
# Web tools
|
||||
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||
self.tools.register(WebFetchTool())
|
||||
|
||||
|
||||
# Message tool
|
||||
message_tool = MessageTool(send_callback=self.bus.publish_outbound, sessions=self.sessions)
|
||||
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
|
||||
self.tools.register(message_tool)
|
||||
|
||||
|
||||
# Spawn tool (for subagents)
|
||||
spawn_tool = SpawnTool(manager=self.subagents)
|
||||
self.tools.register(spawn_tool)
|
||||
self.tools.register(WaitForSubagentsTool(manager=self.subagents))
|
||||
|
||||
|
||||
# Cron tool (for scheduling)
|
||||
if self.cron_service:
|
||||
self.tools.register(CronTool(self.cron_service))
|
||||
if self.enable_memory_tool:
|
||||
from nanobot.agent.tools.anthropic import MemoryTool20250818
|
||||
self.tools.register(MemoryTool20250818(workspace=self.workspace))
|
||||
|
||||
# Register native Anthropic tools
|
||||
self.tools.register(BashTool20250124())
|
||||
self.tools.register(EditTool20250728())
|
||||
self.tools.register(ComputerTool20251124())
|
||||
|
||||
logger.info("Registered native Anthropic tools: bash, text_editor, computer")
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Run the agent loop, processing messages from the bus."""
|
||||
@@ -179,8 +140,7 @@ class AgentLoop:
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=f"Sorry, I encountered an error: {str(e)}",
|
||||
metadata=msg.metadata or {},
|
||||
content=f"Sorry, I encountered an error: {str(e)}"
|
||||
))
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
@@ -308,44 +268,33 @@ class AgentLoop:
|
||||
session.clear()
|
||||
self.sessions.save(session)
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="🐈 New session started. Memory consolidated.",
|
||||
metadata=msg.metadata or {})
|
||||
content="🐈 New session started. Memory consolidated.")
|
||||
if cmd == "/help":
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status",
|
||||
metadata=msg.metadata or {})
|
||||
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status")
|
||||
if cmd == "/quota":
|
||||
status = self._get_quota_status()
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status,
|
||||
metadata=msg.metadata or {})
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status)
|
||||
|
||||
# Consolidate memory before processing if session is too large
|
||||
if len(session.messages) > self.memory_window:
|
||||
await self._consolidate_memory(session)
|
||||
|
||||
# Update tool contexts
|
||||
message_tool = self.tools.get("message")
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_tool.set_context(msg.channel, msg.chat_id)
|
||||
|
||||
|
||||
spawn_tool = self.tools.get("spawn")
|
||||
if isinstance(spawn_tool, SpawnTool):
|
||||
spawn_tool.set_context(msg.channel, msg.chat_id, msg.metadata)
|
||||
|
||||
spawn_tool.set_context(msg.channel, msg.chat_id)
|
||||
|
||||
cron_tool = self.tools.get("cron")
|
||||
if isinstance(cron_tool, CronTool):
|
||||
cron_tool.set_context(msg.channel, msg.chat_id)
|
||||
|
||||
# Track media for this turn (screenshots from computer tool)
|
||||
media_paths_for_turn: list[str] = []
|
||||
|
||||
# Prepend current time + optional time-gap notice to every user message
|
||||
now_dt = datetime.now()
|
||||
tz = time.strftime("%Z") or "UTC"
|
||||
time_str = now_dt.strftime("%Y-%m-%d %H:%M (%A)")
|
||||
current_message = f"[Current time: {time_str} {tz}]\n{msg.content}"
|
||||
|
||||
# Prefix hook messages so the agent can identify them
|
||||
hook_source = msg.metadata.get("hook_source") if msg.metadata else None
|
||||
if hook_source:
|
||||
current_message = f'[HOOK MESSAGE from "{hook_source}"]\n{current_message}'
|
||||
|
||||
|
||||
# Prepend time-gap notice if >5 minutes since last user message
|
||||
current_message = msg.content
|
||||
last_user_ts = None
|
||||
for m in reversed(session.messages):
|
||||
if m.get("role") == "user":
|
||||
@@ -354,6 +303,7 @@ class AgentLoop:
|
||||
if last_user_ts:
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_user_ts)
|
||||
now_dt = datetime.now()
|
||||
elapsed_seconds = (now_dt - last_dt).total_seconds()
|
||||
if elapsed_seconds > 300: # 5 minutes
|
||||
if elapsed_seconds < 3600:
|
||||
@@ -362,7 +312,7 @@ class AgentLoop:
|
||||
gap_str = f"{int(elapsed_seconds // 3600)} hours"
|
||||
else:
|
||||
gap_str = f"{int(elapsed_seconds // 86400)} days"
|
||||
current_message = f"[SYSTEM ANNOUNCEMENT: {gap_str} have elapsed since last user message; take this into account when replying to user. Ask about it if appropriate]\n\n{current_message}"
|
||||
current_message = f"[SYSTEM ANNOUNCEMENT: {gap_str} have elapsed since last user message; take this into account when replying to user]\n\n{msg.content}"
|
||||
except (ValueError, TypeError):
|
||||
pass # Malformed timestamp — skip silently
|
||||
|
||||
@@ -374,8 +324,6 @@ class AgentLoop:
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
)
|
||||
# Mark where the current turn starts so we can slice the tool chain for storage
|
||||
turn_start = len(messages)
|
||||
|
||||
# Select model based on quota
|
||||
selected_model = self._select_model_based_on_quota()
|
||||
@@ -383,7 +331,7 @@ class AgentLoop:
|
||||
# Agent loop
|
||||
iteration = 0
|
||||
final_content = None
|
||||
final_reasoning = None
|
||||
tools_used: list[str] = []
|
||||
|
||||
while iteration < self.max_iterations:
|
||||
iteration += 1
|
||||
@@ -392,11 +340,10 @@ class AgentLoop:
|
||||
logger.debug(f"Calling LLM with model={selected_model}, provider.thinking_budget={self.provider.thinking_budget}")
|
||||
response = await self.provider.chat(
|
||||
messages=messages,
|
||||
tools=self.tools.get_tools(), # Pass tool objects for beta flag extraction
|
||||
model=selected_model,
|
||||
context_management=self.CONTEXT_MANAGEMENT,
|
||||
tools=self.tools.get_definitions(),
|
||||
model=selected_model
|
||||
)
|
||||
|
||||
|
||||
# Handle tool calls
|
||||
if response.has_tool_calls:
|
||||
# Add assistant message with tool calls
|
||||
@@ -415,147 +362,44 @@ class AgentLoop:
|
||||
messages, response.content, tool_call_dicts,
|
||||
reasoning_content=response.reasoning_content,
|
||||
)
|
||||
|
||||
|
||||
# Execute tools
|
||||
for tool_call in response.tool_calls:
|
||||
tools_used.append(tool_call.name)
|
||||
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
|
||||
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
||||
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
||||
|
||||
# Handle different result types
|
||||
if isinstance(result, ToolResult):
|
||||
# Native Anthropic tool result
|
||||
content_parts = []
|
||||
|
||||
# Add text content
|
||||
if result.output:
|
||||
text_content = result.output
|
||||
elif result.error:
|
||||
text_content = f"Error: {result.error}"
|
||||
else:
|
||||
text_content = ""
|
||||
|
||||
# If both output and error, combine them
|
||||
if result.output and result.error:
|
||||
text_content = f"{result.output}\n\nError: {result.error}"
|
||||
|
||||
# If there's an image, use multipart content
|
||||
if result.base64_image:
|
||||
# Save screenshot to disk for user
|
||||
import base64
|
||||
media_dir = Path.home() / ".nanobot" / "media"
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
screenshot_path = media_dir / f"screenshot_{int(time.time() * 1000)}.png"
|
||||
screenshot_path.write_bytes(base64.b64decode(result.base64_image))
|
||||
media_paths_for_turn.append(str(screenshot_path))
|
||||
logger.info(f"Saved screenshot to {screenshot_path}")
|
||||
|
||||
# Include in tool result for model to see
|
||||
content_parts = [
|
||||
{"type": "text", "text": text_content},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": result.base64_image,
|
||||
}
|
||||
}
|
||||
]
|
||||
tool_content = content_parts
|
||||
else:
|
||||
tool_content = text_content
|
||||
|
||||
elif isinstance(result, CLIResult):
|
||||
# CLI-style tool result (text editor)
|
||||
tool_content = result.output
|
||||
|
||||
else:
|
||||
# Legacy string result from function tools
|
||||
tool_content = result
|
||||
|
||||
messages = self.context.add_tool_result(
|
||||
messages, tool_call.id, tool_call.name, tool_content
|
||||
messages, tool_call.id, tool_call.name, result
|
||||
)
|
||||
# Interleaved CoT: reflect before next action (skip when thinking is active)
|
||||
if not getattr(self.provider, 'thinking_budget', 0):
|
||||
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
||||
# Interleaved CoT: reflect before next action
|
||||
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
||||
else:
|
||||
# No tool calls
|
||||
# No tool calls, we're done
|
||||
final_content = response.content
|
||||
final_reasoning = response.reasoning_content
|
||||
|
||||
# Check for forged signatures if in suppress mode
|
||||
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||
if suppress_output and has_forged_marker(final_content):
|
||||
# Initialize retry counter if needed
|
||||
if not hasattr(self, '_forge_retry_count'):
|
||||
self._forge_retry_count = 0
|
||||
|
||||
if self._forge_retry_count < 1:
|
||||
# First offense: reject and retry with correction
|
||||
self._forge_retry_count += 1
|
||||
logger.warning("Model attempted to forge visibility marker, rejecting output")
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "[System: Previous response rejected. Do not generate [HIDDEN:*] markers.]"
|
||||
})
|
||||
continue # Back to while loop, will retry LLM call
|
||||
else:
|
||||
# Second offense: strip and log error (fallback)
|
||||
logger.error("Model persisted in forging markers despite correction, stripping")
|
||||
final_content = strip_all_hidden_markers(final_content)
|
||||
|
||||
# Reset retry counter on successful completion
|
||||
if hasattr(self, '_forge_retry_count'):
|
||||
self._forge_retry_count = 0
|
||||
|
||||
break
|
||||
|
||||
|
||||
if final_content is None:
|
||||
if iteration >= self.max_iterations:
|
||||
final_content = f"Reached {self.max_iterations} iterations without completion."
|
||||
else:
|
||||
final_content = "I've completed processing but have no response to give."
|
||||
|
||||
|
||||
# Log response preview
|
||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
|
||||
|
||||
# Check for suppress mode BEFORE adding to session
|
||||
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||
|
||||
if suppress_output:
|
||||
# Sign content with our secret key (forgery detection happens in loop above)
|
||||
final_content_for_session = sign_content(final_content)
|
||||
# Mark as suppressed for channel handler
|
||||
outbound_metadata = {**(msg.metadata or {}), "suppressed": True}
|
||||
else:
|
||||
final_content_for_session = final_content
|
||||
outbound_metadata = msg.metadata or {}
|
||||
|
||||
# Append final assistant response to messages so it's captured in the tool chain slice
|
||||
# Use the prefixed version for session storage
|
||||
messages = self.context.add_assistant_message(
|
||||
messages, final_content_for_session, None,
|
||||
reasoning_content=final_reasoning,
|
||||
)
|
||||
|
||||
# Save to session: user message + full tool chain (tool_use, tool_results, thinking, final reply)
|
||||
# Store current_message (not msg.content) so the time prefix is preserved
|
||||
# and cache keys match on subsequent turns
|
||||
# Include sender_id to distinguish real user messages from system-generated ones
|
||||
session.add_message("user", current_message, sender_id=msg.sender_id)
|
||||
for chain_msg in messages[turn_start:]:
|
||||
session.add_raw_message(chain_msg)
|
||||
|
||||
# Save to session (include tool names so consolidation sees what happened)
|
||||
session.add_message("user", msg.content)
|
||||
session.add_message("assistant", final_content,
|
||||
tools_used=tools_used if tools_used else None)
|
||||
self.sessions.save(session)
|
||||
|
||||
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=final_content_for_session,
|
||||
metadata=outbound_metadata,
|
||||
media=media_paths_for_turn if media_paths_for_turn else None,
|
||||
content=final_content,
|
||||
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
|
||||
)
|
||||
|
||||
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||
@@ -585,12 +429,11 @@ class AgentLoop:
|
||||
message_tool = self.tools.get("message")
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_tool.set_context(origin_channel, origin_chat_id)
|
||||
|
||||
|
||||
|
||||
spawn_tool = self.tools.get("spawn")
|
||||
if isinstance(spawn_tool, SpawnTool):
|
||||
spawn_tool.set_context(origin_channel, origin_chat_id, msg.metadata)
|
||||
|
||||
spawn_tool.set_context(origin_channel, origin_chat_id)
|
||||
|
||||
cron_tool = self.tools.get("cron")
|
||||
if isinstance(cron_tool, CronTool):
|
||||
cron_tool.set_context(origin_channel, origin_chat_id)
|
||||
@@ -602,12 +445,10 @@ class AgentLoop:
|
||||
channel=origin_channel,
|
||||
chat_id=origin_chat_id,
|
||||
)
|
||||
turn_start = len(messages)
|
||||
|
||||
|
||||
# Agent loop (limited for announce handling)
|
||||
iteration = 0
|
||||
final_content = None
|
||||
final_reasoning = None
|
||||
|
||||
# Select model based on quota
|
||||
selected_model = self._select_model_based_on_quota()
|
||||
@@ -617,11 +458,10 @@ class AgentLoop:
|
||||
|
||||
response = await self.provider.chat(
|
||||
messages=messages,
|
||||
tools=self.tools.get_tools(), # Pass tool objects for beta flag extraction
|
||||
model=selected_model,
|
||||
context_management=self.CONTEXT_MANAGEMENT,
|
||||
tools=self.tools.get_definitions(),
|
||||
model=selected_model
|
||||
)
|
||||
|
||||
|
||||
if response.has_tool_calls:
|
||||
tool_call_dicts = [
|
||||
{
|
||||
@@ -638,194 +478,56 @@ class AgentLoop:
|
||||
messages, response.content, tool_call_dicts,
|
||||
reasoning_content=response.reasoning_content,
|
||||
)
|
||||
|
||||
|
||||
for tool_call in response.tool_calls:
|
||||
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
|
||||
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
||||
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
||||
|
||||
# Handle different result types (same logic as main handler)
|
||||
if isinstance(result, ToolResult):
|
||||
# Native Anthropic tool result
|
||||
# Add text content
|
||||
if result.output:
|
||||
text_content = result.output
|
||||
elif result.error:
|
||||
text_content = f"Error: {result.error}"
|
||||
else:
|
||||
text_content = ""
|
||||
|
||||
# If both output and error, combine them
|
||||
if result.output and result.error:
|
||||
text_content = f"{result.output}\n\nError: {result.error}"
|
||||
|
||||
# Note: Image handling for system messages not needed
|
||||
# (system messages don't render images to users)
|
||||
# But we should still log if present
|
||||
if result.base64_image:
|
||||
logger.warning(
|
||||
f"Tool {tool_call.name} returned image in system message context - "
|
||||
"images not supported here"
|
||||
)
|
||||
|
||||
tool_content = text_content
|
||||
|
||||
elif isinstance(result, CLIResult):
|
||||
# CLI-style tool result (text editor)
|
||||
tool_content = result.output
|
||||
|
||||
else:
|
||||
# Legacy string result from function tools
|
||||
tool_content = result
|
||||
|
||||
messages = self.context.add_tool_result(
|
||||
messages, tool_call.id, tool_call.name, tool_content
|
||||
messages, tool_call.id, tool_call.name, result
|
||||
)
|
||||
# Interleaved CoT: reflect before next action (skip when thinking is active)
|
||||
if not getattr(self.provider, 'thinking_budget', 0):
|
||||
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
||||
# Interleaved CoT: reflect before next action
|
||||
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
||||
else:
|
||||
# No tool calls
|
||||
final_content = response.content
|
||||
final_reasoning = response.reasoning_content
|
||||
|
||||
# Check for forged signatures if in suppress mode
|
||||
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||
if suppress_output and has_forged_marker(final_content):
|
||||
# Initialize retry counter if needed
|
||||
if not hasattr(self, '_forge_retry_count_system'):
|
||||
self._forge_retry_count_system = 0
|
||||
|
||||
if self._forge_retry_count_system < 1:
|
||||
# First offense: reject and retry with correction
|
||||
self._forge_retry_count_system += 1
|
||||
logger.warning("Model attempted to forge visibility marker in system message, rejecting output")
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "[System: Previous response rejected. Do not generate [HIDDEN:*] markers.]"
|
||||
})
|
||||
continue # Back to while loop, will retry LLM call
|
||||
else:
|
||||
# Second offense: strip and log error (fallback)
|
||||
logger.error("Model persisted in forging markers despite correction, stripping")
|
||||
final_content = strip_all_hidden_markers(final_content)
|
||||
|
||||
# Reset retry counter on successful completion
|
||||
if hasattr(self, '_forge_retry_count_system'):
|
||||
self._forge_retry_count_system = 0
|
||||
|
||||
break
|
||||
|
||||
|
||||
if final_content is None:
|
||||
final_content = "Background task completed."
|
||||
|
||||
# Check for suppress mode BEFORE adding to session
|
||||
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||
|
||||
if suppress_output:
|
||||
# Sign content with our secret key (forgery detection happens in loop above)
|
||||
final_content_for_session = sign_content(final_content)
|
||||
# Mark as suppressed for channel handler
|
||||
outbound_metadata = {**(msg.metadata or {}), "suppressed": True}
|
||||
else:
|
||||
final_content_for_session = final_content
|
||||
outbound_metadata = msg.metadata or {}
|
||||
|
||||
# Append final assistant response to messages (use signed version for session)
|
||||
messages = self.context.add_assistant_message(
|
||||
messages, final_content_for_session, None,
|
||||
reasoning_content=final_reasoning,
|
||||
)
|
||||
|
||||
# Save to session: user message + full tool chain
|
||||
|
||||
# Save to session (mark as system message in history)
|
||||
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
||||
for chain_msg in messages[turn_start:]:
|
||||
session.add_raw_message(chain_msg)
|
||||
session.add_message("assistant", final_content)
|
||||
self.sessions.save(session)
|
||||
|
||||
# Return original content (not signed) for outbound, but with suppressed metadata
|
||||
|
||||
return OutboundMessage(
|
||||
channel=origin_channel,
|
||||
chat_id=origin_chat_id,
|
||||
content=final_content,
|
||||
metadata=outbound_metadata,
|
||||
content=final_content
|
||||
)
|
||||
|
||||
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
|
||||
"""Consolidate session into MEMORY.md + HISTORY.md.
|
||||
|
||||
Context window management is now handled server-side via context_management.
|
||||
This only runs on /new to write long-term facts and searchable history.
|
||||
"""
|
||||
"""Consolidate old messages into MEMORY.md + HISTORY.md, then trim session."""
|
||||
if not session.messages:
|
||||
return
|
||||
|
||||
# Choose memory backend
|
||||
from nanobot.agent.memory_mem0 import Mem0MemoryStore, HAS_MEM0
|
||||
|
||||
if self.mem0_config and self.mem0_config.get("enabled") and HAS_MEM0:
|
||||
memory = Mem0MemoryStore(self.workspace, config=self.mem0_config)
|
||||
logger.debug("Using mem0 for memory consolidation")
|
||||
# Mem0 has its own consolidation logic (feeds messages to mem0 for extraction)
|
||||
await memory.consolidate(
|
||||
session,
|
||||
self.provider,
|
||||
self.model,
|
||||
archive_all=archive_all,
|
||||
memory_window=self.memory_window,
|
||||
)
|
||||
return
|
||||
else:
|
||||
memory = MemoryStore(self.workspace)
|
||||
logger.debug("Using MemoryStore for memory consolidation")
|
||||
memory = MemoryStore(self.workspace)
|
||||
if archive_all:
|
||||
old_messages = session.messages
|
||||
keep_count = 0
|
||||
else:
|
||||
# Only write truly old messages; keep the recent ones
|
||||
keep_count = min(10, max(2, self.memory_window // 2))
|
||||
old_messages = session.messages[:-keep_count]
|
||||
if not old_messages:
|
||||
return
|
||||
logger.info(f"Memory consolidation: archiving {len(old_messages)} messages, keeping {keep_count}")
|
||||
logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}")
|
||||
|
||||
# Format messages for LLM — handle full tool chain format
|
||||
# Format messages for LLM (include tool names when available)
|
||||
lines = []
|
||||
for m in old_messages:
|
||||
role = m.get("role", "?")
|
||||
content = m.get("content")
|
||||
timestamp = m.get("timestamp", "?")[:16]
|
||||
|
||||
if role == "tool":
|
||||
result = str(content or "")[:200]
|
||||
lines.append(f"[{timestamp}] TOOL_RESULT({m.get('name', '?')}): {result}")
|
||||
if not m.get("content"):
|
||||
continue
|
||||
|
||||
# Skip internal reflect prompts
|
||||
if role == "user" and content == "Reflect on the results and decide next steps.":
|
||||
continue
|
||||
|
||||
# Extract text from content (may be list of blocks)
|
||||
if isinstance(content, list):
|
||||
text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
||||
content_str = " ".join(text_parts)
|
||||
elif isinstance(content, str):
|
||||
content_str = content
|
||||
else:
|
||||
content_str = ""
|
||||
|
||||
# Get tool names from tool_calls or legacy tools_used
|
||||
tool_names = []
|
||||
if m.get("tool_calls"):
|
||||
tool_names = [tc.get("function", {}).get("name", "?") for tc in m["tool_calls"]]
|
||||
elif m.get("tools_used"):
|
||||
tool_names = m["tools_used"]
|
||||
|
||||
if not content_str and not tool_names:
|
||||
continue
|
||||
|
||||
tools_str = f" [tools: {', '.join(tool_names)}]" if tool_names else ""
|
||||
lines.append(f"[{timestamp}] {role.upper()}{tools_str}: {content_str}")
|
||||
tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
|
||||
lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
|
||||
conversation = "\n".join(lines)
|
||||
current_memory = memory.read_long_term()
|
||||
|
||||
@@ -876,18 +578,16 @@ Respond with ONLY valid JSON, no markdown fences."""
|
||||
session_key: str = "cli:direct",
|
||||
channel: str = "cli",
|
||||
chat_id: str = "direct",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Process a message directly (for CLI or cron usage).
|
||||
|
||||
|
||||
Args:
|
||||
content: The message content.
|
||||
session_key: Session identifier (overrides channel:chat_id for session lookup).
|
||||
channel: Source channel (for tool context routing).
|
||||
chat_id: Source chat ID (for tool context routing).
|
||||
metadata: Optional metadata to pass through (for suppress mode, etc.).
|
||||
|
||||
|
||||
Returns:
|
||||
The agent's response.
|
||||
"""
|
||||
@@ -895,9 +595,8 @@ Respond with ONLY valid JSON, no markdown fences."""
|
||||
channel=channel,
|
||||
sender_id="user",
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
content=content
|
||||
)
|
||||
|
||||
|
||||
response = await self._process_message(msg, session_key=session_key)
|
||||
return response.content if response else ""
|
||||
|
||||
@@ -1,46 +1,9 @@
|
||||
"""Memory system for persistent agent memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.utils.helpers import ensure_dir
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
|
||||
_SAVE_MEMORY_TOOL = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "save_memory",
|
||||
"description": "Save the memory consolidation result to persistent storage.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"history_entry": {
|
||||
"type": "string",
|
||||
"description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. "
|
||||
"Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.",
|
||||
},
|
||||
"memory_update": {
|
||||
"type": "string",
|
||||
"description": "Full updated long-term memory as markdown. Include all existing "
|
||||
"facts plus new ones. Return unchanged if nothing new.",
|
||||
},
|
||||
},
|
||||
"required": ["history_entry", "memory_update"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
|
||||
@@ -65,86 +28,3 @@ class MemoryStore:
|
||||
def get_memory_context(self) -> str:
|
||||
long_term = self.read_long_term()
|
||||
return f"## Long-term Memory\n{long_term}" if long_term else ""
|
||||
|
||||
async def consolidate(
|
||||
self,
|
||||
session: Session,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
*,
|
||||
archive_all: bool = False,
|
||||
memory_window: int = 50,
|
||||
) -> bool:
|
||||
"""Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call.
|
||||
|
||||
Returns True on success (including no-op), False on failure.
|
||||
"""
|
||||
if archive_all:
|
||||
old_messages = session.messages
|
||||
keep_count = 0
|
||||
logger.info("Memory consolidation (archive_all): {} messages", len(session.messages))
|
||||
else:
|
||||
keep_count = memory_window // 2
|
||||
if len(session.messages) <= keep_count:
|
||||
return True
|
||||
if len(session.messages) - session.last_consolidated <= 0:
|
||||
return True
|
||||
old_messages = session.messages[session.last_consolidated:-keep_count]
|
||||
if not old_messages:
|
||||
return True
|
||||
logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count)
|
||||
|
||||
lines = []
|
||||
for m in old_messages:
|
||||
if not m.get("content"):
|
||||
continue
|
||||
tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
|
||||
lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
|
||||
|
||||
current_memory = self.read_long_term()
|
||||
prompt = f"""Process this conversation and call the save_memory tool with your consolidation.
|
||||
|
||||
## Current Long-term Memory
|
||||
{current_memory or "(empty)"}
|
||||
|
||||
## Conversation to Process
|
||||
{chr(10).join(lines)}"""
|
||||
|
||||
try:
|
||||
response = await provider.chat(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
tools=_SAVE_MEMORY_TOOL,
|
||||
model=model,
|
||||
)
|
||||
|
||||
if not response.has_tool_calls:
|
||||
logger.warning("Memory consolidation: LLM did not call save_memory, skipping")
|
||||
return False
|
||||
|
||||
args = response.tool_calls[0].arguments
|
||||
# Some providers return arguments as a JSON string instead of dict
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
if not isinstance(args, dict):
|
||||
logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__)
|
||||
return False
|
||||
|
||||
if entry := args.get("history_entry"):
|
||||
if not isinstance(entry, str):
|
||||
entry = json.dumps(entry, ensure_ascii=False)
|
||||
self.append_history(entry)
|
||||
if update := args.get("memory_update"):
|
||||
if not isinstance(update, str):
|
||||
update = json.dumps(update, ensure_ascii=False)
|
||||
if update != current_memory:
|
||||
self.write_long_term(update)
|
||||
|
||||
session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count
|
||||
logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Memory consolidation failed")
|
||||
return False
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
"""Mem0-powered memory system for intelligent semantic retrieval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
try:
|
||||
from mem0 import Memory
|
||||
from mem0.configs.base import MemoryConfig
|
||||
HAS_MEM0 = True
|
||||
except ImportError:
|
||||
HAS_MEM0 = False
|
||||
MemoryConfig = None # type: ignore
|
||||
|
||||
|
||||
class Mem0MemoryStore:
|
||||
"""
|
||||
Enhanced memory store using mem0 for semantic search and automatic extraction.
|
||||
|
||||
Features:
|
||||
- Multi-level memory (user, session, agent)
|
||||
- Semantic search with embeddings
|
||||
- Automatic memory extraction from conversations
|
||||
- 90% token reduction vs full-context
|
||||
- 91% faster responses
|
||||
"""
|
||||
|
||||
def __init__(self, workspace: Path, config: dict[str, Any] | None = None):
|
||||
if not HAS_MEM0:
|
||||
raise ImportError(
|
||||
"mem0 not installed. Install with: pip install mem0ai"
|
||||
)
|
||||
|
||||
self.workspace = workspace
|
||||
self.memory_dir = workspace / "memory"
|
||||
self.memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build custom extraction prompt tuned for nanobot conversations
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
custom_prompt = f"Extract dated facts from this conversation as JSON: {{\"facts\": [...]}}. Today is {today}.\n\n"
|
||||
|
||||
# Initialize mem0 with optional config + custom prompt
|
||||
# Extract only MemoryConfig-relevant fields
|
||||
raw_config = config if config else {}
|
||||
logger.debug(f"Mem0MemoryStore received config keys: {list(raw_config.keys())}")
|
||||
mem0_cfg_dict = {}
|
||||
for key in ("vector_store", "llm", "embedder", "graph_store", "version"):
|
||||
if key in raw_config:
|
||||
mem0_cfg_dict[key] = raw_config[key]
|
||||
logger.debug(f"Extracted for MemoryConfig: {list(mem0_cfg_dict.keys())}")
|
||||
logger.debug(f"Custom prompt length: {len(custom_prompt)} chars")
|
||||
self.custom_prompt = custom_prompt
|
||||
mem0_cfg_dict["custom_fact_extraction_prompt"] = custom_prompt
|
||||
mem0_config = MemoryConfig(**mem0_cfg_dict)
|
||||
logger.debug(f"MemoryConfig created: vector_store={mem0_config.vector_store.provider if mem0_config.vector_store else None}")
|
||||
self.memory = Memory(config=mem0_config)
|
||||
|
||||
logger.info("Mem0 memory system initialized with custom nanobot prompt")
|
||||
|
||||
def search_memories(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
limit: int = 5,
|
||||
session_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Search for relevant memories using semantic search.
|
||||
|
||||
Args:
|
||||
query: Search query (user's current message)
|
||||
user_id: User identifier (e.g., "telegram_12345")
|
||||
limit: Max number of memories to return
|
||||
session_id: Optional session-specific memories
|
||||
|
||||
Returns:
|
||||
List of memory dicts with 'memory' and 'score' keys
|
||||
"""
|
||||
try:
|
||||
# Search user-level memories
|
||||
user_memories = self.memory.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
results = []
|
||||
if user_memories and "results" in user_memories:
|
||||
results.extend(user_memories["results"])
|
||||
|
||||
# Optionally search session-level memories
|
||||
if session_id:
|
||||
session_memories = self.memory.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
metadata={"session_id": session_id},
|
||||
limit=limit // 2 # Reserve half for session context
|
||||
)
|
||||
if session_memories and "results" in session_memories:
|
||||
results.extend(session_memories["results"])
|
||||
|
||||
logger.debug(
|
||||
f"Mem0 search: query='{query[:50]}...', found {len(results)} memories"
|
||||
)
|
||||
return results[:limit] # Limit total results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 search failed: {e}")
|
||||
return []
|
||||
|
||||
def add_conversation(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
user_id: str,
|
||||
session_id: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Add conversation messages to memory for automatic extraction.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'
|
||||
user_id: User identifier
|
||||
session_id: Optional session identifier for session-level memories
|
||||
"""
|
||||
try:
|
||||
metadata = {}
|
||||
if session_id:
|
||||
metadata["session_id"] = session_id
|
||||
|
||||
# mem0 automatically extracts and stores relevant facts
|
||||
result = self.memory.add(
|
||||
messages,
|
||||
user_id=user_id,
|
||||
metadata=metadata if metadata else None
|
||||
)
|
||||
|
||||
facts_count = len(result.get("results", [])) if result else 0
|
||||
logger.debug(
|
||||
f"Mem0 add: {len(messages)} messages for user {user_id}, extracted {facts_count} facts"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 add failed: {e}")
|
||||
|
||||
async def extract_facts(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
provider: Any,
|
||||
model: str,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Extract facts from conversation using the main agent's LLM provider.
|
||||
|
||||
Uses the same provider/model already running (e.g. Haiku via Claude Max),
|
||||
avoiding a separate LLM call to mem0's default GPT-nano.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
# Build conversation text for extraction
|
||||
conv_text = ""
|
||||
for msg in messages:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content.strip():
|
||||
conv_text += f"{role}: {content}\n\n"
|
||||
|
||||
if not conv_text.strip():
|
||||
return []
|
||||
|
||||
extraction_messages = [
|
||||
{"role": "user", "content": self.custom_prompt + conv_text}
|
||||
]
|
||||
|
||||
try:
|
||||
response = await provider.chat(
|
||||
messages=extraction_messages,
|
||||
model=model,
|
||||
max_tokens=2000,
|
||||
temperature=0.3,
|
||||
)
|
||||
|
||||
# Parse the JSON response — LLMResponse.content is a string
|
||||
text = response.content or ""
|
||||
# Strip markdown code fences if present
|
||||
text = text.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
data = _json.loads(text)
|
||||
facts = data.get("facts", [])
|
||||
logger.debug(f"Extracted {len(facts)} facts using {model}")
|
||||
return facts
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fact extraction failed: {e}")
|
||||
return []
|
||||
|
||||
def store_facts(
|
||||
self,
|
||||
facts: list[str],
|
||||
user_id: str,
|
||||
session_id: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Store pre-extracted facts in mem0 with infer=False.
|
||||
|
||||
Bypasses mem0's built-in LLM extraction — facts are already
|
||||
in final form from extract_facts().
|
||||
"""
|
||||
if not facts:
|
||||
return
|
||||
|
||||
metadata = {}
|
||||
if session_id:
|
||||
metadata["session_id"] = session_id
|
||||
|
||||
stored = 0
|
||||
for fact in facts:
|
||||
try:
|
||||
self.memory.add(
|
||||
fact,
|
||||
user_id=user_id,
|
||||
infer=False,
|
||||
metadata=metadata if metadata else None,
|
||||
)
|
||||
stored += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store fact '{fact[:50]}...': {e}")
|
||||
|
||||
logger.info(f"Stored {stored}/{len(facts)} facts for user {user_id}")
|
||||
|
||||
def get_memory_context(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
limit: int = 5
|
||||
) -> str:
|
||||
"""
|
||||
Get formatted memory context for inclusion in system prompt.
|
||||
|
||||
Args:
|
||||
query: Current user query
|
||||
user_id: User identifier
|
||||
limit: Max memories to include
|
||||
|
||||
Returns:
|
||||
Formatted memory context string
|
||||
"""
|
||||
memories = self.search_memories(query, user_id, limit=limit)
|
||||
|
||||
if not memories:
|
||||
return ""
|
||||
|
||||
lines = ["## Relevant Memories"]
|
||||
for i, mem in enumerate(memories, 1):
|
||||
memory_text = mem.get("memory", "")
|
||||
# Include score if available for debugging
|
||||
score = mem.get("score", "")
|
||||
score_str = f" (relevance: {score:.2f})" if score else ""
|
||||
lines.append(f"{i}. {memory_text}{score_str}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def update_memory(self, memory_id: str, data: dict[str, Any]) -> None:
|
||||
"""Update a specific memory by ID."""
|
||||
try:
|
||||
self.memory.update(memory_id, data)
|
||||
logger.debug(f"Mem0 update: memory_id={memory_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 update failed: {e}")
|
||||
|
||||
def delete_memory(self, memory_id: str) -> None:
|
||||
"""Delete a specific memory by ID."""
|
||||
try:
|
||||
self.memory.delete(memory_id)
|
||||
logger.debug(f"Mem0 delete: memory_id={memory_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 delete failed: {e}")
|
||||
|
||||
def get_all_memories(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all memories for a user."""
|
||||
try:
|
||||
result = self.memory.get_all(user_id=user_id)
|
||||
return result.get("results", []) if result else []
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 get_all failed: {e}")
|
||||
return []
|
||||
|
||||
async def consolidate(
|
||||
self,
|
||||
session: Session,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
*,
|
||||
archive_all: bool = False,
|
||||
memory_window: int = 50,
|
||||
) -> bool:
|
||||
"""
|
||||
Consolidate session messages into mem0 memory.
|
||||
|
||||
Facts are extracted using the main agent's LLM provider, then stored with infer=False.
|
||||
|
||||
Returns True on success.
|
||||
"""
|
||||
try:
|
||||
# Extract user_id from session key (e.g., "telegram:12345" -> "telegram_12345")
|
||||
user_id = session.key.replace(":", "_")
|
||||
|
||||
# Determine which messages to consolidate
|
||||
if archive_all:
|
||||
messages_to_add = session.messages
|
||||
logger.info(
|
||||
f"Mem0 consolidation (archive_all): {len(messages_to_add)} messages"
|
||||
)
|
||||
else:
|
||||
keep_count = memory_window // 2
|
||||
if len(session.messages) <= keep_count:
|
||||
return True
|
||||
|
||||
# Get unconsolidated messages
|
||||
start_idx = session.last_consolidated
|
||||
end_idx = len(session.messages) - keep_count
|
||||
|
||||
if end_idx <= start_idx:
|
||||
return True
|
||||
|
||||
messages_to_add = session.messages[start_idx:end_idx]
|
||||
|
||||
if not messages_to_add:
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Mem0 consolidation: {len(messages_to_add)} to consolidate, "
|
||||
f"{keep_count} keep"
|
||||
)
|
||||
|
||||
# Convert to mem0 format with intelligent filtering
|
||||
mem0_messages = []
|
||||
for msg in messages_to_add:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
|
||||
# Keep tool results but truncate long ones — they often contain
|
||||
# the actual substance (file reads, search results, web pages).
|
||||
# The extraction prompt handles ignoring code/JSON noise.
|
||||
if role == "tool":
|
||||
if isinstance(content, list):
|
||||
text_parts = [
|
||||
block.get("content", "") if isinstance(block, dict) else str(block)
|
||||
for block in content
|
||||
]
|
||||
content = " ".join(text_parts).strip()
|
||||
if isinstance(content, str) and len(content) > 2000:
|
||||
content = content[:2000]
|
||||
if not content or (isinstance(content, str) and len(content.strip()) < 10):
|
||||
continue
|
||||
mem0_messages.append({"role": "user", "content": content})
|
||||
continue
|
||||
|
||||
# Skip system messages — they're boilerplate instructions, not facts
|
||||
if role == "system":
|
||||
continue
|
||||
|
||||
# Skip messages with no content
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Normalize assistant message content: extract text from Anthropic list format
|
||||
if role == "assistant" and isinstance(content, list):
|
||||
# Anthropic format: list of {type: "text"|"tool_use", text: "..."} blocks
|
||||
text_parts = [
|
||||
block.get("text", "")
|
||||
for block in content
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
]
|
||||
content = " ".join(text_parts).strip()
|
||||
if not content:
|
||||
continue # Skip if assistant only called tools with no text explanation
|
||||
|
||||
# Normalize user message content (could also be a list in some formats)
|
||||
if isinstance(content, list):
|
||||
text_parts = [
|
||||
block.get("text", "") if isinstance(block, dict) else str(block)
|
||||
for block in content
|
||||
]
|
||||
content = " ".join(text_parts).strip()
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Skip trivially short messages (commands like "/new")
|
||||
if len(content.strip()) < 10:
|
||||
continue
|
||||
|
||||
mem0_messages.append({
|
||||
"role": role,
|
||||
"content": content
|
||||
})
|
||||
|
||||
if mem0_messages:
|
||||
# Extract facts using the main agent's LLM (already paid for),
|
||||
# then store with infer=False to bypass mem0's GPT-nano
|
||||
facts = await self.extract_facts(mem0_messages, provider, model)
|
||||
self.store_facts(facts, user_id=user_id, session_id=session.key)
|
||||
|
||||
# Update consolidation marker
|
||||
if archive_all:
|
||||
session.last_consolidated = len(session.messages)
|
||||
else:
|
||||
session.last_consolidated = end_idx
|
||||
|
||||
logger.info(
|
||||
f"Mem0 consolidation done: {len(session.messages)} messages, "
|
||||
f"last_consolidated={session.last_consolidated}"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Mem0 consolidation failed")
|
||||
return False
|
||||
@@ -167,10 +167,10 @@ class SkillsLoader:
|
||||
return content
|
||||
|
||||
def _parse_nanobot_metadata(self, raw: str) -> dict:
|
||||
"""Parse skill metadata JSON from frontmatter (supports nanobot, clawdbot, and openclaw keys)."""
|
||||
"""Parse nanobot metadata JSON from frontmatter."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return (data.get("nanobot") or data.get("clawdbot") or data.get("openclaw") or {}) if isinstance(data, dict) else {}
|
||||
return (data.get("nanobot") or data.get("clawdbot") or {}) if isinstance(data, dict) else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
+21
-34
@@ -16,7 +16,6 @@ from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFile
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.subagent_message import SubagentMessageTool
|
||||
from nanobot.agent.tools.wait import WaitForSubagentsTool
|
||||
|
||||
|
||||
@@ -60,28 +59,25 @@ class SubagentManager:
|
||||
model: str | None = None,
|
||||
origin_channel: str = "cli",
|
||||
origin_chat_id: str = "direct",
|
||||
origin_metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Spawn a subagent to execute a task in the background.
|
||||
|
||||
|
||||
Args:
|
||||
task: The task description for the subagent.
|
||||
label: Optional human-readable label for the task.
|
||||
origin_channel: The channel to announce results to.
|
||||
origin_chat_id: The chat ID to announce results to.
|
||||
origin_metadata: Optional metadata to propagate to announcement (e.g. suppress_output).
|
||||
|
||||
|
||||
Returns:
|
||||
Status message indicating the subagent was started.
|
||||
"""
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||
|
||||
|
||||
origin = {
|
||||
"channel": origin_channel,
|
||||
"chat_id": origin_chat_id,
|
||||
"metadata": origin_metadata or {},
|
||||
}
|
||||
|
||||
# Create background task
|
||||
@@ -122,19 +118,8 @@ class SubagentManager:
|
||||
))
|
||||
tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||
tools.register(WebFetchTool())
|
||||
|
||||
# Message tool for communicating with user (via main agent)
|
||||
message_tool = SubagentMessageTool(
|
||||
bus=self.bus,
|
||||
origin_channel=origin["channel"],
|
||||
origin_chat_id=origin["chat_id"],
|
||||
origin_metadata=origin.get("metadata"),
|
||||
)
|
||||
tools.register(message_tool)
|
||||
|
||||
# Spawn tool for creating child subagents
|
||||
spawn_tool = SpawnTool(manager=self)
|
||||
spawn_tool.set_context("subagent", origin["chat_id"], origin.get("metadata"))
|
||||
spawn_tool.set_context("subagent", origin["chat_id"])
|
||||
tools.register(spawn_tool)
|
||||
tools.register(WaitForSubagentsTool(manager=self))
|
||||
|
||||
@@ -216,15 +201,13 @@ class SubagentManager:
|
||||
"""Announce the subagent result to the main agent via the message bus."""
|
||||
status_text = "completed successfully" if status == "ok" else "failed"
|
||||
|
||||
# ALWAYS store result so wait_for_subagents can find it
|
||||
self._task_results[task_id] = result
|
||||
|
||||
# Child subagents (spawned by other subagents) don't announce - parent waits for them
|
||||
# Child subagents (spawned by other subagents) store results silently.
|
||||
# The parent orchestrator collects them via wait_for_subagents.
|
||||
if origin["channel"] == "subagent":
|
||||
self._task_results[task_id] = result
|
||||
logger.debug(f"Subagent [{task_id}] stored result silently (child subagent)")
|
||||
return
|
||||
|
||||
# Top-level subagents announce via bus to trigger main agent
|
||||
announce_content = f"""[Subagent '{label}' {status_text}]
|
||||
|
||||
Task: {task}
|
||||
@@ -235,13 +218,11 @@ Result:
|
||||
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs."""
|
||||
|
||||
# Inject as system message to trigger main agent
|
||||
# Propagate metadata from origin (e.g. suppress_output)
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="subagent",
|
||||
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
||||
content=announce_content,
|
||||
metadata=origin.get("metadata", {}),
|
||||
)
|
||||
|
||||
await self.bus.publish_inbound(msg)
|
||||
@@ -249,27 +230,33 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men
|
||||
|
||||
def _build_subagent_prompt(self, task: str) -> str:
|
||||
"""Build a focused system prompt for the subagent."""
|
||||
from datetime import datetime
|
||||
import time as _time
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||||
tz = _time.strftime("%Z") or "UTC"
|
||||
|
||||
return f"""# Subagent
|
||||
|
||||
## Current Time
|
||||
{now} ({tz})
|
||||
|
||||
You are a subagent spawned by the main agent to complete a specific task.
|
||||
|
||||
## Rules
|
||||
1. Run `exec date` as your very first action to get the current date and time
|
||||
2. Stay focused - complete only the assigned task, nothing else
|
||||
3. Your final response will be reported back to the main agent
|
||||
4. Do not initiate conversations or take on side tasks
|
||||
5. Be concise but informative in your findings
|
||||
1. Stay focused - complete only the assigned task, nothing else
|
||||
2. Your final response will be reported back to the main agent
|
||||
3. Do not initiate conversations or take on side tasks
|
||||
4. Be concise but informative in your findings
|
||||
|
||||
## What You Can Do
|
||||
- Read and write files in the workspace
|
||||
- Execute shell commands
|
||||
- Search the web and fetch web pages
|
||||
- Send messages to the main agent (via the message tool)
|
||||
- Spawn child subagents for parallel tasks
|
||||
- Complete the task thoroughly
|
||||
|
||||
## What You Cannot Do
|
||||
- Access the main agent's conversation history directly
|
||||
- Send messages directly to users (no message tool available)
|
||||
- Access the main agent's conversation history
|
||||
|
||||
## Workspace
|
||||
Your workspace is at: {self.workspace}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Anthropic native tools implementation."""
|
||||
|
||||
from nanobot.agent.tools.anthropic.base import (
|
||||
BaseAnthropicTool,
|
||||
ToolResult,
|
||||
CLIResult,
|
||||
ToolError,
|
||||
)
|
||||
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||
from nanobot.agent.tools.anthropic.edit import EditTool20250728
|
||||
from nanobot.agent.tools.anthropic.computer import ComputerTool20251124
|
||||
from nanobot.agent.tools.anthropic.memory import MemoryTool20250818
|
||||
|
||||
__all__ = [
|
||||
"BaseAnthropicTool",
|
||||
"ToolResult",
|
||||
"CLIResult",
|
||||
"ToolError",
|
||||
"BashTool20250124",
|
||||
"EditTool20250728",
|
||||
"ComputerTool20251124",
|
||||
"MemoryTool20250818",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,68 +0,0 @@
|
||||
"""Base classes for Anthropic native tools.
|
||||
|
||||
Ported from anthropic-quickstarts/computer-use-demo.
|
||||
"""
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ToolResult:
|
||||
"""Result from tool execution.
|
||||
|
||||
Structured result that can contain text output, errors, images, and system messages.
|
||||
"""
|
||||
output: str | None = None
|
||||
error: str | None = None
|
||||
base64_image: str | None = None
|
||||
system: str | None = None
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class CLIResult:
|
||||
"""Result from CLI-style tools (like text editor).
|
||||
|
||||
Similar to ToolResult but simpler for text-only tools.
|
||||
"""
|
||||
exit_code: int
|
||||
output: str
|
||||
error: str
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
"""Exception raised by tool execution."""
|
||||
pass
|
||||
|
||||
|
||||
class BaseAnthropicTool(metaclass=ABCMeta):
|
||||
"""Base class for Anthropic native tools.
|
||||
|
||||
Native tools are version-coupled to model training and don't require schemas.
|
||||
"""
|
||||
|
||||
api_type: str # e.g., "bash_20250124"
|
||||
name: str # e.g., "bash"
|
||||
beta_flag: str | None = None # e.g., "computer-use-2025-11-24"
|
||||
|
||||
@abstractmethod
|
||||
async def __call__(self, **kwargs: Any) -> ToolResult | CLIResult:
|
||||
"""Execute the tool.
|
||||
|
||||
Args:
|
||||
**kwargs: Tool-specific parameters
|
||||
|
||||
Returns:
|
||||
ToolResult or CLIResult with execution output
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
"""Return tool definition for API.
|
||||
|
||||
Returns:
|
||||
Dict with type and name (no schema for native tools)
|
||||
"""
|
||||
...
|
||||
@@ -1,174 +0,0 @@
|
||||
"""BashTool20250124 - Persistent bash session with sentinel-based output.
|
||||
|
||||
Anthropic's native bash_20250124 tool with a long-running session.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, ToolResult
|
||||
|
||||
|
||||
class _BashSession:
|
||||
"""Manages a persistent bash subprocess with sentinel-based output reading."""
|
||||
|
||||
def __init__(self):
|
||||
self.process: subprocess.Popen | None = None
|
||||
self._start()
|
||||
|
||||
def _start(self):
|
||||
"""Start the bash process."""
|
||||
self.process = subprocess.Popen(
|
||||
["bash"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
def restart(self):
|
||||
"""Restart the bash session."""
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
self._start()
|
||||
|
||||
async def run_command(self, command: str, timeout: float = 120.0) -> str:
|
||||
"""Run a command in the persistent bash session.
|
||||
|
||||
Uses a unique sentinel to detect command completion.
|
||||
|
||||
Args:
|
||||
command: Bash command to execute
|
||||
timeout: Maximum time to wait for command completion (seconds)
|
||||
|
||||
Returns:
|
||||
Command output (stdout + stderr combined)
|
||||
|
||||
Raises:
|
||||
asyncio.TimeoutError: If command doesn't complete within timeout
|
||||
RuntimeError: If bash process has died
|
||||
"""
|
||||
if not self.process or self.process.poll() is not None:
|
||||
raise RuntimeError("Bash process has died")
|
||||
|
||||
# Generate unique sentinel
|
||||
sentinel = f"<<BASH_COMMAND_DONE_{uuid.uuid4().hex}>>"
|
||||
|
||||
# Send command + sentinel
|
||||
full_command = f"{command}\necho '{sentinel}'\n"
|
||||
self.process.stdin.write(full_command)
|
||||
self.process.stdin.flush()
|
||||
|
||||
# Read output until sentinel appears
|
||||
output_lines = []
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
while True:
|
||||
# Check timeout
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
if elapsed > timeout:
|
||||
raise asyncio.TimeoutError(
|
||||
f"Command timed out after {timeout}s: {command[:50]}..."
|
||||
)
|
||||
|
||||
# Read line (non-blocking via asyncio)
|
||||
try:
|
||||
line = await asyncio.wait_for(
|
||||
asyncio.to_thread(self.process.stdout.readline),
|
||||
timeout=1.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# No output yet, continue waiting
|
||||
continue
|
||||
|
||||
if not line:
|
||||
# EOF - process died
|
||||
raise RuntimeError("Bash process terminated unexpectedly")
|
||||
|
||||
# Check for sentinel
|
||||
if sentinel in line:
|
||||
break
|
||||
|
||||
output_lines.append(line.rstrip("\n"))
|
||||
|
||||
return "\n".join(output_lines)
|
||||
|
||||
def __del__(self):
|
||||
"""Clean up bash process on deletion."""
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
|
||||
|
||||
class BashTool20250124(BaseAnthropicTool):
|
||||
"""Anthropic's native bash_20250124 tool with persistent session.
|
||||
|
||||
Executes bash commands in a long-running shell session. Environment
|
||||
variables and working directory persist across commands.
|
||||
|
||||
Parameters:
|
||||
command (str, optional): Bash command to execute
|
||||
restart (bool, optional): Restart the bash session (clears state)
|
||||
"""
|
||||
|
||||
api_type: Literal["bash_20250124"] = "bash_20250124"
|
||||
name: Literal["bash"] = "bash"
|
||||
beta_flag: str = "computer-use-2025-11-24"
|
||||
|
||||
def __init__(self):
|
||||
self._session = _BashSession()
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
command: str | None = None,
|
||||
restart: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> ToolResult:
|
||||
"""Execute bash command or restart session.
|
||||
|
||||
Args:
|
||||
command: Bash command to execute (optional)
|
||||
restart: Restart the bash session (optional)
|
||||
**kwargs: Additional arguments (ignored)
|
||||
|
||||
Returns:
|
||||
ToolResult with command output or error
|
||||
"""
|
||||
if restart:
|
||||
self._session.restart()
|
||||
return ToolResult(output="Bash session restarted successfully.")
|
||||
|
||||
if not command:
|
||||
return ToolResult(
|
||||
error="Either 'command' or 'restart=True' must be provided."
|
||||
)
|
||||
|
||||
try:
|
||||
output = await self._session.run_command(command)
|
||||
return ToolResult(output=output if output else "(no output)")
|
||||
except asyncio.TimeoutError as e:
|
||||
return ToolResult(error=f"Command timed out: {e}")
|
||||
except Exception as e:
|
||||
return ToolResult(error=f"{e}")
|
||||
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
"""Convert to Anthropic API tool parameter format.
|
||||
|
||||
Returns:
|
||||
Tool definition for Anthropic API with bash_20250124 type
|
||||
"""
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
"""Computer control tool for VNC desktop interaction.
|
||||
|
||||
VNC-based implementation of Anthropic's computer_20251124 native tool.
|
||||
|
||||
CRITICAL vncdotool syntax:
|
||||
- Use :: (double colon) for port numbers: '172.17.0.1::5900'
|
||||
- Single colon means display number (port = display + 5900)
|
||||
- vncdotool API is synchronous, wrapped in asyncio.to_thread()
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Literal, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
from vncdotool import api as vnc_api
|
||||
except ImportError:
|
||||
vnc_api = None
|
||||
|
||||
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, ToolResult
|
||||
|
||||
|
||||
class ComputerTool20251124(BaseAnthropicTool):
|
||||
"""Computer control via VNC for desktop interaction.
|
||||
|
||||
Supports keyboard input, mouse control, and screenshots.
|
||||
"""
|
||||
|
||||
api_type: Literal["computer_20251124"] = "computer_20251124"
|
||||
name: Literal["computer"] = "computer"
|
||||
beta_flag: str = "computer-use-2025-11-24"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vnc_host: str = "172.17.0.1",
|
||||
vnc_port: int = 5900,
|
||||
vnc_username: str = "deckedmoth",
|
||||
vnc_password: str = "123",
|
||||
display_width_px: int = 1024,
|
||||
display_height_px: int = 768,
|
||||
):
|
||||
"""Initialize computer tool.
|
||||
|
||||
Args:
|
||||
vnc_host: VNC server hostname/IP
|
||||
vnc_port: VNC server port
|
||||
vnc_username: VNC username (if required)
|
||||
vnc_password: VNC password (if required)
|
||||
display_width_px: Display width for screenshots
|
||||
display_height_px: Display height for screenshots
|
||||
"""
|
||||
if vnc_api is None:
|
||||
raise ImportError(
|
||||
"vncdotool is required for computer tool. "
|
||||
"Install with: pip install vncdotool"
|
||||
)
|
||||
|
||||
self.vnc_host = vnc_host
|
||||
self.vnc_port = vnc_port
|
||||
self.vnc_username = vnc_username
|
||||
self.vnc_password = vnc_password
|
||||
self.display_width_px = display_width_px
|
||||
self.display_height_px = display_height_px
|
||||
|
||||
def to_params(self):
|
||||
"""Return tool definition for API."""
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
"display_width_px": self.display_width_px,
|
||||
"display_height_px": self.display_height_px,
|
||||
"enable_zoom": True,
|
||||
}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
action: Literal[
|
||||
# Basic actions
|
||||
"key", "type", "mouse_move", "screenshot", "cursor_position",
|
||||
# Click actions
|
||||
"left_click", "right_click", "middle_click", "double_click", "triple_click",
|
||||
# Advanced mouse
|
||||
"left_mouse_down", "left_mouse_up", "left_click_drag",
|
||||
# Scroll
|
||||
"scroll",
|
||||
# Advanced keyboard
|
||||
"hold_key", "paste", # paste bypasses keyboard layout issues
|
||||
# Utility
|
||||
"wait",
|
||||
# Zoom (computer_20251124)
|
||||
"zoom"
|
||||
] | None = None,
|
||||
coordinate: list[int] | None = None,
|
||||
text: str | None = None,
|
||||
# Additional parameters for specific actions
|
||||
start_coordinate: list[int] | None = None, # For left_click_drag
|
||||
scroll_direction: Literal["up", "down", "left", "right"] | None = None, # For scroll
|
||||
scroll_amount: int | None = None, # For scroll
|
||||
duration: float | None = None, # For hold_key, wait
|
||||
region: list[int] | None = None, # For zoom [x1, y1, x2, y2]
|
||||
key: str | None = None, # Modifier key for clicks/scroll
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
"""Execute computer control action.
|
||||
|
||||
Args:
|
||||
action: Action to perform
|
||||
coordinate: [x, y] coordinates for mouse actions
|
||||
text: Text to type or key name to press
|
||||
|
||||
Returns:
|
||||
ToolResult with action result or screenshot
|
||||
"""
|
||||
if not action:
|
||||
return ToolResult(error="No action provided")
|
||||
|
||||
try:
|
||||
# Connect with correct syntax: double colon (::) for port number
|
||||
result = await asyncio.to_thread(
|
||||
self._execute_vnc_action,
|
||||
action,
|
||||
coordinate,
|
||||
text,
|
||||
start_coordinate,
|
||||
scroll_direction,
|
||||
scroll_amount,
|
||||
duration,
|
||||
region,
|
||||
key
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Computer tool error: {e}")
|
||||
return ToolResult(error=str(e))
|
||||
|
||||
def _execute_vnc_action(
|
||||
self,
|
||||
action: str,
|
||||
coordinate: list[int] | None,
|
||||
text: str | None,
|
||||
start_coordinate: list[int] | None,
|
||||
scroll_direction: str | None,
|
||||
scroll_amount: int | None,
|
||||
duration: float | None,
|
||||
region: list[int] | None,
|
||||
modifier_key: str | None
|
||||
) -> ToolResult:
|
||||
"""Execute VNC action in thread (vncdotool is synchronous).
|
||||
|
||||
CRITICAL: vncdotool syntax requires :: (double colon) for port numbers!
|
||||
Single colon means display number: 172.17.0.1:5900 = display 5900 (port 11800)
|
||||
Double colon means port number: 172.17.0.1::5900 = port 5900
|
||||
"""
|
||||
# Connect with DOUBLE colon for port
|
||||
server = f"{self.vnc_host}::{self.vnc_port}"
|
||||
client = vnc_api.connect(server, username=self.vnc_username, password=self.vnc_password)
|
||||
|
||||
try:
|
||||
# Basic actions
|
||||
if action == "screenshot":
|
||||
return self._screenshot(client)
|
||||
elif action == "key":
|
||||
return self._key(client, text or "")
|
||||
elif action == "type":
|
||||
return self._type(client, text or "")
|
||||
elif action == "mouse_move":
|
||||
return self._mouse_move(client, coordinate or [0, 0])
|
||||
elif action == "cursor_position":
|
||||
return ToolResult(output="Cursor position tracking not implemented")
|
||||
|
||||
# Click actions
|
||||
elif action == "left_click":
|
||||
return self._left_click(client, coordinate, modifier_key)
|
||||
elif action == "right_click":
|
||||
return self._right_click(client, coordinate, modifier_key)
|
||||
elif action == "middle_click":
|
||||
return self._middle_click(client, coordinate, modifier_key)
|
||||
elif action == "double_click":
|
||||
return self._double_click(client, coordinate, modifier_key)
|
||||
elif action == "triple_click":
|
||||
return self._triple_click(client, coordinate, modifier_key)
|
||||
|
||||
# Advanced mouse
|
||||
elif action == "left_mouse_down":
|
||||
return self._left_mouse_down(client)
|
||||
elif action == "left_mouse_up":
|
||||
return self._left_mouse_up(client)
|
||||
elif action == "left_click_drag":
|
||||
return self._left_click_drag(client, start_coordinate, coordinate)
|
||||
|
||||
# Scroll
|
||||
elif action == "scroll":
|
||||
return self._scroll(client, coordinate, scroll_direction, scroll_amount, modifier_key)
|
||||
|
||||
# Advanced keyboard
|
||||
elif action == "hold_key":
|
||||
return self._hold_key(client, text, duration)
|
||||
elif action == "paste":
|
||||
return self._paste(client, text)
|
||||
|
||||
# Utility
|
||||
elif action == "wait":
|
||||
return self._wait(duration)
|
||||
|
||||
# Zoom
|
||||
elif action == "zoom":
|
||||
return self._zoom(client, region)
|
||||
|
||||
else:
|
||||
return ToolResult(error=f"Unknown action: {action}")
|
||||
finally:
|
||||
client.disconnect()
|
||||
|
||||
def _screenshot(self, client) -> ToolResult:
|
||||
"""Capture screenshot.
|
||||
|
||||
captureScreen() requires a file path, can't use BytesIO without format.
|
||||
Use temp file then read as bytes.
|
||||
|
||||
IMPORTANT: VNC display may be in sleep mode. Wake it up before screenshot.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Wake up display (move mouse + press space to wake screensaver)
|
||||
client.mouseMove(self.display_width_px // 2, self.display_height_px // 2)
|
||||
time.sleep(0.1)
|
||||
client.keyPress('space')
|
||||
time.sleep(0.5) # Wait for display to wake
|
||||
|
||||
# Request framebuffer update
|
||||
client.refreshScreen()
|
||||
time.sleep(0.5) # Wait for framebuffer refresh
|
||||
|
||||
# Capture screenshot
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
client.captureScreen(tmp_path)
|
||||
png_data = Path(tmp_path).read_bytes()
|
||||
Path(tmp_path).unlink() # Clean up
|
||||
|
||||
base64_data = base64.b64encode(png_data).decode()
|
||||
return ToolResult(base64_image=base64_data)
|
||||
|
||||
def _key(self, client, text: str) -> ToolResult:
|
||||
"""Press a key.
|
||||
|
||||
Use lowercase names from KEYMAP: 'esc', 'return', 'tab', etc.
|
||||
Single characters work directly: 'a', 'b', '1', etc.
|
||||
"""
|
||||
client.keyPress(text.lower())
|
||||
return ToolResult(output=f"Pressed key: {text}")
|
||||
|
||||
def _type(self, client, text: str) -> ToolResult:
|
||||
"""Type text character by character."""
|
||||
for char in text:
|
||||
client.keyPress(char)
|
||||
return ToolResult(output=f"Typed: {text}")
|
||||
|
||||
def _mouse_move(self, client, coordinate: list[int]) -> ToolResult:
|
||||
"""Move mouse to coordinate."""
|
||||
x, y = coordinate[0], coordinate[1]
|
||||
client.mouseMove(x, y)
|
||||
return ToolResult(output=f"Moved mouse to ({x}, {y})")
|
||||
|
||||
def _left_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult:
|
||||
"""Left click at coordinate (or current position)."""
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
client.mousePress(1) # 1 = left button
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
return ToolResult(output="Left clicked")
|
||||
|
||||
def _right_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult:
|
||||
"""Right click at coordinate (or current position)."""
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
client.mousePress(3) # 3 = right button
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
return ToolResult(output="Right clicked")
|
||||
|
||||
def _middle_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult:
|
||||
"""Middle click at coordinate (or current position)."""
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
client.mousePress(2) # 2 = middle button
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
return ToolResult(output="Middle clicked")
|
||||
|
||||
def _double_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult:
|
||||
"""Double click at coordinate (or current position)."""
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
client.mousePress(1)
|
||||
import time
|
||||
time.sleep(0.01) # 10ms delay between clicks
|
||||
client.mousePress(1)
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
return ToolResult(output="Double clicked")
|
||||
|
||||
def _triple_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult:
|
||||
"""Triple click at coordinate (or current position)."""
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
import time
|
||||
for _ in range(3):
|
||||
client.mousePress(1)
|
||||
time.sleep(0.01) # 10ms delay between clicks
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
return ToolResult(output="Triple clicked")
|
||||
|
||||
def _left_mouse_down(self, client) -> ToolResult:
|
||||
"""Press and hold left mouse button."""
|
||||
client.mouseDown(1)
|
||||
return ToolResult(output="Left mouse button down")
|
||||
|
||||
def _left_mouse_up(self, client) -> ToolResult:
|
||||
"""Release left mouse button."""
|
||||
client.mouseUp(1)
|
||||
return ToolResult(output="Left mouse button up")
|
||||
|
||||
def _left_click_drag(self, client, start_coordinate: list[int] | None, end_coordinate: list[int] | None) -> ToolResult:
|
||||
"""Drag from start to end coordinate."""
|
||||
if not start_coordinate or not end_coordinate:
|
||||
return ToolResult(error="Both start_coordinate and coordinate required for left_click_drag")
|
||||
|
||||
start_x, start_y = start_coordinate[0], start_coordinate[1]
|
||||
end_x, end_y = end_coordinate[0], end_coordinate[1]
|
||||
|
||||
client.mouseMove(start_x, start_y)
|
||||
client.mouseDown(1)
|
||||
client.mouseDrag(end_x, end_y) # vncdotool's mouseDrag method
|
||||
client.mouseUp(1)
|
||||
return ToolResult(output=f"Dragged from ({start_x}, {start_y}) to ({end_x}, {end_y})")
|
||||
|
||||
def _scroll(
|
||||
self,
|
||||
client,
|
||||
coordinate: list[int] | None,
|
||||
scroll_direction: str | None,
|
||||
scroll_amount: int | None,
|
||||
modifier_key: str | None
|
||||
) -> ToolResult:
|
||||
"""Scroll in specified direction."""
|
||||
if not scroll_direction or scroll_direction not in ("up", "down", "left", "right"):
|
||||
return ToolResult(error=f"scroll_direction must be 'up', 'down', 'left', or 'right'")
|
||||
|
||||
amount = scroll_amount or 5 # Default scroll amount
|
||||
|
||||
# Move to coordinate if specified
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
|
||||
# VNC scroll buttons: 4=up, 5=down, 6=left, 7=right
|
||||
scroll_button = {"up": 4, "down": 5, "left": 6, "right": 7}[scroll_direction]
|
||||
|
||||
# Hold modifier key if specified
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
|
||||
# Scroll by pressing scroll button multiple times
|
||||
import time
|
||||
for _ in range(amount):
|
||||
client.mousePress(scroll_button)
|
||||
time.sleep(0.05) # Small delay between scroll events
|
||||
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
|
||||
return ToolResult(output=f"Scrolled {scroll_direction} {amount} times")
|
||||
|
||||
def _hold_key(self, client, text: str | None, duration: float | None) -> ToolResult:
|
||||
"""Hold a key for specified duration."""
|
||||
if not text:
|
||||
return ToolResult(error="text (key name) required for hold_key")
|
||||
|
||||
hold_duration = duration or 1.0 # Default 1 second
|
||||
if hold_duration < 0 or hold_duration > 100:
|
||||
return ToolResult(error="duration must be between 0 and 100 seconds")
|
||||
|
||||
import time
|
||||
client.keyDown(text.lower())
|
||||
time.sleep(hold_duration)
|
||||
client.keyUp(text.lower())
|
||||
|
||||
return ToolResult(output=f"Held key '{text}' for {hold_duration}s")
|
||||
|
||||
def _paste(self, client, text: str | None) -> ToolResult:
|
||||
"""Paste text via clipboard (bypasses keyboard layout issues).
|
||||
|
||||
This uses VNC clipboard to send text, avoiding keyboard layout mismatches
|
||||
where characters like ':' become ';' due to different keyboard mappings.
|
||||
"""
|
||||
if not text:
|
||||
return ToolResult(error="text required for paste")
|
||||
|
||||
# Send text via clipboard and trigger paste
|
||||
client.paste(text)
|
||||
return ToolResult(output=f"Pasted via clipboard: {text[:50]}{'...' if len(text) > 50 else ''}")
|
||||
|
||||
def _wait(self, duration: float | None) -> ToolResult:
|
||||
"""Wait for specified duration."""
|
||||
wait_duration = duration or 1.0
|
||||
if wait_duration < 0 or wait_duration > 100:
|
||||
return ToolResult(error="duration must be between 0 and 100 seconds")
|
||||
|
||||
import time
|
||||
time.sleep(wait_duration)
|
||||
return ToolResult(output=f"Waited {wait_duration}s")
|
||||
|
||||
def _zoom(self, client, region: list[int] | None) -> ToolResult:
|
||||
"""Zoom into specified region and capture screenshot.
|
||||
|
||||
Region format: [x1, y1, x2, y2] - top-left and bottom-right corners.
|
||||
"""
|
||||
if not region or len(region) != 4:
|
||||
return ToolResult(error="region must be [x1, y1, x2, y2]")
|
||||
|
||||
# Take full screenshot first
|
||||
import time
|
||||
from PIL import Image
|
||||
|
||||
# Wake up display
|
||||
client.mouseMove(self.display_width_px // 2, self.display_height_px // 2)
|
||||
time.sleep(0.1)
|
||||
client.keyPress('space')
|
||||
time.sleep(0.5)
|
||||
client.refreshScreen()
|
||||
time.sleep(0.5)
|
||||
|
||||
# Capture screenshot
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
client.captureScreen(tmp_path)
|
||||
|
||||
# Crop to region
|
||||
img = Image.open(tmp_path)
|
||||
x1, y1, x2, y2 = region
|
||||
cropped = img.crop((x1, y1, x2, y2))
|
||||
|
||||
# Save cropped image
|
||||
cropped_path = tmp_path.replace('.png', '_cropped.png')
|
||||
cropped.save(cropped_path)
|
||||
|
||||
# Read and encode
|
||||
png_data = Path(cropped_path).read_bytes()
|
||||
Path(tmp_path).unlink() # Clean up original
|
||||
Path(cropped_path).unlink() # Clean up cropped
|
||||
|
||||
base64_data = base64.b64encode(png_data).decode()
|
||||
return ToolResult(base64_image=base64_data)
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
"""
|
||||
EditTool20250728 - File editor with view/create/str_replace/insert commands.
|
||||
|
||||
Anthropic's native trained tool for file editing operations.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from .base import BaseAnthropicTool, CLIResult
|
||||
|
||||
|
||||
class EditTool20250728(BaseAnthropicTool):
|
||||
"""
|
||||
File editor supporting view, create, str_replace, and insert operations.
|
||||
|
||||
Trained by Anthropic, this tool provides comprehensive file editing
|
||||
capabilities with strict safety checks.
|
||||
"""
|
||||
|
||||
api_type: Literal["text_editor_20250728"] = "text_editor_20250728"
|
||||
name: Literal["str_replace_based_edit_tool"] = "str_replace_based_edit_tool"
|
||||
beta_flag: str = "computer-use-2025-11-24"
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
command: Literal["view", "create", "str_replace", "insert"],
|
||||
path: str,
|
||||
file_text: str | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> CLIResult:
|
||||
"""
|
||||
Execute a file editing command.
|
||||
|
||||
Args:
|
||||
command: The operation to perform
|
||||
path: Absolute path to the file
|
||||
file_text: Full file content (for create)
|
||||
old_str: String to replace (for str_replace)
|
||||
new_str: Replacement string (for str_replace/insert)
|
||||
insert_line: Line number to insert at (for insert)
|
||||
view_range: [start, end] line range (for view)
|
||||
**kwargs: Additional arguments (ignored)
|
||||
|
||||
Returns:
|
||||
CLIResult with exit code, output, and error
|
||||
"""
|
||||
# Validate absolute path
|
||||
file_path = Path(path)
|
||||
if not file_path.is_absolute():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: path must be absolute, got: {path}"
|
||||
)
|
||||
|
||||
try:
|
||||
if command == "view":
|
||||
return await self._view(file_path, view_range)
|
||||
elif command == "create":
|
||||
return await self._create(file_path, file_text)
|
||||
elif command == "str_replace":
|
||||
return await self._str_replace(file_path, old_str, new_str)
|
||||
elif command == "insert":
|
||||
return await self._insert(file_path, insert_line, new_str)
|
||||
else:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: unknown command: {command}"
|
||||
)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: {str(e)}"
|
||||
)
|
||||
|
||||
async def _view(self, path: Path, view_range: list[int] | None) -> CLIResult:
|
||||
"""View file contents with line numbers."""
|
||||
if not path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: file not found: {path}"
|
||||
)
|
||||
|
||||
content = path.read_text()
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Apply view range if specified
|
||||
if view_range:
|
||||
start, end = view_range
|
||||
lines = lines[start - 1:end]
|
||||
start_num = start
|
||||
else:
|
||||
start_num = 1
|
||||
|
||||
# Format with line numbers
|
||||
formatted_lines = [
|
||||
f"{start_num + i}|{line.rstrip()}"
|
||||
for i, line in enumerate(lines)
|
||||
]
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output="\n".join(formatted_lines),
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _create(self, path: Path, file_text: str | None) -> CLIResult:
|
||||
"""Create a new file with the given content."""
|
||||
if file_text is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: file_text is required for create command"
|
||||
)
|
||||
|
||||
if path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: file already exists: {path}"
|
||||
)
|
||||
|
||||
# Create parent directories if needed
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write the file
|
||||
path.write_text(file_text)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"File created: {path}",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _str_replace(
|
||||
self,
|
||||
path: Path,
|
||||
old_str: str | None,
|
||||
new_str: str | None
|
||||
) -> CLIResult:
|
||||
"""Replace a unique occurrence of old_str with new_str."""
|
||||
if old_str is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: old_str is required for str_replace command"
|
||||
)
|
||||
|
||||
if new_str is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: new_str is required for str_replace command"
|
||||
)
|
||||
|
||||
if not path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: file not found: {path}"
|
||||
)
|
||||
|
||||
content = path.read_text()
|
||||
|
||||
# Check for unique match
|
||||
count = content.count(old_str)
|
||||
if count == 0:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: old_str not found in file: {old_str!r}"
|
||||
)
|
||||
elif count > 1:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: old_str must match exactly once, found {count} matches"
|
||||
)
|
||||
|
||||
# Perform replacement
|
||||
new_content = content.replace(old_str, new_str)
|
||||
path.write_text(new_content)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"Replaced 1 occurrence in: {path}",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _insert(
|
||||
self,
|
||||
path: Path,
|
||||
insert_line: int | None,
|
||||
new_str: str | None
|
||||
) -> CLIResult:
|
||||
"""Insert new_str at the specified line number."""
|
||||
if insert_line is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: insert_line is required for insert command"
|
||||
)
|
||||
|
||||
if new_str is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: new_str is required for insert command"
|
||||
)
|
||||
|
||||
if not path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: file not found: {path}"
|
||||
)
|
||||
|
||||
content = path.read_text()
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Validate line number
|
||||
if insert_line < 0 or insert_line > len(lines):
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: insert_line {insert_line} out of range [0, {len(lines)}]"
|
||||
)
|
||||
|
||||
# Insert the new string
|
||||
lines.insert(insert_line, new_str)
|
||||
new_content = "".join(lines)
|
||||
path.write_text(new_content)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"Inserted text at line {insert_line} in: {path}",
|
||||
error=""
|
||||
)
|
||||
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
"""Convert to Anthropic API tool parameter format.
|
||||
|
||||
Returns:
|
||||
Tool definition for Anthropic API with text_editor_20250728 type
|
||||
"""
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
"""MemoryTool20250818 - Anthropic's native memory tool.
|
||||
|
||||
Enables Claude to create, read, update, and delete files in a persistent
|
||||
/memories directory across conversations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, CLIResult
|
||||
|
||||
|
||||
class MemoryTool20250818(BaseAnthropicTool):
|
||||
"""Anthropic's native memory_20250818 tool.
|
||||
|
||||
Client-side tool for persistent memory storage across conversations.
|
||||
All operations are restricted to the /memories directory.
|
||||
|
||||
Commands:
|
||||
- view: Show directory contents or file contents with line numbers
|
||||
- create: Create a new file with content
|
||||
- str_replace: Replace unique text occurrence in a file
|
||||
- insert: Insert text at a specific line number
|
||||
- delete: Delete a file or directory
|
||||
- rename: Rename or move a file/directory
|
||||
"""
|
||||
|
||||
api_type: Literal["memory_20250818"] = "memory_20250818"
|
||||
name: Literal["memory"] = "memory"
|
||||
beta_flag: str = "context-management-2025-06-27"
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
"""Initialize Memory tool.
|
||||
|
||||
Args:
|
||||
workspace: Root workspace directory
|
||||
"""
|
||||
self.workspace = workspace
|
||||
self.memories_dir = workspace / "memories"
|
||||
self.memories_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _validate_memory_path(self, path: str) -> Path:
|
||||
"""Validate and resolve path to prevent directory traversal.
|
||||
|
||||
Args:
|
||||
path: Path string starting with /memories
|
||||
|
||||
Returns:
|
||||
Validated absolute Path within memories directory
|
||||
|
||||
Raises:
|
||||
ValueError: If path is invalid or escapes /memories directory
|
||||
"""
|
||||
# Reject paths not starting with /memories
|
||||
if not path.startswith("/memories"):
|
||||
raise ValueError(f"Path must start with /memories, got: {path}")
|
||||
|
||||
# Resolve to absolute path within workspace
|
||||
# lstrip("/") removes leading slash: "/memories/file.txt" -> "memories/file.txt"
|
||||
relative_path = path.lstrip("/")
|
||||
full_path = (self.workspace / relative_path).resolve()
|
||||
|
||||
# Verify resolved path is within memories directory
|
||||
memories_dir_resolved = self.memories_dir.resolve()
|
||||
try:
|
||||
full_path.relative_to(memories_dir_resolved)
|
||||
except ValueError:
|
||||
raise ValueError(f"Path escapes /memories directory: {path}")
|
||||
|
||||
return full_path
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
command: Literal["view", "create", "str_replace", "insert", "delete", "rename"],
|
||||
path: str | None = None,
|
||||
old_path: str | None = None,
|
||||
new_path: str | None = None,
|
||||
file_text: str | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
insert_text: str | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> CLIResult:
|
||||
"""Execute memory command.
|
||||
|
||||
Args:
|
||||
command: Command to execute
|
||||
path: File/directory path (for view/create/str_replace/insert/delete)
|
||||
old_path: Source path (for rename)
|
||||
new_path: Destination path (for rename)
|
||||
file_text: File content (for create)
|
||||
old_str: Text to find (for str_replace)
|
||||
new_str: Replacement text (for str_replace)
|
||||
insert_line: Line number to insert at (for insert)
|
||||
insert_text: Text to insert (for insert)
|
||||
view_range: [start_line, end_line] for view
|
||||
**kwargs: Additional arguments (ignored)
|
||||
|
||||
Returns:
|
||||
CLIResult with command output or error
|
||||
"""
|
||||
try:
|
||||
if command == "view":
|
||||
return await self._view(path, view_range)
|
||||
elif command == "create":
|
||||
return await self._create(path, file_text)
|
||||
elif command == "str_replace":
|
||||
return await self._str_replace(path, old_str, new_str)
|
||||
elif command == "insert":
|
||||
return await self._insert(path, insert_line, insert_text)
|
||||
elif command == "delete":
|
||||
return await self._delete(path)
|
||||
elif command == "rename":
|
||||
return await self._rename(old_path, new_path)
|
||||
else:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Unknown command: {command}"
|
||||
)
|
||||
except ValueError as e:
|
||||
# Path security error
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: {e}"
|
||||
)
|
||||
|
||||
async def _view(
|
||||
self,
|
||||
path: str | None,
|
||||
view_range: list[int] | None = None,
|
||||
) -> CLIResult:
|
||||
"""View directory listing or file contents.
|
||||
|
||||
Args:
|
||||
path: Path to view
|
||||
view_range: Optional [start_line, end_line] for file viewing (1-indexed)
|
||||
|
||||
Returns:
|
||||
CLIResult with directory listing or file contents
|
||||
"""
|
||||
if path is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: path is required for view command"
|
||||
)
|
||||
|
||||
path_str = path # Keep original for error messages
|
||||
validated_path = self._validate_memory_path(path)
|
||||
|
||||
# Directory listing
|
||||
if validated_path.is_dir():
|
||||
return await self._view_directory(validated_path, path_str)
|
||||
|
||||
# File viewing
|
||||
if not validated_path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"The path {path_str} does not exist. Please provide a valid path."
|
||||
)
|
||||
|
||||
# Read file
|
||||
try:
|
||||
content = validated_path.read_text()
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error reading file: {e}"
|
||||
)
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Check line limit
|
||||
if len(lines) > 999_999:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"File {path_str} exceeds maximum line limit of 999,999 lines."
|
||||
)
|
||||
|
||||
# Apply view_range if specified
|
||||
if view_range:
|
||||
start, end = view_range
|
||||
# Convert to 0-indexed, clamp to valid range
|
||||
start_idx = max(0, start - 1)
|
||||
end_idx = min(len(lines), end)
|
||||
lines_to_show = lines[start_idx:end_idx]
|
||||
start_num = start
|
||||
else:
|
||||
lines_to_show = lines
|
||||
start_num = 1
|
||||
|
||||
# Format with line numbers (6 chars, right-aligned, tab-separated)
|
||||
formatted_lines = []
|
||||
for i, line in enumerate(lines_to_show):
|
||||
line_num = start_num + i
|
||||
# Remove trailing newline for display
|
||||
line_content = line.rstrip("\n")
|
||||
formatted_lines.append(f"{line_num:6d}\t{line_content}")
|
||||
|
||||
output = f"Here's the content of {path_str} with line numbers:\n"
|
||||
output += "\n".join(formatted_lines)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=output,
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _view_directory(self, path: Path, path_str: str) -> CLIResult:
|
||||
"""View directory listing up to 2 levels deep.
|
||||
|
||||
Args:
|
||||
path: Validated Path object
|
||||
path_str: Original path string for display
|
||||
|
||||
Returns:
|
||||
CLIResult with directory listing
|
||||
"""
|
||||
import os
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Convert bytes to human-readable format."""
|
||||
for unit in ['B', 'K', 'M', 'G', 'T']:
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes:.1f}{unit}"
|
||||
size_bytes /= 1024
|
||||
return f"{size_bytes:.1f}P"
|
||||
|
||||
lines = []
|
||||
header = f"Here're the files and directories up to 2 levels deep in {path_str}, excluding hidden items and node_modules:"
|
||||
lines.append(header)
|
||||
|
||||
# Walk directory tree (max depth 2)
|
||||
base_depth = str(path).count(os.sep)
|
||||
|
||||
for root, dirs, files in os.walk(path):
|
||||
# Calculate current depth
|
||||
current_depth = str(root).count(os.sep) - base_depth
|
||||
|
||||
# Filter out hidden items and node_modules at this level
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.') and d != 'node_modules']
|
||||
|
||||
# Stop if we've gone too deep
|
||||
if current_depth >= 2:
|
||||
dirs.clear() # Don't recurse further
|
||||
continue
|
||||
|
||||
# Get size and add directory entry
|
||||
root_path = Path(root)
|
||||
try:
|
||||
# Directory size (sum of all files within, or 4K default)
|
||||
dir_size = sum(f.stat().st_size for f in root_path.rglob('*') if f.is_file())
|
||||
if dir_size == 0:
|
||||
dir_size = 4096 # Default directory size
|
||||
size_str = format_size(dir_size)
|
||||
|
||||
# Convert absolute path to /memories/... format
|
||||
relative = root_path.relative_to(self.workspace)
|
||||
display_path = "/" + str(relative).replace(os.sep, "/")
|
||||
|
||||
lines.append(f"{size_str}\t{display_path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add file entries at this level
|
||||
for filename in sorted(files):
|
||||
if filename.startswith('.'):
|
||||
continue # Skip hidden files
|
||||
|
||||
file_path = root_path / filename
|
||||
try:
|
||||
file_size = file_path.stat().st_size
|
||||
size_str = format_size(file_size)
|
||||
|
||||
# Convert to /memories/... format
|
||||
relative = file_path.relative_to(self.workspace)
|
||||
display_path = "/" + str(relative).replace(os.sep, "/")
|
||||
|
||||
lines.append(f"{size_str}\t{display_path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output="\n".join(lines),
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _create(
|
||||
self,
|
||||
path: str | None,
|
||||
file_text: str | None,
|
||||
) -> CLIResult:
|
||||
"""Create a new file with content.
|
||||
|
||||
Args:
|
||||
path: File path to create
|
||||
file_text: Content to write
|
||||
|
||||
Returns:
|
||||
CLIResult with success message or error
|
||||
"""
|
||||
if path is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: path is required for create command"
|
||||
)
|
||||
|
||||
if file_text is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: file_text is required for create command"
|
||||
)
|
||||
|
||||
path_str = path # Keep original for error messages
|
||||
validated_path = self._validate_memory_path(path)
|
||||
|
||||
# Check if file already exists
|
||||
if validated_path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: File {path_str} already exists"
|
||||
)
|
||||
|
||||
# Create parent directories if needed
|
||||
try:
|
||||
validated_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error creating parent directories: {e}"
|
||||
)
|
||||
|
||||
# Write file
|
||||
try:
|
||||
validated_path.write_text(file_text)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error writing file: {e}"
|
||||
)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"File created successfully at: {path_str}",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _str_replace(
|
||||
self,
|
||||
path: str | None,
|
||||
old_str: str | None,
|
||||
new_str: str | None,
|
||||
) -> CLIResult:
|
||||
"""Replace unique occurrence of old_str with new_str."""
|
||||
if path is None or old_str is None or new_str is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: old_str and new_str are required for str_replace command"
|
||||
)
|
||||
|
||||
path_str = path
|
||||
validated_path = self._validate_memory_path(path)
|
||||
|
||||
if not validated_path.exists() or validated_path.is_dir():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: The path {path_str} does not exist. Please provide a valid path."
|
||||
)
|
||||
|
||||
content = validated_path.read_text()
|
||||
count = content.count(old_str)
|
||||
|
||||
if count == 0:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"No replacement was performed, old_str `{old_str}` did not appear verbatim in {path_str}."
|
||||
)
|
||||
elif count > 1:
|
||||
lines = content.splitlines()
|
||||
line_nums = [i + 1 for i, line in enumerate(lines) if old_str in line]
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines: {line_nums}. Please ensure it is unique"
|
||||
)
|
||||
|
||||
new_content = content.replace(old_str, new_str, 1)
|
||||
validated_path.write_text(new_content)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output="The memory file has been edited.",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _insert(
|
||||
self, path: str | None, insert_line: int | None, insert_text: str | None
|
||||
) -> CLIResult:
|
||||
"""Insert text at a specific line number.
|
||||
|
||||
Args:
|
||||
path: File path to modify
|
||||
insert_line: Line number to insert at (0 = beginning)
|
||||
insert_text: Text to insert
|
||||
|
||||
Returns:
|
||||
CLIResult with success message or error
|
||||
"""
|
||||
if path is None or insert_line is None or insert_text is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: path, insert_line, and insert_text are required for insert command"
|
||||
)
|
||||
|
||||
path_str = path
|
||||
validated_path = self._validate_memory_path(path)
|
||||
|
||||
if not validated_path.exists() or validated_path.is_dir():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: The path {path_str} does not exist. Please provide a valid path."
|
||||
)
|
||||
|
||||
# Read current content
|
||||
content = validated_path.read_text()
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Validate insert_line
|
||||
if insert_line < 0 or insert_line > len(lines):
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Invalid `insert_line` parameter: {insert_line}. It should be within 0 to {len(lines)}"
|
||||
)
|
||||
|
||||
# Insert text at specified line
|
||||
lines.insert(insert_line, insert_text)
|
||||
new_content = "".join(lines)
|
||||
validated_path.write_text(new_content)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"The file {path_str} has been edited.",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _delete(self, path: str | None) -> CLIResult:
|
||||
"""Delete a file or directory.
|
||||
|
||||
Args:
|
||||
path: Path to delete
|
||||
|
||||
Returns:
|
||||
CLIResult with success message or error
|
||||
"""
|
||||
if path is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: path is required for delete command"
|
||||
)
|
||||
|
||||
path_str = path
|
||||
validated_path = self._validate_memory_path(path)
|
||||
|
||||
if not validated_path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: The path {path_str} does not exist. Please provide a valid path."
|
||||
)
|
||||
|
||||
# Delete file or directory
|
||||
try:
|
||||
if validated_path.is_dir():
|
||||
import shutil
|
||||
shutil.rmtree(validated_path)
|
||||
else:
|
||||
validated_path.unlink()
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error deleting {path_str}: {e}"
|
||||
)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"Successfully deleted {path_str}",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _rename(self, old_path: str | None, new_path: str | None) -> CLIResult:
|
||||
"""Rename or move a file or directory.
|
||||
|
||||
Args:
|
||||
old_path: Source path
|
||||
new_path: Destination path
|
||||
|
||||
Returns:
|
||||
CLIResult with success message or error
|
||||
"""
|
||||
if old_path is None or new_path is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: old_path and new_path are required for rename command"
|
||||
)
|
||||
|
||||
old_path_str = old_path
|
||||
new_path_str = new_path
|
||||
validated_old = self._validate_memory_path(old_path)
|
||||
validated_new = self._validate_memory_path(new_path)
|
||||
|
||||
# Check if source exists
|
||||
if not validated_old.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: The path {old_path_str} does not exist. Please provide a valid path."
|
||||
)
|
||||
|
||||
# Check if destination already exists
|
||||
if validated_new.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: The destination {new_path_str} already exists. Please provide a different destination."
|
||||
)
|
||||
|
||||
# Create parent directories if needed
|
||||
try:
|
||||
validated_new.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error creating parent directories: {e}"
|
||||
)
|
||||
|
||||
# Rename/move
|
||||
try:
|
||||
validated_old.rename(validated_new)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error renaming {old_path_str}: {e}"
|
||||
)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"Successfully renamed {old_path_str} to {new_path_str}",
|
||||
error=""
|
||||
)
|
||||
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
"""Convert to Anthropic API tool parameter format.
|
||||
|
||||
Returns:
|
||||
Tool definition for Anthropic API
|
||||
"""
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
}
|
||||
@@ -50,10 +50,6 @@ class CronTool(Tool):
|
||||
"type": "string",
|
||||
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
|
||||
},
|
||||
"tz": {
|
||||
"type": "string",
|
||||
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
|
||||
},
|
||||
"at": {
|
||||
"type": "string",
|
||||
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
|
||||
@@ -72,46 +68,30 @@ class CronTool(Tool):
|
||||
message: str = "",
|
||||
every_seconds: int | None = None,
|
||||
cron_expr: str | None = None,
|
||||
tz: str | None = None,
|
||||
at: str | None = None,
|
||||
job_id: str | None = None,
|
||||
**kwargs: Any
|
||||
) -> str:
|
||||
if action == "add":
|
||||
return self._add_job(message, every_seconds, cron_expr, tz, at)
|
||||
return self._add_job(message, every_seconds, cron_expr, at)
|
||||
elif action == "list":
|
||||
return self._list_jobs()
|
||||
elif action == "remove":
|
||||
return self._remove_job(job_id)
|
||||
return f"Unknown action: {action}"
|
||||
|
||||
def _add_job(
|
||||
self,
|
||||
message: str,
|
||||
every_seconds: int | None,
|
||||
cron_expr: str | None,
|
||||
tz: str | None,
|
||||
at: str | None,
|
||||
) -> str:
|
||||
def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str:
|
||||
if not message:
|
||||
return "Error: message is required for add"
|
||||
if not self._channel or not self._chat_id:
|
||||
return "Error: no session context (channel/chat_id)"
|
||||
if tz and not cron_expr:
|
||||
return "Error: tz can only be used with cron_expr"
|
||||
if tz:
|
||||
from zoneinfo import ZoneInfo
|
||||
try:
|
||||
ZoneInfo(tz)
|
||||
except (KeyError, Exception):
|
||||
return f"Error: unknown timezone '{tz}'"
|
||||
|
||||
# Build schedule
|
||||
delete_after = False
|
||||
if every_seconds:
|
||||
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
|
||||
elif cron_expr:
|
||||
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
|
||||
schedule = CronSchedule(kind="cron", expr=cron_expr)
|
||||
elif at:
|
||||
from datetime import datetime
|
||||
dt = datetime.fromisoformat(at)
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
"""File system tools: read, write, edit."""
|
||||
|
||||
import difflib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
|
||||
def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path:
|
||||
"""Resolve path against workspace (if relative) and enforce directory restriction."""
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_absolute() and workspace:
|
||||
p = workspace / p
|
||||
resolved = p.resolve()
|
||||
if allowed_dir:
|
||||
try:
|
||||
resolved.relative_to(allowed_dir.resolve())
|
||||
except ValueError:
|
||||
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
|
||||
def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path:
|
||||
"""Resolve path and optionally enforce directory restriction."""
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())):
|
||||
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
|
||||
return resolved
|
||||
|
||||
|
||||
class ReadFileTool(Tool):
|
||||
"""Tool to read file contents."""
|
||||
|
||||
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
|
||||
self._workspace = workspace
|
||||
|
||||
def __init__(self, allowed_dir: Path | None = None):
|
||||
self._allowed_dir = allowed_dir
|
||||
|
||||
@property
|
||||
@@ -51,12 +43,12 @@ class ReadFileTool(Tool):
|
||||
|
||||
async def execute(self, path: str, **kwargs: Any) -> str:
|
||||
try:
|
||||
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
|
||||
file_path = _resolve_path(path, self._allowed_dir)
|
||||
if not file_path.exists():
|
||||
return f"Error: File not found: {path}"
|
||||
if not file_path.is_file():
|
||||
return f"Error: Not a file: {path}"
|
||||
|
||||
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
return content
|
||||
except PermissionError as e:
|
||||
@@ -67,9 +59,8 @@ class ReadFileTool(Tool):
|
||||
|
||||
class WriteFileTool(Tool):
|
||||
"""Tool to write content to a file."""
|
||||
|
||||
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
|
||||
self._workspace = workspace
|
||||
|
||||
def __init__(self, allowed_dir: Path | None = None):
|
||||
self._allowed_dir = allowed_dir
|
||||
|
||||
@property
|
||||
@@ -99,10 +90,10 @@ class WriteFileTool(Tool):
|
||||
|
||||
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
|
||||
try:
|
||||
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
|
||||
file_path = _resolve_path(path, self._allowed_dir)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
return f"Successfully wrote {len(content)} bytes to {file_path}"
|
||||
return f"Successfully wrote {len(content)} bytes to {path}"
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
except Exception as e:
|
||||
@@ -111,9 +102,8 @@ class WriteFileTool(Tool):
|
||||
|
||||
class EditFileTool(Tool):
|
||||
"""Tool to edit a file by replacing text."""
|
||||
|
||||
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
|
||||
self._workspace = workspace
|
||||
|
||||
def __init__(self, allowed_dir: Path | None = None):
|
||||
self._allowed_dir = allowed_dir
|
||||
|
||||
@property
|
||||
@@ -147,57 +137,34 @@ class EditFileTool(Tool):
|
||||
|
||||
async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
|
||||
try:
|
||||
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
|
||||
file_path = _resolve_path(path, self._allowed_dir)
|
||||
if not file_path.exists():
|
||||
return f"Error: File not found: {path}"
|
||||
|
||||
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
if old_text not in content:
|
||||
return self._not_found_message(old_text, content, path)
|
||||
|
||||
return f"Error: old_text not found in file. Make sure it matches exactly."
|
||||
|
||||
# Count occurrences
|
||||
count = content.count(old_text)
|
||||
if count > 1:
|
||||
return f"Warning: old_text appears {count} times. Please provide more context to make it unique."
|
||||
|
||||
|
||||
new_content = content.replace(old_text, new_text, 1)
|
||||
file_path.write_text(new_content, encoding="utf-8")
|
||||
|
||||
return f"Successfully edited {file_path}"
|
||||
|
||||
return f"Successfully edited {path}"
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
except Exception as e:
|
||||
return f"Error editing file: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def _not_found_message(old_text: str, content: str, path: str) -> str:
|
||||
"""Build a helpful error when old_text is not found."""
|
||||
lines = content.splitlines(keepends=True)
|
||||
old_lines = old_text.splitlines(keepends=True)
|
||||
window = len(old_lines)
|
||||
|
||||
best_ratio, best_start = 0.0, 0
|
||||
for i in range(max(1, len(lines) - window + 1)):
|
||||
ratio = difflib.SequenceMatcher(None, old_lines, lines[i : i + window]).ratio()
|
||||
if ratio > best_ratio:
|
||||
best_ratio, best_start = ratio, i
|
||||
|
||||
if best_ratio > 0.5:
|
||||
diff = "\n".join(difflib.unified_diff(
|
||||
old_lines, lines[best_start : best_start + window],
|
||||
fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})",
|
||||
lineterm="",
|
||||
))
|
||||
return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
|
||||
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
|
||||
|
||||
|
||||
class ListDirTool(Tool):
|
||||
"""Tool to list directory contents."""
|
||||
|
||||
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
|
||||
self._workspace = workspace
|
||||
|
||||
def __init__(self, allowed_dir: Path | None = None):
|
||||
self._allowed_dir = allowed_dir
|
||||
|
||||
@property
|
||||
@@ -223,20 +190,20 @@ class ListDirTool(Tool):
|
||||
|
||||
async def execute(self, path: str, **kwargs: Any) -> str:
|
||||
try:
|
||||
dir_path = _resolve_path(path, self._workspace, self._allowed_dir)
|
||||
dir_path = _resolve_path(path, self._allowed_dir)
|
||||
if not dir_path.exists():
|
||||
return f"Error: Directory not found: {path}"
|
||||
if not dir_path.is_dir():
|
||||
return f"Error: Not a directory: {path}"
|
||||
|
||||
|
||||
items = []
|
||||
for item in sorted(dir_path.iterdir()):
|
||||
prefix = "📁 " if item.is_dir() else "📄 "
|
||||
items.append(f"{prefix}{item.name}")
|
||||
|
||||
|
||||
if not items:
|
||||
return f"Directory {path} is empty"
|
||||
|
||||
|
||||
return "\n".join(items)
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
|
||||
class MCPToolWrapper(Tool):
|
||||
"""Wraps a single MCP server tool as a nanobot Tool."""
|
||||
|
||||
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
|
||||
self._session = session
|
||||
self._original_name = tool_def.name
|
||||
self._name = f"mcp_{server_name}_{tool_def.name}"
|
||||
self._description = tool_def.description or tool_def.name
|
||||
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
|
||||
self._tool_timeout = tool_timeout
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self._description
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return self._parameters
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
from mcp import types
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self._session.call_tool(self._original_name, arguments=kwargs),
|
||||
timeout=self._tool_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
|
||||
return f"(MCP tool call timed out after {self._tool_timeout}s)"
|
||||
parts = []
|
||||
for block in result.content:
|
||||
if isinstance(block, types.TextContent):
|
||||
parts.append(block.text)
|
||||
else:
|
||||
parts.append(str(block))
|
||||
return "\n".join(parts) or "(no output)"
|
||||
|
||||
|
||||
async def connect_mcp_servers(
|
||||
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
|
||||
) -> None:
|
||||
"""Connect to configured MCP servers and register their tools."""
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
for name, cfg in mcp_servers.items():
|
||||
try:
|
||||
if cfg.command:
|
||||
params = StdioServerParameters(
|
||||
command=cfg.command, args=cfg.args, env=cfg.env or None
|
||||
)
|
||||
read, write = await stack.enter_async_context(stdio_client(params))
|
||||
elif cfg.url:
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
# Always provide an explicit httpx client so MCP HTTP transport does not
|
||||
# inherit httpx's default 5s timeout and preempt the higher-level tool timeout.
|
||||
http_client = await stack.enter_async_context(
|
||||
httpx.AsyncClient(
|
||||
headers=cfg.headers or None,
|
||||
follow_redirects=True,
|
||||
timeout=None,
|
||||
)
|
||||
)
|
||||
read, write, _ = await stack.enter_async_context(
|
||||
streamable_http_client(cfg.url, http_client=http_client)
|
||||
)
|
||||
else:
|
||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||
continue
|
||||
|
||||
session = await stack.enter_async_context(ClientSession(read, write))
|
||||
await session.initialize()
|
||||
|
||||
tools = await session.list_tools()
|
||||
for tool_def in tools.tools:
|
||||
wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout)
|
||||
registry.register(wrapper)
|
||||
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
|
||||
|
||||
logger.info("MCP server '{}': connected, {} tools registered", name, len(tools.tools))
|
||||
except Exception as e:
|
||||
logger.error("MCP server '{}': failed to connect: {}", name, e)
|
||||
@@ -4,21 +4,18 @@ from typing import Any, Callable, Awaitable
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.session import SessionManager
|
||||
|
||||
|
||||
class MessageTool(Tool):
|
||||
"""Tool to send messages to users on chat channels."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
self,
|
||||
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
||||
sessions: SessionManager | None = None,
|
||||
default_channel: str = "",
|
||||
default_chat_id: str = ""
|
||||
):
|
||||
self._send_callback = send_callback
|
||||
self._sessions = sessions
|
||||
self._default_channel = default_channel
|
||||
self._default_chat_id = default_chat_id
|
||||
|
||||
@@ -48,11 +45,6 @@ class MessageTool(Tool):
|
||||
"type": "string",
|
||||
"description": "The message content to send"
|
||||
},
|
||||
"media": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional: list of media file paths or URLs to attach"
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Optional: target channel (telegram, discord, etc.)"
|
||||
@@ -66,38 +58,29 @@ class MessageTool(Tool):
|
||||
}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
content: str,
|
||||
media: list[str] | None = None,
|
||||
channel: str | None = None,
|
||||
self,
|
||||
content: str,
|
||||
channel: str | None = None,
|
||||
chat_id: str | None = None,
|
||||
**kwargs: Any
|
||||
) -> str:
|
||||
channel = channel or self._default_channel
|
||||
chat_id = chat_id or self._default_chat_id
|
||||
|
||||
|
||||
if not channel or not chat_id:
|
||||
return "Error: No target channel/chat specified"
|
||||
|
||||
|
||||
if not self._send_callback:
|
||||
return "Error: Message sending not configured"
|
||||
|
||||
|
||||
msg = OutboundMessage(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
media=media or []
|
||||
content=content
|
||||
)
|
||||
|
||||
try:
|
||||
await self._send_callback(msg)
|
||||
|
||||
if self._sessions:
|
||||
session_key = f"{channel}:{chat_id}"
|
||||
session = self._sessions.get_or_create(session_key)
|
||||
session.add_message("assistant", content)
|
||||
self._sessions.save(session)
|
||||
|
||||
return f"Message sent to {channel}:{chat_id}"
|
||||
except Exception as e:
|
||||
return f"Error sending message: {str(e)}"
|
||||
|
||||
@@ -32,33 +32,20 @@ class ToolRegistry:
|
||||
return name in self._tools
|
||||
|
||||
def get_definitions(self) -> list[dict[str, Any]]:
|
||||
"""Get tool definitions for all registered tools.
|
||||
|
||||
Supports both function tools (with to_schema) and native tools (with to_params).
|
||||
"""
|
||||
definitions = []
|
||||
for tool in self._tools.values():
|
||||
if hasattr(tool, 'to_params'): # Native Anthropic tool
|
||||
definitions.append(tool.to_params())
|
||||
elif hasattr(tool, 'to_schema'): # Function tool
|
||||
definitions.append(tool.to_schema())
|
||||
else:
|
||||
raise ValueError(f"Tool {tool.name} has no schema method (to_params or to_schema)")
|
||||
return definitions
|
||||
"""Get all tool definitions in OpenAI format."""
|
||||
return [tool.to_schema() for tool in self._tools.values()]
|
||||
|
||||
async def execute(self, name: str, params: dict[str, Any]) -> Any:
|
||||
async def execute(self, name: str, params: dict[str, Any]) -> str:
|
||||
"""
|
||||
Execute a tool by name with given parameters.
|
||||
|
||||
Supports both native Anthropic tools (via __call__) and function tools (via execute).
|
||||
|
||||
|
||||
Args:
|
||||
name: Tool name.
|
||||
params: Tool parameters.
|
||||
|
||||
|
||||
Returns:
|
||||
Tool execution result (ToolResult, CLIResult, or string).
|
||||
|
||||
Tool execution result as string.
|
||||
|
||||
Raises:
|
||||
KeyError: If tool not found.
|
||||
"""
|
||||
@@ -67,33 +54,20 @@ class ToolRegistry:
|
||||
return f"Error: Tool '{name}' not found"
|
||||
|
||||
try:
|
||||
# Duck typing - support both native and function tools
|
||||
if hasattr(tool, 'to_params'):
|
||||
# Native Anthropic tool - call directly via __call__, no validation needed
|
||||
return await tool(**params)
|
||||
else:
|
||||
# Legacy function tool - validate then execute
|
||||
errors = tool.validate_params(params)
|
||||
if errors:
|
||||
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
||||
return await tool.execute(**params)
|
||||
errors = tool.validate_params(params)
|
||||
if errors:
|
||||
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
||||
return await tool.execute(**params)
|
||||
except Exception as e:
|
||||
return f"Error executing {name}: {str(e)}"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
"""Get list of tool objects (not definitions).
|
||||
|
||||
Returns tool objects which can be inspected for metadata like beta_flag.
|
||||
"""
|
||||
return list(self._tools.values())
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]:
|
||||
"""Get list of registered tool names."""
|
||||
return list(self._tools.keys())
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._tools)
|
||||
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return name in self._tools
|
||||
|
||||
@@ -19,7 +19,6 @@ class ExecTool(Tool):
|
||||
deny_patterns: list[str] | None = None,
|
||||
allow_patterns: list[str] | None = None,
|
||||
restrict_to_workspace: bool = False,
|
||||
path_append: str = "",
|
||||
):
|
||||
self.timeout = timeout
|
||||
self.working_dir = working_dir
|
||||
@@ -27,8 +26,7 @@ class ExecTool(Tool):
|
||||
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
||||
r"\bdel\s+/[fq]\b", # del /f, del /q
|
||||
r"\brmdir\s+/s\b", # rmdir /s
|
||||
r"(?:^|[;&|]\s*)format\b", # format (as standalone command only)
|
||||
r"\b(mkfs|diskpart)\b", # disk operations
|
||||
r"\b(format|mkfs|diskpart)\b", # disk operations
|
||||
r"\bdd\s+if=", # dd
|
||||
r">\s*/dev/sd", # write to disk
|
||||
r"\b(shutdown|reboot|poweroff)\b", # system power
|
||||
@@ -36,7 +34,6 @@ class ExecTool(Tool):
|
||||
]
|
||||
self.allow_patterns = allow_patterns or []
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self.path_append = path_append
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -69,17 +66,12 @@ class ExecTool(Tool):
|
||||
if guard_error:
|
||||
return guard_error
|
||||
|
||||
env = os.environ.copy()
|
||||
if self.path_append:
|
||||
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -89,12 +81,6 @@ class ExecTool(Tool):
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
# Wait for the process to fully terminate so pipes are
|
||||
# drained and file descriptors are released.
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return f"Error: Command timed out after {self.timeout} seconds"
|
||||
|
||||
output_parts = []
|
||||
|
||||
@@ -20,13 +20,11 @@ class SpawnTool(Tool):
|
||||
self._manager = manager
|
||||
self._origin_channel = "cli"
|
||||
self._origin_chat_id = "direct"
|
||||
self._origin_metadata: dict[str, Any] = {}
|
||||
|
||||
def set_context(self, channel: str, chat_id: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
|
||||
def set_context(self, channel: str, chat_id: str) -> None:
|
||||
"""Set the origin context for subagent announcements."""
|
||||
self._origin_channel = channel
|
||||
self._origin_chat_id = chat_id
|
||||
self._origin_metadata = metadata or {}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -69,5 +67,4 @@ class SpawnTool(Tool):
|
||||
model=model,
|
||||
origin_channel=self._origin_channel,
|
||||
origin_chat_id=self._origin_chat_id,
|
||||
origin_metadata=self._origin_metadata,
|
||||
)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Message tool for subagents to communicate with the main agent."""
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
|
||||
class SubagentMessageTool(Tool):
|
||||
"""
|
||||
Tool for subagents to send messages to the main agent.
|
||||
|
||||
Messages are sent via the bus and preserve metadata (e.g. suppress_output)
|
||||
from the originating message that spawned the subagent.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus: "MessageBus",
|
||||
origin_channel: str,
|
||||
origin_chat_id: str,
|
||||
origin_metadata: dict[str, Any] | None = None,
|
||||
):
|
||||
self._bus = bus
|
||||
self._origin_channel = origin_channel
|
||||
self._origin_chat_id = origin_chat_id
|
||||
self._origin_metadata = origin_metadata or {}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "message"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Send a message to the main agent. "
|
||||
"Use this to communicate findings, request clarification, or provide updates. "
|
||||
"The main agent will process your message and decide how to respond."
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The message content to send to the main agent"
|
||||
},
|
||||
},
|
||||
"required": ["content"]
|
||||
}
|
||||
|
||||
async def execute(self, content: str, **kwargs: Any) -> str:
|
||||
"""Send a message to the main agent via the bus."""
|
||||
# Create InboundMessage to trigger main agent
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="subagent",
|
||||
chat_id=f"{self._origin_channel}:{self._origin_chat_id}",
|
||||
content=f"[Subagent message]\n\n{content}",
|
||||
metadata=self._origin_metadata,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._bus.publish_inbound(msg)
|
||||
return "Message sent to main agent"
|
||||
except Exception as e:
|
||||
return f"Error sending message: {str(e)}"
|
||||
@@ -58,21 +58,12 @@ class WebSearchTool(Tool):
|
||||
}
|
||||
|
||||
def __init__(self, api_key: str | None = None, max_results: int = 5):
|
||||
self._init_api_key = api_key
|
||||
self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "")
|
||||
self.max_results = max_results
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
"""Resolve API key at call time so env/config changes are picked up."""
|
||||
return self._init_api_key or os.environ.get("BRAVE_API_KEY", "")
|
||||
|
||||
|
||||
async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str:
|
||||
if not self.api_key:
|
||||
return (
|
||||
"Error: Brave Search API key not configured. "
|
||||
"Set it in ~/.nanobot/config.json under tools.web.search.apiKey "
|
||||
"(or export BRAVE_API_KEY), then restart the gateway."
|
||||
)
|
||||
return "Error: BRAVE_API_KEY not configured"
|
||||
|
||||
try:
|
||||
n = min(max(count or self.max_results, 1), 10)
|
||||
@@ -125,7 +116,7 @@ class WebFetchTool(Tool):
|
||||
# Validate URL before fetching
|
||||
is_valid, error_msg = _validate_url(url)
|
||||
if not is_valid:
|
||||
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
|
||||
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url})
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
@@ -140,7 +131,7 @@ class WebFetchTool(Tool):
|
||||
|
||||
# JSON
|
||||
if "application/json" in ctype:
|
||||
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
|
||||
text, extractor = json.dumps(r.json(), indent=2), "json"
|
||||
# HTML
|
||||
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
|
||||
doc = Document(r.text)
|
||||
@@ -155,9 +146,9 @@ class WebFetchTool(Tool):
|
||||
text = text[:max_chars]
|
||||
|
||||
return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code,
|
||||
"extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False)
|
||||
"extractor": extractor, "truncated": truncated, "length": len(text), "text": text})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
|
||||
return json.dumps({"error": str(e), "url": url})
|
||||
|
||||
def _to_markdown(self, html: str) -> str:
|
||||
"""Convert HTML to markdown."""
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# nanobot/agent/visibility.py
|
||||
"""Cryptographic signing for visibility markers to prevent model forgery."""
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
SECRET_KEY = "nanobot_visibility_secret_key_v1"
|
||||
|
||||
|
||||
def sign_content(content: str) -> str:
|
||||
"""
|
||||
Sign content with HMAC and prepend marker.
|
||||
|
||||
Args:
|
||||
content: The message content to sign
|
||||
|
||||
Returns:
|
||||
Content with signed visibility marker: "[HIDDEN:{sig}] {content}"
|
||||
"""
|
||||
sig = hmac.new(
|
||||
SECRET_KEY.encode(),
|
||||
content.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()[:8]
|
||||
return f"[HIDDEN:{sig}] {content}"
|
||||
|
||||
|
||||
def verify_signature(marked_content: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Verify HMAC signature and extract clean content.
|
||||
|
||||
Args:
|
||||
marked_content: Content potentially with [HIDDEN:{sig}] marker
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, clean_content)
|
||||
- is_valid: True if signature is valid, False otherwise
|
||||
- clean_content: Content without marker
|
||||
"""
|
||||
match = re.match(r'\[HIDDEN:([a-f0-9]{8})\] (.*)', marked_content, re.DOTALL)
|
||||
if not match:
|
||||
return False, marked_content
|
||||
|
||||
claimed_sig, content = match.groups()
|
||||
expected_sig = hmac.new(
|
||||
SECRET_KEY.encode(),
|
||||
content.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()[:8]
|
||||
|
||||
is_valid = hmac.compare_digest(claimed_sig, expected_sig)
|
||||
return is_valid, content
|
||||
|
||||
|
||||
def has_forged_marker(content: str) -> bool:
|
||||
"""
|
||||
Check if content has an invalid [HIDDEN:*] marker at the start.
|
||||
|
||||
Args:
|
||||
content: Content to check
|
||||
|
||||
Returns:
|
||||
True if content starts with forged marker, False otherwise
|
||||
"""
|
||||
if not content.startswith("[HIDDEN:"):
|
||||
return False
|
||||
is_valid, _ = verify_signature(content)
|
||||
return not is_valid
|
||||
|
||||
|
||||
def strip_all_hidden_markers(content: str) -> str:
|
||||
"""
|
||||
Remove all [HIDDEN:*] patterns from content (valid or invalid).
|
||||
|
||||
Args:
|
||||
content: Content potentially with markers
|
||||
|
||||
Returns:
|
||||
Content with all markers stripped
|
||||
"""
|
||||
return re.sub(r'\[HIDDEN:[a-f0-9]{8}\]\s*', '', content)
|
||||
@@ -16,12 +16,11 @@ class InboundMessage:
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
media: list[str] = field(default_factory=list) # Media URLs
|
||||
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
||||
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
||||
|
||||
@property
|
||||
def session_key(self) -> str:
|
||||
"""Unique key for session identification."""
|
||||
return self.session_key_override or f"{self.channel}:{self.chat_id}"
|
||||
return f"{self.channel}:{self.chat_id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+12
-32
@@ -1,7 +1,9 @@
|
||||
"""Async message queue for decoupled channel-agent communication."""
|
||||
|
||||
import asyncio
|
||||
from typing import Awaitable, Callable
|
||||
from typing import Callable, Awaitable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
|
||||
@@ -9,55 +11,33 @@ from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
class MessageBus:
|
||||
"""
|
||||
Async message bus that decouples chat channels from the agent core.
|
||||
|
||||
|
||||
Channels push messages to the inbound queue, and the agent processes
|
||||
them and pushes responses to the outbound queue.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
|
||||
self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {}
|
||||
self._correlation_store: dict[str, asyncio.Future] = {}
|
||||
self._running = False
|
||||
|
||||
|
||||
async def publish_inbound(self, msg: InboundMessage) -> None:
|
||||
"""Publish a message from a channel to the agent."""
|
||||
await self.inbound.put(msg)
|
||||
|
||||
|
||||
async def consume_inbound(self) -> InboundMessage:
|
||||
"""Consume the next inbound message (blocks until available)."""
|
||||
return await self.inbound.get()
|
||||
|
||||
|
||||
async def publish_outbound(self, msg: OutboundMessage) -> None:
|
||||
"""Publish a response from the agent to channels."""
|
||||
await self.outbound.put(msg)
|
||||
|
||||
|
||||
async def consume_outbound(self) -> OutboundMessage:
|
||||
"""Consume the next outbound message (blocks until available)."""
|
||||
return await self.outbound.get()
|
||||
|
||||
def register_correlation(self, correlation_id: str) -> asyncio.Future:
|
||||
"""Register a Future to be resolved when a matching outbound message appears."""
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
self._correlation_store[correlation_id] = future
|
||||
return future
|
||||
|
||||
def resolve_correlation(self, msg: OutboundMessage) -> None:
|
||||
"""Check if an outbound message has a correlation_id and resolve the matching Future."""
|
||||
cid = msg.metadata.get("correlation_id") if msg.metadata else None
|
||||
if cid and cid in self._correlation_store:
|
||||
future = self._correlation_store.pop(cid)
|
||||
if not future.done():
|
||||
future.set_result(msg.content)
|
||||
|
||||
def cancel_correlation(self, correlation_id: str) -> None:
|
||||
"""Cancel and remove a pending correlation."""
|
||||
future = self._correlation_store.pop(correlation_id, None)
|
||||
if future and not future.done():
|
||||
future.cancel()
|
||||
|
||||
|
||||
def subscribe_outbound(
|
||||
self,
|
||||
channel: str,
|
||||
@@ -89,12 +69,12 @@ class MessageBus:
|
||||
def stop(self) -> None:
|
||||
"""Stop the dispatcher loop."""
|
||||
self._running = False
|
||||
|
||||
|
||||
@property
|
||||
def inbound_size(self) -> int:
|
||||
"""Number of pending inbound messages."""
|
||||
return self.inbound.qsize()
|
||||
|
||||
|
||||
@property
|
||||
def outbound_size(self) -> int:
|
||||
"""Number of pending outbound messages."""
|
||||
|
||||
@@ -93,8 +93,7 @@ class BaseChannel(ABC):
|
||||
chat_id: str,
|
||||
content: str,
|
||||
media: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
session_key: str | None = None,
|
||||
metadata: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Handle an incoming message from the chat platform.
|
||||
@@ -107,13 +106,11 @@ class BaseChannel(ABC):
|
||||
content: Message text content.
|
||||
media: Optional list of media URLs.
|
||||
metadata: Optional channel-specific metadata.
|
||||
session_key: Optional session key override (e.g. thread-scoped sessions).
|
||||
"""
|
||||
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,
|
||||
f"Access denied for sender {sender_id} on channel {self.name}. "
|
||||
f"Add them to allowFrom list in config to grant access."
|
||||
)
|
||||
return
|
||||
|
||||
@@ -123,8 +120,7 @@ class BaseChannel(ABC):
|
||||
chat_id=str(chat_id),
|
||||
content=content,
|
||||
media=media or [],
|
||||
metadata=metadata or {},
|
||||
session_key_override=session_key,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
await self.bus.publish_inbound(msg)
|
||||
|
||||
@@ -58,15 +58,14 @@ class NanobotDingTalkHandler(CallbackHandler):
|
||||
|
||||
if not content:
|
||||
logger.warning(
|
||||
"Received empty or unsupported message type: {}",
|
||||
chatbot_msg.message_type,
|
||||
f"Received empty or unsupported message type: {chatbot_msg.message_type}"
|
||||
)
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
|
||||
sender_name = chatbot_msg.sender_nick or "Unknown"
|
||||
|
||||
logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content)
|
||||
logger.info(f"Received DingTalk message from {sender_name} ({sender_id}): {content}")
|
||||
|
||||
# Forward to Nanobot via _on_message (non-blocking).
|
||||
# Store reference to prevent GC before task completes.
|
||||
@@ -79,7 +78,7 @@ class NanobotDingTalkHandler(CallbackHandler):
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing DingTalk message: {}", e)
|
||||
logger.error(f"Error processing DingTalk message: {e}")
|
||||
# Return OK to avoid retry loop from DingTalk server
|
||||
return AckMessage.STATUS_OK, "Error"
|
||||
|
||||
@@ -127,8 +126,7 @@ class DingTalkChannel(BaseChannel):
|
||||
self._http = httpx.AsyncClient()
|
||||
|
||||
logger.info(
|
||||
"Initializing DingTalk Stream Client with Client ID: {}...",
|
||||
self.config.client_id,
|
||||
f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}..."
|
||||
)
|
||||
credential = Credential(self.config.client_id, self.config.client_secret)
|
||||
self._client = DingTalkStreamClient(credential)
|
||||
@@ -144,13 +142,13 @@ class DingTalkChannel(BaseChannel):
|
||||
try:
|
||||
await self._client.start()
|
||||
except Exception as e:
|
||||
logger.warning("DingTalk stream error: {}", e)
|
||||
logger.warning(f"DingTalk stream error: {e}")
|
||||
if self._running:
|
||||
logger.info("Reconnecting DingTalk stream in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to start DingTalk channel: {}", e)
|
||||
logger.exception(f"Failed to start DingTalk channel: {e}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the DingTalk bot."""
|
||||
@@ -188,7 +186,7 @@ class DingTalkChannel(BaseChannel):
|
||||
self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60
|
||||
return self._access_token
|
||||
except Exception as e:
|
||||
logger.error("Failed to get DingTalk access token: {}", e)
|
||||
logger.error(f"Failed to get DingTalk access token: {e}")
|
||||
return None
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
@@ -210,7 +208,7 @@ class DingTalkChannel(BaseChannel):
|
||||
"msgParam": json.dumps({
|
||||
"text": msg.content,
|
||||
"title": "Nanobot Reply",
|
||||
}, ensure_ascii=False),
|
||||
}),
|
||||
}
|
||||
|
||||
if not self._http:
|
||||
@@ -220,11 +218,11 @@ class DingTalkChannel(BaseChannel):
|
||||
try:
|
||||
resp = await self._http.post(url, json=data, headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logger.error("DingTalk send failed: {}", resp.text)
|
||||
logger.error(f"DingTalk send failed: {resp.text}")
|
||||
else:
|
||||
logger.debug("DingTalk message sent to {}", msg.chat_id)
|
||||
logger.debug(f"DingTalk message sent to {msg.chat_id}")
|
||||
except Exception as e:
|
||||
logger.error("Error sending DingTalk message: {}", e)
|
||||
logger.error(f"Error sending DingTalk message: {e}")
|
||||
|
||||
async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None:
|
||||
"""Handle incoming message (called by NanobotDingTalkHandler).
|
||||
@@ -233,7 +231,7 @@ class DingTalkChannel(BaseChannel):
|
||||
permission checks before publishing to the bus.
|
||||
"""
|
||||
try:
|
||||
logger.info("DingTalk inbound: {} from {}", content, sender_name)
|
||||
logger.info(f"DingTalk inbound: {content} from {sender_name}")
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=sender_id, # For private chat, chat_id == sender_id
|
||||
@@ -244,4 +242,4 @@ class DingTalkChannel(BaseChannel):
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error publishing DingTalk message: {}", e)
|
||||
logger.error(f"Error publishing DingTalk message: {e}")
|
||||
|
||||
+28
-68
@@ -17,29 +17,6 @@ from nanobot.config.schema import DiscordConfig
|
||||
|
||||
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
|
||||
MAX_MESSAGE_LEN = 2000 # Discord message character limit
|
||||
|
||||
|
||||
def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]:
|
||||
"""Split content into chunks within max_len, preferring line breaks."""
|
||||
if not content:
|
||||
return []
|
||||
if len(content) <= max_len:
|
||||
return [content]
|
||||
chunks: list[str] = []
|
||||
while content:
|
||||
if len(content) <= max_len:
|
||||
chunks.append(content)
|
||||
break
|
||||
cut = content[:max_len]
|
||||
pos = cut.rfind('\n')
|
||||
if pos <= 0:
|
||||
pos = cut.rfind(' ')
|
||||
if pos <= 0:
|
||||
pos = max_len
|
||||
chunks.append(content[:pos])
|
||||
content = content[pos:].lstrip()
|
||||
return chunks
|
||||
|
||||
|
||||
class DiscordChannel(BaseChannel):
|
||||
@@ -74,7 +51,7 @@ class DiscordChannel(BaseChannel):
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Discord gateway error: {}", e)
|
||||
logger.warning(f"Discord gateway error: {e}")
|
||||
if self._running:
|
||||
logger.info("Reconnecting to Discord gateway in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
@@ -102,48 +79,34 @@ class DiscordChannel(BaseChannel):
|
||||
return
|
||||
|
||||
url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages"
|
||||
payload: dict[str, Any] = {"content": msg.content}
|
||||
|
||||
if msg.reply_to:
|
||||
payload["message_reference"] = {"message_id": msg.reply_to}
|
||||
payload["allowed_mentions"] = {"replied_user": False}
|
||||
|
||||
headers = {"Authorization": f"Bot {self.config.token}"}
|
||||
|
||||
try:
|
||||
chunks = _split_message(msg.content or "")
|
||||
if not chunks:
|
||||
return
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
payload: dict[str, Any] = {"content": chunk}
|
||||
|
||||
# Only set reply reference on the first chunk
|
||||
if i == 0 and msg.reply_to:
|
||||
payload["message_reference"] = {"message_id": msg.reply_to}
|
||||
payload["allowed_mentions"] = {"replied_user": False}
|
||||
|
||||
if not await self._send_payload(url, headers, payload):
|
||||
break # Abort remaining chunks on failure
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = await self._http.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 429:
|
||||
data = response.json()
|
||||
retry_after = float(data.get("retry_after", 1.0))
|
||||
logger.warning(f"Discord rate limited, retrying in {retry_after}s")
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
logger.error(f"Error sending Discord message: {e}")
|
||||
else:
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
await self._stop_typing(msg.chat_id)
|
||||
|
||||
async def _send_payload(
|
||||
self, url: str, headers: dict[str, str], payload: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Send a single Discord API payload with retry on rate-limit. Returns True on success."""
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = await self._http.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 429:
|
||||
data = response.json()
|
||||
retry_after = float(data.get("retry_after", 1.0))
|
||||
logger.warning("Discord rate limited, retrying in {}s", retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
logger.error("Error sending Discord message: {}", e)
|
||||
else:
|
||||
await asyncio.sleep(1)
|
||||
return False
|
||||
|
||||
async def _gateway_loop(self) -> None:
|
||||
"""Main gateway loop: identify, heartbeat, dispatch events."""
|
||||
if not self._ws:
|
||||
@@ -153,7 +116,7 @@ class DiscordChannel(BaseChannel):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Invalid JSON from Discord gateway: {}", raw[:100])
|
||||
logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}")
|
||||
continue
|
||||
|
||||
op = data.get("op")
|
||||
@@ -212,7 +175,7 @@ class DiscordChannel(BaseChannel):
|
||||
try:
|
||||
await self._ws.send(json.dumps(payload))
|
||||
except Exception as e:
|
||||
logger.warning("Discord heartbeat failed: {}", e)
|
||||
logger.warning(f"Discord heartbeat failed: {e}")
|
||||
break
|
||||
await asyncio.sleep(interval_s)
|
||||
|
||||
@@ -256,7 +219,7 @@ class DiscordChannel(BaseChannel):
|
||||
media_paths.append(str(file_path))
|
||||
content_parts.append(f"[attachment: {file_path}]")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to download Discord attachment: {}", e)
|
||||
logger.warning(f"Failed to download Discord attachment: {e}")
|
||||
content_parts.append(f"[attachment: {filename} - download failed]")
|
||||
|
||||
reply_to = (payload.get("referenced_message") or {}).get("id")
|
||||
@@ -285,11 +248,8 @@ class DiscordChannel(BaseChannel):
|
||||
while self._running:
|
||||
try:
|
||||
await self._http.post(url, headers=headers)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug("Discord typing indicator failed for {}: {}", channel_id, e)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(8)
|
||||
|
||||
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
|
||||
|
||||
@@ -94,7 +94,7 @@ class EmailChannel(BaseChannel):
|
||||
metadata=item.get("metadata", {}),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Email polling error: {}", e)
|
||||
logger.error(f"Email polling error: {e}")
|
||||
|
||||
await asyncio.sleep(poll_seconds)
|
||||
|
||||
@@ -108,6 +108,11 @@ class EmailChannel(BaseChannel):
|
||||
logger.warning("Skip email send: consent_granted is false")
|
||||
return
|
||||
|
||||
force_send = bool((msg.metadata or {}).get("force_send"))
|
||||
if not self.config.auto_reply_enabled and not force_send:
|
||||
logger.info("Skip automatic email reply: auto_reply_enabled is false")
|
||||
return
|
||||
|
||||
if not self.config.smtp_host:
|
||||
logger.warning("Email channel SMTP host not configured")
|
||||
return
|
||||
@@ -117,15 +122,6 @@ class EmailChannel(BaseChannel):
|
||||
logger.warning("Email channel missing recipient address")
|
||||
return
|
||||
|
||||
# Determine if this is a reply (recipient has sent us an email before)
|
||||
is_reply = to_addr in self._last_subject_by_chat
|
||||
force_send = bool((msg.metadata or {}).get("force_send"))
|
||||
|
||||
# autoReplyEnabled only controls automatic replies, not proactive sends
|
||||
if is_reply and not self.config.auto_reply_enabled and not force_send:
|
||||
logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr)
|
||||
return
|
||||
|
||||
base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
|
||||
subject = self._reply_subject(base_subject)
|
||||
if msg.metadata and isinstance(msg.metadata.get("subject"), str):
|
||||
@@ -147,7 +143,7 @@ class EmailChannel(BaseChannel):
|
||||
try:
|
||||
await asyncio.to_thread(self._smtp_send, email_msg)
|
||||
except Exception as e:
|
||||
logger.error("Error sending email to {}: {}", to_addr, e)
|
||||
logger.error(f"Error sending email to {to_addr}: {e}")
|
||||
raise
|
||||
|
||||
def _validate_config(self) -> bool:
|
||||
@@ -166,7 +162,7 @@ class EmailChannel(BaseChannel):
|
||||
missing.append("smtp_password")
|
||||
|
||||
if missing:
|
||||
logger.error("Email channel not configured, missing: {}", ', '.join(missing))
|
||||
logger.error(f"Email channel not configured, missing: {', '.join(missing)}")
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -308,8 +304,7 @@ class EmailChannel(BaseChannel):
|
||||
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._processed_uids.clear()
|
||||
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
|
||||
+59
-469
@@ -2,11 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -19,17 +17,11 @@ from nanobot.config.schema import FeishuConfig
|
||||
try:
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest,
|
||||
CreateFileRequestBody,
|
||||
CreateImageRequest,
|
||||
CreateImageRequestBody,
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestBody,
|
||||
CreateMessageReactionRequest,
|
||||
CreateMessageReactionRequestBody,
|
||||
Emoji,
|
||||
GetFileRequest,
|
||||
GetMessageResourceRequest,
|
||||
P2ImMessageReceiveV1,
|
||||
)
|
||||
FEISHU_AVAILABLE = True
|
||||
@@ -47,204 +39,6 @@ MSG_TYPE_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _extract_share_card_content(content_json: dict, msg_type: str) -> str:
|
||||
"""Extract text representation from share cards and interactive messages."""
|
||||
parts = []
|
||||
|
||||
if msg_type == "share_chat":
|
||||
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
|
||||
elif msg_type == "share_user":
|
||||
parts.append(f"[shared user: {content_json.get('user_id', '')}]")
|
||||
elif msg_type == "interactive":
|
||||
parts.extend(_extract_interactive_content(content_json))
|
||||
elif msg_type == "share_calendar_event":
|
||||
parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]")
|
||||
elif msg_type == "system":
|
||||
parts.append("[system message]")
|
||||
elif msg_type == "merge_forward":
|
||||
parts.append("[merged forward messages]")
|
||||
|
||||
return "\n".join(parts) if parts else f"[{msg_type}]"
|
||||
|
||||
|
||||
def _extract_interactive_content(content: dict) -> list[str]:
|
||||
"""Recursively extract text and links from interactive card content."""
|
||||
parts = []
|
||||
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return [content] if content.strip() else []
|
||||
|
||||
if not isinstance(content, dict):
|
||||
return parts
|
||||
|
||||
if "title" in content:
|
||||
title = content["title"]
|
||||
if isinstance(title, dict):
|
||||
title_content = title.get("content", "") or title.get("text", "")
|
||||
if title_content:
|
||||
parts.append(f"title: {title_content}")
|
||||
elif isinstance(title, str):
|
||||
parts.append(f"title: {title}")
|
||||
|
||||
for element in content.get("elements", []) if isinstance(content.get("elements"), list) else []:
|
||||
parts.extend(_extract_element_content(element))
|
||||
|
||||
card = content.get("card", {})
|
||||
if card:
|
||||
parts.extend(_extract_interactive_content(card))
|
||||
|
||||
header = content.get("header", {})
|
||||
if header:
|
||||
header_title = header.get("title", {})
|
||||
if isinstance(header_title, dict):
|
||||
header_text = header_title.get("content", "") or header_title.get("text", "")
|
||||
if header_text:
|
||||
parts.append(f"title: {header_text}")
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _extract_element_content(element: dict) -> list[str]:
|
||||
"""Extract content from a single card element."""
|
||||
parts = []
|
||||
|
||||
if not isinstance(element, dict):
|
||||
return parts
|
||||
|
||||
tag = element.get("tag", "")
|
||||
|
||||
if tag in ("markdown", "lark_md"):
|
||||
content = element.get("content", "")
|
||||
if content:
|
||||
parts.append(content)
|
||||
|
||||
elif tag == "div":
|
||||
text = element.get("text", {})
|
||||
if isinstance(text, dict):
|
||||
text_content = text.get("content", "") or text.get("text", "")
|
||||
if text_content:
|
||||
parts.append(text_content)
|
||||
elif isinstance(text, str):
|
||||
parts.append(text)
|
||||
for field in element.get("fields", []):
|
||||
if isinstance(field, dict):
|
||||
field_text = field.get("text", {})
|
||||
if isinstance(field_text, dict):
|
||||
c = field_text.get("content", "")
|
||||
if c:
|
||||
parts.append(c)
|
||||
|
||||
elif tag == "a":
|
||||
href = element.get("href", "")
|
||||
text = element.get("text", "")
|
||||
if href:
|
||||
parts.append(f"link: {href}")
|
||||
if text:
|
||||
parts.append(text)
|
||||
|
||||
elif tag == "button":
|
||||
text = element.get("text", {})
|
||||
if isinstance(text, dict):
|
||||
c = text.get("content", "")
|
||||
if c:
|
||||
parts.append(c)
|
||||
url = element.get("url", "") or element.get("multi_url", {}).get("url", "")
|
||||
if url:
|
||||
parts.append(f"link: {url}")
|
||||
|
||||
elif tag == "img":
|
||||
alt = element.get("alt", {})
|
||||
parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]")
|
||||
|
||||
elif tag == "note":
|
||||
for ne in element.get("elements", []):
|
||||
parts.extend(_extract_element_content(ne))
|
||||
|
||||
elif tag == "column_set":
|
||||
for col in element.get("columns", []):
|
||||
for ce in col.get("elements", []):
|
||||
parts.extend(_extract_element_content(ce))
|
||||
|
||||
elif tag == "plain_text":
|
||||
content = element.get("content", "")
|
||||
if content:
|
||||
parts.append(content)
|
||||
|
||||
else:
|
||||
for ne in element.get("elements", []):
|
||||
parts.extend(_extract_element_content(ne))
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
|
||||
"""Extract text and image keys from Feishu post (rich text) message content.
|
||||
|
||||
Supports two formats:
|
||||
1. Direct format: {"title": "...", "content": [...]}
|
||||
2. Localized format: {"zh_cn": {"title": "...", "content": [...]}}
|
||||
|
||||
Returns:
|
||||
(text, image_keys) - extracted text and list of image keys
|
||||
"""
|
||||
def extract_from_lang(lang_content: dict) -> tuple[str | None, list[str]]:
|
||||
if not isinstance(lang_content, dict):
|
||||
return None, []
|
||||
title = lang_content.get("title", "")
|
||||
content_blocks = lang_content.get("content", [])
|
||||
if not isinstance(content_blocks, list):
|
||||
return None, []
|
||||
text_parts = []
|
||||
image_keys = []
|
||||
if title:
|
||||
text_parts.append(title)
|
||||
for block in content_blocks:
|
||||
if not isinstance(block, list):
|
||||
continue
|
||||
for element in block:
|
||||
if isinstance(element, dict):
|
||||
tag = element.get("tag")
|
||||
if tag == "text":
|
||||
text_parts.append(element.get("text", ""))
|
||||
elif tag == "a":
|
||||
text_parts.append(element.get("text", ""))
|
||||
elif tag == "at":
|
||||
text_parts.append(f"@{element.get('user_name', 'user')}")
|
||||
elif tag == "img":
|
||||
img_key = element.get("image_key")
|
||||
if img_key:
|
||||
image_keys.append(img_key)
|
||||
text = " ".join(text_parts).strip() if text_parts else None
|
||||
return text, image_keys
|
||||
|
||||
# Try direct format first
|
||||
if "content" in content_json:
|
||||
text, images = extract_from_lang(content_json)
|
||||
if text or images:
|
||||
return text or "", images
|
||||
|
||||
# Try localized format
|
||||
for lang_key in ("zh_cn", "en_us", "ja_jp"):
|
||||
lang_content = content_json.get(lang_key)
|
||||
text, images = extract_from_lang(lang_content)
|
||||
if text or images:
|
||||
return text or "", images
|
||||
|
||||
return "", []
|
||||
|
||||
|
||||
def _extract_post_text(content_json: dict) -> str:
|
||||
"""Extract plain text from Feishu post (rich text) message content.
|
||||
|
||||
Legacy wrapper for _extract_post_content, returns only text.
|
||||
"""
|
||||
text, _ = _extract_post_content(content_json)
|
||||
return text
|
||||
|
||||
|
||||
class FeishuChannel(BaseChannel):
|
||||
"""
|
||||
Feishu/Lark channel using WebSocket long connection.
|
||||
@@ -310,7 +104,7 @@ class FeishuChannel(BaseChannel):
|
||||
try:
|
||||
self._ws_client.start()
|
||||
except Exception as e:
|
||||
logger.warning("Feishu WebSocket error: {}", e)
|
||||
logger.warning(f"Feishu WebSocket error: {e}")
|
||||
if self._running:
|
||||
import time; time.sleep(5)
|
||||
|
||||
@@ -331,7 +125,7 @@ class FeishuChannel(BaseChannel):
|
||||
try:
|
||||
self._ws_client.stop()
|
||||
except Exception as e:
|
||||
logger.warning("Error stopping WebSocket client: {}", e)
|
||||
logger.warning(f"Error stopping WebSocket client: {e}")
|
||||
logger.info("Feishu bot stopped")
|
||||
|
||||
def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None:
|
||||
@@ -348,11 +142,11 @@ class FeishuChannel(BaseChannel):
|
||||
response = self._client.im.v1.message_reaction.create(request)
|
||||
|
||||
if not response.success():
|
||||
logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg)
|
||||
logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}")
|
||||
else:
|
||||
logger.debug("Added {} reaction to message {}", emoji_type, message_id)
|
||||
logger.debug(f"Added {emoji_type} reaction to message {message_id}")
|
||||
except Exception as e:
|
||||
logger.warning("Error adding reaction: {}", e)
|
||||
logger.warning(f"Error adding reaction: {e}")
|
||||
|
||||
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None:
|
||||
"""
|
||||
@@ -422,6 +216,7 @@ class FeishuChannel(BaseChannel):
|
||||
before = protected[last_end:m.start()].strip()
|
||||
if before:
|
||||
elements.append({"tag": "markdown", "content": before})
|
||||
level = len(m.group(1))
|
||||
text = m.group(2).strip()
|
||||
elements.append({
|
||||
"tag": "div",
|
||||
@@ -442,220 +237,50 @@ class FeishuChannel(BaseChannel):
|
||||
|
||||
return elements or [{"tag": "markdown", "content": content}]
|
||||
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
||||
_AUDIO_EXTS = {".opus"}
|
||||
_FILE_TYPE_MAP = {
|
||||
".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc",
|
||||
".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt",
|
||||
}
|
||||
|
||||
def _upload_image_sync(self, file_path: str) -> str | None:
|
||||
"""Upload an image to Feishu and return the image_key."""
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through Feishu."""
|
||||
if not self._client:
|
||||
logger.warning("Feishu client not initialized")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
request = CreateImageRequest.builder() \
|
||||
.request_body(
|
||||
CreateImageRequestBody.builder()
|
||||
.image_type("message")
|
||||
.image(f)
|
||||
.build()
|
||||
).build()
|
||||
response = self._client.im.v1.image.create(request)
|
||||
if response.success():
|
||||
image_key = response.data.image_key
|
||||
logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key)
|
||||
return image_key
|
||||
else:
|
||||
logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Error uploading image {}: {}", file_path, e)
|
||||
return None
|
||||
|
||||
def _upload_file_sync(self, file_path: str) -> str | None:
|
||||
"""Upload a file to Feishu and return the file_key."""
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
file_type = self._FILE_TYPE_MAP.get(ext, "stream")
|
||||
file_name = os.path.basename(file_path)
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
request = CreateFileRequest.builder() \
|
||||
.request_body(
|
||||
CreateFileRequestBody.builder()
|
||||
.file_type(file_type)
|
||||
.file_name(file_name)
|
||||
.file(f)
|
||||
.build()
|
||||
).build()
|
||||
response = self._client.im.v1.file.create(request)
|
||||
if response.success():
|
||||
file_key = response.data.file_key
|
||||
logger.debug("Uploaded file {}: {}", file_name, file_key)
|
||||
return file_key
|
||||
else:
|
||||
logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Error uploading file {}: {}", file_path, e)
|
||||
return None
|
||||
|
||||
def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]:
|
||||
"""Download an image from Feishu message by message_id and image_key."""
|
||||
try:
|
||||
request = GetMessageResourceRequest.builder() \
|
||||
.message_id(message_id) \
|
||||
.file_key(image_key) \
|
||||
.type("image") \
|
||||
.build()
|
||||
response = self._client.im.v1.message_resource.get(request)
|
||||
if response.success():
|
||||
file_data = response.file
|
||||
# GetMessageResourceRequest returns BytesIO, need to read bytes
|
||||
if hasattr(file_data, 'read'):
|
||||
file_data = file_data.read()
|
||||
return file_data, response.file_name
|
||||
# Determine receive_id_type based on chat_id format
|
||||
# open_id starts with "ou_", chat_id starts with "oc_"
|
||||
if msg.chat_id.startswith("oc_"):
|
||||
receive_id_type = "chat_id"
|
||||
else:
|
||||
logger.error("Failed to download image: code={}, msg={}", response.code, response.msg)
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error("Error downloading image {}: {}", image_key, e)
|
||||
return None, None
|
||||
|
||||
def _download_file_sync(
|
||||
self, message_id: str, file_key: str, resource_type: str = "file"
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
"""Download a file/audio/media from a Feishu message by message_id and file_key."""
|
||||
try:
|
||||
request = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(message_id)
|
||||
.file_key(file_key)
|
||||
.type(resource_type)
|
||||
.build()
|
||||
)
|
||||
response = self._client.im.v1.message_resource.get(request)
|
||||
if response.success():
|
||||
file_data = response.file
|
||||
if hasattr(file_data, "read"):
|
||||
file_data = file_data.read()
|
||||
return file_data, response.file_name
|
||||
else:
|
||||
logger.error("Failed to download {}: code={}, msg={}", resource_type, response.code, response.msg)
|
||||
return None, None
|
||||
except Exception:
|
||||
logger.exception("Error downloading {} {}", resource_type, file_key)
|
||||
return None, None
|
||||
|
||||
async def _download_and_save_media(
|
||||
self,
|
||||
msg_type: str,
|
||||
content_json: dict,
|
||||
message_id: str | None = None
|
||||
) -> tuple[str | None, str]:
|
||||
"""
|
||||
Download media from Feishu and save to local disk.
|
||||
|
||||
Returns:
|
||||
(file_path, content_text) - file_path is None if download failed
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
media_dir = Path.home() / ".nanobot" / "media"
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data, filename = None, None
|
||||
|
||||
if msg_type == "image":
|
||||
image_key = content_json.get("image_key")
|
||||
if image_key and message_id:
|
||||
data, filename = await loop.run_in_executor(
|
||||
None, self._download_image_sync, message_id, image_key
|
||||
)
|
||||
if not filename:
|
||||
filename = f"{image_key[:16]}.jpg"
|
||||
|
||||
elif msg_type in ("audio", "file", "media"):
|
||||
file_key = content_json.get("file_key")
|
||||
if file_key and message_id:
|
||||
data, filename = await loop.run_in_executor(
|
||||
None, self._download_file_sync, message_id, file_key, msg_type
|
||||
)
|
||||
if not filename:
|
||||
ext = {"audio": ".opus", "media": ".mp4"}.get(msg_type, "")
|
||||
filename = f"{file_key[:16]}{ext}"
|
||||
|
||||
if data and filename:
|
||||
file_path = media_dir / filename
|
||||
file_path.write_bytes(data)
|
||||
logger.debug("Downloaded {} to {}", msg_type, file_path)
|
||||
return str(file_path), f"[{msg_type}: {filename}]"
|
||||
|
||||
return None, f"[{msg_type}: download failed]"
|
||||
|
||||
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool:
|
||||
"""Send a single message (text/image/file/interactive) synchronously."""
|
||||
try:
|
||||
receive_id_type = "open_id"
|
||||
|
||||
# Build card with markdown + table support
|
||||
elements = self._build_card_elements(msg.content)
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"elements": elements,
|
||||
}
|
||||
content = json.dumps(card, ensure_ascii=False)
|
||||
|
||||
request = CreateMessageRequest.builder() \
|
||||
.receive_id_type(receive_id_type) \
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(receive_id)
|
||||
.msg_type(msg_type)
|
||||
.receive_id(msg.chat_id)
|
||||
.msg_type("interactive")
|
||||
.content(content)
|
||||
.build()
|
||||
).build()
|
||||
|
||||
response = self._client.im.v1.message.create(request)
|
||||
|
||||
if not response.success():
|
||||
logger.error(
|
||||
"Failed to send Feishu {} message: code={}, msg={}, log_id={}",
|
||||
msg_type, response.code, response.msg, response.get_log_id()
|
||||
f"Failed to send Feishu message: code={response.code}, "
|
||||
f"msg={response.msg}, log_id={response.get_log_id()}"
|
||||
)
|
||||
return False
|
||||
logger.debug("Feishu {} message sent to {}", msg_type, receive_id)
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"Feishu message sent to {msg.chat_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending Feishu {} message: {}", msg_type, e)
|
||||
return False
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through Feishu, including media (images/files) if present."""
|
||||
if not self._client:
|
||||
logger.warning("Feishu client not initialized")
|
||||
return
|
||||
|
||||
try:
|
||||
receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
for file_path in msg.media:
|
||||
if not os.path.isfile(file_path):
|
||||
logger.warning("Media file not found: {}", file_path)
|
||||
continue
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in self._IMAGE_EXTS:
|
||||
key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
|
||||
if key:
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}, ensure_ascii=False),
|
||||
)
|
||||
else:
|
||||
key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
|
||||
if key:
|
||||
media_type = "audio" if ext in self._AUDIO_EXTS else "file"
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False),
|
||||
)
|
||||
|
||||
if msg.content and msg.content.strip():
|
||||
card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)}
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending Feishu message: {}", e)
|
||||
logger.error(f"Error sending Feishu message: {e}")
|
||||
|
||||
def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None:
|
||||
"""
|
||||
@@ -671,89 +296,54 @@ class FeishuChannel(BaseChannel):
|
||||
event = data.event
|
||||
message = event.message
|
||||
sender = event.sender
|
||||
|
||||
|
||||
# Deduplication check
|
||||
message_id = message.message_id
|
||||
if message_id in self._processed_message_ids:
|
||||
return
|
||||
self._processed_message_ids[message_id] = None
|
||||
|
||||
# Trim cache
|
||||
|
||||
# Trim cache: keep most recent 500 when exceeds 1000
|
||||
while len(self._processed_message_ids) > 1000:
|
||||
self._processed_message_ids.popitem(last=False)
|
||||
|
||||
|
||||
# Skip bot messages
|
||||
if sender.sender_type == "bot":
|
||||
sender_type = sender.sender_type
|
||||
if sender_type == "bot":
|
||||
return
|
||||
|
||||
|
||||
sender_id = sender.sender_id.open_id if sender.sender_id else "unknown"
|
||||
chat_id = message.chat_id
|
||||
chat_type = message.chat_type
|
||||
chat_type = message.chat_type # "p2p" or "group"
|
||||
msg_type = message.message_type
|
||||
|
||||
# Add reaction
|
||||
await self._add_reaction(message_id, self.config.react_emoji)
|
||||
|
||||
# Parse content
|
||||
content_parts = []
|
||||
media_paths = []
|
||||
|
||||
try:
|
||||
content_json = json.loads(message.content) if message.content else {}
|
||||
except json.JSONDecodeError:
|
||||
content_json = {}
|
||||
|
||||
|
||||
# Add reaction to indicate "seen"
|
||||
await self._add_reaction(message_id, "THUMBSUP")
|
||||
|
||||
# Parse message content
|
||||
if msg_type == "text":
|
||||
text = content_json.get("text", "")
|
||||
if text:
|
||||
content_parts.append(text)
|
||||
|
||||
elif msg_type == "post":
|
||||
text, image_keys = _extract_post_content(content_json)
|
||||
if text:
|
||||
content_parts.append(text)
|
||||
# Download images embedded in post
|
||||
for img_key in image_keys:
|
||||
file_path, content_text = await self._download_and_save_media(
|
||||
"image", {"image_key": img_key}, message_id
|
||||
)
|
||||
if file_path:
|
||||
media_paths.append(file_path)
|
||||
content_parts.append(content_text)
|
||||
|
||||
elif msg_type in ("image", "audio", "file", "media"):
|
||||
file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id)
|
||||
if file_path:
|
||||
media_paths.append(file_path)
|
||||
content_parts.append(content_text)
|
||||
|
||||
elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"):
|
||||
# Handle share cards and interactive messages
|
||||
text = _extract_share_card_content(content_json, msg_type)
|
||||
if text:
|
||||
content_parts.append(text)
|
||||
|
||||
try:
|
||||
content = json.loads(message.content).get("text", "")
|
||||
except json.JSONDecodeError:
|
||||
content = message.content or ""
|
||||
else:
|
||||
content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
|
||||
|
||||
content = "\n".join(content_parts) if content_parts else ""
|
||||
|
||||
if not content and not media_paths:
|
||||
content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")
|
||||
|
||||
if not content:
|
||||
return
|
||||
|
||||
|
||||
# Forward to message bus
|
||||
reply_to = chat_id if chat_type == "group" else sender_id
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=reply_to,
|
||||
content=content,
|
||||
media=media_paths,
|
||||
metadata={
|
||||
"message_id": message_id,
|
||||
"chat_type": chat_type,
|
||||
"msg_type": msg_type,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing Feishu message: {}", e)
|
||||
logger.error(f"Error processing Feishu message: {e}")
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Hook channel — receives outbound messages from hook-initiated conversations."""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
|
||||
class HookChannel:
|
||||
"""
|
||||
Minimal channel for hook-initiated conversations.
|
||||
|
||||
The hook HTTP server publishes InboundMessages to the bus.
|
||||
Responses come back as OutboundMessages routed here.
|
||||
send() is a no-op because the HTTP caller gets the response
|
||||
via bus correlation, not channel delivery.
|
||||
"""
|
||||
|
||||
name = "hook"
|
||||
|
||||
def __init__(self, bus: MessageBus):
|
||||
self.bus = bus
|
||||
self._running = False
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
logger.info("Hook channel started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""No-op — response is returned via bus correlation to the HTTP caller."""
|
||||
logger.debug(f"Hook channel received outbound for {msg.chat_id} (no-op)")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
+16
-42
@@ -45,7 +45,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Telegram channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("Telegram channel not available: {}", e)
|
||||
logger.warning(f"Telegram channel not available: {e}")
|
||||
|
||||
# WhatsApp channel
|
||||
if self.config.channels.whatsapp.enabled:
|
||||
@@ -56,7 +56,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("WhatsApp channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("WhatsApp channel not available: {}", e)
|
||||
logger.warning(f"WhatsApp channel not available: {e}")
|
||||
|
||||
# Discord channel
|
||||
if self.config.channels.discord.enabled:
|
||||
@@ -67,7 +67,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Discord channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("Discord channel not available: {}", e)
|
||||
logger.warning(f"Discord channel not available: {e}")
|
||||
|
||||
# Feishu channel
|
||||
if self.config.channels.feishu.enabled:
|
||||
@@ -78,7 +78,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Feishu channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("Feishu channel not available: {}", e)
|
||||
logger.warning(f"Feishu channel not available: {e}")
|
||||
|
||||
# Mochat channel
|
||||
if self.config.channels.mochat.enabled:
|
||||
@@ -90,7 +90,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Mochat channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("Mochat channel not available: {}", e)
|
||||
logger.warning(f"Mochat channel not available: {e}")
|
||||
|
||||
# DingTalk channel
|
||||
if self.config.channels.dingtalk.enabled:
|
||||
@@ -101,7 +101,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("DingTalk channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("DingTalk channel not available: {}", e)
|
||||
logger.warning(f"DingTalk channel not available: {e}")
|
||||
|
||||
# Email channel
|
||||
if self.config.channels.email.enabled:
|
||||
@@ -112,7 +112,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Email channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("Email channel not available: {}", e)
|
||||
logger.warning(f"Email channel not available: {e}")
|
||||
|
||||
# Slack channel
|
||||
if self.config.channels.slack.enabled:
|
||||
@@ -123,7 +123,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Slack channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("Slack channel not available: {}", e)
|
||||
logger.warning(f"Slack channel not available: {e}")
|
||||
|
||||
# QQ channel
|
||||
if self.config.channels.qq.enabled:
|
||||
@@ -135,31 +135,14 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("QQ channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("QQ channel not available: {}", e)
|
||||
|
||||
# Matrix channel
|
||||
if self.config.channels.matrix.enabled:
|
||||
try:
|
||||
from nanobot.channels.matrix import MatrixChannel
|
||||
self.channels["matrix"] = MatrixChannel(
|
||||
self.config.channels.matrix,
|
||||
self.bus,
|
||||
)
|
||||
logger.info("Matrix channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("Matrix channel not available: {}", e)
|
||||
logger.warning(f"QQ channel not available: {e}")
|
||||
|
||||
def register_channel(self, name: str, channel: BaseChannel) -> None:
|
||||
"""Register an external channel."""
|
||||
self.channels[name] = channel
|
||||
logger.info(f"{name} channel registered")
|
||||
|
||||
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
|
||||
"""Start a channel and log any exceptions."""
|
||||
try:
|
||||
await channel.start()
|
||||
except Exception as e:
|
||||
logger.error("Failed to start channel {}: {}", name, e)
|
||||
logger.error(f"Failed to start channel {name}: {e}")
|
||||
|
||||
async def start_all(self) -> None:
|
||||
"""Start all channels and the outbound dispatcher."""
|
||||
@@ -173,7 +156,7 @@ class ChannelManager:
|
||||
# Start channels
|
||||
tasks = []
|
||||
for name, channel in self.channels.items():
|
||||
logger.info("Starting {} channel...", name)
|
||||
logger.info(f"Starting {name} channel...")
|
||||
tasks.append(asyncio.create_task(self._start_channel(name, channel)))
|
||||
|
||||
# Wait for all to complete (they should run forever)
|
||||
@@ -195,9 +178,9 @@ class ChannelManager:
|
||||
for name, channel in self.channels.items():
|
||||
try:
|
||||
await channel.stop()
|
||||
logger.info("Stopped {} channel", name)
|
||||
logger.info(f"Stopped {name} channel")
|
||||
except Exception as e:
|
||||
logger.error("Error stopping {}: {}", name, e)
|
||||
logger.error(f"Error stopping {name}: {e}")
|
||||
|
||||
async def _dispatch_outbound(self) -> None:
|
||||
"""Dispatch outbound messages to the appropriate channel."""
|
||||
@@ -209,24 +192,15 @@ class ChannelManager:
|
||||
self.bus.consume_outbound(),
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
# Resolve any pending correlation (hook request-response)
|
||||
self.bus.resolve_correlation(msg)
|
||||
|
||||
if msg.metadata.get("_progress"):
|
||||
if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints:
|
||||
continue
|
||||
if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress:
|
||||
continue
|
||||
|
||||
|
||||
channel = self.channels.get(msg.channel)
|
||||
if channel:
|
||||
try:
|
||||
await channel.send(msg)
|
||||
except Exception as e:
|
||||
logger.error("Error sending to {}: {}", msg.channel, e)
|
||||
logger.error(f"Error sending to {msg.channel}: {e}")
|
||||
else:
|
||||
logger.warning("Unknown channel: {}", msg.channel)
|
||||
logger.warning(f"Unknown channel: {msg.channel}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
@@ -1,682 +0,0 @@
|
||||
"""Matrix (Element) channel — inbound sync + outbound message/media delivery."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
import nh3
|
||||
from mistune import create_markdown
|
||||
from nio import (
|
||||
AsyncClient, AsyncClientConfig, ContentRepositoryConfigError,
|
||||
DownloadError, InviteEvent, JoinError, MatrixRoom, MemoryDownloadResponse,
|
||||
RoomEncryptedMedia, RoomMessage, RoomMessageMedia, RoomMessageText,
|
||||
RoomSendError, RoomTypingError, SyncError, UploadError,
|
||||
)
|
||||
from nio.crypto.attachments import decrypt_attachment
|
||||
from nio.exceptions import EncryptionError
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Matrix dependencies not installed. Run: pip install nanobot-ai[matrix]"
|
||||
) from e
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
|
||||
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
||||
# Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing.
|
||||
TYPING_KEEPALIVE_INTERVAL_MS = 20_000
|
||||
MATRIX_HTML_FORMAT = "org.matrix.custom.html"
|
||||
_ATTACH_MARKER = "[attachment: {}]"
|
||||
_ATTACH_TOO_LARGE = "[attachment: {} - too large]"
|
||||
_ATTACH_FAILED = "[attachment: {} - download failed]"
|
||||
_ATTACH_UPLOAD_FAILED = "[attachment: {} - upload failed]"
|
||||
_DEFAULT_ATTACH_NAME = "attachment"
|
||||
_MSGTYPE_MAP = {"m.image": "image", "m.audio": "audio", "m.video": "video", "m.file": "file"}
|
||||
|
||||
MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia)
|
||||
MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia
|
||||
|
||||
MATRIX_MARKDOWN = create_markdown(
|
||||
escape=True,
|
||||
plugins=["table", "strikethrough", "url", "superscript", "subscript"],
|
||||
)
|
||||
|
||||
MATRIX_ALLOWED_HTML_TAGS = {
|
||||
"p", "a", "strong", "em", "del", "code", "pre", "blockquote",
|
||||
"ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"hr", "br", "table", "thead", "tbody", "tr", "th", "td",
|
||||
"caption", "sup", "sub", "img",
|
||||
}
|
||||
MATRIX_ALLOWED_HTML_ATTRIBUTES: dict[str, set[str]] = {
|
||||
"a": {"href"}, "code": {"class"}, "ol": {"start"},
|
||||
"img": {"src", "alt", "title", "width", "height"},
|
||||
}
|
||||
MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"}
|
||||
|
||||
|
||||
def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None:
|
||||
"""Filter attribute values to a safe Matrix-compatible subset."""
|
||||
if tag == "a" and attr == "href":
|
||||
return value if value.lower().startswith(("https://", "http://", "matrix:", "mailto:")) else None
|
||||
if tag == "img" and attr == "src":
|
||||
return value if value.lower().startswith("mxc://") else None
|
||||
if tag == "code" and attr == "class":
|
||||
classes = [c for c in value.split() if c.startswith("language-") and not c.startswith("language-_")]
|
||||
return " ".join(classes) if classes else None
|
||||
return value
|
||||
|
||||
|
||||
MATRIX_HTML_CLEANER = nh3.Cleaner(
|
||||
tags=MATRIX_ALLOWED_HTML_TAGS,
|
||||
attributes=MATRIX_ALLOWED_HTML_ATTRIBUTES,
|
||||
attribute_filter=_filter_matrix_html_attribute,
|
||||
url_schemes=MATRIX_ALLOWED_URL_SCHEMES,
|
||||
strip_comments=True,
|
||||
link_rel="noopener noreferrer",
|
||||
)
|
||||
|
||||
|
||||
def _render_markdown_html(text: str) -> str | None:
|
||||
"""Render markdown to sanitized HTML; returns None for plain text."""
|
||||
try:
|
||||
formatted = MATRIX_HTML_CLEANER.clean(MATRIX_MARKDOWN(text)).strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not formatted:
|
||||
return None
|
||||
# Skip formatted_body for plain <p>text</p> to keep payload minimal.
|
||||
if formatted.startswith("<p>") and formatted.endswith("</p>"):
|
||||
inner = formatted[3:-4]
|
||||
if "<" not in inner and ">" not in inner:
|
||||
return None
|
||||
return formatted
|
||||
|
||||
|
||||
def _build_matrix_text_content(text: str) -> dict[str, object]:
|
||||
"""Build Matrix m.text payload with optional HTML formatted_body."""
|
||||
content: dict[str, object] = {"msgtype": "m.text", "body": text, "m.mentions": {}}
|
||||
if html := _render_markdown_html(text):
|
||||
content["format"] = MATRIX_HTML_FORMAT
|
||||
content["formatted_body"] = html
|
||||
return content
|
||||
|
||||
|
||||
class _NioLoguruHandler(logging.Handler):
|
||||
"""Route matrix-nio stdlib logs into Loguru."""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
frame, depth = logging.currentframe(), 2
|
||||
while frame and frame.f_code.co_filename == logging.__file__:
|
||||
frame, depth = frame.f_back, depth + 1
|
||||
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
def _configure_nio_logging_bridge() -> None:
|
||||
"""Bridge matrix-nio logs to Loguru (idempotent)."""
|
||||
nio_logger = logging.getLogger("nio")
|
||||
if not any(isinstance(h, _NioLoguruHandler) for h in nio_logger.handlers):
|
||||
nio_logger.handlers = [_NioLoguruHandler()]
|
||||
nio_logger.propagate = False
|
||||
|
||||
|
||||
class MatrixChannel(BaseChannel):
|
||||
"""Matrix (Element) channel using long-polling sync."""
|
||||
|
||||
name = "matrix"
|
||||
|
||||
def __init__(self, config: Any, bus, *, restrict_to_workspace: bool = False,
|
||||
workspace: Path | None = None):
|
||||
super().__init__(config, bus)
|
||||
self.client: AsyncClient | None = None
|
||||
self._sync_task: asyncio.Task | None = None
|
||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
||||
self._restrict_to_workspace = restrict_to_workspace
|
||||
self._workspace = workspace.expanduser().resolve() if workspace else None
|
||||
self._server_upload_limit_bytes: int | None = None
|
||||
self._server_upload_limit_checked = False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start Matrix client and begin sync loop."""
|
||||
self._running = True
|
||||
_configure_nio_logging_bridge()
|
||||
|
||||
store_path = get_data_dir() / "matrix-store"
|
||||
store_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.client = AsyncClient(
|
||||
homeserver=self.config.homeserver, user=self.config.user_id,
|
||||
store_path=store_path,
|
||||
config=AsyncClientConfig(store_sync_tokens=True, encryption_enabled=self.config.e2ee_enabled),
|
||||
)
|
||||
self.client.user_id = self.config.user_id
|
||||
self.client.access_token = self.config.access_token
|
||||
self.client.device_id = self.config.device_id
|
||||
|
||||
self._register_event_callbacks()
|
||||
self._register_response_callbacks()
|
||||
|
||||
if not self.config.e2ee_enabled:
|
||||
logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.")
|
||||
|
||||
if self.config.device_id:
|
||||
try:
|
||||
self.client.load_store()
|
||||
except Exception:
|
||||
logger.exception("Matrix store load failed; restart may replay recent messages.")
|
||||
else:
|
||||
logger.warning("Matrix device_id empty; restart may replay recent messages.")
|
||||
|
||||
self._sync_task = asyncio.create_task(self._sync_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the Matrix channel with graceful sync shutdown."""
|
||||
self._running = False
|
||||
for room_id in list(self._typing_tasks):
|
||||
await self._stop_typing_keepalive(room_id, clear_typing=False)
|
||||
if self.client:
|
||||
self.client.stop_sync_forever()
|
||||
if self._sync_task:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(self._sync_task),
|
||||
timeout=self.config.sync_stop_grace_seconds)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
self._sync_task.cancel()
|
||||
try:
|
||||
await self._sync_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
|
||||
def _is_workspace_path_allowed(self, path: Path) -> bool:
|
||||
"""Check path is inside workspace (when restriction enabled)."""
|
||||
if not self._restrict_to_workspace or not self._workspace:
|
||||
return True
|
||||
try:
|
||||
path.resolve(strict=False).relative_to(self._workspace)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]:
|
||||
"""Deduplicate and resolve outbound attachment paths."""
|
||||
seen: set[str] = set()
|
||||
candidates: list[Path] = []
|
||||
for raw in media:
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
continue
|
||||
path = Path(raw.strip()).expanduser()
|
||||
try:
|
||||
key = str(path.resolve(strict=False))
|
||||
except OSError:
|
||||
key = str(path)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
candidates.append(path)
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _build_outbound_attachment_content(
|
||||
*, filename: str, mime: str, size_bytes: int,
|
||||
mxc_url: str, encryption_info: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build Matrix content payload for an uploaded file/image/audio/video."""
|
||||
prefix = mime.split("/")[0]
|
||||
msgtype = {"image": "m.image", "audio": "m.audio", "video": "m.video"}.get(prefix, "m.file")
|
||||
content: dict[str, Any] = {
|
||||
"msgtype": msgtype, "body": filename, "filename": filename,
|
||||
"info": {"mimetype": mime, "size": size_bytes}, "m.mentions": {},
|
||||
}
|
||||
if encryption_info:
|
||||
content["file"] = {**encryption_info, "url": mxc_url}
|
||||
else:
|
||||
content["url"] = mxc_url
|
||||
return content
|
||||
|
||||
def _is_encrypted_room(self, room_id: str) -> bool:
|
||||
if not self.client:
|
||||
return False
|
||||
room = getattr(self.client, "rooms", {}).get(room_id)
|
||||
return bool(getattr(room, "encrypted", False))
|
||||
|
||||
async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None:
|
||||
"""Send m.room.message with E2EE options."""
|
||||
if not self.client:
|
||||
return
|
||||
kwargs: dict[str, Any] = {"room_id": room_id, "message_type": "m.room.message", "content": content}
|
||||
if self.config.e2ee_enabled:
|
||||
kwargs["ignore_unverified_devices"] = True
|
||||
await self.client.room_send(**kwargs)
|
||||
|
||||
async def _resolve_server_upload_limit_bytes(self) -> int | None:
|
||||
"""Query homeserver upload limit once per channel lifecycle."""
|
||||
if self._server_upload_limit_checked:
|
||||
return self._server_upload_limit_bytes
|
||||
self._server_upload_limit_checked = True
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
response = await self.client.content_repository_config()
|
||||
except Exception:
|
||||
return None
|
||||
upload_size = getattr(response, "upload_size", None)
|
||||
if isinstance(upload_size, int) and upload_size > 0:
|
||||
self._server_upload_limit_bytes = upload_size
|
||||
return upload_size
|
||||
return None
|
||||
|
||||
async def _effective_media_limit_bytes(self) -> int:
|
||||
"""min(local config, server advertised) — 0 blocks all uploads."""
|
||||
local_limit = max(int(self.config.max_media_bytes), 0)
|
||||
server_limit = await self._resolve_server_upload_limit_bytes()
|
||||
if server_limit is None:
|
||||
return local_limit
|
||||
return min(local_limit, server_limit) if local_limit else 0
|
||||
|
||||
async def _upload_and_send_attachment(
|
||||
self, room_id: str, path: Path, limit_bytes: int,
|
||||
relates_to: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Upload one local file to Matrix and send it as a media message. Returns failure marker or None."""
|
||||
if not self.client:
|
||||
return _ATTACH_UPLOAD_FAILED.format(path.name or _DEFAULT_ATTACH_NAME)
|
||||
|
||||
resolved = path.expanduser().resolve(strict=False)
|
||||
filename = safe_filename(resolved.name) or _DEFAULT_ATTACH_NAME
|
||||
fail = _ATTACH_UPLOAD_FAILED.format(filename)
|
||||
|
||||
if not resolved.is_file() or not self._is_workspace_path_allowed(resolved):
|
||||
return fail
|
||||
try:
|
||||
size_bytes = resolved.stat().st_size
|
||||
except OSError:
|
||||
return fail
|
||||
if limit_bytes <= 0 or size_bytes > limit_bytes:
|
||||
return _ATTACH_TOO_LARGE.format(filename)
|
||||
|
||||
mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream"
|
||||
try:
|
||||
with resolved.open("rb") as f:
|
||||
upload_result = await self.client.upload(
|
||||
f, content_type=mime, filename=filename,
|
||||
encrypt=self.config.e2ee_enabled and self._is_encrypted_room(room_id),
|
||||
filesize=size_bytes,
|
||||
)
|
||||
except Exception:
|
||||
return fail
|
||||
|
||||
upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result
|
||||
encryption_info = upload_result[1] if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict) else None
|
||||
if isinstance(upload_response, UploadError):
|
||||
return fail
|
||||
mxc_url = getattr(upload_response, "content_uri", None)
|
||||
if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"):
|
||||
return fail
|
||||
|
||||
content = self._build_outbound_attachment_content(
|
||||
filename=filename, mime=mime, size_bytes=size_bytes,
|
||||
mxc_url=mxc_url, encryption_info=encryption_info,
|
||||
)
|
||||
if relates_to:
|
||||
content["m.relates_to"] = relates_to
|
||||
try:
|
||||
await self._send_room_content(room_id, content)
|
||||
except Exception:
|
||||
return fail
|
||||
return None
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send outbound content; clear typing for non-progress messages."""
|
||||
if not self.client:
|
||||
return
|
||||
text = msg.content or ""
|
||||
candidates = self._collect_outbound_media_candidates(msg.media)
|
||||
relates_to = self._build_thread_relates_to(msg.metadata)
|
||||
is_progress = bool((msg.metadata or {}).get("_progress"))
|
||||
try:
|
||||
failures: list[str] = []
|
||||
if candidates:
|
||||
limit_bytes = await self._effective_media_limit_bytes()
|
||||
for path in candidates:
|
||||
if fail := await self._upload_and_send_attachment(
|
||||
msg.chat_id, path, limit_bytes, relates_to):
|
||||
failures.append(fail)
|
||||
if failures:
|
||||
text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures)
|
||||
if text or not candidates:
|
||||
content = _build_matrix_text_content(text)
|
||||
if relates_to:
|
||||
content["m.relates_to"] = relates_to
|
||||
await self._send_room_content(msg.chat_id, content)
|
||||
finally:
|
||||
if not is_progress:
|
||||
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
|
||||
|
||||
def _register_event_callbacks(self) -> None:
|
||||
self.client.add_event_callback(self._on_message, RoomMessageText)
|
||||
self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
|
||||
self.client.add_event_callback(self._on_room_invite, InviteEvent)
|
||||
|
||||
def _register_response_callbacks(self) -> None:
|
||||
self.client.add_response_callback(self._on_sync_error, SyncError)
|
||||
self.client.add_response_callback(self._on_join_error, JoinError)
|
||||
self.client.add_response_callback(self._on_send_error, RoomSendError)
|
||||
|
||||
def _log_response_error(self, label: str, response: Any) -> None:
|
||||
"""Log Matrix response errors — auth errors at ERROR level, rest at WARNING."""
|
||||
code = getattr(response, "status_code", None)
|
||||
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
|
||||
is_fatal = is_auth or getattr(response, "soft_logout", False)
|
||||
(logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response)
|
||||
|
||||
async def _on_sync_error(self, response: SyncError) -> None:
|
||||
self._log_response_error("sync", response)
|
||||
|
||||
async def _on_join_error(self, response: JoinError) -> None:
|
||||
self._log_response_error("join", response)
|
||||
|
||||
async def _on_send_error(self, response: RoomSendError) -> None:
|
||||
self._log_response_error("send", response)
|
||||
|
||||
async def _set_typing(self, room_id: str, typing: bool) -> None:
|
||||
"""Best-effort typing indicator update."""
|
||||
if not self.client:
|
||||
return
|
||||
try:
|
||||
response = await self.client.room_typing(room_id=room_id, typing_state=typing,
|
||||
timeout=TYPING_NOTICE_TIMEOUT_MS)
|
||||
if isinstance(response, RoomTypingError):
|
||||
logger.debug("Matrix typing failed for {}: {}", room_id, response)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _start_typing_keepalive(self, room_id: str) -> None:
|
||||
"""Start periodic typing refresh (spec-recommended keepalive)."""
|
||||
await self._stop_typing_keepalive(room_id, clear_typing=False)
|
||||
await self._set_typing(room_id, True)
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
async def loop() -> None:
|
||||
try:
|
||||
while self._running:
|
||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000)
|
||||
await self._set_typing(room_id, True)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._typing_tasks[room_id] = asyncio.create_task(loop())
|
||||
|
||||
async def _stop_typing_keepalive(self, room_id: str, *, clear_typing: bool) -> None:
|
||||
if task := self._typing_tasks.pop(room_id, None):
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if clear_typing:
|
||||
await self._set_typing(room_id, False)
|
||||
|
||||
async def _sync_loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
await self.client.sync_forever(timeout=30000, full_state=True)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
||||
allow_from = self.config.allow_from or []
|
||||
if not allow_from or event.sender in allow_from:
|
||||
await self.client.join(room.room_id)
|
||||
|
||||
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
||||
count = getattr(room, "member_count", None)
|
||||
return isinstance(count, int) and count <= 2
|
||||
|
||||
def _is_bot_mentioned(self, event: RoomMessage) -> bool:
|
||||
"""Check m.mentions payload for bot mention."""
|
||||
source = getattr(event, "source", None)
|
||||
if not isinstance(source, dict):
|
||||
return False
|
||||
mentions = (source.get("content") or {}).get("m.mentions")
|
||||
if not isinstance(mentions, dict):
|
||||
return False
|
||||
user_ids = mentions.get("user_ids")
|
||||
if isinstance(user_ids, list) and self.config.user_id in user_ids:
|
||||
return True
|
||||
return bool(self.config.allow_room_mentions and mentions.get("room") is True)
|
||||
|
||||
def _should_process_message(self, room: MatrixRoom, event: RoomMessage) -> bool:
|
||||
"""Apply sender and room policy checks."""
|
||||
if not self.is_allowed(event.sender):
|
||||
return False
|
||||
if self._is_direct_room(room):
|
||||
return True
|
||||
policy = self.config.group_policy
|
||||
if policy == "open":
|
||||
return True
|
||||
if policy == "allowlist":
|
||||
return room.room_id in (self.config.group_allow_from or [])
|
||||
if policy == "mention":
|
||||
return self._is_bot_mentioned(event)
|
||||
return False
|
||||
|
||||
def _media_dir(self) -> Path:
|
||||
d = get_data_dir() / "media" / "matrix"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def _event_source_content(event: RoomMessage) -> dict[str, Any]:
|
||||
source = getattr(event, "source", None)
|
||||
if not isinstance(source, dict):
|
||||
return {}
|
||||
content = source.get("content")
|
||||
return content if isinstance(content, dict) else {}
|
||||
|
||||
def _event_thread_root_id(self, event: RoomMessage) -> str | None:
|
||||
relates_to = self._event_source_content(event).get("m.relates_to")
|
||||
if not isinstance(relates_to, dict) or relates_to.get("rel_type") != "m.thread":
|
||||
return None
|
||||
root_id = relates_to.get("event_id")
|
||||
return root_id if isinstance(root_id, str) and root_id else None
|
||||
|
||||
def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None:
|
||||
if not (root_id := self._event_thread_root_id(event)):
|
||||
return None
|
||||
meta: dict[str, str] = {"thread_root_event_id": root_id}
|
||||
if isinstance(reply_to := getattr(event, "event_id", None), str) and reply_to:
|
||||
meta["thread_reply_to_event_id"] = reply_to
|
||||
return meta
|
||||
|
||||
@staticmethod
|
||||
def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not metadata:
|
||||
return None
|
||||
root_id = metadata.get("thread_root_event_id")
|
||||
if not isinstance(root_id, str) or not root_id:
|
||||
return None
|
||||
reply_to = metadata.get("thread_reply_to_event_id") or metadata.get("event_id")
|
||||
if not isinstance(reply_to, str) or not reply_to:
|
||||
return None
|
||||
return {"rel_type": "m.thread", "event_id": root_id,
|
||||
"m.in_reply_to": {"event_id": reply_to}, "is_falling_back": True}
|
||||
|
||||
def _event_attachment_type(self, event: MatrixMediaEvent) -> str:
|
||||
msgtype = self._event_source_content(event).get("msgtype")
|
||||
return _MSGTYPE_MAP.get(msgtype, "file")
|
||||
|
||||
@staticmethod
|
||||
def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool:
|
||||
return (isinstance(getattr(event, "key", None), dict)
|
||||
and isinstance(getattr(event, "hashes", None), dict)
|
||||
and isinstance(getattr(event, "iv", None), str))
|
||||
|
||||
def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None:
|
||||
info = self._event_source_content(event).get("info")
|
||||
size = info.get("size") if isinstance(info, dict) else None
|
||||
return size if isinstance(size, int) and size >= 0 else None
|
||||
|
||||
def _event_mime(self, event: MatrixMediaEvent) -> str | None:
|
||||
info = self._event_source_content(event).get("info")
|
||||
if isinstance(info, dict) and isinstance(m := info.get("mimetype"), str) and m:
|
||||
return m
|
||||
m = getattr(event, "mimetype", None)
|
||||
return m if isinstance(m, str) and m else None
|
||||
|
||||
def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str:
|
||||
body = getattr(event, "body", None)
|
||||
if isinstance(body, str) and body.strip():
|
||||
if candidate := safe_filename(Path(body).name):
|
||||
return candidate
|
||||
return _DEFAULT_ATTACH_NAME if attachment_type == "file" else attachment_type
|
||||
|
||||
def _build_attachment_path(self, event: MatrixMediaEvent, attachment_type: str,
|
||||
filename: str, mime: str | None) -> Path:
|
||||
safe_name = safe_filename(Path(filename).name) or _DEFAULT_ATTACH_NAME
|
||||
suffix = Path(safe_name).suffix
|
||||
if not suffix and mime:
|
||||
if guessed := mimetypes.guess_extension(mime, strict=False):
|
||||
safe_name, suffix = f"{safe_name}{guessed}", guessed
|
||||
stem = (Path(safe_name).stem or attachment_type)[:72]
|
||||
suffix = suffix[:16]
|
||||
event_id = safe_filename(str(getattr(event, "event_id", "") or "evt").lstrip("$"))
|
||||
event_prefix = (event_id[:24] or "evt").strip("_")
|
||||
return self._media_dir() / f"{event_prefix}_{stem}{suffix}"
|
||||
|
||||
async def _download_media_bytes(self, mxc_url: str) -> bytes | None:
|
||||
if not self.client:
|
||||
return None
|
||||
response = await self.client.download(mxc=mxc_url)
|
||||
if isinstance(response, DownloadError):
|
||||
logger.warning("Matrix download failed for {}: {}", mxc_url, response)
|
||||
return None
|
||||
body = getattr(response, "body", None)
|
||||
if isinstance(body, (bytes, bytearray)):
|
||||
return bytes(body)
|
||||
if isinstance(response, MemoryDownloadResponse):
|
||||
return bytes(response.body)
|
||||
if isinstance(body, (str, Path)):
|
||||
path = Path(body)
|
||||
if path.is_file():
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None:
|
||||
key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None)
|
||||
key = key_obj.get("k") if isinstance(key_obj, dict) else None
|
||||
sha256 = hashes.get("sha256") if isinstance(hashes, dict) else None
|
||||
if not all(isinstance(v, str) for v in (key, sha256, iv)):
|
||||
return None
|
||||
try:
|
||||
return decrypt_attachment(ciphertext, key, sha256, iv)
|
||||
except (EncryptionError, ValueError, TypeError):
|
||||
logger.warning("Matrix decrypt failed for event {}", getattr(event, "event_id", ""))
|
||||
return None
|
||||
|
||||
async def _fetch_media_attachment(
|
||||
self, room: MatrixRoom, event: MatrixMediaEvent,
|
||||
) -> tuple[dict[str, Any] | None, str]:
|
||||
"""Download, decrypt if needed, and persist a Matrix attachment."""
|
||||
atype = self._event_attachment_type(event)
|
||||
mime = self._event_mime(event)
|
||||
filename = self._event_filename(event, atype)
|
||||
mxc_url = getattr(event, "url", None)
|
||||
fail = _ATTACH_FAILED.format(filename)
|
||||
|
||||
if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"):
|
||||
return None, fail
|
||||
|
||||
limit_bytes = await self._effective_media_limit_bytes()
|
||||
declared = self._event_declared_size_bytes(event)
|
||||
if declared is not None and declared > limit_bytes:
|
||||
return None, _ATTACH_TOO_LARGE.format(filename)
|
||||
|
||||
downloaded = await self._download_media_bytes(mxc_url)
|
||||
if downloaded is None:
|
||||
return None, fail
|
||||
|
||||
encrypted = self._is_encrypted_media_event(event)
|
||||
data = downloaded
|
||||
if encrypted:
|
||||
if (data := self._decrypt_media_bytes(event, downloaded)) is None:
|
||||
return None, fail
|
||||
|
||||
if len(data) > limit_bytes:
|
||||
return None, _ATTACH_TOO_LARGE.format(filename)
|
||||
|
||||
path = self._build_attachment_path(event, atype, filename, mime)
|
||||
try:
|
||||
path.write_bytes(data)
|
||||
except OSError:
|
||||
return None, fail
|
||||
|
||||
attachment = {
|
||||
"type": atype, "mime": mime, "filename": filename,
|
||||
"event_id": str(getattr(event, "event_id", "") or ""),
|
||||
"encrypted": encrypted, "size_bytes": len(data),
|
||||
"path": str(path), "mxc_url": mxc_url,
|
||||
}
|
||||
return attachment, _ATTACH_MARKER.format(path)
|
||||
|
||||
def _base_metadata(self, room: MatrixRoom, event: RoomMessage) -> dict[str, Any]:
|
||||
"""Build common metadata for text and media handlers."""
|
||||
meta: dict[str, Any] = {"room": getattr(room, "display_name", room.room_id)}
|
||||
if isinstance(eid := getattr(event, "event_id", None), str) and eid:
|
||||
meta["event_id"] = eid
|
||||
if thread := self._thread_metadata(event):
|
||||
meta.update(thread)
|
||||
return meta
|
||||
|
||||
async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
|
||||
if event.sender == self.config.user_id or not self._should_process_message(room, event):
|
||||
return
|
||||
await self._start_typing_keepalive(room.room_id)
|
||||
try:
|
||||
await self._handle_message(
|
||||
sender_id=event.sender, chat_id=room.room_id,
|
||||
content=event.body, metadata=self._base_metadata(room, event),
|
||||
)
|
||||
except Exception:
|
||||
await self._stop_typing_keepalive(room.room_id, clear_typing=True)
|
||||
raise
|
||||
|
||||
async def _on_media_message(self, room: MatrixRoom, event: MatrixMediaEvent) -> None:
|
||||
if event.sender == self.config.user_id or not self._should_process_message(room, event):
|
||||
return
|
||||
attachment, marker = await self._fetch_media_attachment(room, event)
|
||||
parts: list[str] = []
|
||||
if isinstance(body := getattr(event, "body", None), str) and body.strip():
|
||||
parts.append(body.strip())
|
||||
parts.append(marker)
|
||||
|
||||
await self._start_typing_keepalive(room.room_id)
|
||||
try:
|
||||
meta = self._base_metadata(room, event)
|
||||
if attachment:
|
||||
meta["attachments"] = [attachment]
|
||||
await self._handle_message(
|
||||
sender_id=event.sender, chat_id=room.room_id,
|
||||
content="\n".join(parts),
|
||||
media=[attachment["path"]] if attachment else [],
|
||||
metadata=meta,
|
||||
)
|
||||
except Exception:
|
||||
await self._stop_typing_keepalive(room.room_id, clear_typing=True)
|
||||
raise
|
||||
+12
-12
@@ -322,7 +322,7 @@ class MochatChannel(BaseChannel):
|
||||
await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
|
||||
content, msg.reply_to)
|
||||
except Exception as e:
|
||||
logger.error("Failed to send Mochat message: {}", e)
|
||||
logger.error(f"Failed to send Mochat message: {e}")
|
||||
|
||||
# ---- config / init helpers ---------------------------------------------
|
||||
|
||||
@@ -380,7 +380,7 @@ class MochatChannel(BaseChannel):
|
||||
|
||||
@client.event
|
||||
async def connect_error(data: Any) -> None:
|
||||
logger.error("Mochat websocket connect error: {}", data)
|
||||
logger.error(f"Mochat websocket connect error: {data}")
|
||||
|
||||
@client.on("claw.session.events")
|
||||
async def on_session_events(payload: dict[str, Any]) -> None:
|
||||
@@ -407,7 +407,7 @@ class MochatChannel(BaseChannel):
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect Mochat websocket: {}", e)
|
||||
logger.error(f"Failed to connect Mochat websocket: {e}")
|
||||
try:
|
||||
await client.disconnect()
|
||||
except Exception:
|
||||
@@ -444,7 +444,7 @@ class MochatChannel(BaseChannel):
|
||||
"limit": self.config.watch_limit,
|
||||
})
|
||||
if not ack.get("result"):
|
||||
logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error'))
|
||||
logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}")
|
||||
return False
|
||||
|
||||
data = ack.get("data")
|
||||
@@ -466,7 +466,7 @@ class MochatChannel(BaseChannel):
|
||||
return True
|
||||
ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
|
||||
if not ack.get("result"):
|
||||
logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error'))
|
||||
logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}")
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -488,7 +488,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
await self._refresh_targets(subscribe_new=self._ws_ready)
|
||||
except Exception as e:
|
||||
logger.warning("Mochat refresh failed: {}", e)
|
||||
logger.warning(f"Mochat refresh failed: {e}")
|
||||
if self._fallback_mode:
|
||||
await self._ensure_fallback_workers()
|
||||
|
||||
@@ -502,7 +502,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
response = await self._post_json("/api/claw/sessions/list", {})
|
||||
except Exception as e:
|
||||
logger.warning("Mochat listSessions failed: {}", e)
|
||||
logger.warning(f"Mochat listSessions failed: {e}")
|
||||
return
|
||||
|
||||
sessions = response.get("sessions")
|
||||
@@ -536,7 +536,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
response = await self._post_json("/api/claw/groups/get", {})
|
||||
except Exception as e:
|
||||
logger.warning("Mochat getWorkspaceGroup failed: {}", e)
|
||||
logger.warning(f"Mochat getWorkspaceGroup failed: {e}")
|
||||
return
|
||||
|
||||
raw_panels = response.get("panels")
|
||||
@@ -598,7 +598,7 @@ class MochatChannel(BaseChannel):
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Mochat watch fallback error ({}): {}", session_id, e)
|
||||
logger.warning(f"Mochat watch fallback error ({session_id}): {e}")
|
||||
await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0))
|
||||
|
||||
async def _panel_poll_worker(self, panel_id: str) -> None:
|
||||
@@ -625,7 +625,7 @@ class MochatChannel(BaseChannel):
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Mochat panel polling error ({}): {}", panel_id, e)
|
||||
logger.warning(f"Mochat panel polling error ({panel_id}): {e}")
|
||||
await asyncio.sleep(sleep_s)
|
||||
|
||||
# ---- inbound event processing ------------------------------------------
|
||||
@@ -836,7 +836,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
data = json.loads(self._cursor_path.read_text("utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read Mochat cursor file: {}", e)
|
||||
logger.warning(f"Failed to read Mochat cursor file: {e}")
|
||||
return
|
||||
cursors = data.get("cursors") if isinstance(data, dict) else None
|
||||
if isinstance(cursors, dict):
|
||||
@@ -852,7 +852,7 @@ class MochatChannel(BaseChannel):
|
||||
"cursors": self._session_cursor,
|
||||
}, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save Mochat cursor file: {}", e)
|
||||
logger.warning(f"Failed to save Mochat cursor file: {e}")
|
||||
|
||||
# ---- HTTP helpers ------------------------------------------------------
|
||||
|
||||
|
||||
+11
-9
@@ -34,7 +34,7 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
|
||||
super().__init__(intents=intents)
|
||||
|
||||
async def on_ready(self):
|
||||
logger.info("QQ bot ready: {}", self.robot.name)
|
||||
logger.info(f"QQ bot ready: {self.robot.name}")
|
||||
|
||||
async def on_c2c_message_create(self, message: "C2CMessage"):
|
||||
await channel._on_message(message)
|
||||
@@ -55,6 +55,7 @@ class QQChannel(BaseChannel):
|
||||
self.config: QQConfig = config
|
||||
self._client: "botpy.Client | None" = None
|
||||
self._processed_ids: deque = deque(maxlen=1000)
|
||||
self._bot_task: asyncio.Task | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the QQ bot."""
|
||||
@@ -70,8 +71,8 @@ class QQChannel(BaseChannel):
|
||||
BotClass = _make_bot_class(self)
|
||||
self._client = BotClass()
|
||||
|
||||
self._bot_task = asyncio.create_task(self._run_bot())
|
||||
logger.info("QQ bot started (C2C private message)")
|
||||
await self._run_bot()
|
||||
|
||||
async def _run_bot(self) -> None:
|
||||
"""Run the bot connection with auto-reconnect."""
|
||||
@@ -79,7 +80,7 @@ class QQChannel(BaseChannel):
|
||||
try:
|
||||
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
|
||||
except Exception as e:
|
||||
logger.warning("QQ bot error: {}", e)
|
||||
logger.warning(f"QQ bot error: {e}")
|
||||
if self._running:
|
||||
logger.info("Reconnecting QQ bot in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
@@ -87,10 +88,11 @@ class QQChannel(BaseChannel):
|
||||
async def stop(self) -> None:
|
||||
"""Stop the QQ bot."""
|
||||
self._running = False
|
||||
if self._client:
|
||||
if self._bot_task:
|
||||
self._bot_task.cancel()
|
||||
try:
|
||||
await self._client.close()
|
||||
except Exception:
|
||||
await self._bot_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("QQ bot stopped")
|
||||
|
||||
@@ -106,7 +108,7 @@ class QQChannel(BaseChannel):
|
||||
content=msg.content,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error sending QQ message: {}", e)
|
||||
logger.error(f"Error sending QQ message: {e}")
|
||||
|
||||
async def _on_message(self, data: "C2CMessage") -> None:
|
||||
"""Handle incoming message from QQ."""
|
||||
@@ -128,5 +130,5 @@ class QQChannel(BaseChannel):
|
||||
content=content,
|
||||
metadata={"message_id": data.id},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error handling QQ message")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling QQ message: {e}")
|
||||
|
||||
+25
-101
@@ -10,8 +10,6 @@ from slack_sdk.socket_mode.request import SocketModeRequest
|
||||
from slack_sdk.socket_mode.response import SocketModeResponse
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
|
||||
from slackify_markdown import slackify_markdown
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
@@ -36,7 +34,7 @@ class SlackChannel(BaseChannel):
|
||||
logger.error("Slack bot/app token not configured")
|
||||
return
|
||||
if self.config.mode != "socket":
|
||||
logger.error("Unsupported Slack mode: {}", self.config.mode)
|
||||
logger.error(f"Unsupported Slack mode: {self.config.mode}")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
@@ -53,9 +51,9 @@ class SlackChannel(BaseChannel):
|
||||
try:
|
||||
auth = await self._web_client.auth_test()
|
||||
self._bot_user_id = auth.get("user_id")
|
||||
logger.info("Slack bot connected as {}", self._bot_user_id)
|
||||
logger.info(f"Slack bot connected as {self._bot_user_id}")
|
||||
except Exception as e:
|
||||
logger.warning("Slack auth_test failed: {}", e)
|
||||
logger.warning(f"Slack auth_test failed: {e}")
|
||||
|
||||
logger.info("Starting Slack Socket Mode client...")
|
||||
await self._socket_client.connect()
|
||||
@@ -70,7 +68,7 @@ class SlackChannel(BaseChannel):
|
||||
try:
|
||||
await self._socket_client.close()
|
||||
except Exception as e:
|
||||
logger.warning("Slack socket close failed: {}", e)
|
||||
logger.warning(f"Slack socket close failed: {e}")
|
||||
self._socket_client = None
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
@@ -84,26 +82,13 @@ class SlackChannel(BaseChannel):
|
||||
channel_type = slack_meta.get("channel_type")
|
||||
# Only reply in thread for channel/group messages; DMs don't use threads
|
||||
use_thread = thread_ts and channel_type != "im"
|
||||
thread_ts_param = thread_ts if use_thread else None
|
||||
|
||||
if msg.content:
|
||||
await self._web_client.chat_postMessage(
|
||||
channel=msg.chat_id,
|
||||
text=self._to_mrkdwn(msg.content),
|
||||
thread_ts=thread_ts_param,
|
||||
)
|
||||
|
||||
for media_path in msg.media or []:
|
||||
try:
|
||||
await self._web_client.files_upload_v2(
|
||||
channel=msg.chat_id,
|
||||
file=media_path,
|
||||
thread_ts=thread_ts_param,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to upload file {}: {}", media_path, e)
|
||||
await self._web_client.chat_postMessage(
|
||||
channel=msg.chat_id,
|
||||
text=msg.content or "",
|
||||
thread_ts=thread_ts if use_thread else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error sending Slack message: {}", e)
|
||||
logger.error(f"Error sending Slack message: {e}")
|
||||
|
||||
async def _on_socket_request(
|
||||
self,
|
||||
@@ -165,39 +150,30 @@ class SlackChannel(BaseChannel):
|
||||
|
||||
text = self._strip_bot_mention(text)
|
||||
|
||||
thread_ts = event.get("thread_ts")
|
||||
if self.config.reply_in_thread and not thread_ts:
|
||||
thread_ts = event.get("ts")
|
||||
thread_ts = event.get("thread_ts") or event.get("ts")
|
||||
# Add :eyes: reaction to the triggering message (best-effort)
|
||||
try:
|
||||
if self._web_client and event.get("ts"):
|
||||
await self._web_client.reactions_add(
|
||||
channel=chat_id,
|
||||
name=self.config.react_emoji,
|
||||
name="eyes",
|
||||
timestamp=event.get("ts"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Slack reactions_add failed: {}", e)
|
||||
logger.debug(f"Slack reactions_add failed: {e}")
|
||||
|
||||
# Thread-scoped session key for channel/group messages
|
||||
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None
|
||||
|
||||
try:
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_id,
|
||||
content=text,
|
||||
metadata={
|
||||
"slack": {
|
||||
"event": event,
|
||||
"thread_ts": thread_ts,
|
||||
"channel_type": channel_type,
|
||||
},
|
||||
},
|
||||
session_key=session_key,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error handling Slack message from {}", sender_id)
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_id,
|
||||
content=text,
|
||||
metadata={
|
||||
"slack": {
|
||||
"event": event,
|
||||
"thread_ts": thread_ts,
|
||||
"channel_type": channel_type,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
|
||||
if channel_type == "im":
|
||||
@@ -227,55 +203,3 @@ class SlackChannel(BaseChannel):
|
||||
if not text or not self._bot_user_id:
|
||||
return text
|
||||
return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()
|
||||
|
||||
_TABLE_RE = re.compile(r"(?m)^\|.*\|$(?:\n\|[\s:|-]*\|$)(?:\n\|.*\|$)*")
|
||||
_CODE_FENCE_RE = re.compile(r"```[\s\S]*?```")
|
||||
_INLINE_CODE_RE = re.compile(r"`[^`]+`")
|
||||
_LEFTOVER_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
||||
_LEFTOVER_HEADER_RE = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE)
|
||||
_BARE_URL_RE = re.compile(r"(?<![|<])(https?://\S+)")
|
||||
|
||||
@classmethod
|
||||
def _to_mrkdwn(cls, text: str) -> str:
|
||||
"""Convert Markdown to Slack mrkdwn, including tables."""
|
||||
if not text:
|
||||
return ""
|
||||
text = cls._TABLE_RE.sub(cls._convert_table, text)
|
||||
return cls._fixup_mrkdwn(slackify_markdown(text))
|
||||
|
||||
@classmethod
|
||||
def _fixup_mrkdwn(cls, text: str) -> str:
|
||||
"""Fix markdown artifacts that slackify_markdown misses."""
|
||||
code_blocks: list[str] = []
|
||||
|
||||
def _save_code(m: re.Match) -> str:
|
||||
code_blocks.append(m.group(0))
|
||||
return f"\x00CB{len(code_blocks) - 1}\x00"
|
||||
|
||||
text = cls._CODE_FENCE_RE.sub(_save_code, text)
|
||||
text = cls._INLINE_CODE_RE.sub(_save_code, text)
|
||||
text = cls._LEFTOVER_BOLD_RE.sub(r"*\1*", text)
|
||||
text = cls._LEFTOVER_HEADER_RE.sub(r"*\1*", text)
|
||||
text = cls._BARE_URL_RE.sub(lambda m: m.group(0).replace("&", "&"), text)
|
||||
|
||||
for i, block in enumerate(code_blocks):
|
||||
text = text.replace(f"\x00CB{i}\x00", block)
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _convert_table(match: re.Match) -> str:
|
||||
"""Convert a Markdown table to a Slack-readable list."""
|
||||
lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()]
|
||||
if len(lines) < 2:
|
||||
return match.group(0)
|
||||
headers = [h.strip() for h in lines[0].strip("|").split("|")]
|
||||
start = 2 if re.fullmatch(r"[|\s:\-]+", lines[1]) else 1
|
||||
rows: list[str] = []
|
||||
for line in lines[start:]:
|
||||
cells = [c.strip() for c in line.strip("|").split("|")]
|
||||
cells = (cells + [""] * len(headers))[: len(headers)]
|
||||
parts = [f"**{headers[i]}**: {cells[i]}" for i in range(len(headers)) if cells[i]]
|
||||
if parts:
|
||||
rows.append(" · ".join(parts))
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
+11
-221
@@ -4,8 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
from telegram import BotCommand, Update
|
||||
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
||||
@@ -187,241 +185,33 @@ class TelegramChannel(BaseChannel):
|
||||
if not self._app:
|
||||
logger.warning("Telegram bot not running")
|
||||
return
|
||||
|
||||
|
||||
# Stop typing indicator for this chat
|
||||
self._stop_typing(msg.chat_id)
|
||||
|
||||
# Check for suppression
|
||||
if msg.metadata.get("suppressed", False):
|
||||
logger.debug(f"Suppressed output (not sent to Telegram): {msg.content[:100]}...")
|
||||
return # Don't send to Telegram API
|
||||
|
||||
try:
|
||||
# chat_id should be the Telegram chat ID (integer)
|
||||
chat_id = int(msg.chat_id)
|
||||
# Convert markdown to Telegram HTML
|
||||
html_content = _markdown_to_telegram_html(msg.content)
|
||||
|
||||
# Check if message has media attachments
|
||||
if msg.media:
|
||||
await self._send_with_media(chat_id, html_content, msg.media)
|
||||
else:
|
||||
# Text-only message - split if too long
|
||||
await self._send_text_chunks(chat_id, html_content, parse_mode="HTML")
|
||||
await self._app.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=html_content,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid chat_id: {msg.chat_id}")
|
||||
except Exception as e:
|
||||
# Fallback to plain text if HTML parsing fails
|
||||
logger.warning(f"HTML parse failed, falling back to plain text: {e}")
|
||||
try:
|
||||
await self._send_text_chunks(int(msg.chat_id), msg.content, parse_mode=None)
|
||||
await self._app.bot.send_message(
|
||||
chat_id=int(msg.chat_id),
|
||||
text=msg.content
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.error(f"Error sending Telegram message: {e2}")
|
||||
|
||||
@staticmethod
|
||||
def _split_message(content: str, max_len: int = 4000) -> list[str]:
|
||||
"""Split content into chunks within max_len, preferring line breaks.
|
||||
|
||||
From upstream HKUDS/nanobot - battle-tested implementation.
|
||||
Uses 4000 char limit (safer than 4096) with split priority: \n → space → hard cut.
|
||||
"""
|
||||
if len(content) <= max_len:
|
||||
return [content]
|
||||
chunks: list[str] = []
|
||||
while content:
|
||||
if len(content) <= max_len:
|
||||
chunks.append(content)
|
||||
break
|
||||
cut = content[:max_len]
|
||||
pos = cut.rfind('\n')
|
||||
if pos == -1:
|
||||
pos = cut.rfind(' ')
|
||||
if pos == -1:
|
||||
pos = max_len
|
||||
chunks.append(content[:pos])
|
||||
content = content[pos:].lstrip()
|
||||
return chunks
|
||||
|
||||
async def _send_text_chunks(
|
||||
self,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
parse_mode: str | None = "HTML"
|
||||
) -> None:
|
||||
"""Split and send long messages.
|
||||
|
||||
Telegram has a 4096 character limit per message.
|
||||
Uses upstream's proven implementation - splits at line breaks, then spaces.
|
||||
"""
|
||||
chunks = self._split_message(text)
|
||||
|
||||
for chunk in chunks:
|
||||
await self._app.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=chunk.strip(),
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
|
||||
async def _send_with_media(self, chat_id: int, caption: str, media_paths: list[str]) -> None:
|
||||
"""
|
||||
Send message with media attachments.
|
||||
|
||||
Args:
|
||||
chat_id: Telegram chat ID
|
||||
caption: Message caption
|
||||
media_paths: List of file paths or URLs
|
||||
"""
|
||||
from telegram import InputMediaPhoto, InputMediaVideo
|
||||
|
||||
from nanobot.channels.telegram_media import (
|
||||
MediaKind,
|
||||
classify_media,
|
||||
detect_mime,
|
||||
fetch_media,
|
||||
group_media_for_album,
|
||||
optimize_image,
|
||||
)
|
||||
|
||||
# Process each media item
|
||||
processed_media: list[tuple[str, MediaKind, bytes, str]] = []
|
||||
|
||||
for path in media_paths:
|
||||
try:
|
||||
# Fetch remote URLs
|
||||
if path.startswith(("http://", "https://")):
|
||||
content, mime = await fetch_media(path, max_bytes=100_000_000)
|
||||
kind = classify_media(mime)
|
||||
# Extract filename from URL
|
||||
filename = Path(path).name
|
||||
else:
|
||||
# Local file
|
||||
file_path = Path(path)
|
||||
if not file_path.exists():
|
||||
logger.warning(f"Media file not found: {path}")
|
||||
continue
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
mime = detect_mime(path, content)
|
||||
kind = classify_media(mime)
|
||||
# Extract filename from local path
|
||||
filename = file_path.name
|
||||
|
||||
# Optimize images
|
||||
if kind == MediaKind.IMAGE:
|
||||
try:
|
||||
content = optimize_image(path, max_bytes=6_000_000)
|
||||
except Exception as e:
|
||||
logger.warning(f"Image optimization failed: {e}, sending original")
|
||||
|
||||
processed_media.append((path, kind, content, filename))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process media {path}: {e}")
|
||||
continue
|
||||
|
||||
if not processed_media:
|
||||
# No media could be processed, send text only
|
||||
await self._app.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=caption,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Group media for album sending
|
||||
media_items = [(path, kind) for path, kind, _, _ in processed_media]
|
||||
grouping = group_media_for_album(media_items)
|
||||
|
||||
# Handle caption length (Telegram limit: 1024 chars)
|
||||
if len(caption) > 1024:
|
||||
# Send media without caption, then follow-up text
|
||||
media_caption = None
|
||||
followup_text = caption
|
||||
else:
|
||||
media_caption = caption
|
||||
followup_text = None
|
||||
|
||||
# Send album if grouped
|
||||
if grouping["album"]:
|
||||
album_paths = grouping["album"]
|
||||
album_media = []
|
||||
|
||||
for path, kind, content, filename in processed_media:
|
||||
if path not in album_paths:
|
||||
continue
|
||||
|
||||
if kind == MediaKind.IMAGE:
|
||||
media_obj = InputMediaPhoto(
|
||||
media=content,
|
||||
caption=media_caption if len(album_media) == 0 else None,
|
||||
parse_mode="HTML" if media_caption else None
|
||||
)
|
||||
elif kind == MediaKind.VIDEO:
|
||||
media_obj = InputMediaVideo(
|
||||
media=content,
|
||||
caption=media_caption if len(album_media) == 0 else None,
|
||||
parse_mode="HTML" if media_caption else None
|
||||
)
|
||||
else:
|
||||
continue # Skip non-album types
|
||||
|
||||
album_media.append(media_obj)
|
||||
|
||||
if album_media:
|
||||
await self._app.bot.send_media_group(
|
||||
chat_id=chat_id,
|
||||
media=album_media
|
||||
)
|
||||
|
||||
# Send separate media
|
||||
for i, (path, kind, content, filename) in enumerate(processed_media):
|
||||
if path in grouping["album"]:
|
||||
continue # Already sent in album
|
||||
|
||||
# Only first separate item gets caption
|
||||
item_caption = media_caption if i == 0 else None
|
||||
|
||||
if kind == MediaKind.IMAGE:
|
||||
await self._app.bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=content,
|
||||
caption=item_caption,
|
||||
parse_mode="HTML" if item_caption else None
|
||||
)
|
||||
elif kind == MediaKind.VIDEO:
|
||||
await self._app.bot.send_video(
|
||||
chat_id=chat_id,
|
||||
video=content,
|
||||
caption=item_caption,
|
||||
parse_mode="HTML" if item_caption else None
|
||||
)
|
||||
elif kind == MediaKind.AUDIO:
|
||||
await self._app.bot.send_audio(
|
||||
chat_id=chat_id,
|
||||
audio=content,
|
||||
caption=item_caption,
|
||||
parse_mode="HTML" if item_caption else None,
|
||||
filename=filename
|
||||
)
|
||||
elif kind == MediaKind.DOCUMENT:
|
||||
await self._app.bot.send_document(
|
||||
chat_id=chat_id,
|
||||
document=content,
|
||||
caption=item_caption,
|
||||
parse_mode="HTML" if item_caption else None,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
# Send follow-up text if caption was too long
|
||||
if followup_text:
|
||||
await self._app.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=followup_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /start command."""
|
||||
if not update.message or not update.effective_user:
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
"""Media handling utilities for Telegram channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import mimetypes
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from PIL import Image
|
||||
|
||||
# Telegram API photo size limit (6MB)
|
||||
TELEGRAM_PHOTO_SIZE_LIMIT = 6_000_000
|
||||
|
||||
try:
|
||||
import magic
|
||||
HAS_MAGIC = True
|
||||
except ImportError:
|
||||
HAS_MAGIC = False
|
||||
|
||||
try:
|
||||
from pillow_heif import register_heif_opener
|
||||
register_heif_opener()
|
||||
HAS_HEIF = True
|
||||
except ImportError:
|
||||
HAS_HEIF = False
|
||||
|
||||
|
||||
class MediaKind(Enum):
|
||||
"""Media type classification."""
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
DOCUMENT = "document"
|
||||
|
||||
|
||||
def detect_mime(path: str, content: bytes | None = None) -> str:
|
||||
"""
|
||||
Detect MIME type of media file.
|
||||
|
||||
Priority:
|
||||
1. python-magic sniff (if available and content provided)
|
||||
2. Extension-based lookup
|
||||
3. Fallback to application/octet-stream
|
||||
|
||||
Args:
|
||||
path: File path (used for extension detection)
|
||||
content: Optional file content bytes for magic sniffing
|
||||
|
||||
Returns:
|
||||
MIME type string (e.g., "image/jpeg")
|
||||
"""
|
||||
# Try magic detection first if we have content
|
||||
if HAS_MAGIC and content:
|
||||
try:
|
||||
mime = magic.from_buffer(content, mime=True)
|
||||
# Avoid generic types if we can be more specific from extension
|
||||
if mime and mime != "application/octet-stream":
|
||||
return mime
|
||||
except Exception as e:
|
||||
logger.debug(f"Magic detection failed, falling back to extension: {e}")
|
||||
|
||||
# Extension-based detection
|
||||
mime_type, _ = mimetypes.guess_type(path)
|
||||
if mime_type:
|
||||
return mime_type
|
||||
|
||||
# Fallback
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def classify_media(mime: str) -> MediaKind:
|
||||
"""
|
||||
Classify MIME type into media kind.
|
||||
|
||||
Args:
|
||||
mime: MIME type string (e.g., "image/jpeg")
|
||||
|
||||
Returns:
|
||||
MediaKind enum value
|
||||
"""
|
||||
if mime.startswith("image/"):
|
||||
return MediaKind.IMAGE
|
||||
if mime.startswith("video/"):
|
||||
return MediaKind.VIDEO
|
||||
if mime.startswith("audio/"):
|
||||
return MediaKind.AUDIO
|
||||
# Everything else is a document
|
||||
return MediaKind.DOCUMENT
|
||||
|
||||
|
||||
def is_heic_format(path: str) -> bool:
|
||||
"""
|
||||
Check if file is HEIC/HEIF format.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
True if file extension is .heic or .heif
|
||||
"""
|
||||
ext = Path(path).suffix.lower()
|
||||
return ext in (".heic", ".heif")
|
||||
|
||||
|
||||
async def fetch_media(url: str, max_bytes: int) -> tuple[bytes, str]:
|
||||
"""
|
||||
Download media from remote URL.
|
||||
|
||||
Args:
|
||||
url: Remote URL to fetch
|
||||
max_bytes: Maximum size to download
|
||||
|
||||
Returns:
|
||||
Tuple of (content bytes, detected MIME type)
|
||||
|
||||
Raises:
|
||||
ValueError: If download fails or exceeds size limit
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
|
||||
if len(content) > max_bytes:
|
||||
raise ValueError(f"Media exceeds size limit: {len(content)} > {max_bytes}")
|
||||
|
||||
# Get MIME type from response or detect
|
||||
mime = response.headers.get("content-type", "application/octet-stream")
|
||||
# Strip charset if present (e.g., "image/jpeg; charset=utf-8" → "image/jpeg")
|
||||
mime = mime.split(";")[0].strip()
|
||||
|
||||
# Detect from content if generic type
|
||||
if mime == "application/octet-stream":
|
||||
mime = detect_mime(url, content)
|
||||
|
||||
return content, mime
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
raise ValueError(f"Download timeout: {url}") from e
|
||||
except httpx.HTTPError as e:
|
||||
raise ValueError(f"Download failed: {url}: {e}") from e
|
||||
|
||||
|
||||
def optimize_image(path: str, max_bytes: int = TELEGRAM_PHOTO_SIZE_LIMIT) -> bytes:
|
||||
"""
|
||||
Optimize image to fit under size limit.
|
||||
|
||||
Strategy:
|
||||
1. Convert HEIC to JPEG if needed
|
||||
2. PNG with alpha → preserve with compression levels [6,7,8,9]
|
||||
3. JPEG/PNG without alpha → resize + quality grid
|
||||
|
||||
Sizes: [2048, 1536, 1280, 1024, 800] px (max dimension)
|
||||
Qualities: [80, 70, 60, 50, 40] (JPEG only)
|
||||
|
||||
Args:
|
||||
path: Path to image file
|
||||
max_bytes: Maximum size in bytes (default 6MB for Telegram)
|
||||
|
||||
Returns:
|
||||
Optimized image bytes
|
||||
|
||||
Raises:
|
||||
ValueError: If image cannot be optimized under limit
|
||||
"""
|
||||
# Load image with context manager to ensure file handle is closed
|
||||
with Image.open(path) as img:
|
||||
# Convert HEIC to JPEG
|
||||
if is_heic_format(path):
|
||||
if not HAS_HEIF:
|
||||
raise ValueError("pillow-heif not available for HEIC conversion")
|
||||
# Convert to RGB (HEIC → JPEG)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
return _optimize_jpeg(img, max_bytes)
|
||||
|
||||
# PNG with alpha channel - preserve it
|
||||
if img.mode == "RGBA" or img.mode == "LA":
|
||||
return _optimize_png(img, max_bytes)
|
||||
|
||||
# Everything else → convert to JPEG and optimize
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
return _optimize_jpeg(img, max_bytes)
|
||||
|
||||
|
||||
def _optimize_jpeg(img: Image.Image, max_bytes: int) -> bytes:
|
||||
"""Optimize JPEG with size/quality grid."""
|
||||
sizes = [2048, 1536, 1280, 1024, 800]
|
||||
qualities = [80, 70, 60, 50, 40]
|
||||
|
||||
for size in sizes:
|
||||
# Always copy to avoid mutation issues
|
||||
resized = img.copy()
|
||||
if max(img.size) > size:
|
||||
resized.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
for quality in qualities:
|
||||
buf = io.BytesIO()
|
||||
resized.save(buf, format="JPEG", quality=quality, optimize=True)
|
||||
data = buf.getvalue()
|
||||
|
||||
if len(data) <= max_bytes:
|
||||
return data
|
||||
|
||||
# If we get here, even smallest size/quality is too large
|
||||
raise ValueError(f"Cannot optimize image under {max_bytes} bytes")
|
||||
|
||||
|
||||
def _optimize_png(img: Image.Image, max_bytes: int) -> bytes:
|
||||
"""Optimize PNG while preserving alpha channel."""
|
||||
compress_levels = [6, 7, 8, 9]
|
||||
sizes = [2048, 1536, 1280, 1024, 800]
|
||||
|
||||
for size in sizes:
|
||||
# Always copy to avoid mutation issues
|
||||
resized = img.copy()
|
||||
if max(img.size) > size:
|
||||
resized.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
for compress_level in compress_levels:
|
||||
buf = io.BytesIO()
|
||||
resized.save(buf, format="PNG", compress_level=compress_level, optimize=True)
|
||||
data = buf.getvalue()
|
||||
|
||||
if len(data) <= max_bytes:
|
||||
return data
|
||||
|
||||
# Fallback: try converting to JPEG if still too large
|
||||
if img.mode in ("RGBA", "LA"):
|
||||
# Create white background
|
||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||
if img.mode == "RGBA":
|
||||
background.paste(img, mask=img.split()[3]) # Use alpha as mask
|
||||
else: # LA (grayscale + alpha)
|
||||
background.paste(img.convert("L"), mask=img.split()[1])
|
||||
return _optimize_jpeg(background, max_bytes)
|
||||
|
||||
raise ValueError(f"Cannot optimize PNG under {max_bytes} bytes")
|
||||
|
||||
|
||||
def group_media_for_album(media_items: list[tuple[str, MediaKind]]) -> dict[str, list[str]]:
|
||||
"""
|
||||
Group media items for album sending.
|
||||
|
||||
Logic:
|
||||
- All images (2+) → album
|
||||
- All videos (2+) → album
|
||||
- Mixed types → separate
|
||||
- Single item → separate
|
||||
|
||||
Args:
|
||||
media_items: List of (path, MediaKind) tuples
|
||||
|
||||
Returns:
|
||||
Dict with 'album' and 'separate' keys containing lists of paths
|
||||
"""
|
||||
if len(media_items) <= 1:
|
||||
return {
|
||||
"album": [],
|
||||
"separate": [path for path, _ in media_items]
|
||||
}
|
||||
|
||||
# Count each kind
|
||||
kinds = [kind for _, kind in media_items]
|
||||
unique_kinds = set(kinds)
|
||||
|
||||
# All same type → album (if images or videos)
|
||||
if len(unique_kinds) == 1:
|
||||
kind = kinds[0]
|
||||
if kind in (MediaKind.IMAGE, MediaKind.VIDEO):
|
||||
return {
|
||||
"album": [path for path, _ in media_items],
|
||||
"separate": []
|
||||
}
|
||||
|
||||
# Mixed types or non-album-able types → separate
|
||||
return {
|
||||
"album": [],
|
||||
"separate": [path for path, _ in media_items]
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class WhatsAppChannel(BaseChannel):
|
||||
|
||||
bridge_url = self.config.bridge_url
|
||||
|
||||
logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
|
||||
logger.info(f"Connecting to WhatsApp bridge at {bridge_url}...")
|
||||
|
||||
self._running = True
|
||||
|
||||
@@ -53,14 +53,14 @@ class WhatsAppChannel(BaseChannel):
|
||||
try:
|
||||
await self._handle_bridge_message(message)
|
||||
except Exception as e:
|
||||
logger.error("Error handling bridge message: {}", e)
|
||||
logger.error(f"Error handling bridge message: {e}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self._connected = False
|
||||
self._ws = None
|
||||
logger.warning("WhatsApp bridge connection error: {}", e)
|
||||
logger.warning(f"WhatsApp bridge connection error: {e}")
|
||||
|
||||
if self._running:
|
||||
logger.info("Reconnecting in 5 seconds...")
|
||||
@@ -87,16 +87,16 @@ class WhatsAppChannel(BaseChannel):
|
||||
"to": msg.chat_id,
|
||||
"text": msg.content
|
||||
}
|
||||
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
||||
await self._ws.send(json.dumps(payload))
|
||||
except Exception as e:
|
||||
logger.error("Error sending WhatsApp message: {}", e)
|
||||
logger.error(f"Error sending WhatsApp message: {e}")
|
||||
|
||||
async def _handle_bridge_message(self, raw: str) -> None:
|
||||
"""Handle a message from the bridge."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Invalid JSON from bridge: {}", raw[:100])
|
||||
logger.warning(f"Invalid JSON from bridge: {raw[:100]}")
|
||||
return
|
||||
|
||||
msg_type = data.get("type")
|
||||
@@ -112,11 +112,11 @@ class WhatsAppChannel(BaseChannel):
|
||||
# Extract just the phone number or lid as chat_id
|
||||
user_id = pn if pn else sender
|
||||
sender_id = user_id.split("@")[0] if "@" in user_id else user_id
|
||||
logger.info("Sender {}", sender)
|
||||
logger.info(f"Sender {sender}")
|
||||
|
||||
# Handle voice transcription if it's a voice message
|
||||
if content == "[Voice Message]":
|
||||
logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id)
|
||||
logger.info(f"Voice message received from {sender_id}, but direct download from bridge is not yet supported.")
|
||||
content = "[Voice Message: Transcription not available for WhatsApp yet]"
|
||||
|
||||
await self._handle_message(
|
||||
@@ -133,7 +133,7 @@ class WhatsAppChannel(BaseChannel):
|
||||
elif msg_type == "status":
|
||||
# Connection status update
|
||||
status = data.get("status")
|
||||
logger.info("WhatsApp status: {}", status)
|
||||
logger.info(f"WhatsApp status: {status}")
|
||||
|
||||
if status == "connected":
|
||||
self._connected = True
|
||||
@@ -145,4 +145,4 @@ class WhatsAppChannel(BaseChannel):
|
||||
logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
|
||||
|
||||
elif msg_type == "error":
|
||||
logger.error("WhatsApp bridge error: {}", data.get('error'))
|
||||
logger.error(f"WhatsApp bridge error: {data.get('error')}")
|
||||
|
||||
+90
-201
@@ -2,26 +2,23 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import select
|
||||
import sys
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from nanobot import __logo__, __version__
|
||||
from nanobot.cli.oauth import oauth_app
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
|
||||
from nanobot import __version__, __logo__
|
||||
|
||||
app = typer.Typer(
|
||||
name="nanobot",
|
||||
@@ -161,26 +158,26 @@ def onboard():
|
||||
from nanobot.config.loader import get_config_path, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.helpers import get_workspace_path
|
||||
|
||||
|
||||
config_path = get_config_path()
|
||||
|
||||
|
||||
if config_path.exists():
|
||||
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
||||
if not typer.confirm("Overwrite?"):
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
# Create default config
|
||||
config = Config()
|
||||
save_config(config)
|
||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||
|
||||
|
||||
# Create workspace
|
||||
workspace = get_workspace_path()
|
||||
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
||||
|
||||
|
||||
# Create default bootstrap files
|
||||
_create_workspace_templates(workspace)
|
||||
|
||||
|
||||
console.print(f"\n{__logo__} nanobot is ready!")
|
||||
console.print("\nNext steps:")
|
||||
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
|
||||
@@ -232,13 +229,13 @@ Information about the user goes here.
|
||||
- Language: (your preferred language)
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
for filename, content in templates.items():
|
||||
file_path = workspace / filename
|
||||
if not file_path.exists():
|
||||
file_path.write_text(content)
|
||||
console.print(f" [dim]Created {filename}[/dim]")
|
||||
|
||||
|
||||
# Create memory directory and MEMORY.md
|
||||
memory_dir = workspace / "memory"
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
@@ -261,7 +258,7 @@ This file stores important information that should persist across sessions.
|
||||
(Things to remember)
|
||||
""")
|
||||
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
||||
|
||||
|
||||
history_file = memory_dir / "HISTORY.md"
|
||||
if not history_file.exists():
|
||||
history_file.write_text("")
|
||||
@@ -296,81 +293,36 @@ def _make_provider(config):
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _start_moltbook_loop():
|
||||
"""Start the moltbook polling loop in the background."""
|
||||
loop_script = Path.home() / ".nanobot" / "scripts" / "moltbook-loop.sh"
|
||||
log_file = Path.home() / ".nanobot" / "scripts" / "moltbook-loop.log"
|
||||
|
||||
if not loop_script.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["/bin/bash", str(loop_script)],
|
||||
stdout=open(log_file, "a"),
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
console.print(f"[green]✓[/green] Moltbook polling: every 15m")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Could not start moltbook loop: {e}[/yellow]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def gateway(
|
||||
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
):
|
||||
"""Start the nanobot gateway."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.config.loader import load_config, get_data_dir
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.config.loader import get_data_dir, load_config
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
if verbose:
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
||||
|
||||
|
||||
config = load_config()
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(config)
|
||||
session_manager = SessionManager(config.workspace_path)
|
||||
|
||||
|
||||
# Create cron service first (callback set after agent creation)
|
||||
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
cron = CronService(cron_store_path)
|
||||
|
||||
# Convert mem0 config to dict for AgentLoop
|
||||
mem0_config = None
|
||||
if config.tools.mem0.enabled:
|
||||
mem0_config = {
|
||||
"enabled": True,
|
||||
"search_limit": config.tools.mem0.search_limit,
|
||||
}
|
||||
if config.tools.mem0.api_key:
|
||||
mem0_config["api_key"] = config.tools.mem0.api_key
|
||||
if config.tools.mem0.llm:
|
||||
mem0_config["llm"] = {
|
||||
"provider": "openai",
|
||||
"config": {"model": config.tools.mem0.llm}
|
||||
}
|
||||
if config.tools.mem0.embedder:
|
||||
mem0_config["embedder"] = {
|
||||
"provider": "openai",
|
||||
"config": {"model": config.tools.mem0.embedder}
|
||||
}
|
||||
if config.tools.mem0.vector_store:
|
||||
mem0_config["vector_store"] = config.tools.mem0.vector_store
|
||||
|
||||
# DEBUG: Log what's being passed
|
||||
logger.debug(f"Passing mem0_config to AgentLoop: {list(mem0_config.keys())}")
|
||||
|
||||
|
||||
# Create agent with cron service
|
||||
agent = AgentLoop(
|
||||
bus=bus,
|
||||
@@ -383,11 +335,9 @@ def gateway(
|
||||
exec_config=config.tools.exec,
|
||||
cron_service=cron,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
enable_memory_tool=config.tools.enable_memory_tool,
|
||||
mem0_config=mem0_config,
|
||||
session_manager=session_manager,
|
||||
)
|
||||
|
||||
|
||||
# Set cron callback (needs agent)
|
||||
async def on_cron_job(job: CronJob) -> str | None:
|
||||
"""Execute a cron job through the agent."""
|
||||
@@ -406,84 +356,48 @@ def gateway(
|
||||
))
|
||||
return response
|
||||
cron.on_job = on_cron_job
|
||||
|
||||
|
||||
# Create heartbeat service
|
||||
async def on_heartbeat(prompt: str, metadata: dict[str, Any] | None = None) -> str:
|
||||
async def on_heartbeat(prompt: str) -> str:
|
||||
"""Execute heartbeat through the agent."""
|
||||
return await agent.process_direct(
|
||||
prompt,
|
||||
session_key="telegram:239824268", # Run in main telegram session
|
||||
channel="telegram",
|
||||
chat_id="239824268",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
return await agent.process_direct(prompt, session_key="heartbeat")
|
||||
|
||||
heartbeat = HeartbeatService(
|
||||
workspace=config.workspace_path,
|
||||
on_heartbeat=on_heartbeat,
|
||||
interval_s=30 * 60, # 30 minutes
|
||||
enabled=True,
|
||||
session_manager=session_manager, # Pass session manager
|
||||
target_session_key="telegram:239824268", # Target session
|
||||
idle_threshold_s=20 * 60, # 20 minutes idle
|
||||
enabled=True
|
||||
)
|
||||
|
||||
|
||||
# Create channel manager
|
||||
channels = ChannelManager(config, bus)
|
||||
|
||||
# Create hooks server
|
||||
from nanobot.channels.hook import HookChannel
|
||||
from nanobot.hooks.server import HooksServer
|
||||
|
||||
hooks_config = config.hooks if hasattr(config, 'hooks') else None
|
||||
hooks_server = None
|
||||
|
||||
if hooks_config and hooks_config.enabled:
|
||||
# Register hook channel
|
||||
hook_channel = HookChannel(bus)
|
||||
channels.register_channel("hook", hook_channel)
|
||||
|
||||
# Create hooks server (checks has_tokens internally)
|
||||
hooks_server = HooksServer(
|
||||
host=config.gateway.host,
|
||||
port=config.gateway.port,
|
||||
config=hooks_config,
|
||||
bus=bus,
|
||||
)
|
||||
console.print(f"[green]✓[/green] Hooks: {hooks_config.path} on port {config.gateway.port}")
|
||||
|
||||
|
||||
if channels.enabled_channels:
|
||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||
else:
|
||||
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
||||
|
||||
|
||||
cron_status = cron.status()
|
||||
if cron_status["jobs"] > 0:
|
||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
||||
|
||||
console.print("[green]✓[/green] Heartbeat: every 30m")
|
||||
|
||||
_start_moltbook_loop()
|
||||
|
||||
|
||||
console.print(f"[green]✓[/green] Heartbeat: every 30m")
|
||||
|
||||
async def run():
|
||||
try:
|
||||
await cron.start()
|
||||
await heartbeat.start()
|
||||
if hooks_server:
|
||||
await hooks_server.start()
|
||||
await asyncio.gather(
|
||||
agent.run(),
|
||||
channels.start_all(),
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\nShutting down...")
|
||||
if hooks_server:
|
||||
await hooks_server.stop()
|
||||
heartbeat.stop()
|
||||
cron.stop()
|
||||
agent.stop()
|
||||
await channels.stop_all()
|
||||
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@@ -502,14 +416,13 @@ def agent(
|
||||
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
|
||||
):
|
||||
"""Interact with the agent directly."""
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from loguru import logger
|
||||
|
||||
config = load_config()
|
||||
|
||||
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(config)
|
||||
|
||||
@@ -517,29 +430,7 @@ def agent(
|
||||
logger.enable("nanobot")
|
||||
else:
|
||||
logger.disable("nanobot")
|
||||
|
||||
# Convert mem0 config to dict for AgentLoop
|
||||
mem0_config = None
|
||||
if config.tools.mem0.enabled:
|
||||
mem0_config = {
|
||||
"enabled": True,
|
||||
"search_limit": config.tools.mem0.search_limit,
|
||||
}
|
||||
if config.tools.mem0.api_key:
|
||||
mem0_config["api_key"] = config.tools.mem0.api_key
|
||||
if config.tools.mem0.llm:
|
||||
mem0_config["llm"] = {
|
||||
"provider": "openai",
|
||||
"config": {"model": config.tools.mem0.llm}
|
||||
}
|
||||
if config.tools.mem0.embedder:
|
||||
mem0_config["embedder"] = {
|
||||
"provider": "openai",
|
||||
"config": {"model": config.tools.mem0.embedder}
|
||||
}
|
||||
if config.tools.mem0.vector_store:
|
||||
mem0_config["vector_store"] = config.tools.mem0.vector_store
|
||||
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
@@ -550,10 +441,8 @@ def agent(
|
||||
brave_api_key=config.tools.web.search.api_key or None,
|
||||
exec_config=config.tools.exec,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
enable_memory_tool=config.tools.enable_memory_tool,
|
||||
mem0_config=mem0_config,
|
||||
)
|
||||
|
||||
|
||||
# Show spinner when logs are off (no output to miss); skip when logs are on
|
||||
def _thinking_ctx():
|
||||
if logs:
|
||||
@@ -568,7 +457,7 @@ def agent(
|
||||
with _thinking_ctx():
|
||||
response = await agent_loop.process_direct(message, session_id)
|
||||
_print_agent_response(response, render_markdown=markdown)
|
||||
|
||||
|
||||
asyncio.run(run_once())
|
||||
else:
|
||||
# Interactive mode
|
||||
@@ -581,7 +470,7 @@ def agent(
|
||||
os._exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, _exit_on_sigint)
|
||||
|
||||
|
||||
async def run_interactive():
|
||||
while True:
|
||||
try:
|
||||
@@ -595,7 +484,7 @@ def agent(
|
||||
_restore_terminal()
|
||||
console.print("\nGoodbye!")
|
||||
break
|
||||
|
||||
|
||||
with _thinking_ctx():
|
||||
response = await agent_loop.process_direct(user_input, session_id)
|
||||
_print_agent_response(response, render_markdown=markdown)
|
||||
@@ -607,7 +496,7 @@ def agent(
|
||||
_restore_terminal()
|
||||
console.print("\nGoodbye!")
|
||||
break
|
||||
|
||||
|
||||
asyncio.run(run_interactive())
|
||||
|
||||
|
||||
@@ -619,6 +508,7 @@ def agent(
|
||||
channels_app = typer.Typer(help="Manage channels")
|
||||
app.add_typer(channels_app, name="channels")
|
||||
|
||||
from nanobot.cli.oauth import oauth_app
|
||||
app.add_typer(oauth_app, name="oauth")
|
||||
|
||||
|
||||
@@ -666,7 +556,7 @@ def channels_status():
|
||||
"✓" if mc.enabled else "✗",
|
||||
mc_base
|
||||
)
|
||||
|
||||
|
||||
# Telegram
|
||||
tg = config.channels.telegram
|
||||
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
|
||||
@@ -692,57 +582,57 @@ def _get_bridge_dir() -> Path:
|
||||
"""Get the bridge directory, setting it up if needed."""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
# User's bridge location
|
||||
user_bridge = Path.home() / ".nanobot" / "bridge"
|
||||
|
||||
|
||||
# Check if already built
|
||||
if (user_bridge / "dist" / "index.js").exists():
|
||||
return user_bridge
|
||||
|
||||
|
||||
# Check for npm
|
||||
if not shutil.which("npm"):
|
||||
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# Find source bridge: first check package data, then source dir
|
||||
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
|
||||
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
|
||||
|
||||
|
||||
source = None
|
||||
if (pkg_bridge / "package.json").exists():
|
||||
source = pkg_bridge
|
||||
elif (src_bridge / "package.json").exists():
|
||||
source = src_bridge
|
||||
|
||||
|
||||
if not source:
|
||||
console.print("[red]Bridge source not found.[/red]")
|
||||
console.print("Try reinstalling: pip install --force-reinstall nanobot")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
console.print(f"{__logo__} Setting up bridge...")
|
||||
|
||||
|
||||
# Copy to user directory
|
||||
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
||||
if user_bridge.exists():
|
||||
shutil.rmtree(user_bridge)
|
||||
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
|
||||
|
||||
|
||||
# Install and build
|
||||
try:
|
||||
console.print(" Installing dependencies...")
|
||||
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
|
||||
|
||||
|
||||
console.print(" Building...")
|
||||
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
||||
|
||||
|
||||
console.print("[green]✓[/green] Bridge ready\n")
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]Build failed: {e}[/red]")
|
||||
if e.stderr:
|
||||
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
return user_bridge
|
||||
|
||||
|
||||
@@ -750,19 +640,18 @@ def _get_bridge_dir() -> Path:
|
||||
def channels_login():
|
||||
"""Link device via QR code."""
|
||||
import subprocess
|
||||
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
|
||||
config = load_config()
|
||||
bridge_dir = _get_bridge_dir()
|
||||
|
||||
|
||||
console.print(f"{__logo__} Starting bridge...")
|
||||
console.print("Scan the QR code to connect.\n")
|
||||
|
||||
|
||||
env = {**os.environ}
|
||||
if config.channels.whatsapp.bridge_token:
|
||||
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
||||
|
||||
|
||||
try:
|
||||
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -786,23 +675,23 @@ def cron_list(
|
||||
"""List scheduled jobs."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
jobs = service.list_jobs(include_disabled=all)
|
||||
|
||||
|
||||
if not jobs:
|
||||
console.print("No scheduled jobs.")
|
||||
return
|
||||
|
||||
|
||||
table = Table(title="Scheduled Jobs")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Schedule")
|
||||
table.add_column("Status")
|
||||
table.add_column("Next Run")
|
||||
|
||||
|
||||
import time
|
||||
for job in jobs:
|
||||
# Format schedule
|
||||
@@ -812,17 +701,17 @@ def cron_list(
|
||||
sched = job.schedule.expr or ""
|
||||
else:
|
||||
sched = "one-time"
|
||||
|
||||
|
||||
# Format next run
|
||||
next_run = ""
|
||||
if job.state.next_run_at_ms:
|
||||
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
|
||||
next_run = next_time
|
||||
|
||||
|
||||
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
||||
|
||||
|
||||
table.add_row(job.id, job.name, sched, status, next_run)
|
||||
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -841,7 +730,7 @@ def cron_add(
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronSchedule
|
||||
|
||||
|
||||
# Determine schedule type
|
||||
if every:
|
||||
schedule = CronSchedule(kind="every", every_ms=every * 1000)
|
||||
@@ -854,10 +743,10 @@ def cron_add(
|
||||
else:
|
||||
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
job = service.add_job(
|
||||
name=name,
|
||||
schedule=schedule,
|
||||
@@ -866,7 +755,7 @@ def cron_add(
|
||||
to=to,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
|
||||
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
||||
|
||||
|
||||
@@ -877,10 +766,10 @@ def cron_remove(
|
||||
"""Remove a scheduled job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
if service.remove_job(job_id):
|
||||
console.print(f"[green]✓[/green] Removed job {job_id}")
|
||||
else:
|
||||
@@ -895,10 +784,10 @@ def cron_enable(
|
||||
"""Enable or disable a job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
job = service.enable_job(job_id, enabled=not disable)
|
||||
if job:
|
||||
status = "disabled" if disable else "enabled"
|
||||
@@ -915,15 +804,15 @@ def cron_run(
|
||||
"""Manually run a job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
async def run():
|
||||
return await service.run_job(job_id, force=force)
|
||||
|
||||
|
||||
if asyncio.run(run()):
|
||||
console.print("[green]✓[/green] Job executed")
|
||||
console.print(f"[green]✓[/green] Job executed")
|
||||
else:
|
||||
console.print(f"[red]Failed to run job {job_id}[/red]")
|
||||
|
||||
@@ -936,7 +825,7 @@ def cron_run(
|
||||
@app.command()
|
||||
def status():
|
||||
"""Show nanobot status."""
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
from nanobot.config.loader import load_config, get_config_path
|
||||
|
||||
config_path = get_config_path()
|
||||
config = load_config()
|
||||
@@ -951,7 +840,7 @@ def status():
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
console.print(f"Model: {config.agents.defaults.model}")
|
||||
|
||||
|
||||
# Check API keys from registry
|
||||
for spec in PROVIDERS:
|
||||
p = getattr(config.providers, spec.name, None)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
@@ -48,10 +49,10 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
data = _migrate_config(data)
|
||||
config = Config.model_validate(data)
|
||||
config = Config.model_validate(convert_keys(data))
|
||||
return _inject_oauth_credentials(config)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
print(f"Warning: Failed to load config from {path}: {e}")
|
||||
@@ -63,18 +64,20 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||
"""
|
||||
Save configuration to file.
|
||||
|
||||
|
||||
Args:
|
||||
config: Configuration to save.
|
||||
config_path: Optional path to save to. Uses default if not provided.
|
||||
"""
|
||||
path = config_path or get_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = config.model_dump(by_alias=True)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Convert to camelCase format
|
||||
data = config.model_dump()
|
||||
data = convert_to_camel(data)
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def _migrate_config(data: dict) -> dict:
|
||||
@@ -85,3 +88,37 @@ def _migrate_config(data: dict) -> dict:
|
||||
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
|
||||
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
||||
return data
|
||||
|
||||
|
||||
def convert_keys(data: Any) -> Any:
|
||||
"""Convert camelCase keys to snake_case for Pydantic."""
|
||||
if isinstance(data, dict):
|
||||
return {camel_to_snake(k): convert_keys(v) for k, v in data.items()}
|
||||
if isinstance(data, list):
|
||||
return [convert_keys(item) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
def convert_to_camel(data: Any) -> Any:
|
||||
"""Convert snake_case keys to camelCase."""
|
||||
if isinstance(data, dict):
|
||||
return {snake_to_camel(k): convert_to_camel(v) for k, v in data.items()}
|
||||
if isinstance(data, list):
|
||||
return [convert_to_camel(item) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
def camel_to_snake(name: str) -> str:
|
||||
"""Convert camelCase to snake_case."""
|
||||
result = []
|
||||
for i, char in enumerate(name):
|
||||
if char.isupper() and i > 0:
|
||||
result.append("_")
|
||||
result.append(char.lower())
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def snake_to_camel(name: str) -> str:
|
||||
"""Convert snake_case to camelCase."""
|
||||
components = name.split("_")
|
||||
return components[0] + "".join(x.title() for x in components[1:])
|
||||
|
||||
+34
-188
@@ -1,89 +1,54 @@
|
||||
"""Configuration schema using Pydantic."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Base(BaseModel):
|
||||
"""Base model that accepts both camelCase and snake_case keys."""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class WhatsAppConfig(Base):
|
||||
class WhatsAppConfig(BaseModel):
|
||||
"""WhatsApp channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
bridge_url: str = "ws://localhost:3001"
|
||||
bridge_token: str = "" # Shared token for bridge auth (optional, recommended)
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
|
||||
|
||||
|
||||
class TelegramConfig(Base):
|
||||
class TelegramConfig(BaseModel):
|
||||
"""Telegram channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
token: str = "" # Bot token from @BotFather
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames
|
||||
proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
||||
reply_to_message: bool = False # If true, bot replies quote the original message
|
||||
|
||||
|
||||
class FeishuConfig(Base):
|
||||
class FeishuConfig(BaseModel):
|
||||
"""Feishu/Lark channel configuration using WebSocket long connection."""
|
||||
|
||||
enabled: bool = False
|
||||
app_id: str = "" # App ID from Feishu Open Platform
|
||||
app_secret: str = "" # App Secret from Feishu Open Platform
|
||||
encrypt_key: str = "" # Encrypt Key for event subscription (optional)
|
||||
verification_token: str = "" # Verification Token for event subscription (optional)
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
|
||||
react_emoji: str = "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE)
|
||||
|
||||
|
||||
class DingTalkConfig(Base):
|
||||
class DingTalkConfig(BaseModel):
|
||||
"""DingTalk channel configuration using Stream mode."""
|
||||
|
||||
enabled: bool = False
|
||||
client_id: str = "" # AppKey
|
||||
client_secret: str = "" # AppSecret
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids
|
||||
|
||||
|
||||
class DiscordConfig(Base):
|
||||
class DiscordConfig(BaseModel):
|
||||
"""Discord channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
token: str = "" # Bot token from Discord Developer Portal
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
|
||||
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
|
||||
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
|
||||
|
||||
|
||||
class MatrixConfig(Base):
|
||||
"""Matrix (Element) channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
homeserver: str = "https://matrix.org"
|
||||
access_token: str = ""
|
||||
user_id: str = "" # @bot:matrix.org
|
||||
device_id: str = ""
|
||||
e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling).
|
||||
sync_stop_grace_seconds: int = 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback.
|
||||
max_media_bytes: int = 20 * 1024 * 1024 # Max attachment size accepted for Matrix media handling (inbound + outbound).
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||
group_allow_from: list[str] = Field(default_factory=list)
|
||||
allow_room_mentions: bool = False
|
||||
|
||||
|
||||
class EmailConfig(Base):
|
||||
class EmailConfig(BaseModel):
|
||||
"""Email channel configuration (IMAP inbound + SMTP outbound)."""
|
||||
|
||||
enabled: bool = False
|
||||
consent_granted: bool = False # Explicit owner permission to access mailbox data
|
||||
|
||||
@@ -113,21 +78,18 @@ class EmailConfig(Base):
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses
|
||||
|
||||
|
||||
class MochatMentionConfig(Base):
|
||||
class MochatMentionConfig(BaseModel):
|
||||
"""Mochat mention behavior configuration."""
|
||||
|
||||
require_in_groups: bool = False
|
||||
|
||||
|
||||
class MochatGroupRule(Base):
|
||||
class MochatGroupRule(BaseModel):
|
||||
"""Mochat per-group mention requirement."""
|
||||
|
||||
require_mention: bool = False
|
||||
|
||||
|
||||
class MochatConfig(Base):
|
||||
class MochatConfig(BaseModel):
|
||||
"""Mochat channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
base_url: str = "https://mochat.io"
|
||||
socket_url: str = ""
|
||||
@@ -152,58 +114,36 @@ class MochatConfig(Base):
|
||||
reply_delay_ms: int = 120000
|
||||
|
||||
|
||||
class SlackDMConfig(Base):
|
||||
class SlackDMConfig(BaseModel):
|
||||
"""Slack DM policy configuration."""
|
||||
|
||||
enabled: bool = True
|
||||
policy: str = "open" # "open" or "allowlist"
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs
|
||||
|
||||
|
||||
class SlackConfig(Base):
|
||||
class SlackConfig(BaseModel):
|
||||
"""Slack channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
mode: str = "socket" # "socket" supported
|
||||
webhook_path: str = "/slack/events"
|
||||
bot_token: str = "" # xoxb-...
|
||||
app_token: str = "" # xapp-...
|
||||
user_token_read_only: bool = True
|
||||
reply_in_thread: bool = True
|
||||
react_emoji: str = "eyes"
|
||||
group_policy: str = "mention" # "mention", "open", "allowlist"
|
||||
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
|
||||
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
||||
|
||||
|
||||
class QQConfig(Base):
|
||||
class QQConfig(BaseModel):
|
||||
"""QQ channel configuration using botpy SDK."""
|
||||
|
||||
enabled: bool = False
|
||||
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
|
||||
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access)
|
||||
|
||||
class MatrixConfig(Base):
|
||||
"""Matrix (Element) channel configuration."""
|
||||
enabled: bool = False
|
||||
homeserver: str = "https://matrix.org"
|
||||
access_token: str = ""
|
||||
user_id: str = "" # e.g. @bot:matrix.org
|
||||
device_id: str = ""
|
||||
e2ee_enabled: bool = True # end-to-end encryption support
|
||||
sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout
|
||||
max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||
group_allow_from: list[str] = Field(default_factory=list)
|
||||
allow_room_mentions: bool = False
|
||||
|
||||
class ChannelsConfig(Base):
|
||||
class ChannelsConfig(BaseModel):
|
||||
"""Configuration for chat channels."""
|
||||
|
||||
send_progress: bool = True # stream agent's text progress to the channel
|
||||
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
||||
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
||||
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
||||
discord: DiscordConfig = Field(default_factory=DiscordConfig)
|
||||
@@ -213,25 +153,21 @@ class ChannelsConfig(Base):
|
||||
email: EmailConfig = Field(default_factory=EmailConfig)
|
||||
slack: SlackConfig = Field(default_factory=SlackConfig)
|
||||
qq: QQConfig = Field(default_factory=QQConfig)
|
||||
matrix: MatrixConfig = Field(default_factory=MatrixConfig)
|
||||
|
||||
|
||||
class AgentDefaults(Base):
|
||||
class AgentDefaults(BaseModel):
|
||||
"""Default agent configuration."""
|
||||
|
||||
workspace: str = "~/.nanobot/workspace"
|
||||
model: str = "anthropic/claude-opus-4-5"
|
||||
provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
||||
max_tokens: int = 8192
|
||||
temperature: float = 0.1
|
||||
max_tool_iterations: int = 40
|
||||
memory_window: int = 100
|
||||
temperature: float = 0.7
|
||||
max_tool_iterations: int = 20
|
||||
memory_window: int = 50
|
||||
thinking_budget: int = 0 # 0 = disabled; >0 = token budget for extended thinking
|
||||
|
||||
|
||||
class AgentsConfig(Base):
|
||||
class AgentsConfig(BaseModel):
|
||||
"""Agent configuration."""
|
||||
|
||||
defaults: AgentDefaults = Field(default_factory=AgentDefaults)
|
||||
|
||||
|
||||
@@ -264,19 +200,16 @@ class OAuthCredentials(BaseModel):
|
||||
return time.time() > (self.expires_at - 600)
|
||||
|
||||
|
||||
class ProviderConfig(Base):
|
||||
class ProviderConfig(BaseModel):
|
||||
"""LLM provider configuration."""
|
||||
|
||||
api_key: str = ""
|
||||
api_base: str | None = None
|
||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||
oauth_credentials: OAuthCredentials | None = None
|
||||
|
||||
|
||||
class ProvidersConfig(Base):
|
||||
class ProvidersConfig(BaseModel):
|
||||
"""Configuration for LLM providers."""
|
||||
|
||||
custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint
|
||||
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
@@ -289,152 +222,63 @@ class ProvidersConfig(Base):
|
||||
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
||||
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway
|
||||
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) API gateway
|
||||
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
|
||||
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
|
||||
|
||||
|
||||
class HeartbeatConfig(Base):
|
||||
"""Heartbeat service configuration."""
|
||||
|
||||
enabled: bool = True
|
||||
interval_s: int = 30 * 60 # 30 minutes
|
||||
|
||||
|
||||
class GatewayConfig(Base):
|
||||
class GatewayConfig(BaseModel):
|
||||
"""Gateway/server configuration."""
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 18790
|
||||
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
|
||||
|
||||
|
||||
class HooksConfig(BaseModel):
|
||||
"""Webhook endpoint configuration."""
|
||||
enabled: bool = False
|
||||
tokens: dict[str, str] = Field(default_factory=dict) # Named tokens: {name: secret}
|
||||
path: str = "/hooks" # URL path for the endpoint
|
||||
timeout_seconds: int = 120 # Max time to wait for agent response
|
||||
|
||||
def resolve_token(self, provided: str) -> str | None:
|
||||
"""Return token name if provided secret matches, else None."""
|
||||
for name, secret in self.tokens.items():
|
||||
if secret == provided:
|
||||
return name
|
||||
return None
|
||||
|
||||
@property
|
||||
def has_tokens(self) -> bool:
|
||||
"""True if at least one token is configured."""
|
||||
return bool(self.tokens)
|
||||
|
||||
|
||||
class WebSearchConfig(BaseModel):
|
||||
"""Web search tool configuration."""
|
||||
|
||||
api_key: str = "" # Brave Search API key
|
||||
max_results: int = 5
|
||||
|
||||
|
||||
class WebToolsConfig(Base):
|
||||
class WebToolsConfig(BaseModel):
|
||||
"""Web tools configuration."""
|
||||
|
||||
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
|
||||
|
||||
|
||||
class ExecToolConfig(Base):
|
||||
class ExecToolConfig(BaseModel):
|
||||
"""Shell exec tool configuration."""
|
||||
|
||||
timeout: int = 60
|
||||
path_append: str = ""
|
||||
|
||||
|
||||
class MCPServerConfig(Base):
|
||||
"""MCP server connection configuration (stdio or HTTP)."""
|
||||
|
||||
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
||||
url: str = "" # HTTP: streamable HTTP endpoint URL
|
||||
headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers
|
||||
tool_timeout: int = 30 # Seconds before a tool call is cancelled
|
||||
|
||||
|
||||
class Mem0Config(Base):
|
||||
"""Mem0 memory system configuration."""
|
||||
|
||||
enabled: bool = False # If true, use mem0 for semantic memory instead of simple MEMORY.md
|
||||
api_key: str = "" # Optional: mem0 cloud API key (leave empty for self-hosted)
|
||||
search_limit: int = 5 # Max memories to retrieve per query
|
||||
llm: str = "" # Optional: LLM for memory extraction (default: gpt-4.1-nano-2025-04-14)
|
||||
embedder: str = "" # Optional: Embedding model (default: mem0's default)
|
||||
vector_store: dict[str, Any] = Field(default_factory=dict) # Vector store config (e.g., {"provider": "qdrant", "config": {...}})
|
||||
|
||||
|
||||
class ToolsConfig(Base):
|
||||
class ToolsConfig(BaseModel):
|
||||
"""Tools configuration."""
|
||||
|
||||
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
|
||||
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
||||
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
|
||||
enable_memory_tool: bool = True # If true, enable Anthropic's native memory tool
|
||||
mem0: Mem0Config = Field(default_factory=Mem0Config) # Mem0 semantic memory configuration
|
||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class Config(BaseSettings):
|
||||
"""Root configuration for nanobot."""
|
||||
|
||||
agents: AgentsConfig = Field(default_factory=AgentsConfig)
|
||||
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
|
||||
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
|
||||
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
||||
hooks: HooksConfig = Field(default_factory=HooksConfig)
|
||||
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
||||
|
||||
|
||||
@property
|
||||
def workspace_path(self) -> Path:
|
||||
"""Get expanded workspace path."""
|
||||
return Path(self.agents.defaults.workspace).expanduser()
|
||||
|
||||
|
||||
def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]:
|
||||
"""Match provider config and its registry name. Returns (config, spec_name)."""
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
forced = self.agents.defaults.provider
|
||||
if forced != "auto":
|
||||
p = getattr(self.providers, forced, None)
|
||||
return (p, forced) if p else (None, None)
|
||||
|
||||
model_lower = (model or self.agents.defaults.model).lower()
|
||||
model_normalized = model_lower.replace("-", "_")
|
||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||
normalized_prefix = model_prefix.replace("-", "_")
|
||||
|
||||
def _kw_matches(kw: str) -> bool:
|
||||
kw = kw.lower()
|
||||
return kw in model_lower or kw.replace("-", "_") in model_normalized
|
||||
|
||||
# Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex.
|
||||
for spec in PROVIDERS:
|
||||
p = getattr(self.providers, spec.name, None)
|
||||
if p and model_prefix and normalized_prefix == spec.name:
|
||||
if spec.is_oauth or p.api_key:
|
||||
return p, spec.name
|
||||
|
||||
# Match by keyword (order follows PROVIDERS registry)
|
||||
for spec in PROVIDERS:
|
||||
p = getattr(self.providers, spec.name, None)
|
||||
if p and any(_kw_matches(kw) for kw in spec.keywords):
|
||||
if spec.is_oauth or p.api_key:
|
||||
return p, spec.name
|
||||
if p and any(kw in model_lower for kw in spec.keywords) and p.api_key:
|
||||
return p, spec.name
|
||||
|
||||
# Fallback: gateways first, then others (follows registry order)
|
||||
# OAuth providers are NOT valid fallbacks — they require explicit model selection
|
||||
for spec in PROVIDERS:
|
||||
if spec.is_oauth:
|
||||
continue
|
||||
p = getattr(self.providers, spec.name, None)
|
||||
if p and p.api_key:
|
||||
return p, spec.name
|
||||
@@ -454,11 +298,10 @@ class Config(BaseSettings):
|
||||
"""Get API key for the given model. Falls back to first available key."""
|
||||
p = self.get_provider(model)
|
||||
return p.api_key if p else None
|
||||
|
||||
|
||||
def get_api_base(self, model: str | None = None) -> str | None:
|
||||
"""Get API base URL for the given model. Applies default URLs for known gateways."""
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
p, name = self._match_provider(model)
|
||||
if p and p.api_base:
|
||||
return p.api_base
|
||||
@@ -470,5 +313,8 @@ class Config(BaseSettings):
|
||||
if spec and spec.is_gateway and spec.default_api_base:
|
||||
return spec.default_api_base
|
||||
return None
|
||||
|
||||
model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__")
|
||||
|
||||
model_config = ConfigDict(
|
||||
env_prefix="NANOBOT_",
|
||||
env_nested_delimiter="__"
|
||||
)
|
||||
|
||||
+12
-33
@@ -4,7 +4,6 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
@@ -31,34 +30,15 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
|
||||
if schedule.kind == "cron" and schedule.expr:
|
||||
try:
|
||||
from croniter import croniter
|
||||
from zoneinfo import ZoneInfo
|
||||
# Use caller-provided reference time for deterministic scheduling
|
||||
base_time = now_ms / 1000
|
||||
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
|
||||
base_dt = datetime.fromtimestamp(base_time, tz=tz)
|
||||
cron = croniter(schedule.expr, base_dt)
|
||||
next_dt = cron.get_next(datetime)
|
||||
return int(next_dt.timestamp() * 1000)
|
||||
cron = croniter(schedule.expr, time.time())
|
||||
next_time = cron.get_next()
|
||||
return int(next_time * 1000)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_schedule_for_add(schedule: CronSchedule) -> None:
|
||||
"""Validate schedule fields that would otherwise create non-runnable jobs."""
|
||||
if schedule.tz and schedule.kind != "cron":
|
||||
raise ValueError("tz can only be used with cron schedules")
|
||||
|
||||
if schedule.kind == "cron" and schedule.tz:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
ZoneInfo(schedule.tz)
|
||||
except Exception:
|
||||
raise ValueError(f"unknown timezone '{schedule.tz}'") from None
|
||||
|
||||
|
||||
class CronService:
|
||||
"""Service for managing and executing scheduled jobs."""
|
||||
|
||||
@@ -80,7 +60,7 @@ class CronService:
|
||||
|
||||
if self.store_path.exists():
|
||||
try:
|
||||
data = json.loads(self.store_path.read_text(encoding="utf-8"))
|
||||
data = json.loads(self.store_path.read_text())
|
||||
jobs = []
|
||||
for j in data.get("jobs", []):
|
||||
jobs.append(CronJob(
|
||||
@@ -113,7 +93,7 @@ class CronService:
|
||||
))
|
||||
self._store = CronStore(jobs=jobs)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load cron store: {}", e)
|
||||
logger.warning(f"Failed to load cron store: {e}")
|
||||
self._store = CronStore()
|
||||
else:
|
||||
self._store = CronStore()
|
||||
@@ -162,7 +142,7 @@ class CronService:
|
||||
]
|
||||
}
|
||||
|
||||
self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
self.store_path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the cron service."""
|
||||
@@ -171,7 +151,7 @@ class CronService:
|
||||
self._recompute_next_runs()
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else []))
|
||||
logger.info(f"Cron service started with {len(self._store.jobs if self._store else [])} jobs")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the cron service."""
|
||||
@@ -236,7 +216,7 @@ class CronService:
|
||||
async def _execute_job(self, job: CronJob) -> None:
|
||||
"""Execute a single job."""
|
||||
start_ms = _now_ms()
|
||||
logger.info("Cron: executing job '{}' ({})", job.name, job.id)
|
||||
logger.info(f"Cron: executing job '{job.name}' ({job.id})")
|
||||
|
||||
try:
|
||||
response = None
|
||||
@@ -245,12 +225,12 @@ class CronService:
|
||||
|
||||
job.state.last_status = "ok"
|
||||
job.state.last_error = None
|
||||
logger.info("Cron: job '{}' completed", job.name)
|
||||
logger.info(f"Cron: job '{job.name}' completed")
|
||||
|
||||
except Exception as e:
|
||||
job.state.last_status = "error"
|
||||
job.state.last_error = str(e)
|
||||
logger.error("Cron: job '{}' failed: {}", job.name, e)
|
||||
logger.error(f"Cron: job '{job.name}' failed: {e}")
|
||||
|
||||
job.state.last_run_at_ms = start_ms
|
||||
job.updated_at_ms = _now_ms()
|
||||
@@ -286,7 +266,6 @@ class CronService:
|
||||
) -> CronJob:
|
||||
"""Add a new job."""
|
||||
store = self._load_store()
|
||||
_validate_schedule_for_add(schedule)
|
||||
now = _now_ms()
|
||||
|
||||
job = CronJob(
|
||||
@@ -311,7 +290,7 @@ class CronService:
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
|
||||
logger.info("Cron: added job '{}' ({})", name, job.id)
|
||||
logger.info(f"Cron: added job '{name}' ({job.id})")
|
||||
return job
|
||||
|
||||
def remove_job(self, job_id: str) -> bool:
|
||||
@@ -324,7 +303,7 @@ class CronService:
|
||||
if removed:
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
logger.info("Cron: removed job {}", job_id)
|
||||
logger.info(f"Cron: removed job {job_id}")
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
"""Heartbeat service - periodic agent wake-up to check for tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
# Default interval: 30 minutes
|
||||
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
|
||||
|
||||
@@ -27,51 +22,45 @@ def _is_heartbeat_empty(content: str | None) -> bool:
|
||||
"""Check if HEARTBEAT.md has no actionable content."""
|
||||
if not content:
|
||||
return True
|
||||
|
||||
|
||||
# Lines to skip: empty, headers, HTML comments, empty checkboxes
|
||||
skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"}
|
||||
|
||||
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns:
|
||||
continue
|
||||
return False # Found actionable content
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class HeartbeatService:
|
||||
"""
|
||||
Periodic heartbeat service that wakes the agent to check for tasks.
|
||||
|
||||
|
||||
The agent reads HEARTBEAT.md from the workspace and executes any
|
||||
tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Path,
|
||||
on_heartbeat: Callable[[str, dict[str, Any] | None], Coroutine[Any, Any, str]] | None = None,
|
||||
on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
||||
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
|
||||
enabled: bool = True,
|
||||
session_manager: SessionManager | None = None,
|
||||
target_session_key: str = "telegram:239824268",
|
||||
idle_threshold_s: int = 30 * 60, # 30 minutes
|
||||
):
|
||||
self.workspace = workspace
|
||||
self.on_heartbeat = on_heartbeat
|
||||
self.interval_s = interval_s
|
||||
self.enabled = enabled
|
||||
self.session_manager = session_manager
|
||||
self.target_session_key = target_session_key
|
||||
self.idle_threshold_s = idle_threshold_s
|
||||
self._running = False
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@property
|
||||
def heartbeat_file(self) -> Path:
|
||||
return self.workspace / "HEARTBEAT.md"
|
||||
|
||||
|
||||
def _read_heartbeat_file(self) -> str | None:
|
||||
"""Read HEARTBEAT.md content."""
|
||||
if self.heartbeat_file.exists():
|
||||
@@ -80,24 +69,24 @@ class HeartbeatService:
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the heartbeat service."""
|
||||
if not self.enabled:
|
||||
logger.info("Heartbeat disabled")
|
||||
return
|
||||
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info(f"Heartbeat started (every {self.interval_s}s)")
|
||||
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the heartbeat service."""
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
"""Main heartbeat loop."""
|
||||
while self._running:
|
||||
@@ -109,68 +98,33 @@ class HeartbeatService:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat error: {e}")
|
||||
|
||||
|
||||
async def _tick(self) -> None:
|
||||
"""Execute a single heartbeat tick."""
|
||||
|
||||
# Check if user is idle (if session manager provided)
|
||||
if self.session_manager and self.target_session_key:
|
||||
try:
|
||||
session = self.session_manager.get_or_create(self.target_session_key)
|
||||
|
||||
# Find last real user message timestamp (exclude system-generated messages)
|
||||
# Real Telegram messages have sender_id like "239824268|username"
|
||||
# System messages (heartbeat, cron) created via process_direct have sender_id="user"
|
||||
# Old messages may not have sender_id field (backwards compat: treat as real user messages)
|
||||
last_user_timestamp = None
|
||||
for msg in reversed(session.messages):
|
||||
if msg.get("role") == "user":
|
||||
sender_id = msg.get("sender_id")
|
||||
# Skip if explicitly marked as system-generated
|
||||
if sender_id == "user":
|
||||
continue
|
||||
# Accept if no sender_id (old message) or if real user ID
|
||||
last_user_timestamp = msg.get("timestamp")
|
||||
break
|
||||
|
||||
if last_user_timestamp:
|
||||
from datetime import datetime
|
||||
last_dt = datetime.fromisoformat(last_user_timestamp)
|
||||
elapsed = (datetime.now() - last_dt).total_seconds()
|
||||
|
||||
if elapsed < self.idle_threshold_s:
|
||||
logger.debug(f"Heartbeat: user active {int(elapsed)}s ago, skipping")
|
||||
return # User is active, don't trigger heartbeat
|
||||
except Exception as e:
|
||||
logger.warning(f"Heartbeat: error checking idle state: {e}")
|
||||
# Continue with heartbeat on error (fail open)
|
||||
|
||||
# Original heartbeat logic
|
||||
content = self._read_heartbeat_file()
|
||||
|
||||
|
||||
# Skip if HEARTBEAT.md is empty or doesn't exist
|
||||
if _is_heartbeat_empty(content):
|
||||
logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
|
||||
return
|
||||
|
||||
logger.info("Heartbeat: user idle, checking for tasks...")
|
||||
|
||||
|
||||
logger.info("Heartbeat: checking for tasks...")
|
||||
|
||||
if self.on_heartbeat:
|
||||
try:
|
||||
# Call with suppress_output metadata
|
||||
await self.on_heartbeat(
|
||||
HEARTBEAT_PROMPT,
|
||||
metadata={"suppress_output": True}
|
||||
)
|
||||
|
||||
# Note: HEARTBEAT_OK check removed - suppress mode makes it unnecessary
|
||||
logger.info("Heartbeat: completed")
|
||||
|
||||
response = await self.on_heartbeat(HEARTBEAT_PROMPT)
|
||||
|
||||
# Check if agent said "nothing to do"
|
||||
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
|
||||
logger.info("Heartbeat: OK (no action needed)")
|
||||
else:
|
||||
logger.info(f"Heartbeat: completed task")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat execution failed: {e}")
|
||||
|
||||
|
||||
async def trigger_now(self) -> str | None:
|
||||
"""Manually trigger a heartbeat."""
|
||||
if self.on_heartbeat:
|
||||
return await self.on_heartbeat(HEARTBEAT_PROMPT, metadata={"suppress_output": True})
|
||||
return await self.on_heartbeat(HEARTBEAT_PROMPT)
|
||||
return None
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
"""HTTP hooks server for external service integration."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import HooksConfig
|
||||
|
||||
|
||||
class HooksServer:
|
||||
"""
|
||||
HTTP server exposing a /hooks endpoint.
|
||||
|
||||
External services POST JSON messages. The server publishes them
|
||||
to the bus as InboundMessages and uses bus-level correlation
|
||||
to return the agent's response synchronously.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
config: HooksConfig,
|
||||
bus: MessageBus,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.config = config
|
||||
self.bus = bus
|
||||
self._app = web.Application()
|
||||
self._app.router.add_post(self.config.path, self._handle_hook)
|
||||
self._app.router.add_get("/health", self._handle_health)
|
||||
self._runner: web.AppRunner | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the HTTP server."""
|
||||
if not self.config.has_tokens:
|
||||
logger.warning("Hooks server has no tokens configured — endpoint disabled for security")
|
||||
return
|
||||
|
||||
self._runner = web.AppRunner(self._app)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, self.host, self.port)
|
||||
await site.start()
|
||||
logger.info(f"Hooks server listening on {self.host}:{self.port}{self.config.path}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the HTTP server."""
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
|
||||
def _resolve_auth(self, request: web.Request) -> str | None:
|
||||
"""
|
||||
Validate auth and return token name if valid, None otherwise.
|
||||
Checks Authorization: Bearer <token> and X-Hook-Token headers.
|
||||
"""
|
||||
# Try Authorization: Bearer <token>
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token = auth[7:]
|
||||
else:
|
||||
# Try X-Hook-Token header
|
||||
token = request.headers.get("X-Hook-Token", "")
|
||||
|
||||
return self.config.resolve_token(token) if token else None
|
||||
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
"""Health check endpoint — no auth required."""
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
async def _handle_hook(self, request: web.Request) -> web.Response:
|
||||
"""Handle incoming hook request."""
|
||||
# Auth check — resolve token name
|
||||
token_name = self._resolve_auth(request)
|
||||
if not token_name:
|
||||
return web.json_response({"error": "unauthorized"}, status=401)
|
||||
|
||||
# Parse body
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||
|
||||
# Validate required fields
|
||||
message = body.get("message")
|
||||
if not message or not isinstance(message, str):
|
||||
return web.json_response(
|
||||
{"error": "missing or invalid 'message' field"}, status=400
|
||||
)
|
||||
|
||||
# Optional fields
|
||||
channel = body.get("channel", "hook")
|
||||
chat_id = body.get("chat_id", token_name)
|
||||
timeout = body.get("timeout", self.config.timeout_seconds)
|
||||
|
||||
# Create correlation
|
||||
correlation_id = str(uuid.uuid4())
|
||||
|
||||
# Build InboundMessage
|
||||
msg = InboundMessage(
|
||||
channel=channel,
|
||||
sender_id=f"hook:{token_name}",
|
||||
chat_id=str(chat_id),
|
||||
content=message,
|
||||
metadata={
|
||||
"correlation_id": correlation_id,
|
||||
"hook_source": token_name,
|
||||
},
|
||||
)
|
||||
|
||||
# Fire-and-forget mode
|
||||
if timeout == 0:
|
||||
await self.bus.publish_inbound(msg)
|
||||
return web.json_response({"ok": True}, status=202)
|
||||
|
||||
# Request-response mode
|
||||
future = self.bus.register_correlation(correlation_id)
|
||||
await self.bus.publish_inbound(msg)
|
||||
|
||||
try:
|
||||
response = await asyncio.wait_for(future, timeout=timeout)
|
||||
return web.json_response({"ok": True, "response": response})
|
||||
except asyncio.TimeoutError:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": f"agent did not respond within {timeout}s"},
|
||||
status=504,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Hook processing error: {e}")
|
||||
return web.json_response({"error": "internal error"}, status=500)
|
||||
finally:
|
||||
# Clean up correlation on any failure
|
||||
self.bus.cancel_correlation(correlation_id)
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||
from nanobot.providers.registry import should_use_oauth_provider
|
||||
|
||||
@@ -11,7 +10,6 @@ __all__ = [
|
||||
"LLMResponse",
|
||||
"ToolCallRequest",
|
||||
"LiteLLMProvider",
|
||||
"OpenAICodexProvider",
|
||||
"AnthropicOAuthProvider",
|
||||
"create_provider",
|
||||
]
|
||||
|
||||
@@ -199,41 +199,21 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
|
||||
def _convert_tools_to_anthropic(
|
||||
self,
|
||||
tools: list[dict[str, Any]] | list[Any] | None
|
||||
tools: list[dict[str, Any]] | None
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Convert tools to Anthropic API format.
|
||||
|
||||
Supports both function tools (custom) and native tools (Anthropic).
|
||||
Function tools are converted to Anthropic format.
|
||||
Native tools are passed through unchanged.
|
||||
Tool objects (with to_params/to_schema methods) are converted to dicts.
|
||||
"""
|
||||
"""Convert OpenAI-format tools to Anthropic format."""
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
anthropic_tools = []
|
||||
for tool in tools:
|
||||
# Convert tool objects to dicts first
|
||||
if hasattr(tool, 'to_params'): # Native Anthropic tool
|
||||
tool_dict = tool.to_params()
|
||||
elif hasattr(tool, 'to_schema'): # Function tool
|
||||
tool_dict = tool.to_schema()
|
||||
else:
|
||||
tool_dict = tool # Already a dict
|
||||
|
||||
# Now process the dict
|
||||
if tool_dict.get("type") == "function":
|
||||
# Convert function tool format
|
||||
func = tool_dict["function"]
|
||||
if tool.get("type") == "function":
|
||||
func = tool["function"]
|
||||
anthropic_tools.append({
|
||||
"name": func["name"],
|
||||
"description": func.get("description", ""),
|
||||
"input_schema": func.get("parameters", {"type": "object", "properties": {}})
|
||||
})
|
||||
else:
|
||||
# Pass through native tool format as-is
|
||||
# (bash_20250124, text_editor_20250728, computer_20251124, etc.)
|
||||
anthropic_tools.append(tool_dict)
|
||||
|
||||
return anthropic_tools if anthropic_tools else None
|
||||
|
||||
@@ -246,25 +226,10 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
temperature: float = 0.7,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
thinking_budget_override: int | None = None,
|
||||
context_management: dict[str, Any] | None = None,
|
||||
beta_flags: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Make request to Anthropic API."""
|
||||
client = await self._get_client()
|
||||
|
||||
# Cache the last user message so conversation history is cached across turns
|
||||
if messages:
|
||||
last = messages[-1]
|
||||
if last.get("role") == "user":
|
||||
content = last["content"]
|
||||
if isinstance(content, str):
|
||||
last = {**last, "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]}
|
||||
elif isinstance(content, list) and content:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
last = {**last, "content": new_content}
|
||||
messages = messages[:-1] + [last]
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -286,44 +251,21 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
payload["temperature"] = temperature
|
||||
|
||||
if system:
|
||||
payload["system"] = [{"type": "text", "text": system, "cache_control": {"type": "ephemeral", "ttl": "1h"}}]
|
||||
payload["system"] = system
|
||||
|
||||
if tools:
|
||||
cached_tools = list(tools)
|
||||
cached_tools[-1] = {**cached_tools[-1], "cache_control": {"type": "ephemeral", "ttl": "1h"}}
|
||||
payload["tools"] = cached_tools
|
||||
|
||||
if context_management:
|
||||
payload["context_management"] = context_management
|
||||
|
||||
edit_types = [e.get("type") for e in (context_management or {}).get("edits", [])]
|
||||
|
||||
# Build headers with beta flags if provided
|
||||
headers = self._get_headers()
|
||||
if beta_flags:
|
||||
# Merge with existing beta header (from OAuth hardcoded flags)
|
||||
existing_beta = headers.get("anthropic-beta", "")
|
||||
existing_flags = set(existing_beta.split(",")) if existing_beta else set()
|
||||
all_flags = existing_flags | beta_flags
|
||||
headers["anthropic-beta"] = ",".join(sorted(all_flags))
|
||||
payload["tools"] = tools
|
||||
|
||||
logger.info(
|
||||
"Anthropic request: model={} max_tokens={} thinking={} tools={} context_mgmt={} beta={}",
|
||||
"Anthropic request: model={} max_tokens={} thinking={} tools={}",
|
||||
payload.get("model"), payload.get("max_tokens"),
|
||||
payload.get("thinking", "disabled"),
|
||||
len(payload.get("tools", [])),
|
||||
edit_types or "none",
|
||||
headers.get("anthropic-beta", "none"),
|
||||
)
|
||||
|
||||
# Debug: Log tool names for diagnostic purposes
|
||||
if payload.get("tools"):
|
||||
tool_names = [t.get("name", "unnamed") for t in payload["tools"]]
|
||||
logger.debug(f"Tool names in request: {tool_names}")
|
||||
|
||||
response = await client.post(
|
||||
self._get_api_url(),
|
||||
headers=headers,
|
||||
headers=self._get_headers(),
|
||||
json=payload,
|
||||
)
|
||||
|
||||
@@ -369,12 +311,11 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | list[Any] | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_budget: int | None = None,
|
||||
context_management: dict[str, Any] | None = None,
|
||||
) -> LLMResponse:
|
||||
"""Send chat completion request to Anthropic API."""
|
||||
model = model or self.default_model
|
||||
@@ -387,17 +328,6 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
model = self._normalize_model(model)
|
||||
|
||||
system, prepared_messages = self._prepare_messages(messages)
|
||||
|
||||
# Collect beta flags from native tools BEFORE conversion
|
||||
beta_flags: set[str] = set()
|
||||
if tools:
|
||||
for tool in tools:
|
||||
if hasattr(tool, 'beta_flag') and tool.beta_flag:
|
||||
beta_flags.add(tool.beta_flag)
|
||||
|
||||
logger.debug(f"Beta flags collected: {beta_flags} (from {len(tools) if tools else 0} tools)")
|
||||
|
||||
# Convert tools to API format
|
||||
anthropic_tools = self._convert_tools_to_anthropic(tools)
|
||||
|
||||
# Per-call thinking override (None = use instance default)
|
||||
@@ -412,15 +342,11 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
temperature=temperature,
|
||||
tools=anthropic_tools,
|
||||
thinking_budget_override=effective_thinking,
|
||||
context_management=context_management,
|
||||
beta_flags=beta_flags,
|
||||
)
|
||||
return self._parse_response(response)
|
||||
except Exception as e:
|
||||
logger.exception("Exception in chat():")
|
||||
error_msg = f"{type(e).__name__}: {str(e)}" if str(e) else f"{type(e).__name__} (no message)"
|
||||
return LLMResponse(
|
||||
content=f"Error calling LLM: {error_msg}",
|
||||
content=f"Error calling LLM: {str(e)}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
@@ -458,33 +384,13 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
|
||||
stop_reason = response.get("stop_reason", "end_turn")
|
||||
thinking_chars = sum(len(b.get("thinking", "")) for b in thinking_blocks) if thinking_blocks else 0
|
||||
raw_usage = response.get("usage", {})
|
||||
cache_write = raw_usage.get("cache_creation_input_tokens", 0)
|
||||
cache_read = raw_usage.get("cache_read_input_tokens", 0)
|
||||
logger.info(
|
||||
"Anthropic response: stop={} tool_calls={} thinking={} chars, "
|
||||
"input={} output={} cache_write={} cache_read={} tokens",
|
||||
"input={} output={} tokens",
|
||||
stop_reason, len(tool_calls), thinking_chars,
|
||||
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
|
||||
cache_write, cache_read,
|
||||
)
|
||||
|
||||
# Log context editing activity if any edits were applied
|
||||
if applied_edits := response.get("context_management", {}).get("applied_edits"):
|
||||
for edit in applied_edits:
|
||||
edit_type = edit.get("type", "?")
|
||||
cleared_tokens = edit.get("cleared_input_tokens", 0)
|
||||
if edit_type == "clear_tool_uses_20250919":
|
||||
logger.info(
|
||||
"Context edit: cleared {} tool uses ({} tokens)",
|
||||
edit.get("cleared_tool_uses", 0), cleared_tokens,
|
||||
)
|
||||
elif edit_type == "clear_thinking_20251015":
|
||||
logger.info(
|
||||
"Context edit: cleared {} thinking turns ({} tokens)",
|
||||
edit.get("cleared_thinking_turns", 0), cleared_tokens,
|
||||
)
|
||||
|
||||
return LLMResponse(
|
||||
content=text_content or None,
|
||||
tool_calls=tool_calls,
|
||||
|
||||
@@ -39,46 +39,6 @@ class LLMProvider(ABC):
|
||||
def __init__(self, api_key: str | None = None, api_base: str | None = None):
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Replace empty text content that causes provider 400 errors.
|
||||
|
||||
Empty content can appear when MCP tools return nothing. Most providers
|
||||
reject empty-string content or empty text blocks in list content.
|
||||
"""
|
||||
result: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
|
||||
if isinstance(content, str) and not content:
|
||||
clean = dict(msg)
|
||||
clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)"
|
||||
result.append(clean)
|
||||
continue
|
||||
|
||||
if isinstance(content, list):
|
||||
filtered = [
|
||||
item for item in content
|
||||
if not (
|
||||
isinstance(item, dict)
|
||||
and item.get("type") in ("text", "input_text", "output_text")
|
||||
and not item.get("text")
|
||||
)
|
||||
]
|
||||
if len(filtered) != len(content):
|
||||
clean = dict(msg)
|
||||
if filtered:
|
||||
clean["content"] = filtered
|
||||
elif msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
clean["content"] = None
|
||||
else:
|
||||
clean["content"] = "(empty)"
|
||||
result.append(clean)
|
||||
continue
|
||||
|
||||
result.append(msg)
|
||||
return result
|
||||
|
||||
@abstractmethod
|
||||
async def chat(
|
||||
@@ -89,7 +49,6 @@ class LLMProvider(ABC):
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_budget: int | None = None,
|
||||
context_management: dict[str, Any] | None = None,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Send a chat completion request.
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Direct OpenAI-compatible provider — bypasses LiteLLM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import json_repair
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
|
||||
class CustomProvider(LLMProvider):
|
||||
|
||||
def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"):
|
||||
super().__init__(api_key, api_base)
|
||||
self.default_model = default_model
|
||||
self._client = AsyncOpenAI(api_key=api_key, base_url=api_base)
|
||||
|
||||
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse:
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model or self.default_model,
|
||||
"messages": self._sanitize_empty_content(messages),
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
}
|
||||
if tools:
|
||||
kwargs.update(tools=tools, tool_choice="auto")
|
||||
try:
|
||||
return self._parse(await self._client.chat.completions.create(**kwargs))
|
||||
except Exception as e:
|
||||
return LLMResponse(content=f"Error: {e}", finish_reason="error")
|
||||
|
||||
def _parse(self, response: Any) -> LLMResponse:
|
||||
choice = response.choices[0]
|
||||
msg = choice.message
|
||||
tool_calls = [
|
||||
ToolCallRequest(id=tc.id, name=tc.function.name,
|
||||
arguments=json_repair.loads(tc.function.arguments) if isinstance(tc.function.arguments, str) else tc.function.arguments)
|
||||
for tc in (msg.tool_calls or [])
|
||||
]
|
||||
u = response.usage
|
||||
return LLMResponse(
|
||||
content=msg.content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop",
|
||||
usage={"prompt_tokens": u.prompt_tokens, "completion_tokens": u.completion_tokens, "total_tokens": u.total_tokens} if u else {},
|
||||
reasoning_content=getattr(msg, "reasoning_content", None) or None,
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return self.default_model
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""LiteLLM provider implementation for multi-provider support."""
|
||||
|
||||
import json
|
||||
import json_repair
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import litellm
|
||||
@@ -14,16 +11,6 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.registry import find_by_model, find_gateway
|
||||
|
||||
|
||||
# Standard OpenAI chat-completion message keys plus reasoning_content for
|
||||
# thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.).
|
||||
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"})
|
||||
_ALNUM = string.ascii_letters + string.digits
|
||||
|
||||
def _short_tool_id() -> str:
|
||||
"""Generate a 9-char alphanumeric ID compatible with all providers (incl. Mistral)."""
|
||||
return "".join(secrets.choice(_ALNUM) for _ in range(9))
|
||||
|
||||
|
||||
class LiteLLMProvider(LLMProvider):
|
||||
"""
|
||||
LLM provider using LiteLLM for multi-provider support.
|
||||
@@ -67,9 +54,6 @@ class LiteLLMProvider(LLMProvider):
|
||||
spec = self._gateway or find_by_model(model)
|
||||
if not spec:
|
||||
return
|
||||
if not spec.env_key:
|
||||
# OAuth/provider-only specs (for example: openai_codex)
|
||||
return
|
||||
|
||||
# Gateway/local overrides existing env; standard provider doesn't
|
||||
if self._gateway:
|
||||
@@ -100,55 +84,11 @@ class LiteLLMProvider(LLMProvider):
|
||||
# Standard mode: auto-prefix for known providers
|
||||
spec = find_by_model(model)
|
||||
if spec and spec.litellm_prefix:
|
||||
model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix)
|
||||
if not any(model.startswith(s) for s in spec.skip_prefixes):
|
||||
model = f"{spec.litellm_prefix}/{model}"
|
||||
|
||||
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str:
|
||||
"""Normalize explicit provider prefixes like `github-copilot/...`."""
|
||||
if "/" not in model:
|
||||
return model
|
||||
prefix, remainder = model.split("/", 1)
|
||||
if prefix.lower().replace("-", "_") != spec_name:
|
||||
return model
|
||||
return f"{canonical_prefix}/{remainder}"
|
||||
|
||||
def _supports_cache_control(self, model: str) -> bool:
|
||||
"""Return True when the provider supports cache_control on content blocks."""
|
||||
if self._gateway is not None:
|
||||
return self._gateway.supports_prompt_caching
|
||||
spec = find_by_model(model)
|
||||
return spec is not None and spec.supports_prompt_caching
|
||||
|
||||
def _apply_cache_control(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]:
|
||||
"""Return copies of messages and tools with cache_control injected."""
|
||||
new_messages = []
|
||||
for msg in messages:
|
||||
if msg.get("role") == "system":
|
||||
content = msg["content"]
|
||||
if isinstance(content, str):
|
||||
new_content = [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]
|
||||
else:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
new_messages.append({**msg, "content": new_content})
|
||||
else:
|
||||
new_messages.append(msg)
|
||||
|
||||
new_tools = tools
|
||||
if tools:
|
||||
new_tools = list(tools)
|
||||
new_tools[-1] = {**new_tools[-1], "cache_control": {"type": "ephemeral"}}
|
||||
|
||||
return new_messages, new_tools
|
||||
|
||||
def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
|
||||
"""Apply model-specific parameter overrides from the registry."""
|
||||
model_lower = model.lower()
|
||||
@@ -159,18 +99,6 @@ class LiteLLMProvider(LLMProvider):
|
||||
kwargs.update(overrides)
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Strip non-standard keys and ensure assistant messages have a content key."""
|
||||
sanitized = []
|
||||
for msg in messages:
|
||||
clean = {k: v for k, v in msg.items() if k in _ALLOWED_MSG_KEYS}
|
||||
# Strict providers require "content" even when assistant only has tool_calls
|
||||
if clean.get("role") == "assistant" and "content" not in clean:
|
||||
clean["content"] = None
|
||||
sanitized.append(clean)
|
||||
return sanitized
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -179,7 +107,6 @@ class LiteLLMProvider(LLMProvider):
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_budget: int | None = None,
|
||||
context_management: dict[str, Any] | None = None, # Anthropic-only, ignored here
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Send a chat completion request via LiteLLM.
|
||||
@@ -194,19 +121,11 @@ class LiteLLMProvider(LLMProvider):
|
||||
Returns:
|
||||
LLMResponse with content and/or tool calls.
|
||||
"""
|
||||
original_model = model or self.default_model
|
||||
model = self._resolve_model(original_model)
|
||||
|
||||
if self._supports_cache_control(original_model):
|
||||
messages, tools = self._apply_cache_control(messages, tools)
|
||||
|
||||
# Clamp max_tokens to at least 1 — negative or zero values cause
|
||||
# LiteLLM to reject the request with "max_tokens must be at least 1".
|
||||
max_tokens = max(1, max_tokens)
|
||||
model = self._resolve_model(model or self.default_model)
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
@@ -251,10 +170,13 @@ class LiteLLMProvider(LLMProvider):
|
||||
# Parse arguments from JSON string if needed
|
||||
args = tc.function.arguments
|
||||
if isinstance(args, str):
|
||||
args = json_repair.loads(args)
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
args = {"raw": args}
|
||||
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=_short_tool_id(),
|
||||
id=tc.id,
|
||||
name=tc.function.name,
|
||||
arguments=args,
|
||||
))
|
||||
@@ -267,7 +189,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
}
|
||||
|
||||
reasoning_content = getattr(message, "reasoning_content", None) or None
|
||||
reasoning_content = getattr(message, "reasoning_content", None)
|
||||
|
||||
return LLMResponse(
|
||||
content=message.content,
|
||||
|
||||
@@ -28,7 +28,7 @@ def get_auth_headers(token: str, is_oauth: bool = False) -> dict[str, str]:
|
||||
if is_oauth:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
# Required headers to mimic Claude Code client
|
||||
headers["anthropic-beta"] = "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27"
|
||||
headers["anthropic-beta"] = "claude-code-20250219,oauth-2025-04-20"
|
||||
headers["anthropic-dangerous-direct-browser-access"] = "true"
|
||||
headers["user-agent"] = "claude-cli/2.1.2 (external, cli)"
|
||||
headers["x-app"] = "cli"
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
"""OpenAI Codex Responses Provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from oauth_cli_kit import get_token as get_codex_token
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
DEFAULT_ORIGINATOR = "nanobot"
|
||||
|
||||
|
||||
class OpenAICodexProvider(LLMProvider):
|
||||
"""Use Codex OAuth to call the Responses API."""
|
||||
|
||||
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
|
||||
super().__init__(api_key=None, api_base=None)
|
||||
self.default_model = default_model
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
) -> LLMResponse:
|
||||
model = model or self.default_model
|
||||
system_prompt, input_items = _convert_messages(messages)
|
||||
|
||||
token = await asyncio.to_thread(get_codex_token)
|
||||
headers = _build_headers(token.account_id, token.access)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": _strip_model_prefix(model),
|
||||
"store": False,
|
||||
"stream": True,
|
||||
"instructions": system_prompt,
|
||||
"input": input_items,
|
||||
"text": {"verbosity": "medium"},
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"prompt_cache_key": _prompt_cache_key(messages),
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": True,
|
||||
}
|
||||
|
||||
if tools:
|
||||
body["tools"] = _convert_tools(tools)
|
||||
|
||||
url = DEFAULT_CODEX_URL
|
||||
|
||||
try:
|
||||
try:
|
||||
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True)
|
||||
except Exception as e:
|
||||
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
|
||||
raise
|
||||
logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False")
|
||||
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False)
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
except Exception as e:
|
||||
return LLMResponse(
|
||||
content=f"Error calling Codex: {str(e)}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return self.default_model
|
||||
|
||||
|
||||
def _strip_model_prefix(model: str) -> str:
|
||||
if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
|
||||
return model.split("/", 1)[1]
|
||||
return model
|
||||
|
||||
|
||||
def _build_headers(account_id: str, token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"chatgpt-account-id": account_id,
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"originator": DEFAULT_ORIGINATOR,
|
||||
"User-Agent": "nanobot (python)",
|
||||
"accept": "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def _request_codex(
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
body: dict[str, Any],
|
||||
verify: bool,
|
||||
) -> tuple[str, list[ToolCallRequest], str]:
|
||||
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
|
||||
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||
if response.status_code != 200:
|
||||
text = await response.aread()
|
||||
raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore")))
|
||||
return await _consume_sse(response)
|
||||
|
||||
|
||||
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Convert OpenAI function-calling schema to Codex flat format."""
|
||||
converted: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool
|
||||
name = fn.get("name")
|
||||
if not name:
|
||||
continue
|
||||
params = fn.get("parameters") or {}
|
||||
converted.append({
|
||||
"type": "function",
|
||||
"name": name,
|
||||
"description": fn.get("description") or "",
|
||||
"parameters": params if isinstance(params, dict) else {},
|
||||
})
|
||||
return converted
|
||||
|
||||
|
||||
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
system_prompt = ""
|
||||
input_items: list[dict[str, Any]] = []
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
|
||||
if role == "system":
|
||||
system_prompt = content if isinstance(content, str) else ""
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
input_items.append(_convert_user_message(content))
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
# Handle text first.
|
||||
if isinstance(content, str) and content:
|
||||
input_items.append(
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": content}],
|
||||
"status": "completed",
|
||||
"id": f"msg_{idx}",
|
||||
}
|
||||
)
|
||||
# Then handle tool calls.
|
||||
for tool_call in msg.get("tool_calls", []) or []:
|
||||
fn = tool_call.get("function") or {}
|
||||
call_id, item_id = _split_tool_call_id(tool_call.get("id"))
|
||||
call_id = call_id or f"call_{idx}"
|
||||
item_id = item_id or f"fc_{idx}"
|
||||
input_items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": item_id,
|
||||
"call_id": call_id,
|
||||
"name": fn.get("name"),
|
||||
"arguments": fn.get("arguments") or "{}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
call_id, _ = _split_tool_call_id(msg.get("tool_call_id"))
|
||||
output_text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
|
||||
input_items.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": output_text,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
return system_prompt, input_items
|
||||
|
||||
|
||||
def _convert_user_message(content: Any) -> dict[str, Any]:
|
||||
if isinstance(content, str):
|
||||
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
|
||||
if isinstance(content, list):
|
||||
converted: list[dict[str, Any]] = []
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") == "text":
|
||||
converted.append({"type": "input_text", "text": item.get("text", "")})
|
||||
elif item.get("type") == "image_url":
|
||||
url = (item.get("image_url") or {}).get("url")
|
||||
if url:
|
||||
converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
|
||||
if converted:
|
||||
return {"role": "user", "content": converted}
|
||||
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
|
||||
|
||||
|
||||
def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
||||
if isinstance(tool_call_id, str) and tool_call_id:
|
||||
if "|" in tool_call_id:
|
||||
call_id, item_id = tool_call_id.split("|", 1)
|
||||
return call_id, item_id or None
|
||||
return tool_call_id, None
|
||||
return "call_0", None
|
||||
|
||||
|
||||
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
||||
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
|
||||
buffer: list[str] = []
|
||||
async for line in response.aiter_lines():
|
||||
if line == "":
|
||||
if buffer:
|
||||
data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")]
|
||||
buffer = []
|
||||
if not data_lines:
|
||||
continue
|
||||
data = "\n".join(data_lines).strip()
|
||||
if not data or data == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
yield json.loads(data)
|
||||
except Exception:
|
||||
continue
|
||||
continue
|
||||
buffer.append(line)
|
||||
|
||||
|
||||
async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]:
|
||||
content = ""
|
||||
tool_calls: list[ToolCallRequest] = []
|
||||
tool_call_buffers: dict[str, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
|
||||
async for event in _iter_sse(response):
|
||||
event_type = event.get("type")
|
||||
if event_type == "response.output_item.added":
|
||||
item = event.get("item") or {}
|
||||
if item.get("type") == "function_call":
|
||||
call_id = item.get("call_id")
|
||||
if not call_id:
|
||||
continue
|
||||
tool_call_buffers[call_id] = {
|
||||
"id": item.get("id") or "fc_0",
|
||||
"name": item.get("name"),
|
||||
"arguments": item.get("arguments") or "",
|
||||
}
|
||||
elif event_type == "response.output_text.delta":
|
||||
content += event.get("delta") or ""
|
||||
elif event_type == "response.function_call_arguments.delta":
|
||||
call_id = event.get("call_id")
|
||||
if call_id and call_id in tool_call_buffers:
|
||||
tool_call_buffers[call_id]["arguments"] += event.get("delta") or ""
|
||||
elif event_type == "response.function_call_arguments.done":
|
||||
call_id = event.get("call_id")
|
||||
if call_id and call_id in tool_call_buffers:
|
||||
tool_call_buffers[call_id]["arguments"] = event.get("arguments") or ""
|
||||
elif event_type == "response.output_item.done":
|
||||
item = event.get("item") or {}
|
||||
if item.get("type") == "function_call":
|
||||
call_id = item.get("call_id")
|
||||
if not call_id:
|
||||
continue
|
||||
buf = tool_call_buffers.get(call_id) or {}
|
||||
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
|
||||
try:
|
||||
args = json.loads(args_raw)
|
||||
except Exception:
|
||||
args = {"raw": args_raw}
|
||||
tool_calls.append(
|
||||
ToolCallRequest(
|
||||
id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}",
|
||||
name=buf.get("name") or item.get("name"),
|
||||
arguments=args,
|
||||
)
|
||||
)
|
||||
elif event_type == "response.completed":
|
||||
status = (event.get("response") or {}).get("status")
|
||||
finish_reason = _map_finish_reason(status)
|
||||
elif event_type in {"error", "response.failed"}:
|
||||
raise RuntimeError("Codex response failed")
|
||||
|
||||
return content, tool_calls, finish_reason
|
||||
|
||||
|
||||
_FINISH_REASON_MAP = {"completed": "stop", "incomplete": "length", "failed": "error", "cancelled": "error"}
|
||||
|
||||
|
||||
def _map_finish_reason(status: str | None) -> str:
|
||||
return _FINISH_REASON_MAP.get(status or "completed", "stop")
|
||||
|
||||
|
||||
def _friendly_error(status_code: int, raw: str) -> str:
|
||||
if status_code == 429:
|
||||
return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
|
||||
return f"HTTP {status_code}: {raw}"
|
||||
@@ -51,15 +51,6 @@ class ProviderSpec:
|
||||
# per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),)
|
||||
model_overrides: tuple[tuple[str, dict[str, Any]], ...] = ()
|
||||
|
||||
# OAuth-based providers (e.g., OpenAI Codex) don't use API keys
|
||||
is_oauth: bool = False # if True, uses OAuth flow instead of API key
|
||||
|
||||
# Direct providers bypass LiteLLM entirely (e.g., CustomProvider)
|
||||
is_direct: bool = False
|
||||
|
||||
# Provider supports cache_control on content blocks (e.g. Anthropic prompt caching)
|
||||
supports_prompt_caching: bool = False
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.display_name or self.name.title()
|
||||
@@ -71,16 +62,6 @@ class ProviderSpec:
|
||||
|
||||
PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
|
||||
# === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ======
|
||||
ProviderSpec(
|
||||
name="custom",
|
||||
keywords=(),
|
||||
env_key="",
|
||||
display_name="Custom",
|
||||
litellm_prefix="",
|
||||
is_direct=True,
|
||||
),
|
||||
|
||||
# === Gateways (detected by api_key / api_base, not model name) =========
|
||||
# Gateways can route any model, so they win in fallback.
|
||||
|
||||
@@ -100,7 +81,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
default_api_base="https://openrouter.ai/api/v1",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
supports_prompt_caching=True,
|
||||
),
|
||||
|
||||
# AiHubMix: global gateway, OpenAI-compatible interface.
|
||||
@@ -123,42 +103,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
model_overrides=(),
|
||||
),
|
||||
|
||||
# SiliconFlow (硅基流动): OpenAI-compatible gateway, model names keep org prefix
|
||||
ProviderSpec(
|
||||
name="siliconflow",
|
||||
keywords=("siliconflow",),
|
||||
env_key="OPENAI_API_KEY",
|
||||
display_name="SiliconFlow",
|
||||
litellm_prefix="openai",
|
||||
skip_prefixes=(),
|
||||
env_extras=(),
|
||||
is_gateway=True,
|
||||
is_local=False,
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="siliconflow",
|
||||
default_api_base="https://api.siliconflow.cn/v1",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
),
|
||||
|
||||
# VolcEngine (火山引擎): OpenAI-compatible gateway
|
||||
ProviderSpec(
|
||||
name="volcengine",
|
||||
keywords=("volcengine", "volces", "ark"),
|
||||
env_key="OPENAI_API_KEY",
|
||||
display_name="VolcEngine",
|
||||
litellm_prefix="volcengine",
|
||||
skip_prefixes=(),
|
||||
env_extras=(),
|
||||
is_gateway=True,
|
||||
is_local=False,
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="volces",
|
||||
default_api_base="https://ark.cn-beijing.volces.com/api/v3",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
),
|
||||
|
||||
# === Standard providers (matched by model-name keywords) ===============
|
||||
|
||||
# Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed.
|
||||
@@ -177,7 +121,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
default_api_base="",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
supports_prompt_caching=True,
|
||||
),
|
||||
|
||||
# OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed.
|
||||
@@ -198,44 +141,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
model_overrides=(),
|
||||
),
|
||||
|
||||
# OpenAI Codex: uses OAuth, not API key.
|
||||
ProviderSpec(
|
||||
name="openai_codex",
|
||||
keywords=("openai-codex", "codex"),
|
||||
env_key="", # OAuth-based, no API key
|
||||
display_name="OpenAI Codex",
|
||||
litellm_prefix="", # Not routed through LiteLLM
|
||||
skip_prefixes=(),
|
||||
env_extras=(),
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="codex",
|
||||
default_api_base="https://chatgpt.com/backend-api",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
is_oauth=True, # OAuth-based authentication
|
||||
),
|
||||
|
||||
# Github Copilot: uses OAuth, not API key.
|
||||
ProviderSpec(
|
||||
name="github_copilot",
|
||||
keywords=("github_copilot", "copilot"),
|
||||
env_key="", # OAuth-based, no API key
|
||||
display_name="Github Copilot",
|
||||
litellm_prefix="github_copilot", # github_copilot/model → github_copilot/model
|
||||
skip_prefixes=("github_copilot/",),
|
||||
env_extras=(),
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="",
|
||||
default_api_base="",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
is_oauth=True, # OAuth-based authentication
|
||||
),
|
||||
|
||||
# DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
|
||||
ProviderSpec(
|
||||
name="deepseek",
|
||||
@@ -407,18 +312,10 @@ def find_by_model(model: str) -> ProviderSpec | None:
|
||||
"""Match a standard provider by model-name keyword (case-insensitive).
|
||||
Skips gateways/local — those are matched by api_key/api_base instead."""
|
||||
model_lower = model.lower()
|
||||
model_normalized = model_lower.replace("-", "_")
|
||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||
normalized_prefix = model_prefix.replace("-", "_")
|
||||
std_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local]
|
||||
|
||||
# Prefer explicit provider prefix — prevents `github-copilot/...codex` matching openai_codex.
|
||||
for spec in std_specs:
|
||||
if model_prefix and normalized_prefix == spec.name:
|
||||
return spec
|
||||
|
||||
for spec in std_specs:
|
||||
if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords):
|
||||
for spec in PROVIDERS:
|
||||
if spec.is_gateway or spec.is_local:
|
||||
continue
|
||||
if any(kw in model_lower for kw in spec.keywords):
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class GroqTranscriptionProvider:
|
||||
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.error("Audio file not found: {}", file_path)
|
||||
logger.error(f"Audio file not found: {file_path}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
@@ -61,5 +61,5 @@ class GroqTranscriptionProvider:
|
||||
return data.get("text", "")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Groq transcription error: {}", e)
|
||||
logger.error(f"Groq transcription error: {e}")
|
||||
return ""
|
||||
|
||||
+59
-77
@@ -1,7 +1,6 @@
|
||||
"""Session management for conversation history."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -16,20 +15,15 @@ from nanobot.utils.helpers import ensure_dir, safe_filename
|
||||
class Session:
|
||||
"""
|
||||
A conversation session.
|
||||
|
||||
|
||||
Stores messages in JSONL format for easy reading and persistence.
|
||||
|
||||
Important: Messages are append-only for LLM cache efficiency.
|
||||
The consolidation process writes summaries to MEMORY.md/HISTORY.md
|
||||
but does NOT modify the messages list or get_history() output.
|
||||
"""
|
||||
|
||||
|
||||
key: str # channel:chat_id
|
||||
messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
last_consolidated: int = 0 # Number of messages already consolidated to files
|
||||
|
||||
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
|
||||
"""Add a message to the session."""
|
||||
@@ -41,64 +35,45 @@ class Session:
|
||||
}
|
||||
self.messages.append(msg)
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def add_raw_message(self, msg: dict[str, Any]) -> None:
|
||||
"""Add a pre-formed message dict to the session, preserving all fields."""
|
||||
stored = dict(msg)
|
||||
if "timestamp" not in stored:
|
||||
stored["timestamp"] = datetime.now().isoformat()
|
||||
self.messages.append(stored)
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
# Fields that are valid in the Anthropic/OpenAI messages API.
|
||||
# Everything else (timestamp, tools_used, etc.) is internal metadata.
|
||||
_API_FIELDS = {"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}
|
||||
|
||||
def get_history(self) -> list[dict[str, Any]]:
|
||||
|
||||
def get_history(self, max_messages: int = 50) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get full message history for LLM context.
|
||||
|
||||
The server-side context editing API (clear_tool_uses_20250919) handles
|
||||
trimming old tool chains safely at token thresholds, so we send the full
|
||||
history and let the server decide what to drop.
|
||||
|
||||
Get message history for LLM context.
|
||||
|
||||
Args:
|
||||
max_messages: Maximum messages to return.
|
||||
|
||||
Returns:
|
||||
List of messages in LLM format (API-relevant fields only).
|
||||
List of messages in LLM format.
|
||||
"""
|
||||
return [
|
||||
{k: v for k, v in m.items() if k in self._API_FIELDS and v is not None}
|
||||
for m in self.messages
|
||||
]
|
||||
# Get recent messages
|
||||
recent = self.messages[-max_messages:] if len(self.messages) > max_messages else self.messages
|
||||
|
||||
# Convert to LLM format (just role and content)
|
||||
return [{"role": m["role"], "content": m["content"]} for m in recent]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all messages and reset session to initial state."""
|
||||
"""Clear all messages in the session."""
|
||||
self.messages = []
|
||||
self.last_consolidated = 0
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""
|
||||
Manages conversation sessions.
|
||||
|
||||
|
||||
Sessions are stored as JSONL files in the sessions directory.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
self.workspace = workspace
|
||||
self.sessions_dir = ensure_dir(self.workspace / "sessions")
|
||||
self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
|
||||
self.sessions_dir = ensure_dir(Path.home() / ".nanobot" / "sessions")
|
||||
self._cache: dict[str, Session] = {}
|
||||
|
||||
def _get_session_path(self, key: str) -> Path:
|
||||
"""Get the file path for a session."""
|
||||
safe_key = safe_filename(key.replace(":", "_"))
|
||||
return self.sessions_dir / f"{safe_key}.jsonl"
|
||||
|
||||
def _get_legacy_session_path(self, key: str) -> Path:
|
||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||
safe_key = safe_filename(key.replace(":", "_"))
|
||||
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
|
||||
|
||||
def get_or_create(self, key: str) -> Session:
|
||||
"""
|
||||
@@ -110,9 +85,11 @@ class SessionManager:
|
||||
Returns:
|
||||
The session.
|
||||
"""
|
||||
# Check cache
|
||||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
|
||||
# Try to load from disk
|
||||
session = self._load(key)
|
||||
if session is None:
|
||||
session = Session(key=key)
|
||||
@@ -123,72 +100,78 @@ class SessionManager:
|
||||
def _load(self, key: str) -> Session | None:
|
||||
"""Load a session from disk."""
|
||||
path = self._get_session_path(key)
|
||||
if not path.exists():
|
||||
legacy_path = self._get_legacy_session_path(key)
|
||||
if legacy_path.exists():
|
||||
try:
|
||||
shutil.move(str(legacy_path), str(path))
|
||||
logger.info("Migrated session {} from legacy path", key)
|
||||
except Exception:
|
||||
logger.exception("Failed to migrate session {}", key)
|
||||
|
||||
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
messages = []
|
||||
metadata = {}
|
||||
created_at = None
|
||||
last_consolidated = 0
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
|
||||
data = json.loads(line)
|
||||
|
||||
|
||||
if data.get("_type") == "metadata":
|
||||
metadata = data.get("metadata", {})
|
||||
created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None
|
||||
last_consolidated = data.get("last_consolidated", 0)
|
||||
else:
|
||||
messages.append(data)
|
||||
|
||||
|
||||
return Session(
|
||||
key=key,
|
||||
messages=messages,
|
||||
created_at=created_at or datetime.now(),
|
||||
metadata=metadata,
|
||||
last_consolidated=last_consolidated
|
||||
metadata=metadata
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load session {}: {}", key, e)
|
||||
logger.warning(f"Failed to load session {key}: {e}")
|
||||
return None
|
||||
|
||||
def save(self, session: Session) -> None:
|
||||
"""Save a session to disk."""
|
||||
path = self._get_session_path(session.key)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
|
||||
with open(path, "w") as f:
|
||||
# Write metadata first
|
||||
metadata_line = {
|
||||
"_type": "metadata",
|
||||
"key": session.key,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"updated_at": session.updated_at.isoformat(),
|
||||
"metadata": session.metadata,
|
||||
"last_consolidated": session.last_consolidated
|
||||
"metadata": session.metadata
|
||||
}
|
||||
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
||||
f.write(json.dumps(metadata_line) + "\n")
|
||||
|
||||
# Write messages
|
||||
for msg in session.messages:
|
||||
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
||||
|
||||
f.write(json.dumps(msg) + "\n")
|
||||
|
||||
self._cache[session.key] = session
|
||||
|
||||
def invalidate(self, key: str) -> None:
|
||||
"""Remove a session from the in-memory cache."""
|
||||
def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Delete a session.
|
||||
|
||||
Args:
|
||||
key: Session key.
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found.
|
||||
"""
|
||||
# Remove from cache
|
||||
self._cache.pop(key, None)
|
||||
|
||||
# Remove file
|
||||
path = self._get_session_path(key)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_sessions(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@@ -202,14 +185,13 @@ class SessionManager:
|
||||
for path in self.sessions_dir.glob("*.jsonl"):
|
||||
try:
|
||||
# Read just the metadata line
|
||||
with open(path, encoding="utf-8") as f:
|
||||
with open(path) as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line:
|
||||
data = json.loads(first_line)
|
||||
if data.get("_type") == "metadata":
|
||||
key = data.get("key") or path.stem.replace("_", ":", 1)
|
||||
sessions.append({
|
||||
"key": key,
|
||||
"key": path.stem.replace("_", ":"),
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"path": str(path)
|
||||
|
||||
@@ -21,5 +21,4 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai
|
||||
| `weather` | Get weather info using wttr.in and Open-Meteo |
|
||||
| `summarize` | Summarize URLs, files, and YouTube videos |
|
||||
| `tmux` | Remote-control tmux sessions |
|
||||
| `clawhub` | Search and install skills from ClawHub registry |
|
||||
| `skill-creator` | Create new skills |
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: clawhub
|
||||
description: Search and install agent skills from ClawHub, the public skill registry.
|
||||
homepage: https://clawhub.ai
|
||||
metadata: {"nanobot":{"emoji":"🦞"}}
|
||||
---
|
||||
|
||||
# ClawHub
|
||||
|
||||
Public skill registry for AI agents. Search by natural language (vector search).
|
||||
|
||||
## When to use
|
||||
|
||||
Use this skill when the user asks any of:
|
||||
- "find a skill for …"
|
||||
- "search for skills"
|
||||
- "install a skill"
|
||||
- "what skills are available?"
|
||||
- "update my skills"
|
||||
|
||||
## Search
|
||||
|
||||
```bash
|
||||
npx --yes clawhub@latest search "web scraping" --limit 5
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npx --yes clawhub@latest install <slug> --workdir ~/.nanobot/workspace
|
||||
```
|
||||
|
||||
Replace `<slug>` with the skill name from search results. This places the skill into `~/.nanobot/workspace/skills/`, where nanobot loads workspace skills from. Always include `--workdir`.
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
npx --yes clawhub@latest update --all --workdir ~/.nanobot/workspace
|
||||
```
|
||||
|
||||
## List installed
|
||||
|
||||
```bash
|
||||
npx --yes clawhub@latest list --workdir ~/.nanobot/workspace
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires Node.js (`npx` comes with it).
|
||||
- No API key needed for search and install.
|
||||
- Login (`npx --yes clawhub@latest login`) is only required for publishing.
|
||||
- `--workdir ~/.nanobot/workspace` is critical — without it, skills install to the current directory instead of the nanobot workspace.
|
||||
- After install, remind the user to start a new session to load the skill.
|
||||
@@ -30,11 +30,6 @@ One-time scheduled task (compute ISO datetime from current time):
|
||||
cron(action="add", message="Remind me about the meeting", at="<ISO datetime>")
|
||||
```
|
||||
|
||||
Timezone-aware cron:
|
||||
```
|
||||
cron(action="add", message="Morning standup", cron_expr="0 9 * * 1-5", tz="America/Vancouver")
|
||||
```
|
||||
|
||||
List/remove:
|
||||
```
|
||||
cron(action="list")
|
||||
@@ -49,9 +44,4 @@ cron(action="remove", job_id="abc123")
|
||||
| every hour | every_seconds: 3600 |
|
||||
| every day at 8am | cron_expr: "0 8 * * *" |
|
||||
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
|
||||
| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
|
||||
| at a specific time | at: ISO datetime string (compute from current time) |
|
||||
|
||||
## Timezone
|
||||
|
||||
Use `tz` with `cron_expr` to schedule in a specific IANA timezone. Without `tz`, the server's local timezone is used.
|
||||
|
||||
@@ -9,7 +9,7 @@ always: true
|
||||
## Structure
|
||||
|
||||
- `memory/MEMORY.md` — Long-term facts (preferences, project context, relationships). Always loaded into your context.
|
||||
- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep. Each entry starts with [YYYY-MM-DD HH:MM].
|
||||
- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep.
|
||||
|
||||
## Search Past Events
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Agent Instructions
|
||||
|
||||
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
|
||||
## Scheduled Reminders
|
||||
|
||||
When user asks for a reminder at a specific time, use `exec` to run:
|
||||
```
|
||||
nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL"
|
||||
```
|
||||
Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
|
||||
|
||||
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
|
||||
|
||||
## Heartbeat Tasks
|
||||
|
||||
`HEARTBEAT.md` is checked every 30 minutes. Use file tools to manage periodic tasks:
|
||||
|
||||
- **Add**: `edit_file` to append new tasks
|
||||
- **Remove**: `edit_file` to delete completed tasks
|
||||
- **Rewrite**: `write_file` to replace all tasks
|
||||
|
||||
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder.
|
||||
@@ -1,15 +0,0 @@
|
||||
# Tool Usage Notes
|
||||
|
||||
Tool signatures are provided automatically via function calling.
|
||||
This file documents non-obvious constraints and usage patterns.
|
||||
|
||||
## exec — Safety Limits
|
||||
|
||||
- Commands have a configurable timeout (default 60s)
|
||||
- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.)
|
||||
- Output is truncated at 10,000 characters
|
||||
- `restrictToWorkspace` config can limit file access to the workspace
|
||||
|
||||
## cron — Scheduled Reminders
|
||||
|
||||
- Please refer to cron skill for usage.
|
||||
+53
-40
@@ -1,67 +1,80 @@
|
||||
"""Utility functions for nanobot."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
"""Ensure directory exists, return it."""
|
||||
"""Ensure a directory exists, creating it if necessary."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_data_path() -> Path:
|
||||
"""~/.nanobot data directory."""
|
||||
"""Get the nanobot data directory (~/.nanobot)."""
|
||||
return ensure_dir(Path.home() / ".nanobot")
|
||||
|
||||
|
||||
def get_workspace_path(workspace: str | None = None) -> Path:
|
||||
"""Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace."""
|
||||
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
||||
"""
|
||||
Get the workspace path.
|
||||
|
||||
Args:
|
||||
workspace: Optional workspace path. Defaults to ~/.nanobot/workspace.
|
||||
|
||||
Returns:
|
||||
Expanded and ensured workspace path.
|
||||
"""
|
||||
if workspace:
|
||||
path = Path(workspace).expanduser()
|
||||
else:
|
||||
path = Path.home() / ".nanobot" / "workspace"
|
||||
return ensure_dir(path)
|
||||
|
||||
|
||||
def get_sessions_path() -> Path:
|
||||
"""Get the sessions storage directory."""
|
||||
return ensure_dir(get_data_path() / "sessions")
|
||||
|
||||
|
||||
def get_skills_path(workspace: Path | None = None) -> Path:
|
||||
"""Get the skills directory within the workspace."""
|
||||
ws = workspace or get_workspace_path()
|
||||
return ensure_dir(ws / "skills")
|
||||
|
||||
|
||||
def timestamp() -> str:
|
||||
"""Current ISO timestamp."""
|
||||
"""Get current timestamp in ISO format."""
|
||||
return datetime.now().isoformat()
|
||||
|
||||
|
||||
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||
def truncate_string(s: str, max_len: int = 100, suffix: str = "...") -> str:
|
||||
"""Truncate a string to max length, adding suffix if truncated."""
|
||||
if len(s) <= max_len:
|
||||
return s
|
||||
return s[: max_len - len(suffix)] + suffix
|
||||
|
||||
|
||||
def safe_filename(name: str) -> str:
|
||||
"""Replace unsafe path characters with underscores."""
|
||||
return _UNSAFE_CHARS.sub("_", name).strip()
|
||||
"""Convert a string to a safe filename."""
|
||||
# Replace unsafe characters
|
||||
unsafe = '<>:"/\\|?*'
|
||||
for char in unsafe:
|
||||
name = name.replace(char, "_")
|
||||
return name.strip()
|
||||
|
||||
|
||||
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
|
||||
"""Sync bundled templates to workspace. Only creates missing files."""
|
||||
from importlib.resources import files as pkg_files
|
||||
try:
|
||||
tpl = pkg_files("nanobot") / "templates"
|
||||
except Exception:
|
||||
return []
|
||||
if not tpl.is_dir():
|
||||
return []
|
||||
|
||||
added: list[str] = []
|
||||
|
||||
def _write(src, dest: Path):
|
||||
if dest.exists():
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8")
|
||||
added.append(str(dest.relative_to(workspace)))
|
||||
|
||||
for item in tpl.iterdir():
|
||||
if item.name.endswith(".md"):
|
||||
_write(item, workspace / item.name)
|
||||
_write(tpl / "memory" / "MEMORY.md", workspace / "memory" / "MEMORY.md")
|
||||
_write(None, workspace / "memory" / "HISTORY.md")
|
||||
(workspace / "skills").mkdir(exist_ok=True)
|
||||
|
||||
if added and not silent:
|
||||
from rich.console import Console
|
||||
for name in added:
|
||||
Console().print(f" [dim]Created {name}[/dim]")
|
||||
return added
|
||||
def parse_session_key(key: str) -> tuple[str, str]:
|
||||
"""
|
||||
Parse a session key into channel and chat_id.
|
||||
|
||||
Args:
|
||||
key: Session key in format "channel:chat_id"
|
||||
|
||||
Returns:
|
||||
Tuple of (channel, chat_id)
|
||||
"""
|
||||
parts = key.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Invalid session key: {key}")
|
||||
return parts[0], parts[1]
|
||||
|
||||
@@ -38,7 +38,6 @@ dependencies = [
|
||||
"qq-botpy>=1.0.0",
|
||||
"python-socks[asyncio]>=2.4.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
"vncdotool>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -47,9 +46,6 @@ dev = [
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
mem0 = [
|
||||
"mem0ai>=0.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nanobot = "nanobot.cli.commands:app"
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# tests/test_agent_loop_metadata.py
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_direct_passes_metadata():
|
||||
"""Test that process_direct passes metadata to InboundMessage."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||
content="test response",
|
||||
tool_calls=[]
|
||||
))
|
||||
provider.get_default_model = MagicMock(return_value="test-model")
|
||||
provider.thinking_budget = 0
|
||||
|
||||
workspace = Path("/tmp/test-workspace")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||
|
||||
# Call with metadata
|
||||
test_metadata = {"suppress_output": True, "test_key": "test_value"}
|
||||
await loop.process_direct(
|
||||
content="test message",
|
||||
metadata=test_metadata
|
||||
)
|
||||
|
||||
# Verify provider.chat was called
|
||||
assert provider.chat.called
|
||||
call_args = provider.chat.call_args
|
||||
messages = call_args.kwargs["messages"]
|
||||
|
||||
# The user message should contain the content
|
||||
# (We can't easily check InboundMessage directly, but we verify
|
||||
# the flow worked by checking the session was created)
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
assert len(session.messages) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suppress_mode_adds_hidden_prefix():
|
||||
"""Test that suppress_output metadata adds [HIDDEN:signature] prefix."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||
content="This is the agent response",
|
||||
tool_calls=[]
|
||||
))
|
||||
provider.get_default_model = MagicMock(return_value="test-model")
|
||||
provider.thinking_budget = 0
|
||||
|
||||
workspace = Path("/tmp/test-workspace")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||
|
||||
# Call with suppress_output=True
|
||||
response = await loop.process_direct(
|
||||
content="test message",
|
||||
metadata={"suppress_output": True}
|
||||
)
|
||||
|
||||
# Response content should have [HIDDEN:signature] prefix with 8-char hex signature
|
||||
assert response.startswith("[HIDDEN:")
|
||||
assert "]" in response
|
||||
# Extract signature part between [HIDDEN: and ]
|
||||
prefix_end = response.index("]")
|
||||
signature = response[8:prefix_end] # Skip "[HIDDEN:" to get signature
|
||||
assert len(signature) == 8 # 8-character hex signature
|
||||
assert all(c in "0123456789abcdef" for c in signature) # Valid hex
|
||||
assert "This is the agent response" in response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_mode_no_hidden_prefix():
|
||||
"""Test that normal messages don't get [HIDDEN:signature] prefix."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||
content="Normal response",
|
||||
tool_calls=[]
|
||||
))
|
||||
provider.get_default_model = MagicMock(return_value="test-model")
|
||||
provider.thinking_budget = 0
|
||||
|
||||
workspace = Path("/tmp/test-workspace")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||
|
||||
# Call without suppress_output
|
||||
response = await loop.process_direct(content="test message")
|
||||
|
||||
# Response should NOT have [HIDDEN:signature] prefix
|
||||
assert not response.startswith("[HIDDEN:")
|
||||
assert response == "Normal response"
|
||||
@@ -1,262 +0,0 @@
|
||||
"""Tests for agent loop handling of ToolResult and CLIResult objects."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.anthropic.base import ToolResult, CLIResult
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
"""Create mock LLM provider."""
|
||||
provider = MagicMock()
|
||||
provider.chat = AsyncMock()
|
||||
provider.thinking_budget = 0
|
||||
return provider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_manager():
|
||||
"""Create mock session manager."""
|
||||
session_mgr = MagicMock()
|
||||
session_mgr.load = AsyncMock(return_value={
|
||||
"messages": [],
|
||||
"metadata": {},
|
||||
})
|
||||
session_mgr.save = AsyncMock()
|
||||
return session_mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bus():
|
||||
"""Create mock message bus."""
|
||||
bus = MagicMock(spec=MessageBus)
|
||||
bus.publish = AsyncMock()
|
||||
return bus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_loop(mock_provider, mock_session_manager, mock_bus, tmp_path):
|
||||
"""Create agent loop for testing."""
|
||||
return AgentLoop(
|
||||
provider=mock_provider,
|
||||
session_manager=mock_session_manager,
|
||||
bus=mock_bus,
|
||||
workspace=tmp_path,
|
||||
max_iterations=5,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_result_with_output(agent_loop, mock_provider):
|
||||
"""Test handling ToolResult with output field."""
|
||||
# Mock LLM responses
|
||||
mock_provider.chat.side_effect = [
|
||||
# First call: request tool
|
||||
LLMResponse(
|
||||
content="Using tool",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="test_tool", arguments={})],
|
||||
),
|
||||
# Second call: final response
|
||||
LLMResponse(content="Done"),
|
||||
]
|
||||
|
||||
# Mock tool that returns ToolResult
|
||||
tool_result = ToolResult(output="Tool executed successfully")
|
||||
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
# Verify tool result was added to messages
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
|
||||
# Find the tool result message
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
assert tool_msg["content"] == "Tool executed successfully"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_result_with_error(agent_loop, mock_provider):
|
||||
"""Test handling ToolResult with error field."""
|
||||
mock_provider.chat.side_effect = [
|
||||
LLMResponse(
|
||||
content="Using tool",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="test_tool", arguments={})],
|
||||
),
|
||||
LLMResponse(content="Error handled"),
|
||||
]
|
||||
|
||||
tool_result = ToolResult(error="Command failed: exit code 1")
|
||||
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
assert "Error:" in tool_msg["content"]
|
||||
assert "Command failed: exit code 1" in tool_msg["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_result_with_base64_image(agent_loop, mock_provider):
|
||||
"""Test handling ToolResult with base64_image field."""
|
||||
mock_provider.chat.side_effect = [
|
||||
LLMResponse(
|
||||
content="Taking screenshot",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="screenshot", arguments={})],
|
||||
),
|
||||
LLMResponse(content="Screenshot analyzed"),
|
||||
]
|
||||
|
||||
tool_result = ToolResult(
|
||||
output="Screenshot taken",
|
||||
base64_image="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
)
|
||||
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
|
||||
# Should contain both text and image
|
||||
assert isinstance(tool_msg["content"], list)
|
||||
assert len(tool_msg["content"]) == 2
|
||||
|
||||
# Text content
|
||||
text_part = next(p for p in tool_msg["content"] if p["type"] == "text")
|
||||
assert text_part["text"] == "Screenshot taken"
|
||||
|
||||
# Image content
|
||||
image_part = next(p for p in tool_msg["content"] if p["type"] == "image")
|
||||
assert image_part["source"]["type"] == "base64"
|
||||
assert image_part["source"]["media_type"] == "image/png"
|
||||
assert "iVBORw0KGgoAAAANS" in image_part["source"]["data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_result_handling(agent_loop, mock_provider):
|
||||
"""Test handling CLIResult from text editor tools."""
|
||||
mock_provider.chat.side_effect = [
|
||||
LLMResponse(
|
||||
content="Editing file",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="edit", arguments={})],
|
||||
),
|
||||
LLMResponse(content="File edited"),
|
||||
]
|
||||
|
||||
cli_result = CLIResult(
|
||||
exit_code=0,
|
||||
output="File updated successfully",
|
||||
error="",
|
||||
)
|
||||
agent_loop.tools.execute = AsyncMock(return_value=cli_result)
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
assert tool_msg["content"] == "File updated successfully"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_string_result(agent_loop, mock_provider):
|
||||
"""Test backward compatibility with string results from function tools."""
|
||||
mock_provider.chat.side_effect = [
|
||||
LLMResponse(
|
||||
content="Using tool",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="legacy_tool", arguments={})],
|
||||
),
|
||||
LLMResponse(content="Done"),
|
||||
]
|
||||
|
||||
# Legacy tool returns plain string
|
||||
agent_loop.tools.execute = AsyncMock(return_value="Plain text result")
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
assert tool_msg["content"] == "Plain text result"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_result_output_and_error(agent_loop, mock_provider):
|
||||
"""Test handling ToolResult with both output and error."""
|
||||
mock_provider.chat.side_effect = [
|
||||
LLMResponse(
|
||||
content="Running command",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="bash", arguments={})],
|
||||
),
|
||||
LLMResponse(content="Handled"),
|
||||
]
|
||||
|
||||
tool_result = ToolResult(
|
||||
output="Partial output before error",
|
||||
error="Unexpected termination",
|
||||
)
|
||||
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
|
||||
# Should contain both output and error
|
||||
content = tool_msg["content"]
|
||||
assert "Partial output before error" in content
|
||||
assert "Error:" in content
|
||||
assert "Unexpected termination" in content
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Tests for Anthropic native tool base classes."""
|
||||
|
||||
import pytest
|
||||
from nanobot.agent.tools.anthropic.base import (
|
||||
BaseAnthropicTool,
|
||||
ToolResult,
|
||||
CLIResult,
|
||||
ToolError,
|
||||
)
|
||||
|
||||
|
||||
class DummyTool(BaseAnthropicTool):
|
||||
"""Test tool implementation."""
|
||||
api_type = "test_20250227"
|
||||
name = "test_tool"
|
||||
beta_flag = "test-beta"
|
||||
|
||||
async def __call__(self, **kwargs):
|
||||
return ToolResult(output="test output")
|
||||
|
||||
def to_params(self):
|
||||
return {"type": self.api_type, "name": self.name}
|
||||
|
||||
|
||||
def test_tool_result_dataclass():
|
||||
"""Test ToolResult can be created with all fields."""
|
||||
result = ToolResult(output="hello", error=None, base64_image=None, system="system message")
|
||||
assert result.output == "hello"
|
||||
assert result.error is None
|
||||
assert result.base64_image is None
|
||||
assert result.system == "system message"
|
||||
|
||||
|
||||
def test_cli_result_dataclass():
|
||||
"""Test CLIResult can be created with all fields."""
|
||||
result = CLIResult(exit_code=0, output="command output", error="")
|
||||
assert result.output == "command output"
|
||||
assert result.exit_code == 0
|
||||
assert result.error == ""
|
||||
|
||||
|
||||
def test_tool_error_exception():
|
||||
"""Test ToolError can be raised and caught."""
|
||||
with pytest.raises(ToolError):
|
||||
raise ToolError("Test error message")
|
||||
|
||||
|
||||
def test_base_anthropic_tool_to_params():
|
||||
"""Test tool returns correct params format."""
|
||||
tool = DummyTool()
|
||||
params = tool.to_params()
|
||||
assert params["type"] == "test_20250227"
|
||||
assert params["name"] == "test_tool"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_anthropic_tool_call():
|
||||
"""Test tool can be called and returns ToolResult."""
|
||||
tool = DummyTool()
|
||||
result = await tool()
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.output == "test output"
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Tests for native tool support in AnthropicOAuthProvider."""
|
||||
|
||||
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||
|
||||
|
||||
def test_convert_tools_passes_through_native_tools():
|
||||
"""Test that native tool format is passed through unchanged."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "bash_20250124",
|
||||
"name": "bash"
|
||||
}
|
||||
]
|
||||
|
||||
result = provider._convert_tools_to_anthropic(tools)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "bash_20250124"
|
||||
assert result[0]["name"] == "bash"
|
||||
|
||||
|
||||
def test_convert_tools_handles_mixed_tool_types():
|
||||
"""Test conversion of both function and native tools."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "custom_tool",
|
||||
"description": "A custom tool",
|
||||
"parameters": {"type": "object", "properties": {"arg": {"type": "string"}}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "bash_20250124",
|
||||
"name": "bash"
|
||||
}
|
||||
]
|
||||
|
||||
result = provider._convert_tools_to_anthropic(tools)
|
||||
assert len(result) == 2
|
||||
|
||||
# Function tool gets converted
|
||||
assert result[0]["name"] == "custom_tool"
|
||||
assert result[0]["description"] == "A custom tool"
|
||||
assert "input_schema" in result[0]
|
||||
|
||||
# Native tool passed through
|
||||
assert result[1]["type"] == "bash_20250124"
|
||||
assert result[1]["name"] == "bash"
|
||||
|
||||
|
||||
def test_convert_tools_preserves_function_tool_conversion():
|
||||
"""Test that existing function tool conversion still works."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "test",
|
||||
"description": "desc",
|
||||
"parameters": {"type": "object"}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
result = provider._convert_tools_to_anthropic(tools)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "test"
|
||||
assert result[0]["description"] == "desc"
|
||||
assert result[0]["input_schema"] == {"type": "object"}
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Test that bash tool handles heredoc commands correctly.
|
||||
|
||||
Reproduces the bug where `; echo '<<exit>>'` appended on the same line
|
||||
as a heredoc terminator prevents bash from recognizing the terminator,
|
||||
causing the session to hang forever.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heredoc_command():
|
||||
"""Heredoc commands must complete without hanging."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
# Simple command works
|
||||
result = await tool(command="echo hello")
|
||||
assert result.output == "hello"
|
||||
|
||||
# Heredoc command — this is the exact pattern that caused the hang
|
||||
result = await asyncio.wait_for(
|
||||
tool(command="cat << 'EOF'\nline1\nline2\nEOF"),
|
||||
timeout=5.0,
|
||||
)
|
||||
assert "line1" in result.output
|
||||
assert "line2" in result.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heredoc_append_to_file():
|
||||
"""Heredoc append (the exact pattern the LLM uses) must work."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
tool(command="cat >> /tmp/test_heredoc_bash.txt << 'EOF'\nhello world\nEOF"),
|
||||
timeout=5.0,
|
||||
)
|
||||
# Should complete without error
|
||||
assert result.error is None or result.error == ""
|
||||
|
||||
# Verify the file was written
|
||||
result2 = await tool(command="cat /tmp/test_heredoc_bash.txt")
|
||||
assert "hello world" in result2.output
|
||||
|
||||
# Cleanup
|
||||
await tool(command="rm -f /tmp/test_heredoc_bash.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_commands_still_work():
|
||||
"""Ensure regular commands still work after the fix."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
# Semicolons in commands
|
||||
result = await tool(command="echo a; echo b")
|
||||
assert "a" in result.output
|
||||
assert "b" in result.output
|
||||
|
||||
# Multiline script
|
||||
result = await tool(command="for i in 1 2 3; do echo $i; done")
|
||||
assert "1" in result.output
|
||||
assert "3" in result.output
|
||||
|
||||
# Command with exit code
|
||||
result = await tool(command="true")
|
||||
assert result.output == "(no output)" or result.output is not None
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Tests for BashTool20250124."""
|
||||
|
||||
import pytest
|
||||
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||
from nanobot.agent.tools.anthropic.base import ToolResult
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_simple_command():
|
||||
"""Test bash tool executes simple command."""
|
||||
tool = BashTool20250124()
|
||||
result = await tool(command="echo hello")
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert "hello" in result.output
|
||||
assert result.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_persistent_session():
|
||||
"""Test bash tool maintains session across calls."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
# Set variable
|
||||
result1 = await tool(command="export TEST_VAR=42")
|
||||
assert result1.error is None
|
||||
|
||||
# Read variable (should persist)
|
||||
result2 = await tool(command="echo $TEST_VAR")
|
||||
assert "42" in result2.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_restart():
|
||||
"""Test bash tool can restart session."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
# Set variable
|
||||
await tool(command="export TEST_VAR=42")
|
||||
|
||||
# Restart
|
||||
result = await tool(restart=True)
|
||||
assert "restarted" in (result.system or result.output or "").lower()
|
||||
|
||||
# Variable should be gone
|
||||
result2 = await tool(command="echo $TEST_VAR")
|
||||
assert "42" not in result2.output
|
||||
|
||||
|
||||
def test_bash_tool_to_params():
|
||||
"""Test bash tool returns correct params."""
|
||||
tool = BashTool20250124()
|
||||
params = tool.to_params()
|
||||
|
||||
assert params["type"] == "bash_20250124"
|
||||
assert params["name"] == "bash"
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Tests for beta flag collection from native tools."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_beta_flags_collected_from_tools():
|
||||
"""Test that beta flags are extracted from tool objects."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
# Mock tool objects with beta_flag attribute and to_params method
|
||||
class MockTool:
|
||||
def __init__(self, beta_flag):
|
||||
self.beta_flag = beta_flag
|
||||
|
||||
def to_params(self):
|
||||
return {"type": "bash_20250124", "name": "bash"}
|
||||
|
||||
tools_with_flags = [
|
||||
MockTool("computer-use-2025-11-24"),
|
||||
MockTool("computer-use-2025-11-24"), # Duplicate should be deduplicated
|
||||
]
|
||||
|
||||
# We need to test this via the actual API call flow
|
||||
# Mock httpx client
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "test"}],
|
||||
"model": "claude-opus-4",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 10}
|
||||
}
|
||||
|
||||
with patch.object(provider, '_client') as mock_client:
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Call with messages and tools
|
||||
await provider.chat(
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
model="claude-opus-4",
|
||||
max_tokens=100,
|
||||
tools=tools_with_flags
|
||||
)
|
||||
|
||||
# Check that beta flag was added to headers
|
||||
call_args = mock_client.post.call_args
|
||||
headers = call_args[1]["headers"]
|
||||
assert "anthropic-beta" in headers
|
||||
assert headers["anthropic-beta"] == "computer-use-2025-11-24"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_beta_flags_joined():
|
||||
"""Test that multiple unique beta flags are joined with commas."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
class MockTool:
|
||||
def __init__(self, beta_flag):
|
||||
self.beta_flag = beta_flag
|
||||
|
||||
def to_params(self):
|
||||
return {"type": "bash_20250124", "name": "bash"}
|
||||
|
||||
tools_with_flags = [
|
||||
MockTool("flag-a"),
|
||||
MockTool("flag-b"),
|
||||
]
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "test"}],
|
||||
"model": "claude-opus-4",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 10}
|
||||
}
|
||||
|
||||
with patch.object(provider, '_client') as mock_client:
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
await provider.chat(
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
model="claude-opus-4",
|
||||
max_tokens=100,
|
||||
tools=tools_with_flags
|
||||
)
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
headers = call_args[1]["headers"]
|
||||
assert "anthropic-beta" in headers
|
||||
# Should be sorted alphabetically and joined with comma
|
||||
assert headers["anthropic-beta"] == "flag-a,flag-b"
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Tests for bus-level correlation (request-response via Futures)."""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bus():
|
||||
return MessageBus()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_correlation_returns_future(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
assert isinstance(future, asyncio.Future)
|
||||
assert not future.done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_sets_future_result(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "test-id-1"})
|
||||
bus.resolve_correlation(msg)
|
||||
assert future.done()
|
||||
assert future.result() == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_no_match_is_noop(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "other-id"})
|
||||
bus.resolve_correlation(msg)
|
||||
assert not future.done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_no_metadata_is_noop(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
msg = OutboundMessage(channel="hook", chat_id="test", content="hello")
|
||||
bus.resolve_correlation(msg)
|
||||
assert not future.done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_cleans_up_store(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "test-id-1"})
|
||||
bus.resolve_correlation(msg)
|
||||
assert "test-id-1" not in bus._correlation_store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_correlation(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
bus.cancel_correlation("test-id-1")
|
||||
assert "test-id-1" not in bus._correlation_store
|
||||
assert future.cancelled()
|
||||
@@ -12,8 +12,7 @@ def mock_prompt_session():
|
||||
"""Mock the global prompt session."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.prompt_async = AsyncMock()
|
||||
with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session), \
|
||||
patch("nanobot.cli.commands.patch_stdout"):
|
||||
with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session):
|
||||
yield mock_session
|
||||
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from nanobot.cli.commands import app
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||
from nanobot.providers.registry import find_by_model
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_paths():
|
||||
"""Mock config/workspace paths for test isolation."""
|
||||
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
|
||||
patch("nanobot.config.loader.save_config") as mock_sc, \
|
||||
patch("nanobot.config.loader.load_config") as mock_lc, \
|
||||
patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
|
||||
|
||||
base_dir = Path("./test_onboard_data")
|
||||
if base_dir.exists():
|
||||
shutil.rmtree(base_dir)
|
||||
base_dir.mkdir()
|
||||
|
||||
config_file = base_dir / "config.json"
|
||||
workspace_dir = base_dir / "workspace"
|
||||
|
||||
mock_cp.return_value = config_file
|
||||
mock_ws.return_value = workspace_dir
|
||||
mock_sc.side_effect = lambda config: config_file.write_text("{}")
|
||||
|
||||
yield config_file, workspace_dir
|
||||
|
||||
if base_dir.exists():
|
||||
shutil.rmtree(base_dir)
|
||||
|
||||
|
||||
def test_onboard_fresh_install(mock_paths):
|
||||
"""No existing config — should create from scratch."""
|
||||
config_file, workspace_dir = mock_paths
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Created config" in result.stdout
|
||||
assert "Created workspace" in result.stdout
|
||||
assert "nanobot is ready" in result.stdout
|
||||
assert config_file.exists()
|
||||
assert (workspace_dir / "AGENTS.md").exists()
|
||||
assert (workspace_dir / "memory" / "MEMORY.md").exists()
|
||||
|
||||
|
||||
def test_onboard_existing_config_refresh(mock_paths):
|
||||
"""Config exists, user declines overwrite — should refresh (load-merge-save)."""
|
||||
config_file, workspace_dir = mock_paths
|
||||
config_file.write_text('{"existing": true}')
|
||||
|
||||
result = runner.invoke(app, ["onboard"], input="n\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Config already exists" in result.stdout
|
||||
assert "existing values preserved" in result.stdout
|
||||
assert workspace_dir.exists()
|
||||
assert (workspace_dir / "AGENTS.md").exists()
|
||||
|
||||
|
||||
def test_onboard_existing_config_overwrite(mock_paths):
|
||||
"""Config exists, user confirms overwrite — should reset to defaults."""
|
||||
config_file, workspace_dir = mock_paths
|
||||
config_file.write_text('{"existing": true}')
|
||||
|
||||
result = runner.invoke(app, ["onboard"], input="y\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Config already exists" in result.stdout
|
||||
assert "Config reset to defaults" in result.stdout
|
||||
assert workspace_dir.exists()
|
||||
|
||||
|
||||
def test_onboard_existing_workspace_safe_create(mock_paths):
|
||||
"""Workspace exists — should not recreate, but still add missing templates."""
|
||||
config_file, workspace_dir = mock_paths
|
||||
workspace_dir.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
result = runner.invoke(app, ["onboard"], input="n\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Created workspace" not in result.stdout
|
||||
assert "Created AGENTS.md" in result.stdout
|
||||
assert (workspace_dir / "AGENTS.md").exists()
|
||||
|
||||
|
||||
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
|
||||
config = Config()
|
||||
config.agents.defaults.model = "github-copilot/gpt-5.3-codex"
|
||||
|
||||
assert config.get_provider_name() == "github_copilot"
|
||||
|
||||
|
||||
def test_config_matches_openai_codex_with_hyphen_prefix():
|
||||
config = Config()
|
||||
config.agents.defaults.model = "openai-codex/gpt-5.1-codex"
|
||||
|
||||
assert config.get_provider_name() == "openai_codex"
|
||||
|
||||
|
||||
def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword():
|
||||
spec = find_by_model("github-copilot/gpt-5.3-codex")
|
||||
|
||||
assert spec is not None
|
||||
assert spec.name == "github_copilot"
|
||||
|
||||
|
||||
def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
|
||||
provider = LiteLLMProvider(default_model="github-copilot/gpt-5.3-codex")
|
||||
|
||||
resolved = provider._resolve_model("github-copilot/gpt-5.3-codex")
|
||||
|
||||
assert resolved == "github_copilot/gpt-5.3-codex"
|
||||
|
||||
|
||||
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
||||
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Tests for ComputerTool20251124."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from nanobot.agent.tools.anthropic.computer import ComputerTool20251124
|
||||
from nanobot.agent.tools.anthropic.base import ToolResult
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_computer_tool_screenshot():
|
||||
"""Test computer tool can take screenshot."""
|
||||
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||
|
||||
# Mock VNC client
|
||||
with patch('nanobot.agent.tools.anthropic.computer.VNCDoToolClient') as mock_vnc:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.captureScreen = AsyncMock(return_value=b"fake_png_data")
|
||||
|
||||
# Set up async context manager
|
||||
mock_context = MagicMock()
|
||||
mock_context.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_context.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_vnc.create = MagicMock(return_value=mock_context)
|
||||
|
||||
result = await tool(action="screenshot")
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.base64_image is not None
|
||||
assert len(result.base64_image) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_computer_tool_mouse_move():
|
||||
"""Test computer tool can move mouse."""
|
||||
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||
|
||||
with patch('nanobot.agent.tools.anthropic.computer.VNCDoToolClient') as mock_vnc:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.mouseMove = AsyncMock()
|
||||
|
||||
# Set up async context manager
|
||||
mock_context = MagicMock()
|
||||
mock_context.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_context.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_vnc.create = MagicMock(return_value=mock_context)
|
||||
|
||||
result = await tool(action="mouse_move", coordinate=[100, 200])
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.error is None
|
||||
mock_client.mouseMove.assert_called_once_with(100, 200)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_computer_tool_key():
|
||||
"""Test computer tool can press keys."""
|
||||
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||
|
||||
with patch('nanobot.agent.tools.anthropic.computer.VNCDoToolClient') as mock_vnc:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.keyPress = AsyncMock()
|
||||
|
||||
# Set up async context manager
|
||||
mock_context = MagicMock()
|
||||
mock_context.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_context.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_vnc.create = MagicMock(return_value=mock_context)
|
||||
|
||||
result = await tool(action="key", text="Return")
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.error is None
|
||||
mock_client.keyPress.assert_called_once_with("Return")
|
||||
|
||||
|
||||
def test_computer_tool_to_params():
|
||||
"""Test computer tool returns correct params."""
|
||||
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||
params = tool.to_params()
|
||||
|
||||
assert params["type"] == "computer_20251124"
|
||||
assert params["name"] == "computer"
|
||||
@@ -1,828 +0,0 @@
|
||||
"""Test session management with cache-friendly message handling."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
# Test constants
|
||||
MEMORY_WINDOW = 50
|
||||
KEEP_COUNT = MEMORY_WINDOW // 2 # 25
|
||||
|
||||
|
||||
def create_session_with_messages(key: str, count: int, role: str = "user") -> Session:
|
||||
"""Create a session and add the specified number of messages.
|
||||
|
||||
Args:
|
||||
key: Session identifier
|
||||
count: Number of messages to add
|
||||
role: Message role (default: "user")
|
||||
|
||||
Returns:
|
||||
Session with the specified messages
|
||||
"""
|
||||
session = Session(key=key)
|
||||
for i in range(count):
|
||||
session.add_message(role, f"msg{i}")
|
||||
return session
|
||||
|
||||
|
||||
def assert_messages_content(messages: list, start_index: int, end_index: int) -> None:
|
||||
"""Assert that messages contain expected content from start to end index.
|
||||
|
||||
Args:
|
||||
messages: List of message dictionaries
|
||||
start_index: Expected first message index
|
||||
end_index: Expected last message index
|
||||
"""
|
||||
assert len(messages) > 0
|
||||
assert messages[0]["content"] == f"msg{start_index}"
|
||||
assert messages[-1]["content"] == f"msg{end_index}"
|
||||
|
||||
|
||||
def get_old_messages(session: Session, last_consolidated: int, keep_count: int) -> list:
|
||||
"""Extract messages that would be consolidated using the standard slice logic.
|
||||
|
||||
Args:
|
||||
session: The session containing messages
|
||||
last_consolidated: Index of last consolidated message
|
||||
keep_count: Number of recent messages to keep
|
||||
|
||||
Returns:
|
||||
List of messages that would be consolidated
|
||||
"""
|
||||
return session.messages[last_consolidated:-keep_count]
|
||||
|
||||
|
||||
class TestSessionLastConsolidated:
|
||||
"""Test last_consolidated tracking to avoid duplicate processing."""
|
||||
|
||||
def test_initial_last_consolidated_zero(self) -> None:
|
||||
"""Test that new session starts with last_consolidated=0."""
|
||||
session = Session(key="test:initial")
|
||||
assert session.last_consolidated == 0
|
||||
|
||||
def test_last_consolidated_persistence(self, tmp_path) -> None:
|
||||
"""Test that last_consolidated persists across save/load."""
|
||||
manager = SessionManager(Path(tmp_path))
|
||||
session1 = create_session_with_messages("test:persist", 20)
|
||||
session1.last_consolidated = 15
|
||||
manager.save(session1)
|
||||
|
||||
session2 = manager.get_or_create("test:persist")
|
||||
assert session2.last_consolidated == 15
|
||||
assert len(session2.messages) == 20
|
||||
|
||||
def test_clear_resets_last_consolidated(self) -> None:
|
||||
"""Test that clear() resets last_consolidated to 0."""
|
||||
session = create_session_with_messages("test:clear", 10)
|
||||
session.last_consolidated = 5
|
||||
|
||||
session.clear()
|
||||
assert len(session.messages) == 0
|
||||
assert session.last_consolidated == 0
|
||||
|
||||
|
||||
class TestSessionImmutableHistory:
|
||||
"""Test Session message immutability for cache efficiency."""
|
||||
|
||||
def test_initial_state(self) -> None:
|
||||
"""Test that new session has empty messages list."""
|
||||
session = Session(key="test:initial")
|
||||
assert len(session.messages) == 0
|
||||
|
||||
def test_add_messages_appends_only(self) -> None:
|
||||
"""Test that adding messages only appends, never modifies."""
|
||||
session = Session(key="test:preserve")
|
||||
session.add_message("user", "msg1")
|
||||
session.add_message("assistant", "resp1")
|
||||
session.add_message("user", "msg2")
|
||||
assert len(session.messages) == 3
|
||||
assert session.messages[0]["content"] == "msg1"
|
||||
|
||||
def test_get_history_returns_most_recent(self) -> None:
|
||||
"""Test get_history returns the most recent messages."""
|
||||
session = Session(key="test:history")
|
||||
for i in range(10):
|
||||
session.add_message("user", f"msg{i}")
|
||||
session.add_message("assistant", f"resp{i}")
|
||||
|
||||
history = session.get_history(max_messages=6)
|
||||
assert len(history) == 6
|
||||
assert history[0]["content"] == "msg7"
|
||||
assert history[-1]["content"] == "resp9"
|
||||
|
||||
def test_get_history_with_all_messages(self) -> None:
|
||||
"""Test get_history with max_messages larger than actual."""
|
||||
session = create_session_with_messages("test:all", 5)
|
||||
history = session.get_history(max_messages=100)
|
||||
assert len(history) == 5
|
||||
assert history[0]["content"] == "msg0"
|
||||
|
||||
def test_get_history_stable_for_same_session(self) -> None:
|
||||
"""Test that get_history returns same content for same max_messages."""
|
||||
session = create_session_with_messages("test:stable", 20)
|
||||
history1 = session.get_history(max_messages=10)
|
||||
history2 = session.get_history(max_messages=10)
|
||||
assert history1 == history2
|
||||
|
||||
def test_messages_list_never_modified(self) -> None:
|
||||
"""Test that messages list is never modified after creation."""
|
||||
session = create_session_with_messages("test:immutable", 5)
|
||||
original_len = len(session.messages)
|
||||
|
||||
session.get_history(max_messages=2)
|
||||
assert len(session.messages) == original_len
|
||||
|
||||
for _ in range(10):
|
||||
session.get_history(max_messages=3)
|
||||
assert len(session.messages) == original_len
|
||||
|
||||
|
||||
class TestSessionPersistence:
|
||||
"""Test Session persistence and reload."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_manager(self, tmp_path):
|
||||
return SessionManager(Path(tmp_path))
|
||||
|
||||
def test_persistence_roundtrip(self, temp_manager):
|
||||
"""Test that messages persist across save/load."""
|
||||
session1 = create_session_with_messages("test:persistence", 20)
|
||||
temp_manager.save(session1)
|
||||
|
||||
session2 = temp_manager.get_or_create("test:persistence")
|
||||
assert len(session2.messages) == 20
|
||||
assert session2.messages[0]["content"] == "msg0"
|
||||
assert session2.messages[-1]["content"] == "msg19"
|
||||
|
||||
def test_get_history_after_reload(self, temp_manager):
|
||||
"""Test that get_history works correctly after reload."""
|
||||
session1 = create_session_with_messages("test:reload", 30)
|
||||
temp_manager.save(session1)
|
||||
|
||||
session2 = temp_manager.get_or_create("test:reload")
|
||||
history = session2.get_history(max_messages=10)
|
||||
assert len(history) == 10
|
||||
assert history[0]["content"] == "msg20"
|
||||
assert history[-1]["content"] == "msg29"
|
||||
|
||||
def test_clear_resets_session(self, temp_manager):
|
||||
"""Test that clear() properly resets session."""
|
||||
session = create_session_with_messages("test:clear", 10)
|
||||
assert len(session.messages) == 10
|
||||
|
||||
session.clear()
|
||||
assert len(session.messages) == 0
|
||||
|
||||
|
||||
class TestConsolidationTriggerConditions:
|
||||
"""Test consolidation trigger conditions and logic."""
|
||||
|
||||
def test_consolidation_needed_when_messages_exceed_window(self):
|
||||
"""Test consolidation logic: should trigger when messages > memory_window."""
|
||||
session = create_session_with_messages("test:trigger", 60)
|
||||
|
||||
total_messages = len(session.messages)
|
||||
messages_to_process = total_messages - session.last_consolidated
|
||||
|
||||
assert total_messages > MEMORY_WINDOW
|
||||
assert messages_to_process > 0
|
||||
|
||||
expected_consolidate_count = total_messages - KEEP_COUNT
|
||||
assert expected_consolidate_count == 35
|
||||
|
||||
def test_consolidation_skipped_when_within_keep_count(self):
|
||||
"""Test consolidation skipped when total messages <= keep_count."""
|
||||
session = create_session_with_messages("test:skip", 20)
|
||||
|
||||
total_messages = len(session.messages)
|
||||
assert total_messages <= KEEP_COUNT
|
||||
|
||||
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
|
||||
assert len(old_messages) == 0
|
||||
|
||||
def test_consolidation_skipped_when_no_new_messages(self):
|
||||
"""Test consolidation skipped when messages_to_process <= 0."""
|
||||
session = create_session_with_messages("test:already_consolidated", 40)
|
||||
session.last_consolidated = len(session.messages) - KEEP_COUNT # 15
|
||||
|
||||
# Add a few more messages
|
||||
for i in range(40, 42):
|
||||
session.add_message("user", f"msg{i}")
|
||||
|
||||
total_messages = len(session.messages)
|
||||
messages_to_process = total_messages - session.last_consolidated
|
||||
assert messages_to_process > 0
|
||||
|
||||
# Simulate last_consolidated catching up
|
||||
session.last_consolidated = total_messages - KEEP_COUNT
|
||||
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
|
||||
assert len(old_messages) == 0
|
||||
|
||||
|
||||
class TestLastConsolidatedEdgeCases:
|
||||
"""Test last_consolidated edge cases and data corruption scenarios."""
|
||||
|
||||
def test_last_consolidated_exceeds_message_count(self):
|
||||
"""Test behavior when last_consolidated > len(messages) (data corruption)."""
|
||||
session = create_session_with_messages("test:corruption", 10)
|
||||
session.last_consolidated = 20
|
||||
|
||||
total_messages = len(session.messages)
|
||||
messages_to_process = total_messages - session.last_consolidated
|
||||
assert messages_to_process <= 0
|
||||
|
||||
old_messages = get_old_messages(session, session.last_consolidated, 5)
|
||||
assert len(old_messages) == 0
|
||||
|
||||
def test_last_consolidated_negative_value(self):
|
||||
"""Test behavior with negative last_consolidated (invalid state)."""
|
||||
session = create_session_with_messages("test:negative", 10)
|
||||
session.last_consolidated = -5
|
||||
|
||||
keep_count = 3
|
||||
old_messages = get_old_messages(session, session.last_consolidated, keep_count)
|
||||
|
||||
# messages[-5:-3] with 10 messages gives indices 5,6
|
||||
assert len(old_messages) == 2
|
||||
assert old_messages[0]["content"] == "msg5"
|
||||
assert old_messages[-1]["content"] == "msg6"
|
||||
|
||||
def test_messages_added_after_consolidation(self):
|
||||
"""Test correct behavior when new messages arrive after consolidation."""
|
||||
session = create_session_with_messages("test:new_messages", 40)
|
||||
session.last_consolidated = len(session.messages) - KEEP_COUNT # 15
|
||||
|
||||
# Add new messages after consolidation
|
||||
for i in range(40, 50):
|
||||
session.add_message("user", f"msg{i}")
|
||||
|
||||
total_messages = len(session.messages)
|
||||
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
|
||||
expected_consolidate_count = total_messages - KEEP_COUNT - session.last_consolidated
|
||||
|
||||
assert len(old_messages) == expected_consolidate_count
|
||||
assert_messages_content(old_messages, 15, 24)
|
||||
|
||||
def test_slice_behavior_when_indices_overlap(self):
|
||||
"""Test slice behavior when last_consolidated >= total - keep_count."""
|
||||
session = create_session_with_messages("test:overlap", 30)
|
||||
session.last_consolidated = 12
|
||||
|
||||
old_messages = get_old_messages(session, session.last_consolidated, 20)
|
||||
assert len(old_messages) == 0
|
||||
|
||||
|
||||
class TestArchiveAllMode:
|
||||
"""Test archive_all mode (used by /new command)."""
|
||||
|
||||
def test_archive_all_consolidates_everything(self):
|
||||
"""Test archive_all=True consolidates all messages."""
|
||||
session = create_session_with_messages("test:archive_all", 50)
|
||||
|
||||
archive_all = True
|
||||
if archive_all:
|
||||
old_messages = session.messages
|
||||
assert len(old_messages) == 50
|
||||
|
||||
assert session.last_consolidated == 0
|
||||
|
||||
def test_archive_all_resets_last_consolidated(self):
|
||||
"""Test that archive_all mode resets last_consolidated to 0."""
|
||||
session = create_session_with_messages("test:reset", 40)
|
||||
session.last_consolidated = 15
|
||||
|
||||
archive_all = True
|
||||
if archive_all:
|
||||
session.last_consolidated = 0
|
||||
|
||||
assert session.last_consolidated == 0
|
||||
assert len(session.messages) == 40
|
||||
|
||||
def test_archive_all_vs_normal_consolidation(self):
|
||||
"""Test difference between archive_all and normal consolidation."""
|
||||
# Normal consolidation
|
||||
session1 = create_session_with_messages("test:normal", 60)
|
||||
session1.last_consolidated = len(session1.messages) - KEEP_COUNT
|
||||
|
||||
# archive_all mode
|
||||
session2 = create_session_with_messages("test:all", 60)
|
||||
session2.last_consolidated = 0
|
||||
|
||||
assert session1.last_consolidated == 35
|
||||
assert len(session1.messages) == 60
|
||||
assert session2.last_consolidated == 0
|
||||
assert len(session2.messages) == 60
|
||||
|
||||
|
||||
class TestCacheImmutability:
|
||||
"""Test that consolidation doesn't modify session.messages (cache safety)."""
|
||||
|
||||
def test_consolidation_does_not_modify_messages_list(self):
|
||||
"""Test that consolidation leaves messages list unchanged."""
|
||||
session = create_session_with_messages("test:immutable", 50)
|
||||
|
||||
original_messages = session.messages.copy()
|
||||
original_len = len(session.messages)
|
||||
session.last_consolidated = original_len - KEEP_COUNT
|
||||
|
||||
assert len(session.messages) == original_len
|
||||
assert session.messages == original_messages
|
||||
|
||||
def test_get_history_does_not_modify_messages(self):
|
||||
"""Test that get_history doesn't modify messages list."""
|
||||
session = create_session_with_messages("test:history_immutable", 40)
|
||||
original_messages = [m.copy() for m in session.messages]
|
||||
|
||||
for _ in range(5):
|
||||
history = session.get_history(max_messages=10)
|
||||
assert len(history) == 10
|
||||
|
||||
assert len(session.messages) == 40
|
||||
for i, msg in enumerate(session.messages):
|
||||
assert msg["content"] == original_messages[i]["content"]
|
||||
|
||||
def test_consolidation_only_updates_last_consolidated(self):
|
||||
"""Test that consolidation only updates last_consolidated field."""
|
||||
session = create_session_with_messages("test:field_only", 60)
|
||||
|
||||
original_messages = session.messages.copy()
|
||||
original_key = session.key
|
||||
original_metadata = session.metadata.copy()
|
||||
|
||||
session.last_consolidated = len(session.messages) - KEEP_COUNT
|
||||
|
||||
assert session.messages == original_messages
|
||||
assert session.key == original_key
|
||||
assert session.metadata == original_metadata
|
||||
assert session.last_consolidated == 35
|
||||
|
||||
|
||||
class TestSliceLogic:
|
||||
"""Test the slice logic: messages[last_consolidated:-keep_count]."""
|
||||
|
||||
def test_slice_extracts_correct_range(self):
|
||||
"""Test that slice extracts the correct message range."""
|
||||
session = create_session_with_messages("test:slice", 60)
|
||||
|
||||
old_messages = get_old_messages(session, 0, KEEP_COUNT)
|
||||
|
||||
assert len(old_messages) == 35
|
||||
assert_messages_content(old_messages, 0, 34)
|
||||
|
||||
remaining = session.messages[-KEEP_COUNT:]
|
||||
assert len(remaining) == 25
|
||||
assert_messages_content(remaining, 35, 59)
|
||||
|
||||
def test_slice_with_partial_consolidation(self):
|
||||
"""Test slice when some messages already consolidated."""
|
||||
session = create_session_with_messages("test:partial", 70)
|
||||
|
||||
last_consolidated = 30
|
||||
old_messages = get_old_messages(session, last_consolidated, KEEP_COUNT)
|
||||
|
||||
assert len(old_messages) == 15
|
||||
assert_messages_content(old_messages, 30, 44)
|
||||
|
||||
def test_slice_with_various_keep_counts(self):
|
||||
"""Test slice behavior with different keep_count values."""
|
||||
session = create_session_with_messages("test:keep_counts", 50)
|
||||
|
||||
test_cases = [(10, 40), (20, 30), (30, 20), (40, 10)]
|
||||
|
||||
for keep_count, expected_count in test_cases:
|
||||
old_messages = session.messages[0:-keep_count]
|
||||
assert len(old_messages) == expected_count
|
||||
|
||||
def test_slice_when_keep_count_exceeds_messages(self):
|
||||
"""Test slice when keep_count > len(messages)."""
|
||||
session = create_session_with_messages("test:exceed", 10)
|
||||
|
||||
old_messages = session.messages[0:-20]
|
||||
assert len(old_messages) == 0
|
||||
|
||||
|
||||
class TestEmptyAndBoundarySessions:
|
||||
"""Test empty sessions and boundary conditions."""
|
||||
|
||||
def test_empty_session_consolidation(self):
|
||||
"""Test consolidation behavior with empty session."""
|
||||
session = Session(key="test:empty")
|
||||
|
||||
assert len(session.messages) == 0
|
||||
assert session.last_consolidated == 0
|
||||
|
||||
messages_to_process = len(session.messages) - session.last_consolidated
|
||||
assert messages_to_process == 0
|
||||
|
||||
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
|
||||
assert len(old_messages) == 0
|
||||
|
||||
def test_single_message_session(self):
|
||||
"""Test consolidation with single message."""
|
||||
session = Session(key="test:single")
|
||||
session.add_message("user", "only message")
|
||||
|
||||
assert len(session.messages) == 1
|
||||
|
||||
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
|
||||
assert len(old_messages) == 0
|
||||
|
||||
def test_exactly_keep_count_messages(self):
|
||||
"""Test session with exactly keep_count messages."""
|
||||
session = create_session_with_messages("test:exact", KEEP_COUNT)
|
||||
|
||||
assert len(session.messages) == KEEP_COUNT
|
||||
|
||||
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
|
||||
assert len(old_messages) == 0
|
||||
|
||||
def test_just_over_keep_count(self):
|
||||
"""Test session with one message over keep_count."""
|
||||
session = create_session_with_messages("test:over", KEEP_COUNT + 1)
|
||||
|
||||
assert len(session.messages) == 26
|
||||
|
||||
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
|
||||
assert len(old_messages) == 1
|
||||
assert old_messages[0]["content"] == "msg0"
|
||||
|
||||
def test_very_large_session(self):
|
||||
"""Test consolidation with very large message count."""
|
||||
session = create_session_with_messages("test:large", 1000)
|
||||
|
||||
assert len(session.messages) == 1000
|
||||
|
||||
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
|
||||
assert len(old_messages) == 975
|
||||
assert_messages_content(old_messages, 0, 974)
|
||||
|
||||
remaining = session.messages[-KEEP_COUNT:]
|
||||
assert len(remaining) == 25
|
||||
assert_messages_content(remaining, 975, 999)
|
||||
|
||||
def test_session_with_gaps_in_consolidation(self):
|
||||
"""Test session with potential gaps in consolidation history."""
|
||||
session = create_session_with_messages("test:gaps", 50)
|
||||
session.last_consolidated = 10
|
||||
|
||||
# Add more messages
|
||||
for i in range(50, 60):
|
||||
session.add_message("user", f"msg{i}")
|
||||
|
||||
old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
|
||||
|
||||
expected_count = 60 - KEEP_COUNT - 10
|
||||
assert len(old_messages) == expected_count
|
||||
assert_messages_content(old_messages, 10, 34)
|
||||
|
||||
|
||||
class TestConsolidationDeduplicationGuard:
|
||||
"""Test that consolidation tasks are deduplicated and serialized."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_guard_prevents_duplicate_tasks(self, tmp_path: Path) -> None:
|
||||
"""Concurrent messages above memory_window spawn only one consolidation task."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(
|
||||
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
|
||||
)
|
||||
|
||||
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
for i in range(15):
|
||||
session.add_message("user", f"msg{i}")
|
||||
session.add_message("assistant", f"resp{i}")
|
||||
loop.sessions.save(session)
|
||||
|
||||
consolidation_calls = 0
|
||||
|
||||
async def _fake_consolidate(_session, archive_all: bool = False) -> None:
|
||||
nonlocal consolidation_calls
|
||||
consolidation_calls += 1
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
|
||||
await loop._process_message(msg)
|
||||
await loop._process_message(msg)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert consolidation_calls == 1, (
|
||||
f"Expected exactly 1 consolidation, got {consolidation_calls}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_command_guard_prevents_concurrent_consolidation(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""/new command does not run consolidation concurrently with in-flight consolidation."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(
|
||||
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
|
||||
)
|
||||
|
||||
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
for i in range(15):
|
||||
session.add_message("user", f"msg{i}")
|
||||
session.add_message("assistant", f"resp{i}")
|
||||
loop.sessions.save(session)
|
||||
|
||||
consolidation_calls = 0
|
||||
active = 0
|
||||
max_active = 0
|
||||
|
||||
async def _fake_consolidate(_session, archive_all: bool = False) -> None:
|
||||
nonlocal consolidation_calls, active, max_active
|
||||
consolidation_calls += 1
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
await asyncio.sleep(0.05)
|
||||
active -= 1
|
||||
|
||||
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
|
||||
await loop._process_message(msg)
|
||||
|
||||
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
||||
await loop._process_message(new_msg)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert consolidation_calls == 2, (
|
||||
f"Expected normal + /new consolidations, got {consolidation_calls}"
|
||||
)
|
||||
assert max_active == 1, (
|
||||
f"Expected serialized consolidation, observed concurrency={max_active}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_tasks_are_referenced(self, tmp_path: Path) -> None:
|
||||
"""create_task results are tracked in _consolidation_tasks while in flight."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(
|
||||
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
|
||||
)
|
||||
|
||||
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
for i in range(15):
|
||||
session.add_message("user", f"msg{i}")
|
||||
session.add_message("assistant", f"resp{i}")
|
||||
loop.sessions.save(session)
|
||||
|
||||
started = asyncio.Event()
|
||||
|
||||
async def _slow_consolidate(_session, archive_all: bool = False) -> None:
|
||||
started.set()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
loop._consolidate_memory = _slow_consolidate # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
|
||||
await loop._process_message(msg)
|
||||
|
||||
await started.wait()
|
||||
assert len(loop._consolidation_tasks) == 1, "Task must be referenced while in-flight"
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
assert len(loop._consolidation_tasks) == 0, (
|
||||
"Task reference must be removed after completion"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_waits_for_inflight_consolidation_and_preserves_messages(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""/new waits for in-flight consolidation and archives before clear."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(
|
||||
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
|
||||
)
|
||||
|
||||
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
for i in range(15):
|
||||
session.add_message("user", f"msg{i}")
|
||||
session.add_message("assistant", f"resp{i}")
|
||||
loop.sessions.save(session)
|
||||
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
archived_count = 0
|
||||
|
||||
async def _fake_consolidate(sess, archive_all: bool = False) -> bool:
|
||||
nonlocal archived_count
|
||||
if archive_all:
|
||||
archived_count = len(sess.messages)
|
||||
return True
|
||||
started.set()
|
||||
await release.wait()
|
||||
return True
|
||||
|
||||
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
|
||||
await loop._process_message(msg)
|
||||
await started.wait()
|
||||
|
||||
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
||||
pending_new = asyncio.create_task(loop._process_message(new_msg))
|
||||
|
||||
await asyncio.sleep(0.02)
|
||||
assert not pending_new.done(), "/new should wait while consolidation is in-flight"
|
||||
|
||||
release.set()
|
||||
response = await pending_new
|
||||
assert response is not None
|
||||
assert "new session started" in response.content.lower()
|
||||
assert archived_count > 0, "Expected /new archival to process a non-empty snapshot"
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert session_after.messages == [], "Session should be cleared after successful archival"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_does_not_clear_session_when_archive_fails(self, tmp_path: Path) -> None:
|
||||
"""/new must keep session data if archive step reports failure."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(
|
||||
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
|
||||
)
|
||||
|
||||
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
for i in range(5):
|
||||
session.add_message("user", f"msg{i}")
|
||||
session.add_message("assistant", f"resp{i}")
|
||||
loop.sessions.save(session)
|
||||
before_count = len(session.messages)
|
||||
|
||||
async def _failing_consolidate(sess, archive_all: bool = False) -> bool:
|
||||
if archive_all:
|
||||
return False
|
||||
return True
|
||||
|
||||
loop._consolidate_memory = _failing_consolidate # type: ignore[method-assign]
|
||||
|
||||
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
||||
response = await loop._process_message(new_msg)
|
||||
|
||||
assert response is not None
|
||||
assert "failed" in response.content.lower()
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert len(session_after.messages) == before_count, (
|
||||
"Session must remain intact when /new archival fails"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_archives_only_unconsolidated_messages_after_inflight_task(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""/new should archive only messages not yet consolidated by prior task."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(
|
||||
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
|
||||
)
|
||||
|
||||
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
for i in range(15):
|
||||
session.add_message("user", f"msg{i}")
|
||||
session.add_message("assistant", f"resp{i}")
|
||||
loop.sessions.save(session)
|
||||
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
archived_count = -1
|
||||
|
||||
async def _fake_consolidate(sess, archive_all: bool = False) -> bool:
|
||||
nonlocal archived_count
|
||||
if archive_all:
|
||||
archived_count = len(sess.messages)
|
||||
return True
|
||||
|
||||
started.set()
|
||||
await release.wait()
|
||||
sess.last_consolidated = len(sess.messages) - 3
|
||||
return True
|
||||
|
||||
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
|
||||
await loop._process_message(msg)
|
||||
await started.wait()
|
||||
|
||||
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
||||
pending_new = asyncio.create_task(loop._process_message(new_msg))
|
||||
await asyncio.sleep(0.02)
|
||||
assert not pending_new.done()
|
||||
|
||||
release.set()
|
||||
response = await pending_new
|
||||
|
||||
assert response is not None
|
||||
assert "new session started" in response.content.lower()
|
||||
assert archived_count == 3, (
|
||||
f"Expected only unconsolidated tail to archive, got {archived_count}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_cleans_up_consolidation_lock_for_invalidated_session(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""/new should remove lock entry for fully invalidated session key."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(
|
||||
bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
|
||||
)
|
||||
|
||||
loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
for i in range(3):
|
||||
session.add_message("user", f"msg{i}")
|
||||
session.add_message("assistant", f"resp{i}")
|
||||
loop.sessions.save(session)
|
||||
|
||||
# Ensure lock exists before /new.
|
||||
loop._consolidation_locks.setdefault(session.key, asyncio.Lock())
|
||||
assert session.key in loop._consolidation_locks
|
||||
|
||||
async def _ok_consolidate(sess, archive_all: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
loop._consolidate_memory = _ok_consolidate # type: ignore[method-assign]
|
||||
|
||||
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
|
||||
response = await loop._process_message(new_msg)
|
||||
|
||||
assert response is not None
|
||||
assert "new session started" in response.content.lower()
|
||||
assert session.key not in loop._consolidation_locks
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Tests for cache-friendly prompt construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime as real_datetime
|
||||
from pathlib import Path
|
||||
import datetime as datetime_module
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
|
||||
|
||||
class _FakeDatetime(real_datetime):
|
||||
current = real_datetime(2026, 2, 24, 13, 59)
|
||||
|
||||
@classmethod
|
||||
def now(cls, tz=None): # type: ignore[override]
|
||||
return cls.current
|
||||
|
||||
|
||||
def _make_workspace(tmp_path: Path) -> Path:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir(parents=True)
|
||||
return workspace
|
||||
|
||||
|
||||
def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> None:
|
||||
"""System prompt should not change just because wall clock minute changes."""
|
||||
monkeypatch.setattr(datetime_module, "datetime", _FakeDatetime)
|
||||
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
_FakeDatetime.current = real_datetime(2026, 2, 24, 13, 59)
|
||||
prompt1 = builder.build_system_prompt()
|
||||
|
||||
_FakeDatetime.current = real_datetime(2026, 2, 24, 14, 0)
|
||||
prompt2 = builder.build_system_prompt()
|
||||
|
||||
assert prompt1 == prompt2
|
||||
|
||||
|
||||
def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
|
||||
"""Runtime metadata should be a separate user message before the actual user message."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
messages = builder.build_messages(
|
||||
history=[],
|
||||
current_message="Return exactly: OK",
|
||||
channel="cli",
|
||||
chat_id="direct",
|
||||
)
|
||||
|
||||
assert messages[0]["role"] == "system"
|
||||
assert "## Current Session" not in messages[0]["content"]
|
||||
|
||||
assert messages[-2]["role"] == "user"
|
||||
runtime_content = messages[-2]["content"]
|
||||
assert isinstance(runtime_content, str)
|
||||
assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content
|
||||
assert "Current Time:" in runtime_content
|
||||
assert "Channel: cli" in runtime_content
|
||||
assert "Chat ID: direct" in runtime_content
|
||||
|
||||
assert messages[-1]["role"] == "user"
|
||||
assert messages[-1]["content"] == "Return exactly: OK"
|
||||
@@ -1,29 +0,0 @@
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from nanobot.cli.commands import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
"demo",
|
||||
"--message",
|
||||
"hello",
|
||||
"--cron",
|
||||
"0 9 * * *",
|
||||
"--tz",
|
||||
"America/Vancovuer",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout
|
||||
assert not (tmp_path / "cron" / "jobs.json").exists()
|
||||
@@ -1,30 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronSchedule
|
||||
|
||||
|
||||
def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
|
||||
with pytest.raises(ValueError, match="unknown timezone 'America/Vancovuer'"):
|
||||
service.add_job(
|
||||
name="tz typo",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancovuer"),
|
||||
message="hello",
|
||||
)
|
||||
|
||||
assert service.list_jobs(include_disabled=True) == []
|
||||
|
||||
|
||||
def test_add_job_accepts_valid_timezone(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
|
||||
job = service.add_job(
|
||||
name="tz ok",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancouver"),
|
||||
message="hello",
|
||||
)
|
||||
|
||||
assert job.schedule.tz == "America/Vancouver"
|
||||
assert job.state.next_run_at_ms is not None
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Tests for EditTool20250728."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from nanobot.agent.tools.anthropic.edit import EditTool20250728
|
||||
from nanobot.agent.tools.anthropic.base import CLIResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def edit_tool():
|
||||
"""Create an EditTool20250728 instance."""
|
||||
return EditTool20250728()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_file(tmp_path):
|
||||
"""Create a temporary file with some content."""
|
||||
file_path = tmp_path / "test.txt"
|
||||
file_path.write_text("line 1\nline 2\nline 3\n")
|
||||
return file_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_command(edit_tool, temp_file):
|
||||
"""Test viewing a file with line numbers."""
|
||||
result = await edit_tool(
|
||||
command="view",
|
||||
path=str(temp_file)
|
||||
)
|
||||
assert result.output is not None
|
||||
assert "1|line 1" in result.output
|
||||
assert "2|line 2" in result.output
|
||||
assert "3|line 3" in result.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_command(edit_tool, tmp_path):
|
||||
"""Test creating a new file."""
|
||||
new_file = tmp_path / "new.txt"
|
||||
result = await edit_tool(
|
||||
command="create",
|
||||
path=str(new_file),
|
||||
file_text="Hello\nWorld\n"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert new_file.exists()
|
||||
assert new_file.read_text() == "Hello\nWorld\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_str_replace_command(edit_tool, temp_file):
|
||||
"""Test replacing a unique string."""
|
||||
result = await edit_tool(
|
||||
command="str_replace",
|
||||
path=str(temp_file),
|
||||
old_str="line 2",
|
||||
new_str="LINE TWO"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = temp_file.read_text()
|
||||
assert "LINE TWO" in content
|
||||
assert "line 2" not in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_str_replace_non_unique(edit_tool, temp_file):
|
||||
"""Test that str_replace fails on non-unique match."""
|
||||
# Write content with duplicate "line"
|
||||
temp_file.write_text("line 1\nline 2\nline 3\n")
|
||||
result = await edit_tool(
|
||||
command="str_replace",
|
||||
path=str(temp_file),
|
||||
old_str="line", # This appears 3 times
|
||||
new_str="LINE"
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "must match exactly once" in result.error.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_command(edit_tool, temp_file):
|
||||
"""Test inserting text at a specific line."""
|
||||
result = await edit_tool(
|
||||
command="insert",
|
||||
path=str(temp_file),
|
||||
insert_line=1,
|
||||
new_str="inserted line\n"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = temp_file.read_text()
|
||||
lines = content.splitlines()
|
||||
assert lines[1] == "inserted line"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_tool_requires_absolute_path():
|
||||
"""Test edit tool rejects relative paths."""
|
||||
tool = EditTool20250728()
|
||||
|
||||
result = await tool(
|
||||
command="view",
|
||||
path="relative/path.txt"
|
||||
)
|
||||
|
||||
assert isinstance(result, CLIResult)
|
||||
assert result.exit_code == 1
|
||||
assert "absolute" in result.error.lower()
|
||||
|
||||
|
||||
def test_edit_tool_to_params():
|
||||
"""Test edit tool returns correct params."""
|
||||
tool = EditTool20250728()
|
||||
params = tool.to_params()
|
||||
|
||||
assert params["type"] == "text_editor_20250728"
|
||||
assert params["name"] == "str_replace_editor"
|
||||
@@ -169,8 +169,7 @@ async def test_send_uses_smtp_and_reply_subject(monkeypatch) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_skips_reply_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
"""When auto_reply_enabled=False, replies should be skipped but proactive sends allowed."""
|
||||
async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
class FakeSMTP:
|
||||
def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
|
||||
self.sent_messages: list[EmailMessage] = []
|
||||
@@ -202,11 +201,6 @@ async def test_send_skips_reply_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
cfg = _make_config()
|
||||
cfg.auto_reply_enabled = False
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
|
||||
# Mark alice as someone who sent us an email (making this a "reply")
|
||||
channel._last_subject_by_chat["alice@example.com"] = "Previous email"
|
||||
|
||||
# Reply should be skipped (auto_reply_enabled=False)
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="email",
|
||||
@@ -216,7 +210,6 @@ async def test_send_skips_reply_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
)
|
||||
assert fake_instances == []
|
||||
|
||||
# Reply with force_send=True should be sent
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="email",
|
||||
@@ -229,56 +222,6 @@ async def test_send_skips_reply_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
assert len(fake_instances[0].sent_messages) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_proactive_email_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
"""Proactive emails (not replies) should be sent even when auto_reply_enabled=False."""
|
||||
class FakeSMTP:
|
||||
def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
|
||||
self.sent_messages: list[EmailMessage] = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def starttls(self, context=None):
|
||||
return None
|
||||
|
||||
def login(self, _user: str, _pw: str):
|
||||
return None
|
||||
|
||||
def send_message(self, msg: EmailMessage):
|
||||
self.sent_messages.append(msg)
|
||||
|
||||
fake_instances: list[FakeSMTP] = []
|
||||
|
||||
def _smtp_factory(host: str, port: int, timeout: int = 30):
|
||||
instance = FakeSMTP(host, port, timeout=timeout)
|
||||
fake_instances.append(instance)
|
||||
return instance
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
|
||||
|
||||
cfg = _make_config()
|
||||
cfg.auto_reply_enabled = False
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
|
||||
# bob@example.com has never sent us an email (proactive send)
|
||||
# This should be sent even with auto_reply_enabled=False
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="email",
|
||||
chat_id="bob@example.com",
|
||||
content="Hello, this is a proactive email.",
|
||||
)
|
||||
)
|
||||
assert len(fake_instances) == 1
|
||||
assert len(fake_instances[0].sent_messages) == 1
|
||||
sent = fake_instances[0].sent_messages[0]
|
||||
assert sent["To"] == "bob@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_skips_when_consent_not_granted(monkeypatch) -> None:
|
||||
class FakeSMTP:
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# tests/test_heartbeat_idle.py
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_skips_when_user_active():
|
||||
"""Test that heartbeat doesn't trigger if user messaged recently."""
|
||||
workspace = Path("/tmp/test-heartbeat")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
# Create session with recent user message
|
||||
sessions = SessionManager(workspace)
|
||||
session = sessions.get_or_create("telegram:239824268")
|
||||
session.add_message("user", "Recent message")
|
||||
sessions.save(session)
|
||||
|
||||
# Create heartbeat callback
|
||||
callback_called = False
|
||||
async def on_heartbeat(prompt, metadata=None):
|
||||
nonlocal callback_called
|
||||
callback_called = True
|
||||
return "response"
|
||||
|
||||
# Create heartbeat service
|
||||
service = HeartbeatService(
|
||||
workspace=workspace,
|
||||
on_heartbeat=on_heartbeat,
|
||||
interval_s=1, # Short interval for testing
|
||||
enabled=True,
|
||||
session_manager=sessions,
|
||||
target_session_key="telegram:239824268"
|
||||
)
|
||||
|
||||
# Trigger heartbeat
|
||||
await service._tick()
|
||||
|
||||
# Callback should NOT have been called (user was active recently)
|
||||
assert not callback_called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_triggers_when_user_idle():
|
||||
"""Test that heartbeat triggers after 30min of inactivity."""
|
||||
workspace = Path("/tmp/test-heartbeat")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
# Create HEARTBEAT.md with content
|
||||
heartbeat_file = workspace / "HEARTBEAT.md"
|
||||
heartbeat_file.write_text("# Tasks\n- Check something\n")
|
||||
|
||||
# Create session with old user message (>30min ago)
|
||||
sessions = SessionManager(workspace)
|
||||
session = sessions.get_or_create("telegram:239824268")
|
||||
|
||||
# Manually set old timestamp
|
||||
old_timestamp = (datetime.now() - timedelta(minutes=31)).isoformat()
|
||||
session.messages.append({
|
||||
"role": "user",
|
||||
"content": "Old message",
|
||||
"timestamp": old_timestamp
|
||||
})
|
||||
sessions.save(session)
|
||||
|
||||
# Create heartbeat callback
|
||||
callback_called = False
|
||||
callback_metadata = None
|
||||
|
||||
async def on_heartbeat(prompt, metadata=None):
|
||||
nonlocal callback_called, callback_metadata
|
||||
callback_called = True
|
||||
callback_metadata = metadata
|
||||
return "response"
|
||||
|
||||
# Create heartbeat service
|
||||
service = HeartbeatService(
|
||||
workspace=workspace,
|
||||
on_heartbeat=on_heartbeat,
|
||||
interval_s=1,
|
||||
enabled=True,
|
||||
session_manager=sessions,
|
||||
target_session_key="telegram:239824268"
|
||||
)
|
||||
|
||||
# Trigger heartbeat
|
||||
await service._tick()
|
||||
|
||||
# Callback SHOULD have been called (user idle for >30min)
|
||||
assert callback_called
|
||||
assert callback_metadata == {"suppress_output": True}
|
||||
@@ -1,146 +0,0 @@
|
||||
# tests/test_heartbeat_idle_detection.py
|
||||
"""Tests for heartbeat idle detection with sender_id filtering."""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_detection_ignores_system_messages():
|
||||
"""Test that heartbeat only counts real user messages for idle detection."""
|
||||
# Create session manager and session
|
||||
session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat"))
|
||||
session = session_manager.get_or_create("telegram:239824268")
|
||||
|
||||
# Add a real user message 45 minutes ago
|
||||
real_user_time = datetime.now() - timedelta(minutes=45)
|
||||
session.add_message(
|
||||
"user",
|
||||
"This is a real user message",
|
||||
sender_id="239824268|testuser",
|
||||
timestamp=real_user_time.isoformat()
|
||||
)
|
||||
|
||||
# Add a heartbeat system message 10 minutes ago (should be ignored)
|
||||
heartbeat_time = datetime.now() - timedelta(minutes=10)
|
||||
session.add_message(
|
||||
"user",
|
||||
"Read HEARTBEAT.md...",
|
||||
sender_id="user",
|
||||
timestamp=heartbeat_time.isoformat()
|
||||
)
|
||||
|
||||
# Add an assistant response
|
||||
session.add_message("assistant", "Response to heartbeat")
|
||||
|
||||
# Create heartbeat service with 30 minute idle threshold
|
||||
heartbeat = HeartbeatService(
|
||||
workspace=Path("/tmp/test-heartbeat"),
|
||||
session_manager=session_manager,
|
||||
target_session_key="telegram:239824268",
|
||||
idle_threshold_s=30 * 60, # 30 minutes
|
||||
interval_s=30 * 60,
|
||||
enabled=False # Don't actually start the loop
|
||||
)
|
||||
|
||||
# Manually check idle logic (replicate _tick logic)
|
||||
session = session_manager.get_or_create("telegram:239824268")
|
||||
|
||||
# Find last user message timestamp (should find the 45-minute-old message, not the 10-minute-old one)
|
||||
last_user_timestamp = None
|
||||
for msg in reversed(session.messages):
|
||||
if msg.get("role") == "user":
|
||||
sender_id = msg.get("sender_id")
|
||||
if sender_id == "user":
|
||||
continue
|
||||
last_user_timestamp = msg.get("timestamp")
|
||||
break
|
||||
|
||||
assert last_user_timestamp is not None
|
||||
last_dt = datetime.fromisoformat(last_user_timestamp)
|
||||
elapsed = (datetime.now() - last_dt).total_seconds()
|
||||
|
||||
# Should detect user is idle (45 minutes > 30 minute threshold)
|
||||
assert elapsed >= 30 * 60, f"Expected idle (45min), but elapsed={elapsed/60:.1f}min"
|
||||
# Should NOT be 10 minutes (heartbeat message was ignored)
|
||||
assert elapsed >= 40 * 60, f"Heartbeat message was not ignored, elapsed={elapsed/60:.1f}min"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_detection_counts_real_user_messages():
|
||||
"""Test that heartbeat correctly identifies when user is active."""
|
||||
# Create session manager and session
|
||||
session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat"))
|
||||
session = session_manager.get_or_create("telegram:239824268")
|
||||
|
||||
# Add a real user message 10 minutes ago (recent activity)
|
||||
real_user_time = datetime.now() - timedelta(minutes=10)
|
||||
session.add_message(
|
||||
"user",
|
||||
"This is a recent user message",
|
||||
sender_id="239824268|testuser",
|
||||
timestamp=real_user_time.isoformat()
|
||||
)
|
||||
|
||||
# Create heartbeat service with 30 minute idle threshold
|
||||
heartbeat = HeartbeatService(
|
||||
workspace=Path("/tmp/test-heartbeat"),
|
||||
session_manager=session_manager,
|
||||
target_session_key="telegram:239824268",
|
||||
idle_threshold_s=30 * 60, # 30 minutes
|
||||
interval_s=30 * 60,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
# Find last user message timestamp
|
||||
session = session_manager.get_or_create("telegram:239824268")
|
||||
last_user_timestamp = None
|
||||
for msg in reversed(session.messages):
|
||||
if msg.get("role") == "user":
|
||||
sender_id = msg.get("sender_id")
|
||||
if sender_id == "user":
|
||||
continue
|
||||
last_user_timestamp = msg.get("timestamp")
|
||||
break
|
||||
|
||||
assert last_user_timestamp is not None
|
||||
last_dt = datetime.fromisoformat(last_user_timestamp)
|
||||
elapsed = (datetime.now() - last_dt).total_seconds()
|
||||
|
||||
# Should detect user is active (10 minutes < 30 minute threshold)
|
||||
assert elapsed < 30 * 60, f"Expected active (10min), but elapsed={elapsed/60:.1f}min"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backwards_compat_messages_without_sender_id():
|
||||
"""Test that old messages without sender_id are treated as real user messages."""
|
||||
# Create session manager and session
|
||||
session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat"))
|
||||
session = session_manager.get_or_create("telegram:239824268")
|
||||
|
||||
# Add an old message without sender_id (backwards compat)
|
||||
old_time = datetime.now() - timedelta(minutes=20)
|
||||
session.add_message(
|
||||
"user",
|
||||
"Old message without sender_id",
|
||||
timestamp=old_time.isoformat()
|
||||
)
|
||||
|
||||
# Find last user message timestamp (should find the old message)
|
||||
last_user_timestamp = None
|
||||
for msg in reversed(session.messages):
|
||||
if msg.get("role") == "user":
|
||||
sender_id = msg.get("sender_id")
|
||||
if sender_id == "user":
|
||||
continue
|
||||
last_user_timestamp = msg.get("timestamp")
|
||||
break
|
||||
|
||||
assert last_user_timestamp is not None
|
||||
last_dt = datetime.fromisoformat(last_user_timestamp)
|
||||
elapsed = (datetime.now() - last_dt).total_seconds()
|
||||
|
||||
# Should accept old message (backwards compat)
|
||||
assert elapsed < 25 * 60, f"Old message not counted, elapsed={elapsed/60:.1f}min"
|
||||
@@ -1,117 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
|
||||
class DummyProvider:
|
||||
def __init__(self, responses: list[LLMResponse]):
|
||||
self._responses = list(responses)
|
||||
|
||||
async def chat(self, *args, **kwargs) -> LLMResponse:
|
||||
if self._responses:
|
||||
return self._responses.pop(0)
|
||||
return LLMResponse(content="", tool_calls=[])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_is_idempotent(tmp_path) -> None:
|
||||
provider = DummyProvider([])
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
interval_s=9999,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
await service.start()
|
||||
first_task = service._task
|
||||
await service.start()
|
||||
|
||||
assert service._task is first_task
|
||||
|
||||
service.stop()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decide_returns_skip_when_no_tool_call(tmp_path) -> None:
|
||||
provider = DummyProvider([LLMResponse(content="no tool call", tool_calls=[])])
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
)
|
||||
|
||||
action, tasks = await service._decide("heartbeat content")
|
||||
assert action == "skip"
|
||||
assert tasks == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_now_executes_when_decision_is_run(tmp_path) -> None:
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] do thing", encoding="utf-8")
|
||||
|
||||
provider = DummyProvider([
|
||||
LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1",
|
||||
name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check open tasks"},
|
||||
)
|
||||
],
|
||||
)
|
||||
])
|
||||
|
||||
called_with: list[str] = []
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
called_with.append(tasks)
|
||||
return "done"
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
on_execute=_on_execute,
|
||||
)
|
||||
|
||||
result = await service.trigger_now()
|
||||
assert result == "done"
|
||||
assert called_with == ["check open tasks"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_now_returns_none_when_decision_is_skip(tmp_path) -> None:
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] do thing", encoding="utf-8")
|
||||
|
||||
provider = DummyProvider([
|
||||
LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1",
|
||||
name="heartbeat",
|
||||
arguments={"action": "skip"},
|
||||
)
|
||||
],
|
||||
)
|
||||
])
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
return tasks
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=provider,
|
||||
model="openai/gpt-4o-mini",
|
||||
on_execute=_on_execute,
|
||||
)
|
||||
|
||||
assert await service.trigger_now() is None
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Tests for the hook channel."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from nanobot.channels.hook import HookChannel
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bus():
|
||||
return MessageBus()
|
||||
|
||||
|
||||
def test_hook_channel_name():
|
||||
bus = MessageBus()
|
||||
channel = HookChannel(bus)
|
||||
assert channel.name == "hook"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_channel_send_is_noop():
|
||||
"""send() should not raise and should not do anything."""
|
||||
bus = MessageBus()
|
||||
channel = HookChannel(bus)
|
||||
msg = OutboundMessage(channel="hook", chat_id="test", content="hello")
|
||||
await channel.send(msg) # Should not raise
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_channel_start_stop():
|
||||
bus = MessageBus()
|
||||
channel = HookChannel(bus)
|
||||
await channel.start()
|
||||
assert channel.is_running
|
||||
await channel.stop()
|
||||
assert not channel.is_running
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Tests for HooksConfig with named tokens."""
|
||||
|
||||
from nanobot.config.schema import HooksConfig
|
||||
|
||||
|
||||
def test_hooks_config_named_tokens():
|
||||
"""Named tokens dict should work."""
|
||||
config = HooksConfig(enabled=True, tokens={"gitea": "secret1", "ha": "secret2"})
|
||||
assert config.tokens == {"gitea": "secret1", "ha": "secret2"}
|
||||
|
||||
|
||||
def test_hooks_config_resolve_token():
|
||||
"""resolve_token should return token name for a matching token."""
|
||||
config = HooksConfig(enabled=True, tokens={"gitea": "secret1", "ha": "secret2"})
|
||||
assert config.resolve_token("secret1") == "gitea"
|
||||
assert config.resolve_token("secret2") == "ha"
|
||||
assert config.resolve_token("unknown") is None
|
||||
|
||||
|
||||
def test_hooks_config_has_tokens():
|
||||
"""has_tokens should be True if tokens dict is non-empty."""
|
||||
assert HooksConfig(enabled=True, tokens={"webhook": "secret"}).has_tokens
|
||||
assert not HooksConfig(enabled=True).has_tokens
|
||||
assert not HooksConfig(enabled=True, tokens={}).has_tokens
|
||||
@@ -1,128 +0,0 @@
|
||||
"""End-to-end integration test for hooks → bus → correlation → response."""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from nanobot.hooks.server import HooksServer
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.config.schema import HooksConfig
|
||||
from nanobot.channels.hook import HookChannel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bus():
|
||||
return MessageBus()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return HooksConfig(
|
||||
enabled=True,
|
||||
tokens={"gitea": "gitea-secret", "ha": "ha-secret"},
|
||||
timeout_seconds=5,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(bus, config):
|
||||
return HooksServer(host="127.0.0.1", port=0, config=config, bus=bus)
|
||||
|
||||
|
||||
async def fake_agent_loop(bus: MessageBus):
|
||||
"""Simulate agent loop: consume inbound, process, publish outbound."""
|
||||
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=3.0)
|
||||
response_content = f"Processed: {msg.content}"
|
||||
await bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=response_content,
|
||||
metadata=msg.metadata or {},
|
||||
))
|
||||
|
||||
|
||||
async def fake_dispatch_loop(bus: MessageBus, hook_channel: HookChannel):
|
||||
"""Simulate outbound dispatcher: consume outbound, resolve correlation, dispatch."""
|
||||
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=3.0)
|
||||
bus.resolve_correlation(msg)
|
||||
if msg.channel == "hook":
|
||||
await hook_channel.send(msg)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_hook_flow_default_channel(server, bus):
|
||||
"""Hook with default channel: message goes through bus, response returned to HTTP caller."""
|
||||
hook_channel = HookChannel(bus)
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
async def do_request():
|
||||
return await client.post(
|
||||
"/hooks",
|
||||
json={"message": "deploy started"},
|
||||
headers={"Authorization": "Bearer gitea-secret"},
|
||||
)
|
||||
|
||||
# Run request + fake agent + fake dispatcher concurrently
|
||||
request_task = asyncio.create_task(do_request())
|
||||
agent_task = asyncio.create_task(fake_agent_loop(bus))
|
||||
dispatch_task = asyncio.create_task(fake_dispatch_loop(bus, hook_channel))
|
||||
|
||||
resp = await asyncio.wait_for(request_task, timeout=5.0)
|
||||
await agent_task
|
||||
await dispatch_task
|
||||
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["ok"] is True
|
||||
assert "deploy started" in data["response"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_hook_flow_telegram_channel(server, bus):
|
||||
"""Hook targeting telegram: uses telegram session, response still returned to HTTP caller."""
|
||||
hook_channel = HookChannel(bus)
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
async def do_request():
|
||||
return await client.post(
|
||||
"/hooks",
|
||||
json={"message": "doorbell rang", "channel": "telegram", "chat_id": "239824268"},
|
||||
headers={"Authorization": "Bearer ha-secret"},
|
||||
)
|
||||
|
||||
request_task = asyncio.create_task(do_request())
|
||||
agent_task = asyncio.create_task(fake_agent_loop(bus))
|
||||
dispatch_task = asyncio.create_task(fake_dispatch_loop(bus, hook_channel))
|
||||
|
||||
resp = await asyncio.wait_for(request_task, timeout=5.0)
|
||||
await agent_task
|
||||
await dispatch_task
|
||||
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["ok"] is True
|
||||
assert "doorbell rang" in data["response"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_token_identification(server, bus):
|
||||
"""Different tokens should produce different hook_source in metadata."""
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
asyncio.create_task(client.post(
|
||||
"/hooks",
|
||||
json={"message": "from gitea"},
|
||||
headers={"Authorization": "Bearer gitea-secret"},
|
||||
))
|
||||
msg1 = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||
assert msg1.metadata["hook_source"] == "gitea"
|
||||
assert msg1.chat_id == "gitea"
|
||||
|
||||
asyncio.create_task(client.post(
|
||||
"/hooks",
|
||||
json={"message": "from ha"},
|
||||
headers={"Authorization": "Bearer ha-secret"},
|
||||
))
|
||||
msg2 = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||
assert msg2.metadata["hook_source"] == "ha"
|
||||
assert msg2.chat_id == "ha"
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Tests for the rewritten hooks server."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop, TestClient, TestServer
|
||||
from nanobot.hooks.server import HooksServer
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.config.schema import HooksConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bus():
|
||||
return MessageBus()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return HooksConfig(
|
||||
enabled=True,
|
||||
tokens={"test-hook": "test-secret-123"},
|
||||
timeout_seconds=5,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(bus, config):
|
||||
return HooksServer(host="127.0.0.1", port=0, config=config, bus=bus)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check(server):
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
resp = await client.get("/health")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_without_token(server):
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
resp = await client.post("/hooks", json={"message": "test"})
|
||||
assert resp.status == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_wrong_token(server):
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
"/hooks",
|
||||
json={"message": "test"},
|
||||
headers={"Authorization": "Bearer wrong-token"},
|
||||
)
|
||||
assert resp.status == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_message_field(server, bus):
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
"/hooks",
|
||||
json={"not_message": "test"},
|
||||
headers={"Authorization": "Bearer test-secret-123"},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_publishes_to_bus(server, bus):
|
||||
"""Hook should publish InboundMessage to bus and the message should contain hook prefix."""
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
# Send hook request in background (it will block waiting for correlation)
|
||||
async def send_request():
|
||||
return await client.post(
|
||||
"/hooks",
|
||||
json={"message": "hello from webhook"},
|
||||
headers={"Authorization": "Bearer test-secret-123"},
|
||||
)
|
||||
|
||||
task = asyncio.create_task(send_request())
|
||||
|
||||
# Consume the inbound message
|
||||
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||
|
||||
assert msg.channel == "hook"
|
||||
assert msg.chat_id == "test-hook" # defaults to token name
|
||||
assert msg.metadata.get("hook_source") == "test-hook"
|
||||
assert msg.metadata.get("correlation_id") is not None
|
||||
|
||||
# Simulate agent response by resolving correlation
|
||||
bus.resolve_correlation(OutboundMessage(
|
||||
channel="hook",
|
||||
chat_id="test-hook",
|
||||
content="agent says hi",
|
||||
metadata={"correlation_id": msg.metadata["correlation_id"]},
|
||||
))
|
||||
|
||||
resp = await asyncio.wait_for(task, timeout=2.0)
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["response"] == "agent says hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_with_custom_channel(server, bus):
|
||||
"""Hook targeting telegram should use telegram channel in InboundMessage."""
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
async def send_request():
|
||||
return await client.post(
|
||||
"/hooks",
|
||||
json={"message": "notify user", "channel": "telegram", "chat_id": "239824268"},
|
||||
headers={"Authorization": "Bearer test-secret-123"},
|
||||
)
|
||||
|
||||
task = asyncio.create_task(send_request())
|
||||
|
||||
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||
assert msg.channel == "telegram"
|
||||
assert msg.chat_id == "239824268"
|
||||
assert msg.session_key == "telegram:239824268"
|
||||
|
||||
bus.resolve_correlation(OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="239824268",
|
||||
content="done",
|
||||
metadata={"correlation_id": msg.metadata["correlation_id"]},
|
||||
))
|
||||
|
||||
resp = await asyncio.wait_for(task, timeout=2.0)
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["response"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_timeout_returns_504(bus):
|
||||
"""If agent doesn't respond in time, return 504."""
|
||||
config = HooksConfig(enabled=True, tokens={"test-hook": "test-secret-123"}, timeout_seconds=1)
|
||||
server = HooksServer(host="127.0.0.1", port=0, config=config, bus=bus)
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
"/hooks",
|
||||
json={"message": "slow request"},
|
||||
headers={"Authorization": "Bearer test-secret-123"},
|
||||
)
|
||||
assert resp.status == 504
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_timeout_zero_returns_202(server, bus):
|
||||
"""timeout=0 should return 202 immediately without waiting."""
|
||||
client = TestClient(TestServer(server._app))
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
"/hooks",
|
||||
json={"message": "fire and forget", "timeout": 0},
|
||||
headers={"Authorization": "Bearer test-secret-123"},
|
||||
)
|
||||
assert resp.status == 202
|
||||
data = await resp.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
# Message should still be on the bus
|
||||
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0)
|
||||
assert msg.content == "fire and forget"
|
||||
@@ -1,110 +0,0 @@
|
||||
# tests/test_idle_heartbeat_integration.py
|
||||
import pytest
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_heartbeat_end_to_end(tmp_path):
|
||||
"""
|
||||
Integration test: heartbeat triggers when idle, runs in main session,
|
||||
output is suppressed, session contains [HIDDEN:signature] content.
|
||||
"""
|
||||
workspace = tmp_path / "test-integration"
|
||||
workspace.mkdir()
|
||||
|
||||
# Use test-specific session key
|
||||
test_session_key = "telegram:test_integration"
|
||||
|
||||
# Create HEARTBEAT.md with content
|
||||
heartbeat_file = workspace / "HEARTBEAT.md"
|
||||
heartbeat_file.write_text("# Test Task\n- Check something")
|
||||
|
||||
# Create mock provider
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||
content="Heartbeat executed successfully",
|
||||
tool_calls=[] # has_tool_calls is a property, not a parameter
|
||||
))
|
||||
provider.get_default_model = MagicMock(return_value="test-model")
|
||||
provider.thinking_budget = 0
|
||||
|
||||
# Create components
|
||||
bus = MessageBus()
|
||||
sessions = SessionManager(workspace)
|
||||
# Override sessions_dir to use tmp_path for test isolation
|
||||
sessions.sessions_dir = tmp_path / "sessions"
|
||||
sessions.sessions_dir.mkdir()
|
||||
loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=workspace,
|
||||
session_manager=sessions
|
||||
)
|
||||
|
||||
# Create session with old user message
|
||||
session = sessions.get_or_create(test_session_key)
|
||||
old_timestamp = (datetime.now() - timedelta(minutes=31)).isoformat()
|
||||
session.messages.append({
|
||||
"role": "user",
|
||||
"content": "Old user message",
|
||||
"timestamp": old_timestamp
|
||||
})
|
||||
sessions.save(session)
|
||||
|
||||
# Create heartbeat callback
|
||||
async def on_heartbeat(prompt: str, metadata=None):
|
||||
return await loop.process_direct(
|
||||
prompt,
|
||||
session_key=test_session_key,
|
||||
channel="telegram",
|
||||
chat_id="test_integration",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# Create heartbeat service
|
||||
heartbeat = HeartbeatService(
|
||||
workspace=workspace,
|
||||
on_heartbeat=on_heartbeat,
|
||||
interval_s=1,
|
||||
enabled=True,
|
||||
session_manager=sessions,
|
||||
target_session_key=test_session_key,
|
||||
idle_threshold_s=30 * 60,
|
||||
)
|
||||
|
||||
# Trigger heartbeat
|
||||
await heartbeat._tick()
|
||||
|
||||
# Reload session from disk
|
||||
sessions._cache.clear() # Clear cache to force reload
|
||||
session = sessions.get_or_create(test_session_key)
|
||||
|
||||
# Verify:
|
||||
# 1. Session has new messages
|
||||
assert len(session.messages) > 1
|
||||
|
||||
# 2. Find the heartbeat response (assistant message with signed visibility marker)
|
||||
heartbeat_messages = [
|
||||
m for m in session.messages
|
||||
if m.get("role") == "assistant" and "[HIDDEN:" in m.get("content", "")
|
||||
]
|
||||
assert len(heartbeat_messages) == 1, "Expected exactly 1 [HIDDEN:signature] heartbeat message"
|
||||
|
||||
# 3. Verify content is prefixed with [HIDDEN:signature]
|
||||
heartbeat_msg = heartbeat_messages[0]
|
||||
assert heartbeat_msg["content"].startswith("[HIDDEN:")
|
||||
# Verify signature format (8-char hex)
|
||||
content = heartbeat_msg["content"]
|
||||
prefix_end = content.index("]")
|
||||
signature = content[8:prefix_end] # Skip "[HIDDEN:" to get signature
|
||||
assert len(signature) == 8, f"Expected 8-char signature, got {len(signature)}"
|
||||
assert all(c in "0123456789abcdef" for c in signature), "Signature should be hex"
|
||||
assert "Heartbeat executed successfully" in heartbeat_msg["content"]
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user