mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3b55ba289 | ||
|
|
bc4cc49a59 | ||
|
|
df6f9dd171 | ||
|
|
6af81bc4a3 | ||
|
|
4a33c1392b | ||
|
|
4b0fdffe39 | ||
|
|
4fe23a01c9 | ||
|
|
973b888d39 | ||
|
|
7b1ce24600 | ||
|
|
3bece171c2 | ||
|
|
8f7ce9fef7 | ||
|
|
d327c19db0 | ||
|
|
e00dca2f84 | ||
|
|
51200a954c | ||
|
|
c121547114 | ||
|
|
dcc9c057bb | ||
|
|
586d4e2411 | ||
|
|
a4bb1923ac | ||
|
|
8d6f41e484 | ||
|
|
4962867112 | ||
|
|
473637ceff | ||
|
|
c44d4f2b2b | ||
|
|
ae27d69ecb | ||
|
|
ba38d41ad1 | ||
|
|
ad4d095080 | ||
|
|
3723cd726e | ||
|
|
8f0b653a4c | ||
|
|
5857f7fdd0 | ||
|
|
7d2c62716c |
+12
-72
@@ -1,86 +1,26 @@
|
|||||||
# Project-specific
|
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.assets
|
.assets
|
||||||
.docs
|
.docs
|
||||||
.env
|
.env
|
||||||
.web
|
.web
|
||||||
|
|
||||||
# Python bytecode & caches
|
|
||||||
*.pyc
|
*.pyc
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
*.pycs
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
*.pyw
|
*.pyw
|
||||||
*.pyz
|
*.pyz
|
||||||
__pycache__/
|
*.pywz
|
||||||
*.egg-info/
|
*.pyzz
|
||||||
*.egg
|
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
.pytest_cache/
|
__pycache__/
|
||||||
.mypy_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
.pytype/
|
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.hypothesis/
|
|
||||||
|
|
||||||
# Build & packaging
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
pip-wheel-metadata/
|
|
||||||
share/python-wheels/
|
|
||||||
|
|
||||||
# Test & coverage
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
htmlcov/
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
|
|
||||||
# Lock files (project policy)
|
|
||||||
poetry.lock
|
poetry.lock
|
||||||
uv.lock
|
.pytest_cache/
|
||||||
|
botpy.log
|
||||||
# Jupyter
|
|
||||||
.ipynb_checkpoints/
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
.AppleDouble
|
|
||||||
.LSOverride
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
Thumbs.db
|
|
||||||
ehthumbs.db
|
|
||||||
Desktop.ini
|
|
||||||
|
|
||||||
# Linux
|
|
||||||
.directory
|
|
||||||
|
|
||||||
# Editors & IDEs (local workspace / user settings)
|
|
||||||
.vscode/
|
|
||||||
.cursor/
|
|
||||||
.idea/
|
|
||||||
.fleet/
|
|
||||||
*.code-workspace
|
|
||||||
*.sublime-project
|
|
||||||
*.sublime-workspace
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
nano.*.save
|
nano.*.save
|
||||||
|
.DS_Store
|
||||||
# Environment & secrets (keep examples tracked if needed)
|
uv.lock
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Logs & temp
|
|
||||||
*.log
|
|
||||||
logs/
|
|
||||||
tmp/
|
|
||||||
temp/
|
|
||||||
*.tmp
|
|
||||||
|
|||||||
@@ -394,8 +394,7 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allowFrom": ["YOUR_USER_ID"],
|
"allowFrom": ["YOUR_USER_ID"],
|
||||||
"groupPolicy": "mention",
|
"groupPolicy": "mention"
|
||||||
"streaming": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,7 +405,6 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso
|
|||||||
> - `"open"` — Respond to all messages
|
> - `"open"` — Respond to all messages
|
||||||
> DMs always respond when the sender is in `allowFrom`.
|
> DMs always respond when the sender is in `allowFrom`.
|
||||||
> - If you set group policy to open create new threads as private threads and then @ the bot into it. Otherwise the thread itself and the channel in which you spawned it will spawn a bot session.
|
> - If you set group policy to open create new threads as private threads and then @ the bot into it. Otherwise the thread itself and the channel in which you spawned it will spawn a bot session.
|
||||||
> `streaming` defaults to `true`. Disable it only if you explicitly want non-streaming replies.
|
|
||||||
|
|
||||||
**5. Invite the bot**
|
**5. Invite the bot**
|
||||||
- OAuth2 → URL Generator
|
- OAuth2 → URL Generator
|
||||||
@@ -562,7 +560,6 @@ Uses **WebSocket** long connection — no public IP required.
|
|||||||
"groupPolicy": "mention",
|
"groupPolicy": "mention",
|
||||||
"reactEmoji": "OnIt",
|
"reactEmoji": "OnIt",
|
||||||
"doneEmoji": "DONE",
|
"doneEmoji": "DONE",
|
||||||
"toolHintPrefix": "🔧",
|
|
||||||
"streaming": true,
|
"streaming": true,
|
||||||
"domain": "feishu"
|
"domain": "feishu"
|
||||||
}
|
}
|
||||||
@@ -576,7 +573,6 @@ Uses **WebSocket** long connection — no public IP required.
|
|||||||
> `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all group messages). Private chats always respond.
|
> `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all group messages). Private chats always respond.
|
||||||
> `reactEmoji`: Emoji for "processing" status (default: `OnIt`). See [available emojis](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce).
|
> `reactEmoji`: Emoji for "processing" status (default: `OnIt`). See [available emojis](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce).
|
||||||
> `doneEmoji`: Optional emoji for "completed" status (e.g., `DONE`, `OK`, `HEART`). When set, bot adds this reaction after removing `reactEmoji`.
|
> `doneEmoji`: Optional emoji for "completed" status (e.g., `DONE`, `OK`, `HEART`). When set, bot adds this reaction after removing `reactEmoji`.
|
||||||
> `toolHintPrefix`: Prefix for inline tool hints in streaming cards (default: `🔧`).
|
|
||||||
> `domain`: `"feishu"` (default) for China (open.feishu.cn), `"lark"` for international Lark (open.larksuite.com).
|
> `domain`: `"feishu"` (default) for China (open.feishu.cn), `"lark"` for international Lark (open.larksuite.com).
|
||||||
|
|
||||||
**3. Run**
|
**3. Run**
|
||||||
@@ -1053,30 +1049,6 @@ Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, To
|
|||||||
```
|
```
|
||||||
|
|
||||||
> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`).
|
> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`).
|
||||||
>
|
|
||||||
> `custom` is the right choice for providers that expose an OpenAI-compatible **chat completions** API. It does **not** force third-party endpoints onto the OpenAI/Azure **Responses API**.
|
|
||||||
>
|
|
||||||
> If your proxy or gateway is specifically Responses-API-compatible, use the `azure_openai` provider shape instead and point `apiBase` at that endpoint:
|
|
||||||
>
|
|
||||||
> ```json
|
|
||||||
> {
|
|
||||||
> "providers": {
|
|
||||||
> "azure_openai": {
|
|
||||||
> "apiKey": "your-api-key",
|
|
||||||
> "apiBase": "https://api.your-provider.com",
|
|
||||||
> "defaultModel": "your-model-name"
|
|
||||||
> }
|
|
||||||
> },
|
|
||||||
> "agents": {
|
|
||||||
> "defaults": {
|
|
||||||
> "provider": "azure_openai",
|
|
||||||
> "model": "your-model-name"
|
|
||||||
> }
|
|
||||||
> }
|
|
||||||
> }
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> In short: **chat-completions-compatible endpoint → `custom`**; **Responses-compatible endpoint → `azure_openai`**.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -1338,7 +1310,6 @@ If you need to allow trusted private ranges such as Tailscale / CGNAT addresses,
|
|||||||
| `brave` | `apiKey` | `BRAVE_API_KEY` | No |
|
| `brave` | `apiKey` | `BRAVE_API_KEY` | No |
|
||||||
| `tavily` | `apiKey` | `TAVILY_API_KEY` | No |
|
| `tavily` | `apiKey` | `TAVILY_API_KEY` | No |
|
||||||
| `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) |
|
| `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) |
|
||||||
| `kagi` | `apiKey` | `KAGI_API_KEY` | No |
|
|
||||||
| `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) |
|
| `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) |
|
||||||
| `duckduckgo` (default) | — | — | Yes |
|
| `duckduckgo` (default) | — | — | Yes |
|
||||||
|
|
||||||
@@ -1395,20 +1366,6 @@ If you need to allow trusted private ranges such as Tailscale / CGNAT addresses,
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Kagi:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tools": {
|
|
||||||
"web": {
|
|
||||||
"search": {
|
|
||||||
"provider": "kagi",
|
|
||||||
"apiKey": "your-kagi-api-key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**SearXNG** (self-hosted, no API key needed):
|
**SearXNG** (self-hosted, no API key needed):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1546,13 +1503,13 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
|||||||
|
|
||||||
### Auto Compact
|
### Auto Compact
|
||||||
|
|
||||||
When a user is idle for longer than a configured threshold, nanobot **proactively** compresses the older part of the session context into a summary while keeping a recent legal suffix of live messages. This reduces token cost and first-token latency when the user returns — instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary, the most recent live context, and fresh input.
|
When a user is idle for longer than a configured TTL, nanobot **proactively** compresses the session context into a summary. This reduces token cost and first-token latency when the user returns — instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary and fresh input.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"idleCompactAfterMinutes": 15
|
"sessionTtlMinutes": 15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1560,18 +1517,15 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
|
|||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `agents.defaults.idleCompactAfterMinutes` | `0` (disabled) | Minutes of idle time before auto-compaction starts. Set to `0` to disable. Recommended: `15` — close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. |
|
| `agents.defaults.sessionTtlMinutes` | `0` (disabled) | Minutes of idle time before auto-compaction. Set to `0` to disable. Recommended: `15` — matches typical LLM KV cache expiration, so compacted sessions won't waste cache on cold entries. |
|
||||||
|
|
||||||
`sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward.
|
|
||||||
|
|
||||||
How it works:
|
How it works:
|
||||||
1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration.
|
1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration.
|
||||||
2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages).
|
2. **Background compaction**: Expired sessions are summarized via LLM, then cleared.
|
||||||
3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix.
|
3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted).
|
||||||
4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart.
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Think of auto compact as "summarize older context, keep the freshest live turns." It is not a hard session reset.
|
> The summary survives bot restarts — it's stored in session metadata and recovered on the next message.
|
||||||
|
|
||||||
### Timezone
|
### Timezone
|
||||||
|
|
||||||
@@ -1595,52 +1549,6 @@ Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/Londo
|
|||||||
|
|
||||||
> Need another timezone? Browse the full [IANA Time Zone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
|
> Need another timezone? Browse the full [IANA Time Zone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
|
||||||
|
|
||||||
### Unified Session
|
|
||||||
|
|
||||||
By default, each channel × chat ID combination gets its own session. If you use nanobot across multiple channels (e.g. Telegram + Discord + CLI) and want them to share the same conversation, enable `unifiedSession`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"unifiedSession": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When enabled, all incoming messages — regardless of which channel they arrive on — are routed into a single shared session. Switching from Telegram to Discord (or any other channel) continues the same conversation seamlessly.
|
|
||||||
|
|
||||||
| Behavior | `false` (default) | `true` |
|
|
||||||
|----------|-------------------|--------|
|
|
||||||
| Session key | `channel:chat_id` | `unified:default` |
|
|
||||||
| Cross-channel continuity | No | Yes |
|
|
||||||
| `/new` clears | Current channel session | Shared session |
|
|
||||||
| `/stop` finds tasks | By channel session | By shared session |
|
|
||||||
| Existing `session_key_override` (e.g. Telegram thread) | Respected | Still respected — not overwritten |
|
|
||||||
|
|
||||||
> This is designed for single-user, multi-device setups. It is **off by default** — existing users see zero behavior change.
|
|
||||||
|
|
||||||
### Disabled Skills
|
|
||||||
|
|
||||||
nanobot ships with built-in skills, and your workspace can also define custom skills under `skills/`. If you want to hide specific skills from the agent, set `agents.defaults.disabledSkills` to a list of skill directory names:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"disabledSkills": ["github", "weather"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Disabled skills are excluded from the main agent's skill summary, from always-on skill injection, and from subagent skill summaries. This is useful when some bundled skills are unnecessary for your deployment or should not be exposed to end users.
|
|
||||||
|
|
||||||
| Option | Default | Description |
|
|
||||||
|--------|---------|-------------|
|
|
||||||
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
|
|
||||||
|
|
||||||
## 🧩 Multiple Instances
|
## 🧩 Multiple Instances
|
||||||
|
|
||||||
Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance.
|
Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance.
|
||||||
@@ -1766,7 +1674,6 @@ time.
|
|||||||
|
|
||||||
- `memory/history.jsonl` stores append-only summarized history
|
- `memory/history.jsonl` stores append-only summarized history
|
||||||
- `SOUL.md`, `USER.md`, and `memory/MEMORY.md` store long-term knowledge managed by Dream
|
- `SOUL.md`, `USER.md`, and `memory/MEMORY.md` store long-term knowledge managed by Dream
|
||||||
- `Dream` can also promote repeated workflows into reusable workspace skills under `skills/`
|
|
||||||
- `Dream` runs on a schedule and can also be triggered manually
|
- `Dream` runs on a schedule and can also be triggered manually
|
||||||
- memory changes can be inspected and restored with built-in commands
|
- memory changes can be inspected and restored with built-in commands
|
||||||
|
|
||||||
@@ -1882,19 +1789,6 @@ By default, the API binds to `127.0.0.1:8900`. You can change this in `config.js
|
|||||||
- Single-message input: each request must contain exactly one `user` message
|
- Single-message input: each request must contain exactly one `user` message
|
||||||
- Fixed model: omit `model`, or pass the same model shown by `/v1/models`
|
- Fixed model: omit `model`, or pass the same model shown by `/v1/models`
|
||||||
- No streaming: `stream=true` is not supported
|
- No streaming: `stream=true` is not supported
|
||||||
- API requests run in the synthetic `api` channel, so the `message` tool does **not** automatically deliver to Telegram/Discord/etc. To proactively send to another chat, call `message` with an explicit `channel` and `chat_id` for an enabled channel.
|
|
||||||
|
|
||||||
Example tool call for cross-channel delivery from an API session:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"content": "Build finished successfully.",
|
|
||||||
"channel": "telegram",
|
|
||||||
"chat_id": "123456789"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If `channel` points to a channel that is not enabled in your config, nanobot will queue the outbound event but no platform delivery will occur.
|
|
||||||
|
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -43,33 +43,18 @@ from typing import Any
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.config.schema import Base
|
|
||||||
|
|
||||||
|
|
||||||
class WebhookConfig(Base):
|
|
||||||
"""Webhook channel configuration."""
|
|
||||||
enabled: bool = False
|
|
||||||
port: int = 9000
|
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class WebhookChannel(BaseChannel):
|
class WebhookChannel(BaseChannel):
|
||||||
name = "webhook"
|
name = "webhook"
|
||||||
display_name = "Webhook"
|
display_name = "Webhook"
|
||||||
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
|
||||||
if isinstance(config, dict):
|
|
||||||
config = WebhookConfig(**config)
|
|
||||||
super().__init__(config, bus)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def default_config(cls) -> dict[str, Any]:
|
def default_config(cls) -> dict[str, Any]:
|
||||||
return WebhookConfig().model_dump(by_alias=True)
|
return {"enabled": False, "port": 9000, "allowFrom": []}
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start an HTTP server that listens for incoming messages.
|
"""Start an HTTP server that listens for incoming messages.
|
||||||
@@ -78,7 +63,7 @@ class WebhookChannel(BaseChannel):
|
|||||||
If it returns, the channel is considered dead.
|
If it returns, the channel is considered dead.
|
||||||
"""
|
"""
|
||||||
self._running = True
|
self._running = True
|
||||||
port = self.config.port
|
port = self.config.get("port", 9000)
|
||||||
|
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_post("/message", self._on_request)
|
app.router.add_post("/message", self._on_request)
|
||||||
@@ -229,7 +214,7 @@ nanobot channels login <channel_name> --force # re-authenticate
|
|||||||
| Method / Property | Description |
|
| Method / Property | Description |
|
||||||
|-------------------|-------------|
|
|-------------------|-------------|
|
||||||
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
|
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
|
||||||
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
|
| `is_allowed(sender_id)` | Checks against `config["allowFrom"]`; `"*"` allows all, `[]` denies all. |
|
||||||
| `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
|
| `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
|
||||||
| `transcribe_audio(file_path)` | Transcribes audio via Groq Whisper (if configured). |
|
| `transcribe_audio(file_path)` | Transcribes audio via Groq Whisper (if configured). |
|
||||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||||
@@ -299,9 +284,7 @@ class WebhookChannel(BaseChannel):
|
|||||||
name = "webhook"
|
name = "webhook"
|
||||||
display_name = "Webhook"
|
display_name = "Webhook"
|
||||||
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
def __init__(self, config, bus):
|
||||||
if isinstance(config, dict):
|
|
||||||
config = WebhookConfig(**config)
|
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self._buffers: dict[str, str] = {}
|
self._buffers: dict[str, str] = {}
|
||||||
|
|
||||||
@@ -350,48 +333,12 @@ When `streaming` is `false` (default) or omitted, only `send()` is called — no
|
|||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
### Why Pydantic model is required
|
Your channel receives config as a plain `dict`. Access fields with `.get()`:
|
||||||
|
|
||||||
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`** — `dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
|
|
||||||
|
|
||||||
Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**.
|
|
||||||
|
|
||||||
### Pattern
|
|
||||||
|
|
||||||
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pydantic import Field
|
|
||||||
from nanobot.config.schema import Base
|
|
||||||
|
|
||||||
class WebhookConfig(Base):
|
|
||||||
"""Webhook channel configuration."""
|
|
||||||
enabled: bool = False
|
|
||||||
port: int = 9000
|
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
|
|
||||||
|
|
||||||
2. Convert `dict` → model in `__init__`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Any
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
|
|
||||||
class WebhookChannel(BaseChannel):
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
|
||||||
if isinstance(config, dict):
|
|
||||||
config = WebhookConfig(**config)
|
|
||||||
super().__init__(config, bus)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Access config as attributes (not `.get()`):
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
port = self.config.port
|
port = self.config.get("port", 9000)
|
||||||
token = self.config.token
|
token = self.config.get("token", "")
|
||||||
```
|
```
|
||||||
|
|
||||||
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
|
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
|
||||||
@@ -401,11 +348,9 @@ Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
|
|||||||
```python
|
```python
|
||||||
@classmethod
|
@classmethod
|
||||||
def default_config(cls) -> dict[str, Any]:
|
def default_config(cls) -> dict[str, Any]:
|
||||||
return WebhookConfig().model_dump(by_alias=True)
|
return {"enabled": False, "port": 9000, "allowFrom": []}
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** `default_config()` returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. The recommended way is to instantiate your config model and call `model_dump(by_alias=True)` — this automatically uses camelCase keys (`allowFrom`) and keeps defaults in a single source of truth.
|
|
||||||
|
|
||||||
If not overridden, the base class returns `{"enabled": false}`.
|
If not overridden, the base class returns `{"enabled": false}`.
|
||||||
|
|
||||||
## Naming Convention
|
## Naming Convention
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Microsoft Teams (MVP)
|
||||||
|
|
||||||
|
This repository includes a built-in `msteams` channel MVP for Microsoft Teams direct messages.
|
||||||
|
|
||||||
|
## Current scope
|
||||||
|
|
||||||
|
- Direct-message text in/out
|
||||||
|
- Tenant-aware OAuth token acquisition
|
||||||
|
- Conversation reference persistence for replies
|
||||||
|
- Public HTTPS webhook support through a tunnel or reverse proxy
|
||||||
|
|
||||||
|
## Not yet included
|
||||||
|
|
||||||
|
- Group/channel handling
|
||||||
|
- Attachments and cards
|
||||||
|
- Polls
|
||||||
|
- Richer Teams activity handling
|
||||||
|
|
||||||
|
## Example config
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"msteams": {
|
||||||
|
"enabled": true,
|
||||||
|
"appId": "YOUR_APP_ID",
|
||||||
|
"appPassword": "YOUR_APP_SECRET",
|
||||||
|
"tenantId": "YOUR_TENANT_ID",
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 3978,
|
||||||
|
"path": "/api/messages",
|
||||||
|
"allowFrom": ["*"],
|
||||||
|
"replyInThread": true,
|
||||||
|
"mentionOnlyResponse": "Hi — what can I help with?",
|
||||||
|
"validateInboundAuth": false,
|
||||||
|
"restartNotifyEnabled": false,
|
||||||
|
"restartNotifyPreMessage": "Nanobot agent initiated a gateway restart. I will message again when the gateway is back online.",
|
||||||
|
"restartNotifyPostMessage": "Nanobot gateway is back online."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior notes
|
||||||
|
|
||||||
|
- `replyInThread: true` replies to the triggering Teams activity when a stored `activity_id` is available.
|
||||||
|
- `replyInThread: false` posts replies as normal conversation messages.
|
||||||
|
- If `replyInThread` is enabled but no `activity_id` is stored, Nanobot falls back to a normal conversation message.
|
||||||
|
- `mentionOnlyResponse` controls what Nanobot receives when a user sends only a bot mention such as `<at>Nanobot</at>`.
|
||||||
|
- Set `mentionOnlyResponse` to an empty string to ignore mention-only messages.
|
||||||
|
- `validateInboundAuth: true` enables inbound Bot Framework bearer-token validation.
|
||||||
|
- `validateInboundAuth: false` leaves inbound auth unenforced, which is safer while first validating a new relay, tunnel, or proxy path.
|
||||||
|
- When enabled, Nanobot validates the inbound bearer token signature, issuer, audience, token lifetime, and `serviceUrl` claim when present.
|
||||||
|
- `restartNotifyEnabled: true` enables optional Teams restart-notification configuration for external wrapper-script driven restarts.
|
||||||
|
- `restartNotifyPreMessage` and `restartNotifyPostMessage` control the before/after announcement text used by that external wrapper.
|
||||||
|
|
||||||
|
## Setup notes
|
||||||
|
|
||||||
|
1. Create or reuse a Microsoft Teams / Azure bot app registration.
|
||||||
|
2. Set the bot messaging endpoint to a public HTTPS URL ending in `/api/messages`.
|
||||||
|
3. Forward that public endpoint to `http://localhost:3978/api/messages`.
|
||||||
|
4. Start Nanobot with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Optional: if you use an external restart wrapper (for example a script that stops and restarts the gateway), you can enable Teams restart announcements with `restartNotifyEnabled: true` and have the wrapper send `restartNotifyPreMessage` before restart and `restartNotifyPostMessage` after the gateway is back online.
|
||||||
+1
-23
@@ -2,29 +2,7 @@
|
|||||||
nanobot - A lightweight AI agent framework
|
nanobot - A lightweight AI agent framework
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
__version__ = "0.1.5"
|
||||||
from pathlib import Path
|
|
||||||
import tomllib
|
|
||||||
|
|
||||||
|
|
||||||
def _read_pyproject_version() -> str | None:
|
|
||||||
"""Read the source-tree version when package metadata is unavailable."""
|
|
||||||
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
||||||
if not pyproject.exists():
|
|
||||||
return None
|
|
||||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
||||||
return data.get("project", {}).get("version")
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_version() -> str:
|
|
||||||
try:
|
|
||||||
return _pkg_version("nanobot-ai")
|
|
||||||
except PackageNotFoundError:
|
|
||||||
# Source checkouts often import nanobot without installed dist-info.
|
|
||||||
return _read_pyproject_version() or "0.1.5"
|
|
||||||
|
|
||||||
|
|
||||||
__version__ = _resolve_version()
|
|
||||||
__logo__ = "🐈"
|
__logo__ = "🐈"
|
||||||
|
|
||||||
from nanobot.nanobot import Nanobot, RunResult
|
from nanobot.nanobot import Nanobot, RunResult
|
||||||
|
|||||||
@@ -2,20 +2,17 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Collection
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
from typing import TYPE_CHECKING, Callable, Coroutine
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nanobot.session.manager import Session, SessionManager
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.memory import Consolidator
|
from nanobot.agent.memory import Consolidator
|
||||||
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
|
||||||
|
|
||||||
class AutoCompact:
|
class AutoCompact:
|
||||||
_RECENT_SUFFIX_MESSAGES = 8
|
|
||||||
|
|
||||||
def __init__(self, sessions: SessionManager, consolidator: Consolidator,
|
def __init__(self, sessions: SessionManager, consolidator: Consolidator,
|
||||||
session_ttl_minutes: int = 0):
|
session_ttl_minutes: int = 0):
|
||||||
self.sessions = sessions
|
self.sessions = sessions
|
||||||
@@ -24,83 +21,47 @@ class AutoCompact:
|
|||||||
self._archiving: set[str] = set()
|
self._archiving: set[str] = set()
|
||||||
self._summaries: dict[str, tuple[str, datetime]] = {}
|
self._summaries: dict[str, tuple[str, datetime]] = {}
|
||||||
|
|
||||||
def _is_expired(self, ts: datetime | str | None,
|
def _is_expired(self, ts: datetime | str | None) -> bool:
|
||||||
now: datetime | None = None) -> bool:
|
|
||||||
if self._ttl <= 0 or not ts:
|
if self._ttl <= 0 or not ts:
|
||||||
return False
|
return False
|
||||||
if isinstance(ts, str):
|
if isinstance(ts, str):
|
||||||
ts = datetime.fromisoformat(ts)
|
ts = datetime.fromisoformat(ts)
|
||||||
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
|
return (datetime.now() - ts).total_seconds() >= self._ttl * 60
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_summary(text: str, last_active: datetime) -> str:
|
def _format_summary(text: str, last_active: datetime) -> str:
|
||||||
idle_min = int((datetime.now() - last_active).total_seconds() / 60)
|
idle_min = int((datetime.now() - last_active).total_seconds() / 60)
|
||||||
return f"Inactive for {idle_min} minutes.\nPrevious conversation summary: {text}"
|
return f"Inactive for {idle_min} minutes.\nPrevious conversation summary: {text}"
|
||||||
|
|
||||||
def _split_unconsolidated(
|
def check_expired(self, schedule_background: Callable[[Coroutine], None]) -> None:
|
||||||
self, session: Session,
|
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
"""Split live session tail into archiveable prefix and retained recent suffix."""
|
|
||||||
tail = list(session.messages[session.last_consolidated:])
|
|
||||||
if not tail:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
probe = Session(
|
|
||||||
key=session.key,
|
|
||||||
messages=tail.copy(),
|
|
||||||
created_at=session.created_at,
|
|
||||||
updated_at=session.updated_at,
|
|
||||||
metadata={},
|
|
||||||
last_consolidated=0,
|
|
||||||
)
|
|
||||||
probe.retain_recent_legal_suffix(self._RECENT_SUFFIX_MESSAGES)
|
|
||||||
kept = probe.messages
|
|
||||||
cut = len(tail) - len(kept)
|
|
||||||
return tail[:cut], kept
|
|
||||||
|
|
||||||
def check_expired(self, schedule_background: Callable[[Coroutine], None],
|
|
||||||
active_session_keys: Collection[str] = ()) -> None:
|
|
||||||
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
|
|
||||||
now = datetime.now()
|
|
||||||
for info in self.sessions.list_sessions():
|
for info in self.sessions.list_sessions():
|
||||||
key = info.get("key", "")
|
key = info.get("key", "")
|
||||||
if not key or key in self._archiving:
|
if key and key not in self._archiving and self._is_expired(info.get("updated_at")):
|
||||||
continue
|
|
||||||
if key in active_session_keys:
|
|
||||||
continue
|
|
||||||
if self._is_expired(info.get("updated_at"), now):
|
|
||||||
self._archiving.add(key)
|
self._archiving.add(key)
|
||||||
|
logger.debug("Auto-compact: scheduling archival for {} (idle > {} min)", key, self._ttl)
|
||||||
schedule_background(self._archive(key))
|
schedule_background(self._archive(key))
|
||||||
|
|
||||||
async def _archive(self, key: str) -> None:
|
async def _archive(self, key: str) -> None:
|
||||||
try:
|
try:
|
||||||
self.sessions.invalidate(key)
|
self.sessions.invalidate(key)
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
archive_msgs, kept_msgs = self._split_unconsolidated(session)
|
msgs = session.messages[session.last_consolidated:]
|
||||||
if not archive_msgs and not kept_msgs:
|
if not msgs:
|
||||||
|
logger.debug("Auto-compact: skipping {}, no un-consolidated messages", key)
|
||||||
session.updated_at = datetime.now()
|
session.updated_at = datetime.now()
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return
|
return
|
||||||
|
n = len(msgs)
|
||||||
last_active = session.updated_at
|
last_active = session.updated_at
|
||||||
summary = ""
|
await self.consolidator.archive(msgs)
|
||||||
if archive_msgs:
|
entry = self.consolidator.get_last_history_entry()
|
||||||
summary = await self.consolidator.archive(archive_msgs) or ""
|
summary = (entry or {}).get("content", "")
|
||||||
if summary and summary != "(nothing)":
|
if summary and summary != "(nothing)":
|
||||||
self._summaries[key] = (summary, last_active)
|
self._summaries[key] = (summary, last_active)
|
||||||
session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()}
|
session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()}
|
||||||
session.messages = kept_msgs
|
session.clear()
|
||||||
session.last_consolidated = 0
|
|
||||||
session.updated_at = datetime.now()
|
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
if archive_msgs:
|
logger.info("Auto-compact: archived {} ({} messages, summary={})", key, n, bool(summary))
|
||||||
logger.info(
|
|
||||||
"Auto-compact: archived {} (archived={}, kept={}, summary={})",
|
|
||||||
key,
|
|
||||||
len(archive_msgs),
|
|
||||||
len(kept_msgs),
|
|
||||||
bool(summary),
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Auto-compact: failed for {}", key)
|
logger.exception("Auto-compact: failed for {}", key)
|
||||||
finally:
|
finally:
|
||||||
@@ -110,13 +71,11 @@ class AutoCompact:
|
|||||||
if key in self._archiving or self._is_expired(session.updated_at):
|
if key in self._archiving or self._is_expired(session.updated_at):
|
||||||
logger.info("Auto-compact: reloading session {} (archiving={})", key, key in self._archiving)
|
logger.info("Auto-compact: reloading session {} (archiving={})", key, key in self._archiving)
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
# Hot path: summary from in-memory dict (process hasn't restarted).
|
|
||||||
# Also clean metadata copy so stale _last_summary never leaks to disk.
|
|
||||||
entry = self._summaries.pop(key, None)
|
entry = self._summaries.pop(key, None)
|
||||||
if entry:
|
if entry:
|
||||||
session.metadata.pop("_last_summary", None)
|
session.metadata.pop("_last_summary", None)
|
||||||
return session, self._format_summary(entry[0], entry[1])
|
return session, self._format_summary(entry[0], entry[1])
|
||||||
if "_last_summary" in session.metadata:
|
if not session.messages and "_last_summary" in session.metadata:
|
||||||
meta = session.metadata.pop("_last_summary")
|
meta = session.metadata.pop("_last_summary")
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"]))
|
return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"]))
|
||||||
@@ -19,22 +19,17 @@ class ContextBuilder:
|
|||||||
|
|
||||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
|
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
|
||||||
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
||||||
_MAX_RECENT_HISTORY = 50
|
|
||||||
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
|
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
|
||||||
|
|
||||||
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
def __init__(self, workspace: Path, timezone: str | None = None):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.timezone = timezone
|
self.timezone = timezone
|
||||||
self.memory = MemoryStore(workspace)
|
self.memory = MemoryStore(workspace)
|
||||||
self.skills = SkillsLoader(workspace, disabled_skills=set(disabled_skills) if disabled_skills else None)
|
self.skills = SkillsLoader(workspace)
|
||||||
|
|
||||||
def build_system_prompt(
|
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
|
||||||
self,
|
|
||||||
skill_names: list[str] | None = None,
|
|
||||||
channel: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
|
||||||
parts = [self._get_identity(channel=channel)]
|
parts = [self._get_identity()]
|
||||||
|
|
||||||
bootstrap = self._load_bootstrap_files()
|
bootstrap = self._load_bootstrap_files()
|
||||||
if bootstrap:
|
if bootstrap:
|
||||||
@@ -54,16 +49,9 @@ class ContextBuilder:
|
|||||||
if skills_summary:
|
if skills_summary:
|
||||||
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
|
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
|
||||||
|
|
||||||
entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor())
|
|
||||||
if entries:
|
|
||||||
capped = entries[-self._MAX_RECENT_HISTORY:]
|
|
||||||
parts.append("# Recent History\n\n" + "\n".join(
|
|
||||||
f"- [{e['timestamp']}] {e['content']}" for e in capped
|
|
||||||
))
|
|
||||||
|
|
||||||
return "\n\n---\n\n".join(parts)
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
def _get_identity(self, channel: str | None = None) -> str:
|
def _get_identity(self) -> str:
|
||||||
"""Get the core identity section."""
|
"""Get the core identity section."""
|
||||||
workspace_path = str(self.workspace.expanduser().resolve())
|
workspace_path = str(self.workspace.expanduser().resolve())
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
@@ -74,7 +62,6 @@ class ContextBuilder:
|
|||||||
workspace_path=workspace_path,
|
workspace_path=workspace_path,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
platform_policy=render_template("agent/platform_policy.md", system=system),
|
platform_policy=render_template("agent/platform_policy.md", system=system),
|
||||||
channel=channel or "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -138,7 +125,7 @@ class ContextBuilder:
|
|||||||
else:
|
else:
|
||||||
merged = [{"type": "text", "text": runtime_ctx}] + user_content
|
merged = [{"type": "text", "text": runtime_ctx}] + user_content
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": self.build_system_prompt(skill_names, channel=channel)},
|
{"role": "system", "content": self.build_system_prompt(skill_names)},
|
||||||
*history,
|
*history,
|
||||||
]
|
]
|
||||||
if messages[-1].get("role") == current_role:
|
if messages[-1].get("role") == current_role:
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ class AgentHookContext:
|
|||||||
class AgentHook:
|
class AgentHook:
|
||||||
"""Minimal lifecycle surface for shared runner customization."""
|
"""Minimal lifecycle surface for shared runner customization."""
|
||||||
|
|
||||||
def __init__(self, reraise: bool = False) -> None:
|
|
||||||
self._reraise = reraise
|
|
||||||
|
|
||||||
def wants_streaming(self) -> bool:
|
def wants_streaming(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -65,7 +62,6 @@ class CompositeHook(AgentHook):
|
|||||||
__slots__ = ("_hooks",)
|
__slots__ = ("_hooks",)
|
||||||
|
|
||||||
def __init__(self, hooks: list[AgentHook]) -> None:
|
def __init__(self, hooks: list[AgentHook]) -> None:
|
||||||
super().__init__()
|
|
||||||
self._hooks = list(hooks)
|
self._hooks = list(hooks)
|
||||||
|
|
||||||
def wants_streaming(self) -> bool:
|
def wants_streaming(self) -> bool:
|
||||||
@@ -73,10 +69,6 @@ class CompositeHook(AgentHook):
|
|||||||
|
|
||||||
async def _for_each_hook_safe(self, method_name: str, *args: Any, **kwargs: Any) -> None:
|
async def _for_each_hook_safe(self, method_name: str, *args: Any, **kwargs: Any) -> None:
|
||||||
for h in self._hooks:
|
for h in self._hooks:
|
||||||
if getattr(h, "_reraise", False):
|
|
||||||
await getattr(h, method_name)(*args, **kwargs)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await getattr(h, method_name)(*args, **kwargs)
|
await getattr(h, method_name)(*args, **kwargs)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+121
-284
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import dataclasses
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -13,17 +12,16 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.autocompact import AutoCompact
|
from nanobot.agent.auto_compact import AutoCompact
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook
|
from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook
|
||||||
from nanobot.agent.memory import Consolidator, Dream
|
from nanobot.agent.memory import Consolidator, Dream
|
||||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunSpec, AgentRunner
|
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
from nanobot.agent.tools.cron import CronTool
|
from nanobot.agent.tools.cron import CronTool
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.notebook import NotebookEditTool
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.agent.tools.search import GlobTool, GrepTool
|
from nanobot.agent.tools.search import GlobTool, GrepTool
|
||||||
from nanobot.agent.tools.shell import ExecTool
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
@@ -35,7 +33,7 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.helpers import image_placeholder_text, truncate_text as truncate_text_fn
|
from nanobot.utils.helpers import image_placeholder_text, truncate_text
|
||||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -43,9 +41,6 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
|
|
||||||
UNIFIED_SESSION_KEY = "unified:default"
|
|
||||||
|
|
||||||
|
|
||||||
class _LoopHook(AgentHook):
|
class _LoopHook(AgentHook):
|
||||||
"""Core hook for the main loop."""
|
"""Core hook for the main loop."""
|
||||||
|
|
||||||
@@ -60,7 +55,6 @@ class _LoopHook(AgentHook):
|
|||||||
chat_id: str = "direct",
|
chat_id: str = "direct",
|
||||||
message_id: str | None = None,
|
message_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(reraise=True)
|
|
||||||
self._loop = agent_loop
|
self._loop = agent_loop
|
||||||
self._on_progress = on_progress
|
self._on_progress = on_progress
|
||||||
self._on_stream = on_stream
|
self._on_stream = on_stream
|
||||||
@@ -79,7 +73,7 @@ class _LoopHook(AgentHook):
|
|||||||
prev_clean = strip_think(self._stream_buf)
|
prev_clean = strip_think(self._stream_buf)
|
||||||
self._stream_buf += delta
|
self._stream_buf += delta
|
||||||
new_clean = strip_think(self._stream_buf)
|
new_clean = strip_think(self._stream_buf)
|
||||||
incremental = new_clean[len(prev_clean) :]
|
incremental = new_clean[len(prev_clean):]
|
||||||
if incremental and self._on_stream:
|
if incremental and self._on_stream:
|
||||||
await self._on_stream(incremental)
|
await self._on_stream(incremental)
|
||||||
|
|
||||||
@@ -116,6 +110,43 @@ class _LoopHook(AgentHook):
|
|||||||
return self._loop._strip_think(content)
|
return self._loop._strip_think(content)
|
||||||
|
|
||||||
|
|
||||||
|
class _LoopHookChain(AgentHook):
|
||||||
|
"""Run the core hook before extra hooks."""
|
||||||
|
|
||||||
|
__slots__ = ("_primary", "_extras")
|
||||||
|
|
||||||
|
def __init__(self, primary: AgentHook, extra_hooks: list[AgentHook]) -> None:
|
||||||
|
self._primary = primary
|
||||||
|
self._extras = CompositeHook(extra_hooks)
|
||||||
|
|
||||||
|
def wants_streaming(self) -> bool:
|
||||||
|
return self._primary.wants_streaming() or self._extras.wants_streaming()
|
||||||
|
|
||||||
|
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||||
|
await self._primary.before_iteration(context)
|
||||||
|
await self._extras.before_iteration(context)
|
||||||
|
|
||||||
|
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
|
||||||
|
await self._primary.on_stream(context, delta)
|
||||||
|
await self._extras.on_stream(context, delta)
|
||||||
|
|
||||||
|
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
||||||
|
await self._primary.on_stream_end(context, resuming=resuming)
|
||||||
|
await self._extras.on_stream_end(context, resuming=resuming)
|
||||||
|
|
||||||
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||||
|
await self._primary.before_execute_tools(context)
|
||||||
|
await self._extras.before_execute_tools(context)
|
||||||
|
|
||||||
|
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||||
|
await self._primary.after_iteration(context)
|
||||||
|
await self._extras.after_iteration(context)
|
||||||
|
|
||||||
|
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
|
||||||
|
content = self._primary.finalize_content(context, content)
|
||||||
|
return self._extras.finalize_content(context, content)
|
||||||
|
|
||||||
|
|
||||||
class AgentLoop:
|
class AgentLoop:
|
||||||
"""
|
"""
|
||||||
The agent loop is the core processing engine.
|
The agent loop is the core processing engine.
|
||||||
@@ -129,7 +160,6 @@ class AgentLoop:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -152,8 +182,6 @@ class AgentLoop:
|
|||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
session_ttl_minutes: int = 0,
|
session_ttl_minutes: int = 0,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
unified_session: bool = False,
|
|
||||||
disabled_skills: list[str] | None = None,
|
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ExecToolConfig, WebToolsConfig
|
from nanobot.config.schema import ExecToolConfig, WebToolsConfig
|
||||||
|
|
||||||
@@ -186,7 +214,7 @@ class AgentLoop:
|
|||||||
self._last_usage: dict[str, int] = {}
|
self._last_usage: dict[str, int] = {}
|
||||||
self._extra_hooks: list[AgentHook] = hooks or []
|
self._extra_hooks: list[AgentHook] = hooks or []
|
||||||
|
|
||||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
self.context = ContextBuilder(workspace, timezone=timezone)
|
||||||
self.sessions = session_manager or SessionManager(workspace)
|
self.sessions = session_manager or SessionManager(workspace)
|
||||||
self.tools = ToolRegistry()
|
self.tools = ToolRegistry()
|
||||||
self.runner = AgentRunner(provider)
|
self.runner = AgentRunner(provider)
|
||||||
@@ -199,12 +227,11 @@ class AgentLoop:
|
|||||||
max_tool_result_chars=self.max_tool_result_chars,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
exec_config=self.exec_config,
|
exec_config=self.exec_config,
|
||||||
restrict_to_workspace=restrict_to_workspace,
|
restrict_to_workspace=restrict_to_workspace,
|
||||||
disabled_skills=disabled_skills,
|
|
||||||
)
|
)
|
||||||
self._unified_session = unified_session
|
|
||||||
self._running = False
|
self._running = False
|
||||||
self._mcp_servers = mcp_servers or {}
|
self._mcp_servers = mcp_servers or {}
|
||||||
self._mcp_stacks: dict[str, AsyncExitStack] = {}
|
self._mcp_stack: AsyncExitStack | None = None
|
||||||
self._mcp_connected = False
|
self._mcp_connected = False
|
||||||
self._mcp_connecting = False
|
self._mcp_connecting = False
|
||||||
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
|
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
|
||||||
@@ -245,35 +272,23 @@ class AgentLoop:
|
|||||||
|
|
||||||
def _register_default_tools(self) -> None:
|
def _register_default_tools(self) -> None:
|
||||||
"""Register the default set of tools."""
|
"""Register the default set of tools."""
|
||||||
allowed_dir = (
|
allowed_dir = self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None
|
||||||
self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None
|
|
||||||
)
|
|
||||||
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
||||||
self.tools.register(
|
self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
|
||||||
ReadFileTool(
|
|
||||||
workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for cls in (WriteFileTool, EditFileTool, ListDirTool):
|
for cls in (WriteFileTool, EditFileTool, ListDirTool):
|
||||||
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
|
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||||
for cls in (GlobTool, GrepTool):
|
for cls in (GlobTool, GrepTool):
|
||||||
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
|
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||||
self.tools.register(NotebookEditTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
|
||||||
if self.exec_config.enable:
|
if self.exec_config.enable:
|
||||||
self.tools.register(
|
self.tools.register(ExecTool(
|
||||||
ExecTool(
|
working_dir=str(self.workspace),
|
||||||
working_dir=str(self.workspace),
|
timeout=self.exec_config.timeout,
|
||||||
timeout=self.exec_config.timeout,
|
restrict_to_workspace=self.restrict_to_workspace,
|
||||||
restrict_to_workspace=self.restrict_to_workspace,
|
sandbox=self.exec_config.sandbox,
|
||||||
sandbox=self.exec_config.sandbox,
|
path_append=self.exec_config.path_append,
|
||||||
path_append=self.exec_config.path_append,
|
))
|
||||||
allowed_env_keys=self.exec_config.allowed_env_keys,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if self.web_config.enable:
|
if self.web_config.enable:
|
||||||
self.tools.register(
|
self.tools.register(WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy))
|
||||||
WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy)
|
|
||||||
)
|
|
||||||
self.tools.register(WebFetchTool(proxy=self.web_config.proxy))
|
self.tools.register(WebFetchTool(proxy=self.web_config.proxy))
|
||||||
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
|
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
|
||||||
self.tools.register(SpawnTool(manager=self.subagents))
|
self.tools.register(SpawnTool(manager=self.subagents))
|
||||||
@@ -288,19 +303,19 @@ class AgentLoop:
|
|||||||
return
|
return
|
||||||
self._mcp_connecting = True
|
self._mcp_connecting = True
|
||||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._mcp_stacks = await connect_mcp_servers(self._mcp_servers, self.tools)
|
self._mcp_stack = AsyncExitStack()
|
||||||
if self._mcp_stacks:
|
await self._mcp_stack.__aenter__()
|
||||||
self._mcp_connected = True
|
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
|
||||||
else:
|
self._mcp_connected = True
|
||||||
logger.warning("No MCP servers connected successfully (will retry next message)")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.warning("MCP connection cancelled (will retry next message)")
|
|
||||||
self._mcp_stacks.clear()
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.error("Failed to connect MCP servers (will retry next message): {}", e)
|
logger.error("Failed to connect MCP servers (will retry next message): {}", e)
|
||||||
self._mcp_stacks.clear()
|
if self._mcp_stack:
|
||||||
|
try:
|
||||||
|
await self._mcp_stack.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._mcp_stack = None
|
||||||
finally:
|
finally:
|
||||||
self._mcp_connecting = False
|
self._mcp_connecting = False
|
||||||
|
|
||||||
@@ -317,7 +332,6 @@ class AgentLoop:
|
|||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
from nanobot.utils.helpers import strip_think
|
from nanobot.utils.helpers import strip_think
|
||||||
|
|
||||||
return strip_think(text) or None
|
return strip_think(text) or None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -327,12 +341,6 @@ class AgentLoop:
|
|||||||
|
|
||||||
return format_tool_hints(tool_calls)
|
return format_tool_hints(tool_calls)
|
||||||
|
|
||||||
def _effective_session_key(self, msg: InboundMessage) -> str:
|
|
||||||
"""Return the session key used for task routing and mid-turn injections."""
|
|
||||||
if self._unified_session and not msg.session_key_override:
|
|
||||||
return UNIFIED_SESSION_KEY
|
|
||||||
return msg.session_key
|
|
||||||
|
|
||||||
async def _run_agent_loop(
|
async def _run_agent_loop(
|
||||||
self,
|
self,
|
||||||
initial_messages: list[dict],
|
initial_messages: list[dict],
|
||||||
@@ -365,7 +373,9 @@ class AgentLoop:
|
|||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
)
|
)
|
||||||
hook: AgentHook = (
|
hook: AgentHook = (
|
||||||
CompositeHook([loop_hook] + self._extra_hooks) if self._extra_hooks else loop_hook
|
_LoopHookChain(loop_hook, self._extra_hooks)
|
||||||
|
if self._extra_hooks
|
||||||
|
else loop_hook
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _checkpoint(payload: dict[str, Any]) -> None:
|
async def _checkpoint(payload: dict[str, Any]) -> None:
|
||||||
@@ -373,30 +383,16 @@ class AgentLoop:
|
|||||||
return
|
return
|
||||||
self._set_runtime_checkpoint(session, payload)
|
self._set_runtime_checkpoint(session, payload)
|
||||||
|
|
||||||
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
|
async def _drain_pending() -> list[InboundMessage]:
|
||||||
"""Non-blocking drain of follow-up messages from the pending queue."""
|
"""Non-blocking drain of follow-up messages from the pending queue."""
|
||||||
if pending_queue is None:
|
if pending_queue is None:
|
||||||
return []
|
return []
|
||||||
items: list[dict[str, Any]] = []
|
items: list[InboundMessage] = []
|
||||||
while len(items) < limit:
|
while True:
|
||||||
try:
|
try:
|
||||||
pending_msg = pending_queue.get_nowait()
|
items.append(pending_queue.get_nowait())
|
||||||
except asyncio.QueueEmpty:
|
except asyncio.QueueEmpty:
|
||||||
break
|
break
|
||||||
user_content = self.context._build_user_content(
|
|
||||||
pending_msg.content,
|
|
||||||
pending_msg.media if pending_msg.media else None,
|
|
||||||
)
|
|
||||||
runtime_ctx = self.context._build_runtime_context(
|
|
||||||
pending_msg.channel,
|
|
||||||
pending_msg.chat_id,
|
|
||||||
self.context.timezone,
|
|
||||||
)
|
|
||||||
if isinstance(user_content, str):
|
|
||||||
merged: str | list[dict[str, Any]] = f"{runtime_ctx}\n\n{user_content}"
|
|
||||||
else:
|
|
||||||
merged = [{"type": "text", "text": runtime_ctx}] + user_content
|
|
||||||
items.append({"role": "user", "content": merged})
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
result = await self.runner.run(AgentRunSpec(
|
result = await self.runner.run(AgentRunSpec(
|
||||||
@@ -434,10 +430,7 @@ class AgentLoop:
|
|||||||
try:
|
try:
|
||||||
msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
|
msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
self.auto_compact.check_expired(
|
self.auto_compact.check_expired(self._schedule_background)
|
||||||
self._schedule_background,
|
|
||||||
active_session_keys=self._pending_queues.keys(),
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Preserve real task cancellation so shutdown can complete cleanly.
|
# Preserve real task cancellation so shutdown can complete cleanly.
|
||||||
@@ -456,46 +449,30 @@ class AgentLoop:
|
|||||||
if result:
|
if result:
|
||||||
await self.bus.publish_outbound(result)
|
await self.bus.publish_outbound(result)
|
||||||
continue
|
continue
|
||||||
effective_key = self._effective_session_key(msg)
|
|
||||||
# If this session already has an active pending queue (i.e. a task
|
# If this session already has an active pending queue (i.e. a task
|
||||||
# is processing this session), route the message there for mid-turn
|
# is processing this session), route the message there for mid-turn
|
||||||
# injection instead of creating a competing task.
|
# injection instead of creating a competing task.
|
||||||
if effective_key in self._pending_queues:
|
if msg.session_key in self._pending_queues:
|
||||||
pending_msg = msg
|
|
||||||
if effective_key != msg.session_key:
|
|
||||||
pending_msg = dataclasses.replace(
|
|
||||||
msg,
|
|
||||||
session_key_override=effective_key,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
self._pending_queues[effective_key].put_nowait(pending_msg)
|
self._pending_queues[msg.session_key].put_nowait(msg)
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Pending queue full for session {}, falling back to queued task",
|
"Pending queue full for session {}, dropping follow-up",
|
||||||
effective_key,
|
msg.session_key,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Routed follow-up message to pending queue for session {}",
|
"Routed follow-up message to pending queue for session {}",
|
||||||
effective_key,
|
msg.session_key,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
# Compute the effective session key before dispatching
|
|
||||||
# This ensures /stop command can find tasks correctly when unified session is enabled
|
|
||||||
task = asyncio.create_task(self._dispatch(msg))
|
task = asyncio.create_task(self._dispatch(msg))
|
||||||
self._active_tasks.setdefault(effective_key, []).append(task)
|
self._active_tasks.setdefault(msg.session_key, []).append(task)
|
||||||
task.add_done_callback(
|
task.add_done_callback(lambda t, k=msg.session_key: self._active_tasks.get(k, []) and self._active_tasks[k].remove(t) if t in self._active_tasks.get(k, []) else None)
|
||||||
lambda t, k=effective_key: self._active_tasks.get(k, [])
|
|
||||||
and self._active_tasks[k].remove(t)
|
|
||||||
if t in self._active_tasks.get(k, [])
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _dispatch(self, msg: InboundMessage) -> None:
|
async def _dispatch(self, msg: InboundMessage) -> None:
|
||||||
"""Process a message: per-session serial, cross-session concurrent."""
|
"""Process a message: per-session serial, cross-session concurrent."""
|
||||||
session_key = self._effective_session_key(msg)
|
session_key = msg.session_key
|
||||||
if session_key != msg.session_key:
|
|
||||||
msg = dataclasses.replace(msg, session_key_override=session_key)
|
|
||||||
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
||||||
gate = self._concurrency_gate or nullcontext()
|
gate = self._concurrency_gate or nullcontext()
|
||||||
|
|
||||||
@@ -584,12 +561,12 @@ class AgentLoop:
|
|||||||
if self._background_tasks:
|
if self._background_tasks:
|
||||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||||
self._background_tasks.clear()
|
self._background_tasks.clear()
|
||||||
for name, stack in self._mcp_stacks.items():
|
if self._mcp_stack:
|
||||||
try:
|
try:
|
||||||
await stack.aclose()
|
await self._mcp_stack.aclose()
|
||||||
except (RuntimeError, BaseExceptionGroup):
|
except (RuntimeError, BaseExceptionGroup):
|
||||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
pass # MCP SDK cancel scope cleanup is noisy but harmless
|
||||||
self._mcp_stacks.clear()
|
self._mcp_stack = None
|
||||||
|
|
||||||
def _schedule_background(self, coro) -> None:
|
def _schedule_background(self, coro) -> None:
|
||||||
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
||||||
@@ -614,16 +591,13 @@ class AgentLoop:
|
|||||||
"""Process a single inbound message and return the response."""
|
"""Process a single inbound message and return the response."""
|
||||||
# System messages: parse origin from chat_id ("channel:chat_id")
|
# System messages: parse origin from chat_id ("channel:chat_id")
|
||||||
if msg.channel == "system":
|
if msg.channel == "system":
|
||||||
channel, chat_id = (
|
channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id
|
||||||
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
else ("cli", msg.chat_id))
|
||||||
)
|
|
||||||
logger.info("Processing system message from {}", msg.sender_id)
|
logger.info("Processing system message from {}", msg.sender_id)
|
||||||
key = f"{channel}:{chat_id}"
|
key = f"{channel}:{chat_id}"
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
if self._restore_runtime_checkpoint(session):
|
if self._restore_runtime_checkpoint(session):
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
if self._restore_pending_user_turn(session):
|
|
||||||
self.sessions.save(session)
|
|
||||||
|
|
||||||
session, pending = self.auto_compact.prepare_session(session, key)
|
session, pending = self.auto_compact.prepare_session(session, key)
|
||||||
|
|
||||||
@@ -646,11 +620,8 @@ class AgentLoop:
|
|||||||
self._clear_runtime_checkpoint(session)
|
self._clear_runtime_checkpoint(session)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session))
|
self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session))
|
||||||
return OutboundMessage(
|
return OutboundMessage(channel=channel, chat_id=chat_id,
|
||||||
channel=channel,
|
content=final_content or "Background task completed.")
|
||||||
chat_id=chat_id,
|
|
||||||
content=final_content or "Background task completed.",
|
|
||||||
)
|
|
||||||
|
|
||||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||||
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||||
@@ -659,8 +630,6 @@ class AgentLoop:
|
|||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
if self._restore_runtime_checkpoint(session):
|
if self._restore_runtime_checkpoint(session):
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
if self._restore_pending_user_turn(session):
|
|
||||||
self.sessions.save(session)
|
|
||||||
|
|
||||||
session, pending = self.auto_compact.prepare_session(session, key)
|
session, pending = self.auto_compact.prepare_session(session, key)
|
||||||
|
|
||||||
@@ -684,35 +653,16 @@ class AgentLoop:
|
|||||||
current_message=msg.content,
|
current_message=msg.content,
|
||||||
session_summary=pending,
|
session_summary=pending,
|
||||||
media=msg.media if msg.media else None,
|
media=msg.media if msg.media else None,
|
||||||
channel=msg.channel,
|
channel=msg.channel, chat_id=msg.chat_id,
|
||||||
chat_id=msg.chat_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
|
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
|
||||||
meta = dict(msg.metadata or {})
|
meta = dict(msg.metadata or {})
|
||||||
meta["_progress"] = True
|
meta["_progress"] = True
|
||||||
meta["_tool_hint"] = tool_hint
|
meta["_tool_hint"] = tool_hint
|
||||||
await self.bus.publish_outbound(
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
OutboundMessage(
|
channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
|
||||||
channel=msg.channel,
|
))
|
||||||
chat_id=msg.chat_id,
|
|
||||||
content=content,
|
|
||||||
metadata=meta,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Persist the triggering user message immediately, before running the
|
|
||||||
# agent loop. If the process is killed mid-turn (OOM, SIGKILL, self-
|
|
||||||
# restart, etc.), the existing runtime_checkpoint preserves the
|
|
||||||
# in-flight assistant/tool state but NOT the user message itself, so
|
|
||||||
# the user's prompt is silently lost on recovery. Saving it up front
|
|
||||||
# makes recovery possible from the session log alone.
|
|
||||||
user_persisted_early = False
|
|
||||||
if isinstance(msg.content, str) and msg.content.strip():
|
|
||||||
session.add_message("user", msg.content)
|
|
||||||
self._mark_pending_user_turn(session)
|
|
||||||
self.sessions.save(session)
|
|
||||||
user_persisted_early = True
|
|
||||||
|
|
||||||
final_content, _, all_msgs, stop_reason, had_injections = await self._run_agent_loop(
|
final_content, _, all_msgs, stop_reason, had_injections = await self._run_agent_loop(
|
||||||
initial_messages,
|
initial_messages,
|
||||||
@@ -720,8 +670,7 @@ class AgentLoop:
|
|||||||
on_stream=on_stream,
|
on_stream=on_stream,
|
||||||
on_stream_end=on_stream_end,
|
on_stream_end=on_stream_end,
|
||||||
session=session,
|
session=session,
|
||||||
channel=msg.channel,
|
channel=msg.channel, chat_id=msg.chat_id,
|
||||||
chat_id=msg.chat_id,
|
|
||||||
message_id=msg.metadata.get("message_id"),
|
message_id=msg.metadata.get("message_id"),
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
)
|
)
|
||||||
@@ -729,34 +678,27 @@ class AgentLoop:
|
|||||||
if final_content is None or not final_content.strip():
|
if final_content is None or not final_content.strip():
|
||||||
final_content = EMPTY_FINAL_RESPONSE_MESSAGE
|
final_content = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
|
|
||||||
# Skip the already-persisted user message when saving the turn
|
self._save_turn(session, all_msgs, 1 + len(history))
|
||||||
save_skip = 1 + len(history) + (1 if user_persisted_early else 0)
|
|
||||||
self._save_turn(session, all_msgs, save_skip)
|
|
||||||
self._clear_pending_user_turn(session)
|
|
||||||
self._clear_runtime_checkpoint(session)
|
self._clear_runtime_checkpoint(session)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session))
|
self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session))
|
||||||
|
|
||||||
# When follow-up messages were injected mid-turn, a later natural
|
# When follow-up messages were injected mid-turn, the LLM's final
|
||||||
# language reply may address those follow-ups and should not be
|
# response addresses those follow-ups. Always send the response in
|
||||||
# suppressed just because MessageTool was used earlier in the turn.
|
# this case, even if MessageTool was used earlier in the turn — the
|
||||||
# However, if the turn falls back to the empty-final-response
|
# follow-up response is new content the user hasn't seen.
|
||||||
# placeholder, suppress it when the real user-visible output already
|
if not had_injections:
|
||||||
# came from MessageTool.
|
if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
|
||||||
if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
|
|
||||||
if not had_injections or stop_reason == "empty_final_response":
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||||
|
|
||||||
meta = dict(msg.metadata or {})
|
meta = dict(msg.metadata or {})
|
||||||
if on_stream is not None and stop_reason != "error":
|
if on_stream is not None:
|
||||||
meta["_streamed"] = True
|
meta["_streamed"] = True
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel, chat_id=msg.chat_id, content=final_content,
|
||||||
chat_id=msg.chat_id,
|
|
||||||
content=final_content,
|
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -764,7 +706,7 @@ class AgentLoop:
|
|||||||
self,
|
self,
|
||||||
content: list[dict[str, Any]],
|
content: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
should_truncate_text: bool = False,
|
truncate_text: bool = False,
|
||||||
drop_runtime: bool = False,
|
drop_runtime: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Strip volatile multimodal payloads before writing session history."""
|
"""Strip volatile multimodal payloads before writing session history."""
|
||||||
@@ -782,17 +724,18 @@ class AgentLoop:
|
|||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if block.get("type") == "image_url" and block.get("image_url", {}).get(
|
if (
|
||||||
"url", ""
|
block.get("type") == "image_url"
|
||||||
).startswith("data:image/"):
|
and block.get("image_url", {}).get("url", "").startswith("data:image/")
|
||||||
|
):
|
||||||
path = (block.get("_meta") or {}).get("path", "")
|
path = (block.get("_meta") or {}).get("path", "")
|
||||||
filtered.append({"type": "text", "text": image_placeholder_text(path)})
|
filtered.append({"type": "text", "text": image_placeholder_text(path)})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
||||||
text = block["text"]
|
text = block["text"]
|
||||||
if should_truncate_text and len(text) > self.max_tool_result_chars:
|
if truncate_text and len(text) > self.max_tool_result_chars:
|
||||||
text = truncate_text_fn(text, self.max_tool_result_chars)
|
text = truncate_text(text, self.max_tool_result_chars)
|
||||||
filtered.append({**block, "text": text})
|
filtered.append({**block, "text": text})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -803,7 +746,6 @@ class AgentLoop:
|
|||||||
def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
|
def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
|
||||||
"""Save new-turn messages into session, truncating large tool results."""
|
"""Save new-turn messages into session, truncating large tool results."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
for m in messages[skip:]:
|
for m in messages[skip:]:
|
||||||
entry = dict(m)
|
entry = dict(m)
|
||||||
role, content = entry.get("role"), entry.get("content")
|
role, content = entry.get("role"), entry.get("content")
|
||||||
@@ -811,9 +753,9 @@ class AgentLoop:
|
|||||||
continue # skip empty assistant messages — they poison session context
|
continue # skip empty assistant messages — they poison session context
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
if isinstance(content, str) and len(content) > self.max_tool_result_chars:
|
if isinstance(content, str) and len(content) > self.max_tool_result_chars:
|
||||||
entry["content"] = truncate_text_fn(content, self.max_tool_result_chars)
|
entry["content"] = truncate_text(content, self.max_tool_result_chars)
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
filtered = self._sanitize_persisted_blocks(content, should_truncate_text=True)
|
filtered = self._sanitize_persisted_blocks(content, truncate_text=True)
|
||||||
if not filtered:
|
if not filtered:
|
||||||
continue
|
continue
|
||||||
entry["content"] = filtered
|
entry["content"] = filtered
|
||||||
@@ -843,92 +785,13 @@ class AgentLoop:
|
|||||||
entry["content"] = filtered
|
entry["content"] = filtered
|
||||||
entry.setdefault("timestamp", datetime.now().isoformat())
|
entry.setdefault("timestamp", datetime.now().isoformat())
|
||||||
session.messages.append(entry)
|
session.messages.append(entry)
|
||||||
|
|
||||||
# Persist cross-channel message tool calls into target sessions so
|
|
||||||
# that the target session has context when the user replies there.
|
|
||||||
self._persist_cross_channel_calls(session, messages[skip:])
|
|
||||||
session.updated_at = datetime.now()
|
session.updated_at = datetime.now()
|
||||||
|
|
||||||
def _persist_cross_channel_calls(
|
|
||||||
self, source_session: Session, new_messages: list[dict[str, Any]]
|
|
||||||
) -> None:
|
|
||||||
"""Record cross-channel ``message`` tool calls into the target session.
|
|
||||||
|
|
||||||
When session A (e.g. websocket) uses the *message* tool to send to
|
|
||||||
channel B (e.g. feishu), the outbound message is delivered to the user
|
|
||||||
but is not recorded in session B's history. This causes session B to
|
|
||||||
lose context when the user replies on channel B.
|
|
||||||
|
|
||||||
This method detects such cross-channel sends and appends a lightweight
|
|
||||||
assistant entry to the target session so it has the necessary context.
|
|
||||||
|
|
||||||
Improvements over the initial implementation:
|
|
||||||
- Use ``sessions.get_or_create()`` instead of accessing ``_cache``
|
|
||||||
directly, so sessions persisted on disk but evicted from memory are
|
|
||||||
still found.
|
|
||||||
- Persist ``media`` file paths alongside ``content`` so the target
|
|
||||||
session retains full context about attachments.
|
|
||||||
- Record ``_source_session`` to make the provenance traceable.
|
|
||||||
"""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
for m in new_messages:
|
|
||||||
if m.get("role") != "assistant":
|
|
||||||
continue
|
|
||||||
tool_calls = m.get("tool_calls") or []
|
|
||||||
for tc in tool_calls:
|
|
||||||
func = tc.get("function", {})
|
|
||||||
if func.get("name") != "message":
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
args = json.loads(func.get("arguments", "{}"))
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
target_channel = args.get("channel") or source_session.key.split(":", 1)[0]
|
|
||||||
target_chat_id = args.get("chat_id") or source_session.key.split(":", 1)[-1]
|
|
||||||
target_key = f"{target_channel}:{target_chat_id}"
|
|
||||||
|
|
||||||
if target_key == source_session.key:
|
|
||||||
continue # same session, nothing to do
|
|
||||||
|
|
||||||
content = args.get("content", "")
|
|
||||||
media = args.get("media")
|
|
||||||
if not content and not media:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Use the public API so disk-persisted sessions are loaded too.
|
|
||||||
target_session = self.sessions.get_or_create(target_key)
|
|
||||||
|
|
||||||
entry: dict[str, Any] = {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": content,
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"_cross_channel": True,
|
|
||||||
"_source_session": source_session.key,
|
|
||||||
}
|
|
||||||
if media:
|
|
||||||
entry["_media"] = media
|
|
||||||
|
|
||||||
target_session.messages.append(entry)
|
|
||||||
target_session.updated_at = datetime.now()
|
|
||||||
self.sessions.save(target_session)
|
|
||||||
logger.info(
|
|
||||||
"Cross-channel message persisted: {} -> {}",
|
|
||||||
source_session.key, target_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
|
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
|
||||||
"""Persist the latest in-flight turn state into session metadata."""
|
"""Persist the latest in-flight turn state into session metadata."""
|
||||||
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
|
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
def _mark_pending_user_turn(self, session: Session) -> None:
|
|
||||||
session.metadata[self._PENDING_USER_TURN_KEY] = True
|
|
||||||
|
|
||||||
def _clear_pending_user_turn(self, session: Session) -> None:
|
|
||||||
session.metadata.pop(self._PENDING_USER_TURN_KEY, None)
|
|
||||||
|
|
||||||
def _clear_runtime_checkpoint(self, session: Session) -> None:
|
def _clear_runtime_checkpoint(self, session: Session) -> None:
|
||||||
if self._RUNTIME_CHECKPOINT_KEY in session.metadata:
|
if self._RUNTIME_CHECKPOINT_KEY in session.metadata:
|
||||||
session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None)
|
session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None)
|
||||||
@@ -972,15 +835,13 @@ class AgentLoop:
|
|||||||
continue
|
continue
|
||||||
tool_id = tool_call.get("id")
|
tool_id = tool_call.get("id")
|
||||||
name = ((tool_call.get("function") or {}).get("name")) or "tool"
|
name = ((tool_call.get("function") or {}).get("name")) or "tool"
|
||||||
restored_messages.append(
|
restored_messages.append({
|
||||||
{
|
"role": "tool",
|
||||||
"role": "tool",
|
"tool_call_id": tool_id,
|
||||||
"tool_call_id": tool_id,
|
"name": name,
|
||||||
"name": name,
|
"content": "Error: Task interrupted before this tool finished.",
|
||||||
"content": "Error: Task interrupted before this tool finished.",
|
"timestamp": datetime.now().isoformat(),
|
||||||
"timestamp": datetime.now().isoformat(),
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
overlap = 0
|
overlap = 0
|
||||||
max_overlap = min(len(session.messages), len(restored_messages))
|
max_overlap = min(len(session.messages), len(restored_messages))
|
||||||
@@ -995,30 +856,9 @@ class AgentLoop:
|
|||||||
break
|
break
|
||||||
session.messages.extend(restored_messages[overlap:])
|
session.messages.extend(restored_messages[overlap:])
|
||||||
|
|
||||||
self._clear_pending_user_turn(session)
|
|
||||||
self._clear_runtime_checkpoint(session)
|
self._clear_runtime_checkpoint(session)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _restore_pending_user_turn(self, session: Session) -> bool:
|
|
||||||
"""Close a turn that only persisted the user message before crashing."""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
if not session.metadata.get(self._PENDING_USER_TURN_KEY):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if session.messages and session.messages[-1].get("role") == "user":
|
|
||||||
session.messages.append(
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Error: Task interrupted before a response was generated.",
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
session.updated_at = datetime.now()
|
|
||||||
|
|
||||||
self._clear_pending_user_turn(session)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def process_direct(
|
async def process_direct(
|
||||||
self,
|
self,
|
||||||
content: str,
|
content: str,
|
||||||
@@ -1033,9 +873,6 @@ class AgentLoop:
|
|||||||
await self._connect_mcp()
|
await self._connect_mcp()
|
||||||
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
|
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
|
||||||
return await self._process_message(
|
return await self._process_message(
|
||||||
msg,
|
msg, session_key=session_key, on_progress=on_progress,
|
||||||
session_key=session_key,
|
on_stream=on_stream, on_stream_end=on_stream_end,
|
||||||
on_progress=on_progress,
|
|
||||||
on_stream=on_stream,
|
|
||||||
on_stream_end=on_stream_end,
|
|
||||||
)
|
)
|
||||||
|
|||||||
+18
-105
@@ -290,7 +290,7 @@ class MemoryStore:
|
|||||||
if not lines:
|
if not lines:
|
||||||
return None
|
return None
|
||||||
return json.loads(lines[-1])
|
return json.loads(lines[-1])
|
||||||
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _write_entries(self, entries: list[dict[str, Any]]) -> None:
|
def _write_entries(self, entries: list[dict[str, Any]]) -> None:
|
||||||
@@ -347,7 +347,6 @@ class Consolidator:
|
|||||||
"""Lightweight consolidation: summarizes evicted messages into history.jsonl."""
|
"""Lightweight consolidation: summarizes evicted messages into history.jsonl."""
|
||||||
|
|
||||||
_MAX_CONSOLIDATION_ROUNDS = 5
|
_MAX_CONSOLIDATION_ROUNDS = 5
|
||||||
_MAX_CHUNK_MESSAGES = 60 # hard cap per consolidation round
|
|
||||||
|
|
||||||
_SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
|
_SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
|
||||||
|
|
||||||
@@ -374,6 +373,10 @@ class Consolidator:
|
|||||||
weakref.WeakValueDictionary()
|
weakref.WeakValueDictionary()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_last_history_entry(self) -> dict[str, Any] | None:
|
||||||
|
"""Return the most recent entry from history.jsonl."""
|
||||||
|
return self.store._read_last_entry()
|
||||||
|
|
||||||
def get_lock(self, session_key: str) -> asyncio.Lock:
|
def get_lock(self, session_key: str) -> asyncio.Lock:
|
||||||
"""Return the shared consolidation lock for one session."""
|
"""Return the shared consolidation lock for one session."""
|
||||||
return self._locks.setdefault(session_key, asyncio.Lock())
|
return self._locks.setdefault(session_key, asyncio.Lock())
|
||||||
@@ -400,22 +403,6 @@ class Consolidator:
|
|||||||
|
|
||||||
return last_boundary
|
return last_boundary
|
||||||
|
|
||||||
def _cap_consolidation_boundary(
|
|
||||||
self,
|
|
||||||
session: Session,
|
|
||||||
end_idx: int,
|
|
||||||
) -> int | None:
|
|
||||||
"""Clamp the chunk size without breaking the user-turn boundary."""
|
|
||||||
start = session.last_consolidated
|
|
||||||
if end_idx - start <= self._MAX_CHUNK_MESSAGES:
|
|
||||||
return end_idx
|
|
||||||
|
|
||||||
capped_end = start + self._MAX_CHUNK_MESSAGES
|
|
||||||
for idx in range(capped_end, start, -1):
|
|
||||||
if session.messages[idx].get("role") == "user":
|
|
||||||
return idx
|
|
||||||
return None
|
|
||||||
|
|
||||||
def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
|
def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
|
||||||
"""Estimate current prompt size for the normal session history view."""
|
"""Estimate current prompt size for the normal session history view."""
|
||||||
history = session.get_history(max_messages=0)
|
history = session.get_history(max_messages=0)
|
||||||
@@ -433,13 +420,13 @@ class Consolidator:
|
|||||||
self._get_tool_definitions(),
|
self._get_tool_definitions(),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def archive(self, messages: list[dict]) -> str | None:
|
async def archive(self, messages: list[dict]) -> bool:
|
||||||
"""Summarize messages via LLM and append to history.jsonl.
|
"""Summarize messages via LLM and append to history.jsonl.
|
||||||
|
|
||||||
Returns the summary text on success, None if nothing to archive.
|
Returns True on success (or degraded success), False if nothing to do.
|
||||||
"""
|
"""
|
||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return False
|
||||||
try:
|
try:
|
||||||
formatted = MemoryStore._format_messages(messages)
|
formatted = MemoryStore._format_messages(messages)
|
||||||
response = await self.provider.chat_with_retry(
|
response = await self.provider.chat_with_retry(
|
||||||
@@ -459,11 +446,11 @@ class Consolidator:
|
|||||||
)
|
)
|
||||||
summary = response.content or "[no summary]"
|
summary = response.content or "[no summary]"
|
||||||
self.store.append_history(summary)
|
self.store.append_history(summary)
|
||||||
return summary
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
||||||
self.store.raw_archive(messages)
|
self.store.raw_archive(messages)
|
||||||
return None
|
return True
|
||||||
|
|
||||||
async def maybe_consolidate_by_tokens(self, session: Session) -> None:
|
async def maybe_consolidate_by_tokens(self, session: Session) -> None:
|
||||||
"""Loop: archive old messages until prompt fits within safe budget.
|
"""Loop: archive old messages until prompt fits within safe budget.
|
||||||
@@ -478,22 +465,16 @@ class Consolidator:
|
|||||||
async with lock:
|
async with lock:
|
||||||
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
||||||
target = budget // 2
|
target = budget // 2
|
||||||
try:
|
estimated, source = self.estimate_session_prompt_tokens(session)
|
||||||
estimated, source = self.estimate_session_prompt_tokens(session)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Token estimation failed for {}", session.key)
|
|
||||||
estimated, source = 0, "error"
|
|
||||||
if estimated <= 0:
|
if estimated <= 0:
|
||||||
return
|
return
|
||||||
if estimated < budget:
|
if estimated < budget:
|
||||||
unconsolidated_count = len(session.messages) - session.last_consolidated
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Token consolidation idle {}: {}/{} via {}, msgs={}",
|
"Token consolidation idle {}: {}/{} via {}",
|
||||||
session.key,
|
session.key,
|
||||||
estimated,
|
estimated,
|
||||||
self.context_window_tokens,
|
self.context_window_tokens,
|
||||||
source,
|
source,
|
||||||
unconsolidated_count,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -511,15 +492,6 @@ class Consolidator:
|
|||||||
return
|
return
|
||||||
|
|
||||||
end_idx = boundary[0]
|
end_idx = boundary[0]
|
||||||
end_idx = self._cap_consolidation_boundary(session, end_idx)
|
|
||||||
if end_idx is None:
|
|
||||||
logger.debug(
|
|
||||||
"Token consolidation: no capped boundary for {} (round {})",
|
|
||||||
session.key,
|
|
||||||
round_num,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
chunk = session.messages[session.last_consolidated:end_idx]
|
chunk = session.messages[session.last_consolidated:end_idx]
|
||||||
if not chunk:
|
if not chunk:
|
||||||
return
|
return
|
||||||
@@ -538,11 +510,7 @@ class Consolidator:
|
|||||||
session.last_consolidated = end_idx
|
session.last_consolidated = end_idx
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
try:
|
estimated, source = self.estimate_session_prompt_tokens(session)
|
||||||
estimated, source = self.estimate_session_prompt_tokens(session)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Token estimation failed for {}", session.key)
|
|
||||||
estimated, source = 0, "error"
|
|
||||||
if estimated <= 0:
|
if estimated <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -582,60 +550,18 @@ class Dream:
|
|||||||
|
|
||||||
def _build_tools(self) -> ToolRegistry:
|
def _build_tools(self) -> ToolRegistry:
|
||||||
"""Build a minimal tool registry for the Dream agent."""
|
"""Build a minimal tool registry for the Dream agent."""
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
|
||||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
|
|
||||||
|
|
||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
workspace = self.store.workspace
|
workspace = self.store.workspace
|
||||||
# Allow reading builtin skills for reference during skill creation
|
tools.register(ReadFileTool(workspace=workspace, allowed_dir=workspace))
|
||||||
extra_read = [BUILTIN_SKILLS_DIR] if BUILTIN_SKILLS_DIR.exists() else None
|
|
||||||
tools.register(ReadFileTool(
|
|
||||||
workspace=workspace,
|
|
||||||
allowed_dir=workspace,
|
|
||||||
extra_allowed_dirs=extra_read,
|
|
||||||
))
|
|
||||||
tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace))
|
tools.register(EditFileTool(workspace=workspace, allowed_dir=workspace))
|
||||||
# write_file resolves relative paths from workspace root, but can only
|
|
||||||
# write under skills/ so the prompt can safely use skills/<name>/SKILL.md.
|
|
||||||
skills_dir = workspace / "skills"
|
|
||||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
tools.register(WriteFileTool(workspace=workspace, allowed_dir=skills_dir))
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
# -- skill listing --------------------------------------------------------
|
|
||||||
|
|
||||||
def _list_existing_skills(self) -> list[str]:
|
|
||||||
"""List existing skills as 'name — description' for dedup context."""
|
|
||||||
import re as _re
|
|
||||||
|
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
|
||||||
|
|
||||||
_DESC_RE = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
|
|
||||||
entries: dict[str, str] = {}
|
|
||||||
for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR):
|
|
||||||
if not base.exists():
|
|
||||||
continue
|
|
||||||
for d in base.iterdir():
|
|
||||||
if not d.is_dir():
|
|
||||||
continue
|
|
||||||
skill_md = d / "SKILL.md"
|
|
||||||
if not skill_md.exists():
|
|
||||||
continue
|
|
||||||
# Prefer workspace skills over builtin (same name)
|
|
||||||
if d.name in entries and base == BUILTIN_SKILLS_DIR:
|
|
||||||
continue
|
|
||||||
content = skill_md.read_text(encoding="utf-8")[:500]
|
|
||||||
m = _DESC_RE.search(content)
|
|
||||||
desc = m.group(1).strip() if m else "(no description)"
|
|
||||||
entries[d.name] = desc
|
|
||||||
return [f"{name} — {desc}" for name, desc in sorted(entries.items())]
|
|
||||||
|
|
||||||
# -- main entry ----------------------------------------------------------
|
# -- main entry ----------------------------------------------------------
|
||||||
|
|
||||||
async def run(self) -> bool:
|
async def run(self) -> bool:
|
||||||
"""Process unprocessed history entries. Returns True if work was done."""
|
"""Process unprocessed history entries. Returns True if work was done."""
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
|
||||||
|
|
||||||
last_cursor = self.store.get_last_dream_cursor()
|
last_cursor = self.store.get_last_dream_cursor()
|
||||||
entries = self.store.read_unprocessed_history(since_cursor=last_cursor)
|
entries = self.store.read_unprocessed_history(since_cursor=last_cursor)
|
||||||
if not entries:
|
if not entries:
|
||||||
@@ -657,7 +583,6 @@ class Dream:
|
|||||||
current_memory = self.store.read_memory() or "(empty)"
|
current_memory = self.store.read_memory() or "(empty)"
|
||||||
current_soul = self.store.read_soul() or "(empty)"
|
current_soul = self.store.read_soul() or "(empty)"
|
||||||
current_user = self.store.read_user() or "(empty)"
|
current_user = self.store.read_user() or "(empty)"
|
||||||
|
|
||||||
file_context = (
|
file_context = (
|
||||||
f"## Current Date\n{current_date}\n\n"
|
f"## Current Date\n{current_date}\n\n"
|
||||||
f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n"
|
f"## Current MEMORY.md ({len(current_memory)} chars)\n{current_memory}\n\n"
|
||||||
@@ -665,7 +590,7 @@ class Dream:
|
|||||||
f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
|
f"## Current USER.md ({len(current_user)} chars)\n{current_user}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 1: Analyze (no skills list — dedup is Phase 2's job)
|
# Phase 1: Analyze
|
||||||
phase1_prompt = (
|
phase1_prompt = (
|
||||||
f"## Conversation History\n{history_text}\n\n{file_context}"
|
f"## Conversation History\n{history_text}\n\n{file_context}"
|
||||||
)
|
)
|
||||||
@@ -690,25 +615,13 @@ class Dream:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Phase 2: Delegate to AgentRunner with read_file / edit_file
|
# Phase 2: Delegate to AgentRunner with read_file / edit_file
|
||||||
existing_skills = self._list_existing_skills()
|
phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}"
|
||||||
skills_section = ""
|
|
||||||
if existing_skills:
|
|
||||||
skills_section = (
|
|
||||||
"\n\n## Existing Skills\n"
|
|
||||||
+ "\n".join(f"- {s}" for s in existing_skills)
|
|
||||||
)
|
|
||||||
phase2_prompt = f"## Analysis Result\n{analysis}\n\n{file_context}{skills_section}"
|
|
||||||
|
|
||||||
tools = self._tools
|
tools = self._tools
|
||||||
skill_creator_path = BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"
|
|
||||||
messages: list[dict[str, Any]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": render_template(
|
"content": render_template("agent/dream_phase2.md", strip=True),
|
||||||
"agent/dream_phase2.md",
|
|
||||||
strip=True,
|
|
||||||
skill_creator_path=str(skill_creator_path),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{"role": "user", "content": phase2_prompt},
|
{"role": "user", "content": phase2_prompt},
|
||||||
]
|
]
|
||||||
|
|||||||
+33
-252
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import inspect
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -25,27 +24,16 @@ from nanobot.utils.helpers import (
|
|||||||
from nanobot.utils.runtime import (
|
from nanobot.utils.runtime import (
|
||||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||||
build_finalization_retry_message,
|
build_finalization_retry_message,
|
||||||
build_length_recovery_message,
|
|
||||||
ensure_nonempty_tool_result,
|
ensure_nonempty_tool_result,
|
||||||
is_blank_text,
|
is_blank_text,
|
||||||
repeated_external_lookup_error,
|
repeated_external_lookup_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
|
||||||
_PERSISTED_MODEL_ERROR_PLACEHOLDER = "[Assistant reply unavailable due to model error.]"
|
|
||||||
_MAX_EMPTY_RETRIES = 2
|
_MAX_EMPTY_RETRIES = 2
|
||||||
_MAX_LENGTH_RECOVERIES = 3
|
|
||||||
_MAX_INJECTIONS_PER_TURN = 3
|
_MAX_INJECTIONS_PER_TURN = 3
|
||||||
_MAX_INJECTION_CYCLES = 5
|
_MAX_INJECTION_CYCLES = 5
|
||||||
_SNIP_SAFETY_BUFFER = 1024
|
_SNIP_SAFETY_BUFFER = 1024
|
||||||
_MICROCOMPACT_KEEP_RECENT = 10
|
|
||||||
_MICROCOMPACT_MIN_CHARS = 500
|
|
||||||
_COMPACTABLE_TOOLS = frozenset({
|
|
||||||
"read_file", "exec", "grep", "glob",
|
|
||||||
"web_search", "web_fetch", "list_dir",
|
|
||||||
})
|
|
||||||
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -95,89 +83,37 @@ class AgentRunner:
|
|||||||
def __init__(self, provider: LLMProvider):
|
def __init__(self, provider: LLMProvider):
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
|
|
||||||
@staticmethod
|
async def _drain_injections(self, spec: AgentRunSpec) -> list[str]:
|
||||||
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
|
||||||
if isinstance(left, str) and isinstance(right, str):
|
|
||||||
return f"{left}\n\n{right}" if left else right
|
|
||||||
|
|
||||||
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [
|
|
||||||
item if isinstance(item, dict) else {"type": "text", "text": str(item)}
|
|
||||||
for item in value
|
|
||||||
]
|
|
||||||
if value is None:
|
|
||||||
return []
|
|
||||||
return [{"type": "text", "text": str(value)}]
|
|
||||||
|
|
||||||
return _to_blocks(left) + _to_blocks(right)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _append_injected_messages(
|
|
||||||
cls,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
injections: list[dict[str, Any]],
|
|
||||||
) -> None:
|
|
||||||
"""Append injected user messages while preserving role alternation."""
|
|
||||||
for injection in injections:
|
|
||||||
if (
|
|
||||||
messages
|
|
||||||
and injection.get("role") == "user"
|
|
||||||
and messages[-1].get("role") == "user"
|
|
||||||
):
|
|
||||||
merged = dict(messages[-1])
|
|
||||||
merged["content"] = cls._merge_message_content(
|
|
||||||
merged.get("content"),
|
|
||||||
injection.get("content"),
|
|
||||||
)
|
|
||||||
messages[-1] = merged
|
|
||||||
continue
|
|
||||||
messages.append(injection)
|
|
||||||
|
|
||||||
async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]:
|
|
||||||
"""Drain pending user messages via the injection callback.
|
"""Drain pending user messages via the injection callback.
|
||||||
|
|
||||||
Returns normalized user messages (capped by
|
Returns all drained message contents (capped by
|
||||||
``_MAX_INJECTIONS_PER_TURN``), or an empty list when there is
|
``_MAX_INJECTIONS_PER_TURN``), or an empty list when there is
|
||||||
nothing to inject. Messages beyond the cap are logged so they
|
nothing to inject. Messages beyond the cap are logged so they
|
||||||
are not silently lost.
|
are not silently lost.
|
||||||
"""
|
"""
|
||||||
if spec.injection_callback is None:
|
if spec.injection_callback is None:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
signature = inspect.signature(spec.injection_callback)
|
items = await spec.injection_callback()
|
||||||
accepts_limit = (
|
|
||||||
"limit" in signature.parameters
|
|
||||||
or any(
|
|
||||||
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
||||||
for parameter in signature.parameters.values()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if accepts_limit:
|
|
||||||
items = await spec.injection_callback(limit=_MAX_INJECTIONS_PER_TURN)
|
|
||||||
else:
|
|
||||||
items = await spec.injection_callback()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("injection_callback failed")
|
logger.exception("injection_callback failed")
|
||||||
return []
|
return []
|
||||||
if not items:
|
if not items:
|
||||||
return []
|
return []
|
||||||
injected_messages: list[dict[str, Any]] = []
|
# items are InboundMessage objects from _drain_pending
|
||||||
|
texts: list[str] = []
|
||||||
for item in items:
|
for item in items:
|
||||||
if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
|
|
||||||
injected_messages.append(item)
|
|
||||||
continue
|
|
||||||
text = getattr(item, "content", str(item))
|
text = getattr(item, "content", str(item))
|
||||||
if text.strip():
|
if text.strip():
|
||||||
injected_messages.append({"role": "user", "content": text})
|
texts.append(text)
|
||||||
if len(injected_messages) > _MAX_INJECTIONS_PER_TURN:
|
if len(texts) > _MAX_INJECTIONS_PER_TURN:
|
||||||
dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN
|
dropped = len(texts) - _MAX_INJECTIONS_PER_TURN
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Injection callback returned {} messages, capping to {} ({} dropped)",
|
"Injection batch has {} messages, capping to {} ({} dropped)",
|
||||||
len(injected_messages), _MAX_INJECTIONS_PER_TURN, dropped,
|
len(texts), _MAX_INJECTIONS_PER_TURN, dropped,
|
||||||
)
|
)
|
||||||
injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN]
|
texts = texts[-_MAX_INJECTIONS_PER_TURN:]
|
||||||
return injected_messages
|
return texts
|
||||||
|
|
||||||
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
|
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
|
||||||
hook = spec.hook or AgentHook()
|
hook = spec.hook or AgentHook()
|
||||||
@@ -190,36 +126,21 @@ class AgentRunner:
|
|||||||
tool_events: list[dict[str, str]] = []
|
tool_events: list[dict[str, str]] = []
|
||||||
external_lookup_counts: dict[str, int] = {}
|
external_lookup_counts: dict[str, int] = {}
|
||||||
empty_content_retries = 0
|
empty_content_retries = 0
|
||||||
length_recovery_count = 0
|
|
||||||
had_injections = False
|
had_injections = False
|
||||||
injection_cycles = 0
|
injection_cycles = 0
|
||||||
|
|
||||||
for iteration in range(spec.max_iterations):
|
for iteration in range(spec.max_iterations):
|
||||||
try:
|
try:
|
||||||
# Keep the persisted conversation untouched. Context governance
|
messages = self._apply_tool_result_budget(spec, messages)
|
||||||
# may repair or compact historical messages for the model, but
|
messages_for_model = self._snip_history(spec, messages)
|
||||||
# those synthetic edits must not shift the append boundary used
|
|
||||||
# later when the caller saves only the new turn.
|
|
||||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
|
||||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
|
||||||
messages_for_model = self._microcompact(messages_for_model)
|
|
||||||
messages_for_model = self._apply_tool_result_budget(spec, messages_for_model)
|
|
||||||
messages_for_model = self._snip_history(spec, messages_for_model)
|
|
||||||
# Snipping may have created new orphans; clean them up.
|
|
||||||
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
|
||||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Context governance failed on turn {} for {}: {}; applying minimal repair",
|
"Context governance failed on turn {} for {}: {}; using raw messages",
|
||||||
iteration,
|
iteration,
|
||||||
spec.session_key or "default",
|
spec.session_key or "default",
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
try:
|
messages_for_model = messages
|
||||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
|
||||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
|
||||||
except Exception:
|
|
||||||
messages_for_model = messages
|
|
||||||
context = AgentHookContext(iteration=iteration, messages=messages)
|
context = AgentHookContext(iteration=iteration, messages=messages)
|
||||||
await hook.before_iteration(context)
|
await hook.before_iteration(context)
|
||||||
response = await self._request_model(spec, messages_for_model, hook, context)
|
response = await self._request_model(spec, messages_for_model, hook, context)
|
||||||
@@ -263,6 +184,16 @@ class AgentRunner:
|
|||||||
tool_events.extend(new_events)
|
tool_events.extend(new_events)
|
||||||
context.tool_results = list(results)
|
context.tool_results = list(results)
|
||||||
context.tool_events = list(new_events)
|
context.tool_events = list(new_events)
|
||||||
|
if fatal_error is not None:
|
||||||
|
error = f"Error: {type(fatal_error).__name__}: {fatal_error}"
|
||||||
|
final_content = error
|
||||||
|
stop_reason = "tool_error"
|
||||||
|
self._append_final_message(messages, final_content)
|
||||||
|
context.final_content = final_content
|
||||||
|
context.error = error
|
||||||
|
context.stop_reason = stop_reason
|
||||||
|
await hook.after_iteration(context)
|
||||||
|
break
|
||||||
completed_tool_results: list[dict[str, Any]] = []
|
completed_tool_results: list[dict[str, Any]] = []
|
||||||
for tool_call, result in zip(response.tool_calls, results):
|
for tool_call, result in zip(response.tool_calls, results):
|
||||||
tool_message = {
|
tool_message = {
|
||||||
@@ -278,16 +209,6 @@ class AgentRunner:
|
|||||||
}
|
}
|
||||||
messages.append(tool_message)
|
messages.append(tool_message)
|
||||||
completed_tool_results.append(tool_message)
|
completed_tool_results.append(tool_message)
|
||||||
if fatal_error is not None:
|
|
||||||
error = f"Error: {type(fatal_error).__name__}: {fatal_error}"
|
|
||||||
final_content = error
|
|
||||||
stop_reason = "tool_error"
|
|
||||||
self._append_final_message(messages, final_content)
|
|
||||||
context.final_content = final_content
|
|
||||||
context.error = error
|
|
||||||
context.stop_reason = stop_reason
|
|
||||||
await hook.after_iteration(context)
|
|
||||||
break
|
|
||||||
await self._emit_checkpoint(
|
await self._emit_checkpoint(
|
||||||
spec,
|
spec,
|
||||||
{
|
{
|
||||||
@@ -300,14 +221,14 @@ class AgentRunner:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
empty_content_retries = 0
|
empty_content_retries = 0
|
||||||
length_recovery_count = 0
|
|
||||||
# Checkpoint 1: drain injections after tools, before next LLM call
|
# Checkpoint 1: drain injections after tools, before next LLM call
|
||||||
if injection_cycles < _MAX_INJECTION_CYCLES:
|
if injection_cycles < _MAX_INJECTION_CYCLES:
|
||||||
injections = await self._drain_injections(spec)
|
injections = await self._drain_injections(spec)
|
||||||
if injections:
|
if injections:
|
||||||
had_injections = True
|
had_injections = True
|
||||||
injection_cycles += 1
|
injection_cycles += 1
|
||||||
self._append_injected_messages(messages, injections)
|
for text in injections:
|
||||||
|
messages.append({"role": "user", "content": text})
|
||||||
logger.info(
|
logger.info(
|
||||||
"Injected {} follow-up message(s) after tool execution ({}/{})",
|
"Injected {} follow-up message(s) after tool execution ({}/{})",
|
||||||
len(injections), injection_cycles, _MAX_INJECTION_CYCLES,
|
len(injections), injection_cycles, _MAX_INJECTION_CYCLES,
|
||||||
@@ -347,35 +268,6 @@ class AgentRunner:
|
|||||||
context.tool_calls = list(response.tool_calls)
|
context.tool_calls = list(response.tool_calls)
|
||||||
clean = hook.finalize_content(context, response.content)
|
clean = hook.finalize_content(context, response.content)
|
||||||
|
|
||||||
if response.finish_reason == "length" and not is_blank_text(clean):
|
|
||||||
length_recovery_count += 1
|
|
||||||
if length_recovery_count <= _MAX_LENGTH_RECOVERIES:
|
|
||||||
logger.info(
|
|
||||||
"Output truncated on turn {} for {} ({}/{}); continuing",
|
|
||||||
iteration,
|
|
||||||
spec.session_key or "default",
|
|
||||||
length_recovery_count,
|
|
||||||
_MAX_LENGTH_RECOVERIES,
|
|
||||||
)
|
|
||||||
if hook.wants_streaming():
|
|
||||||
await hook.on_stream_end(context, resuming=True)
|
|
||||||
messages.append(build_assistant_message(
|
|
||||||
clean,
|
|
||||||
reasoning_content=response.reasoning_content,
|
|
||||||
thinking_blocks=response.thinking_blocks,
|
|
||||||
))
|
|
||||||
messages.append(build_length_recovery_message())
|
|
||||||
await hook.after_iteration(context)
|
|
||||||
continue
|
|
||||||
|
|
||||||
assistant_message: dict[str, Any] | None = None
|
|
||||||
if response.finish_reason != "error" and not is_blank_text(clean):
|
|
||||||
assistant_message = build_assistant_message(
|
|
||||||
clean,
|
|
||||||
reasoning_content=response.reasoning_content,
|
|
||||||
thinking_blocks=response.thinking_blocks,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for mid-turn injections BEFORE signaling stream end.
|
# Check for mid-turn injections BEFORE signaling stream end.
|
||||||
# If injections are found we keep the stream alive (resuming=True)
|
# If injections are found we keep the stream alive (resuming=True)
|
||||||
# so streaming channels don't prematurely finalize the card.
|
# so streaming channels don't prematurely finalize the card.
|
||||||
@@ -386,20 +278,8 @@ class AgentRunner:
|
|||||||
had_injections = True
|
had_injections = True
|
||||||
injection_cycles += 1
|
injection_cycles += 1
|
||||||
_injected_after_final = True
|
_injected_after_final = True
|
||||||
if assistant_message is not None:
|
for text in injections:
|
||||||
messages.append(assistant_message)
|
messages.append({"role": "user", "content": text})
|
||||||
await self._emit_checkpoint(
|
|
||||||
spec,
|
|
||||||
{
|
|
||||||
"phase": "final_response",
|
|
||||||
"iteration": iteration,
|
|
||||||
"model": spec.model,
|
|
||||||
"assistant_message": assistant_message,
|
|
||||||
"completed_tool_results": [],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self._append_injected_messages(messages, injections)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Injected {} follow-up message(s) after final response ({}/{})",
|
"Injected {} follow-up message(s) after final response ({}/{})",
|
||||||
len(injections), injection_cycles, _MAX_INJECTION_CYCLES,
|
len(injections), injection_cycles, _MAX_INJECTION_CYCLES,
|
||||||
@@ -416,7 +296,7 @@ class AgentRunner:
|
|||||||
final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE
|
final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE
|
||||||
stop_reason = "error"
|
stop_reason = "error"
|
||||||
error = final_content
|
error = final_content
|
||||||
self._append_model_error_placeholder(messages)
|
self._append_final_message(messages, final_content)
|
||||||
context.final_content = final_content
|
context.final_content = final_content
|
||||||
context.error = error
|
context.error = error
|
||||||
context.stop_reason = stop_reason
|
context.stop_reason = stop_reason
|
||||||
@@ -433,7 +313,7 @@ class AgentRunner:
|
|||||||
await hook.after_iteration(context)
|
await hook.after_iteration(context)
|
||||||
break
|
break
|
||||||
|
|
||||||
messages.append(assistant_message or build_assistant_message(
|
messages.append(build_assistant_message(
|
||||||
clean,
|
clean,
|
||||||
reasoning_content=response.reasoning_content,
|
reasoning_content=response.reasoning_content,
|
||||||
thinking_blocks=response.thinking_blocks,
|
thinking_blocks=response.thinking_blocks,
|
||||||
@@ -681,12 +561,6 @@ class AgentRunner:
|
|||||||
return
|
return
|
||||||
messages.append(build_assistant_message(content))
|
messages.append(build_assistant_message(content))
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _append_model_error_placeholder(messages: list[dict[str, Any]]) -> None:
|
|
||||||
if messages and messages[-1].get("role") == "assistant" and not messages[-1].get("tool_calls"):
|
|
||||||
return
|
|
||||||
messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER))
|
|
||||||
|
|
||||||
def _normalize_tool_result(
|
def _normalize_tool_result(
|
||||||
self,
|
self,
|
||||||
spec: AgentRunSpec,
|
spec: AgentRunSpec,
|
||||||
@@ -715,99 +589,6 @@ class AgentRunner:
|
|||||||
return truncate_text(content, spec.max_tool_result_chars)
|
return truncate_text(content, spec.max_tool_result_chars)
|
||||||
return content
|
return content
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _drop_orphan_tool_results(
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Drop tool results that have no matching assistant tool_call earlier in the history."""
|
|
||||||
declared: set[str] = set()
|
|
||||||
updated: list[dict[str, Any]] | None = None
|
|
||||||
for idx, msg in enumerate(messages):
|
|
||||||
role = msg.get("role")
|
|
||||||
if role == "assistant":
|
|
||||||
for tc in msg.get("tool_calls") or []:
|
|
||||||
if isinstance(tc, dict) and tc.get("id"):
|
|
||||||
declared.add(str(tc["id"]))
|
|
||||||
if role == "tool":
|
|
||||||
tid = msg.get("tool_call_id")
|
|
||||||
if tid and str(tid) not in declared:
|
|
||||||
if updated is None:
|
|
||||||
updated = [dict(m) for m in messages[:idx]]
|
|
||||||
continue
|
|
||||||
if updated is not None:
|
|
||||||
updated.append(dict(msg))
|
|
||||||
|
|
||||||
if updated is None:
|
|
||||||
return messages
|
|
||||||
return updated
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _backfill_missing_tool_results(
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Insert synthetic error results for orphaned tool_use blocks."""
|
|
||||||
declared: list[tuple[int, str, str]] = [] # (assistant_idx, call_id, name)
|
|
||||||
fulfilled: set[str] = set()
|
|
||||||
for idx, msg in enumerate(messages):
|
|
||||||
role = msg.get("role")
|
|
||||||
if role == "assistant":
|
|
||||||
for tc in msg.get("tool_calls") or []:
|
|
||||||
if isinstance(tc, dict) and tc.get("id"):
|
|
||||||
name = ""
|
|
||||||
func = tc.get("function")
|
|
||||||
if isinstance(func, dict):
|
|
||||||
name = func.get("name", "")
|
|
||||||
declared.append((idx, str(tc["id"]), name))
|
|
||||||
elif role == "tool":
|
|
||||||
tid = msg.get("tool_call_id")
|
|
||||||
if tid:
|
|
||||||
fulfilled.add(str(tid))
|
|
||||||
|
|
||||||
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
|
|
||||||
if not missing:
|
|
||||||
return messages
|
|
||||||
|
|
||||||
updated = list(messages)
|
|
||||||
offset = 0
|
|
||||||
for assistant_idx, call_id, name in missing:
|
|
||||||
insert_at = assistant_idx + 1 + offset
|
|
||||||
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
|
|
||||||
insert_at += 1
|
|
||||||
updated.insert(insert_at, {
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": call_id,
|
|
||||||
"name": name,
|
|
||||||
"content": _BACKFILL_CONTENT,
|
|
||||||
})
|
|
||||||
offset += 1
|
|
||||||
return updated
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _microcompact(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
"""Replace old compactable tool results with one-line summaries."""
|
|
||||||
compactable_indices: list[int] = []
|
|
||||||
for idx, msg in enumerate(messages):
|
|
||||||
if msg.get("role") == "tool" and msg.get("name") in _COMPACTABLE_TOOLS:
|
|
||||||
compactable_indices.append(idx)
|
|
||||||
|
|
||||||
if len(compactable_indices) <= _MICROCOMPACT_KEEP_RECENT:
|
|
||||||
return messages
|
|
||||||
|
|
||||||
stale = compactable_indices[: len(compactable_indices) - _MICROCOMPACT_KEEP_RECENT]
|
|
||||||
updated: list[dict[str, Any]] | None = None
|
|
||||||
for idx in stale:
|
|
||||||
msg = messages[idx]
|
|
||||||
content = msg.get("content")
|
|
||||||
if not isinstance(content, str) or len(content) < _MICROCOMPACT_MIN_CHARS:
|
|
||||||
continue
|
|
||||||
name = msg.get("name", "tool")
|
|
||||||
summary = f"[{name} result omitted from context]"
|
|
||||||
if updated is None:
|
|
||||||
updated = [dict(m) for m in messages]
|
|
||||||
updated[idx]["content"] = summary
|
|
||||||
|
|
||||||
return updated if updated is not None else messages
|
|
||||||
|
|
||||||
def _apply_tool_result_budget(
|
def _apply_tool_result_budget(
|
||||||
self,
|
self,
|
||||||
spec: AgentRunSpec,
|
spec: AgentRunSpec,
|
||||||
|
|||||||
@@ -28,11 +28,10 @@ class SkillsLoader:
|
|||||||
specific tools or perform certain tasks.
|
specific tools or perform certain tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None, disabled_skills: set[str] | None = None):
|
def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.workspace_skills = workspace / "skills"
|
self.workspace_skills = workspace / "skills"
|
||||||
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
|
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
|
||||||
self.disabled_skills = disabled_skills or set()
|
|
||||||
|
|
||||||
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
|
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
|
||||||
if not base.exists():
|
if not base.exists():
|
||||||
@@ -67,9 +66,6 @@ class SkillsLoader:
|
|||||||
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
|
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.disabled_skills:
|
|
||||||
skills = [s for s in skills if s["name"] not in self.disabled_skills]
|
|
||||||
|
|
||||||
if filter_unavailable:
|
if filter_unavailable:
|
||||||
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
|
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
|
||||||
return skills
|
return skills
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class _SubagentHook(AgentHook):
|
|||||||
"""Logging-only hook for subagent execution."""
|
"""Logging-only hook for subagent execution."""
|
||||||
|
|
||||||
def __init__(self, task_id: str) -> None:
|
def __init__(self, task_id: str) -> None:
|
||||||
super().__init__()
|
|
||||||
self._task_id = task_id
|
self._task_id = task_id
|
||||||
|
|
||||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||||
@@ -52,7 +51,6 @@ class SubagentManager:
|
|||||||
web_config: "WebToolsConfig | None" = None,
|
web_config: "WebToolsConfig | None" = None,
|
||||||
exec_config: "ExecToolConfig | None" = None,
|
exec_config: "ExecToolConfig | None" = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
disabled_skills: list[str] | None = None,
|
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ExecToolConfig
|
from nanobot.config.schema import ExecToolConfig
|
||||||
|
|
||||||
@@ -64,7 +62,6 @@ class SubagentManager:
|
|||||||
self.max_tool_result_chars = max_tool_result_chars
|
self.max_tool_result_chars = max_tool_result_chars
|
||||||
self.exec_config = exec_config or ExecToolConfig()
|
self.exec_config = exec_config or ExecToolConfig()
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self.disabled_skills = set(disabled_skills or [])
|
|
||||||
self.runner = AgentRunner(provider)
|
self.runner = AgentRunner(provider)
|
||||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||||
@@ -238,10 +235,7 @@ class SubagentManager:
|
|||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
|
||||||
time_ctx = ContextBuilder._build_runtime_context(None, None)
|
time_ctx = ContextBuilder._build_runtime_context(None, None)
|
||||||
skills_summary = SkillsLoader(
|
skills_summary = SkillsLoader(self.workspace).build_skills_summary()
|
||||||
self.workspace,
|
|
||||||
disabled_skills=self.disabled_skills,
|
|
||||||
).build_skills_summary()
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"agent/subagent_system.md",
|
"agent/subagent_system.md",
|
||||||
time_ctx=time_ctx,
|
time_ctx=time_ctx,
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
"""Track file-read state for read-before-edit warnings and read deduplication."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class ReadState:
|
|
||||||
mtime: float
|
|
||||||
offset: int
|
|
||||||
limit: int | None
|
|
||||||
content_hash: str | None
|
|
||||||
can_dedup: bool
|
|
||||||
|
|
||||||
|
|
||||||
_state: dict[str, ReadState] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_file(p: str) -> str | None:
|
|
||||||
try:
|
|
||||||
return hashlib.sha256(Path(p).read_bytes()).hexdigest()
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def record_read(path: str | Path, offset: int = 1, limit: int | None = None) -> None:
|
|
||||||
"""Record that a file was read (called after successful read)."""
|
|
||||||
p = str(Path(path).resolve())
|
|
||||||
try:
|
|
||||||
mtime = os.path.getmtime(p)
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
_state[p] = ReadState(
|
|
||||||
mtime=mtime,
|
|
||||||
offset=offset,
|
|
||||||
limit=limit,
|
|
||||||
content_hash=_hash_file(p),
|
|
||||||
can_dedup=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def record_write(path: str | Path) -> None:
|
|
||||||
"""Record that a file was written (updates mtime in state)."""
|
|
||||||
p = str(Path(path).resolve())
|
|
||||||
try:
|
|
||||||
mtime = os.path.getmtime(p)
|
|
||||||
except OSError:
|
|
||||||
_state.pop(p, None)
|
|
||||||
return
|
|
||||||
_state[p] = ReadState(
|
|
||||||
mtime=mtime,
|
|
||||||
offset=1,
|
|
||||||
limit=None,
|
|
||||||
content_hash=_hash_file(p),
|
|
||||||
can_dedup=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_read(path: str | Path) -> str | None:
|
|
||||||
"""Check if a file has been read and is fresh.
|
|
||||||
|
|
||||||
Returns None if OK, or a warning string.
|
|
||||||
When mtime changed but file content is identical (e.g. touch, editor save),
|
|
||||||
the check passes to avoid false-positive staleness warnings.
|
|
||||||
"""
|
|
||||||
p = str(Path(path).resolve())
|
|
||||||
entry = _state.get(p)
|
|
||||||
if entry is None:
|
|
||||||
return "Warning: file has not been read yet. Read it first to verify content before editing."
|
|
||||||
try:
|
|
||||||
current_mtime = os.path.getmtime(p)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
if current_mtime != entry.mtime:
|
|
||||||
if entry.content_hash and _hash_file(p) == entry.content_hash:
|
|
||||||
entry.mtime = current_mtime
|
|
||||||
return None
|
|
||||||
return "Warning: file has been modified since last read. Re-read to verify content before editing."
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) -> bool:
|
|
||||||
"""Return True if file was previously read with same params and mtime is unchanged."""
|
|
||||||
p = str(Path(path).resolve())
|
|
||||||
entry = _state.get(p)
|
|
||||||
if entry is None:
|
|
||||||
return False
|
|
||||||
if not entry.can_dedup:
|
|
||||||
return False
|
|
||||||
if entry.offset != offset or entry.limit != limit:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
current_mtime = os.path.getmtime(p)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
return current_mtime == entry.mtime
|
|
||||||
|
|
||||||
|
|
||||||
def clear() -> None:
|
|
||||||
"""Clear all tracked state (useful for testing)."""
|
|
||||||
_state.clear()
|
|
||||||
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
import difflib
|
import difflib
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
||||||
from nanobot.agent.tools import file_state
|
|
||||||
from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime
|
from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
|
|
||||||
@@ -62,36 +60,6 @@ class _FsTool(Tool):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
_BLOCKED_DEVICE_PATHS = frozenset({
|
|
||||||
"/dev/zero", "/dev/random", "/dev/urandom", "/dev/full",
|
|
||||||
"/dev/stdin", "/dev/stdout", "/dev/stderr",
|
|
||||||
"/dev/tty", "/dev/console",
|
|
||||||
"/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _is_blocked_device(path: str | Path) -> bool:
|
|
||||||
"""Check if path is a blocked device that could hang or produce infinite output."""
|
|
||||||
import re
|
|
||||||
raw = str(path)
|
|
||||||
if raw in _BLOCKED_DEVICE_PATHS:
|
|
||||||
return True
|
|
||||||
if re.match(r"/proc/\d+/fd/[012]$", raw) or re.match(r"/proc/self/fd/[012]$", raw):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
|
|
||||||
"""Parse a page range like '2-5' into 0-based (start, end) inclusive."""
|
|
||||||
parts = pages.strip().split("-")
|
|
||||||
if len(parts) == 1:
|
|
||||||
p = int(parts[0])
|
|
||||||
return max(0, p - 1), min(p - 1, total - 1)
|
|
||||||
start = int(parts[0])
|
|
||||||
end = int(parts[1])
|
|
||||||
return max(0, start - 1), min(end - 1, total - 1)
|
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
path=StringSchema("The file path to read"),
|
path=StringSchema("The file path to read"),
|
||||||
@@ -105,7 +73,6 @@ def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
|
|||||||
description="Maximum number of lines to read (default 2000)",
|
description="Maximum number of lines to read (default 2000)",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
|
|
||||||
required=["path"],
|
required=["path"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -114,7 +81,6 @@ class ReadFileTool(_FsTool):
|
|||||||
|
|
||||||
_MAX_CHARS = 128_000
|
_MAX_CHARS = 128_000
|
||||||
_DEFAULT_LIMIT = 2000
|
_DEFAULT_LIMIT = 2000
|
||||||
_MAX_PDF_PAGES = 20
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -123,38 +89,24 @@ class ReadFileTool(_FsTool):
|
|||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Read a file (text or image). Text output format: LINE_NUM|CONTENT. "
|
"Read the contents of a file. Returns numbered lines. "
|
||||||
"Images return visual content for analysis. "
|
"Use offset and limit to paginate through large files."
|
||||||
"Use offset and limit for large files. "
|
|
||||||
"Cannot read non-image binary files. "
|
|
||||||
"Reads exceeding ~128K chars are truncated."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def read_only(self) -> bool:
|
def read_only(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def execute(self, path: str | None = None, offset: int = 1, limit: int | None = None, pages: str | None = None, **kwargs: Any) -> Any:
|
async def execute(self, path: str | None = None, offset: int = 1, limit: int | None = None, **kwargs: Any) -> Any:
|
||||||
try:
|
try:
|
||||||
if not path:
|
if not path:
|
||||||
return "Error reading file: Unknown path"
|
return "Error reading file: Unknown path"
|
||||||
|
|
||||||
# Device path blacklist
|
|
||||||
if _is_blocked_device(path):
|
|
||||||
return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)."
|
|
||||||
|
|
||||||
fp = self._resolve(path)
|
fp = self._resolve(path)
|
||||||
if _is_blocked_device(fp):
|
|
||||||
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)."
|
|
||||||
if not fp.exists():
|
if not fp.exists():
|
||||||
return f"Error: File not found: {path}"
|
return f"Error: File not found: {path}"
|
||||||
if not fp.is_file():
|
if not fp.is_file():
|
||||||
return f"Error: Not a file: {path}"
|
return f"Error: Not a file: {path}"
|
||||||
|
|
||||||
# PDF support
|
|
||||||
if fp.suffix.lower() == ".pdf":
|
|
||||||
return self._read_pdf(fp, pages)
|
|
||||||
|
|
||||||
raw = fp.read_bytes()
|
raw = fp.read_bytes()
|
||||||
if not raw:
|
if not raw:
|
||||||
return f"(Empty file: {path})"
|
return f"(Empty file: {path})"
|
||||||
@@ -163,10 +115,6 @@ class ReadFileTool(_FsTool):
|
|||||||
if mime and mime.startswith("image/"):
|
if mime and mime.startswith("image/"):
|
||||||
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
|
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
|
||||||
|
|
||||||
# Read dedup: same path + offset + limit + unchanged mtime → stub
|
|
||||||
if file_state.is_unchanged(fp, offset=offset, limit=limit):
|
|
||||||
return f"[File unchanged since last read: {path}]"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text_content = raw.decode("utf-8")
|
text_content = raw.decode("utf-8")
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
@@ -199,59 +147,12 @@ class ReadFileTool(_FsTool):
|
|||||||
result += f"\n\n(Showing lines {offset}-{end} of {total}. Use offset={end + 1} to continue.)"
|
result += f"\n\n(Showing lines {offset}-{end} of {total}. Use offset={end + 1} to continue.)"
|
||||||
else:
|
else:
|
||||||
result += f"\n\n(End of file — {total} lines total)"
|
result += f"\n\n(End of file — {total} lines total)"
|
||||||
file_state.record_read(fp, offset=offset, limit=limit)
|
|
||||||
return result
|
return result
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error reading file: {e}"
|
return f"Error reading file: {e}"
|
||||||
|
|
||||||
def _read_pdf(self, fp: Path, pages: str | None) -> str:
|
|
||||||
try:
|
|
||||||
import fitz # pymupdf
|
|
||||||
except ImportError:
|
|
||||||
return "Error: PDF reading requires pymupdf. Install with: pip install pymupdf"
|
|
||||||
|
|
||||||
try:
|
|
||||||
doc = fitz.open(str(fp))
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error reading PDF: {e}"
|
|
||||||
|
|
||||||
total_pages = len(doc)
|
|
||||||
if pages:
|
|
||||||
try:
|
|
||||||
start, end = _parse_page_range(pages, total_pages)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
doc.close()
|
|
||||||
return f"Error: Invalid page range '{pages}'. Use format like '1-5'."
|
|
||||||
if start > end or start >= total_pages:
|
|
||||||
doc.close()
|
|
||||||
return f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages)."
|
|
||||||
else:
|
|
||||||
start = 0
|
|
||||||
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
|
|
||||||
|
|
||||||
if end - start + 1 > self._MAX_PDF_PAGES:
|
|
||||||
end = start + self._MAX_PDF_PAGES - 1
|
|
||||||
|
|
||||||
parts: list[str] = []
|
|
||||||
for i in range(start, end + 1):
|
|
||||||
page = doc[i]
|
|
||||||
text = page.get_text().strip()
|
|
||||||
if text:
|
|
||||||
parts.append(f"--- Page {i + 1} ---\n{text}")
|
|
||||||
doc.close()
|
|
||||||
|
|
||||||
if not parts:
|
|
||||||
return f"(PDF has no extractable text: {fp})"
|
|
||||||
|
|
||||||
result = "\n\n".join(parts)
|
|
||||||
if end < total_pages - 1:
|
|
||||||
result += f"\n\n(Showing pages {start + 1}-{end + 1} of {total_pages}. Use pages='{end + 2}-{min(end + 1 + self._MAX_PDF_PAGES, total_pages)}' to continue.)"
|
|
||||||
if len(result) > self._MAX_CHARS:
|
|
||||||
result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)"
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# write_file
|
# write_file
|
||||||
@@ -274,11 +175,7 @@ class WriteFileTool(_FsTool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return "Write content to a file at the given path. Creates parent directories if needed."
|
||||||
"Write content to a file. Overwrites if the file already exists; "
|
|
||||||
"creates parent directories as needed. "
|
|
||||||
"For partial edits, prefer edit_file instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
|
async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
|
||||||
try:
|
try:
|
||||||
@@ -289,7 +186,6 @@ class WriteFileTool(_FsTool):
|
|||||||
fp = self._resolve(path)
|
fp = self._resolve(path)
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fp.write_text(content, encoding="utf-8")
|
fp.write_text(content, encoding="utf-8")
|
||||||
file_state.record_write(fp)
|
|
||||||
return f"Successfully wrote {len(content)} characters to {fp}"
|
return f"Successfully wrote {len(content)} characters to {fp}"
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
@@ -301,269 +197,30 @@ class WriteFileTool(_FsTool):
|
|||||||
# edit_file
|
# edit_file
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_QUOTE_TABLE = str.maketrans({
|
|
||||||
"\u2018": "'", "\u2019": "'", # curly single → straight
|
|
||||||
"\u201c": '"', "\u201d": '"', # curly double → straight
|
|
||||||
"'": "'", '"': '"', # identity (kept for completeness)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_quotes(s: str) -> str:
|
|
||||||
return s.translate(_QUOTE_TABLE)
|
|
||||||
|
|
||||||
|
|
||||||
def _curly_double_quotes(text: str) -> str:
|
|
||||||
parts: list[str] = []
|
|
||||||
opening = True
|
|
||||||
for ch in text:
|
|
||||||
if ch == '"':
|
|
||||||
parts.append("\u201c" if opening else "\u201d")
|
|
||||||
opening = not opening
|
|
||||||
else:
|
|
||||||
parts.append(ch)
|
|
||||||
return "".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _curly_single_quotes(text: str) -> str:
|
|
||||||
parts: list[str] = []
|
|
||||||
opening = True
|
|
||||||
for i, ch in enumerate(text):
|
|
||||||
if ch != "'":
|
|
||||||
parts.append(ch)
|
|
||||||
continue
|
|
||||||
prev_ch = text[i - 1] if i > 0 else ""
|
|
||||||
next_ch = text[i + 1] if i + 1 < len(text) else ""
|
|
||||||
if prev_ch.isalnum() and next_ch.isalnum():
|
|
||||||
parts.append("\u2019")
|
|
||||||
continue
|
|
||||||
parts.append("\u2018" if opening else "\u2019")
|
|
||||||
opening = not opening
|
|
||||||
return "".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _preserve_quote_style(old_text: str, actual_text: str, new_text: str) -> str:
|
|
||||||
"""Preserve curly quote style when a quote-normalized fallback matched."""
|
|
||||||
if _normalize_quotes(old_text.strip()) != _normalize_quotes(actual_text.strip()) or old_text == actual_text:
|
|
||||||
return new_text
|
|
||||||
|
|
||||||
styled = new_text
|
|
||||||
if any(ch in actual_text for ch in ("\u201c", "\u201d")) and '"' in styled:
|
|
||||||
styled = _curly_double_quotes(styled)
|
|
||||||
if any(ch in actual_text for ch in ("\u2018", "\u2019")) and "'" in styled:
|
|
||||||
styled = _curly_single_quotes(styled)
|
|
||||||
return styled
|
|
||||||
|
|
||||||
|
|
||||||
def _leading_ws(line: str) -> str:
|
|
||||||
return line[: len(line) - len(line.lstrip(" \t"))]
|
|
||||||
|
|
||||||
|
|
||||||
def _reindent_like_match(old_text: str, actual_text: str, new_text: str) -> str:
|
|
||||||
"""Preserve the outer indentation from the actual matched block."""
|
|
||||||
old_lines = old_text.split("\n")
|
|
||||||
actual_lines = actual_text.split("\n")
|
|
||||||
if len(old_lines) != len(actual_lines):
|
|
||||||
return new_text
|
|
||||||
|
|
||||||
comparable = [
|
|
||||||
(old_line, actual_line)
|
|
||||||
for old_line, actual_line in zip(old_lines, actual_lines)
|
|
||||||
if old_line.strip() and actual_line.strip()
|
|
||||||
]
|
|
||||||
if not comparable or any(
|
|
||||||
_normalize_quotes(old_line.strip()) != _normalize_quotes(actual_line.strip())
|
|
||||||
for old_line, actual_line in comparable
|
|
||||||
):
|
|
||||||
return new_text
|
|
||||||
|
|
||||||
old_ws = _leading_ws(comparable[0][0])
|
|
||||||
actual_ws = _leading_ws(comparable[0][1])
|
|
||||||
if actual_ws == old_ws:
|
|
||||||
return new_text
|
|
||||||
|
|
||||||
if old_ws:
|
|
||||||
if not actual_ws.startswith(old_ws):
|
|
||||||
return new_text
|
|
||||||
delta = actual_ws[len(old_ws):]
|
|
||||||
else:
|
|
||||||
delta = actual_ws
|
|
||||||
|
|
||||||
if not delta:
|
|
||||||
return new_text
|
|
||||||
|
|
||||||
return "\n".join((delta + line) if line else line for line in new_text.split("\n"))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class _MatchSpan:
|
|
||||||
start: int
|
|
||||||
end: int
|
|
||||||
text: str
|
|
||||||
line: int
|
|
||||||
|
|
||||||
|
|
||||||
def _find_exact_matches(content: str, old_text: str) -> list[_MatchSpan]:
|
|
||||||
matches: list[_MatchSpan] = []
|
|
||||||
start = 0
|
|
||||||
while True:
|
|
||||||
idx = content.find(old_text, start)
|
|
||||||
if idx == -1:
|
|
||||||
break
|
|
||||||
matches.append(
|
|
||||||
_MatchSpan(
|
|
||||||
start=idx,
|
|
||||||
end=idx + len(old_text),
|
|
||||||
text=content[idx : idx + len(old_text)],
|
|
||||||
line=content.count("\n", 0, idx) + 1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
start = idx + max(1, len(old_text))
|
|
||||||
return matches
|
|
||||||
|
|
||||||
|
|
||||||
def _find_trim_matches(content: str, old_text: str, *, normalize_quotes: bool = False) -> list[_MatchSpan]:
|
|
||||||
old_lines = old_text.splitlines()
|
|
||||||
if not old_lines:
|
|
||||||
return []
|
|
||||||
|
|
||||||
content_lines = content.splitlines()
|
|
||||||
content_lines_keepends = content.splitlines(keepends=True)
|
|
||||||
if len(content_lines) < len(old_lines):
|
|
||||||
return []
|
|
||||||
|
|
||||||
offsets: list[int] = []
|
|
||||||
pos = 0
|
|
||||||
for line in content_lines_keepends:
|
|
||||||
offsets.append(pos)
|
|
||||||
pos += len(line)
|
|
||||||
offsets.append(pos)
|
|
||||||
|
|
||||||
if normalize_quotes:
|
|
||||||
stripped_old = [_normalize_quotes(line.strip()) for line in old_lines]
|
|
||||||
else:
|
|
||||||
stripped_old = [line.strip() for line in old_lines]
|
|
||||||
|
|
||||||
matches: list[_MatchSpan] = []
|
|
||||||
window_size = len(stripped_old)
|
|
||||||
for i in range(len(content_lines) - window_size + 1):
|
|
||||||
window = content_lines[i : i + window_size]
|
|
||||||
if normalize_quotes:
|
|
||||||
comparable = [_normalize_quotes(line.strip()) for line in window]
|
|
||||||
else:
|
|
||||||
comparable = [line.strip() for line in window]
|
|
||||||
if comparable != stripped_old:
|
|
||||||
continue
|
|
||||||
|
|
||||||
start = offsets[i]
|
|
||||||
end = offsets[i + window_size]
|
|
||||||
if content_lines_keepends[i + window_size - 1].endswith("\n"):
|
|
||||||
end -= 1
|
|
||||||
matches.append(
|
|
||||||
_MatchSpan(
|
|
||||||
start=start,
|
|
||||||
end=end,
|
|
||||||
text=content[start:end],
|
|
||||||
line=i + 1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return matches
|
|
||||||
|
|
||||||
|
|
||||||
def _find_quote_matches(content: str, old_text: str) -> list[_MatchSpan]:
|
|
||||||
norm_content = _normalize_quotes(content)
|
|
||||||
norm_old = _normalize_quotes(old_text)
|
|
||||||
matches: list[_MatchSpan] = []
|
|
||||||
start = 0
|
|
||||||
while True:
|
|
||||||
idx = norm_content.find(norm_old, start)
|
|
||||||
if idx == -1:
|
|
||||||
break
|
|
||||||
matches.append(
|
|
||||||
_MatchSpan(
|
|
||||||
start=idx,
|
|
||||||
end=idx + len(old_text),
|
|
||||||
text=content[idx : idx + len(old_text)],
|
|
||||||
line=content.count("\n", 0, idx) + 1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
start = idx + max(1, len(norm_old))
|
|
||||||
return matches
|
|
||||||
|
|
||||||
|
|
||||||
def _find_matches(content: str, old_text: str) -> list[_MatchSpan]:
|
|
||||||
"""Locate all matches using progressively looser strategies."""
|
|
||||||
for matcher in (
|
|
||||||
lambda: _find_exact_matches(content, old_text),
|
|
||||||
lambda: _find_trim_matches(content, old_text),
|
|
||||||
lambda: _find_trim_matches(content, old_text, normalize_quotes=True),
|
|
||||||
lambda: _find_quote_matches(content, old_text),
|
|
||||||
):
|
|
||||||
matches = matcher()
|
|
||||||
if matches:
|
|
||||||
return matches
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _find_match_line_numbers(content: str, old_text: str) -> list[int]:
|
|
||||||
"""Return 1-based starting line numbers for the current matching strategies."""
|
|
||||||
return [match.line for match in _find_matches(content, old_text)]
|
|
||||||
|
|
||||||
|
|
||||||
def _collapse_internal_whitespace(text: str) -> str:
|
|
||||||
return "\n".join(" ".join(line.split()) for line in text.splitlines())
|
|
||||||
|
|
||||||
|
|
||||||
def _diagnose_near_match(old_text: str, actual_text: str) -> list[str]:
|
|
||||||
"""Return actionable hints describing why text was close but not exact."""
|
|
||||||
hints: list[str] = []
|
|
||||||
|
|
||||||
if old_text.lower() == actual_text.lower() and old_text != actual_text:
|
|
||||||
hints.append("letter case differs")
|
|
||||||
if _collapse_internal_whitespace(old_text) == _collapse_internal_whitespace(actual_text) and old_text != actual_text:
|
|
||||||
hints.append("whitespace differs")
|
|
||||||
if old_text.rstrip("\n") == actual_text.rstrip("\n") and old_text != actual_text:
|
|
||||||
hints.append("trailing newline differs")
|
|
||||||
if _normalize_quotes(old_text) == _normalize_quotes(actual_text) and old_text != actual_text:
|
|
||||||
hints.append("quote style differs")
|
|
||||||
|
|
||||||
return hints
|
|
||||||
|
|
||||||
|
|
||||||
def _best_window(old_text: str, content: str) -> tuple[float, int, list[str], list[str]]:
|
|
||||||
"""Find the closest line-window match and return ratio/start/snippet/hints."""
|
|
||||||
lines = content.splitlines(keepends=True)
|
|
||||||
old_lines = old_text.splitlines(keepends=True)
|
|
||||||
window = max(1, len(old_lines))
|
|
||||||
|
|
||||||
best_ratio, best_start = -1.0, 0
|
|
||||||
best_window_lines: list[str] = []
|
|
||||||
|
|
||||||
for i in range(max(1, len(lines) - window + 1)):
|
|
||||||
current = lines[i : i + window]
|
|
||||||
ratio = difflib.SequenceMatcher(None, old_lines, current).ratio()
|
|
||||||
if ratio > best_ratio:
|
|
||||||
best_ratio, best_start = ratio, i
|
|
||||||
best_window_lines = current
|
|
||||||
|
|
||||||
actual_text = "".join(best_window_lines).replace("\r\n", "\n").rstrip("\n")
|
|
||||||
hints = _diagnose_near_match(old_text.replace("\r\n", "\n").rstrip("\n"), actual_text)
|
|
||||||
return best_ratio, best_start, best_window_lines, hints
|
|
||||||
|
|
||||||
|
|
||||||
def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
||||||
"""Locate old_text in content with a multi-level fallback chain:
|
"""Locate old_text in content: exact first, then line-trimmed sliding window.
|
||||||
|
|
||||||
1. Exact substring match
|
|
||||||
2. Line-trimmed sliding window (handles indentation differences)
|
|
||||||
3. Smart quote normalization (curly ↔ straight quotes)
|
|
||||||
|
|
||||||
Both inputs should use LF line endings (caller normalises CRLF).
|
Both inputs should use LF line endings (caller normalises CRLF).
|
||||||
Returns (matched_fragment, count) or (None, 0).
|
Returns (matched_fragment, count) or (None, 0).
|
||||||
"""
|
"""
|
||||||
matches = _find_matches(content, old_text)
|
if old_text in content:
|
||||||
if not matches:
|
return old_text, content.count(old_text)
|
||||||
|
|
||||||
|
old_lines = old_text.splitlines()
|
||||||
|
if not old_lines:
|
||||||
return None, 0
|
return None, 0
|
||||||
return matches[0].text, len(matches)
|
stripped_old = [l.strip() for l in old_lines]
|
||||||
|
content_lines = content.splitlines()
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for i in range(len(content_lines) - len(stripped_old) + 1):
|
||||||
|
window = content_lines[i : i + len(stripped_old)]
|
||||||
|
if [l.strip() for l in window] == stripped_old:
|
||||||
|
candidates.append("\n".join(window))
|
||||||
|
|
||||||
|
if candidates:
|
||||||
|
return candidates[0], len(candidates)
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
@@ -578,9 +235,6 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
|||||||
class EditFileTool(_FsTool):
|
class EditFileTool(_FsTool):
|
||||||
"""Edit a file by replacing text with fallback matching."""
|
"""Edit a file by replacing text with fallback matching."""
|
||||||
|
|
||||||
_MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 # 1 GiB
|
|
||||||
_MARKDOWN_EXTS = frozenset({".md", ".mdx", ".markdown"})
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "edit_file"
|
return "edit_file"
|
||||||
@@ -589,16 +243,10 @@ class EditFileTool(_FsTool):
|
|||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Edit a file by replacing old_text with new_text. "
|
"Edit a file by replacing old_text with new_text. "
|
||||||
"Tolerates minor whitespace/indentation differences and curly/straight quote mismatches. "
|
"Supports minor whitespace/line-ending differences. "
|
||||||
"If old_text matches multiple times, you must provide more context "
|
"Set replace_all=true to replace every occurrence."
|
||||||
"or set replace_all=true. Shows a diff of the closest match on failure."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _strip_trailing_ws(text: str) -> str:
|
|
||||||
"""Strip trailing whitespace from each line."""
|
|
||||||
return "\n".join(line.rstrip() for line in text.split("\n"))
|
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self, path: str | None = None, old_text: str | None = None,
|
self, path: str | None = None, old_text: str | None = None,
|
||||||
new_text: str | None = None,
|
new_text: str | None = None,
|
||||||
@@ -612,133 +260,55 @@ class EditFileTool(_FsTool):
|
|||||||
if new_text is None:
|
if new_text is None:
|
||||||
raise ValueError("Unknown new_text")
|
raise ValueError("Unknown new_text")
|
||||||
|
|
||||||
# .ipynb detection
|
|
||||||
if path.endswith(".ipynb"):
|
|
||||||
return "Error: This is a Jupyter notebook. Use the notebook_edit tool instead of edit_file."
|
|
||||||
|
|
||||||
fp = self._resolve(path)
|
fp = self._resolve(path)
|
||||||
|
|
||||||
# Create-file semantics: old_text='' + file doesn't exist → create
|
|
||||||
if not fp.exists():
|
if not fp.exists():
|
||||||
if old_text == "":
|
return f"Error: File not found: {path}"
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fp.write_text(new_text, encoding="utf-8")
|
|
||||||
file_state.record_write(fp)
|
|
||||||
return f"Successfully created {fp}"
|
|
||||||
return self._file_not_found_msg(path, fp)
|
|
||||||
|
|
||||||
# File size protection
|
|
||||||
try:
|
|
||||||
fsize = fp.stat().st_size
|
|
||||||
except OSError:
|
|
||||||
fsize = 0
|
|
||||||
if fsize > self._MAX_EDIT_FILE_SIZE:
|
|
||||||
return f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB."
|
|
||||||
|
|
||||||
# Create-file: old_text='' but file exists and not empty → reject
|
|
||||||
if old_text == "":
|
|
||||||
raw = fp.read_bytes()
|
|
||||||
content = raw.decode("utf-8")
|
|
||||||
if content.strip():
|
|
||||||
return f"Error: Cannot create file — {path} already exists and is not empty."
|
|
||||||
fp.write_text(new_text, encoding="utf-8")
|
|
||||||
file_state.record_write(fp)
|
|
||||||
return f"Successfully edited {fp}"
|
|
||||||
|
|
||||||
# Read-before-edit check
|
|
||||||
warning = file_state.check_read(fp)
|
|
||||||
|
|
||||||
raw = fp.read_bytes()
|
raw = fp.read_bytes()
|
||||||
uses_crlf = b"\r\n" in raw
|
uses_crlf = b"\r\n" in raw
|
||||||
content = raw.decode("utf-8").replace("\r\n", "\n")
|
content = raw.decode("utf-8").replace("\r\n", "\n")
|
||||||
norm_old = old_text.replace("\r\n", "\n")
|
match, count = _find_match(content, old_text.replace("\r\n", "\n"))
|
||||||
matches = _find_matches(content, norm_old)
|
|
||||||
|
|
||||||
if not matches:
|
if match is None:
|
||||||
return self._not_found_msg(old_text, content, path)
|
return self._not_found_msg(old_text, content, path)
|
||||||
count = len(matches)
|
|
||||||
if count > 1 and not replace_all:
|
if count > 1 and not replace_all:
|
||||||
line_numbers = [match.line for match in matches]
|
|
||||||
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
|
|
||||||
if len(line_numbers) > 3:
|
|
||||||
preview += ", ..."
|
|
||||||
location_hint = f" at {preview}" if preview else ""
|
|
||||||
return (
|
return (
|
||||||
f"Warning: old_text appears {count} times{location_hint}. "
|
f"Warning: old_text appears {count} times. "
|
||||||
"Provide more context to make it unique, or set replace_all=true."
|
"Provide more context to make it unique, or set replace_all=true."
|
||||||
)
|
)
|
||||||
|
|
||||||
norm_new = new_text.replace("\r\n", "\n")
|
norm_new = new_text.replace("\r\n", "\n")
|
||||||
|
new_content = content.replace(match, norm_new) if replace_all else content.replace(match, norm_new, 1)
|
||||||
# Trailing whitespace stripping (skip markdown to preserve double-space line breaks)
|
|
||||||
if fp.suffix.lower() not in self._MARKDOWN_EXTS:
|
|
||||||
norm_new = self._strip_trailing_ws(norm_new)
|
|
||||||
|
|
||||||
selected = matches if replace_all else matches[:1]
|
|
||||||
new_content = content
|
|
||||||
for match in reversed(selected):
|
|
||||||
replacement = _preserve_quote_style(norm_old, match.text, norm_new)
|
|
||||||
replacement = _reindent_like_match(norm_old, match.text, replacement)
|
|
||||||
|
|
||||||
# Delete-line cleanup: when deleting text (new_text=''), consume trailing
|
|
||||||
# newline to avoid leaving a blank line
|
|
||||||
end = match.end
|
|
||||||
if replacement == "" and not match.text.endswith("\n") and content[end:end + 1] == "\n":
|
|
||||||
end += 1
|
|
||||||
|
|
||||||
new_content = new_content[: match.start] + replacement + new_content[end:]
|
|
||||||
if uses_crlf:
|
if uses_crlf:
|
||||||
new_content = new_content.replace("\n", "\r\n")
|
new_content = new_content.replace("\n", "\r\n")
|
||||||
|
|
||||||
fp.write_bytes(new_content.encode("utf-8"))
|
fp.write_bytes(new_content.encode("utf-8"))
|
||||||
file_state.record_write(fp)
|
return f"Successfully edited {fp}"
|
||||||
msg = f"Successfully edited {fp}"
|
|
||||||
if warning:
|
|
||||||
msg = f"{warning}\n{msg}"
|
|
||||||
return msg
|
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error editing file: {e}"
|
return f"Error editing file: {e}"
|
||||||
|
|
||||||
def _file_not_found_msg(self, path: str, fp: Path) -> str:
|
|
||||||
"""Build an error message with 'Did you mean ...?' suggestions."""
|
|
||||||
parent = fp.parent
|
|
||||||
suggestions: list[str] = []
|
|
||||||
if parent.is_dir():
|
|
||||||
siblings = [f.name for f in parent.iterdir() if f.is_file()]
|
|
||||||
close = difflib.get_close_matches(fp.name, siblings, n=3, cutoff=0.6)
|
|
||||||
suggestions = [str(parent / c) for c in close]
|
|
||||||
parts = [f"Error: File not found: {path}"]
|
|
||||||
if suggestions:
|
|
||||||
parts.append("Did you mean: " + ", ".join(suggestions) + "?")
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _not_found_msg(old_text: str, content: str, path: str) -> str:
|
def _not_found_msg(old_text: str, content: str, path: str) -> str:
|
||||||
best_ratio, best_start, best_window_lines, hints = _best_window(old_text, content)
|
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:
|
if best_ratio > 0.5:
|
||||||
diff = "\n".join(difflib.unified_diff(
|
diff = "\n".join(difflib.unified_diff(
|
||||||
old_text.splitlines(keepends=True),
|
old_lines, lines[best_start : best_start + window],
|
||||||
best_window_lines,
|
|
||||||
fromfile="old_text (provided)",
|
fromfile="old_text (provided)",
|
||||||
tofile=f"{path} (actual, line {best_start + 1})",
|
tofile=f"{path} (actual, line {best_start + 1})",
|
||||||
lineterm="",
|
lineterm="",
|
||||||
))
|
))
|
||||||
hint_text = ""
|
return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
|
||||||
if hints:
|
|
||||||
hint_text = "\nPossible cause: " + ", ".join(hints) + "."
|
|
||||||
return (
|
|
||||||
f"Error: old_text not found in {path}."
|
|
||||||
f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if hints:
|
|
||||||
return (
|
|
||||||
f"Error: old_text not found in {path}. "
|
|
||||||
f"Possible cause: {', '.join(hints)}. "
|
|
||||||
"Copy the exact text from read_file and try again."
|
|
||||||
)
|
|
||||||
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
|
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+19
-264
@@ -57,7 +57,9 @@ def _normalize_schema_for_openai(schema: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
if "properties" in normalized and isinstance(normalized["properties"], dict):
|
if "properties" in normalized and isinstance(normalized["properties"], dict):
|
||||||
normalized["properties"] = {
|
normalized["properties"] = {
|
||||||
name: _normalize_schema_for_openai(prop) if isinstance(prop, dict) else prop
|
name: _normalize_schema_for_openai(prop)
|
||||||
|
if isinstance(prop, dict)
|
||||||
|
else prop
|
||||||
for name, prop in normalized["properties"].items()
|
for name, prop in normalized["properties"].items()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,214 +135,36 @@ class MCPToolWrapper(Tool):
|
|||||||
return "\n".join(parts) or "(no output)"
|
return "\n".join(parts) or "(no output)"
|
||||||
|
|
||||||
|
|
||||||
class MCPResourceWrapper(Tool):
|
|
||||||
"""Wraps an MCP resource URI as a read-only nanobot Tool."""
|
|
||||||
|
|
||||||
def __init__(self, session, server_name: str, resource_def, resource_timeout: int = 30):
|
|
||||||
self._session = session
|
|
||||||
self._uri = resource_def.uri
|
|
||||||
self._name = f"mcp_{server_name}_resource_{resource_def.name}"
|
|
||||||
desc = resource_def.description or resource_def.name
|
|
||||||
self._description = f"[MCP Resource] {desc}\nURI: {self._uri}"
|
|
||||||
self._parameters: dict[str, Any] = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
"required": [],
|
|
||||||
}
|
|
||||||
self._resource_timeout = resource_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
|
|
||||||
|
|
||||||
@property
|
|
||||||
def read_only(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def execute(self, **kwargs: Any) -> str:
|
|
||||||
from mcp import types
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await asyncio.wait_for(
|
|
||||||
self._session.read_resource(self._uri),
|
|
||||||
timeout=self._resource_timeout,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
"MCP resource '{}' timed out after {}s", self._name, self._resource_timeout
|
|
||||||
)
|
|
||||||
return f"(MCP resource read timed out after {self._resource_timeout}s)"
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
task = asyncio.current_task()
|
|
||||||
if task is not None and task.cancelling() > 0:
|
|
||||||
raise
|
|
||||||
logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name)
|
|
||||||
return "(MCP resource read was cancelled)"
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(
|
|
||||||
"MCP resource '{}' failed: {}: {}",
|
|
||||||
self._name,
|
|
||||||
type(exc).__name__,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return f"(MCP resource read failed: {type(exc).__name__})"
|
|
||||||
|
|
||||||
parts: list[str] = []
|
|
||||||
for block in result.contents:
|
|
||||||
if isinstance(block, types.TextResourceContents):
|
|
||||||
parts.append(block.text)
|
|
||||||
elif isinstance(block, types.BlobResourceContents):
|
|
||||||
parts.append(f"[Binary resource: {len(block.blob)} bytes]")
|
|
||||||
else:
|
|
||||||
parts.append(str(block))
|
|
||||||
return "\n".join(parts) or "(no output)"
|
|
||||||
|
|
||||||
|
|
||||||
class MCPPromptWrapper(Tool):
|
|
||||||
"""Wraps an MCP prompt as a read-only nanobot Tool."""
|
|
||||||
|
|
||||||
def __init__(self, session, server_name: str, prompt_def, prompt_timeout: int = 30):
|
|
||||||
self._session = session
|
|
||||||
self._prompt_name = prompt_def.name
|
|
||||||
self._name = f"mcp_{server_name}_prompt_{prompt_def.name}"
|
|
||||||
desc = prompt_def.description or prompt_def.name
|
|
||||||
self._description = (
|
|
||||||
f"[MCP Prompt] {desc}\n"
|
|
||||||
"Returns a filled prompt template that can be used as a workflow guide."
|
|
||||||
)
|
|
||||||
self._prompt_timeout = prompt_timeout
|
|
||||||
|
|
||||||
# Build parameters from prompt arguments
|
|
||||||
properties: dict[str, Any] = {}
|
|
||||||
required: list[str] = []
|
|
||||||
for arg in prompt_def.arguments or []:
|
|
||||||
prop: dict[str, Any] = {"type": "string"}
|
|
||||||
if getattr(arg, "description", None):
|
|
||||||
prop["description"] = arg.description
|
|
||||||
properties[arg.name] = prop
|
|
||||||
if arg.required:
|
|
||||||
required.append(arg.name)
|
|
||||||
self._parameters: dict[str, Any] = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": properties,
|
|
||||||
"required": required,
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
@property
|
|
||||||
def read_only(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def execute(self, **kwargs: Any) -> str:
|
|
||||||
from mcp import types
|
|
||||||
from mcp.shared.exceptions import McpError
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await asyncio.wait_for(
|
|
||||||
self._session.get_prompt(self._prompt_name, arguments=kwargs),
|
|
||||||
timeout=self._prompt_timeout,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("MCP prompt '{}' timed out after {}s", self._name, self._prompt_timeout)
|
|
||||||
return f"(MCP prompt call timed out after {self._prompt_timeout}s)"
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
task = asyncio.current_task()
|
|
||||||
if task is not None and task.cancelling() > 0:
|
|
||||||
raise
|
|
||||||
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
|
||||||
return "(MCP prompt call was cancelled)"
|
|
||||||
except McpError as exc:
|
|
||||||
logger.error(
|
|
||||||
"MCP prompt '{}' failed: code={} message={}",
|
|
||||||
self._name,
|
|
||||||
exc.error.code,
|
|
||||||
exc.error.message,
|
|
||||||
)
|
|
||||||
return f"(MCP prompt call failed: {exc.error.message} [code {exc.error.code}])"
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(
|
|
||||||
"MCP prompt '{}' failed: {}: {}",
|
|
||||||
self._name,
|
|
||||||
type(exc).__name__,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return f"(MCP prompt call failed: {type(exc).__name__})"
|
|
||||||
|
|
||||||
parts: list[str] = []
|
|
||||||
for message in result.messages:
|
|
||||||
content = message.content
|
|
||||||
# content is a single ContentBlock (not a list) in MCP SDK >= 1.x
|
|
||||||
if isinstance(content, types.TextContent):
|
|
||||||
parts.append(content.text)
|
|
||||||
elif isinstance(content, list):
|
|
||||||
for block in content:
|
|
||||||
if isinstance(block, types.TextContent):
|
|
||||||
parts.append(block.text)
|
|
||||||
else:
|
|
||||||
parts.append(str(block))
|
|
||||||
else:
|
|
||||||
parts.append(str(content))
|
|
||||||
return "\n".join(parts) or "(no output)"
|
|
||||||
|
|
||||||
|
|
||||||
async def connect_mcp_servers(
|
async def connect_mcp_servers(
|
||||||
mcp_servers: dict, registry: ToolRegistry
|
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
|
||||||
) -> dict[str, AsyncExitStack]:
|
) -> None:
|
||||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
"""Connect to configured MCP servers and register their tools."""
|
||||||
|
|
||||||
Returns a dict mapping server name -> its dedicated AsyncExitStack.
|
|
||||||
Each server gets its own stack and runs in its own task to prevent
|
|
||||||
cancel scope conflicts when multiple MCP servers are configured.
|
|
||||||
"""
|
|
||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.sse import sse_client
|
from mcp.client.sse import sse_client
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
async def connect_single_server(name: str, cfg) -> tuple[str, AsyncExitStack | None]:
|
for name, cfg in mcp_servers.items():
|
||||||
server_stack = AsyncExitStack()
|
|
||||||
await server_stack.__aenter__()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transport_type = cfg.type
|
transport_type = cfg.type
|
||||||
if not transport_type:
|
if not transport_type:
|
||||||
if cfg.command:
|
if cfg.command:
|
||||||
transport_type = "stdio"
|
transport_type = "stdio"
|
||||||
elif cfg.url:
|
elif cfg.url:
|
||||||
|
# Convention: URLs ending with /sse use SSE transport; others use streamableHttp
|
||||||
transport_type = (
|
transport_type = (
|
||||||
"sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp"
|
"sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||||
await server_stack.aclose()
|
continue
|
||||||
return name, None
|
|
||||||
|
|
||||||
if transport_type == "stdio":
|
if transport_type == "stdio":
|
||||||
params = StdioServerParameters(
|
params = StdioServerParameters(
|
||||||
command=cfg.command, args=cfg.args, env=cfg.env or None
|
command=cfg.command, args=cfg.args, env=cfg.env or None
|
||||||
)
|
)
|
||||||
read, write = await server_stack.enter_async_context(stdio_client(params))
|
read, write = await stack.enter_async_context(stdio_client(params))
|
||||||
elif transport_type == "sse":
|
elif transport_type == "sse":
|
||||||
|
|
||||||
def httpx_client_factory(
|
def httpx_client_factory(
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
timeout: httpx.Timeout | None = None,
|
timeout: httpx.Timeout | None = None,
|
||||||
@@ -358,26 +182,27 @@ async def connect_mcp_servers(
|
|||||||
auth=auth,
|
auth=auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
read, write = await server_stack.enter_async_context(
|
read, write = await stack.enter_async_context(
|
||||||
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
|
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
|
||||||
)
|
)
|
||||||
elif transport_type == "streamableHttp":
|
elif transport_type == "streamableHttp":
|
||||||
http_client = await server_stack.enter_async_context(
|
# 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(
|
httpx.AsyncClient(
|
||||||
headers=cfg.headers or None,
|
headers=cfg.headers or None,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
timeout=None,
|
timeout=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
read, write, _ = await server_stack.enter_async_context(
|
read, write, _ = await stack.enter_async_context(
|
||||||
streamable_http_client(cfg.url, http_client=http_client)
|
streamable_http_client(cfg.url, http_client=http_client)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||||
await server_stack.aclose()
|
continue
|
||||||
return name, None
|
|
||||||
|
|
||||||
session = await server_stack.enter_async_context(ClientSession(read, write))
|
session = await stack.enter_async_context(ClientSession(read, write))
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
|
|
||||||
tools = await session.list_tools()
|
tools = await session.list_tools()
|
||||||
@@ -422,76 +247,6 @@ async def connect_mcp_servers(
|
|||||||
", ".join(available_wrapped_names) or "(none)",
|
", ".join(available_wrapped_names) or "(none)",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
logger.info("MCP server '{}': connected, {} tools registered", name, registered_count)
|
||||||
resources_result = await session.list_resources()
|
|
||||||
for resource in resources_result.resources:
|
|
||||||
wrapper = MCPResourceWrapper(
|
|
||||||
session, name, resource, resource_timeout=cfg.tool_timeout
|
|
||||||
)
|
|
||||||
registry.register(wrapper)
|
|
||||||
registered_count += 1
|
|
||||||
logger.debug(
|
|
||||||
"MCP: registered resource '{}' from server '{}'", wrapper.name, name
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("MCP server '{}': resources not supported or failed: {}", name, e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
prompts_result = await session.list_prompts()
|
|
||||||
for prompt in prompts_result.prompts:
|
|
||||||
wrapper = MCPPromptWrapper(
|
|
||||||
session, name, prompt, prompt_timeout=cfg.tool_timeout
|
|
||||||
)
|
|
||||||
registry.register(wrapper)
|
|
||||||
registered_count += 1
|
|
||||||
logger.debug("MCP: registered prompt '{}' from server '{}'", wrapper.name, name)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("MCP server '{}': prompts not supported or failed: {}", name, e)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
|
||||||
)
|
|
||||||
return name, server_stack
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
hint = ""
|
logger.error("MCP server '{}': failed to connect: {}", name, e)
|
||||||
text = str(e).lower()
|
|
||||||
if any(
|
|
||||||
marker in text
|
|
||||||
for marker in (
|
|
||||||
"parse error",
|
|
||||||
"invalid json",
|
|
||||||
"unexpected token",
|
|
||||||
"jsonrpc",
|
|
||||||
"content-length",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
hint = (
|
|
||||||
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
|
|
||||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
|
||||||
)
|
|
||||||
logger.error("MCP server '{}': failed to connect: {}{}", name, e, hint)
|
|
||||||
try:
|
|
||||||
await server_stack.aclose()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return name, None
|
|
||||||
|
|
||||||
server_stacks: dict[str, AsyncExitStack] = {}
|
|
||||||
|
|
||||||
tasks: list[asyncio.Task] = []
|
|
||||||
for name, cfg in mcp_servers.items():
|
|
||||||
task = asyncio.create_task(connect_single_server(name, cfg))
|
|
||||||
tasks.append(task)
|
|
||||||
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
for i, result in enumerate(results):
|
|
||||||
name = list(mcp_servers.keys())[i]
|
|
||||||
if isinstance(result, BaseException):
|
|
||||||
if not isinstance(result, asyncio.CancelledError):
|
|
||||||
logger.error("MCP server '{}' connection task failed: {}", name, result)
|
|
||||||
elif result is not None and result[1] is not None:
|
|
||||||
server_stacks[result[0]] = result[1]
|
|
||||||
|
|
||||||
return server_stacks
|
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
"""NotebookEditTool — edit Jupyter .ipynb notebooks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.agent.tools.base import tool_parameters
|
|
||||||
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
|
|
||||||
from nanobot.agent.tools.filesystem import _FsTool
|
|
||||||
|
|
||||||
|
|
||||||
def _new_cell(source: str, cell_type: str = "code", generate_id: bool = False) -> dict:
|
|
||||||
cell: dict[str, Any] = {
|
|
||||||
"cell_type": cell_type,
|
|
||||||
"source": source,
|
|
||||||
"metadata": {},
|
|
||||||
}
|
|
||||||
if cell_type == "code":
|
|
||||||
cell["outputs"] = []
|
|
||||||
cell["execution_count"] = None
|
|
||||||
if generate_id:
|
|
||||||
cell["id"] = uuid.uuid4().hex[:8]
|
|
||||||
return cell
|
|
||||||
|
|
||||||
|
|
||||||
def _make_empty_notebook() -> dict:
|
|
||||||
return {
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5,
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
|
||||||
"language_info": {"name": "python"},
|
|
||||||
},
|
|
||||||
"cells": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
|
||||||
tool_parameters_schema(
|
|
||||||
path=StringSchema("Path to the .ipynb notebook file"),
|
|
||||||
cell_index=IntegerSchema(0, description="0-based index of the cell to edit", minimum=0),
|
|
||||||
new_source=StringSchema("New source content for the cell"),
|
|
||||||
cell_type=StringSchema(
|
|
||||||
"Cell type: 'code' or 'markdown' (default: code)",
|
|
||||||
enum=["code", "markdown"],
|
|
||||||
),
|
|
||||||
edit_mode=StringSchema(
|
|
||||||
"Mode: 'replace' (default), 'insert' (after target), or 'delete'",
|
|
||||||
enum=["replace", "insert", "delete"],
|
|
||||||
),
|
|
||||||
required=["path", "cell_index"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
class NotebookEditTool(_FsTool):
|
|
||||||
"""Edit Jupyter notebook cells: replace, insert, or delete."""
|
|
||||||
|
|
||||||
_VALID_CELL_TYPES = frozenset({"code", "markdown"})
|
|
||||||
_VALID_EDIT_MODES = frozenset({"replace", "insert", "delete"})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "notebook_edit"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return (
|
|
||||||
"Edit a Jupyter notebook (.ipynb) cell. "
|
|
||||||
"Modes: replace (default) replaces cell content, "
|
|
||||||
"insert adds a new cell after the target index, "
|
|
||||||
"delete removes the cell at the index. "
|
|
||||||
"cell_index is 0-based."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute(
|
|
||||||
self,
|
|
||||||
path: str | None = None,
|
|
||||||
cell_index: int = 0,
|
|
||||||
new_source: str = "",
|
|
||||||
cell_type: str = "code",
|
|
||||||
edit_mode: str = "replace",
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
|
||||||
try:
|
|
||||||
if not path:
|
|
||||||
return "Error: path is required"
|
|
||||||
|
|
||||||
if not path.endswith(".ipynb"):
|
|
||||||
return "Error: notebook_edit only works on .ipynb files. Use edit_file for other files."
|
|
||||||
|
|
||||||
if edit_mode not in self._VALID_EDIT_MODES:
|
|
||||||
return (
|
|
||||||
f"Error: Invalid edit_mode '{edit_mode}'. "
|
|
||||||
"Use one of: replace, insert, delete."
|
|
||||||
)
|
|
||||||
|
|
||||||
if cell_type not in self._VALID_CELL_TYPES:
|
|
||||||
return (
|
|
||||||
f"Error: Invalid cell_type '{cell_type}'. "
|
|
||||||
"Use one of: code, markdown."
|
|
||||||
)
|
|
||||||
|
|
||||||
fp = self._resolve(path)
|
|
||||||
|
|
||||||
# Create new notebook if file doesn't exist and mode is insert
|
|
||||||
if not fp.exists():
|
|
||||||
if edit_mode != "insert":
|
|
||||||
return f"Error: File not found: {path}"
|
|
||||||
nb = _make_empty_notebook()
|
|
||||||
cell = _new_cell(new_source, cell_type, generate_id=True)
|
|
||||||
nb["cells"].append(cell)
|
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully created {fp} with 1 cell"
|
|
||||||
|
|
||||||
try:
|
|
||||||
nb = json.loads(fp.read_text(encoding="utf-8"))
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
||||||
return f"Error: Failed to parse notebook: {e}"
|
|
||||||
|
|
||||||
cells = nb.get("cells", [])
|
|
||||||
nbformat_minor = nb.get("nbformat_minor", 0)
|
|
||||||
generate_id = nb.get("nbformat", 0) >= 4 and nbformat_minor >= 5
|
|
||||||
|
|
||||||
if edit_mode == "delete":
|
|
||||||
if cell_index < 0 or cell_index >= len(cells):
|
|
||||||
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
|
|
||||||
cells.pop(cell_index)
|
|
||||||
nb["cells"] = cells
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully deleted cell {cell_index} from {fp}"
|
|
||||||
|
|
||||||
if edit_mode == "insert":
|
|
||||||
insert_at = min(cell_index + 1, len(cells))
|
|
||||||
cell = _new_cell(new_source, cell_type, generate_id=generate_id)
|
|
||||||
cells.insert(insert_at, cell)
|
|
||||||
nb["cells"] = cells
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully inserted cell at index {insert_at} in {fp}"
|
|
||||||
|
|
||||||
# Default: replace
|
|
||||||
if cell_index < 0 or cell_index >= len(cells):
|
|
||||||
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
|
|
||||||
cells[cell_index]["source"] = new_source
|
|
||||||
if cell_type and cells[cell_index].get("cell_type") != cell_type:
|
|
||||||
cells[cell_index]["cell_type"] = cell_type
|
|
||||||
if cell_type == "code":
|
|
||||||
cells[cell_index].setdefault("outputs", [])
|
|
||||||
cells[cell_index].setdefault("execution_count", None)
|
|
||||||
elif "outputs" in cells[cell_index]:
|
|
||||||
del cells[cell_index]["outputs"]
|
|
||||||
cells[cell_index].pop("execution_count", None)
|
|
||||||
nb["cells"] = cells
|
|
||||||
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return f"Successfully edited cell {cell_index} in {fp}"
|
|
||||||
|
|
||||||
except PermissionError as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error editing notebook: {e}"
|
|
||||||
@@ -68,13 +68,6 @@ class ToolRegistry:
|
|||||||
params: dict[str, Any],
|
params: dict[str, Any],
|
||||||
) -> tuple[Tool | None, dict[str, Any], str | None]:
|
) -> tuple[Tool | None, dict[str, Any], str | None]:
|
||||||
"""Resolve, cast, and validate one tool call."""
|
"""Resolve, cast, and validate one tool call."""
|
||||||
# Guard against invalid parameter types (e.g., list instead of dict)
|
|
||||||
if not isinstance(params, dict) and name in ('write_file', 'read_file'):
|
|
||||||
return None, params, (
|
|
||||||
f"Error: Tool '{name}' parameters must be a JSON object, got {type(params).__name__}. "
|
|
||||||
"Use named parameters: tool_name(param1=\"value1\", param2=\"value2\")"
|
|
||||||
)
|
|
||||||
|
|
||||||
tool = self._tools.get(name)
|
tool = self._tools.get(name)
|
||||||
if not tool:
|
if not tool:
|
||||||
return None, params, (
|
return None, params, (
|
||||||
|
|||||||
@@ -142,9 +142,8 @@ class GlobTool(_SearchTool):
|
|||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Find files matching a glob pattern (e.g. '*.py', 'tests/**/test_*.py'). "
|
"Find files matching a glob pattern. "
|
||||||
"Results are sorted by modification time (newest first). "
|
"Simple patterns like '*.py' match by filename recursively."
|
||||||
"Skips .git, node_modules, __pycache__, and other noise directories."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -262,10 +261,9 @@ class GrepTool(_SearchTool):
|
|||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Search file contents with a regex pattern. "
|
"Search file contents with a regex-like pattern. "
|
||||||
"Default output_mode is files_with_matches (file paths only); "
|
"Supports optional glob filtering, structured output modes, "
|
||||||
"use content mode for matching lines with context. "
|
"type filters, pagination, and surrounding context lines."
|
||||||
"Skips binary and files >2 MB. Supports glob/type filtering."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ class ExecTool(Tool):
|
|||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
sandbox: str = "",
|
sandbox: str = "",
|
||||||
path_append: str = "",
|
path_append: str = "",
|
||||||
allowed_env_keys: list[str] | None = None,
|
|
||||||
):
|
):
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.working_dir = working_dir
|
self.working_dir = working_dir
|
||||||
@@ -61,19 +60,10 @@ class ExecTool(Tool):
|
|||||||
r">\s*/dev/sd", # write to disk
|
r">\s*/dev/sd", # write to disk
|
||||||
r"\b(shutdown|reboot|poweroff)\b", # system power
|
r"\b(shutdown|reboot|poweroff)\b", # system power
|
||||||
r":\(\)\s*\{.*\};\s*:", # fork bomb
|
r":\(\)\s*\{.*\};\s*:", # fork bomb
|
||||||
# Block writes to nanobot internal state files (#2989).
|
|
||||||
# history.jsonl / .dream_cursor are managed by append_history();
|
|
||||||
# direct writes corrupt the cursor format and crash /dream.
|
|
||||||
r">>?\s*\S*(?:history\.jsonl|\.dream_cursor)", # > / >> redirect
|
|
||||||
r"\btee\b[^|;&<>]*(?:history\.jsonl|\.dream_cursor)", # tee / tee -a
|
|
||||||
r"\b(?:cp|mv)\b(?:\s+[^\s|;&<>]+)+\s+\S*(?:history\.jsonl|\.dream_cursor)", # cp/mv target
|
|
||||||
r"\bdd\b[^|;&<>]*\bof=\S*(?:history\.jsonl|\.dream_cursor)", # dd of=
|
|
||||||
r"\bsed\s+-i[^|;&<>]*(?:history\.jsonl|\.dream_cursor)", # sed -i
|
|
||||||
]
|
]
|
||||||
self.allow_patterns = allow_patterns or []
|
self.allow_patterns = allow_patterns or []
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self.path_append = path_append
|
self.path_append = path_append
|
||||||
self.allowed_env_keys = allowed_env_keys or []
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -84,13 +74,7 @@ class ExecTool(Tool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return "Execute a shell command and return its output. Use with caution."
|
||||||
"Execute a shell command and return its output. "
|
|
||||||
"Prefer read_file/write_file/edit_file over cat/echo/sed, "
|
|
||||||
"and grep/glob over shell find/grep. "
|
|
||||||
"Use -y or --yes flags to avoid interactive prompts. "
|
|
||||||
"Output is truncated at 10 000 chars; timeout defaults to 60s."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def exclusive(self) -> bool:
|
def exclusive(self) -> bool:
|
||||||
@@ -101,21 +85,6 @@ class ExecTool(Tool):
|
|||||||
timeout: int | None = None, **kwargs: Any,
|
timeout: int | None = None, **kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
cwd = working_dir or self.working_dir or os.getcwd()
|
cwd = working_dir or self.working_dir or os.getcwd()
|
||||||
|
|
||||||
# Prevent an LLM-supplied working_dir from escaping the configured
|
|
||||||
# workspace when restrict_to_workspace is enabled (#2826). Without
|
|
||||||
# this, a caller can pass working_dir="/etc" and then all absolute
|
|
||||||
# paths under /etc would pass the _guard_command check that anchors
|
|
||||||
# on cwd.
|
|
||||||
if self.restrict_to_workspace and self.working_dir:
|
|
||||||
try:
|
|
||||||
requested = Path(cwd).expanduser().resolve()
|
|
||||||
workspace_root = Path(self.working_dir).expanduser().resolve()
|
|
||||||
except Exception:
|
|
||||||
return "Error: working_dir could not be resolved"
|
|
||||||
if requested != workspace_root and workspace_root not in requested.parents:
|
|
||||||
return "Error: working_dir is outside the configured workspace"
|
|
||||||
|
|
||||||
guard_error = self._guard_command(command, cwd)
|
guard_error = self._guard_command(command, cwd)
|
||||||
if guard_error:
|
if guard_error:
|
||||||
return guard_error
|
return guard_error
|
||||||
@@ -233,7 +202,7 @@ class ExecTool(Tool):
|
|||||||
"""
|
"""
|
||||||
if _IS_WINDOWS:
|
if _IS_WINDOWS:
|
||||||
sr = os.environ.get("SYSTEMROOT", r"C:\Windows")
|
sr = os.environ.get("SYSTEMROOT", r"C:\Windows")
|
||||||
env = {
|
return {
|
||||||
"SYSTEMROOT": sr,
|
"SYSTEMROOT": sr,
|
||||||
"COMSPEC": os.environ.get("COMSPEC", f"{sr}\\system32\\cmd.exe"),
|
"COMSPEC": os.environ.get("COMSPEC", f"{sr}\\system32\\cmd.exe"),
|
||||||
"USERPROFILE": os.environ.get("USERPROFILE", ""),
|
"USERPROFILE": os.environ.get("USERPROFILE", ""),
|
||||||
@@ -243,29 +212,13 @@ class ExecTool(Tool):
|
|||||||
"TMP": os.environ.get("TMP", f"{sr}\\Temp"),
|
"TMP": os.environ.get("TMP", f"{sr}\\Temp"),
|
||||||
"PATHEXT": os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"),
|
"PATHEXT": os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"),
|
||||||
"PATH": os.environ.get("PATH", f"{sr}\\system32;{sr}"),
|
"PATH": os.environ.get("PATH", f"{sr}\\system32;{sr}"),
|
||||||
"APPDATA": os.environ.get("APPDATA", ""),
|
|
||||||
"LOCALAPPDATA": os.environ.get("LOCALAPPDATA", ""),
|
|
||||||
"ProgramData": os.environ.get("ProgramData", ""),
|
|
||||||
"ProgramFiles": os.environ.get("ProgramFiles", ""),
|
|
||||||
"ProgramFiles(x86)": os.environ.get("ProgramFiles(x86)", ""),
|
|
||||||
"ProgramW6432": os.environ.get("ProgramW6432", ""),
|
|
||||||
}
|
}
|
||||||
for key in self.allowed_env_keys:
|
|
||||||
val = os.environ.get(key)
|
|
||||||
if val is not None:
|
|
||||||
env[key] = val
|
|
||||||
return env
|
|
||||||
home = os.environ.get("HOME", "/tmp")
|
home = os.environ.get("HOME", "/tmp")
|
||||||
env = {
|
return {
|
||||||
"HOME": home,
|
"HOME": home,
|
||||||
"LANG": os.environ.get("LANG", "C.UTF-8"),
|
"LANG": os.environ.get("LANG", "C.UTF-8"),
|
||||||
"TERM": os.environ.get("TERM", "dumb"),
|
"TERM": os.environ.get("TERM", "dumb"),
|
||||||
}
|
}
|
||||||
for key in self.allowed_env_keys:
|
|
||||||
val = os.environ.get(key)
|
|
||||||
if val is not None:
|
|
||||||
env[key] = val
|
|
||||||
return env
|
|
||||||
|
|
||||||
def _guard_command(self, command: str, cwd: str) -> str | None:
|
def _guard_command(self, command: str, cwd: str) -> str | None:
|
||||||
"""Best-effort safety guard for potentially destructive commands."""
|
"""Best-effort safety guard for potentially destructive commands."""
|
||||||
|
|||||||
@@ -84,11 +84,7 @@ class WebSearchTool(Tool):
|
|||||||
"""Search the web using configured provider."""
|
"""Search the web using configured provider."""
|
||||||
|
|
||||||
name = "web_search"
|
name = "web_search"
|
||||||
description = (
|
description = "Search the web. Returns titles, URLs, and snippets."
|
||||||
"Search the web. Returns titles, URLs, and snippets. "
|
|
||||||
"count defaults to 5 (max 10). "
|
|
||||||
"Use web_fetch to read a specific page in full."
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, config: WebSearchConfig | None = None, proxy: str | None = None):
|
def __init__(self, config: WebSearchConfig | None = None, proxy: str | None = None):
|
||||||
from nanobot.config.schema import WebSearchConfig
|
from nanobot.config.schema import WebSearchConfig
|
||||||
@@ -114,8 +110,6 @@ class WebSearchTool(Tool):
|
|||||||
return await self._search_jina(query, n)
|
return await self._search_jina(query, n)
|
||||||
elif provider == "brave":
|
elif provider == "brave":
|
||||||
return await self._search_brave(query, n)
|
return await self._search_brave(query, n)
|
||||||
elif provider == "kagi":
|
|
||||||
return await self._search_kagi(query, n)
|
|
||||||
else:
|
else:
|
||||||
return f"Error: unknown search provider '{provider}'"
|
return f"Error: unknown search provider '{provider}'"
|
||||||
|
|
||||||
@@ -206,29 +200,6 @@ class WebSearchTool(Tool):
|
|||||||
logger.warning("Jina search failed ({}), falling back to DuckDuckGo", e)
|
logger.warning("Jina search failed ({}), falling back to DuckDuckGo", e)
|
||||||
return await self._search_duckduckgo(query, n)
|
return await self._search_duckduckgo(query, n)
|
||||||
|
|
||||||
async def _search_kagi(self, query: str, n: int) -> str:
|
|
||||||
api_key = self.config.api_key or os.environ.get("KAGI_API_KEY", "")
|
|
||||||
if not api_key:
|
|
||||||
logger.warning("KAGI_API_KEY not set, falling back to DuckDuckGo")
|
|
||||||
return await self._search_duckduckgo(query, n)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(proxy=self.proxy) as client:
|
|
||||||
r = await client.get(
|
|
||||||
"https://kagi.com/api/v0/search",
|
|
||||||
params={"q": query, "limit": n},
|
|
||||||
headers={"Authorization": f"Bot {api_key}"},
|
|
||||||
timeout=10.0,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
# t=0 items are search results; other values are related searches, etc.
|
|
||||||
items = [
|
|
||||||
{"title": d.get("title", ""), "url": d.get("url", ""), "content": d.get("snippet", "")}
|
|
||||||
for d in r.json().get("data", []) if d.get("t") == 0
|
|
||||||
]
|
|
||||||
return _format_results(query, items, n)
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
async def _search_duckduckgo(self, query: str, n: int) -> str:
|
async def _search_duckduckgo(self, query: str, n: int) -> str:
|
||||||
try:
|
try:
|
||||||
# Note: duckduckgo_search is synchronous and does its own requests
|
# Note: duckduckgo_search is synchronous and does its own requests
|
||||||
@@ -268,11 +239,7 @@ class WebFetchTool(Tool):
|
|||||||
"""Fetch and extract content from a URL."""
|
"""Fetch and extract content from a URL."""
|
||||||
|
|
||||||
name = "web_fetch"
|
name = "web_fetch"
|
||||||
description = (
|
description = "Fetch URL and extract readable content (HTML → markdown/text)."
|
||||||
"Fetch a URL and extract readable content (HTML → markdown/text). "
|
|
||||||
"Output is capped at maxChars (default 50 000). "
|
|
||||||
"Works for most web pages and docs; may fail on login-walled or JS-heavy sites."
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, max_chars: int = 50000, proxy: str | None = None):
|
def __init__(self, max_chars: int = 50000, proxy: str | None = None):
|
||||||
self.max_chars = max_chars
|
self.max_chars = max_chars
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import json
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import zipfile
|
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
@@ -173,7 +171,6 @@ class DingTalkChannel(BaseChannel):
|
|||||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
|
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
|
||||||
_AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"}
|
_AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"}
|
||||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
|
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
|
||||||
_ZIP_BEFORE_UPLOAD_EXTS = {".htm", ".html"}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def default_config(cls) -> dict[str, Any]:
|
def default_config(cls) -> dict[str, Any]:
|
||||||
@@ -290,31 +287,6 @@ class DingTalkChannel(BaseChannel):
|
|||||||
name = os.path.basename(urlparse(media_ref).path)
|
name = os.path.basename(urlparse(media_ref).path)
|
||||||
return name or {"image": "image.jpg", "voice": "audio.amr", "video": "video.mp4"}.get(upload_type, "file.bin")
|
return name or {"image": "image.jpg", "voice": "audio.amr", "video": "video.mp4"}.get(upload_type, "file.bin")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _zip_bytes(filename: str, data: bytes) -> tuple[bytes, str, str]:
|
|
||||||
stem = Path(filename).stem or "attachment"
|
|
||||||
safe_name = filename or "attachment.bin"
|
|
||||||
zip_name = f"{stem}.zip"
|
|
||||||
buffer = BytesIO()
|
|
||||||
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
||||||
archive.writestr(safe_name, data)
|
|
||||||
return buffer.getvalue(), zip_name, "application/zip"
|
|
||||||
|
|
||||||
def _normalize_upload_payload(
|
|
||||||
self,
|
|
||||||
filename: str,
|
|
||||||
data: bytes,
|
|
||||||
content_type: str | None,
|
|
||||||
) -> tuple[bytes, str, str | None]:
|
|
||||||
ext = Path(filename).suffix.lower()
|
|
||||||
if ext in self._ZIP_BEFORE_UPLOAD_EXTS or content_type == "text/html":
|
|
||||||
logger.info(
|
|
||||||
"DingTalk does not accept raw HTML attachments, zipping {} before upload",
|
|
||||||
filename,
|
|
||||||
)
|
|
||||||
return self._zip_bytes(filename, data)
|
|
||||||
return data, filename, content_type
|
|
||||||
|
|
||||||
async def _read_media_bytes(
|
async def _read_media_bytes(
|
||||||
self,
|
self,
|
||||||
media_ref: str,
|
media_ref: str,
|
||||||
@@ -337,9 +309,6 @@ class DingTalkChannel(BaseChannel):
|
|||||||
content_type = (resp.headers.get("content-type") or "").split(";")[0].strip()
|
content_type = (resp.headers.get("content-type") or "").split(";")[0].strip()
|
||||||
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
||||||
return resp.content, filename, content_type or None
|
return resp.content, filename, content_type or None
|
||||||
except httpx.TransportError as e:
|
|
||||||
logger.error("DingTalk media download network error ref={} err={}", media_ref, e)
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("DingTalk media download error ref={} err={}", media_ref, e)
|
logger.error("DingTalk media download error ref={} err={}", media_ref, e)
|
||||||
return None, None, None
|
return None, None, None
|
||||||
@@ -391,9 +360,6 @@ class DingTalkChannel(BaseChannel):
|
|||||||
logger.error("DingTalk media upload missing media_id body={}", text[:500])
|
logger.error("DingTalk media upload missing media_id body={}", text[:500])
|
||||||
return None
|
return None
|
||||||
return str(media_id)
|
return str(media_id)
|
||||||
except httpx.TransportError as e:
|
|
||||||
logger.error("DingTalk media upload network error type={} err={}", media_type, e)
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("DingTalk media upload error type={} err={}", media_type, e)
|
logger.error("DingTalk media upload error type={} err={}", media_type, e)
|
||||||
return None
|
return None
|
||||||
@@ -443,9 +409,6 @@ class DingTalkChannel(BaseChannel):
|
|||||||
return False
|
return False
|
||||||
logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key)
|
logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key)
|
||||||
return True
|
return True
|
||||||
except httpx.TransportError as e:
|
|
||||||
logger.error("DingTalk network error sending message msgKey={} err={}", msg_key, e)
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e)
|
logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e)
|
||||||
return False
|
return False
|
||||||
@@ -481,7 +444,6 @@ class DingTalkChannel(BaseChannel):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
filename = filename or self._guess_filename(media_ref, upload_type)
|
filename = filename or self._guess_filename(media_ref, upload_type)
|
||||||
data, filename, content_type = self._normalize_upload_payload(filename, data, content_type)
|
|
||||||
file_type = Path(filename).suffix.lower().lstrip(".")
|
file_type = Path(filename).suffix.lower().lstrip(".")
|
||||||
if not file_type:
|
if not file_type:
|
||||||
guessed = mimetypes.guess_extension(content_type or "")
|
guessed = mimetypes.guess_extension(content_type or "")
|
||||||
|
|||||||
+7
-165
@@ -4,8 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
@@ -22,7 +20,6 @@ from nanobot.utils.helpers import safe_filename, split_message
|
|||||||
|
|
||||||
DISCORD_AVAILABLE = importlib.util.find_spec("discord") is not None
|
DISCORD_AVAILABLE = importlib.util.find_spec("discord") is not None
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import aiohttp
|
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
from discord.abc import Messageable
|
from discord.abc import Messageable
|
||||||
@@ -37,16 +34,6 @@ MAX_MESSAGE_LEN = 2000 # Discord message character limit
|
|||||||
TYPING_INTERVAL_S = 8
|
TYPING_INTERVAL_S = 8
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _StreamBuf:
|
|
||||||
"""Per-chat streaming accumulator for progressive Discord message edits."""
|
|
||||||
|
|
||||||
text: str = ""
|
|
||||||
message: Any | None = None
|
|
||||||
last_edit: float = 0.0
|
|
||||||
stream_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class DiscordConfig(Base):
|
class DiscordConfig(Base):
|
||||||
"""Discord channel configuration."""
|
"""Discord channel configuration."""
|
||||||
|
|
||||||
@@ -58,10 +45,6 @@ class DiscordConfig(Base):
|
|||||||
read_receipt_emoji: str = "👀"
|
read_receipt_emoji: str = "👀"
|
||||||
working_emoji: str = "🔧"
|
working_emoji: str = "🔧"
|
||||||
working_emoji_delay: float = 2.0
|
working_emoji_delay: float = 2.0
|
||||||
streaming: bool = True
|
|
||||||
proxy: str | None = None
|
|
||||||
proxy_username: str | None = None
|
|
||||||
proxy_password: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
if DISCORD_AVAILABLE:
|
if DISCORD_AVAILABLE:
|
||||||
@@ -69,15 +52,8 @@ if DISCORD_AVAILABLE:
|
|||||||
class DiscordBotClient(discord.Client):
|
class DiscordBotClient(discord.Client):
|
||||||
"""discord.py client that forwards events to the channel."""
|
"""discord.py client that forwards events to the channel."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, channel: DiscordChannel, *, intents: discord.Intents) -> None:
|
||||||
self,
|
super().__init__(intents=intents)
|
||||||
channel: DiscordChannel,
|
|
||||||
*,
|
|
||||||
intents: discord.Intents,
|
|
||||||
proxy: str | None = None,
|
|
||||||
proxy_auth: aiohttp.BasicAuth | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(intents=intents, proxy=proxy, proxy_auth=proxy_auth)
|
|
||||||
self._channel = channel
|
self._channel = channel
|
||||||
self.tree = app_commands.CommandTree(self)
|
self.tree = app_commands.CommandTree(self)
|
||||||
self._register_app_commands()
|
self._register_app_commands()
|
||||||
@@ -141,7 +117,6 @@ if DISCORD_AVAILABLE:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for name, description, command_text in commands:
|
for name, description, command_text in commands:
|
||||||
|
|
||||||
@self.tree.command(name=name, description=description)
|
@self.tree.command(name=name, description=description)
|
||||||
async def command_handler(
|
async def command_handler(
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
@@ -198,9 +173,7 @@ if DISCORD_AVAILABLE:
|
|||||||
else:
|
else:
|
||||||
failed_media.append(Path(media_path).name)
|
failed_media.append(Path(media_path).name)
|
||||||
|
|
||||||
for index, chunk in enumerate(
|
for index, chunk in enumerate(self._build_chunks(msg.content or "", failed_media, sent_media)):
|
||||||
self._build_chunks(msg.content or "", failed_media, sent_media)
|
|
||||||
):
|
|
||||||
kwargs: dict[str, Any] = {"content": chunk}
|
kwargs: dict[str, Any] = {"content": chunk}
|
||||||
if index == 0 and reference is not None and not sent_media:
|
if index == 0 and reference is not None and not sent_media:
|
||||||
kwargs["reference"] = reference
|
kwargs["reference"] = reference
|
||||||
@@ -269,7 +242,6 @@ class DiscordChannel(BaseChannel):
|
|||||||
|
|
||||||
name = "discord"
|
name = "discord"
|
||||||
display_name = "Discord"
|
display_name = "Discord"
|
||||||
_STREAM_EDIT_INTERVAL = 0.8
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def default_config(cls) -> dict[str, Any]:
|
def default_config(cls) -> dict[str, Any]:
|
||||||
@@ -291,7 +263,6 @@ class DiscordChannel(BaseChannel):
|
|||||||
self._bot_user_id: str | None = None
|
self._bot_user_id: str | None = None
|
||||||
self._pending_reactions: dict[str, Any] = {} # chat_id -> message object
|
self._pending_reactions: dict[str, Any] = {} # chat_id -> message object
|
||||||
self._working_emoji_tasks: dict[str, asyncio.Task[None]] = {}
|
self._working_emoji_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._stream_bufs: dict[str, _StreamBuf] = {}
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Discord client."""
|
"""Start the Discord client."""
|
||||||
@@ -306,29 +277,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
intents = discord.Intents.none()
|
intents = discord.Intents.none()
|
||||||
intents.value = self.config.intents
|
intents.value = self.config.intents
|
||||||
|
self._client = DiscordBotClient(self, intents=intents)
|
||||||
proxy_auth = None
|
|
||||||
has_user = bool(self.config.proxy_username)
|
|
||||||
has_pass = bool(self.config.proxy_password)
|
|
||||||
if has_user and has_pass:
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
proxy_auth = aiohttp.BasicAuth(
|
|
||||||
login=self.config.proxy_username,
|
|
||||||
password=self.config.proxy_password,
|
|
||||||
)
|
|
||||||
elif has_user != has_pass:
|
|
||||||
logger.warning(
|
|
||||||
"Discord proxy auth incomplete: both proxy_username and "
|
|
||||||
"proxy_password must be set; ignoring partial credentials",
|
|
||||||
)
|
|
||||||
|
|
||||||
self._client = DiscordBotClient(
|
|
||||||
self,
|
|
||||||
intents=intents,
|
|
||||||
proxy=self.config.proxy,
|
|
||||||
proxy_auth=proxy_auth,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to initialize Discord client: {}", e)
|
logger.error("Failed to initialize Discord client: {}", e)
|
||||||
self._client = None
|
self._client = None
|
||||||
@@ -366,71 +315,11 @@ class DiscordChannel(BaseChannel):
|
|||||||
await client.send_outbound(msg)
|
await client.send_outbound(msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error sending Discord message: {}", e)
|
logger.error("Error sending Discord message: {}", e)
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
if not is_progress:
|
if not is_progress:
|
||||||
await self._stop_typing(msg.chat_id)
|
await self._stop_typing(msg.chat_id)
|
||||||
await self._clear_reactions(msg.chat_id)
|
await self._clear_reactions(msg.chat_id)
|
||||||
|
|
||||||
async def send_delta(
|
|
||||||
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Progressive Discord delivery: send once, then edit until the stream ends."""
|
|
||||||
client = self._client
|
|
||||||
if client is None or not client.is_ready():
|
|
||||||
logger.warning("Discord client not ready; dropping stream delta")
|
|
||||||
return
|
|
||||||
|
|
||||||
meta = metadata or {}
|
|
||||||
stream_id = meta.get("_stream_id")
|
|
||||||
|
|
||||||
if meta.get("_stream_end"):
|
|
||||||
buf = self._stream_bufs.get(chat_id)
|
|
||||||
if not buf or buf.message is None or not buf.text:
|
|
||||||
return
|
|
||||||
if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id:
|
|
||||||
return
|
|
||||||
await self._finalize_stream(chat_id, buf)
|
|
||||||
return
|
|
||||||
|
|
||||||
buf = self._stream_bufs.get(chat_id)
|
|
||||||
if buf is None or (
|
|
||||||
stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id
|
|
||||||
):
|
|
||||||
buf = _StreamBuf(stream_id=stream_id)
|
|
||||||
self._stream_bufs[chat_id] = buf
|
|
||||||
elif buf.stream_id is None:
|
|
||||||
buf.stream_id = stream_id
|
|
||||||
|
|
||||||
buf.text += delta
|
|
||||||
if not buf.text.strip():
|
|
||||||
return
|
|
||||||
|
|
||||||
target = await self._resolve_channel(chat_id)
|
|
||||||
if target is None:
|
|
||||||
logger.warning("Discord stream target {} unavailable", chat_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
if buf.message is None:
|
|
||||||
try:
|
|
||||||
buf.message = await target.send(content=buf.text)
|
|
||||||
buf.last_edit = now
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Discord stream initial send failed: {}", e)
|
|
||||||
raise
|
|
||||||
return
|
|
||||||
|
|
||||||
if (now - buf.last_edit) < self._STREAM_EDIT_INTERVAL:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0])
|
|
||||||
buf.last_edit = now
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Discord stream edit failed: {}", e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _handle_discord_message(self, message: discord.Message) -> None:
|
async def _handle_discord_message(self, message: discord.Message) -> None:
|
||||||
"""Handle incoming Discord messages from discord.py."""
|
"""Handle incoming Discord messages from discord.py."""
|
||||||
if message.author.bot:
|
if message.author.bot:
|
||||||
@@ -484,47 +373,6 @@ class DiscordChannel(BaseChannel):
|
|||||||
"""Backward-compatible alias for legacy tests/callers."""
|
"""Backward-compatible alias for legacy tests/callers."""
|
||||||
await self._handle_discord_message(message)
|
await self._handle_discord_message(message)
|
||||||
|
|
||||||
async def _resolve_channel(self, chat_id: str) -> Any | None:
|
|
||||||
"""Resolve a Discord channel from cache first, then network fetch."""
|
|
||||||
client = self._client
|
|
||||||
if client is None or not client.is_ready():
|
|
||||||
return None
|
|
||||||
channel_id = int(chat_id)
|
|
||||||
channel = client.get_channel(channel_id)
|
|
||||||
if channel is not None:
|
|
||||||
return channel
|
|
||||||
try:
|
|
||||||
return await client.fetch_channel(channel_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Discord channel {} unavailable: {}", chat_id, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None:
|
|
||||||
"""Commit the final streamed content and flush overflow chunks."""
|
|
||||||
chunks = DiscordBotClient._build_chunks(buf.text, [], False)
|
|
||||||
if not chunks:
|
|
||||||
self._stream_bufs.pop(chat_id, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
await buf.message.edit(content=chunks[0])
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Discord final stream edit failed: {}", e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id)
|
|
||||||
if target is None:
|
|
||||||
logger.warning("Discord stream follow-up target {} unavailable", chat_id)
|
|
||||||
self._stream_bufs.pop(chat_id, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
for extra_chunk in chunks[1:]:
|
|
||||||
await target.send(content=extra_chunk)
|
|
||||||
|
|
||||||
self._stream_bufs.pop(chat_id, None)
|
|
||||||
await self._stop_typing(chat_id)
|
|
||||||
await self._clear_reactions(chat_id)
|
|
||||||
|
|
||||||
def _should_accept_inbound(
|
def _should_accept_inbound(
|
||||||
self,
|
self,
|
||||||
message: discord.Message,
|
message: discord.Message,
|
||||||
@@ -575,11 +423,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_inbound_metadata(message: discord.Message) -> dict[str, str | None]:
|
def _build_inbound_metadata(message: discord.Message) -> dict[str, str | None]:
|
||||||
"""Build metadata for inbound Discord messages."""
|
"""Build metadata for inbound Discord messages."""
|
||||||
reply_to = (
|
reply_to = str(message.reference.message_id) if message.reference and message.reference.message_id else None
|
||||||
str(message.reference.message_id)
|
|
||||||
if message.reference and message.reference.message_id
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"message_id": str(message.id),
|
"message_id": str(message.id),
|
||||||
"guild_id": str(message.guild.id) if message.guild else None,
|
"guild_id": str(message.guild.id) if message.guild else None,
|
||||||
@@ -594,9 +438,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
if self.config.group_policy == "mention":
|
if self.config.group_policy == "mention":
|
||||||
bot_user_id = self._bot_user_id
|
bot_user_id = self._bot_user_id
|
||||||
if bot_user_id is None:
|
if bot_user_id is None:
|
||||||
logger.debug(
|
logger.debug("Discord message in {} ignored (bot identity unavailable)", message.channel.id)
|
||||||
"Discord message in {} ignored (bot identity unavailable)", message.channel.id
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if any(str(user.id) == bot_user_id for user in message.mentions):
|
if any(str(user.id) == bot_user_id for user in message.mentions):
|
||||||
@@ -638,6 +480,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _clear_reactions(self, chat_id: str) -> None:
|
async def _clear_reactions(self, chat_id: str) -> None:
|
||||||
"""Remove all pending reactions after bot replies."""
|
"""Remove all pending reactions after bot replies."""
|
||||||
# Cancel delayed working emoji if it hasn't fired yet
|
# Cancel delayed working emoji if it hasn't fired yet
|
||||||
@@ -664,7 +507,6 @@ class DiscordChannel(BaseChannel):
|
|||||||
async def _reset_runtime_state(self, close_client: bool) -> None:
|
async def _reset_runtime_state(self, close_client: bool) -> None:
|
||||||
"""Reset client and typing state."""
|
"""Reset client and typing state."""
|
||||||
await self._cancel_all_typing()
|
await self._cancel_all_typing()
|
||||||
self._stream_bufs.clear()
|
|
||||||
if close_client and self._client is not None and not self._client.is_closed():
|
if close_client and self._client is not None and not self._client.is_closed():
|
||||||
try:
|
try:
|
||||||
await self._client.close()
|
await self._client.close()
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
"""Microsoft Teams channel MVP using a tiny built-in HTTP webhook server.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- DM-focused MVP
|
||||||
|
- text inbound/outbound
|
||||||
|
- conversation reference persistence
|
||||||
|
- sender allowlist support
|
||||||
|
- optional inbound Bot Framework bearer-token validation
|
||||||
|
- no attachments/cards/polls yet
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import html
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.config.paths import get_workspace_path
|
||||||
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
MSTEAMS_AVAILABLE = importlib.util.find_spec("jwt") is not None
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
if MSTEAMS_AVAILABLE:
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
|
||||||
|
class MSTeamsConfig(Base):
|
||||||
|
"""Microsoft Teams channel configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
app_id: str = ""
|
||||||
|
app_password: str = ""
|
||||||
|
tenant_id: str = ""
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 3978
|
||||||
|
path: str = "/api/messages"
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
reply_in_thread: bool = True
|
||||||
|
mention_only_response: str = "Hi — what can I help with?"
|
||||||
|
validate_inbound_auth: bool = False
|
||||||
|
restart_notify_enabled: bool = False
|
||||||
|
restart_notify_pre_message: str = (
|
||||||
|
"Nanobot agent initiated a gateway restart. I will message again when the gateway is back online."
|
||||||
|
)
|
||||||
|
restart_notify_post_message: str = "Nanobot gateway is back online."
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversationRef:
|
||||||
|
"""Minimal stored conversation reference for replies."""
|
||||||
|
|
||||||
|
service_url: str
|
||||||
|
conversation_id: str
|
||||||
|
bot_id: str | None = None
|
||||||
|
activity_id: str | None = None
|
||||||
|
conversation_type: str | None = None
|
||||||
|
tenant_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MSTeamsChannel(BaseChannel):
|
||||||
|
"""Microsoft Teams channel (DM-first MVP)."""
|
||||||
|
|
||||||
|
name = "msteams"
|
||||||
|
display_name = "Microsoft Teams"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return MSTeamsConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = MSTeamsConfig.model_validate(config)
|
||||||
|
super().__init__(config, bus)
|
||||||
|
self.config: MSTeamsConfig = config
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._server: ThreadingHTTPServer | None = None
|
||||||
|
self._server_thread: threading.Thread | None = None
|
||||||
|
self._http: httpx.AsyncClient | None = None
|
||||||
|
self._token: str | None = None
|
||||||
|
self._token_expires_at: float = 0.0
|
||||||
|
self._botframework_openid_config_url = (
|
||||||
|
"https://login.botframework.com/v1/.well-known/openidconfiguration"
|
||||||
|
)
|
||||||
|
self._botframework_openid_config: dict[str, Any] | None = None
|
||||||
|
self._botframework_openid_config_expires_at: float = 0.0
|
||||||
|
self._botframework_jwks: dict[str, Any] | None = None
|
||||||
|
self._botframework_jwks_expires_at: float = 0.0
|
||||||
|
self._refs_path = get_workspace_path() / "state" / "msteams_conversations.json"
|
||||||
|
self._refs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._conversation_refs: dict[str, ConversationRef] = self._load_refs()
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the Teams webhook listener."""
|
||||||
|
if not MSTEAMS_AVAILABLE:
|
||||||
|
logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.config.app_id or not self.config.app_password:
|
||||||
|
logger.error("MSTeams app_id/app_password not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self._http = httpx.AsyncClient(timeout=30.0)
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
channel = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
if self.path != channel.config.path:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
raw = self.rfile.read(length) if length > 0 else b"{}"
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("MSTeams invalid request body: {}", e)
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
auth_header = self.headers.get("Authorization", "")
|
||||||
|
if channel.config.validate_inbound_auth:
|
||||||
|
try:
|
||||||
|
fut = asyncio.run_coroutine_threadsafe(
|
||||||
|
channel._validate_inbound_auth(auth_header, payload),
|
||||||
|
channel._loop,
|
||||||
|
)
|
||||||
|
fut.result(timeout=15)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("MSTeams inbound auth validation failed: {}", e)
|
||||||
|
self.send_response(401)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error":"unauthorized"}')
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
fut = asyncio.run_coroutine_threadsafe(
|
||||||
|
channel._handle_activity(payload),
|
||||||
|
channel._loop,
|
||||||
|
)
|
||||||
|
fut.result(timeout=15)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("MSTeams activity handling failed: {}", e)
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"{}")
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: Any) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._server = ThreadingHTTPServer((self.config.host, self.config.port), Handler)
|
||||||
|
self._server_thread = threading.Thread(
|
||||||
|
target=self._server.serve_forever,
|
||||||
|
name="nanobot-msteams",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._server_thread.start()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"MSTeams webhook listening on http://{}:{}{}",
|
||||||
|
self.config.host,
|
||||||
|
self.config.port,
|
||||||
|
self.config.path,
|
||||||
|
)
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the channel."""
|
||||||
|
self._running = False
|
||||||
|
if self._server:
|
||||||
|
self._server.shutdown()
|
||||||
|
self._server.server_close()
|
||||||
|
self._server = None
|
||||||
|
if self._server_thread and self._server_thread.is_alive():
|
||||||
|
self._server_thread.join(timeout=2)
|
||||||
|
self._server_thread = None
|
||||||
|
if self._http:
|
||||||
|
await self._http.aclose()
|
||||||
|
self._http = None
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
"""Send a plain text reply into an existing Teams conversation."""
|
||||||
|
if not self._http:
|
||||||
|
raise RuntimeError("MSTeams HTTP client not initialized")
|
||||||
|
|
||||||
|
ref = self._conversation_refs.get(str(msg.chat_id))
|
||||||
|
if not ref:
|
||||||
|
raise RuntimeError(f"MSTeams conversation ref not found for chat_id={msg.chat_id}")
|
||||||
|
|
||||||
|
token = await self._get_access_token()
|
||||||
|
base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities"
|
||||||
|
use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id)
|
||||||
|
url = f"{base_url}/{ref.activity_id}" if use_thread_reply else base_url
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"type": "message",
|
||||||
|
"text": msg.content or " ",
|
||||||
|
}
|
||||||
|
if use_thread_reply:
|
||||||
|
payload["replyToId"] = ref.activity_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self._http.post(url, headers=headers, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logger.info("MSTeams message sent to {}", ref.conversation_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("MSTeams send failed: {}", e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _handle_activity(self, activity: dict[str, Any]) -> None:
|
||||||
|
"""Handle inbound Teams/Bot Framework activity."""
|
||||||
|
if activity.get("type") != "message":
|
||||||
|
return
|
||||||
|
|
||||||
|
conversation = activity.get("conversation") or {}
|
||||||
|
from_user = activity.get("from") or {}
|
||||||
|
recipient = activity.get("recipient") or {}
|
||||||
|
channel_data = activity.get("channelData") or {}
|
||||||
|
|
||||||
|
sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip()
|
||||||
|
conversation_id = str(conversation.get("id") or "").strip()
|
||||||
|
service_url = str(activity.get("serviceUrl") or "").strip()
|
||||||
|
activity_id = str(activity.get("id") or "").strip()
|
||||||
|
conversation_type = str(conversation.get("conversationType") or "").strip()
|
||||||
|
|
||||||
|
if not sender_id or not conversation_id or not service_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
if recipient.get("id") and from_user.get("id") == recipient.get("id"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# DM-only MVP: ignore group/channel traffic for now
|
||||||
|
if conversation_type and conversation_type not in ("personal", ""):
|
||||||
|
logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = self._sanitize_inbound_text(activity)
|
||||||
|
if not text:
|
||||||
|
text = self.config.mention_only_response.strip()
|
||||||
|
if not text:
|
||||||
|
logger.debug("MSTeams ignoring empty message after Teams text sanitization")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._conversation_refs[conversation_id] = ConversationRef(
|
||||||
|
service_url=service_url,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
bot_id=str(recipient.get("id") or "") or None,
|
||||||
|
activity_id=activity_id or None,
|
||||||
|
conversation_type=conversation_type or None,
|
||||||
|
tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None,
|
||||||
|
)
|
||||||
|
self._save_refs()
|
||||||
|
|
||||||
|
await self._handle_message(
|
||||||
|
sender_id=sender_id,
|
||||||
|
chat_id=conversation_id,
|
||||||
|
content=text,
|
||||||
|
metadata={
|
||||||
|
"msteams": {
|
||||||
|
"activity_id": activity_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"conversation_type": conversation_type or "personal",
|
||||||
|
"from_name": from_user.get("name"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sanitize_inbound_text(self, activity: dict[str, Any]) -> str:
|
||||||
|
"""Extract the user-authored text from a Teams activity."""
|
||||||
|
text = str(activity.get("text") or "")
|
||||||
|
text = self._strip_possible_bot_mention(text)
|
||||||
|
|
||||||
|
channel_data = activity.get("channelData") or {}
|
||||||
|
reply_to_id = str(activity.get("replyToId") or "").strip()
|
||||||
|
normalized_preview = html.unescape(text).replace("&rsquo", "’").strip()
|
||||||
|
normalized_preview = normalized_preview.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
preview_lines = [line.strip() for line in normalized_preview.split("\n")]
|
||||||
|
while preview_lines and not preview_lines[0]:
|
||||||
|
preview_lines.pop(0)
|
||||||
|
first_line = preview_lines[0] if preview_lines else ""
|
||||||
|
looks_like_quote_wrapper = first_line.lower().startswith("replying to ") or first_line.startswith("FWDIOC-BOT")
|
||||||
|
|
||||||
|
if reply_to_id or channel_data.get("messageType") == "reply" or looks_like_quote_wrapper:
|
||||||
|
text = self._normalize_teams_reply_quote(text)
|
||||||
|
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def _strip_possible_bot_mention(self, text: str) -> str:
|
||||||
|
"""Remove simple Teams mention markup from message text."""
|
||||||
|
cleaned = re.sub(r"<at\b[^>]*>.*?</at>", " ", text, flags=re.IGNORECASE | re.DOTALL)
|
||||||
|
cleaned = re.sub(r"[^\S\r\n]+", " ", cleaned)
|
||||||
|
cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
def _normalize_teams_reply_quote(self, text: str) -> str:
|
||||||
|
"""Normalize Teams quoted replies into a compact structured form."""
|
||||||
|
cleaned = html.unescape(text).replace("&rsquo", "’").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
normalized_newlines = cleaned.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
lines = [line.strip() for line in normalized_newlines.split("\n")]
|
||||||
|
while lines and not lines[0]:
|
||||||
|
lines.pop(0)
|
||||||
|
|
||||||
|
# Observed native Teams reply wrapper:
|
||||||
|
# Replying to Bob Smith
|
||||||
|
# actual reply text
|
||||||
|
if len(lines) >= 2 and lines[0].lower().startswith("replying to "):
|
||||||
|
quoted = lines[0][len("replying to ") :].strip(" :")
|
||||||
|
reply = "\n".join(lines[1:]).strip()
|
||||||
|
return self._format_reply_with_quote(quoted, reply)
|
||||||
|
|
||||||
|
# Observed FWDIOC relay wrapper where the quoted content is surfaced after a
|
||||||
|
# synthetic "FWDIOC-BOT" header, sometimes with a blank line separating quote
|
||||||
|
# and reply, and sometimes as a compact line-based fallback shape.
|
||||||
|
if lines and lines[0].strip().startswith("FWDIOC-BOT"):
|
||||||
|
body = normalized_newlines.split("\n", 1)[1] if "\n" in normalized_newlines else ""
|
||||||
|
body = body.lstrip()
|
||||||
|
parts = re.split(r"\n\s*\n", body, maxsplit=1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
quoted = re.sub(r"\s+", " ", parts[0]).strip()
|
||||||
|
reply = re.sub(r"\s+", " ", parts[1]).strip()
|
||||||
|
if quoted or reply:
|
||||||
|
return self._format_reply_with_quote(quoted, reply)
|
||||||
|
|
||||||
|
body_lines = [line.strip() for line in body.split("\n") if line.strip()]
|
||||||
|
if body_lines:
|
||||||
|
quoted = " ".join(body_lines[:-1]).strip()
|
||||||
|
reply = body_lines[-1].strip()
|
||||||
|
if quoted and reply:
|
||||||
|
return self._format_reply_with_quote(quoted, reply)
|
||||||
|
|
||||||
|
# Observed compact fallback where the relay flattens everything into one line
|
||||||
|
# and appends the literal reply text marker at the end.
|
||||||
|
compact = re.sub(r"\s+", " ", normalized_newlines).strip()
|
||||||
|
if compact.startswith("FWDIOC-BOT "):
|
||||||
|
compact = compact[len("FWDIOC-BOT ") :].strip()
|
||||||
|
|
||||||
|
marker = " Reply with quote test"
|
||||||
|
if compact.endswith(marker):
|
||||||
|
quoted = compact[: -len(marker)].strip()
|
||||||
|
reply = marker.strip()
|
||||||
|
return self._format_reply_with_quote(quoted, reply)
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def _format_reply_with_quote(self, quoted: str, reply: str) -> str:
|
||||||
|
"""Format a quoted reply for the model without Teams wrapper noise."""
|
||||||
|
quoted = quoted.strip()
|
||||||
|
reply = reply.strip()
|
||||||
|
if quoted and reply:
|
||||||
|
return f"User is replying to: {quoted}\nUser reply: {reply}"
|
||||||
|
if reply:
|
||||||
|
return reply
|
||||||
|
return quoted
|
||||||
|
|
||||||
|
async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None:
|
||||||
|
"""Validate inbound Bot Framework bearer token."""
|
||||||
|
if not MSTEAMS_AVAILABLE:
|
||||||
|
raise RuntimeError("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
|
||||||
|
|
||||||
|
if not auth_header.lower().startswith("bearer "):
|
||||||
|
raise ValueError("missing bearer token")
|
||||||
|
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
if not token:
|
||||||
|
raise ValueError("empty bearer token")
|
||||||
|
|
||||||
|
header = jwt.get_unverified_header(token)
|
||||||
|
kid = str(header.get("kid") or "").strip()
|
||||||
|
if not kid:
|
||||||
|
raise ValueError("missing token kid")
|
||||||
|
|
||||||
|
jwks = await self._get_botframework_jwks()
|
||||||
|
keys = jwks.get("keys") or []
|
||||||
|
jwk = next((key for key in keys if key.get("kid") == kid), None)
|
||||||
|
if not jwk:
|
||||||
|
raise ValueError(f"signing key not found for kid={kid}")
|
||||||
|
|
||||||
|
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
key=public_key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
audience=self.config.app_id,
|
||||||
|
issuer="https://api.botframework.com",
|
||||||
|
options={
|
||||||
|
"require": ["exp", "nbf", "iss", "aud"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
claim_service_url = str(
|
||||||
|
claims.get("serviceurl") or claims.get("serviceUrl") or "",
|
||||||
|
).strip()
|
||||||
|
activity_service_url = str(activity.get("serviceUrl") or "").strip()
|
||||||
|
if claim_service_url and activity_service_url and claim_service_url != activity_service_url:
|
||||||
|
raise ValueError("serviceUrl claim mismatch")
|
||||||
|
|
||||||
|
async def _get_botframework_openid_config(self) -> dict[str, Any]:
|
||||||
|
"""Fetch and cache Bot Framework OpenID configuration."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if self._botframework_openid_config and now < self._botframework_openid_config_expires_at:
|
||||||
|
return self._botframework_openid_config
|
||||||
|
|
||||||
|
if not self._http:
|
||||||
|
raise RuntimeError("MSTeams HTTP client not initialized")
|
||||||
|
|
||||||
|
resp = await self._http.get(self._botframework_openid_config_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._botframework_openid_config = resp.json()
|
||||||
|
self._botframework_openid_config_expires_at = now + 3600
|
||||||
|
return self._botframework_openid_config
|
||||||
|
|
||||||
|
async def _get_botframework_jwks(self) -> dict[str, Any]:
|
||||||
|
"""Fetch and cache Bot Framework JWKS."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if self._botframework_jwks and now < self._botframework_jwks_expires_at:
|
||||||
|
return self._botframework_jwks
|
||||||
|
|
||||||
|
if not self._http:
|
||||||
|
raise RuntimeError("MSTeams HTTP client not initialized")
|
||||||
|
|
||||||
|
openid_config = await self._get_botframework_openid_config()
|
||||||
|
jwks_uri = str(openid_config.get("jwks_uri") or "").strip()
|
||||||
|
if not jwks_uri:
|
||||||
|
raise RuntimeError("Bot Framework OpenID config missing jwks_uri")
|
||||||
|
|
||||||
|
resp = await self._http.get(jwks_uri)
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._botframework_jwks = resp.json()
|
||||||
|
self._botframework_jwks_expires_at = now + 3600
|
||||||
|
return self._botframework_jwks
|
||||||
|
|
||||||
|
def _load_refs(self) -> dict[str, ConversationRef]:
|
||||||
|
"""Load stored conversation references."""
|
||||||
|
if not self._refs_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(self._refs_path.read_text(encoding="utf-8"))
|
||||||
|
out: dict[str, ConversationRef] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
out[key] = ConversationRef(**value)
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to load MSTeams conversation refs: {}", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_refs(self) -> None:
|
||||||
|
"""Persist conversation references."""
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
key: {
|
||||||
|
"service_url": ref.service_url,
|
||||||
|
"conversation_id": ref.conversation_id,
|
||||||
|
"bot_id": ref.bot_id,
|
||||||
|
"activity_id": ref.activity_id,
|
||||||
|
"conversation_type": ref.conversation_type,
|
||||||
|
"tenant_id": ref.tenant_id,
|
||||||
|
}
|
||||||
|
for key, ref in self._conversation_refs.items()
|
||||||
|
}
|
||||||
|
self._refs_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to save MSTeams conversation refs: {}", e)
|
||||||
|
|
||||||
|
async def _get_access_token(self) -> str:
|
||||||
|
"""Fetch an access token for Bot Framework / Azure Bot auth."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if self._token and now < self._token_expires_at - 60:
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
if not self._http:
|
||||||
|
raise RuntimeError("MSTeams HTTP client not initialized")
|
||||||
|
|
||||||
|
tenant = (self.config.tenant_id or "").strip() or "botframework.com"
|
||||||
|
token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
||||||
|
data = {
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": self.config.app_id,
|
||||||
|
"client_secret": self.config.app_password,
|
||||||
|
"scope": "https://api.botframework.com/.default",
|
||||||
|
}
|
||||||
|
resp = await self._http.post(token_url, data=data)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
self._token = payload["access_token"]
|
||||||
|
self._token_expires_at = now + int(payload.get("expires_in", 3600))
|
||||||
|
return self._token
|
||||||
@@ -280,9 +280,6 @@ class QQChannel(BaseChannel):
|
|||||||
msg_id=msg_id,
|
msg_id=msg_id,
|
||||||
content=msg.content.strip(),
|
content=msg.content.strip(),
|
||||||
)
|
)
|
||||||
except (aiohttp.ClientError, OSError):
|
|
||||||
# Network / transport errors — propagate so ChannelManager can retry
|
|
||||||
raise
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error sending QQ message to chat_id={}", msg.chat_id)
|
logger.exception("Error sending QQ message to chat_id={}", msg.chat_id)
|
||||||
|
|
||||||
@@ -365,12 +362,7 @@ class QQChannel(BaseChannel):
|
|||||||
|
|
||||||
logger.info("QQ media sent: {}", filename)
|
logger.info("QQ media sent: {}", filename)
|
||||||
return True
|
return True
|
||||||
except (aiohttp.ClientError, OSError) as e:
|
|
||||||
# Network / transport errors — propagate for retry by caller
|
|
||||||
logger.warning("QQ send media network error filename={} err={}", filename, e)
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# API-level or other non-network errors — return False so send() can fallback
|
|
||||||
logger.error("QQ send media failed filename={} err={}", filename, e)
|
logger.error("QQ send media failed filename={} err={}", filename, e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ def _markdown_to_telegram_html(text: str) -> str:
|
|||||||
|
|
||||||
_SEND_MAX_RETRIES = 3
|
_SEND_MAX_RETRIES = 3
|
||||||
_SEND_RETRY_BASE_DELAY = 0.5 # seconds, doubled each retry
|
_SEND_RETRY_BASE_DELAY = 0.5 # seconds, doubled each retry
|
||||||
_STREAM_EDIT_INTERVAL_DEFAULT = 0.6 # min seconds between edit_message_text calls
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -191,7 +190,6 @@ class TelegramConfig(Base):
|
|||||||
connection_pool_size: int = 32
|
connection_pool_size: int = 32
|
||||||
pool_timeout: float = 5.0
|
pool_timeout: float = 5.0
|
||||||
streaming: bool = True
|
streaming: bool = True
|
||||||
stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1)
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramChannel(BaseChannel):
|
class TelegramChannel(BaseChannel):
|
||||||
@@ -221,6 +219,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
def default_config(cls) -> dict[str, Any]:
|
def default_config(cls) -> dict[str, Any]:
|
||||||
return TelegramConfig().model_dump(by_alias=True)
|
return TelegramConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
_STREAM_EDIT_INTERVAL = 0.6 # min seconds between edit_message_text calls
|
||||||
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
if isinstance(config, dict):
|
if isinstance(config, dict):
|
||||||
config = TelegramConfig.model_validate(config)
|
config = TelegramConfig.model_validate(config)
|
||||||
@@ -316,10 +316,10 @@ class TelegramChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
self._app.add_handler(MessageHandler(filters.Regex(r"^/help(?:@\w+)?$"), self._on_help))
|
self._app.add_handler(MessageHandler(filters.Regex(r"^/help(?:@\w+)?$"), self._on_help))
|
||||||
|
|
||||||
# Add message handler for text, photos, voice, documents, and locations
|
# Add message handler for text, photos, voice, documents
|
||||||
self._app.add_handler(
|
self._app.add_handler(
|
||||||
MessageHandler(
|
MessageHandler(
|
||||||
(filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL | filters.LOCATION)
|
(filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL)
|
||||||
& ~filters.COMMAND,
|
& ~filters.COMMAND,
|
||||||
self._on_message
|
self._on_message
|
||||||
)
|
)
|
||||||
@@ -520,10 +520,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
reply_parameters=reply_params,
|
reply_parameters=reply_params,
|
||||||
**(thread_kwargs or {}),
|
**(thread_kwargs or {}),
|
||||||
)
|
)
|
||||||
except BadRequest as e:
|
except Exception as e:
|
||||||
# Only fall back to plain text on actual HTML parse/format errors.
|
|
||||||
# Network errors (TimedOut, NetworkError) should propagate immediately
|
|
||||||
# to avoid doubling connection demand during pool exhaustion.
|
|
||||||
logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
||||||
try:
|
try:
|
||||||
await self._call_with_retry(
|
await self._call_with_retry(
|
||||||
@@ -570,10 +567,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
chat_id=int_chat_id, message_id=buf.message_id,
|
chat_id=int_chat_id, message_id=buf.message_id,
|
||||||
text=html, parse_mode="HTML",
|
text=html, parse_mode="HTML",
|
||||||
)
|
)
|
||||||
except BadRequest as e:
|
except Exception as e:
|
||||||
# Only fall back to plain text on actual HTML parse/format errors.
|
|
||||||
# Network errors (TimedOut, NetworkError) should propagate immediately
|
|
||||||
# to avoid doubling connection demand during pool exhaustion.
|
|
||||||
if self._is_not_modified_error(e):
|
if self._is_not_modified_error(e):
|
||||||
logger.debug("Final stream edit already applied for {}", chat_id)
|
logger.debug("Final stream edit already applied for {}", chat_id)
|
||||||
self._stream_bufs.pop(chat_id, None)
|
self._stream_bufs.pop(chat_id, None)
|
||||||
@@ -625,7 +619,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Stream initial send failed: {}", e)
|
logger.warning("Stream initial send failed: {}", e)
|
||||||
raise # Let ChannelManager handle retry
|
raise # Let ChannelManager handle retry
|
||||||
elif (now - buf.last_edit) >= self.config.stream_edit_interval:
|
elif (now - buf.last_edit) >= self._STREAM_EDIT_INTERVAL:
|
||||||
try:
|
try:
|
||||||
await self._call_with_retry(
|
await self._call_with_retry(
|
||||||
self._app.bot.edit_message_text,
|
self._app.bot.edit_message_text,
|
||||||
@@ -890,12 +884,6 @@ class TelegramChannel(BaseChannel):
|
|||||||
if message.caption:
|
if message.caption:
|
||||||
content_parts.append(message.caption)
|
content_parts.append(message.caption)
|
||||||
|
|
||||||
# Location content
|
|
||||||
if message.location:
|
|
||||||
lat = message.location.latitude
|
|
||||||
lon = message.location.longitude
|
|
||||||
content_parts.append(f"[location: {lat}, {lon}]")
|
|
||||||
|
|
||||||
# Download current message media
|
# Download current message media
|
||||||
current_media_paths, current_media_parts = await self._download_message_media(
|
current_media_paths, current_media_parts = await self._download_message_media(
|
||||||
message, add_failure_content=True
|
message, add_failure_content=True
|
||||||
|
|||||||
@@ -985,43 +985,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
for media_path in (msg.media or []):
|
for media_path in (msg.media or []):
|
||||||
try:
|
try:
|
||||||
await self._send_media_file(msg.chat_id, media_path, ctx_token)
|
await self._send_media_file(msg.chat_id, media_path, ctx_token)
|
||||||
except (httpx.TimeoutException, httpx.TransportError) as net_err:
|
|
||||||
# Network/transport errors: do NOT fall back to text —
|
|
||||||
# the text send would also likely fail, and the outer
|
|
||||||
# except will re-raise so ChannelManager retries properly.
|
|
||||||
logger.error(
|
|
||||||
"Network error sending WeChat media {}: {}",
|
|
||||||
media_path,
|
|
||||||
net_err,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
except httpx.HTTPStatusError as http_err:
|
|
||||||
status_code = (
|
|
||||||
http_err.response.status_code
|
|
||||||
if http_err.response is not None
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
if status_code >= 500:
|
|
||||||
# Server-side / retryable HTTP error — same as network.
|
|
||||||
logger.error(
|
|
||||||
"Server error ({} {}) sending WeChat media {}: {}",
|
|
||||||
status_code,
|
|
||||||
http_err.response.reason_phrase
|
|
||||||
if http_err.response is not None
|
|
||||||
else "",
|
|
||||||
media_path,
|
|
||||||
http_err,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
# 4xx client errors are NOT retryable — fall back to text.
|
|
||||||
filename = Path(media_path).name
|
|
||||||
logger.error("Failed to send WeChat media {}: {}", media_path, http_err)
|
|
||||||
await self._send_text(
|
|
||||||
msg.chat_id, f"[Failed to send: {filename}]", ctx_token,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Non-network errors (format, file-not-found, etc.):
|
|
||||||
# notify the user via text fallback.
|
|
||||||
filename = Path(media_path).name
|
filename = Path(media_path).name
|
||||||
logger.error("Failed to send WeChat media {}: {}", media_path, e)
|
logger.error("Failed to send WeChat media {}: {}", media_path, e)
|
||||||
# Notify user about failure via text
|
# Notify user about failure via text
|
||||||
|
|||||||
@@ -590,8 +590,6 @@ def serve(
|
|||||||
mcp_servers=runtime_config.tools.mcp_servers,
|
mcp_servers=runtime_config.tools.mcp_servers,
|
||||||
channels_config=runtime_config.channels,
|
channels_config=runtime_config.channels,
|
||||||
timezone=runtime_config.agents.defaults.timezone,
|
timezone=runtime_config.agents.defaults.timezone,
|
||||||
unified_session=runtime_config.agents.defaults.unified_session,
|
|
||||||
disabled_skills=runtime_config.agents.defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
|
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -684,8 +682,6 @@ def gateway(
|
|||||||
mcp_servers=config.tools.mcp_servers,
|
mcp_servers=config.tools.mcp_servers,
|
||||||
channels_config=config.channels,
|
channels_config=config.channels,
|
||||||
timezone=config.agents.defaults.timezone,
|
timezone=config.agents.defaults.timezone,
|
||||||
unified_session=config.agents.defaults.unified_session,
|
|
||||||
disabled_skills=config.agents.defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -918,8 +914,6 @@ def agent(
|
|||||||
mcp_servers=config.tools.mcp_servers,
|
mcp_servers=config.tools.mcp_servers,
|
||||||
channels_config=config.channels,
|
channels_config=config.channels,
|
||||||
timezone=config.agents.defaults.timezone,
|
timezone=config.agents.defaults.timezone,
|
||||||
unified_session=config.agents.defaults.unified_session,
|
|
||||||
disabled_skills=config.agents.defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
||||||
)
|
)
|
||||||
restart_notice = consume_restart_notice_from_env()
|
restart_notice = consume_restart_notice_from_env()
|
||||||
@@ -1125,7 +1119,7 @@ def channels_status(
|
|||||||
|
|
||||||
table = Table(title="Channel Status")
|
table = Table(title="Channel Status")
|
||||||
table.add_column("Channel", style="cyan")
|
table.add_column("Channel", style="cyan")
|
||||||
table.add_column("Enabled")
|
table.add_column("Enabled", style="green")
|
||||||
|
|
||||||
for name, cls in sorted(discover_all().items()):
|
for name, cls in sorted(discover_all().items()):
|
||||||
section = getattr(config.channels, name, None)
|
section = getattr(config.channels, name, None)
|
||||||
@@ -1260,7 +1254,7 @@ def plugins_list():
|
|||||||
table = Table(title="Channel Plugins")
|
table = Table(title="Channel Plugins")
|
||||||
table.add_column("Name", style="cyan")
|
table.add_column("Name", style="cyan")
|
||||||
table.add_column("Source", style="magenta")
|
table.add_column("Source", style="magenta")
|
||||||
table.add_column("Enabled")
|
table.add_column("Enabled", style="green")
|
||||||
|
|
||||||
for name in sorted(all_channels):
|
for name in sorted(all_channels):
|
||||||
cls = all_channels[name]
|
cls = all_channels[name]
|
||||||
|
|||||||
@@ -74,16 +74,9 @@ class AgentDefaults(Base):
|
|||||||
max_tool_iterations: int = 200
|
max_tool_iterations: int = 200
|
||||||
max_tool_result_chars: int = 16_000
|
max_tool_result_chars: int = 16_000
|
||||||
provider_retry_mode: Literal["standard", "persistent"] = "standard"
|
provider_retry_mode: Literal["standard", "persistent"] = "standard"
|
||||||
reasoning_effort: str | None = None # low / medium / high / adaptive - enables LLM thinking mode
|
reasoning_effort: str | None = None # low / medium / high - enables LLM thinking mode
|
||||||
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
|
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
|
||||||
unified_session: bool = False # Share one session across all channels (single-user multi-device)
|
session_ttl_minutes: int = Field(default=0, ge=0) # Auto /new after idle (0 = disabled)
|
||||||
disabled_skills: list[str] = Field(default_factory=list) # Skill names to exclude from loading (e.g. ["summarize", "skill-creator"])
|
|
||||||
session_ttl_minutes: int = Field(
|
|
||||||
default=0,
|
|
||||||
ge=0,
|
|
||||||
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
|
|
||||||
serialization_alias="idleCompactAfterMinutes",
|
|
||||||
) # Auto-compact idle threshold in minutes (0 = disabled)
|
|
||||||
dream: DreamConfig = Field(default_factory=DreamConfig)
|
dream: DreamConfig = Field(default_factory=DreamConfig)
|
||||||
|
|
||||||
|
|
||||||
@@ -160,7 +153,7 @@ class GatewayConfig(Base):
|
|||||||
class WebSearchConfig(Base):
|
class WebSearchConfig(Base):
|
||||||
"""Web search tool configuration."""
|
"""Web search tool configuration."""
|
||||||
|
|
||||||
provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina, kagi
|
provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
base_url: str = "" # SearXNG base URL
|
base_url: str = "" # SearXNG base URL
|
||||||
max_results: int = 5
|
max_results: int = 5
|
||||||
@@ -184,7 +177,6 @@ class ExecToolConfig(Base):
|
|||||||
timeout: int = 60
|
timeout: int = 60
|
||||||
path_append: str = ""
|
path_append: str = ""
|
||||||
sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
|
sandbox: str = "" # sandbox backend: "" (none) or "bwrap"
|
||||||
allowed_env_keys: list[str] = Field(default_factory=list) # Env var names to pass through to subprocess (e.g. ["GOPATH", "JAVA_HOME"])
|
|
||||||
|
|
||||||
class MCPServerConfig(Base):
|
class MCPServerConfig(Base):
|
||||||
"""MCP server connection configuration (stdio or HTTP)."""
|
"""MCP server connection configuration (stdio or HTTP)."""
|
||||||
|
|||||||
+47
-173
@@ -4,12 +4,10 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import asdict
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Coroutine, Literal
|
from typing import Any, Callable, Coroutine, Literal
|
||||||
|
|
||||||
from filelock import FileLock
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore
|
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore
|
||||||
@@ -71,26 +69,28 @@ class CronService:
|
|||||||
self,
|
self,
|
||||||
store_path: Path,
|
store_path: Path,
|
||||||
on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None,
|
on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None,
|
||||||
max_sleep_ms: int = 300_000, # 5 minutes
|
|
||||||
):
|
):
|
||||||
self.store_path = store_path
|
self.store_path = store_path
|
||||||
self._action_path = store_path.parent / "action.jsonl"
|
|
||||||
self._lock = FileLock(str(self._action_path.parent) + ".lock")
|
|
||||||
self.on_job = on_job
|
self.on_job = on_job
|
||||||
self._store: CronStore | None = None
|
self._store: CronStore | None = None
|
||||||
|
self._last_mtime: float = 0.0
|
||||||
self._timer_task: asyncio.Task | None = None
|
self._timer_task: asyncio.Task | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._timer_active = False
|
|
||||||
self.max_sleep_ms = max_sleep_ms
|
|
||||||
|
|
||||||
def _load_jobs(self) -> tuple[list[CronJob], int]:
|
def _load_store(self) -> CronStore:
|
||||||
jobs = []
|
"""Load jobs from disk. Reloads automatically if file was modified externally."""
|
||||||
version = 1
|
if self._store and self.store_path.exists():
|
||||||
|
mtime = self.store_path.stat().st_mtime
|
||||||
|
if mtime != self._last_mtime:
|
||||||
|
logger.info("Cron: jobs.json modified externally, reloading")
|
||||||
|
self._store = None
|
||||||
|
if self._store:
|
||||||
|
return self._store
|
||||||
|
|
||||||
if self.store_path.exists():
|
if self.store_path.exists():
|
||||||
try:
|
try:
|
||||||
data = json.loads(self.store_path.read_text(encoding="utf-8"))
|
data = json.loads(self.store_path.read_text(encoding="utf-8"))
|
||||||
jobs = []
|
jobs = []
|
||||||
version = data.get("version", 1)
|
|
||||||
for j in data.get("jobs", []):
|
for j in data.get("jobs", []):
|
||||||
jobs.append(CronJob(
|
jobs.append(CronJob(
|
||||||
id=j["id"],
|
id=j["id"],
|
||||||
@@ -129,57 +129,12 @@ class CronService:
|
|||||||
updated_at_ms=j.get("updatedAtMs", 0),
|
updated_at_ms=j.get("updatedAtMs", 0),
|
||||||
delete_after_run=j.get("deleteAfterRun", False),
|
delete_after_run=j.get("deleteAfterRun", False),
|
||||||
))
|
))
|
||||||
|
self._store = CronStore(jobs=jobs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to load cron store: {}", e)
|
logger.warning("Failed to load cron store: {}", e)
|
||||||
return jobs, version
|
self._store = CronStore()
|
||||||
|
else:
|
||||||
def _merge_action(self):
|
self._store = CronStore()
|
||||||
if not self._action_path.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
jobs_map = {j.id: j for j in self._store.jobs}
|
|
||||||
def _update(params: dict):
|
|
||||||
j = CronJob.from_dict(params)
|
|
||||||
jobs_map[j.id] = j
|
|
||||||
|
|
||||||
def _del(params: dict):
|
|
||||||
if job_id := params.get("job_id"):
|
|
||||||
jobs_map.pop(job_id)
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
with open(self._action_path, "r", encoding="utf-8") as f:
|
|
||||||
changed = False
|
|
||||||
for line in f:
|
|
||||||
try:
|
|
||||||
line = line.strip()
|
|
||||||
action = json.loads(line)
|
|
||||||
if "action" not in action:
|
|
||||||
continue
|
|
||||||
if action["action"] == "del":
|
|
||||||
_del(action.get("params", {}))
|
|
||||||
else:
|
|
||||||
_update(action.get("params", {}))
|
|
||||||
changed = True
|
|
||||||
except Exception as exp:
|
|
||||||
logger.debug(f"load action line error: {exp}")
|
|
||||||
continue
|
|
||||||
self._store.jobs = list(jobs_map.values())
|
|
||||||
if self._running and changed:
|
|
||||||
self._action_path.write_text("", encoding="utf-8")
|
|
||||||
self._save_store()
|
|
||||||
return
|
|
||||||
|
|
||||||
def _load_store(self) -> CronStore:
|
|
||||||
"""Load jobs from disk. Reloads automatically if file was modified externally.
|
|
||||||
- Reload every time because it needs to merge operations on the jobs object from other instances.
|
|
||||||
- During _on_timer execution, return the existing store to prevent concurrent
|
|
||||||
_load_store calls (e.g. from list_jobs polling) from replacing it mid-execution.
|
|
||||||
"""
|
|
||||||
if self._timer_active and self._store:
|
|
||||||
return self._store
|
|
||||||
jobs, version = self._load_jobs()
|
|
||||||
self._store = CronStore(version=version, jobs=jobs)
|
|
||||||
self._merge_action()
|
|
||||||
|
|
||||||
return self._store
|
return self._store
|
||||||
|
|
||||||
@@ -235,7 +190,8 @@ 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, ensure_ascii=False), encoding="utf-8")
|
||||||
|
self._last_mtime = self.store_path.stat().st_mtime
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the cron service."""
|
"""Start the cron service."""
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -274,14 +230,11 @@ class CronService:
|
|||||||
if self._timer_task:
|
if self._timer_task:
|
||||||
self._timer_task.cancel()
|
self._timer_task.cancel()
|
||||||
|
|
||||||
if not self._running:
|
next_wake = self._get_next_wake_ms()
|
||||||
|
if not next_wake or not self._running:
|
||||||
return
|
return
|
||||||
|
|
||||||
next_wake = self._get_next_wake_ms()
|
delay_ms = max(0, next_wake - _now_ms())
|
||||||
if next_wake is None:
|
|
||||||
delay_ms = self.max_sleep_ms
|
|
||||||
else:
|
|
||||||
delay_ms = min(self.max_sleep_ms, max(0, next_wake - _now_ms()))
|
|
||||||
delay_s = delay_ms / 1000
|
delay_s = delay_ms / 1000
|
||||||
|
|
||||||
async def tick():
|
async def tick():
|
||||||
@@ -295,23 +248,18 @@ class CronService:
|
|||||||
"""Handle timer tick - run due jobs."""
|
"""Handle timer tick - run due jobs."""
|
||||||
self._load_store()
|
self._load_store()
|
||||||
if not self._store:
|
if not self._store:
|
||||||
self._arm_timer()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self._timer_active = True
|
now = _now_ms()
|
||||||
try:
|
due_jobs = [
|
||||||
now = _now_ms()
|
j for j in self._store.jobs
|
||||||
due_jobs = [
|
if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
|
||||||
j for j in self._store.jobs
|
]
|
||||||
if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
|
|
||||||
]
|
|
||||||
|
|
||||||
for job in due_jobs:
|
for job in due_jobs:
|
||||||
await self._execute_job(job)
|
await self._execute_job(job)
|
||||||
|
|
||||||
self._save_store()
|
self._save_store()
|
||||||
finally:
|
|
||||||
self._timer_active = False
|
|
||||||
self._arm_timer()
|
self._arm_timer()
|
||||||
|
|
||||||
async def _execute_job(self, job: CronJob) -> None:
|
async def _execute_job(self, job: CronJob) -> None:
|
||||||
@@ -355,13 +303,6 @@ class CronService:
|
|||||||
# Compute next run
|
# Compute next run
|
||||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
||||||
|
|
||||||
def _append_action(self, action: Literal["add", "del", "update"], params: dict):
|
|
||||||
self.store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with self._lock:
|
|
||||||
with open(self._action_path, "a", encoding="utf-8") as f:
|
|
||||||
f.write(json.dumps({"action": action, "params": params}, ensure_ascii=False) + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Public API ==========
|
# ========== Public API ==========
|
||||||
|
|
||||||
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
|
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
|
||||||
@@ -381,6 +322,7 @@ class CronService:
|
|||||||
delete_after_run: bool = False,
|
delete_after_run: bool = False,
|
||||||
) -> CronJob:
|
) -> CronJob:
|
||||||
"""Add a new job."""
|
"""Add a new job."""
|
||||||
|
store = self._load_store()
|
||||||
_validate_schedule_for_add(schedule)
|
_validate_schedule_for_add(schedule)
|
||||||
now = _now_ms()
|
now = _now_ms()
|
||||||
|
|
||||||
@@ -401,13 +343,10 @@ class CronService:
|
|||||||
updated_at_ms=now,
|
updated_at_ms=now,
|
||||||
delete_after_run=delete_after_run,
|
delete_after_run=delete_after_run,
|
||||||
)
|
)
|
||||||
if self._running:
|
|
||||||
store = self._load_store()
|
store.jobs.append(job)
|
||||||
store.jobs.append(job)
|
self._save_store()
|
||||||
self._save_store()
|
self._arm_timer()
|
||||||
self._arm_timer()
|
|
||||||
else:
|
|
||||||
self._append_action("add", asdict(job))
|
|
||||||
|
|
||||||
logger.info("Cron: added job '{}' ({})", name, job.id)
|
logger.info("Cron: added job '{}' ({})", name, job.id)
|
||||||
return job
|
return job
|
||||||
@@ -441,11 +380,8 @@ class CronService:
|
|||||||
removed = len(store.jobs) < before
|
removed = len(store.jobs) < before
|
||||||
|
|
||||||
if removed:
|
if removed:
|
||||||
if self._running:
|
self._save_store()
|
||||||
self._save_store()
|
self._arm_timer()
|
||||||
self._arm_timer()
|
|
||||||
else:
|
|
||||||
self._append_action("del", {"job_id": job_id})
|
|
||||||
logger.info("Cron: removed job {}", job_id)
|
logger.info("Cron: removed job {}", job_id)
|
||||||
return "removed"
|
return "removed"
|
||||||
|
|
||||||
@@ -462,85 +398,23 @@ class CronService:
|
|||||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
||||||
else:
|
else:
|
||||||
job.state.next_run_at_ms = None
|
job.state.next_run_at_ms = None
|
||||||
if self._running:
|
self._save_store()
|
||||||
self._save_store()
|
self._arm_timer()
|
||||||
self._arm_timer()
|
|
||||||
else:
|
|
||||||
self._append_action("update", asdict(job))
|
|
||||||
return job
|
return job
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_job(
|
|
||||||
self,
|
|
||||||
job_id: str,
|
|
||||||
*,
|
|
||||||
name: str | None = None,
|
|
||||||
schedule: CronSchedule | None = None,
|
|
||||||
message: str | None = None,
|
|
||||||
deliver: bool | None = None,
|
|
||||||
channel: str | None = ...,
|
|
||||||
to: str | None = ...,
|
|
||||||
delete_after_run: bool | None = None,
|
|
||||||
) -> CronJob | Literal["not_found", "protected"]:
|
|
||||||
"""Update mutable fields of an existing job. System jobs cannot be updated.
|
|
||||||
|
|
||||||
For ``channel`` and ``to``, pass an explicit value (including ``None``)
|
|
||||||
to update; omit (sentinel ``...``) to leave unchanged.
|
|
||||||
"""
|
|
||||||
store = self._load_store()
|
|
||||||
job = next((j for j in store.jobs if j.id == job_id), None)
|
|
||||||
if job is None:
|
|
||||||
return "not_found"
|
|
||||||
if job.payload.kind == "system_event":
|
|
||||||
return "protected"
|
|
||||||
|
|
||||||
if schedule is not None:
|
|
||||||
_validate_schedule_for_add(schedule)
|
|
||||||
job.schedule = schedule
|
|
||||||
if name is not None:
|
|
||||||
job.name = name
|
|
||||||
if message is not None:
|
|
||||||
job.payload.message = message
|
|
||||||
if deliver is not None:
|
|
||||||
job.payload.deliver = deliver
|
|
||||||
if channel is not ...:
|
|
||||||
job.payload.channel = channel
|
|
||||||
if to is not ...:
|
|
||||||
job.payload.to = to
|
|
||||||
if delete_after_run is not None:
|
|
||||||
job.delete_after_run = delete_after_run
|
|
||||||
|
|
||||||
job.updated_at_ms = _now_ms()
|
|
||||||
if job.enabled:
|
|
||||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
|
||||||
|
|
||||||
if self._running:
|
|
||||||
self._save_store()
|
|
||||||
self._arm_timer()
|
|
||||||
else:
|
|
||||||
self._append_action("update", asdict(job))
|
|
||||||
|
|
||||||
logger.info("Cron: updated job '{}' ({})", job.name, job.id)
|
|
||||||
return job
|
|
||||||
|
|
||||||
async def run_job(self, job_id: str, force: bool = False) -> bool:
|
async def run_job(self, job_id: str, force: bool = False) -> bool:
|
||||||
"""Manually run a job without disturbing the service's running state."""
|
"""Manually run a job."""
|
||||||
was_running = self._running
|
store = self._load_store()
|
||||||
self._running = True
|
for job in store.jobs:
|
||||||
try:
|
if job.id == job_id:
|
||||||
store = self._load_store()
|
if not force and not job.enabled:
|
||||||
for job in store.jobs:
|
return False
|
||||||
if job.id == job_id:
|
await self._execute_job(job)
|
||||||
if not force and not job.enabled:
|
self._save_store()
|
||||||
return False
|
|
||||||
await self._execute_job(job)
|
|
||||||
self._save_store()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
self._running = was_running
|
|
||||||
if was_running:
|
|
||||||
self._arm_timer()
|
self._arm_timer()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def get_job(self, job_id: str) -> CronJob | None:
|
def get_job(self, job_id: str) -> CronJob | None:
|
||||||
"""Get a job by ID."""
|
"""Get a job by ID."""
|
||||||
|
|||||||
@@ -61,18 +61,6 @@ class CronJob:
|
|||||||
updated_at_ms: int = 0
|
updated_at_ms: int = 0
|
||||||
delete_after_run: bool = False
|
delete_after_run: bool = False
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, kwargs: dict):
|
|
||||||
state_kwargs = dict(kwargs.get("state", {}))
|
|
||||||
state_kwargs["run_history"] = [
|
|
||||||
record if isinstance(record, CronRunRecord) else CronRunRecord(**record)
|
|
||||||
for record in state_kwargs.get("run_history", [])
|
|
||||||
]
|
|
||||||
kwargs["schedule"] = CronSchedule(**kwargs.get("schedule", {"kind": "every"}))
|
|
||||||
kwargs["payload"] = CronPayload(**kwargs.get("payload", {}))
|
|
||||||
kwargs["state"] = CronJobState(**state_kwargs)
|
|
||||||
return cls(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CronStore:
|
class CronStore:
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ class Nanobot:
|
|||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
mcp_servers=config.tools.mcp_servers,
|
||||||
timezone=defaults.timezone,
|
timezone=defaults.timezone,
|
||||||
unified_session=defaults.unified_session,
|
|
||||||
disabled_skills=defaults.disabled_skills,
|
|
||||||
session_ttl_minutes=defaults.session_ttl_minutes,
|
session_ttl_minutes=defaults.session_ttl_minutes,
|
||||||
)
|
)
|
||||||
return cls(loop)
|
return cls(loop)
|
||||||
|
|||||||
@@ -380,15 +380,9 @@ class AnthropicProvider(LLMProvider):
|
|||||||
if system:
|
if system:
|
||||||
kwargs["system"] = system
|
kwargs["system"] = system
|
||||||
|
|
||||||
if reasoning_effort == "adaptive":
|
if thinking_enabled:
|
||||||
# Adaptive thinking: model decides when and how much to think
|
|
||||||
# Supported on claude-sonnet-4-6 and claude-opus-4-6.
|
|
||||||
# Also auto-enables interleaved thinking between tool calls.
|
|
||||||
kwargs["thinking"] = {"type": "adaptive"}
|
|
||||||
kwargs["temperature"] = 1.0
|
|
||||||
elif thinking_enabled:
|
|
||||||
budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)}
|
budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)}
|
||||||
budget = budget_map.get(reasoning_effort.lower(), 4096)
|
budget = budget_map.get(reasoning_effort.lower(), 4096) # type: ignore[union-attr]
|
||||||
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||||||
kwargs["max_tokens"] = max(max_tokens, budget + 4096)
|
kwargs["max_tokens"] = max(max_tokens, budget + 4096)
|
||||||
kwargs["temperature"] = 1.0
|
kwargs["temperature"] = 1.0
|
||||||
|
|||||||
@@ -353,64 +353,6 @@ class LLMProvider(ABC):
|
|||||||
# Unknown 429 defaults to WAIT+retry.
|
# Unknown 429 defaults to WAIT+retry.
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _enforce_role_alternation(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
"""Merge consecutive same-role messages and drop trailing assistant messages.
|
|
||||||
|
|
||||||
Some providers (OpenAI-compat, Azure, vLLM, Ollama, etc.) reject requests
|
|
||||||
where the last message is 'assistant' (prefill not supported) or two
|
|
||||||
consecutive non-system messages share the same role.
|
|
||||||
"""
|
|
||||||
if not messages:
|
|
||||||
return messages
|
|
||||||
|
|
||||||
merged: list[dict[str, Any]] = []
|
|
||||||
for msg in messages:
|
|
||||||
role = msg.get("role")
|
|
||||||
if (
|
|
||||||
merged
|
|
||||||
and role != "system"
|
|
||||||
and role not in ("tool",)
|
|
||||||
and merged[-1].get("role") == role
|
|
||||||
and role in ("user", "assistant")
|
|
||||||
):
|
|
||||||
prev = merged[-1]
|
|
||||||
if role == "assistant":
|
|
||||||
prev_has_tools = bool(prev.get("tool_calls"))
|
|
||||||
curr_has_tools = bool(msg.get("tool_calls"))
|
|
||||||
if curr_has_tools:
|
|
||||||
merged[-1] = dict(msg)
|
|
||||||
continue
|
|
||||||
if prev_has_tools:
|
|
||||||
continue
|
|
||||||
prev_content = prev.get("content") or ""
|
|
||||||
curr_content = msg.get("content") or ""
|
|
||||||
if isinstance(prev_content, str) and isinstance(curr_content, str):
|
|
||||||
prev["content"] = (prev_content + "\n\n" + curr_content).strip()
|
|
||||||
else:
|
|
||||||
merged[-1] = dict(msg)
|
|
||||||
else:
|
|
||||||
merged.append(dict(msg))
|
|
||||||
|
|
||||||
last_popped = None
|
|
||||||
while merged and merged[-1].get("role") == "assistant":
|
|
||||||
last_popped = merged.pop()
|
|
||||||
|
|
||||||
# If removing trailing assistant messages left only system messages,
|
|
||||||
# the request would be invalid for most providers (e.g. Zhipu/GLM
|
|
||||||
# error 1214). Recover by converting the last popped assistant
|
|
||||||
# message to a user message so the LLM can still see the content.
|
|
||||||
if (
|
|
||||||
merged
|
|
||||||
and last_popped is not None
|
|
||||||
and not any(m.get("role") in ("user", "tool") for m in merged)
|
|
||||||
):
|
|
||||||
recovered = dict(last_popped)
|
|
||||||
recovered["role"] = "user"
|
|
||||||
merged.append(recovered)
|
|
||||||
|
|
||||||
return merged
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
||||||
"""Replace image_url blocks with text placeholder. Returns None if no images found."""
|
"""Replace image_url blocks with text placeholder. Returns None if no images found."""
|
||||||
@@ -433,26 +375,6 @@ class LLMProvider(ABC):
|
|||||||
result.append(msg)
|
result.append(msg)
|
||||||
return result if found else None
|
return result if found else None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
|
|
||||||
"""Replace image_url blocks with text placeholder *in-place*.
|
|
||||||
|
|
||||||
Mutates the content lists of the original message dicts so that
|
|
||||||
callers holding references to those dicts also see the stripped
|
|
||||||
version.
|
|
||||||
"""
|
|
||||||
found = False
|
|
||||||
for msg in messages:
|
|
||||||
content = msg.get("content")
|
|
||||||
if isinstance(content, list):
|
|
||||||
for i, b in enumerate(content):
|
|
||||||
if isinstance(b, dict) and b.get("type") == "image_url":
|
|
||||||
path = (b.get("_meta") or {}).get("path", "")
|
|
||||||
placeholder = image_placeholder_text(path, empty="[image omitted]")
|
|
||||||
content[i] = {"type": "text", "text": placeholder}
|
|
||||||
found = True
|
|
||||||
return found
|
|
||||||
|
|
||||||
async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
|
async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
|
||||||
"""Call chat() and convert unexpected exceptions to error responses."""
|
"""Call chat() and convert unexpected exceptions to error responses."""
|
||||||
try:
|
try:
|
||||||
@@ -704,12 +626,7 @@ class LLMProvider(ABC):
|
|||||||
)
|
)
|
||||||
retry_kw = dict(kw)
|
retry_kw = dict(kw)
|
||||||
retry_kw["messages"] = stripped
|
retry_kw["messages"] = stripped
|
||||||
result = await call(**retry_kw)
|
return await call(**retry_kw)
|
||||||
# Permanently strip images from the original messages so
|
|
||||||
# subsequent iterations do not repeat the error-retry cycle.
|
|
||||||
if result.finish_reason != "error":
|
|
||||||
self._strip_image_content_inplace(original_messages)
|
|
||||||
return result
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
if persistent and identical_error_count >= self._PERSISTENT_IDENTICAL_ERROR_LIMIT:
|
if persistent and identical_error_count >= self._PERSISTENT_IDENTICAL_ERROR_LIMIT:
|
||||||
|
|||||||
@@ -26,12 +26,6 @@ else:
|
|||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
from nanobot.providers.openai_responses import (
|
|
||||||
consume_sdk_stream,
|
|
||||||
convert_messages,
|
|
||||||
convert_tools,
|
|
||||||
parse_response_output,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.providers.registry import ProviderSpec
|
from nanobot.providers.registry import ProviderSpec
|
||||||
@@ -119,14 +113,6 @@ def _uses_openrouter_attribution(spec: "ProviderSpec | None", api_base: str | No
|
|||||||
return bool(api_base and "openrouter" in api_base.lower())
|
return bool(api_base and "openrouter" in api_base.lower())
|
||||||
|
|
||||||
|
|
||||||
def _is_direct_openai_base(api_base: str | None) -> bool:
|
|
||||||
"""Return True for direct OpenAI endpoints, not generic OpenAI-compatible gateways."""
|
|
||||||
if not api_base:
|
|
||||||
return True
|
|
||||||
normalized = api_base.strip().lower().rstrip("/")
|
|
||||||
return "api.openai.com" in normalized and "openrouter" not in normalized
|
|
||||||
|
|
||||||
|
|
||||||
class OpenAICompatProvider(LLMProvider):
|
class OpenAICompatProvider(LLMProvider):
|
||||||
"""Unified provider for all OpenAI-compatible APIs.
|
"""Unified provider for all OpenAI-compatible APIs.
|
||||||
|
|
||||||
@@ -151,7 +137,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self._setup_env(api_key, api_base)
|
self._setup_env(api_key, api_base)
|
||||||
|
|
||||||
effective_base = api_base or (spec.default_api_base if spec else None) or None
|
effective_base = api_base or (spec.default_api_base if spec else None) or None
|
||||||
self._effective_base = effective_base
|
|
||||||
default_headers = {"x-session-affinity": uuid.uuid4().hex}
|
default_headers = {"x-session-affinity": uuid.uuid4().hex}
|
||||||
if _uses_openrouter_attribution(spec, effective_base):
|
if _uses_openrouter_attribution(spec, effective_base):
|
||||||
default_headers.update(_DEFAULT_OPENROUTER_HEADERS)
|
default_headers.update(_DEFAULT_OPENROUTER_HEADERS)
|
||||||
@@ -243,13 +228,9 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
tc_clean["id"] = map_id(tc_clean.get("id"))
|
tc_clean["id"] = map_id(tc_clean.get("id"))
|
||||||
normalized.append(tc_clean)
|
normalized.append(tc_clean)
|
||||||
clean["tool_calls"] = normalized
|
clean["tool_calls"] = normalized
|
||||||
if clean.get("role") == "assistant":
|
|
||||||
# Some OpenAI-compatible gateways reject assistant messages
|
|
||||||
# that mix non-empty content with tool_calls.
|
|
||||||
clean["content"] = None
|
|
||||||
if "tool_call_id" in clean and clean["tool_call_id"]:
|
if "tool_call_id" in clean and clean["tool_call_id"]:
|
||||||
clean["tool_call_id"] = map_id(clean["tool_call_id"])
|
clean["tool_call_id"] = map_id(clean["tool_call_id"])
|
||||||
return self._enforce_role_alternation(sanitized)
|
return sanitized
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Build kwargs
|
# Build kwargs
|
||||||
@@ -340,88 +321,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
|
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def _should_use_responses_api(
|
|
||||||
self,
|
|
||||||
model: str | None,
|
|
||||||
reasoning_effort: str | None,
|
|
||||||
) -> bool:
|
|
||||||
"""Use Responses API only for direct OpenAI requests that benefit from it."""
|
|
||||||
if self._spec and self._spec.name != "openai":
|
|
||||||
return False
|
|
||||||
if not _is_direct_openai_base(self._effective_base):
|
|
||||||
return False
|
|
||||||
|
|
||||||
model_name = (model or self.default_model).lower()
|
|
||||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
|
||||||
return True
|
|
||||||
return any(token in model_name for token in ("gpt-5", "o1", "o3", "o4"))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _should_fallback_from_responses_error(e: Exception) -> bool:
|
|
||||||
"""Fallback only for likely Responses API compatibility errors."""
|
|
||||||
response = getattr(e, "response", None)
|
|
||||||
status_code = getattr(e, "status_code", None)
|
|
||||||
if status_code is None and response is not None:
|
|
||||||
status_code = getattr(response, "status_code", None)
|
|
||||||
if status_code not in {400, 404, 422}:
|
|
||||||
return False
|
|
||||||
|
|
||||||
body = (
|
|
||||||
getattr(e, "body", None)
|
|
||||||
or getattr(e, "doc", None)
|
|
||||||
or getattr(response, "text", None)
|
|
||||||
)
|
|
||||||
body_text = str(body).lower() if body is not None else ""
|
|
||||||
compatibility_markers = (
|
|
||||||
"responses",
|
|
||||||
"response api",
|
|
||||||
"max_output_tokens",
|
|
||||||
"instructions",
|
|
||||||
"previous_response",
|
|
||||||
"unsupported",
|
|
||||||
"not supported",
|
|
||||||
"unknown parameter",
|
|
||||||
"unrecognized request argument",
|
|
||||||
)
|
|
||||||
return any(marker in body_text for marker in compatibility_markers)
|
|
||||||
|
|
||||||
def _build_responses_body(
|
|
||||||
self,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
tools: list[dict[str, Any]] | None,
|
|
||||||
model: str | None,
|
|
||||||
max_tokens: int,
|
|
||||||
temperature: float,
|
|
||||||
reasoning_effort: str | None,
|
|
||||||
tool_choice: str | dict[str, Any] | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Build a Responses API body for direct OpenAI requests."""
|
|
||||||
model_name = model or self.default_model
|
|
||||||
sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages))
|
|
||||||
instructions, input_items = convert_messages(sanitized_messages)
|
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
|
||||||
"model": model_name,
|
|
||||||
"instructions": instructions or None,
|
|
||||||
"input": input_items,
|
|
||||||
"max_output_tokens": max(1, max_tokens),
|
|
||||||
"store": False,
|
|
||||||
"stream": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
if self._supports_temperature(model_name, reasoning_effort):
|
|
||||||
body["temperature"] = temperature
|
|
||||||
|
|
||||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
|
||||||
body["reasoning"] = {"effort": reasoning_effort}
|
|
||||||
body["include"] = ["reasoning.encrypted_content"]
|
|
||||||
|
|
||||||
if tools:
|
|
||||||
body["tools"] = convert_tools(tools)
|
|
||||||
body["tool_choice"] = tool_choice or "auto"
|
|
||||||
|
|
||||||
return body
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Response parsing
|
# Response parsing
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -556,12 +455,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
finish_reason = str(choice0.get("finish_reason") or "stop")
|
finish_reason = str(choice0.get("finish_reason") or "stop")
|
||||||
|
|
||||||
raw_tool_calls: list[Any] = []
|
raw_tool_calls: list[Any] = []
|
||||||
# StepFun Plan: fallback to reasoning field when content is empty
|
|
||||||
if not content and msg0.get("reasoning"):
|
|
||||||
content = self._extract_text_content(msg0.get("reasoning"))
|
|
||||||
reasoning_content = msg0.get("reasoning_content")
|
reasoning_content = msg0.get("reasoning_content")
|
||||||
if not reasoning_content and msg0.get("reasoning"):
|
|
||||||
reasoning_content = self._extract_text_content(msg0.get("reasoning"))
|
|
||||||
for ch in choices:
|
for ch in choices:
|
||||||
ch_map = self._maybe_mapping(ch) or {}
|
ch_map = self._maybe_mapping(ch) or {}
|
||||||
m = self._maybe_mapping(ch_map.get("message")) or {}
|
m = self._maybe_mapping(ch_map.get("message")) or {}
|
||||||
@@ -617,8 +511,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
finish_reason = ch.finish_reason
|
finish_reason = ch.finish_reason
|
||||||
if not content and m.content:
|
if not content and m.content:
|
||||||
content = m.content
|
content = m.content
|
||||||
if not content and getattr(m, "reasoning", None):
|
|
||||||
content = m.reasoning
|
|
||||||
|
|
||||||
tool_calls = []
|
tool_calls = []
|
||||||
for tc in raw_tool_calls:
|
for tc in raw_tool_calls:
|
||||||
@@ -635,16 +527,12 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
function_provider_specific_fields=fn_prov,
|
function_provider_specific_fields=fn_prov,
|
||||||
))
|
))
|
||||||
|
|
||||||
reasoning_content = getattr(msg, "reasoning_content", None) or None
|
|
||||||
if not reasoning_content and getattr(msg, "reasoning", None):
|
|
||||||
reasoning_content = msg.reasoning
|
|
||||||
|
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=content,
|
content=content,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
finish_reason=finish_reason or "stop",
|
finish_reason=finish_reason or "stop",
|
||||||
usage=self._extract_usage(response),
|
usage=self._extract_usage(response),
|
||||||
reasoning_content=reasoning_content,
|
reasoning_content=getattr(msg, "reasoning_content", None) or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -705,8 +593,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if text:
|
if text:
|
||||||
content_parts.append(text)
|
content_parts.append(text)
|
||||||
text = cls._extract_text_content(delta.get("reasoning_content"))
|
text = cls._extract_text_content(delta.get("reasoning_content"))
|
||||||
if not text:
|
|
||||||
text = cls._extract_text_content(delta.get("reasoning"))
|
|
||||||
if text:
|
if text:
|
||||||
reasoning_parts.append(text)
|
reasoning_parts.append(text)
|
||||||
for idx, tc in enumerate(delta.get("tool_calls") or []):
|
for idx, tc in enumerate(delta.get("tool_calls") or []):
|
||||||
@@ -725,8 +611,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
content_parts.append(delta.content)
|
content_parts.append(delta.content)
|
||||||
if delta:
|
if delta:
|
||||||
reasoning = getattr(delta, "reasoning_content", None)
|
reasoning = getattr(delta, "reasoning_content", None)
|
||||||
if not reasoning:
|
|
||||||
reasoning = getattr(delta, "reasoning", None)
|
|
||||||
if reasoning:
|
if reasoning:
|
||||||
reasoning_parts.append(reasoning)
|
reasoning_parts.append(reasoning)
|
||||||
for tc in (delta.tool_calls or []) if delta else []:
|
for tc in (delta.tool_calls or []) if delta else []:
|
||||||
@@ -799,12 +683,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_error(
|
def _handle_error(e: Exception) -> LLMResponse:
|
||||||
e: Exception,
|
|
||||||
*,
|
|
||||||
spec: ProviderSpec | None = None,
|
|
||||||
api_base: str | None = None,
|
|
||||||
) -> LLMResponse:
|
|
||||||
body = (
|
body = (
|
||||||
getattr(e, "doc", None)
|
getattr(e, "doc", None)
|
||||||
or getattr(e, "body", None)
|
or getattr(e, "body", None)
|
||||||
@@ -812,15 +691,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
body_text = body if isinstance(body, str) else str(body) if body is not None else ""
|
body_text = body if isinstance(body, str) else str(body) if body is not None else ""
|
||||||
msg = f"Error: {body_text.strip()[:500]}" if body_text.strip() else f"Error calling LLM: {e}"
|
msg = f"Error: {body_text.strip()[:500]}" if body_text.strip() else f"Error calling LLM: {e}"
|
||||||
|
|
||||||
text = f"{body_text} {e}".lower()
|
|
||||||
if spec and spec.is_local and ("502" in text or "connection" in text or "refused" in text):
|
|
||||||
msg += (
|
|
||||||
"\nHint: this is a local model endpoint. Check that the local server is reachable at "
|
|
||||||
f"{api_base or spec.default_api_base}, and if you are using a proxy/tunnel, make sure it "
|
|
||||||
"can reach your local Ollama/vLLM service instead of routing localhost through the remote host."
|
|
||||||
)
|
|
||||||
|
|
||||||
response = getattr(e, "response", None)
|
response = getattr(e, "response", None)
|
||||||
retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None))
|
retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None))
|
||||||
if retry_after is None:
|
if retry_after is None:
|
||||||
@@ -846,25 +716,14 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
kwargs = self._build_kwargs(
|
||||||
|
messages, tools, model, max_tokens, temperature,
|
||||||
|
reasoning_effort, tool_choice,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
if self._should_use_responses_api(model, reasoning_effort):
|
|
||||||
try:
|
|
||||||
body = self._build_responses_body(
|
|
||||||
messages, tools, model, max_tokens, temperature,
|
|
||||||
reasoning_effort, tool_choice,
|
|
||||||
)
|
|
||||||
return parse_response_output(await self._client.responses.create(**body))
|
|
||||||
except Exception as responses_error:
|
|
||||||
if not self._should_fallback_from_responses_error(responses_error):
|
|
||||||
raise
|
|
||||||
|
|
||||||
kwargs = self._build_kwargs(
|
|
||||||
messages, tools, model, max_tokens, temperature,
|
|
||||||
reasoning_effort, tool_choice,
|
|
||||||
)
|
|
||||||
return self._parse(await self._client.chat.completions.create(**kwargs))
|
return self._parse(await self._client.chat.completions.create(**kwargs))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._handle_error(e, spec=self._spec, api_base=self.api_base)
|
return self._handle_error(e)
|
||||||
|
|
||||||
async def chat_stream(
|
async def chat_stream(
|
||||||
self,
|
self,
|
||||||
@@ -877,49 +736,14 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
kwargs = self._build_kwargs(
|
||||||
|
messages, tools, model, max_tokens, temperature,
|
||||||
|
reasoning_effort, tool_choice,
|
||||||
|
)
|
||||||
|
kwargs["stream"] = True
|
||||||
|
kwargs["stream_options"] = {"include_usage": True}
|
||||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||||
try:
|
try:
|
||||||
if self._should_use_responses_api(model, reasoning_effort):
|
|
||||||
try:
|
|
||||||
body = self._build_responses_body(
|
|
||||||
messages, tools, model, max_tokens, temperature,
|
|
||||||
reasoning_effort, tool_choice,
|
|
||||||
)
|
|
||||||
body["stream"] = True
|
|
||||||
stream = await self._client.responses.create(**body)
|
|
||||||
|
|
||||||
async def _timed_stream():
|
|
||||||
stream_iter = stream.__aiter__()
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
yield await asyncio.wait_for(
|
|
||||||
stream_iter.__anext__(),
|
|
||||||
timeout=idle_timeout_s,
|
|
||||||
)
|
|
||||||
except StopAsyncIteration:
|
|
||||||
break
|
|
||||||
|
|
||||||
content, tool_calls, finish_reason, usage, reasoning_content = await consume_sdk_stream(
|
|
||||||
_timed_stream(),
|
|
||||||
on_content_delta,
|
|
||||||
)
|
|
||||||
return LLMResponse(
|
|
||||||
content=content or None,
|
|
||||||
tool_calls=tool_calls,
|
|
||||||
finish_reason=finish_reason,
|
|
||||||
usage=usage,
|
|
||||||
reasoning_content=reasoning_content,
|
|
||||||
)
|
|
||||||
except Exception as responses_error:
|
|
||||||
if not self._should_fallback_from_responses_error(responses_error):
|
|
||||||
raise
|
|
||||||
|
|
||||||
kwargs = self._build_kwargs(
|
|
||||||
messages, tools, model, max_tokens, temperature,
|
|
||||||
reasoning_effort, tool_choice,
|
|
||||||
)
|
|
||||||
kwargs["stream"] = True
|
|
||||||
kwargs["stream_options"] = {"include_usage": True}
|
|
||||||
stream = await self._client.chat.completions.create(**kwargs)
|
stream = await self._client.chat.completions.create(**kwargs)
|
||||||
chunks: list[Any] = []
|
chunks: list[Any] = []
|
||||||
stream_iter = stream.__aiter__()
|
stream_iter = stream.__aiter__()
|
||||||
@@ -947,7 +771,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
error_kind="timeout",
|
error_kind="timeout",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._handle_error(e, spec=self._spec, api_base=self.api_base)
|
return self._handle_error(e)
|
||||||
|
|
||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
return self.default_model
|
return self.default_model
|
||||||
|
|||||||
@@ -57,12 +57,6 @@ class Session:
|
|||||||
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content"):
|
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content"):
|
||||||
if key in message:
|
if key in message:
|
||||||
entry[key] = message[key]
|
entry[key] = message[key]
|
||||||
# Annotate cross-channel messages so the LLM knows the provenance,
|
|
||||||
# but keep the entry clean of internal metadata keys.
|
|
||||||
if message.get("_cross_channel"):
|
|
||||||
source = message.get("_source_session", "unknown")
|
|
||||||
prefix = f"[Sent from {source}] "
|
|
||||||
entry["content"] = prefix + (entry.get("content") or "")
|
|
||||||
out.append(entry)
|
out.append(entry)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Agent Instructions
|
# Agent Instructions
|
||||||
|
|
||||||
|
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||||
|
|
||||||
## Scheduled Reminders
|
## Scheduled Reminders
|
||||||
|
|
||||||
Before scheduling reminders, check available skills and follow skill guidance first.
|
Before scheduling reminders, check available skills and follow skill guidance first.
|
||||||
|
|||||||
@@ -2,8 +2,20 @@
|
|||||||
|
|
||||||
I am nanobot 🐈, a personal AI assistant.
|
I am nanobot 🐈, a personal AI assistant.
|
||||||
|
|
||||||
I solve problems by doing, not by describing what I would do.
|
## Personality
|
||||||
I keep responses short unless depth is asked for.
|
|
||||||
I say what I know, flag what I don't, and never fake confidence.
|
- Helpful and friendly
|
||||||
I stay friendly and curious — I'd rather ask a good question than guess wrong.
|
- Concise and to the point
|
||||||
I treat the user's time as the scarcest resource, and their trust as the most valuable.
|
- Curious and eager to learn
|
||||||
|
|
||||||
|
## Values
|
||||||
|
|
||||||
|
- Accuracy over speed
|
||||||
|
- User privacy and safety
|
||||||
|
- Transparency in actions
|
||||||
|
|
||||||
|
## Communication Style
|
||||||
|
|
||||||
|
- Be clear and direct
|
||||||
|
- Explain reasoning when helpful
|
||||||
|
- Ask clarifying questions when needed
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ Compare conversation history against current memory files. Also scan memory file
|
|||||||
Output one line per finding:
|
Output one line per finding:
|
||||||
[FILE] atomic fact (not already in memory)
|
[FILE] atomic fact (not already in memory)
|
||||||
[FILE-REMOVE] reason for removal
|
[FILE-REMOVE] reason for removal
|
||||||
[SKILL] kebab-case-name: one-line description of the reusable pattern
|
|
||||||
|
|
||||||
Files: USER (identity, preferences), SOUL (bot behavior, tone), MEMORY (knowledge, project context)
|
Files: USER (identity, preferences), SOUL (bot behavior, tone), MEMORY (knowledge, project context)
|
||||||
|
|
||||||
@@ -19,12 +18,6 @@ Staleness — flag for [FILE-REMOVE]:
|
|||||||
- Detailed incident info after 14 days — reduce to one-line summary
|
- Detailed incident info after 14 days — reduce to one-line summary
|
||||||
- Superseded: approaches replaced by newer solutions, deprecated dependencies
|
- Superseded: approaches replaced by newer solutions, deprecated dependencies
|
||||||
|
|
||||||
Skill discovery — flag [SKILL] when ALL of these are true:
|
|
||||||
- A specific, repeatable workflow appeared 2+ times in the conversation history
|
|
||||||
- It involves clear steps (not vague preferences like "likes concise answers")
|
|
||||||
- It is substantial enough to warrant its own instruction set (not trivial like "read a file")
|
|
||||||
- Do not worry about duplicates — the next phase will check against existing skills
|
|
||||||
|
|
||||||
Do not add: current weather, transient status, temporary errors, conversational filler.
|
Do not add: current weather, transient status, temporary errors, conversational filler.
|
||||||
|
|
||||||
[SKIP] if nothing needs updating.
|
[SKIP] if nothing needs updating.
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
Update memory files based on the analysis below.
|
Update memory files based on the analysis below.
|
||||||
- [FILE] entries: add the described content to the appropriate file
|
- [FILE] entries: add the described content to the appropriate file
|
||||||
- [FILE-REMOVE] entries: delete the corresponding content from memory files
|
- [FILE-REMOVE] entries: delete the corresponding content from memory files
|
||||||
- [SKILL] entries: create a new skill under skills/<name>/SKILL.md using write_file
|
|
||||||
|
|
||||||
## File paths (relative to workspace root)
|
## File paths (relative to workspace root)
|
||||||
- SOUL.md
|
- SOUL.md
|
||||||
- USER.md
|
- USER.md
|
||||||
- memory/MEMORY.md
|
- memory/MEMORY.md
|
||||||
- skills/<name>/SKILL.md (for [SKILL] entries only)
|
|
||||||
|
|
||||||
Do NOT guess paths.
|
Do NOT guess paths.
|
||||||
|
|
||||||
@@ -19,17 +17,6 @@ Do NOT guess paths.
|
|||||||
- Surgical edits only — never rewrite entire files
|
- Surgical edits only — never rewrite entire files
|
||||||
- If nothing to update, stop without calling tools
|
- If nothing to update, stop without calling tools
|
||||||
|
|
||||||
## Skill creation rules (for [SKILL] entries)
|
|
||||||
- Use write_file to create skills/<name>/SKILL.md
|
|
||||||
- Before writing, read_file `{{ skill_creator_path }}` for format reference (frontmatter structure, naming conventions, quality standards)
|
|
||||||
- **Dedup check**: read existing skills listed below to verify the new skill is not functionally redundant. Skip creation if an existing skill already covers the same workflow.
|
|
||||||
- Include YAML frontmatter with name and description fields
|
|
||||||
- Keep SKILL.md under 2000 words — concise and actionable
|
|
||||||
- Include: when to use, steps, output format, at least one example
|
|
||||||
- Do NOT overwrite existing skills — skip if the skill directory already exists
|
|
||||||
- Reference specific tools the agent has access to (read_file, write_file, exec, web_search, etc.)
|
|
||||||
- Skills are instruction sets, not code — do not include implementation code
|
|
||||||
|
|
||||||
## Quality
|
## Quality
|
||||||
- Every line must carry standalone value
|
- Every line must carry standalone value
|
||||||
- Concise bullets under clear headers
|
- Concise bullets under clear headers
|
||||||
|
|||||||
@@ -12,32 +12,15 @@ Your workspace is at: {{ workspace_path }}
|
|||||||
- Custom skills: {{ workspace_path }}/skills/{% raw %}{skill-name}{% endraw %}/SKILL.md
|
- Custom skills: {{ workspace_path }}/skills/{% raw %}{skill-name}{% endraw %}/SKILL.md
|
||||||
|
|
||||||
{{ platform_policy }}
|
{{ platform_policy }}
|
||||||
{% if channel == 'telegram' or channel == 'qq' or channel == 'discord' %}
|
|
||||||
## Format Hint
|
|
||||||
This conversation is on a messaging app. Use short paragraphs. Avoid large headings (#, ##). Use **bold** sparingly. No tables — use plain lists.
|
|
||||||
{% elif channel == 'whatsapp' or channel == 'sms' %}
|
|
||||||
## Format Hint
|
|
||||||
This conversation is on a text messaging platform that does not render markdown. Use plain text only.
|
|
||||||
{% elif channel == 'email' %}
|
|
||||||
## Format Hint
|
|
||||||
This conversation is via email. Structure with clear sections. Markdown may not render — keep formatting simple.
|
|
||||||
{% elif channel == 'cli' or channel == 'mochat' %}
|
|
||||||
## Format Hint
|
|
||||||
Output is rendered in a terminal. Avoid markdown headings and tables. Use plain text with minimal formatting.
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
## Execution Rules
|
## nanobot Guidelines
|
||||||
|
- State intent before tool calls, but NEVER predict or claim results before receiving them.
|
||||||
- Act, don't narrate. If you can do it with a tool, do it now — never end a turn with just a plan or promise.
|
- Before modifying a file, read it first. Do not assume files or directories exist.
|
||||||
- Read before you write. Do not assume a file exists or contains what you expect.
|
- After writing or editing a file, re-read it if accuracy matters.
|
||||||
- If a tool call fails, diagnose the error and retry with a different approach before reporting failure.
|
- If a tool call fails, analyze the error before retrying with a different approach.
|
||||||
- When information is missing, look it up with tools first. Only ask the user when tools cannot answer.
|
- Ask for clarification when the request is ambiguous.
|
||||||
- After multi-step changes, verify the result (re-read the file, run the test, check the output).
|
- Prefer built-in `grep` / `glob` tools for workspace search before falling back to `exec`.
|
||||||
|
- On broad searches, use `grep(output_mode="count")` or `grep(output_mode="files_with_matches")` to scope the result set before requesting full content.
|
||||||
## Search & Discovery
|
|
||||||
|
|
||||||
- Prefer built-in `grep` / `glob` over `exec` for workspace search.
|
|
||||||
- On broad searches, use `grep(output_mode="count")` to scope before requesting full content.
|
|
||||||
{% include 'agent/_snippets/untrusted_content.md' %}
|
{% include 'agent/_snippets/untrusted_content.md' %}
|
||||||
|
|
||||||
Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.
|
Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ from loguru import logger
|
|||||||
def strip_think(text: str) -> str:
|
def strip_think(text: str) -> str:
|
||||||
"""Remove thinking blocks and any unclosed trailing tag."""
|
"""Remove thinking blocks and any unclosed trailing tag."""
|
||||||
text = re.sub(r"<think>[\s\S]*?</think>", "", text)
|
text = re.sub(r"<think>[\s\S]*?</think>", "", text)
|
||||||
text = re.sub(r"^\s*<think>[\s\S]*$", "", text)
|
text = re.sub(r"<think>[\s\S]*$", "", text)
|
||||||
# Gemma 4 and similar models use <thought>...</thought> blocks
|
# Gemma 4 and similar models use <thought>...</thought> blocks
|
||||||
text = re.sub(r"<thought>[\s\S]*?</thought>", "", text)
|
text = re.sub(r"<thought>[\s\S]*?</thought>", "", text)
|
||||||
text = re.sub(r"^\s*<thought>[\s\S]*$", "", text)
|
text = re.sub(r"<thought>[\s\S]*$", "", text)
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ def build_assistant_message(
|
|||||||
thinking_blocks: list[dict] | None = None,
|
thinking_blocks: list[dict] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build a provider-safe assistant message with optional reasoning fields."""
|
"""Build a provider-safe assistant message with optional reasoning fields."""
|
||||||
msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
|
msg: dict[str, Any] = {"role": "assistant", "content": content}
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
msg["tool_calls"] = tool_calls
|
msg["tool_calls"] = tool_calls
|
||||||
if reasoning_content is not None or thinking_blocks:
|
if reasoning_content is not None or thinking_blocks:
|
||||||
@@ -420,7 +420,7 @@ def build_status_content(
|
|||||||
ctx_total = max(context_window_tokens, 0)
|
ctx_total = max(context_window_tokens, 0)
|
||||||
ctx_pct = int((context_tokens_estimate / ctx_total) * 100) if ctx_total > 0 else 0
|
ctx_pct = int((context_tokens_estimate / ctx_total) * 100) if ctx_total > 0 else 0
|
||||||
ctx_used_str = f"{context_tokens_estimate // 1000}k" if context_tokens_estimate >= 1000 else str(context_tokens_estimate)
|
ctx_used_str = f"{context_tokens_estimate // 1000}k" if context_tokens_estimate >= 1000 else str(context_tokens_estimate)
|
||||||
ctx_total_str = f"{ctx_total // 1000}k" if ctx_total > 0 else "n/a"
|
ctx_total_str = f"{ctx_total // 1024}k" if ctx_total > 0 else "n/a"
|
||||||
token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out"
|
token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out"
|
||||||
if cached and last_in:
|
if cached and last_in:
|
||||||
token_line += f" ({cached * 100 // last_in}% cached)"
|
token_line += f" ({cached * 100 // last_in}% cached)"
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ FINALIZATION_RETRY_PROMPT = (
|
|||||||
"Please provide your response to the user based on the conversation above."
|
"Please provide your response to the user based on the conversation above."
|
||||||
)
|
)
|
||||||
|
|
||||||
LENGTH_RECOVERY_PROMPT = (
|
|
||||||
"Output limit reached. Continue exactly where you left off "
|
|
||||||
"— no recap, no apology. Break remaining work into smaller steps if needed."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def empty_tool_result_message(tool_name: str) -> str:
|
def empty_tool_result_message(tool_name: str) -> str:
|
||||||
"""Short prompt-safe marker for tools that completed without visible output."""
|
"""Short prompt-safe marker for tools that completed without visible output."""
|
||||||
@@ -55,11 +50,6 @@ def build_finalization_retry_message() -> dict[str, str]:
|
|||||||
return {"role": "user", "content": FINALIZATION_RETRY_PROMPT}
|
return {"role": "user", "content": FINALIZATION_RETRY_PROMPT}
|
||||||
|
|
||||||
|
|
||||||
def build_length_recovery_message() -> dict[str, str]:
|
|
||||||
"""Prompt the model to continue after hitting output token limit."""
|
|
||||||
return {"role": "user", "content": LENGTH_RECOVERY_PROMPT}
|
|
||||||
|
|
||||||
|
|
||||||
def external_lookup_signature(tool_name: str, arguments: dict[str, Any]) -> str | None:
|
def external_lookup_signature(tool_name: str, arguments: dict[str, Any]) -> str | None:
|
||||||
"""Stable signature for repeated external lookups we want to throttle."""
|
"""Stable signature for repeated external lookups we want to throttle."""
|
||||||
if tool_name == "web_fetch":
|
if tool_name == "web_fetch":
|
||||||
|
|||||||
@@ -19,11 +19,9 @@ _TOOL_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = {
|
|||||||
"list_dir": (["path"], "ls {}", True, False),
|
"list_dir": (["path"], "ls {}", True, False),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Matches file paths embedded in shell commands, including quoted paths with spaces.
|
# Matches file paths embedded in shell commands (Windows drive, ~/, or absolute after space)
|
||||||
_PATH_IN_CMD_RE = re.compile(
|
_PATH_IN_CMD_RE = re.compile(
|
||||||
r'"(?P<double>(?:[A-Za-z]:[/\\]|~/|/)[^"]+)"'
|
r"(?:[A-Za-z]:[/\\]|~/|(?<=\s)/)[^\s;&|<>\"']+"
|
||||||
r"|'(?P<single>(?:[A-Za-z]:[/\\]|~/|/)[^']+)'"
|
|
||||||
r"|(?P<bare>(?:[A-Za-z]:[/\\]|~/|(?<=\s)/)[^\s;&|<>\"']+)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -94,14 +92,9 @@ def _fmt_known(tc, fmt: tuple) -> str:
|
|||||||
|
|
||||||
def _abbreviate_command(cmd: str, max_len: int = 40) -> str:
|
def _abbreviate_command(cmd: str, max_len: int = 40) -> str:
|
||||||
"""Abbreviate paths in a command string, then truncate."""
|
"""Abbreviate paths in a command string, then truncate."""
|
||||||
def _replace_path(match: re.Match[str]) -> str:
|
abbreviated = _PATH_IN_CMD_RE.sub(
|
||||||
if match.group("double") is not None:
|
lambda m: abbreviate_path(m.group(), max_len=25), cmd
|
||||||
return f'"{abbreviate_path(match.group("double"), max_len=25)}"'
|
)
|
||||||
if match.group("single") is not None:
|
|
||||||
return f"'{abbreviate_path(match.group('single'), max_len=25)}'"
|
|
||||||
return abbreviate_path(match.group("bare"), max_len=25)
|
|
||||||
|
|
||||||
abbreviated = _PATH_IN_CMD_RE.sub(_replace_path, cmd)
|
|
||||||
if len(abbreviated) <= max_len:
|
if len(abbreviated) <= max_len:
|
||||||
return abbreviated
|
return abbreviated
|
||||||
return abbreviated[:max_len - 1] + "\u2026"
|
return abbreviated[:max_len - 1] + "\u2026"
|
||||||
|
|||||||
+4
-5
@@ -50,7 +50,6 @@ dependencies = [
|
|||||||
"tiktoken>=0.12.0,<1.0.0",
|
"tiktoken>=0.12.0,<1.0.0",
|
||||||
"jinja2>=3.1.0,<4.0.0",
|
"jinja2>=3.1.0,<4.0.0",
|
||||||
"dulwich>=0.22.0,<1.0.0",
|
"dulwich>=0.22.0,<1.0.0",
|
||||||
"filelock>=3.25.2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -64,6 +63,10 @@ weixin = [
|
|||||||
"qrcode[pil]>=8.0",
|
"qrcode[pil]>=8.0",
|
||||||
"pycryptodome>=3.20.0",
|
"pycryptodome>=3.20.0",
|
||||||
]
|
]
|
||||||
|
msteams = [
|
||||||
|
"PyJWT>=2.0,<3.0",
|
||||||
|
"cryptography>=41.0",
|
||||||
|
]
|
||||||
|
|
||||||
matrix = [
|
matrix = [
|
||||||
"matrix-nio[e2e]>=0.25.2",
|
"matrix-nio[e2e]>=0.25.2",
|
||||||
@@ -76,16 +79,12 @@ discord = [
|
|||||||
langsmith = [
|
langsmith = [
|
||||||
"langsmith>=0.1.0",
|
"langsmith>=0.1.0",
|
||||||
]
|
]
|
||||||
pdf = [
|
|
||||||
"pymupdf>=1.25.0",
|
|
||||||
]
|
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.0,<10.0.0",
|
"pytest>=9.0.0,<10.0.0",
|
||||||
"pytest-asyncio>=1.3.0,<2.0.0",
|
"pytest-asyncio>=1.3.0,<2.0.0",
|
||||||
"aiohttp>=3.9.0,<4.0.0",
|
"aiohttp>=3.9.0,<4.0.0",
|
||||||
"pytest-cov>=6.0.0,<7.0.0",
|
"pytest-cov>=6.0.0,<7.0.0",
|
||||||
"ruff>=0.1.0",
|
"ruff>=0.1.0",
|
||||||
"pymupdf>=1.25.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
+115
-194
@@ -35,13 +35,6 @@ def _make_loop(tmp_path: Path, session_ttl_minutes: int = 15) -> AgentLoop:
|
|||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|
||||||
def _add_turns(session, turns: int, *, prefix: str = "msg") -> None:
|
|
||||||
"""Append simple user/assistant turns to a session."""
|
|
||||||
for i in range(turns):
|
|
||||||
session.add_message("user", f"{prefix} user {i}")
|
|
||||||
session.add_message("assistant", f"{prefix} assistant {i}")
|
|
||||||
|
|
||||||
|
|
||||||
class TestSessionTTLConfig:
|
class TestSessionTTLConfig:
|
||||||
"""Test session TTL configuration."""
|
"""Test session TTL configuration."""
|
||||||
|
|
||||||
@@ -55,23 +48,6 @@ class TestSessionTTLConfig:
|
|||||||
defaults = AgentDefaults(session_ttl_minutes=30)
|
defaults = AgentDefaults(session_ttl_minutes=30)
|
||||||
assert defaults.session_ttl_minutes == 30
|
assert defaults.session_ttl_minutes == 30
|
||||||
|
|
||||||
def test_user_friendly_alias_is_supported(self):
|
|
||||||
"""Config should accept idleCompactAfterMinutes as the preferred JSON key."""
|
|
||||||
defaults = AgentDefaults.model_validate({"idleCompactAfterMinutes": 30})
|
|
||||||
assert defaults.session_ttl_minutes == 30
|
|
||||||
|
|
||||||
def test_legacy_alias_is_still_supported(self):
|
|
||||||
"""Config should still accept the old sessionTtlMinutes key for compatibility."""
|
|
||||||
defaults = AgentDefaults.model_validate({"sessionTtlMinutes": 30})
|
|
||||||
assert defaults.session_ttl_minutes == 30
|
|
||||||
|
|
||||||
def test_serializes_with_user_friendly_alias(self):
|
|
||||||
"""Config dumps should use idleCompactAfterMinutes for JSON output."""
|
|
||||||
defaults = AgentDefaults(session_ttl_minutes=30)
|
|
||||||
data = defaults.model_dump(mode="json", by_alias=True)
|
|
||||||
assert data["idleCompactAfterMinutes"] == 30
|
|
||||||
assert "sessionTtlMinutes" not in data
|
|
||||||
|
|
||||||
|
|
||||||
class TestAgentLoopTTLParam:
|
class TestAgentLoopTTLParam:
|
||||||
"""Test that AutoCompact receives and stores session_ttl_minutes."""
|
"""Test that AutoCompact receives and stores session_ttl_minutes."""
|
||||||
@@ -125,7 +101,7 @@ class TestAutoCompact:
|
|||||||
loop.sessions.save(s2)
|
loop.sessions.save(s2)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
loop.auto_compact.check_expired(loop._schedule_background)
|
loop.auto_compact.check_expired(loop._schedule_background)
|
||||||
@@ -137,28 +113,28 @@ class TestAutoCompact:
|
|||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_archives_prefix_and_keeps_recent_suffix(self, tmp_path):
|
async def test_auto_compact_archives_and_clears(self, tmp_path):
|
||||||
"""_archive should summarize the old prefix and keep a recent legal suffix."""
|
"""_archive should archive un-consolidated messages and clear session."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6)
|
for i in range(4):
|
||||||
|
session.add_message("user", f"msg{i}")
|
||||||
|
session.add_message("assistant", f"resp{i}")
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
archived_messages = []
|
archived_messages = []
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
archived_messages.extend(messages)
|
archived_messages.extend(messages)
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
assert len(archived_messages) == 4
|
assert len(archived_messages) == 8
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(session_after.messages) == 0
|
||||||
assert session_after.messages[0]["content"] == "msg user 2"
|
|
||||||
assert session_after.messages[-1]["content"] == "msg assistant 5"
|
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -166,13 +142,17 @@ class TestAutoCompact:
|
|||||||
"""_archive should store the summary in _summaries."""
|
"""_archive should store the summary in _summaries."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="hello")
|
session.add_message("user", "hello")
|
||||||
|
session.add_message("assistant", "hi there")
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
return "User said hello."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "User said hello.",
|
||||||
|
}
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
@@ -180,7 +160,7 @@ class TestAutoCompact:
|
|||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry[0] == "User said hello."
|
assert entry[0] == "User said hello."
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(session_after.messages) == 0
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -194,7 +174,7 @@ class TestAutoCompact:
|
|||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
nonlocal archive_called
|
nonlocal archive_called
|
||||||
archive_called = True
|
archive_called = True
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
|
||||||
@@ -210,7 +190,9 @@ class TestAutoCompact:
|
|||||||
"""_archive should only archive un-consolidated messages."""
|
"""_archive should only archive un-consolidated messages."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 14)
|
for i in range(10):
|
||||||
|
session.add_message("user", f"msg{i}")
|
||||||
|
session.add_message("assistant", f"resp{i}")
|
||||||
session.last_consolidated = 18
|
session.last_consolidated = 18
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
@@ -219,7 +201,7 @@ class TestAutoCompact:
|
|||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
nonlocal archived_count
|
nonlocal archived_count
|
||||||
archived_count = len(messages)
|
archived_count = len(messages)
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
|
||||||
@@ -253,7 +235,7 @@ class TestAutoCompactIdleDetection:
|
|||||||
"""Proactive auto-new archives expired session; _process_message reloads it."""
|
"""Proactive auto-new archives expired session; _process_message reloads it."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="old")
|
session.add_message("user", "old message")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
@@ -261,9 +243,12 @@ class TestAutoCompactIdleDetection:
|
|||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
archived_messages.extend(messages)
|
archived_messages.extend(messages)
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
|
||||||
|
}
|
||||||
|
|
||||||
# Simulate proactive archive completing before message arrives
|
# Simulate proactive archive completing before message arrives
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -272,8 +257,7 @@ class TestAutoCompactIdleDetection:
|
|||||||
await loop._process_message(msg)
|
await loop._process_message(msg)
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(archived_messages) == 4
|
assert not any(m["content"] == "old message" for m in session_after.messages)
|
||||||
assert not any(m["content"] == "old user 0" for m in session_after.messages)
|
|
||||||
assert any(m["content"] == "new msg" for m in session_after.messages)
|
assert any(m["content"] == "new msg" for m in session_after.messages)
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@@ -327,7 +311,7 @@ class TestAutoCompactIdleDetection:
|
|||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
|
||||||
@@ -351,14 +335,17 @@ class TestAutoCompactSystemMessages:
|
|||||||
"""Proactive auto-new archives expired session; system messages reload it."""
|
"""Proactive auto-new archives expired session; system messages reload it."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="old")
|
session.add_message("user", "old message from subagent context")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
|
||||||
|
}
|
||||||
|
|
||||||
# Simulate proactive archive completing before system message arrives
|
# Simulate proactive archive completing before system message arrives
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -371,7 +358,7 @@ class TestAutoCompactSystemMessages:
|
|||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert not any(
|
assert not any(
|
||||||
m["content"] == "old user 0"
|
m["content"] == "old message from subagent context"
|
||||||
for m in session_after.messages
|
for m in session_after.messages
|
||||||
)
|
)
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
@@ -385,7 +372,8 @@ class TestAutoCompactEdgeCases:
|
|||||||
"""Auto-new should not inject when archive produces '(nothing)'."""
|
"""Auto-new should not inject when archive produces '(nothing)'."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="thanks")
|
session.add_message("user", "thanks")
|
||||||
|
session.add_message("assistant", "you're welcome")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
@@ -396,18 +384,18 @@ class TestAutoCompactEdgeCases:
|
|||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(session_after.messages) == 0
|
||||||
# "(nothing)" summary should not be stored
|
# "(nothing)" summary should not be stored
|
||||||
assert "cli:test" not in loop.auto_compact._summaries
|
assert "cli:test" not in loop.auto_compact._summaries
|
||||||
|
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_archive_failure_still_keeps_recent_suffix(self, tmp_path):
|
async def test_auto_compact_archive_failure_still_clears(self, tmp_path):
|
||||||
"""Auto-new should keep the recent suffix even if LLM archive falls back to raw dump."""
|
"""Auto-new should clear session even if LLM archive fails (raw_archive fallback)."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="important")
|
session.add_message("user", "important data")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
@@ -417,13 +405,14 @@ class TestAutoCompactEdgeCases:
|
|||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
# Session should be cleared (archive falls back to raw dump)
|
||||||
|
assert len(session_after.messages) == 0
|
||||||
|
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_preserves_runtime_checkpoint_before_check(self, tmp_path):
|
async def test_auto_compact_preserves_runtime_checkpoint_before_check(self, tmp_path):
|
||||||
"""Short expired sessions keep recent messages; checkpoint restore still works on resume."""
|
"""Runtime checkpoint is restored; proactive archive handles the expired session."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
session.metadata[AgentLoop._RUNTIME_CHECKPOINT_KEY] = {
|
session.metadata[AgentLoop._RUNTIME_CHECKPOINT_KEY] = {
|
||||||
@@ -439,9 +428,12 @@ class TestAutoCompactEdgeCases:
|
|||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
archived_messages.extend(messages)
|
archived_messages.extend(messages)
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
|
||||||
|
}
|
||||||
|
|
||||||
# Simulate proactive archive completing before message arrives
|
# Simulate proactive archive completing before message arrives
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -449,10 +441,8 @@ class TestAutoCompactEdgeCases:
|
|||||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="continue")
|
msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="continue")
|
||||||
await loop._process_message(msg)
|
await loop._process_message(msg)
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
# The checkpoint-restored message should have been archived by proactive path
|
||||||
assert archived_messages == []
|
assert len(archived_messages) >= 1
|
||||||
assert any(m["content"] == "previous message" for m in session_after.messages)
|
|
||||||
assert any(m["content"] == "interrupted response" for m in session_after.messages)
|
|
||||||
|
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@@ -468,17 +458,11 @@ class TestAutoCompactIntegration:
|
|||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
|
|
||||||
# Phase 1: User has a conversation longer than the retained recent suffix
|
# Phase 1: User has a conversation
|
||||||
session.add_message("user", "I'm learning English, teach me past tense")
|
session.add_message("user", "I'm learning English, teach me past tense")
|
||||||
session.add_message("assistant", "Past tense is used for actions completed in the past...")
|
session.add_message("assistant", "Past tense is used for actions completed in the past...")
|
||||||
session.add_message("user", "Give me an example")
|
session.add_message("user", "Give me an example")
|
||||||
session.add_message("assistant", '"I walked to the store yesterday."')
|
session.add_message("assistant", '"I walked to the store yesterday."')
|
||||||
session.add_message("user", "Give me another example")
|
|
||||||
session.add_message("assistant", '"She visited Paris last year."')
|
|
||||||
session.add_message("user", "Quiz me")
|
|
||||||
session.add_message("assistant", "What is the past tense of go?")
|
|
||||||
session.add_message("user", "I think it is went")
|
|
||||||
session.add_message("assistant", "Correct.")
|
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
# Phase 2: Time passes (simulate idle)
|
# Phase 2: Time passes (simulate idle)
|
||||||
@@ -502,7 +486,7 @@ class TestAutoCompactIntegration:
|
|||||||
# Phase 4: Verify
|
# Phase 4: Verify
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
|
|
||||||
# The oldest messages should be trimmed from live session history
|
# Old messages should be gone
|
||||||
assert not any(
|
assert not any(
|
||||||
"past tense is used" in str(m.get("content", "")) for m in session_after.messages
|
"past tense is used" in str(m.get("content", "")) for m in session_after.messages
|
||||||
)
|
)
|
||||||
@@ -525,8 +509,8 @@ class TestAutoCompactIntegration:
|
|||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_context_markers_not_persisted_for_multi_paragraph_turn(self, tmp_path):
|
async def test_multi_paragraph_user_message_preserved(self, tmp_path):
|
||||||
"""Auto-compact resume context must not leak runtime markers into persisted session history."""
|
"""Multi-paragraph user messages must be fully preserved after auto-new."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
session.add_message("user", "old message")
|
session.add_message("user", "old message")
|
||||||
@@ -534,9 +518,12 @@ class TestAutoCompactIntegration:
|
|||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
|
||||||
|
}
|
||||||
|
|
||||||
# Simulate proactive archive completing before message arrives
|
# Simulate proactive archive completing before message arrives
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -548,11 +535,16 @@ class TestAutoCompactIntegration:
|
|||||||
await loop._process_message(msg)
|
await loop._process_message(msg)
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert any(m.get("content") == "old message" for m in session_after.messages)
|
user_msgs = [m for m in session_after.messages if m.get("role") == "user"]
|
||||||
for persisted in session_after.messages:
|
assert len(user_msgs) >= 1
|
||||||
content = str(persisted.get("content", ""))
|
# All three paragraphs must be preserved
|
||||||
assert "[Runtime Context" not in content
|
persisted = user_msgs[-1]["content"]
|
||||||
assert "[/Runtime Context]" not in content
|
assert "Paragraph one" in persisted
|
||||||
|
assert "Paragraph two" in persisted
|
||||||
|
assert "Paragraph three" in persisted
|
||||||
|
# No runtime context markers in persisted message
|
||||||
|
assert "[Runtime Context" not in persisted
|
||||||
|
assert "[/Runtime Context]" not in persisted
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
@@ -560,12 +552,9 @@ class TestProactiveAutoCompact:
|
|||||||
"""Test proactive auto-new on idle ticks (TimeoutError path in run loop)."""
|
"""Test proactive auto-new on idle ticks (TimeoutError path in run loop)."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _run_check_expired(loop, active_session_keys=()):
|
async def _run_check_expired(loop):
|
||||||
"""Helper: run check_expired via callback and wait for background tasks."""
|
"""Helper: run check_expired via callback and wait for background tasks."""
|
||||||
loop.auto_compact.check_expired(
|
loop.auto_compact.check_expired(loop._schedule_background)
|
||||||
loop._schedule_background,
|
|
||||||
active_session_keys=active_session_keys,
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -588,7 +577,8 @@ class TestProactiveAutoCompact:
|
|||||||
"""Expired session should be archived during idle tick."""
|
"""Expired session should be archived during idle tick."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 5, prefix="old")
|
session.add_message("user", "old message")
|
||||||
|
session.add_message("assistant", "old response")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
@@ -596,14 +586,17 @@ class TestProactiveAutoCompact:
|
|||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
archived_messages.extend(messages)
|
archived_messages.extend(messages)
|
||||||
return "User chatted about old things."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "User chatted about old things.",
|
||||||
|
}
|
||||||
|
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
assert len(session_after.messages) == 0
|
||||||
assert len(archived_messages) == 2
|
assert len(archived_messages) == 2
|
||||||
entry = loop.auto_compact._summaries.get("cli:test")
|
entry = loop.auto_compact._summaries.get("cli:test")
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
@@ -629,7 +622,7 @@ class TestProactiveAutoCompact:
|
|||||||
"""Should not archive the same session twice if already in progress."""
|
"""Should not archive the same session twice if already in progress."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="old")
|
session.add_message("user", "old message")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
@@ -642,7 +635,7 @@ class TestProactiveAutoCompact:
|
|||||||
archive_count += 1
|
archive_count += 1
|
||||||
started.set()
|
started.set()
|
||||||
await block_forever.wait()
|
await block_forever.wait()
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _slow_archive
|
loop.consolidator.archive = _slow_archive
|
||||||
|
|
||||||
@@ -666,7 +659,7 @@ class TestProactiveAutoCompact:
|
|||||||
"""Proactive archive failure should be caught and not block future ticks."""
|
"""Proactive archive failure should be caught and not block future ticks."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="old")
|
session.add_message("user", "old message")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
@@ -695,7 +688,7 @@ class TestProactiveAutoCompact:
|
|||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
nonlocal archive_called
|
nonlocal archive_called
|
||||||
archive_called = True
|
archive_called = True
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
|
||||||
@@ -704,105 +697,13 @@ class TestProactiveAutoCompact:
|
|||||||
assert not archive_called
|
assert not archive_called
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_skip_expired_session_with_active_agent_task(self, tmp_path):
|
|
||||||
"""Expired session with an active agent task should NOT be archived."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
|
||||||
_add_turns(session, 6, prefix="old")
|
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
|
||||||
loop.sessions.save(session)
|
|
||||||
|
|
||||||
archive_count = 0
|
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_count
|
|
||||||
archive_count += 1
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# Simulate an active agent task for this session
|
|
||||||
await self._run_check_expired(loop, active_session_keys={"cli:test"})
|
|
||||||
assert archive_count == 0
|
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
|
||||||
assert len(session_after.messages) == 12 # All messages preserved
|
|
||||||
|
|
||||||
await loop.close_mcp()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_archive_after_active_task_completes(self, tmp_path):
|
|
||||||
"""Session should be archived on next tick after active task completes."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
|
||||||
_add_turns(session, 6, prefix="old")
|
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
|
||||||
loop.sessions.save(session)
|
|
||||||
|
|
||||||
archive_count = 0
|
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_count
|
|
||||||
archive_count += 1
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
# First tick: active task, skip
|
|
||||||
await self._run_check_expired(loop, active_session_keys={"cli:test"})
|
|
||||||
assert archive_count == 0
|
|
||||||
|
|
||||||
# Second tick: task completed, should archive
|
|
||||||
await self._run_check_expired(loop)
|
|
||||||
assert archive_count == 1
|
|
||||||
await loop.close_mcp()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_partial_active_set_only_archives_inactive_expired(self, tmp_path):
|
|
||||||
"""With multiple sessions, only the expired+inactive one should be archived."""
|
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
|
||||||
# Session A: expired, no active task -> should be archived
|
|
||||||
s1 = loop.sessions.get_or_create("cli:expired_idle")
|
|
||||||
_add_turns(s1, 6, prefix="old_a")
|
|
||||||
s1.updated_at = datetime.now() - timedelta(minutes=20)
|
|
||||||
loop.sessions.save(s1)
|
|
||||||
# Session B: expired, has active task -> should be skipped
|
|
||||||
s2 = loop.sessions.get_or_create("cli:expired_active")
|
|
||||||
_add_turns(s2, 6, prefix="old_b")
|
|
||||||
s2.updated_at = datetime.now() - timedelta(minutes=20)
|
|
||||||
loop.sessions.save(s2)
|
|
||||||
# Session C: recent, no active task -> should be skipped
|
|
||||||
s3 = loop.sessions.get_or_create("cli:recent")
|
|
||||||
s3.add_message("user", "recent")
|
|
||||||
loop.sessions.save(s3)
|
|
||||||
|
|
||||||
archive_count = 0
|
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
|
||||||
nonlocal archive_count
|
|
||||||
archive_count += 1
|
|
||||||
return "Summary."
|
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
|
||||||
|
|
||||||
await self._run_check_expired(loop, active_session_keys={"cli:expired_active"})
|
|
||||||
|
|
||||||
assert archive_count == 1
|
|
||||||
s1_after = loop.sessions.get_or_create("cli:expired_idle")
|
|
||||||
assert len(s1_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
|
||||||
s2_after = loop.sessions.get_or_create("cli:expired_active")
|
|
||||||
assert len(s2_after.messages) == 12 # Preserved
|
|
||||||
s3_after = loop.sessions.get_or_create("cli:recent")
|
|
||||||
assert len(s3_after.messages) == 1 # Preserved
|
|
||||||
await loop.close_mcp()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_reschedule_after_successful_archive(self, tmp_path):
|
async def test_no_reschedule_after_successful_archive(self, tmp_path):
|
||||||
"""Already-archived session should NOT be re-scheduled on subsequent ticks."""
|
"""Already-archived session should NOT be re-scheduled on subsequent ticks."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 5, prefix="old")
|
session.add_message("user", "old message")
|
||||||
|
session.add_message("assistant", "old response")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
@@ -811,9 +712,12 @@ class TestProactiveAutoCompact:
|
|||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
nonlocal archive_count
|
nonlocal archive_count
|
||||||
archive_count += 1
|
archive_count += 1
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
|
||||||
|
}
|
||||||
|
|
||||||
# First tick: archives the session
|
# First tick: archives the session
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
@@ -837,7 +741,7 @@ class TestProactiveAutoCompact:
|
|||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
nonlocal archive_count
|
nonlocal archive_count
|
||||||
archive_count += 1
|
archive_count += 1
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
|
||||||
@@ -855,7 +759,8 @@ class TestProactiveAutoCompact:
|
|||||||
"""After successful compact + user sends new messages + idle again, should compact again."""
|
"""After successful compact + user sends new messages + idle again, should compact again."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 5, prefix="first")
|
session.add_message("user", "first conversation")
|
||||||
|
session.add_message("assistant", "first response")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
@@ -864,9 +769,12 @@ class TestProactiveAutoCompact:
|
|||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
nonlocal archive_count
|
nonlocal archive_count
|
||||||
archive_count += 1
|
archive_count += 1
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
|
||||||
|
}
|
||||||
|
|
||||||
# First compact cycle
|
# First compact cycle
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -896,14 +804,18 @@ class TestSummaryPersistence:
|
|||||||
"""After archive, _last_summary should be in session metadata."""
|
"""After archive, _last_summary should be in session metadata."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="hello")
|
session.add_message("user", "hello")
|
||||||
|
session.add_message("assistant", "hi there")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
return "User said hello."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "User said hello.",
|
||||||
|
}
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
@@ -920,15 +832,19 @@ class TestSummaryPersistence:
|
|||||||
"""Summary should be recovered from metadata when _summaries is empty (simulates restart)."""
|
"""Summary should be recovered from metadata when _summaries is empty (simulates restart)."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="hello")
|
session.add_message("user", "hello")
|
||||||
|
session.add_message("assistant", "hi there")
|
||||||
last_active = datetime.now() - timedelta(minutes=20)
|
last_active = datetime.now() - timedelta(minutes=20)
|
||||||
session.updated_at = last_active
|
session.updated_at = last_active
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
return "User said hello."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "User said hello.",
|
||||||
|
}
|
||||||
|
|
||||||
# Archive
|
# Archive
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
@@ -939,7 +855,6 @@ class TestSummaryPersistence:
|
|||||||
|
|
||||||
# prepare_session should recover summary from metadata
|
# prepare_session should recover summary from metadata
|
||||||
reloaded = loop.sessions.get_or_create("cli:test")
|
reloaded = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(reloaded.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
|
||||||
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||||
|
|
||||||
assert summary is not None
|
assert summary is not None
|
||||||
@@ -954,14 +869,17 @@ class TestSummaryPersistence:
|
|||||||
"""_last_summary should be removed from metadata after being consumed."""
|
"""_last_summary should be removed from metadata after being consumed."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="hello")
|
session.add_message("user", "hello")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
|
||||||
|
}
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
@@ -985,14 +903,17 @@ class TestSummaryPersistence:
|
|||||||
"""In-memory _summaries path should also clean up _last_summary from metadata."""
|
"""In-memory _summaries path should also clean up _last_summary from metadata."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6, prefix="hello")
|
session.add_message("user", "hello")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
async def _fake_archive(messages):
|
async def _fake_archive(messages):
|
||||||
return "Summary."
|
return True
|
||||||
|
|
||||||
loop.consolidator.archive = _fake_archive
|
loop.consolidator.archive = _fake_archive
|
||||||
|
loop.consolidator.get_last_history_entry = lambda: {
|
||||||
|
"cursor": 1, "timestamp": "2026-01-01 00:00", "content": "Summary.",
|
||||||
|
}
|
||||||
|
|
||||||
await loop.auto_compact._archive("cli:test")
|
await loop.auto_compact._archive("cli:test")
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class TestConsolidatorSummarize:
|
|||||||
{"role": "assistant", "content": "Done, fixed the race condition."},
|
{"role": "assistant", "content": "Done, fixed the race condition."},
|
||||||
]
|
]
|
||||||
result = await consolidator.archive(messages)
|
result = await consolidator.archive(messages)
|
||||||
assert result == "User fixed a bug in the auth module."
|
assert result is True
|
||||||
entries = store.read_unprocessed_history(since_cursor=0)
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
|
|
||||||
@@ -55,14 +55,14 @@ class TestConsolidatorSummarize:
|
|||||||
mock_provider.chat_with_retry.side_effect = Exception("API error")
|
mock_provider.chat_with_retry.side_effect = Exception("API error")
|
||||||
messages = [{"role": "user", "content": "hello"}]
|
messages = [{"role": "user", "content": "hello"}]
|
||||||
result = await consolidator.archive(messages)
|
result = await consolidator.archive(messages)
|
||||||
assert result is None # no summary on raw dump fallback
|
assert result is True # always succeeds
|
||||||
entries = store.read_unprocessed_history(since_cursor=0)
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert "[RAW]" in entries[0]["content"]
|
assert "[RAW]" in entries[0]["content"]
|
||||||
|
|
||||||
async def test_summarize_skips_empty_messages(self, consolidator):
|
async def test_summarize_skips_empty_messages(self, consolidator):
|
||||||
result = await consolidator.archive([])
|
result = await consolidator.archive([])
|
||||||
assert result is None
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
class TestConsolidatorTokenBudget:
|
class TestConsolidatorTokenBudget:
|
||||||
@@ -76,52 +76,3 @@ class TestConsolidatorTokenBudget:
|
|||||||
consolidator.archive = AsyncMock(return_value=True)
|
consolidator.archive = AsyncMock(return_value=True)
|
||||||
await consolidator.maybe_consolidate_by_tokens(session)
|
await consolidator.maybe_consolidate_by_tokens(session)
|
||||||
consolidator.archive.assert_not_called()
|
consolidator.archive.assert_not_called()
|
||||||
|
|
||||||
async def test_chunk_cap_preserves_user_turn_boundary(self, consolidator):
|
|
||||||
"""Chunk cap should rewind to the last user boundary within the cap."""
|
|
||||||
consolidator._SAFETY_BUFFER = 0
|
|
||||||
session = MagicMock()
|
|
||||||
session.last_consolidated = 0
|
|
||||||
session.key = "test:key"
|
|
||||||
session.messages = [
|
|
||||||
{
|
|
||||||
"role": "user" if i in {0, 50, 61} else "assistant",
|
|
||||||
"content": f"m{i}",
|
|
||||||
}
|
|
||||||
for i in range(70)
|
|
||||||
]
|
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
|
||||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
|
||||||
)
|
|
||||||
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
|
|
||||||
consolidator.archive = AsyncMock(return_value=True)
|
|
||||||
|
|
||||||
await consolidator.maybe_consolidate_by_tokens(session)
|
|
||||||
|
|
||||||
archived_chunk = consolidator.archive.await_args.args[0]
|
|
||||||
assert len(archived_chunk) == 50
|
|
||||||
assert archived_chunk[0]["content"] == "m0"
|
|
||||||
assert archived_chunk[-1]["content"] == "m49"
|
|
||||||
assert session.last_consolidated == 50
|
|
||||||
|
|
||||||
async def test_chunk_cap_skips_when_no_user_boundary_within_cap(self, consolidator):
|
|
||||||
"""If the cap would cut mid-turn, consolidation should skip that round."""
|
|
||||||
consolidator._SAFETY_BUFFER = 0
|
|
||||||
session = MagicMock()
|
|
||||||
session.last_consolidated = 0
|
|
||||||
session.key = "test:key"
|
|
||||||
session.messages = [
|
|
||||||
{
|
|
||||||
"role": "user" if i in {0, 61} else "assistant",
|
|
||||||
"content": f"m{i}",
|
|
||||||
}
|
|
||||||
for i in range(70)
|
|
||||||
]
|
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(1200, "tiktoken"))
|
|
||||||
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
|
|
||||||
consolidator.archive = AsyncMock(return_value=True)
|
|
||||||
|
|
||||||
await consolidator.maybe_consolidate_by_tokens(session)
|
|
||||||
|
|
||||||
consolidator.archive.assert_not_awaited()
|
|
||||||
assert session.last_consolidated == 0
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime as real_datetime
|
from datetime import datetime as real_datetime
|
||||||
from importlib.resources import files as pkg_files
|
from importlib.resources import files as pkg_files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -87,124 +86,6 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
|
|||||||
assert "Return exactly: OK" in user_content
|
assert "Return exactly: OK" in user_content
|
||||||
|
|
||||||
|
|
||||||
def test_unprocessed_history_injected_into_system_prompt(tmp_path) -> None:
|
|
||||||
"""Entries in history.jsonl not yet consumed by Dream appear with timestamps."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
|
||||||
builder = ContextBuilder(workspace)
|
|
||||||
|
|
||||||
builder.memory.append_history("User asked about weather in Tokyo")
|
|
||||||
builder.memory.append_history("Agent fetched forecast via web_search")
|
|
||||||
|
|
||||||
prompt = builder.build_system_prompt()
|
|
||||||
assert "# Recent History" in prompt
|
|
||||||
assert "User asked about weather in Tokyo" in prompt
|
|
||||||
assert "Agent fetched forecast via web_search" in prompt
|
|
||||||
assert re.search(r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]", prompt)
|
|
||||||
|
|
||||||
|
|
||||||
def test_recent_history_capped_at_max(tmp_path) -> None:
|
|
||||||
"""Only the most recent _MAX_RECENT_HISTORY entries are injected."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
|
||||||
builder = ContextBuilder(workspace)
|
|
||||||
|
|
||||||
for i in range(builder._MAX_RECENT_HISTORY + 20):
|
|
||||||
builder.memory.append_history(f"entry-{i}")
|
|
||||||
|
|
||||||
prompt = builder.build_system_prompt()
|
|
||||||
assert "entry-0" not in prompt
|
|
||||||
assert "entry-19" not in prompt
|
|
||||||
assert f"entry-{builder._MAX_RECENT_HISTORY + 19}" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_recent_history_when_dream_has_processed_all(tmp_path) -> None:
|
|
||||||
"""If Dream has consumed everything, no Recent History section should appear."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
|
||||||
builder = ContextBuilder(workspace)
|
|
||||||
|
|
||||||
cursor = builder.memory.append_history("already processed entry")
|
|
||||||
builder.memory.set_last_dream_cursor(cursor)
|
|
||||||
|
|
||||||
prompt = builder.build_system_prompt()
|
|
||||||
assert "# Recent History" not in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_partial_dream_processing_shows_only_remainder(tmp_path) -> None:
|
|
||||||
"""When Dream has processed some entries, only the unprocessed ones appear."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
|
||||||
builder = ContextBuilder(workspace)
|
|
||||||
|
|
||||||
c1 = builder.memory.append_history("old conversation about Python")
|
|
||||||
c2 = builder.memory.append_history("old conversation about Rust")
|
|
||||||
builder.memory.append_history("recent question about Docker")
|
|
||||||
builder.memory.append_history("recent question about K8s")
|
|
||||||
|
|
||||||
builder.memory.set_last_dream_cursor(c2)
|
|
||||||
|
|
||||||
prompt = builder.build_system_prompt()
|
|
||||||
assert "# Recent History" in prompt
|
|
||||||
assert "old conversation about Python" not in prompt
|
|
||||||
assert "old conversation about Rust" not in prompt
|
|
||||||
assert "recent question about Docker" in prompt
|
|
||||||
assert "recent question about K8s" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_execution_rules_in_system_prompt(tmp_path) -> None:
|
|
||||||
"""New execution rules should appear in the system prompt."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
|
||||||
builder = ContextBuilder(workspace)
|
|
||||||
|
|
||||||
prompt = builder.build_system_prompt()
|
|
||||||
assert "Act, don't narrate" in prompt
|
|
||||||
assert "Read before you write" in prompt
|
|
||||||
assert "verify the result" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_channel_format_hint_telegram(tmp_path) -> None:
|
|
||||||
"""Telegram channel should get messaging-app format hint."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
|
||||||
builder = ContextBuilder(workspace)
|
|
||||||
|
|
||||||
prompt = builder.build_system_prompt(channel="telegram")
|
|
||||||
assert "Format Hint" in prompt
|
|
||||||
assert "messaging app" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_channel_format_hint_whatsapp(tmp_path) -> None:
|
|
||||||
"""WhatsApp should get plain-text format hint."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
|
||||||
builder = ContextBuilder(workspace)
|
|
||||||
|
|
||||||
prompt = builder.build_system_prompt(channel="whatsapp")
|
|
||||||
assert "Format Hint" in prompt
|
|
||||||
assert "plain text only" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_channel_format_hint_absent_for_unknown(tmp_path) -> None:
|
|
||||||
"""Unknown or None channel should not inject a format hint."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
|
||||||
builder = ContextBuilder(workspace)
|
|
||||||
|
|
||||||
prompt = builder.build_system_prompt(channel=None)
|
|
||||||
assert "Format Hint" not in prompt
|
|
||||||
|
|
||||||
prompt2 = builder.build_system_prompt(channel="feishu")
|
|
||||||
assert "Format Hint" not in prompt2
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_messages_passes_channel_to_system_prompt(tmp_path) -> None:
|
|
||||||
"""build_messages should pass channel through to build_system_prompt."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
|
||||||
builder = ContextBuilder(workspace)
|
|
||||||
|
|
||||||
messages = builder.build_messages(
|
|
||||||
history=[], current_message="hi",
|
|
||||||
channel="telegram", chat_id="123",
|
|
||||||
)
|
|
||||||
system = messages[0]["content"]
|
|
||||||
assert "Format Hint" in system
|
|
||||||
assert "messaging app" in system
|
|
||||||
|
|
||||||
|
|
||||||
def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path) -> None:
|
def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path) -> None:
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
builder = ContextBuilder(workspace)
|
builder = ContextBuilder(workspace)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
from nanobot.agent.memory import Dream, MemoryStore
|
from nanobot.agent.memory import Dream, MemoryStore
|
||||||
from nanobot.agent.runner import AgentRunResult
|
from nanobot.agent.runner import AgentRunResult
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -96,30 +95,3 @@ class TestDreamRun:
|
|||||||
entries = store.read_unprocessed_history(since_cursor=0)
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
assert all(e["cursor"] > 0 for e in entries)
|
assert all(e["cursor"] > 0 for e in entries)
|
||||||
|
|
||||||
async def test_skill_phase_uses_builtin_skill_creator_path(self, dream, mock_provider, mock_runner, store):
|
|
||||||
"""Dream should point skill creation guidance at the builtin skill-creator template."""
|
|
||||||
store.append_history("Repeated workflow one")
|
|
||||||
store.append_history("Repeated workflow two")
|
|
||||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKILL] test-skill: test description")
|
|
||||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
||||||
|
|
||||||
await dream.run()
|
|
||||||
|
|
||||||
spec = mock_runner.run.call_args[0][0]
|
|
||||||
system_prompt = spec.initial_messages[0]["content"]
|
|
||||||
expected = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
|
|
||||||
assert expected in system_prompt
|
|
||||||
|
|
||||||
async def test_skill_write_tool_accepts_workspace_relative_skill_path(self, dream, store):
|
|
||||||
"""Dream skill creation should allow skills/<name>/SKILL.md relative to workspace root."""
|
|
||||||
write_tool = dream._tools.get("write_file")
|
|
||||||
assert write_tool is not None
|
|
||||||
|
|
||||||
result = await write_tool.execute(
|
|
||||||
path="skills/test-skill/SKILL.md",
|
|
||||||
content="---\nname: test-skill\ndescription: Test\n---\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "Successfully wrote" in result
|
|
||||||
assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists()
|
|
||||||
|
|
||||||
|
|||||||
@@ -184,22 +184,17 @@ def test_stale_extra_content_in_tool_calls_survives_sanitize() -> None:
|
|||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
provider = OpenAICompatProvider()
|
provider = OpenAICompatProvider()
|
||||||
|
|
||||||
messages = [
|
messages = [{
|
||||||
{"role": "user", "content": "hi"},
|
"role": "assistant",
|
||||||
{
|
"content": None,
|
||||||
"role": "assistant",
|
"tool_calls": [{
|
||||||
"content": None,
|
"id": "call_1",
|
||||||
"tool_calls": [{
|
"type": "function",
|
||||||
"id": "call_1",
|
"function": {"name": "fn", "arguments": "{}"},
|
||||||
"type": "function",
|
"extra_content": GEMINI_EXTRA,
|
||||||
"function": {"name": "fn", "arguments": "{}"},
|
}],
|
||||||
"extra_content": GEMINI_EXTRA,
|
}]
|
||||||
}],
|
|
||||||
},
|
|
||||||
{"role": "tool", "content": "ok", "tool_call_id": "call_1"},
|
|
||||||
{"role": "user", "content": "thanks"},
|
|
||||||
]
|
|
||||||
|
|
||||||
sanitized = provider._sanitize_messages(messages)
|
sanitized = provider._sanitize_messages(messages)
|
||||||
|
|
||||||
assert sanitized[1]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA
|
assert sanitized[0]["tool_calls"][0]["extra_content"] == GEMINI_EXTRA
|
||||||
|
|||||||
@@ -232,35 +232,6 @@ async def test_composite_empty_hooks_no_ops():
|
|||||||
assert hook.finalize_content(ctx, "test") == "test"
|
assert hook.finalize_content(ctx, "test") == "test"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_composite_supports_legacy_hook_init_without_super():
|
|
||||||
calls: list[str] = []
|
|
||||||
|
|
||||||
class LegacyHook(AgentHook):
|
|
||||||
def __init__(self, label: str) -> None:
|
|
||||||
self.label = label
|
|
||||||
|
|
||||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
|
||||||
calls.append(self.label)
|
|
||||||
|
|
||||||
hook = CompositeHook([LegacyHook("legacy")])
|
|
||||||
await hook.before_iteration(_ctx())
|
|
||||||
assert calls == ["legacy"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_composite_can_wrap_another_composite():
|
|
||||||
calls: list[str] = []
|
|
||||||
|
|
||||||
class Inner(AgentHook):
|
|
||||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
|
||||||
calls.append("inner")
|
|
||||||
|
|
||||||
hook = CompositeHook([CompositeHook([Inner()])])
|
|
||||||
await hook.before_iteration(_ctx())
|
|
||||||
assert calls == ["inner"]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Integration: AgentLoop with extra hooks
|
# Integration: AgentLoop with extra hooks
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session
|
||||||
|
|
||||||
|
|
||||||
@@ -19,12 +11,6 @@ def _mk_loop() -> AgentLoop:
|
|||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|
||||||
def _make_full_loop(tmp_path: Path) -> AgentLoop:
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
|
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:runtime-only")
|
session = Session(key="test:runtime-only")
|
||||||
@@ -214,392 +200,3 @@ def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None:
|
|||||||
assert session.messages[0]["role"] == "assistant"
|
assert session.messages[0]["role"] == "assistant"
|
||||||
assert session.messages[1]["tool_call_id"] == "call_done"
|
assert session.messages[1]["tool_call_id"] == "call_done"
|
||||||
assert session.messages[2]["tool_call_id"] == "call_pending"
|
assert session.messages[2]["tool_call_id"] == "call_pending"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_message_persists_user_message_before_turn_completes(tmp_path: Path) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
|
||||||
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
|
|
||||||
|
|
||||||
msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="persist me")
|
|
||||||
with pytest.raises(RuntimeError, match="boom"):
|
|
||||||
await loop._process_message(msg)
|
|
||||||
|
|
||||||
loop.sessions.invalidate("feishu:c1")
|
|
||||||
persisted = loop.sessions.get_or_create("feishu:c1")
|
|
||||||
assert [m["role"] for m in persisted.messages] == ["user"]
|
|
||||||
assert persisted.messages[0]["content"] == "persist me"
|
|
||||||
assert persisted.metadata.get(AgentLoop._PENDING_USER_TURN_KEY) is True
|
|
||||||
assert persisted.updated_at >= persisted.created_at
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_message_does_not_duplicate_early_persisted_user_message(tmp_path: Path) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
|
||||||
loop._run_agent_loop = AsyncMock(return_value=(
|
|
||||||
"done",
|
|
||||||
None,
|
|
||||||
[
|
|
||||||
{"role": "system", "content": "system"},
|
|
||||||
{"role": "user", "content": "hello"},
|
|
||||||
{"role": "assistant", "content": "done"},
|
|
||||||
],
|
|
||||||
"stop",
|
|
||||||
False,
|
|
||||||
)) # type: ignore[method-assign]
|
|
||||||
|
|
||||||
result = await loop._process_message(
|
|
||||||
InboundMessage(channel="feishu", sender_id="u1", chat_id="c2", content="hello")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.content == "done"
|
|
||||||
session = loop.sessions.get_or_create("feishu:c2")
|
|
||||||
assert [
|
|
||||||
{k: v for k, v in m.items() if k in {"role", "content"}}
|
|
||||||
for m in session.messages
|
|
||||||
] == [
|
|
||||||
{"role": "user", "content": "hello"},
|
|
||||||
{"role": "assistant", "content": "done"},
|
|
||||||
]
|
|
||||||
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(tmp_path: Path) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
|
||||||
loop.provider.chat_with_retry = AsyncMock(return_value=MagicMock()) # unused because _run_agent_loop is stubbed
|
|
||||||
|
|
||||||
session = loop.sessions.get_or_create("feishu:c3")
|
|
||||||
session.add_message("user", "old question")
|
|
||||||
session.metadata[AgentLoop._PENDING_USER_TURN_KEY] = True
|
|
||||||
loop.sessions.save(session)
|
|
||||||
|
|
||||||
loop._run_agent_loop = AsyncMock(return_value=(
|
|
||||||
"new answer",
|
|
||||||
None,
|
|
||||||
[
|
|
||||||
{"role": "system", "content": "system"},
|
|
||||||
{"role": "user", "content": "old question"},
|
|
||||||
{"role": "assistant", "content": "Error: Task interrupted before a response was generated."},
|
|
||||||
{"role": "user", "content": "new question"},
|
|
||||||
{"role": "assistant", "content": "new answer"},
|
|
||||||
],
|
|
||||||
"stop",
|
|
||||||
False,
|
|
||||||
)) # type: ignore[method-assign]
|
|
||||||
|
|
||||||
result = await loop._process_message(
|
|
||||||
InboundMessage(channel="feishu", sender_id="u1", chat_id="c3", content="new question")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.content == "new answer"
|
|
||||||
session = loop.sessions.get_or_create("feishu:c3")
|
|
||||||
assert [
|
|
||||||
{k: v for k, v in m.items() if k in {"role", "content"}}
|
|
||||||
for m in session.messages
|
|
||||||
] == [
|
|
||||||
{"role": "user", "content": "old question"},
|
|
||||||
{"role": "assistant", "content": "Error: Task interrupted before a response was generated."},
|
|
||||||
{"role": "user", "content": "new question"},
|
|
||||||
{"role": "assistant", "content": "new answer"},
|
|
||||||
]
|
|
||||||
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def _cross_channel_messages(
|
|
||||||
source_channel: str = "websocket",
|
|
||||||
source_chat_id: str = "ws-uuid-123",
|
|
||||||
target_channel: str = "feishu",
|
|
||||||
target_chat_id: str = "ou_abc123",
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Build a message list with a cross-channel message tool call."""
|
|
||||||
return [
|
|
||||||
{"role": "user", "content": "send report to feishu"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_x1",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "message",
|
|
||||||
"arguments": json.dumps({
|
|
||||||
"content": "Report: audit complete",
|
|
||||||
"channel": target_channel,
|
|
||||||
"chat_id": target_chat_id,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_x1",
|
|
||||||
"name": "message",
|
|
||||||
"content": f"Message sent to {target_channel}:{target_chat_id}",
|
|
||||||
},
|
|
||||||
{"role": "assistant", "content": "Done, sent to feishu."},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_channel_message_persisted_in_target_session(tmp_path: Path) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
source_key = "websocket:ws-uuid-123"
|
|
||||||
target_key = "feishu:ou_abc123"
|
|
||||||
|
|
||||||
# Pre-create the target session (simulate an existing feishu conversation)
|
|
||||||
target_session = loop.sessions.get_or_create(target_key)
|
|
||||||
target_session.add_message("user", "hello from feishu")
|
|
||||||
loop.sessions.save(target_session)
|
|
||||||
|
|
||||||
source_session = loop.sessions.get_or_create(source_key)
|
|
||||||
msgs = _cross_channel_messages()
|
|
||||||
loop._save_turn(source_session, msgs, skip=1) # skip user message
|
|
||||||
|
|
||||||
# Source session has its own messages
|
|
||||||
source_session = loop.sessions.get_or_create(source_key)
|
|
||||||
assert len(source_session.messages) >= 2 # assistant + tool + final
|
|
||||||
|
|
||||||
# Target session now has the cross-channel message appended
|
|
||||||
loop.sessions.invalidate(target_key)
|
|
||||||
target = loop.sessions.get_or_create(target_key)
|
|
||||||
cross_msg = [m for m in target.messages if m.get("_cross_channel")]
|
|
||||||
assert len(cross_msg) == 1
|
|
||||||
assert cross_msg[0]["content"] == "Report: audit complete"
|
|
||||||
assert cross_msg[0]["role"] == "assistant"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_channel_same_session_not_duplicated(tmp_path: Path) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
key = "feishu:ou_same"
|
|
||||||
|
|
||||||
session = loop.sessions.get_or_create(key)
|
|
||||||
# message tool call targeting the same session — should NOT create a duplicate
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "hello"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_s1",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "message",
|
|
||||||
"arguments": json.dumps({
|
|
||||||
"content": "same channel msg",
|
|
||||||
"channel": "feishu",
|
|
||||||
"chat_id": "ou_same",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"role": "tool", "tool_call_id": "call_s1", "name": "message", "content": "ok"},
|
|
||||||
]
|
|
||||||
loop._save_turn(session, msgs, skip=1)
|
|
||||||
|
|
||||||
# No _cross_channel entries should exist
|
|
||||||
assert all(not m.get("_cross_channel") for m in session.messages)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_channel_target_session_not_exist_creates_session(tmp_path: Path) -> None:
|
|
||||||
"""When the target session does not exist yet, get_or_create will create it
|
|
||||||
and the cross-channel message should still be persisted."""
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
source_session = loop.sessions.get_or_create("websocket:ws-xyz")
|
|
||||||
|
|
||||||
msgs = _cross_channel_messages(
|
|
||||||
target_channel="feishu", target_chat_id="ou_nonexistent"
|
|
||||||
)
|
|
||||||
loop._save_turn(source_session, msgs, skip=1)
|
|
||||||
|
|
||||||
# Target session is now auto-created with the cross-channel message
|
|
||||||
target = loop.sessions.get_or_create("feishu:ou_nonexistent")
|
|
||||||
cross_msgs = [m for m in target.messages if m.get("_cross_channel")]
|
|
||||||
assert len(cross_msgs) == 1
|
|
||||||
assert cross_msgs[0]["content"] == "Report: audit complete"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_channel_persists_media_attachments(tmp_path: Path) -> None:
|
|
||||||
"""When the message tool call includes media, the cross-channel entry
|
|
||||||
should preserve the media paths so the target session has full context."""
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
source_key = "websocket:ws-media"
|
|
||||||
target_key = "telegram:tg_user1"
|
|
||||||
|
|
||||||
target_session = loop.sessions.get_or_create(target_key)
|
|
||||||
target_session.add_message("user", "waiting for report")
|
|
||||||
loop.sessions.save(target_session)
|
|
||||||
|
|
||||||
source_session = loop.sessions.get_or_create(source_key)
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "send chart to telegram"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_m1",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "message",
|
|
||||||
"arguments": json.dumps({
|
|
||||||
"content": "Here is the chart",
|
|
||||||
"channel": "telegram",
|
|
||||||
"chat_id": "tg_user1",
|
|
||||||
"media": ["/tmp/chart.png", "/tmp/data.csv"],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_m1",
|
|
||||||
"name": "message",
|
|
||||||
"content": "Message sent to telegram:tg_user1",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
loop._save_turn(source_session, msgs, skip=1)
|
|
||||||
|
|
||||||
loop.sessions.invalidate(target_key)
|
|
||||||
target = loop.sessions.get_or_create(target_key)
|
|
||||||
cross_msgs = [m for m in target.messages if m.get("_cross_channel")]
|
|
||||||
assert len(cross_msgs) == 1
|
|
||||||
assert cross_msgs[0]["content"] == "Here is the chart"
|
|
||||||
assert cross_msgs[0]["_media"] == ["/tmp/chart.png", "/tmp/data.csv"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_channel_records_source_session(tmp_path: Path) -> None:
|
|
||||||
"""Cross-channel entries should include _source_session for traceability."""
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
source_key = "cron:heartbeat"
|
|
||||||
target_key = "feishu:ou_trace"
|
|
||||||
|
|
||||||
target_session = loop.sessions.get_or_create(target_key)
|
|
||||||
target_session.add_message("user", "hi")
|
|
||||||
loop.sessions.save(target_session)
|
|
||||||
|
|
||||||
source_session = loop.sessions.get_or_create(source_key)
|
|
||||||
msgs = _cross_channel_messages(
|
|
||||||
source_channel="cron", source_chat_id="heartbeat",
|
|
||||||
target_channel="feishu", target_chat_id="ou_trace",
|
|
||||||
)
|
|
||||||
loop._save_turn(source_session, msgs, skip=1)
|
|
||||||
|
|
||||||
loop.sessions.invalidate(target_key)
|
|
||||||
target = loop.sessions.get_or_create(target_key)
|
|
||||||
cross_msgs = [m for m in target.messages if m.get("_cross_channel")]
|
|
||||||
assert len(cross_msgs) == 1
|
|
||||||
assert cross_msgs[0]["_source_session"] == "cron:heartbeat"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_channel_media_only_no_content(tmp_path: Path) -> None:
|
|
||||||
"""A message with media but empty content should still be persisted."""
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
target_key = "discord:ch_img"
|
|
||||||
|
|
||||||
target_session = loop.sessions.get_or_create(target_key)
|
|
||||||
loop.sessions.save(target_session)
|
|
||||||
|
|
||||||
source_session = loop.sessions.get_or_create("websocket:ws-img")
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "send image"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_img",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "message",
|
|
||||||
"arguments": json.dumps({
|
|
||||||
"content": "",
|
|
||||||
"channel": "discord",
|
|
||||||
"chat_id": "ch_img",
|
|
||||||
"media": ["/tmp/photo.jpg"],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"role": "tool", "tool_call_id": "call_img", "name": "message", "content": "ok"},
|
|
||||||
]
|
|
||||||
loop._save_turn(source_session, msgs, skip=1)
|
|
||||||
|
|
||||||
loop.sessions.invalidate(target_key)
|
|
||||||
target = loop.sessions.get_or_create(target_key)
|
|
||||||
cross_msgs = [m for m in target.messages if m.get("_cross_channel")]
|
|
||||||
assert len(cross_msgs) == 1
|
|
||||||
assert cross_msgs[0]["_media"] == ["/tmp/photo.jpg"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_channel_non_message_tools_ignored(tmp_path: Path) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
source_session = loop.sessions.get_or_create("websocket:ws-abc")
|
|
||||||
target_session = loop.sessions.get_or_create("feishu:ou_tgt")
|
|
||||||
target_session.add_message("user", "hi")
|
|
||||||
loop.sessions.save(target_session)
|
|
||||||
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "do stuff"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_e1",
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "exec",
|
|
||||||
"arguments": json.dumps({"command": "echo hi"}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"role": "tool", "tool_call_id": "call_e1", "name": "exec", "content": "hi"},
|
|
||||||
]
|
|
||||||
loop._save_turn(source_session, msgs, skip=1)
|
|
||||||
|
|
||||||
# exec tool should NOT produce cross-channel entries
|
|
||||||
target = loop.sessions.get_or_create("feishu:ou_tgt")
|
|
||||||
cross_msgs = [m for m in target.messages if m.get("_cross_channel")]
|
|
||||||
assert len(cross_msgs) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_cross_channel_get_history_annotates_provenance(tmp_path: Path) -> None:
|
|
||||||
"""get_history() should prefix cross-channel messages with source info
|
|
||||||
so the LLM knows where the message came from."""
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
target_key = "feishu:ou_hist"
|
|
||||||
|
|
||||||
target_session = loop.sessions.get_or_create(target_key)
|
|
||||||
target_session.add_message("user", "hello")
|
|
||||||
# Simulate a cross-channel entry as _persist_cross_channel_calls would create
|
|
||||||
target_session.messages.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Daily report ready",
|
|
||||||
"_cross_channel": True,
|
|
||||||
"_source_session": "cron:daily-report",
|
|
||||||
})
|
|
||||||
loop.sessions.save(target_session)
|
|
||||||
|
|
||||||
loop.sessions.invalidate(target_key)
|
|
||||||
target = loop.sessions.get_or_create(target_key)
|
|
||||||
history = target.get_history()
|
|
||||||
|
|
||||||
# Find the annotated message
|
|
||||||
annotated = [m for m in history if "cron:daily-report" in m.get("content", "")]
|
|
||||||
assert len(annotated) == 1
|
|
||||||
assert annotated[0]["content"] == "[Sent from cron:daily-report] Daily report ready"
|
|
||||||
# Internal metadata keys should NOT leak into the history output
|
|
||||||
assert "_cross_channel" not in annotated[0]
|
|
||||||
assert "_source_session" not in annotated[0]
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"""Tests for MCP connection lifecycle in AgentLoop."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
|
|
||||||
|
|
||||||
def _make_loop(tmp_path, *, mcp_servers: dict | None = None) -> AgentLoop:
|
|
||||||
bus = MessageBus()
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
provider.generation.max_tokens = 4096
|
|
||||||
return AgentLoop(
|
|
||||||
bus=bus,
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="test-model",
|
|
||||||
mcp_servers=mcp_servers or {"test": object()},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch: pytest.MonkeyPatch):
|
|
||||||
loop = _make_loop(tmp_path)
|
|
||||||
attempts = 0
|
|
||||||
|
|
||||||
async def _fake_connect(_servers, _registry):
|
|
||||||
nonlocal attempts
|
|
||||||
attempts += 1
|
|
||||||
return {}
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
|
||||||
|
|
||||||
await loop._connect_mcp()
|
|
||||||
await loop._connect_mcp()
|
|
||||||
|
|
||||||
assert attempts == 2
|
|
||||||
assert loop._mcp_connected is False
|
|
||||||
assert loop._mcp_stacks == {}
|
|
||||||
+19
-1021
File diff suppressed because it is too large
Load Diff
@@ -250,63 +250,3 @@ def test_list_skills_openclaw_metadata_parsed_for_requirements(
|
|||||||
assert entries == [
|
assert entries == [
|
||||||
{"name": "openclaw_skill", "path": str(skill_path), "source": "workspace"},
|
{"name": "openclaw_skill", "path": str(skill_path), "source": "workspace"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "ws"
|
|
||||||
ws_skills = workspace / "skills"
|
|
||||||
ws_skills.mkdir(parents=True)
|
|
||||||
_write_skill(ws_skills, "alpha", body="# Alpha")
|
|
||||||
beta_path = _write_skill(ws_skills, "beta", body="# Beta")
|
|
||||||
builtin = tmp_path / "builtin"
|
|
||||||
builtin.mkdir()
|
|
||||||
|
|
||||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills={"alpha"})
|
|
||||||
entries = loader.list_skills(filter_unavailable=False)
|
|
||||||
assert len(entries) == 1
|
|
||||||
assert entries[0]["name"] == "beta"
|
|
||||||
assert entries[0]["path"] == str(beta_path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "ws"
|
|
||||||
ws_skills = workspace / "skills"
|
|
||||||
ws_skills.mkdir(parents=True)
|
|
||||||
_write_skill(ws_skills, "alpha", body="# Alpha")
|
|
||||||
_write_skill(ws_skills, "beta", body="# Beta")
|
|
||||||
builtin = tmp_path / "builtin"
|
|
||||||
builtin.mkdir()
|
|
||||||
|
|
||||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills=set())
|
|
||||||
entries = loader.list_skills(filter_unavailable=False)
|
|
||||||
assert len(entries) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_skills_excluded_from_build_skills_summary(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "ws"
|
|
||||||
ws_skills = workspace / "skills"
|
|
||||||
ws_skills.mkdir(parents=True)
|
|
||||||
_write_skill(ws_skills, "alpha", body="# Alpha")
|
|
||||||
_write_skill(ws_skills, "beta", body="# Beta")
|
|
||||||
builtin = tmp_path / "builtin"
|
|
||||||
builtin.mkdir()
|
|
||||||
|
|
||||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills={"alpha"})
|
|
||||||
summary = loader.build_skills_summary()
|
|
||||||
assert "alpha" not in summary
|
|
||||||
assert "beta" in summary
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "ws"
|
|
||||||
ws_skills = workspace / "skills"
|
|
||||||
ws_skills.mkdir(parents=True)
|
|
||||||
_write_skill(ws_skills, "alpha", metadata_json={"always": True}, body="# Alpha")
|
|
||||||
_write_skill(ws_skills, "beta", metadata_json={"always": True}, body="# Beta")
|
|
||||||
builtin = tmp_path / "builtin"
|
|
||||||
builtin.mkdir()
|
|
||||||
|
|
||||||
loader = SkillsLoader(workspace, builtin_skills_dir=builtin, disabled_skills={"alpha"})
|
|
||||||
always = loader.get_always_skills()
|
|
||||||
assert "alpha" not in always
|
|
||||||
assert "beta" in always
|
|
||||||
|
|||||||
@@ -72,22 +72,6 @@ class TestToolHintKnownTools:
|
|||||||
result = _hint([_tc("exec", {"command": cmd})])
|
result = _hint([_tc("exec", {"command": cmd})])
|
||||||
assert "\u2026/" in result
|
assert "\u2026/" in result
|
||||||
|
|
||||||
def test_exec_abbreviates_quoted_linux_paths_with_spaces(self):
|
|
||||||
"""Quoted Unix paths with spaces should still be folded."""
|
|
||||||
cmd = 'cd "/home/user/My Documents/project" && pytest tests/'
|
|
||||||
result = _hint([_tc("exec", {"command": cmd})])
|
|
||||||
assert "\u2026/" in result
|
|
||||||
assert '"/home/user/My Documents/project"' not in result
|
|
||||||
assert '"' in result
|
|
||||||
|
|
||||||
def test_exec_abbreviates_quoted_windows_paths_with_spaces(self):
|
|
||||||
"""Quoted Windows paths with spaces should still be folded."""
|
|
||||||
cmd = 'cd "C:/Program Files/Git/project" && git status'
|
|
||||||
result = _hint([_tc("exec", {"command": cmd})])
|
|
||||||
assert "\u2026/" in result
|
|
||||||
assert '"C:/Program Files/Git/project"' not in result
|
|
||||||
assert '"' in result
|
|
||||||
|
|
||||||
def test_exec_short_command_unchanged(self):
|
def test_exec_short_command_unchanged(self):
|
||||||
result = _hint([_tc("exec", {"command": "npm install typescript"})])
|
result = _hint([_tc("exec", {"command": "npm install typescript"})])
|
||||||
assert result == "$ npm install typescript"
|
assert result == "$ npm install typescript"
|
||||||
|
|||||||
@@ -1,502 +0,0 @@
|
|||||||
"""Tests for unified_session feature.
|
|
||||||
|
|
||||||
Covers:
|
|
||||||
- AgentLoop._dispatch() rewrites session_key to "unified:default" when enabled
|
|
||||||
- Existing session_key_override is respected (not overwritten)
|
|
||||||
- Feature is off by default (no behavior change for existing users)
|
|
||||||
- Config schema serialises unified_session as camelCase "unifiedSession"
|
|
||||||
- onboard-generated config.json contains "unifiedSession" key
|
|
||||||
- /new command correctly clears the shared session in unified mode
|
|
||||||
- /new is NOT a priority command (goes through _dispatch, key rewrite applies)
|
|
||||||
- Context window consolidation is unaffected by unified_session
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.command.builtin import cmd_new, register_builtin_commands
|
|
||||||
from nanobot.command.router import CommandContext, CommandRouter
|
|
||||||
from nanobot.config.schema import AgentDefaults, Config
|
|
||||||
from nanobot.session.manager import Session, SessionManager
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _make_loop(tmp_path: Path, unified_session: bool = False) -> AgentLoop:
|
|
||||||
"""Create a minimal AgentLoop for dispatch-level tests."""
|
|
||||||
bus = MessageBus()
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
|
|
||||||
with patch("nanobot.agent.loop.SessionManager"), \
|
|
||||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr, \
|
|
||||||
patch("nanobot.agent.loop.Dream"):
|
|
||||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=bus,
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
unified_session=unified_session,
|
|
||||||
)
|
|
||||||
return loop
|
|
||||||
|
|
||||||
|
|
||||||
def _make_msg(channel: str = "telegram", chat_id: str = "111",
|
|
||||||
session_key_override: str | None = None) -> InboundMessage:
|
|
||||||
return InboundMessage(
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
sender_id="user1",
|
|
||||||
content="hello",
|
|
||||||
session_key_override=session_key_override,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TestUnifiedSessionDispatch — core behaviour
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestUnifiedSessionDispatch:
|
|
||||||
"""AgentLoop._dispatch() session key rewriting logic."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unified_session_rewrites_key_to_unified_default(self, tmp_path: Path):
|
|
||||||
"""When unified_session=True, all messages use 'unified:default' as session key."""
|
|
||||||
loop = _make_loop(tmp_path, unified_session=True)
|
|
||||||
|
|
||||||
captured: list[str] = []
|
|
||||||
|
|
||||||
async def fake_process(msg, **kwargs):
|
|
||||||
captured.append(msg.session_key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
loop._process_message = fake_process # type: ignore[method-assign]
|
|
||||||
|
|
||||||
msg = _make_msg(channel="telegram", chat_id="111")
|
|
||||||
await loop._dispatch(msg)
|
|
||||||
|
|
||||||
assert captured == ["unified:default"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unified_session_different_channels_share_same_key(self, tmp_path: Path):
|
|
||||||
"""Messages from different channels all resolve to the same session key."""
|
|
||||||
loop = _make_loop(tmp_path, unified_session=True)
|
|
||||||
|
|
||||||
captured: list[str] = []
|
|
||||||
|
|
||||||
async def fake_process(msg, **kwargs):
|
|
||||||
captured.append(msg.session_key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
loop._process_message = fake_process # type: ignore[method-assign]
|
|
||||||
|
|
||||||
await loop._dispatch(_make_msg(channel="telegram", chat_id="111"))
|
|
||||||
await loop._dispatch(_make_msg(channel="discord", chat_id="222"))
|
|
||||||
await loop._dispatch(_make_msg(channel="cli", chat_id="direct"))
|
|
||||||
|
|
||||||
assert captured == ["unified:default", "unified:default", "unified:default"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unified_session_disabled_preserves_original_key(self, tmp_path: Path):
|
|
||||||
"""When unified_session=False (default), session key is channel:chat_id as usual."""
|
|
||||||
loop = _make_loop(tmp_path, unified_session=False)
|
|
||||||
|
|
||||||
captured: list[str] = []
|
|
||||||
|
|
||||||
async def fake_process(msg, **kwargs):
|
|
||||||
captured.append(msg.session_key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
loop._process_message = fake_process # type: ignore[method-assign]
|
|
||||||
|
|
||||||
msg = _make_msg(channel="telegram", chat_id="999")
|
|
||||||
await loop._dispatch(msg)
|
|
||||||
|
|
||||||
assert captured == ["telegram:999"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unified_session_respects_existing_override(self, tmp_path: Path):
|
|
||||||
"""If session_key_override is already set (e.g. Telegram thread), it is NOT overwritten."""
|
|
||||||
loop = _make_loop(tmp_path, unified_session=True)
|
|
||||||
|
|
||||||
captured: list[str] = []
|
|
||||||
|
|
||||||
async def fake_process(msg, **kwargs):
|
|
||||||
captured.append(msg.session_key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
loop._process_message = fake_process # type: ignore[method-assign]
|
|
||||||
|
|
||||||
msg = _make_msg(channel="telegram", chat_id="111", session_key_override="telegram:thread:42")
|
|
||||||
await loop._dispatch(msg)
|
|
||||||
|
|
||||||
assert captured == ["telegram:thread:42"]
|
|
||||||
|
|
||||||
def test_unified_session_default_is_false(self, tmp_path: Path):
|
|
||||||
"""unified_session defaults to False — no behavior change for existing users."""
|
|
||||||
loop = _make_loop(tmp_path)
|
|
||||||
assert loop._unified_session is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TestUnifiedSessionConfig — schema & serialisation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestUnifiedSessionConfig:
|
|
||||||
"""Config schema and onboard serialisation for unified_session."""
|
|
||||||
|
|
||||||
def test_agent_defaults_unified_session_default_is_false(self):
|
|
||||||
"""AgentDefaults.unified_session defaults to False."""
|
|
||||||
defaults = AgentDefaults()
|
|
||||||
assert defaults.unified_session is False
|
|
||||||
|
|
||||||
def test_agent_defaults_unified_session_can_be_enabled(self):
|
|
||||||
"""AgentDefaults.unified_session can be set to True."""
|
|
||||||
defaults = AgentDefaults(unified_session=True)
|
|
||||||
assert defaults.unified_session is True
|
|
||||||
|
|
||||||
def test_config_serialises_unified_session_as_camel_case(self):
|
|
||||||
"""model_dump(by_alias=True) outputs 'unifiedSession' (camelCase) for JSON."""
|
|
||||||
config = Config()
|
|
||||||
data = config.model_dump(mode="json", by_alias=True)
|
|
||||||
agents_defaults = data["agents"]["defaults"]
|
|
||||||
assert "unifiedSession" in agents_defaults
|
|
||||||
assert agents_defaults["unifiedSession"] is False
|
|
||||||
|
|
||||||
def test_config_parses_unified_session_from_camel_case(self):
|
|
||||||
"""Config can be loaded from JSON with camelCase 'unifiedSession'."""
|
|
||||||
raw = {"agents": {"defaults": {"unifiedSession": True}}}
|
|
||||||
config = Config.model_validate(raw)
|
|
||||||
assert config.agents.defaults.unified_session is True
|
|
||||||
|
|
||||||
def test_config_parses_unified_session_from_snake_case(self):
|
|
||||||
"""Config also accepts snake_case 'unified_session' (populate_by_name=True)."""
|
|
||||||
raw = {"agents": {"defaults": {"unified_session": True}}}
|
|
||||||
config = Config.model_validate(raw)
|
|
||||||
assert config.agents.defaults.unified_session is True
|
|
||||||
|
|
||||||
def test_onboard_generated_config_contains_unified_session(self, tmp_path: Path):
|
|
||||||
"""save_config() writes 'unifiedSession' into config.json (simulates nanobot onboard)."""
|
|
||||||
from nanobot.config.loader import save_config
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
config_path = tmp_path / "config.json"
|
|
||||||
save_config(config, config_path)
|
|
||||||
|
|
||||||
with open(config_path, encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
agents_defaults = data["agents"]["defaults"]
|
|
||||||
assert "unifiedSession" in agents_defaults, (
|
|
||||||
"onboard-generated config.json must contain 'unifiedSession' key"
|
|
||||||
)
|
|
||||||
assert agents_defaults["unifiedSession"] is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TestCmdNewUnifiedSession — /new command behaviour in unified mode
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestCmdNewUnifiedSession:
|
|
||||||
"""/new command routing and session-clear behaviour in unified mode."""
|
|
||||||
|
|
||||||
def test_new_is_not_a_priority_command(self):
|
|
||||||
"""/new must NOT be in the priority table — it must go through _dispatch()
|
|
||||||
so the unified session key rewrite applies before cmd_new runs."""
|
|
||||||
router = CommandRouter()
|
|
||||||
register_builtin_commands(router)
|
|
||||||
assert router.is_priority("/new") is False
|
|
||||||
|
|
||||||
def test_new_is_an_exact_command(self):
|
|
||||||
"""/new must be registered as an exact command."""
|
|
||||||
router = CommandRouter()
|
|
||||||
register_builtin_commands(router)
|
|
||||||
assert "/new" in router._exact
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cmd_new_clears_unified_session(self, tmp_path: Path):
|
|
||||||
"""cmd_new called with key='unified:default' clears the shared session."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
|
|
||||||
# Pre-populate the shared session with some messages
|
|
||||||
shared = sessions.get_or_create("unified:default")
|
|
||||||
shared.add_message("user", "hello from telegram")
|
|
||||||
shared.add_message("assistant", "hi there")
|
|
||||||
sessions.save(shared)
|
|
||||||
assert len(sessions.get_or_create("unified:default").messages) == 2
|
|
||||||
|
|
||||||
# _schedule_background is a *sync* method that schedules a coroutine via
|
|
||||||
# asyncio.create_task(). Mirror that exactly so the coroutine is consumed
|
|
||||||
# and no RuntimeWarning is emitted.
|
|
||||||
loop = SimpleNamespace(
|
|
||||||
sessions=sessions,
|
|
||||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
|
||||||
)
|
|
||||||
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
|
|
||||||
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="telegram", sender_id="user1", chat_id="111", content="/new",
|
|
||||||
session_key_override="unified:default", # as _dispatch() would set it
|
|
||||||
)
|
|
||||||
ctx = CommandContext(msg=msg, session=None, key="unified:default", raw="/new", loop=loop)
|
|
||||||
|
|
||||||
result = await cmd_new(ctx)
|
|
||||||
|
|
||||||
assert "New session started" in result.content
|
|
||||||
# Invalidate cache and reload from disk to confirm persistence
|
|
||||||
sessions.invalidate("unified:default")
|
|
||||||
reloaded = sessions.get_or_create("unified:default")
|
|
||||||
assert reloaded.messages == []
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cmd_new_in_unified_mode_does_not_affect_other_sessions(self, tmp_path: Path):
|
|
||||||
"""Clearing unified:default must not touch other sessions on disk."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
|
|
||||||
other = sessions.get_or_create("discord:999")
|
|
||||||
other.add_message("user", "discord message")
|
|
||||||
sessions.save(other)
|
|
||||||
|
|
||||||
shared = sessions.get_or_create("unified:default")
|
|
||||||
shared.add_message("user", "shared message")
|
|
||||||
sessions.save(shared)
|
|
||||||
|
|
||||||
loop = SimpleNamespace(
|
|
||||||
sessions=sessions,
|
|
||||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
|
||||||
)
|
|
||||||
loop._schedule_background = lambda coro: asyncio.ensure_future(coro)
|
|
||||||
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="telegram", sender_id="user1", chat_id="111", content="/new",
|
|
||||||
session_key_override="unified:default",
|
|
||||||
)
|
|
||||||
ctx = CommandContext(msg=msg, session=None, key="unified:default", raw="/new", loop=loop)
|
|
||||||
await cmd_new(ctx)
|
|
||||||
|
|
||||||
sessions.invalidate("unified:default")
|
|
||||||
sessions.invalidate("discord:999")
|
|
||||||
assert sessions.get_or_create("unified:default").messages == []
|
|
||||||
assert len(sessions.get_or_create("discord:999").messages) == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TestConsolidationUnaffectedByUnifiedSession — consolidation is key-agnostic
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestConsolidationUnaffectedByUnifiedSession:
|
|
||||||
"""maybe_consolidate_by_tokens() behaviour is identical regardless of session key."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_consolidation_skips_empty_session_for_unified_key(self):
|
|
||||||
"""Empty unified:default session → consolidation exits immediately, archive not called."""
|
|
||||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
|
||||||
|
|
||||||
store = MagicMock(spec=MemoryStore)
|
|
||||||
mock_provider = MagicMock()
|
|
||||||
mock_provider.chat_with_retry = AsyncMock(return_value=MagicMock(content="summary"))
|
|
||||||
# Use spec= so MagicMock doesn't auto-generate AsyncMock for non-async methods,
|
|
||||||
# which would leave unawaited coroutines and trigger RuntimeWarning.
|
|
||||||
sessions = MagicMock(spec=SessionManager)
|
|
||||||
|
|
||||||
consolidator = Consolidator(
|
|
||||||
store=store,
|
|
||||||
provider=mock_provider,
|
|
||||||
model="test-model",
|
|
||||||
sessions=sessions,
|
|
||||||
context_window_tokens=1000,
|
|
||||||
build_messages=MagicMock(return_value=[]),
|
|
||||||
get_tool_definitions=MagicMock(return_value=[]),
|
|
||||||
max_completion_tokens=100,
|
|
||||||
)
|
|
||||||
consolidator.archive = AsyncMock()
|
|
||||||
|
|
||||||
session = Session(key="unified:default")
|
|
||||||
session.messages = []
|
|
||||||
|
|
||||||
await consolidator.maybe_consolidate_by_tokens(session)
|
|
||||||
|
|
||||||
consolidator.archive.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_consolidation_behaviour_identical_for_any_key(self):
|
|
||||||
"""archive call count is the same for 'telegram:123' and 'unified:default'
|
|
||||||
under identical token conditions."""
|
|
||||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
|
||||||
|
|
||||||
archive_calls: dict[str, int] = {}
|
|
||||||
|
|
||||||
for key in ("telegram:123", "unified:default"):
|
|
||||||
store = MagicMock(spec=MemoryStore)
|
|
||||||
mock_provider = MagicMock()
|
|
||||||
mock_provider.chat_with_retry = AsyncMock(return_value=MagicMock(content="summary"))
|
|
||||||
sessions = MagicMock(spec=SessionManager)
|
|
||||||
|
|
||||||
consolidator = Consolidator(
|
|
||||||
store=store,
|
|
||||||
provider=mock_provider,
|
|
||||||
model="test-model",
|
|
||||||
sessions=sessions,
|
|
||||||
context_window_tokens=1000,
|
|
||||||
build_messages=MagicMock(return_value=[]),
|
|
||||||
get_tool_definitions=MagicMock(return_value=[]),
|
|
||||||
max_completion_tokens=100,
|
|
||||||
)
|
|
||||||
|
|
||||||
session = Session(key=key)
|
|
||||||
session.messages = [] # empty → exits immediately for both keys
|
|
||||||
|
|
||||||
consolidator.archive = AsyncMock()
|
|
||||||
await consolidator.maybe_consolidate_by_tokens(session)
|
|
||||||
archive_calls[key] = consolidator.archive.call_count
|
|
||||||
|
|
||||||
assert archive_calls["telegram:123"] == archive_calls["unified:default"] == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_consolidation_triggers_when_over_budget_unified_key(self):
|
|
||||||
"""When tokens exceed budget, consolidation attempts to find a boundary —
|
|
||||||
behaviour is identical to any other session key."""
|
|
||||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
|
||||||
|
|
||||||
store = MagicMock(spec=MemoryStore)
|
|
||||||
mock_provider = MagicMock()
|
|
||||||
sessions = MagicMock(spec=SessionManager)
|
|
||||||
|
|
||||||
consolidator = Consolidator(
|
|
||||||
store=store,
|
|
||||||
provider=mock_provider,
|
|
||||||
model="test-model",
|
|
||||||
sessions=sessions,
|
|
||||||
context_window_tokens=1000,
|
|
||||||
build_messages=MagicMock(return_value=[]),
|
|
||||||
get_tool_definitions=MagicMock(return_value=[]),
|
|
||||||
max_completion_tokens=100,
|
|
||||||
)
|
|
||||||
|
|
||||||
session = Session(key="unified:default")
|
|
||||||
session.messages = [{"role": "user", "content": "msg"}]
|
|
||||||
|
|
||||||
# Simulate over-budget: estimated > budget
|
|
||||||
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(950, "tiktoken"))
|
|
||||||
# No valid boundary found → returns gracefully without archiving
|
|
||||||
consolidator.pick_consolidation_boundary = MagicMock(return_value=None)
|
|
||||||
consolidator.archive = AsyncMock()
|
|
||||||
|
|
||||||
await consolidator.maybe_consolidate_by_tokens(session)
|
|
||||||
|
|
||||||
# estimate was called (consolidation was attempted)
|
|
||||||
consolidator.estimate_session_prompt_tokens.assert_called_once_with(session)
|
|
||||||
# but archive was not called (no valid boundary)
|
|
||||||
consolidator.archive.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TestStopCommandWithUnifiedSession — /stop command integration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestStopCommandWithUnifiedSession:
|
|
||||||
"""Verify /stop command works correctly with unified session enabled."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_active_tasks_use_effective_key_in_unified_mode(self, tmp_path: Path):
|
|
||||||
"""When unified_session=True, tasks are stored under UNIFIED_SESSION_KEY."""
|
|
||||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
|
||||||
|
|
||||||
loop = _make_loop(tmp_path, unified_session=True)
|
|
||||||
|
|
||||||
# Create a message from telegram channel
|
|
||||||
msg = _make_msg(channel="telegram", chat_id="123456")
|
|
||||||
|
|
||||||
# Mock _dispatch to complete immediately
|
|
||||||
async def fake_dispatch(m):
|
|
||||||
pass
|
|
||||||
|
|
||||||
loop._dispatch = fake_dispatch # type: ignore[method-assign]
|
|
||||||
|
|
||||||
# Simulate the task creation flow (from _run loop)
|
|
||||||
effective_key = UNIFIED_SESSION_KEY if loop._unified_session and not msg.session_key_override else msg.session_key
|
|
||||||
task = asyncio.create_task(loop._dispatch(msg))
|
|
||||||
loop._active_tasks.setdefault(effective_key, []).append(task)
|
|
||||||
|
|
||||||
# Wait for task to complete
|
|
||||||
await task
|
|
||||||
|
|
||||||
# Verify the task is stored under UNIFIED_SESSION_KEY, not the original channel:chat_id
|
|
||||||
assert UNIFIED_SESSION_KEY in loop._active_tasks
|
|
||||||
assert "telegram:123456" not in loop._active_tasks
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_command_finds_task_in_unified_mode(self, tmp_path: Path):
|
|
||||||
"""cmd_stop can cancel tasks when unified_session=True."""
|
|
||||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
|
||||||
from nanobot.command.builtin import cmd_stop
|
|
||||||
|
|
||||||
loop = _make_loop(tmp_path, unified_session=True)
|
|
||||||
|
|
||||||
# Create a long-running task stored under UNIFIED_SESSION_KEY
|
|
||||||
async def long_running():
|
|
||||||
await asyncio.sleep(10) # Will be cancelled
|
|
||||||
|
|
||||||
task = asyncio.create_task(long_running())
|
|
||||||
loop._active_tasks[UNIFIED_SESSION_KEY] = [task]
|
|
||||||
|
|
||||||
# Create a message that would have session_key=UNIFIED_SESSION_KEY after dispatch
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="telegram",
|
|
||||||
chat_id="123456",
|
|
||||||
sender_id="user1",
|
|
||||||
content="/stop",
|
|
||||||
session_key_override=UNIFIED_SESSION_KEY, # Simulate post-dispatch state
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx = CommandContext(msg=msg, session=None, key=UNIFIED_SESSION_KEY, raw="/stop", loop=loop)
|
|
||||||
|
|
||||||
# Execute /stop
|
|
||||||
result = await cmd_stop(ctx)
|
|
||||||
|
|
||||||
# Verify task was cancelled
|
|
||||||
assert task.cancelled() or task.done()
|
|
||||||
assert "Stopped 1 task" in result.content
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_command_cross_channel_in_unified_mode(self, tmp_path: Path):
|
|
||||||
"""In unified mode, /stop from one channel cancels tasks from another channel."""
|
|
||||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
|
||||||
from nanobot.command.builtin import cmd_stop
|
|
||||||
|
|
||||||
loop = _make_loop(tmp_path, unified_session=True)
|
|
||||||
|
|
||||||
# Create tasks from different channels, all stored under UNIFIED_SESSION_KEY
|
|
||||||
async def long_running():
|
|
||||||
await asyncio.sleep(10)
|
|
||||||
|
|
||||||
task1 = asyncio.create_task(long_running())
|
|
||||||
task2 = asyncio.create_task(long_running())
|
|
||||||
loop._active_tasks[UNIFIED_SESSION_KEY] = [task1, task2]
|
|
||||||
|
|
||||||
# /stop from discord should cancel tasks started from telegram
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="discord",
|
|
||||||
chat_id="789012",
|
|
||||||
sender_id="user2",
|
|
||||||
content="/stop",
|
|
||||||
session_key_override=UNIFIED_SESSION_KEY,
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx = CommandContext(msg=msg, session=None, key=UNIFIED_SESSION_KEY, raw="/stop", loop=loop)
|
|
||||||
|
|
||||||
result = await cmd_stop(ctx)
|
|
||||||
|
|
||||||
# Both tasks should be cancelled
|
|
||||||
assert "Stopped 2 task" in result.content
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import zipfile
|
|
||||||
from io import BytesIO
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# Check optional dingtalk dependencies before running tests
|
# Check optional dingtalk dependencies before running tests
|
||||||
@@ -54,21 +50,6 @@ class _FakeHttp:
|
|||||||
return self._next_response()
|
return self._next_response()
|
||||||
|
|
||||||
|
|
||||||
class _NetworkErrorHttp:
|
|
||||||
"""HTTP client stub that raises httpx.TransportError on every request."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls: list[dict] = []
|
|
||||||
|
|
||||||
async def post(self, url: str, json=None, headers=None, **kwargs):
|
|
||||||
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
|
|
||||||
raise httpx.ConnectError("Connection refused")
|
|
||||||
|
|
||||||
async def get(self, url: str, **kwargs):
|
|
||||||
self.calls.append({"method": "GET", "url": url})
|
|
||||||
raise httpx.ConnectError("Connection refused")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None:
|
async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None:
|
||||||
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"])
|
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"])
|
||||||
@@ -240,216 +221,3 @@ async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
|
|||||||
assert "messageFiles/download" in channel._http.calls[0]["url"]
|
assert "messageFiles/download" in channel._http.calls[0]["url"]
|
||||||
assert channel._http.calls[0]["json"]["downloadCode"] == "code123"
|
assert channel._http.calls[0]["json"]["downloadCode"] == "code123"
|
||||||
assert channel._http.calls[1]["method"] == "GET"
|
assert channel._http.calls[1]["method"] == "GET"
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_upload_payload_zips_html_attachment() -> None:
|
|
||||||
channel = DingTalkChannel(
|
|
||||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
|
|
||||||
data, filename, content_type = channel._normalize_upload_payload(
|
|
||||||
"report.html",
|
|
||||||
b"<html><body>Hello</body></html>",
|
|
||||||
"text/html",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert filename == "report.zip"
|
|
||||||
assert content_type == "application/zip"
|
|
||||||
|
|
||||||
archive = zipfile.ZipFile(BytesIO(data))
|
|
||||||
assert archive.namelist() == ["report.html"]
|
|
||||||
assert archive.read("report.html") == b"<html><body>Hello</body></html>"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_ref_zips_html_before_upload(tmp_path, monkeypatch) -> None:
|
|
||||||
channel = DingTalkChannel(
|
|
||||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
|
|
||||||
html_path = tmp_path / "report.html"
|
|
||||||
html_path.write_text("<html><body>Hello</body></html>", encoding="utf-8")
|
|
||||||
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
|
|
||||||
async def fake_upload_media(*, token, data, media_type, filename, content_type):
|
|
||||||
captured.update(
|
|
||||||
{
|
|
||||||
"token": token,
|
|
||||||
"data": data,
|
|
||||||
"media_type": media_type,
|
|
||||||
"filename": filename,
|
|
||||||
"content_type": content_type,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return "media-123"
|
|
||||||
|
|
||||||
async def fake_send_batch_message(token, chat_id, msg_key, msg_param):
|
|
||||||
captured.update(
|
|
||||||
{
|
|
||||||
"sent_token": token,
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"msg_key": msg_key,
|
|
||||||
"msg_param": msg_param,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
monkeypatch.setattr(channel, "_upload_media", fake_upload_media)
|
|
||||||
monkeypatch.setattr(channel, "_send_batch_message", fake_send_batch_message)
|
|
||||||
|
|
||||||
ok = await channel._send_media_ref("token-123", "user-1", str(html_path))
|
|
||||||
|
|
||||||
assert ok is True
|
|
||||||
assert captured["media_type"] == "file"
|
|
||||||
assert captured["filename"] == "report.zip"
|
|
||||||
assert captured["content_type"] == "application/zip"
|
|
||||||
assert captured["msg_key"] == "sampleFile"
|
|
||||||
assert captured["msg_param"] == {
|
|
||||||
"mediaId": "media-123",
|
|
||||||
"fileName": "report.zip",
|
|
||||||
"fileType": "zip",
|
|
||||||
}
|
|
||||||
|
|
||||||
archive = zipfile.ZipFile(BytesIO(captured["data"]))
|
|
||||||
assert archive.namelist() == ["report.html"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Exception handling tests ──────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_batch_message_propagates_transport_error() -> None:
|
|
||||||
"""Network/transport errors must re-raise so callers can retry."""
|
|
||||||
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
|
|
||||||
channel = DingTalkChannel(config, MessageBus())
|
|
||||||
channel._http = _NetworkErrorHttp()
|
|
||||||
|
|
||||||
with pytest.raises(httpx.ConnectError, match="Connection refused"):
|
|
||||||
await channel._send_batch_message(
|
|
||||||
"token",
|
|
||||||
"user123",
|
|
||||||
"sampleMarkdown",
|
|
||||||
{"text": "hello", "title": "Nanobot Reply"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# The POST was attempted exactly once
|
|
||||||
assert len(channel._http.calls) == 1
|
|
||||||
assert channel._http.calls[0]["method"] == "POST"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_batch_message_returns_false_on_api_error() -> None:
|
|
||||||
"""DingTalk API-level errors (non-200 status, errcode != 0) should return False."""
|
|
||||||
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
|
|
||||||
channel = DingTalkChannel(config, MessageBus())
|
|
||||||
|
|
||||||
# Non-200 status code → API error → return False
|
|
||||||
channel._http = _FakeHttp(responses=[_FakeResponse(400, {"errcode": 400})])
|
|
||||||
result = await channel._send_batch_message(
|
|
||||||
"token", "user123", "sampleMarkdown", {"text": "hello"}
|
|
||||||
)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
# 200 with non-zero errcode → API error → return False
|
|
||||||
channel._http = _FakeHttp(responses=[_FakeResponse(200, {"errcode": 100})])
|
|
||||||
result = await channel._send_batch_message(
|
|
||||||
"token", "user123", "sampleMarkdown", {"text": "hello"}
|
|
||||||
)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
# 200 with errcode=0 → success → return True
|
|
||||||
channel._http = _FakeHttp(responses=[_FakeResponse(200, {"errcode": 0})])
|
|
||||||
result = await channel._send_batch_message(
|
|
||||||
"token", "user123", "sampleMarkdown", {"text": "hello"}
|
|
||||||
)
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_ref_short_circuits_on_transport_error() -> None:
|
|
||||||
"""When the first send fails with a transport error, _send_media_ref must
|
|
||||||
re-raise immediately instead of trying download+upload+fallback."""
|
|
||||||
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
|
|
||||||
channel = DingTalkChannel(config, MessageBus())
|
|
||||||
channel._http = _NetworkErrorHttp()
|
|
||||||
|
|
||||||
# An image URL triggers the sampleImageMsg path first
|
|
||||||
with pytest.raises(httpx.ConnectError, match="Connection refused"):
|
|
||||||
await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")
|
|
||||||
|
|
||||||
# Only one POST should have been attempted — no download/upload/fallback
|
|
||||||
assert len(channel._http.calls) == 1
|
|
||||||
assert channel._http.calls[0]["method"] == "POST"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_ref_short_circuits_on_download_transport_error() -> None:
|
|
||||||
"""When the image URL send returns an API error (False) but the download
|
|
||||||
for the fallback hits a transport error, it must re-raise rather than
|
|
||||||
silently returning False."""
|
|
||||||
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
|
|
||||||
channel = DingTalkChannel(config, MessageBus())
|
|
||||||
|
|
||||||
# First POST (sampleImageMsg) returns API error → False, then GET (download) raises transport error
|
|
||||||
class _MixedHttp:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls: list[dict] = []
|
|
||||||
|
|
||||||
async def post(self, url, json=None, headers=None, **kwargs):
|
|
||||||
self.calls.append({"method": "POST", "url": url})
|
|
||||||
# API-level failure: 200 with errcode != 0
|
|
||||||
return _FakeResponse(200, {"errcode": 100})
|
|
||||||
|
|
||||||
async def get(self, url, **kwargs):
|
|
||||||
self.calls.append({"method": "GET", "url": url})
|
|
||||||
raise httpx.ConnectError("Connection refused")
|
|
||||||
|
|
||||||
channel._http = _MixedHttp()
|
|
||||||
|
|
||||||
with pytest.raises(httpx.ConnectError, match="Connection refused"):
|
|
||||||
await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")
|
|
||||||
|
|
||||||
# Should have attempted POST (image URL) and GET (download), but NOT upload
|
|
||||||
assert len(channel._http.calls) == 2
|
|
||||||
assert channel._http.calls[0]["method"] == "POST"
|
|
||||||
assert channel._http.calls[1]["method"] == "GET"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_ref_short_circuits_on_upload_transport_error() -> None:
|
|
||||||
"""When download succeeds but upload hits a transport error, must re-raise."""
|
|
||||||
config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
|
|
||||||
channel = DingTalkChannel(config, MessageBus())
|
|
||||||
|
|
||||||
image_bytes = b"\xff\xd8\xff\xe0" + b"\x00" * 100 # minimal JPEG-ish data
|
|
||||||
|
|
||||||
class _UploadFailsHttp:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls: list[dict] = []
|
|
||||||
|
|
||||||
async def post(self, url, json=None, headers=None, files=None, **kwargs):
|
|
||||||
self.calls.append({"method": "POST", "url": url})
|
|
||||||
# If it's the upload endpoint, raise transport error
|
|
||||||
if "media/upload" in url:
|
|
||||||
raise httpx.ConnectError("Connection refused")
|
|
||||||
# Otherwise (sampleImageMsg), return API error to trigger fallback
|
|
||||||
return _FakeResponse(200, {"errcode": 100})
|
|
||||||
|
|
||||||
async def get(self, url, **kwargs):
|
|
||||||
self.calls.append({"method": "GET", "url": url})
|
|
||||||
resp = _FakeResponse(200)
|
|
||||||
resp.content = image_bytes
|
|
||||||
resp.headers = {"content-type": "image/jpeg"}
|
|
||||||
return resp
|
|
||||||
|
|
||||||
channel._http = _UploadFailsHttp()
|
|
||||||
|
|
||||||
with pytest.raises(httpx.ConnectError, match="Connection refused"):
|
|
||||||
await channel._send_media_ref("token", "user123", "https://example.com/photo.jpg")
|
|
||||||
|
|
||||||
# POST (image URL), GET (download), POST (upload) attempted — no further sends
|
|
||||||
methods = [c["method"] for c in channel._http.calls]
|
|
||||||
assert methods == ["POST", "GET", "POST"]
|
|
||||||
|
|||||||
@@ -5,17 +5,11 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
discord = pytest.importorskip("discord")
|
discord = pytest.importorskip("discord")
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.discord import (
|
from nanobot.channels.discord import DiscordBotClient, DiscordChannel, DiscordConfig
|
||||||
MAX_MESSAGE_LEN,
|
|
||||||
DiscordBotClient,
|
|
||||||
DiscordChannel,
|
|
||||||
DiscordConfig,
|
|
||||||
)
|
|
||||||
from nanobot.command.builtin import build_help_text
|
from nanobot.command.builtin import build_help_text
|
||||||
|
|
||||||
|
|
||||||
@@ -24,11 +18,9 @@ class _FakeDiscordClient:
|
|||||||
instances: list["_FakeDiscordClient"] = []
|
instances: list["_FakeDiscordClient"] = []
|
||||||
start_error: Exception | None = None
|
start_error: Exception | None = None
|
||||||
|
|
||||||
def __init__(self, owner, *, intents, proxy=None, proxy_auth=None) -> None:
|
def __init__(self, owner, *, intents) -> None:
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
self.intents = intents
|
self.intents = intents
|
||||||
self.proxy = proxy
|
|
||||||
self.proxy_auth = proxy_auth
|
|
||||||
self.closed = False
|
self.closed = False
|
||||||
self.ready = True
|
self.ready = True
|
||||||
self.channels: dict[int, object] = {}
|
self.channels: dict[int, object] = {}
|
||||||
@@ -61,9 +53,7 @@ class _FakeDiscordClient:
|
|||||||
|
|
||||||
class _FakeAttachment:
|
class _FakeAttachment:
|
||||||
# Attachment double that can simulate successful or failing save() calls.
|
# Attachment double that can simulate successful or failing save() calls.
|
||||||
def __init__(
|
def __init__(self, attachment_id: int, filename: str, *, size: int = 1, fail: bool = False) -> None:
|
||||||
self, attachment_id: int, filename: str, *, size: int = 1, fail: bool = False
|
|
||||||
) -> None:
|
|
||||||
self.id = attachment_id
|
self.id = attachment_id
|
||||||
self.filename = filename
|
self.filename = filename
|
||||||
self.size = size
|
self.size = size
|
||||||
@@ -81,25 +71,11 @@ class _FakePartialMessage:
|
|||||||
self.id = message_id
|
self.id = message_id
|
||||||
|
|
||||||
|
|
||||||
class _FakeSentMessage:
|
|
||||||
# Sent-message double supporting edit() for streaming tests.
|
|
||||||
def __init__(self, channel, content: str) -> None:
|
|
||||||
self.channel = channel
|
|
||||||
self.content = content
|
|
||||||
self.edits: list[dict] = []
|
|
||||||
|
|
||||||
async def edit(self, **kwargs) -> None:
|
|
||||||
self.edits.append(dict(kwargs))
|
|
||||||
if "content" in kwargs:
|
|
||||||
self.content = kwargs["content"]
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeChannel:
|
class _FakeChannel:
|
||||||
# Channel double that records outbound payloads and typing activity.
|
# Channel double that records outbound payloads and typing activity.
|
||||||
def __init__(self, channel_id: int = 123) -> None:
|
def __init__(self, channel_id: int = 123) -> None:
|
||||||
self.id = channel_id
|
self.id = channel_id
|
||||||
self.sent_payloads: list[dict] = []
|
self.sent_payloads: list[dict] = []
|
||||||
self.sent_messages: list[_FakeSentMessage] = []
|
|
||||||
self.trigger_typing_calls = 0
|
self.trigger_typing_calls = 0
|
||||||
self.typing_enter_hook = None
|
self.typing_enter_hook = None
|
||||||
|
|
||||||
@@ -109,9 +85,6 @@ class _FakeChannel:
|
|||||||
payload["file_name"] = payload["file"].filename
|
payload["file_name"] = payload["file"].filename
|
||||||
del payload["file"]
|
del payload["file"]
|
||||||
self.sent_payloads.append(payload)
|
self.sent_payloads.append(payload)
|
||||||
message = _FakeSentMessage(self, payload.get("content", ""))
|
|
||||||
self.sent_messages.append(message)
|
|
||||||
return message
|
|
||||||
|
|
||||||
def get_partial_message(self, message_id: int) -> _FakePartialMessage:
|
def get_partial_message(self, message_id: int) -> _FakePartialMessage:
|
||||||
return _FakePartialMessage(message_id)
|
return _FakePartialMessage(message_id)
|
||||||
@@ -221,7 +194,7 @@ async def test_start_handles_client_construction_failure(monkeypatch) -> None:
|
|||||||
MessageBus(),
|
MessageBus(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _boom(owner, *, intents, proxy=None, proxy_auth=None):
|
def _boom(owner, *, intents):
|
||||||
raise RuntimeError("bad client")
|
raise RuntimeError("bad client")
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom)
|
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom)
|
||||||
@@ -454,60 +427,6 @@ async def test_send_fetches_channel_when_not_cached() -> None:
|
|||||||
assert target.sent_payloads == [{"content": "hello"}]
|
assert target.sent_payloads == [{"content": "hello"}]
|
||||||
|
|
||||||
|
|
||||||
def test_supports_streaming_enabled_by_default() -> None:
|
|
||||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
|
||||||
|
|
||||||
assert channel.supports_streaming is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
|
|
||||||
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
|
||||||
client = _FakeDiscordClient(owner, intents=None)
|
|
||||||
owner._client = client
|
|
||||||
owner._running = True
|
|
||||||
target = _FakeChannel(channel_id=123)
|
|
||||||
client.channels[123] = target
|
|
||||||
|
|
||||||
times = iter([1.0, 3.0, 5.0])
|
|
||||||
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0))
|
|
||||||
|
|
||||||
await owner.send_delta("123", "hel", {"_stream_delta": True, "_stream_id": "s1"})
|
|
||||||
await owner.send_delta("123", "lo", {"_stream_delta": True, "_stream_id": "s1"})
|
|
||||||
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
|
|
||||||
|
|
||||||
assert target.sent_payloads[0] == {"content": "hel"}
|
|
||||||
assert target.sent_messages[0].edits == [{"content": "hello"}, {"content": "hello"}]
|
|
||||||
assert owner._stream_bufs == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None:
|
|
||||||
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
|
||||||
client = _FakeDiscordClient(owner, intents=None)
|
|
||||||
owner._client = client
|
|
||||||
owner._running = True
|
|
||||||
target = _FakeChannel(channel_id=123)
|
|
||||||
client.channels[123] = target
|
|
||||||
|
|
||||||
prefix = "a" * (MAX_MESSAGE_LEN - 100)
|
|
||||||
suffix = "b" * 150
|
|
||||||
full_text = prefix + suffix
|
|
||||||
chunks = DiscordBotClient._build_chunks(full_text, [], False)
|
|
||||||
assert len(chunks) == 2
|
|
||||||
|
|
||||||
times = iter([1.0, 3.0])
|
|
||||||
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0))
|
|
||||||
|
|
||||||
await owner.send_delta("123", prefix, {"_stream_delta": True, "_stream_id": "s1"})
|
|
||||||
await owner.send_delta("123", suffix, {"_stream_delta": True, "_stream_id": "s1"})
|
|
||||||
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
|
|
||||||
|
|
||||||
assert target.sent_payloads == [{"content": prefix}, {"content": chunks[1]}]
|
|
||||||
assert target.sent_messages[0].edits == [{"content": chunks[0]}, {"content": chunks[0]}]
|
|
||||||
assert owner._stream_bufs == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_slash_new_forwards_when_user_is_allowlisted() -> None:
|
async def test_slash_new_forwards_when_user_is_allowlisted() -> None:
|
||||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["123"]), MessageBus())
|
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["123"]), MessageBus())
|
||||||
@@ -524,7 +443,9 @@ async def test_slash_new_forwards_when_user_is_allowlisted() -> None:
|
|||||||
assert new_cmd is not None
|
assert new_cmd is not None
|
||||||
await new_cmd.callback(interaction)
|
await new_cmd.callback(interaction)
|
||||||
|
|
||||||
assert interaction.response.messages == [{"content": "Processing /new...", "ephemeral": True}]
|
assert interaction.response.messages == [
|
||||||
|
{"content": "Processing /new...", "ephemeral": True}
|
||||||
|
]
|
||||||
assert len(handled) == 1
|
assert len(handled) == 1
|
||||||
assert handled[0]["content"] == "/new"
|
assert handled[0]["content"] == "/new"
|
||||||
assert handled[0]["sender_id"] == "123"
|
assert handled[0]["sender_id"] == "123"
|
||||||
@@ -598,7 +519,9 @@ async def test_slash_help_returns_ephemeral_help_text() -> None:
|
|||||||
assert help_cmd is not None
|
assert help_cmd is not None
|
||||||
await help_cmd.callback(interaction)
|
await help_cmd.callback(interaction)
|
||||||
|
|
||||||
assert interaction.response.messages == [{"content": build_help_text(), "ephemeral": True}]
|
assert interaction.response.messages == [
|
||||||
|
{"content": build_help_text(), "ephemeral": True}
|
||||||
|
]
|
||||||
assert handled == []
|
assert handled == []
|
||||||
|
|
||||||
|
|
||||||
@@ -733,13 +656,11 @@ async def test_start_typing_uses_typing_context_when_trigger_typing_missing() ->
|
|||||||
def typing(self):
|
def typing(self):
|
||||||
async def _waiter():
|
async def _waiter():
|
||||||
await release.wait()
|
await release.wait()
|
||||||
|
|
||||||
# Hold the loop so task remains active until explicitly stopped.
|
# Hold the loop so task remains active until explicitly stopped.
|
||||||
class _Ctx(_TypingCtx):
|
class _Ctx(_TypingCtx):
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
await super().__aenter__()
|
await super().__aenter__()
|
||||||
await _waiter()
|
await _waiter()
|
||||||
|
|
||||||
return _Ctx()
|
return _Ctx()
|
||||||
|
|
||||||
typing_channel = _NoTriggerChannel(channel_id=123)
|
typing_channel = _NoTriggerChannel(channel_id=123)
|
||||||
@@ -753,214 +674,3 @@ async def test_start_typing_uses_typing_context_when_trigger_typing_missing() ->
|
|||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
assert channel._typing_tasks == {}
|
assert channel._typing_tasks == {}
|
||||||
|
|
||||||
|
|
||||||
def test_config_accepts_proxy_fields() -> None:
|
|
||||||
config = DiscordConfig(
|
|
||||||
enabled=True,
|
|
||||||
token="token",
|
|
||||||
allow_from=["*"],
|
|
||||||
proxy="http://127.0.0.1:7890",
|
|
||||||
proxy_username="user",
|
|
||||||
proxy_password="pass",
|
|
||||||
)
|
|
||||||
assert config.proxy == "http://127.0.0.1:7890"
|
|
||||||
assert config.proxy_username == "user"
|
|
||||||
assert config.proxy_password == "pass"
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_proxy_defaults_to_none() -> None:
|
|
||||||
config = DiscordConfig(enabled=True, token="token", allow_from=["*"])
|
|
||||||
assert config.proxy is None
|
|
||||||
assert config.proxy_username is None
|
|
||||||
assert config.proxy_password is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_passes_proxy_to_client(monkeypatch) -> None:
|
|
||||||
_FakeDiscordClient.instances.clear()
|
|
||||||
channel = DiscordChannel(
|
|
||||||
DiscordConfig(
|
|
||||||
enabled=True,
|
|
||||||
token="token",
|
|
||||||
allow_from=["*"],
|
|
||||||
proxy="http://127.0.0.1:7890",
|
|
||||||
),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
|
||||||
|
|
||||||
await channel.start()
|
|
||||||
|
|
||||||
assert channel.is_running is False
|
|
||||||
assert len(_FakeDiscordClient.instances) == 1
|
|
||||||
assert _FakeDiscordClient.instances[0].proxy == "http://127.0.0.1:7890"
|
|
||||||
assert _FakeDiscordClient.instances[0].proxy_auth is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_passes_proxy_auth_when_credentials_provided(monkeypatch) -> None:
|
|
||||||
aiohttp = pytest.importorskip("aiohttp")
|
|
||||||
_FakeDiscordClient.instances.clear()
|
|
||||||
channel = DiscordChannel(
|
|
||||||
DiscordConfig(
|
|
||||||
enabled=True,
|
|
||||||
token="token",
|
|
||||||
allow_from=["*"],
|
|
||||||
proxy="http://127.0.0.1:7890",
|
|
||||||
proxy_username="user",
|
|
||||||
proxy_password="pass",
|
|
||||||
),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
|
||||||
|
|
||||||
await channel.start()
|
|
||||||
|
|
||||||
assert channel.is_running is False
|
|
||||||
assert len(_FakeDiscordClient.instances) == 1
|
|
||||||
assert _FakeDiscordClient.instances[0].proxy == "http://127.0.0.1:7890"
|
|
||||||
assert _FakeDiscordClient.instances[0].proxy_auth is not None
|
|
||||||
assert isinstance(_FakeDiscordClient.instances[0].proxy_auth, aiohttp.BasicAuth)
|
|
||||||
assert _FakeDiscordClient.instances[0].proxy_auth.login == "user"
|
|
||||||
assert _FakeDiscordClient.instances[0].proxy_auth.password == "pass"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_no_proxy_auth_when_only_username(monkeypatch) -> None:
|
|
||||||
_FakeDiscordClient.instances.clear()
|
|
||||||
channel = DiscordChannel(
|
|
||||||
DiscordConfig(
|
|
||||||
enabled=True,
|
|
||||||
token="token",
|
|
||||||
allow_from=["*"],
|
|
||||||
proxy="http://127.0.0.1:7890",
|
|
||||||
proxy_username="user",
|
|
||||||
),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
|
||||||
|
|
||||||
await channel.start()
|
|
||||||
|
|
||||||
assert channel.is_running is False
|
|
||||||
assert _FakeDiscordClient.instances[0].proxy_auth is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_no_proxy_auth_when_only_password(monkeypatch) -> None:
|
|
||||||
_FakeDiscordClient.instances.clear()
|
|
||||||
channel = DiscordChannel(
|
|
||||||
DiscordConfig(
|
|
||||||
enabled=True,
|
|
||||||
token="token",
|
|
||||||
allow_from=["*"],
|
|
||||||
proxy="http://127.0.0.1:7890",
|
|
||||||
proxy_password="pass",
|
|
||||||
),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
|
||||||
|
|
||||||
await channel.start()
|
|
||||||
|
|
||||||
assert channel.is_running is False
|
|
||||||
assert _FakeDiscordClient.instances[0].proxy == "http://127.0.0.1:7890"
|
|
||||||
assert _FakeDiscordClient.instances[0].proxy_auth is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tests for the send() exception propagation fix
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_re_raises_network_error() -> None:
|
|
||||||
"""Network errors during send must propagate so ChannelManager can retry."""
|
|
||||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
|
||||||
client = _FakeDiscordClient(channel, intents=None)
|
|
||||||
channel._client = client
|
|
||||||
channel._running = True
|
|
||||||
|
|
||||||
async def _failing_send_outbound(msg: OutboundMessage) -> None:
|
|
||||||
raise ConnectionError("network unreachable")
|
|
||||||
|
|
||||||
client.send_outbound = _failing_send_outbound # type: ignore[method-assign]
|
|
||||||
|
|
||||||
with pytest.raises(ConnectionError, match="network unreachable"):
|
|
||||||
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_re_raises_generic_exception() -> None:
|
|
||||||
"""Any exception from send_outbound must propagate, not be swallowed."""
|
|
||||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
|
||||||
client = _FakeDiscordClient(channel, intents=None)
|
|
||||||
channel._client = client
|
|
||||||
channel._running = True
|
|
||||||
|
|
||||||
async def _failing_send_outbound(msg: OutboundMessage) -> None:
|
|
||||||
raise RuntimeError("discord API failure")
|
|
||||||
|
|
||||||
client.send_outbound = _failing_send_outbound # type: ignore[method-assign]
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="discord API failure"):
|
|
||||||
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_still_stops_typing_on_error() -> None:
|
|
||||||
"""Typing cleanup must still run in the finally block even when send raises."""
|
|
||||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
|
||||||
client = _FakeDiscordClient(channel, intents=None)
|
|
||||||
channel._client = client
|
|
||||||
channel._running = True
|
|
||||||
|
|
||||||
# Start a typing task so we can verify it gets cleaned up
|
|
||||||
start = asyncio.Event()
|
|
||||||
release = asyncio.Event()
|
|
||||||
|
|
||||||
async def slow_typing() -> None:
|
|
||||||
start.set()
|
|
||||||
await release.wait()
|
|
||||||
|
|
||||||
typing_channel = _FakeChannel(channel_id=123)
|
|
||||||
typing_channel.typing_enter_hook = slow_typing
|
|
||||||
await channel._start_typing(typing_channel)
|
|
||||||
await asyncio.wait_for(start.wait(), timeout=1.0)
|
|
||||||
|
|
||||||
async def _failing_send_outbound(msg: OutboundMessage) -> None:
|
|
||||||
raise ConnectionError("timeout")
|
|
||||||
|
|
||||||
client.send_outbound = _failing_send_outbound # type: ignore[method-assign]
|
|
||||||
|
|
||||||
with pytest.raises(ConnectionError, match="timeout"):
|
|
||||||
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
|
|
||||||
|
|
||||||
release.set()
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
# Typing should have been cleaned up by the finally block
|
|
||||||
assert channel._typing_tasks == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_succeeds_normally() -> None:
|
|
||||||
"""Successful sends should work without raising."""
|
|
||||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
|
||||||
client = _FakeDiscordClient(channel, intents=None)
|
|
||||||
channel._client = client
|
|
||||||
channel._running = True
|
|
||||||
|
|
||||||
sent_messages: list[OutboundMessage] = []
|
|
||||||
|
|
||||||
async def _capture_send_outbound(msg: OutboundMessage) -> None:
|
|
||||||
sent_messages.append(msg)
|
|
||||||
|
|
||||||
client.send_outbound = _capture_send_outbound # type: ignore[method-assign]
|
|
||||||
|
|
||||||
msg = OutboundMessage(channel="discord", chat_id="123", content="hello world")
|
|
||||||
await channel.send(msg)
|
|
||||||
|
|
||||||
assert len(sent_messages) == 1
|
|
||||||
assert sent_messages[0].content == "hello world"
|
|
||||||
assert sent_messages[0].chat_id == "123"
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -15,8 +14,6 @@ except ImportError:
|
|||||||
if not QQ_AVAILABLE:
|
if not QQ_AVAILABLE:
|
||||||
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
|
pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True)
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.qq import QQChannel, QQConfig
|
from nanobot.channels.qq import QQChannel, QQConfig
|
||||||
@@ -173,221 +170,3 @@ async def test_read_media_bytes_missing_file() -> None:
|
|||||||
data, filename = await channel._read_media_bytes("/nonexistent/path/image.png")
|
data, filename = await channel._read_media_bytes("/nonexistent/path/image.png")
|
||||||
assert data is None
|
assert data is None
|
||||||
assert filename is None
|
assert filename is None
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------
|
|
||||||
# Tests for _send_media exception handling
|
|
||||||
# -------------------------------------------------------
|
|
||||||
|
|
||||||
def _make_channel_with_local_file(suffix: str = ".png", content: bytes = b"\x89PNG\r\n"):
|
|
||||||
"""Create a QQChannel with a fake client and a temp file for media."""
|
|
||||||
channel = QQChannel(
|
|
||||||
QQConfig(app_id="app", secret="secret", allow_from=["*"]),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._client = _FakeClient()
|
|
||||||
channel._chat_type_cache["user1"] = "c2c"
|
|
||||||
|
|
||||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
|
||||||
tmp.write(content)
|
|
||||||
tmp.close()
|
|
||||||
return channel, tmp.name
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_network_error_propagates() -> None:
|
|
||||||
"""aiohttp.ClientError (network/transport) should re-raise, not return False."""
|
|
||||||
channel, tmp_path = _make_channel_with_local_file()
|
|
||||||
|
|
||||||
# Make the base64 upload raise a network error
|
|
||||||
channel._client.api._http = SimpleNamespace()
|
|
||||||
channel._client.api._http.request = AsyncMock(
|
|
||||||
side_effect=aiohttp.ServerDisconnectedError("connection lost"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(aiohttp.ServerDisconnectedError):
|
|
||||||
await channel._send_media(
|
|
||||||
chat_id="user1",
|
|
||||||
media_ref=tmp_path,
|
|
||||||
msg_id="msg1",
|
|
||||||
is_group=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_client_connector_error_propagates() -> None:
|
|
||||||
"""aiohttp.ClientConnectorError (DNS/connection refused) should re-raise."""
|
|
||||||
channel, tmp_path = _make_channel_with_local_file()
|
|
||||||
|
|
||||||
from aiohttp.client_reqrep import ConnectionKey
|
|
||||||
conn_key = ConnectionKey("api.qq.com", 443, True, None, None, None, None)
|
|
||||||
connector_error = aiohttp.ClientConnectorError(
|
|
||||||
connection_key=conn_key,
|
|
||||||
os_error=OSError("Connection refused"),
|
|
||||||
)
|
|
||||||
|
|
||||||
channel._client.api._http = SimpleNamespace()
|
|
||||||
channel._client.api._http.request = AsyncMock(
|
|
||||||
side_effect=connector_error,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(aiohttp.ClientConnectorError):
|
|
||||||
await channel._send_media(
|
|
||||||
chat_id="user1",
|
|
||||||
media_ref=tmp_path,
|
|
||||||
msg_id="msg1",
|
|
||||||
is_group=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_oserror_propagates() -> None:
|
|
||||||
"""OSError (low-level I/O) should re-raise for retry."""
|
|
||||||
channel, tmp_path = _make_channel_with_local_file()
|
|
||||||
|
|
||||||
channel._client.api._http = SimpleNamespace()
|
|
||||||
channel._client.api._http.request = AsyncMock(
|
|
||||||
side_effect=OSError("Network is unreachable"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(OSError):
|
|
||||||
await channel._send_media(
|
|
||||||
chat_id="user1",
|
|
||||||
media_ref=tmp_path,
|
|
||||||
msg_id="msg1",
|
|
||||||
is_group=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_api_error_returns_false() -> None:
|
|
||||||
"""API-level errors (botpy RuntimeError subclasses) should return False, not raise."""
|
|
||||||
channel, tmp_path = _make_channel_with_local_file()
|
|
||||||
|
|
||||||
# Simulate a botpy API error (e.g. ServerError is a RuntimeError subclass)
|
|
||||||
from botpy.errors import ServerError
|
|
||||||
|
|
||||||
channel._client.api._http = SimpleNamespace()
|
|
||||||
channel._client.api._http.request = AsyncMock(
|
|
||||||
side_effect=ServerError("internal server error"),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await channel._send_media(
|
|
||||||
chat_id="user1",
|
|
||||||
media_ref=tmp_path,
|
|
||||||
msg_id="msg1",
|
|
||||||
is_group=False,
|
|
||||||
)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_generic_runtime_error_returns_false() -> None:
|
|
||||||
"""Generic RuntimeError (not network) should return False."""
|
|
||||||
channel, tmp_path = _make_channel_with_local_file()
|
|
||||||
|
|
||||||
channel._client.api._http = SimpleNamespace()
|
|
||||||
channel._client.api._http.request = AsyncMock(
|
|
||||||
side_effect=RuntimeError("some API error"),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await channel._send_media(
|
|
||||||
chat_id="user1",
|
|
||||||
media_ref=tmp_path,
|
|
||||||
msg_id="msg1",
|
|
||||||
is_group=False,
|
|
||||||
)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_value_error_returns_false() -> None:
|
|
||||||
"""ValueError (bad API response data) should return False."""
|
|
||||||
channel, tmp_path = _make_channel_with_local_file()
|
|
||||||
|
|
||||||
channel._client.api._http = SimpleNamespace()
|
|
||||||
channel._client.api._http.request = AsyncMock(
|
|
||||||
side_effect=ValueError("bad response data"),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await channel._send_media(
|
|
||||||
chat_id="user1",
|
|
||||||
media_ref=tmp_path,
|
|
||||||
msg_id="msg1",
|
|
||||||
is_group=False,
|
|
||||||
)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_timeout_error_propagates() -> None:
|
|
||||||
"""asyncio.TimeoutError inherits from Exception but not ClientError/OSError.
|
|
||||||
However, aiohttp.ServerTimeoutError IS a ClientError subclass, so that propagates.
|
|
||||||
For a plain TimeoutError (which is also OSError in Python 3.11+), it should propagate."""
|
|
||||||
channel, tmp_path = _make_channel_with_local_file()
|
|
||||||
|
|
||||||
channel._client.api._http = SimpleNamespace()
|
|
||||||
channel._client.api._http.request = AsyncMock(
|
|
||||||
side_effect=aiohttp.ServerTimeoutError("request timed out"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(aiohttp.ServerTimeoutError):
|
|
||||||
await channel._send_media(
|
|
||||||
chat_id="user1",
|
|
||||||
media_ref=tmp_path,
|
|
||||||
msg_id="msg1",
|
|
||||||
is_group=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_fallback_text_on_api_error() -> None:
|
|
||||||
"""When _send_media returns False (API error), send() should emit fallback text."""
|
|
||||||
channel, tmp_path = _make_channel_with_local_file()
|
|
||||||
|
|
||||||
from botpy.errors import ServerError
|
|
||||||
|
|
||||||
channel._client.api._http = SimpleNamespace()
|
|
||||||
channel._client.api._http.request = AsyncMock(
|
|
||||||
side_effect=ServerError("internal server error"),
|
|
||||||
)
|
|
||||||
|
|
||||||
await channel.send(
|
|
||||||
OutboundMessage(
|
|
||||||
channel="qq",
|
|
||||||
chat_id="user1",
|
|
||||||
content="",
|
|
||||||
media=[tmp_path],
|
|
||||||
metadata={"message_id": "msg1"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should have sent a fallback text message
|
|
||||||
assert len(channel._client.api.c2c_calls) == 1
|
|
||||||
fallback_content = channel._client.api.c2c_calls[0]["content"]
|
|
||||||
assert "Attachment send failed" in fallback_content
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_propagates_network_error_no_fallback() -> None:
|
|
||||||
"""When _send_media raises a network error, send() should NOT silently fallback."""
|
|
||||||
channel, tmp_path = _make_channel_with_local_file()
|
|
||||||
|
|
||||||
channel._client.api._http = SimpleNamespace()
|
|
||||||
channel._client.api._http.request = AsyncMock(
|
|
||||||
side_effect=aiohttp.ServerDisconnectedError("connection lost"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(aiohttp.ServerDisconnectedError):
|
|
||||||
await channel.send(
|
|
||||||
OutboundMessage(
|
|
||||||
channel="qq",
|
|
||||||
chat_id="user1",
|
|
||||||
content="hello",
|
|
||||||
media=[tmp_path],
|
|
||||||
metadata={"message_id": "msg1"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# No fallback text should have been sent
|
|
||||||
assert len(channel._client.api.c2c_calls) == 0
|
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ def _make_telegram_update(
|
|||||||
entities=None,
|
entities=None,
|
||||||
caption_entities=None,
|
caption_entities=None,
|
||||||
reply_to_message=None,
|
reply_to_message=None,
|
||||||
location=None,
|
|
||||||
):
|
):
|
||||||
user = SimpleNamespace(id=12345, username="alice", first_name="Alice")
|
user = SimpleNamespace(id=12345, username="alice", first_name="Alice")
|
||||||
message = SimpleNamespace(
|
message = SimpleNamespace(
|
||||||
@@ -150,7 +149,6 @@ def _make_telegram_update(
|
|||||||
voice=None,
|
voice=None,
|
||||||
audio=None,
|
audio=None,
|
||||||
document=None,
|
document=None,
|
||||||
location=location,
|
|
||||||
media_group_id=None,
|
media_group_id=None,
|
||||||
message_thread_id=None,
|
message_thread_id=None,
|
||||||
message_id=1,
|
message_id=1,
|
||||||
@@ -387,84 +385,6 @@ async def test_send_delta_stream_end_treats_not_modified_as_success() -> None:
|
|||||||
assert "123" not in channel._stream_bufs
|
assert "123" not in channel._stream_bufs
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> None:
|
|
||||||
"""TimedOut during HTML edit should propagate, never fall back to plain text."""
|
|
||||||
from telegram.error import TimedOut
|
|
||||||
|
|
||||||
channel = TelegramChannel(
|
|
||||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._app = _FakeApp(lambda: None)
|
|
||||||
# _call_with_retry retries TimedOut up to 3 times, so the mock will be called
|
|
||||||
# multiple times – but all calls must be with parse_mode="HTML" (no plain fallback).
|
|
||||||
channel._app.bot.edit_message_text = AsyncMock(side_effect=TimedOut("network timeout"))
|
|
||||||
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
|
|
||||||
|
|
||||||
with pytest.raises(TimedOut, match="network timeout"):
|
|
||||||
await channel.send_delta("123", "", {"_stream_end": True})
|
|
||||||
|
|
||||||
# Every call to edit_message_text must have used parse_mode="HTML" —
|
|
||||||
# no plain-text fallback call should have been made.
|
|
||||||
for call in channel._app.bot.edit_message_text.call_args_list:
|
|
||||||
assert call.kwargs.get("parse_mode") == "HTML"
|
|
||||||
# Buffer should still be present (not cleaned up on error)
|
|
||||||
assert "123" in channel._stream_bufs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_delta_stream_end_does_not_fallback_on_network_error() -> None:
|
|
||||||
"""NetworkError during HTML edit should propagate, never fall back to plain text."""
|
|
||||||
from telegram.error import NetworkError
|
|
||||||
|
|
||||||
channel = TelegramChannel(
|
|
||||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._app = _FakeApp(lambda: None)
|
|
||||||
channel._app.bot.edit_message_text = AsyncMock(side_effect=NetworkError("connection reset"))
|
|
||||||
channel._stream_bufs["123"] = _StreamBuf(text="hello", message_id=7, last_edit=0.0)
|
|
||||||
|
|
||||||
with pytest.raises(NetworkError, match="connection reset"):
|
|
||||||
await channel.send_delta("123", "", {"_stream_end": True})
|
|
||||||
|
|
||||||
# Every call to edit_message_text must have used parse_mode="HTML" —
|
|
||||||
# no plain-text fallback call should have been made.
|
|
||||||
for call in channel._app.bot.edit_message_text.call_args_list:
|
|
||||||
assert call.kwargs.get("parse_mode") == "HTML"
|
|
||||||
# Buffer should still be present (not cleaned up on error)
|
|
||||||
assert "123" in channel._stream_bufs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_delta_stream_end_falls_back_on_bad_request() -> None:
|
|
||||||
"""BadRequest (HTML parse error) should still trigger plain-text fallback."""
|
|
||||||
from telegram.error import BadRequest
|
|
||||||
|
|
||||||
channel = TelegramChannel(
|
|
||||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._app = _FakeApp(lambda: None)
|
|
||||||
|
|
||||||
# First call (HTML) raises BadRequest, second call (plain) succeeds
|
|
||||||
channel._app.bot.edit_message_text = AsyncMock(
|
|
||||||
side_effect=[BadRequest("Can't parse entities"), None]
|
|
||||||
)
|
|
||||||
channel._stream_bufs["123"] = _StreamBuf(text="hello <bad>", message_id=7, last_edit=0.0)
|
|
||||||
|
|
||||||
await channel.send_delta("123", "", {"_stream_end": True})
|
|
||||||
|
|
||||||
# edit_message_text should have been called twice: once for HTML, once for plain fallback
|
|
||||||
assert channel._app.bot.edit_message_text.call_count == 2
|
|
||||||
# Second call should not use parse_mode="HTML"
|
|
||||||
second_call_kwargs = channel._app.bot.edit_message_text.call_args_list[1].kwargs
|
|
||||||
assert "parse_mode" not in second_call_kwargs or second_call_kwargs.get("parse_mode") is None
|
|
||||||
# Buffer should be cleaned up on success
|
|
||||||
assert "123" not in channel._stream_bufs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_delta_stream_end_splits_oversized_reply() -> None:
|
async def test_send_delta_stream_end_splits_oversized_reply() -> None:
|
||||||
"""Final streamed reply exceeding Telegram limit is split into chunks."""
|
"""Final streamed reply exceeding Telegram limit is split into chunks."""
|
||||||
@@ -1192,204 +1112,3 @@ async def test_on_help_includes_restart_command() -> None:
|
|||||||
assert "/dream" in help_text
|
assert "/dream" in help_text
|
||||||
assert "/dream-log" in help_text
|
assert "/dream-log" in help_text
|
||||||
assert "/dream-restore" in help_text
|
assert "/dream-restore" in help_text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_message_location_content() -> None:
|
|
||||||
"""Location messages are forwarded as [location: lat, lon] content."""
|
|
||||||
channel = TelegramChannel(
|
|
||||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._app = _FakeApp(lambda: None)
|
|
||||||
handled = []
|
|
||||||
async def capture_handle(**kwargs) -> None:
|
|
||||||
handled.append(kwargs)
|
|
||||||
channel._handle_message = capture_handle
|
|
||||||
channel._start_typing = lambda _chat_id: None
|
|
||||||
|
|
||||||
location = SimpleNamespace(latitude=48.8566, longitude=2.3522)
|
|
||||||
update = _make_telegram_update(location=location)
|
|
||||||
await channel._on_message(update, None)
|
|
||||||
|
|
||||||
assert len(handled) == 1
|
|
||||||
assert handled[0]["content"] == "[location: 48.8566, 2.3522]"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_message_location_with_text() -> None:
|
|
||||||
"""Location messages with accompanying text include both in content."""
|
|
||||||
channel = TelegramChannel(
|
|
||||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._app = _FakeApp(lambda: None)
|
|
||||||
handled = []
|
|
||||||
async def capture_handle(**kwargs) -> None:
|
|
||||||
handled.append(kwargs)
|
|
||||||
channel._handle_message = capture_handle
|
|
||||||
channel._start_typing = lambda _chat_id: None
|
|
||||||
|
|
||||||
location = SimpleNamespace(latitude=51.5074, longitude=-0.1278)
|
|
||||||
update = _make_telegram_update(text="meet me here", location=location)
|
|
||||||
await channel._on_message(update, None)
|
|
||||||
|
|
||||||
assert len(handled) == 1
|
|
||||||
assert "meet me here" in handled[0]["content"]
|
|
||||||
assert "[location: 51.5074, -0.1278]" in handled[0]["content"]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tests for retry amplification fix (issue #3050)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_text_does_not_fallback_on_network_timeout() -> None:
|
|
||||||
"""TimedOut should propagate immediately, NOT trigger plain-text fallback.
|
|
||||||
|
|
||||||
Before the fix, _send_text caught ALL exceptions (including TimedOut)
|
|
||||||
and retried as plain text, doubling connection demand during pool
|
|
||||||
exhaustion — see issue #3050.
|
|
||||||
"""
|
|
||||||
from telegram.error import TimedOut
|
|
||||||
|
|
||||||
channel = TelegramChannel(
|
|
||||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._app = _FakeApp(lambda: None)
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def always_timeout(**kwargs):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
raise TimedOut()
|
|
||||||
|
|
||||||
channel._app.bot.send_message = always_timeout
|
|
||||||
|
|
||||||
import nanobot.channels.telegram as tg_mod
|
|
||||||
orig_delay = tg_mod._SEND_RETRY_BASE_DELAY
|
|
||||||
tg_mod._SEND_RETRY_BASE_DELAY = 0.01
|
|
||||||
try:
|
|
||||||
with pytest.raises(TimedOut):
|
|
||||||
await channel._send_text(123, "hello", None, {})
|
|
||||||
finally:
|
|
||||||
tg_mod._SEND_RETRY_BASE_DELAY = orig_delay
|
|
||||||
|
|
||||||
# With the fix: only _call_with_retry's 3 HTML attempts (no plain fallback).
|
|
||||||
# Before the fix: 3 HTML + 3 plain = 6 attempts.
|
|
||||||
assert call_count == 3, (
|
|
||||||
f"Expected 3 calls (HTML retries only), got {call_count} "
|
|
||||||
"(plain-text fallback should not trigger on TimedOut)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_text_does_not_fallback_on_network_error() -> None:
|
|
||||||
"""NetworkError should propagate immediately, NOT trigger plain-text fallback."""
|
|
||||||
from telegram.error import NetworkError
|
|
||||||
|
|
||||||
channel = TelegramChannel(
|
|
||||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._app = _FakeApp(lambda: None)
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def always_network_error(**kwargs):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
raise NetworkError("Connection reset")
|
|
||||||
|
|
||||||
channel._app.bot.send_message = always_network_error
|
|
||||||
|
|
||||||
import nanobot.channels.telegram as tg_mod
|
|
||||||
orig_delay = tg_mod._SEND_RETRY_BASE_DELAY
|
|
||||||
tg_mod._SEND_RETRY_BASE_DELAY = 0.01
|
|
||||||
try:
|
|
||||||
with pytest.raises(NetworkError):
|
|
||||||
await channel._send_text(123, "hello", None, {})
|
|
||||||
finally:
|
|
||||||
tg_mod._SEND_RETRY_BASE_DELAY = orig_delay
|
|
||||||
|
|
||||||
# _call_with_retry does NOT retry NetworkError (only TimedOut/RetryAfter),
|
|
||||||
# so it raises after 1 attempt. The fix prevents plain-text fallback.
|
|
||||||
# Before the fix: 1 HTML + 1 plain = 2. After the fix: 1 HTML only.
|
|
||||||
assert call_count == 1, (
|
|
||||||
f"Expected 1 call (HTML only, no plain fallback), got {call_count}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_text_falls_back_on_bad_request() -> None:
|
|
||||||
"""BadRequest (HTML parse error) should still trigger plain-text fallback."""
|
|
||||||
from telegram.error import BadRequest
|
|
||||||
|
|
||||||
channel = TelegramChannel(
|
|
||||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._app = _FakeApp(lambda: None)
|
|
||||||
|
|
||||||
original_send = channel._app.bot.send_message
|
|
||||||
html_call_count = 0
|
|
||||||
|
|
||||||
async def html_fails(**kwargs):
|
|
||||||
nonlocal html_call_count
|
|
||||||
if kwargs.get("parse_mode") == "HTML":
|
|
||||||
html_call_count += 1
|
|
||||||
raise BadRequest("Can't parse entities")
|
|
||||||
return await original_send(**kwargs)
|
|
||||||
|
|
||||||
channel._app.bot.send_message = html_fails
|
|
||||||
|
|
||||||
import nanobot.channels.telegram as tg_mod
|
|
||||||
orig_delay = tg_mod._SEND_RETRY_BASE_DELAY
|
|
||||||
tg_mod._SEND_RETRY_BASE_DELAY = 0.01
|
|
||||||
try:
|
|
||||||
await channel._send_text(123, "hello **world**", None, {})
|
|
||||||
finally:
|
|
||||||
tg_mod._SEND_RETRY_BASE_DELAY = orig_delay
|
|
||||||
|
|
||||||
# HTML attempt failed with BadRequest → fallback to plain text succeeds.
|
|
||||||
assert html_call_count == 1, f"Expected 1 HTML attempt, got {html_call_count}"
|
|
||||||
assert len(channel._app.bot.sent_messages) == 1
|
|
||||||
# Plain text send should NOT have parse_mode
|
|
||||||
assert channel._app.bot.sent_messages[0].get("parse_mode") is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_text_bad_request_plain_fallback_exhausted() -> None:
|
|
||||||
"""When both HTML and plain-text fallback fail with BadRequest, the error propagates."""
|
|
||||||
from telegram.error import BadRequest
|
|
||||||
|
|
||||||
channel = TelegramChannel(
|
|
||||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._app = _FakeApp(lambda: None)
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def always_bad_request(**kwargs):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
raise BadRequest("Bad request")
|
|
||||||
|
|
||||||
channel._app.bot.send_message = always_bad_request
|
|
||||||
|
|
||||||
import nanobot.channels.telegram as tg_mod
|
|
||||||
orig_delay = tg_mod._SEND_RETRY_BASE_DELAY
|
|
||||||
tg_mod._SEND_RETRY_BASE_DELAY = 0.01
|
|
||||||
try:
|
|
||||||
with pytest.raises(BadRequest):
|
|
||||||
await channel._send_text(123, "hello", None, {})
|
|
||||||
finally:
|
|
||||||
tg_mod._SEND_RETRY_BASE_DELAY = orig_delay
|
|
||||||
|
|
||||||
# _call_with_retry does NOT retry BadRequest (only TimedOut/RetryAfter),
|
|
||||||
# so HTML fails after 1 attempt → fallback to plain also fails after 1 attempt.
|
|
||||||
# Before the fix: 2 total. After the fix: still 2 (BadRequest SHOULD fallback).
|
|
||||||
assert call_count == 2, f"Expected 2 calls (1 HTML + 1 plain), got {call_count}"
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
@@ -1003,185 +1003,3 @@ async def test_download_media_item_non_image_requires_aes_key_even_with_full_url
|
|||||||
|
|
||||||
assert saved_path is None
|
assert saved_path is None
|
||||||
channel._client.get.assert_not_awaited()
|
channel._client.get.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tests for media-send error classification (network vs non-network errors)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _make_outbound_msg(chat_id: str = "wx-user", content: str = "", media: list | None = None):
|
|
||||||
"""Build a minimal OutboundMessage-like object for send() tests."""
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
|
||||||
|
|
||||||
return OutboundMessage(
|
|
||||||
channel="weixin",
|
|
||||||
chat_id=chat_id,
|
|
||||||
content=content,
|
|
||||||
media=media or [],
|
|
||||||
metadata={},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_timeout_error_propagates_without_text_fallback() -> None:
|
|
||||||
"""httpx.TimeoutException during media send must re-raise immediately,
|
|
||||||
NOT fall back to _send_text (which would also fail during network issues)."""
|
|
||||||
channel, _bus = _make_channel()
|
|
||||||
channel._client = object()
|
|
||||||
channel._token = "token"
|
|
||||||
channel._context_tokens["wx-user"] = "ctx-1"
|
|
||||||
channel._send_media_file = AsyncMock(side_effect=httpx.TimeoutException("timed out"))
|
|
||||||
channel._send_text = AsyncMock()
|
|
||||||
|
|
||||||
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])
|
|
||||||
|
|
||||||
with pytest.raises(httpx.TimeoutException, match="timed out"):
|
|
||||||
await channel.send(msg)
|
|
||||||
|
|
||||||
# _send_text must NOT have been called as a fallback
|
|
||||||
channel._send_text.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_transport_error_propagates_without_text_fallback() -> None:
|
|
||||||
"""httpx.TransportError during media send must re-raise immediately."""
|
|
||||||
channel, _bus = _make_channel()
|
|
||||||
channel._client = object()
|
|
||||||
channel._token = "token"
|
|
||||||
channel._context_tokens["wx-user"] = "ctx-1"
|
|
||||||
channel._send_media_file = AsyncMock(
|
|
||||||
side_effect=httpx.TransportError("connection reset")
|
|
||||||
)
|
|
||||||
channel._send_text = AsyncMock()
|
|
||||||
|
|
||||||
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])
|
|
||||||
|
|
||||||
with pytest.raises(httpx.TransportError, match="connection reset"):
|
|
||||||
await channel.send(msg)
|
|
||||||
|
|
||||||
channel._send_text.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_5xx_http_status_error_propagates_without_text_fallback() -> None:
|
|
||||||
"""httpx.HTTPStatusError with a 5xx status must re-raise immediately."""
|
|
||||||
channel, _bus = _make_channel()
|
|
||||||
channel._client = object()
|
|
||||||
channel._token = "token"
|
|
||||||
channel._context_tokens["wx-user"] = "ctx-1"
|
|
||||||
|
|
||||||
fake_response = httpx.Response(
|
|
||||||
status_code=503,
|
|
||||||
request=httpx.Request("POST", "https://example.test/upload"),
|
|
||||||
)
|
|
||||||
channel._send_media_file = AsyncMock(
|
|
||||||
side_effect=httpx.HTTPStatusError(
|
|
||||||
"Service Unavailable", request=fake_response.request, response=fake_response
|
|
||||||
)
|
|
||||||
)
|
|
||||||
channel._send_text = AsyncMock()
|
|
||||||
|
|
||||||
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])
|
|
||||||
|
|
||||||
with pytest.raises(httpx.HTTPStatusError, match="Service Unavailable"):
|
|
||||||
await channel.send(msg)
|
|
||||||
|
|
||||||
channel._send_text.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_4xx_http_status_error_falls_back_to_text() -> None:
|
|
||||||
"""httpx.HTTPStatusError with a 4xx status should fall back to text, not re-raise."""
|
|
||||||
channel, _bus = _make_channel()
|
|
||||||
channel._client = object()
|
|
||||||
channel._token = "token"
|
|
||||||
channel._context_tokens["wx-user"] = "ctx-1"
|
|
||||||
|
|
||||||
fake_response = httpx.Response(
|
|
||||||
status_code=400,
|
|
||||||
request=httpx.Request("POST", "https://example.test/upload"),
|
|
||||||
)
|
|
||||||
channel._send_media_file = AsyncMock(
|
|
||||||
side_effect=httpx.HTTPStatusError(
|
|
||||||
"Bad Request", request=fake_response.request, response=fake_response
|
|
||||||
)
|
|
||||||
)
|
|
||||||
channel._send_text = AsyncMock()
|
|
||||||
|
|
||||||
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/photo.jpg"])
|
|
||||||
|
|
||||||
# Should NOT raise — 4xx is a client error, non-retryable
|
|
||||||
await channel.send(msg)
|
|
||||||
|
|
||||||
# _send_text should have been called with the fallback message
|
|
||||||
channel._send_text.assert_awaited_once_with(
|
|
||||||
"wx-user", "[Failed to send: photo.jpg]", "ctx-1"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_file_not_found_falls_back_to_text() -> None:
|
|
||||||
"""FileNotFoundError (a non-network error) should fall back to text."""
|
|
||||||
channel, _bus = _make_channel()
|
|
||||||
channel._client = object()
|
|
||||||
channel._token = "token"
|
|
||||||
channel._context_tokens["wx-user"] = "ctx-1"
|
|
||||||
channel._send_media_file = AsyncMock(
|
|
||||||
side_effect=FileNotFoundError("Media file not found: /tmp/missing.jpg")
|
|
||||||
)
|
|
||||||
channel._send_text = AsyncMock()
|
|
||||||
|
|
||||||
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/missing.jpg"])
|
|
||||||
|
|
||||||
# Should NOT raise
|
|
||||||
await channel.send(msg)
|
|
||||||
|
|
||||||
channel._send_text.assert_awaited_once_with(
|
|
||||||
"wx-user", "[Failed to send: missing.jpg]", "ctx-1"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_value_error_falls_back_to_text() -> None:
|
|
||||||
"""ValueError (e.g. unsupported format) should fall back to text."""
|
|
||||||
channel, _bus = _make_channel()
|
|
||||||
channel._client = object()
|
|
||||||
channel._token = "token"
|
|
||||||
channel._context_tokens["wx-user"] = "ctx-1"
|
|
||||||
channel._send_media_file = AsyncMock(
|
|
||||||
side_effect=ValueError("Unsupported media format")
|
|
||||||
)
|
|
||||||
channel._send_text = AsyncMock()
|
|
||||||
|
|
||||||
msg = _make_outbound_msg(chat_id="wx-user", media=["/tmp/file.xyz"])
|
|
||||||
|
|
||||||
# Should NOT raise
|
|
||||||
await channel.send(msg)
|
|
||||||
|
|
||||||
channel._send_text.assert_awaited_once_with(
|
|
||||||
"wx-user", "[Failed to send: file.xyz]", "ctx-1"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_media_network_error_does_not_double_api_calls() -> None:
|
|
||||||
"""During network issues, media send should make exactly 1 API call attempt,
|
|
||||||
not 2 (media + text fallback). Verify total call count."""
|
|
||||||
channel, _bus = _make_channel()
|
|
||||||
channel._client = object()
|
|
||||||
channel._token = "token"
|
|
||||||
channel._context_tokens["wx-user"] = "ctx-1"
|
|
||||||
channel._send_media_file = AsyncMock(
|
|
||||||
side_effect=httpx.ConnectError("connection refused")
|
|
||||||
)
|
|
||||||
channel._send_text = AsyncMock()
|
|
||||||
|
|
||||||
msg = _make_outbound_msg(chat_id="wx-user", content="hello", media=["/tmp/img.png"])
|
|
||||||
|
|
||||||
with pytest.raises(httpx.ConnectError):
|
|
||||||
await channel.send(msg)
|
|
||||||
|
|
||||||
# _send_media_file called once, _send_text never called
|
|
||||||
channel._send_media_file.assert_awaited_once()
|
|
||||||
channel._send_text.assert_not_awaited()
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class TestRestartCommand:
|
|||||||
assert response is not None
|
assert response is not None
|
||||||
assert "Model: test-model" in response.content
|
assert "Model: test-model" in response.content
|
||||||
assert "Tokens: 0 in / 0 out" in response.content
|
assert "Tokens: 0 in / 0 out" in response.content
|
||||||
assert "Context: 20k/65k (31%)" in response.content
|
assert "Context: 20k/64k (31%)" in response.content
|
||||||
assert "Session: 3 messages" in response.content
|
assert "Session: 3 messages" in response.content
|
||||||
assert "Uptime: 2m 5s" in response.content
|
assert "Uptime: 2m 5s" in response.content
|
||||||
assert response.metadata == {"render_as": "text"}
|
assert response.metadata == {"render_as": "text"}
|
||||||
@@ -186,7 +186,7 @@ class TestRestartCommand:
|
|||||||
|
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert "Tokens: 1200 in / 34 out" in response.content
|
assert "Tokens: 1200 in / 34 out" in response.content
|
||||||
assert "Context: 1k/65k (1%)" in response.content
|
assert "Context: 1k/64k (1%)" in response.content
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_direct_preserves_render_metadata(self):
|
async def test_process_direct_preserves_render_metadata(self):
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -115,41 +114,6 @@ async def test_run_history_persisted_to_disk(tmp_path) -> None:
|
|||||||
assert loaded.state.run_history[0].status == "ok"
|
assert loaded.state.run_history[0].status == "ok"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_job_disabled_does_not_flip_running_state(tmp_path) -> None:
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
|
|
||||||
job = service.add_job(
|
|
||||||
name="disabled",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
service.enable_job(job.id, enabled=False)
|
|
||||||
|
|
||||||
result = await service.run_job(job.id)
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
assert service._running is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_job_preserves_running_service_state(tmp_path) -> None:
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
|
|
||||||
service._running = True
|
|
||||||
job = service.add_job(
|
|
||||||
name="manual",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await service.run_job(job.id, force=True)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
assert service._running is True
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_running_service_honors_external_disable(tmp_path) -> None:
|
async def test_running_service_honors_external_disable(tmp_path) -> None:
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
store_path = tmp_path / "cron" / "jobs.json"
|
||||||
@@ -192,375 +156,3 @@ def test_remove_job_refuses_system_jobs(tmp_path) -> None:
|
|||||||
|
|
||||||
assert result == "protected"
|
assert result == "protected"
|
||||||
assert service.get_job("dream") is not None
|
assert service.get_job("dream") is not None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_server_not_jobs(tmp_path):
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
called = []
|
|
||||||
async def on_job(job):
|
|
||||||
called.append(job.name)
|
|
||||||
|
|
||||||
service = CronService(store_path, on_job=on_job, max_sleep_ms=1000)
|
|
||||||
await service.start()
|
|
||||||
assert len(service.list_jobs()) == 0
|
|
||||||
|
|
||||||
service2 = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
service2.add_job(
|
|
||||||
name="hist",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=500),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
assert len(service.list_jobs()) == 1
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
assert len(called) != 0
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_subsecond_job_not_delayed_to_one_second(tmp_path):
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
called = []
|
|
||||||
|
|
||||||
async def on_job(job):
|
|
||||||
called.append(job.name)
|
|
||||||
|
|
||||||
service = CronService(store_path, on_job=on_job, max_sleep_ms=5000)
|
|
||||||
service.add_job(
|
|
||||||
name="fast",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=100),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
await service.start()
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(0.35)
|
|
||||||
assert called
|
|
||||||
finally:
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_running_service_picks_up_external_add(tmp_path):
|
|
||||||
"""A running service should detect and execute a job added by another instance."""
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
called: list[str] = []
|
|
||||||
|
|
||||||
async def on_job(job):
|
|
||||||
called.append(job.name)
|
|
||||||
|
|
||||||
service = CronService(store_path, on_job=on_job)
|
|
||||||
service.add_job(
|
|
||||||
name="heartbeat",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=150),
|
|
||||||
message="tick",
|
|
||||||
)
|
|
||||||
await service.start()
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
|
|
||||||
external = CronService(store_path)
|
|
||||||
external.add_job(
|
|
||||||
name="external",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=150),
|
|
||||||
message="ping",
|
|
||||||
)
|
|
||||||
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
assert "external" in called
|
|
||||||
finally:
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_add_job_during_jobs_exec(tmp_path):
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
run_once = True
|
|
||||||
|
|
||||||
async def on_job(job):
|
|
||||||
nonlocal run_once
|
|
||||||
if run_once:
|
|
||||||
service2 = CronService(store_path, on_job=lambda x: asyncio.sleep(0))
|
|
||||||
service2.add_job(
|
|
||||||
name="test",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=150),
|
|
||||||
message="tick",
|
|
||||||
)
|
|
||||||
run_once = False
|
|
||||||
|
|
||||||
service = CronService(store_path, on_job=on_job)
|
|
||||||
service.add_job(
|
|
||||||
name="heartbeat",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=150),
|
|
||||||
message="tick",
|
|
||||||
)
|
|
||||||
assert len(service.list_jobs()) == 1
|
|
||||||
await service.start()
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
jobs = service.list_jobs()
|
|
||||||
assert len(jobs) == 2
|
|
||||||
assert "test" in [j.name for j in jobs]
|
|
||||||
finally:
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_external_update_preserves_run_history_records(tmp_path):
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
|
|
||||||
job = service.add_job(
|
|
||||||
name="history",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
await service.run_job(job.id, force=True)
|
|
||||||
|
|
||||||
external = CronService(store_path)
|
|
||||||
updated = external.enable_job(job.id, enabled=False)
|
|
||||||
assert updated is not None
|
|
||||||
|
|
||||||
fresh = CronService(store_path)
|
|
||||||
loaded = fresh.get_job(job.id)
|
|
||||||
assert loaded is not None
|
|
||||||
assert loaded.state.run_history
|
|
||||||
assert loaded.state.run_history[0].status == "ok"
|
|
||||||
|
|
||||||
fresh._running = True
|
|
||||||
fresh._save_store()
|
|
||||||
|
|
||||||
|
|
||||||
# ── timer race regression tests ──
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_timer_execution_is_not_rolled_back_by_list_jobs_reload(tmp_path):
|
|
||||||
"""list_jobs() during _on_timer should not replace the active store and re-run the same due job."""
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
calls: list[str] = []
|
|
||||||
|
|
||||||
async def on_job(job):
|
|
||||||
calls.append(job.id)
|
|
||||||
# Simulate frontend polling list_jobs while the timer callback is mid-execution.
|
|
||||||
service.list_jobs(include_disabled=True)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
service = CronService(store_path, on_job=on_job)
|
|
||||||
service._running = True
|
|
||||||
service._load_store()
|
|
||||||
service._arm_timer = lambda: None
|
|
||||||
|
|
||||||
job = service.add_job(
|
|
||||||
name="race",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
job.state.next_run_at_ms = max(1, int(time.time() * 1000) - 1_000)
|
|
||||||
service._save_store()
|
|
||||||
|
|
||||||
await service._on_timer()
|
|
||||||
await service._on_timer()
|
|
||||||
|
|
||||||
assert calls == [job.id]
|
|
||||||
loaded = service.get_job(job.id)
|
|
||||||
assert loaded is not None
|
|
||||||
assert loaded.state.last_run_at_ms is not None
|
|
||||||
assert loaded.state.next_run_at_ms is not None
|
|
||||||
assert loaded.state.next_run_at_ms > loaded.state.last_run_at_ms
|
|
||||||
|
|
||||||
|
|
||||||
# ── update_job tests ──
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_job_changes_name(tmp_path) -> None:
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
job = service.add_job(
|
|
||||||
name="old name",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
result = service.update_job(job.id, name="new name")
|
|
||||||
assert isinstance(result, CronJob)
|
|
||||||
assert result.name == "new name"
|
|
||||||
assert result.payload.message == "hello"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_job_changes_schedule(tmp_path) -> None:
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
job = service.add_job(
|
|
||||||
name="sched",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
old_next = job.state.next_run_at_ms
|
|
||||||
|
|
||||||
new_sched = CronSchedule(kind="every", every_ms=120_000)
|
|
||||||
result = service.update_job(job.id, schedule=new_sched)
|
|
||||||
assert isinstance(result, CronJob)
|
|
||||||
assert result.schedule.every_ms == 120_000
|
|
||||||
assert result.state.next_run_at_ms != old_next
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_job_changes_message(tmp_path) -> None:
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
job = service.add_job(
|
|
||||||
name="msg",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="old message",
|
|
||||||
)
|
|
||||||
result = service.update_job(job.id, message="new message")
|
|
||||||
assert isinstance(result, CronJob)
|
|
||||||
assert result.payload.message == "new message"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_job_changes_cron_expression(tmp_path) -> None:
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
job = service.add_job(
|
|
||||||
name="cron-job",
|
|
||||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
result = service.update_job(
|
|
||||||
job.id,
|
|
||||||
schedule=CronSchedule(kind="cron", expr="0 18 * * *", tz="UTC"),
|
|
||||||
)
|
|
||||||
assert isinstance(result, CronJob)
|
|
||||||
assert result.schedule.expr == "0 18 * * *"
|
|
||||||
assert result.state.next_run_at_ms is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_job_not_found(tmp_path) -> None:
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
result = service.update_job("nonexistent", name="x")
|
|
||||||
assert result == "not_found"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_job_rejects_system_job(tmp_path) -> None:
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
service.register_system_job(CronJob(
|
|
||||||
id="dream",
|
|
||||||
name="dream",
|
|
||||||
schedule=CronSchedule(kind="cron", expr="0 */2 * * *", tz="UTC"),
|
|
||||||
payload=CronPayload(kind="system_event"),
|
|
||||||
))
|
|
||||||
result = service.update_job("dream", name="hacked")
|
|
||||||
assert result == "protected"
|
|
||||||
assert service.get_job("dream").name == "dream"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_job_validates_schedule(tmp_path) -> None:
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
job = service.add_job(
|
|
||||||
name="validate",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="unknown timezone"):
|
|
||||||
service.update_job(
|
|
||||||
job.id,
|
|
||||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="Bad/Zone"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_job_preserves_run_history(tmp_path) -> None:
|
|
||||||
import asyncio
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
service = CronService(store_path, on_job=lambda _: asyncio.sleep(0))
|
|
||||||
job = service.add_job(
|
|
||||||
name="hist",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
await service.run_job(job.id)
|
|
||||||
|
|
||||||
result = service.update_job(job.id, name="renamed")
|
|
||||||
assert isinstance(result, CronJob)
|
|
||||||
assert len(result.state.run_history) == 1
|
|
||||||
assert result.state.run_history[0].status == "ok"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_job_offline_writes_action(tmp_path) -> None:
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
job = service.add_job(
|
|
||||||
name="offline",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
)
|
|
||||||
service.update_job(job.id, name="updated-offline")
|
|
||||||
|
|
||||||
action_path = tmp_path / "cron" / "action.jsonl"
|
|
||||||
assert action_path.exists()
|
|
||||||
lines = [l for l in action_path.read_text().strip().split("\n") if l]
|
|
||||||
last = json.loads(lines[-1])
|
|
||||||
assert last["action"] == "update"
|
|
||||||
assert last["params"]["name"] == "updated-offline"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_job_sentinel_channel_and_to(tmp_path) -> None:
|
|
||||||
"""Passing None clears channel/to; omitting leaves them unchanged."""
|
|
||||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
|
||||||
job = service.add_job(
|
|
||||||
name="sentinel",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
channel="telegram",
|
|
||||||
to="user123",
|
|
||||||
)
|
|
||||||
assert job.payload.channel == "telegram"
|
|
||||||
assert job.payload.to == "user123"
|
|
||||||
|
|
||||||
result = service.update_job(job.id, name="renamed")
|
|
||||||
assert isinstance(result, CronJob)
|
|
||||||
assert result.payload.channel == "telegram"
|
|
||||||
assert result.payload.to == "user123"
|
|
||||||
|
|
||||||
result = service.update_job(job.id, channel=None, to=None)
|
|
||||||
assert isinstance(result, CronJob)
|
|
||||||
assert result.payload.channel is None
|
|
||||||
assert result.payload.to is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_jobs_during_on_job_does_not_cause_stale_reload(tmp_path) -> None:
|
|
||||||
"""Regression: if the bot calls list_jobs (which reloads from disk) during
|
|
||||||
on_job execution, the in-memory next_run_at_ms update must not be lost.
|
|
||||||
Previously this caused an infinite re-trigger loop."""
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
execution_count = 0
|
|
||||||
|
|
||||||
async def on_job_that_lists(job):
|
|
||||||
nonlocal execution_count
|
|
||||||
execution_count += 1
|
|
||||||
# Simulate the bot calling cron(action=list) mid-execution
|
|
||||||
service.list_jobs()
|
|
||||||
|
|
||||||
service = CronService(store_path, on_job=on_job_that_lists, max_sleep_ms=100)
|
|
||||||
await service.start()
|
|
||||||
|
|
||||||
# Add two jobs scheduled in the past so they're immediately due
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
for name in ("job-a", "job-b"):
|
|
||||||
service.add_job(
|
|
||||||
name=name,
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=3_600_000),
|
|
||||||
message="test",
|
|
||||||
)
|
|
||||||
# Force next_run to the past so _on_timer picks them up
|
|
||||||
for job in service._store.jobs:
|
|
||||||
job.state.next_run_at_ms = now_ms - 1000
|
|
||||||
service._save_store()
|
|
||||||
service._arm_timer()
|
|
||||||
|
|
||||||
# Let the timer fire once
|
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
# Each job should have run exactly once, not looped
|
|
||||||
assert execution_count == 2
|
|
||||||
|
|
||||||
# Verify next_run_at_ms was persisted correctly (in the future)
|
|
||||||
raw = json.loads(store_path.read_text())
|
|
||||||
for j in raw["jobs"]:
|
|
||||||
next_run = j["state"]["nextRunAtMs"]
|
|
||||||
assert next_run is not None
|
|
||||||
assert next_run > now_ms, f"Job '{j['name']}' next_run should be in the future"
|
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.tools.cron import CronTool
|
from nanobot.agent.tools.cron import CronTool
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule
|
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule
|
||||||
from tests.test_openai_api import pytest_plugins
|
|
||||||
|
|
||||||
|
|
||||||
def _make_tool(tmp_path) -> CronTool:
|
def _make_tool(tmp_path) -> CronTool:
|
||||||
@@ -218,10 +215,8 @@ def test_list_at_job_shows_iso_timestamp(tmp_path) -> None:
|
|||||||
assert "Asia/Shanghai" in result
|
assert "Asia/Shanghai" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def test_list_shows_last_run_state(tmp_path) -> None:
|
||||||
async def test_list_shows_last_run_state(tmp_path) -> None:
|
|
||||||
tool = _make_tool(tmp_path)
|
tool = _make_tool(tmp_path)
|
||||||
tool._cron._running = True
|
|
||||||
job = tool._cron.add_job(
|
job = tool._cron.add_job(
|
||||||
name="Stateful job",
|
name="Stateful job",
|
||||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
||||||
@@ -237,10 +232,9 @@ async def test_list_shows_last_run_state(tmp_path) -> None:
|
|||||||
assert "ok" in result
|
assert "ok" in result
|
||||||
assert "(UTC)" in result
|
assert "(UTC)" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_shows_error_message(tmp_path) -> None:
|
def test_list_shows_error_message(tmp_path) -> None:
|
||||||
tool = _make_tool(tmp_path)
|
tool = _make_tool(tmp_path)
|
||||||
tool._cron._running = True
|
|
||||||
job = tool._cron.add_job(
|
job = tool._cron.add_job(
|
||||||
name="Failed job",
|
name="Failed job",
|
||||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="UTC"),
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Tests for Anthropic provider thinking / reasoning_effort modes."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(model: str = "claude-sonnet-4-6") -> AnthropicProvider:
|
|
||||||
with patch("anthropic.AsyncAnthropic"):
|
|
||||||
return AnthropicProvider(api_key="sk-test", default_model=model)
|
|
||||||
|
|
||||||
|
|
||||||
def _build(provider: AnthropicProvider, reasoning_effort: str | None, **overrides):
|
|
||||||
defaults = dict(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
tools=None,
|
|
||||||
model=None,
|
|
||||||
max_tokens=4096,
|
|
||||||
temperature=0.7,
|
|
||||||
reasoning_effort=reasoning_effort,
|
|
||||||
tool_choice=None,
|
|
||||||
supports_caching=False,
|
|
||||||
)
|
|
||||||
defaults.update(overrides)
|
|
||||||
return provider._build_kwargs(**defaults)
|
|
||||||
|
|
||||||
|
|
||||||
def test_adaptive_sets_type_adaptive() -> None:
|
|
||||||
kw = _build(_make_provider(), "adaptive")
|
|
||||||
assert kw["thinking"] == {"type": "adaptive"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_adaptive_forces_temperature_one() -> None:
|
|
||||||
kw = _build(_make_provider(), "adaptive")
|
|
||||||
assert kw["temperature"] == 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_adaptive_does_not_inflate_max_tokens() -> None:
|
|
||||||
kw = _build(_make_provider(), "adaptive", max_tokens=2048)
|
|
||||||
assert kw["max_tokens"] == 2048
|
|
||||||
|
|
||||||
|
|
||||||
def test_adaptive_no_budget_tokens() -> None:
|
|
||||||
kw = _build(_make_provider(), "adaptive")
|
|
||||||
assert "budget_tokens" not in kw["thinking"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_high_uses_enabled_with_budget() -> None:
|
|
||||||
kw = _build(_make_provider(), "high", max_tokens=4096)
|
|
||||||
assert kw["thinking"]["type"] == "enabled"
|
|
||||||
assert kw["thinking"]["budget_tokens"] == max(8192, 4096)
|
|
||||||
assert kw["max_tokens"] >= kw["thinking"]["budget_tokens"] + 4096
|
|
||||||
|
|
||||||
|
|
||||||
def test_low_uses_small_budget() -> None:
|
|
||||||
kw = _build(_make_provider(), "low")
|
|
||||||
assert kw["thinking"] == {"type": "enabled", "budget_tokens": 1024}
|
|
||||||
|
|
||||||
|
|
||||||
def test_none_does_not_enable_thinking() -> None:
|
|
||||||
kw = _build(_make_provider(), None)
|
|
||||||
assert "thinking" not in kw
|
|
||||||
assert kw["temperature"] == 0.7
|
|
||||||
@@ -4,7 +4,6 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
from nanobot.providers.registry import find_by_name
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_provider_parse_handles_empty_choices() -> None:
|
def test_custom_provider_parse_handles_empty_choices() -> None:
|
||||||
@@ -54,20 +53,3 @@ def test_custom_provider_parse_chunks_accepts_plain_text_chunks() -> None:
|
|||||||
|
|
||||||
assert result.finish_reason == "stop"
|
assert result.finish_reason == "stop"
|
||||||
assert result.content == "hello world"
|
assert result.content == "hello world"
|
||||||
|
|
||||||
|
|
||||||
def test_local_provider_502_error_includes_reachability_hint() -> None:
|
|
||||||
spec = find_by_name("ollama")
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = OpenAICompatProvider(api_base="http://localhost:11434/v1", spec=spec)
|
|
||||||
|
|
||||||
result = provider._handle_error(
|
|
||||||
Exception("Error code: 502"),
|
|
||||||
spec=spec,
|
|
||||||
api_base="http://localhost:11434/v1",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.finish_reason == "error"
|
|
||||||
assert "local model endpoint" in result.content
|
|
||||||
assert "http://localhost:11434/v1" in result.content
|
|
||||||
assert "proxy/tunnel" in result.content
|
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
"""Tests for LLMProvider._enforce_role_alternation."""
|
|
||||||
|
|
||||||
from nanobot.providers.base import LLMProvider
|
|
||||||
|
|
||||||
|
|
||||||
class TestEnforceRoleAlternation:
|
|
||||||
"""Verify trailing-assistant removal and consecutive same-role merging."""
|
|
||||||
|
|
||||||
def test_empty_messages(self):
|
|
||||||
assert LLMProvider._enforce_role_alternation([]) == []
|
|
||||||
|
|
||||||
def test_no_change_needed(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "system", "content": "You are helpful."},
|
|
||||||
{"role": "user", "content": "Hi"},
|
|
||||||
{"role": "assistant", "content": "Hello!"},
|
|
||||||
{"role": "user", "content": "Bye"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 4
|
|
||||||
assert result[-1]["role"] == "user"
|
|
||||||
|
|
||||||
def test_trailing_assistant_removed(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "Hi"},
|
|
||||||
{"role": "assistant", "content": "Hello!"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["role"] == "user"
|
|
||||||
|
|
||||||
def test_multiple_trailing_assistants_removed(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "Hi"},
|
|
||||||
{"role": "assistant", "content": "A"},
|
|
||||||
{"role": "assistant", "content": "B"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["role"] == "user"
|
|
||||||
|
|
||||||
def test_consecutive_user_messages_merged(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "Hello"},
|
|
||||||
{"role": "user", "content": "How are you?"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert "Hello" in result[0]["content"]
|
|
||||||
assert "How are you?" in result[0]["content"]
|
|
||||||
|
|
||||||
def test_consecutive_assistant_messages_merged(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "Hi"},
|
|
||||||
{"role": "assistant", "content": "Hello!"},
|
|
||||||
{"role": "assistant", "content": "How can I help?"},
|
|
||||||
{"role": "user", "content": "Thanks"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 3
|
|
||||||
assert "Hello!" in result[1]["content"]
|
|
||||||
assert "How can I help?" in result[1]["content"]
|
|
||||||
|
|
||||||
def test_system_messages_not_merged(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "system", "content": "System A"},
|
|
||||||
{"role": "system", "content": "System B"},
|
|
||||||
{"role": "user", "content": "Hi"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 3
|
|
||||||
assert result[0]["content"] == "System A"
|
|
||||||
assert result[1]["content"] == "System B"
|
|
||||||
|
|
||||||
def test_tool_messages_not_merged(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "Hi"},
|
|
||||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]},
|
|
||||||
{"role": "tool", "content": "result1", "tool_call_id": "1"},
|
|
||||||
{"role": "tool", "content": "result2", "tool_call_id": "2"},
|
|
||||||
{"role": "user", "content": "Next"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
tool_msgs = [m for m in result if m["role"] == "tool"]
|
|
||||||
assert len(tool_msgs) == 2
|
|
||||||
|
|
||||||
def test_consecutive_assistant_keeps_later_tool_call_message(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "Hi"},
|
|
||||||
{"role": "assistant", "content": "Previous reply"},
|
|
||||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]},
|
|
||||||
{"role": "tool", "content": "result1", "tool_call_id": "1"},
|
|
||||||
{"role": "user", "content": "Next"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert result[1]["role"] == "assistant"
|
|
||||||
assert result[1]["tool_calls"] == [{"id": "1"}]
|
|
||||||
assert result[1]["content"] is None
|
|
||||||
assert result[2]["role"] == "tool"
|
|
||||||
|
|
||||||
def test_consecutive_assistant_does_not_overwrite_existing_tool_call_message(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "Hi"},
|
|
||||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]},
|
|
||||||
{"role": "assistant", "content": "Later plain assistant"},
|
|
||||||
{"role": "tool", "content": "result1", "tool_call_id": "1"},
|
|
||||||
{"role": "user", "content": "Next"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert result[1]["role"] == "assistant"
|
|
||||||
assert result[1]["tool_calls"] == [{"id": "1"}]
|
|
||||||
assert result[1]["content"] is None
|
|
||||||
assert result[2]["role"] == "tool"
|
|
||||||
|
|
||||||
def test_non_string_content_uses_latest(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": [{"type": "text", "text": "A"}]},
|
|
||||||
{"role": "user", "content": "B"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["content"] == "B"
|
|
||||||
|
|
||||||
def test_original_messages_not_mutated(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "user", "content": "Hello"},
|
|
||||||
{"role": "user", "content": "World"},
|
|
||||||
]
|
|
||||||
original_first = dict(msgs[0])
|
|
||||||
LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert msgs[0] == original_first
|
|
||||||
assert len(msgs) == 2
|
|
||||||
|
|
||||||
def test_trailing_assistant_recovered_as_user_when_only_system_remains(self):
|
|
||||||
"""Subagent result injected as assistant message must not be silently dropped.
|
|
||||||
|
|
||||||
When build_messages(current_role="assistant") produces [system, assistant],
|
|
||||||
_enforce_role_alternation would drop the assistant, leaving only [system].
|
|
||||||
Most providers (e.g. Zhipu/GLM error 1214) reject such requests.
|
|
||||||
The trailing assistant should be recovered as a user message instead.
|
|
||||||
"""
|
|
||||||
msgs = [
|
|
||||||
{"role": "system", "content": "You are helpful."},
|
|
||||||
{"role": "assistant", "content": "Subagent completed successfully."},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 2
|
|
||||||
assert result[0]["role"] == "system"
|
|
||||||
assert result[1]["role"] == "user"
|
|
||||||
assert "Subagent completed successfully." in result[1]["content"]
|
|
||||||
|
|
||||||
def test_trailing_assistant_not_recovered_when_user_message_present(self):
|
|
||||||
"""Recovery should NOT happen when a user message already exists."""
|
|
||||||
msgs = [
|
|
||||||
{"role": "system", "content": "You are helpful."},
|
|
||||||
{"role": "user", "content": "Hi"},
|
|
||||||
{"role": "assistant", "content": "Hello!"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 2
|
|
||||||
assert result[-1]["role"] == "user"
|
|
||||||
|
|
||||||
def test_trailing_assistant_recovered_with_tool_result_preceding(self):
|
|
||||||
"""When only [system, tool, assistant] remains, recovery is not needed
|
|
||||||
because tool messages are valid non-system content."""
|
|
||||||
msgs = [
|
|
||||||
{"role": "system", "content": "You are helpful."},
|
|
||||||
{"role": "tool", "content": "result", "tool_call_id": "1"},
|
|
||||||
{"role": "assistant", "content": "Done."},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 2
|
|
||||||
assert result[-1]["role"] == "tool"
|
|
||||||
|
|
||||||
def test_only_assistant_messages(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "assistant", "content": "A"},
|
|
||||||
{"role": "assistant", "content": "B"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_realistic_conversation(self):
|
|
||||||
msgs = [
|
|
||||||
{"role": "system", "content": "You are helpful."},
|
|
||||||
{"role": "user", "content": "What is 2+2?"},
|
|
||||||
{"role": "assistant", "content": "4"},
|
|
||||||
{"role": "user", "content": "And 3+3?"},
|
|
||||||
{"role": "user", "content": "(please be quick)"},
|
|
||||||
{"role": "assistant", "content": "6"},
|
|
||||||
]
|
|
||||||
result = LLMProvider._enforce_role_alternation(msgs)
|
|
||||||
assert len(result) == 4
|
|
||||||
assert result[2]["role"] == "assistant"
|
|
||||||
assert result[3]["role"] == "user"
|
|
||||||
assert "And 3+3?" in result[3]["content"]
|
|
||||||
assert "(please be quick)" in result[3]["content"]
|
|
||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -54,57 +54,6 @@ def _fake_tool_call_response() -> SimpleNamespace:
|
|||||||
return SimpleNamespace(choices=[choice], usage=usage)
|
return SimpleNamespace(choices=[choice], usage=usage)
|
||||||
|
|
||||||
|
|
||||||
def _fake_responses_response(content: str = "ok") -> MagicMock:
|
|
||||||
"""Build a minimal Responses API response object."""
|
|
||||||
resp = MagicMock()
|
|
||||||
resp.model_dump.return_value = {
|
|
||||||
"output": [{
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "output_text", "text": content}],
|
|
||||||
}],
|
|
||||||
"status": "completed",
|
|
||||||
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_responses_stream(text: str = "ok"):
|
|
||||||
async def _stream():
|
|
||||||
yield SimpleNamespace(type="response.output_text.delta", delta=text)
|
|
||||||
yield SimpleNamespace(
|
|
||||||
type="response.completed",
|
|
||||||
response=SimpleNamespace(
|
|
||||||
status="completed",
|
|
||||||
usage=SimpleNamespace(input_tokens=10, output_tokens=5, total_tokens=15),
|
|
||||||
output=[],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _stream()
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_chat_stream(text: str = "ok"):
|
|
||||||
async def _stream():
|
|
||||||
yield SimpleNamespace(
|
|
||||||
choices=[SimpleNamespace(finish_reason=None, delta=SimpleNamespace(content=text, reasoning_content=None, tool_calls=None))],
|
|
||||||
usage=None,
|
|
||||||
)
|
|
||||||
yield SimpleNamespace(
|
|
||||||
choices=[SimpleNamespace(finish_reason="stop", delta=SimpleNamespace(content=None, reasoning_content=None, tool_calls=None))],
|
|
||||||
usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _stream()
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponsesError(Exception):
|
|
||||||
def __init__(self, status_code: int, text: str):
|
|
||||||
super().__init__(text)
|
|
||||||
self.status_code = status_code
|
|
||||||
self.response = SimpleNamespace(status_code=status_code, text=text, headers={})
|
|
||||||
|
|
||||||
|
|
||||||
class _StalledStream:
|
class _StalledStream:
|
||||||
def __aiter__(self):
|
def __aiter__(self):
|
||||||
return self
|
return self
|
||||||
@@ -277,224 +226,6 @@ def test_openai_model_passthrough() -> None:
|
|||||||
assert provider.get_default_model() == "gpt-4o"
|
assert provider.get_default_model() == "gpt-4o"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_direct_openai_gpt5_uses_responses_api() -> None:
|
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
|
||||||
mock_responses = AsyncMock(return_value=_fake_responses_response("from responses"))
|
|
||||||
spec = find_by_name("openai")
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
|
||||||
client_instance = MockClient.return_value
|
|
||||||
client_instance.chat.completions.create = mock_chat
|
|
||||||
client_instance.responses.create = mock_responses
|
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="sk-test-key",
|
|
||||||
default_model="gpt-5-chat",
|
|
||||||
spec=spec,
|
|
||||||
)
|
|
||||||
result = await provider.chat(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
model="gpt-5-chat",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.content == "from responses"
|
|
||||||
mock_responses.assert_awaited_once()
|
|
||||||
mock_chat.assert_not_awaited()
|
|
||||||
call_kwargs = mock_responses.call_args.kwargs
|
|
||||||
assert call_kwargs["model"] == "gpt-5-chat"
|
|
||||||
assert call_kwargs["max_output_tokens"] == 4096
|
|
||||||
assert "input" in call_kwargs
|
|
||||||
assert "messages" not in call_kwargs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_direct_openai_reasoning_prefers_responses_api() -> None:
|
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
|
||||||
mock_responses = AsyncMock(return_value=_fake_responses_response("reasoned"))
|
|
||||||
spec = find_by_name("openai")
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
|
||||||
client_instance = MockClient.return_value
|
|
||||||
client_instance.chat.completions.create = mock_chat
|
|
||||||
client_instance.responses.create = mock_responses
|
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="sk-test-key",
|
|
||||||
default_model="gpt-4o",
|
|
||||||
spec=spec,
|
|
||||||
)
|
|
||||||
await provider.chat(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
model="gpt-4o",
|
|
||||||
reasoning_effort="medium",
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_responses.assert_awaited_once()
|
|
||||||
mock_chat.assert_not_awaited()
|
|
||||||
call_kwargs = mock_responses.call_args.kwargs
|
|
||||||
assert call_kwargs["reasoning"] == {"effort": "medium"}
|
|
||||||
assert call_kwargs["include"] == ["reasoning.encrypted_content"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_direct_openai_gpt4o_stays_on_chat_completions() -> None:
|
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
|
||||||
mock_responses = AsyncMock(return_value=_fake_responses_response())
|
|
||||||
spec = find_by_name("openai")
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
|
||||||
client_instance = MockClient.return_value
|
|
||||||
client_instance.chat.completions.create = mock_chat
|
|
||||||
client_instance.responses.create = mock_responses
|
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="sk-test-key",
|
|
||||||
default_model="gpt-4o",
|
|
||||||
spec=spec,
|
|
||||||
)
|
|
||||||
await provider.chat(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
model="gpt-4o",
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_chat.assert_awaited_once()
|
|
||||||
mock_responses.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_openrouter_gpt5_stays_on_chat_completions() -> None:
|
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
|
||||||
mock_responses = AsyncMock(return_value=_fake_responses_response())
|
|
||||||
spec = find_by_name("openrouter")
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
|
||||||
client_instance = MockClient.return_value
|
|
||||||
client_instance.chat.completions.create = mock_chat
|
|
||||||
client_instance.responses.create = mock_responses
|
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="sk-or-test-key",
|
|
||||||
api_base="https://openrouter.ai/api/v1",
|
|
||||||
default_model="openai/gpt-5",
|
|
||||||
spec=spec,
|
|
||||||
)
|
|
||||||
await provider.chat(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
model="openai/gpt-5",
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_chat.assert_awaited_once()
|
|
||||||
mock_responses.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_direct_openai_streaming_gpt5_uses_responses_api() -> None:
|
|
||||||
mock_chat = AsyncMock(return_value=_StalledStream())
|
|
||||||
mock_responses = AsyncMock(return_value=_fake_responses_stream("hi"))
|
|
||||||
spec = find_by_name("openai")
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
|
||||||
client_instance = MockClient.return_value
|
|
||||||
client_instance.chat.completions.create = mock_chat
|
|
||||||
client_instance.responses.create = mock_responses
|
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="sk-test-key",
|
|
||||||
default_model="gpt-5-chat",
|
|
||||||
spec=spec,
|
|
||||||
)
|
|
||||||
result = await provider.chat_stream(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
model="gpt-5-chat",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.content == "hi"
|
|
||||||
assert result.finish_reason == "stop"
|
|
||||||
mock_responses.assert_awaited_once()
|
|
||||||
mock_chat.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_direct_openai_responses_404_falls_back_to_chat_completions() -> None:
|
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_response("from chat"))
|
|
||||||
mock_responses = AsyncMock(side_effect=_FakeResponsesError(404, "Responses endpoint not supported"))
|
|
||||||
spec = find_by_name("openai")
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
|
||||||
client_instance = MockClient.return_value
|
|
||||||
client_instance.chat.completions.create = mock_chat
|
|
||||||
client_instance.responses.create = mock_responses
|
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="sk-test-key",
|
|
||||||
default_model="gpt-5-chat",
|
|
||||||
spec=spec,
|
|
||||||
)
|
|
||||||
result = await provider.chat(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
model="gpt-5-chat",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.content == "from chat"
|
|
||||||
mock_responses.assert_awaited_once()
|
|
||||||
mock_chat.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_direct_openai_stream_responses_unsupported_param_falls_back() -> None:
|
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_stream("fallback stream"))
|
|
||||||
mock_responses = AsyncMock(
|
|
||||||
side_effect=_FakeResponsesError(400, "Unknown parameter: max_output_tokens for Responses API")
|
|
||||||
)
|
|
||||||
spec = find_by_name("openai")
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
|
||||||
client_instance = MockClient.return_value
|
|
||||||
client_instance.chat.completions.create = mock_chat
|
|
||||||
client_instance.responses.create = mock_responses
|
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="sk-test-key",
|
|
||||||
default_model="gpt-5-chat",
|
|
||||||
spec=spec,
|
|
||||||
)
|
|
||||||
result = await provider.chat_stream(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
model="gpt-5-chat",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.content == "fallback stream"
|
|
||||||
mock_responses.assert_awaited_once()
|
|
||||||
mock_chat.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_direct_openai_responses_rate_limit_does_not_fallback() -> None:
|
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_response("from chat"))
|
|
||||||
mock_responses = AsyncMock(side_effect=_FakeResponsesError(429, "rate limit"))
|
|
||||||
spec = find_by_name("openai")
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
|
|
||||||
client_instance = MockClient.return_value
|
|
||||||
client_instance.chat.completions.create = mock_chat
|
|
||||||
client_instance.responses.create = mock_responses
|
|
||||||
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="sk-test-key",
|
|
||||||
default_model="gpt-5-chat",
|
|
||||||
spec=spec,
|
|
||||||
)
|
|
||||||
result = await provider.chat(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
model="gpt-5-chat",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.finish_reason == "error"
|
|
||||||
mock_responses.assert_awaited_once()
|
|
||||||
mock_chat.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compat_supports_temperature_matches_reasoning_model_rules() -> None:
|
def test_openai_compat_supports_temperature_matches_reasoning_model_rules() -> None:
|
||||||
assert OpenAICompatProvider._supports_temperature("gpt-4o") is True
|
assert OpenAICompatProvider._supports_temperature("gpt-4o") is True
|
||||||
assert OpenAICompatProvider._supports_temperature("gpt-5-chat") is False
|
assert OpenAICompatProvider._supports_temperature("gpt-5-chat") is False
|
||||||
@@ -532,7 +263,6 @@ def test_openai_compat_preserves_message_level_reasoning_fields() -> None:
|
|||||||
provider = OpenAICompatProvider()
|
provider = OpenAICompatProvider()
|
||||||
|
|
||||||
sanitized = provider._sanitize_messages([
|
sanitized = provider._sanitize_messages([
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
{
|
{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "done",
|
"content": "done",
|
||||||
@@ -546,42 +276,12 @@ def test_openai_compat_preserves_message_level_reasoning_fields() -> None:
|
|||||||
"extra_content": {"google": {"thought_signature": "sig"}},
|
"extra_content": {"google": {"thought_signature": "sig"}},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
}
|
||||||
{"role": "user", "content": "thanks"},
|
|
||||||
])
|
])
|
||||||
|
|
||||||
assert sanitized[1]["content"] is None
|
assert sanitized[0]["reasoning_content"] == "hidden"
|
||||||
assert sanitized[1]["reasoning_content"] == "hidden"
|
assert sanitized[0]["extra_content"] == {"debug": True}
|
||||||
assert sanitized[1]["extra_content"] == {"debug": True}
|
assert sanitized[0]["tool_calls"][0]["extra_content"] == {"google": {"thought_signature": "sig"}}
|
||||||
assert sanitized[1]["tool_calls"][0]["extra_content"] == {"google": {"thought_signature": "sig"}}
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None:
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = OpenAICompatProvider()
|
|
||||||
|
|
||||||
sanitized = provider._sanitize_messages([
|
|
||||||
{"role": "user", "content": "不错"},
|
|
||||||
{"role": "assistant", "content": "对,破 4 万指日可待"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "<think>我再查一下</think>",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_function_akxp3wqzn7ph_1",
|
|
||||||
"type": "function",
|
|
||||||
"function": {"name": "exec", "arguments": "{}"},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"role": "tool", "tool_call_id": "call_function_akxp3wqzn7ph_1", "name": "exec", "content": "ok"},
|
|
||||||
{"role": "user", "content": "多少star了呢"},
|
|
||||||
])
|
|
||||||
|
|
||||||
assert sanitized[1]["role"] == "assistant"
|
|
||||||
assert sanitized[1]["content"] is None
|
|
||||||
assert sanitized[1]["tool_calls"][0]["id"] == "3ec83c30d"
|
|
||||||
assert sanitized[2]["tool_call_id"] == "3ec83c30d"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -153,7 +152,7 @@ async def test_non_transient_error_with_images_retries_without_images() -> None:
|
|||||||
LLMResponse(content="ok, no image"),
|
LLMResponse(content="ok, no image"),
|
||||||
])
|
])
|
||||||
|
|
||||||
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
|
response = await provider.chat_with_retry(messages=_IMAGE_MSG)
|
||||||
|
|
||||||
assert response.content == "ok, no image"
|
assert response.content == "ok, no image"
|
||||||
assert provider.calls == 2
|
assert provider.calls == 2
|
||||||
@@ -165,24 +164,6 @@ async def test_non_transient_error_with_images_retries_without_images() -> None:
|
|||||||
assert any("[image: /media/test.png]" in (b.get("text") or "") for b in content)
|
assert any("[image: /media/test.png]" in (b.get("text") or "") for b in content)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_successful_image_retry_mutates_original_messages_in_place() -> None:
|
|
||||||
"""Successful no-image retry should update the caller's message history."""
|
|
||||||
provider = ScriptedProvider([
|
|
||||||
LLMResponse(content="model does not support images", finish_reason="error"),
|
|
||||||
LLMResponse(content="ok, no image"),
|
|
||||||
])
|
|
||||||
messages = copy.deepcopy(_IMAGE_MSG)
|
|
||||||
|
|
||||||
response = await provider.chat_with_retry(messages=messages)
|
|
||||||
|
|
||||||
assert response.content == "ok, no image"
|
|
||||||
content = messages[0]["content"]
|
|
||||||
assert isinstance(content, list)
|
|
||||||
assert all(block.get("type") != "image_url" for block in content)
|
|
||||||
assert any("[image: /media/test.png]" in (block.get("text") or "") for block in content)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_transient_error_without_images_no_retry() -> None:
|
async def test_non_transient_error_without_images_no_retry() -> None:
|
||||||
"""Non-transient errors without image content are returned immediately."""
|
"""Non-transient errors without image content are returned immediately."""
|
||||||
@@ -206,7 +187,7 @@ async def test_image_fallback_returns_error_on_second_failure() -> None:
|
|||||||
LLMResponse(content="still failing", finish_reason="error"),
|
LLMResponse(content="still failing", finish_reason="error"),
|
||||||
])
|
])
|
||||||
|
|
||||||
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
|
response = await provider.chat_with_retry(messages=_IMAGE_MSG)
|
||||||
|
|
||||||
assert provider.calls == 2
|
assert provider.calls == 2
|
||||||
assert response.content == "still failing"
|
assert response.content == "still failing"
|
||||||
@@ -221,7 +202,7 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
|
|||||||
LLMResponse(content="ok"),
|
LLMResponse(content="ok"),
|
||||||
])
|
])
|
||||||
|
|
||||||
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG_NO_META))
|
response = await provider.chat_with_retry(messages=_IMAGE_MSG_NO_META)
|
||||||
|
|
||||||
assert response.content == "ok"
|
assert response.content == "ok"
|
||||||
assert provider.calls == 2
|
assert provider.calls == 2
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
"""Tests for StepFun Plan API reasoning field fallback in OpenAICompatProvider.
|
|
||||||
|
|
||||||
StepFun Plan API returns response content in the 'reasoning' field when
|
|
||||||
the model is in thinking mode and 'content' is empty. This test module
|
|
||||||
verifies the fallback logic for all code paths.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
|
||||||
|
|
||||||
|
|
||||||
# ── _parse: dict branch ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_dict_stepfun_reasoning_fallback() -> None:
|
|
||||||
"""When content is None and reasoning exists, content falls back to reasoning."""
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = OpenAICompatProvider()
|
|
||||||
|
|
||||||
response = {
|
|
||||||
"choices": [{
|
|
||||||
"message": {
|
|
||||||
"content": None,
|
|
||||||
"reasoning": "Let me think... The answer is 42.",
|
|
||||||
},
|
|
||||||
"finish_reason": "stop",
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
|
|
||||||
result = provider._parse(response)
|
|
||||||
|
|
||||||
assert result.content == "Let me think... The answer is 42."
|
|
||||||
# reasoning_content should also be populated from reasoning
|
|
||||||
assert result.reasoning_content == "Let me think... The answer is 42."
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_dict_stepfun_reasoning_priority() -> None:
|
|
||||||
"""reasoning_content field takes priority over reasoning when both present."""
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = OpenAICompatProvider()
|
|
||||||
|
|
||||||
response = {
|
|
||||||
"choices": [{
|
|
||||||
"message": {
|
|
||||||
"content": None,
|
|
||||||
"reasoning": "informal thinking",
|
|
||||||
"reasoning_content": "formal reasoning content",
|
|
||||||
},
|
|
||||||
"finish_reason": "stop",
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
|
|
||||||
result = provider._parse(response)
|
|
||||||
|
|
||||||
assert result.content == "informal thinking"
|
|
||||||
# reasoning_content uses the dedicated field, not reasoning
|
|
||||||
assert result.reasoning_content == "formal reasoning content"
|
|
||||||
|
|
||||||
|
|
||||||
# ── _parse: SDK object branch ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _make_sdk_message(content, reasoning=None, reasoning_content=None):
|
|
||||||
"""Create a mock SDK message object."""
|
|
||||||
msg = SimpleNamespace(content=content, tool_calls=None)
|
|
||||||
if reasoning is not None:
|
|
||||||
msg.reasoning = reasoning
|
|
||||||
if reasoning_content is not None:
|
|
||||||
msg.reasoning_content = reasoning_content
|
|
||||||
return msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_sdk_stepfun_reasoning_fallback() -> None:
|
|
||||||
"""SDK branch: content falls back to msg.reasoning when content is None."""
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = OpenAICompatProvider()
|
|
||||||
|
|
||||||
msg = _make_sdk_message(content=None, reasoning="After analysis: result is 4.")
|
|
||||||
choice = SimpleNamespace(finish_reason="stop", message=msg)
|
|
||||||
response = SimpleNamespace(choices=[choice], usage=None)
|
|
||||||
|
|
||||||
result = provider._parse(response)
|
|
||||||
|
|
||||||
assert result.content == "After analysis: result is 4."
|
|
||||||
assert result.reasoning_content == "After analysis: result is 4."
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_sdk_stepfun_reasoning_priority() -> None:
|
|
||||||
"""reasoning_content field takes priority over reasoning in SDK branch."""
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = OpenAICompatProvider()
|
|
||||||
|
|
||||||
msg = _make_sdk_message(
|
|
||||||
content=None,
|
|
||||||
reasoning="thinking process",
|
|
||||||
reasoning_content="formal reasoning"
|
|
||||||
)
|
|
||||||
choice = SimpleNamespace(finish_reason="stop", message=msg)
|
|
||||||
response = SimpleNamespace(choices=[choice], usage=None)
|
|
||||||
|
|
||||||
result = provider._parse(response)
|
|
||||||
|
|
||||||
assert result.content == "thinking process"
|
|
||||||
assert result.reasoning_content == "formal reasoning"
|
|
||||||
|
|
||||||
|
|
||||||
# ── _parse_chunks: streaming dict branch ────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_chunks_dict_stepfun_reasoning_fallback() -> None:
|
|
||||||
"""Streaming dict: reasoning field used when reasoning_content is absent."""
|
|
||||||
chunks = [
|
|
||||||
{
|
|
||||||
"choices": [{
|
|
||||||
"finish_reason": None,
|
|
||||||
"delta": {"content": None, "reasoning": "Thinking step 1... "},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"choices": [{
|
|
||||||
"finish_reason": None,
|
|
||||||
"delta": {"content": None, "reasoning": "step 2."},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"choices": [{
|
|
||||||
"finish_reason": "stop",
|
|
||||||
"delta": {"content": "final answer"},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
|
||||||
|
|
||||||
assert result.content == "final answer"
|
|
||||||
assert result.reasoning_content == "Thinking step 1... step 2."
|
|
||||||
|
|
||||||
|
|
||||||
# ── Regression: normal models unaffected ────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_dict_normal_model_with_reasoning_content_unaffected() -> None:
|
|
||||||
"""Models that use reasoning_content (e.g. DeepSeek-R1) are not affected."""
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = OpenAICompatProvider()
|
|
||||||
|
|
||||||
response = {
|
|
||||||
"choices": [{
|
|
||||||
"message": {
|
|
||||||
"content": "The answer is 42.",
|
|
||||||
"reasoning_content": "Let me think step by step...",
|
|
||||||
},
|
|
||||||
"finish_reason": "stop",
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
|
|
||||||
result = provider._parse(response)
|
|
||||||
|
|
||||||
assert result.content == "The answer is 42."
|
|
||||||
assert result.reasoning_content == "Let me think step by step..."
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_dict_standard_model_no_reasoning_unaffected() -> None:
|
|
||||||
"""Standard models (no reasoning fields at all) work exactly as before."""
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = OpenAICompatProvider()
|
|
||||||
|
|
||||||
response = {
|
|
||||||
"choices": [{
|
|
||||||
"message": {"content": "Hello!"},
|
|
||||||
"finish_reason": "stop",
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
|
|
||||||
result = provider._parse(response)
|
|
||||||
|
|
||||||
assert result.content == "Hello!"
|
|
||||||
assert result.reasoning_content is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_chunks_dict_reasoning_precedence() -> None:
|
|
||||||
"""reasoning_content takes precedence over reasoning in dict chunks."""
|
|
||||||
chunks = [
|
|
||||||
{
|
|
||||||
"choices": [{
|
|
||||||
"finish_reason": None,
|
|
||||||
"delta": {
|
|
||||||
"content": None,
|
|
||||||
"reasoning_content": "formal: ",
|
|
||||||
"reasoning": "informal: ",
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"choices": [{
|
|
||||||
"finish_reason": "stop",
|
|
||||||
"delta": {"content": "result"},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
|
||||||
|
|
||||||
assert result.reasoning_content == "formal: "
|
|
||||||
|
|
||||||
|
|
||||||
# ── _parse_chunks: streaming SDK-object branch ─────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _make_sdk_chunk(reasoning_content=None, reasoning=None, content=None, finish=None):
|
|
||||||
"""Create a mock SDK chunk object."""
|
|
||||||
delta = SimpleNamespace(
|
|
||||||
content=content,
|
|
||||||
reasoning_content=reasoning_content,
|
|
||||||
reasoning=reasoning,
|
|
||||||
tool_calls=None,
|
|
||||||
)
|
|
||||||
choice = SimpleNamespace(finish_reason=finish, delta=delta)
|
|
||||||
return SimpleNamespace(choices=[choice], usage=None)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_chunks_sdk_stepfun_reasoning_fallback() -> None:
|
|
||||||
"""SDK streaming: reasoning field used when reasoning_content is None."""
|
|
||||||
chunks = [
|
|
||||||
_make_sdk_chunk(reasoning="Thinking... ", content=None, finish=None),
|
|
||||||
_make_sdk_chunk(reasoning=None, content="answer", finish="stop"),
|
|
||||||
]
|
|
||||||
|
|
||||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
|
||||||
|
|
||||||
assert result.content == "answer"
|
|
||||||
assert result.reasoning_content == "Thinking... "
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_chunks_sdk_reasoning_precedence() -> None:
|
|
||||||
"""reasoning_content takes precedence over reasoning in SDK chunks."""
|
|
||||||
chunks = [
|
|
||||||
_make_sdk_chunk(reasoning_content="formal: ", reasoning="informal: ", content=None),
|
|
||||||
_make_sdk_chunk(reasoning_content=None, reasoning=None, content="result", finish="stop"),
|
|
||||||
]
|
|
||||||
|
|
||||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
|
||||||
|
|
||||||
assert result.reasoning_content == "formal: "
|
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Check optional msteams dependencies before running tests
|
||||||
|
try:
|
||||||
|
from nanobot.channels import msteams
|
||||||
|
MSTEAMS_AVAILABLE = getattr(msteams, "MSTEAMS_AVAILABLE", False)
|
||||||
|
except ImportError:
|
||||||
|
MSTEAMS_AVAILABLE = False
|
||||||
|
|
||||||
|
if not MSTEAMS_AVAILABLE:
|
||||||
|
pytest.skip("MSTeams dependencies not installed (PyJWT, cryptography). Run: pip install nanobot-ai[msteams]", allow_module_level=True)
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
import nanobot.channels.msteams as msteams_module
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.channels.msteams import ConversationRef, MSTeamsChannel, MSTeamsConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DummyBus:
|
||||||
|
def __init__(self):
|
||||||
|
self.inbound = []
|
||||||
|
|
||||||
|
async def publish_inbound(self, msg):
|
||||||
|
self.inbound.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, payload=None, *, should_raise=False):
|
||||||
|
self._payload = payload or {}
|
||||||
|
self._should_raise = should_raise
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if self._should_raise:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHttpClient:
|
||||||
|
def __init__(self, payload=None, *, should_raise=False):
|
||||||
|
self.payload = payload or {"access_token": "tok", "expires_in": 3600}
|
||||||
|
self.should_raise = should_raise
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def post(self, url, **kwargs):
|
||||||
|
self.calls.append((url, kwargs))
|
||||||
|
return FakeResponse(self.payload, should_raise=self.should_raise)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_channel(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
|
||||||
|
|
||||||
|
def _make_channel(**config_overrides):
|
||||||
|
config = {
|
||||||
|
"enabled": True,
|
||||||
|
"appId": "app-id",
|
||||||
|
"appPassword": "secret",
|
||||||
|
"tenantId": "tenant-id",
|
||||||
|
"allowFrom": ["*"],
|
||||||
|
}
|
||||||
|
config.update(config_overrides)
|
||||||
|
return MSTeamsChannel(config, DummyBus())
|
||||||
|
|
||||||
|
return _make_channel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_activity_personal_message_publishes_and_stores_ref(make_channel, tmp_path):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"type": "message",
|
||||||
|
"id": "activity-1",
|
||||||
|
"text": "Hello from Teams",
|
||||||
|
"serviceUrl": "https://smba.trafficmanager.net/amer/",
|
||||||
|
"conversation": {
|
||||||
|
"id": "conv-123",
|
||||||
|
"conversationType": "personal",
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"id": "29:user-id",
|
||||||
|
"aadObjectId": "aad-user-1",
|
||||||
|
"name": "Bob",
|
||||||
|
},
|
||||||
|
"recipient": {
|
||||||
|
"id": "28:bot-id",
|
||||||
|
"name": "nanobot",
|
||||||
|
},
|
||||||
|
"channelData": {
|
||||||
|
"tenant": {"id": "tenant-id"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await ch._handle_activity(activity)
|
||||||
|
|
||||||
|
assert len(ch.bus.inbound) == 1
|
||||||
|
msg = ch.bus.inbound[0]
|
||||||
|
assert msg.channel == "msteams"
|
||||||
|
assert msg.sender_id == "aad-user-1"
|
||||||
|
assert msg.chat_id == "conv-123"
|
||||||
|
assert msg.content == "Hello from Teams"
|
||||||
|
assert msg.metadata["msteams"]["conversation_id"] == "conv-123"
|
||||||
|
assert "conv-123" in ch._conversation_refs
|
||||||
|
|
||||||
|
saved = json.loads((tmp_path / "state" / "msteams_conversations.json").read_text(encoding="utf-8"))
|
||||||
|
assert saved["conv-123"]["conversation_id"] == "conv-123"
|
||||||
|
assert saved["conv-123"]["tenant_id"] == "tenant-id"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_activity_ignores_group_messages(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"type": "message",
|
||||||
|
"id": "activity-2",
|
||||||
|
"text": "Hello group",
|
||||||
|
"serviceUrl": "https://smba.trafficmanager.net/amer/",
|
||||||
|
"conversation": {
|
||||||
|
"id": "conv-group",
|
||||||
|
"conversationType": "channel",
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"id": "29:user-id",
|
||||||
|
"aadObjectId": "aad-user-1",
|
||||||
|
"name": "Bob",
|
||||||
|
},
|
||||||
|
"recipient": {
|
||||||
|
"id": "28:bot-id",
|
||||||
|
"name": "nanobot",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await ch._handle_activity(activity)
|
||||||
|
|
||||||
|
assert ch.bus.inbound == []
|
||||||
|
assert ch._conversation_refs == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_activity_mention_only_uses_default_response(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"type": "message",
|
||||||
|
"id": "activity-3",
|
||||||
|
"text": "<at>Nanobot</at>",
|
||||||
|
"serviceUrl": "https://smba.trafficmanager.net/amer/",
|
||||||
|
"conversation": {
|
||||||
|
"id": "conv-empty",
|
||||||
|
"conversationType": "personal",
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"id": "29:user-id",
|
||||||
|
"aadObjectId": "aad-user-1",
|
||||||
|
"name": "Bob",
|
||||||
|
},
|
||||||
|
"recipient": {
|
||||||
|
"id": "28:bot-id",
|
||||||
|
"name": "nanobot",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await ch._handle_activity(activity)
|
||||||
|
|
||||||
|
assert len(ch.bus.inbound) == 1
|
||||||
|
assert ch.bus.inbound[0].content == "Hi — what can I help with?"
|
||||||
|
assert "conv-empty" in ch._conversation_refs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_activity_mention_only_ignores_when_response_disabled(make_channel):
|
||||||
|
ch = make_channel(mentionOnlyResponse=" ")
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"type": "message",
|
||||||
|
"id": "activity-4",
|
||||||
|
"text": "<at>Nanobot</at>",
|
||||||
|
"serviceUrl": "https://smba.trafficmanager.net/amer/",
|
||||||
|
"conversation": {
|
||||||
|
"id": "conv-empty-disabled",
|
||||||
|
"conversationType": "personal",
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"id": "29:user-id",
|
||||||
|
"aadObjectId": "aad-user-1",
|
||||||
|
"name": "Bob",
|
||||||
|
},
|
||||||
|
"recipient": {
|
||||||
|
"id": "28:bot-id",
|
||||||
|
"name": "nanobot",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await ch._handle_activity(activity)
|
||||||
|
|
||||||
|
assert ch.bus.inbound == []
|
||||||
|
assert ch._conversation_refs == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_possible_bot_mention_removes_generic_at_tags(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
assert ch._strip_possible_bot_mention("<at>Nanobot</at> hello") == "hello"
|
||||||
|
assert ch._strip_possible_bot_mention("hi <at>Some Bot</at> there") == "hi there"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_inbound_text_keeps_normal_inline_message(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"text": "<at>Nanobot</at> normal inline message",
|
||||||
|
"channelData": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ch._sanitize_inbound_text(activity) == "normal inline message"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_inbound_text_normalizes_fwdioc_wrapper_without_reply_metadata(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"text": "FWDIOC-BOT \r\nQuoted prior message\r\n\r\nThis is a reply with quote test",
|
||||||
|
"channelData": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ch._sanitize_inbound_text(activity) == (
|
||||||
|
"User is replying to: Quoted prior message\n"
|
||||||
|
"User reply: This is a reply with quote test"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_inbound_text_structures_reply_quote_prefix(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"text": "Replying to Bob Smith\nactual reply text",
|
||||||
|
"replyToId": "parent-activity",
|
||||||
|
"channelData": {"messageType": "reply"},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ch._sanitize_inbound_text(activity) == "User is replying to: Bob Smith\nUser reply: actual reply text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_inbound_text_structures_live_fwdioc_quote_shape(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"text": "FWDIOC-BOT Got it. I’ll watch for the exact text reply with quote test and then inspect that turn specifically. Reply with quote test",
|
||||||
|
"replyToId": "parent-activity",
|
||||||
|
"channelData": {"messageType": "reply"},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ch._sanitize_inbound_text(activity) == (
|
||||||
|
"User is replying to: Got it. I’ll watch for the exact text reply with quote test and then inspect that turn specifically.\n"
|
||||||
|
"User reply: Reply with quote test"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_inbound_text_structures_multiline_fwdioc_quote_shape(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"text": (
|
||||||
|
"FWDIOC-BOT\r\n"
|
||||||
|
"Understood — then the restart already happened, and the new Teams quote normalization should now be live. "
|
||||||
|
"Next best step: • send one more real reply-with-quote message in Teams • I&rsquo…\r\n"
|
||||||
|
"\r\n"
|
||||||
|
"This is a reply with quote"
|
||||||
|
),
|
||||||
|
"replyToId": "parent-activity",
|
||||||
|
"channelData": {"messageType": "reply"},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ch._sanitize_inbound_text(activity) == (
|
||||||
|
"User is replying to: Understood — then the restart already happened, and the new Teams quote normalization should now be live. "
|
||||||
|
"Next best step: • send one more real reply-with-quote message in Teams • I’…\n"
|
||||||
|
"User reply: This is a reply with quote"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_inbound_text_structures_exact_live_crlf_fwdioc_shape(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"text": (
|
||||||
|
"FWDIOC-BOT \r\n"
|
||||||
|
"Please send one real reply-with-quote message in Teams. That single test should be enough now: "
|
||||||
|
"• I’ll check the new MSTeams sanitized inbound text ... log • and compare it to the prompt…\r\n"
|
||||||
|
"\r\n"
|
||||||
|
"This is a reply with quote test"
|
||||||
|
),
|
||||||
|
"replyToId": "parent-activity",
|
||||||
|
"channelData": {"messageType": "reply"},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ch._sanitize_inbound_text(activity) == (
|
||||||
|
"User is replying to: Please send one real reply-with-quote message in Teams. That single test should be enough now: "
|
||||||
|
"• I’ll check the new MSTeams sanitized inbound text ... log • and compare it to the prompt…\n"
|
||||||
|
"User reply: This is a reply with quote test"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_access_token_uses_configured_tenant(make_channel):
|
||||||
|
ch = make_channel(tenantId="tenant-123")
|
||||||
|
fake_http = FakeHttpClient()
|
||||||
|
ch._http = fake_http
|
||||||
|
|
||||||
|
token = await ch._get_access_token()
|
||||||
|
|
||||||
|
assert token == "tok"
|
||||||
|
assert len(fake_http.calls) == 1
|
||||||
|
url, kwargs = fake_http.calls[0]
|
||||||
|
assert url == "https://login.microsoftonline.com/tenant-123/oauth2/v2.0/token"
|
||||||
|
assert kwargs["data"]["client_id"] == "app-id"
|
||||||
|
assert kwargs["data"]["client_secret"] == "secret"
|
||||||
|
assert kwargs["data"]["scope"] == "https://api.botframework.com/.default"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channel):
|
||||||
|
ch = make_channel(replyInThread=True)
|
||||||
|
fake_http = FakeHttpClient()
|
||||||
|
ch._http = fake_http
|
||||||
|
ch._token = "tok"
|
||||||
|
ch._token_expires_at = 9999999999
|
||||||
|
ch._conversation_refs["conv-123"] = ConversationRef(
|
||||||
|
service_url="https://smba.trafficmanager.net/amer/",
|
||||||
|
conversation_id="conv-123",
|
||||||
|
activity_id="activity-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
|
||||||
|
|
||||||
|
assert len(fake_http.calls) == 1
|
||||||
|
url, kwargs = fake_http.calls[0]
|
||||||
|
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities/activity-1"
|
||||||
|
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
||||||
|
assert kwargs["json"]["text"] == "Reply text"
|
||||||
|
assert kwargs["json"]["replyToId"] == "activity-1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_posts_to_conversation_when_thread_reply_disabled(make_channel):
|
||||||
|
ch = make_channel(replyInThread=False)
|
||||||
|
fake_http = FakeHttpClient()
|
||||||
|
ch._http = fake_http
|
||||||
|
ch._token = "tok"
|
||||||
|
ch._token_expires_at = 9999999999
|
||||||
|
ch._conversation_refs["conv-123"] = ConversationRef(
|
||||||
|
service_url="https://smba.trafficmanager.net/amer/",
|
||||||
|
conversation_id="conv-123",
|
||||||
|
activity_id="activity-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
|
||||||
|
|
||||||
|
assert len(fake_http.calls) == 1
|
||||||
|
url, kwargs = fake_http.calls[0]
|
||||||
|
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities"
|
||||||
|
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
||||||
|
assert kwargs["json"]["text"] == "Reply text"
|
||||||
|
assert "replyToId" not in kwargs["json"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_posts_to_conversation_when_thread_reply_enabled_but_no_activity_id(make_channel):
|
||||||
|
ch = make_channel(replyInThread=True)
|
||||||
|
fake_http = FakeHttpClient()
|
||||||
|
ch._http = fake_http
|
||||||
|
ch._token = "tok"
|
||||||
|
ch._token_expires_at = 9999999999
|
||||||
|
ch._conversation_refs["conv-123"] = ConversationRef(
|
||||||
|
service_url="https://smba.trafficmanager.net/amer/",
|
||||||
|
conversation_id="conv-123",
|
||||||
|
activity_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
|
||||||
|
|
||||||
|
assert len(fake_http.calls) == 1
|
||||||
|
url, kwargs = fake_http.calls[0]
|
||||||
|
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities"
|
||||||
|
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
||||||
|
assert kwargs["json"]["text"] == "Reply text"
|
||||||
|
assert "replyToId" not in kwargs["json"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_raises_when_conversation_ref_missing(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
ch._http = FakeHttpClient()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="conversation ref not found"):
|
||||||
|
await ch.send(OutboundMessage(channel="msteams", chat_id="missing", content="Reply text"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_raises_delivery_failures_for_retry(make_channel):
|
||||||
|
ch = make_channel()
|
||||||
|
ch._http = FakeHttpClient(should_raise=True)
|
||||||
|
ch._token = "tok"
|
||||||
|
ch._token_expires_at = 9999999999
|
||||||
|
ch._conversation_refs["conv-123"] = ConversationRef(
|
||||||
|
service_url="https://smba.trafficmanager.net/amer/",
|
||||||
|
conversation_id="conv-123",
|
||||||
|
activity_id="activity-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="boom"):
|
||||||
|
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_test_rsa_jwk(kid: str = "test-kid"):
|
||||||
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(public_key))
|
||||||
|
jwk["kid"] = kid
|
||||||
|
jwk["use"] = "sig"
|
||||||
|
jwk["kty"] = "RSA"
|
||||||
|
jwk["alg"] = "RS256"
|
||||||
|
return private_key, jwk
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_inbound_auth_accepts_observed_botframework_shape(make_channel):
|
||||||
|
ch = make_channel(validateInboundAuth=True)
|
||||||
|
|
||||||
|
private_key, jwk = _make_test_rsa_jwk()
|
||||||
|
ch._botframework_jwks = {"keys": [jwk]}
|
||||||
|
ch._botframework_jwks_expires_at = 9999999999
|
||||||
|
|
||||||
|
service_url = "https://smba.trafficmanager.net/amer/tenant/"
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": "https://api.botframework.com",
|
||||||
|
"aud": "app-id",
|
||||||
|
"serviceurl": service_url,
|
||||||
|
"nbf": 1700000000,
|
||||||
|
"exp": 4100000000,
|
||||||
|
},
|
||||||
|
private_key,
|
||||||
|
algorithm="RS256",
|
||||||
|
headers={"kid": jwk["kid"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
await ch._validate_inbound_auth(
|
||||||
|
f"Bearer {token}",
|
||||||
|
{"serviceUrl": service_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_inbound_auth_rejects_service_url_mismatch(make_channel):
|
||||||
|
ch = make_channel(validateInboundAuth=True)
|
||||||
|
|
||||||
|
private_key, jwk = _make_test_rsa_jwk()
|
||||||
|
ch._botframework_jwks = {"keys": [jwk]}
|
||||||
|
ch._botframework_jwks_expires_at = 9999999999
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": "https://api.botframework.com",
|
||||||
|
"aud": "app-id",
|
||||||
|
"serviceurl": "https://smba.trafficmanager.net/amer/tenant-a/",
|
||||||
|
"nbf": 1700000000,
|
||||||
|
"exp": 4100000000,
|
||||||
|
},
|
||||||
|
private_key,
|
||||||
|
algorithm="RS256",
|
||||||
|
headers={"kid": jwk["kid"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="serviceUrl claim mismatch"):
|
||||||
|
await ch._validate_inbound_auth(
|
||||||
|
f"Bearer {token}",
|
||||||
|
{"serviceUrl": "https://smba.trafficmanager.net/amer/tenant-b/"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_inbound_auth_rejects_missing_bearer_token(make_channel):
|
||||||
|
ch = make_channel(validateInboundAuth=True)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="missing bearer token"):
|
||||||
|
await ch._validate_inbound_auth("", {"serviceUrl": "https://smba.trafficmanager.net/amer/tenant/"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypatch):
|
||||||
|
ch = make_channel()
|
||||||
|
errors = []
|
||||||
|
monkeypatch.setattr(msteams_module, "MSTEAMS_AVAILABLE", False)
|
||||||
|
monkeypatch.setattr(msteams_module.logger, "error", lambda message, *args: errors.append(message.format(*args)))
|
||||||
|
|
||||||
|
await ch.start()
|
||||||
|
|
||||||
|
assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_msteams_default_config_includes_restart_notify_fields():
|
||||||
|
cfg = MSTeamsChannel.default_config()
|
||||||
|
|
||||||
|
assert cfg["restartNotifyEnabled"] is False
|
||||||
|
assert "restartNotifyPreMessage" in cfg
|
||||||
|
assert "restartNotifyPostMessage" in cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_msteams_config_accepts_restart_notify_aliases():
|
||||||
|
cfg = MSTeamsConfig.model_validate(
|
||||||
|
{
|
||||||
|
"restartNotifyEnabled": True,
|
||||||
|
"restartNotifyPreMessage": "Restarting now.",
|
||||||
|
"restartNotifyPostMessage": "Back online.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cfg.restart_notify_enabled is True
|
||||||
|
assert cfg.restart_notify_pre_message == "Restarting now."
|
||||||
|
assert cfg.restart_notify_post_message == "Back online."
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import textwrap
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import tomllib
|
|
||||||
|
|
||||||
|
|
||||||
def test_source_checkout_import_uses_pyproject_version_without_metadata() -> None:
|
|
||||||
repo_root = Path(__file__).resolve().parents[1]
|
|
||||||
expected = tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8"))["project"][
|
|
||||||
"version"
|
|
||||||
]
|
|
||||||
script = textwrap.dedent(
|
|
||||||
f"""
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
|
|
||||||
sys.path.insert(0, {str(repo_root)!r})
|
|
||||||
fake = types.ModuleType("nanobot.nanobot")
|
|
||||||
fake.Nanobot = object
|
|
||||||
fake.RunResult = object
|
|
||||||
sys.modules["nanobot.nanobot"] = fake
|
|
||||||
|
|
||||||
import nanobot
|
|
||||||
|
|
||||||
print(nanobot.__version__)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
proc = subprocess.run(
|
|
||||||
[sys.executable, "-S", "-c", script],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert proc.returncode == 0, proc.stderr
|
|
||||||
assert proc.stdout.strip() == expected
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import inspect
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_persisted_blocks_truncate_text_shadowing_regression() -> None:
|
|
||||||
"""Regression: avoid bool param shadowing imported truncate_text.
|
|
||||||
|
|
||||||
Buggy behavior (historical):
|
|
||||||
- loop.py imports `truncate_text` from helpers
|
|
||||||
- `_sanitize_persisted_blocks(..., truncate_text: bool=...)` uses same name
|
|
||||||
- when called with `truncate_text=True`, function body executes `truncate_text(text, ...)`
|
|
||||||
which resolves to bool and raises `TypeError: 'bool' object is not callable`.
|
|
||||||
|
|
||||||
This test asserts the fixed API exists and truncation works without raising.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
|
|
||||||
sig = inspect.signature(AgentLoop._sanitize_persisted_blocks)
|
|
||||||
assert "should_truncate_text" in sig.parameters
|
|
||||||
assert "truncate_text" not in sig.parameters
|
|
||||||
|
|
||||||
dummy = SimpleNamespace(max_tool_result_chars=5)
|
|
||||||
content = [{"type": "text", "text": "0123456789"}]
|
|
||||||
|
|
||||||
out = AgentLoop._sanitize_persisted_blocks(dummy, content, should_truncate_text=True)
|
|
||||||
assert isinstance(out, list)
|
|
||||||
assert out and out[0]["type"] == "text"
|
|
||||||
assert isinstance(out[0]["text"], str)
|
|
||||||
assert out[0]["text"] != content[0]["text"]
|
|
||||||
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
"""Tests for advanced EditFileTool enhancements inspired by claude-code:
|
|
||||||
- Delete-line newline cleanup
|
|
||||||
- Smart quote normalization (curly ↔ straight)
|
|
||||||
- Quote style preservation in replacements
|
|
||||||
- Indentation preservation when fallback match is trimmed
|
|
||||||
- Trailing whitespace stripping for new_text
|
|
||||||
- File size protection
|
|
||||||
- Stale detection with content-equality fallback
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, _find_match
|
|
||||||
from nanobot.agent.tools import file_state
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clear_file_state():
|
|
||||||
file_state.clear()
|
|
||||||
yield
|
|
||||||
file_state.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Delete-line newline cleanup
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteLineCleanup:
|
|
||||||
"""When new_text='' and deleting a line, trailing newline should be consumed."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_line_consumes_trailing_newline(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "a.py"
|
|
||||||
f.write_text("line1\nline2\nline3\n", encoding="utf-8")
|
|
||||||
result = await tool.execute(path=str(f), old_text="line2", new_text="")
|
|
||||||
assert "Successfully" in result
|
|
||||||
content = f.read_text()
|
|
||||||
# Should not leave a blank line where line2 was
|
|
||||||
assert content == "line1\nline3\n"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_line_with_explicit_newline_in_old_text(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "a.py"
|
|
||||||
f.write_text("line1\nline2\nline3\n", encoding="utf-8")
|
|
||||||
result = await tool.execute(path=str(f), old_text="line2\n", new_text="")
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert f.read_text() == "line1\nline3\n"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_preserves_content_when_not_trailing_newline(self, tool, tmp_path):
|
|
||||||
"""Deleting a word mid-line should not consume extra characters."""
|
|
||||||
f = tmp_path / "a.py"
|
|
||||||
f.write_text("hello world here\n", encoding="utf-8")
|
|
||||||
result = await tool.execute(path=str(f), old_text="world ", new_text="")
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert f.read_text() == "hello here\n"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Smart quote normalization
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestSmartQuoteNormalization:
|
|
||||||
"""_find_match should handle curly ↔ straight quote fallback."""
|
|
||||||
|
|
||||||
def test_curly_double_quotes_match_straight(self):
|
|
||||||
content = 'She said \u201chello\u201d to him'
|
|
||||||
old_text = 'She said "hello" to him'
|
|
||||||
match, count = _find_match(content, old_text)
|
|
||||||
assert match is not None
|
|
||||||
assert count == 1
|
|
||||||
# Returned match should be the ORIGINAL content with curly quotes
|
|
||||||
assert "\u201c" in match
|
|
||||||
|
|
||||||
def test_curly_single_quotes_match_straight(self):
|
|
||||||
content = "it\u2019s a test"
|
|
||||||
old_text = "it's a test"
|
|
||||||
match, count = _find_match(content, old_text)
|
|
||||||
assert match is not None
|
|
||||||
assert count == 1
|
|
||||||
assert "\u2019" in match
|
|
||||||
|
|
||||||
def test_straight_matches_curly_in_old_text(self):
|
|
||||||
content = 'x = "hello"'
|
|
||||||
old_text = 'x = \u201chello\u201d'
|
|
||||||
match, count = _find_match(content, old_text)
|
|
||||||
assert match is not None
|
|
||||||
assert count == 1
|
|
||||||
|
|
||||||
def test_exact_match_still_preferred_over_quote_normalization(self):
|
|
||||||
content = 'x = "hello"'
|
|
||||||
old_text = 'x = "hello"'
|
|
||||||
match, count = _find_match(content, old_text)
|
|
||||||
assert match == old_text
|
|
||||||
assert count == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestQuoteStylePreservation:
|
|
||||||
"""When quote-normalized matching occurs, replacement should preserve actual quote style."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_replacement_preserves_curly_double_quotes(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "quotes.txt"
|
|
||||||
f.write_text('message = “hello”\n', encoding="utf-8")
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(f),
|
|
||||||
old_text='message = "hello"',
|
|
||||||
new_text='message = "goodbye"',
|
|
||||||
)
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert f.read_text(encoding="utf-8") == 'message = “goodbye”\n'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_replacement_preserves_curly_apostrophe(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "apostrophe.txt"
|
|
||||||
f.write_text("it’s fine\n", encoding="utf-8")
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(f),
|
|
||||||
old_text="it's fine",
|
|
||||||
new_text="it's better",
|
|
||||||
)
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert f.read_text(encoding="utf-8") == "it’s better\n"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Indentation preservation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestIndentationPreservation:
|
|
||||||
"""Replacement should keep outer indentation when trim fallback matched."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trim_fallback_preserves_outer_indentation(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "indent.py"
|
|
||||||
f.write_text(
|
|
||||||
"if True:\n"
|
|
||||||
" def foo():\n"
|
|
||||||
" pass\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(f),
|
|
||||||
old_text="def foo():\n pass",
|
|
||||||
new_text="def bar():\n return 1",
|
|
||||||
)
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert f.read_text(encoding="utf-8") == (
|
|
||||||
"if True:\n"
|
|
||||||
" def bar():\n"
|
|
||||||
" return 1\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Failure diagnostics
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestEditDiagnostics:
|
|
||||||
"""Failure paths should offer actionable hints."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ambiguous_match_reports_candidate_lines(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "dup.py"
|
|
||||||
f.write_text("aaa\nbbb\naaa\nbbb\n", encoding="utf-8")
|
|
||||||
result = await tool.execute(path=str(f), old_text="aaa\nbbb", new_text="xxx")
|
|
||||||
assert "appears 2 times" in result.lower()
|
|
||||||
assert "line 1" in result.lower()
|
|
||||||
assert "line 3" in result.lower()
|
|
||||||
assert "replace_all=true" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_not_found_reports_whitespace_hint(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "space.py"
|
|
||||||
f.write_text("value = 1\n", encoding="utf-8")
|
|
||||||
result = await tool.execute(path=str(f), old_text="value = 1", new_text="value = 2")
|
|
||||||
assert "Error" in result
|
|
||||||
assert "whitespace" in result.lower()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_not_found_reports_case_hint(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "case.py"
|
|
||||||
f.write_text("HelloWorld\n", encoding="utf-8")
|
|
||||||
result = await tool.execute(path=str(f), old_text="helloworld", new_text="goodbye")
|
|
||||||
assert "Error" in result
|
|
||||||
assert "letter case differs" in result.lower()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Advanced fallback replacement behavior
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdvancedReplaceAll:
|
|
||||||
"""replace_all should work correctly for fallback-based matches too."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_replace_all_preserves_each_match_indentation(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "indent_multi.py"
|
|
||||||
f.write_text(
|
|
||||||
"if a:\n"
|
|
||||||
" def foo():\n"
|
|
||||||
" pass\n"
|
|
||||||
"if b:\n"
|
|
||||||
" def foo():\n"
|
|
||||||
" pass\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(f),
|
|
||||||
old_text="def foo():\n pass",
|
|
||||||
new_text="def bar():\n return 1",
|
|
||||||
replace_all=True,
|
|
||||||
)
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert f.read_text(encoding="utf-8") == (
|
|
||||||
"if a:\n"
|
|
||||||
" def bar():\n"
|
|
||||||
" return 1\n"
|
|
||||||
"if b:\n"
|
|
||||||
" def bar():\n"
|
|
||||||
" return 1\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trim_and_quote_fallback_match_succeeds(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "quote_indent.py"
|
|
||||||
f.write_text(" message = “hello”\n", encoding="utf-8")
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(f),
|
|
||||||
old_text='message = "hello"',
|
|
||||||
new_text='message = "goodbye"',
|
|
||||||
)
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert f.read_text(encoding="utf-8") == " message = “goodbye”\n"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Advanced fallback replacement behavior
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdvancedReplaceAll:
|
|
||||||
"""replace_all should work correctly for fallback-based matches too."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_replace_all_preserves_each_match_indentation(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "indent_multi.py"
|
|
||||||
f.write_text(
|
|
||||||
"if a:\n"
|
|
||||||
" def foo():\n"
|
|
||||||
" pass\n"
|
|
||||||
"if b:\n"
|
|
||||||
" def foo():\n"
|
|
||||||
" pass\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(f),
|
|
||||||
old_text="def foo():\n pass",
|
|
||||||
new_text="def bar():\n return 1",
|
|
||||||
replace_all=True,
|
|
||||||
)
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert f.read_text(encoding="utf-8") == (
|
|
||||||
"if a:\n"
|
|
||||||
" def bar():\n"
|
|
||||||
" return 1\n"
|
|
||||||
"if b:\n"
|
|
||||||
" def bar():\n"
|
|
||||||
" return 1\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trim_and_quote_fallback_match_succeeds(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "quote_indent.py"
|
|
||||||
f.write_text(" message = “hello”\n", encoding="utf-8")
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(f),
|
|
||||||
old_text='message = "hello"',
|
|
||||||
new_text='message = "goodbye"',
|
|
||||||
)
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert f.read_text(encoding="utf-8") == " message = “goodbye”\n"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Trailing whitespace stripping on new_text
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestTrailingWhitespaceStrip:
|
|
||||||
"""new_text trailing whitespace should be stripped (except .md files)."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_strips_trailing_whitespace_from_new_text(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "a.py"
|
|
||||||
f.write_text("x = 1\n", encoding="utf-8")
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(f), old_text="x = 1", new_text="x = 2 \ny = 3 ",
|
|
||||||
)
|
|
||||||
assert "Successfully" in result
|
|
||||||
content = f.read_text()
|
|
||||||
assert "x = 2\ny = 3\n" == content
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_preserves_trailing_whitespace_in_markdown(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "doc.md"
|
|
||||||
f.write_text("# Title\n", encoding="utf-8")
|
|
||||||
# Markdown uses trailing double-space for line breaks
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(f), old_text="# Title", new_text="# Title \nSubtitle ",
|
|
||||||
)
|
|
||||||
assert "Successfully" in result
|
|
||||||
content = f.read_text()
|
|
||||||
# Trailing spaces should be preserved for markdown
|
|
||||||
assert "Title " in content
|
|
||||||
assert "Subtitle " in content
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# File size protection
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestFileSizeProtection:
|
|
||||||
"""Editing extremely large files should be rejected."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_rejects_file_over_size_limit(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "huge.txt"
|
|
||||||
f.write_text("x", encoding="utf-8")
|
|
||||||
# Monkey-patch the file size check by creating a stat mock
|
|
||||||
original_stat = f.stat
|
|
||||||
|
|
||||||
class FakeStat:
|
|
||||||
def __init__(self, real_stat):
|
|
||||||
self._real = real_stat
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
return getattr(self._real, name)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def st_size(self):
|
|
||||||
return 2 * 1024 * 1024 * 1024 # 2 GiB
|
|
||||||
|
|
||||||
import unittest.mock
|
|
||||||
with unittest.mock.patch.object(type(f), 'stat', return_value=FakeStat(f.stat())):
|
|
||||||
result = await tool.execute(path=str(f), old_text="x", new_text="y")
|
|
||||||
assert "Error" in result
|
|
||||||
assert "too large" in result.lower() or "size" in result.lower()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Stale detection with content-equality fallback
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestStaleDetectionContentFallback:
|
|
||||||
"""When mtime changed but file content is unchanged, edit should proceed without warning."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def read_tool(self, tmp_path):
|
|
||||||
return ReadFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def edit_tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mtime_bump_same_content_no_warning(self, read_tool, edit_tool, tmp_path):
|
|
||||||
f = tmp_path / "a.py"
|
|
||||||
f.write_text("hello world", encoding="utf-8")
|
|
||||||
await read_tool.execute(path=str(f))
|
|
||||||
|
|
||||||
# Touch the file to bump mtime without changing content
|
|
||||||
time.sleep(0.05)
|
|
||||||
original_content = f.read_text()
|
|
||||||
f.write_text(original_content, encoding="utf-8")
|
|
||||||
|
|
||||||
result = await edit_tool.execute(path=str(f), old_text="world", new_text="earth")
|
|
||||||
assert "Successfully" in result
|
|
||||||
# Should NOT warn about modification since content is the same
|
|
||||||
assert "modified" not in result.lower()
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"""Tests for EditFileTool enhancements: read-before-edit tracking, path suggestions,
|
|
||||||
.ipynb detection, and create-file semantics."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
|
|
||||||
from nanobot.agent.tools import file_state
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clear_file_state():
|
|
||||||
"""Reset global read-state between tests."""
|
|
||||||
file_state.clear()
|
|
||||||
yield
|
|
||||||
file_state.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Read-before-edit tracking
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestEditReadTracking:
|
|
||||||
"""edit_file should warn when file hasn't been read first."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def read_tool(self, tmp_path):
|
|
||||||
return ReadFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def edit_tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_edit_warns_if_file_not_read_first(self, edit_tool, tmp_path):
|
|
||||||
f = tmp_path / "a.py"
|
|
||||||
f.write_text("hello world", encoding="utf-8")
|
|
||||||
result = await edit_tool.execute(path=str(f), old_text="world", new_text="earth")
|
|
||||||
# Should still succeed but include a warning
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert "not been read" in result.lower() or "warning" in result.lower()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_edit_succeeds_cleanly_after_read(self, read_tool, edit_tool, tmp_path):
|
|
||||||
f = tmp_path / "a.py"
|
|
||||||
f.write_text("hello world", encoding="utf-8")
|
|
||||||
await read_tool.execute(path=str(f))
|
|
||||||
result = await edit_tool.execute(path=str(f), old_text="world", new_text="earth")
|
|
||||||
assert "Successfully" in result
|
|
||||||
# No warning when file was read first
|
|
||||||
assert "not been read" not in result.lower()
|
|
||||||
assert f.read_text() == "hello earth"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_edit_warns_if_file_modified_since_read(self, read_tool, edit_tool, tmp_path):
|
|
||||||
f = tmp_path / "a.py"
|
|
||||||
f.write_text("hello world", encoding="utf-8")
|
|
||||||
await read_tool.execute(path=str(f))
|
|
||||||
# External modification
|
|
||||||
f.write_text("hello universe", encoding="utf-8")
|
|
||||||
result = await edit_tool.execute(path=str(f), old_text="universe", new_text="earth")
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert "modified" in result.lower() or "warning" in result.lower()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Create-file semantics
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestEditCreateFile:
|
|
||||||
"""edit_file with old_text='' creates new file if not exists."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_new_file_with_empty_old_text(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "subdir" / "new.py"
|
|
||||||
result = await tool.execute(path=str(f), old_text="", new_text="print('hi')")
|
|
||||||
assert "created" in result.lower() or "Successfully" in result
|
|
||||||
assert f.exists()
|
|
||||||
assert f.read_text() == "print('hi')"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_fails_if_file_already_exists_and_not_empty(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "existing.py"
|
|
||||||
f.write_text("existing content", encoding="utf-8")
|
|
||||||
result = await tool.execute(path=str(f), old_text="", new_text="new content")
|
|
||||||
assert "Error" in result or "already exists" in result.lower()
|
|
||||||
# File should be unchanged
|
|
||||||
assert f.read_text() == "existing content"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_succeeds_if_file_exists_but_empty(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "empty.py"
|
|
||||||
f.write_text("", encoding="utf-8")
|
|
||||||
result = await tool.execute(path=str(f), old_text="", new_text="print('hi')")
|
|
||||||
assert "Successfully" in result
|
|
||||||
assert f.read_text() == "print('hi')"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# .ipynb detection
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestEditIpynbDetection:
|
|
||||||
"""edit_file should refuse .ipynb and suggest notebook_edit."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ipynb_rejected_with_suggestion(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "analysis.ipynb"
|
|
||||||
f.write_text('{"cells": []}', encoding="utf-8")
|
|
||||||
result = await tool.execute(path=str(f), old_text="x", new_text="y")
|
|
||||||
assert "notebook" in result.lower()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Path suggestion on not-found
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestEditPathSuggestion:
|
|
||||||
"""edit_file should suggest similar paths on not-found."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return EditFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_suggests_similar_filename(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "config.py"
|
|
||||||
f.write_text("x = 1", encoding="utf-8")
|
|
||||||
# Typo: conifg.py
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(tmp_path / "conifg.py"), old_text="x = 1", new_text="x = 2",
|
|
||||||
)
|
|
||||||
assert "Error" in result
|
|
||||||
assert "config.py" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_shows_cwd_in_error(self, tool, tmp_path):
|
|
||||||
result = await tool.execute(
|
|
||||||
path=str(tmp_path / "nonexistent.py"), old_text="a", new_text="b",
|
|
||||||
)
|
|
||||||
assert "Error" in result
|
|
||||||
@@ -43,34 +43,3 @@ async def test_exec_path_append_preserves_system_path():
|
|||||||
tool = ExecTool(path_append="/opt/custom/bin")
|
tool = ExecTool(path_append="/opt/custom/bin")
|
||||||
result = await tool.execute(command="ls /")
|
result = await tool.execute(command="ls /")
|
||||||
assert "Exit code: 0" in result
|
assert "Exit code: 0" in result
|
||||||
|
|
||||||
|
|
||||||
@_UNIX_ONLY
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exec_allowed_env_keys_passthrough(monkeypatch):
|
|
||||||
"""Env vars listed in allowed_env_keys should be visible to commands."""
|
|
||||||
monkeypatch.setenv("MY_CUSTOM_VAR", "hello-from-config")
|
|
||||||
tool = ExecTool(allowed_env_keys=["MY_CUSTOM_VAR"])
|
|
||||||
result = await tool.execute(command="printenv MY_CUSTOM_VAR")
|
|
||||||
assert "hello-from-config" in result
|
|
||||||
|
|
||||||
|
|
||||||
@_UNIX_ONLY
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exec_allowed_env_keys_does_not_leak_others(monkeypatch):
|
|
||||||
"""Env vars NOT in allowed_env_keys should still be blocked."""
|
|
||||||
monkeypatch.setenv("MY_CUSTOM_VAR", "hello-from-config")
|
|
||||||
monkeypatch.setenv("MY_SECRET_VAR", "secret-value")
|
|
||||||
tool = ExecTool(allowed_env_keys=["MY_CUSTOM_VAR"])
|
|
||||||
result = await tool.execute(command="printenv MY_SECRET_VAR")
|
|
||||||
assert "secret-value" not in result
|
|
||||||
|
|
||||||
|
|
||||||
@_UNIX_ONLY
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exec_allowed_env_keys_missing_var_ignored(monkeypatch):
|
|
||||||
"""If an allowed key is not set in the parent process, it should be silently skipped."""
|
|
||||||
monkeypatch.delenv("NONEXISTENT_VAR_12345", raising=False)
|
|
||||||
tool = ExecTool(allowed_env_keys=["NONEXISTENT_VAR_12345"])
|
|
||||||
result = await tool.execute(command="printenv NONEXISTENT_VAR_12345")
|
|
||||||
assert "Exit code: 1" in result
|
|
||||||
|
|||||||
@@ -5,18 +5,12 @@ strategy, and sandbox behaviour per platform — without actually running
|
|||||||
platform-specific binaries (all subprocess calls are mocked).
|
platform-specific binaries (all subprocess calls are mocked).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.tools.shell import ExecTool
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
|
|
||||||
_WINDOWS_ENV_KEYS = {
|
|
||||||
"APPDATA", "LOCALAPPDATA", "ProgramData",
|
|
||||||
"ProgramFiles", "ProgramFiles(x86)", "ProgramW6432",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _build_env
|
# _build_env
|
||||||
@@ -27,10 +21,7 @@ class TestBuildEnvUnix:
|
|||||||
def test_expected_keys(self):
|
def test_expected_keys(self):
|
||||||
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
|
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
|
||||||
env = ExecTool()._build_env()
|
env = ExecTool()._build_env()
|
||||||
expected = {"HOME", "LANG", "TERM"}
|
assert set(env) == {"HOME", "LANG", "TERM"}
|
||||||
assert expected <= set(env)
|
|
||||||
if sys.platform != "win32":
|
|
||||||
assert set(env) == expected
|
|
||||||
|
|
||||||
def test_home_from_environ(self, monkeypatch):
|
def test_home_from_environ(self, monkeypatch):
|
||||||
monkeypatch.setenv("HOME", "/Users/dev")
|
monkeypatch.setenv("HOME", "/Users/dev")
|
||||||
@@ -54,7 +45,6 @@ class TestBuildEnvWindows:
|
|||||||
_EXPECTED_KEYS = {
|
_EXPECTED_KEYS = {
|
||||||
"SYSTEMROOT", "COMSPEC", "USERPROFILE", "HOMEDRIVE",
|
"SYSTEMROOT", "COMSPEC", "USERPROFILE", "HOMEDRIVE",
|
||||||
"HOMEPATH", "TEMP", "TMP", "PATHEXT", "PATH",
|
"HOMEPATH", "TEMP", "TMP", "PATHEXT", "PATH",
|
||||||
*_WINDOWS_ENV_KEYS,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_expected_keys(self):
|
def test_expected_keys(self):
|
||||||
|
|||||||
@@ -67,118 +67,3 @@ async def test_exec_blocks_chained_internal_url():
|
|||||||
command="echo start && curl http://169.254.169.254/latest/meta-data/ && echo done"
|
command="echo start && curl http://169.254.169.254/latest/meta-data/ && echo done"
|
||||||
)
|
)
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
|
|
||||||
|
|
||||||
# --- #2989: block writes to nanobot internal state files -----------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"command",
|
|
||||||
[
|
|
||||||
"cat foo >> history.jsonl",
|
|
||||||
"echo '{}' > history.jsonl",
|
|
||||||
"echo '{}' > memory/history.jsonl",
|
|
||||||
"echo '{}' > ./workspace/memory/history.jsonl",
|
|
||||||
"tee -a history.jsonl < foo",
|
|
||||||
"tee history.jsonl",
|
|
||||||
"cp /tmp/fake.jsonl history.jsonl",
|
|
||||||
"mv backup.jsonl memory/history.jsonl",
|
|
||||||
"dd if=/dev/zero of=memory/history.jsonl",
|
|
||||||
"sed -i 's/old/new/' history.jsonl",
|
|
||||||
"echo x > .dream_cursor",
|
|
||||||
"cp /tmp/x memory/.dream_cursor",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_exec_blocks_writes_to_history_jsonl(command):
|
|
||||||
"""Direct writes to history.jsonl / .dream_cursor must be blocked (#2989)."""
|
|
||||||
tool = ExecTool()
|
|
||||||
result = tool._guard_command(command, "/tmp")
|
|
||||||
assert result is not None
|
|
||||||
assert "dangerous pattern" in result.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"command",
|
|
||||||
[
|
|
||||||
"cat history.jsonl",
|
|
||||||
"wc -l history.jsonl",
|
|
||||||
"tail -n 5 history.jsonl",
|
|
||||||
"grep foo history.jsonl",
|
|
||||||
"cp history.jsonl /tmp/history.backup",
|
|
||||||
"ls memory/",
|
|
||||||
"echo history.jsonl",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_exec_allows_reads_of_history_jsonl(command):
|
|
||||||
"""Read-only access to history.jsonl must still be allowed."""
|
|
||||||
tool = ExecTool()
|
|
||||||
result = tool._guard_command(command, "/tmp")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- #2826: working_dir must not escape the configured workspace ---------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exec_blocks_working_dir_outside_workspace(tmp_path):
|
|
||||||
"""An LLM-supplied working_dir outside the workspace must be rejected."""
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
result = await tool.execute(command="rm calendar.ics", working_dir="/etc")
|
|
||||||
assert "outside the configured workspace" in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exec_blocks_absolute_rm_via_hijacked_working_dir(tmp_path):
|
|
||||||
"""Regression for #2826: `rm /abs/path` via working_dir hijack."""
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
victim_dir = tmp_path / "outside"
|
|
||||||
victim_dir.mkdir()
|
|
||||||
victim = victim_dir / "file.ics"
|
|
||||||
victim.write_text("data")
|
|
||||||
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
|
||||||
result = await tool.execute(
|
|
||||||
command=f"rm {victim}",
|
|
||||||
working_dir=str(victim_dir),
|
|
||||||
)
|
|
||||||
assert "outside the configured workspace" in result
|
|
||||||
assert victim.exists(), "victim file must not have been deleted"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exec_allows_working_dir_within_workspace(tmp_path):
|
|
||||||
"""A working_dir that is a subdirectory of the workspace is fine."""
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
subdir = workspace / "project"
|
|
||||||
subdir.mkdir(parents=True)
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5)
|
|
||||||
result = await tool.execute(command="echo ok", working_dir=str(subdir))
|
|
||||||
assert "ok" in result
|
|
||||||
assert "outside the configured workspace" not in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exec_allows_working_dir_equal_to_workspace(tmp_path):
|
|
||||||
"""Passing working_dir equal to the workspace root must be allowed."""
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5)
|
|
||||||
result = await tool.execute(command="echo ok", working_dir=str(workspace))
|
|
||||||
assert "ok" in result
|
|
||||||
assert "outside the configured workspace" not in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exec_ignores_workspace_check_when_not_restricted(tmp_path):
|
|
||||||
"""Without restrict_to_workspace, the LLM may still choose any working_dir."""
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
other = tmp_path / "other"
|
|
||||||
other.mkdir()
|
|
||||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=False, timeout=5)
|
|
||||||
result = await tool.execute(command="echo ok", working_dir=str(other))
|
|
||||||
assert "ok" in result
|
|
||||||
assert "outside the configured workspace" not in result
|
|
||||||
|
|||||||
+47
-365
@@ -7,12 +7,7 @@ from types import ModuleType, SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.tools.mcp import (
|
from nanobot.agent.tools.mcp import MCPToolWrapper, connect_mcp_servers
|
||||||
MCPResourceWrapper,
|
|
||||||
MCPPromptWrapper,
|
|
||||||
MCPToolWrapper,
|
|
||||||
connect_mcp_servers,
|
|
||||||
)
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.config.schema import MCPServerConfig
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
|
||||||
@@ -22,16 +17,6 @@ class _FakeTextContent:
|
|||||||
self.text = text
|
self.text = text
|
||||||
|
|
||||||
|
|
||||||
class _FakeTextResourceContents:
|
|
||||||
def __init__(self, text: str) -> None:
|
|
||||||
self.text = text
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeBlobResourceContents:
|
|
||||||
def __init__(self, blob: bytes) -> None:
|
|
||||||
self.blob = blob
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def fake_mcp_runtime() -> dict[str, object | None]:
|
def fake_mcp_runtime() -> dict[str, object | None]:
|
||||||
return {"session": None}
|
return {"session": None}
|
||||||
@@ -42,11 +27,7 @@ def _fake_mcp_module(
|
|||||||
monkeypatch: pytest.MonkeyPatch, fake_mcp_runtime: dict[str, object | None]
|
monkeypatch: pytest.MonkeyPatch, fake_mcp_runtime: dict[str, object | None]
|
||||||
) -> None:
|
) -> None:
|
||||||
mod = ModuleType("mcp")
|
mod = ModuleType("mcp")
|
||||||
mod.types = SimpleNamespace(
|
mod.types = SimpleNamespace(TextContent=_FakeTextContent)
|
||||||
TextContent=_FakeTextContent,
|
|
||||||
TextResourceContents=_FakeTextResourceContents,
|
|
||||||
BlobResourceContents=_FakeBlobResourceContents,
|
|
||||||
)
|
|
||||||
|
|
||||||
class _FakeStdioServerParameters:
|
class _FakeStdioServerParameters:
|
||||||
def __init__(self, command: str, args: list[str], env: dict | None = None) -> None:
|
def __init__(self, command: str, args: list[str], env: dict | None = None) -> None:
|
||||||
@@ -93,18 +74,6 @@ def _fake_mcp_module(
|
|||||||
monkeypatch.setitem(sys.modules, "mcp.client.sse", sse_mod)
|
monkeypatch.setitem(sys.modules, "mcp.client.sse", sse_mod)
|
||||||
monkeypatch.setitem(sys.modules, "mcp.client.streamable_http", streamable_http_mod)
|
monkeypatch.setitem(sys.modules, "mcp.client.streamable_http", streamable_http_mod)
|
||||||
|
|
||||||
shared_mod = ModuleType("mcp.shared")
|
|
||||||
exc_mod = ModuleType("mcp.shared.exceptions")
|
|
||||||
|
|
||||||
class _FakeMcpError(Exception):
|
|
||||||
def __init__(self, code: int = -1, message: str = "error"):
|
|
||||||
self.error = SimpleNamespace(code=code, message=message)
|
|
||||||
super().__init__(message)
|
|
||||||
|
|
||||||
exc_mod.McpError = _FakeMcpError
|
|
||||||
monkeypatch.setitem(sys.modules, "mcp.shared", shared_mod)
|
|
||||||
monkeypatch.setitem(sys.modules, "mcp.shared.exceptions", exc_mod)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
|
def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
|
||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
@@ -271,11 +240,15 @@ async def test_connect_mcp_servers_enabled_tools_supports_raw_names(
|
|||||||
) -> None:
|
) -> None:
|
||||||
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
|
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks = await connect_mcp_servers(
|
stack = AsyncExitStack()
|
||||||
{"test": MCPServerConfig(command="fake", enabled_tools=["demo"])},
|
await stack.__aenter__()
|
||||||
registry,
|
try:
|
||||||
)
|
await connect_mcp_servers(
|
||||||
for stack in stacks.values():
|
{"test": MCPServerConfig(command="fake", enabled_tools=["demo"])},
|
||||||
|
registry,
|
||||||
|
stack,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
|
|
||||||
assert registry.tool_names == ["mcp_test_demo"]
|
assert registry.tool_names == ["mcp_test_demo"]
|
||||||
@@ -287,11 +260,15 @@ async def test_connect_mcp_servers_enabled_tools_defaults_to_all(
|
|||||||
) -> None:
|
) -> None:
|
||||||
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
|
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks = await connect_mcp_servers(
|
stack = AsyncExitStack()
|
||||||
{"test": MCPServerConfig(command="fake")},
|
await stack.__aenter__()
|
||||||
registry,
|
try:
|
||||||
)
|
await connect_mcp_servers(
|
||||||
for stack in stacks.values():
|
{"test": MCPServerConfig(command="fake")},
|
||||||
|
registry,
|
||||||
|
stack,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
|
|
||||||
assert registry.tool_names == ["mcp_test_demo", "mcp_test_other"]
|
assert registry.tool_names == ["mcp_test_demo", "mcp_test_other"]
|
||||||
@@ -303,11 +280,15 @@ async def test_connect_mcp_servers_enabled_tools_supports_wrapped_names(
|
|||||||
) -> None:
|
) -> None:
|
||||||
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
|
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks = await connect_mcp_servers(
|
stack = AsyncExitStack()
|
||||||
{"test": MCPServerConfig(command="fake", enabled_tools=["mcp_test_demo"])},
|
await stack.__aenter__()
|
||||||
registry,
|
try:
|
||||||
)
|
await connect_mcp_servers(
|
||||||
for stack in stacks.values():
|
{"test": MCPServerConfig(command="fake", enabled_tools=["mcp_test_demo"])},
|
||||||
|
registry,
|
||||||
|
stack,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
|
|
||||||
assert registry.tool_names == ["mcp_test_demo"]
|
assert registry.tool_names == ["mcp_test_demo"]
|
||||||
@@ -319,11 +300,15 @@ async def test_connect_mcp_servers_enabled_tools_empty_list_registers_none(
|
|||||||
) -> None:
|
) -> None:
|
||||||
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
|
fake_mcp_runtime["session"] = _make_fake_session(["demo", "other"])
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks = await connect_mcp_servers(
|
stack = AsyncExitStack()
|
||||||
{"test": MCPServerConfig(command="fake", enabled_tools=[])},
|
await stack.__aenter__()
|
||||||
registry,
|
try:
|
||||||
)
|
await connect_mcp_servers(
|
||||||
for stack in stacks.values():
|
{"test": MCPServerConfig(command="fake", enabled_tools=[])},
|
||||||
|
registry,
|
||||||
|
stack,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
|
|
||||||
assert registry.tool_names == []
|
assert registry.tool_names == []
|
||||||
@@ -342,11 +327,15 @@ async def test_connect_mcp_servers_enabled_tools_warns_on_unknown_entries(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.warning", _warning)
|
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.warning", _warning)
|
||||||
|
|
||||||
stacks = await connect_mcp_servers(
|
stack = AsyncExitStack()
|
||||||
{"test": MCPServerConfig(command="fake", enabled_tools=["unknown"])},
|
await stack.__aenter__()
|
||||||
registry,
|
try:
|
||||||
)
|
await connect_mcp_servers(
|
||||||
for stack in stacks.values():
|
{"test": MCPServerConfig(command="fake", enabled_tools=["unknown"])},
|
||||||
|
registry,
|
||||||
|
stack,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
|
|
||||||
assert registry.tool_names == []
|
assert registry.tool_names == []
|
||||||
@@ -354,310 +343,3 @@ async def test_connect_mcp_servers_enabled_tools_warns_on_unknown_entries(
|
|||||||
assert "enabledTools entries not found: unknown" in warnings[-1]
|
assert "enabledTools entries not found: unknown" in warnings[-1]
|
||||||
assert "Available raw names: demo" in warnings[-1]
|
assert "Available raw names: demo" in warnings[-1]
|
||||||
assert "Available wrapped names: mcp_test_demo" in warnings[-1]
|
assert "Available wrapped names: mcp_test_demo" in warnings[-1]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect_mcp_servers_logs_stdio_pollution_hint(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
messages: list[str] = []
|
|
||||||
|
|
||||||
def _error(message: str, *args: object) -> None:
|
|
||||||
messages.append(message.format(*args))
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def _broken_stdio_client(_params: object):
|
|
||||||
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
|
|
||||||
yield # pragma: no cover
|
|
||||||
|
|
||||||
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
|
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.error", _error)
|
|
||||||
|
|
||||||
registry = ToolRegistry()
|
|
||||||
stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry)
|
|
||||||
|
|
||||||
assert stacks == {}
|
|
||||||
assert messages
|
|
||||||
assert "stdio protocol pollution" in messages[-1]
|
|
||||||
assert "stdout" in messages[-1]
|
|
||||||
assert "stderr" in messages[-1]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
sessions = {"good": _make_fake_session(["demo"])}
|
|
||||||
|
|
||||||
class _SelectiveClientSession:
|
|
||||||
def __init__(self, read: object, _write: object) -> None:
|
|
||||||
self._session = sessions[read]
|
|
||||||
|
|
||||||
async def __aenter__(self) -> object:
|
|
||||||
return self._session
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def _selective_stdio_client(params: object):
|
|
||||||
if params.command == "bad":
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
yield params.command, object()
|
|
||||||
|
|
||||||
monkeypatch.setattr(sys.modules["mcp"], "ClientSession", _SelectiveClientSession)
|
|
||||||
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _selective_stdio_client)
|
|
||||||
|
|
||||||
registry = ToolRegistry()
|
|
||||||
stacks = await connect_mcp_servers(
|
|
||||||
{
|
|
||||||
"good": MCPServerConfig(command="good"),
|
|
||||||
"bad": MCPServerConfig(command="bad"),
|
|
||||||
},
|
|
||||||
registry,
|
|
||||||
)
|
|
||||||
for stack in stacks.values():
|
|
||||||
await stack.aclose()
|
|
||||||
|
|
||||||
assert registry.tool_names == ["mcp_good_demo"]
|
|
||||||
assert set(stacks) == {"good"}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# MCPResourceWrapper tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _make_resource_def(
|
|
||||||
name: str = "myres",
|
|
||||||
uri: str = "file:///tmp/data.txt",
|
|
||||||
description: str = "A test resource",
|
|
||||||
) -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(name=name, uri=uri, description=description)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_resource_wrapper(session: object, *, timeout: float = 0.1) -> MCPResourceWrapper:
|
|
||||||
return MCPResourceWrapper(session, "srv", _make_resource_def(), resource_timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resource_wrapper_properties() -> None:
|
|
||||||
wrapper = MCPResourceWrapper(None, "myserver", _make_resource_def())
|
|
||||||
assert wrapper.name == "mcp_myserver_resource_myres"
|
|
||||||
assert "[MCP Resource]" in wrapper.description
|
|
||||||
assert "A test resource" in wrapper.description
|
|
||||||
assert "file:///tmp/data.txt" in wrapper.description
|
|
||||||
assert wrapper.parameters == {"type": "object", "properties": {}, "required": []}
|
|
||||||
assert wrapper.read_only is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resource_wrapper_execute_returns_text() -> None:
|
|
||||||
async def read_resource(uri: str) -> object:
|
|
||||||
assert uri == "file:///tmp/data.txt"
|
|
||||||
return SimpleNamespace(
|
|
||||||
contents=[_FakeTextResourceContents("line1"), _FakeTextResourceContents("line2")]
|
|
||||||
)
|
|
||||||
|
|
||||||
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource))
|
|
||||||
result = await wrapper.execute()
|
|
||||||
assert result == "line1\nline2"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resource_wrapper_execute_handles_blob() -> None:
|
|
||||||
async def read_resource(uri: str) -> object:
|
|
||||||
return SimpleNamespace(contents=[_FakeBlobResourceContents(b"\x00\x01\x02")])
|
|
||||||
|
|
||||||
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource))
|
|
||||||
result = await wrapper.execute()
|
|
||||||
assert "[Binary resource: 3 bytes]" in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resource_wrapper_execute_handles_timeout() -> None:
|
|
||||||
async def read_resource(uri: str) -> object:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
return SimpleNamespace(contents=[])
|
|
||||||
|
|
||||||
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource), timeout=0.01)
|
|
||||||
result = await wrapper.execute()
|
|
||||||
assert result == "(MCP resource read timed out after 0.01s)"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resource_wrapper_execute_handles_error() -> None:
|
|
||||||
async def read_resource(uri: str) -> object:
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
|
|
||||||
wrapper = _make_resource_wrapper(SimpleNamespace(read_resource=read_resource))
|
|
||||||
result = await wrapper.execute()
|
|
||||||
assert result == "(MCP resource read failed: RuntimeError)"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# MCPPromptWrapper tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _make_prompt_def(
|
|
||||||
name: str = "myprompt",
|
|
||||||
description: str = "A test prompt",
|
|
||||||
arguments: list | None = None,
|
|
||||||
) -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(name=name, description=description, arguments=arguments)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_prompt_wrapper(session: object, *, timeout: float = 0.1) -> MCPPromptWrapper:
|
|
||||||
return MCPPromptWrapper(session, "srv", _make_prompt_def(), prompt_timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_wrapper_properties() -> None:
|
|
||||||
arg1 = SimpleNamespace(name="topic", required=True)
|
|
||||||
arg2 = SimpleNamespace(name="style", required=False)
|
|
||||||
wrapper = MCPPromptWrapper(None, "myserver", _make_prompt_def(arguments=[arg1, arg2]))
|
|
||||||
assert wrapper.name == "mcp_myserver_prompt_myprompt"
|
|
||||||
assert "[MCP Prompt]" in wrapper.description
|
|
||||||
assert "A test prompt" in wrapper.description
|
|
||||||
assert "workflow guide" in wrapper.description
|
|
||||||
assert wrapper.parameters["properties"]["topic"] == {"type": "string"}
|
|
||||||
assert wrapper.parameters["properties"]["style"] == {"type": "string"}
|
|
||||||
assert wrapper.parameters["required"] == ["topic"]
|
|
||||||
assert wrapper.read_only is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_wrapper_no_arguments() -> None:
|
|
||||||
wrapper = MCPPromptWrapper(None, "myserver", _make_prompt_def())
|
|
||||||
assert wrapper.parameters == {"type": "object", "properties": {}, "required": []}
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_wrapper_preserves_argument_descriptions() -> None:
|
|
||||||
arg = SimpleNamespace(name="topic", required=True, description="The subject to discuss")
|
|
||||||
wrapper = MCPPromptWrapper(None, "srv", _make_prompt_def(arguments=[arg]))
|
|
||||||
assert wrapper.parameters["properties"]["topic"] == {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The subject to discuss",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_prompt_wrapper_execute_returns_text() -> None:
|
|
||||||
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
|
||||||
assert name == "myprompt"
|
|
||||||
msg1 = SimpleNamespace(
|
|
||||||
role="user",
|
|
||||||
content=[_FakeTextContent("You are an expert on {{topic}}.")],
|
|
||||||
)
|
|
||||||
msg2 = SimpleNamespace(
|
|
||||||
role="assistant",
|
|
||||||
content=[_FakeTextContent("Understood. Ask me anything.")],
|
|
||||||
)
|
|
||||||
return SimpleNamespace(messages=[msg1, msg2])
|
|
||||||
|
|
||||||
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
|
|
||||||
result = await wrapper.execute(topic="AI")
|
|
||||||
assert "You are an expert on {{topic}}." in result
|
|
||||||
assert "Understood. Ask me anything." in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_prompt_wrapper_execute_handles_timeout() -> None:
|
|
||||||
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
return SimpleNamespace(messages=[])
|
|
||||||
|
|
||||||
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt), timeout=0.01)
|
|
||||||
result = await wrapper.execute()
|
|
||||||
assert result == "(MCP prompt call timed out after 0.01s)"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_prompt_wrapper_execute_handles_mcp_error() -> None:
|
|
||||||
from mcp.shared.exceptions import McpError
|
|
||||||
|
|
||||||
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
|
||||||
raise McpError(code=42, message="invalid argument")
|
|
||||||
|
|
||||||
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
|
|
||||||
result = await wrapper.execute()
|
|
||||||
assert "invalid argument" in result
|
|
||||||
assert "code 42" in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_prompt_wrapper_execute_handles_error() -> None:
|
|
||||||
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
|
|
||||||
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
|
|
||||||
result = await wrapper.execute()
|
|
||||||
assert result == "(MCP prompt call failed: RuntimeError)"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# connect_mcp_servers: resources + prompts integration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _make_fake_session_with_capabilities(
|
|
||||||
tool_names: list[str],
|
|
||||||
resource_names: list[str] | None = None,
|
|
||||||
prompt_names: list[str] | None = None,
|
|
||||||
) -> SimpleNamespace:
|
|
||||||
async def initialize() -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def list_tools() -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(tools=[_make_tool_def(name) for name in tool_names])
|
|
||||||
|
|
||||||
async def list_resources() -> SimpleNamespace:
|
|
||||||
resources = []
|
|
||||||
for rname in resource_names or []:
|
|
||||||
resources.append(
|
|
||||||
SimpleNamespace(
|
|
||||||
name=rname,
|
|
||||||
uri=f"file:///{rname}",
|
|
||||||
description=f"{rname} resource",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return SimpleNamespace(resources=resources)
|
|
||||||
|
|
||||||
async def list_prompts() -> SimpleNamespace:
|
|
||||||
prompts = []
|
|
||||||
for pname in prompt_names or []:
|
|
||||||
prompts.append(
|
|
||||||
SimpleNamespace(
|
|
||||||
name=pname,
|
|
||||||
description=f"{pname} prompt",
|
|
||||||
arguments=None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return SimpleNamespace(prompts=prompts)
|
|
||||||
|
|
||||||
return SimpleNamespace(
|
|
||||||
initialize=initialize,
|
|
||||||
list_tools=list_tools,
|
|
||||||
list_resources=list_resources,
|
|
||||||
list_prompts=list_prompts,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect_registers_resources_and_prompts(
|
|
||||||
fake_mcp_runtime: dict[str, object | None],
|
|
||||||
) -> None:
|
|
||||||
fake_mcp_runtime["session"] = _make_fake_session_with_capabilities(
|
|
||||||
tool_names=["tool_a"],
|
|
||||||
resource_names=["res_b"],
|
|
||||||
prompt_names=["prompt_c"],
|
|
||||||
)
|
|
||||||
registry = ToolRegistry()
|
|
||||||
stacks = await connect_mcp_servers(
|
|
||||||
{"test": MCPServerConfig(command="fake")},
|
|
||||||
registry,
|
|
||||||
)
|
|
||||||
for stack in stacks.values():
|
|
||||||
await stack.aclose()
|
|
||||||
|
|
||||||
assert "mcp_test_tool_a" in registry.tool_names
|
|
||||||
assert "mcp_test_resource_res_b" in registry.tool_names
|
|
||||||
assert "mcp_test_prompt_prompt_c" in registry.tool_names
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Test message tool suppress logic for final replies."""
|
"""Test message tool suppress logic for final replies."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
@@ -87,42 +86,6 @@ class TestMessageToolSuppressLogic:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert "Hello" in result.content
|
assert "Hello" in result.content
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_injected_followup_with_message_tool_does_not_emit_empty_fallback(
|
|
||||||
self, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
loop = _make_loop(tmp_path)
|
|
||||||
tool_call = ToolCallRequest(
|
|
||||||
id="call1", name="message",
|
|
||||||
arguments={"content": "Tool reply", "channel": "feishu", "chat_id": "chat123"},
|
|
||||||
)
|
|
||||||
calls = iter([
|
|
||||||
LLMResponse(content="First answer", tool_calls=[]),
|
|
||||||
LLMResponse(content="", tool_calls=[tool_call]),
|
|
||||||
LLMResponse(content="", tool_calls=[]),
|
|
||||||
LLMResponse(content="", tool_calls=[]),
|
|
||||||
LLMResponse(content="", tool_calls=[]),
|
|
||||||
])
|
|
||||||
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
|
|
||||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
|
||||||
|
|
||||||
sent: list[OutboundMessage] = []
|
|
||||||
mt = loop.tools.get("message")
|
|
||||||
if isinstance(mt, MessageTool):
|
|
||||||
mt.set_send_callback(AsyncMock(side_effect=lambda m: sent.append(m)))
|
|
||||||
|
|
||||||
pending_queue = asyncio.Queue()
|
|
||||||
await pending_queue.put(
|
|
||||||
InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="follow-up")
|
|
||||||
)
|
|
||||||
|
|
||||||
msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Start")
|
|
||||||
result = await loop._process_message(msg, pending_queue=pending_queue)
|
|
||||||
|
|
||||||
assert len(sent) == 1
|
|
||||||
assert sent[0].content == "Tool reply"
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
async def test_progress_hides_internal_reasoning(self, tmp_path: Path) -> None:
|
async def test_progress_hides_internal_reasoning(self, tmp_path: Path) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
tool_call = ToolCallRequest(id="call1", name="read_file", arguments={"path": "foo.txt"})
|
tool_call = ToolCallRequest(id="call1", name="read_file", arguments={"path": "foo.txt"})
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
"""Tests for NotebookEditTool — Jupyter .ipynb editing."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.tools.notebook import NotebookEditTool
|
|
||||||
|
|
||||||
|
|
||||||
def _make_notebook(cells: list[dict] | None = None, nbformat: int = 4, nbformat_minor: int = 5) -> dict:
|
|
||||||
"""Build a minimal valid .ipynb structure."""
|
|
||||||
return {
|
|
||||||
"nbformat": nbformat,
|
|
||||||
"nbformat_minor": nbformat_minor,
|
|
||||||
"metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}},
|
|
||||||
"cells": cells or [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _code_cell(source: str, cell_id: str | None = None) -> dict:
|
|
||||||
cell = {"cell_type": "code", "source": source, "metadata": {}, "outputs": [], "execution_count": None}
|
|
||||||
if cell_id:
|
|
||||||
cell["id"] = cell_id
|
|
||||||
return cell
|
|
||||||
|
|
||||||
|
|
||||||
def _md_cell(source: str, cell_id: str | None = None) -> dict:
|
|
||||||
cell = {"cell_type": "markdown", "source": source, "metadata": {}}
|
|
||||||
if cell_id:
|
|
||||||
cell["id"] = cell_id
|
|
||||||
return cell
|
|
||||||
|
|
||||||
|
|
||||||
def _write_nb(tmp_path, name: str, nb: dict) -> str:
|
|
||||||
p = tmp_path / name
|
|
||||||
p.write_text(json.dumps(nb), encoding="utf-8")
|
|
||||||
return str(p)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNotebookEdit:
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return NotebookEditTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_replace_cell_content(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("print('hello')"), _code_cell("x = 1")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=0, new_source="print('world')")
|
|
||||||
assert "Successfully" in result
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert saved["cells"][0]["source"] == "print('world')"
|
|
||||||
assert saved["cells"][1]["source"] == "x = 1"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_insert_cell_after_target(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("cell 0"), _code_cell("cell 1")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=0, new_source="inserted", edit_mode="insert")
|
|
||||||
assert "Successfully" in result
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert len(saved["cells"]) == 3
|
|
||||||
assert saved["cells"][0]["source"] == "cell 0"
|
|
||||||
assert saved["cells"][1]["source"] == "inserted"
|
|
||||||
assert saved["cells"][2]["source"] == "cell 1"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_cell(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("A"), _code_cell("B"), _code_cell("C")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=1, edit_mode="delete")
|
|
||||||
assert "Successfully" in result
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert len(saved["cells"]) == 2
|
|
||||||
assert saved["cells"][0]["source"] == "A"
|
|
||||||
assert saved["cells"][1]["source"] == "C"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_new_notebook_from_scratch(self, tool, tmp_path):
|
|
||||||
path = str(tmp_path / "new.ipynb")
|
|
||||||
result = await tool.execute(path=path, cell_index=0, new_source="# Hello", edit_mode="insert", cell_type="markdown")
|
|
||||||
assert "Successfully" in result or "created" in result.lower()
|
|
||||||
saved = json.loads((tmp_path / "new.ipynb").read_text())
|
|
||||||
assert saved["nbformat"] == 4
|
|
||||||
assert len(saved["cells"]) == 1
|
|
||||||
assert saved["cells"][0]["cell_type"] == "markdown"
|
|
||||||
assert saved["cells"][0]["source"] == "# Hello"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_invalid_cell_index_error(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("only cell")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=5, new_source="x")
|
|
||||||
assert "Error" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_non_ipynb_rejected(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "script.py"
|
|
||||||
f.write_text("pass")
|
|
||||||
result = await tool.execute(path=str(f), cell_index=0, new_source="x")
|
|
||||||
assert "Error" in result
|
|
||||||
assert ".ipynb" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_preserves_metadata_and_outputs(self, tool, tmp_path):
|
|
||||||
cell = _code_cell("old")
|
|
||||||
cell["outputs"] = [{"output_type": "stream", "text": "hello\n"}]
|
|
||||||
cell["execution_count"] = 42
|
|
||||||
nb = _make_notebook([cell])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
await tool.execute(path=path, cell_index=0, new_source="new")
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert saved["metadata"]["kernelspec"]["language"] == "python"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nbformat_45_generates_cell_id(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([], nbformat_minor=5)
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
await tool.execute(path=path, cell_index=0, new_source="x = 1", edit_mode="insert")
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert "id" in saved["cells"][0]
|
|
||||||
assert len(saved["cells"][0]["id"]) > 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_insert_with_cell_type_markdown(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("code")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
await tool.execute(path=path, cell_index=0, new_source="# Title", edit_mode="insert", cell_type="markdown")
|
|
||||||
saved = json.loads((tmp_path / "test.ipynb").read_text())
|
|
||||||
assert saved["cells"][1]["cell_type"] == "markdown"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_invalid_edit_mode_rejected(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("code")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=0, new_source="x", edit_mode="replcae")
|
|
||||||
assert "Error" in result
|
|
||||||
assert "edit_mode" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_invalid_cell_type_rejected(self, tool, tmp_path):
|
|
||||||
nb = _make_notebook([_code_cell("code")])
|
|
||||||
path = _write_nb(tmp_path, "test.ipynb", nb)
|
|
||||||
result = await tool.execute(path=path, cell_index=0, new_source="x", cell_type="raw")
|
|
||||||
assert "Error" in result
|
|
||||||
assert "cell_type" in result
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
"""Tests for ReadFileTool enhancements: description fix, read dedup, PDF support, device blacklist."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
|
||||||
from nanobot.agent.tools import file_state
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clear_file_state():
|
|
||||||
file_state.clear()
|
|
||||||
yield
|
|
||||||
file_state.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Description fix
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestReadDescriptionFix:
|
|
||||||
|
|
||||||
def test_description_mentions_image_support(self):
|
|
||||||
tool = ReadFileTool()
|
|
||||||
assert "image" in tool.description.lower()
|
|
||||||
|
|
||||||
def test_description_no_longer_says_cannot_read_images(self):
|
|
||||||
tool = ReadFileTool()
|
|
||||||
assert "cannot read binary files or images" not in tool.description.lower()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Read deduplication
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestReadDedup:
|
|
||||||
"""Same file + same offset/limit + unchanged mtime -> short stub."""
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return ReadFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def write_tool(self, tmp_path):
|
|
||||||
return WriteFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_second_read_returns_unchanged_stub(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "data.txt"
|
|
||||||
f.write_text("\n".join(f"line {i}" for i in range(100)), encoding="utf-8")
|
|
||||||
first = await tool.execute(path=str(f))
|
|
||||||
assert "line 0" in first
|
|
||||||
second = await tool.execute(path=str(f))
|
|
||||||
assert "unchanged" in second.lower()
|
|
||||||
# Stub should not contain file content
|
|
||||||
assert "line 0" not in second
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_read_after_external_modification_returns_full(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "data.txt"
|
|
||||||
f.write_text("original", encoding="utf-8")
|
|
||||||
await tool.execute(path=str(f))
|
|
||||||
# Modify the file externally
|
|
||||||
f.write_text("modified content", encoding="utf-8")
|
|
||||||
second = await tool.execute(path=str(f))
|
|
||||||
assert "modified content" in second
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_different_offset_returns_full(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "data.txt"
|
|
||||||
f.write_text("\n".join(f"line {i}" for i in range(1, 21)), encoding="utf-8")
|
|
||||||
await tool.execute(path=str(f), offset=1, limit=5)
|
|
||||||
second = await tool.execute(path=str(f), offset=6, limit=5)
|
|
||||||
# Different offset → full read, not stub
|
|
||||||
assert "line 6" in second
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_first_read_after_write_returns_full_content(self, tool, write_tool, tmp_path):
|
|
||||||
f = tmp_path / "fresh.txt"
|
|
||||||
result = await write_tool.execute(path=str(f), content="hello")
|
|
||||||
assert "Successfully" in result
|
|
||||||
read_result = await tool.execute(path=str(f))
|
|
||||||
assert "hello" in read_result
|
|
||||||
assert "unchanged" not in read_result.lower()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dedup_does_not_apply_to_images(self, tool, tmp_path):
|
|
||||||
f = tmp_path / "img.png"
|
|
||||||
f.write_bytes(b"\x89PNG\r\n\x1a\nfake-png-data")
|
|
||||||
first = await tool.execute(path=str(f))
|
|
||||||
assert isinstance(first, list)
|
|
||||||
second = await tool.execute(path=str(f))
|
|
||||||
# Images should always return full content blocks, not a stub
|
|
||||||
assert isinstance(second, list)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# PDF support
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestReadPdf:
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self, tmp_path):
|
|
||||||
return ReadFileTool(workspace=tmp_path)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pdf_returns_text_content(self, tool, tmp_path):
|
|
||||||
fitz = pytest.importorskip("fitz")
|
|
||||||
pdf_path = tmp_path / "test.pdf"
|
|
||||||
doc = fitz.open()
|
|
||||||
page = doc.new_page()
|
|
||||||
page.insert_text((72, 72), "Hello PDF World")
|
|
||||||
doc.save(str(pdf_path))
|
|
||||||
doc.close()
|
|
||||||
|
|
||||||
result = await tool.execute(path=str(pdf_path))
|
|
||||||
assert "Hello PDF World" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pdf_pages_parameter(self, tool, tmp_path):
|
|
||||||
fitz = pytest.importorskip("fitz")
|
|
||||||
pdf_path = tmp_path / "multi.pdf"
|
|
||||||
doc = fitz.open()
|
|
||||||
for i in range(5):
|
|
||||||
page = doc.new_page()
|
|
||||||
page.insert_text((72, 72), f"Page {i + 1} content")
|
|
||||||
doc.save(str(pdf_path))
|
|
||||||
doc.close()
|
|
||||||
|
|
||||||
result = await tool.execute(path=str(pdf_path), pages="2-3")
|
|
||||||
assert "Page 2 content" in result
|
|
||||||
assert "Page 3 content" in result
|
|
||||||
assert "Page 1 content" not in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pdf_file_not_found_error(self, tool, tmp_path):
|
|
||||||
result = await tool.execute(path=str(tmp_path / "nope.pdf"))
|
|
||||||
assert "Error" in result
|
|
||||||
assert "not found" in result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Device path blacklist
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestReadDeviceBlacklist:
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tool(self):
|
|
||||||
return ReadFileTool()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dev_random_blocked(self, tool):
|
|
||||||
result = await tool.execute(path="/dev/random")
|
|
||||||
assert "Error" in result
|
|
||||||
assert "blocked" in result.lower() or "device" in result.lower()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dev_urandom_blocked(self, tool):
|
|
||||||
result = await tool.execute(path="/dev/urandom")
|
|
||||||
assert "Error" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dev_zero_blocked(self, tool):
|
|
||||||
result = await tool.execute(path="/dev/zero")
|
|
||||||
assert "Error" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_proc_fd_blocked(self, tool):
|
|
||||||
result = await tool.execute(path="/proc/self/fd/0")
|
|
||||||
assert "Error" in result
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_symlink_to_dev_zero_blocked(self, tmp_path):
|
|
||||||
tool = ReadFileTool(workspace=tmp_path)
|
|
||||||
link = tmp_path / "zero-link"
|
|
||||||
link.symlink_to("/dev/zero")
|
|
||||||
result = await tool.execute(path=str(link))
|
|
||||||
assert "Error" in result
|
|
||||||
assert "blocked" in result.lower() or "device" in result.lower()
|
|
||||||
@@ -172,6 +172,15 @@ async def test_grep_files_with_matches_supports_head_limit_and_offset(tmp_path:
|
|||||||
(tmp_path / "src" / name).write_text("needle\n", encoding="utf-8")
|
(tmp_path / "src" / name).write_text("needle\n", encoding="utf-8")
|
||||||
|
|
||||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||||
|
|
||||||
|
# Get the full (unpaginated) list to determine the expected ordering.
|
||||||
|
full_result = await tool.execute(
|
||||||
|
pattern="needle",
|
||||||
|
path="src",
|
||||||
|
head_limit=0,
|
||||||
|
)
|
||||||
|
all_files = full_result.splitlines()
|
||||||
|
|
||||||
result = await tool.execute(
|
result = await tool.execute(
|
||||||
pattern="needle",
|
pattern="needle",
|
||||||
path="src",
|
path="src",
|
||||||
@@ -179,8 +188,9 @@ async def test_grep_files_with_matches_supports_head_limit_and_offset(tmp_path:
|
|||||||
offset=1,
|
offset=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = result.splitlines()
|
lines = [l for l in result.splitlines() if l and not l.startswith("(pagination")]
|
||||||
assert lines[0] == "src/b.py"
|
assert len(lines) == 1
|
||||||
|
assert lines[0] == all_files[1]
|
||||||
assert "pagination: limit=1, offset=1" in result
|
assert "pagination: limit=1, offset=1" in result
|
||||||
|
|
||||||
|
|
||||||
@@ -323,27 +333,3 @@ async def test_subagent_registers_grep_and_glob(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert "grep" in captured["tool_names"]
|
assert "grep" in captured["tool_names"]
|
||||||
assert "glob" in captured["tool_names"]
|
assert "glob" in captured["tool_names"]
|
||||||
|
|
||||||
|
|
||||||
def test_subagent_prompt_respects_disabled_skills(tmp_path: Path) -> None:
|
|
||||||
bus = MessageBus()
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
skills_dir = tmp_path / "skills"
|
|
||||||
(skills_dir / "alpha").mkdir(parents=True)
|
|
||||||
(skills_dir / "alpha" / "SKILL.md").write_text("# Alpha\n\nhidden\n", encoding="utf-8")
|
|
||||||
(skills_dir / "beta").mkdir(parents=True)
|
|
||||||
(skills_dir / "beta" / "SKILL.md").write_text("# Beta\n\nshown\n", encoding="utf-8")
|
|
||||||
|
|
||||||
mgr = SubagentManager(
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
bus=bus,
|
|
||||||
max_tool_result_chars=4096,
|
|
||||||
disabled_skills=["alpha"],
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = mgr._build_subagent_prompt()
|
|
||||||
|
|
||||||
assert "alpha" not in prompt
|
|
||||||
assert "beta" in prompt
|
|
||||||
|
|||||||
@@ -47,27 +47,3 @@ def test_get_definitions_orders_builtins_then_mcp_tools() -> None:
|
|||||||
"mcp_fs_list",
|
"mcp_fs_list",
|
||||||
"mcp_git_status",
|
"mcp_git_status",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_call_read_file_rejects_non_object_params_with_actionable_hint() -> None:
|
|
||||||
registry = ToolRegistry()
|
|
||||||
registry.register(_FakeTool("read_file"))
|
|
||||||
|
|
||||||
tool, params, error = registry.prepare_call("read_file", ["foo.txt"])
|
|
||||||
|
|
||||||
assert tool is None
|
|
||||||
assert params == ["foo.txt"]
|
|
||||||
assert error is not None
|
|
||||||
assert "must be a JSON object" in error
|
|
||||||
assert "Use named parameters" in error
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_call_other_tools_keep_generic_object_validation() -> None:
|
|
||||||
registry = ToolRegistry()
|
|
||||||
registry.register(_FakeTool("grep"))
|
|
||||||
|
|
||||||
tool, params, error = registry.prepare_call("grep", ["TODO"])
|
|
||||||
|
|
||||||
assert tool is not None
|
|
||||||
assert params == ["TODO"]
|
|
||||||
assert error == "Error: Invalid parameters for tool 'grep': parameters must be an object, got list"
|
|
||||||
|
|||||||
@@ -545,18 +545,18 @@ async def test_exec_always_returns_exit_code() -> None:
|
|||||||
assert "hello" in result
|
assert "hello" in result
|
||||||
|
|
||||||
|
|
||||||
async def test_exec_head_tail_truncation() -> None:
|
async def test_exec_head_tail_truncation(tmp_path) -> None:
|
||||||
"""Long output should preserve both head and tail."""
|
"""Long output should preserve both head and tail."""
|
||||||
tool = ExecTool()
|
tool = ExecTool()
|
||||||
# Generate output that exceeds _MAX_OUTPUT (10_000 chars)
|
# Generate output that exceeds _MAX_OUTPUT (10_000 chars).
|
||||||
# Use current interpreter (PATH may not have `python`). ExecTool uses
|
# Use current interpreter (PATH may not have ``python``). Write the
|
||||||
# create_subprocess_shell: POSIX needs shlex.quote; Windows uses cmd.exe
|
# script to a file to avoid shell-quoting issues on both POSIX and Windows.
|
||||||
# rules, so list2cmdline is appropriate there.
|
script_file = tmp_path / "gen.py"
|
||||||
script = "print('A' * 6000 + '\\n' + 'B' * 6000)"
|
script_file.write_text(
|
||||||
if sys.platform == "win32":
|
"import sys;sys.stdout.write(chr(65)*6000);sys.stdout.write(chr(10));sys.stdout.write(chr(66)*6000)",
|
||||||
command = subprocess.list2cmdline([sys.executable, "-c", script])
|
encoding="utf-8",
|
||||||
else:
|
)
|
||||||
command = f"{shlex.quote(sys.executable)} -c {shlex.quote(script)}"
|
command = f"{sys.executable} {script_file}"
|
||||||
result = await tool.execute(command=command)
|
result = await tool.execute(command=command)
|
||||||
assert "chars truncated" in result
|
assert "chars truncated" in result
|
||||||
# Head portion should start with As
|
# Head portion should start with As
|
||||||
|
|||||||
@@ -120,27 +120,6 @@ async def test_jina_search(monkeypatch):
|
|||||||
assert "https://jina.ai" in result
|
assert "https://jina.ai" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_kagi_search(monkeypatch):
|
|
||||||
async def mock_get(self, url, **kw):
|
|
||||||
assert "kagi.com/api/v0/search" in url
|
|
||||||
assert kw["headers"]["Authorization"] == "Bot kagi-key"
|
|
||||||
assert kw["params"] == {"q": "test", "limit": 2}
|
|
||||||
return _response(json={
|
|
||||||
"data": [
|
|
||||||
{"t": 0, "title": "Kagi Result", "url": "https://kagi.com", "snippet": "Premium search"},
|
|
||||||
{"t": 1, "list": ["ignored related search"]},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "get", mock_get)
|
|
||||||
tool = _tool(provider="kagi", api_key="kagi-key")
|
|
||||||
result = await tool.execute(query="test", count=2)
|
|
||||||
assert "Kagi Result" in result
|
|
||||||
assert "https://kagi.com" in result
|
|
||||||
assert "ignored related search" not in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unknown_provider():
|
async def test_unknown_provider():
|
||||||
tool = _tool(provider="unknown")
|
tool = _tool(provider="unknown")
|
||||||
@@ -210,23 +189,6 @@ async def test_jina_422_falls_back_to_duckduckgo(monkeypatch):
|
|||||||
assert "DuckDuckGo fallback" in result
|
assert "DuckDuckGo fallback" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_kagi_fallback_to_duckduckgo_when_no_key(monkeypatch):
|
|
||||||
class MockDDGS:
|
|
||||||
def __init__(self, **kw):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def text(self, query, max_results=5):
|
|
||||||
return [{"title": "Fallback", "href": "https://ddg.example", "body": "DuckDuckGo fallback"}]
|
|
||||||
|
|
||||||
monkeypatch.setattr("ddgs.DDGS", MockDDGS)
|
|
||||||
monkeypatch.delenv("KAGI_API_KEY", raising=False)
|
|
||||||
|
|
||||||
tool = _tool(provider="kagi", api_key="")
|
|
||||||
result = await tool.execute(query="test")
|
|
||||||
assert "Fallback" in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_jina_search_uses_path_encoded_query(monkeypatch):
|
async def test_jina_search_uses_path_encoded_query(monkeypatch):
|
||||||
calls = {}
|
calls = {}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.utils.helpers import strip_think
|
|
||||||
|
|
||||||
|
|
||||||
class TestStripThinkTag:
|
|
||||||
"""Test <thought>...</thought> block stripping (Gemma 4 and similar models)."""
|
|
||||||
|
|
||||||
def test_closed_tag(self):
|
|
||||||
assert strip_think("Hello <thought>reasoning</thought> World") == "Hello World"
|
|
||||||
|
|
||||||
def test_unclosed_trailing_tag(self):
|
|
||||||
assert strip_think("<thought>ongoing...") == ""
|
|
||||||
|
|
||||||
def test_multiline_tag(self):
|
|
||||||
assert strip_think("<thought>\nline1\nline2\n</thought>End") == "End"
|
|
||||||
|
|
||||||
def test_tag_with_nested_angle_brackets(self):
|
|
||||||
text = "<thought>a < 3 and b > 2</thought>result"
|
|
||||||
assert strip_think(text) == "result"
|
|
||||||
|
|
||||||
def test_multiple_tag_blocks(self):
|
|
||||||
text = "A<thought>x</thought>B<thought>y</thought>C"
|
|
||||||
assert strip_think(text) == "ABC"
|
|
||||||
|
|
||||||
def test_tag_only_whitespace_inside(self):
|
|
||||||
assert strip_think("before<thought> </thought>after") == "beforeafter"
|
|
||||||
|
|
||||||
def test_self_closing_tag_not_matched(self):
|
|
||||||
assert strip_think("<thought/>some text") == "<thought/>some text"
|
|
||||||
|
|
||||||
def test_normal_text_unchanged(self):
|
|
||||||
assert strip_think("Just normal text") == "Just normal text"
|
|
||||||
|
|
||||||
def test_empty_string(self):
|
|
||||||
assert strip_think("") == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestStripThinkFalsePositive:
|
|
||||||
"""Ensure mid-content <think>/<thought> tags are NOT stripped (#3004)."""
|
|
||||||
|
|
||||||
def test_backtick_think_tag_preserved(self):
|
|
||||||
text = "*Think Stripping:* A new utility to strip `<think>` tags from output."
|
|
||||||
assert strip_think(text) == text
|
|
||||||
|
|
||||||
def test_prose_think_tag_preserved(self):
|
|
||||||
text = "The model emits <think> at the start of its response."
|
|
||||||
assert strip_think(text) == text
|
|
||||||
|
|
||||||
def test_code_block_think_tag_preserved(self):
|
|
||||||
text = "Example:\n```\ntext = re.sub(r\"<think>[\\s\\S]*\", \"\", text)\n```\nDone."
|
|
||||||
assert strip_think(text) == text
|
|
||||||
|
|
||||||
def test_backtick_thought_tag_preserved(self):
|
|
||||||
text = "Gemma 4 uses `<thought>` blocks for reasoning."
|
|
||||||
assert strip_think(text) == text
|
|
||||||
|
|
||||||
def test_prefix_unclosed_think_still_stripped(self):
|
|
||||||
assert strip_think("<think>reasoning without closing") == ""
|
|
||||||
|
|
||||||
def test_prefix_unclosed_think_with_whitespace(self):
|
|
||||||
assert strip_think(" <think>reasoning...") == ""
|
|
||||||
|
|
||||||
def test_prefix_unclosed_thought_still_stripped(self):
|
|
||||||
assert strip_think("<thought>reasoning without closing") == ""
|
|
||||||
Reference in New Issue
Block a user